From e91979a622a4b7119489db5aeeed4de3113b79b5 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 19 Jun 2026 17:22:06 +0000 Subject: [PATCH 1/4] 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 -- 2.54.0 From 4173acc9d5de1579202eeb0e826f22a73e67549d Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 29 Jun 2026 21:35:38 +0000 Subject: [PATCH 2/4] docs: add future-hardening roadmap to deploy README Document reliability, security, and CI/operational improvements for the Kubernetes package as an actionable backlog (not yet implemented). Notably flags the loop-heartbeat liveness gap: /api/health proves only the dashboard thread is alive, so a hung trading loop would go undetected. Co-Authored-By: Claude Opus 4.8 (1M context) Claude-Session: https://claude.ai/code/session_01JCVMm2AbmZvvxwLTX6UpNR --- deploy/README.md | 48 ++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 48 insertions(+) diff --git a/deploy/README.md b/deploy/README.md index 829189e..c87dd7f 100644 --- a/deploy/README.md +++ b/deploy/README.md @@ -95,3 +95,51 @@ kubectl -n momentum create job --from=cronjob/momentum-nightly-tune tune-test - **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. -- 2.54.0 From a76c0b53763f68c762e0275dd4fe97cbdafdf2af Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 29 Jun 2026 21:40:19 +0000 Subject: [PATCH 3/4] Add Docker Compose stack for local development Single-machine equivalent of the Helm package: builds the app from the Dockerfile, runs PostgreSQL with a persistent volume, and starts the trading loop with its embedded dashboard on :8010. - postgres:16 with healthcheck + named volume; app waits on it healthy - DATABASE_URL overridden to the in-network postgres service (self-contained); .env is optional (dry mode without ALPACA_* keys) - journal / eod-replay / nightly-tune under a "jobs" profile, run on demand; they share the app-data volume so learned_params.json reaches the loop - Grafana under a "monitoring" profile with a compose-specific datasource override pointed at the postgres service - deploy/README.md documents the Compose workflow Validated with `docker compose config` across all profiles. Co-Authored-By: Claude Opus 4.8 (1M context) Claude-Session: https://claude.ai/code/session_01JCVMm2AbmZvvxwLTX6UpNR --- deploy/README.md | 37 +++++ .../compose/grafana/datasources/momentum.yaml | 19 +++ docker-compose.yml | 137 ++++++++++++++++++ 3 files changed, 193 insertions(+) create mode 100644 deploy/compose/grafana/datasources/momentum.yaml create mode 100644 docker-compose.yml diff --git a/deploy/README.md b/deploy/README.md index c87dd7f..a5caadf 100644 --- a/deploy/README.md +++ b/deploy/README.md @@ -15,6 +15,43 @@ its **PostgreSQL datastore** — inside a single Kubernetes namespace. 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 diff --git a/deploy/compose/grafana/datasources/momentum.yaml b/deploy/compose/grafana/datasources/momentum.yaml new file mode 100644 index 0000000..a072133 --- /dev/null +++ b/deploy/compose/grafana/datasources/momentum.yaml @@ -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 diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..6fe7000 --- /dev/null +++ b/docker-compose.yml @@ -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: -- 2.54.0 From 37f39299ad1b80d2db07214ceeea27b59eaf2fe3 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 29 Jun 2026 21:47:41 +0000 Subject: [PATCH 4/4] spread_pct P0: real decision-time bid/ask spread as an observe-only metric MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit GO_LIVE flags `spread_pct` as a not-yet-real field. The reason it was null is structural: OHLCV bars carry no NBBO, so `minute_bars.spread_pct` can never be populated from the bars endpoint. The execution-relevant figure is the spread at the moment a setup is evaluated — a wide book quietly eats R on the thin sub-$20 names this system trades. This makes that metric real, shadow-first (measure before gating), mirroring how the catalyst score was introduced: - `strategy/evaluation/liquidity.py`: pure `compute_spread_pct(bid, ask)` → (ask-bid)/mid, or None for missing/crossed/non-positive quotes (None never collapses to a real 0.0). Unit-tested. - `AlpacaPaperClient.get_latest_quotes`: batched latest-NBBO fetch, mirrors `get_latest_trades`. - `Watcher`: optional injected `spread_provider` (same shape as `catalyst_score_provider`); default None == prior behavior byte-for-byte. Computed spread is logged onto the criteria_evaluated and signal_ready events. Observe-only — it does NOT gate ready/blocked. - `run_live_paper`: TTL-cached spread provider off the client; degrades to None on any fault, SPREAD_PCT_ENABLED=0 to silence. The dead `scanner.spread_pct_max` knob is intentionally left for a later promotion, once the logged data shows spread separates outcomes. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_019psgRHdtTXz6TCnnqPiqGw --- alpaca_paper/client.py | 16 ++++++++++ run_live_paper.py | 31 ++++++++++++++++++++ runtime/watcher.py | 23 +++++++++++++-- strategy/evaluation/liquidity.py | 34 ++++++++++++++++++++++ tests/unit/runtime/test_watcher.py | 34 ++++++++++++++++++++++ tests/unit/strategy/test_liquidity.py | 42 +++++++++++++++++++++++++++ 6 files changed, 178 insertions(+), 2 deletions(-) create mode 100644 strategy/evaluation/liquidity.py create mode 100644 tests/unit/strategy/test_liquidity.py diff --git a/alpaca_paper/client.py b/alpaca_paper/client.py index 1702ba7..d0e2b63 100644 --- a/alpaca_paper/client.py +++ b/alpaca_paper/client.py @@ -329,6 +329,22 @@ class AlpacaPaperClient: trades.update(payload.get("trades") or {}) return trades + def get_latest_quotes(self, symbols: list[str]) -> dict[str, dict]: + """Latest NBBO quote per symbol (bid/ask). Mirrors ``get_latest_trades``; + each quote dict carries Alpaca's raw keys — ``bp``/``ap`` (bid/ask price) + and ``bs``/``as`` (sizes). On the free IEX feed quotes are thinner than + SIP, so a symbol may be absent; callers must treat a missing entry as + "unknown spread", not a tight one.""" + quotes: dict[str, dict] = {} + for chunk in self._chunked(symbols, 100): + payload = self._data( + "GET", + "/v2/stocks/quotes/latest", + params={"symbols": ",".join(chunk), "feed": self.settings.feed}, + ) + quotes.update(payload.get("quotes") or {}) + return quotes + def get_most_actives(self, top: int = 20, by: str = "volume") -> list[dict]: payload = self._data( "GET", diff --git a/run_live_paper.py b/run_live_paper.py index 202a2a2..36e4b65 100644 --- a/run_live_paper.py +++ b/run_live_paper.py @@ -238,6 +238,36 @@ def build_runtime(args: argparse.Namespace) -> dict: from research.ingestion.news_enrichment import catalyst_score return catalyst_score(catalyst_provider(symbol)) + # Decision-time bid/ask spread provider (observe-only). One per-symbol quote, + # TTL-cached so a watch pass doesn't re-fetch, mapped to spread_pct. Returns + # None on any fault / missing quote / when disabled, so the watcher logs the + # metric without ever blocking or breaking on a quote blip. Default ON when a + # client exists; set SPREAD_PCT_ENABLED=0 to silence the extra quote calls. + _spread_cache: dict[str, tuple[float, float | None]] = {} + _SPREAD_TTL = 15.0 + + def _spread_enabled() -> bool: + return os.environ.get("SPREAD_PCT_ENABLED", "1").strip().lower() in { + "1", "true", "yes", "on"} + + def spread_provider(symbol: str): + if client is None or not _spread_enabled(): + return None + import time as _time + from strategy.evaluation.liquidity import compute_spread_pct + now = _time.monotonic() + hit = _spread_cache.get(symbol) + if hit is not None and (now - hit[0]) < _SPREAD_TTL: + return hit[1] + val = None + try: + quote = (client.get_latest_quotes([symbol]) or {}).get(symbol) or {} + val = compute_spread_pct(quote.get("bp"), quote.get("ap")) + except Exception: # noqa: BLE001 + val = None + _spread_cache[symbol] = (now, val) + return val + watcher = Watcher( store, ResearchWatchlistProvider( @@ -258,6 +288,7 @@ def build_runtime(args: argparse.Namespace) -> dict: catalyst_score_provider=( catalyst_score_provider if ollama_cfg.catalyst_score_enabled else None ), + spread_provider=spread_provider, ) exec_settings = ExecutionSettings.from_env() diff --git a/runtime/watcher.py b/runtime/watcher.py index 56c1db5..df291f8 100644 --- a/runtime/watcher.py +++ b/runtime/watcher.py @@ -81,6 +81,7 @@ class Watcher: provider: WatchlistProvider, config: WatcherConfig | None = None, catalyst_score_provider: Callable[[str], float | None] | None = None, + spread_provider: Callable[[str], float | None] | None = None, ): self.store = store self.provider = provider @@ -88,6 +89,11 @@ class Watcher: # optional symbol -> 0..1 catalyst score (or None). Injected so the pure # evaluator stays DB-free; None provider == today's behavior exactly. self.catalyst_score_provider = catalyst_score_provider + # optional symbol -> decision-time bid/ask spread fraction (or None). + # Observe-only: it is logged onto the evaluation/ready events but does NOT + # gate. None provider == today's behavior exactly (spread_pct logs as None, + # matching the always-null minute_bars.spread_pct column). + self.spread_provider = spread_provider if not self.config.session_id: self.config.session_id = f"session-{date.today().isoformat()}" # symbol -> current state for this session @@ -205,6 +211,16 @@ class Watcher: ) result.evaluated += 1 + # Decision-time bid/ask spread (observe-only). Guarded like the + # catalyst provider so a quote blip never kills the loop; None + # when no provider is injected or the quote is missing/bad. + spread_pct = None + if self.spread_provider is not None: + try: + spread_pct = self.spread_provider(symbol) + except Exception: # noqa: BLE001 + spread_pct = None + self.store.emit( CriteriaEvaluatedEvent( timestamp=datetime.now(), @@ -222,6 +238,7 @@ class Watcher: "detail": evaluation.criteria_detail, "gap_pct": evaluation.gap_pct, "relative_volume": evaluation.relative_volume, + "spread_pct": spread_pct, "status": evaluation.status, "reason": evaluation.reason, }, @@ -235,6 +252,7 @@ class Watcher: "reason": evaluation.reason, "gap_pct": evaluation.gap_pct, "relative_volume": evaluation.relative_volume, + "spread_pct": spread_pct, }, ) ) @@ -244,6 +262,7 @@ class Watcher: if self._states.get(symbol) != "ready": setup = evaluation.setups[0] self._transition(symbol, "ready", setup["setup_type"]) + signal_data = {**setup, "spread_pct": spread_pct} self.store.emit( SignalReadyEvent( timestamp=datetime.now(), @@ -257,12 +276,12 @@ class Watcher: symbol=symbol, signal_type=setup["setup_type"], confidence=setup["confidence"], - signal_data=setup, + signal_data=signal_data, payload={ "symbol": symbol, "signal_type": setup["setup_type"], "confidence": setup["confidence"], - "signal_data": setup, + "signal_data": signal_data, }, ) ) diff --git a/strategy/evaluation/liquidity.py b/strategy/evaluation/liquidity.py new file mode 100644 index 0000000..a44a1ef --- /dev/null +++ b/strategy/evaluation/liquidity.py @@ -0,0 +1,34 @@ +"""Liquidity metrics: bid/ask spread. + +Spread is a *quote* metric, not a *bar* metric — OHLCV bars carry no NBBO, which +is why ``minute_bars.spread_pct`` is null. The real, execution-relevant figure is +the spread at the moment a setup is evaluated: a wide book quietly eats R on the +thin sub-$20 names this system trades, so it is logged as a decision-time metric. +""" + +from __future__ import annotations + + +def compute_spread_pct(bid: float | None, ask: float | None) -> float | None: + """Relative bid/ask spread as a fraction of the mid price. + + Returns ``(ask - bid) / mid`` (e.g. ``0.01`` == a 1% spread) or ``None`` when + the quote is missing or nonsensical (non-positive prices, or a crossed book + where ``ask < bid``). ``None`` means "unknown" and must never collapse to + ``0.0`` — a genuinely tight book and a missing quote are different facts a + downstream liquidity gate has to tell apart. A locked book (``bid == ask``) + is a real, valid ``0.0``. + """ + if bid is None or ask is None: + return None + try: + bid_f = float(bid) + ask_f = float(ask) + except (TypeError, ValueError): + return None + if bid_f <= 0 or ask_f <= 0 or ask_f < bid_f: + return None + mid = (bid_f + ask_f) / 2.0 + if mid <= 0: + return None + return (ask_f - bid_f) / mid diff --git a/tests/unit/runtime/test_watcher.py b/tests/unit/runtime/test_watcher.py index 7009e63..bc2cca4 100644 --- a/tests/unit/runtime/test_watcher.py +++ b/tests/unit/runtime/test_watcher.py @@ -124,6 +124,40 @@ def test_none_catalyst_provider_is_unchanged(store, provider): store2.close() +def _criteria_spread(store, symbol="GOOD"): + import json + events = store.query_events(event_type="criteria_evaluated", symbol=symbol) + assert events, "expected a criteria_evaluated event" + payload = json.loads(events[-1]["payload_json"]) + return payload["payload"]["spread_pct"] + + +def test_spread_provider_logged_observe_only(store, provider): + # a spread provider attaches spread_pct to the evaluation/ready events but + # must NOT change the ready/blocked outcome (observe-only). + watcher = Watcher( + store, provider, + WatcherConfig(session_id="ts", mode=EventMode.PAPER, min_quality=0.0), + spread_provider=lambda sym: 0.012 if sym == "GOOD" else None, + ) + result = watcher.tick(SESSION_DATE) + # outcome identical to the no-provider baseline + assert "GOOD" in result.ready and "FADE" in result.blocked + # and the metric is carried onto the events + assert _criteria_spread(store, "GOOD") == 0.012 + import json + ev = store.query_events(event_type="signal_ready", symbol="GOOD") + p = json.loads(ev[-1]["payload_json"]) + sig = p.get("signal_data") or p.get("payload", {}).get("signal_data") + assert sig["spread_pct"] == 0.012 + + +def test_no_spread_provider_logs_none(store, provider): + # default (no provider) -> spread_pct logs as None, matching prior behavior + make_watcher(store, provider).tick(SESSION_DATE) + assert _criteria_spread(store, "GOOD") is None + + def test_signal_ready_debounced_across_ticks(store, provider): watcher = make_watcher(store, provider) watcher.tick(SESSION_DATE) diff --git a/tests/unit/strategy/test_liquidity.py b/tests/unit/strategy/test_liquidity.py new file mode 100644 index 0000000..4f252b9 --- /dev/null +++ b/tests/unit/strategy/test_liquidity.py @@ -0,0 +1,42 @@ +"""Bid/ask spread metric tests.""" + +import math + +from strategy.evaluation.liquidity import compute_spread_pct + + +def test_normal_spread_is_fraction_of_mid(): + # bid 9.99 / ask 10.01 -> 0.02 / 10.0 mid = 0.002 (20 bps) + assert math.isclose(compute_spread_pct(9.99, 10.01), 0.002, rel_tol=1e-9) + + +def test_wide_spread_on_thin_name(): + # bid 4.00 / ask 4.40 -> 0.40 / 4.20 mid ~= 0.0952 (a 9.5% spread) + assert math.isclose(compute_spread_pct(4.00, 4.40), 0.40 / 4.20, rel_tol=1e-9) + + +def test_locked_book_is_zero_not_none(): + # bid == ask is a real, valid tight market, distinct from a missing quote + assert compute_spread_pct(5.0, 5.0) == 0.0 + + +def test_missing_side_is_none(): + assert compute_spread_pct(None, 10.0) is None + assert compute_spread_pct(10.0, None) is None + assert compute_spread_pct(None, None) is None + + +def test_crossed_book_is_none(): + # ask < bid is a bad/stale quote, not a negative spread + assert compute_spread_pct(10.01, 9.99) is None + + +def test_non_positive_prices_are_none(): + assert compute_spread_pct(0.0, 1.0) is None + assert compute_spread_pct(1.0, 0.0) is None + assert compute_spread_pct(-1.0, 1.0) is None + + +def test_unparseable_inputs_are_none(): + assert compute_spread_pct("n/a", 10.0) is None + assert compute_spread_pct(10.0, "n/a") is None -- 2.54.0