The ONLY entry change that survived rigorous validation on 14 SIP-backfilled sessions (53 trades). Gap-size buckets: 15-30% gap = +0.65R/trade, but 30-60% blow-offs = +0.10R. Train/test split (70/30 by date): lowering TRIGGER_GAP_MAX 60 -> 35 lifts HELD-OUT TEST expectancy 0.194 -> 0.462 R/trade AND total R (18.7 -> 19.9), better win rate, on both splits. Principled (huge gaps fade/buy-the-top) and corroborated by the entry-timing audit (losers were wide-range blow-off gappers). Rejected after validation (do NOT ship): open-delay (forgoes total R for marginal expectancy), breakout-HOLD/close-confirmation (hurts on the larger sample), rvol floor (no-op: all gappers already rvol>=6). Confirmed KEEP: the $-vol floor is fine on SIP volume (the earlier "it hurts" was corrupted IEX data); profit-lock ladder stays. .env TRIGGER_GAP_MAX 60 -> 35. NB: 14-day sample — re-validate as data grows. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
168 lines
8.4 KiB
Bash
168 lines
8.4 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==
|
|
|
|
# ============================================================
|
|
# 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 !!!
|