Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
a76c0b5376 | ||
|
|
4173acc9d5 | ||
|
|
e91979a622 |
No files matched your search
@@ -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
|
||||
@@ -0,0 +1,57 @@
|
||||
name: Build and publish image
|
||||
|
||||
# Builds the app container and pushes it to GitHub Container Registry
|
||||
# (ghcr.io/fez5587/momentum-system). The Helm chart's image.repository points
|
||||
# at this registry; set image.tag to a pushed tag (a commit SHA or `latest`).
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- claude/kubernetes-image-package-kkdkom
|
||||
- main
|
||||
tags:
|
||||
- "v*"
|
||||
workflow_dispatch:
|
||||
|
||||
env:
|
||||
REGISTRY: ghcr.io
|
||||
IMAGE_NAME: ${{ github.repository }}
|
||||
|
||||
jobs:
|
||||
build-and-push:
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
contents: read
|
||||
packages: write
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@v3
|
||||
|
||||
- name: Log in to GHCR
|
||||
uses: docker/login-action@v3
|
||||
with:
|
||||
registry: ${{ env.REGISTRY }}
|
||||
username: ${{ github.actor }}
|
||||
password: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
- name: Derive image tags
|
||||
id: meta
|
||||
uses: docker/metadata-action@v5
|
||||
with:
|
||||
images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}
|
||||
tags: |
|
||||
type=sha
|
||||
type=semver,pattern={{version}}
|
||||
type=raw,value=latest,enable={{is_default_branch}}
|
||||
|
||||
- name: Build and push
|
||||
uses: docker/build-push-action@v6
|
||||
with:
|
||||
context: .
|
||||
push: true
|
||||
tags: ${{ steps.meta.outputs.tags }}
|
||||
labels: ${{ steps.meta.outputs.labels }}
|
||||
cache-from: type=gha
|
||||
cache-to: type=gha,mode=max
|
||||
+41
@@ -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"]
|
||||
@@ -0,0 +1,182 @@
|
||||
# Kubernetes deployment
|
||||
|
||||
A container image and Helm chart that run the whole Momentum System — including
|
||||
its **PostgreSQL datastore** — inside a single Kubernetes namespace.
|
||||
|
||||
## What gets deployed
|
||||
|
||||
| Workload | Kind | Notes |
|
||||
| --- | --- | --- |
|
||||
| PostgreSQL | StatefulSet + PVC | Single source of truth. Schema auto-creates on the app's first connection from `storage/pg_schema.sql`. |
|
||||
| App loop + dashboard | Deployment (`replicas: 1`, `Recreate`) | `run_live_paper.py` with the **embedded** dashboard on :8010. Stateful singleton — never scale up. |
|
||||
| journal / eod-replay / nightly-tune | CronJobs | US/Eastern (`spec.timeZone`), weekdays. Share the app's `data/` PVC. |
|
||||
| Grafana | Deployment + Service | Optional (`grafana.enabled`). Datasource auto-points at the in-cluster Postgres. |
|
||||
|
||||
The app loop and the CronJobs run the **same image** (built from the repo
|
||||
`Dockerfile`), just with different commands.
|
||||
|
||||
## Local development with Docker Compose
|
||||
|
||||
For a single-machine stack (same components, no Kubernetes) use the root
|
||||
`docker-compose.yml`. It builds the app from the `Dockerfile`, brings up
|
||||
PostgreSQL with a persistent volume, and runs the loop with its embedded
|
||||
dashboard. Config comes from a local `.env` (optional — without `ALPACA_*` keys
|
||||
the loop runs in dry mode; the dashboard and DB still work). `DATABASE_URL` is
|
||||
overridden to the in-network `postgres` service, so the stack is self-contained.
|
||||
|
||||
```bash
|
||||
cp .env.example .env # optional: add ALPACA_* keys for live data
|
||||
|
||||
docker compose up -d --build # postgres + app loop + dashboard (:8010)
|
||||
docker compose logs -f app # watch the loop
|
||||
open http://localhost:8010 # dashboard
|
||||
|
||||
docker compose --profile monitoring up -d # also start Grafana (:3000)
|
||||
```
|
||||
|
||||
The scheduled jobs sit under the `jobs` profile so they don't auto-start; run
|
||||
them on demand (or from host cron / a scheduler on the ET times in `.env`):
|
||||
|
||||
```bash
|
||||
docker compose run --rm nightly-tune
|
||||
docker compose run --rm eod-replay
|
||||
docker compose run --rm journal
|
||||
```
|
||||
|
||||
Postgres credentials default to `momentum`/`momentum`/`momentum`; override with
|
||||
`POSTGRES_USER` / `POSTGRES_PASSWORD` / `POSTGRES_DB` in `.env`. Named volumes
|
||||
`pg-data`, `app-data`, and `grafana-data` persist across restarts (the jobs share
|
||||
`app-data` with the app so `nightly_tune.py`'s `learned_params.json` reaches the
|
||||
loop). The Compose Grafana uses its own datasource override at
|
||||
`deploy/compose/grafana/datasources/momentum.yaml` (pointed at the `postgres`
|
||||
service); the legacy `grafana/docker-compose.yml` is unrelated and left for
|
||||
Grafana-only use against an external DB.
|
||||
|
||||
## Build & publish the image
|
||||
|
||||
Pushes to the working branch / `main` / `v*` tags trigger
|
||||
`.github/workflows/docker-publish.yml`, which builds and pushes to
|
||||
`ghcr.io/fez5587/momentum-system`. Point the chart at a pushed tag via
|
||||
`image.tag` (a commit SHA, or `latest` on the default branch).
|
||||
|
||||
Local build:
|
||||
|
||||
```bash
|
||||
docker build -t momentum:dev .
|
||||
```
|
||||
|
||||
## Install
|
||||
|
||||
```bash
|
||||
helm install momentum ./deploy/helm/momentum \
|
||||
-n momentum --create-namespace \
|
||||
--set image.tag=latest \
|
||||
--set secrets.alpacaApiKey=YOUR_KEY \
|
||||
--set secrets.alpacaSecretKey=YOUR_SECRET
|
||||
```
|
||||
|
||||
Without Alpaca keys the loop runs in **dry mode** (no live data/orders); the
|
||||
dashboard and database still work.
|
||||
|
||||
### Secrets
|
||||
|
||||
The chart builds `DATABASE_URL` from the in-cluster Postgres service and a
|
||||
password that is generated on first install (or set `postgres.password` to pin
|
||||
it). Broker keys come from `secrets.*`.
|
||||
|
||||
For real keys, prefer an existing Secret over `values.yaml`:
|
||||
|
||||
```bash
|
||||
kubectl -n momentum create secret generic momentum-broker \
|
||||
--from-literal=DATABASE_URL=... \
|
||||
--from-literal=ALPACA_API_KEY=... --from-literal=ALPACA_SECRET_KEY=... \
|
||||
--from-literal=APCA_API_KEY_ID=... --from-literal=APCA_API_SECRET_KEY=...
|
||||
helm install momentum ./deploy/helm/momentum -n momentum \
|
||||
--set secrets.existingSecret=momentum-broker
|
||||
```
|
||||
|
||||
When `secrets.existingSecret` is set the chart renders no Secret of its own and
|
||||
expects that one to carry `DATABASE_URL`, the `ALPACA_*`/`APCA_*` keys, and
|
||||
`POSTGRES_PASSWORD`.
|
||||
|
||||
## Reach the dashboard
|
||||
|
||||
```bash
|
||||
kubectl -n momentum port-forward svc/momentum-app 8010:8010
|
||||
open http://localhost:8010
|
||||
```
|
||||
|
||||
Set `ingress.enabled=true` (with `ingress.host`/`className`) to expose it
|
||||
instead.
|
||||
|
||||
## Verify
|
||||
|
||||
```bash
|
||||
helm lint deploy/helm/momentum
|
||||
helm template momentum deploy/helm/momentum | less
|
||||
|
||||
# After install:
|
||||
kubectl -n momentum get pods
|
||||
kubectl -n momentum exec sts/momentum-postgres -- psql -U momentum -d momentum -c '\dt'
|
||||
kubectl -n momentum create job --from=cronjob/momentum-nightly-tune tune-test
|
||||
```
|
||||
|
||||
## Notes & constraints
|
||||
|
||||
- **Singleton loop.** The trading loop holds in-memory state and one broker
|
||||
session; `replicas` must stay `1` and the strategy is `Recreate`.
|
||||
- **Shared `data/` PVC is ReadWriteOnce.** The CronJob pods mount the same PVC
|
||||
as the app (so `nightly_tune.py` can write `learned_params.json` for the loop
|
||||
to read), which schedules them onto the app pod's node. For multi-node
|
||||
clusters, use an RWX StorageClass or split the volumes.
|
||||
- **CronJob `spec.timeZone`** requires Kubernetes ≥ 1.27.
|
||||
- The legacy `grafana/docker-compose.yml` is left in place for local use; the
|
||||
chart provisions its own Grafana against the in-cluster Postgres.
|
||||
|
||||
## Future hardening (roadmap)
|
||||
|
||||
The package deploys and runs, but the items below separate "it comes up" from
|
||||
"runs reliably for a trading system." They are **not implemented yet** — captured
|
||||
here so they can be picked up later. Each notes what, why, and the rough approach.
|
||||
|
||||
### Reliability
|
||||
|
||||
- **Loop-heartbeat liveness** *(needs an app-code change).* Today `/api/health`
|
||||
(`api/main.py:343`) returns a static `{"ok": true}`, which only proves the
|
||||
dashboard *thread* is alive. The trading loop runs in the main thread
|
||||
(`run_live_paper.py:328`), so a **crashed** loop exits the process and Kubernetes
|
||||
restarts it (fine), but a **hung** loop (stuck network call, deadlock) keeps
|
||||
answering health checks `200` and runs undetected. Approach: have the loop stamp a
|
||||
last-tick timestamp into `DashboardState` each iteration, make `/api/health` (or a
|
||||
new `/healthz`) return `503` when the last tick is older than a threshold, and
|
||||
point the Deployment's `livenessProbe` at it. Highest-value item.
|
||||
- **Postgres backup CronJob.** The event store is the source of truth and nothing
|
||||
backs it up. Approach: a chart CronJob running `pg_dump` (postgres image, creds
|
||||
from the Secret) to the app `data` PVC with N-day retention; optionally offload to
|
||||
object storage.
|
||||
- **Schema bootstrap hook.** The schema auto-applies on the app's first connection,
|
||||
so a first-run race between the app and a CronJob is possible. Approach: a
|
||||
`pre-install`/`pre-upgrade` Helm-hook Job that applies `storage/pg_schema.sql`
|
||||
deterministically, with `PG_SKIP_SCHEMA=1` set on the app and CronJobs.
|
||||
|
||||
### Security
|
||||
|
||||
- **Pod hardening.** Add a `securityContext` across workloads: `runAsNonRoot`, drop
|
||||
ALL capabilities, `seccompProfile: RuntimeDefault`, and `readOnlyRootFilesystem`
|
||||
where feasible (the app needs `/app/data` writable — already a mounted volume).
|
||||
- **NetworkPolicy.** Default-deny in the namespace; allow only the app, CronJobs, and
|
||||
Grafana to reach Postgres:5432, and restrict who can reach the dashboard.
|
||||
- **Grafana auth.** The chart inherits anonymous-admin from the original compose
|
||||
(`templates/grafana.yaml` env). Make it off by default in-cluster, with a real
|
||||
admin password sourced from the Secret (`GF_SECURITY_ADMIN_PASSWORD`).
|
||||
|
||||
### CI / operational
|
||||
|
||||
- **Chart CI validation.** Add a CI job running `helm lint` + `helm template` +
|
||||
`kubeconform` on every push — catches template/schema errors automatically (the
|
||||
chart could not be rendered in the sandbox where it was authored, since image-blob
|
||||
CDNs were egress-blocked).
|
||||
- **Image digest pinning.** For production installs, pin `image` by SHA digest rather
|
||||
than the mutable `latest` tag.
|
||||
- **PodDisruptionBudget / singleton guard.** A PDB for the singleton loop plus a
|
||||
guard so it is never scaled above `1` or evicted to zero unexpectedly.
|
||||
@@ -0,0 +1,19 @@
|
||||
# Grafana datasource for the docker-compose stack — points at the in-network
|
||||
# `postgres` service (not the LAN IP used by grafana/provisioning/datasources).
|
||||
# Credentials are interpolated from the PG_* env vars set on the grafana service.
|
||||
apiVersion: 1
|
||||
datasources:
|
||||
- name: Momentum
|
||||
uid: momentum_pg
|
||||
type: postgres
|
||||
access: proxy
|
||||
url: postgres:5432
|
||||
user: ${PG_USER}
|
||||
jsonData:
|
||||
database: ${PG_DATABASE}
|
||||
sslmode: disable
|
||||
postgresVersion: 1600
|
||||
secureJsonData:
|
||||
password: ${PG_PASSWORD}
|
||||
isDefault: true
|
||||
editable: true
|
||||
@@ -0,0 +1,5 @@
|
||||
.DS_Store
|
||||
.git
|
||||
*.tmp
|
||||
*.bak
|
||||
*.orig
|
||||
@@ -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
|
||||
@@ -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"
|
||||
}]
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -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 }}
|
||||
@@ -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 }}
|
||||
@@ -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
|
||||
@@ -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:
|
||||
Reference in new issue
Block a user