Author SHA1 Message Date
Claude a76c0b5376 Add Docker Compose stack for local development
Single-machine equivalent of the Helm package: builds the app from the
Dockerfile, runs PostgreSQL with a persistent volume, and starts the trading
loop with its embedded dashboard on :8010.

- postgres:16 with healthcheck + named volume; app waits on it healthy
- DATABASE_URL overridden to the in-network postgres service (self-contained);
  .env is optional (dry mode without ALPACA_* keys)
- journal / eod-replay / nightly-tune under a "jobs" profile, run on demand;
  they share the app-data volume so learned_params.json reaches the loop
- Grafana under a "monitoring" profile with a compose-specific datasource
  override pointed at the postgres service
- deploy/README.md documents the Compose workflow

Validated with `docker compose config` across all profiles.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JCVMm2AbmZvvxwLTX6UpNR
2026-06-29 21:40:19 +00:00
Claude 4173acc9d5 docs: add future-hardening roadmap to deploy README
Document reliability, security, and CI/operational improvements for the
Kubernetes package as an actionable backlog (not yet implemented). Notably
flags the loop-heartbeat liveness gap: /api/health proves only the dashboard
thread is alive, so a hung trading loop would go undetected.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JCVMm2AbmZvvxwLTX6UpNR
2026-06-29 21:35:38 +00:00
Claude e91979a622 Add Kubernetes image + Helm package with in-cluster Postgres
Containerizes the momentum monolith and packages every run mode for
deployment in a single Kubernetes namespace, with PostgreSQL hosted in
the cluster.

- Dockerfile (python:3.11-slim, non-root, writable /app/data) + .dockerignore
- GitHub Actions workflow publishing to ghcr.io/fez5587/momentum-system
- Helm chart (deploy/helm/momentum):
  * PostgreSQL StatefulSet + PVC (single source of truth; schema
    auto-creates from storage/pg_schema.sql on first connect)
  * App Deployment running run_live_paper.py with the embedded dashboard
    (replicas: 1, Recreate; the dashboard's write endpoints need the
    in-process TradingExecutionService) + Service/Ingress on 8010, plus a
    wait-for-postgres initContainer
  * CronJobs for journal / eod-replay / nightly-tune (US/Eastern via
    spec.timeZone) sharing the app data PVC
  * ConfigMap (tuning env) + Secret (DATABASE_URL/broker keys, existingSecret
    supported); DATABASE_URL built from the in-cluster Postgres service
  * Optional Grafana provisioned against the in-cluster Postgres
- deploy/README.md with install, secrets, and constraints

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JCVMm2AbmZvvxwLTX6UpNR
2026-06-19 17:22:06 +00:00
22 changed files with 1375 additions and 0 deletions

No files matched your search

+48
View File
@@ -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
+57
View File
@@ -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
+41
View File
@@ -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"]
+182
View File
@@ -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
+5
View File
@@ -0,0 +1,5 @@
.DS_Store
.git
*.tmp
*.bak
*.orig
+14
View File
@@ -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
+137
View File
@@ -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"
}]
}
]
}
+28
View File
@@ -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 }}
spec:
{{- with .Values.imagePullSecrets }}
imagePullSecrets:
{{- toYaml . | nindent 8 }}
{{- end }}
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 }}
{{- 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 }}
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 }}
+158
View File
@@ -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"
- 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 }}
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 }}
+162
View File
@@ -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
service:
type: ClusterIP
port: 3000
persistence:
enabled: true
size: 1Gi
storageClass: ""
resources:
requests:
cpu: 50m
memory: 128Mi
limits:
memory: 512Mi
+137
View File
@@ -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: