Files
momentum-system/.env.example
T
Claude 618950ce9c Add AI trade analysis (advisory): armed/weak verdict, post-mortems, EOD note
Extends the local-LLM layer from news to TRADES, reusing the proven enrichment
pattern (structured JSON, Postgres cache, off-hot-path scheduler, graceful
degradation). Advisory only — never gates a trade.

strategy/evaluation/trade_analysis.py: four analyses keyed by analysis_type —
  * armed      — does a loaded setup still make sense to pursue? (pursue/avoid/
                 monitor + confidence + concerns), weighing numbers + catalyst
  * weak       — why a too-soft setup failed and whether a thesis remains
  * postmortem — closed-trade lesson (uses r_multiple / exit_reason)
  * eod        — once-a-day session narrative
Schema-constrained decoding (ANALYSIS_SCHEMA), tolerant _parse, and a
context_hash so an unchanged board isn't re-sent to the GPU. run_* helpers
upsert into ai_trade_analysis_cache; trade_analysis_map / query_trade_analysis
read it back. NEVER raise.

Wiring:
- ai_trade_analysis_cache table (storage/pg_schema.sql)
- OllamaConfig.trade_analysis_{enabled,interval_seconds,batch_limit} + env
- run_live_paper step_analyze on the Scheduler, gated by ollama.enabled AND
  TRADE_ANALYSIS_ENABLED (ships OFF); reads the in-memory trigger book + day P&L
- query_trade_analysis projection; api snapshots() attaches the verdict to armed
  triggers + approval rows and exposes trade_analysis
- dashboard: per-name AI verdict chip (pursue=green/avoid=red/monitor=amber) +
  an "AI session note" panel
- .env.example documents the toggles

Tests: parse/enum/clamp, prompt builders, persistence + context_hash dedupe,
closed-trade + EOD paths, config from_env, and the dashboard projection. 230 pass.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01M5HTMkaadGDnAPtXe2hWwg
2026-06-29 22:00:03 +00:00

199 lines
10 KiB
Bash

# ============================================================
# Momentum — environment configuration (copy to .env and fill)
# ============================================================
# ---- Database (PostgreSQL) — the single datastore (event store + bar warehouse)
# Either a full URL:
DATABASE_URL=postgresql://USER:PASSWORD@HOST:5432/momentum
# ...or individual vars (used only if DATABASE_URL is unset):
# PGHOST=127.0.0.1
# PGPORT=5432
# PGUSER=admin
# PGPASSWORD=
# PGDATABASE=momentum
# ---- Alpaca (market data + paper broker) — REQUIRED for live paper trading
ALPACA_API_KEY=
ALPACA_SECRET_KEY=
# Alpaca paper trading endpoint (default is fine)
APCA_API_BASE_URL=https://paper-api.alpaca.markets
# These two aliases are also read by the paper client:
APCA_API_KEY_ID=
APCA_API_SECRET_KEY=
# ---- Schwab (live broker, Milestone 3) — OPTIONAL for paper trading
SCHWAB_MARKET_DATA_APP_KEY=
SCHWAB_MARKET_DATA_APP_SECRET=
SCHWAB_BROKER_APP_KEY=
SCHWAB_BROKER_APP_SECRET=
SCHWAB_REDIRECT_URI=
SCHWAB_TOKEN_PATH=data/schwab_tokens.json
# ---- Optional research / news providers
POLYGON_API_KEY=
FINNHUB_API_KEY=
Alpha_Vantage_Api_Key=
# News-catalyst discovery (optional): RSS feeds as name=url, comma-separated.
# When set, tickers mentioned in recent news ($TICKER or (NASDAQ:TICKER)) are
# added to the watchlist. Unset = disabled (no effect on the loop).
# NEWS_FEEDS=prnewswire=https://www.prnewswire.com/rss/news-releases-list.rss,businesswire=https://feed.businesswire.com/rss/home/?rss=G1QFDERJXkJeGVtRWQ==
# ---- Local LLM news/catalyst enrichment (Ollama) ----
# A local model classifies ingested headlines (catalyst type, sentiment,
# conviction, dilution) into news_catalyst_cache — surfaced on the dashboard
# approval queue. Runs off the hot path; degrades to a no-op if Ollama is down.
# OLLAMA_ENABLED=1
# OLLAMA_HOST=http://localhost:11434
# OLLAMA_MODEL=qwen2.5:7b-instruct # fits 12GB; best small-model JSON.
# # alts: llama3.1:8b-instruct-q4_K_M, mistral:7b-instruct
# NEWS_ENRICH_ENABLED=1
# NEWS_ENRICH_INTERVAL_SECONDS=120
# NEWS_ENRICH_LOOKBACK_HOURS=12
# NEWS_ENRICH_BATCH_LIMIT=50 # max headlines classified per pass (GPU budget)
# Phase 2 (automation — each ships OFF; separate from the advisory above so the
# dashboard never silently changes scoring):
# score blend — fold the catalyst into the setup quality score AND graded arming
# rank (bullish catalyst boosts, confirmed dilution sinks below the armed cut).
# NEWS_CATALYST_SCORE_ENABLED=0
# NEWS_RANK_BOOST_GAIN=2.0 # arming-rank multiplier = 1 + gain*score
# NEWS_DILUTIVE_PENALTY=0.1 # arming-rank multiplier for a confirmed dilutive name
# dilution veto — block confirmed dilutive offerings before sizing.
# NEWS_DILUTION_VETO_ENABLED=0
# NEWS_DILUTION_VETO_CONVICTION=0.6
#
# AI trade analysis (advisory): the local model reads the armed/weak setups, the
# day's closed trades, and the whole session, writing a pursue/avoid verdict +
# post-mortems + an EOD note to ai_trade_analysis_cache (shown on the dashboard).
# OFF the hot path; never gates a trade. Needs OLLAMA_ENABLED=1.
# TRADE_ANALYSIS_ENABLED=0
# TRADE_ANALYSIS_INTERVAL_SECONDS=180
# TRADE_ANALYSIS_BATCH_LIMIT=8 # max setups analyzed per pass (GPU budget)
# ============================================================
# Live paper-trading orchestrator (run_live_paper.py)
# ============================================================
# Execution destination: alpaca_paper (safe) or schwab_live (REAL MONEY)
TRADING_EXECUTION_MODE=alpaca_paper
TRADING_EXECUTION_ENABLED=1
TRADING_EXECUTION_INTERVAL_SECONDS=30
TRADING_AUTO_APPROVE=0 # 0 = manual approval in the dashboard, 1 = auto-arm (no approval)
TRADING_MAX_ORDERS_PER_TICK=1
# NOTE: concurrency is set ONCE in the risk section below (was duplicated here).
TRADING_RISK_PER_TRADE_PCT=0.01 # fraction of equity risked per trade
# --- entry mechanism (Ross-Cameron-style auto-arm; all tunable) ---
TRADING_REWARD_MULTIPLE=2.0 # target = entry + this * (entry - stop); 2R default
TRADING_ENTRY_ORDER_TYPE=limit # limit = rest at trigger (cancellable); market = fill now
TRADING_ENTRY_TIMEOUT_BARS=2 # cancel an unfilled entry after N minutes resting (0 = off)
TRADING_ENTRY_INVALIDATE_PCT=0.0 # cancel if price breaks this far below entry (0 = any break; <0 = off)
TRADING_ENTRY_GUARD_INTERVAL_SECONDS=5 # how often to re-check armed entries vs the LIVE last-trade price
TRADING_MAX_DAILY_LOSS_PCT=0.03 # daily-loss circuit breaker
# Intraday watcher (signal engine)
WATCHER_ENABLED=1
WATCHER_INTERVAL_SECONDS=60
# WATCHER_EVENT_DB_PATH is legacy and ignored — the datastore is PostgreSQL (see DATABASE_URL above)
WATCHER_MAX_SYMBOLS=25
WATCHER_MIN_BARS=30
WATCHER_MIN_QUALITY=0.30
# Comma-separated symbols to follow all day. If empty, candidates are
# discovered from whatever has minute bars in market.duckdb for today.
WATCHER_SYMBOLS=AAPL,TSLA,NVDA,AMD,SOFI
# Price band for auto-discovered candidates (Ross Cameron default 1-20).
WATCHER_PRICE_MIN=1
WATCHER_PRICE_MAX=20
# Active strategy setups (comma-separated). The backtest sweep found the
# opening-range breakout is the only profitable one; empty = all setups.
STRATEGY_SETUPS=opening_range_break
# Live minute-bar ingestion (feeds the watcher with active market data)
LIVE_BARS_ENABLED=1
LIVE_BARS_INTERVAL_SECONDS=60
LIVE_BARS_LOOKBACK_MINUTES=120
# Data feed: live realtime uses iex (free, realtime, but ~2-5% of true volume).
# Backfill/backtest uses sip (full consolidated tape) so rvol/$-volume reflect
# REAL volume — the iex thinness silently corrupts every volume-based decision.
ALPACA_DATA_FEED=iex
ALPACA_BACKFILL_FEED=sip
# Freshness watchdog: warn (emit + display) when the newest bar is older than
# this during RTH. INGEST_STALE_BLOCK=1 also refuses to ARM on stale data
# (off by default so a mis-tuned threshold can't silently halt trading).
INGEST_STALE_SECONDS=180
INGEST_STALE_BLOCK=0
# Broker state sync
ALPACA_PAPER_SYNC_ENABLED=1
ALPACA_PAPER_SYNC_INTERVAL_SECONDS=60
ACCOUNT_SYNC_ENABLED=0 # Schwab live account sync
ORDER_SYNC_ENABLED=0 # Schwab live order sync
# Dashboard
DASHBOARD_HOST=0.0.0.0
DASHBOARD_PORT=8010
# ============================================================
# Armed-trigger fast path (runtime/triggers.py) + entry quality
# ============================================================
TRIGGER_ENABLED=1
TRIGGER_MAX_ARMED=10 # gappers kept ready to fire at once
TRIGGER_INTERVAL_SECONDS=4 # fast path: live-price check / fire cadence
TRIGGER_ARM_INTERVAL_SECONDS=20 # re-rank + recompute triggers cadence
TRIGGER_GAP_MIN=3.0 # min overnight gap %
# Cap the gap: blow-off gappers (>35%) fade — they're the worst-expectancy
# bucket. Train/test validated on 14 SIP-backfilled sessions: lowering 60->35
# lifted held-out TEST expectancy 0.19R -> 0.46R/trade AND raised total R.
TRIGGER_GAP_MAX=35
TRIGGER_RVOL_MIN=2.0 # min RELATIVE volume to fire
# Absolute liquidity gate: rvol is relative, so 2x of near-nothing still
# qualifies and we fill at the top of a thin one-bar spike that reverses. This
# requires recent 5-bar $-volume >= the floor so thin names go WEAK, not armed.
TRIGGER_MIN_DOLLAR_VOL=8000
# Do not fire a trigger on a price older than this (seconds) — a stalled feed
# must not fire a breakout on a frozen quote into an already-reverted market.
TRIGGER_PRICE_MAX_AGE=12
ORB_BARS=5 # opening range = first N regular-hours minutes
TRADING_TRIGGER_SLIP_PCT=0.004 # cap the chase above the trigger on the fill
# Account-aware sizing (matters most on a small REAL account):
TRADING_MAX_POSITION_PCT=0.16 # one position's $ value <= this fraction of equity
# PORTFOLIO gross-notional cap: total $ across ALL open+pending positions stays
# under this fraction of equity. Without it, 6 concurrent x 16% = ~96% gross in
# correlated small-cap gappers. With it, later entries shrink/blocked to fit.
TRADING_MAX_GROSS_NOTIONAL_PCT=0.60
# Liquidity-aware sizing: shares <= this fraction of the name's volume, so a
# thin name is sized DOWN automatically instead of taking a full position it
# can't absorb. 0 = off (fine at tiny size).
TRADING_LIQUIDITY_MAX_VOLUME_PCT=0.025
# ============================================================
# Risk discipline (added 2026-06-18 — see git history for the why)
# ============================================================
# Anti-thrash: a breakout that won't fill on a marketable limit used to re-arm
# every pass forever (observed: NEOV armed+backed-out 68x/session). Bench it.
TRADING_BACKOUT_COOLDOWN_SECONDS=180 # bench this long after an unfilled backout
TRADING_MAX_BACKOUTS_PER_SYMBOL=3 # after N backouts, bench for the session
# Re-entry discipline: don't buy back a name that already round-tripped today...
TRADING_BLOCK_REENTRY_AFTER_EXIT=1
# ...but only if it exited at a real LOSS (>1%), so a scratch/win can re-enter
# (the blunt version killed a +513 re-entry winner in replay). 0 = bench any close.
TRADING_REENTRY_MIN_LOSS_PCT=0.01
# End-of-day flatten — a day-trading book shouldn't carry overnight gap risk.
TRADING_EOD_FLATTEN_ENABLED=1
TRADING_EOD_FLATTEN_TIME=15:55 # flatten in the window [time, 16:00)
# ============================================================
# Managed exits (strategy/exits.py — swept + out-of-sample verified)
# The far take-profit is a backstop; the TRAILING STOP is the real exit.
# ============================================================
TRADING_REWARD_MULTIPLE=10.0 # far take-profit backstop (NOT 2.0; trail does the work)
TRADING_EXIT_TRAIL_MODE=prior_low # trail the stop under each prior bar's low
TRADING_EXIT_TRAIL_AFTER_R=1.0 # start trailing once the trade is +1R
TRADING_EXIT_BREAKEVEN_R=1.0 # lock breakeven once +1R (give-back safeguard)
TRADING_EXIT_PROFIT_TIERS=8:3,15:9,25:18,40:30 # +gain% -> lock% profit-lock ladder
# NOTE: several defaults above intentionally override the simpler values earlier
# in this file (e.g. MAX_CONCURRENT_POSITIONS, REWARD_MULTIPLE) — these reflect
# the current live-verified config. Set MAX_CONCURRENT_POSITIONS=6 for the live book.
TRADING_MAX_CONCURRENT_POSITIONS=6
# !!! Before REAL-money trading, set TRADING_MAX_DAILY_LOSS_PCT back to 0.03 !!!