Add Kubernetes image + Helm package with in-cluster Postgres

Containerizes the momentum monolith and packages every run mode for
deployment in a single Kubernetes namespace, with PostgreSQL hosted in
the cluster.

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

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JCVMm2AbmZvvxwLTX6UpNR
This commit is contained in:
Claude committed 2026-06-19 17:22:06 +00:00
1 parent 506ff253ec
commit e91979a622
20 files changed
+1134

No files matched your search

+97
View File
@@ -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.
+5
View File
@@ -0,0 +1,5 @@
.DS_Store
.git
*.tmp
*.bak
*.orig
+14
View File
@@ -0,0 +1,14 @@
apiVersion: v2
name: momentum
description: Small-cap momentum research & paper-trading system, with an in-cluster PostgreSQL datastore.
type: application
# Chart version is independent of the app; bump on chart changes.
version: 0.1.0
# Tracks the application release (pyproject.toml).
appVersion: "0.4.0"
kubeVersion: ">=1.27.0-0"
keywords:
- trading
- postgres
- momentum
home: https://github.com/fez5587/momentum-system
+137
View File
@@ -0,0 +1,137 @@
{
"uid": "momentum-live",
"title": "Momentum — Live",
"tags": ["momentum"],
"timezone": "browser",
"schemaVersion": 39,
"version": 1,
"refresh": "5s",
"time": { "from": "now/d", "to": "now" },
"templating": { "list": [] },
"annotations": { "list": [] },
"panels": [
{
"id": 1,
"type": "stat",
"title": "Account equity",
"gridPos": { "h": 4, "w": 6, "x": 0, "y": 0 },
"datasource": { "type": "postgres", "uid": "momentum_pg" },
"fieldConfig": { "defaults": { "unit": "currencyUSD" }, "overrides": [] },
"options": { "reduceOptions": { "calcs": ["lastNotNull"] }, "colorMode": "value", "graphMode": "none" },
"targets": [{
"refId": "A",
"format": "table",
"rawQuery": true,
"rawSql": "SELECT (payload_json::jsonb->>'total_equity')::float AS equity FROM events WHERE event_type='account_summary_updated' ORDER BY timestamp DESC LIMIT 1"
}]
},
{
"id": 2,
"type": "stat",
"title": "Ready signals (today)",
"gridPos": { "h": 4, "w": 6, "x": 6, "y": 0 },
"datasource": { "type": "postgres", "uid": "momentum_pg" },
"fieldConfig": { "defaults": { "thresholds": { "mode": "absolute", "steps": [ { "color": "green", "value": null }, { "color": "blue", "value": 1 } ] } }, "overrides": [] },
"options": { "reduceOptions": { "calcs": ["lastNotNull"] }, "colorMode": "background", "graphMode": "none" },
"targets": [{
"refId": "A",
"format": "table",
"rawQuery": true,
"rawSql": "SELECT count(DISTINCT payload_json::jsonb->>'symbol')::int AS ready FROM events WHERE event_type='signal_ready'"
}]
},
{
"id": 6,
"type": "stat",
"title": "Symbols evaluated",
"gridPos": { "h": 4, "w": 6, "x": 12, "y": 0 },
"datasource": { "type": "postgres", "uid": "momentum_pg" },
"options": { "reduceOptions": { "calcs": ["lastNotNull"] }, "colorMode": "value", "graphMode": "none" },
"targets": [{
"refId": "A",
"format": "table",
"rawQuery": true,
"rawSql": "SELECT count(DISTINCT payload_json::jsonb->>'symbol')::int AS evaluated FROM events WHERE event_type='criteria_evaluated'"
}]
},
{
"id": 3,
"type": "table",
"title": "Evaluation board — what it's looking at + the determination",
"gridPos": { "h": 14, "w": 14, "x": 0, "y": 4 },
"datasource": { "type": "postgres", "uid": "momentum_pg" },
"fieldConfig": {
"defaults": {},
"overrides": [
{ "matcher": { "id": "byName", "options": "status" },
"properties": [ { "id": "custom.cellOptions", "value": { "type": "color-background" } },
{ "id": "mappings", "value": [
{ "type": "value", "options": { "ready": { "color": "green", "index": 0 }, "late": { "color": "yellow", "index": 1 }, "blocked": { "color": "red", "index": 2 } } } ] } ] },
{ "matcher": { "id": "byName", "options": "gap" }, "properties": [ { "id": "unit", "value": "percentunit" } ] },
{ "matcher": { "id": "byName", "options": "score" }, "properties": [ { "id": "unit", "value": "percent" } ] }
]
},
"options": { "sortBy": [ { "displayName": "score", "desc": true } ] },
"targets": [{
"refId": "A",
"format": "table",
"rawQuery": true,
"rawSql": "SELECT * FROM ( SELECT DISTINCT ON (sym) sym AS symbol, status, score, gap, rvol, reason FROM ( SELECT payload_json::jsonb->>'symbol' sym, payload_json::jsonb->'criteria_results'->>'status' status, (payload_json::jsonb->>'success_score_pct')::float score, (payload_json::jsonb->'criteria_results'->>'gap_pct')::float gap, (payload_json::jsonb->'criteria_results'->>'relative_volume')::float rvol, payload_json::jsonb->'criteria_results'->>'reason' reason, timestamp ts FROM events WHERE event_type='criteria_evaluated' ) t ORDER BY sym, ts DESC ) x ORDER BY score DESC"
}]
},
{
"id": 4,
"type": "table",
"title": "Ready signals",
"gridPos": { "h": 7, "w": 10, "x": 14, "y": 4 },
"datasource": { "type": "postgres", "uid": "momentum_pg" },
"targets": [{
"refId": "A",
"format": "table",
"rawQuery": true,
"rawSql": "SELECT timestamp AS time, payload_json::jsonb->>'symbol' AS symbol, message FROM events WHERE event_type='signal_ready' ORDER BY timestamp DESC LIMIT 25"
}]
},
{
"id": 5,
"type": "table",
"title": "Risk events (breaker / backouts)",
"gridPos": { "h": 7, "w": 10, "x": 14, "y": 11 },
"datasource": { "type": "postgres", "uid": "momentum_pg" },
"targets": [{
"refId": "A",
"format": "table",
"rawQuery": true,
"rawSql": "SELECT timestamp AS time, payload_json::jsonb->>'rule_type' AS rule, message FROM events WHERE event_type='risk_rule_triggered' ORDER BY timestamp DESC LIMIT 25"
}]
},
{
"id": 7,
"type": "timeseries",
"title": "Minute bars ingested",
"gridPos": { "h": 7, "w": 12, "x": 0, "y": 18 },
"datasource": { "type": "postgres", "uid": "momentum_pg" },
"fieldConfig": { "defaults": { "custom": { "drawStyle": "bars", "fillOpacity": 40 } }, "overrides": [] },
"targets": [{
"refId": "A",
"format": "time_series",
"rawQuery": true,
"rawSql": "SELECT timestamp AS time, (payload_json::jsonb->>'output_count')::float AS minute_rows FROM events WHERE event_type='module_tick' AND payload_json::jsonb->>'module'='ingestion' AND $__timeFilter(timestamp) ORDER BY 1"
}]
},
{
"id": 8,
"type": "timeseries",
"title": "Account equity",
"gridPos": { "h": 7, "w": 12, "x": 12, "y": 18 },
"datasource": { "type": "postgres", "uid": "momentum_pg" },
"fieldConfig": { "defaults": { "unit": "currencyUSD" }, "overrides": [] },
"targets": [{
"refId": "A",
"format": "time_series",
"rawQuery": true,
"rawSql": "SELECT timestamp AS time, (payload_json::jsonb->>'total_equity')::float AS equity FROM events WHERE event_type='account_summary_updated' AND $__timeFilter(timestamp) ORDER BY 1"
}]
}
]
}
+28
View File
@@ -0,0 +1,28 @@
Momentum System deployed as release "{{ .Release.Name }}" in namespace "{{ .Release.Namespace }}".
Components:
- PostgreSQL (StatefulSet, single source of truth) : svc/{{ include "momentum.postgresName" . }}:{{ .Values.postgres.port }}
- App loop + embedded dashboard (replicas: 1) : svc/{{ include "momentum.appName" . }}:{{ .Values.service.port }}
{{- if .Values.cronjobs.enabled }}
- CronJobs (journal, eod-replay, nightly-tune) : timeZone {{ .Values.cronjobs.timeZone }}
{{- end }}
{{- if .Values.grafana.enabled }}
- Grafana : svc/{{ include "momentum.grafanaName" . }}:{{ .Values.grafana.service.port }}
{{- end }}
Reach the dashboard:
kubectl -n {{ .Release.Namespace }} port-forward svc/{{ include "momentum.appName" . }} {{ .Values.service.port }}:{{ .Values.service.port }}
open http://localhost:{{ .Values.service.port }}
{{- if not .Values.secrets.existingSecret }}
{{- if or (not .Values.secrets.alpacaApiKey) (not .Values.secrets.alpacaSecretKey) }}
WARNING: Alpaca keys are not set — the loop runs in dry mode (no live data/orders).
Supply them: helm upgrade {{ .Release.Name }} ... \
--set secrets.alpacaApiKey=YOUR_KEY --set secrets.alpacaSecretKey=YOUR_SECRET
{{- end }}
{{- end }}
The DB schema auto-creates on the app's first connection. Verify:
kubectl -n {{ .Release.Namespace }} exec sts/{{ include "momentum.postgresName" . }} -- \
psql -U {{ .Values.postgres.username }} -d {{ .Values.postgres.database }} -c '\dt'
@@ -0,0 +1,69 @@
{{/* Base name, overridable. */}}
{{- define "momentum.name" -}}
{{- default .Chart.Name .Values.nameOverride | trunc 63 | trimSuffix "-" -}}
{{- end -}}
{{/* Fully qualified release name. */}}
{{- define "momentum.fullname" -}}
{{- if .Values.fullnameOverride -}}
{{- .Values.fullnameOverride | trunc 63 | trimSuffix "-" -}}
{{- else -}}
{{- $name := default .Chart.Name .Values.nameOverride -}}
{{- if contains $name .Release.Name -}}
{{- .Release.Name | trunc 63 | trimSuffix "-" -}}
{{- else -}}
{{- printf "%s-%s" .Release.Name $name | trunc 63 | trimSuffix "-" -}}
{{- end -}}
{{- end -}}
{{- end -}}
{{/* Common labels. */}}
{{- define "momentum.labels" -}}
app.kubernetes.io/name: {{ include "momentum.name" . }}
app.kubernetes.io/instance: {{ .Release.Name }}
app.kubernetes.io/managed-by: {{ .Release.Service }}
helm.sh/chart: {{ printf "%s-%s" .Chart.Name .Chart.Version | replace "+" "_" }}
{{- if .Chart.AppVersion }}
app.kubernetes.io/version: {{ .Chart.AppVersion | quote }}
{{- end }}
{{- end -}}
{{/* Component-scoped selector labels. Call with (dict "ctx" . "component" "app"). */}}
{{- define "momentum.selectorLabels" -}}
app.kubernetes.io/name: {{ include "momentum.name" .ctx }}
app.kubernetes.io/instance: {{ .ctx.Release.Name }}
app.kubernetes.io/component: {{ .component }}
{{- end -}}
{{/* Names of shared resources. */}}
{{- define "momentum.postgresName" -}}{{ printf "%s-postgres" (include "momentum.fullname" .) }}{{- end -}}
{{- define "momentum.appName" -}}{{ printf "%s-app" (include "momentum.fullname" .) }}{{- end -}}
{{- define "momentum.grafanaName" -}}{{ printf "%s-grafana" (include "momentum.fullname" .) }}{{- end -}}
{{- define "momentum.configMapName" -}}{{ printf "%s-config" (include "momentum.fullname" .) }}{{- end -}}
{{- define "momentum.appDataPvcName" -}}{{ printf "%s-app-data" (include "momentum.fullname" .) }}{{- end -}}
{{/* Name of the Secret holding DB URL + broker keys (existing or chart-managed). */}}
{{- define "momentum.secretName" -}}
{{- if .Values.secrets.existingSecret -}}
{{- .Values.secrets.existingSecret -}}
{{- else -}}
{{- printf "%s-secrets" (include "momentum.fullname" .) -}}
{{- end -}}
{{- end -}}
{{/*
Resolve the Postgres password once: explicit value, else reuse the one already
stored in the chart-managed Secret (so upgrades don't rotate it), else generate.
*/}}
{{- define "momentum.postgresPassword" -}}
{{- if .Values.postgres.password -}}
{{- .Values.postgres.password -}}
{{- else -}}
{{- $existing := lookup "v1" "Secret" .Release.Namespace (printf "%s-secrets" (include "momentum.fullname" .)) -}}
{{- if and $existing $existing.data (index $existing.data "POSTGRES_PASSWORD") -}}
{{- index $existing.data "POSTGRES_PASSWORD" | b64dec -}}
{{- else -}}
{{- randAlphaNum 24 -}}
{{- end -}}
{{- end -}}
{{- end -}}
@@ -0,0 +1,16 @@
{{- if .Values.app.persistence.enabled }}
apiVersion: v1
kind: PersistentVolumeClaim
metadata:
name: {{ include "momentum.appDataPvcName" . }}
labels:
{{- include "momentum.labels" . | nindent 4 }}
spec:
accessModes: ["{{ .Values.app.persistence.accessMode }}"]
{{- if .Values.app.persistence.storageClass }}
storageClassName: {{ .Values.app.persistence.storageClass | quote }}
{{- end }}
resources:
requests:
storage: {{ .Values.app.persistence.size | quote }}
{{- end }}
@@ -0,0 +1,80 @@
apiVersion: apps/v1
kind: Deployment
metadata:
name: {{ include "momentum.appName" . }}
labels:
{{- include "momentum.labels" . | nindent 4 }}
app.kubernetes.io/component: app
spec:
# Stateful singleton: the loop holds in-memory trading state and a 1:1 broker
# session. Never scale > 1. Recreate avoids two loops overlapping on rollout.
replicas: 1
strategy:
type: Recreate
selector:
matchLabels:
{{- include "momentum.selectorLabels" (dict "ctx" . "component" "app") | nindent 6 }}
template:
metadata:
labels:
{{- include "momentum.selectorLabels" (dict "ctx" . "component" "app") | nindent 8 }}
annotations:
# Roll the pod when config/secret content changes.
checksum/config: {{ include (print $.Template.BasePath "/configmap.yaml") . | sha256sum }}
spec:
{{- with .Values.imagePullSecrets }}
imagePullSecrets:
{{- toYaml . | nindent 8 }}
{{- end }}
initContainers:
# Block until Postgres accepts connections so the first app connection
# (which auto-applies storage/pg_schema.sql) succeeds.
- name: wait-for-postgres
image: {{ .Values.postgres.image | quote }}
command:
- sh
- -c
- |
until pg_isready -h {{ include "momentum.postgresName" . }} -p {{ .Values.postgres.port }} -U {{ .Values.postgres.username }}; do
echo "waiting for postgres..."; sleep 2;
done
containers:
- name: app
image: "{{ .Values.image.repository }}:{{ .Values.image.tag }}"
imagePullPolicy: {{ .Values.image.pullPolicy }}
command: {{ toYaml .Values.app.command | nindent 12 }}
ports:
- name: http
containerPort: 8010
envFrom:
- configMapRef:
name: {{ include "momentum.configMapName" . }}
- secretRef:
name: {{ include "momentum.secretName" . }}
volumeMounts:
- name: app-data
mountPath: /app/data
{{- if .Values.app.probes.enabled }}
readinessProbe:
httpGet:
path: /
port: http
initialDelaySeconds: {{ .Values.app.probes.initialDelaySeconds }}
periodSeconds: {{ .Values.app.probes.periodSeconds }}
livenessProbe:
httpGet:
path: /
port: http
initialDelaySeconds: {{ .Values.app.probes.initialDelaySeconds }}
periodSeconds: {{ .Values.app.probes.periodSeconds }}
{{- end }}
resources:
{{- toYaml .Values.app.resources | nindent 12 }}
volumes:
- name: app-data
{{- if .Values.app.persistence.enabled }}
persistentVolumeClaim:
claimName: {{ include "momentum.appDataPvcName" . }}
{{- else }}
emptyDir: {}
{{- end }}
@@ -0,0 +1,14 @@
apiVersion: v1
kind: Service
metadata:
name: {{ include "momentum.appName" . }}
labels:
{{- include "momentum.labels" . | nindent 4 }}
spec:
type: {{ .Values.service.type }}
selector:
{{- include "momentum.selectorLabels" (dict "ctx" . "component" "app") | nindent 4 }}
ports:
- name: http
port: {{ .Values.service.port }}
targetPort: http
@@ -0,0 +1,10 @@
apiVersion: v1
kind: ConfigMap
metadata:
name: {{ include "momentum.configMapName" . }}
labels:
{{- include "momentum.labels" . | nindent 4 }}
data:
{{- range $key, $val := .Values.config }}
{{ $key }}: {{ $val | quote }}
{{- end }}
@@ -0,0 +1,59 @@
{{- if .Values.cronjobs.enabled }}
{{- $root := . }}
{{- $jobs := dict "journal" .Values.cronjobs.journal "eod-replay" .Values.cronjobs.eodReplay "nightly-tune" .Values.cronjobs.nightlyTune }}
{{- range $key, $job := $jobs }}
{{- if $job.enabled }}
---
apiVersion: batch/v1
kind: CronJob
metadata:
name: {{ include "momentum.fullname" $root }}-{{ $key }}
labels:
{{- include "momentum.labels" $root | nindent 4 }}
app.kubernetes.io/component: cron-{{ $key }}
spec:
schedule: {{ $job.schedule | quote }}
# US/Eastern so the market-clock schedules in .env's *.sh comments hold.
timeZone: {{ $root.Values.cronjobs.timeZone | quote }}
concurrencyPolicy: Forbid
successfulJobsHistoryLimit: 3
failedJobsHistoryLimit: 3
jobTemplate:
spec:
backoffLimit: 1
template:
metadata:
labels:
{{- include "momentum.selectorLabels" (dict "ctx" $root "component" (printf "cron-%s" $key)) | nindent 12 }}
spec:
restartPolicy: Never
{{- with $root.Values.imagePullSecrets }}
imagePullSecrets:
{{- toYaml . | nindent 12 }}
{{- end }}
containers:
- name: {{ $key }}
image: "{{ $root.Values.image.repository }}:{{ $root.Values.image.tag }}"
imagePullPolicy: {{ $root.Values.image.pullPolicy }}
command: {{ toYaml $job.command | nindent 16 }}
envFrom:
- configMapRef:
name: {{ include "momentum.configMapName" $root }}
- secretRef:
name: {{ include "momentum.secretName" $root }}
volumeMounts:
- name: app-data
mountPath: /app/data
volumes:
- name: app-data
{{- if $root.Values.app.persistence.enabled }}
# Shared with the app pod (nightly-tune writes learned_params.json
# the loop reads on boot). RWO ⇒ cron pods schedule onto the app's node.
persistentVolumeClaim:
claimName: {{ include "momentum.appDataPvcName" $root }}
{{- else }}
emptyDir: {}
{{- end }}
{{- end }}
{{- end }}
{{- end }}
+158
View File
@@ -0,0 +1,158 @@
{{- if .Values.grafana.enabled }}
apiVersion: v1
kind: ConfigMap
metadata:
name: {{ include "momentum.grafanaName" . }}-provisioning
labels:
{{- include "momentum.labels" . | nindent 4 }}
data:
# Datasource points at the in-cluster Postgres service (not the old LAN IP).
datasource.yaml: |
apiVersion: 1
datasources:
- name: Momentum
uid: momentum_pg
type: postgres
access: proxy
url: {{ include "momentum.postgresName" . }}:{{ .Values.postgres.port }}
user: {{ .Values.postgres.username }}
jsonData:
database: {{ .Values.postgres.database }}
sslmode: disable
postgresVersion: 1600
secureJsonData:
# Resolved at runtime from the env var below so it always matches the
# password stored in the Secret (avoids re-deriving it here).
password: $POSTGRES_PASSWORD
isDefault: true
editable: true
provider.yaml: |
apiVersion: 1
providers:
- name: momentum
orgId: 1
type: file
disableDeletion: false
updateIntervalSeconds: 10
allowUiUpdates: true
options:
path: /etc/grafana/provisioning/dashboards
foldersFromFilesStructure: false
---
apiVersion: v1
kind: ConfigMap
metadata:
name: {{ include "momentum.grafanaName" . }}-dashboards
labels:
{{- include "momentum.labels" . | nindent 4 }}
data:
momentum.json: |-
{{- .Files.Get "files/dashboard.json" | nindent 4 }}
---
apiVersion: apps/v1
kind: Deployment
metadata:
name: {{ include "momentum.grafanaName" . }}
labels:
{{- include "momentum.labels" . | nindent 4 }}
app.kubernetes.io/component: grafana
spec:
replicas: 1
selector:
matchLabels:
{{- include "momentum.selectorLabels" (dict "ctx" . "component" "grafana") | nindent 6 }}
template:
metadata:
labels:
{{- include "momentum.selectorLabels" (dict "ctx" . "component" "grafana") | nindent 8 }}
spec:
containers:
- name: grafana
image: {{ .Values.grafana.image | quote }}
ports:
- name: http
containerPort: 3000
env:
- name: GF_AUTH_ANONYMOUS_ENABLED
value: "true"
- name: GF_AUTH_ANONYMOUS_ORG_ROLE
value: "Admin"
- name: GF_AUTH_DISABLE_LOGIN_FORM
value: "true"
- name: GF_DASHBOARDS_MIN_REFRESH_INTERVAL
value: "2s"
# Interpolated into the provisioned datasource (secureJsonData.password).
- name: POSTGRES_PASSWORD
valueFrom:
secretKeyRef:
name: {{ include "momentum.secretName" . }}
key: POSTGRES_PASSWORD
volumeMounts:
- name: datasources
mountPath: /etc/grafana/provisioning/datasources
- name: dashboard-provider
mountPath: /etc/grafana/provisioning/dashboards/provider.yaml
subPath: provider.yaml
- name: dashboards
mountPath: /etc/grafana/provisioning/dashboards/momentum.json
subPath: momentum.json
- name: storage
mountPath: /var/lib/grafana
resources:
{{- toYaml .Values.grafana.resources | nindent 12 }}
volumes:
- name: datasources
configMap:
name: {{ include "momentum.grafanaName" . }}-provisioning
items:
- key: datasource.yaml
path: datasource.yaml
- name: dashboard-provider
configMap:
name: {{ include "momentum.grafanaName" . }}-provisioning
items:
- key: provider.yaml
path: provider.yaml
- name: dashboards
configMap:
name: {{ include "momentum.grafanaName" . }}-dashboards
- name: storage
{{- if .Values.grafana.persistence.enabled }}
persistentVolumeClaim:
claimName: {{ include "momentum.grafanaName" . }}
{{- else }}
emptyDir: {}
{{- end }}
---
apiVersion: v1
kind: Service
metadata:
name: {{ include "momentum.grafanaName" . }}
labels:
{{- include "momentum.labels" . | nindent 4 }}
spec:
type: {{ .Values.grafana.service.type }}
selector:
{{- include "momentum.selectorLabels" (dict "ctx" . "component" "grafana") | nindent 4 }}
ports:
- name: http
port: {{ .Values.grafana.service.port }}
targetPort: http
{{- if .Values.grafana.persistence.enabled }}
---
apiVersion: v1
kind: PersistentVolumeClaim
metadata:
name: {{ include "momentum.grafanaName" . }}
labels:
{{- include "momentum.labels" . | nindent 4 }}
spec:
accessModes: ["ReadWriteOnce"]
{{- if .Values.grafana.persistence.storageClass }}
storageClassName: {{ .Values.grafana.persistence.storageClass | quote }}
{{- end }}
resources:
requests:
storage: {{ .Values.grafana.persistence.size | quote }}
{{- end }}
{{- end }}
@@ -0,0 +1,31 @@
{{- if .Values.ingress.enabled }}
apiVersion: networking.k8s.io/v1
kind: Ingress
metadata:
name: {{ include "momentum.appName" . }}
labels:
{{- include "momentum.labels" . | nindent 4 }}
{{- with .Values.ingress.annotations }}
annotations:
{{- toYaml . | nindent 4 }}
{{- end }}
spec:
{{- if .Values.ingress.className }}
ingressClassName: {{ .Values.ingress.className }}
{{- end }}
{{- with .Values.ingress.tls }}
tls:
{{- toYaml . | nindent 4 }}
{{- end }}
rules:
- host: {{ .Values.ingress.host | quote }}
http:
paths:
- path: /
pathType: Prefix
backend:
service:
name: {{ include "momentum.appName" . }}
port:
number: {{ .Values.service.port }}
{{- end }}
@@ -0,0 +1,15 @@
apiVersion: v1
kind: Service
metadata:
name: {{ include "momentum.postgresName" . }}
labels:
{{- include "momentum.labels" . | nindent 4 }}
spec:
# Headless: stable DNS for the single StatefulSet pod.
clusterIP: None
selector:
{{- include "momentum.selectorLabels" (dict "ctx" . "component" "postgres") | nindent 4 }}
ports:
- name: postgres
port: {{ .Values.postgres.port }}
targetPort: postgres
@@ -0,0 +1,67 @@
apiVersion: apps/v1
kind: StatefulSet
metadata:
name: {{ include "momentum.postgresName" . }}
labels:
{{- include "momentum.labels" . | nindent 4 }}
app.kubernetes.io/component: postgres
spec:
serviceName: {{ include "momentum.postgresName" . }}
replicas: 1
selector:
matchLabels:
{{- include "momentum.selectorLabels" (dict "ctx" . "component" "postgres") | nindent 6 }}
template:
metadata:
labels:
{{- include "momentum.selectorLabels" (dict "ctx" . "component" "postgres") | nindent 8 }}
spec:
{{- with .Values.imagePullSecrets }}
imagePullSecrets:
{{- toYaml . | nindent 8 }}
{{- end }}
containers:
- name: postgres
image: {{ .Values.postgres.image | quote }}
ports:
- name: postgres
containerPort: {{ .Values.postgres.port }}
env:
- name: POSTGRES_USER
value: {{ .Values.postgres.username | quote }}
- name: POSTGRES_DB
value: {{ .Values.postgres.database | quote }}
- name: POSTGRES_PASSWORD
valueFrom:
secretKeyRef:
name: {{ include "momentum.secretName" . }}
key: POSTGRES_PASSWORD
# Keep PGDATA in a subdir so the volume root (lost+found) doesn't clash.
- name: PGDATA
value: /var/lib/postgresql/data/pgdata
volumeMounts:
- name: data
mountPath: /var/lib/postgresql/data
readinessProbe:
exec:
command: ["pg_isready", "-U", {{ .Values.postgres.username | quote }}, "-d", {{ .Values.postgres.database | quote }}]
initialDelaySeconds: 10
periodSeconds: 10
livenessProbe:
exec:
command: ["pg_isready", "-U", {{ .Values.postgres.username | quote }}, "-d", {{ .Values.postgres.database | quote }}]
initialDelaySeconds: 30
periodSeconds: 15
resources:
{{- toYaml .Values.postgres.resources | nindent 12 }}
volumeClaimTemplates:
- metadata:
name: data
spec:
accessModes: ["{{ .Values.postgres.persistence.accessMode }}"]
{{- if .Values.postgres.persistence.storageClass }}
storageClassName: {{ .Values.postgres.persistence.storageClass | quote }}
{{- end }}
resources:
requests:
storage: {{ .Values.postgres.persistence.size | quote }}
@@ -0,0 +1,26 @@
{{- if not .Values.secrets.existingSecret }}
{{- $pgPassword := include "momentum.postgresPassword" . }}
{{- $pgHost := include "momentum.postgresName" . }}
{{- $databaseUrl := printf "postgresql://%s:%s@%s:%v/%s" .Values.postgres.username $pgPassword $pgHost .Values.postgres.port .Values.postgres.database }}
apiVersion: v1
kind: Secret
metadata:
name: {{ include "momentum.fullname" . }}-secrets
labels:
{{- include "momentum.labels" . | nindent 4 }}
type: Opaque
stringData:
# Consumed by the Postgres StatefulSet.
POSTGRES_PASSWORD: {{ $pgPassword | quote }}
# Consumed by the app + CronJobs (storage/db_pg.py prefers DATABASE_URL).
DATABASE_URL: {{ $databaseUrl | quote }}
# Alpaca paper broker (the client reads both the ALPACA_* and APCA_* names).
ALPACA_API_KEY: {{ .Values.secrets.alpacaApiKey | quote }}
ALPACA_SECRET_KEY: {{ .Values.secrets.alpacaSecretKey | quote }}
APCA_API_KEY_ID: {{ .Values.secrets.alpacaApiKey | quote }}
APCA_API_SECRET_KEY: {{ .Values.secrets.alpacaSecretKey | quote }}
APCA_API_BASE_URL: {{ .Values.secrets.alpacaBaseUrl | quote }}
{{- range $k, $v := .Values.secrets.schwab }}
{{ $k }}: {{ $v | quote }}
{{- end }}
{{- end }}
+162
View File
@@ -0,0 +1,162 @@
# Default values for the momentum chart.
# Override per-environment with `-f my-values.yaml` or `--set key=value`.
# -- Container image for every workload (app loop, dashboard, CronJobs).
image:
repository: ghcr.io/fez5587/momentum-system
# Pin to a pushed tag (commit SHA or `latest`) from the docker-publish workflow.
tag: latest
pullPolicy: IfNotPresent
# Optional image pull secrets for a private GHCR package.
imagePullSecrets: []
nameOverride: ""
fullnameOverride: ""
# ----------------------------------------------------------------------------
# App: the live trading loop WITH its embedded dashboard (port 8010).
# It is a stateful singleton — do not raise replicas above 1.
# ----------------------------------------------------------------------------
app:
# Runs the loop with the dashboard attached (the dashboard's approve/reject/
# exit endpoints require the in-process TradingExecutionService).
command: ["python", "run_live_paper.py"]
resources:
requests:
cpu: 100m
memory: 256Mi
limits:
memory: 1Gi
# Shared writable dir (DuckDB research file, logs, learned_params.json, tokens).
# The nightly-tune CronJob writes learned_params.json here for the loop to read.
persistence:
enabled: true
size: 5Gi
storageClass: ""
accessMode: ReadWriteOnce
# HTTP probes hit the dashboard root.
probes:
enabled: true
initialDelaySeconds: 30
periodSeconds: 30
# Dashboard service (ClusterIP by default; port-forward to reach it).
service:
type: ClusterIP
port: 8010
ingress:
enabled: false
className: ""
annotations: {}
host: momentum.local
tls: []
# ----------------------------------------------------------------------------
# In-cluster PostgreSQL — the single source of truth (StatefulSet + PVC).
# Schema auto-creates on first app connection from storage/pg_schema.sql.
# ----------------------------------------------------------------------------
postgres:
image: postgres:16
database: momentum
username: momentum
# If empty, a random password is generated on first install and stored in the
# chart-managed Secret. Set explicitly to keep it stable across upgrades.
password: ""
port: 5432
resources:
requests:
cpu: 100m
memory: 256Mi
limits:
memory: 1Gi
persistence:
size: 10Gi
storageClass: ""
accessMode: ReadWriteOnce
# ----------------------------------------------------------------------------
# Secrets — DB URL is built from the in-cluster Postgres service automatically.
# Provide broker keys via --set or, better, an existingSecret (sealed-secrets,
# external-secrets, etc). Never commit real keys into values.yaml.
# ----------------------------------------------------------------------------
secrets:
# If set, the chart will NOT render its own Secret and instead expects this
# one to already contain DATABASE_URL, ALPACA_*/APCA_*, and any SCHWAB_* keys.
existingSecret: ""
alpacaApiKey: ""
alpacaSecretKey: ""
alpacaBaseUrl: "https://paper-api.alpaca.markets"
schwab: {} # e.g. SCHWAB_MARKET_DATA_APP_KEY: "..."
# ----------------------------------------------------------------------------
# Non-secret tuning env (ConfigMap). Mirrors .env.example; override freely.
# ----------------------------------------------------------------------------
config:
TRADING_EXECUTION_MODE: "alpaca_paper"
TRADING_EXECUTION_ENABLED: "1"
TRADING_AUTO_APPROVE: "0"
TRADING_MAX_CONCURRENT_POSITIONS: "6"
TRADING_RISK_PER_TRADE_PCT: "0.01"
TRADING_MAX_DAILY_LOSS_PCT: "0.03"
TRADING_EOD_FLATTEN_ENABLED: "1"
WATCHER_ENABLED: "1"
WATCHER_INTERVAL_SECONDS: "60"
WATCHER_SYMBOLS: "AAPL,TSLA,NVDA,AMD,SOFI"
WATCHER_PRICE_MIN: "1"
WATCHER_PRICE_MAX: "20"
STRATEGY_SETUPS: "opening_range_break"
LIVE_BARS_ENABLED: "1"
LIVE_BARS_INTERVAL_SECONDS: "60"
ALPACA_DATA_FEED: "iex"
ALPACA_BACKFILL_FEED: "sip"
TRIGGER_ENABLED: "1"
TRIGGER_GAP_MIN: "3.0"
TRIGGER_GAP_MAX: "35"
TRIGGER_RVOL_MIN: "2.0"
ORB_BARS: "5"
DASHBOARD_HOST: "0.0.0.0"
DASHBOARD_PORT: "8010"
# Leave schema auto-creation on (storage/db_pg.py applies pg_schema.sql).
PG_SKIP_SCHEMA: "0"
# ----------------------------------------------------------------------------
# Scheduled jobs (CronJobs). Times are US/Eastern via spec.timeZone (K8s >=1.27),
# weekdays only — matching the existing *.sh crontab comments.
# ----------------------------------------------------------------------------
cronjobs:
enabled: true
timeZone: America/New_York
journal:
enabled: true
schedule: "2 16 * * 1-5"
command: ["python", "momentum_cli.py", "journal"]
eodReplay:
enabled: true
schedule: "5 16 * * 1-5"
command: ["python", "eod_replay.py"]
nightlyTune:
enabled: true
schedule: "30 17 * * 1-5"
command: ["python", "nightly_tune.py"]
# ----------------------------------------------------------------------------
# Grafana (optional) — provisioned against the in-cluster Postgres service.
# ----------------------------------------------------------------------------
grafana:
enabled: true
image: grafana/grafana:11.2.0
service:
type: ClusterIP
port: 3000
persistence:
enabled: true
size: 1Gi
storageClass: ""
resources:
requests:
cpu: 50m
memory: 128Mi
limits:
memory: 512Mi