Files
momentum-system/deploy/docker/truenas-compose.yml
T
momentum devandClaude Fable 5 b3a47d775d feat(019): discovery on its own thread + tuner heartbeat (honest stale alert)
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>
2026-07-19 08:44:10 -05:00

279 lines
12 KiB
YAML

# Momentum stack for TrueNAS SCALE (Apps -> Discover Apps -> Custom App -> YAML).
#
# PULL-ONLY: TrueNAS custom apps cannot `build:` — the image comes from GHCR
# (published by .github/workflows/docker.yml on every master push). Make the
# ghcr.io/fez5587/momentum-app package PUBLIC once (GitHub -> Packages ->
# momentum-app -> Package settings -> Change visibility), or add registry
# credentials in TrueNAS (Apps -> Settings -> Manage Container Images).
#
# BEFORE DEPLOYING:
# 1. Create a dataset for the stack, e.g. /mnt/<pool>/apps/momentum
# with subdirs: data/ pgdata/
# 2. Replace /mnt/tank/apps/momentum below with your dataset path.
# 3. Fill every <FILL-ME> in the environment block (these are the secrets
# you wanted to manage from a protected space — TrueNAS stores the YAML
# server-side, or mount an env file from a restricted dataset instead).
#
# TZ is America/New_York: the loop's market clock thinks in ET.
x-app: &app
image: docker.io/fez5587/momentum-app:latest # also published to ghcr.io/fez5587/momentum-app
environment: &app-env
ALPACA_API_KEY: '<FILL-ME>'
ALPACA_BACKFILL_FEED: 'sip'
ALPACA_BASE_URL: 'https://paper-api.alpaca.markets/v2'
ALPACA_DATA_FEED: 'sip'
ALPACA_PAPER_SYNC_ENABLED: '1'
ALPACA_SECRET_KEY: '<FILL-ME>'
DATABASE_URL: 'postgresql://momentum:<FILL-ME-PG-PASSWORD>@postgres:5432/momentum'
DEADMAN_DAILY_PING: '1'
DEADMAN_STALE_MIN: '5'
DISCOVER_INTERVAL_SECONDS: '120'
FMP_API_KEY: '<FILL-ME>'
INGEST_STALE_BLOCK: '0'
INGEST_STALE_SECONDS: '180'
LIVE_BARS_INTERVAL_SECONDS: '5'
LIVE_BARS_REFRESH_MINUTES: '20'
NEWS_CATALYST_SCORE_ENABLED: '1'
NEWS_DILUTION_VETO_ENABLED: '1'
NEWS_ENRICH_BATCH_LIMIT: '50'
NEWS_ENRICH_ENABLED: '1'
NEWS_ENRICH_INTERVAL_SECONDS: '120'
NEWS_ENRICH_LOOKBACK_HOURS: '12'
NEWS_FEEDS: 'prnewswire=https://www.prnewswire.com/rss/news-releases-list.rss,businesswire=https://feed.businesswire.com/rss/home/?rss=G1QFDERJXkJeGVtRWQ==,globenewswire=https://www.globenewswire.com/RssFeed/orgclass/1/feedTitle/GlobeNewswire%20-%20News%20about%20Public%20Companies'
NEWS_LOOKBACK_HOURS: '8'
NEWS_RANK_BOOST: '2.0'
OLLAMA_ENABLED: '1'
OLLAMA_HOST: 'http://192.168.1.5:30068'
OLLAMA_MODEL: 'qwen2.5:7b-instruct'
OLLAMA_TEMPERATURE: '0.1'
OLLAMA_TIMEOUT_SECONDS: '60'
ORB_BARS: '5'
PG_SKIP_SCHEMA: '0'
STRATEGY_SETUPS: 'opening_range_break'
TELEGRAM_BOT_TOKEN: '<FILL-ME>'
TELEGRAM_CHAT_ID: "'2082795871'"
TRADING_AUTO_APPROVE: '1'
TRADING_BACKOUT_COOLDOWN_SECONDS: '180'
TRADING_BLOCK_REENTRY_AFTER_EXIT: '1'
TRADING_ENTRY_CONFIRM_BY_MINUTE: '0'
TRADING_ENTRY_FILL_MODEL: 'resting'
TRADING_ENTRY_INVALIDATE_PCT: '0.015'
TRADING_ENTRY_ORDER_TYPE: 'limit'
TRADING_ENTRY_TIMEOUT_BARS: '2'
TRADING_EOD_FLATTEN_ENABLED: '1'
TRADING_EOD_FLATTEN_TIME: '15:55'
TRADING_EXECUTION_ENABLED: '1'
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 to
# trade fewer, higher-confidence days. Optional — omit to use the 0.5 default.
TRADING_REGIME_GATE_P_MIN: '0.5'
TRADING_EXIT_TRAIL_AFTER_R: '1.0'
TRADING_EXIT_TRAIL_MODE: 'prior_low'
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_LIQUIDITY_MAX_VOLUME_PCT: '0.025'
TRADING_MAX_BACKOUTS_PER_SYMBOL: '3'
TRADING_MAX_CONCURRENT_POSITIONS: '10'
TRADING_MAX_DAILY_LOSS_PCT: '0.05'
TRADING_MAX_FRESH_ENTRIES_PER_DAY: '10'
TRADING_MAX_GROSS_NOTIONAL_PCT: '0.60'
TRADING_MAX_ORDERS_PER_TICK: '1'
TRADING_MAX_POSITION_PCT: '0.16'
TRADING_MAX_RISK_DOLLARS: '500'
TRADING_MIN_DAY_VOLUME: '300000'
TRADING_MIN_ENTRY_GAP_PCT: '0.10'
TRADING_MIN_QUALITY_SCORE: '0.60'
TRADING_PRICE_MAX: '20.0'
TRADING_PRICE_MIN: '2.0'
TRADING_REENTRY_MIN_LOSS_PCT: '0.01'
TRADING_REQUIRE_ABOVE_VWAP: '1'
TRADING_REWARD_MULTIPLE: '10.0'
TRADING_RISK_PER_TRADE_PCT: '0.01'
TRADING_SESSION_ONLY: '1'
TRADING_TRIGGER_SLIP_PCT: '0.004'
TRADING_UNIFIED_ENTRY: '1'
TRIGGER_ARM_INTERVAL_SECONDS: '8'
TRIGGER_ENABLED: '1'
TRIGGER_GAP_MAX: '35'
TRIGGER_GAP_MIN: '10.0'
TRIGGER_GAP_SQUEEZE_MIN: '150'
TRIGGER_GAP_SQUEEZE_RVOL: '5.0'
TRIGGER_INTERVAL_SECONDS: '2'
TRIGGER_MAX_ARMED: '10'
TRIGGER_MIN_DOLLAR_VOL: '8000'
TRIGGER_PRICE_MAX_AGE: '12'
TRIGGER_RVOL_MIN: '2.0'
TZ: 'America/New_York'
WATCHER_ENABLED: '1'
WATCHER_INTERVAL_SECONDS: '8'
WATCHER_PRICE_MAX: '20'
WATCHER_PRICE_MIN: '2'
YOUTUBE_API_KEY: '<FILL-ME>'
# comma-separated playlist URLs (or bare ids); synced into the playlists
# table at transcripts-service boot — edit here to add/track more playlists
YT_PLAYLIST_URLS: ''
volumes:
- /mnt/volume/Apps/momentum/data:/app/data
depends_on:
postgres:
condition: service_healthy
restart: unless-stopped
services:
postgres:
image: postgres:18
environment:
POSTGRES_USER: momentum
POSTGRES_PASSWORD: <FILL-ME-PG-PASSWORD>
POSTGRES_DB: momentum
PGDATA: /var/lib/postgresql/data/pgdata
volumes:
- /mnt/volume/Apps/momentum/pgdata:/var/lib/postgresql/data
ports:
# published on 5433 so it can coexist with the EXISTING postgres (5432)
# during migration — pgAdmin/pg_restore reach the new instance on 5433.
# In-stack services still use postgres:5432 (the container port).
- "5433:5432"
healthcheck:
test: ["CMD-SHELL", "pg_isready -U momentum -d momentum"]
interval: 10s
timeout: 5s
retries: 5
restart: unless-stopped
loop:
<<: *app
command: ["python", "-u", "run_live_paper.py", "--no-dashboard"]
healthcheck:
# 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
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 + POSTs unauthenticated; fill to arm the
# kill switch. Reference by name only — never inline the secret value.
DASHBOARD_TOKEN: <FILL-ME-DASHBOARD-TOKEN>
# Gate the operator command bus on (also needs DASHBOARD_TOKEN set).
COMMAND_BUS_ENABLED: '1'
qabot:
<<: *app
command: ["python", "-u", "telegram_bot.py"]
transcripts:
<<: *app
command: ["python", "-u", "-m", "youtube_claims.run", "run"]
admin:
<<: *app
command: ["python", "-u", "admin_api.py"]
environment:
<<: *app-env
ADMIN_PORT: "8767"
ADMIN_TOKEN: <FILL-ME-ADMIN-TOKEN>
ports:
- "8767:8767"
volumes:
- /mnt/volume/Apps/momentum/data:/app/data
- /var/run/docker.sock:/var/run/docker.sock # service status + restarts
scheduler:
<<: *app
command:
- /bin/sh
- -c
- |
echo "[scheduler] up; fires 20:00 ET Mon-Fri"
while true; do
if [ "$$(date +%H:%M)" = "20:00" ] && [ "$$(date +%u)" -le 5 ]; then
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, on the postgres:18 image so the
# client always matches the server major (the app image has NO pg_dump).
# This host is pull-only (no repo checkout), so the backup logic is INLINE
# — keep it in sync with deploy/docker/backup_db.sh. Restore:
# gunzip -c momentum-<date>.sql.gz | psql "$DATABASE_URL"
image: postgres:18
restart: unless-stopped
environment:
DATABASE_URL: 'postgresql://momentum:<FILL-ME-PG-PASSWORD>@postgres:5432/momentum'
BACKUP_DIR: '/backups'
BACKUP_KEEP_DAYS: '14'
TZ: 'America/New_York' # postgres image defaults to UTC; the 20:10 gate is ET
volumes:
- /mnt/volume/Apps/momentum/data/backups:/backups
depends_on:
postgres:
condition: service_healthy
command:
- /bin/bash
- -c
- |
set -o pipefail
echo "[backup] up; fires 20:10 ET Mon-Fri"
while true; do
if [ "$$(date +%H:%M)" = "20:10" ] && [ "$$(date +%u)" -le 5 ]; then
out="$$BACKUP_DIR/momentum-$$(date +%F).sql.gz"
mkdir -p "$$BACKUP_DIR" \
&& pg_dump "$$DATABASE_URL" | gzip > "$$out.tmp" \
&& gunzip -t "$$out.tmp" && mv "$$out.tmp" "$$out" \
&& find "$$BACKUP_DIR" -name 'momentum-*.sql.gz*' -mtime +"$$BACKUP_KEEP_DAYS" -delete \
&& date > "$$BACKUP_DIR/last_backup_ok" \
&& echo "[backup] OK $$out" \
|| echo "[backup] FAILED"
sleep 120
fi
sleep 20
done