Add Kubernetes image + Helm package with in-cluster Postgres #1

Merged
fez5587 merged 3 commits from claude/kubernetes-image-package-kkdkom into master 2026-06-29 17:47:26 -04: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
copilot-pull-request-reviewer[bot] commented 2026-06-29 17:46:13 -04:00 (Migrated from github.com)
Review

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.

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.
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 }}
copilot-pull-request-reviewer[bot] commented 2026-06-29 17:46:12 -04:00 (Migrated from github.com)
Review

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 }}
copilot-pull-request-reviewer[bot] commented 2026-06-29 17:46:13 -04:00 (Migrated from github.com)
Review

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 }}
copilot-pull-request-reviewer[bot] commented 2026-06-29 17:46:14 -04:00 (Migrated from github.com)
Review

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.

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 }}
copilot-pull-request-reviewer[bot] commented 2026-06-29 17:46:13 -04:00 (Migrated from github.com)
Review

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 }}
+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"
copilot-pull-request-reviewer[bot] commented 2026-06-29 17:46:14 -04:00 (Migrated from github.com)
Review

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 }}
copilot-pull-request-reviewer[bot] commented 2026-06-29 17:46:13 -04:00 (Migrated from github.com)
Review

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 }}
+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
copilot-pull-request-reviewer[bot] commented 2026-06-29 17:46:14 -04:00 (Migrated from github.com)
Review

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
+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: