from __future__ import annotations import asyncio from libs.backtest.domain import WalkForwardAggregate, WalkForwardGapStats, WalkForwardSummary from libs.intraday.domain import CacheParams, IntradayConfig, IntradayMetrics, StrategyParams from apps.intraday_bt.momentum_research import ( _normalize_momentum_research_strategy, _quarterly_score_payload, _wfv_score_payload, MomentumResearchContext, MomentumResearchSnapshotStore, build_momentum_strategy, build_momentum_research_context, group_trading_days_by_quarter, intraday_metrics_to_momentum_split_result, simulate_momentum_params, ) def _summary( *, mean_return: float, positive_rate: float, worst_return: float, ) -> WalkForwardSummary: return WalkForwardSummary( train_days=84, test_days=21, step_days=21, fold_count=5, folds=[], train_aggregate=WalkForwardAggregate(mean_return_pct=4.0), test_aggregate=WalkForwardAggregate( mean_return_pct=mean_return, median_return_pct=mean_return, worst_return_pct=worst_return, positive_fold_rate_pct=positive_rate, mean_profit_factor=1.5, mean_max_drawdown_pct=4.0, mean_trade_count=40.0, mean_win_rate=0.52, ), gap_stats=WalkForwardGapStats( mean_train_test_return_gap_pct=2.0, worst_train_test_return_gap_pct=4.0, fold_return_cv=0.4, ), engine_reliability_ratio=1.0, ) def test_wfv_score_prefers_better_walk_forward_and_holdout() -> None: strong = _wfv_score_payload( _summary(mean_return=2.5, positive_rate=80.0, worst_return=-1.0), IntradayMetrics(total_return_pct=0.10, sharpe_ratio=1.4), ) weak = _wfv_score_payload( _summary(mean_return=0.3, positive_rate=40.0, worst_return=-6.0), IntradayMetrics(total_return_pct=0.01, sharpe_ratio=0.2), ) assert strong["selection_score"] > weak["selection_score"] assert strong["holdout_return_pct"] > weak["holdout_return_pct"] def test_wfv_score_rewards_better_holdout_loss_containment() -> None: summary = _summary(mean_return=1.2, positive_rate=60.0, worst_return=-2.0) strong = _wfv_score_payload( summary, IntradayMetrics( total_return_pct=0.06, sharpe_ratio=0.9, avg_loss_day_pct=-0.004, tail_loss_20_pct=-0.009, loss_containment_score=89.0, ), ) weak = _wfv_score_payload( summary, IntradayMetrics( total_return_pct=0.06, sharpe_ratio=0.9, avg_loss_day_pct=-0.012, tail_loss_20_pct=-0.026, loss_containment_score=54.0, ), ) assert strong["selection_score"] > weak["selection_score"] assert strong["holdout_loss_containment_score"] > weak["holdout_loss_containment_score"] def test_group_trading_days_by_quarter_preserves_calendar_order() -> None: grouped = group_trading_days_by_quarter( [ "2025-01-02", "2025-03-31", "2025-04-01", "2025-07-01", "2025-10-01", ] ) assert grouped == [ ("2025Q1", ["2025-01-02", "2025-03-31"]), ("2025Q2", ["2025-04-01"]), ("2025Q3", ["2025-07-01"]), ("2025Q4", ["2025-10-01"]), ] def test_quarterly_score_prefers_stable_positive_quarters() -> None: wfv = _wfv_score_payload( _summary(mean_return=1.8, positive_rate=80.0, worst_return=-1.0), IntradayMetrics(total_return_pct=0.08, sharpe_ratio=1.1), ) strong, _ = _quarterly_score_payload( wfv, [ ("2025Q1", IntradayMetrics(total_return_pct=0.08, sharpe_ratio=1.2, max_drawdown_pct=-0.03)), ("2025Q2", IntradayMetrics(total_return_pct=0.06, sharpe_ratio=1.0, max_drawdown_pct=-0.02)), ("2025Q3", IntradayMetrics(total_return_pct=0.07, sharpe_ratio=1.1, max_drawdown_pct=-0.03)), ("2025Q4", IntradayMetrics(total_return_pct=0.05, sharpe_ratio=0.9, max_drawdown_pct=-0.02)), ], ) weak, _ = _quarterly_score_payload( wfv, [ ("2025Q1", IntradayMetrics(total_return_pct=0.16, sharpe_ratio=1.8, max_drawdown_pct=-0.08)), ("2025Q2", IntradayMetrics(total_return_pct=-0.09, sharpe_ratio=-0.7, max_drawdown_pct=-0.10)), ("2025Q3", IntradayMetrics(total_return_pct=0.02, sharpe_ratio=0.2, max_drawdown_pct=-0.05)), ("2025Q4", IntradayMetrics(total_return_pct=-0.03, sharpe_ratio=-0.3, max_drawdown_pct=-0.06)), ], ) assert strong["quarter_mean_return_pct"] > weak["quarter_mean_return_pct"] assert strong["quarter_worst_return_pct"] > weak["quarter_worst_return_pct"] assert strong["quarterly_selection_score"] > weak["quarterly_selection_score"] def test_build_momentum_strategy_applies_overrides_without_mutating_base() -> None: config = IntradayConfig(strategy_mode="momentum", strategy=StrategyParams(top_n=5, max_vix=30.0)) updated = build_momentum_strategy(config, {"top_n": 4, "max_vix": 28.0}) assert updated.top_n == 4 assert updated.max_vix == 28.0 assert config.strategy.top_n == 5 assert config.strategy.max_vix == 30.0 def test_normalize_momentum_research_strategy_forces_reset_simple_mode() -> None: strategy = StrategyParams( top_n=5, compound_returns=True, daily_budget_reset=False, ) normalized = _normalize_momentum_research_strategy(strategy) assert normalized.compound_returns is False assert normalized.daily_budget_reset is True assert strategy.compound_returns is True assert strategy.daily_budget_reset is False def test_intraday_metrics_to_momentum_split_result_maps_simple_returns() -> None: strategy = StrategyParams(top_n=5) result = intraday_metrics_to_momentum_split_result( IntradayMetrics( run_id="mwf", trading_days=20, days_with_trades=8, total_trades=12, total_return_pct=0.1234, annualized_return_pct=0.4567, max_drawdown_pct=-0.089, sharpe_ratio=1.8, profit_factor=1.4, win_rate=0.55, ), strategy, ) assert result.run_id == "mwf" assert result.trade_count == 12 assert result.total_return_pct == 12.34 assert result.annualized_return_pct == 45.67 assert result.max_drawdown_pct == 8.9 assert result.avg_gross_exposure_pct == 100.0 assert result.days_in_market_pct == 40.0 def test_build_momentum_research_context_uses_snapshot_cache(tmp_path, monkeypatch) -> None: config = IntradayConfig( strategy_mode="momentum", strategy=StrategyParams(top_n=5), cache=CacheParams(enabled=True, dir=str(tmp_path / "intraday")), ) trading_days = ["2025-01-02", "2025-01-03"] daily_bars = { "AAA": [{"date": "2025-01-02", "close": 10.0}], "BBB": [{"date": "2025-01-02", "close": 11.0}], } candidates = {"2025-01-02": ["AAA"], "2025-01-03": ["BBB"]} all_intraday = { "2025-01-02": {"AAA": [{"timestamp": "2025-01-02T14:30:00+00:00", "open": 10.0, "high": 10.5, "low": 9.9, "close": 10.3, "volume": 1000}]}, "2025-01-03": {"BBB": [{"timestamp": "2025-01-03T14:30:00+00:00", "open": 11.0, "high": 11.4, "low": 10.8, "close": 11.2, "volume": 1200}]}, } async def _resolve_universe(_universe, _client): return ["AAA", "BBB"] async def _get_trading_days(_client, _start, _end, lookback=0): assert lookback == 0 return trading_days async def _fetch_daily_bars_bulk(*args, **kwargs): return daily_bars def _pre_screen_candidates(*args, **kwargs): return candidates async def _fetch_intraday_bulk(*args, **kwargs): return all_intraday monkeypatch.setattr("apps.intraday_bt.momentum_research.resolve_universe", _resolve_universe) monkeypatch.setattr("apps.intraday_bt.momentum_research.get_trading_days", _get_trading_days) monkeypatch.setattr("apps.intraday_bt.momentum_research.fetch_daily_bars_bulk", _fetch_daily_bars_bulk) monkeypatch.setattr("apps.intraday_bt.momentum_research.momentum_pre_screen_candidates", _pre_screen_candidates) monkeypatch.setattr("apps.intraday_bt.momentum_research.fetch_intraday_bulk", _fetch_intraday_bulk) first = asyncio.run(build_momentum_research_context(config, "2025-01-02", "2025-01-03", client=None)) assert first.candidate_pairs == 2 assert first.research_snapshot_key is not None async def _should_not_fetch(*args, **kwargs): raise AssertionError("fetch path should not run after snapshot is saved") def _should_not_screen(*args, **kwargs): raise AssertionError("screen path should not run after snapshot is saved") monkeypatch.setattr("apps.intraday_bt.momentum_research.fetch_daily_bars_bulk", _should_not_fetch) monkeypatch.setattr("apps.intraday_bt.momentum_research.momentum_pre_screen_candidates", _should_not_screen) monkeypatch.setattr("apps.intraday_bt.momentum_research.fetch_intraday_bulk", _should_not_fetch) second = asyncio.run(build_momentum_research_context(config, "2025-01-02", "2025-01-03", client=None)) assert second.research_snapshot_key == first.research_snapshot_key assert second.candidates == candidates assert second.all_intraday == all_intraday def test_momentum_research_snapshot_key_changes_when_seed_overlay_changes(tmp_path) -> None: config_a = IntradayConfig( strategy_mode="momentum", strategy=StrategyParams(top_n=5, candidate_source_mode="intraday_first"), cache=CacheParams(enabled=True, dir=str(tmp_path / "intraday")), ) config_b = IntradayConfig( strategy_mode="momentum", strategy=StrategyParams( top_n=5, candidate_source_mode="intraday_first", candidate_seed_liquid_overlay_slots=1, ), cache=CacheParams(enabled=True, dir=str(tmp_path / "intraday")), ) key_a = MomentumResearchSnapshotStore(tmp_path / "snapshots").build_key( config_a, start_date="2025-01-02", end_date="2025-01-03", tickers=["AAA"], trading_days=["2025-01-02", "2025-01-03"], ) key_b = MomentumResearchSnapshotStore(tmp_path / "snapshots").build_key( config_b, start_date="2025-01-02", end_date="2025-01-03", tickers=["AAA"], trading_days=["2025-01-02", "2025-01-03"], ) assert key_a != key_b def test_build_momentum_research_context_applies_seed_overlay_before_intraday_fetch( tmp_path, monkeypatch, ) -> None: config = IntradayConfig( strategy_mode="momentum", strategy=StrategyParams( top_n=5, candidate_source_mode="intraday_first", candidate_seed_liquid_overlay_slots=1, ), cache=CacheParams(enabled=False, dir=str(tmp_path / "intraday")), ) async def _resolve_universe(_universe, _client): return ["AAA", "BBB"] async def _get_trading_days(_client, _start, _end, lookback=0): assert lookback == 0 return ["2025-01-02"] async def _fetch_daily_bars_bulk(*args, **kwargs): return {"AAA": [{"date": "2025-01-02", "close": 10.0}], "BBB": [{"date": "2025-01-02", "close": 11.0}]} def _enrichment(*args, **kwargs): return {} def _seed_candidates(*args, **kwargs): return {"2025-01-02": ["AAA"]} def _augment(candidates, *args, **kwargs): assert candidates == {"2025-01-02": ["AAA"]} return {"2025-01-02": ["AAA", "BBB"]}, {} captured: dict[str, object] = {} async def _fetch_intraday_bulk(candidates, *args, **kwargs): captured["candidates"] = candidates return { "2025-01-02": { "AAA": [{"timestamp": "2025-01-02T14:30:00+00:00", "open": 10.0, "high": 10.2, "low": 9.9, "close": 10.1, "volume": 1000}], "BBB": [{"timestamp": "2025-01-02T14:30:00+00:00", "open": 11.0, "high": 11.2, "low": 10.9, "close": 11.1, "volume": 1000}], } } def _intraday_first_candidates(*args, **kwargs): return {"2025-01-02": ["AAA", "BBB"]} monkeypatch.setattr("apps.intraday_bt.momentum_research.resolve_universe", _resolve_universe) monkeypatch.setattr("apps.intraday_bt.momentum_research.get_trading_days", _get_trading_days) monkeypatch.setattr("apps.intraday_bt.momentum_research.fetch_daily_bars_bulk", _fetch_daily_bars_bulk) monkeypatch.setattr("apps.intraday_bt.momentum_research._momentum_enrichment_for_days", _enrichment) monkeypatch.setattr("apps.intraday_bt.momentum_research._momentum_intraday_seed_candidates", _seed_candidates) monkeypatch.setattr( "apps.intraday_bt.momentum_research._augment_momentum_seed_candidates_with_liquid_overlay", _augment, ) monkeypatch.setattr("apps.intraday_bt.momentum_research.fetch_intraday_bulk", _fetch_intraday_bulk) monkeypatch.setattr( "apps.intraday_bt.momentum_research.momentum_intraday_first_candidates", _intraday_first_candidates, ) context = asyncio.run(build_momentum_research_context(config, "2025-01-02", "2025-01-02", client=None)) assert captured["candidates"] == {"2025-01-02": ["AAA", "BBB"]} assert context.candidates == {"2025-01-02": ["AAA", "BBB"]} def test_build_momentum_research_context_fetches_all_tickers_for_candidate_stage_catalyst( tmp_path, monkeypatch, ) -> None: config = IntradayConfig( strategy_mode="momentum", strategy=StrategyParams( top_n=5, candidate_source_mode="intraday_first", candidate_seed_event_overlay_slots=1, ), cache=CacheParams(enabled=False, dir=str(tmp_path / "intraday")), ) async def _resolve_universe(_universe, _client): return ["AAA", "BBB", "CCC"] async def _get_trading_days(_client, _start, _end, lookback=0): assert lookback == 0 return ["2025-01-02"] async def _fetch_daily_bars_bulk(*args, **kwargs): return { "AAA": [{"date": "2025-01-02", "close": 10.0}], "BBB": [{"date": "2025-01-02", "close": 11.0}], "CCC": [{"date": "2025-01-02", "close": 12.0}], } def _enrichment(*args, **kwargs): return { "AAA": {"2025-01-02": {"gap_pct": 0.03}}, "BBB": {"2025-01-02": {"gap_pct": 0.02}}, "CCC": {"2025-01-02": {"gap_pct": 0.01}}, } def _seed_candidates(*args, **kwargs): return {"2025-01-02": ["AAA"]} async def _fetch_filing_event_features_bulk(tickers, *args, **kwargs): assert tickers == ["AAA", "BBB", "CCC"] return {} async def _fetch_intraday_bulk(*args, **kwargs): return {} monkeypatch.setattr("apps.intraday_bt.momentum_research.resolve_universe", _resolve_universe) monkeypatch.setattr("apps.intraday_bt.momentum_research.get_trading_days", _get_trading_days) monkeypatch.setattr("apps.intraday_bt.momentum_research.fetch_daily_bars_bulk", _fetch_daily_bars_bulk) monkeypatch.setattr("apps.intraday_bt.momentum_research._momentum_enrichment_for_days", _enrichment) monkeypatch.setattr("apps.intraday_bt.momentum_research._momentum_intraday_seed_candidates", _seed_candidates) monkeypatch.setattr( "apps.intraday_bt.momentum_research.fetch_filing_event_features_bulk", _fetch_filing_event_features_bulk, ) monkeypatch.setattr("apps.intraday_bt.momentum_research.fetch_intraday_bulk", _fetch_intraday_bulk) monkeypatch.setattr( "apps.intraday_bt.momentum_research.momentum_intraday_first_candidates", lambda *args, **kwargs: {}, ) context = asyncio.run(build_momentum_research_context(config, "2025-01-02", "2025-01-02", client=None)) assert context.candidates == {} def test_simulate_momentum_params_recomputes_candidates_for_strategy() -> None: context = MomentumResearchContext( config=IntradayConfig( strategy_mode="momentum", strategy=StrategyParams(top_n=2), ), tickers=["AAA", "BBB"], ticker_sectors={}, trading_days=["2026-01-05"], daily_bars={ "AAA": [ {"date": "2026-01-02", "open": 10.0, "high": 10.2, "low": 9.8, "close": 10.0, "volume": 1000}, {"date": "2026-01-05", "open": 10.3, "high": 10.8, "low": 10.2, "close": 10.6, "volume": 2000}, ], "BBB": [ {"date": "2026-01-02", "open": 11.0, "high": 11.1, "low": 10.9, "close": 11.0, "volume": 1000}, {"date": "2026-01-05", "open": 11.4, "high": 11.9, "low": 11.3, "close": 11.7, "volume": 2000}, ], }, all_intraday={ "2026-01-05": { "AAA": [ {"timestamp": "2026-01-05T14:30:00+00:00", "open": 10.3, "high": 10.5, "low": 10.2, "close": 10.4, "volume": 50000}, {"timestamp": "2026-01-05T14:35:00+00:00", "open": 10.4, "high": 10.6, "low": 10.3, "close": 10.5, "volume": 50000}, {"timestamp": "2026-01-05T14:40:00+00:00", "open": 10.5, "high": 10.7, "low": 10.4, "close": 10.6, "volume": 50000}, {"timestamp": "2026-01-05T14:45:00+00:00", "open": 10.6, "high": 10.8, "low": 10.5, "close": 10.7, "volume": 50000}, {"timestamp": "2026-01-05T14:50:00+00:00", "open": 10.7, "high": 10.9, "low": 10.6, "close": 10.8, "volume": 50000}, {"timestamp": "2026-01-05T20:55:00+00:00", "open": 10.9, "high": 11.0, "low": 10.8, "close": 10.95, "volume": 50000}, ], "BBB": [ {"timestamp": "2026-01-05T14:30:00+00:00", "open": 11.4, "high": 11.5, "low": 11.3, "close": 11.45, "volume": 50000}, {"timestamp": "2026-01-05T14:35:00+00:00", "open": 11.45, "high": 11.6, "low": 11.4, "close": 11.55, "volume": 50000}, {"timestamp": "2026-01-05T14:40:00+00:00", "open": 11.55, "high": 11.8, "low": 11.5, "close": 11.75, "volume": 50000}, {"timestamp": "2026-01-05T14:45:00+00:00", "open": 11.75, "high": 11.9, "low": 11.7, "close": 11.85, "volume": 50000}, {"timestamp": "2026-01-05T14:50:00+00:00", "open": 11.85, "high": 12.0, "low": 11.8, "close": 11.95, "volume": 50000}, {"timestamp": "2026-01-05T20:55:00+00:00", "open": 11.9, "high": 12.0, "low": 11.8, "close": 11.92, "volume": 50000}, ], } }, daily_enrichment={ "AAA": {"2026-01-05": {"gap_pct": 0.03, "ret_5d": 0.01, "entropy_20d": 0.8, "avg_dollar_vol_30d": 20_000_000.0, "atr_14": 1.0, "event_flag": False}}, "BBB": {"2026-01-05": {"gap_pct": 0.03, "ret_5d": 0.02, "entropy_20d": 0.7, "avg_dollar_vol_30d": 25_000_000.0, "atr_14": 1.1, "event_flag": True, "event_score": 1.0}}, }, vix_by_day=None, candidates={"2026-01-05": ["AAA", "BBB"]}, candidate_pairs=2, research_snapshot_key=None, ) strategy = StrategyParams( top_n=2, entry_minutes_after_open=20, min_morning_gain_pct=0.0, min_entry_volume=0, candidate_require_event_flag=True, exit_minutes_before_close=5, ) day_results, metrics = simulate_momentum_params(context, strategy, ["2026-01-05"], run_id="test") assert metrics.total_trades == 1 assert len(day_results) == 1 assert day_results[0].trades[0].ticker == "BBB" def test_simulate_momentum_params_recomputes_intraday_first_candidates_for_strategy() -> None: context = MomentumResearchContext( config=IntradayConfig( strategy_mode="momentum", strategy=StrategyParams(top_n=2, candidate_source_mode="intraday_first"), ), tickers=["AAA", "BBB"], ticker_sectors={}, trading_days=["2026-01-05"], daily_bars={}, all_intraday={ "2026-01-05": { "AAA": [ {"timestamp": "2026-01-05T14:30:00+00:00", "open": 10.0, "high": 10.1, "low": 9.9, "close": 10.0, "volume": 60_000}, {"timestamp": "2026-01-05T14:35:00+00:00", "open": 10.0, "high": 10.2, "low": 9.9, "close": 10.1, "volume": 60_000}, {"timestamp": "2026-01-05T14:40:00+00:00", "open": 10.1, "high": 10.5, "low": 10.0, "close": 10.3, "volume": 60_000}, {"timestamp": "2026-01-05T14:45:00+00:00", "open": 10.3, "high": 10.4, "low": 10.1, "close": 10.2, "volume": 60_000}, {"timestamp": "2026-01-05T14:50:00+00:00", "open": 10.2, "high": 10.3, "low": 10.1, "close": 10.2, "volume": 60_000}, {"timestamp": "2026-01-05T20:55:00+00:00", "open": 10.2, "high": 10.3, "low": 10.0, "close": 10.1, "volume": 60_000}, ], "BBB": [ {"timestamp": "2026-01-05T14:30:00+00:00", "open": 11.0, "high": 11.1, "low": 10.9, "close": 11.0, "volume": 70_000}, {"timestamp": "2026-01-05T14:35:00+00:00", "open": 11.0, "high": 11.2, "low": 10.9, "close": 11.1, "volume": 70_000}, {"timestamp": "2026-01-05T14:40:00+00:00", "open": 11.1, "high": 11.6, "low": 11.0, "close": 11.4, "volume": 70_000}, {"timestamp": "2026-01-05T14:45:00+00:00", "open": 11.4, "high": 11.8, "low": 11.3, "close": 11.7, "volume": 70_000}, {"timestamp": "2026-01-05T14:50:00+00:00", "open": 11.7, "high": 11.9, "low": 11.6, "close": 11.8, "volume": 70_000}, {"timestamp": "2026-01-05T20:55:00+00:00", "open": 11.8, "high": 11.9, "low": 11.7, "close": 11.85, "volume": 70_000}, ], } }, daily_enrichment={ "AAA": {"2026-01-05": {"gap_pct": 0.01, "avg_daily_vol_14d": 1_000_000.0}}, "BBB": {"2026-01-05": {"gap_pct": 0.01, "avg_daily_vol_14d": 1_000_000.0}}, }, vix_by_day=None, candidates={"2026-01-05": ["AAA", "BBB"]}, candidate_pairs=2, research_snapshot_key=None, ) strategy = StrategyParams( top_n=2, candidate_source_mode="intraday_first", candidate_final_max_per_day=1, entry_minutes_after_open=10, confirmation_minutes_after_entry=5, min_confirmation_return_pct=0.0, min_morning_gain_pct=0.01, min_entry_volume=0, exit_minutes_before_close=5, ) day_results, metrics = simulate_momentum_params(context, strategy, ["2026-01-05"], run_id="test_if") assert metrics.total_trades == 1 assert len(day_results) == 1 assert day_results[0].trades[0].ticker == "BBB"