diff --git a/configs/intraday/default.yaml b/configs/intraday/default.yaml index 8341a47..c6eb054 100644 --- a/configs/intraday/default.yaml +++ b/configs/intraday/default.yaml @@ -19,7 +19,7 @@ backtest: start_date: null # null = auto (today - lookback_trading_days) end_date: null # null = today lookback_trading_days: 40 # ~2 months - pre_screen_threshold: 0.015 # phase 1 filter: (high-open)/open >= 1.5% + pre_screen_threshold: 0.015 # phase 1 filter: opening gap vs prev_close >= 1.5% cache: enabled: true diff --git a/configs/intraday/orb_default.yaml b/configs/intraday/orb_default.yaml deleted file mode 100644 index 4c0b1d8..0000000 --- a/configs/intraday/orb_default.yaml +++ /dev/null @@ -1,87 +0,0 @@ -# ORB (Opening Range Breakout) Strategy — Default Configuration -# Strategy: Buy breakout of first 5-min candle high (bullish candles only). -# Uses ATR-based stops, risk-based position sizing, 15:55 ET time exit. -# Based on ORB academic research adapted for available data infrastructure. - -strategy_mode: orb - -orb_strategy: - # ORB window - orb_minutes: 5 # 9:30–9:35 ET opening range - sim_bar_minutes: 30 # 30-min bars for breakout/stop management (ORB candle stays 5-min) - - # Entry - entry_direction: long_only # bullish candle only (V1; 'candle' for both directions) - order_timeout_minutes: 45 # cancel if no fill by 10:15 ET - - # Universe quality filters (applied during pre-screening) - min_price: 10.0 # $10+ stocks only - min_avg_dollar_volume: 25000000 # $25M 30-day avg daily dollar volume - min_atr_14: 0.50 # ATR(14) > $0.50 (sufficient range to trade) - - # RVOL-based candidate selection - min_rvol: 1.0 # minimum approx RVOL at open (see note in features.py) - max_candidates: 20 # top N candidates per day - min_candidates_to_trade: 3 # skip day if fewer qualify - - # Composite ranking weights (must sum to 1.0) - weight_rvol: 0.60 # relative volume (main signal) - weight_gap: 0.25 # gap% (proxy for premarket activity) - weight_dollar_vol: 0.15 # first-bar dollar volume - - # ATR-based stop management - atr_stop_multiplier: 0.50 # initial stop = ATR(14) × 50% from entry - breakeven_at_r: 1.0 # move stop to entry at +1R - trailing_at_r: 2.0 # activate trailing stop (3-bar swing low) at +2R - - # Risk-based position sizing - risk_per_trade_pct: 0.0025 # 0.25% of equity per trade - max_position_pct: 0.20 # cap at 20% of equity per position - daily_max_loss_pct: 0.0125 # stop trading at -1.25% daily loss - max_stops_per_day: 3 # stop trading after 3 full-R stops - - # Exit - exit_minutes_before_close: 5 # time exit at 15:55 ET - - # Execution - slippage_bps: 5.0 # 0.05% one-way slippage (both entry and exit) - initial_capital: 10000 # $10,000 starting capital - ticker_cooldown_days: 0 # no cooldown (ORB trades daily runners) - - # Cash account GFV (Good Faith Violation) constraint - # Unsettled proceeds can buy but not same-day sell → ORB always exits same day - # → only settled cash is usable. 0=disabled, 1=T+1 (US since May 2024), 2=T+2 - settlement_days: 1 - - # Max opening gap filter: exclude stocks that gapped up more than this at open. - # Stocks with large gaps are over-extended and show low breakout continuation rate. - # Sweep result: 3% >> 5% >> 10% in Sharpe (5.59 vs 4.50 vs 3.74). - max_gap_pct: 0.03 - - # Market regime: skip days when index gaps down > threshold at open - # Sweep result: SPY -0.5% filter hurts absolute return with minimal Sharpe gain. - # Individual ORB candidates can surge even on weak-SPY days (e.g. sector rotation). - market_regime_spy_threshold: null # disabled — breadth filter below is superior - - # Candidate breadth filter: skip day if + V22 → V23 via 2 validated improvements: ATR% quality filter + position cap adjustment. + + Validation mode: daily_budget_reset=true, compound_returns=false (단리/simple interest). + + V22 baseline: +98.73% (200d), Sharpe 3.24, DD -9.34%, 161 trades, WR 59.6% + V23 result: +116.92% (200d), Sharpe 3.26, DD -9.35%, 154 trades, WR 59.7% + + Changes from V22: + 1. min_atr_pct: null → 0.04 (require ATR-14 ≥ 4% of prev_close) + Analysis of 400d trade distribution revealed that stocks with ATR/price < 4% + (moderate-volatility names like energy stocks, stable tech) have 42-46% WR + and contribute −20% total P&L, while >5% ATR names have 62.4% WR and +104% + contribution. Filtering for high-ATR% ensures ORB candidates have the explosive + follow-through potential the strategy relies on. + Effect: 200d return +18.19pp. WR unchanged (+0.1pp). DD essentially same (−0.01pp). + + 2. max_position_pct: 0.80 → 0.70 + High-ATR% stocks have larger individual trade variance (bigger swings). + Reducing position cap from 80% to 70% of daily budget compensates, keeping + portfolio-level DD comparable while the ATR% filter improves return. + Without this adjustment: 400d DD −24.38% (fails gate). With: −23.97% (passes). + + Interaction: Neither change alone passes both 200d and 400d gates cleanly. + Together they are synergistic: min_atr_pct selects high-quality candidates, + max_pos_pct=0.70 manages their higher individual volatility. + + 400d validation (daily_reset): + V23 400d: +120.87%, WR 57.7%, DD −23.97%, 286 trades, 112 days, Sharpe 1.94 + V22 400d: +89.62%, WR 52.2%, DD −20.93%, 312 trades, 119 days, Sharpe 1.67 + 400d gates: return≥88% ✓, WR≥52% ✓, DD≥−24% ✓ (−23.97% passes by 0.03pp) + 400d DD worsened 3pp but return/WR/Sharpe all improved significantly. + + Quarterly 400d attribution (known): + 2024-Q3: −4.4%, 2024-Q4: +1.2%, 2025-Q1: −8.4% (volatile macro periods driving DD) + 2025-Q2: +13.0%, 2025-Q3: +42.4%, 2025-Q4: +11.0%, 2026-Q1: +35.2% + + min_atr_pct filter graveyard (200d, daily_reset, on V22 base): + - min_atr_pct 0.03: +86.09%, WR 58.7%, DD −10.85% (too lenient, includes drags) + - min_atr_pct 0.04: +122.02%, WR 59.5%, DD −9.25% ← best 200d + - min_atr_pct 0.05: +84.34%, WR 57.1%, DD −13.30% (over-filters, fewer candidates) + - min_atr_pct 0.06: +98.40%, WR 61.2%, DD −16.49% (too few trades, high DD) + - min_atr_pct 0.04 + max_atr_pct 0.08: +37.93% (filters too many good trades) + - min_atr_pct 0.04 + max_atr_pct 0.09: 200d +81.70%, 400d +85.65% (below gates) + - min_atr_pct 0.04 + risk=0.045: 200d WR 56.8% (fails gate), worse DD + + max_position_pct 0.75 + min_atr_pct 0.04: + 200d: +110.75%, WR 60.7%, DD −8.93% + 400d: +127.59%, WR 56.6%, DD −24.32% (FAILS 400d DD gate by 0.32pp) + +strategy_mode: orb + +orb_strategy: + engine_family: gainers_leader + live_readiness: live_ready + + orb_minutes: 5 + sim_bar_minutes: 5 + + entry_direction: long_only + order_timeout_minutes: 45 + + allow_doji_breakout: true + allow_red_to_green_breakout: true + + min_price: 10.0 + min_avg_dollar_volume: 25000000 + min_atr_14: 0.50 + + # === CHANGE: require ATR ≥ 4% of prev_close (filter low-volatility drag candidates) === + min_atr_pct: 0.04 + + min_rvol: 1.5 + min_abs_gap_pct: 0.02 + min_premarket_dollar_vol: 1500000 + max_candidates: 20 + max_candidates_per_sector: 3 + min_candidates_to_trade: 1 + ticker_cooldown_days: 0 + max_gap_pct: 0.04 + + min_candidate_breadth: 0.60 + market_regime_spy_threshold: 0.0015 + market_regime_ticker: QQQ + rolling_loss_days: 7 + rolling_loss_threshold: -0.07 + max_simultaneous_entries: 3 + min_breakout_rel_vol: 1.2 + + weight_rvol: 0.35 + weight_gap: 0.20 + weight_dollar_vol: 0.05 + weight_premarket_dollar_vol: 0.25 + weight_body_ratio: 0.0 + weight_momentum: 0.15 + + atr_stop_multiplier: 0.75 + breakeven_at_r: 1.0 + trailing_at_r: 1.0 + trailing_stop_atr_multiplier: 0.8 + trailing_tighten_at_r: 2.0 + trailing_stop_atr_multiplier_tight: 0.3 + + partial_exit_at_r: 99.0 + partial_exit_pct: 0.50 + + risk_per_trade_pct: 0.05 + # === CHANGE: max position 70% (from 80%) to manage higher per-trade variance === + max_position_pct: 0.70 + daily_max_loss_pct: 0.05 + max_stops_per_day: 5 + exit_minutes_before_close: 5 + + slippage_bps: 5.0 + initial_capital: 10000 + + compound_returns: false + daily_budget_reset: true + settlement_days: 1 + + # Preserved from V22 + drawdown_governor_threshold: 0.025 + drawdown_governor_min_scale: 0.30 + + # Preserved from V22 + streak_sizing_win_bonus: 0.70 + streak_sizing_max: 2.5 + +universe: + source: midlarge + +backtest: + start_date: null + end_date: null + lookback_trading_days: 200 + +cache: + enabled: true + dir: data/cache/intraday + +output: + dir: runs/intraday_orb + verbose: false diff --git a/configs/intraday/strategies/sweep/sw_atr_stop_multiplier_0p3.yaml b/configs/intraday/strategies/sweep/sw_atr_stop_multiplier_0p3.yaml deleted file mode 100644 index 5cc0b67..0000000 --- a/configs/intraday/strategies/sweep/sw_atr_stop_multiplier_0p3.yaml +++ /dev/null @@ -1,43 +0,0 @@ -strategy_mode: orb -orb_strategy: - orb_minutes: 5 - sim_bar_minutes: 30 - entry_direction: long_only - order_timeout_minutes: 30 - min_price: 10.0 - min_avg_dollar_volume: 25000000 - min_atr_14: 0.5 - min_rvol: 1.0 - max_candidates: 20 - min_candidates_to_trade: 3 - weight_rvol: 0.6 - weight_gap: 0.25 - weight_dollar_vol: 0.15 - atr_stop_multiplier: 0.3 - breakeven_at_r: 2.0 - trailing_at_r: 3.0 - risk_per_trade_pct: 0.0025 - max_position_pct: 0.2 - daily_max_loss_pct: 0.0125 - max_stops_per_day: 3 - exit_minutes_before_close: 5 - slippage_bps: 5.0 - initial_capital: 10000 - ticker_cooldown_days: 0 - settlement_days: 1 - max_gap_pct: 0.02 - market_regime_spy_threshold: -0.005 -universe: - source: midlarge - min_price: 10.0 -backtest: - start_date: null - end_date: null - lookback_trading_days: 200 - pre_screen_threshold: 0.01 -cache: - enabled: true - dir: data/cache/intraday -output: - dir: runs/intraday_orb - verbose: false diff --git a/configs/intraday/strategies/sweep/sw_atr_stop_multiplier_0p4.yaml b/configs/intraday/strategies/sweep/sw_atr_stop_multiplier_0p4.yaml deleted file mode 100644 index 21e47a9..0000000 --- a/configs/intraday/strategies/sweep/sw_atr_stop_multiplier_0p4.yaml +++ /dev/null @@ -1,43 +0,0 @@ -strategy_mode: orb -orb_strategy: - orb_minutes: 5 - sim_bar_minutes: 30 - entry_direction: long_only - order_timeout_minutes: 30 - min_price: 10.0 - min_avg_dollar_volume: 25000000 - min_atr_14: 0.5 - min_rvol: 1.0 - max_candidates: 20 - min_candidates_to_trade: 3 - weight_rvol: 0.6 - weight_gap: 0.25 - weight_dollar_vol: 0.15 - atr_stop_multiplier: 0.4 - breakeven_at_r: 2.0 - trailing_at_r: 3.0 - risk_per_trade_pct: 0.0025 - max_position_pct: 0.2 - daily_max_loss_pct: 0.0125 - max_stops_per_day: 3 - exit_minutes_before_close: 5 - slippage_bps: 5.0 - initial_capital: 10000 - ticker_cooldown_days: 0 - settlement_days: 1 - max_gap_pct: 0.02 - market_regime_spy_threshold: -0.005 -universe: - source: midlarge - min_price: 10.0 -backtest: - start_date: null - end_date: null - lookback_trading_days: 200 - pre_screen_threshold: 0.01 -cache: - enabled: true - dir: data/cache/intraday -output: - dir: runs/intraday_orb - verbose: false diff --git a/configs/intraday/strategies/sweep/sw_atr_stop_multiplier_0p6.yaml b/configs/intraday/strategies/sweep/sw_atr_stop_multiplier_0p6.yaml deleted file mode 100644 index 08e45ea..0000000 --- a/configs/intraday/strategies/sweep/sw_atr_stop_multiplier_0p6.yaml +++ /dev/null @@ -1,43 +0,0 @@ -strategy_mode: orb -orb_strategy: - orb_minutes: 5 - sim_bar_minutes: 30 - entry_direction: long_only - order_timeout_minutes: 30 - min_price: 10.0 - min_avg_dollar_volume: 25000000 - min_atr_14: 0.5 - min_rvol: 1.0 - max_candidates: 20 - min_candidates_to_trade: 3 - weight_rvol: 0.6 - weight_gap: 0.25 - weight_dollar_vol: 0.15 - atr_stop_multiplier: 0.6 - breakeven_at_r: 2.0 - trailing_at_r: 3.0 - risk_per_trade_pct: 0.0025 - max_position_pct: 0.2 - daily_max_loss_pct: 0.0125 - max_stops_per_day: 3 - exit_minutes_before_close: 5 - slippage_bps: 5.0 - initial_capital: 10000 - ticker_cooldown_days: 0 - settlement_days: 1 - max_gap_pct: 0.02 - market_regime_spy_threshold: -0.005 -universe: - source: midlarge - min_price: 10.0 -backtest: - start_date: null - end_date: null - lookback_trading_days: 200 - pre_screen_threshold: 0.01 -cache: - enabled: true - dir: data/cache/intraday -output: - dir: runs/intraday_orb - verbose: false diff --git a/configs/intraday/strategies/sweep/sw_atr_stop_multiplier_0p75.yaml b/configs/intraday/strategies/sweep/sw_atr_stop_multiplier_0p75.yaml deleted file mode 100644 index 8d8eba3..0000000 --- a/configs/intraday/strategies/sweep/sw_atr_stop_multiplier_0p75.yaml +++ /dev/null @@ -1,43 +0,0 @@ -strategy_mode: orb -orb_strategy: - orb_minutes: 5 - sim_bar_minutes: 30 - entry_direction: long_only - order_timeout_minutes: 30 - min_price: 10.0 - min_avg_dollar_volume: 25000000 - min_atr_14: 0.5 - min_rvol: 1.0 - max_candidates: 20 - min_candidates_to_trade: 3 - weight_rvol: 0.6 - weight_gap: 0.25 - weight_dollar_vol: 0.15 - atr_stop_multiplier: 0.75 - breakeven_at_r: 2.0 - trailing_at_r: 3.0 - risk_per_trade_pct: 0.0025 - max_position_pct: 0.2 - daily_max_loss_pct: 0.0125 - max_stops_per_day: 3 - exit_minutes_before_close: 5 - slippage_bps: 5.0 - initial_capital: 10000 - ticker_cooldown_days: 0 - settlement_days: 1 - max_gap_pct: 0.02 - market_regime_spy_threshold: -0.005 -universe: - source: midlarge - min_price: 10.0 -backtest: - start_date: null - end_date: null - lookback_trading_days: 200 - pre_screen_threshold: 0.01 -cache: - enabled: true - dir: data/cache/intraday -output: - dir: runs/intraday_orb - verbose: false diff --git a/configs/intraday/strategies/sweep/sw_atr_stop_multiplier_1p0.yaml b/configs/intraday/strategies/sweep/sw_atr_stop_multiplier_1p0.yaml deleted file mode 100644 index 6110236..0000000 --- a/configs/intraday/strategies/sweep/sw_atr_stop_multiplier_1p0.yaml +++ /dev/null @@ -1,43 +0,0 @@ -strategy_mode: orb -orb_strategy: - orb_minutes: 5 - sim_bar_minutes: 30 - entry_direction: long_only - order_timeout_minutes: 30 - min_price: 10.0 - min_avg_dollar_volume: 25000000 - min_atr_14: 0.5 - min_rvol: 1.0 - max_candidates: 20 - min_candidates_to_trade: 3 - weight_rvol: 0.6 - weight_gap: 0.25 - weight_dollar_vol: 0.15 - atr_stop_multiplier: 1.0 - breakeven_at_r: 2.0 - trailing_at_r: 3.0 - risk_per_trade_pct: 0.0025 - max_position_pct: 0.2 - daily_max_loss_pct: 0.0125 - max_stops_per_day: 3 - exit_minutes_before_close: 5 - slippage_bps: 5.0 - initial_capital: 10000 - ticker_cooldown_days: 0 - settlement_days: 1 - max_gap_pct: 0.02 - market_regime_spy_threshold: -0.005 -universe: - source: midlarge - min_price: 10.0 -backtest: - start_date: null - end_date: null - lookback_trading_days: 200 - pre_screen_threshold: 0.01 -cache: - enabled: true - dir: data/cache/intraday -output: - dir: runs/intraday_orb - verbose: false diff --git a/configs/intraday/strategies/sweep/sw_breakeven_at_r_1p0.yaml b/configs/intraday/strategies/sweep/sw_breakeven_at_r_1p0.yaml deleted file mode 100644 index 40582e8..0000000 --- a/configs/intraday/strategies/sweep/sw_breakeven_at_r_1p0.yaml +++ /dev/null @@ -1,43 +0,0 @@ -strategy_mode: orb -orb_strategy: - orb_minutes: 5 - sim_bar_minutes: 30 - entry_direction: long_only - order_timeout_minutes: 30 - min_price: 10.0 - min_avg_dollar_volume: 25000000 - min_atr_14: 0.5 - min_rvol: 1.0 - max_candidates: 20 - min_candidates_to_trade: 3 - weight_rvol: 0.6 - weight_gap: 0.25 - weight_dollar_vol: 0.15 - atr_stop_multiplier: 0.5 - breakeven_at_r: 1.0 - trailing_at_r: 3.0 - risk_per_trade_pct: 0.0025 - max_position_pct: 0.2 - daily_max_loss_pct: 0.0125 - max_stops_per_day: 3 - exit_minutes_before_close: 5 - slippage_bps: 5.0 - initial_capital: 10000 - ticker_cooldown_days: 0 - settlement_days: 1 - max_gap_pct: 0.02 - market_regime_spy_threshold: -0.005 -universe: - source: midlarge - min_price: 10.0 -backtest: - start_date: null - end_date: null - lookback_trading_days: 200 - pre_screen_threshold: 0.01 -cache: - enabled: true - dir: data/cache/intraday -output: - dir: runs/intraday_orb - verbose: false diff --git a/configs/intraday/strategies/sweep/sw_breakeven_at_r_1p5.yaml b/configs/intraday/strategies/sweep/sw_breakeven_at_r_1p5.yaml deleted file mode 100644 index 2e0f99d..0000000 --- a/configs/intraday/strategies/sweep/sw_breakeven_at_r_1p5.yaml +++ /dev/null @@ -1,43 +0,0 @@ -strategy_mode: orb -orb_strategy: - orb_minutes: 5 - sim_bar_minutes: 30 - entry_direction: long_only - order_timeout_minutes: 30 - min_price: 10.0 - min_avg_dollar_volume: 25000000 - min_atr_14: 0.5 - min_rvol: 1.0 - max_candidates: 20 - min_candidates_to_trade: 3 - weight_rvol: 0.6 - weight_gap: 0.25 - weight_dollar_vol: 0.15 - atr_stop_multiplier: 0.5 - breakeven_at_r: 1.5 - trailing_at_r: 3.0 - risk_per_trade_pct: 0.0025 - max_position_pct: 0.2 - daily_max_loss_pct: 0.0125 - max_stops_per_day: 3 - exit_minutes_before_close: 5 - slippage_bps: 5.0 - initial_capital: 10000 - ticker_cooldown_days: 0 - settlement_days: 1 - max_gap_pct: 0.02 - market_regime_spy_threshold: -0.005 -universe: - source: midlarge - min_price: 10.0 -backtest: - start_date: null - end_date: null - lookback_trading_days: 200 - pre_screen_threshold: 0.01 -cache: - enabled: true - dir: data/cache/intraday -output: - dir: runs/intraday_orb - verbose: false diff --git a/configs/intraday/strategies/sweep/sw_breakeven_at_r_2p5.yaml b/configs/intraday/strategies/sweep/sw_breakeven_at_r_2p5.yaml deleted file mode 100644 index edc0435..0000000 --- a/configs/intraday/strategies/sweep/sw_breakeven_at_r_2p5.yaml +++ /dev/null @@ -1,43 +0,0 @@ -strategy_mode: orb -orb_strategy: - orb_minutes: 5 - sim_bar_minutes: 30 - entry_direction: long_only - order_timeout_minutes: 30 - min_price: 10.0 - min_avg_dollar_volume: 25000000 - min_atr_14: 0.5 - min_rvol: 1.0 - max_candidates: 20 - min_candidates_to_trade: 3 - weight_rvol: 0.6 - weight_gap: 0.25 - weight_dollar_vol: 0.15 - atr_stop_multiplier: 0.5 - breakeven_at_r: 2.5 - trailing_at_r: 3.0 - risk_per_trade_pct: 0.0025 - max_position_pct: 0.2 - daily_max_loss_pct: 0.0125 - max_stops_per_day: 3 - exit_minutes_before_close: 5 - slippage_bps: 5.0 - initial_capital: 10000 - ticker_cooldown_days: 0 - settlement_days: 1 - max_gap_pct: 0.02 - market_regime_spy_threshold: -0.005 -universe: - source: midlarge - min_price: 10.0 -backtest: - start_date: null - end_date: null - lookback_trading_days: 200 - pre_screen_threshold: 0.01 -cache: - enabled: true - dir: data/cache/intraday -output: - dir: runs/intraday_orb - verbose: false diff --git a/configs/intraday/strategies/sweep/sw_breakeven_at_r_3p0.yaml b/configs/intraday/strategies/sweep/sw_breakeven_at_r_3p0.yaml deleted file mode 100644 index aad717f..0000000 --- a/configs/intraday/strategies/sweep/sw_breakeven_at_r_3p0.yaml +++ /dev/null @@ -1,43 +0,0 @@ -strategy_mode: orb -orb_strategy: - orb_minutes: 5 - sim_bar_minutes: 30 - entry_direction: long_only - order_timeout_minutes: 30 - min_price: 10.0 - min_avg_dollar_volume: 25000000 - min_atr_14: 0.5 - min_rvol: 1.0 - max_candidates: 20 - min_candidates_to_trade: 3 - weight_rvol: 0.6 - weight_gap: 0.25 - weight_dollar_vol: 0.15 - atr_stop_multiplier: 0.5 - breakeven_at_r: 3.0 - trailing_at_r: 3.0 - risk_per_trade_pct: 0.0025 - max_position_pct: 0.2 - daily_max_loss_pct: 0.0125 - max_stops_per_day: 3 - exit_minutes_before_close: 5 - slippage_bps: 5.0 - initial_capital: 10000 - ticker_cooldown_days: 0 - settlement_days: 1 - max_gap_pct: 0.02 - market_regime_spy_threshold: -0.005 -universe: - source: midlarge - min_price: 10.0 -backtest: - start_date: null - end_date: null - lookback_trading_days: 200 - pre_screen_threshold: 0.01 -cache: - enabled: true - dir: data/cache/intraday -output: - dir: runs/intraday_orb - verbose: false diff --git a/configs/intraday/strategies/sweep/sw_max_candidates_10.yaml b/configs/intraday/strategies/sweep/sw_max_candidates_10.yaml deleted file mode 100644 index 89307b2..0000000 --- a/configs/intraday/strategies/sweep/sw_max_candidates_10.yaml +++ /dev/null @@ -1,43 +0,0 @@ -strategy_mode: orb -orb_strategy: - orb_minutes: 5 - sim_bar_minutes: 30 - entry_direction: long_only - order_timeout_minutes: 30 - min_price: 10.0 - min_avg_dollar_volume: 25000000 - min_atr_14: 0.5 - min_rvol: 1.0 - max_candidates: 10 - min_candidates_to_trade: 3 - weight_rvol: 0.6 - weight_gap: 0.25 - weight_dollar_vol: 0.15 - atr_stop_multiplier: 0.5 - breakeven_at_r: 2.0 - trailing_at_r: 3.0 - risk_per_trade_pct: 0.0025 - max_position_pct: 0.2 - daily_max_loss_pct: 0.0125 - max_stops_per_day: 3 - exit_minutes_before_close: 5 - slippage_bps: 5.0 - initial_capital: 10000 - ticker_cooldown_days: 0 - settlement_days: 1 - max_gap_pct: 0.02 - market_regime_spy_threshold: -0.005 -universe: - source: midlarge - min_price: 10.0 -backtest: - start_date: null - end_date: null - lookback_trading_days: 200 - pre_screen_threshold: 0.01 -cache: - enabled: true - dir: data/cache/intraday -output: - dir: runs/intraday_orb - verbose: false diff --git a/configs/intraday/strategies/sweep/sw_max_candidates_15.yaml b/configs/intraday/strategies/sweep/sw_max_candidates_15.yaml deleted file mode 100644 index 7c5bddc..0000000 --- a/configs/intraday/strategies/sweep/sw_max_candidates_15.yaml +++ /dev/null @@ -1,43 +0,0 @@ -strategy_mode: orb -orb_strategy: - orb_minutes: 5 - sim_bar_minutes: 30 - entry_direction: long_only - order_timeout_minutes: 30 - min_price: 10.0 - min_avg_dollar_volume: 25000000 - min_atr_14: 0.5 - min_rvol: 1.0 - max_candidates: 15 - min_candidates_to_trade: 3 - weight_rvol: 0.6 - weight_gap: 0.25 - weight_dollar_vol: 0.15 - atr_stop_multiplier: 0.5 - breakeven_at_r: 2.0 - trailing_at_r: 3.0 - risk_per_trade_pct: 0.0025 - max_position_pct: 0.2 - daily_max_loss_pct: 0.0125 - max_stops_per_day: 3 - exit_minutes_before_close: 5 - slippage_bps: 5.0 - initial_capital: 10000 - ticker_cooldown_days: 0 - settlement_days: 1 - max_gap_pct: 0.02 - market_regime_spy_threshold: -0.005 -universe: - source: midlarge - min_price: 10.0 -backtest: - start_date: null - end_date: null - lookback_trading_days: 200 - pre_screen_threshold: 0.01 -cache: - enabled: true - dir: data/cache/intraday -output: - dir: runs/intraday_orb - verbose: false diff --git a/configs/intraday/strategies/sweep/sw_max_candidates_30.yaml b/configs/intraday/strategies/sweep/sw_max_candidates_30.yaml deleted file mode 100644 index 76f4dcf..0000000 --- a/configs/intraday/strategies/sweep/sw_max_candidates_30.yaml +++ /dev/null @@ -1,43 +0,0 @@ -strategy_mode: orb -orb_strategy: - orb_minutes: 5 - sim_bar_minutes: 30 - entry_direction: long_only - order_timeout_minutes: 30 - min_price: 10.0 - min_avg_dollar_volume: 25000000 - min_atr_14: 0.5 - min_rvol: 1.0 - max_candidates: 30 - min_candidates_to_trade: 3 - weight_rvol: 0.6 - weight_gap: 0.25 - weight_dollar_vol: 0.15 - atr_stop_multiplier: 0.5 - breakeven_at_r: 2.0 - trailing_at_r: 3.0 - risk_per_trade_pct: 0.0025 - max_position_pct: 0.2 - daily_max_loss_pct: 0.0125 - max_stops_per_day: 3 - exit_minutes_before_close: 5 - slippage_bps: 5.0 - initial_capital: 10000 - ticker_cooldown_days: 0 - settlement_days: 1 - max_gap_pct: 0.02 - market_regime_spy_threshold: -0.005 -universe: - source: midlarge - min_price: 10.0 -backtest: - start_date: null - end_date: null - lookback_trading_days: 200 - pre_screen_threshold: 0.01 -cache: - enabled: true - dir: data/cache/intraday -output: - dir: runs/intraday_orb - verbose: false diff --git a/configs/intraday/strategies/sweep/sw_max_gap_pct_0p01.yaml b/configs/intraday/strategies/sweep/sw_max_gap_pct_0p01.yaml deleted file mode 100644 index f4812da..0000000 --- a/configs/intraday/strategies/sweep/sw_max_gap_pct_0p01.yaml +++ /dev/null @@ -1,43 +0,0 @@ -strategy_mode: orb -orb_strategy: - orb_minutes: 5 - sim_bar_minutes: 30 - entry_direction: long_only - order_timeout_minutes: 30 - min_price: 10.0 - min_avg_dollar_volume: 25000000 - min_atr_14: 0.5 - min_rvol: 1.0 - max_candidates: 20 - min_candidates_to_trade: 3 - weight_rvol: 0.6 - weight_gap: 0.25 - weight_dollar_vol: 0.15 - atr_stop_multiplier: 0.5 - breakeven_at_r: 2.0 - trailing_at_r: 3.0 - risk_per_trade_pct: 0.0025 - max_position_pct: 0.2 - daily_max_loss_pct: 0.0125 - max_stops_per_day: 3 - exit_minutes_before_close: 5 - slippage_bps: 5.0 - initial_capital: 10000 - ticker_cooldown_days: 0 - settlement_days: 1 - max_gap_pct: 0.01 - market_regime_spy_threshold: -0.005 -universe: - source: midlarge - min_price: 10.0 -backtest: - start_date: null - end_date: null - lookback_trading_days: 200 - pre_screen_threshold: 0.01 -cache: - enabled: true - dir: data/cache/intraday -output: - dir: runs/intraday_orb - verbose: false diff --git a/configs/intraday/strategies/sweep/sw_max_gap_pct_0p015.yaml b/configs/intraday/strategies/sweep/sw_max_gap_pct_0p015.yaml deleted file mode 100644 index 391034a..0000000 --- a/configs/intraday/strategies/sweep/sw_max_gap_pct_0p015.yaml +++ /dev/null @@ -1,43 +0,0 @@ -strategy_mode: orb -orb_strategy: - orb_minutes: 5 - sim_bar_minutes: 30 - entry_direction: long_only - order_timeout_minutes: 30 - min_price: 10.0 - min_avg_dollar_volume: 25000000 - min_atr_14: 0.5 - min_rvol: 1.0 - max_candidates: 20 - min_candidates_to_trade: 3 - weight_rvol: 0.6 - weight_gap: 0.25 - weight_dollar_vol: 0.15 - atr_stop_multiplier: 0.5 - breakeven_at_r: 2.0 - trailing_at_r: 3.0 - risk_per_trade_pct: 0.0025 - max_position_pct: 0.2 - daily_max_loss_pct: 0.0125 - max_stops_per_day: 3 - exit_minutes_before_close: 5 - slippage_bps: 5.0 - initial_capital: 10000 - ticker_cooldown_days: 0 - settlement_days: 1 - max_gap_pct: 0.015 - market_regime_spy_threshold: -0.005 -universe: - source: midlarge - min_price: 10.0 -backtest: - start_date: null - end_date: null - lookback_trading_days: 200 - pre_screen_threshold: 0.01 -cache: - enabled: true - dir: data/cache/intraday -output: - dir: runs/intraday_orb - verbose: false diff --git a/configs/intraday/strategies/sweep/sw_max_gap_pct_0p025.yaml b/configs/intraday/strategies/sweep/sw_max_gap_pct_0p025.yaml deleted file mode 100644 index 704f183..0000000 --- a/configs/intraday/strategies/sweep/sw_max_gap_pct_0p025.yaml +++ /dev/null @@ -1,43 +0,0 @@ -strategy_mode: orb -orb_strategy: - orb_minutes: 5 - sim_bar_minutes: 30 - entry_direction: long_only - order_timeout_minutes: 30 - min_price: 10.0 - min_avg_dollar_volume: 25000000 - min_atr_14: 0.5 - min_rvol: 1.0 - max_candidates: 20 - min_candidates_to_trade: 3 - weight_rvol: 0.6 - weight_gap: 0.25 - weight_dollar_vol: 0.15 - atr_stop_multiplier: 0.5 - breakeven_at_r: 2.0 - trailing_at_r: 3.0 - risk_per_trade_pct: 0.0025 - max_position_pct: 0.2 - daily_max_loss_pct: 0.0125 - max_stops_per_day: 3 - exit_minutes_before_close: 5 - slippage_bps: 5.0 - initial_capital: 10000 - ticker_cooldown_days: 0 - settlement_days: 1 - max_gap_pct: 0.025 - market_regime_spy_threshold: -0.005 -universe: - source: midlarge - min_price: 10.0 -backtest: - start_date: null - end_date: null - lookback_trading_days: 200 - pre_screen_threshold: 0.01 -cache: - enabled: true - dir: data/cache/intraday -output: - dir: runs/intraday_orb - verbose: false diff --git a/configs/intraday/strategies/sweep/sw_max_gap_pct_0p03.yaml b/configs/intraday/strategies/sweep/sw_max_gap_pct_0p03.yaml deleted file mode 100644 index da591e3..0000000 --- a/configs/intraday/strategies/sweep/sw_max_gap_pct_0p03.yaml +++ /dev/null @@ -1,43 +0,0 @@ -strategy_mode: orb -orb_strategy: - orb_minutes: 5 - sim_bar_minutes: 30 - entry_direction: long_only - order_timeout_minutes: 30 - min_price: 10.0 - min_avg_dollar_volume: 25000000 - min_atr_14: 0.5 - min_rvol: 1.0 - max_candidates: 20 - min_candidates_to_trade: 3 - weight_rvol: 0.6 - weight_gap: 0.25 - weight_dollar_vol: 0.15 - atr_stop_multiplier: 0.5 - breakeven_at_r: 2.0 - trailing_at_r: 3.0 - risk_per_trade_pct: 0.0025 - max_position_pct: 0.2 - daily_max_loss_pct: 0.0125 - max_stops_per_day: 3 - exit_minutes_before_close: 5 - slippage_bps: 5.0 - initial_capital: 10000 - ticker_cooldown_days: 0 - settlement_days: 1 - max_gap_pct: 0.03 - market_regime_spy_threshold: -0.005 -universe: - source: midlarge - min_price: 10.0 -backtest: - start_date: null - end_date: null - lookback_trading_days: 200 - pre_screen_threshold: 0.01 -cache: - enabled: true - dir: data/cache/intraday -output: - dir: runs/intraday_orb - verbose: false diff --git a/configs/intraday/strategies/sweep/sw_max_gap_pct_0p04.yaml b/configs/intraday/strategies/sweep/sw_max_gap_pct_0p04.yaml deleted file mode 100644 index 1104a1c..0000000 --- a/configs/intraday/strategies/sweep/sw_max_gap_pct_0p04.yaml +++ /dev/null @@ -1,43 +0,0 @@ -strategy_mode: orb -orb_strategy: - orb_minutes: 5 - sim_bar_minutes: 30 - entry_direction: long_only - order_timeout_minutes: 30 - min_price: 10.0 - min_avg_dollar_volume: 25000000 - min_atr_14: 0.5 - min_rvol: 1.0 - max_candidates: 20 - min_candidates_to_trade: 3 - weight_rvol: 0.6 - weight_gap: 0.25 - weight_dollar_vol: 0.15 - atr_stop_multiplier: 0.5 - breakeven_at_r: 2.0 - trailing_at_r: 3.0 - risk_per_trade_pct: 0.0025 - max_position_pct: 0.2 - daily_max_loss_pct: 0.0125 - max_stops_per_day: 3 - exit_minutes_before_close: 5 - slippage_bps: 5.0 - initial_capital: 10000 - ticker_cooldown_days: 0 - settlement_days: 1 - max_gap_pct: 0.04 - market_regime_spy_threshold: -0.005 -universe: - source: midlarge - min_price: 10.0 -backtest: - start_date: null - end_date: null - lookback_trading_days: 200 - pre_screen_threshold: 0.01 -cache: - enabled: true - dir: data/cache/intraday -output: - dir: runs/intraday_orb - verbose: false diff --git a/configs/intraday/strategies/sweep/sw_min_rvol_0p8.yaml b/configs/intraday/strategies/sweep/sw_min_rvol_0p8.yaml deleted file mode 100644 index cb1d356..0000000 --- a/configs/intraday/strategies/sweep/sw_min_rvol_0p8.yaml +++ /dev/null @@ -1,43 +0,0 @@ -strategy_mode: orb -orb_strategy: - orb_minutes: 5 - sim_bar_minutes: 30 - entry_direction: long_only - order_timeout_minutes: 30 - min_price: 10.0 - min_avg_dollar_volume: 25000000 - min_atr_14: 0.5 - min_rvol: 0.8 - max_candidates: 20 - min_candidates_to_trade: 3 - weight_rvol: 0.6 - weight_gap: 0.25 - weight_dollar_vol: 0.15 - atr_stop_multiplier: 0.5 - breakeven_at_r: 2.0 - trailing_at_r: 3.0 - risk_per_trade_pct: 0.0025 - max_position_pct: 0.2 - daily_max_loss_pct: 0.0125 - max_stops_per_day: 3 - exit_minutes_before_close: 5 - slippage_bps: 5.0 - initial_capital: 10000 - ticker_cooldown_days: 0 - settlement_days: 1 - max_gap_pct: 0.02 - market_regime_spy_threshold: -0.005 -universe: - source: midlarge - min_price: 10.0 -backtest: - start_date: null - end_date: null - lookback_trading_days: 200 - pre_screen_threshold: 0.01 -cache: - enabled: true - dir: data/cache/intraday -output: - dir: runs/intraday_orb - verbose: false diff --git a/configs/intraday/strategies/sweep/sw_min_rvol_1p2.yaml b/configs/intraday/strategies/sweep/sw_min_rvol_1p2.yaml deleted file mode 100644 index f53b2ce..0000000 --- a/configs/intraday/strategies/sweep/sw_min_rvol_1p2.yaml +++ /dev/null @@ -1,43 +0,0 @@ -strategy_mode: orb -orb_strategy: - orb_minutes: 5 - sim_bar_minutes: 30 - entry_direction: long_only - order_timeout_minutes: 30 - min_price: 10.0 - min_avg_dollar_volume: 25000000 - min_atr_14: 0.5 - min_rvol: 1.2 - max_candidates: 20 - min_candidates_to_trade: 3 - weight_rvol: 0.6 - weight_gap: 0.25 - weight_dollar_vol: 0.15 - atr_stop_multiplier: 0.5 - breakeven_at_r: 2.0 - trailing_at_r: 3.0 - risk_per_trade_pct: 0.0025 - max_position_pct: 0.2 - daily_max_loss_pct: 0.0125 - max_stops_per_day: 3 - exit_minutes_before_close: 5 - slippage_bps: 5.0 - initial_capital: 10000 - ticker_cooldown_days: 0 - settlement_days: 1 - max_gap_pct: 0.02 - market_regime_spy_threshold: -0.005 -universe: - source: midlarge - min_price: 10.0 -backtest: - start_date: null - end_date: null - lookback_trading_days: 200 - pre_screen_threshold: 0.01 -cache: - enabled: true - dir: data/cache/intraday -output: - dir: runs/intraday_orb - verbose: false diff --git a/configs/intraday/strategies/sweep/sw_min_rvol_1p5.yaml b/configs/intraday/strategies/sweep/sw_min_rvol_1p5.yaml deleted file mode 100644 index d362bd0..0000000 --- a/configs/intraday/strategies/sweep/sw_min_rvol_1p5.yaml +++ /dev/null @@ -1,43 +0,0 @@ -strategy_mode: orb -orb_strategy: - orb_minutes: 5 - sim_bar_minutes: 30 - entry_direction: long_only - order_timeout_minutes: 30 - min_price: 10.0 - min_avg_dollar_volume: 25000000 - min_atr_14: 0.5 - min_rvol: 1.5 - max_candidates: 20 - min_candidates_to_trade: 3 - weight_rvol: 0.6 - weight_gap: 0.25 - weight_dollar_vol: 0.15 - atr_stop_multiplier: 0.5 - breakeven_at_r: 2.0 - trailing_at_r: 3.0 - risk_per_trade_pct: 0.0025 - max_position_pct: 0.2 - daily_max_loss_pct: 0.0125 - max_stops_per_day: 3 - exit_minutes_before_close: 5 - slippage_bps: 5.0 - initial_capital: 10000 - ticker_cooldown_days: 0 - settlement_days: 1 - max_gap_pct: 0.02 - market_regime_spy_threshold: -0.005 -universe: - source: midlarge - min_price: 10.0 -backtest: - start_date: null - end_date: null - lookback_trading_days: 200 - pre_screen_threshold: 0.01 -cache: - enabled: true - dir: data/cache/intraday -output: - dir: runs/intraday_orb - verbose: false diff --git a/configs/intraday/strategies/sweep/sw_orb_minutes_10.yaml b/configs/intraday/strategies/sweep/sw_orb_minutes_10.yaml deleted file mode 100644 index d2f2af9..0000000 --- a/configs/intraday/strategies/sweep/sw_orb_minutes_10.yaml +++ /dev/null @@ -1,43 +0,0 @@ -strategy_mode: orb -orb_strategy: - orb_minutes: 10 - sim_bar_minutes: 30 - entry_direction: long_only - order_timeout_minutes: 30 - min_price: 10.0 - min_avg_dollar_volume: 25000000 - min_atr_14: 0.5 - min_rvol: 1.0 - max_candidates: 20 - min_candidates_to_trade: 3 - weight_rvol: 0.6 - weight_gap: 0.25 - weight_dollar_vol: 0.15 - atr_stop_multiplier: 0.5 - breakeven_at_r: 2.0 - trailing_at_r: 3.0 - risk_per_trade_pct: 0.0025 - max_position_pct: 0.2 - daily_max_loss_pct: 0.0125 - max_stops_per_day: 3 - exit_minutes_before_close: 5 - slippage_bps: 5.0 - initial_capital: 10000 - ticker_cooldown_days: 0 - settlement_days: 1 - max_gap_pct: 0.02 - market_regime_spy_threshold: -0.005 -universe: - source: midlarge - min_price: 10.0 -backtest: - start_date: null - end_date: null - lookback_trading_days: 200 - pre_screen_threshold: 0.01 -cache: - enabled: true - dir: data/cache/intraday -output: - dir: runs/intraday_orb - verbose: false diff --git a/configs/intraday/strategies/sweep/sw_orb_minutes_15.yaml b/configs/intraday/strategies/sweep/sw_orb_minutes_15.yaml deleted file mode 100644 index 50c5f1b..0000000 --- a/configs/intraday/strategies/sweep/sw_orb_minutes_15.yaml +++ /dev/null @@ -1,43 +0,0 @@ -strategy_mode: orb -orb_strategy: - orb_minutes: 15 - sim_bar_minutes: 30 - entry_direction: long_only - order_timeout_minutes: 30 - min_price: 10.0 - min_avg_dollar_volume: 25000000 - min_atr_14: 0.5 - min_rvol: 1.0 - max_candidates: 20 - min_candidates_to_trade: 3 - weight_rvol: 0.6 - weight_gap: 0.25 - weight_dollar_vol: 0.15 - atr_stop_multiplier: 0.5 - breakeven_at_r: 2.0 - trailing_at_r: 3.0 - risk_per_trade_pct: 0.0025 - max_position_pct: 0.2 - daily_max_loss_pct: 0.0125 - max_stops_per_day: 3 - exit_minutes_before_close: 5 - slippage_bps: 5.0 - initial_capital: 10000 - ticker_cooldown_days: 0 - settlement_days: 1 - max_gap_pct: 0.02 - market_regime_spy_threshold: -0.005 -universe: - source: midlarge - min_price: 10.0 -backtest: - start_date: null - end_date: null - lookback_trading_days: 200 - pre_screen_threshold: 0.01 -cache: - enabled: true - dir: data/cache/intraday -output: - dir: runs/intraday_orb - verbose: false diff --git a/configs/intraday/strategies/sweep/sw_orb_minutes_20.yaml b/configs/intraday/strategies/sweep/sw_orb_minutes_20.yaml deleted file mode 100644 index 01550b8..0000000 --- a/configs/intraday/strategies/sweep/sw_orb_minutes_20.yaml +++ /dev/null @@ -1,43 +0,0 @@ -strategy_mode: orb -orb_strategy: - orb_minutes: 20 - sim_bar_minutes: 30 - entry_direction: long_only - order_timeout_minutes: 30 - min_price: 10.0 - min_avg_dollar_volume: 25000000 - min_atr_14: 0.5 - min_rvol: 1.0 - max_candidates: 20 - min_candidates_to_trade: 3 - weight_rvol: 0.6 - weight_gap: 0.25 - weight_dollar_vol: 0.15 - atr_stop_multiplier: 0.5 - breakeven_at_r: 2.0 - trailing_at_r: 3.0 - risk_per_trade_pct: 0.0025 - max_position_pct: 0.2 - daily_max_loss_pct: 0.0125 - max_stops_per_day: 3 - exit_minutes_before_close: 5 - slippage_bps: 5.0 - initial_capital: 10000 - ticker_cooldown_days: 0 - settlement_days: 1 - max_gap_pct: 0.02 - market_regime_spy_threshold: -0.005 -universe: - source: midlarge - min_price: 10.0 -backtest: - start_date: null - end_date: null - lookback_trading_days: 200 - pre_screen_threshold: 0.01 -cache: - enabled: true - dir: data/cache/intraday -output: - dir: runs/intraday_orb - verbose: false diff --git a/configs/intraday/strategies/sweep/sw_order_timeout_minutes_15.yaml b/configs/intraday/strategies/sweep/sw_order_timeout_minutes_15.yaml deleted file mode 100644 index d9844ce..0000000 --- a/configs/intraday/strategies/sweep/sw_order_timeout_minutes_15.yaml +++ /dev/null @@ -1,43 +0,0 @@ -strategy_mode: orb -orb_strategy: - orb_minutes: 5 - sim_bar_minutes: 30 - entry_direction: long_only - order_timeout_minutes: 15 - min_price: 10.0 - min_avg_dollar_volume: 25000000 - min_atr_14: 0.5 - min_rvol: 1.0 - max_candidates: 20 - min_candidates_to_trade: 3 - weight_rvol: 0.6 - weight_gap: 0.25 - weight_dollar_vol: 0.15 - atr_stop_multiplier: 0.5 - breakeven_at_r: 2.0 - trailing_at_r: 3.0 - risk_per_trade_pct: 0.0025 - max_position_pct: 0.2 - daily_max_loss_pct: 0.0125 - max_stops_per_day: 3 - exit_minutes_before_close: 5 - slippage_bps: 5.0 - initial_capital: 10000 - ticker_cooldown_days: 0 - settlement_days: 1 - max_gap_pct: 0.02 - market_regime_spy_threshold: -0.005 -universe: - source: midlarge - min_price: 10.0 -backtest: - start_date: null - end_date: null - lookback_trading_days: 200 - pre_screen_threshold: 0.01 -cache: - enabled: true - dir: data/cache/intraday -output: - dir: runs/intraday_orb - verbose: false diff --git a/configs/intraday/strategies/sweep/sw_order_timeout_minutes_20.yaml b/configs/intraday/strategies/sweep/sw_order_timeout_minutes_20.yaml deleted file mode 100644 index d9b3acf..0000000 --- a/configs/intraday/strategies/sweep/sw_order_timeout_minutes_20.yaml +++ /dev/null @@ -1,43 +0,0 @@ -strategy_mode: orb -orb_strategy: - orb_minutes: 5 - sim_bar_minutes: 30 - entry_direction: long_only - order_timeout_minutes: 20 - min_price: 10.0 - min_avg_dollar_volume: 25000000 - min_atr_14: 0.5 - min_rvol: 1.0 - max_candidates: 20 - min_candidates_to_trade: 3 - weight_rvol: 0.6 - weight_gap: 0.25 - weight_dollar_vol: 0.15 - atr_stop_multiplier: 0.5 - breakeven_at_r: 2.0 - trailing_at_r: 3.0 - risk_per_trade_pct: 0.0025 - max_position_pct: 0.2 - daily_max_loss_pct: 0.0125 - max_stops_per_day: 3 - exit_minutes_before_close: 5 - slippage_bps: 5.0 - initial_capital: 10000 - ticker_cooldown_days: 0 - settlement_days: 1 - max_gap_pct: 0.02 - market_regime_spy_threshold: -0.005 -universe: - source: midlarge - min_price: 10.0 -backtest: - start_date: null - end_date: null - lookback_trading_days: 200 - pre_screen_threshold: 0.01 -cache: - enabled: true - dir: data/cache/intraday -output: - dir: runs/intraday_orb - verbose: false diff --git a/configs/intraday/strategies/sweep/sw_order_timeout_minutes_45.yaml b/configs/intraday/strategies/sweep/sw_order_timeout_minutes_45.yaml deleted file mode 100644 index 9ab817d..0000000 --- a/configs/intraday/strategies/sweep/sw_order_timeout_minutes_45.yaml +++ /dev/null @@ -1,43 +0,0 @@ -strategy_mode: orb -orb_strategy: - orb_minutes: 5 - sim_bar_minutes: 30 - entry_direction: long_only - order_timeout_minutes: 45 - min_price: 10.0 - min_avg_dollar_volume: 25000000 - min_atr_14: 0.5 - min_rvol: 1.0 - max_candidates: 20 - min_candidates_to_trade: 3 - weight_rvol: 0.6 - weight_gap: 0.25 - weight_dollar_vol: 0.15 - atr_stop_multiplier: 0.5 - breakeven_at_r: 2.0 - trailing_at_r: 3.0 - risk_per_trade_pct: 0.0025 - max_position_pct: 0.2 - daily_max_loss_pct: 0.0125 - max_stops_per_day: 3 - exit_minutes_before_close: 5 - slippage_bps: 5.0 - initial_capital: 10000 - ticker_cooldown_days: 0 - settlement_days: 1 - max_gap_pct: 0.02 - market_regime_spy_threshold: -0.005 -universe: - source: midlarge - min_price: 10.0 -backtest: - start_date: null - end_date: null - lookback_trading_days: 200 - pre_screen_threshold: 0.01 -cache: - enabled: true - dir: data/cache/intraday -output: - dir: runs/intraday_orb - verbose: false diff --git a/configs/intraday/strategies/sweep/sw_sim_bar_minutes_10.yaml b/configs/intraday/strategies/sweep/sw_sim_bar_minutes_10.yaml deleted file mode 100644 index 3421829..0000000 --- a/configs/intraday/strategies/sweep/sw_sim_bar_minutes_10.yaml +++ /dev/null @@ -1,43 +0,0 @@ -strategy_mode: orb -orb_strategy: - orb_minutes: 5 - sim_bar_minutes: 10 - entry_direction: long_only - order_timeout_minutes: 30 - min_price: 10.0 - min_avg_dollar_volume: 25000000 - min_atr_14: 0.5 - min_rvol: 1.0 - max_candidates: 20 - min_candidates_to_trade: 3 - weight_rvol: 0.6 - weight_gap: 0.25 - weight_dollar_vol: 0.15 - atr_stop_multiplier: 0.5 - breakeven_at_r: 2.0 - trailing_at_r: 3.0 - risk_per_trade_pct: 0.0025 - max_position_pct: 0.2 - daily_max_loss_pct: 0.0125 - max_stops_per_day: 3 - exit_minutes_before_close: 5 - slippage_bps: 5.0 - initial_capital: 10000 - ticker_cooldown_days: 0 - settlement_days: 1 - max_gap_pct: 0.02 - market_regime_spy_threshold: -0.005 -universe: - source: midlarge - min_price: 10.0 -backtest: - start_date: null - end_date: null - lookback_trading_days: 200 - pre_screen_threshold: 0.01 -cache: - enabled: true - dir: data/cache/intraday -output: - dir: runs/intraday_orb - verbose: false diff --git a/configs/intraday/strategies/sweep/sw_sim_bar_minutes_15.yaml b/configs/intraday/strategies/sweep/sw_sim_bar_minutes_15.yaml deleted file mode 100644 index 9e948ce..0000000 --- a/configs/intraday/strategies/sweep/sw_sim_bar_minutes_15.yaml +++ /dev/null @@ -1,43 +0,0 @@ -strategy_mode: orb -orb_strategy: - orb_minutes: 5 - sim_bar_minutes: 15 - entry_direction: long_only - order_timeout_minutes: 30 - min_price: 10.0 - min_avg_dollar_volume: 25000000 - min_atr_14: 0.5 - min_rvol: 1.0 - max_candidates: 20 - min_candidates_to_trade: 3 - weight_rvol: 0.6 - weight_gap: 0.25 - weight_dollar_vol: 0.15 - atr_stop_multiplier: 0.5 - breakeven_at_r: 2.0 - trailing_at_r: 3.0 - risk_per_trade_pct: 0.0025 - max_position_pct: 0.2 - daily_max_loss_pct: 0.0125 - max_stops_per_day: 3 - exit_minutes_before_close: 5 - slippage_bps: 5.0 - initial_capital: 10000 - ticker_cooldown_days: 0 - settlement_days: 1 - max_gap_pct: 0.02 - market_regime_spy_threshold: -0.005 -universe: - source: midlarge - min_price: 10.0 -backtest: - start_date: null - end_date: null - lookback_trading_days: 200 - pre_screen_threshold: 0.01 -cache: - enabled: true - dir: data/cache/intraday -output: - dir: runs/intraday_orb - verbose: false diff --git a/configs/intraday/strategies/sweep/sw_sim_bar_minutes_20.yaml b/configs/intraday/strategies/sweep/sw_sim_bar_minutes_20.yaml deleted file mode 100644 index 1eaf6b2..0000000 --- a/configs/intraday/strategies/sweep/sw_sim_bar_minutes_20.yaml +++ /dev/null @@ -1,43 +0,0 @@ -strategy_mode: orb -orb_strategy: - orb_minutes: 5 - sim_bar_minutes: 20 - entry_direction: long_only - order_timeout_minutes: 30 - min_price: 10.0 - min_avg_dollar_volume: 25000000 - min_atr_14: 0.5 - min_rvol: 1.0 - max_candidates: 20 - min_candidates_to_trade: 3 - weight_rvol: 0.6 - weight_gap: 0.25 - weight_dollar_vol: 0.15 - atr_stop_multiplier: 0.5 - breakeven_at_r: 2.0 - trailing_at_r: 3.0 - risk_per_trade_pct: 0.0025 - max_position_pct: 0.2 - daily_max_loss_pct: 0.0125 - max_stops_per_day: 3 - exit_minutes_before_close: 5 - slippage_bps: 5.0 - initial_capital: 10000 - ticker_cooldown_days: 0 - settlement_days: 1 - max_gap_pct: 0.02 - market_regime_spy_threshold: -0.005 -universe: - source: midlarge - min_price: 10.0 -backtest: - start_date: null - end_date: null - lookback_trading_days: 200 - pre_screen_threshold: 0.01 -cache: - enabled: true - dir: data/cache/intraday -output: - dir: runs/intraday_orb - verbose: false diff --git a/configs/intraday/strategies/sweep/sw_trailing_at_r_3p5.yaml b/configs/intraday/strategies/sweep/sw_trailing_at_r_3p5.yaml deleted file mode 100644 index 0844dae..0000000 --- a/configs/intraday/strategies/sweep/sw_trailing_at_r_3p5.yaml +++ /dev/null @@ -1,43 +0,0 @@ -strategy_mode: orb -orb_strategy: - orb_minutes: 5 - sim_bar_minutes: 30 - entry_direction: long_only - order_timeout_minutes: 30 - min_price: 10.0 - min_avg_dollar_volume: 25000000 - min_atr_14: 0.5 - min_rvol: 1.0 - max_candidates: 20 - min_candidates_to_trade: 3 - weight_rvol: 0.6 - weight_gap: 0.25 - weight_dollar_vol: 0.15 - atr_stop_multiplier: 0.5 - breakeven_at_r: 2.0 - trailing_at_r: 3.5 - risk_per_trade_pct: 0.0025 - max_position_pct: 0.2 - daily_max_loss_pct: 0.0125 - max_stops_per_day: 3 - exit_minutes_before_close: 5 - slippage_bps: 5.0 - initial_capital: 10000 - ticker_cooldown_days: 0 - settlement_days: 1 - max_gap_pct: 0.02 - market_regime_spy_threshold: -0.005 -universe: - source: midlarge - min_price: 10.0 -backtest: - start_date: null - end_date: null - lookback_trading_days: 200 - pre_screen_threshold: 0.01 -cache: - enabled: true - dir: data/cache/intraday -output: - dir: runs/intraday_orb - verbose: false diff --git a/configs/intraday/strategies/sweep/sw_trailing_at_r_4p0.yaml b/configs/intraday/strategies/sweep/sw_trailing_at_r_4p0.yaml deleted file mode 100644 index 34213c5..0000000 --- a/configs/intraday/strategies/sweep/sw_trailing_at_r_4p0.yaml +++ /dev/null @@ -1,43 +0,0 @@ -strategy_mode: orb -orb_strategy: - orb_minutes: 5 - sim_bar_minutes: 30 - entry_direction: long_only - order_timeout_minutes: 30 - min_price: 10.0 - min_avg_dollar_volume: 25000000 - min_atr_14: 0.5 - min_rvol: 1.0 - max_candidates: 20 - min_candidates_to_trade: 3 - weight_rvol: 0.6 - weight_gap: 0.25 - weight_dollar_vol: 0.15 - atr_stop_multiplier: 0.5 - breakeven_at_r: 2.0 - trailing_at_r: 4.0 - risk_per_trade_pct: 0.0025 - max_position_pct: 0.2 - daily_max_loss_pct: 0.0125 - max_stops_per_day: 3 - exit_minutes_before_close: 5 - slippage_bps: 5.0 - initial_capital: 10000 - ticker_cooldown_days: 0 - settlement_days: 1 - max_gap_pct: 0.02 - market_regime_spy_threshold: -0.005 -universe: - source: midlarge - min_price: 10.0 -backtest: - start_date: null - end_date: null - lookback_trading_days: 200 - pre_screen_threshold: 0.01 -cache: - enabled: true - dir: data/cache/intraday -output: - dir: runs/intraday_orb - verbose: false diff --git a/configs/intraday/strategies/sweep/sw_trailing_at_r_5p0.yaml b/configs/intraday/strategies/sweep/sw_trailing_at_r_5p0.yaml deleted file mode 100644 index d6ba37d..0000000 --- a/configs/intraday/strategies/sweep/sw_trailing_at_r_5p0.yaml +++ /dev/null @@ -1,43 +0,0 @@ -strategy_mode: orb -orb_strategy: - orb_minutes: 5 - sim_bar_minutes: 30 - entry_direction: long_only - order_timeout_minutes: 30 - min_price: 10.0 - min_avg_dollar_volume: 25000000 - min_atr_14: 0.5 - min_rvol: 1.0 - max_candidates: 20 - min_candidates_to_trade: 3 - weight_rvol: 0.6 - weight_gap: 0.25 - weight_dollar_vol: 0.15 - atr_stop_multiplier: 0.5 - breakeven_at_r: 2.0 - trailing_at_r: 5.0 - risk_per_trade_pct: 0.0025 - max_position_pct: 0.2 - daily_max_loss_pct: 0.0125 - max_stops_per_day: 3 - exit_minutes_before_close: 5 - slippage_bps: 5.0 - initial_capital: 10000 - ticker_cooldown_days: 0 - settlement_days: 1 - max_gap_pct: 0.02 - market_regime_spy_threshold: -0.005 -universe: - source: midlarge - min_price: 10.0 -backtest: - start_date: null - end_date: null - lookback_trading_days: 200 - pre_screen_threshold: 0.01 -cache: - enabled: true - dir: data/cache/intraday -output: - dir: runs/intraday_orb - verbose: false diff --git a/configs/intraday/sweep_leader_sector_entropy_vix.yaml b/configs/intraday/sweep_leader_sector_entropy_vix.yaml new file mode 100644 index 0000000..71c057c --- /dev/null +++ b/configs/intraday/sweep_leader_sector_entropy_vix.yaml @@ -0,0 +1,10 @@ +sweep: + max_positions_per_sector: [2, 1] + five_sleeve_force_count: [4, 3] + liquid_largecap_weight: [0.05, 0.08] + vix_size_scale_low: [18.0] + vix_size_scale_high: [30.0] + vix_size_scale_min: [1.0, 0.85] + entropy_size_scale_low: [0.70] + entropy_size_scale_high: [0.86] + entropy_size_scale_min: [1.0, 0.85] diff --git a/configs/intraday/sweep_leader_vix_entropy.yaml b/configs/intraday/sweep_leader_vix_entropy.yaml new file mode 100644 index 0000000..524a439 --- /dev/null +++ b/configs/intraday/sweep_leader_vix_entropy.yaml @@ -0,0 +1,5 @@ +sweep: + use_five_sleeves: [false, true] + max_entropy_20d: [null, 0.92] + max_vix: [null, 30.0, 35.0] + min_gap_pct: [null, 0.01] diff --git a/configs/intraday/sweep_leader_vix_entropy_scalers.yaml b/configs/intraday/sweep_leader_vix_entropy_scalers.yaml new file mode 100644 index 0000000..91027d9 --- /dev/null +++ b/configs/intraday/sweep_leader_vix_entropy_scalers.yaml @@ -0,0 +1,8 @@ +sweep: + use_five_sleeves: [true] + vix_size_scale_low: [18.0] + vix_size_scale_high: [30.0] + vix_size_scale_min: [1.0, 0.85, 0.70] + entropy_size_scale_low: [0.55] + entropy_size_scale_high: [0.90] + entropy_size_scale_min: [1.0, 0.85, 0.70] diff --git a/configs/intraday/sweep_orb.yaml b/configs/intraday/sweep_orb.yaml index 06799b4..2d8d597 100644 --- a/configs/intraday/sweep_orb.yaml +++ b/configs/intraday/sweep_orb.yaml @@ -3,7 +3,7 @@ # 4 × 4 × 2 × 2 × 3 × 3 × 2 = 1,152 combinations # Simulation-only time: ~1,152 × <0.5s ≈ ~10 minutes -base_config: configs/intraday/orb_default.yaml +base_config: configs/intraday/strategies/orb_default.yaml sweep: # Simulation bar interval for breakout/stop management (ORB candle always 5-min) diff --git a/configs/intraday/sweep_orb_rebuild_p1.yaml b/configs/intraday/sweep_orb_rebuild_p1.yaml index b27cff1..00a0238 100644 --- a/configs/intraday/sweep_orb_rebuild_p1.yaml +++ b/configs/intraday/sweep_orb_rebuild_p1.yaml @@ -1,7 +1,7 @@ # ORB Rebuild Phase 1 — Core Structure Sweep # # Post bar_close fix: all stop/peak/R-multiple logic now uses bar close price. -# Start from orb_default.yaml and sweep the 3 most impactful parameters. +# Start from strategies/orb_default.yaml and sweep the 3 most impactful parameters. # # Key question: which bar size + stop distance + direction works best # now that stops use realistic close prices (not theoretical stop level)? @@ -10,17 +10,17 @@ # # Run: # python -m apps.intraday_bt.evaluate \ -# --config configs/intraday/orb_default.yaml \ +# --config configs/intraday/strategies/orb_default.yaml \ # --sweep configs/intraday/sweep_orb_rebuild_p1.yaml \ # --start 2022-01-01 --split-date 2025-01-01 # # Or quick test (no IS/OOS split): # python -m apps.intraday_bt.run \ -# --config configs/intraday/orb_default.yaml \ +# --config configs/intraday/strategies/orb_default.yaml \ # --sweep configs/intraday/sweep_orb_rebuild_p1.yaml \ # --start 2022-01-01 -base_config: configs/intraday/orb_default.yaml +base_config: configs/intraday/strategies/orb_default.yaml sweep: # Bar size: how often the trader checks price diff --git a/configs/intraday/sweep_orb_rebuild_p2.yaml b/configs/intraday/sweep_orb_rebuild_p2.yaml index 57ca6af..b30fab0 100644 --- a/configs/intraday/sweep_orb_rebuild_p2.yaml +++ b/configs/intraday/sweep_orb_rebuild_p2.yaml @@ -9,7 +9,7 @@ # # Run: # python -m apps.intraday_bt.evaluate \ -# --config configs/intraday/orb_default.yaml \ +# --config configs/intraday/strategies/orb_default.yaml \ # --sweep configs/intraday/sweep_orb_rebuild_p2.yaml \ # --start 2022-01-01 --split-date 2025-01-01 diff --git a/configs/symbols_midlarge_snapshot_exact.yaml b/configs/symbols_midlarge_snapshot_exact.yaml index 8a0db41..c9686c0 100644 --- a/configs/symbols_midlarge_snapshot_exact.yaml +++ b/configs/symbols_midlarge_snapshot_exact.yaml @@ -134,6 +134,7 @@ symbols: - BK - BKH - BKR +- BLD - BLDR - BLK - BLSH diff --git a/libs/intraday/cache.py b/libs/intraday/cache.py index da3a9c3..be44d5b 100644 --- a/libs/intraday/cache.py +++ b/libs/intraday/cache.py @@ -20,6 +20,10 @@ _CACHE_METADATA = { b"intraday_cache_source": b"api_v1_alpaca_intraday", b"intraday_cache_interval": b"5min", } +_INTRADAY_KIND_KEY = b"intraday_cache_kind" +_INTRADAY_KIND_POSITIVE = b"bars" +_INTRADAY_KIND_NEGATIVE = b"negative" +_INTRADAY_NEGATIVE_REASON_KEY = b"intraday_negative_reason" _SCHEMA = pa.schema([ pa.field("timestamp", pa.string()), @@ -31,7 +35,60 @@ _SCHEMA = pa.schema([ pa.field("vwap", pa.float64()), ]) _REQUIRED_COLUMNS = {"timestamp", "open", "high", "low", "close", "volume"} -_SCHEMA_WITH_METADATA = _SCHEMA.with_metadata(_CACHE_METADATA) +_SCHEMA_WITH_METADATA = _SCHEMA.with_metadata({ + **_CACHE_METADATA, + _INTRADAY_KIND_KEY: _INTRADAY_KIND_POSITIVE, +}) +_MIN_VALID_INTRADAY_ROWS = 10 + +_DAILY_CACHE_STATIC_METADATA = { + b"daily_cache_version": b"1", + b"daily_cache_source": b"api_v1_price_data", + b"daily_cache_interval": b"1d", +} +_DAILY_COVERAGE_START_KEY = b"daily_cache_coverage_start" +_DAILY_COVERAGE_END_KEY = b"daily_cache_coverage_end" + +_DAILY_SCHEMA = pa.schema([ + pa.field("date", pa.string()), + pa.field("open", pa.float64()), + pa.field("high", pa.float64()), + pa.field("low", pa.float64()), + pa.field("close", pa.float64()), + pa.field("volume", pa.float64()), +]) +_DAILY_REQUIRED_COLUMNS = {"date", "open", "high", "low", "close", "volume"} + + +def _daily_rows_match_coverage_sanity( + rows: list[dict[str, Any]], + coverage_start: str | None, + coverage_end: str | None, +) -> bool: + """Reject egregiously partial daily caches that claim much wider coverage. + + Small mismatches are expected around weekends / holidays because the cache + stores calendar coverage while the rows only contain trading sessions. Large + gaps, however, usually indicate a truncated bulk Oracle response that should + not be reused for feature warmup. + """ + if not rows or coverage_start is None or coverage_end is None: + return True + try: + from datetime import date as _date + + first_row = _date.fromisoformat(str(rows[0]["date"])[:10]) + last_row = _date.fromisoformat(str(rows[-1]["date"])[:10]) + coverage_start_dt = _date.fromisoformat(coverage_start) + coverage_end_dt = _date.fromisoformat(coverage_end) + except Exception: + return True + + if (first_row - coverage_start_dt).days > 10: + return False + if (coverage_end_dt - last_row).days > 10: + return False + return True class IntradayCache: """Disk-based cache for 5-minute intraday bars using Parquet. @@ -46,25 +103,35 @@ class IntradayCache: return self._root / ticker.upper() / f"{date}.parquet" @staticmethod - def _metadata_valid(path: Path) -> bool: + def _metadata_status(path: Path) -> str | None: try: meta = pq.read_metadata(str(path)) - if meta.num_rows <= 0: - return False arrow_schema = meta.schema.to_arrow_schema() if not _REQUIRED_COLUMNS.issubset(set(arrow_schema.names)): - return False + return None schema_meta = arrow_schema.metadata or {} - return all(schema_meta.get(k) == v for k, v in _CACHE_METADATA.items()) + if not all(schema_meta.get(k) == v for k, v in _CACHE_METADATA.items()): + return None + kind = schema_meta.get(_INTRADAY_KIND_KEY, _INTRADAY_KIND_POSITIVE) + if kind == _INTRADAY_KIND_NEGATIVE: + return "negative" if meta.num_rows == 0 else None + if kind not in (_INTRADAY_KIND_POSITIVE, None): + return None + return "positive" if meta.num_rows >= _MIN_VALID_INTRADAY_ROWS else None except Exception: - return False + return None + + @staticmethod + def is_complete_enough(bars: list[dict[str, Any]]) -> bool: + """Heuristic: keep only intraday responses with enough bars to be useful.""" + return len(bars) >= _MIN_VALID_INTRADAY_ROWS def has(self, ticker: str, date: str) -> bool: """Return True if cached bars exist for ticker on date.""" p = self._path(ticker, date) if not p.exists(): return False - if not self._metadata_valid(p): + if not self._metadata_status(p): p.unlink(missing_ok=True) return False return True @@ -74,13 +141,14 @@ class IntradayCache: p = self._path(ticker, date) if not p.exists(): return None - if not self._metadata_valid(p): + status = self._metadata_status(p) + if not status: p.unlink(missing_ok=True) return None + if status == "negative": + return [] try: table = pq.read_table(str(p)) - if table.num_rows == 0: - return None return table.to_pylist() except Exception: return None @@ -119,6 +187,25 @@ class IntradayCache: tmp.unlink(missing_ok=True) raise + def put_negative(self, ticker: str, date: str, reason: str = "empty_or_sparse") -> None: + """Cache a stable empty/sparse response to avoid repeating futile API calls.""" + p = self._path(ticker, date) + p.parent.mkdir(parents=True, exist_ok=True) + schema = _SCHEMA.with_metadata({ + **_CACHE_METADATA, + _INTRADAY_KIND_KEY: _INTRADAY_KIND_NEGATIVE, + _INTRADAY_NEGATIVE_REASON_KEY: reason.encode(), + }) + table = pa.Table.from_pylist([], schema=schema) + tmp = p.with_suffix(".tmp") + try: + pq.write_table(table, str(tmp), compression="snappy") + os.replace(str(tmp), str(p)) + except Exception: + if tmp.exists(): + tmp.unlink(missing_ok=True) + raise + def evict( self, ticker: str | None = None, @@ -150,6 +237,34 @@ class IntradayCache: return removed + def available_dates( + self, + ticker: str, + start_date: str | None = None, + end_date: str | None = None, + ) -> list[str]: + """List cache dates for one ticker within an optional date range. + + Invalid cache files are discarded as they are encountered. Negative cache + entries are included so callers can preserve the original trading-day + shape while deciding how to handle empty responses. + """ + ticker_dir = self._root / ticker.upper() + if not ticker_dir.exists(): + return [] + dates: list[str] = [] + for path in ticker_dir.glob("*.parquet"): + date_str = path.stem + if start_date and date_str < start_date: + continue + if end_date and date_str > end_date: + continue + if not self._metadata_status(path): + path.unlink(missing_ok=True) + continue + dates.append(date_str) + return sorted(dates) + def stats(self) -> dict[str, Any]: """Return cache statistics.""" if not self._root.exists(): @@ -177,3 +292,206 @@ class IntradayCache: "date_min": min(dates) if dates else None, "date_max": max(dates) if dates else None, } + + +class DailyBarCache: + """Disk-based cache for daily OHLCV bars using one Parquet file per ticker.""" + + def __init__(self, cache_dir: str = "data/cache/daily") -> None: + self._root = Path(cache_dir) + + def _path(self, ticker: str) -> Path: + return self._root / f"{ticker.upper()}.parquet" + + @staticmethod + def _read_schema_metadata(path: Path) -> dict[bytes, bytes] | None: + try: + meta = pq.read_metadata(str(path)) + if meta.num_rows < 0: + return None + return meta.schema.to_arrow_schema().metadata or {} + except Exception: + return None + + @classmethod + def _metadata_valid(cls, path: Path) -> bool: + try: + meta = pq.read_metadata(str(path)) + if meta.num_rows < 0: + return False + arrow_schema = meta.schema.to_arrow_schema() + if not _DAILY_REQUIRED_COLUMNS.issubset(set(arrow_schema.names)): + return False + schema_meta = arrow_schema.metadata or {} + if not all(schema_meta.get(k) == v for k, v in _DAILY_CACHE_STATIC_METADATA.items()): + return False + return ( + schema_meta.get(_DAILY_COVERAGE_START_KEY) is not None + and schema_meta.get(_DAILY_COVERAGE_END_KEY) is not None + ) + except Exception: + return False + + @classmethod + def _coverage_from_metadata(cls, path: Path) -> tuple[str | None, str | None]: + schema_meta = cls._read_schema_metadata(path) or {} + start = schema_meta.get(_DAILY_COVERAGE_START_KEY) + end = schema_meta.get(_DAILY_COVERAGE_END_KEY) + return ( + start.decode() if start else None, + end.decode() if end else None, + ) + + @staticmethod + def _normalize_rows(rows: list[dict[str, Any]]) -> list[dict[str, Any]]: + normalized: list[dict[str, Any]] = [] + for b in rows: + normalized.append( + { + "date": str(b.get("date", ""))[:10], + "open": float(b.get("open", 0.0) or 0.0), + "high": float(b.get("high", 0.0) or 0.0), + "low": float(b.get("low", 0.0) or 0.0), + "close": float(b.get("close", 0.0) or 0.0), + "volume": float(b.get("volume", 0.0) or 0.0), + } + ) + return normalized + + def get( + self, + ticker: str, + start_date: str, + end_date: str, + ) -> list[dict[str, Any]] | None: + """Read cached daily bars for an exact date range if covered.""" + p = self._path(ticker) + if not p.exists(): + return None + if not self._metadata_valid(p): + p.unlink(missing_ok=True) + return None + + coverage_start, coverage_end = self._coverage_from_metadata(p) + if coverage_start is None or coverage_end is None: + p.unlink(missing_ok=True) + return None + if start_date < coverage_start or end_date > coverage_end: + return None + + try: + table = pq.read_table( + str(p), + filters=[ + ("date", ">=", start_date), + ("date", "<=", end_date), + ], + ) + rows = self._normalize_rows(table.to_pylist()) + if not _daily_rows_match_coverage_sanity(rows, coverage_start, coverage_end): + p.unlink(missing_ok=True) + return None + return rows + except Exception: + return None + + def get_with_tail( + self, + ticker: str, + start_date: str, + end_date: str, + ) -> tuple[list[dict[str, Any]] | None, str | None]: + """Like get(), but supports partial hits when end_date > coverage_end. + + Returns: + (bars, None) — full hit (end_date <= coverage_end) + (bars, tail_start) — partial hit; bars cover start_date..coverage_end, + tail_start is the first calendar day to fetch from Oracle + (None, None) — true miss (no file, or start_date not covered) + """ + from datetime import date as _date, timedelta as _td + + p = self._path(ticker) + if not p.exists(): + return None, None + if not self._metadata_valid(p): + p.unlink(missing_ok=True) + return None, None + + coverage_start, coverage_end = self._coverage_from_metadata(p) + if coverage_start is None or coverage_end is None: + p.unlink(missing_ok=True) + return None, None + if start_date < coverage_start: + return None, None # true miss: need earlier data than cached + + effective_end = coverage_end if end_date > coverage_end else end_date + try: + table = pq.read_table( + str(p), + filters=[("date", ">=", start_date), ("date", "<=", effective_end)], + ) + rows = self._normalize_rows(table.to_pylist()) + except Exception: + return None, None + if not _daily_rows_match_coverage_sanity(rows, coverage_start, coverage_end): + p.unlink(missing_ok=True) + return None, None + + if end_date <= coverage_end: + return rows, None # full hit + + # Partial hit — caller must fetch from tail_start to end_date + tail_start = (_date.fromisoformat(coverage_end) + _td(days=1)).isoformat() + return rows, tail_start + + def put( + self, + ticker: str, + start_date: str, + end_date: str, + bars: list[dict[str, Any]], + ) -> None: + """Merge a fetched daily-bar range into the ticker cache.""" + p = self._path(ticker) + p.parent.mkdir(parents=True, exist_ok=True) + + normalized_new = self._normalize_rows(bars) + merged_by_date: dict[str, dict[str, Any]] = {} + coverage_start = start_date + coverage_end = end_date + + if p.exists() and self._metadata_valid(p): + try: + existing = pq.read_table(str(p)).to_pylist() + for row in self._normalize_rows(existing): + merged_by_date[row["date"]] = row + existing_start, existing_end = self._coverage_from_metadata(p) + if existing_start: + coverage_start = min(coverage_start, existing_start) + if existing_end: + coverage_end = max(coverage_end, existing_end) + except Exception: + p.unlink(missing_ok=True) + merged_by_date = {} + elif p.exists(): + p.unlink(missing_ok=True) + + for row in normalized_new: + merged_by_date[row["date"]] = row + + rows = [merged_by_date[d] for d in sorted(merged_by_date)] + metadata = dict(_DAILY_CACHE_STATIC_METADATA) + metadata[_DAILY_COVERAGE_START_KEY] = coverage_start.encode() + metadata[_DAILY_COVERAGE_END_KEY] = coverage_end.encode() + schema = _DAILY_SCHEMA.with_metadata(metadata) + table = pa.Table.from_pylist(rows, schema=schema) + + tmp = p.with_suffix(".tmp") + try: + pq.write_table(table, str(tmp), compression="snappy") + os.replace(str(tmp), str(p)) + except Exception: + if tmp.exists(): + tmp.unlink(missing_ok=True) + raise diff --git a/libs/intraday/domain.py b/libs/intraday/domain.py index e8f1012..12fa92d 100644 --- a/libs/intraday/domain.py +++ b/libs/intraday/domain.py @@ -16,9 +16,32 @@ from pydantic import BaseModel, Field class StrategyParams(BaseModel): """Core strategy parameters controlling when to enter/exit.""" + compound_returns: bool = False + """When True, position sizing scales with current equity (compounding). + When False, position sizing uses min(initial_capital, current_equity) (simple returns, + capped at actual equity to avoid trading money that doesn't exist after drawdowns). + Momentum research defaults to simple returns to avoid late-period overweighting. + Ignored when daily_budget_reset is True.""" + + daily_budget_reset: bool = False + """Research-only mode: every day resets sizing_capital to initial_capital, + ignoring prior-day PnL entirely (no compounding, no drawdown cap). + Useful for isolating strategy alpha from capital-path effects. + When True, takes precedence over compound_returns.""" + entry_minutes_after_open: int = 30 """Minutes after 9:30 AM ET to evaluate morning gainers and enter trades.""" + confirmation_minutes_after_entry: int = 0 + """Optional extra confirmation delay after the primary entry time. + Example: 5 means evaluate leaders at +10min but only enter at +15min if + the confirmation rule is still satisfied.""" + + min_confirmation_return_pct: float | None = None + """Minimum return between the primary entry bar close and the confirmation + bar close. Example: 0.0 = no fade allowed; 0.002 = require +0.2% follow-through. + Ignored when confirmation_minutes_after_entry <= 0.""" + exit_minutes_before_close: int = 30 """Minutes before 4:00 PM ET to force-close all positions.""" @@ -29,6 +52,33 @@ class StrategyParams(BaseModel): """Trailing stop: if set, ratchet stop up as price rises. e.g. -0.03 = trail 3% below peak. When both stop_loss_pct and trailing_stop_pct are set, trailing_stop_pct is used.""" + atr_stop_multiplier: float | None = None + """Catastrophic stop distance in ATR(14) units from the actual entry price. + Example: 0.5 means stop at entry - 0.5 x ATR. When set, this overrides + stop_loss_pct as the initial stop anchor.""" + + opening_range_stop_multiplier: float | None = None + """Catastrophic stop distance in opening-range-width units from the actual + entry price. Example: 1.0 means stop at entry - 1.0 x opening range width. + Used as an alternative to ATR when the opening range itself is the better + volatility proxy.""" + + trailing_activation_gain_pct: float | None = None + """Optional delayed trailing activation threshold. + When set, trailing_stop_pct does not turn on until peak return from entry + reaches this gain threshold. Before activation, only the catastrophic / + fixed stop is active.""" + + overextended_trailing_gain_pct: float | None = None + """If set together with overextended_trailing_stop_pct, trades whose morning gain + at entry is at least this large use the alternate trailing stop instead of the + baseline trailing_stop_pct.""" + + overextended_trailing_stop_pct: float | None = None + """Alternate trailing stop for overextended morning leaders. + Example: base trail -0.075 with overextended trail -0.065 tightens risk only for + names already up sharply by entry time.""" + min_morning_gain_pct: float = 0.01 """Minimum gain from open to entry time for a stock to qualify (e.g. 0.01 = 1%).""" @@ -40,6 +90,11 @@ class StrategyParams(BaseModel): """Minimum cumulative volume by entry time (shares). Filters illiquid stocks. E.g. 50000 = must have traded 50K shares in first 30 minutes.""" + min_entry_dollar_volume: float | None = None + """Minimum cumulative dollar volume by entry time. + Helps reject low-priced names that pass a raw share-volume filter but still + trade too little notional size for reliable execution.""" + ticker_cooldown_days: int = 0 """Blackout period after trading a ticker (calendar days). E.g. 5 = same ticker can't be selected again within 5 days. 0 = disabled.""" @@ -47,6 +102,25 @@ class StrategyParams(BaseModel): top_n: int = 3 """Number of top gainers to buy each day (equal-weight allocation).""" + min_positions_to_trade: int = 1 + """Minimum number of qualified picks required to trade the day at all. + Event-driven leader strategies often degrade when only one or two names pass. + Use this to explicitly allow no-trade days instead of forcing sparse baskets.""" + + max_positions_per_sector: int | None = None + """Optional basket diversification cap. + When set, at most this many positions may be opened from the same sector + in the day's momentum basket. Unknown sectors are left uncapped.""" + + full_size_positions_threshold: int | None = None + """When set, sparse days scale down total deployed capital instead of always + using the full daily book. Example: threshold=4 means 1-3 position days are + sized below 100% of the daily budget, while 4+ position days use full size.""" + + sparse_day_size_floor: float = 1.0 + """Minimum day-level size scaler when full_size_positions_threshold is active. + 0.5 means even a 1-position day still deploys 50% of the normal daily budget.""" + initial_capital: float = 10_000.0 """Starting capital in USD.""" @@ -57,10 +131,400 @@ class StrategyParams(BaseModel): """Skip trading if SPY's morning return (open to entry time) is below this threshold. E.g. -0.005 = skip if SPY is down more than -0.5% by entry time. None = disabled.""" + min_gap_pct: float | None = None + """Minimum opening gap versus prior close. None = disabled.""" + + max_gap_pct: float | None = None + """Maximum opening gap versus prior close. None = disabled.""" + + min_volume_ratio_14d: float | None = None + """Minimum cumulative volume by entry time divided by 14-day average daily volume. + Helps reject low-attention names that are up but not truly in play.""" + + min_ret_5d: float | None = None + """Minimum prior 5-day return. None = disabled.""" + + min_entropy_20d: float | None = None + """Minimum allowed entropy(20d). None = disabled.""" + + max_entropy_20d: float | None = None + """Maximum allowed entropy(20d). None = disabled.""" + + max_vix: float | None = None + """Skip the whole day if VIX closes above this threshold. None = disabled.""" + + vix_size_scale_low: float | None = None + """VIX level where size scaling starts. None = disabled.""" + + vix_size_scale_high: float | None = None + """VIX level where the day-size scaler reaches vix_size_scale_min.""" + + vix_size_scale_min: float = 1.0 + """Minimum position-size scaler once VIX reaches vix_size_scale_high.""" + + entropy_size_scale_low: float | None = None + """Entropy level where trade-size scaling starts. None = disabled.""" + + entropy_size_scale_high: float | None = None + """Entropy level where the trade-size scaler reaches entropy_size_scale_min.""" + + entropy_size_scale_min: float = 1.0 + """Minimum per-trade size scaler once entropy reaches entropy_size_scale_high.""" + + use_five_sleeves: bool = False + """When True, build the daily basket from five sleeve rankings instead of one raw top-N list. + Sleeves: core gain, gap, volume surprise, low entropy, and prior trend.""" + + five_sleeve_force_count: int = 5 + """How many sleeve-specific picks to force before the weighted blend fill starts. + 5 preserves the original behavior of taking one pick from each sleeve. + Lower values let the weighted blend dominate sooner and reduce quota-style overfitting.""" + + five_sleeve_core_weight: float = 0.40 + """Blend weight for the core momentum sleeve (morning gain).""" + + five_sleeve_gap_weight: float = 0.20 + """Blend weight for the opening-gap sleeve.""" + + five_sleeve_volume_weight: float = 0.25 + """Blend weight for the volume-surprise sleeve.""" + + five_sleeve_entropy_weight: float = 0.05 + """Blend weight for the low-entropy sleeve.""" + + five_sleeve_trend_weight: float = 0.10 + """Blend weight for the prior-trend sleeve.""" + + use_slow_ignite_sleeve: bool = False + """When True, allow one extra sleeve for slower-starting but still high-attention leaders. + This is intended for names that are not yet above the primary morning-gain floor by the + standard entry time, but are showing strong confirmation, liquidity, and prior trend.""" + + slow_ignite_weight: float = 0.0 + """Blend weight for the slow-ignite sleeve. Ignored when use_slow_ignite_sleeve is False.""" + + slow_ignite_min_gain_pct: float | None = None + """Lower gain floor for slow-ignite candidates. Typically below min_morning_gain_pct.""" + + slow_ignite_max_gain_pct: float | None = None + """Upper gain cap for slow-ignite candidates. Keeps the sleeve focused on slower starters.""" + + slow_ignite_min_entry_dollar_volume: float | None = None + """Minimum entry-time dollar volume required for slow-ignite candidates.""" + + slow_ignite_min_volume_ratio_14d: float | None = None + """Minimum entry-time volume ratio required for slow-ignite candidates.""" + + slow_ignite_min_ret_5d: float | None = None + """Minimum prior 5-day return required for slow-ignite candidates.""" + + slow_ignite_max_entropy_20d: float | None = None + """Maximum entropy allowed for slow-ignite candidates.""" + + use_liquid_largecap_sleeve: bool = False + """When True, allow a dedicated sleeve for liquid large-cap leaders. + This is designed for slower large-cap continuation names that may not rank highly + in the small/midcap-oriented momentum sleeves despite strong notional liquidity.""" + + liquid_largecap_weight: float = 0.0 + """Blend weight for the liquid large-cap sleeve. Ignored when disabled.""" + + liquid_largecap_min_gain_pct: float | None = None + """Lower gain floor for liquid large-cap candidates.""" + + liquid_largecap_max_gain_pct: float | None = None + """Upper gain cap for liquid large-cap candidates.""" + + liquid_largecap_min_confirmation_return_pct: float | None = None + """Minimum follow-through required for liquid large-cap candidates.""" + + liquid_largecap_min_entry_dollar_volume: float | None = None + """Minimum entry-time dollar volume required for liquid large-cap candidates.""" + + liquid_largecap_min_avg_dollar_vol_30d: float | None = None + """Minimum prior 30-day average daily dollar volume required for liquid large-cap candidates.""" + + liquid_largecap_max_entropy_20d: float | None = None + """Maximum entropy allowed for liquid large-cap candidates.""" + + use_gap_reclaim_sleeve: bool = False + """Enable a high-gap reclaim sleeve for early flushes that stabilize below the open.""" + + gap_reclaim_weight: float = 0.0 + """Blend weight for the high-gap reclaim sleeve.""" + + gap_reclaim_min_gap_pct: float | None = None + """Minimum opening gap required for a high-gap reclaim candidate.""" + + gap_reclaim_min_gain_pct: float | None = None + """Minimum allowed entry-time gain for the reclaim sleeve (can be negative).""" + + gap_reclaim_max_gain_pct: float | None = None + """Maximum allowed entry-time gain for the reclaim sleeve.""" + + gap_reclaim_min_confirmation_return_pct: float | None = None + """Minimum confirmation-bar return required for the reclaim sleeve.""" + + gap_reclaim_min_entry_dollar_volume: float | None = None + """Minimum entry-time cumulative dollar volume required for the reclaim sleeve.""" + + gap_reclaim_min_recovery_from_opening_low_pct: float | None = None + """Minimum rebound from the opening-range low required for the reclaim sleeve.""" + + fallback_liquid_largecap_slots: int = 0 + """Number of liquid large-cap fallback seats available after regular selection. + + This does not change the main basket on normal days. It only allows a small + number of highly liquid large-cap names to fill otherwise sparse baskets. + """ + + fallback_liquid_largecap_trigger_below: int = 0 + """Enable the liquid large-cap fallback only when regular picks are below this count. + + Example: 2 means "only consider fallback seats when the main selection + found fewer than 2 names." + """ + + candidate_source_mode: str = "daily_gap" + """How to build the candidate universe before the final basket is selected. + + - daily_gap: existing point-in-time opening-gap shortlist from daily bars + - intraday_first: build a broader daily seed list, fetch intraday for that + seed, then rank the final candidate shortlist using same-day entry-time + information only (still lookahead-free). + """ + + candidate_seed_threshold: float = 0.0 + """Opening-gap threshold used only for the broader seed list when + candidate_source_mode='intraday_first'. Lower values widen the intraday + fetch universe without using same-day highs/closes.""" + + candidate_seed_max_per_day: int = 150 + """Maximum seed shortlist size per day when candidate_source_mode is + 'intraday_first'. This bounds intraday fetch cost before the final + entry-time rerank.""" + + candidate_seed_liquid_overlay_slots: int = 0 + """Optional number of extra prior-day liquid large-cap seeds to add per day. + + This is designed for names like TSLA/AVGO/NVDA that may not clear the main + opening-gap seed threshold but still deserve intraday-first evaluation + because of exceptional prior-day liquidity. + """ + + candidate_seed_liquid_min_gap_pct: float | None = None + """Minimum opening gap for the liquid overlay seed list. + + Uses today's open vs prior close only, so it remains lookahead-free. + """ + + candidate_seed_liquid_max_gap_pct: float | None = None + """Maximum opening gap for the liquid overlay seed list.""" + + candidate_seed_liquid_min_avg_dollar_vol_30d: float | None = None + """Minimum prior 30-day average dollar volume required for overlay seeds.""" + + candidate_seed_liquid_min_ret_5d: float | None = None + """Minimum prior 5-day return required for overlay seeds.""" + + candidate_seed_liquid_max_entropy_20d: float | None = None + """Maximum prior 20-day entropy allowed for overlay seeds.""" + + candidate_seed_leader_overlay_slots: int = 0 + """Optional number of extra liquid trend-leader seeds to add per day. + + Unlike the liquid gap overlay, this path is meant to catch strong same-day + continuation names that did not gap enough to enter the main seed list but + already have exceptional prior trend, volatility, and liquidity. + """ + + candidate_seed_leader_min_gap_pct: float | None = None + """Minimum opening gap allowed for the trend-leader overlay.""" + + candidate_seed_leader_max_gap_pct: float | None = None + """Maximum opening gap allowed for the trend-leader overlay.""" + + candidate_seed_leader_min_avg_dollar_vol_30d: float | None = None + """Minimum prior 30-day average dollar volume for trend-leader overlay seeds.""" + + candidate_seed_leader_min_ret_5d: float | None = None + """Minimum prior 5-day return required for trend-leader overlay seeds.""" + + candidate_seed_leader_min_atr_pct: float | None = None + """Minimum ATR/open ratio required for trend-leader overlay seeds.""" + + candidate_seed_leader_max_entropy_20d: float | None = None + """Maximum prior 20-day entropy allowed for trend-leader overlay seeds.""" + + candidate_final_max_per_day: int = 30 + """Final candidate shortlist size per day after intraday-first reranking.""" + + candidate_intraday_rank_mode: str = "sleeves" + """Final intraday-first shortlist ranking mode. + + - sleeves: reuse the same five-sleeve / top-N basket logic used by the + execution engine, but at a wider candidate cutoff. + - weighted: rank entry-time candidates with a weighted quality score using + only same-day information known by the entry/confirmation bar. + """ + + candidate_intraday_weight_gain: float = 0.0 + """Weighted-mode contribution from same-day morning gain at entry.""" + + candidate_intraday_weight_confirmation: float = 0.0 + """Weighted-mode contribution from confirmation-bar follow-through.""" + + candidate_intraday_weight_volume_ratio: float = 0.0 + """Weighted-mode contribution from entry-time volume ratio vs 14-day ADV.""" + + candidate_intraday_weight_entry_dollar_volume: float = 0.0 + """Weighted-mode contribution from entry-time cumulative dollar volume.""" + + candidate_intraday_weight_avg_dollar_vol_30d: float = 0.0 + """Weighted-mode contribution from prior 30-day average dollar volume.""" + + candidate_intraday_weight_gap: float = 0.0 + """Weighted-mode contribution from opening gap vs prior close.""" + + candidate_intraday_weight_ret_5d: float = 0.0 + """Weighted-mode contribution from prior 5-day return.""" + + candidate_intraday_weight_low_entropy: float = 0.0 + """Weighted-mode contribution from lower 20-day entropy.""" + + candidate_intraday_weight_event_score: float = 0.0 + """Weighted-mode contribution from same-day filing/event score.""" + + candidate_intraday_weight_attention_wiki: float = 0.0 + """Weighted-mode contribution from same-day wiki attention.""" + + candidate_intraday_weight_attention_news: float = 0.0 + """Weighted-mode contribution from same-day news/article attention.""" + + recent_live_scan_days: int = 0 + """When > 0, very recent backtests (window length <= this many trading days and ending + within this many calendar days of the latest completed backtest date) bypass the static + universe + daily pre-screen path and instead use a broad screener universe with + intraday-first candidate generation. Designed for same-day / recent sanity checks where + names like current Yahoo top gainers may not exist in the static research universe.""" + + recent_live_scan_min_price: float = 2.0 + """Minimum price for the recent live screener universe.""" + + recent_live_scan_avg_volume_min: int = 200_000 + """Minimum 3-month average volume for the recent live screener universe.""" + + recent_live_scan_market_cap_min: float = 100_000_000.0 + """Minimum market cap for the recent live screener universe.""" + + recent_live_scan_max_candidates_per_day: int = 150 + """Maximum daily shortlist size produced by the intraday-first recent scan.""" + + recent_live_scan_top_n: int | None = None + """Optional top-N override used only for recent live-scan windows.""" + + recent_live_scan_min_morning_gain_pct: float | None = None + """Optional morning-gain floor override used only for recent live-scan windows.""" + + recent_live_scan_max_morning_gain_pct: float | None = None + """Optional morning-gain cap override used only for recent live-scan windows.""" + + recent_live_scan_min_confirmation_return_pct: float | None = None + """Optional confirmation-return override used only for recent live-scan windows.""" + + recent_live_scan_min_entry_dollar_volume: float | None = None + """Optional entry dollar-volume override used only for recent live-scan windows.""" + + recent_live_scan_max_gap_pct: float | None = None + """Optional opening-gap cap override used only for recent live-scan windows.""" + + recent_live_scan_max_entropy_20d: float | None = None + """Optional entropy cap override used only for recent live-scan windows.""" + + recent_live_scan_use_slow_ignite_sleeve: bool | None = None + """Optional recent-window override for enabling the slow-ignite sleeve.""" + + recent_live_scan_slow_ignite_weight: float | None = None + """Optional recent-window override for slow-ignite sleeve weight.""" + + recent_live_scan_slow_ignite_min_gain_pct: float | None = None + """Optional recent-window override for slow-ignite minimum gain.""" + + recent_live_scan_slow_ignite_max_gain_pct: float | None = None + """Optional recent-window override for slow-ignite maximum gain.""" + + recent_live_scan_slow_ignite_min_entry_dollar_volume: float | None = None + """Optional recent-window override for slow-ignite minimum entry dollar volume.""" + + recent_live_scan_slow_ignite_max_entropy_20d: float | None = None + """Optional recent-window override for slow-ignite maximum entropy.""" + + recent_live_scan_use_liquid_largecap_sleeve: bool | None = None + """Optional recent-window override for enabling the liquid large-cap sleeve.""" + + recent_live_scan_liquid_largecap_weight: float | None = None + """Optional recent-window override for liquid large-cap sleeve weight.""" + + recent_live_scan_liquid_largecap_min_gain_pct: float | None = None + """Optional recent-window override for liquid large-cap minimum gain.""" + + recent_live_scan_liquid_largecap_max_gain_pct: float | None = None + """Optional recent-window override for liquid large-cap maximum gain.""" + + recent_live_scan_liquid_largecap_min_confirmation_return_pct: float | None = None + """Optional recent-window override for liquid large-cap confirmation return.""" + + recent_live_scan_liquid_largecap_min_entry_dollar_volume: float | None = None + """Optional recent-window override for liquid large-cap minimum entry dollar volume.""" + + recent_live_scan_liquid_largecap_min_avg_dollar_vol_30d: float | None = None + """Optional recent-window override for liquid large-cap minimum average dollar volume.""" + + recent_live_scan_liquid_largecap_max_entropy_20d: float | None = None + """Optional recent-window override for liquid large-cap maximum entropy.""" + + candidate_require_event_flag: bool = False + """When True, the daily candidate shortlist only includes names with a same-day + filing-based catalyst flag. This is stricter than using attention proxies alone + and is intended for event-driven research variants.""" + + candidate_min_event_score: float | None = None + """Minimum same-day filing event score required at the candidate stage. + Ignored when no same-day event features are present.""" + + candidate_weight_event_score: float = 0.0 + """Ranking weight for same-day filing event score in momentum candidate selection.""" + + candidate_weight_attention_wiki: float = 0.0 + """Ranking weight for same-day Wikipedia attention in momentum candidate selection.""" + + candidate_weight_attention_news: float = 0.0 + """Ranking weight for same-day news/article attention in momentum candidate selection.""" + + candidate_min_attention_wiki_spike_10d: float | None = None + """Minimum same-day wiki spike required at the candidate stage.""" + + candidate_min_attention_article_count_3d: int | None = None + """Minimum same-day 3-day article count required at the candidate stage.""" + + candidate_min_attention_us_article_count_3d: int | None = None + """Minimum same-day 3-day US article count required at the candidate stage.""" + + candidate_min_attention_resolver_confidence: float | None = None + """Minimum same-day entity resolver confidence required at the candidate stage.""" + class ORBStrategyParams(BaseModel): """Parameters for the Opening Range Breakout (ORB) strategy.""" + engine_family: str = "quality_breakout" + """Candidate engine family: classic_breakout | quality_breakout | compression_breakout | gainers_leader | leader_followthrough | stocks_in_play_dual_regime. + quality_breakout is the backward-compatible default because it supports the + existing body-ratio / momentum extensions while leaving them disabled at 0 weight.""" + + live_readiness: str = "live_ready" + """Research classification for the signal family: live_ready | research_only.""" + # ORB window orb_minutes: int = 5 """Duration of the opening range in minutes. 5 = first 5-min candle (9:30–9:35 ET).""" @@ -87,6 +551,15 @@ class ORBStrategyParams(BaseModel): min_atr_14: float = 0.50 """Minimum ATR(14) in dollars ($0.50). Ensures sufficient intraday range to trade.""" + min_atr_pct: float | None = None + """Minimum ATR(14) as a fraction of prev_close (e.g. 0.04 = 4%). Filters out + low-volatility names where the ORB setup lacks explosive follow-through potential. + None disables (legacy behavior).""" + + max_atr_pct: float | None = None + """Maximum ATR(14) as a fraction of prev_close (e.g. 0.10 = 10%). Caps extreme-volatility + names that have large individual losses (MSTR, crypto stocks, micro-caps). None disables.""" + # RVOL-based candidate selection min_rvol: float = 1.0 """Minimum approximate RVOL at open. RVOL = first_5min_vol / (avg_daily_vol / 78). @@ -96,6 +569,11 @@ class ORBStrategyParams(BaseModel): max_candidates: int = 20 """Maximum candidates to pass to intraday fetch and simulate per day.""" + max_candidates_per_sector: int | None = None + """Optional diversification cap after ranking. + Example: 2 = at most two names from the same sector in the day's final ORB list. + None disables the cap.""" + min_candidates_to_trade: int = 3 """Skip the day entirely if fewer than this many candidates pass all filters.""" @@ -109,13 +587,109 @@ class ORBStrategyParams(BaseModel): weight_dollar_vol: float = 0.15 """First-5-min dollar volume weight.""" + weight_premarket_dollar_vol: float = 0.0 + """Premarket dollar-volume weight. Serves as a same-day catalyst / attention proxy + when dedicated news data is unavailable.""" + weight_body_ratio: float = 0.0 """ORB candle directional conviction: (close-open)/(high-low) for longs, reversed for shorts. High value = first candle decisively moved in the breakout direction.""" + weight_close_location: float = 0.0 + """First ORB candle close location within its range: (close-low)/(high-low). + leader_followthrough typically rewards closes that finish near the candle high, + even if the opening bar is slightly red (red-to-green reclaim behavior).""" + weight_momentum: float = 0.0 """5-day prior price momentum weight. Positive = stock already trending in breakout direction.""" + weight_entropy: float = 0.0 + """Entropy(20d) ranking weight. compression_breakout typically rewards lower entropy.""" + + weight_atr_ratio: float = 0.0 + """Recent ATR(10) / ATR(60) ranking weight.""" + + weight_gap_zscore: float = 0.0 + """Opening-gap z-score ranking weight relative to prior 20 sessions.""" + + weight_event_catalyst: float = 0.0 + """Same-day catalyst weight from actual filing events. + Used by stocks_in_play_dual_regime to reward names with a concrete event + instead of relying only on attention proxies.""" + + weight_attention_wiki: float = 0.0 + """Wikipedia attention weight for actual stocks-in-play ranking.""" + + weight_attention_news: float = 0.0 + """News/article attention weight for actual stocks-in-play ranking.""" + + min_body_ratio: float = 0.0 + """Minimum ORB candle body/range conviction. 0 disables the filter.""" + + min_close_location: float = 0.0 + """Minimum ORB candle close-location filter for leader_followthrough. + Example: 0.50 means the candle must close in the upper half of its range.""" + + max_close_location_short: float = 1.0 + """Maximum ORB candle close-location filter for short setups. + Example: 0.40 means a failed-ORB short must close in the lower 40% of the + opening range. 1.0 disables the filter.""" + + allow_doji_breakout: bool = False + """When True, doji first bars are still allowed to trade via ORB high/low breakout. + Useful for gainers-style leader chasing or leader followthrough setups where the + opening 5-min candle can pause before a strong trend day. Default False to preserve + classic ORB behavior.""" + + allow_red_to_green_breakout: bool = False + """When True, gainers_leader / leader_followthrough may trade long ORB-high breakouts + even if the first ORB candle closes red. This is meant for leader-followthrough days + where a strong name briefly dips after the open before reclaiming the ORB high.""" + + require_event_flag: bool = False + """Require a same-day filing-based catalyst flag for candidate inclusion. + Designed for stocks_in_play_dual_regime, where attention alone is not enough.""" + + allowed_event_types: list[str] = Field(default_factory=list) + """Optional whitelist of filing event types that count as catalysts. + Empty list means any same-day filing event is accepted.""" + + allow_failed_orb_short: bool = False + """Allow failed gap-up ORB shorts in dual-regime mode.""" + + require_vwap_confirmation: bool = False + """Require the ORB candle close to confirm against VWAP: + longs must close above VWAP, failed-ORB shorts must close below VWAP.""" + + attention_min_wiki_spike_10d: float | None = None + """Minimum wiki spike to accept a stocks-in-play candidate.""" + + attention_min_wiki_zscore_20d: float | None = None + """Minimum wiki z-score to accept a stocks-in-play candidate.""" + + attention_min_article_count_3d: int | None = None + """Minimum article count to accept a stocks-in-play candidate.""" + + attention_min_us_article_count_3d: int | None = None + """Minimum US article count to accept a stocks-in-play candidate.""" + + attention_min_resolver_confidence: float | None = None + """Minimum entity-resolution confidence for attention data usage.""" + + min_sector_relative_strength: float | None = None + """Minimum ORB return minus sector ORB return for continuation longs. + Positive values force the name to outperform its own sector in the opening range.""" + + min_entropy: float | None = None + """Minimum allowed entropy_20d. None disables the lower bound.""" + + max_entropy: float | None = None + """Maximum allowed entropy_20d. None disables the upper bound.""" + + compression_ratio_max: float | None = None + """Maximum allowed recent range compression ratio (10d / 60d). Lower = tighter setup. + None disables the filter.""" + # ATR-based stop management atr_stop_multiplier: float = 0.10 """Initial stop distance = ATR(14) × this multiplier. Paper uses 10% (0.10).""" @@ -166,6 +740,29 @@ class ORBStrategyParams(BaseModel): Stocks that gap >10% at open are over-extended and prone to reversal — they have a low ORB breakout continuation rate. None = no cap (allow any gap). E.g. 0.10 = 10% cap.""" + min_abs_gap_pct: float | None = None + """Minimum absolute opening gap required to treat the session as catalyst-like. + Useful proxy when explicit same-day news labels are unavailable. None = disabled.""" + + min_premarket_dollar_vol: float | None = None + """Minimum premarket dollar volume (04:00-09:30 ET) required for candidate inclusion. + Acts as a same-day attention/liquidity filter. None = disabled.""" + + small_gap_attention_override_premarket_dollar_vol: float | None = None + """For gainers_leader / leader_followthrough: allow candidates below min_abs_gap_pct when premarket + dollar volume is exceptionally high. This is meant for mega-cap / leader followthrough + days that do not gap much but clearly dominate premarket attention.""" + + small_gap_attention_override_rvol: float | None = None + """Optional RVOL floor paired with small_gap_attention_override_premarket_dollar_vol. + When set, small-gap candidates must also show at least this opening-range RVOL to + bypass min_abs_gap_pct.""" + + max_small_gap_attention_candidates: int | None = None + """Optional daily cap on candidates admitted via the small-gap attention override. + Useful to keep exceptional followthrough names from diluting the core gap-driven + gainers basket. None disables the cap.""" + # Market regime market_regime_spy_threshold: float | None = None """Skip trading if regime ticker's opening gap is below this threshold. None = disabled. @@ -186,7 +783,14 @@ class ORBStrategyParams(BaseModel): compound_returns: bool = True """When True (default), position sizing scales with current equity (compounding). When False, position sizing always uses initial_capital (simple/단리 mode). - Simple mode prevents late-period bias where larger equity dominates the return metric.""" + Simple mode prevents late-period bias where larger equity dominates the return metric. + Ignored when daily_budget_reset is True.""" + + daily_budget_reset: bool = False + """Research-only mode: every day resets sizing_capital to initial_capital, + ignoring prior-day PnL entirely (no compounding, no drawdown cap). + Useful for isolating strategy alpha from capital-path effects. + When True, takes precedence over compound_returns.""" trailing_stop_atr_multiplier: float = 0.0 """ATR-based trailing stop distance from peak price. 0 = disabled (use swing-low mode). @@ -194,6 +798,304 @@ class ORBStrategyParams(BaseModel): E.g. 1.5 = trail 1.5×ATR(14) below the running peak. Activates at trailing_at_r. Swing-low mode (0.0) ties trailing sensitivity to sim_bar_minutes — ATR mode removes that dependency.""" + trailing_tighten_at_r: float | None = None + """Two-stage trailing: when R reaches this level, switch to trailing_stop_atr_multiplier_tight. + None = single-stage trailing (no tightening). Requires trailing_stop_atr_multiplier > 0. + E.g. trailing_at_r=1.5 (wide trail) + trailing_tighten_at_r=3.0 (tight trail).""" + + trailing_stop_atr_multiplier_tight: float = 0.0 + """ATR multiplier for the tighter second-stage trailing stop (used when trailing_tighten_at_r is hit). + 0.0 = fall back to trailing_stop_atr_multiplier (effectively disables tightening).""" + + max_simultaneous_entries: int | None = None + """Maximum trades that can enter on the same bar timestamp. None = unlimited. + Prevents the 9:35 burst where all candidates break out simultaneously, overwhelming + the kill switch and creating uncontrolled correlated risk. Top-ranked candidates are taken first.""" + + partial_exit_at_r: float | None = None + """Lock in partial profits when R-multiple reaches this level. None = disabled. + E.g. 1.0 = sell partial_exit_pct of the position at 1R, let remainder run.""" + + partial_exit_pct: float = 0.50 + """Fraction of position to exit at partial_exit_at_r. 0.50 = sell half the position.""" + + market_regime_spy_trend_days: int | None = None + """Multi-day SPY trend filter: look back this many trading days for cumulative return. + None = disabled. Requires market_regime_spy_trend_threshold to also be set. + E.g. 5 = look at SPY's 5-day cumulative return ending yesterday.""" + + market_regime_spy_trend_threshold: float | None = None + """Skip day if SPY's cumulative return over market_regime_spy_trend_days is below this. + E.g. -0.03 = skip if SPY down >3% over the past N days. + Protects against sustained bear-market weeks (single-day gap filter misses these).""" + + rolling_loss_days: int | None = None + """Self-referential rolling loss filter: look back this many trading days for strategy P&L. + None = disabled. Requires rolling_loss_threshold to also be set. + E.g. 5 = compute strategy's cumulative return over past 5 trading days.""" + + rolling_loss_threshold: float | None = None + """Skip day if strategy's own rolling return (past rolling_loss_days) is below this. + E.g. -0.05 = pause trading if strategy lost >5% of initial capital in past 5 days. + Protects against cascading losses in regimes where the strategy stops working.""" + + # VIX regime filter and position size scaling + max_vix: float | None = None + """Skip the whole day if VIX closes above this threshold. None = disabled. + E.g. 30.0 = skip days when VIX > 30 (high fear regime).""" + + vix_size_scale_low: float | None = None + """VIX level where position size scaling starts (scaler = 1.0 below this). None = disabled.""" + + vix_size_scale_high: float | None = None + """VIX level where the position-size scaler reaches vix_size_scale_min.""" + + vix_size_scale_min: float = 1.0 + """Minimum position-size scaler once VIX reaches vix_size_scale_high. + E.g. 0.50 = halve position sizes when VIX is at or above vix_size_scale_high.""" + + # V20: soft regime/breadth scalers (all default to V19 binary-skip behavior) + regime_size_scale_low: float | None = None + """QQQ gap at which regime scaler bottoms out. None = V19 binary skip.""" + regime_size_scale_high: float | None = None + """QQQ gap at which regime scaler = 1.0. Binary skip fires when gap < market_regime_spy_threshold.""" + regime_size_scale_min: float = 1.0 + """Minimum regime scaler. 1.0 = V19 behavior.""" + regime_skip_below: float | None = None + """Hard skip floor below regime_size_scale_low. None = no extra skip.""" + + breadth_size_scale_low: float | None = None + """Breadth ratio at which breadth scaler bottoms out. None = V19 binary skip.""" + breadth_size_scale_high: float | None = None + """Breadth ratio at which breadth scaler = 1.0.""" + breadth_size_scale_min: float = 1.0 + """Minimum breadth scaler. 1.0 = V19 behavior.""" + breadth_skip_below: float | None = None + """Hard skip floor on breadth. None = no extra skip.""" + + # V20: regime-adaptive stops + soft_day_scaler_threshold: float = 1.0 + """combined_scaler (regime*breadth) below this triggers soft-day stop adjustments.""" + atr_stop_multiplier_weak: float | None = None + """Replaces atr_stop_multiplier on soft days. None = no change.""" + breakeven_at_r_weak: float | None = None + """Replaces breakeven_at_r on soft days. None = no change.""" + + # V20: soft-day selection bar + soft_day_max_trades: int | None = None + """Max trades per soft day (top-N by rank). None = no cap.""" + soft_day_min_score_pct: float | None = None + """Min composite score rank_pct on soft days. None = no filter.""" + + # Breakout volume confirmation + min_breakout_rel_vol: float | None = None + """Minimum relative volume on the breakout bar vs average post-ORB bar volume. + Filters low-conviction breakouts where price touches the level on thin volume. + None = disabled (any volume accepted). E.g. 1.5 = breakout bar must have 1.5× avg bar volume.""" + + # Time-decay trailing stop tightening + time_decay_start_minutes: int | None = None + """Minutes after market open (9:30 ET) to start tightening the trailing stop. + None = disabled. E.g. 180 = start tightening at 12:30 PM ET.""" + + time_decay_factor: float = 0.5 + """By close, the trailing ATR multiplier shrinks to this fraction of its base value. + E.g. 0.5 = trail width halves linearly from time_decay_start_minutes to close.""" + + # Running VWAP trailing exit + vwap_exit_mode: str = "none" + """VWAP-based exit mode: + - 'none': disabled (default) + - 'exit': exit when bar close crosses below running VWAP (longs) or above (shorts) + - 'floor': use VWAP - buffer as trailing stop floor (can't trail above VWAP for longs) + Running VWAP is computed from cumulative (typical_price × volume) / cumulative(volume) + starting from market open.""" + + vwap_exit_buffer_atr: float = 0.0 + """Buffer below VWAP (in ATR units) for 'floor' mode. + E.g. 0.3 = trailing stop can't go below VWAP - 0.3×ATR. + For 'exit' mode: exit only when close < VWAP - buffer×ATR (allows noise).""" + + vwap_exit_after_r: float = 0.0 + """Only activate VWAP exit after reaching this R-multiple. + 0.0 = active from entry. 1.0 = only after trade reaches 1R. + Prevents premature VWAP exits on initial pullbacks after breakout.""" + + # Score-based position sizing + score_sizing_multiplier: float | None = None + """Scale risk_per_trade_pct by candidate rank. Top candidate gets this multiplier, + bottom gets 1.0x (linear interpolation). None = disabled (equal sizing). + E.g. 2.0 = top pick risks 2× base, bottom pick risks 1×. Requires score rank + to be passed from simulate_orb_day.""" + + # Confirmation bar requirement + require_confirmation_bar: bool = False + """After breakout, require the NEXT bar to close above entry price (long) or + below (short) to confirm. If the confirmation bar fails, skip the trade. + Filters false breakouts where price barely touches the level and reverses.""" + + # Gap fill protection + exit_on_gap_fill: bool = False + """Exit immediately if price drops below prev_close (long) or rises above (short). + A gap fill means the original catalyst is being rejected by the market. + Uses bar close for the check (not intra-bar low).""" + + # Max hold time + max_hold_minutes: int | None = None + """Maximum minutes to hold a position. None = hold until exit_minutes_before_close. + E.g. 120 = exit 2 hours after entry regardless of profit/loss. + Useful for capturing morning momentum without afternoon reversal risk.""" + + # Bar close confirmation entry + entry_on_bar_close: bool = False + """Require breakout bar's CLOSE to be above breakout level (long) or below (short), + not just the bar's HIGH/LOW. Enter at the bar's close price. + Filters wick-only breakouts where price barely touches the ORB level and reverses. + The trader waits for the 5-min bar to complete, then enters at the close price. + Same-bar stop is skipped (trader was not in position during the bar).""" + + # ── Pyramiding (add to winners) ── + pyramid_at_r: float | None = None + """Add to winning position when R-multiple reaches this level. None = disabled. + E.g. 1.0 = add pyramid_add_pct of original position when trade reaches 1R. + Stop is moved to at least breakeven on the blended cost after adding.""" + + pyramid_add_pct: float = 0.50 + """Fraction of original position size to add at each pyramid level. + 0.50 = add 50% of original shares (100 shares → add 50 → 150 total).""" + + pyramid_max_adds: int = 1 + """Maximum number of pyramid additions per trade. 1 = single add-on. + Each subsequent add triggers at pyramid_at_r + n * pyramid_at_r (staggered).""" + + # ── Re-entry after stop-out ── + reentry_after_stop: bool = False + """Allow re-entry on a ticker that was stopped out earlier in the same day. + The ticker must re-break the ORB level with volume >= reentry_min_volume_ratio + times the original breakout volume. Simulates the 'shakeout then real move' pattern.""" + + reentry_min_volume_ratio: float = 1.5 + """Minimum volume ratio (vs original breakout bar) required for re-entry. + 1.5 = re-breakout bar must have 50% more volume than original breakout bar.""" + + reentry_max_per_ticker: int = 1 + """Maximum re-entries allowed per ticker per day.""" + + # ── Portfolio deployment cap ── + max_total_deployment_pct: float | None = None + """Maximum total capital deployed across all concurrent positions as fraction of equity. + None = no limit (original behavior). E.g. 0.80 = never deploy more than 80% of equity. + Prevents over-concentration when settlement_days=0 allows unlimited deployment.""" + + # ── Drawdown governor ── + drawdown_governor_threshold: float | None = None + """Enable drawdown governor when equity drops this fraction below peak. + None = disabled. E.g. 0.05 = start reducing sizing when equity is 5% below peak. + Linearly scales sizing from 1.0 at threshold to drawdown_governor_min_scale at 2× threshold.""" + + drawdown_governor_min_scale: float = 0.30 + """Minimum sizing scale at maximum drawdown governor activation. + 0.30 = reduce position sizes to 30% of normal at 2× drawdown_governor_threshold.""" + + # ── Streak-based sizing ── + streak_sizing_win_bonus: float | None = None + """Bonus sizing multiplier per consecutive win in recent trade history. + None = disabled. E.g. 0.15 = add 15% sizing per consecutive win. + 3 consecutive wins → 1.0 + 3*0.15 = 1.45x sizing. + Computed at start of each day from previous days' trade outcomes.""" + + streak_sizing_loss_penalty: float | None = None + """Reduce sizing per consecutive loss. None = no penalty (only reward wins). + E.g. 0.10 = subtract 10% per consecutive loss. + 2 consecutive losses → 1.0 - 2*0.10 = 0.80x sizing.""" + + streak_sizing_max: float = 2.0 + """Cap on streak-based sizing multiplier. Prevents excessive leverage on long streaks.""" + + streak_sizing_min: float = 0.50 + """Floor on streak-based sizing multiplier. Prevents sizing from going too low.""" + + # ── Rolling performance sizing ── + rolling_wr_sizing_window: int | None = None + """Window of recent trades for rolling win-rate sizing bonus. + None = disabled. E.g. 15 = compute WR over last 15 trades. + Applied AFTER streak sizing (multiplicative).""" + + rolling_wr_sizing_threshold: float = 0.55 + """WR above this threshold triggers the bonus multiplier. + E.g. 0.55 = if rolling WR > 55%, apply rolling_wr_sizing_bonus.""" + + rolling_wr_sizing_bonus: float = 0.30 + """Bonus multiplier when rolling WR exceeds threshold. + E.g. 0.30 = size 1.30x when rolling WR is above threshold.""" + + rolling_wr_sizing_penalty_threshold: float | None = None + """WR below this triggers a sizing reduction. None = no penalty. + E.g. 0.40 = if rolling WR < 40%, reduce sizing by rolling_wr_sizing_penalty.""" + + rolling_wr_sizing_penalty: float = 0.20 + """Penalty reduction when rolling WR is below penalty threshold. + E.g. 0.20 = size 0.80x when rolling WR is below penalty threshold.""" + + # ── Gap-adaptive trailing ── + gap_trail_wide_threshold: float | None = None + """Gap% above which trailing uses wider ATR multiplier. None = disabled. + E.g. 0.05 = gaps > 5% get wider trailing (strong catalyst = longer trend). + Uses gap_trail_wide_atr_multiplier instead of trailing_stop_atr_multiplier.""" + + gap_trail_wide_atr_multiplier: float = 1.2 + """Trailing ATR multiplier for large-gap stocks (gap > gap_trail_wide_threshold). + Wider trail lets strong catalyst stocks run further before stopping out.""" + + gap_trail_tight_atr_multiplier: float | None = None + """Optional tighter trailing for small-gap stocks (gap <= gap_trail_wide_threshold). + None = use default trailing_stop_atr_multiplier. E.g. 0.5 = tight trail for small gaps.""" + + # ── Pullback continuation entry ── + pullback_entry: bool = False + """Enable pullback continuation entry mode. Instead of entering immediately on + ORB breakout, wait for a pullback after breakout and enter on continuation. + Filters false breakouts and gives better entry prices with tighter stops.""" + + pullback_max_bars: int = 6 + """Maximum bars to wait for pullback-continuation pattern after initial breakout. + If no valid pullback+continuation within this window, skip the trade.""" + + pullback_min_retracement_pct: float = 0.30 + """Minimum retracement of the breakout move to qualify as a pullback. + 0.30 = price must pull back at least 30% of (post-breakout peak - breakout level).""" + + pullback_stop_at_low: bool = True + """Set stop at the pullback low instead of ATR-based stop. + Gives naturally tighter stops based on actual price structure.""" + + # ── Profit target ── + profit_target_r: float | None = None + """Exit at market when R-multiple reaches this level. None = disabled. + E.g. 3.0 = exit when trade reaches 3R profit. Locks in gains before + trailing stop gives back profits.""" + + # ── ORB range quality filter ── + orb_range_atr_min: float | None = None + """Minimum ORB candle range as fraction of ATR(14). None = disabled. + Filters stocks with too-narrow opening ranges (likely noise). + E.g. 0.3 = ORB range must be at least 30% of ATR.""" + + orb_range_atr_max: float | None = None + """Maximum ORB candle range as fraction of ATR(14). None = disabled. + Filters stocks whose opening range already consumed the day's move. + E.g. 1.5 = ORB range must be at most 150% of ATR.""" + + # ── SPY intraday guard ── + spy_intraday_guard_pct: float | None = None + """Tighten trailing stop when SPY drops this % from its open intraday. None = disabled. + E.g. -0.005 = if SPY drops 0.5% from open, tighten trail. + Applied during Phase 2 exit management.""" + + spy_intraday_guard_tighten: float = 0.5 + """Factor to multiply trailing ATR multiplier when SPY guard triggers. + 0.5 = trail becomes 50% tighter (e.g., 0.8 ATR → 0.4 ATR).""" + class UniverseParams(BaseModel): """Parameters controlling which stocks to scan.""" @@ -230,8 +1132,8 @@ class BacktestParams(BaseModel): """Number of trading days to backtest when start_date is None.""" pre_screen_threshold: float = 0.015 - """Phase 1 pre-screening threshold: (high - open) / open >= this to be a candidate. - Conservative value to avoid missing morning runners.""" + """Phase 1 pre-screening threshold: (today_open - prev_close) / prev_close >= this. + Uses only open-time information plus prior-day data (no lookahead).""" class CacheParams(BaseModel): @@ -317,6 +1219,9 @@ class IntradayTrade(BaseModel): slippage_cost: float = 0.0 """Total slippage cost in USD (entry + exit).""" + trade_sleeve: str | None = None + """Selection sleeve label for momentum strategies. None for ORB trades.""" + # ORB-specific fields (optional, None for momentum trades) orb_direction: str | None = None """ORB trade direction: 'long' or 'short'. None for momentum trades.""" @@ -330,6 +1235,27 @@ class IntradayTrade(BaseModel): r_multiple_at_exit: float | None = None """Final R-multiple at exit: (exit_price - entry_price) / initial_risk. None for momentum.""" + stop_level_at_exit: str | None = None + """Stop level active when the trade exited: 'initial', 'breakeven', or 'trailing'. + None for momentum trades. Helps diagnose whether winners were protected before exiting.""" + + partial_exit_r: float | None = None + """R-multiple at which the partial exit fired, if partial_exit_at_r was set. None otherwise.""" + + pyramid_adds: int = 0 + """Number of pyramid additions executed during this trade. 0 = no pyramiding.""" + + pyramid_pnl: float = 0.0 + """Dollar P&L contributed by pyramid add-on shares. 0.0 = no pyramid or no pyramid PnL.""" + + is_reentry: bool = False + """True if this trade is a re-entry after a prior stop-out on the same ticker same day.""" + + total_capital_deployed: float = 0.0 + """Total capital deployed including pyramid additions. + Computed as original_shares * entry_price + sum(pyramid_shares * pyramid_entry). + Used for accurate portfolio deployment tracking.""" + class DayResult(BaseModel): """Simulation result for one trading day.""" @@ -349,6 +1275,22 @@ class DayResult(BaseModel): skipped_insufficient_cash: int = 0 """Candidates skipped because available settled cash was exhausted.""" + # Diagnostic fields (populated by simulate_orb_day / run_orb_simulation_with_state) + skip_reason: str | None = None + """Why this day had no trades. One of: 'vix_gate', 'market_regime', 'breadth', + 'rolling_loss', 'spy_trend', 'no_candidates', 'below_min_candidates'. None = traded.""" + candidate_filter_stats: dict | None = None + """Per-filter drop counts from compute_orb_candidates: {gap, rvol, atr, dolvol, dir, + no_bars, late, price}. Present on all days (traded and skipped-after-candidates).""" + + # V20 diagnostics + regime_scaler: float | None = None + """Regime size scaler for this day (1.0 = full size or V19 path).""" + breadth_scaler: float | None = None + """Breadth size scaler for this day (1.0 = full size or V19 path).""" + is_soft_day: bool = False + """True when combined_scaler < soft_day_scaler_threshold (soft-regime day).""" + # ── Aggregate Metrics ────────────────────────────────────────────────────── @@ -387,6 +1329,16 @@ class IntradayMetrics(BaseModel): sharpe_ratio: float | None = None sortino_ratio: float | None = None calmar_ratio: float | None = None + loss_day_rate: float | None = None + """Fraction of trading days with negative PnL.""" + avg_loss_day_pct: float | None = None + """Average return across negative-PnL days only.""" + tail_loss_20_pct: float | None = None + """Average return of the worst 20% of losing days.""" + worst_day_return_pct: float | None = None + """Worst single-day return.""" + loss_containment_score: float | None = None + """0-100 score favoring strategies that lose small amounts on bad days.""" # Intraday-specific avg_hold_minutes: float | None = None diff --git a/libs/intraday/features.py b/libs/intraday/features.py index a0fd876..722965e 100644 --- a/libs/intraday/features.py +++ b/libs/intraday/features.py @@ -8,6 +8,8 @@ Used by orb_simulator.py and orb_pre_screen_candidates in screener.py. from __future__ import annotations from collections import deque +import math +import statistics def compute_atr_from_dicts(daily_bars: list[dict], period: int = 14) -> float | None: @@ -76,6 +78,118 @@ def compute_gap_pct(prev_close: float, today_open: float) -> float | None: return (today_open - prev_close) / prev_close +def compute_entropy_approx(daily_bars: list[dict], lookback: int = 20) -> float | None: + """Normalized Shannon entropy of recent close-to-close returns. + + Uses fixed return buckets and returns a value in [0, 1], where lower values + indicate more ordered / repetitive recent behaviour and higher values + indicate a broader return distribution. + """ + if len(daily_bars) < max(lookback, 2): + return None + sorted_bars = sorted(daily_bars, key=lambda b: b["date"]) + recent = sorted_bars[-(lookback + 1):] + returns: list[float] = [] + for i in range(1, len(recent)): + prev_close = recent[i - 1].get("close") + curr_close = recent[i].get("close") + if not prev_close or prev_close <= 0 or curr_close is None: + continue + returns.append((curr_close - prev_close) / prev_close) + if len(returns) < lookback: + return None + + edges = [-0.05, -0.02, -0.01, -0.0025, 0.0025, 0.01, 0.02, 0.05] + counts = [0] * (len(edges) + 1) + for ret in returns[-lookback:]: + placed = False + for idx, edge in enumerate(edges): + if ret < edge: + counts[idx] += 1 + placed = True + break + if not placed: + counts[-1] += 1 + + total = sum(counts) + if total <= 0: + return None + probs = [count / total for count in counts if count > 0] + if not probs: + return None + entropy = -sum(p * math.log(p) for p in probs) + max_entropy = math.log(len(counts)) + if max_entropy <= 0: + return None + return entropy / max_entropy + + +def compute_average_true_range(daily_bars: list[dict], lookback: int) -> float | None: + """Average true range over the last `lookback` completed daily bars.""" + if len(daily_bars) < 2: + return None + sorted_bars = sorted(daily_bars, key=lambda b: b["date"]) + true_ranges: list[float] = [] + for i in range(1, len(sorted_bars)): + curr = sorted_bars[i] + prev = sorted_bars[i - 1] + tr = max( + curr["high"] - curr["low"], + abs(curr["high"] - prev["close"]), + abs(curr["low"] - prev["close"]), + ) + true_ranges.append(tr) + if len(true_ranges) < lookback: + return None + recent = true_ranges[-lookback:] + return sum(recent) / len(recent) + + +def compute_average_range(daily_bars: list[dict], lookback: int) -> float | None: + """Average high-low range over the last `lookback` completed daily bars.""" + if len(daily_bars) < lookback: + return None + sorted_bars = sorted(daily_bars, key=lambda b: b["date"]) + recent = sorted_bars[-lookback:] + ranges = [b["high"] - b["low"] for b in recent if b.get("high") is not None and b.get("low") is not None] + if len(ranges) < lookback: + return None + return sum(ranges) / len(ranges) + + +def compute_gap_zscore( + daily_bars: list[dict], + today_open: float, + lookback: int = 20, +) -> float | None: + """Today's opening gap z-score relative to prior completed daily gaps.""" + if len(daily_bars) < max(lookback + 1, 2): + return None + sorted_bars = sorted(daily_bars, key=lambda b: b["date"]) + if today_open <= 0: + return None + prev_close = sorted_bars[-1].get("close") + if prev_close is None or prev_close <= 0: + return None + + gaps: list[float] = [] + for i in range(1, len(sorted_bars)): + prev = sorted_bars[i - 1].get("close") + curr_open = sorted_bars[i].get("open") + if prev and prev > 0 and curr_open and curr_open > 0: + gaps.append((curr_open - prev) / prev) + if len(gaps) < lookback: + return None + + sample = gaps[-lookback:] + mean_gap = statistics.mean(sample) + std_gap = statistics.stdev(sample) if len(sample) >= 2 else 0.0 + if std_gap <= 0: + return 0.0 + today_gap = (today_open - prev_close) / prev_close + return (today_gap - mean_gap) / std_gap + + def compute_rvol_approx( first_bar_volume: float, avg_daily_volume: float, @@ -122,6 +236,10 @@ def enrich_daily_bars( "avg_daily_vol_14d": float | None, — 14-day avg daily share volume "prev_close": float | None, — prior day's close (for gap calc) "today_open": float | None, — today's open (from today's bar) + "entropy_20d": float | None, — normalized entropy of recent returns + "atr_ratio_10_60": float | None, — ATR(10) / ATR(60) + "range_compression_10_60": float | None, — avg_range_10 / avg_range_60 + "gap_zscore_20d": float | None, — today's opening gap z-score }}} """ result: dict[str, dict[str, dict]] = {} @@ -168,9 +286,32 @@ def enrich_daily_bars( "prev_close": prev_close, "today_open": today_bar.get("open"), "ret_5d": ret_5d, + "entropy_20d": ( + compute_entropy_approx(prev_bars, lookback=20) + if len(prev_bars) >= 20 else None + ), + "atr_ratio_10_60": _compute_ratio( + compute_average_true_range(prev_bars, lookback=10), + compute_average_true_range(prev_bars, lookback=60), + ), + "range_compression_10_60": _compute_ratio( + compute_average_range(prev_bars, lookback=10), + compute_average_range(prev_bars, lookback=60), + ), + "gap_zscore_20d": ( + compute_gap_zscore(prev_bars, today_bar.get("open") or 0.0, lookback=20) + if len(prev_bars) >= 21 and (today_bar.get("open") or 0.0) > 0 + else None + ), } if ticker_result: result[ticker] = ticker_result return result + + +def _compute_ratio(numerator: float | None, denominator: float | None) -> float | None: + if numerator is None or denominator is None or denominator == 0: + return None + return numerator / denominator diff --git a/libs/intraday/metrics.py b/libs/intraday/metrics.py index c21437d..0a9441a 100644 --- a/libs/intraday/metrics.py +++ b/libs/intraday/metrics.py @@ -33,6 +33,275 @@ def _get_initial_capital(config: IntradayConfig) -> float: return config.strategy.initial_capital +class IntradayMetricsAccumulator: + """Streaming metrics accumulator for bounded-memory intraday research runs.""" + + def __init__(self, config: IntradayConfig, run_id: str = "") -> None: + self.config = config + self.run_id = run_id or str(uuid.uuid4())[:8] + self.initial_capital = _get_initial_capital(config) + self.is_orb = getattr(config, "strategy_mode", "momentum") == "orb" + self.active_strategy = ( + config.orb_strategy if (self.is_orb and config.orb_strategy) else config.strategy + ) + self.n_days = 0 + self.days_with_trades = 0 + self.start_date = "" + self.end_date = "" + self.total_trades = 0 + self.stop_loss_exits = 0 + self.win_count = 0 + self.loss_count = 0 + self.sum_win_pct = 0.0 + self.sum_loss_pct = 0.0 + self.gross_profit = 0.0 + self.gross_loss = 0.0 + self.hold_minutes_sum = 0.0 + self.hold_minutes_count = 0 + self.daily_returns: list[float] = [] + self.equity = self.initial_capital + self.max_equity = self.initial_capital + self.max_drawdown = 0.0 + + def update(self, day_result: DayResult) -> None: + if not self.start_date: + self.start_date = day_result.date + self.end_date = day_result.date + self.n_days += 1 + self.daily_returns.append(day_result.daily_return_pct) + if day_result.trades: + self.days_with_trades += 1 + + for trade in day_result.trades: + self.total_trades += 1 + if trade.exit_reason == "stop_loss": + self.stop_loss_exits += 1 + if trade.pnl > 0: + self.win_count += 1 + self.sum_win_pct += trade.pnl_pct + self.gross_profit += trade.pnl + else: + self.loss_count += 1 + self.sum_loss_pct += trade.pnl_pct + self.gross_loss += abs(trade.pnl) + try: + entry = datetime.fromisoformat(trade.entry_time.replace("Z", "+00:00")) + exit_ = datetime.fromisoformat(trade.exit_time.replace("Z", "+00:00")) + self.hold_minutes_sum += (exit_ - entry).total_seconds() / 60 + self.hold_minutes_count += 1 + except Exception: + pass + + self.equity += day_result.daily_pnl + self.max_equity = max(self.max_equity, self.equity) + if self.max_equity > 0: + dd = (self.equity - self.max_equity) / self.max_equity + self.max_drawdown = min(self.max_drawdown, dd) + + def extend(self, day_results: list[DayResult]) -> None: + for day_result in day_results: + self.update(day_result) + + def snapshot(self) -> dict[str, Any]: + """Serialize accumulator state for chunk-level checkpoint/resume.""" + return { + "run_id": self.run_id, + "initial_capital": self.initial_capital, + "n_days": self.n_days, + "days_with_trades": self.days_with_trades, + "start_date": self.start_date, + "end_date": self.end_date, + "total_trades": self.total_trades, + "stop_loss_exits": self.stop_loss_exits, + "win_count": self.win_count, + "loss_count": self.loss_count, + "sum_win_pct": self.sum_win_pct, + "sum_loss_pct": self.sum_loss_pct, + "gross_profit": self.gross_profit, + "gross_loss": self.gross_loss, + "hold_minutes_sum": self.hold_minutes_sum, + "hold_minutes_count": self.hold_minutes_count, + "daily_returns": list(self.daily_returns), + "equity": self.equity, + "max_equity": self.max_equity, + "max_drawdown": self.max_drawdown, + } + + @classmethod + def from_snapshot( + cls, + config: IntradayConfig, + snapshot: dict[str, Any], + *, + run_id: str = "", + ) -> "IntradayMetricsAccumulator": + """Restore a previously serialized accumulator state.""" + accumulator = cls(config, run_id=run_id or snapshot.get("run_id", "")) + accumulator.initial_capital = float(snapshot.get("initial_capital", accumulator.initial_capital)) + accumulator.n_days = int(snapshot.get("n_days", 0)) + accumulator.days_with_trades = int(snapshot.get("days_with_trades", 0)) + accumulator.start_date = snapshot.get("start_date", "") or "" + accumulator.end_date = snapshot.get("end_date", "") or "" + accumulator.total_trades = int(snapshot.get("total_trades", 0)) + accumulator.stop_loss_exits = int(snapshot.get("stop_loss_exits", 0)) + accumulator.win_count = int(snapshot.get("win_count", 0)) + accumulator.loss_count = int(snapshot.get("loss_count", 0)) + accumulator.sum_win_pct = float(snapshot.get("sum_win_pct", 0.0)) + accumulator.sum_loss_pct = float(snapshot.get("sum_loss_pct", 0.0)) + accumulator.gross_profit = float(snapshot.get("gross_profit", 0.0)) + accumulator.gross_loss = float(snapshot.get("gross_loss", 0.0)) + accumulator.hold_minutes_sum = float(snapshot.get("hold_minutes_sum", 0.0)) + accumulator.hold_minutes_count = int(snapshot.get("hold_minutes_count", 0)) + accumulator.daily_returns = [ + float(value) for value in snapshot.get("daily_returns", []) + ] + accumulator.equity = float(snapshot.get("equity", accumulator.initial_capital)) + accumulator.max_equity = float(snapshot.get("max_equity", accumulator.initial_capital)) + accumulator.max_drawdown = float(snapshot.get("max_drawdown", 0.0)) + return accumulator + + def finalize(self) -> IntradayMetrics: + if self.total_trades == 0: + return IntradayMetrics( + run_id=self.run_id, + params_hash=_hash_strategy(self.active_strategy), + start_date=self.start_date, + end_date=self.end_date, + trading_days=self.n_days, + days_with_trades=self.days_with_trades, + total_trades=0, + stop_loss_exits=0, + total_return_pct=0.0 if self.n_days > 0 else None, + annualized_return_pct=0.0 if self.n_days > 0 else None, + avg_daily_return_pct=round(statistics.mean(self.daily_returns), 6) if self.daily_returns else None, + max_drawdown_pct=0.0 if self.n_days > 0 else None, + initial_capital=self.initial_capital, + final_equity=round(self.equity, 2), + ) + + win_rate = self.win_count / self.total_trades if self.total_trades else None + avg_win_pct = self.sum_win_pct / self.win_count if self.win_count > 0 else None + avg_loss_pct = self.sum_loss_pct / self.loss_count if self.loss_count > 0 else None + profit_factor = ( + self.gross_profit / self.gross_loss if self.gross_loss > 0 else None + ) + expectancy_pct = ( + (win_rate * avg_win_pct + (1 - win_rate) * avg_loss_pct) + if win_rate is not None and avg_win_pct is not None and avg_loss_pct is not None + else None + ) + + total_return_pct = ( + (self.equity - self.initial_capital) / self.initial_capital + if self.initial_capital > 0 else None + ) + annualized = ( + total_return_pct * (252 / self.n_days) + if total_return_pct is not None and self.n_days > 0 else None + ) + avg_daily = statistics.mean(self.daily_returns) if self.daily_returns else None + + sharpe = sortino = calmar = None + if len(self.daily_returns) >= 5: + try: + mean_r = statistics.mean(self.daily_returns) + std_r = statistics.stdev(self.daily_returns) + if std_r > 0: + sharpe = (mean_r / std_r) * math.sqrt(252) + down_devs = [r for r in self.daily_returns if r < 0] + if down_devs: + downside_std = math.sqrt( + sum(r ** 2 for r in down_devs) / len(self.daily_returns) + ) + if downside_std > 0: + sortino = (mean_r / downside_std) * math.sqrt(252) + except Exception: + pass + + if annualized is not None and self.max_drawdown < 0: + calmar = annualized / abs(self.max_drawdown) + + stop_pct = self.stop_loss_exits / self.total_trades if self.total_trades else None + loss_stats = _loss_containment_stats(self.daily_returns, include_score=True) + + return IntradayMetrics( + run_id=self.run_id, + params_hash=_hash_strategy(self.active_strategy), + start_date=self.start_date, + end_date=self.end_date, + trading_days=self.n_days, + days_with_trades=self.days_with_trades, + total_trades=self.total_trades, + stop_loss_exits=self.stop_loss_exits, + win_rate=round(win_rate, 4) if win_rate is not None else None, + avg_win_pct=round(avg_win_pct, 4) if avg_win_pct is not None else None, + avg_loss_pct=round(avg_loss_pct, 4) if avg_loss_pct is not None else None, + profit_factor=round(profit_factor, 4) if profit_factor is not None else None, + expectancy_pct=round(expectancy_pct, 4) if expectancy_pct is not None else None, + total_return_pct=round(total_return_pct, 4) if total_return_pct is not None else None, + annualized_return_pct=round(annualized, 4) if annualized is not None else None, + avg_daily_return_pct=round(avg_daily, 6) if avg_daily is not None else None, + max_drawdown_pct=round(self.max_drawdown, 4), + sharpe_ratio=round(sharpe, 4) if sharpe is not None else None, + sortino_ratio=round(sortino, 4) if sortino is not None else None, + calmar_ratio=round(calmar, 4) if calmar is not None else None, + loss_day_rate=loss_stats["loss_day_rate"], + avg_loss_day_pct=loss_stats["avg_loss_day_pct"], + tail_loss_20_pct=loss_stats["tail_loss_20_pct"], + worst_day_return_pct=loss_stats["worst_day_return_pct"], + loss_containment_score=loss_stats["loss_containment_score"], + avg_hold_minutes=( + round(self.hold_minutes_sum / self.hold_minutes_count, 1) + if self.hold_minutes_count > 0 else None + ), + stop_loss_exit_pct=round(stop_pct, 4) if stop_pct is not None else None, + initial_capital=self.initial_capital, + final_equity=round(self.equity, 2), + ) + + +def _loss_containment_stats( + daily_returns: list[float], + *, + include_score: bool, +) -> dict[str, float | None]: + if not daily_returns: + return { + "loss_day_rate": None, + "avg_loss_day_pct": None, + "tail_loss_20_pct": None, + "worst_day_return_pct": None, + "loss_containment_score": None, + } + + loss_days = sorted(r for r in daily_returns if r < 0) + loss_day_rate = len(loss_days) / len(daily_returns) + worst_day = min(daily_returns) + avg_loss_day = statistics.mean(loss_days) if loss_days else None + tail_loss = None + if loss_days: + tail_n = max(1, math.ceil(len(loss_days) * 0.2)) + tail_loss = statistics.mean(loss_days[:tail_n]) + + score = None + if include_score: + if not loss_days: + score = 100.0 + else: + avg_abs = abs(avg_loss_day or 0.0) * 100.0 + tail_abs = abs(tail_loss or 0.0) * 100.0 + worst_abs = abs(worst_day) * 100.0 + score = max(0.0, min(100.0, 100.0 - avg_abs * 12.0 - tail_abs * 6.0 - worst_abs * 2.0)) + + return { + "loss_day_rate": round(loss_day_rate, 4), + "avg_loss_day_pct": None if avg_loss_day is None else round(avg_loss_day, 4), + "tail_loss_20_pct": None if tail_loss is None else round(tail_loss, 4), + "worst_day_return_pct": round(worst_day, 4), + "loss_containment_score": None if score is None else round(score, 2), + } + + def compute_metrics( day_results: list[DayResult], config: IntradayConfig, @@ -43,10 +312,28 @@ def compute_metrics( # Include ALL days (0% for no-trade days) — idle capital dilutes Sharpe correctly daily_returns = [r.daily_return_pct for r in day_results] initial_capital = _get_initial_capital(config) + n_days = len(day_results) + days_with_trades = sum(1 for r in day_results if r.trades) + dates = sorted(r.date for r in day_results) + start_date = dates[0] if dates else "" + end_date = dates[-1] if dates else "" + is_orb = getattr(config, "strategy_mode", "momentum") == "orb" + active_strategy = config.orb_strategy if (is_orb and config.orb_strategy) else config.strategy if not all_trades: return IntradayMetrics( run_id=run_id or str(uuid.uuid4())[:8], + params_hash=_hash_strategy(active_strategy), + start_date=start_date, + end_date=end_date, + trading_days=n_days, + days_with_trades=days_with_trades, + total_trades=0, + stop_loss_exits=0, + total_return_pct=0.0 if n_days > 0 else None, + annualized_return_pct=0.0 if n_days > 0 else None, + avg_daily_return_pct=round(statistics.mean(daily_returns), 6) if daily_returns else None, + max_drawdown_pct=0.0 if n_days > 0 else None, initial_capital=initial_capital, final_equity=initial_capital, ) @@ -76,7 +363,6 @@ def compute_metrics( # Returns total_return_pct = (equity_curve[-1] - equity_curve[0]) / equity_curve[0] - n_days = len(day_results) annualized = total_return_pct * (252 / n_days) if n_days > 0 else None avg_daily = statistics.mean(daily_returns) if daily_returns else None @@ -112,6 +398,7 @@ def compute_metrics( # Intraday-specific stop_exits = [t for t in all_trades if t.exit_reason == "stop_loss"] stop_pct = len(stop_exits) / len(all_trades) if all_trades else None + loss_stats = _loss_containment_stats(daily_returns, include_score=True) # Average hold time (in minutes) hold_minutes: list[float] = [] @@ -125,16 +412,6 @@ def compute_metrics( except Exception: pass - days_with_trades = sum(1 for r in day_results if r.trades) - - # Date range from day_results - dates = sorted(r.date for r in day_results) - start_date = dates[0] if dates else "" - end_date = dates[-1] if dates else "" - - is_orb = getattr(config, "strategy_mode", "momentum") == "orb" - active_strategy = config.orb_strategy if (is_orb and config.orb_strategy) else config.strategy - return IntradayMetrics( run_id=run_id or str(uuid.uuid4())[:8], params_hash=_hash_strategy(active_strategy), @@ -156,6 +433,11 @@ def compute_metrics( sharpe_ratio=round(sharpe, 4) if sharpe is not None else None, sortino_ratio=round(sortino, 4) if sortino is not None else None, calmar_ratio=round(calmar, 4) if calmar is not None else None, + loss_day_rate=loss_stats["loss_day_rate"], + avg_loss_day_pct=loss_stats["avg_loss_day_pct"], + tail_loss_20_pct=loss_stats["tail_loss_20_pct"], + worst_day_return_pct=loss_stats["worst_day_return_pct"], + loss_containment_score=loss_stats["loss_containment_score"], avg_hold_minutes=round(statistics.mean(hold_minutes), 1) if hold_minutes else None, stop_loss_exit_pct=round(stop_pct, 4) if stop_pct is not None else None, initial_capital=initial_capital, @@ -171,6 +453,23 @@ def _hash_strategy(strategy: Any) -> str: # ── Reporting ────────────────────────────────────────────────────────────── +def _describe_momentum_stop(strategy) -> str: + if strategy.atr_stop_multiplier is not None: + base = f"{strategy.atr_stop_multiplier:.2f}xATR" + elif strategy.opening_range_stop_multiplier is not None: + base = f"{strategy.opening_range_stop_multiplier:.2f}xOR" + elif strategy.stop_loss_pct is not None: + base = f"{strategy.stop_loss_pct:.3f}" + else: + base = "none" + if strategy.trailing_stop_pct is None: + return base + trail = f"trail {strategy.trailing_stop_pct:.3f}" + if strategy.trailing_activation_gain_pct is not None: + trail += f" @+{strategy.trailing_activation_gain_pct*100:.1f}%" + return f"{base} + {trail}" + + def format_summary(metrics: IntradayMetrics, config: IntradayConfig) -> str: """Format summary table for terminal output using rich.""" from rich.console import Console @@ -203,7 +502,7 @@ def format_summary(metrics: IntradayMetrics, config: IntradayConfig) -> str: f"[dim]Universe: {config.universe.source} | " f"Entry: +{config.strategy.entry_minutes_after_open}min | " f"Exit: -{config.strategy.exit_minutes_before_close}min | " - f"Stop: {config.strategy.stop_loss_pct or 'none'} | " + f"Stop: {_describe_momentum_stop(config.strategy)} | " f"Top N: {config.strategy.top_n}[/dim]" ) console.print() @@ -240,6 +539,9 @@ def format_summary(metrics: IntradayMetrics, config: IntradayConfig) -> str: t.add_row("Sharpe ratio", _f(metrics.sharpe_ratio)) t.add_row("Sortino ratio", _f(metrics.sortino_ratio)) t.add_row("Calmar ratio", _f(metrics.calmar_ratio)) + t.add_row("Avg losing day", _pct(metrics.avg_loss_day_pct)) + t.add_row("Tail loss (20%)", _pct(metrics.tail_loss_20_pct)) + t.add_row("Loss containment", _f(metrics.loss_containment_score)) t.add_section() t.add_row("Avg hold (min)", _f(metrics.avg_hold_minutes, 0)) t.add_row("Stop-loss rate", _pct(metrics.stop_loss_exit_pct, 1)) @@ -390,7 +692,14 @@ def format_sweep_comparison(sweep_results: list[SweepResult], top_n: int = 20) - f"{p.get('risk_per_trade_pct', 0)*100:.2f}%", ] else: - stop = f"{p.get('stop_loss_pct', '')*100:.0f}" if p.get("stop_loss_pct") else "none" + if p.get("atr_stop_multiplier") is not None: + stop = f"{p.get('atr_stop_multiplier'):.2f}xATR" + elif p.get("opening_range_stop_multiplier") is not None: + stop = f"{p.get('opening_range_stop_multiplier'):.2f}xOR" + elif p.get("stop_loss_pct") is not None: + stop = f"{p.get('stop_loss_pct', 0)*100:.0f}" + else: + stop = "none" param_cells = [ str(p.get("entry_minutes_after_open", "")), str(p.get("exit_minutes_before_close", "")), @@ -433,11 +742,27 @@ def write_results( all_trades = [t.model_dump() for r in day_results for t in r.trades] + # Aggregate skip breakdown and filter stats across all days + skip_breakdown: dict[str, int] = {"traded": 0} + agg_filter_stats: dict[str, int] = {} + for r in day_results: + if r.skip_reason: + skip_breakdown[r.skip_reason] = skip_breakdown.get(r.skip_reason, 0) + 1 + elif r.trades: + skip_breakdown["traded"] += 1 + else: + skip_breakdown["traded_no_fill"] = skip_breakdown.get("traded_no_fill", 0) + 1 + if r.candidate_filter_stats: + for k, v in r.candidate_filter_stats.items(): + agg_filter_stats[k] = agg_filter_stats.get(k, 0) + v + payload = { "run_id": metrics.run_id, "generated_at": datetime.now().isoformat(), "config": config.model_dump(), "metrics": metrics.model_dump(), + "skip_breakdown": skip_breakdown, + "aggregate_filter_stats": agg_filter_stats, "trades": all_trades, "daily_summary": [ { @@ -446,6 +771,11 @@ def write_results( "daily_return_pct": r.daily_return_pct, "candidates_found": r.candidates_found, "trades": len(r.trades), + "skip_reason": r.skip_reason, + "candidate_filter_stats": r.candidate_filter_stats, + "regime_scaler": r.regime_scaler, + "breadth_scaler": r.breadth_scaler, + "is_soft_day": r.is_soft_day, } for r in day_results ], diff --git a/libs/intraday/simulator.py b/libs/intraday/simulator.py index 232c9da..d2ca179 100644 --- a/libs/intraday/simulator.py +++ b/libs/intraday/simulator.py @@ -8,7 +8,6 @@ making sweep mode trivial (call once per parameter combination). from __future__ import annotations import datetime as dt -from collections import deque from zoneinfo import ZoneInfo from libs.intraday.domain import DayResult, IntradayTrade, StrategyParams @@ -85,6 +84,385 @@ def _volume_up_to_bar(bars: list[dict], entry_ts: dt.datetime) -> float: return total +def _dollar_volume_up_to_bar(bars: list[dict], entry_ts: dt.datetime) -> float: + """Sum approximate dollar volume of all bars up to and including entry_ts.""" + total = 0.0 + for b in bars: + ts = _parse_ts(b["timestamp"]) + if ts <= entry_ts: + close = b.get("close") or 0.0 + volume = b.get("volume") or 0.0 + total += float(close) * float(volume) + return total + + +def _linear_scaler( + value: float | None, + low: float | None, + high: float | None, + floor: float, + *, + invert: bool = False, +) -> float: + """Piecewise-linear scaler bounded to [floor, 1.0]. + + When invert=False, values <= low map to 1.0 and values >= high map to floor. + When invert=True, values <= low map to floor and values >= high map to 1.0. + """ + if value is None or low is None or high is None or high <= low: + return 1.0 + floor = max(0.0, min(1.0, floor)) + if invert: + if value <= low: + return floor + if value >= high: + return 1.0 + frac = (value - low) / (high - low) + return floor + frac * (1.0 - floor) + if value <= low: + return 1.0 + if value >= high: + return floor + frac = (value - low) / (high - low) + return 1.0 - frac * (1.0 - floor) + + +def _vix_day_scaler(vix_value: float | None, strategy: StrategyParams) -> float: + return _linear_scaler( + vix_value, + strategy.vix_size_scale_low, + strategy.vix_size_scale_high, + strategy.vix_size_scale_min, + ) + + +def _entropy_trade_scaler(entropy_20d: float | None, strategy: StrategyParams) -> float: + return _linear_scaler( + entropy_20d, + strategy.entropy_size_scale_low, + strategy.entropy_size_scale_high, + strategy.entropy_size_scale_min, + ) + + +def _sparse_day_scaler(selected_count: int, strategy: StrategyParams) -> float: + threshold = strategy.full_size_positions_threshold + if threshold is None or threshold <= 0: + return 1.0 + floor = max(0.0, min(1.0, strategy.sparse_day_size_floor)) + if selected_count >= threshold: + return 1.0 + ratio = selected_count / threshold + return max(floor, min(1.0, ratio)) + + +def _safe_value(value: float | None, *, default: float = 0.0) -> float: + return default if value is None else float(value) + + +def _trade_trailing_stop_pct(info: dict, strategy: StrategyParams) -> float | None: + """Return the per-trade trailing stop, tightening only overextended leaders.""" + trailing_stop_pct = strategy.trailing_stop_pct + if ( + trailing_stop_pct is None + or strategy.overextended_trailing_gain_pct is None + or strategy.overextended_trailing_stop_pct is None + ): + return trailing_stop_pct + gain_pct = info.get("gain_pct") + if gain_pct is None or gain_pct < strategy.overextended_trailing_gain_pct: + return trailing_stop_pct + return strategy.overextended_trailing_stop_pct + + +def _trade_catastrophic_stop_price(info: dict, strategy: StrategyParams) -> float | None: + """Return the initial catastrophic stop price for a trade, if any.""" + entry_price_raw = info.get("entry_price_raw") + if entry_price_raw is None or entry_price_raw <= 0: + return None + if strategy.atr_stop_multiplier is not None: + atr_14 = info.get("atr_14") + if atr_14 is None or atr_14 <= 0: + return None + return max(0.0, float(entry_price_raw) - float(atr_14) * strategy.atr_stop_multiplier) + if strategy.opening_range_stop_multiplier is not None: + opening_range_width = info.get("opening_range_width") + if opening_range_width is None or opening_range_width <= 0: + return None + return max( + 0.0, + float(entry_price_raw) - float(opening_range_width) * strategy.opening_range_stop_multiplier, + ) + if strategy.stop_loss_pct is not None: + return max(0.0, float(entry_price_raw) * (1.0 + strategy.stop_loss_pct)) + return None + + +def _five_sleeve_specs(strategy: StrategyParams) -> list[dict[str, object]]: + sleeves: list[dict[str, object]] = [ + { + "label": "core", + "weight": strategy.five_sleeve_core_weight, + "key_fn": lambda item: ( + item[1]["gain_pct"], + item[1].get("volume_ratio_14d", 0.0), + item[1].get("entry_volume", 0.0), + ), + "component": lambda info: info["gain_pct"], + }, + { + "label": "gap", + "weight": strategy.five_sleeve_gap_weight, + "key_fn": lambda item: ( + _safe_value(item[1].get("gap_pct"), default=-999.0), + item[1]["gain_pct"], + item[1].get("volume_ratio_14d", 0.0), + ), + "component": lambda info: max(info.get("gap_pct") or 0.0, 0.0), + }, + { + "label": "volume", + "weight": strategy.five_sleeve_volume_weight, + "key_fn": lambda item: ( + item[1].get("volume_ratio_14d", 0.0), + item[1].get("entry_volume", 0.0), + item[1]["gain_pct"], + ), + "component": lambda info: info.get("volume_ratio_14d") or 0.0, + }, + { + "label": "entropy", + "weight": strategy.five_sleeve_entropy_weight, + "key_fn": lambda item: ( + -_safe_value(item[1].get("entropy_20d"), default=1.0), + item[1]["gain_pct"], + item[1].get("volume_ratio_14d", 0.0), + ), + "component": lambda info: ( + 1.0 - info["entropy_20d"] if info.get("entropy_20d") is not None else 0.0 + ), + }, + { + "label": "trend", + "weight": strategy.five_sleeve_trend_weight, + "key_fn": lambda item: ( + _safe_value(item[1].get("ret_5d"), default=-999.0), + item[1]["gain_pct"], + item[1].get("volume_ratio_14d", 0.0), + ), + "component": lambda info: max(info.get("ret_5d") or 0.0, 0.0), + }, + ] + if strategy.use_slow_ignite_sleeve and strategy.slow_ignite_weight > 0: + sleeves.append( + { + "label": "slow_ignite", + "weight": strategy.slow_ignite_weight, + "key_fn": lambda item: ( + 1 if item[1].get("is_slow_ignite") else 0, + item[1].get("confirmation_return_pct", -999.0), + item[1].get("volume_ratio_14d", 0.0), + _safe_value(item[1].get("ret_5d"), default=-999.0), + item[1].get("entry_dollar_volume", 0.0), + ), + "component": lambda info: ( + ( + max(info.get("confirmation_return_pct") or 0.0, 0.0) * 5.0 + + max(min(info.get("volume_ratio_14d") or 0.0, 0.5), 0.0) + + max(min(info.get("ret_5d") or 0.0, 0.2), 0.0) + ) + if info.get("is_slow_ignite") + else 0.0 + ), + } + ) + if strategy.use_liquid_largecap_sleeve and strategy.liquid_largecap_weight > 0: + sleeves.append( + { + "label": "liquid_largecap", + "weight": strategy.liquid_largecap_weight, + "key_fn": lambda item: ( + 1 if item[1].get("is_liquid_largecap") else 0, + item[1].get("entry_dollar_volume", 0.0), + item[1].get("confirmation_return_pct", -999.0), + item[1].get("gain_pct", 0.0), + item[1].get("avg_dollar_vol_30d", 0.0), + ), + "component": lambda info: ( + ( + min(max((info.get("entry_dollar_volume") or 0.0) / 500_000_000.0, 0.0), 4.0) + + max((info.get("confirmation_return_pct") or 0.0) * 10.0, 0.0) + + max((info.get("gain_pct") or 0.0) * 10.0, 0.0) + + min(max((info.get("avg_dollar_vol_30d") or 0.0) / 1_000_000_000.0, 0.0), 3.0) + ) + if info.get("is_liquid_largecap") + else 0.0 + ), + } + ) + if strategy.use_gap_reclaim_sleeve and strategy.gap_reclaim_weight > 0: + sleeves.append( + { + "label": "gap_reclaim", + "weight": strategy.gap_reclaim_weight, + "key_fn": lambda item: ( + 1 if item[1].get("is_gap_reclaim") else 0, + item[1].get("confirmation_return_pct", -999.0), + item[1].get("recovery_from_opening_low_pct", 0.0), + item[1].get("entry_dollar_volume", 0.0), + item[1].get("gap_pct", 0.0), + ), + "component": lambda info: ( + ( + max((info.get("confirmation_return_pct") or 0.0) * 10.0, 0.0) + + max((info.get("recovery_from_opening_low_pct") or 0.0) * 20.0, 0.0) + + min(max((info.get("entry_dollar_volume") or 0.0) / 250_000_000.0, 0.0), 4.0) + + min(max((info.get("gap_pct") or 0.0) * 5.0, 0.0), 2.0) + ) + if info.get("is_gap_reclaim") + else 0.0 + ), + } + ) + return sleeves + + +def _select_momentum_sleeves( + morning_gains: dict[str, dict], + strategy: StrategyParams, + ticker_sectors: dict[str, str] | None = None, +) -> list[tuple[str, str]]: + """Return ordered (ticker, sleeve) picks for the day.""" + if not morning_gains: + return [] + sector_cap = strategy.max_positions_per_sector if strategy.max_positions_per_sector and strategy.max_positions_per_sector > 0 else None + sector_counts: dict[str, int] = {} + + def _sector_for_ticker(ticker: str) -> str | None: + if not ticker_sectors: + return None + sector = str(ticker_sectors.get(ticker) or "").strip() + if not sector or sector.upper() == "UNKNOWN": + return None + return sector + + def _can_pick_ticker(ticker: str) -> bool: + if sector_cap is None: + return True + sector = _sector_for_ticker(ticker) + if sector is None: + return True + return sector_counts.get(sector, 0) < sector_cap + + def _record_pick(ticker: str) -> None: + if sector_cap is None: + return + sector = _sector_for_ticker(ticker) + if sector is None: + return + sector_counts[sector] = sector_counts.get(sector, 0) + 1 + + if not strategy.use_five_sleeves: + ranked = sorted( + morning_gains.keys(), + key=lambda t: ( + morning_gains[t]["gain_pct"], + morning_gains[t].get("entry_volume", 0.0), + ), + reverse=True, + ) + picks: list[tuple[str, str]] = [] + for ticker in ranked: + if not _can_pick_ticker(ticker): + continue + picks.append((ticker, "core")) + _record_pick(ticker) + if len(picks) >= strategy.top_n: + break + return picks + + sleeves = _five_sleeve_specs(strategy) + + picks: list[tuple[str, str]] = [] + chosen: set[str] = set() + items = list(morning_gains.items()) + forced_sleeves = [ + sleeve + for sleeve in sorted(sleeves, key=lambda sleeve: float(sleeve["weight"]), reverse=True) + if float(sleeve["weight"]) > 0 + ][: max(0, min(strategy.five_sleeve_force_count, len(sleeves)))] + for sleeve in forced_sleeves: + key_fn = sleeve["key_fn"] + ranked = sorted(items, key=key_fn, reverse=True) + for ticker, _info in ranked: + if ticker in chosen: + continue + if not _can_pick_ticker(ticker): + continue + picks.append((ticker, str(sleeve["label"]))) + chosen.add(ticker) + _record_pick(ticker) + break + if len(picks) >= strategy.top_n: + return picks[: strategy.top_n] + + fallback_slots = max(0, int(getattr(strategy, "fallback_liquid_largecap_slots", 0) or 0)) + fallback_trigger = max(0, int(getattr(strategy, "fallback_liquid_largecap_trigger_below", 0) or 0)) + if ( + fallback_slots > 0 + and len(picks) < strategy.top_n + and len(picks) < fallback_trigger + ): + ranked_liquid = sorted( + ( + item for item in items + if item[1].get("is_liquid_largecap") + ), + key=lambda item: ( + item[1].get("entry_dollar_volume", 0.0), + item[1].get("confirmation_return_pct", 0.0), + item[1].get("gain_pct", 0.0), + item[1].get("avg_dollar_vol_30d", 0.0), + ), + reverse=True, + ) + added = 0 + for ticker, _info in ranked_liquid: + if ticker in chosen: + continue + if not _can_pick_ticker(ticker): + continue + picks.append((ticker, "liquid_largecap_fallback")) + chosen.add(ticker) + _record_pick(ticker) + added += 1 + if len(picks) >= strategy.top_n or added >= fallback_slots: + break + + def blended_score(item: tuple[str, dict]) -> float: + _ticker, info = item + score = 0.0 + for sleeve in sleeves: + weight = float(sleeve["weight"]) + if weight <= 0: + continue + score += weight * float(sleeve["component"](info)) + return score + + ranked_fill = sorted(items, key=blended_score, reverse=True) + for ticker, _info in ranked_fill: + if ticker in chosen: + continue + if not _can_pick_ticker(ticker): + continue + picks.append((ticker, "blend")) + chosen.add(ticker) + _record_pick(ticker) + if len(picks) >= strategy.top_n: + break + return picks + + # ── Trade Simulation ─────────────────────────────────────────────────────── @@ -105,13 +483,14 @@ def simulate_trade( exit_offset_minutes: int, stop_loss_pct: float | None, trailing_stop_pct: float | None, + catastrophic_stop_price_raw: float | None, + trailing_activation_gain_pct: float | None, slippage_bps: float, date_str: str, ) -> tuple[float, str, str]: """Simulate a single intraday trade. - Supports both fixed stop-loss and trailing stop. - When trailing_stop_pct is set, it takes precedence over stop_loss_pct. + Supports catastrophic/fixed stops plus optional delayed trailing stops. Returns: (exit_price_after_slippage, exit_time_str, exit_reason) @@ -139,8 +518,17 @@ def simulate_trade( if b["high"] > peak_price: peak_price = b["high"] + peak_gain_pct = (peak_price - entry_price_raw) / entry_price_raw if entry_price_raw > 0 else 0.0 + trailing_active = ( + trailing_stop_pct is not None + and ( + trailing_activation_gain_pct is None + or peak_gain_pct >= trailing_activation_gain_pct + ) + ) + # Determine effective stop level - if trailing_stop_pct is not None: + if trailing_active: # Trailing: stop = peak × (1 + trailing_pct), trails upward stop_price = peak_price * (1.0 + trailing_stop_pct) # trailing_pct is negative low_price = b["low"] @@ -149,11 +537,12 @@ def simulate_trade( exit_time_str = b["timestamp"] exit_reason = "trailing_stop" break - elif stop_loss_pct is not None: - # Fixed stop: relative to entry - low_return = (b["low"] - entry_price_raw) / entry_price_raw - if low_return <= stop_loss_pct: - exit_price_raw = entry_price_raw * (1.0 + stop_loss_pct) + else: + stop_price = catastrophic_stop_price_raw + if stop_price is None and stop_loss_pct is not None: + stop_price = entry_price_raw * (1.0 + stop_loss_pct) + if stop_price is not None and b["low"] <= stop_price: + exit_price_raw = stop_price exit_time_str = b["timestamp"] exit_reason = "stop_loss" break @@ -182,6 +571,8 @@ def compute_morning_gains( date_str: str, blacklisted_tickers: set[str] | None = None, spy_bars: list[dict] | None = None, + daily_features_by_ticker: dict[str, dict] | None = None, + vix_value: float | None = None, ) -> dict[str, dict]: """Compute each ticker's gain from open to entry time, applying all filters. @@ -209,6 +600,9 @@ def compute_morning_gains( if spy_gain < strategy.market_regime_spy_threshold: return {} # Skip this day entirely + if strategy.max_vix is not None and vix_value is not None and vix_value > strategy.max_vix: + return {} + result = {} for ticker, all_bars in bars_by_ticker.items(): @@ -224,29 +618,221 @@ def compute_morning_gains( if open_price <= 0: continue - entry_bar = _bar_at_offset(mkt_bars, market_open, strategy.entry_minutes_after_open) - if entry_bar is None: + initial_entry_bar = _bar_at_offset(mkt_bars, market_open, strategy.entry_minutes_after_open) + if initial_entry_bar is None: continue + entry_bar = initial_entry_bar + if strategy.confirmation_minutes_after_entry > 0: + confirmation_bar = _bar_at_offset( + mkt_bars, + market_open, + strategy.entry_minutes_after_open + strategy.confirmation_minutes_after_entry, + ) + if confirmation_bar is None: + continue + confirmation_return = ( + confirmation_bar["close"] - initial_entry_bar["close"] + ) / initial_entry_bar["close"] + entry_bar = confirmation_bar + else: + confirmation_return = None + entry_price_raw = entry_bar["close"] if entry_price_raw <= 0: continue gain_pct = (entry_price_raw - open_price) / open_price - # min gain filter - if gain_pct < strategy.min_morning_gain_pct: - continue - - # max gain filter (avoid extreme gap-ups that tend to mean-revert) - if strategy.max_morning_gain_pct is not None and gain_pct > strategy.max_morning_gain_pct: - continue - # volume filter: cumulative volume up to entry time entry_ts = _parse_ts(entry_bar["timestamp"]) entry_vol = _volume_up_to_bar(mkt_bars, entry_ts) if strategy.min_entry_volume is not None and entry_vol < strategy.min_entry_volume: continue + entry_dollar_vol = _dollar_volume_up_to_bar(mkt_bars, entry_ts) + if ( + strategy.min_entry_dollar_volume is not None + and entry_dollar_vol < strategy.min_entry_dollar_volume + ): + continue + + daily_features = (daily_features_by_ticker or {}).get(ticker, {}) + gap_pct = daily_features.get("gap_pct") + gap_min_ok = ( + strategy.min_gap_pct is None + or (gap_pct is not None and gap_pct >= strategy.min_gap_pct) + ) + gap_max_ok = ( + strategy.max_gap_pct is None + or (gap_pct is not None and gap_pct <= strategy.max_gap_pct) + ) + + volume_ratio_14d = None + avg_daily_vol_14d = daily_features.get("avg_daily_vol_14d") + if avg_daily_vol_14d and avg_daily_vol_14d > 0: + volume_ratio_14d = entry_vol / avg_daily_vol_14d + if ( + strategy.min_volume_ratio_14d is not None + and (volume_ratio_14d is None or volume_ratio_14d < strategy.min_volume_ratio_14d) + ): + continue + + ret_5d = daily_features.get("ret_5d") + if strategy.min_ret_5d is not None and (ret_5d is None or ret_5d < strategy.min_ret_5d): + continue + + entropy_20d = daily_features.get("entropy_20d") + avg_dollar_vol_30d = daily_features.get("avg_dollar_vol_30d") + atr_14 = daily_features.get("atr_14") + if strategy.min_entropy_20d is not None and (entropy_20d is None or entropy_20d < strategy.min_entropy_20d): + continue + global_max_entropy_ok = True + if strategy.max_entropy_20d is not None and (entropy_20d is None or entropy_20d > strategy.max_entropy_20d): + global_max_entropy_ok = False + + opening_range_bars = [bar for bar in mkt_bars if _parse_ts(bar["timestamp"]) <= entry_ts] + if not opening_range_bars: + continue + opening_range_high = max(float(bar["high"]) for bar in opening_range_bars) + opening_range_low = min(float(bar["low"]) for bar in opening_range_bars) + opening_range_width = max(0.0, opening_range_high - opening_range_low) + recovery_from_opening_low_pct = ( + (entry_price_raw - opening_range_low) / opening_range_low + if opening_range_low > 0 + else None + ) + + if strategy.atr_stop_multiplier is not None and (atr_14 is None or atr_14 <= 0): + continue + if strategy.opening_range_stop_multiplier is not None and opening_range_width <= 0: + continue + + confirmation_ok = ( + strategy.min_confirmation_return_pct is None + or confirmation_return is None + or confirmation_return >= strategy.min_confirmation_return_pct + ) + + regular_ok = gap_min_ok and gap_max_ok and global_max_entropy_ok and confirmation_ok and gain_pct >= strategy.min_morning_gain_pct and ( + strategy.max_morning_gain_pct is None or gain_pct <= strategy.max_morning_gain_pct + ) + slow_ignite_ok = False + if ( + strategy.use_slow_ignite_sleeve + and gap_min_ok + and gap_max_ok + and global_max_entropy_ok + and confirmation_ok + and gain_pct < strategy.min_morning_gain_pct + ): + if strategy.slow_ignite_min_gain_pct is not None and gain_pct < strategy.slow_ignite_min_gain_pct: + pass + elif strategy.slow_ignite_max_gain_pct is not None and gain_pct > strategy.slow_ignite_max_gain_pct: + pass + elif ( + strategy.slow_ignite_min_entry_dollar_volume is not None + and entry_dollar_vol < strategy.slow_ignite_min_entry_dollar_volume + ): + pass + elif ( + strategy.slow_ignite_min_volume_ratio_14d is not None + and (volume_ratio_14d is None or volume_ratio_14d < strategy.slow_ignite_min_volume_ratio_14d) + ): + pass + elif ( + strategy.slow_ignite_min_ret_5d is not None + and (ret_5d is None or ret_5d < strategy.slow_ignite_min_ret_5d) + ): + pass + elif ( + strategy.slow_ignite_max_entropy_20d is not None + and (entropy_20d is None or entropy_20d > strategy.slow_ignite_max_entropy_20d) + ): + pass + else: + slow_ignite_ok = True + + liquid_largecap_ok = False + liquid_largecap_enabled = ( + strategy.use_liquid_largecap_sleeve + or (getattr(strategy, "fallback_liquid_largecap_slots", 0) or 0) > 0 + ) + if liquid_largecap_enabled and gap_min_ok and gap_max_ok and confirmation_ok: + liquid_largecap_entropy_cap = strategy.liquid_largecap_max_entropy_20d + if liquid_largecap_entropy_cap is None: + liquid_largecap_entropy_cap = strategy.max_entropy_20d + if ( + strategy.liquid_largecap_min_gain_pct is not None + and gain_pct < strategy.liquid_largecap_min_gain_pct + ): + pass + elif ( + strategy.liquid_largecap_max_gain_pct is not None + and gain_pct > strategy.liquid_largecap_max_gain_pct + ): + pass + elif ( + strategy.liquid_largecap_min_confirmation_return_pct is not None + and confirmation_return < strategy.liquid_largecap_min_confirmation_return_pct + ): + pass + elif ( + strategy.liquid_largecap_min_entry_dollar_volume is not None + and entry_dollar_vol < strategy.liquid_largecap_min_entry_dollar_volume + ): + pass + elif ( + strategy.liquid_largecap_min_avg_dollar_vol_30d is not None + and ( + avg_dollar_vol_30d is None + or avg_dollar_vol_30d < strategy.liquid_largecap_min_avg_dollar_vol_30d + ) + ): + pass + elif ( + liquid_largecap_entropy_cap is not None + and (entropy_20d is None or entropy_20d > liquid_largecap_entropy_cap) + ): + pass + else: + liquid_largecap_ok = True + + gap_reclaim_ok = False + if strategy.use_gap_reclaim_sleeve: + if strategy.gap_reclaim_min_gap_pct is not None and ( + gap_pct is None or gap_pct < strategy.gap_reclaim_min_gap_pct + ): + pass + elif strategy.gap_reclaim_min_gain_pct is not None and gain_pct < strategy.gap_reclaim_min_gain_pct: + pass + elif strategy.gap_reclaim_max_gain_pct is not None and gain_pct > strategy.gap_reclaim_max_gain_pct: + pass + elif ( + strategy.gap_reclaim_min_confirmation_return_pct is not None + and ( + confirmation_return is None + or confirmation_return < strategy.gap_reclaim_min_confirmation_return_pct + ) + ): + pass + elif ( + strategy.gap_reclaim_min_entry_dollar_volume is not None + and entry_dollar_vol < strategy.gap_reclaim_min_entry_dollar_volume + ): + pass + elif ( + strategy.gap_reclaim_min_recovery_from_opening_low_pct is not None + and ( + recovery_from_opening_low_pct is None + or recovery_from_opening_low_pct < strategy.gap_reclaim_min_recovery_from_opening_low_pct + ) + ): + pass + else: + gap_reclaim_ok = True + + if not regular_ok and not slow_ignite_ok and not liquid_largecap_ok and not gap_reclaim_ok: + continue result[ticker] = { "gain_pct": gain_pct, @@ -254,6 +840,19 @@ def compute_morning_gains( "entry_bar": entry_bar, "mkt_bars": mkt_bars, "entry_volume": entry_vol, + "entry_dollar_volume": entry_dollar_vol, + "gap_pct": gap_pct, + "volume_ratio_14d": volume_ratio_14d, + "ret_5d": ret_5d, + "entropy_20d": entropy_20d, + "avg_dollar_vol_30d": avg_dollar_vol_30d, + "atr_14": atr_14, + "opening_range_width": opening_range_width, + "recovery_from_opening_low_pct": recovery_from_opening_low_pct, + "confirmation_return_pct": confirmation_return, + "is_slow_ignite": slow_ignite_ok, + "is_liquid_largecap": liquid_largecap_ok, + "is_gap_reclaim": gap_reclaim_ok, } return result @@ -268,6 +867,10 @@ def simulate_day( strategy: StrategyParams, blacklisted_tickers: set[str] | None = None, spy_bars: list[dict] | None = None, + daily_features_by_ticker: dict[str, dict] | None = None, + vix_value: float | None = None, + current_equity: float | None = None, + ticker_sectors: dict[str, str] | None = None, ) -> DayResult: """Simulate one full trading day. @@ -275,6 +878,12 @@ def simulate_day( 2. Rank by gain, pick top N. 3. Simulate each trade with stop-loss / trailing stop. 4. Compute daily P&L. + + Args: + current_equity: Current portfolio equity for compound position sizing. + When strategy.compound_returns=True and this is provided, + position sizes scale with current equity. Otherwise uses + strategy.initial_capital (simple/단리 mode). """ result = DayResult(date=date_str) @@ -284,22 +893,39 @@ def simulate_day( date_str, blacklisted_tickers=blacklisted_tickers, spy_bars=spy_bars, + daily_features_by_ticker=daily_features_by_ticker, + vix_value=vix_value, ) result.candidates_found = len(morning_gains) if not morning_gains: return result - # Pick top N by morning gain - top_tickers = sorted( - morning_gains.keys(), - key=lambda t: morning_gains[t]["gain_pct"], - reverse=True, - )[: strategy.top_n] + top_tickers = _select_momentum_sleeves(morning_gains, strategy, ticker_sectors=ticker_sectors) + if not top_tickers: + return result + if len(top_tickers) < max(1, strategy.min_positions_to_trade): + return result - capital_per_trade = strategy.initial_capital / strategy.top_n + if strategy.daily_budget_reset: + # Research mode: every day resets to initial_capital (ignore prior-day PnL). + sizing_capital = strategy.initial_capital + elif strategy.compound_returns and current_equity is not None: + sizing_capital = max(current_equity, 0.0) + elif current_equity is not None: + # Simple mode: fixed at initial_capital, but cannot exceed actual equity + # (can't invest money you don't have after drawdowns). + sizing_capital = min(strategy.initial_capital, max(current_equity, 0.0)) + else: + sizing_capital = strategy.initial_capital + capital_budget = ( + sizing_capital + * _vix_day_scaler(vix_value, strategy) + * _sparse_day_scaler(len(top_tickers), strategy) + ) + capital_per_trade = capital_budget / len(top_tickers) - for ticker in top_tickers: + for ticker, sleeve in top_tickers: info = morning_gains[ticker] entry_price_raw = info["entry_price_raw"] entry_bar = info["entry_bar"] @@ -311,15 +937,18 @@ def simulate_day( entry_price_raw, strategy.exit_minutes_before_close, strategy.stop_loss_pct, - strategy.trailing_stop_pct, + _trade_trailing_stop_pct(info, strategy), + _trade_catastrophic_stop_price(info, strategy), + strategy.trailing_activation_gain_pct, strategy.slippage_bps, date_str, ) entry_price_filled = _apply_slippage_entry(entry_price_raw, strategy.slippage_bps) - shares = capital_per_trade / entry_price_filled + trade_capital = capital_per_trade * _entropy_trade_scaler(info.get("entropy_20d"), strategy) + shares = trade_capital / entry_price_filled pnl_pct = (exit_price - entry_price_filled) / entry_price_filled - pnl = pnl_pct * capital_per_trade + pnl = pnl_pct * trade_capital slippage_cost = ( (entry_price_filled - entry_price_raw) + @@ -339,13 +968,15 @@ def simulate_day( exit_reason=exit_reason, morning_gain_pct=round(info["gain_pct"], 6), slippage_cost=round(slippage_cost, 4), + trade_sleeve=sleeve, ) result.trades.append(trade) result.daily_pnl += trade.pnl if result.trades: - total_deployed = capital_per_trade * len(result.trades) - result.daily_return_pct = result.daily_pnl / total_deployed + total_deployed = sum(t.shares * t.entry_price for t in result.trades) + if total_deployed > 0: + result.daily_return_pct = result.daily_pnl / total_deployed return result @@ -357,6 +988,10 @@ def run_simulation( all_intraday: dict[str, dict[str, list[dict]]], trading_days: list[str], strategy: StrategyParams, + *, + daily_enrichment: dict[str, dict[str, dict]] | None = None, + vix_by_day: dict[str, float] | None = None, + ticker_sectors: dict[str, str] | None = None, ) -> list[DayResult]: """Run the full backtest simulation across all trading days. @@ -374,16 +1009,21 @@ def run_simulation( strategy: Strategy parameters. Returns: - List of DayResult objects (one per day that had intraday data). + List of DayResult objects (one per trading day; days without intraday data get a 0% return result). """ results: list[DayResult] = [] # Ticker cooldown: map ticker -> last traded date ticker_last_traded: dict[str, dt.date] = {} + # Compound return tracking: equity grows with each day's P&L + equity = strategy.initial_capital + for date_str in trading_days: bars_by_ticker = all_intraday.get(date_str) if not bars_by_ticker: + # No intraday data for this day — still record it (0% return, no trades) + results.append(DayResult(date=date_str)) continue # Build blacklist from cooldown @@ -404,8 +1044,19 @@ def run_simulation( strategy, blacklisted_tickers=blacklisted if blacklisted else None, spy_bars=spy_bars, + daily_features_by_ticker=( + { + ticker: daily_enrichment.get(ticker, {}).get(date_str, {}) + for ticker in bars_by_ticker.keys() + } + if daily_enrichment else None + ), + vix_value=(vix_by_day or {}).get(date_str), + current_equity=equity, + ticker_sectors=ticker_sectors, ) results.append(day_result) + equity += day_result.daily_pnl # Update cooldown tracker if strategy.ticker_cooldown_days > 0: