From e91979a622a4b7119489db5aeeed4de3113b79b5 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 19 Jun 2026 17:22:06 +0000 Subject: [PATCH] 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) Claude-Session: https://claude.ai/code/session_01JCVMm2AbmZvvxwLTX6UpNR --- .dockerignore | 48 ++++++ .github/workflows/docker-publish.yml | 57 ++++++ Dockerfile | 41 +++++ deploy/README.md | 97 +++++++++++ deploy/helm/momentum/.helmignore | 5 + deploy/helm/momentum/Chart.yaml | 14 ++ deploy/helm/momentum/files/dashboard.json | 137 +++++++++++++++ deploy/helm/momentum/templates/NOTES.txt | 28 +++ deploy/helm/momentum/templates/_helpers.tpl | 69 ++++++++ .../helm/momentum/templates/app-data-pvc.yaml | 16 ++ .../momentum/templates/app-deployment.yaml | 80 +++++++++ .../helm/momentum/templates/app-service.yaml | 14 ++ deploy/helm/momentum/templates/configmap.yaml | 10 ++ deploy/helm/momentum/templates/cronjobs.yaml | 59 +++++++ deploy/helm/momentum/templates/grafana.yaml | 158 +++++++++++++++++ deploy/helm/momentum/templates/ingress.yaml | 31 ++++ .../momentum/templates/postgres-service.yaml | 15 ++ .../templates/postgres-statefulset.yaml | 67 ++++++++ deploy/helm/momentum/templates/secret.yaml | 26 +++ deploy/helm/momentum/values.yaml | 162 ++++++++++++++++++ 20 files changed, 1134 insertions(+) create mode 100644 .dockerignore create mode 100644 .github/workflows/docker-publish.yml create mode 100644 Dockerfile create mode 100644 deploy/README.md create mode 100644 deploy/helm/momentum/.helmignore create mode 100644 deploy/helm/momentum/Chart.yaml create mode 100644 deploy/helm/momentum/files/dashboard.json create mode 100644 deploy/helm/momentum/templates/NOTES.txt create mode 100644 deploy/helm/momentum/templates/_helpers.tpl create mode 100644 deploy/helm/momentum/templates/app-data-pvc.yaml create mode 100644 deploy/helm/momentum/templates/app-deployment.yaml create mode 100644 deploy/helm/momentum/templates/app-service.yaml create mode 100644 deploy/helm/momentum/templates/configmap.yaml create mode 100644 deploy/helm/momentum/templates/cronjobs.yaml create mode 100644 deploy/helm/momentum/templates/grafana.yaml create mode 100644 deploy/helm/momentum/templates/ingress.yaml create mode 100644 deploy/helm/momentum/templates/postgres-service.yaml create mode 100644 deploy/helm/momentum/templates/postgres-statefulset.yaml create mode 100644 deploy/helm/momentum/templates/secret.yaml create mode 100644 deploy/helm/momentum/values.yaml diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..550f626 --- /dev/null +++ b/.dockerignore @@ -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 diff --git a/.github/workflows/docker-publish.yml b/.github/workflows/docker-publish.yml new file mode 100644 index 0000000..a361078 --- /dev/null +++ b/.github/workflows/docker-publish.yml @@ -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 diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..7ff57f9 --- /dev/null +++ b/Dockerfile @@ -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"] diff --git a/deploy/README.md b/deploy/README.md new file mode 100644 index 0000000..829189e --- /dev/null +++ b/deploy/README.md @@ -0,0 +1,97 @@ +# 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. + +## 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. diff --git a/deploy/helm/momentum/.helmignore b/deploy/helm/momentum/.helmignore new file mode 100644 index 0000000..4efa5fd --- /dev/null +++ b/deploy/helm/momentum/.helmignore @@ -0,0 +1,5 @@ +.DS_Store +.git +*.tmp +*.bak +*.orig diff --git a/deploy/helm/momentum/Chart.yaml b/deploy/helm/momentum/Chart.yaml new file mode 100644 index 0000000..f5870d5 --- /dev/null +++ b/deploy/helm/momentum/Chart.yaml @@ -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 diff --git a/deploy/helm/momentum/files/dashboard.json b/deploy/helm/momentum/files/dashboard.json new file mode 100644 index 0000000..657375f --- /dev/null +++ b/deploy/helm/momentum/files/dashboard.json @@ -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" + }] + } + ] +} diff --git a/deploy/helm/momentum/templates/NOTES.txt b/deploy/helm/momentum/templates/NOTES.txt new file mode 100644 index 0000000..c52cf3b --- /dev/null +++ b/deploy/helm/momentum/templates/NOTES.txt @@ -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' diff --git a/deploy/helm/momentum/templates/_helpers.tpl b/deploy/helm/momentum/templates/_helpers.tpl new file mode 100644 index 0000000..3691301 --- /dev/null +++ b/deploy/helm/momentum/templates/_helpers.tpl @@ -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 -}} diff --git a/deploy/helm/momentum/templates/app-data-pvc.yaml b/deploy/helm/momentum/templates/app-data-pvc.yaml new file mode 100644 index 0000000..e10ecd3 --- /dev/null +++ b/deploy/helm/momentum/templates/app-data-pvc.yaml @@ -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 }} diff --git a/deploy/helm/momentum/templates/app-deployment.yaml b/deploy/helm/momentum/templates/app-deployment.yaml new file mode 100644 index 0000000..e816227 --- /dev/null +++ b/deploy/helm/momentum/templates/app-deployment.yaml @@ -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 }} diff --git a/deploy/helm/momentum/templates/app-service.yaml b/deploy/helm/momentum/templates/app-service.yaml new file mode 100644 index 0000000..8c99a4b --- /dev/null +++ b/deploy/helm/momentum/templates/app-service.yaml @@ -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 diff --git a/deploy/helm/momentum/templates/configmap.yaml b/deploy/helm/momentum/templates/configmap.yaml new file mode 100644 index 0000000..75c03fa --- /dev/null +++ b/deploy/helm/momentum/templates/configmap.yaml @@ -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 }} diff --git a/deploy/helm/momentum/templates/cronjobs.yaml b/deploy/helm/momentum/templates/cronjobs.yaml new file mode 100644 index 0000000..9414b85 --- /dev/null +++ b/deploy/helm/momentum/templates/cronjobs.yaml @@ -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 }} diff --git a/deploy/helm/momentum/templates/grafana.yaml b/deploy/helm/momentum/templates/grafana.yaml new file mode 100644 index 0000000..5204d98 --- /dev/null +++ b/deploy/helm/momentum/templates/grafana.yaml @@ -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 }} diff --git a/deploy/helm/momentum/templates/ingress.yaml b/deploy/helm/momentum/templates/ingress.yaml new file mode 100644 index 0000000..97b8dd0 --- /dev/null +++ b/deploy/helm/momentum/templates/ingress.yaml @@ -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 }} diff --git a/deploy/helm/momentum/templates/postgres-service.yaml b/deploy/helm/momentum/templates/postgres-service.yaml new file mode 100644 index 0000000..6c2f9c8 --- /dev/null +++ b/deploy/helm/momentum/templates/postgres-service.yaml @@ -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 diff --git a/deploy/helm/momentum/templates/postgres-statefulset.yaml b/deploy/helm/momentum/templates/postgres-statefulset.yaml new file mode 100644 index 0000000..d40decc --- /dev/null +++ b/deploy/helm/momentum/templates/postgres-statefulset.yaml @@ -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 }} diff --git a/deploy/helm/momentum/templates/secret.yaml b/deploy/helm/momentum/templates/secret.yaml new file mode 100644 index 0000000..a278207 --- /dev/null +++ b/deploy/helm/momentum/templates/secret.yaml @@ -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 }} diff --git a/deploy/helm/momentum/values.yaml b/deploy/helm/momentum/values.yaml new file mode 100644 index 0000000..ee691e0 --- /dev/null +++ b/deploy/helm/momentum/values.yaml @@ -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