Telemetry: discovery took 6-30s ON the single scheduler, freezing ingest/arm so the trigger read stale bars — the real 'late edge detection'. Now discovery runs on its own daemon thread with a dedicated DB connection; the watchlist it mutates is a lock-guarded Watchlist (list-like drop-in, no torn reads); the scheduler discover step auto-disables (single-writer). Loop is now 3 fast threads (entries/exits/discovery) + slow scheduler for tolerant work. Also: nightly_tune writes a heartbeat every run, so the boot alert distinguishes 'tuner not running' (real) from 'ran, no profitable ORB config, kept params' (expected — the strategy has no edge). Kills the misleading Telegram alarm. 543 tests green. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
283 lines
11 KiB
YAML
283 lines
11 KiB
YAML
# Full self-contained stack for the Momentum System.
|
|
#
|
|
# docker compose up -d # postgres + loop + dashboard + qabot + transcripts + scheduler
|
|
# docker compose --profile monitoring up -d # also start Grafana
|
|
# docker compose run --rm nightly-tune # run a job on demand
|
|
#
|
|
# Secrets come from .env (never baked into the image). DATABASE_URL is
|
|
# overridden below to the in-stack postgres so the stack is self-contained —
|
|
# migrating from the LAN Postgres (192.168.1.5) is a pg_dump/restore into the
|
|
# pg-data volume; see deploy/docker/README.md. To run the app services against
|
|
# the LAN Postgres instead (transition mode), comment out the DATABASE_URL
|
|
# override and let .env's value through.
|
|
#
|
|
# TZ is America/New_York in the image: the loop's market clock thinks in ET.
|
|
|
|
x-app: &app
|
|
build: .
|
|
image: momentum-app:latest
|
|
env_file:
|
|
- path: .env
|
|
required: false
|
|
volumes:
|
|
- app-data:/app/data
|
|
depends_on:
|
|
postgres:
|
|
condition: service_healthy
|
|
restart: unless-stopped
|
|
logging:
|
|
driver: json-file
|
|
options: { max-size: "20m", max-file: "3" }
|
|
|
|
x-app-env: &app-env
|
|
# Self-contained default: the in-stack postgres. Overrides .env.
|
|
DATABASE_URL: postgresql://${POSTGRES_USER:-momentum}:${POSTGRES_PASSWORD:-momentum}@postgres:5432/${POSTGRES_DB:-momentum}
|
|
PG_SKIP_SCHEMA: "0"
|
|
LIVE_BARS_INTERVAL_SECONDS: "5"
|
|
TRIGGER_ARM_INTERVAL_SECONDS: "8"
|
|
WATCHER_INTERVAL_SECONDS: "8"
|
|
TRIGGER_INTERVAL_SECONDS: "2"
|
|
# catalyst enrichment ON as a labeler FEATURE source (plan 009) — loop + scheduler
|
|
# (labeler builds) both inherit this anchor. Needs a reachable Ollama (see .env);
|
|
# enabled-but-down never impedes trading (fully gated).
|
|
NEWS_ENRICH_ENABLED: "1"
|
|
# Managed-exit config — matches deploy/docker/truenas-compose.yml so both
|
|
# templates agree. Without these the loop runs code defaults (target 2R,
|
|
# BE@+5%, NO trail) which scratches slow v1 winners at breakeven. prior_low
|
|
# trailing after +1R lifts the win rate (counterfactual: v1 17%->24%). Only the
|
|
# LOOP acts on these (ExitConfig.from_env, run_live_paper.py); other services
|
|
# inherit them harmlessly.
|
|
TRADING_EXIT_TRAIL_MODE: "prior_low"
|
|
TRADING_EXIT_TRAIL_AFTER_R: "1.0"
|
|
TRADING_EXIT_MANAGE_INTERVAL_SECONDS: '4' # 12->4s: react to fast small-cap round-trips (was giving back gains)
|
|
TRADING_EXIT_THREAD_ENABLED: '1'
|
|
DISCOVER_THREAD_ENABLED: '1'
|
|
TRADING_EXIT_THREAD_INTERVAL_SECONDS: '1.0'
|
|
TRADING_EXIT_TRAIL_R_STEP: "0.25"
|
|
TRADING_EXIT_BREAKEVEN_R: "1.0"
|
|
TRADING_EXIT_PROFIT_TIERS: "8:3,15:9,25:18,40:30"
|
|
# Regime gate (plan 011) LIVE: block fresh entries on sessions the walk-forward
|
|
# metalabel scores unfavorable (sit out bad regimes). Only the LOOP acts on it;
|
|
# fail-open (unfit model / error never blocks). Needs regime_features populated
|
|
# (the scheduler builds them nightly). Set 0 to disable.
|
|
TRADING_REGIME_GATE_ENABLED: "1"
|
|
# Favorable threshold: block a session when P(profit) < this. 0.5 = code default
|
|
# (matches the backtest). Lower (e.g. 0.4) trades MORE days; raise for fewer,
|
|
# higher-confidence days. Optional — omit to use the 0.5 default.
|
|
TRADING_REGIME_GATE_P_MIN: "0.5"
|
|
|
|
services:
|
|
postgres:
|
|
image: postgres:18
|
|
environment:
|
|
POSTGRES_USER: ${POSTGRES_USER:-momentum}
|
|
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:-momentum}
|
|
POSTGRES_DB: ${POSTGRES_DB:-momentum}
|
|
PGDATA: /var/lib/postgresql/data/pgdata
|
|
volumes:
|
|
- pg-data:/var/lib/postgresql/data
|
|
ports:
|
|
- "5432:5432" # psql/Grafana-on-host debugging; remove if not needed
|
|
healthcheck:
|
|
test: ["CMD-SHELL", "pg_isready -U ${POSTGRES_USER:-momentum} -d ${POSTGRES_DB:-momentum}"]
|
|
interval: 10s
|
|
timeout: 5s
|
|
retries: 5
|
|
restart: unless-stopped
|
|
|
|
loop:
|
|
<<: *app
|
|
container_name: momentum-loop
|
|
command: ["python", "-u", "run_live_paper.py", "--no-dashboard"]
|
|
environment:
|
|
<<: *app-env
|
|
healthcheck:
|
|
# heartbeat: the loop must have written SOME event in the last 5 minutes
|
|
# (module ticks flow around the clock, not just market hours)
|
|
# PG_SKIP_SCHEMA=1: a healthcheck must NOT re-apply schema DDL — the 60s
|
|
# CREATE-IF-NOT-EXISTS lock burst cancelled minute_bars autovacuum forever
|
|
# (2026-07-09 post-migration: stats rotted, dashboard queries crawled)
|
|
test: ["CMD", "python", "-c", "import os; os.environ['PG_SKIP_SCHEMA']='1'; import datetime,sys; from research.multi_schema import open_research_db; r=open_research_db('market').execute('SELECT max(timestamp) FROM events').fetchall()[0][0]; sys.exit(0 if r and (datetime.datetime.now()-r).total_seconds()<300 else 1)"]
|
|
interval: 60s
|
|
timeout: 20s
|
|
retries: 5
|
|
start_period: 180s
|
|
|
|
dashboard:
|
|
<<: *app
|
|
container_name: momentum-dashboard
|
|
command: ["python", "-u", "dashboard_api.py"]
|
|
environment:
|
|
<<: *app-env
|
|
DASHBOARD_HOST: "0.0.0.0"
|
|
DASHBOARD_PORT: "8765"
|
|
# read-only viewer: never re-apply the 473-line schema (the SSE change
|
|
# detector holds one long-lived connection) — mirrors the loop healthcheck.
|
|
PG_SKIP_SCHEMA: "1"
|
|
# Shared secret required on every POST (approve/reject/exit + the command
|
|
# bus). Empty = commands disabled and POSTs unauthenticated; set a value to
|
|
# arm the kill switch. Reference by name only — never inline the secret.
|
|
DASHBOARD_TOKEN: "${DASHBOARD_TOKEN:-}"
|
|
# Gate the operator command bus on (also needs DASHBOARD_TOKEN set).
|
|
COMMAND_BUS_ENABLED: "${COMMAND_BUS_ENABLED:-0}"
|
|
ports:
|
|
- "8765:8765"
|
|
healthcheck:
|
|
test: ["CMD", "curl", "-sf", "http://localhost:8765/"]
|
|
interval: 30s
|
|
timeout: 10s
|
|
retries: 3
|
|
start_period: 30s
|
|
|
|
qabot:
|
|
<<: *app
|
|
container_name: momentum-qabot
|
|
command: ["python", "-u", "telegram_bot.py"]
|
|
environment:
|
|
<<: *app-env
|
|
|
|
transcripts:
|
|
<<: *app
|
|
container_name: momentum-transcripts
|
|
command: ["python", "-u", "-m", "youtube_claims.run", "run"]
|
|
environment:
|
|
<<: *app-env
|
|
|
|
admin:
|
|
<<: *app
|
|
container_name: momentum-admin
|
|
command: ["python", "-u", "admin_api.py"]
|
|
environment:
|
|
<<: *app-env
|
|
ADMIN_PORT: "8767"
|
|
# set ADMIN_TOKEN in .env — without it the panel is open on the LAN
|
|
ports:
|
|
- "8767:8767"
|
|
volumes:
|
|
- app-data:/app/data
|
|
- /var/run/docker.sock:/var/run/docker.sock # service status + restarts
|
|
|
|
scheduler:
|
|
# Weekday-evening premarket eval + DB/log housekeeping at 20:00 ET —
|
|
# replaces the momentum-premarket-eval systemd timer.
|
|
<<: *app
|
|
container_name: momentum-scheduler
|
|
environment:
|
|
<<: *app-env
|
|
command:
|
|
- /bin/sh
|
|
- -c
|
|
- |
|
|
echo "[scheduler] up; will fire 20:00 ET Mon-Fri"
|
|
while true; do
|
|
if [ "$$(date +%H:%M)" = "20:00" ] && [ "$$(date +%u)" -le 5 ]; then
|
|
echo "[scheduler] premarket_eval $$(date)"
|
|
python -m research.premarket_eval || echo "[scheduler] eval FAILED"
|
|
# SPY/QQQ ingest -> forward source for the labeler's market-context features (plan 009)
|
|
python -m research.ingestion.market_context || echo "[scheduler] market_context ingest FAILED"
|
|
python -m research.eod_reconcile || echo "[scheduler] reconcile FAILED"
|
|
# Flywheel: grow the whole-market dataset first, then fit the tuner on
|
|
# it. 20:00 ET is after close, so replay of "today" is complete data —
|
|
# NOT look-ahead (replay is a labeling job, not a live gate).
|
|
python eod_replay.py || echo "[scheduler] eod_replay FAILED"
|
|
python nightly_tune.py || echo "[scheduler] nightly_tune FAILED"
|
|
# Regime gate (plan 011, ALL-SHADOW): a session-level metalabel
|
|
# P(our ORB profits today). build-features labels each session from its
|
|
# OWN realized expR; score writes the WALK-FORWARD favorable flag the
|
|
# experiments' regime_gate intervention reads. Must run BEFORE
|
|
# experiments run. Does NOT gate live trading (that is owner-gated).
|
|
python -m research.regime build-features || echo "[scheduler] regime build-features FAILED"
|
|
python -m research.regime score --model threshold_v1 || echo "[scheduler] regime score threshold FAILED"
|
|
python -m research.regime score --model meta_v1 || echo "[scheduler] regime score meta FAILED"
|
|
# Experiment OS (plan 006, ALL-SHADOW): replay running experiments over
|
|
# newly-logged fires, then adjudicate against pre-registered bars.
|
|
python -m research.experiments run || echo "[scheduler] experiments run FAILED"
|
|
python -m research.experiments adjudicate || echo "[scheduler] experiments adjudicate FAILED"
|
|
sleep 120
|
|
fi
|
|
sleep 20
|
|
done
|
|
|
|
db-backup:
|
|
# Nightly pg_dump at 20:10 ET Mon-Fri (after the 20:00 scheduler pass).
|
|
# Runs on the postgres:18 image — a version-matched pg_dump for free; the
|
|
# app image has none. Backups land in the app-data volume under backups/.
|
|
image: postgres:18
|
|
container_name: momentum-db-backup
|
|
restart: unless-stopped
|
|
environment:
|
|
# in-stack postgres, same value the app services use (NOT .env's
|
|
# DATABASE_URL, which may point at the old LAN instance)
|
|
DATABASE_URL: postgresql://${POSTGRES_USER:-momentum}:${POSTGRES_PASSWORD:-momentum}@postgres:5432/${POSTGRES_DB:-momentum}
|
|
BACKUP_DIR: /data/backups
|
|
BACKUP_KEEP_DAYS: "14"
|
|
TZ: America/New_York # the postgres image defaults to UTC; the 20:10 gate is ET
|
|
volumes:
|
|
- ./deploy/docker/backup_db.sh:/backup_db.sh:ro
|
|
- app-data:/data
|
|
depends_on:
|
|
postgres:
|
|
condition: service_healthy
|
|
command:
|
|
- /bin/bash
|
|
- -c
|
|
- |
|
|
echo "[backup] up; fires 20:10 ET Mon-Fri"
|
|
while true; do
|
|
if [ "$$(date +%H:%M)" = "20:10" ] && [ "$$(date +%u)" -le 5 ]; then
|
|
bash /backup_db.sh || echo "[backup] FAILED"
|
|
sleep 120
|
|
fi
|
|
sleep 20
|
|
done
|
|
|
|
# ---- On-demand jobs: docker compose run --rm <name>
|
|
journal:
|
|
<<: *app
|
|
profiles: ["jobs"]
|
|
command: ["python", "momentum_cli.py", "journal"]
|
|
environment: *app-env
|
|
restart: "no"
|
|
|
|
eod-replay:
|
|
<<: *app
|
|
profiles: ["jobs"]
|
|
command: ["python", "eod_replay.py"]
|
|
environment: *app-env
|
|
restart: "no"
|
|
|
|
nightly-tune:
|
|
<<: *app
|
|
profiles: ["jobs"]
|
|
command: ["python", "nightly_tune.py"]
|
|
environment: *app-env
|
|
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"
|
|
PG_USER: ${POSTGRES_USER:-momentum}
|
|
PG_PASSWORD: ${POSTGRES_PASSWORD:-momentum}
|
|
PG_DATABASE: ${POSTGRES_DB:-momentum}
|
|
ports:
|
|
- "3000:3000"
|
|
volumes:
|
|
- ./deploy/compose/grafana/datasources:/etc/grafana/provisioning/datasources:ro
|
|
- ./deploy/compose/grafana/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:
|