from __future__ import annotations from libs.intraday.domain import StrategyParams from libs.intraday.simulator import ( _same_day_support_score, _select_momentum_sleeves, compute_morning_gains, run_simulation, simulate_day, ) def _bars(day: str, *, open_price: float, closes: list[float], volumes: list[int]) -> list[dict]: times = ["09:30:00", "09:35:00", "09:40:00", "09:45:00", "09:50:00", "15:55:00"] bars: list[dict] = [] prev = open_price for idx, close in enumerate(closes): ts = f"{day}T{times[idx]}-05:00" high = max(prev, close) + 0.2 low = min(prev, close) - 0.2 bars.append( { "timestamp": ts, "open": prev, "high": high, "low": low, "close": close, "volume": volumes[idx], } ) prev = close return bars def test_compute_morning_gains_respects_vix_gate() -> None: strategy = StrategyParams( entry_minutes_after_open=10, min_morning_gain_pct=0.01, max_vix=25.0, ) bars_by_ticker = { "AAA": _bars( "2026-01-05", open_price=10.0, closes=[10.2, 10.5, 10.7, 10.8, 10.9, 11.0], volumes=[100_000, 100_000, 100_000, 100_000, 100_000, 100_000], ) } gains = compute_morning_gains( bars_by_ticker, strategy, "2026-01-05", vix_value=30.0, ) assert gains == {} def test_simulate_day_applies_gap_regime_scaler_and_soft_day_trade_cap() -> None: strategy = StrategyParams( entry_minutes_after_open=10, min_morning_gain_pct=0.01, top_n=2, slippage_bps=0.0, daily_budget_reset=True, initial_capital=10_000.0, market_regime_gap_threshold=-0.03, market_regime_gap_ticker="SPY", regime_size_scale_low=-0.02, regime_size_scale_high=0.0, regime_size_scale_min=0.5, soft_day_scaler_threshold=0.8, soft_day_max_trades=1, ) bars_by_ticker = { "AAA": _bars("2026-01-05", open_price=10.0, closes=[10.2, 10.6, 10.8, 11.0, 11.1, 11.2], volumes=[120_000] * 6), "BBB": _bars("2026-01-05", open_price=20.0, closes=[20.3, 20.8, 21.1, 21.2, 21.3, 21.4], volumes=[120_000] * 6), } day = simulate_day( bars_by_ticker, "2026-01-05", strategy, daily_features_by_ticker={ "AAA": {"prev_close": 9.8, "today_open": 10.0}, "BBB": {"prev_close": 19.7, "today_open": 20.0}, "SPY": {"prev_close": 100.0, "today_open": 99.0}, }, ) assert day.regime_scaler == 0.75 assert day.is_soft_day is True assert len(day.trades) == 1 assert round(day.capital_deployed, 2) == 7500.0 def test_simulate_day_skips_on_candidate_breadth() -> None: strategy = StrategyParams( entry_minutes_after_open=10, min_morning_gain_pct=0.01, top_n=2, min_candidate_breadth=0.75, ) bars_by_ticker = { "UP": _bars("2026-01-05", open_price=10.0, closes=[10.2, 10.6, 10.8, 10.9, 11.0, 11.1], volumes=[120_000] * 6), "DOWN": _bars("2026-01-05", open_price=20.0, closes=[20.2, 20.6, 20.8, 20.9, 21.0, 21.1], volumes=[120_000] * 6), } day = simulate_day( bars_by_ticker, "2026-01-05", strategy, daily_features_by_ticker={ "UP": {"prev_close": 9.5, "today_open": 10.0}, "DOWN": {"prev_close": 21.0, "today_open": 20.0}, }, ) assert day.skip_reason == "breadth" assert day.trades == [] def test_simulate_day_applies_sector_concentration_scaler() -> None: strategy = StrategyParams( entry_minutes_after_open=10, min_morning_gain_pct=0.01, top_n=3, slippage_bps=0.0, daily_budget_reset=True, initial_capital=10_000.0, sector_concentration_scale_low=0.34, sector_concentration_scale_high=0.67, sector_concentration_scale_min=0.75, soft_day_scaler_threshold=0.8, use_five_sleeves=False, ) bars_by_ticker = { "TECH_A": _bars("2026-01-05", open_price=10.0, closes=[10.2, 10.6, 10.8, 11.0, 11.1, 11.2], volumes=[120_000] * 6), "TECH_B": _bars("2026-01-05", open_price=20.0, closes=[20.3, 20.8, 21.0, 21.2, 21.3, 21.4], volumes=[120_000] * 6), "HEALTH": _bars("2026-01-05", open_price=30.0, closes=[30.3, 30.8, 31.0, 31.2, 31.3, 31.4], volumes=[120_000] * 6), } day = simulate_day( bars_by_ticker, "2026-01-05", strategy, ticker_sectors={ "TECH_A": "Technology", "TECH_B": "Technology", "HEALTH": "Healthcare", }, ) assert round(day.sector_scaler or 0.0, 2) == 0.75 assert day.is_soft_day is True assert round(day.capital_deployed, 2) == 7525.25 def test_simulate_day_uses_five_sleeves_and_tags_trade_sleeve() -> None: strategy = StrategyParams( entry_minutes_after_open=10, min_morning_gain_pct=0.01, top_n=3, use_five_sleeves=True, min_entry_volume=50_000, ) bars_by_ticker = { "CORE": _bars("2026-01-05", open_price=10.0, closes=[10.3, 10.8, 11.0, 11.2, 11.3, 11.5], volumes=[200_000] * 6), "GAP": _bars("2026-01-05", open_price=20.0, closes=[20.2, 20.6, 20.8, 20.9, 21.0, 21.1], volumes=[180_000] * 6), "VOL": _bars("2026-01-05", open_price=30.0, closes=[30.2, 30.7, 31.0, 31.2, 31.3, 31.4], volumes=[500_000] * 6), "ENT": _bars("2026-01-05", open_price=40.0, closes=[40.3, 40.8, 41.2, 41.4, 41.5, 41.7], volumes=[160_000] * 6), "TRND": _bars("2026-01-05", open_price=50.0, closes=[50.4, 51.0, 51.4, 51.5, 51.7, 52.0], volumes=[150_000] * 6), } daily_features = { "CORE": {"gap_pct": 0.01, "avg_daily_vol_14d": 1_000_000.0, "ret_5d": 0.02, "entropy_20d": 0.50}, "GAP": {"gap_pct": 0.07, "avg_daily_vol_14d": 1_000_000.0, "ret_5d": 0.01, "entropy_20d": 0.60}, "VOL": {"gap_pct": 0.02, "avg_daily_vol_14d": 600_000.0, "ret_5d": 0.00, "entropy_20d": 0.55}, "ENT": {"gap_pct": 0.02, "avg_daily_vol_14d": 1_000_000.0, "ret_5d": 0.03, "entropy_20d": 0.20}, "TRND": {"gap_pct": 0.03, "avg_daily_vol_14d": 1_000_000.0, "ret_5d": 0.12, "entropy_20d": 0.45}, } day = simulate_day( bars_by_ticker, "2026-01-05", strategy, daily_features_by_ticker=daily_features, ) assert len(day.trades) == 3 sleeves = {trade.trade_sleeve for trade in day.trades} assert sleeves <= {"core", "gap", "volume", "entropy", "trend", "blend"} assert all(trade.trade_sleeve is not None for trade in day.trades) def test_run_simulation_applies_entropy_filter_and_scaler() -> None: strategy = StrategyParams( entry_minutes_after_open=10, min_morning_gain_pct=0.01, top_n=2, max_entropy_20d=0.80, entropy_size_scale_low=0.40, entropy_size_scale_high=0.80, entropy_size_scale_min=0.50, ) all_intraday = { "2026-01-05": { "LOW": _bars("2026-01-05", open_price=10.0, closes=[10.2, 10.8, 11.2, 11.3, 11.4, 11.5], volumes=[120_000] * 6), "HIGH": _bars("2026-01-05", open_price=15.0, closes=[15.2, 15.8, 16.2, 16.3, 16.4, 16.5], volumes=[120_000] * 6), } } enrichment = { "LOW": {"2026-01-05": {"gap_pct": 0.02, "avg_daily_vol_14d": 1_000_000.0, "ret_5d": 0.05, "entropy_20d": 0.40}}, "HIGH": {"2026-01-05": {"gap_pct": 0.02, "avg_daily_vol_14d": 1_000_000.0, "ret_5d": 0.05, "entropy_20d": 0.90}}, } results = run_simulation( all_intraday, ["2026-01-05"], strategy, daily_enrichment=enrichment, ) assert len(results) == 1 assert [trade.ticker for trade in results[0].trades] == ["LOW"] def test_simulate_day_reports_portfolio_return_after_sparse_scaling() -> None: strategy = StrategyParams( entry_minutes_after_open=10, min_morning_gain_pct=0.01, top_n=1, slippage_bps=0.0, daily_budget_reset=True, initial_capital=10_000.0, full_size_positions_threshold=2, sparse_day_size_floor=0.5, ) bars_by_ticker = { "AAA": _bars( "2026-01-05", open_price=10.0, closes=[10.2, 10.5, 10.8, 11.0, 11.1, 11.2], volumes=[120_000] * 6, ) } day = simulate_day( bars_by_ticker, "2026-01-05", strategy, ) assert len(day.trades) == 1 assert day.capital_deployed == 5_000.0 assert round(day.daily_pnl, 2) == 185.19 assert round(day.daily_return_pct, 4) == 0.0185 def test_run_simulation_applies_rolling_loss_pause() -> None: strategy = StrategyParams( entry_minutes_after_open=10, min_morning_gain_pct=0.01, top_n=1, stop_loss_pct=None, slippage_bps=0.0, daily_budget_reset=True, initial_capital=10_000.0, rolling_loss_days=1, rolling_loss_threshold=-0.05, ) all_intraday = { "2026-01-05": { "LOSER": _bars( "2026-01-05", open_price=10.0, closes=[10.2, 10.6, 10.8, 9.8, 9.2, 9.0], volumes=[120_000] * 6, ), }, "2026-01-06": { "WINNER": _bars( "2026-01-06", open_price=10.0, closes=[10.2, 10.6, 10.9, 11.0, 11.1, 11.2], volumes=[120_000] * 6, ), }, } results = run_simulation(all_intraday, ["2026-01-05", "2026-01-06"], strategy) assert len(results) == 2 assert results[0].daily_return_pct < -0.05 assert results[1].skip_reason == "rolling_loss" assert results[1].trades == [] def test_select_momentum_sleeves_respects_weights_and_force_count() -> None: strategy = StrategyParams( top_n=2, use_five_sleeves=True, five_sleeve_force_count=1, five_sleeve_core_weight=0.05, five_sleeve_gap_weight=0.0, five_sleeve_volume_weight=0.0, five_sleeve_entropy_weight=0.90, five_sleeve_trend_weight=0.05, ) morning_gains = { "CORE": { "gain_pct": 0.09, "entry_volume": 150_000, "volume_ratio_14d": 0.03, "gap_pct": 0.01, "ret_5d": 0.02, "entropy_20d": 0.60, }, "ENT": { "gain_pct": 0.05, "entry_volume": 140_000, "volume_ratio_14d": 0.02, "gap_pct": 0.01, "ret_5d": 0.03, "entropy_20d": 0.10, }, "TRND": { "gain_pct": 0.04, "entry_volume": 130_000, "volume_ratio_14d": 0.02, "gap_pct": 0.01, "ret_5d": 0.15, "entropy_20d": 0.55, }, } picks = _select_momentum_sleeves(morning_gains, strategy) assert picks[0] == ("ENT", "entropy") assert len(picks) == 2 def test_select_momentum_sleeves_respects_sector_cap() -> None: strategy = StrategyParams( top_n=3, use_five_sleeves=False, max_positions_per_sector=1, ) morning_gains = { "TECH_A": {"gain_pct": 0.10, "entry_volume": 300_000}, "TECH_B": {"gain_pct": 0.09, "entry_volume": 280_000}, "HEALTH": {"gain_pct": 0.08, "entry_volume": 260_000}, "FIN": {"gain_pct": 0.07, "entry_volume": 250_000}, } picks = _select_momentum_sleeves( morning_gains, strategy, ticker_sectors={ "TECH_A": "Technology", "TECH_B": "Technology", "HEALTH": "Healthcare", "FIN": "Financial Services", }, ) assert [ticker for ticker, _ in picks] == ["TECH_A", "HEALTH", "FIN"] def test_select_momentum_sleeves_prunes_weak_tail_with_quality_floor() -> None: strategy = StrategyParams( top_n=3, use_five_sleeves=True, five_sleeve_force_count=0, five_sleeve_core_weight=1.0, five_sleeve_gap_weight=0.0, five_sleeve_volume_weight=0.0, five_sleeve_entropy_weight=0.0, five_sleeve_trend_weight=0.0, basket_quality_relative_floor=0.5, basket_quality_min_count=2, ) morning_gains = { "STRONG": { "gain_pct": 0.10, "entry_volume": 300_000, "volume_ratio_14d": 0.08, "gap_pct": 0.03, "ret_5d": 0.12, "entropy_20d": 0.30, }, "MID": { "gain_pct": 0.06, "entry_volume": 250_000, "volume_ratio_14d": 0.05, "gap_pct": 0.02, "ret_5d": 0.08, "entropy_20d": 0.40, }, "WEAK": { "gain_pct": 0.025, "entry_volume": 200_000, "volume_ratio_14d": 0.02, "gap_pct": 0.005, "ret_5d": 0.01, "entropy_20d": 0.70, }, } picks = _select_momentum_sleeves(morning_gains, strategy) assert [ticker for ticker, _ in picks] == ["STRONG", "MID"] def test_select_momentum_sleeves_prunes_only_blend_tail_when_configured() -> None: strategy = StrategyParams( top_n=3, use_five_sleeves=True, five_sleeve_force_count=1, five_sleeve_core_weight=1.0, five_sleeve_gap_weight=0.0, five_sleeve_volume_weight=0.0, five_sleeve_entropy_weight=0.0, five_sleeve_trend_weight=0.0, basket_quality_relative_floor=0.5, basket_quality_min_count=2, basket_quality_prune_blend_only=True, ) morning_gains = { "FORCED": { "gain_pct": 0.10, "entry_volume": 300_000, "volume_ratio_14d": 0.08, "gap_pct": 0.03, "ret_5d": 0.12, "entropy_20d": 0.30, }, "MID": { "gain_pct": 0.06, "entry_volume": 250_000, "volume_ratio_14d": 0.05, "gap_pct": 0.02, "ret_5d": 0.08, "entropy_20d": 0.40, }, "WEAK": { "gain_pct": 0.025, "entry_volume": 200_000, "volume_ratio_14d": 0.02, "gap_pct": 0.005, "ret_5d": 0.01, "entropy_20d": 0.70, }, } picks = _select_momentum_sleeves(morning_gains, strategy) assert picks[0] == ("FORCED", "core") assert [ticker for ticker, _ in picks] == ["FORCED", "MID"] def test_compute_morning_gains_applies_confirmation_filter_and_later_entry() -> None: strategy = StrategyParams( entry_minutes_after_open=10, confirmation_minutes_after_entry=5, min_confirmation_return_pct=0.0, min_morning_gain_pct=0.01, ) bars_by_ticker = { "KEEP": _bars( "2026-01-13", open_price=10.0, closes=[10.2, 10.6, 10.8, 11.0, 11.1, 11.2], volumes=[100_000] * 6, ), "DROP": _bars( "2026-01-13", open_price=10.0, closes=[10.2, 10.6, 10.8, 10.7, 10.6, 10.5], volumes=[100_000] * 6, ), } gains = compute_morning_gains( bars_by_ticker, strategy, "2026-01-13", ) assert list(gains) == ["KEEP"] assert gains["KEEP"]["entry_bar"]["timestamp"].endswith("09:45:00-05:00") def test_compute_morning_gains_allows_slow_ignite_below_primary_gain_floor() -> None: strategy = StrategyParams( entry_minutes_after_open=10, confirmation_minutes_after_entry=5, min_confirmation_return_pct=0.0005, min_morning_gain_pct=0.015, use_slow_ignite_sleeve=True, slow_ignite_weight=0.08, slow_ignite_min_gain_pct=0.005, slow_ignite_max_gain_pct=0.015, slow_ignite_min_entry_dollar_volume=50_000_000, slow_ignite_min_volume_ratio_14d=0.08, slow_ignite_min_ret_5d=0.03, slow_ignite_max_entropy_20d=0.80, ) bars_by_ticker = { "SLOW": _bars( "2026-01-13", open_price=100.0, closes=[100.2, 100.4, 100.6, 101.2, 101.3, 101.5], volumes=[250_000, 250_000, 250_000, 250_000, 250_000, 250_000], ), } gains = compute_morning_gains( bars_by_ticker, strategy, "2026-01-13", daily_features_by_ticker={ "SLOW": { "avg_daily_vol_14d": 10_000_000.0, "ret_5d": 0.06, "entropy_20d": 0.70, "gap_pct": 0.01, } }, ) assert list(gains) == ["SLOW"] assert gains["SLOW"]["is_slow_ignite"] is True assert gains["SLOW"]["gain_pct"] < strategy.min_morning_gain_pct def test_select_momentum_sleeves_can_force_slow_ignite_pick() -> None: strategy = StrategyParams( top_n=2, use_five_sleeves=True, use_slow_ignite_sleeve=True, five_sleeve_core_weight=0.0, five_sleeve_gap_weight=0.0, five_sleeve_volume_weight=0.0, five_sleeve_entropy_weight=0.0, five_sleeve_trend_weight=0.0, slow_ignite_weight=1.0, five_sleeve_force_count=1, ) morning_gains = { "FAST": { "gain_pct": 0.06, "entry_volume": 300_000, "entry_dollar_volume": 20_000_000.0, "volume_ratio_14d": 0.03, "gap_pct": 0.01, "ret_5d": 0.02, "entropy_20d": 0.55, "confirmation_return_pct": 0.001, "is_slow_ignite": False, }, "SLOW": { "gain_pct": 0.009, "entry_volume": 1_000_000, "entry_dollar_volume": 150_000_000.0, "volume_ratio_14d": 0.12, "gap_pct": 0.008, "ret_5d": 0.08, "entropy_20d": 0.70, "confirmation_return_pct": 0.006, "is_slow_ignite": True, }, } picks = _select_momentum_sleeves(morning_gains, strategy) assert ("SLOW", "slow_ignite") in picks def test_compute_morning_gains_allows_gap_reclaim_candidates() -> None: strategy = StrategyParams( entry_minutes_after_open=10, confirmation_minutes_after_entry=5, min_morning_gain_pct=0.015, min_confirmation_return_pct=0.005, max_gap_pct=0.055, use_gap_reclaim_sleeve=True, gap_reclaim_weight=0.05, gap_reclaim_min_gap_pct=0.10, gap_reclaim_min_gain_pct=-0.03, gap_reclaim_max_gain_pct=0.003, gap_reclaim_min_confirmation_return_pct=0.0015, gap_reclaim_min_entry_dollar_volume=100_000_000, gap_reclaim_min_recovery_from_opening_low_pct=0.005, ) bars_by_ticker = { "RECLAIM": _bars( "2026-01-13", open_price=100.0, closes=[96.0, 95.0, 97.8, 98.0, 98.5, 101.0], volumes=[400_000, 400_000, 400_000, 400_000, 400_000, 400_000], ), } gains = compute_morning_gains( bars_by_ticker, strategy, "2026-01-13", daily_features_by_ticker={ "RECLAIM": { "avg_daily_vol_14d": 5_000_000.0, "avg_dollar_vol_30d": 800_000_000.0, "ret_5d": 0.02, "entropy_20d": None, "gap_pct": 0.18, } }, ) assert list(gains) == ["RECLAIM"] assert gains["RECLAIM"]["is_gap_reclaim"] is True assert gains["RECLAIM"]["gain_pct"] < 0.0 assert gains["RECLAIM"]["recovery_from_opening_low_pct"] > 0.005 def test_select_momentum_sleeves_can_force_gap_reclaim_pick() -> None: strategy = StrategyParams( top_n=1, use_five_sleeves=True, use_gap_reclaim_sleeve=True, five_sleeve_core_weight=0.0, five_sleeve_gap_weight=0.0, five_sleeve_volume_weight=0.0, five_sleeve_entropy_weight=0.0, five_sleeve_trend_weight=0.0, gap_reclaim_weight=1.0, five_sleeve_force_count=1, ) morning_gains = { "FAST": { "gain_pct": 0.05, "entry_volume": 200_000, "entry_dollar_volume": 10_000_000.0, "gap_pct": 0.02, "confirmation_return_pct": 0.002, "recovery_from_opening_low_pct": 0.002, "is_gap_reclaim": False, }, "RECLAIM": { "gain_pct": -0.015, "entry_volume": 900_000, "entry_dollar_volume": 220_000_000.0, "gap_pct": 0.21, "confirmation_return_pct": 0.003, "recovery_from_opening_low_pct": 0.01, "is_gap_reclaim": True, }, } picks = _select_momentum_sleeves(morning_gains, strategy) assert ("RECLAIM", "gap_reclaim") in picks def test_compute_morning_gains_allows_liquid_largecap_candidates() -> None: strategy = StrategyParams( entry_minutes_after_open=10, confirmation_minutes_after_entry=5, min_morning_gain_pct=0.015, min_confirmation_return_pct=0.0005, use_liquid_largecap_sleeve=True, liquid_largecap_weight=0.3, liquid_largecap_min_gain_pct=0.004, liquid_largecap_max_gain_pct=0.02, liquid_largecap_min_confirmation_return_pct=0.0005, liquid_largecap_min_entry_dollar_volume=50_000_000, liquid_largecap_min_avg_dollar_vol_30d=500_000_000, liquid_largecap_max_entropy_20d=0.90, ) bars_by_ticker = { "LQ": _bars( "2026-01-13", open_price=100.0, closes=[100.2, 100.5, 100.8, 101.4, 101.6, 101.8], volumes=[500_000, 500_000, 500_000, 500_000, 500_000, 500_000], ), } gains = compute_morning_gains( bars_by_ticker, strategy, "2026-01-13", daily_features_by_ticker={ "LQ": { "avg_daily_vol_14d": 10_000_000.0, "avg_dollar_vol_30d": 1_000_000_000.0, "ret_5d": 0.01, "entropy_20d": 0.88, "gap_pct": 0.01, } }, ) assert list(gains) == ["LQ"] assert gains["LQ"]["is_liquid_largecap"] is True def test_compute_morning_gains_can_relax_global_entropy_for_liquid_largecap() -> None: strategy = StrategyParams( entry_minutes_after_open=10, confirmation_minutes_after_entry=5, min_morning_gain_pct=0.015, min_confirmation_return_pct=0.0005, max_entropy_20d=0.86, use_liquid_largecap_sleeve=True, liquid_largecap_weight=0.3, liquid_largecap_min_gain_pct=0.004, liquid_largecap_max_gain_pct=0.02, liquid_largecap_min_confirmation_return_pct=0.0005, liquid_largecap_min_entry_dollar_volume=50_000_000, liquid_largecap_min_avg_dollar_vol_30d=500_000_000, liquid_largecap_max_entropy_20d=0.87, ) bars_by_ticker = { "LQ": _bars( "2026-01-13", open_price=100.0, closes=[100.2, 100.5, 100.8, 101.4, 101.6, 101.8], volumes=[500_000, 500_000, 500_000, 500_000, 500_000, 500_000], ), } gains = compute_morning_gains( bars_by_ticker, strategy, "2026-01-13", daily_features_by_ticker={ "LQ": { "avg_daily_vol_14d": 10_000_000.0, "avg_dollar_vol_30d": 1_000_000_000.0, "ret_5d": 0.01, "entropy_20d": 0.865, "gap_pct": 0.01, } }, ) assert list(gains) == ["LQ"] assert gains["LQ"]["is_liquid_largecap"] is True def test_select_momentum_sleeves_can_force_liquid_largecap_pick() -> None: strategy = StrategyParams( top_n=1, use_five_sleeves=True, use_liquid_largecap_sleeve=True, five_sleeve_core_weight=0.0, five_sleeve_gap_weight=0.0, five_sleeve_volume_weight=0.0, five_sleeve_entropy_weight=0.0, five_sleeve_trend_weight=0.0, liquid_largecap_weight=1.0, five_sleeve_force_count=1, ) morning_gains = { "FAST": { "gain_pct": 0.06, "entry_volume": 150_000, "entry_dollar_volume": 5_000_000.0, "avg_dollar_vol_30d": 100_000_000.0, "confirmation_return_pct": 0.003, "volume_ratio_14d": 0.03, "gap_pct": 0.01, "ret_5d": 0.02, "entropy_20d": 0.50, "is_liquid_largecap": False, }, "LQ": { "gain_pct": 0.008, "entry_volume": 500_000, "entry_dollar_volume": 80_000_000.0, "avg_dollar_vol_30d": 1_500_000_000.0, "confirmation_return_pct": 0.001, "volume_ratio_14d": 0.12, "gap_pct": 0.01, "ret_5d": 0.01, "entropy_20d": 0.85, "is_liquid_largecap": True, }, } picks = _select_momentum_sleeves(morning_gains, strategy) assert ("LQ", "liquid_largecap") in picks def test_compute_morning_gains_allows_moderate_gap_liquid_followthrough() -> None: strategy = StrategyParams( entry_minutes_after_open=10, confirmation_minutes_after_entry=5, min_confirmation_return_pct=0.01, min_morning_gain_pct=0.04, use_moderate_gap_liquid_sleeve=True, moderate_gap_liquid_weight=0.2, moderate_gap_liquid_min_gap_pct=0.005, moderate_gap_liquid_max_gap_pct=0.025, moderate_gap_liquid_min_gain_pct=0.015, moderate_gap_liquid_max_gain_pct=0.04, moderate_gap_liquid_min_confirmation_return_pct=0.005, moderate_gap_liquid_min_entry_dollar_volume=40_000_000, moderate_gap_liquid_min_avg_dollar_vol_30d=250_000_000, moderate_gap_liquid_max_avg_dollar_vol_30d=2_000_000_000, moderate_gap_liquid_max_entropy_20d=0.86, ) bars_by_ticker = { "TER": _bars( "2026-01-13", open_price=100.0, closes=[100.4, 100.8, 101.2, 101.9, 102.0, 102.5], volumes=[150_000, 150_000, 150_000, 150_000, 150_000, 150_000], ), } gains = compute_morning_gains( bars_by_ticker, strategy, "2026-01-13", daily_features_by_ticker={ "TER": { "gap_pct": 0.012, "avg_daily_vol_14d": 8_000_000.0, "avg_dollar_vol_30d": 750_000_000.0, "entropy_20d": 0.79, "atr_14": 5.0, } }, ) assert list(gains) == ["TER"] assert gains["TER"]["is_moderate_gap_liquid"] is True assert gains["TER"]["gain_pct"] < strategy.min_morning_gain_pct def test_select_momentum_sleeves_can_force_moderate_gap_liquid_pick() -> None: strategy = StrategyParams( top_n=1, use_five_sleeves=True, use_moderate_gap_liquid_sleeve=True, five_sleeve_core_weight=0.0, five_sleeve_gap_weight=0.0, five_sleeve_volume_weight=0.0, five_sleeve_entropy_weight=0.0, five_sleeve_trend_weight=0.0, moderate_gap_liquid_weight=1.0, five_sleeve_force_count=1, ) morning_gains = { "FAST": { "gain_pct": 0.06, "entry_volume": 150_000, "entry_dollar_volume": 5_000_000.0, "avg_dollar_vol_30d": 100_000_000.0, "confirmation_return_pct": 0.003, "volume_ratio_14d": 0.03, "gap_pct": 0.04, "entropy_20d": 0.50, "is_moderate_gap_liquid": False, }, "TER": { "gain_pct": 0.019, "entry_volume": 500_000, "entry_dollar_volume": 55_000_000.0, "avg_dollar_vol_30d": 750_000_000.0, "confirmation_return_pct": 0.011, "volume_ratio_14d": 0.08, "gap_pct": 0.012, "entropy_20d": 0.79, "is_moderate_gap_liquid": True, }, } picks = _select_momentum_sleeves(morning_gains, strategy) assert picks == [("TER", "moderate_gap_liquid")] def test_select_momentum_sleeves_can_force_sector_thrust_pick() -> None: strategy = StrategyParams( top_n=1, use_five_sleeves=True, use_sector_thrust_sleeve=True, five_sleeve_core_weight=0.0, five_sleeve_gap_weight=0.0, five_sleeve_volume_weight=0.0, five_sleeve_entropy_weight=0.0, five_sleeve_trend_weight=0.0, sector_thrust_weight=1.0, five_sleeve_force_count=1, sector_thrust_min_members=2, sector_thrust_min_gain_pct=0.015, sector_thrust_min_confirmation_return_pct=0.004, sector_thrust_min_entry_dollar_volume=50_000_000.0, sector_thrust_min_avg_dollar_vol_30d=500_000_000.0, sector_thrust_min_sector_avg_confirmation_return_pct=0.004, sector_thrust_min_sector_total_entry_dollar_volume=120_000_000.0, ) morning_gains = { "ALLY_A": { "gain_pct": 0.03, "entry_volume": 600_000, "entry_dollar_volume": 80_000_000.0, "avg_dollar_vol_30d": 900_000_000.0, "confirmation_return_pct": 0.006, }, "ALLY_B": { "gain_pct": 0.028, "entry_volume": 500_000, "entry_dollar_volume": 70_000_000.0, "avg_dollar_vol_30d": 850_000_000.0, "confirmation_return_pct": 0.005, }, "SOLO": { "gain_pct": 0.05, "entry_volume": 550_000, "entry_dollar_volume": 90_000_000.0, "avg_dollar_vol_30d": 1_000_000_000.0, "confirmation_return_pct": 0.007, }, } picks = _select_momentum_sleeves( morning_gains, strategy, ticker_sectors={ "ALLY_A": "Technology", "ALLY_B": "Technology", "SOLO": "Energy", }, ) assert picks == [("ALLY_A", "sector_thrust")] def test_select_momentum_sleeves_can_use_liquid_continuation_selection_mode() -> None: strategy = StrategyParams( top_n=2, momentum_selection_mode="liquid_continuation", ) morning_gains = { "HOT": { "gain_pct": 0.05, "confirmation_return_pct": 0.002, "entry_dollar_volume": 4_000_000.0, "avg_dollar_vol_30d": 30_000_000.0, "entropy_20d": 0.82, "is_liquid_largecap": False, "is_moderate_gap_liquid": False, "is_sector_thrust": False, }, "LIQ": { "gain_pct": 0.015, "confirmation_return_pct": 0.006, "entry_dollar_volume": 90_000_000.0, "avg_dollar_vol_30d": 3_000_000_000.0, "entropy_20d": 0.72, "is_liquid_largecap": True, "is_moderate_gap_liquid": True, "is_sector_thrust": False, }, } picks = _select_momentum_sleeves(morning_gains, strategy) assert picks == [("LIQ", "liquid_continuation_core")] def test_simulate_day_records_sector_thrust_trade_diagnostics() -> None: strategy = StrategyParams( entry_minutes_after_open=10, confirmation_minutes_after_entry=5, min_confirmation_return_pct=0.0, min_morning_gain_pct=0.01, top_n=1, slippage_bps=0.0, use_five_sleeves=True, use_sector_thrust_sleeve=True, five_sleeve_core_weight=0.0, five_sleeve_gap_weight=0.0, five_sleeve_volume_weight=0.0, five_sleeve_entropy_weight=0.0, five_sleeve_trend_weight=0.0, sector_thrust_weight=1.0, five_sleeve_force_count=1, sector_thrust_min_members=2, sector_thrust_min_gain_pct=0.015, sector_thrust_min_confirmation_return_pct=0.004, sector_thrust_min_entry_dollar_volume=50_000_000.0, sector_thrust_min_avg_dollar_vol_30d=500_000_000.0, ) bars_by_ticker = { "ALLY_A": _bars( "2026-01-13", open_price=100.0, closes=[100.2, 100.8, 101.2, 101.8, 102.0, 102.5], volumes=[180_000, 180_000, 180_000, 180_000, 180_000, 180_000], ), "ALLY_B": _bars( "2026-01-13", open_price=80.0, closes=[80.2, 80.7, 81.0, 81.4, 81.6, 81.9], volumes=[170_000, 170_000, 170_000, 170_000, 170_000, 170_000], ), "SOLO": _bars( "2026-01-13", open_price=50.0, closes=[50.2, 50.8, 51.2, 51.7, 51.8, 52.0], volumes=[220_000, 220_000, 220_000, 220_000, 220_000, 220_000], ), } day = simulate_day( bars_by_ticker, "2026-01-13", strategy, daily_features_by_ticker={ "ALLY_A": {"avg_daily_vol_14d": 2_000_000.0, "avg_dollar_vol_30d": 900_000_000.0}, "ALLY_B": {"avg_daily_vol_14d": 2_000_000.0, "avg_dollar_vol_30d": 850_000_000.0}, "SOLO": {"avg_daily_vol_14d": 2_000_000.0, "avg_dollar_vol_30d": 1_200_000_000.0}, }, ticker_sectors={ "ALLY_A": "Technology", "ALLY_B": "Technology", "SOLO": "Energy", }, ) assert len(day.trades) == 1 assert day.trades[0].trade_sleeve == "sector_thrust" assert day.trades[0].is_sector_thrust is True assert day.trades[0].sector_thrust_member_count == 2 assert day.trades[0].sector_thrust_total_entry_dollar_volume is not None assert day.trades[0].sector_thrust_total_entry_dollar_volume > 120_000_000.0 def test_simulate_day_can_enable_event_sleeve_only_on_soft_days() -> None: strategy = StrategyParams( entry_minutes_after_open=10, min_morning_gain_pct=0.01, top_n=1, use_five_sleeves=True, use_event_sleeve=True, event_weight=1.0, event_min_score=1.0, event_sleeve_soft_day_only=True, five_sleeve_core_weight=0.2, five_sleeve_gap_weight=0.0, five_sleeve_volume_weight=0.0, five_sleeve_entropy_weight=0.0, five_sleeve_trend_weight=0.0, five_sleeve_force_count=1, min_entry_volume=50_000, market_regime_gap_threshold=-0.03, market_regime_gap_ticker="SPY", regime_size_scale_low=-0.02, regime_size_scale_high=0.0, regime_size_scale_min=0.5, soft_day_scaler_threshold=0.8, ) bars_by_ticker = { "FAST": _bars( "2026-01-16", open_price=10.0, closes=[10.2, 10.5, 10.8, 10.9, 11.0, 11.1], volumes=[120_000] * 6, ), "CAT": _bars( "2026-01-16", open_price=20.0, closes=[20.15, 20.35, 20.45, 20.55, 20.6, 20.7], volumes=[120_000] * 6, ), } day = simulate_day( bars_by_ticker, "2026-01-16", strategy, daily_features_by_ticker={ "FAST": {"gap_pct": 0.01, "avg_daily_vol_14d": 1_000_000.0, "ret_5d": 0.01, "entropy_20d": 0.45}, "CAT": { "gap_pct": 0.01, "avg_daily_vol_14d": 1_000_000.0, "ret_5d": 0.01, "entropy_20d": 0.45, "event_flag": True, "event_score": 1.5, }, "SPY": {"prev_close": 100.0, "today_open": 99.0}, }, ) assert day.is_soft_day is True assert [trade.ticker for trade in day.trades] == ["CAT"] assert [trade.trade_sleeve for trade in day.trades] == ["event"] def test_simulate_day_can_keep_event_sleeve_off_when_soft_day_basket_is_still_strong() -> None: strategy = StrategyParams( entry_minutes_after_open=10, min_morning_gain_pct=0.01, top_n=1, use_five_sleeves=True, use_event_sleeve=True, event_weight=1.0, event_min_score=1.0, event_sleeve_soft_day_only=True, event_sleeve_soft_day_max_avg_quality=0.01, five_sleeve_core_weight=0.2, five_sleeve_gap_weight=0.0, five_sleeve_volume_weight=0.0, five_sleeve_entropy_weight=0.0, five_sleeve_trend_weight=0.0, five_sleeve_force_count=1, min_entry_volume=50_000, market_regime_gap_threshold=-0.03, market_regime_gap_ticker="SPY", regime_size_scale_low=-0.02, regime_size_scale_high=0.0, regime_size_scale_min=0.5, soft_day_scaler_threshold=0.8, ) bars_by_ticker = { "FAST": _bars( "2026-01-16", open_price=10.0, closes=[10.2, 10.5, 10.8, 10.9, 11.0, 11.1], volumes=[120_000] * 6, ), "CAT": _bars( "2026-01-16", open_price=20.0, closes=[20.15, 20.35, 20.45, 20.55, 20.6, 20.7], volumes=[120_000] * 6, ), } day = simulate_day( bars_by_ticker, "2026-01-16", strategy, daily_features_by_ticker={ "FAST": {"gap_pct": 0.01, "avg_daily_vol_14d": 1_000_000.0, "ret_5d": 0.01, "entropy_20d": 0.45}, "CAT": { "gap_pct": 0.01, "avg_daily_vol_14d": 1_000_000.0, "ret_5d": 0.01, "entropy_20d": 0.45, "event_flag": True, "event_score": 1.5, }, "SPY": {"prev_close": 100.0, "today_open": 99.0}, }, ) assert day.is_soft_day is True assert [trade.ticker for trade in day.trades] == ["FAST"] assert [trade.trade_sleeve for trade in day.trades] == ["core"] def test_simulate_day_can_apply_tail_risk_day_scaler() -> None: strategy = StrategyParams( entry_minutes_after_open=10, min_morning_gain_pct=0.01, top_n=1, slippage_bps=0.0, daily_budget_reset=True, initial_capital=10_000.0, tail_risk_day_max_trades=1, tail_risk_day_min_max_gain_pct=0.04, tail_risk_day_max_avg_quality=5.0, tail_risk_day_require_no_event=True, tail_risk_day_exempt_largecap=True, tail_risk_day_scale=0.5, ) bars_by_ticker = { "TAIL": _bars( "2026-01-21", open_price=10.0, closes=[10.1, 10.35, 10.5, 10.55, 10.45, 10.4], volumes=[120_000] * 6, ), } day = simulate_day( bars_by_ticker, "2026-01-21", strategy, daily_features_by_ticker={ "TAIL": { "gap_pct": 0.01, "avg_daily_vol_14d": 1_000_000.0, "avg_dollar_vol_30d": 100_000_000.0, "ret_5d": 0.01, "entropy_20d": 0.45, }, }, ) assert day.tail_risk_scaler == 0.5 assert round(day.capital_deployed, 2) == 5000.0 assert len(day.trades) == 1 def test_same_day_support_score_prefers_prior_and_entry_liquidity_over_weak_spike() -> None: strong_liquidity = _same_day_support_score( { "avg_dollar_vol_30d": 90_000_000.0, "entry_dollar_volume": 12_000_000.0, "attention_wiki_spike_10d": 0.0, "attention_article_count_3d": 0, "attention_us_article_count_3d": 0, "event_score": 0.0, } ) weak_single_name = _same_day_support_score( { "avg_dollar_vol_30d": 50_000_000.0, "entry_dollar_volume": 18_000_000.0, "attention_wiki_spike_10d": 0.8, "attention_article_count_3d": 0, "attention_us_article_count_3d": 0, "event_score": 0.0, } ) assert round(strong_liquidity, 3) == 0.38 assert round(weak_single_name, 3) == 0.222 assert strong_liquidity > weak_single_name def test_simulate_day_can_apply_tail_risk_scaler_on_weak_support_single_name() -> None: strategy = StrategyParams( entry_minutes_after_open=15, confirmation_minutes_after_entry=5, min_morning_gain_pct=0.01, top_n=1, slippage_bps=0.0, daily_budget_reset=True, initial_capital=10_000.0, tail_risk_day_max_trades=1, tail_risk_day_min_max_gain_pct=0.03, tail_risk_day_max_support_score=0.25, tail_risk_day_min_max_entropy_20d=0.80, tail_risk_day_min_max_confirmation_return_pct=0.01, tail_risk_day_exempt_largecap=True, tail_risk_day_scale=0.6, ) day = simulate_day( { "TAIL": _bars( "2026-01-13", open_price=72.64, closes=[73.75, 73.02, 73.52, 76.235, 77.1, 70.965], volumes=[56_506, 51_911, 16_479, 120_666, 78_459, 33_740], ), }, "2026-01-13", strategy, daily_features_by_ticker={ "TAIL": { "gap_pct": 0.0308, "avg_daily_vol_14d": 853_044.0, "avg_dollar_vol_30d": 51_257_180.0, "ret_5d": -0.0023, "entropy_20d": 0.8333, "attention_wiki_spike_10d": 0.806, }, }, ) assert day.tail_risk_scaler == 0.6 assert round(day.capital_deployed, 2) == 6000.0 assert len(day.trades) == 1 def test_tail_risk_event_exemption_requires_support_when_configured() -> None: strategy = StrategyParams( entry_minutes_after_open=10, confirmation_minutes_after_entry=5, min_morning_gain_pct=0.01, top_n=1, slippage_bps=0.0, daily_budget_reset=True, initial_capital=10_000.0, use_event_sleeve=True, event_min_score=1.0, tail_risk_day_max_trades=1, tail_risk_day_min_max_gain_pct=0.03, tail_risk_day_max_support_score=0.35, tail_risk_day_min_max_confirmation_return_pct=0.01, tail_risk_day_require_no_event=True, tail_risk_day_event_exemption_min_support_score=0.35, tail_risk_day_scale=0.5, ) day = simulate_day( { "WEAK_EVENT": _bars( "2026-02-18", open_price=10.0, closes=[10.1, 10.35, 10.5, 10.7, 10.6, 10.2], volumes=[120_000] * 6, ), }, "2026-02-18", strategy, daily_features_by_ticker={ "WEAK_EVENT": { "gap_pct": 0.04, "avg_daily_vol_14d": 1_000_000.0, "avg_dollar_vol_30d": 25_000_000.0, "ret_5d": 0.07, "entropy_20d": 0.82, "event_flag": True, "event_score": 1.0, }, }, ) assert day.tail_risk_scaler == 0.5 assert round(day.capital_deployed, 2) == 5000.0 assert day.trades[0].support_score == 0.2 def test_tail_risk_event_exemption_keeps_supported_event_full_size() -> None: strategy = StrategyParams( entry_minutes_after_open=10, confirmation_minutes_after_entry=5, min_morning_gain_pct=0.01, top_n=1, slippage_bps=0.0, daily_budget_reset=True, initial_capital=10_000.0, use_event_sleeve=True, event_min_score=1.0, tail_risk_day_max_trades=1, tail_risk_day_min_max_gain_pct=0.03, tail_risk_day_max_support_score=0.60, tail_risk_day_min_max_confirmation_return_pct=0.01, tail_risk_day_require_no_event=True, tail_risk_day_event_exemption_min_support_score=0.35, tail_risk_day_scale=0.5, ) day = simulate_day( { "SUPPORTED_EVENT": _bars( "2026-02-18", open_price=10.0, closes=[10.1, 10.35, 10.5, 10.7, 10.6, 10.2], volumes=[500_000] * 6, ), }, "2026-02-18", strategy, daily_features_by_ticker={ "SUPPORTED_EVENT": { "gap_pct": 0.04, "avg_daily_vol_14d": 3_000_000.0, "avg_dollar_vol_30d": 100_000_000.0, "ret_5d": 0.07, "entropy_20d": 0.82, "event_flag": True, "event_score": 1.0, }, }, ) assert day.tail_risk_scaler == 1.0 assert round(day.capital_deployed, 2) == 10000.0 assert day.trades[0].support_score is not None assert day.trades[0].support_score >= 0.35 def test_low_momentum_single_name_scaler_reduces_weak_single_pick() -> None: strategy = StrategyParams( entry_minutes_after_open=10, confirmation_minutes_after_entry=5, min_morning_gain_pct=0.01, top_n=1, slippage_bps=0.0, daily_budget_reset=True, initial_capital=10_000.0, low_momentum_single_name_max_gain_pct=0.025, low_momentum_single_name_require_no_event=True, low_momentum_single_name_exempt_largecap=True, low_momentum_single_name_scale=0.55, ) day = simulate_day( { "LOW": _bars( "2025-12-15", open_price=100.0, closes=[100.8, 101.1, 101.4, 101.8, 101.7, 97.0], volumes=[100_000] * 6, ), }, "2025-12-15", strategy, daily_features_by_ticker={ "LOW": { "gap_pct": 0.02, "avg_daily_vol_14d": 1_000_000.0, "avg_dollar_vol_30d": 150_000_000.0, "ret_5d": 0.02, "entropy_20d": 0.75, "event_flag": False, "event_score": 0.0, }, }, ) assert day.tail_risk_scaler == 0.55 assert round(day.capital_deployed, 2) == 5500.0 assert len(day.trades) == 1 def test_soft_day_sparse_scaler_reduces_unsupported_soft_basket() -> None: strategy = StrategyParams( entry_minutes_after_open=10, confirmation_minutes_after_entry=5, min_morning_gain_pct=0.01, top_n=1, slippage_bps=0.0, daily_budget_reset=True, initial_capital=10_000.0, market_regime_gap_threshold=-0.03, market_regime_gap_ticker="SPY", regime_size_scale_low=-0.02, regime_size_scale_high=0.0, regime_size_scale_min=0.5, soft_day_scaler_threshold=0.8, soft_day_sparse_max_trades=2, soft_day_sparse_require_no_event=True, soft_day_sparse_exempt_largecap=True, soft_day_sparse_exempt_moderate_gap_liquid=True, soft_day_sparse_scale=0.7, ) day = simulate_day( { "FRAGILE": _bars( "2026-01-28", open_price=20.0, closes=[20.1, 20.3, 20.5, 20.7, 20.9, 19.8], volumes=[100_000] * 6, ), }, "2026-01-28", strategy, daily_features_by_ticker={ "SPY": {"prev_close": 100.0, "today_open": 98.0}, "FRAGILE": { "gap_pct": 0.015, "avg_daily_vol_14d": 1_000_000.0, "avg_dollar_vol_30d": 120_000_000.0, "ret_5d": 0.01, "entropy_20d": 0.75, "event_flag": False, "event_score": 0.0, }, }, ) assert day.is_soft_day is True assert day.soft_day_sparse_scaler == 0.7 assert day.tail_risk_scaler == 0.7 assert round(day.capital_deployed, 2) == 3500.0 def test_soft_day_sparse_scaler_keeps_supported_moderate_liquid_basket_full_size() -> None: strategy = StrategyParams( entry_minutes_after_open=10, confirmation_minutes_after_entry=5, min_morning_gain_pct=0.01, top_n=1, slippage_bps=0.0, daily_budget_reset=True, initial_capital=10_000.0, market_regime_gap_threshold=-0.03, market_regime_gap_ticker="SPY", regime_size_scale_low=-0.02, regime_size_scale_high=0.0, regime_size_scale_min=0.5, soft_day_scaler_threshold=0.8, soft_day_sparse_max_trades=2, soft_day_sparse_require_no_event=True, soft_day_sparse_exempt_largecap=True, soft_day_sparse_exempt_moderate_gap_liquid=True, soft_day_sparse_scale=0.7, use_moderate_gap_liquid_sleeve=True, moderate_gap_liquid_min_gap_pct=0.005, moderate_gap_liquid_max_gap_pct=0.025, moderate_gap_liquid_min_gain_pct=0.015, moderate_gap_liquid_max_gain_pct=0.04, moderate_gap_liquid_min_confirmation_return_pct=0.005, moderate_gap_liquid_min_entry_dollar_volume=20_000_000.0, moderate_gap_liquid_min_avg_dollar_vol_30d=250_000_000.0, moderate_gap_liquid_max_avg_dollar_vol_30d=2_000_000_000.0, moderate_gap_liquid_min_volume_ratio_14d=0.04, moderate_gap_liquid_max_entropy_20d=0.86, ) day = simulate_day( { "TER": _bars( "2026-01-29", open_price=100.0, closes=[100.4, 101.0, 101.7, 102.3, 102.7, 103.0], volumes=[500_000] * 6, ), }, "2026-01-29", strategy, daily_features_by_ticker={ "SPY": {"prev_close": 100.0, "today_open": 98.0}, "TER": { "gap_pct": 0.012, "avg_daily_vol_14d": 8_000_000.0, "avg_dollar_vol_30d": 750_000_000.0, "ret_5d": 0.03, "entropy_20d": 0.79, "event_flag": False, "event_score": 0.0, }, }, ) assert day.is_soft_day is True assert day.soft_day_sparse_scaler == 1.0 assert day.tail_risk_scaler == 1.0 assert round(day.capital_deployed, 2) == 5000.0 assert day.trades[0].is_moderate_gap_liquid is True def test_simulate_day_keeps_full_size_for_supported_single_name() -> None: strategy = StrategyParams( entry_minutes_after_open=15, confirmation_minutes_after_entry=5, min_morning_gain_pct=0.01, top_n=1, slippage_bps=0.0, daily_budget_reset=True, initial_capital=10_000.0, tail_risk_day_max_trades=1, tail_risk_day_min_max_gain_pct=0.03, tail_risk_day_max_support_score=0.25, tail_risk_day_min_max_entropy_20d=0.80, tail_risk_day_min_max_confirmation_return_pct=0.01, tail_risk_day_scale=0.6, ) day = simulate_day( { "FLY": _bars( "2026-01-27", open_price=25.61, closes=[25.59, 25.673, 26.4162, 26.65, 26.5381, 29.455], volumes=[98_085, 69_040, 108_146, 159_489, 167_075, 60_723], ), }, "2026-01-27", strategy, daily_features_by_ticker={ "FLY": { "gap_pct": 0.0163, "avg_daily_vol_14d": 3_455_439.0, "avg_dollar_vol_30d": 86_573_309.1, "ret_5d": -0.1696, "entropy_20d": 0.7976, }, }, ) assert day.tail_risk_scaler == 1.0 assert round(day.capital_deployed, 2) == 10000.0 assert len(day.trades) == 1 def test_select_momentum_sleeves_can_add_liquid_largecap_fallback_when_sparse() -> None: strategy = StrategyParams( top_n=3, use_five_sleeves=True, five_sleeve_force_count=0, five_sleeve_core_weight=1.0, five_sleeve_gap_weight=0.0, five_sleeve_volume_weight=0.0, five_sleeve_entropy_weight=0.0, five_sleeve_trend_weight=0.0, fallback_liquid_largecap_slots=1, fallback_liquid_largecap_trigger_below=2, ) morning_gains = { "FAST": { "gain_pct": 0.06, "entry_volume": 150_000, "entry_dollar_volume": 5_000_000.0, "avg_dollar_vol_30d": 100_000_000.0, "confirmation_return_pct": 0.003, "volume_ratio_14d": 0.03, "gap_pct": 0.01, "ret_5d": 0.02, "entropy_20d": 0.50, "is_liquid_largecap": False, }, "LQ": { "gain_pct": 0.008, "entry_volume": 500_000, "entry_dollar_volume": 80_000_000.0, "avg_dollar_vol_30d": 1_500_000_000.0, "confirmation_return_pct": 0.005, "volume_ratio_14d": 0.12, "gap_pct": 0.01, "ret_5d": 0.01, "entropy_20d": 0.85, "is_liquid_largecap": True, }, } picks = _select_momentum_sleeves(morning_gains, strategy) assert ("FAST", "blend") in picks assert ("LQ", "liquid_largecap_fallback") in picks def test_simulate_day_can_add_liquid_cluster_engine_without_disturbing_base_basket() -> None: strategy = StrategyParams( entry_minutes_after_open=10, confirmation_minutes_after_entry=5, min_morning_gain_pct=0.04, min_confirmation_return_pct=0.003, top_n=1, slippage_bps=0.0, daily_budget_reset=True, initial_capital=10_000.0, use_liquid_cluster_engine=True, liquid_cluster_capital_fraction=0.25, liquid_cluster_max_positions=1, liquid_cluster_min_members=2, liquid_cluster_min_gain_pct=0.015, liquid_cluster_max_gain_pct=0.04, liquid_cluster_min_confirmation_return_pct=0.003, liquid_cluster_min_entry_dollar_volume=30_000_000.0, liquid_cluster_min_avg_dollar_vol_30d=300_000_000.0, liquid_cluster_min_volume_ratio_14d=0.10, liquid_cluster_max_entropy_20d=0.80, liquid_cluster_min_sector_avg_confirmation_return_pct=0.003, liquid_cluster_min_sector_total_entry_dollar_volume=100_000_000.0, ) day = simulate_day( { "CORE": _bars( "2026-02-03", open_price=100.0, closes=[100.5, 102.0, 103.0, 106.0, 106.4, 107.0], volumes=[250_000] * 6, ), "CL_A": _bars( "2026-02-03", open_price=50.0, closes=[50.2, 50.7, 50.9, 51.2, 51.3, 51.4], volumes=[400_000] * 6, ), "CL_B": _bars( "2026-02-03", open_price=60.0, closes=[60.2, 60.7, 60.9, 61.3, 61.4, 61.5], volumes=[350_000] * 6, ), }, "2026-02-03", strategy, daily_features_by_ticker={ "CORE": { "gap_pct": 0.03, "avg_daily_vol_14d": 2_000_000.0, "avg_dollar_vol_30d": 900_000_000.0, "ret_5d": 0.12, "entropy_20d": 0.40, }, "CL_A": { "gap_pct": 0.015, "avg_daily_vol_14d": 3_000_000.0, "avg_dollar_vol_30d": 800_000_000.0, "ret_5d": 0.05, "entropy_20d": 0.45, }, "CL_B": { "gap_pct": 0.012, "avg_daily_vol_14d": 2_500_000.0, "avg_dollar_vol_30d": 750_000_000.0, "ret_5d": 0.04, "entropy_20d": 0.48, }, }, ticker_sectors={ "CORE": "Energy", "CL_A": "Technology", "CL_B": "Technology", }, ) assert day.trades[0].ticker == "CORE" assert day.trades[0].trade_sleeve == "core" assert day.trades[1].ticker in {"CL_A", "CL_B"} assert day.trades[1].trade_sleeve == "liquid_cluster_engine" assert day.trades[1].is_liquid_cluster is True assert day.trades[1].liquid_cluster_sector == "Technology" assert day.capital_deployed == 10_000.0 def test_simulate_day_can_add_event_day_liquid_sleeve_without_changing_base_basket() -> None: strategy = StrategyParams( entry_minutes_after_open=10, confirmation_minutes_after_entry=5, min_morning_gain_pct=0.04, min_confirmation_return_pct=0.003, top_n=1, use_five_sleeves=False, slippage_bps=0.0, daily_budget_reset=True, initial_capital=10_000.0, candidate_allowed_event_types=["earnings_release"], use_event_day_liquid_sleeve=True, event_day_liquid_capital_fraction=0.25, event_day_liquid_max_positions=1, event_day_liquid_min_event_names=1, event_day_liquid_min_event_score=1.0, event_day_liquid_min_event_support_score=0.2, event_day_liquid_min_gain_pct=0.005, event_day_liquid_max_gain_pct=0.03, event_day_liquid_min_confirmation_return_pct=0.003, event_day_liquid_min_entry_dollar_volume=30_000_000.0, event_day_liquid_min_avg_dollar_vol_30d=500_000_000.0, event_day_liquid_max_entropy_20d=0.80, event_day_liquid_min_support_score=0.50, liquid_largecap_min_gain_pct=0.005, liquid_largecap_max_gain_pct=0.03, liquid_largecap_min_confirmation_return_pct=0.003, liquid_largecap_min_entry_dollar_volume=30_000_000.0, liquid_largecap_min_avg_dollar_vol_30d=500_000_000.0, liquid_largecap_max_entropy_20d=0.80, ) day = simulate_day( { "CORE": _bars( "2026-02-05", open_price=100.0, closes=[100.5, 102.0, 103.2, 105.0, 105.6, 106.0], volumes=[250_000] * 6, ), "EVT": _bars( "2026-02-05", open_price=50.0, closes=[50.3, 51.0, 51.8, 52.4, 52.7, 53.0], volumes=[250_000] * 6, ), "LQ": _bars( "2026-02-05", open_price=200.0, closes=[200.4, 201.0, 201.7, 202.6, 202.9, 203.5], volumes=[250_000] * 6, ), }, "2026-02-05", strategy, daily_features_by_ticker={ "CORE": { "gap_pct": 0.02, "avg_daily_vol_14d": 2_000_000.0, "avg_dollar_vol_30d": 1_000_000_000.0, "ret_5d": 0.12, "entropy_20d": 0.40, }, "EVT": { "gap_pct": 0.03, "avg_daily_vol_14d": 1_500_000.0, "avg_dollar_vol_30d": 600_000_000.0, "ret_5d": 0.08, "entropy_20d": 0.45, "event_flag": True, "event_score": 2.0, "event_types": ["earnings_release"], }, "LQ": { "gap_pct": 0.01, "avg_daily_vol_14d": 3_000_000.0, "avg_dollar_vol_30d": 2_000_000_000.0, "ret_5d": 0.03, "entropy_20d": 0.40, }, }, ) assert [trade.ticker for trade in day.trades] == ["CORE", "LQ"] assert [trade.trade_sleeve for trade in day.trades] == ["core", "event_day_liquid"] assert day.capital_deployed == 10_000.0 def test_event_day_liquid_activation_can_use_broader_raw_event_types_than_core_event_filters() -> None: strategy = StrategyParams( entry_minutes_after_open=10, confirmation_minutes_after_entry=5, min_morning_gain_pct=0.04, min_confirmation_return_pct=0.003, top_n=1, use_five_sleeves=False, slippage_bps=0.0, daily_budget_reset=True, initial_capital=10_000.0, candidate_allowed_event_types=["earnings_release"], use_event_day_liquid_sleeve=True, event_day_liquid_capital_fraction=0.25, event_day_liquid_max_positions=1, event_day_liquid_allowed_event_types=["unknown"], event_day_liquid_min_event_names=1, event_day_liquid_min_event_score=1.0, event_day_liquid_min_gain_pct=0.005, event_day_liquid_max_gain_pct=0.03, event_day_liquid_min_confirmation_return_pct=0.003, event_day_liquid_min_entry_dollar_volume=30_000_000.0, event_day_liquid_min_avg_dollar_vol_30d=500_000_000.0, event_day_liquid_max_entropy_20d=0.80, event_day_liquid_min_support_score=0.50, liquid_largecap_min_gain_pct=0.005, liquid_largecap_max_gain_pct=0.03, liquid_largecap_min_confirmation_return_pct=0.003, liquid_largecap_min_entry_dollar_volume=30_000_000.0, liquid_largecap_min_avg_dollar_vol_30d=500_000_000.0, liquid_largecap_max_entropy_20d=0.80, ) day = simulate_day( { "CORE": _bars( "2026-02-06", open_price=100.0, closes=[100.5, 102.0, 103.2, 105.0, 105.6, 106.0], volumes=[250_000] * 6, ), "RAW_EVT": _bars( "2026-02-06", open_price=50.0, closes=[50.2, 50.9, 51.6, 52.2, 52.5, 52.9], volumes=[250_000] * 6, ), "LQ": _bars( "2026-02-06", open_price=200.0, closes=[200.4, 201.0, 201.7, 202.6, 202.9, 203.5], volumes=[250_000] * 6, ), }, "2026-02-06", strategy, daily_features_by_ticker={ "CORE": { "gap_pct": 0.02, "avg_daily_vol_14d": 2_000_000.0, "avg_dollar_vol_30d": 1_000_000_000.0, "ret_5d": 0.12, "entropy_20d": 0.40, }, "RAW_EVT": { "gap_pct": 0.03, "avg_daily_vol_14d": 1_500_000.0, "avg_dollar_vol_30d": 600_000_000.0, "ret_5d": 0.08, "entropy_20d": 0.45, "event_flag": True, "event_score": 2.0, "event_types": ["unknown"], }, "LQ": { "gap_pct": 0.01, "avg_daily_vol_14d": 3_000_000.0, "avg_dollar_vol_30d": 2_000_000_000.0, "ret_5d": 0.03, "entropy_20d": 0.40, }, }, ) assert [trade.ticker for trade in day.trades] == ["CORE", "LQ"] assert [trade.trade_sleeve for trade in day.trades] == ["core", "event_day_liquid"] def test_simulate_day_can_add_sector_etf_proxy_sleeve_from_liquid_cluster() -> None: strategy = StrategyParams( entry_minutes_after_open=10, confirmation_minutes_after_entry=5, min_morning_gain_pct=0.04, min_confirmation_return_pct=0.003, top_n=1, slippage_bps=0.0, daily_budget_reset=True, initial_capital=10_000.0, use_sector_etf_sleeve=True, sector_etf_capital_fraction=0.25, sector_etf_max_positions=1, sector_etf_min_sector_score=0.20, liquid_cluster_min_members=2, liquid_cluster_min_gain_pct=0.015, liquid_cluster_max_gain_pct=0.04, liquid_cluster_min_confirmation_return_pct=0.003, liquid_cluster_min_entry_dollar_volume=30_000_000.0, liquid_cluster_min_avg_dollar_vol_30d=300_000_000.0, liquid_cluster_min_volume_ratio_14d=0.10, liquid_cluster_max_entropy_20d=0.80, liquid_cluster_min_sector_avg_confirmation_return_pct=0.003, liquid_cluster_min_sector_total_entry_dollar_volume=100_000_000.0, ) day = simulate_day( { "CORE": _bars( "2026-02-04", open_price=100.0, closes=[100.5, 102.0, 103.0, 106.0, 106.4, 107.0], volumes=[250_000] * 6, ), "CL_A": _bars( "2026-02-04", open_price=50.0, closes=[50.2, 50.7, 50.9, 51.2, 51.3, 51.4], volumes=[400_000] * 6, ), "CL_B": _bars( "2026-02-04", open_price=60.0, closes=[60.2, 60.7, 60.9, 61.3, 61.4, 61.5], volumes=[350_000] * 6, ), }, "2026-02-04", strategy, daily_features_by_ticker={ "CORE": { "gap_pct": 0.03, "avg_daily_vol_14d": 2_000_000.0, "avg_dollar_vol_30d": 900_000_000.0, "ret_5d": 0.12, "entropy_20d": 0.40, }, "CL_A": { "gap_pct": 0.015, "avg_daily_vol_14d": 3_000_000.0, "avg_dollar_vol_30d": 800_000_000.0, "ret_5d": 0.05, "entropy_20d": 0.45, }, "CL_B": { "gap_pct": 0.012, "avg_daily_vol_14d": 2_500_000.0, "avg_dollar_vol_30d": 750_000_000.0, "ret_5d": 0.04, "entropy_20d": 0.48, }, }, ticker_sectors={ "CORE": "Energy", "CL_A": "Technology", "CL_B": "Technology", }, sector_proxy_bars_by_ticker={ "XLK": _bars( "2026-02-04", open_price=200.0, closes=[200.3, 201.0, 201.3, 202.0, 202.1, 202.6], volumes=[150_000] * 6, ), }, ) assert [trade.ticker for trade in day.trades] == ["CORE", "XLK"] assert [trade.trade_sleeve for trade in day.trades] == ["core", "sector_etf"] assert day.trades[1].liquid_cluster_sector == "Technology" assert day.trades[1].sector_proxy_ticker == "XLK" assert day.capital_deployed == 10_000.0 def test_compute_morning_gains_applies_entry_dollar_volume_filter() -> None: strategy = StrategyParams( entry_minutes_after_open=10, min_morning_gain_pct=0.01, min_entry_volume=100_000, min_entry_dollar_volume=2_000_000, ) bars_by_ticker = { "CHEAP": _bars( "2026-01-14", open_price=5.0, closes=[5.1, 5.3, 5.4, 5.5, 5.6, 5.7], volumes=[60_000, 60_000, 60_000, 60_000, 60_000, 60_000], ), "RICH": _bars( "2026-01-14", open_price=20.0, closes=[20.2, 20.8, 21.0, 21.2, 21.3, 21.4], volumes=[60_000, 60_000, 60_000, 60_000, 60_000, 60_000], ), } gains = compute_morning_gains( bars_by_ticker, strategy, "2026-01-14", ) assert list(gains) == ["RICH"] def test_compute_morning_gains_allows_liquid_largecap_candidates_for_fallback() -> None: strategy = StrategyParams( entry_minutes_after_open=10, confirmation_minutes_after_entry=5, min_morning_gain_pct=0.015, min_confirmation_return_pct=0.005, fallback_liquid_largecap_slots=1, fallback_liquid_largecap_trigger_below=2, liquid_largecap_min_gain_pct=0.004, liquid_largecap_max_gain_pct=0.02, liquid_largecap_min_confirmation_return_pct=0.0005, liquid_largecap_min_entry_dollar_volume=50_000_000, liquid_largecap_min_avg_dollar_vol_30d=500_000_000, liquid_largecap_max_entropy_20d=0.90, ) bars_by_ticker = { "LQ": _bars( "2026-01-13", open_price=100.0, closes=[100.2, 100.5, 100.8, 101.4, 101.6, 101.8], volumes=[500_000, 500_000, 500_000, 500_000, 500_000, 500_000], ), } gains = compute_morning_gains( bars_by_ticker, strategy, "2026-01-13", daily_features_by_ticker={ "LQ": { "avg_daily_vol_14d": 10_000_000.0, "avg_dollar_vol_30d": 1_000_000_000.0, "ret_5d": 0.01, "entropy_20d": 0.88, "gap_pct": 0.01, } }, ) assert list(gains) == ["LQ"] assert gains["LQ"]["is_liquid_largecap"] is True def test_simulate_day_skips_sparse_baskets_when_min_positions_required() -> None: strategy = StrategyParams( entry_minutes_after_open=10, min_morning_gain_pct=0.01, top_n=3, min_positions_to_trade=2, ) bars_by_ticker = { "ONLY": _bars( "2026-01-15", open_price=10.0, closes=[10.2, 10.7, 10.9, 11.0, 11.1, 11.2], volumes=[120_000] * 6, ), } day = simulate_day( bars_by_ticker, "2026-01-15", strategy, ) assert day.candidates_found == 1 assert day.trades == [] assert day.daily_pnl == 0.0 def test_simulate_day_scales_sparse_days_when_threshold_set() -> None: base = StrategyParams( entry_minutes_after_open=10, min_morning_gain_pct=0.01, top_n=3, initial_capital=9_000.0, exit_minutes_before_close=5, ) scaled = StrategyParams( entry_minutes_after_open=10, min_morning_gain_pct=0.01, top_n=3, initial_capital=9_000.0, exit_minutes_before_close=5, full_size_positions_threshold=4, sparse_day_size_floor=0.5, ) bars_by_ticker = { "ONLY": _bars( "2026-01-16", open_price=10.0, closes=[10.2, 10.8, 11.0, 11.1, 11.2, 11.4], volumes=[120_000] * 6, ), } base_day = simulate_day(bars_by_ticker, "2026-01-16", base) scaled_day = simulate_day(bars_by_ticker, "2026-01-16", scaled) assert len(base_day.trades) == 1 assert len(scaled_day.trades) == 1 assert round(scaled_day.daily_pnl, 2) == round(base_day.daily_pnl * 0.5, 2) def test_simulate_day_tightens_trailing_for_overextended_leaders() -> None: base = StrategyParams( entry_minutes_after_open=10, min_morning_gain_pct=0.01, trailing_stop_pct=-0.075, exit_minutes_before_close=5, ) tightened = StrategyParams( entry_minutes_after_open=10, min_morning_gain_pct=0.01, trailing_stop_pct=-0.075, overextended_trailing_gain_pct=0.05, overextended_trailing_stop_pct=-0.065, exit_minutes_before_close=5, ) bars_by_ticker = { "HOT": _bars( "2026-01-20", open_price=10.0, closes=[10.3, 10.8, 10.9, 10.4, 10.3, 10.2], volumes=[120_000] * 6, ), } base_day = simulate_day(bars_by_ticker, "2026-01-20", base) tightened_day = simulate_day(bars_by_ticker, "2026-01-20", tightened) assert len(base_day.trades) == 1 assert len(tightened_day.trades) == 1 assert tightened_day.trades[0].exit_reason == "trailing_stop" assert base_day.trades[0].exit_reason == "trailing_stop" assert tightened_day.trades[0].exit_price > base_day.trades[0].exit_price assert tightened_day.daily_pnl > base_day.daily_pnl def test_simulate_day_can_use_atr_catastrophic_stop_without_trailing() -> None: strategy = StrategyParams( entry_minutes_after_open=10, min_morning_gain_pct=0.01, exit_minutes_before_close=5, atr_stop_multiplier=0.5, trailing_stop_pct=None, ) bars_by_ticker = { "ATR": _bars( "2026-01-21", open_price=10.0, closes=[10.2, 10.8, 11.0, 10.6, 10.5, 10.4], volumes=[120_000] * 6, ), } day = simulate_day( bars_by_ticker, "2026-01-21", strategy, daily_features_by_ticker={ "ATR": { "gap_pct": 0.01, "avg_daily_vol_14d": 1_000_000.0, "atr_14": 1.0, } }, ) assert len(day.trades) == 1 assert day.trades[0].exit_reason == "stop_loss" def test_simulate_day_delays_trailing_until_gain_threshold() -> None: strategy = StrategyParams( entry_minutes_after_open=10, min_morning_gain_pct=0.01, exit_minutes_before_close=5, atr_stop_multiplier=0.5, trailing_stop_pct=-0.05, trailing_activation_gain_pct=0.03, ) bars_by_ticker = { "TRAIL": _bars( "2026-01-22", open_price=10.0, closes=[10.2, 10.8, 11.0, 11.3, 11.0, 10.9], volumes=[120_000] * 6, ), } day = simulate_day( bars_by_ticker, "2026-01-22", strategy, daily_features_by_ticker={ "TRAIL": { "gap_pct": 0.01, "avg_daily_vol_14d": 1_000_000.0, "atr_14": 1.0, } }, ) assert len(day.trades) == 1 assert day.trades[0].exit_reason == "trailing_stop" def test_simulate_day_can_use_opening_range_catastrophic_stop() -> None: strategy = StrategyParams( entry_minutes_after_open=10, min_morning_gain_pct=0.01, exit_minutes_before_close=5, opening_range_stop_multiplier=1.0, trailing_stop_pct=None, ) bars_by_ticker = { "OR": _bars( "2026-01-23", open_price=10.0, closes=[10.15, 10.25, 10.3, 9.6, 9.5, 9.4], volumes=[120_000] * 6, ), } day = simulate_day( bars_by_ticker, "2026-01-23", strategy, daily_features_by_ticker={ "OR": { "gap_pct": 0.01, "avg_daily_vol_14d": 1_000_000.0, } }, ) assert len(day.trades) == 1 assert day.trades[0].exit_reason == "stop_loss"