Add Kubernetes image + Helm package with in-cluster Postgres #1
No files matched your search
@@ -0,0 +1,48 @@
|
||||
# VCS / CI
|
||||
.git
|
||||
.github
|
||||
.gitignore
|
||||
|
||||
# Secrets and runtime data (never bake into the image; provided via Secret/PVC)
|
||||
.env
|
||||
.env.*
|
||||
data/
|
||||
*.duckdb
|
||||
*.wal
|
||||
|
||||
# Local Grafana stack (provisioned via the Helm chart instead)
|
||||
grafana/
|
||||
|
||||
# Deploy artifacts don't belong inside the app image
|
||||
deploy/
|
||||
Dockerfile
|
||||
.dockerignore
|
||||
|
||||
# Python caches / build / venvs
|
||||
__pycache__/
|
||||
*.py[cod]
|
||||
*.egg-info/
|
||||
build/
|
||||
dist/
|
||||
.venv
|
||||
venv/
|
||||
ENV/
|
||||
env/
|
||||
.ruff_cache/
|
||||
|
||||
# Tests and coverage
|
||||
tests/
|
||||
.pytest_cache/
|
||||
.coverage
|
||||
htmlcov/
|
||||
|
||||
# Editor / OS cruft
|
||||
.vscode/
|
||||
.idea/
|
||||
*.swp
|
||||
.DS_Store
|
||||
|
||||
# Large artifacts
|
||||
*.zip
|
||||
archive/
|
||||
uv.lock
|
||||
@@ -0,0 +1,57 @@
|
||||
name: Build and publish image
|
||||
|
||||
# Builds the app container and pushes it to GitHub Container Registry
|
||||
# (ghcr.io/fez5587/momentum-system). The Helm chart's image.repository points
|
||||
# at this registry; set image.tag to a pushed tag (a commit SHA or `latest`).
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- claude/kubernetes-image-package-kkdkom
|
||||
- main
|
||||
|
|
||||
tags:
|
||||
- "v*"
|
||||
workflow_dispatch:
|
||||
|
||||
env:
|
||||
REGISTRY: ghcr.io
|
||||
IMAGE_NAME: ${{ github.repository }}
|
||||
|
||||
jobs:
|
||||
build-and-push:
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
contents: read
|
||||
packages: write
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@v3
|
||||
|
||||
- name: Log in to GHCR
|
||||
uses: docker/login-action@v3
|
||||
with:
|
||||
registry: ${{ env.REGISTRY }}
|
||||
username: ${{ github.actor }}
|
||||
password: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
- name: Derive image tags
|
||||
id: meta
|
||||
uses: docker/metadata-action@v5
|
||||
with:
|
||||
images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}
|
||||
tags: |
|
||||
type=sha
|
||||
type=semver,pattern={{version}}
|
||||
type=raw,value=latest,enable={{is_default_branch}}
|
||||
|
||||
- name: Build and push
|
||||
uses: docker/build-push-action@v6
|
||||
with:
|
||||
context: .
|
||||
push: true
|
||||
tags: ${{ steps.meta.outputs.tags }}
|
||||
labels: ${{ steps.meta.outputs.labels }}
|
||||
cache-from: type=gha
|
||||
cache-to: type=gha,mode=max
|
||||
@@ -0,0 +1,41 @@
|
||||
# Momentum System — container image
|
||||
#
|
||||
# Single image for every run mode (the app is a monolith run from the repo root
|
||||
# with PYTHONPATH=/app, not a pip-installed wheel — see pyproject.toml
|
||||
# `package = false`). The Kubernetes workloads override CMD:
|
||||
# - app Deployment : python run_live_paper.py (loop + embedded dashboard)
|
||||
# - CronJobs : python eod_replay.py / nightly_tune.py / momentum_cli.py journal
|
||||
FROM python:3.11-slim
|
||||
|
||||
# psycopg2-binary ships its own libpq, so no build toolchain is needed; we only
|
||||
# add a tiny client for healthchecks/debugging and clean up the apt cache.
|
||||
RUN apt-get update \
|
||||
&& apt-get install -y --no-install-recommends libpq5 \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
ENV PYTHONUNBUFFERED=1 \
|
||||
PYTHONDONTWRITEBYTECODE=1 \
|
||||
PYTHONPATH=/app \
|
||||
PIP_NO_CACHE_DIR=1
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
# Install dependencies first so they cache across code-only changes.
|
||||
COPY requirements.txt ./
|
||||
RUN pip install --no-cache-dir -r requirements.txt
|
||||
|
||||
# Application code.
|
||||
COPY . .
|
||||
|
||||
# Run as a non-root user that owns the writable data dir (DuckDB research file,
|
||||
# logs, learned_params.json, schwab tokens). In K8s this path is a mounted PVC.
|
||||
RUN useradd --create-home --uid 10001 app \
|
||||
&& mkdir -p /app/data \
|
||||
&& chown -R app:app /app/data
|
||||
USER app
|
||||
|
||||
EXPOSE 8010
|
||||
|
||||
# Default to the headless loop; the chart's app Deployment runs it WITH the
|
||||
# dashboard, and the CronJobs override this entirely.
|
||||
CMD ["python", "run_live_paper.py", "--no-dashboard"]
|
||||
@@ -0,0 +1,182 @@
|
||||
# Kubernetes deployment
|
||||
|
||||
A container image and Helm chart that run the whole Momentum System — including
|
||||
its **PostgreSQL datastore** — inside a single Kubernetes namespace.
|
||||
|
||||
## What gets deployed
|
||||
|
||||
| Workload | Kind | Notes |
|
||||
| --- | --- | --- |
|
||||
| PostgreSQL | StatefulSet + PVC | Single source of truth. Schema auto-creates on the app's first connection from `storage/pg_schema.sql`. |
|
||||
| App loop + dashboard | Deployment (`replicas: 1`, `Recreate`) | `run_live_paper.py` with the **embedded** dashboard on :8010. Stateful singleton — never scale up. |
|
||||
| journal / eod-replay / nightly-tune | CronJobs | US/Eastern (`spec.timeZone`), weekdays. Share the app's `data/` PVC. |
|
||||
| Grafana | Deployment + Service | Optional (`grafana.enabled`). Datasource auto-points at the in-cluster Postgres. |
|
||||
|
||||
The app loop and the CronJobs run the **same image** (built from the repo
|
||||
`Dockerfile`), just with different commands.
|
||||
|
||||
## Local development with Docker Compose
|
||||
|
||||
For a single-machine stack (same components, no Kubernetes) use the root
|
||||
`docker-compose.yml`. It builds the app from the `Dockerfile`, brings up
|
||||
PostgreSQL with a persistent volume, and runs the loop with its embedded
|
||||
dashboard. Config comes from a local `.env` (optional — without `ALPACA_*` keys
|
||||
the loop runs in dry mode; the dashboard and DB still work). `DATABASE_URL` is
|
||||
overridden to the in-network `postgres` service, so the stack is self-contained.
|
||||
|
||||
```bash
|
||||
cp .env.example .env # optional: add ALPACA_* keys for live data
|
||||
|
||||
docker compose up -d --build # postgres + app loop + dashboard (:8010)
|
||||
docker compose logs -f app # watch the loop
|
||||
open http://localhost:8010 # dashboard
|
||||
|
||||
docker compose --profile monitoring up -d # also start Grafana (:3000)
|
||||
```
|
||||
|
||||
The scheduled jobs sit under the `jobs` profile so they don't auto-start; run
|
||||
them on demand (or from host cron / a scheduler on the ET times in `.env`):
|
||||
|
||||
```bash
|
||||
docker compose run --rm nightly-tune
|
||||
docker compose run --rm eod-replay
|
||||
docker compose run --rm journal
|
||||
```
|
||||
|
||||
Postgres credentials default to `momentum`/`momentum`/`momentum`; override with
|
||||
`POSTGRES_USER` / `POSTGRES_PASSWORD` / `POSTGRES_DB` in `.env`. Named volumes
|
||||
`pg-data`, `app-data`, and `grafana-data` persist across restarts (the jobs share
|
||||
`app-data` with the app so `nightly_tune.py`'s `learned_params.json` reaches the
|
||||
loop). The Compose Grafana uses its own datasource override at
|
||||
`deploy/compose/grafana/datasources/momentum.yaml` (pointed at the `postgres`
|
||||
service); the legacy `grafana/docker-compose.yml` is unrelated and left for
|
||||
Grafana-only use against an external DB.
|
||||
|
||||
## Build & publish the image
|
||||
|
||||
Pushes to the working branch / `main` / `v*` tags trigger
|
||||
`.github/workflows/docker-publish.yml`, which builds and pushes to
|
||||
`ghcr.io/fez5587/momentum-system`. Point the chart at a pushed tag via
|
||||
`image.tag` (a commit SHA, or `latest` on the default branch).
|
||||
|
||||
Local build:
|
||||
|
||||
```bash
|
||||
docker build -t momentum:dev .
|
||||
```
|
||||
|
||||
## Install
|
||||
|
||||
```bash
|
||||
helm install momentum ./deploy/helm/momentum \
|
||||
-n momentum --create-namespace \
|
||||
--set image.tag=latest \
|
||||
--set secrets.alpacaApiKey=YOUR_KEY \
|
||||
--set secrets.alpacaSecretKey=YOUR_SECRET
|
||||
```
|
||||
|
||||
Without Alpaca keys the loop runs in **dry mode** (no live data/orders); the
|
||||
dashboard and database still work.
|
||||
|
||||
### Secrets
|
||||
|
||||
The chart builds `DATABASE_URL` from the in-cluster Postgres service and a
|
||||
password that is generated on first install (or set `postgres.password` to pin
|
||||
it). Broker keys come from `secrets.*`.
|
||||
|
||||
For real keys, prefer an existing Secret over `values.yaml`:
|
||||
|
||||
```bash
|
||||
kubectl -n momentum create secret generic momentum-broker \
|
||||
--from-literal=DATABASE_URL=... \
|
||||
--from-literal=ALPACA_API_KEY=... --from-literal=ALPACA_SECRET_KEY=... \
|
||||
--from-literal=APCA_API_KEY_ID=... --from-literal=APCA_API_SECRET_KEY=...
|
||||
helm install momentum ./deploy/helm/momentum -n momentum \
|
||||
--set secrets.existingSecret=momentum-broker
|
||||
```
|
||||
|
||||
When `secrets.existingSecret` is set the chart renders no Secret of its own and
|
||||
expects that one to carry `DATABASE_URL`, the `ALPACA_*`/`APCA_*` keys, and
|
||||
`POSTGRES_PASSWORD`.
|
||||
|
||||
## Reach the dashboard
|
||||
|
||||
```bash
|
||||
kubectl -n momentum port-forward svc/momentum-app 8010:8010
|
||||
open http://localhost:8010
|
||||
```
|
||||
|
||||
Set `ingress.enabled=true` (with `ingress.host`/`className`) to expose it
|
||||
instead.
|
||||
|
||||
## Verify
|
||||
|
||||
```bash
|
||||
helm lint deploy/helm/momentum
|
||||
helm template momentum deploy/helm/momentum | less
|
||||
|
||||
# After install:
|
||||
kubectl -n momentum get pods
|
||||
kubectl -n momentum exec sts/momentum-postgres -- psql -U momentum -d momentum -c '\dt'
|
||||
kubectl -n momentum create job --from=cronjob/momentum-nightly-tune tune-test
|
||||
```
|
||||
|
||||
## Notes & constraints
|
||||
|
||||
- **Singleton loop.** The trading loop holds in-memory state and one broker
|
||||
session; `replicas` must stay `1` and the strategy is `Recreate`.
|
||||
- **Shared `data/` PVC is ReadWriteOnce.** The CronJob pods mount the same PVC
|
||||
as the app (so `nightly_tune.py` can write `learned_params.json` for the loop
|
||||
to read), which schedules them onto the app pod's node. For multi-node
|
||||
clusters, use an RWX StorageClass or split the volumes.
|
||||
- **CronJob `spec.timeZone`** requires Kubernetes ≥ 1.27.
|
||||
- The legacy `grafana/docker-compose.yml` is left in place for local use; the
|
||||
chart provisions its own Grafana against the in-cluster Postgres.
|
||||
|
||||
## Future hardening (roadmap)
|
||||
|
||||
The package deploys and runs, but the items below separate "it comes up" from
|
||||
"runs reliably for a trading system." They are **not implemented yet** — captured
|
||||
here so they can be picked up later. Each notes what, why, and the rough approach.
|
||||
|
||||
### Reliability
|
||||
|
||||
- **Loop-heartbeat liveness** *(needs an app-code change).* Today `/api/health`
|
||||
(`api/main.py:343`) returns a static `{"ok": true}`, which only proves the
|
||||
dashboard *thread* is alive. The trading loop runs in the main thread
|
||||
(`run_live_paper.py:328`), so a **crashed** loop exits the process and Kubernetes
|
||||
restarts it (fine), but a **hung** loop (stuck network call, deadlock) keeps
|
||||
answering health checks `200` and runs undetected. Approach: have the loop stamp a
|
||||
last-tick timestamp into `DashboardState` each iteration, make `/api/health` (or a
|
||||
new `/healthz`) return `503` when the last tick is older than a threshold, and
|
||||
point the Deployment's `livenessProbe` at it. Highest-value item.
|
||||
- **Postgres backup CronJob.** The event store is the source of truth and nothing
|
||||
backs it up. Approach: a chart CronJob running `pg_dump` (postgres image, creds
|
||||
from the Secret) to the app `data` PVC with N-day retention; optionally offload to
|
||||
object storage.
|
||||
- **Schema bootstrap hook.** The schema auto-applies on the app's first connection,
|
||||
so a first-run race between the app and a CronJob is possible. Approach: a
|
||||
`pre-install`/`pre-upgrade` Helm-hook Job that applies `storage/pg_schema.sql`
|
||||
deterministically, with `PG_SKIP_SCHEMA=1` set on the app and CronJobs.
|
||||
|
||||
### Security
|
||||
|
||||
- **Pod hardening.** Add a `securityContext` across workloads: `runAsNonRoot`, drop
|
||||
ALL capabilities, `seccompProfile: RuntimeDefault`, and `readOnlyRootFilesystem`
|
||||
where feasible (the app needs `/app/data` writable — already a mounted volume).
|
||||
- **NetworkPolicy.** Default-deny in the namespace; allow only the app, CronJobs, and
|
||||
Grafana to reach Postgres:5432, and restrict who can reach the dashboard.
|
||||
- **Grafana auth.** The chart inherits anonymous-admin from the original compose
|
||||
(`templates/grafana.yaml` env). Make it off by default in-cluster, with a real
|
||||
admin password sourced from the Secret (`GF_SECURITY_ADMIN_PASSWORD`).
|
||||
|
||||
### CI / operational
|
||||
|
||||
- **Chart CI validation.** Add a CI job running `helm lint` + `helm template` +
|
||||
`kubeconform` on every push — catches template/schema errors automatically (the
|
||||
chart could not be rendered in the sandbox where it was authored, since image-blob
|
||||
CDNs were egress-blocked).
|
||||
- **Image digest pinning.** For production installs, pin `image` by SHA digest rather
|
||||
than the mutable `latest` tag.
|
||||
- **PodDisruptionBudget / singleton guard.** A PDB for the singleton loop plus a
|
||||
guard so it is never scaled above `1` or evicted to zero unexpectedly.
|
||||
@@ -0,0 +1,19 @@
|
||||
# Grafana datasource for the docker-compose stack — points at the in-network
|
||||
# `postgres` service (not the LAN IP used by grafana/provisioning/datasources).
|
||||
# Credentials are interpolated from the PG_* env vars set on the grafana service.
|
||||
apiVersion: 1
|
||||
datasources:
|
||||
- name: Momentum
|
||||
uid: momentum_pg
|
||||
type: postgres
|
||||
access: proxy
|
||||
url: postgres:5432
|
||||
user: ${PG_USER}
|
||||
jsonData:
|
||||
database: ${PG_DATABASE}
|
||||
sslmode: disable
|
||||
postgresVersion: 1600
|
||||
secureJsonData:
|
||||
password: ${PG_PASSWORD}
|
||||
isDefault: true
|
||||
editable: true
|
||||
@@ -0,0 +1,5 @@
|
||||
.DS_Store
|
||||
.git
|
||||
*.tmp
|
||||
*.bak
|
||||
*.orig
|
||||
@@ -0,0 +1,14 @@
|
||||
apiVersion: v2
|
||||
name: momentum
|
||||
description: Small-cap momentum research & paper-trading system, with an in-cluster PostgreSQL datastore.
|
||||
type: application
|
||||
# Chart version is independent of the app; bump on chart changes.
|
||||
version: 0.1.0
|
||||
# Tracks the application release (pyproject.toml).
|
||||
appVersion: "0.4.0"
|
||||
kubeVersion: ">=1.27.0-0"
|
||||
keywords:
|
||||
- trading
|
||||
- postgres
|
||||
- momentum
|
||||
home: https://github.com/fez5587/momentum-system
|
||||
@@ -0,0 +1,137 @@
|
||||
{
|
||||
"uid": "momentum-live",
|
||||
"title": "Momentum — Live",
|
||||
"tags": ["momentum"],
|
||||
"timezone": "browser",
|
||||
"schemaVersion": 39,
|
||||
"version": 1,
|
||||
"refresh": "5s",
|
||||
"time": { "from": "now/d", "to": "now" },
|
||||
"templating": { "list": [] },
|
||||
"annotations": { "list": [] },
|
||||
"panels": [
|
||||
{
|
||||
"id": 1,
|
||||
"type": "stat",
|
||||
"title": "Account equity",
|
||||
"gridPos": { "h": 4, "w": 6, "x": 0, "y": 0 },
|
||||
"datasource": { "type": "postgres", "uid": "momentum_pg" },
|
||||
"fieldConfig": { "defaults": { "unit": "currencyUSD" }, "overrides": [] },
|
||||
"options": { "reduceOptions": { "calcs": ["lastNotNull"] }, "colorMode": "value", "graphMode": "none" },
|
||||
"targets": [{
|
||||
"refId": "A",
|
||||
"format": "table",
|
||||
"rawQuery": true,
|
||||
"rawSql": "SELECT (payload_json::jsonb->>'total_equity')::float AS equity FROM events WHERE event_type='account_summary_updated' ORDER BY timestamp DESC LIMIT 1"
|
||||
}]
|
||||
},
|
||||
{
|
||||
"id": 2,
|
||||
"type": "stat",
|
||||
"title": "Ready signals (today)",
|
||||
"gridPos": { "h": 4, "w": 6, "x": 6, "y": 0 },
|
||||
"datasource": { "type": "postgres", "uid": "momentum_pg" },
|
||||
"fieldConfig": { "defaults": { "thresholds": { "mode": "absolute", "steps": [ { "color": "green", "value": null }, { "color": "blue", "value": 1 } ] } }, "overrides": [] },
|
||||
"options": { "reduceOptions": { "calcs": ["lastNotNull"] }, "colorMode": "background", "graphMode": "none" },
|
||||
"targets": [{
|
||||
"refId": "A",
|
||||
"format": "table",
|
||||
"rawQuery": true,
|
||||
"rawSql": "SELECT count(DISTINCT payload_json::jsonb->>'symbol')::int AS ready FROM events WHERE event_type='signal_ready'"
|
||||
}]
|
||||
},
|
||||
{
|
||||
"id": 6,
|
||||
"type": "stat",
|
||||
"title": "Symbols evaluated",
|
||||
"gridPos": { "h": 4, "w": 6, "x": 12, "y": 0 },
|
||||
"datasource": { "type": "postgres", "uid": "momentum_pg" },
|
||||
"options": { "reduceOptions": { "calcs": ["lastNotNull"] }, "colorMode": "value", "graphMode": "none" },
|
||||
"targets": [{
|
||||
"refId": "A",
|
||||
"format": "table",
|
||||
"rawQuery": true,
|
||||
"rawSql": "SELECT count(DISTINCT payload_json::jsonb->>'symbol')::int AS evaluated FROM events WHERE event_type='criteria_evaluated'"
|
||||
}]
|
||||
},
|
||||
{
|
||||
"id": 3,
|
||||
"type": "table",
|
||||
"title": "Evaluation board — what it's looking at + the determination",
|
||||
"gridPos": { "h": 14, "w": 14, "x": 0, "y": 4 },
|
||||
"datasource": { "type": "postgres", "uid": "momentum_pg" },
|
||||
"fieldConfig": {
|
||||
"defaults": {},
|
||||
"overrides": [
|
||||
{ "matcher": { "id": "byName", "options": "status" },
|
||||
"properties": [ { "id": "custom.cellOptions", "value": { "type": "color-background" } },
|
||||
{ "id": "mappings", "value": [
|
||||
{ "type": "value", "options": { "ready": { "color": "green", "index": 0 }, "late": { "color": "yellow", "index": 1 }, "blocked": { "color": "red", "index": 2 } } } ] } ] },
|
||||
{ "matcher": { "id": "byName", "options": "gap" }, "properties": [ { "id": "unit", "value": "percentunit" } ] },
|
||||
{ "matcher": { "id": "byName", "options": "score" }, "properties": [ { "id": "unit", "value": "percent" } ] }
|
||||
]
|
||||
},
|
||||
"options": { "sortBy": [ { "displayName": "score", "desc": true } ] },
|
||||
"targets": [{
|
||||
"refId": "A",
|
||||
"format": "table",
|
||||
"rawQuery": true,
|
||||
"rawSql": "SELECT * FROM ( SELECT DISTINCT ON (sym) sym AS symbol, status, score, gap, rvol, reason FROM ( SELECT payload_json::jsonb->>'symbol' sym, payload_json::jsonb->'criteria_results'->>'status' status, (payload_json::jsonb->>'success_score_pct')::float score, (payload_json::jsonb->'criteria_results'->>'gap_pct')::float gap, (payload_json::jsonb->'criteria_results'->>'relative_volume')::float rvol, payload_json::jsonb->'criteria_results'->>'reason' reason, timestamp ts FROM events WHERE event_type='criteria_evaluated' ) t ORDER BY sym, ts DESC ) x ORDER BY score DESC"
|
||||
}]
|
||||
},
|
||||
{
|
||||
"id": 4,
|
||||
"type": "table",
|
||||
"title": "Ready signals",
|
||||
"gridPos": { "h": 7, "w": 10, "x": 14, "y": 4 },
|
||||
"datasource": { "type": "postgres", "uid": "momentum_pg" },
|
||||
"targets": [{
|
||||
"refId": "A",
|
||||
"format": "table",
|
||||
"rawQuery": true,
|
||||
"rawSql": "SELECT timestamp AS time, payload_json::jsonb->>'symbol' AS symbol, message FROM events WHERE event_type='signal_ready' ORDER BY timestamp DESC LIMIT 25"
|
||||
}]
|
||||
},
|
||||
{
|
||||
"id": 5,
|
||||
"type": "table",
|
||||
"title": "Risk events (breaker / backouts)",
|
||||
"gridPos": { "h": 7, "w": 10, "x": 14, "y": 11 },
|
||||
"datasource": { "type": "postgres", "uid": "momentum_pg" },
|
||||
"targets": [{
|
||||
"refId": "A",
|
||||
"format": "table",
|
||||
"rawQuery": true,
|
||||
"rawSql": "SELECT timestamp AS time, payload_json::jsonb->>'rule_type' AS rule, message FROM events WHERE event_type='risk_rule_triggered' ORDER BY timestamp DESC LIMIT 25"
|
||||
}]
|
||||
},
|
||||
{
|
||||
"id": 7,
|
||||
"type": "timeseries",
|
||||
"title": "Minute bars ingested",
|
||||
"gridPos": { "h": 7, "w": 12, "x": 0, "y": 18 },
|
||||
"datasource": { "type": "postgres", "uid": "momentum_pg" },
|
||||
"fieldConfig": { "defaults": { "custom": { "drawStyle": "bars", "fillOpacity": 40 } }, "overrides": [] },
|
||||
"targets": [{
|
||||
"refId": "A",
|
||||
"format": "time_series",
|
||||
"rawQuery": true,
|
||||
"rawSql": "SELECT timestamp AS time, (payload_json::jsonb->>'output_count')::float AS minute_rows FROM events WHERE event_type='module_tick' AND payload_json::jsonb->>'module'='ingestion' AND $__timeFilter(timestamp) ORDER BY 1"
|
||||
}]
|
||||
},
|
||||
{
|
||||
"id": 8,
|
||||
"type": "timeseries",
|
||||
"title": "Account equity",
|
||||
"gridPos": { "h": 7, "w": 12, "x": 12, "y": 18 },
|
||||
"datasource": { "type": "postgres", "uid": "momentum_pg" },
|
||||
"fieldConfig": { "defaults": { "unit": "currencyUSD" }, "overrides": [] },
|
||||
"targets": [{
|
||||
"refId": "A",
|
||||
"format": "time_series",
|
||||
"rawQuery": true,
|
||||
"rawSql": "SELECT timestamp AS time, (payload_json::jsonb->>'total_equity')::float AS equity FROM events WHERE event_type='account_summary_updated' AND $__timeFilter(timestamp) ORDER BY 1"
|
||||
}]
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
Momentum System deployed as release "{{ .Release.Name }}" in namespace "{{ .Release.Namespace }}".
|
||||
|
||||
Components:
|
||||
- PostgreSQL (StatefulSet, single source of truth) : svc/{{ include "momentum.postgresName" . }}:{{ .Values.postgres.port }}
|
||||
- App loop + embedded dashboard (replicas: 1) : svc/{{ include "momentum.appName" . }}:{{ .Values.service.port }}
|
||||
{{- if .Values.cronjobs.enabled }}
|
||||
- CronJobs (journal, eod-replay, nightly-tune) : timeZone {{ .Values.cronjobs.timeZone }}
|
||||
{{- end }}
|
||||
{{- if .Values.grafana.enabled }}
|
||||
- Grafana : svc/{{ include "momentum.grafanaName" . }}:{{ .Values.grafana.service.port }}
|
||||
{{- end }}
|
||||
|
||||
Reach the dashboard:
|
||||
kubectl -n {{ .Release.Namespace }} port-forward svc/{{ include "momentum.appName" . }} {{ .Values.service.port }}:{{ .Values.service.port }}
|
||||
open http://localhost:{{ .Values.service.port }}
|
||||
|
||||
{{- if not .Values.secrets.existingSecret }}
|
||||
{{- if or (not .Values.secrets.alpacaApiKey) (not .Values.secrets.alpacaSecretKey) }}
|
||||
|
||||
WARNING: Alpaca keys are not set — the loop runs in dry mode (no live data/orders).
|
||||
Supply them: helm upgrade {{ .Release.Name }} ... \
|
||||
--set secrets.alpacaApiKey=YOUR_KEY --set secrets.alpacaSecretKey=YOUR_SECRET
|
||||
{{- end }}
|
||||
{{- end }}
|
||||
|
||||
The DB schema auto-creates on the app's first connection. Verify:
|
||||
kubectl -n {{ .Release.Namespace }} exec sts/{{ include "momentum.postgresName" . }} -- \
|
||||
psql -U {{ .Values.postgres.username }} -d {{ .Values.postgres.database }} -c '\dt'
|
||||
@@ -0,0 +1,69 @@
|
||||
{{/* Base name, overridable. */}}
|
||||
{{- define "momentum.name" -}}
|
||||
{{- default .Chart.Name .Values.nameOverride | trunc 63 | trimSuffix "-" -}}
|
||||
{{- end -}}
|
||||
|
||||
{{/* Fully qualified release name. */}}
|
||||
{{- define "momentum.fullname" -}}
|
||||
{{- if .Values.fullnameOverride -}}
|
||||
{{- .Values.fullnameOverride | trunc 63 | trimSuffix "-" -}}
|
||||
{{- else -}}
|
||||
{{- $name := default .Chart.Name .Values.nameOverride -}}
|
||||
{{- if contains $name .Release.Name -}}
|
||||
{{- .Release.Name | trunc 63 | trimSuffix "-" -}}
|
||||
{{- else -}}
|
||||
{{- printf "%s-%s" .Release.Name $name | trunc 63 | trimSuffix "-" -}}
|
||||
{{- end -}}
|
||||
{{- end -}}
|
||||
{{- end -}}
|
||||
|
||||
{{/* Common labels. */}}
|
||||
{{- define "momentum.labels" -}}
|
||||
app.kubernetes.io/name: {{ include "momentum.name" . }}
|
||||
app.kubernetes.io/instance: {{ .Release.Name }}
|
||||
app.kubernetes.io/managed-by: {{ .Release.Service }}
|
||||
helm.sh/chart: {{ printf "%s-%s" .Chart.Name .Chart.Version | replace "+" "_" }}
|
||||
{{- if .Chart.AppVersion }}
|
||||
app.kubernetes.io/version: {{ .Chart.AppVersion | quote }}
|
||||
{{- end }}
|
||||
{{- end -}}
|
||||
|
||||
{{/* Component-scoped selector labels. Call with (dict "ctx" . "component" "app"). */}}
|
||||
{{- define "momentum.selectorLabels" -}}
|
||||
app.kubernetes.io/name: {{ include "momentum.name" .ctx }}
|
||||
app.kubernetes.io/instance: {{ .ctx.Release.Name }}
|
||||
app.kubernetes.io/component: {{ .component }}
|
||||
{{- end -}}
|
||||
|
||||
{{/* Names of shared resources. */}}
|
||||
{{- define "momentum.postgresName" -}}{{ printf "%s-postgres" (include "momentum.fullname" .) }}{{- end -}}
|
||||
{{- define "momentum.appName" -}}{{ printf "%s-app" (include "momentum.fullname" .) }}{{- end -}}
|
||||
{{- define "momentum.grafanaName" -}}{{ printf "%s-grafana" (include "momentum.fullname" .) }}{{- end -}}
|
||||
{{- define "momentum.configMapName" -}}{{ printf "%s-config" (include "momentum.fullname" .) }}{{- end -}}
|
||||
{{- define "momentum.appDataPvcName" -}}{{ printf "%s-app-data" (include "momentum.fullname" .) }}{{- end -}}
|
||||
|
||||
{{/* Name of the Secret holding DB URL + broker keys (existing or chart-managed). */}}
|
||||
{{- define "momentum.secretName" -}}
|
||||
{{- if .Values.secrets.existingSecret -}}
|
||||
{{- .Values.secrets.existingSecret -}}
|
||||
{{- else -}}
|
||||
{{- printf "%s-secrets" (include "momentum.fullname" .) -}}
|
||||
{{- end -}}
|
||||
{{- end -}}
|
||||
|
||||
{{/*
|
||||
Resolve the Postgres password once: explicit value, else reuse the one already
|
||||
stored in the chart-managed Secret (so upgrades don't rotate it), else generate.
|
||||
*/}}
|
||||
{{- define "momentum.postgresPassword" -}}
|
||||
{{- if .Values.postgres.password -}}
|
||||
{{- .Values.postgres.password -}}
|
||||
{{- else -}}
|
||||
{{- $existing := lookup "v1" "Secret" .Release.Namespace (printf "%s-secrets" (include "momentum.fullname" .)) -}}
|
||||
{{- if and $existing $existing.data (index $existing.data "POSTGRES_PASSWORD") -}}
|
||||
{{- index $existing.data "POSTGRES_PASSWORD" | b64dec -}}
|
||||
{{- else -}}
|
||||
{{- randAlphaNum 24 -}}
|
||||
{{- end -}}
|
||||
{{- end -}}
|
||||
{{- end -}}
|
||||
@@ -0,0 +1,16 @@
|
||||
{{- if .Values.app.persistence.enabled }}
|
||||
apiVersion: v1
|
||||
kind: PersistentVolumeClaim
|
||||
metadata:
|
||||
name: {{ include "momentum.appDataPvcName" . }}
|
||||
labels:
|
||||
{{- include "momentum.labels" . | nindent 4 }}
|
||||
spec:
|
||||
accessModes: ["{{ .Values.app.persistence.accessMode }}"]
|
||||
{{- if .Values.app.persistence.storageClass }}
|
||||
storageClassName: {{ .Values.app.persistence.storageClass | quote }}
|
||||
{{- end }}
|
||||
resources:
|
||||
requests:
|
||||
storage: {{ .Values.app.persistence.size | quote }}
|
||||
{{- end }}
|
||||
@@ -0,0 +1,80 @@
|
||||
apiVersion: apps/v1
|
||||
kind: Deployment
|
||||
metadata:
|
||||
name: {{ include "momentum.appName" . }}
|
||||
labels:
|
||||
{{- include "momentum.labels" . | nindent 4 }}
|
||||
app.kubernetes.io/component: app
|
||||
spec:
|
||||
# Stateful singleton: the loop holds in-memory trading state and a 1:1 broker
|
||||
# session. Never scale > 1. Recreate avoids two loops overlapping on rollout.
|
||||
replicas: 1
|
||||
strategy:
|
||||
type: Recreate
|
||||
selector:
|
||||
matchLabels:
|
||||
{{- include "momentum.selectorLabels" (dict "ctx" . "component" "app") | nindent 6 }}
|
||||
template:
|
||||
metadata:
|
||||
labels:
|
||||
{{- include "momentum.selectorLabels" (dict "ctx" . "component" "app") | nindent 8 }}
|
||||
annotations:
|
||||
# Roll the pod when config/secret content changes.
|
||||
checksum/config: {{ include (print $.Template.BasePath "/configmap.yaml") . | sha256sum }}
|
||||
|
The pod template annotation claims to roll on config/secret changes, but only the ConfigMap is hashed. As a result, updating the chart-managed Secret (e.g., rotating DB/broker keys) won’t trigger a rollout automatically. The pod template annotation claims to roll on config/secret changes, but only the ConfigMap is hashed. As a result, updating the chart-managed Secret (e.g., rotating DB/broker keys) won’t trigger a rollout automatically.
|
||||
spec:
|
||||
{{- with .Values.imagePullSecrets }}
|
||||
imagePullSecrets:
|
||||
{{- toYaml . | nindent 8 }}
|
||||
{{- end }}
|
||||
|
The container runs as a non-root user (per the Dockerfile), but the mounted PVC at /app/data will typically be owned by root on first mount. Without a pod securityContext (fsGroup / runAsUser), the app may fail to write logs/state into /app/data at runtime. The container runs as a non-root user (per the Dockerfile), but the mounted PVC at /app/data will typically be owned by root on first mount. Without a pod securityContext (fsGroup / runAsUser), the app may fail to write logs/state into /app/data at runtime.
|
||||
initContainers:
|
||||
# Block until Postgres accepts connections so the first app connection
|
||||
# (which auto-applies storage/pg_schema.sql) succeeds.
|
||||
- name: wait-for-postgres
|
||||
image: {{ .Values.postgres.image | quote }}
|
||||
command:
|
||||
- sh
|
||||
- -c
|
||||
- |
|
||||
until pg_isready -h {{ include "momentum.postgresName" . }} -p {{ .Values.postgres.port }} -U {{ .Values.postgres.username }}; do
|
||||
echo "waiting for postgres..."; sleep 2;
|
||||
done
|
||||
containers:
|
||||
- name: app
|
||||
image: "{{ .Values.image.repository }}:{{ .Values.image.tag }}"
|
||||
imagePullPolicy: {{ .Values.image.pullPolicy }}
|
||||
command: {{ toYaml .Values.app.command | nindent 12 }}
|
||||
ports:
|
||||
- name: http
|
||||
containerPort: 8010
|
||||
envFrom:
|
||||
- configMapRef:
|
||||
name: {{ include "momentum.configMapName" . }}
|
||||
- secretRef:
|
||||
name: {{ include "momentum.secretName" . }}
|
||||
volumeMounts:
|
||||
- name: app-data
|
||||
mountPath: /app/data
|
||||
{{- if .Values.app.probes.enabled }}
|
||||
readinessProbe:
|
||||
httpGet:
|
||||
path: /
|
||||
port: http
|
||||
initialDelaySeconds: {{ .Values.app.probes.initialDelaySeconds }}
|
||||
periodSeconds: {{ .Values.app.probes.periodSeconds }}
|
||||
livenessProbe:
|
||||
httpGet:
|
||||
path: /
|
||||
port: http
|
||||
initialDelaySeconds: {{ .Values.app.probes.initialDelaySeconds }}
|
||||
periodSeconds: {{ .Values.app.probes.periodSeconds }}
|
||||
{{- end }}
|
||||
resources:
|
||||
{{- toYaml .Values.app.resources | nindent 12 }}
|
||||
volumes:
|
||||
- name: app-data
|
||||
{{- if .Values.app.persistence.enabled }}
|
||||
persistentVolumeClaim:
|
||||
claimName: {{ include "momentum.appDataPvcName" . }}
|
||||
{{- else }}
|
||||
emptyDir: {}
|
||||
{{- end }}
|
||||
@@ -0,0 +1,14 @@
|
||||
apiVersion: v1
|
||||
kind: Service
|
||||
metadata:
|
||||
name: {{ include "momentum.appName" . }}
|
||||
labels:
|
||||
{{- include "momentum.labels" . | nindent 4 }}
|
||||
spec:
|
||||
type: {{ .Values.service.type }}
|
||||
selector:
|
||||
{{- include "momentum.selectorLabels" (dict "ctx" . "component" "app") | nindent 4 }}
|
||||
ports:
|
||||
- name: http
|
||||
port: {{ .Values.service.port }}
|
||||
targetPort: http
|
||||
@@ -0,0 +1,10 @@
|
||||
apiVersion: v1
|
||||
kind: ConfigMap
|
||||
metadata:
|
||||
name: {{ include "momentum.configMapName" . }}
|
||||
labels:
|
||||
{{- include "momentum.labels" . | nindent 4 }}
|
||||
data:
|
||||
{{- range $key, $val := .Values.config }}
|
||||
{{ $key }}: {{ $val | quote }}
|
||||
{{- end }}
|
||||
@@ -0,0 +1,59 @@
|
||||
{{- if .Values.cronjobs.enabled }}
|
||||
{{- $root := . }}
|
||||
{{- $jobs := dict "journal" .Values.cronjobs.journal "eod-replay" .Values.cronjobs.eodReplay "nightly-tune" .Values.cronjobs.nightlyTune }}
|
||||
{{- range $key, $job := $jobs }}
|
||||
|
This template ranges over a dict of jobs. Go map iteration order is non-deterministic, so the rendered manifest order can change between Helm runs, creating noisy diffs (e.g. in This template ranges over a dict of jobs. Go map iteration order is non-deterministic, so the rendered manifest order can change between Helm runs, creating noisy diffs (e.g. in `helm template` output / CI). Consider iterating a list (or rendering each CronJob explicitly) to keep output stable.
|
||||
{{- if $job.enabled }}
|
||||
---
|
||||
apiVersion: batch/v1
|
||||
kind: CronJob
|
||||
metadata:
|
||||
name: {{ include "momentum.fullname" $root }}-{{ $key }}
|
||||
labels:
|
||||
{{- include "momentum.labels" $root | nindent 4 }}
|
||||
app.kubernetes.io/component: cron-{{ $key }}
|
||||
spec:
|
||||
schedule: {{ $job.schedule | quote }}
|
||||
# US/Eastern so the market-clock schedules in .env's *.sh comments hold.
|
||||
timeZone: {{ $root.Values.cronjobs.timeZone | quote }}
|
||||
concurrencyPolicy: Forbid
|
||||
successfulJobsHistoryLimit: 3
|
||||
failedJobsHistoryLimit: 3
|
||||
jobTemplate:
|
||||
spec:
|
||||
backoffLimit: 1
|
||||
template:
|
||||
metadata:
|
||||
labels:
|
||||
{{- include "momentum.selectorLabels" (dict "ctx" $root "component" (printf "cron-%s" $key)) | nindent 12 }}
|
||||
spec:
|
||||
restartPolicy: Never
|
||||
{{- with $root.Values.imagePullSecrets }}
|
||||
imagePullSecrets:
|
||||
{{- toYaml . | nindent 12 }}
|
||||
{{- end }}
|
||||
|
CronJob pods mount the shared /app/data PVC while running as a non-root user (from the image). Without setting fsGroup/runAsUser in the pod securityContext, these jobs may be unable to read/write learned_params.json, logs, tokens, etc. on many storage classes. CronJob pods mount the shared /app/data PVC while running as a non-root user (from the image). Without setting fsGroup/runAsUser in the pod securityContext, these jobs may be unable to read/write learned_params.json, logs, tokens, etc. on many storage classes.
|
||||
containers:
|
||||
- name: {{ $key }}
|
||||
image: "{{ $root.Values.image.repository }}:{{ $root.Values.image.tag }}"
|
||||
imagePullPolicy: {{ $root.Values.image.pullPolicy }}
|
||||
command: {{ toYaml $job.command | nindent 16 }}
|
||||
envFrom:
|
||||
- configMapRef:
|
||||
name: {{ include "momentum.configMapName" $root }}
|
||||
- secretRef:
|
||||
name: {{ include "momentum.secretName" $root }}
|
||||
volumeMounts:
|
||||
- name: app-data
|
||||
mountPath: /app/data
|
||||
volumes:
|
||||
- name: app-data
|
||||
{{- if $root.Values.app.persistence.enabled }}
|
||||
# Shared with the app pod (nightly-tune writes learned_params.json
|
||||
# the loop reads on boot). RWO ⇒ cron pods schedule onto the app's node.
|
||||
persistentVolumeClaim:
|
||||
claimName: {{ include "momentum.appDataPvcName" $root }}
|
||||
{{- else }}
|
||||
emptyDir: {}
|
||||
{{- end }}
|
||||
{{- end }}
|
||||
{{- end }}
|
||||
{{- end }}
|
||||
@@ -0,0 +1,158 @@
|
||||
{{- if .Values.grafana.enabled }}
|
||||
apiVersion: v1
|
||||
kind: ConfigMap
|
||||
metadata:
|
||||
name: {{ include "momentum.grafanaName" . }}-provisioning
|
||||
labels:
|
||||
{{- include "momentum.labels" . | nindent 4 }}
|
||||
data:
|
||||
# Datasource points at the in-cluster Postgres service (not the old LAN IP).
|
||||
datasource.yaml: |
|
||||
apiVersion: 1
|
||||
datasources:
|
||||
- name: Momentum
|
||||
uid: momentum_pg
|
||||
type: postgres
|
||||
access: proxy
|
||||
url: {{ include "momentum.postgresName" . }}:{{ .Values.postgres.port }}
|
||||
user: {{ .Values.postgres.username }}
|
||||
jsonData:
|
||||
database: {{ .Values.postgres.database }}
|
||||
sslmode: disable
|
||||
postgresVersion: 1600
|
||||
secureJsonData:
|
||||
# Resolved at runtime from the env var below so it always matches the
|
||||
# password stored in the Secret (avoids re-deriving it here).
|
||||
password: $POSTGRES_PASSWORD
|
||||
isDefault: true
|
||||
editable: true
|
||||
provider.yaml: |
|
||||
apiVersion: 1
|
||||
providers:
|
||||
- name: momentum
|
||||
orgId: 1
|
||||
type: file
|
||||
disableDeletion: false
|
||||
updateIntervalSeconds: 10
|
||||
allowUiUpdates: true
|
||||
options:
|
||||
path: /etc/grafana/provisioning/dashboards
|
||||
foldersFromFilesStructure: false
|
||||
---
|
||||
apiVersion: v1
|
||||
kind: ConfigMap
|
||||
metadata:
|
||||
name: {{ include "momentum.grafanaName" . }}-dashboards
|
||||
labels:
|
||||
{{- include "momentum.labels" . | nindent 4 }}
|
||||
data:
|
||||
momentum.json: |-
|
||||
{{- .Files.Get "files/dashboard.json" | nindent 4 }}
|
||||
---
|
||||
apiVersion: apps/v1
|
||||
kind: Deployment
|
||||
metadata:
|
||||
name: {{ include "momentum.grafanaName" . }}
|
||||
labels:
|
||||
{{- include "momentum.labels" . | nindent 4 }}
|
||||
app.kubernetes.io/component: grafana
|
||||
spec:
|
||||
replicas: 1
|
||||
selector:
|
||||
matchLabels:
|
||||
{{- include "momentum.selectorLabels" (dict "ctx" . "component" "grafana") | nindent 6 }}
|
||||
template:
|
||||
metadata:
|
||||
labels:
|
||||
{{- include "momentum.selectorLabels" (dict "ctx" . "component" "grafana") | nindent 8 }}
|
||||
spec:
|
||||
containers:
|
||||
- name: grafana
|
||||
image: {{ .Values.grafana.image | quote }}
|
||||
ports:
|
||||
- name: http
|
||||
containerPort: 3000
|
||||
env:
|
||||
- name: GF_AUTH_ANONYMOUS_ENABLED
|
||||
value: "true"
|
||||
- name: GF_AUTH_ANONYMOUS_ORG_ROLE
|
||||
value: "Admin"
|
||||
- name: GF_AUTH_DISABLE_LOGIN_FORM
|
||||
value: "true"
|
||||
|
The Grafana deployment enables anonymous access with Admin role and disables the login form. In a cluster this effectively grants admin access to anyone who can reach the Service (including other pods), which is unsafe by default. The Grafana deployment enables anonymous access with Admin role and disables the login form. In a cluster this effectively grants admin access to anyone who can reach the Service (including other pods), which is unsafe by default.
|
||||
- name: GF_DASHBOARDS_MIN_REFRESH_INTERVAL
|
||||
value: "2s"
|
||||
# Interpolated into the provisioned datasource (secureJsonData.password).
|
||||
- name: POSTGRES_PASSWORD
|
||||
valueFrom:
|
||||
secretKeyRef:
|
||||
name: {{ include "momentum.secretName" . }}
|
||||
key: POSTGRES_PASSWORD
|
||||
volumeMounts:
|
||||
- name: datasources
|
||||
mountPath: /etc/grafana/provisioning/datasources
|
||||
- name: dashboard-provider
|
||||
mountPath: /etc/grafana/provisioning/dashboards/provider.yaml
|
||||
subPath: provider.yaml
|
||||
- name: dashboards
|
||||
mountPath: /etc/grafana/provisioning/dashboards/momentum.json
|
||||
subPath: momentum.json
|
||||
- name: storage
|
||||
mountPath: /var/lib/grafana
|
||||
resources:
|
||||
{{- toYaml .Values.grafana.resources | nindent 12 }}
|
||||
volumes:
|
||||
- name: datasources
|
||||
configMap:
|
||||
name: {{ include "momentum.grafanaName" . }}-provisioning
|
||||
items:
|
||||
- key: datasource.yaml
|
||||
path: datasource.yaml
|
||||
- name: dashboard-provider
|
||||
configMap:
|
||||
name: {{ include "momentum.grafanaName" . }}-provisioning
|
||||
items:
|
||||
- key: provider.yaml
|
||||
path: provider.yaml
|
||||
- name: dashboards
|
||||
configMap:
|
||||
name: {{ include "momentum.grafanaName" . }}-dashboards
|
||||
- name: storage
|
||||
{{- if .Values.grafana.persistence.enabled }}
|
||||
persistentVolumeClaim:
|
||||
claimName: {{ include "momentum.grafanaName" . }}
|
||||
{{- else }}
|
||||
emptyDir: {}
|
||||
{{- end }}
|
||||
---
|
||||
apiVersion: v1
|
||||
kind: Service
|
||||
metadata:
|
||||
name: {{ include "momentum.grafanaName" . }}
|
||||
labels:
|
||||
{{- include "momentum.labels" . | nindent 4 }}
|
||||
spec:
|
||||
type: {{ .Values.grafana.service.type }}
|
||||
selector:
|
||||
{{- include "momentum.selectorLabels" (dict "ctx" . "component" "grafana") | nindent 4 }}
|
||||
ports:
|
||||
- name: http
|
||||
port: {{ .Values.grafana.service.port }}
|
||||
targetPort: http
|
||||
{{- if .Values.grafana.persistence.enabled }}
|
||||
---
|
||||
apiVersion: v1
|
||||
kind: PersistentVolumeClaim
|
||||
metadata:
|
||||
name: {{ include "momentum.grafanaName" . }}
|
||||
labels:
|
||||
{{- include "momentum.labels" . | nindent 4 }}
|
||||
spec:
|
||||
accessModes: ["ReadWriteOnce"]
|
||||
{{- if .Values.grafana.persistence.storageClass }}
|
||||
storageClassName: {{ .Values.grafana.persistence.storageClass | quote }}
|
||||
{{- end }}
|
||||
resources:
|
||||
requests:
|
||||
storage: {{ .Values.grafana.persistence.size | quote }}
|
||||
{{- end }}
|
||||
{{- end }}
|
||||
@@ -0,0 +1,31 @@
|
||||
{{- if .Values.ingress.enabled }}
|
||||
apiVersion: networking.k8s.io/v1
|
||||
kind: Ingress
|
||||
metadata:
|
||||
name: {{ include "momentum.appName" . }}
|
||||
labels:
|
||||
{{- include "momentum.labels" . | nindent 4 }}
|
||||
{{- with .Values.ingress.annotations }}
|
||||
annotations:
|
||||
{{- toYaml . | nindent 4 }}
|
||||
{{- end }}
|
||||
spec:
|
||||
{{- if .Values.ingress.className }}
|
||||
ingressClassName: {{ .Values.ingress.className }}
|
||||
{{- end }}
|
||||
{{- with .Values.ingress.tls }}
|
||||
tls:
|
||||
{{- toYaml . | nindent 4 }}
|
||||
{{- end }}
|
||||
rules:
|
||||
- host: {{ .Values.ingress.host | quote }}
|
||||
http:
|
||||
paths:
|
||||
- path: /
|
||||
pathType: Prefix
|
||||
backend:
|
||||
service:
|
||||
name: {{ include "momentum.appName" . }}
|
||||
port:
|
||||
number: {{ .Values.service.port }}
|
||||
{{- end }}
|
||||
@@ -0,0 +1,15 @@
|
||||
apiVersion: v1
|
||||
kind: Service
|
||||
metadata:
|
||||
name: {{ include "momentum.postgresName" . }}
|
||||
labels:
|
||||
{{- include "momentum.labels" . | nindent 4 }}
|
||||
spec:
|
||||
# Headless: stable DNS for the single StatefulSet pod.
|
||||
clusterIP: None
|
||||
selector:
|
||||
{{- include "momentum.selectorLabels" (dict "ctx" . "component" "postgres") | nindent 4 }}
|
||||
ports:
|
||||
- name: postgres
|
||||
port: {{ .Values.postgres.port }}
|
||||
targetPort: postgres
|
||||
@@ -0,0 +1,67 @@
|
||||
apiVersion: apps/v1
|
||||
kind: StatefulSet
|
||||
metadata:
|
||||
name: {{ include "momentum.postgresName" . }}
|
||||
labels:
|
||||
{{- include "momentum.labels" . | nindent 4 }}
|
||||
app.kubernetes.io/component: postgres
|
||||
spec:
|
||||
serviceName: {{ include "momentum.postgresName" . }}
|
||||
replicas: 1
|
||||
selector:
|
||||
matchLabels:
|
||||
{{- include "momentum.selectorLabels" (dict "ctx" . "component" "postgres") | nindent 6 }}
|
||||
template:
|
||||
metadata:
|
||||
labels:
|
||||
{{- include "momentum.selectorLabels" (dict "ctx" . "component" "postgres") | nindent 8 }}
|
||||
spec:
|
||||
{{- with .Values.imagePullSecrets }}
|
||||
imagePullSecrets:
|
||||
{{- toYaml . | nindent 8 }}
|
||||
{{- end }}
|
||||
containers:
|
||||
- name: postgres
|
||||
image: {{ .Values.postgres.image | quote }}
|
||||
ports:
|
||||
- name: postgres
|
||||
containerPort: {{ .Values.postgres.port }}
|
||||
env:
|
||||
- name: POSTGRES_USER
|
||||
value: {{ .Values.postgres.username | quote }}
|
||||
- name: POSTGRES_DB
|
||||
value: {{ .Values.postgres.database | quote }}
|
||||
- name: POSTGRES_PASSWORD
|
||||
valueFrom:
|
||||
secretKeyRef:
|
||||
name: {{ include "momentum.secretName" . }}
|
||||
key: POSTGRES_PASSWORD
|
||||
# Keep PGDATA in a subdir so the volume root (lost+found) doesn't clash.
|
||||
- name: PGDATA
|
||||
value: /var/lib/postgresql/data/pgdata
|
||||
volumeMounts:
|
||||
- name: data
|
||||
mountPath: /var/lib/postgresql/data
|
||||
readinessProbe:
|
||||
exec:
|
||||
command: ["pg_isready", "-U", {{ .Values.postgres.username | quote }}, "-d", {{ .Values.postgres.database | quote }}]
|
||||
initialDelaySeconds: 10
|
||||
periodSeconds: 10
|
||||
livenessProbe:
|
||||
exec:
|
||||
command: ["pg_isready", "-U", {{ .Values.postgres.username | quote }}, "-d", {{ .Values.postgres.database | quote }}]
|
||||
initialDelaySeconds: 30
|
||||
periodSeconds: 15
|
||||
resources:
|
||||
{{- toYaml .Values.postgres.resources | nindent 12 }}
|
||||
volumeClaimTemplates:
|
||||
- metadata:
|
||||
name: data
|
||||
spec:
|
||||
accessModes: ["{{ .Values.postgres.persistence.accessMode }}"]
|
||||
{{- if .Values.postgres.persistence.storageClass }}
|
||||
storageClassName: {{ .Values.postgres.persistence.storageClass | quote }}
|
||||
{{- end }}
|
||||
resources:
|
||||
requests:
|
||||
storage: {{ .Values.postgres.persistence.size | quote }}
|
||||
@@ -0,0 +1,26 @@
|
||||
{{- if not .Values.secrets.existingSecret }}
|
||||
{{- $pgPassword := include "momentum.postgresPassword" . }}
|
||||
{{- $pgHost := include "momentum.postgresName" . }}
|
||||
{{- $databaseUrl := printf "postgresql://%s:%s@%s:%v/%s" .Values.postgres.username $pgPassword $pgHost .Values.postgres.port .Values.postgres.database }}
|
||||
|
DATABASE_URL is constructed via string interpolation without URL-encoding. If a user supplies a Postgres password/username containing reserved URL characters (e.g. ':' or '@'), psycopg2 URL parsing will break and the app won’t be able to connect. DATABASE_URL is constructed via string interpolation without URL-encoding. If a user supplies a Postgres password/username containing reserved URL characters (e.g. ':' or '@'), psycopg2 URL parsing will break and the app won’t be able to connect.
|
||||
apiVersion: v1
|
||||
kind: Secret
|
||||
metadata:
|
||||
name: {{ include "momentum.fullname" . }}-secrets
|
||||
labels:
|
||||
{{- include "momentum.labels" . | nindent 4 }}
|
||||
type: Opaque
|
||||
stringData:
|
||||
# Consumed by the Postgres StatefulSet.
|
||||
POSTGRES_PASSWORD: {{ $pgPassword | quote }}
|
||||
# Consumed by the app + CronJobs (storage/db_pg.py prefers DATABASE_URL).
|
||||
DATABASE_URL: {{ $databaseUrl | quote }}
|
||||
# Alpaca paper broker (the client reads both the ALPACA_* and APCA_* names).
|
||||
ALPACA_API_KEY: {{ .Values.secrets.alpacaApiKey | quote }}
|
||||
ALPACA_SECRET_KEY: {{ .Values.secrets.alpacaSecretKey | quote }}
|
||||
APCA_API_KEY_ID: {{ .Values.secrets.alpacaApiKey | quote }}
|
||||
APCA_API_SECRET_KEY: {{ .Values.secrets.alpacaSecretKey | quote }}
|
||||
APCA_API_BASE_URL: {{ .Values.secrets.alpacaBaseUrl | quote }}
|
||||
{{- range $k, $v := .Values.secrets.schwab }}
|
||||
{{ $k }}: {{ $v | quote }}
|
||||
{{- end }}
|
||||
{{- end }}
|
||||
@@ -0,0 +1,162 @@
|
||||
# Default values for the momentum chart.
|
||||
# Override per-environment with `-f my-values.yaml` or `--set key=value`.
|
||||
|
||||
# -- Container image for every workload (app loop, dashboard, CronJobs).
|
||||
image:
|
||||
repository: ghcr.io/fez5587/momentum-system
|
||||
# Pin to a pushed tag (commit SHA or `latest`) from the docker-publish workflow.
|
||||
tag: latest
|
||||
pullPolicy: IfNotPresent
|
||||
|
||||
# Optional image pull secrets for a private GHCR package.
|
||||
imagePullSecrets: []
|
||||
|
||||
nameOverride: ""
|
||||
fullnameOverride: ""
|
||||
|
||||
# ----------------------------------------------------------------------------
|
||||
# App: the live trading loop WITH its embedded dashboard (port 8010).
|
||||
# It is a stateful singleton — do not raise replicas above 1.
|
||||
# ----------------------------------------------------------------------------
|
||||
app:
|
||||
# Runs the loop with the dashboard attached (the dashboard's approve/reject/
|
||||
# exit endpoints require the in-process TradingExecutionService).
|
||||
command: ["python", "run_live_paper.py"]
|
||||
resources:
|
||||
requests:
|
||||
cpu: 100m
|
||||
memory: 256Mi
|
||||
limits:
|
||||
memory: 1Gi
|
||||
# Shared writable dir (DuckDB research file, logs, learned_params.json, tokens).
|
||||
# The nightly-tune CronJob writes learned_params.json here for the loop to read.
|
||||
persistence:
|
||||
enabled: true
|
||||
size: 5Gi
|
||||
storageClass: ""
|
||||
accessMode: ReadWriteOnce
|
||||
# HTTP probes hit the dashboard root.
|
||||
probes:
|
||||
enabled: true
|
||||
initialDelaySeconds: 30
|
||||
periodSeconds: 30
|
||||
|
||||
# Dashboard service (ClusterIP by default; port-forward to reach it).
|
||||
service:
|
||||
type: ClusterIP
|
||||
port: 8010
|
||||
|
||||
ingress:
|
||||
enabled: false
|
||||
className: ""
|
||||
annotations: {}
|
||||
host: momentum.local
|
||||
tls: []
|
||||
|
||||
# ----------------------------------------------------------------------------
|
||||
# In-cluster PostgreSQL — the single source of truth (StatefulSet + PVC).
|
||||
# Schema auto-creates on first app connection from storage/pg_schema.sql.
|
||||
# ----------------------------------------------------------------------------
|
||||
postgres:
|
||||
image: postgres:16
|
||||
database: momentum
|
||||
username: momentum
|
||||
# If empty, a random password is generated on first install and stored in the
|
||||
# chart-managed Secret. Set explicitly to keep it stable across upgrades.
|
||||
password: ""
|
||||
port: 5432
|
||||
resources:
|
||||
requests:
|
||||
cpu: 100m
|
||||
memory: 256Mi
|
||||
limits:
|
||||
memory: 1Gi
|
||||
persistence:
|
||||
size: 10Gi
|
||||
storageClass: ""
|
||||
accessMode: ReadWriteOnce
|
||||
|
||||
# ----------------------------------------------------------------------------
|
||||
# Secrets — DB URL is built from the in-cluster Postgres service automatically.
|
||||
# Provide broker keys via --set or, better, an existingSecret (sealed-secrets,
|
||||
# external-secrets, etc). Never commit real keys into values.yaml.
|
||||
# ----------------------------------------------------------------------------
|
||||
secrets:
|
||||
# If set, the chart will NOT render its own Secret and instead expects this
|
||||
# one to already contain DATABASE_URL, ALPACA_*/APCA_*, and any SCHWAB_* keys.
|
||||
existingSecret: ""
|
||||
alpacaApiKey: ""
|
||||
alpacaSecretKey: ""
|
||||
alpacaBaseUrl: "https://paper-api.alpaca.markets"
|
||||
schwab: {} # e.g. SCHWAB_MARKET_DATA_APP_KEY: "..."
|
||||
|
||||
# ----------------------------------------------------------------------------
|
||||
# Non-secret tuning env (ConfigMap). Mirrors .env.example; override freely.
|
||||
# ----------------------------------------------------------------------------
|
||||
config:
|
||||
TRADING_EXECUTION_MODE: "alpaca_paper"
|
||||
TRADING_EXECUTION_ENABLED: "1"
|
||||
TRADING_AUTO_APPROVE: "0"
|
||||
TRADING_MAX_CONCURRENT_POSITIONS: "6"
|
||||
TRADING_RISK_PER_TRADE_PCT: "0.01"
|
||||
TRADING_MAX_DAILY_LOSS_PCT: "0.03"
|
||||
TRADING_EOD_FLATTEN_ENABLED: "1"
|
||||
WATCHER_ENABLED: "1"
|
||||
WATCHER_INTERVAL_SECONDS: "60"
|
||||
WATCHER_SYMBOLS: "AAPL,TSLA,NVDA,AMD,SOFI"
|
||||
WATCHER_PRICE_MIN: "1"
|
||||
WATCHER_PRICE_MAX: "20"
|
||||
STRATEGY_SETUPS: "opening_range_break"
|
||||
LIVE_BARS_ENABLED: "1"
|
||||
LIVE_BARS_INTERVAL_SECONDS: "60"
|
||||
ALPACA_DATA_FEED: "iex"
|
||||
ALPACA_BACKFILL_FEED: "sip"
|
||||
TRIGGER_ENABLED: "1"
|
||||
TRIGGER_GAP_MIN: "3.0"
|
||||
TRIGGER_GAP_MAX: "35"
|
||||
TRIGGER_RVOL_MIN: "2.0"
|
||||
ORB_BARS: "5"
|
||||
DASHBOARD_HOST: "0.0.0.0"
|
||||
DASHBOARD_PORT: "8010"
|
||||
# Leave schema auto-creation on (storage/db_pg.py applies pg_schema.sql).
|
||||
PG_SKIP_SCHEMA: "0"
|
||||
|
||||
# ----------------------------------------------------------------------------
|
||||
# Scheduled jobs (CronJobs). Times are US/Eastern via spec.timeZone (K8s >=1.27),
|
||||
# weekdays only — matching the existing *.sh crontab comments.
|
||||
# ----------------------------------------------------------------------------
|
||||
cronjobs:
|
||||
enabled: true
|
||||
timeZone: America/New_York
|
||||
journal:
|
||||
enabled: true
|
||||
schedule: "2 16 * * 1-5"
|
||||
command: ["python", "momentum_cli.py", "journal"]
|
||||
eodReplay:
|
||||
enabled: true
|
||||
schedule: "5 16 * * 1-5"
|
||||
command: ["python", "eod_replay.py"]
|
||||
nightlyTune:
|
||||
enabled: true
|
||||
schedule: "30 17 * * 1-5"
|
||||
command: ["python", "nightly_tune.py"]
|
||||
|
||||
# ----------------------------------------------------------------------------
|
||||
# Grafana (optional) — provisioned against the in-cluster Postgres service.
|
||||
# ----------------------------------------------------------------------------
|
||||
grafana:
|
||||
enabled: true
|
||||
image: grafana/grafana:11.2.0
|
||||
|
Grafana is described as optional, but it is enabled by default. Given the current template enables anonymous admin access, default-on Grafana is a risky default for in-cluster installs. Grafana is described as optional, but it is enabled by default. Given the current template enables anonymous admin access, default-on Grafana is a risky default for in-cluster installs.
|
||||
service:
|
||||
type: ClusterIP
|
||||
port: 3000
|
||||
persistence:
|
||||
enabled: true
|
||||
size: 1Gi
|
||||
storageClass: ""
|
||||
resources:
|
||||
requests:
|
||||
cpu: 50m
|
||||
memory: 128Mi
|
||||
limits:
|
||||
memory: 512Mi
|
||||
@@ -0,0 +1,137 @@
|
||||
# Full local stack for the Momentum System — mirrors the Helm package (deploy/).
|
||||
#
|
||||
# docker compose up -d # postgres + app loop + dashboard
|
||||
# docker compose --profile monitoring up -d # also start Grafana
|
||||
# docker compose run --rm nightly-tune # run a scheduled job on demand
|
||||
#
|
||||
# Config/secrets come from a local .env (copy from .env.example). DATABASE_URL is
|
||||
# overridden below to point at the in-network postgres service regardless of what
|
||||
# .env says, so the stack is self-contained. Without ALPACA_* keys the loop runs
|
||||
# in dry mode (dashboard + DB still work).
|
||||
|
||||
services:
|
||||
postgres:
|
||||
image: postgres:16
|
||||
environment:
|
||||
POSTGRES_USER: ${POSTGRES_USER:-momentum}
|
||||
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:-momentum}
|
||||
POSTGRES_DB: ${POSTGRES_DB:-momentum}
|
||||
# Keep data in a subdir so the volume root doesn't clash with PGDATA init.
|
||||
PGDATA: /var/lib/postgresql/data/pgdata
|
||||
volumes:
|
||||
- pg-data:/var/lib/postgresql/data
|
||||
ports:
|
||||
# Exposed for psql/Grafana-on-host debugging; remove if not needed.
|
||||
- "5432:5432"
|
||||
healthcheck:
|
||||
test: ["CMD-SHELL", "pg_isready -U ${POSTGRES_USER:-momentum} -d ${POSTGRES_DB:-momentum}"]
|
||||
interval: 10s
|
||||
timeout: 5s
|
||||
retries: 5
|
||||
restart: unless-stopped
|
||||
|
||||
# Trading loop WITH the embedded dashboard (the dashboard's approve/reject/exit
|
||||
# endpoints need the in-process TradingExecutionService — see deploy/README.md).
|
||||
app:
|
||||
build: .
|
||||
command: ["python", "run_live_paper.py"]
|
||||
env_file:
|
||||
- path: .env
|
||||
required: false
|
||||
environment: &app-env
|
||||
# Built from the postgres service; overrides any DATABASE_URL in .env.
|
||||
DATABASE_URL: postgresql://${POSTGRES_USER:-momentum}:${POSTGRES_PASSWORD:-momentum}@postgres:5432/${POSTGRES_DB:-momentum}
|
||||
DASHBOARD_HOST: "0.0.0.0"
|
||||
DASHBOARD_PORT: "8010"
|
||||
# Schema auto-creates from storage/pg_schema.sql on first connect.
|
||||
PG_SKIP_SCHEMA: "0"
|
||||
ports:
|
||||
- "8010:8010"
|
||||
volumes:
|
||||
# DuckDB research file, logs, learned_params.json, schwab tokens. Shared
|
||||
# with the job services so nightly-tune's output reaches the loop.
|
||||
- app-data:/app/data
|
||||
depends_on:
|
||||
postgres:
|
||||
condition: service_healthy
|
||||
restart: unless-stopped
|
||||
|
||||
# ---- Scheduled jobs (the *.sh crontab equivalents). On-demand only: they sit
|
||||
# under the "jobs" profile so `up` doesn't start them. Run with:
|
||||
# docker compose run --rm journal | eod-replay | nightly-tune
|
||||
# (Use host cron / a scheduler to invoke these on the ET schedule in .env.)
|
||||
journal:
|
||||
build: .
|
||||
profiles: ["jobs"]
|
||||
command: ["python", "momentum_cli.py", "journal"]
|
||||
env_file:
|
||||
- path: .env
|
||||
required: false
|
||||
environment: *app-env
|
||||
volumes:
|
||||
- app-data:/app/data
|
||||
depends_on:
|
||||
postgres:
|
||||
condition: service_healthy
|
||||
restart: "no"
|
||||
|
||||
eod-replay:
|
||||
build: .
|
||||
profiles: ["jobs"]
|
||||
command: ["python", "eod_replay.py"]
|
||||
env_file:
|
||||
- path: .env
|
||||
required: false
|
||||
environment: *app-env
|
||||
volumes:
|
||||
- app-data:/app/data
|
||||
depends_on:
|
||||
postgres:
|
||||
condition: service_healthy
|
||||
restart: "no"
|
||||
|
||||
nightly-tune:
|
||||
build: .
|
||||
profiles: ["jobs"]
|
||||
command: ["python", "nightly_tune.py"]
|
||||
env_file:
|
||||
- path: .env
|
||||
required: false
|
||||
environment: *app-env
|
||||
volumes:
|
||||
- app-data:/app/data
|
||||
depends_on:
|
||||
postgres:
|
||||
condition: service_healthy
|
||||
restart: "no"
|
||||
|
||||
# ---- Grafana (optional): `docker compose --profile monitoring up -d`
|
||||
grafana:
|
||||
image: grafana/grafana:11.2.0
|
||||
profiles: ["monitoring"]
|
||||
environment:
|
||||
GF_AUTH_ANONYMOUS_ENABLED: "true"
|
||||
GF_AUTH_ANONYMOUS_ORG_ROLE: "Admin"
|
||||
GF_AUTH_DISABLE_LOGIN_FORM: "true"
|
||||
GF_DASHBOARDS_MIN_REFRESH_INTERVAL: "2s"
|
||||
# Interpolated into the provisioned datasource (see datasource override).
|
||||
PG_USER: ${POSTGRES_USER:-momentum}
|
||||
PG_PASSWORD: ${POSTGRES_PASSWORD:-momentum}
|
||||
PG_DATABASE: ${POSTGRES_DB:-momentum}
|
||||
ports:
|
||||
- "3000:3000"
|
||||
volumes:
|
||||
# Compose-specific datasource points at the in-network postgres service;
|
||||
# the dashboard + provider are reused from the existing grafana/ dir.
|
||||
- ./deploy/compose/grafana/datasources:/etc/grafana/provisioning/datasources:ro
|
||||
- ./grafana/provisioning/dashboards:/etc/grafana/provisioning/dashboards:ro
|
||||
- grafana-data:/var/lib/grafana
|
||||
depends_on:
|
||||
postgres:
|
||||
condition: service_healthy
|
||||
restart: unless-stopped
|
||||
|
||||
volumes:
|
||||
pg-data:
|
||||
app-data:
|
||||
grafana-data:
|
||||
The workflow is configured to build/publish on a specific feature branch name (claude/kubernetes-image-package-kkdkom). Keeping a one-off branch trigger in main will cause unexpected publishes if the branch is reused, and it’s easy to forget to remove later.