# 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 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: