"""Integration tests for the full backtest pipeline (no DB/HTTP — uses SnapshotStore directly).""" from __future__ import annotations import datetime as dt import json from pathlib import Path from zoneinfo import ZoneInfo import pytest import pyarrow as pa import pyarrow.parquet as pq _UTC = ZoneInfo("UTC") def _build_synthetic_store() -> object: """Build a SnapshotStore with synthetic data for end-to-end testing.""" from libs.backtest.snapshot_store import SnapshotStore # 5 trading days, 2 symbols dates = [dt.date(2026, 1, d) for d in [5, 6, 7, 8, 9]] candidates = { dt.date(2026, 1, 5): [ { "event_id": "EVT::001", "symbol": "AAPL", "execution_date": dt.date(2026, 1, 5), "entry_date": "2026-01-05", "entry_price": 150.0, "score": 0.85, "sector": "Technology", "event_type": "earnings", "event_timestamp": "2026-01-02T21:00:00+00:00", "filing_time_bucket": "post_market", "reaction_date": "2026-01-02", "avg_dollar_volume": 5_000_000.0, "atr_14": 3.0, }, ], dt.date(2026, 1, 6): [ { "event_id": "EVT::002", "symbol": "MSFT", "execution_date": dt.date(2026, 1, 6), "entry_date": "2026-01-06", "entry_price": 300.0, "score": 0.70, "sector": "Technology", "event_type": "guidance", "event_timestamp": "2026-01-05T21:00:00+00:00", "filing_time_bucket": "post_market", "reaction_date": "2026-01-05", "avg_dollar_volume": 10_000_000.0, "atr_14": 5.0, }, ], } bars = { "AAPL": { dt.date(2026, 1, 5): {"date": dt.date(2026, 1, 5), "open": 150.0, "high": 160.0, "low": 148.0, "close": 158.0, "volume": 1_000_000}, dt.date(2026, 1, 6): {"date": dt.date(2026, 1, 6), "open": 158.0, "high": 170.0, "low": 155.0, "close": 165.0, "volume": 900_000}, dt.date(2026, 1, 7): {"date": dt.date(2026, 1, 7), "open": 165.0, "high": 175.0, "low": 160.0, "close": 170.0, "volume": 800_000}, dt.date(2026, 1, 8): {"date": dt.date(2026, 1, 8), "open": 170.0, "high": 180.0, "low": 165.0, "close": 175.0, "volume": 750_000}, dt.date(2026, 1, 9): {"date": dt.date(2026, 1, 9), "open": 175.0, "high": 185.0, "low": 170.0, "close": 180.0, "volume": 700_000}, }, "MSFT": { dt.date(2026, 1, 6): {"date": dt.date(2026, 1, 6), "open": 300.0, "high": 305.0, "low": 280.0, "close": 282.0, "volume": 500_000}, dt.date(2026, 1, 7): {"date": dt.date(2026, 1, 7), "open": 282.0, "high": 290.0, "low": 270.0, "close": 272.0, "volume": 480_000}, dt.date(2026, 1, 8): {"date": dt.date(2026, 1, 8), "open": 272.0, "high": 280.0, "low": 260.0, "close": 265.0, "volume": 450_000}, dt.date(2026, 1, 9): {"date": dt.date(2026, 1, 9), "open": 265.0, "high": 270.0, "low": 255.0, "close": 258.0, "volume": 420_000}, }, } return SnapshotStore( candidates_by_exec_date=candidates, bars_by_symbol_date=bars, ) def _build_multi_engine_store() -> object: from libs.backtest.snapshot_store import SnapshotStore candidates = { dt.date(2026, 1, 7): [ { "event_id": "EVT::SD::SHORT", "symbol": "NFLX", "execution_date": dt.date(2026, 1, 7), "entry_date": "2026-01-07", "event_date": "2026-01-06", "event_close": 400.0, "entry_price": 399.0, "score": 0.90, "sector": "Communication Services", "event_type": "earnings_release", "event_timestamp": "2026-01-06T21:00:00+00:00", "filing_time_bucket": "post_market", "reaction_date": "2026-01-06", "reaction_day_return": -0.12, "avg_dollar_volume": 8_000_000.0, "atr_14": 4.0, }, { "event_id": "EVT::AC::LONG", "symbol": "AMD", "execution_date": dt.date(2026, 1, 7), "entry_date": "2026-01-07", "event_date": "2026-01-06", "event_close": 122.0, "entry_price": 123.0, "score": 0.88, "sector": "Technology", "event_type": "earnings_release", "event_timestamp": "2026-01-06T21:00:00+00:00", "filing_time_bucket": "post_market", "reaction_date": "2026-01-07", "reaction_day_return": 0.14, "avg_dollar_volume": 9_000_000.0, "atr_14": 3.0, }, ], } bars = { "NFLX": { dt.date(2026, 1, 6): {"date": dt.date(2026, 1, 6), "open": 395.0, "high": 405.0, "low": 390.0, "close": 400.0, "volume": 1_200_000}, dt.date(2026, 1, 7): {"date": dt.date(2026, 1, 7), "open": 390.0, "high": 392.0, "low": 380.0, "close": 382.0, "volume": 1_100_000}, dt.date(2026, 1, 8): {"date": dt.date(2026, 1, 8), "open": 382.0, "high": 384.0, "low": 370.0, "close": 372.0, "volume": 1_000_000}, }, "AMD": { dt.date(2026, 1, 7): {"date": dt.date(2026, 1, 7), "open": 123.0, "high": 130.0, "low": 122.0, "close": 129.0, "volume": 1_500_000}, dt.date(2026, 1, 8): {"date": dt.date(2026, 1, 8), "open": 129.0, "high": 135.0, "low": 128.0, "close": 134.0, "volume": 1_300_000}, dt.date(2026, 1, 9): {"date": dt.date(2026, 1, 9), "open": 134.0, "high": 138.0, "low": 133.0, "close": 137.0, "volume": 1_250_000}, }, } return SnapshotStore(candidates_by_exec_date=candidates, bars_by_symbol_date=bars) def _make_config(strategy_engines=None): from libs.backtest.domain import ( BacktestConfig, ExecutionConfig, ReportingConfig, RiskConfig, SignalConfig, UniverseConfig, ) return BacktestConfig( strategy_name="test_strategy", dataset_snapshot_id="test_snapshot", universe=UniverseConfig(min_price=5.0, min_avg_dollar_volume=100_000), signal=SignalConfig(score_threshold=0.5, max_candidates_per_day=5), risk=RiskConfig( per_trade_risk_pct=0.01, max_daily_new_risk_pct=0.05, max_positions=10, max_positions_per_sector=5, ), execution=ExecutionConfig( entry_fill_model="next_open", exit_fill_model="daily_bar_approximation", slippage_bps_base=10.0, commission_per_share=0.005, same_bar_priority="stop_first_conservative", max_holding_days=10, ), reporting=ReportingConfig( write_trade_blotter=True, write_equity_curve=True, write_metrics_summary=True, generate_plots=False, ), strategy_engines=strategy_engines or [], ) @pytest.mark.integration class TestBacktestRunIntegration: def test_run_completes(self, tmp_path): """Full run completes without error.""" from apps.backtester.run import BacktestRunner from libs.backtest.domain import ExperimentManifest store = _build_synthetic_store() manifest = ExperimentManifest( experiment_name="test_exp", dataset_snapshot_id="test_snapshot", base_config="configs/backtest/defaults.json", overrides={}, ) config = _make_config() runner = BacktestRunner(manifest=manifest, config=config, store=store, initial_equity=100_000.0) result = runner.run(output_root=tmp_path) assert result.run_id.startswith("bt_") assert result.total_trading_days >= 0 assert result.metrics.trade_count >= 0 def test_engine_allowed_macro_regimes_filters_engine_by_date(self, tmp_path): from apps.backtester.run import BacktestRunner from libs.backtest.domain import ( BacktestConfig, EventTypeProfile, ExecutionConfig, ExperimentManifest, ReportingConfig, RiskConfig, SignalConfig, StrategyEngineConfig, UniverseConfig, ) from libs.backtest.snapshot_store import SnapshotStore import pyarrow.parquet as pq store = SnapshotStore( candidates_by_exec_date={ dt.date(2026, 1, 5): [ { "event_id": "EVT::ROFF", "symbol": "AAPL", "execution_date": dt.date(2026, 1, 5), "entry_date": "2026-01-05", "entry_price": 100.0, "score": 0.80, "sector": "Technology", "event_type": "earnings_release", "event_timestamp": "2026-01-02T21:00:00+00:00", "filing_time_bucket": "post_market", "reaction_date": "2026-01-02", "avg_dollar_volume": 5_000_000.0, "atr_14": 3.0, "reaction_day_return": 0.02, }, ], dt.date(2026, 1, 6): [ { "event_id": "EVT::RON", "symbol": "MSFT", "execution_date": dt.date(2026, 1, 6), "entry_date": "2026-01-06", "entry_price": 110.0, "score": 0.82, "sector": "Technology", "event_type": "earnings_release", "event_timestamp": "2026-01-05T21:00:00+00:00", "filing_time_bucket": "post_market", "reaction_date": "2026-01-05", "avg_dollar_volume": 5_000_000.0, "atr_14": 3.0, "reaction_day_return": 0.02, }, ], }, bars_by_symbol_date={ "AAPL": { dt.date(2026, 1, 5): {"date": dt.date(2026, 1, 5), "open": 100.0, "high": 104.0, "low": 98.0, "close": 103.0, "volume": 1_000_000}, dt.date(2026, 1, 6): {"date": dt.date(2026, 1, 6), "open": 103.0, "high": 105.0, "low": 102.0, "close": 104.0, "volume": 1_000_000}, }, "MSFT": { dt.date(2026, 1, 6): {"date": dt.date(2026, 1, 6), "open": 110.0, "high": 114.0, "low": 109.0, "close": 113.0, "volume": 1_000_000}, dt.date(2026, 1, 7): {"date": dt.date(2026, 1, 7), "open": 113.0, "high": 115.0, "low": 111.0, "close": 114.0, "volume": 1_000_000}, }, }, macro_by_date={ dt.date(2026, 1, 5): { "spy_close": 90.0, "spy_sma_20": 100.0, "qqq_close": 180.0, "qqq_sma_20": 200.0, }, dt.date(2026, 1, 6): { "spy_close": 110.0, "spy_sma_20": 100.0, "qqq_close": 210.0, "qqq_sma_20": 200.0, }, }, ) manifest = ExperimentManifest( experiment_name="macro_gate_engine_test", dataset_snapshot_id="test_snapshot", base_config="configs/backtest/defaults.json", overrides={}, strategy_engines=[ StrategyEngineConfig( engine_id="macro_gated_engine", event_types=["earnings_release"], timing_class="any", direction="long_only", entry_timing_policy="next_open", allowed_macro_regimes=["risk_on"], ), ], ) config = BacktestConfig( strategy_name="return_max_long_v1", dataset_snapshot_id="test_snapshot", universe=UniverseConfig(min_price=5.0, min_avg_dollar_volume=0.0), signal=SignalConfig(score_threshold=0.5, max_candidates_per_day=5), risk=RiskConfig( per_trade_risk_pct=0.01, max_daily_new_risk_pct=0.10, max_positions=5, max_positions_per_sector=5, macro_regime_enabled=True, macro_regime_mode="spy_qqq_scaler", macro_regime_neutral_size_scaler=1.0, macro_regime_risk_off_size_scaler=1.0, macro_regime_risk_off_a_tier_only=False, veto_unknown_direction=False, veto_bearish_direction=False, ), execution=ExecutionConfig( entry_fill_model="next_open", exit_fill_model="daily_bar_approximation", slippage_bps_base=0.0, commission_per_share=0.0, same_bar_priority="stop_first_conservative", max_holding_days=1, ), reporting=ReportingConfig( write_trade_blotter=True, write_equity_curve=True, write_metrics_summary=True, generate_plots=False, ), event_type_profiles={ "earnings_release": EventTypeProfile(enabled=True), }, strategy_engines=manifest.strategy_engines, ) runner = BacktestRunner(manifest=manifest, config=config, store=store, initial_equity=100_000.0) result = runner.run(output_root=tmp_path) trade_blotter = pq.read_table(tmp_path / result.run_id / "artifacts" / "trade_blotter.parquet").to_pylist() assert [row["symbol"] for row in trade_blotter] == ["MSFT"] def test_output_files_created(self, tmp_path): """All expected output files are written.""" from apps.backtester.run import BacktestRunner from libs.backtest.domain import ExperimentManifest store = _build_synthetic_store() manifest = ExperimentManifest( experiment_name="test_exp", dataset_snapshot_id="test_snapshot", base_config="configs/backtest/defaults.json", overrides={}, ) config = _make_config() runner = BacktestRunner(manifest=manifest, config=config, store=store, initial_equity=100_000.0) result = runner.run(output_root=tmp_path) run_dir = tmp_path / result.run_id assert run_dir.exists() assert (run_dir / "metadata.json").exists() assert (run_dir / "manifest.json").exists() def test_same_day_cash_recycle_replaces_stale_core(self, tmp_path): from apps.backtester.run import BacktestRunner from libs.backtest.domain import ( BacktestConfig, ExecutionConfig, ExperimentManifest, ReportingConfig, RiskConfig, SignalConfig, StrategyEngineConfig, UniverseConfig, ) from libs.backtest.snapshot_store import SnapshotStore d1 = dt.date(2026, 1, 5) d2 = dt.date(2026, 1, 6) d3 = dt.date(2026, 1, 7) store = SnapshotStore( candidates_by_exec_date={ d1: [ { "event_id": "EVT::OLD", "symbol": "OLD", "execution_date": d1, "entry_date": d1.isoformat(), "event_date": d1.isoformat(), "event_close": 100.0, "entry_price": 100.0, "score": 0.60, "sector": "Technology", "event_type": "earnings_release", "event_direction": "bullish", "guidance_status": "not_provided", "event_timestamp": "2026-01-05T21:00:00+00:00", "filing_time_bucket": "post_market", "reaction_date": d1.isoformat(), "reaction_day_return": 0.08, "close_location": 0.74, "volume_ratio_20d": 2.1, "gap_size": 0.01, "avg_dollar_volume": 5_000_000.0, "atr_14": 2.0, }, ], d2: [ { "event_id": "EVT::NEW", "symbol": "NEW", "execution_date": d2, "entry_date": d2.isoformat(), "event_date": d2.isoformat(), "event_close": 50.0, "entry_price": 50.0, "score": 0.80, "sector": "Technology", "event_type": "earnings_release", "event_direction": "bullish", "guidance_status": "not_provided", "event_timestamp": "2026-01-06T21:00:00+00:00", "filing_time_bucket": "post_market", "reaction_date": d2.isoformat(), "reaction_day_return": 0.09, "close_location": 0.76, "volume_ratio_20d": 2.2, "gap_size": 0.01, "avg_dollar_volume": 5_000_000.0, "atr_14": 2.0, }, ], }, bars_by_symbol_date={ "OLD": { d1: {"date": d1, "open": 100.0, "high": 100.0, "low": 99.0, "close": 100.0, "volume": 1_000_000}, d2: {"date": d2, "open": 100.0, "high": 102.0, "low": 100.0, "close": 101.0, "volume": 1_000_000}, d3: {"date": d3, "open": 101.0, "high": 101.0, "low": 101.0, "close": 101.0, "volume": 1_000_000}, }, "NEW": { d2: {"date": d2, "open": 50.0, "high": 50.0, "low": 50.0, "close": 50.0, "volume": 1_000_000}, d3: {"date": d3, "open": 50.0, "high": 55.0, "low": 50.0, "close": 55.0, "volume": 1_000_000}, }, }, ) engine = StrategyEngineConfig( engine_id="core", event_types=["earnings_release"], timing_class="same_day", direction="long_only", entry_timing_policy="reaction_close", score_threshold_override=0.5, recycle_on_cash_block=True, recycle_min_days_held=1, recycle_min_score_delta=0.05, recycle_allowed_victim_engine_ids=["core"], recycle_positive_pnl_only=True, enabled=True, ) manifest = ExperimentManifest( experiment_name="test_same_day_recycle", dataset_snapshot_id="test_snapshot", base_config="configs/backtest/defaults.json", overrides={}, strategy_engines=[engine], ) config = BacktestConfig( strategy_name="test_strategy", dataset_snapshot_id="test_snapshot", universe=UniverseConfig(min_price=5.0, min_avg_dollar_volume=100_000), signal=SignalConfig(score_threshold=0.5, max_candidates_per_day=5), risk=RiskConfig( per_trade_risk_pct=1.0, max_daily_new_risk_pct=1.0, max_positions=10, max_positions_per_sector=5, max_position_value_pct=1.0, ), execution=ExecutionConfig(max_holding_days=10), reporting=ReportingConfig(), strategy_engines=[engine], ) runner = BacktestRunner(manifest=manifest, config=config, store=store, initial_equity=1_000.0) result = runner.run(output_root=tmp_path) run_dir = tmp_path / result.run_id recycle_trades = [trade for trade in runner._closed_trades if trade.exit_reason.value == "RECYCLE"] assert recycle_trades assert recycle_trades[0].symbol == "OLD" assert any(trade.symbol == "NEW" for trade in runner._closed_trades) assert result.metrics.trade_count >= 2 assert (run_dir / "resolved_config.json").exists() assert (run_dir / "metrics" / "metrics_summary.json").exists() assert (run_dir / "plots").exists() # empty dir def test_next_open_cash_recycle_replaces_stale_next_open_position(self, tmp_path): from apps.backtester.run import BacktestRunner from libs.backtest.domain import ( BacktestConfig, ExecutionConfig, ExperimentManifest, ReportingConfig, RiskConfig, SignalConfig, StrategyEngineConfig, UniverseConfig, ) from libs.backtest.snapshot_store import SnapshotStore d1 = dt.date(2026, 1, 5) d2 = dt.date(2026, 1, 6) d3 = dt.date(2026, 1, 7) store = SnapshotStore( candidates_by_exec_date={ d1: [ { "event_id": "EVT::OLD::NEXT", "symbol": "OLDN", "execution_date": d1, "entry_date": d1.isoformat(), "event_date": d1.isoformat(), "event_close": 100.0, "entry_price": 100.0, "score": 0.60, "sector": "Technology", "event_type": "earnings_release", "event_direction": "bullish", "guidance_status": "raised", "event_timestamp": "2026-01-05T13:00:00+00:00", "filing_time_bucket": "pre_market", "reaction_date": d1.isoformat(), "reaction_day_return": 0.01, "close_location": 0.65, "volume_ratio_20d": 1.2, "gap_size": 0.01, "avg_dollar_volume": 5_000_000.0, "atr_14": 2.0, }, ], d2: [ { "event_id": "EVT::NEW::NEXT", "symbol": "NEWN", "execution_date": d2, "entry_date": d2.isoformat(), "event_date": d2.isoformat(), "event_close": 50.0, "entry_price": 50.0, "score": 0.80, "sector": "Technology", "event_type": "earnings_release", "event_direction": "bullish", "guidance_status": "raised", "event_timestamp": "2026-01-06T13:00:00+00:00", "filing_time_bucket": "pre_market", "reaction_date": d2.isoformat(), "reaction_day_return": 0.01, "close_location": 0.66, "volume_ratio_20d": 1.2, "gap_size": 0.01, "avg_dollar_volume": 5_000_000.0, "atr_14": 2.0, }, ], }, bars_by_symbol_date={ "OLDN": { d1: {"date": d1, "open": 100.0, "high": 101.0, "low": 99.0, "close": 100.0, "volume": 1_000_000}, d2: {"date": d2, "open": 100.0, "high": 102.0, "low": 100.0, "close": 101.0, "volume": 1_000_000}, d3: {"date": d3, "open": 101.0, "high": 101.0, "low": 100.0, "close": 100.5, "volume": 1_000_000}, }, "NEWN": { d2: {"date": d2, "open": 50.0, "high": 50.0, "low": 50.0, "close": 50.0, "volume": 1_000_000}, d3: {"date": d3, "open": 50.0, "high": 54.0, "low": 50.0, "close": 53.0, "volume": 1_000_000}, }, }, ) engine = StrategyEngineConfig( engine_id="next_open_guidance", event_types=["earnings_release"], timing_class="any", direction="long_only", entry_timing_policy="next_open", score_threshold_override=0.5, recycle_on_cash_block=True, recycle_min_days_held=1, recycle_min_score_delta=0.05, recycle_allowed_victim_engine_ids=["next_open_guidance"], recycle_positive_pnl_only=True, enabled=True, ) manifest = ExperimentManifest( experiment_name="test_next_open_recycle", dataset_snapshot_id="test_snapshot", base_config="configs/backtest/defaults.json", overrides={}, strategy_engines=[engine], ) config = BacktestConfig( strategy_name="test_strategy", dataset_snapshot_id="test_snapshot", universe=UniverseConfig(min_price=5.0, min_avg_dollar_volume=100_000), signal=SignalConfig(score_threshold=0.5, max_candidates_per_day=5), risk=RiskConfig( per_trade_risk_pct=1.0, max_daily_new_risk_pct=1.0, max_positions=10, max_positions_per_sector=5, max_position_value_pct=1.0, ), execution=ExecutionConfig(max_holding_days=10), reporting=ReportingConfig(), strategy_engines=[engine], ) runner = BacktestRunner(manifest=manifest, config=config, store=store, initial_equity=1_000.0) result = runner.run(output_root=tmp_path) recycle_trades = [trade for trade in runner._closed_trades if trade.exit_reason.value == "RECYCLE"] assert recycle_trades assert recycle_trades[0].symbol == "OLDN" assert any(trade.symbol == "NEWN" for trade in runner._closed_trades) assert result.metrics.trade_count >= 2 def test_next_open_cash_recycle_can_target_stale_cross_engine_victim(self, tmp_path): from apps.backtester.run import BacktestRunner from libs.backtest.domain import ( BacktestConfig, ExecutionConfig, ExperimentManifest, ReportingConfig, RiskConfig, SignalConfig, StrategyEngineConfig, UniverseConfig, ) from libs.backtest.snapshot_store import SnapshotStore d1 = dt.date(2026, 1, 5) d2 = dt.date(2026, 1, 6) d3 = dt.date(2026, 1, 7) d4 = dt.date(2026, 1, 8) d5 = dt.date(2026, 1, 9) store = SnapshotStore( candidates_by_exec_date={ d1: [ { "event_id": "EVT::OLD::CROSS", "symbol": "OLDX", "execution_date": d1, "entry_date": d1.isoformat(), "event_date": d1.isoformat(), "event_close": 100.0, "entry_price": 100.0, "score": 0.55, "sector": "Technology", "event_type": "earnings_release", "event_direction": "bullish", "guidance_status": "raised", "event_timestamp": "2026-01-05T13:00:00+00:00", "filing_time_bucket": "pre_market", "reaction_date": d1.isoformat(), "reaction_day_return": 0.01, "close_location": 0.60, "volume_ratio_20d": 1.2, "gap_size": 0.01, "avg_dollar_volume": 5_000_000.0, "atr_14": 2.0, }, ], d5: [ { "event_id": "EVT::NEW::CROSS", "symbol": "NEWX", "execution_date": d5, "entry_date": d5.isoformat(), "event_date": d5.isoformat(), "event_close": 50.0, "entry_price": 50.0, "score": 0.80, "sector": "Technology", "event_type": "other_material_event", "event_direction": "bullish", "guidance_status": "not_provided", "event_timestamp": "2026-01-09T13:00:00+00:00", "filing_time_bucket": "post_market", "reaction_date": d5.isoformat(), "reaction_day_return": 0.01, "close_location": 0.70, "volume_ratio_20d": 1.2, "gap_size": 0.01, "avg_dollar_volume": 5_000_000.0, "atr_14": 2.0, }, ], }, bars_by_symbol_date={ "OLDX": { d1: {"date": d1, "open": 100.0, "high": 101.0, "low": 99.0, "close": 100.0, "volume": 1_000_000}, d2: {"date": d2, "open": 100.0, "high": 101.0, "low": 99.5, "close": 100.2, "volume": 1_000_000}, d3: {"date": d3, "open": 100.2, "high": 100.8, "low": 99.8, "close": 100.3, "volume": 1_000_000}, d4: {"date": d4, "open": 100.3, "high": 100.7, "low": 100.0, "close": 100.4, "volume": 1_000_000}, d5: {"date": d5, "open": 100.4, "high": 100.8, "low": 100.2, "close": 100.5, "volume": 1_000_000}, }, "NEWX": { d5: {"date": d5, "open": 50.0, "high": 50.0, "low": 50.0, "close": 50.0, "volume": 1_000_000}, }, }, ) old_engine = StrategyEngineConfig( engine_id="stale_next", event_types=["earnings_release"], timing_class="any", direction="long_only", entry_timing_policy="next_open", score_threshold_override=0.5, enabled=True, ) new_engine = StrategyEngineConfig( engine_id="fresh_next", event_types=["other_material_event"], timing_class="any", direction="long_only", entry_timing_policy="next_open", score_threshold_override=0.5, recycle_on_cash_block=True, recycle_min_days_held=3, recycle_min_score_delta=0.05, recycle_allow_any_victim_engine=True, recycle_max_victim_fitness=0.45, recycle_max_victim_unrealized_r=0.30, recycle_positive_pnl_only=True, enabled=True, ) manifest = ExperimentManifest( experiment_name="test_cross_engine_recycle", dataset_snapshot_id="test_snapshot", base_config="configs/backtest/defaults.json", overrides={}, strategy_engines=[old_engine, new_engine], ) config = BacktestConfig( strategy_name="test_strategy", dataset_snapshot_id="test_snapshot", universe=UniverseConfig(min_price=5.0, min_avg_dollar_volume=100_000), signal=SignalConfig(score_threshold=0.5, max_candidates_per_day=5), risk=RiskConfig( per_trade_risk_pct=1.0, max_daily_new_risk_pct=1.0, max_positions=10, max_positions_per_sector=5, max_position_value_pct=1.0, ), execution=ExecutionConfig(max_holding_days=5), reporting=ReportingConfig(), strategy_engines=[old_engine, new_engine], ) runner = BacktestRunner(manifest=manifest, config=config, store=store, initial_equity=1_000.0) result = runner.run(output_root=tmp_path) recycle_trades = [trade for trade in runner._closed_trades if trade.exit_reason.value == "RECYCLE"] assert recycle_trades assert recycle_trades[0].symbol == "OLDX" assert any(trade.symbol == "NEWX" for trade in runner._closed_trades) assert result.metrics.trade_count >= 2 def test_rotation_can_skip_large_unrealized_winner(self, tmp_path): from apps.backtester.run import BacktestRunner from libs.backtest.domain import ( BacktestConfig, ExecutionConfig, ExperimentManifest, ReportingConfig, RiskConfig, SignalConfig, StrategyEngineConfig, UniverseConfig, ) from libs.backtest.snapshot_store import SnapshotStore d1 = dt.date(2026, 1, 5) d2 = dt.date(2026, 1, 6) d3 = dt.date(2026, 1, 7) d4 = dt.date(2026, 1, 8) d5 = dt.date(2026, 1, 9) store = SnapshotStore( candidates_by_exec_date={ d1: [ { "event_id": "EVT::OLD::ROT", "symbol": "OLDR", "execution_date": d1, "entry_date": d1.isoformat(), "event_date": d1.isoformat(), "event_close": 100.0, "entry_price": 100.0, "score": 0.60, "sector": "Technology", "event_type": "earnings_release", "event_direction": "bullish", "guidance_status": "raised", "event_timestamp": "2026-01-05T21:00:00+00:00", "filing_time_bucket": "post_market", "reaction_date": d1.isoformat(), "reaction_day_return": 0.03, "close_location": 0.70, "volume_ratio_20d": 1.5, "gap_size": 0.01, "avg_dollar_volume": 5_000_000.0, "atr_14": 2.0, }, ], d5: [ { "event_id": "EVT::NEW::ROT", "symbol": "NEWR", "execution_date": d5, "entry_date": d5.isoformat(), "event_date": d5.isoformat(), "event_close": 50.0, "entry_price": 50.0, "score": 0.85, "sector": "Technology", "event_type": "earnings_release", "event_direction": "bullish", "guidance_status": "raised", "event_timestamp": "2026-01-09T21:00:00+00:00", "filing_time_bucket": "post_market", "reaction_date": d5.isoformat(), "reaction_day_return": 0.04, "close_location": 0.72, "volume_ratio_20d": 1.6, "gap_size": 0.01, "avg_dollar_volume": 5_000_000.0, "atr_14": 2.0, }, ], }, bars_by_symbol_date={ "OLDR": { d1: {"date": d1, "open": 100.0, "high": 100.0, "low": 99.0, "close": 100.0, "volume": 1_000_000}, d2: {"date": d2, "open": 100.0, "high": 101.0, "low": 99.8, "close": 100.8, "volume": 1_000_000}, d3: {"date": d3, "open": 100.8, "high": 102.5, "low": 100.6, "close": 102.4, "volume": 1_000_000}, d4: {"date": d4, "open": 102.4, "high": 103.4, "low": 102.2, "close": 103.0, "volume": 1_000_000}, d5: {"date": d5, "open": 103.0, "high": 103.8, "low": 102.8, "close": 103.5, "volume": 1_000_000}, }, "NEWR": { d5: {"date": d5, "open": 50.0, "high": 50.0, "low": 50.0, "close": 50.0, "volume": 1_000_000}, }, }, ) engine = StrategyEngineConfig( engine_id="rot_next", event_types=["earnings_release"], timing_class="any", direction="long_only", entry_timing_policy="next_open", score_threshold_override=0.5, target_1_r_override=6.0, rotation_enabled=True, rotation_min_days_held=3, rotation_fitness_threshold=0.45, rotation_min_candidate_score=0.8, rotation_max_unrealized_r=0.5, enabled=True, ) manifest = ExperimentManifest( experiment_name="test_rotation_max_unrealized_r", dataset_snapshot_id="test_snapshot", base_config="configs/backtest/defaults.json", overrides={}, strategy_engines=[engine], ) config = BacktestConfig( strategy_name="test_strategy", dataset_snapshot_id="test_snapshot", universe=UniverseConfig(min_price=5.0, min_avg_dollar_volume=100_000), signal=SignalConfig(score_threshold=0.5, max_candidates_per_day=5), risk=RiskConfig( per_trade_risk_pct=1.0, max_daily_new_risk_pct=1.0, max_positions=10, max_positions_per_sector=5, max_position_value_pct=1.0, ), execution=ExecutionConfig(max_holding_days=5), reporting=ReportingConfig(), strategy_engines=[engine], ) runner = BacktestRunner(manifest=manifest, config=config, store=store, initial_equity=1_000.0) result = runner.run(output_root=tmp_path) rotation_trades = [trade for trade in runner._closed_trades if trade.exit_reason.value == "ROTATION"] assert not rotation_trades assert not any(trade.symbol == "NEWR" for trade in runner._closed_trades) assert result.metrics.trade_count == 1 def test_equity_curve_has_all_days(self, tmp_path): """Equity curve has one entry per candidate date.""" from apps.backtester.run import BacktestRunner from libs.backtest.domain import ExperimentManifest store = _build_synthetic_store() manifest = ExperimentManifest( experiment_name="test_exp", dataset_snapshot_id="test_snapshot", base_config="configs/backtest/defaults.json", overrides={}, ) config = _make_config() runner = BacktestRunner(manifest=manifest, config=config, store=store) result = runner.run() # Should have simulated days covering the range (all_trading_days between # first and last execution date), plus the initial equity state assert result.total_trading_days >= 2 def test_deterministic_results(self, tmp_path): """Two runs with same inputs produce identical metrics.""" from apps.backtester.run import BacktestRunner from libs.backtest.domain import ExperimentManifest manifest = ExperimentManifest( experiment_name="test_exp", dataset_snapshot_id="test_snapshot", base_config="configs/backtest/defaults.json", overrides={}, ) config = _make_config() store1 = _build_synthetic_store() runner1 = BacktestRunner(manifest=manifest, config=config, store=store1) result1 = runner1.run() store2 = _build_synthetic_store() runner2 = BacktestRunner(manifest=manifest, config=config, store=store2) result2 = runner2.run() assert result1.metrics.trade_count == result2.metrics.trade_count assert result1.metrics.win_rate == result2.metrics.win_rate assert result1.metrics.total_return_pct == result2.metrics.total_return_pct assert result1.total_candidates_seen == result2.total_candidates_seen assert result1.total_orders_rejected == result2.total_orders_rejected def test_no_future_data_used(self, tmp_path): """Candidates for day D should not appear in a simulation of day D-1.""" from libs.backtest.snapshot_store import SnapshotStore candidates = { dt.date(2026, 1, 5): [ { "event_id": "EVT::001", "symbol": "AAPL", "execution_date": dt.date(2026, 1, 5), "entry_date": "2026-01-05", "entry_price": 150.0, "score": 0.85, "sector": "Technology", "event_type": "earnings", "event_timestamp": "2026-01-02T21:00:00+00:00", "filing_time_bucket": "post_market", "reaction_date": "2026-01-02", "avg_dollar_volume": 5_000_000.0, "atr_14": 3.0, } ], dt.date(2026, 1, 6): [ { "event_id": "EVT::FUTURE", "symbol": "FUTURE_TICKER", "execution_date": dt.date(2026, 1, 6), "entry_date": "2026-01-06", "entry_price": 50.0, "score": 0.99, "sector": "Technology", "event_type": "earnings", "event_timestamp": "2026-01-05T21:00:00+00:00", "filing_time_bucket": "post_market", "reaction_date": "2026-01-05", "avg_dollar_volume": 1_000_000.0, "atr_14": 1.0, } ], } bars = { "AAPL": {dt.date(2026, 1, 5): {"date": dt.date(2026, 1, 5), "open": 150.0, "high": 160.0, "low": 148.0, "close": 158.0, "volume": 1_000_000}}, "FUTURE_TICKER": {dt.date(2026, 1, 6): {"date": dt.date(2026, 1, 6), "open": 50.0, "high": 55.0, "low": 48.0, "close": 52.0, "volume": 500_000}}, } store = SnapshotStore(candidates_by_exec_date=candidates, bars_by_symbol_date=bars) # Querying Jan 5 should NOT return FUTURE_TICKER candidate rows = store.get_candidates_for_date(dt.date(2026, 1, 5)) symbols = [r["symbol"] for r in rows] assert "FUTURE_TICKER" not in symbols assert "AAPL" in symbols def test_multi_engine_run_writes_per_engine_metrics(self, tmp_path): from apps.backtester.run import BacktestRunner from libs.backtest.domain import ExperimentManifest, StrategyEngineConfig import json import pyarrow.parquet as pq store = _build_multi_engine_store() manifest = ExperimentManifest( experiment_name="portfolio_v2", dataset_snapshot_id="test_snapshot", base_config="configs/backtest/defaults.json", overrides={}, strategy_engines=[ StrategyEngineConfig( engine_id="earnings_same_day_short_v1", event_types=["earnings_release"], timing_class="same_day", direction="short_only", entry_timing_policy="next_open", max_holding_days=3, engine_risk_budget_pct=0.40, ), StrategyEngineConfig( engine_id="earnings_after_close_long_v1", event_types=["earnings_release"], timing_class="after_close", direction="long_only", entry_timing_policy="next_open", max_holding_days=5, engine_risk_budget_pct=0.25, ), StrategyEngineConfig( engine_id="earnings_after_close_short_v1", event_types=["earnings_release"], timing_class="after_close", direction="short_only", entry_timing_policy="next_open", max_holding_days=3, engine_risk_budget_pct=0.10, shadow_only=True, ), ], ) config = _make_config(strategy_engines=manifest.strategy_engines) runner = BacktestRunner(manifest=manifest, config=config, store=store, initial_equity=100_000.0) result = runner.run(output_root=tmp_path) run_dir = tmp_path / result.run_id per_engine_metrics = run_dir / "metrics" / "per_engine_metrics.json" attribution_by_engine = run_dir / "metrics" / "attribution_by_engine.csv" assert per_engine_metrics.exists() assert attribution_by_engine.exists() payload = per_engine_metrics.read_text() assert "earnings_same_day_short_v1" in payload assert "earnings_after_close_long_v1" in payload assert "earnings_after_close_short_v1" in payload assert result.metrics.trade_count >= 1 trade_blotter = pq.read_table(run_dir / "artifacts" / "trade_blotter.parquet").to_pylist() engine_ids = {row["engine_id"] for row in trade_blotter} assert "earnings_after_close_short_v1" not in engine_ids equity_curve = pq.read_table(run_dir / "artifacts" / "daily_equity_curve.parquet").to_pylist() assert any(float(row["net_exposure"]) < 0 for row in equity_curve if float(row["gross_exposure"]) > 0) metrics_summary = json.loads((run_dir / "metrics" / "metrics_summary.json").read_text()) assert "avg_gross_exposure_pct" in metrics_summary assert "avg_net_exposure_pct" in metrics_summary assert "days_in_market_pct" in metrics_summary def test_engine_execution_overrides_flow_into_effective_execution_config(self): from apps.backtester.run import BacktestRunner from libs.backtest.domain import Candidate, ExperimentManifest, StrategyEngineConfig store = _build_multi_engine_store() manifest = ExperimentManifest( experiment_name="portfolio_exec_overrides", dataset_snapshot_id="test_snapshot", base_config="configs/backtest/defaults.json", overrides={}, strategy_engines=[ StrategyEngineConfig( engine_id="earnings_same_day_long_trend_v1", event_types=["earnings_release"], timing_class="same_day", direction="long_only", entry_timing_policy="reaction_close", max_holding_days=12, engine_risk_budget_pct=0.25, target_atr_multiplier_override=2.5, target_1_r_override=3.25, target_1_fraction_override=0.33, trailing_model_override="pct_10", trailing_warmup_days_override=2, ), ], ) config = _make_config(strategy_engines=manifest.strategy_engines) runner = BacktestRunner(manifest=manifest, config=config, store=store, initial_equity=100_000.0) candidate = Candidate( event_id="EVT::SD::LONG", symbol="AMD", issuer_id="ISSUER::AMD", score=0.92, sector="Technology", event_type="earnings_release", event_timestamp=dt.datetime(2026, 1, 6, 21, 0, tzinfo=_UTC), event_date=dt.date(2026, 1, 6), filing_time_bucket="post_market", timing_class="same_day", reaction_date=dt.date(2026, 1, 6), execution_date=dt.date(2026, 1, 6), entry_price_est=122.0, avg_dollar_volume=9_000_000.0, atr_14=3.0, score_bucket="high", engine_id="earnings_same_day_long_trend_v1", entry_timing_policy="reaction_close", shadow_only=False, engine_max_holding_days=12, engine_risk_budget_pct=0.25, engine_target_atr_multiplier=2.5, engine_target_1_r=3.25, engine_target_1_fraction=0.33, engine_trailing_model="pct_10", engine_trailing_warmup_days=2, trade_direction="long", ) effective_exec = runner._build_effective_execution_config(candidate) assert candidate.engine_id == "earnings_same_day_long_trend_v1" assert effective_exec.max_holding_days == 12 assert effective_exec.target_atr_multiplier == pytest.approx(2.5) assert effective_exec.target_1_r == pytest.approx(3.25) assert effective_exec.target_1_fraction == pytest.approx(0.33) assert effective_exec.trailing_model == "pct_10" assert effective_exec.trailing_warmup_days == 2 def test_engine_target_overrides_take_precedence_over_tiered_targets(self): from apps.backtester.run import BacktestRunner from libs.backtest.domain import Candidate, ExecutionConfig, ExperimentManifest, SignalConfig, StrategyEngineConfig store = _build_multi_engine_store() manifest = ExperimentManifest( experiment_name="portfolio_exec_target_override_precedence", dataset_snapshot_id="test_snapshot", base_config="configs/backtest/defaults.json", overrides={}, strategy_engines=[ StrategyEngineConfig( engine_id="earnings_same_day_long_trend_v1", event_types=["earnings_release"], timing_class="same_day", direction="long_only", entry_timing_policy="reaction_close", target_1_r_override=3.5, target_1_fraction_override=0.1, ), ], ) config = _make_config(strategy_engines=manifest.strategy_engines) config = config.model_copy( update={ "signal": SignalConfig(score_threshold=0.5, max_candidates_per_day=5, a_tier_score_threshold=0.8), "execution": ExecutionConfig( entry_fill_model="next_open", exit_fill_model="daily_bar_approximation", slippage_bps_base=10.0, commission_per_share=0.005, same_bar_priority="stop_first_conservative", max_holding_days=10, target_1_r=1.5, target_1_fraction=0.5, use_tiered_targets=True, a_tier_target_1_r=2.0, a_tier_target_1_fraction=0.25, non_a_tier_target_1_r=1.25, non_a_tier_target_1_fraction=0.6, ), } ) runner = BacktestRunner(manifest=manifest, config=config, store=store, initial_equity=100_000.0) candidate = Candidate( event_id="EVT::SD::LONG::TIER", symbol="AMD", issuer_id="ISSUER::AMD", score=0.92, sector="Technology", event_type="earnings_release", event_timestamp=dt.datetime(2026, 1, 6, 21, 0, tzinfo=_UTC), event_date=dt.date(2026, 1, 6), filing_time_bucket="post_market", timing_class="same_day", reaction_date=dt.date(2026, 1, 6), execution_date=dt.date(2026, 1, 6), entry_price_est=122.0, avg_dollar_volume=9_000_000.0, atr_14=3.0, score_bucket="high", engine_id="earnings_same_day_long_trend_v1", entry_timing_policy="reaction_close", shadow_only=False, engine_target_1_r=3.5, engine_target_1_fraction=0.1, trade_direction="long", ) effective_exec = runner._build_effective_execution_config(candidate) assert effective_exec.target_1_r == pytest.approx(3.5) assert effective_exec.target_1_fraction == pytest.approx(0.1) def test_tail_exit_adjuster_tightens_execution_for_hot_candidate(self): from apps.backtester.run import BacktestRunner from libs.backtest.domain import Candidate, ExecutionConfig, ExperimentManifest, RiskConfig store = _build_multi_engine_store() manifest = ExperimentManifest( experiment_name="portfolio_tail_exit_adjuster", dataset_snapshot_id="test_snapshot", base_config="configs/backtest/defaults.json", overrides={}, ) config = _make_config() config = config.model_copy( update={ "risk": RiskConfig( per_trade_risk_pct=0.01, max_daily_new_risk_pct=0.05, max_positions=10, max_positions_per_sector=5, tail_exit_adjuster_enabled=True, tail_exit_threshold=0.60, tail_exit_min_signals=2, tail_exit_max_holding_days=8, tail_exit_no_progress_days=1, tail_exit_no_progress_r=0.25, tail_exit_no_progress_fraction=1.0, ), "execution": ExecutionConfig( entry_fill_model="next_open", exit_fill_model="daily_bar_approximation", slippage_bps_base=10.0, commission_per_share=0.005, same_bar_priority="stop_first_conservative", max_holding_days=12, early_failure_no_progress_days=2, early_failure_no_progress_r=0.15, early_failure_no_progress_fraction=0.5, ), } ) runner = BacktestRunner(manifest=manifest, config=config, store=store, initial_equity=100_000.0) candidate = Candidate( event_id="EVT::TAIL::HOT", symbol="AMD", issuer_id="ISSUER::AMD", score=0.88, sector="Technology", event_type="earnings_release", event_timestamp=dt.datetime(2026, 1, 6, 21, 0, tzinfo=_UTC), event_date=dt.date(2026, 1, 6), filing_time_bucket="post_market", timing_class="same_day", reaction_date=dt.date(2026, 1, 6), execution_date=dt.date(2026, 1, 6), entry_price_est=122.0, avg_dollar_volume=9_000_000.0, atr_14=3.0, score_bucket="high", engine_id="tail_hot", entry_timing_policy="reaction_close", shadow_only=False, engine_max_holding_days=12, trade_direction="long", features={ "reaction_day_return": 0.14, "oneoff_penalty": 0.4, "pre_event_market_temperature": 1.4, "pre_event_entropy_60d": 2.0, }, ) effective_exec = runner._build_effective_execution_config(candidate) assert effective_exec.max_holding_days == 8 assert effective_exec.early_failure_no_progress_days == 1 assert effective_exec.early_failure_no_progress_r == pytest.approx(0.25) assert effective_exec.early_failure_no_progress_fraction == pytest.approx(1.0) def test_funding_optimizer_prefers_more_capital_efficient_candidate(self): from apps.backtester.run import BacktestRunner from libs.backtest.domain import Candidate, DailyPortfolioState, ExperimentManifest store = _build_multi_engine_store() manifest = ExperimentManifest( experiment_name="portfolio_cap_efficiency_order", dataset_snapshot_id="test_snapshot", base_config="configs/backtest/defaults.json", overrides={}, ) config = _make_config() config = config.model_copy(update={"strategy_engine_selection_mode": "interleave_cap_efficiency_soft"}) runner = BacktestRunner(manifest=manifest, config=config, store=store, initial_equity=100_000.0) expensive = Candidate( event_id="EVT::EXPENSIVE", symbol="EXP", issuer_id="ISSUER::EXP", score=0.9, sector="Technology", event_type="earnings_release", event_timestamp=dt.datetime(2026, 1, 6, 21, 0, tzinfo=_UTC), event_date=dt.date(2026, 1, 6), filing_time_bucket="post_market", timing_class="same_day", reaction_date=dt.date(2026, 1, 6), execution_date=dt.date(2026, 1, 6), entry_price_est=100.0, avg_dollar_volume=20_000_000.0, atr_14=2.0, score_bucket="high", engine_id="core", entry_timing_policy="reaction_close", shadow_only=False, trade_direction="long", forced_shares=500, ) cheap = Candidate( event_id="EVT::CHEAP", symbol="CHP", issuer_id="ISSUER::CHP", score=0.9, sector="Technology", event_type="earnings_release", event_timestamp=dt.datetime(2026, 1, 6, 21, 0, tzinfo=_UTC), event_date=dt.date(2026, 1, 6), filing_time_bucket="post_market", timing_class="same_day", reaction_date=dt.date(2026, 1, 6), execution_date=dt.date(2026, 1, 6), entry_price_est=100.0, avg_dollar_volume=20_000_000.0, atr_14=2.0, score_bucket="high", engine_id="core", entry_timing_policy="reaction_close", shadow_only=False, trade_direction="long", forced_shares=50, ) portfolio_state = DailyPortfolioState( date=dt.date(2026, 1, 6), equity=100_000.0, cash_available=100_000.0, gross_exposure=0.0, net_exposure=0.0, reserved_risk_budget=0.0, unrealized_pnl=0.0, realized_pnl=0.0, open_positions=[], daily_new_risk_used=0.0, peak_equity=100_000.0, current_drawdown_pct=0.0, ) ordered = runner._reorder_candidates_for_funding([expensive, cheap], portfolio_state, macro_data=None) assert [candidate.symbol for candidate in ordered[:2]] == ["CHP", "EXP"] def test_attention_gate_filters_engine_candidates(self): from apps.backtester.run import BacktestRunner from libs.backtest.domain import ExperimentManifest, StrategyEngineConfig from libs.backtest.snapshot_store import SnapshotStore from libs.oracle_client.models import EntityInfo, EventAttentionResponse, NewsFeatures, WikiFeatures date = dt.date(2026, 1, 7) store = SnapshotStore( candidates_by_exec_date={ date: [ { "event_id": "EVT::ATTN::PASS", "symbol": "PASS", "execution_date": date, "entry_date": "2026-01-07", "event_date": "2026-01-06", "event_close": 100.0, "gap_size": 0.12, "entry_price": 100.0, "score": 0.90, "sector": "Technology", "event_type": "earnings_release", "event_timestamp": "2026-01-06T21:00:00+00:00", "filing_time_bucket": "post_market", "reaction_date": "2026-01-06", "reaction_day_return": -0.12, "avg_dollar_volume": 8_000_000.0, "atr_14": 4.0, }, { "event_id": "EVT::ATTN::FAIL", "symbol": "FAIL", "execution_date": date, "entry_date": "2026-01-07", "event_date": "2026-01-06", "event_close": 101.0, "gap_size": 0.11, "entry_price": 101.0, "score": 0.89, "sector": "Technology", "event_type": "earnings_release", "event_timestamp": "2026-01-06T21:00:00+00:00", "filing_time_bucket": "post_market", "reaction_date": "2026-01-06", "reaction_day_return": -0.11, "avg_dollar_volume": 7_500_000.0, "atr_14": 4.0, }, ] }, bars_by_symbol_date={ "PASS": {date: {"date": date, "open": 100.0, "high": 100.0, "low": 95.0, "close": 96.0, "volume": 1_000_000}}, "FAIL": {date: {"date": date, "open": 101.0, "high": 102.0, "low": 98.0, "close": 99.0, "volume": 900_000}}, }, ) manifest = ExperimentManifest( experiment_name="attention_gate_test", dataset_snapshot_id="test_snapshot", base_config="configs/backtest/defaults.json", overrides={}, strategy_engines=[ StrategyEngineConfig( engine_id="same_day_short_attention", event_types=["earnings_release"], timing_class="same_day", direction="short_only", attention_min_wiki_spike_10d=1.2, ) ], ) config = _make_config(strategy_engines=manifest.strategy_engines) runner = BacktestRunner(manifest=manifest, config=config, store=store) def _fake_attention(candidate): spike = 1.5 if candidate.symbol == "PASS" else 0.9 return EventAttentionResponse( ticker=candidate.symbol, event_date="2026-01-06", entity=EntityInfo( ticker=candidate.symbol, canonical_name=candidate.symbol, resolver_confidence=0.9, ), wiki=WikiFeatures(spike_10d=spike, zscore_20d=1.0), news=NewsFeatures(), metadata={}, ) runner._attention_service._get_event_attention = _fake_attention # type: ignore[method-assign] selected = runner._select_candidates_for_date(date) assert [candidate.symbol for candidate in selected] == ["PASS"] assert selected[0].features["attention_wiki_spike_10d"] == pytest.approx(1.5) def test_attention_max_gate_allows_missing_payload(self): from apps.backtester.run import BacktestRunner from libs.backtest.domain import ExperimentManifest, StrategyEngineConfig from libs.backtest.snapshot_store import SnapshotStore date = dt.date(2026, 1, 6) store = SnapshotStore( candidates_by_exec_date={ date: [ { "event_id": "EVT::ATTN::KNOWN", "symbol": "KNOWN", "execution_date": date, "entry_date": "2026-01-06", "event_date": "2026-01-06", "event_close": 100.0, "gap_size": 0.12, "entry_price": 100.0, "score": 0.90, "sector": "Technology", "event_type": "earnings_release", "event_timestamp": "2026-01-06T21:00:00+00:00", "filing_time_bucket": "post_market", "reaction_date": "2026-01-06", "reaction_day_return": 0.15, "avg_dollar_volume": 8_000_000.0, "atr_14": 4.0, }, { "event_id": "EVT::ATTN::MISSING", "symbol": "MISSING", "execution_date": date, "entry_date": "2026-01-06", "event_date": "2026-01-06", "event_close": 101.0, "gap_size": 0.11, "entry_price": 101.0, "score": 0.89, "sector": "Technology", "event_type": "earnings_release", "event_timestamp": "2026-01-06T21:00:00+00:00", "filing_time_bucket": "post_market", "reaction_date": "2026-01-06", "reaction_day_return": 0.14, "avg_dollar_volume": 7_500_000.0, "atr_14": 4.0, }, ] }, bars_by_symbol_date={ "KNOWN": {date: {"date": date, "open": 100.0, "high": 105.0, "low": 99.0, "close": 103.0, "volume": 1_000_000}}, "MISSING": {date: {"date": date, "open": 101.0, "high": 104.0, "low": 100.0, "close": 102.0, "volume": 900_000}}, }, ) manifest = ExperimentManifest( experiment_name="attention_max_gate_test", dataset_snapshot_id="test_snapshot", base_config="configs/backtest/defaults.json", overrides={}, strategy_engines=[ StrategyEngineConfig( engine_id="same_day_long_attention_cap", event_types=["earnings_release"], timing_class="same_day", direction="long_only", entry_timing_policy="reaction_close", gap_size_min=0.10, attention_max_wiki_spike_10d=1.5, ) ], ) config = _make_config(strategy_engines=manifest.strategy_engines) runner = BacktestRunner(manifest=manifest, config=config, store=store) def _fake_attention(candidate): if candidate.symbol == "KNOWN": from libs.oracle_client.models import EntityInfo, EventAttentionResponse, NewsFeatures, WikiFeatures return EventAttentionResponse( ticker=candidate.symbol, event_date="2026-01-06", entity=EntityInfo( ticker=candidate.symbol, canonical_name=candidate.symbol, resolver_confidence=0.9, ), wiki=WikiFeatures(spike_10d=1.4, zscore_20d=0.5), news=NewsFeatures(), metadata={}, ) return None runner._attention_service._get_event_attention = _fake_attention # type: ignore[method-assign] selected = runner._select_candidates_for_date(date) assert [candidate.symbol for candidate in selected] == ["KNOWN", "MISSING"] assert selected[0].features["attention_wiki_spike_10d"] == pytest.approx(1.4) assert "attention_wiki_spike_10d" not in selected[1].features def test_attention_path_honors_custom_ranking_fields(self): from apps.backtester.run import BacktestRunner from libs.backtest.domain import ExperimentManifest, StrategyEngineConfig from libs.backtest.snapshot_store import SnapshotStore from libs.oracle_client.models import EntityInfo, EventAttentionResponse, NewsFeatures, WikiFeatures date = dt.date(2026, 1, 8) store = SnapshotStore( candidates_by_exec_date={ date: [ { "event_id": "EVT::ATTN::DOCWIN", "symbol": "DOCWIN", "execution_date": date, "entry_date": date.isoformat(), "event_date": date.isoformat(), "event_close": 100.0, "entry_price": 100.0, "score": 0.90, "sector": "Technology", "event_type": "earnings_release", "event_direction": "bullish", "guidance_status": "raised", "event_timestamp": "2026-01-08T21:00:00+00:00", "filing_time_bucket": "post_market", "reaction_date": date.isoformat(), "reaction_day_return": 0.09, "close_location": 0.72, "volume_ratio_20d": 1.8, "gap_size": 0.01, "parse_confidence_overall": 0.82, "parse_confidence_event_direction": 0.82, "parse_confidence_guidance": 0.82, "document_quality_score": 0.95, "signal_strength_score": 0.95, "guidance_direction_score": 1.0, "oneoff_penalty": 0.05, "avg_dollar_volume": 8_000_000.0, "atr_14": 4.0, }, { "event_id": "EVT::ATTN::CLOSEWIN", "symbol": "CLOSEWIN", "execution_date": date, "entry_date": date.isoformat(), "event_date": date.isoformat(), "event_close": 101.0, "entry_price": 101.0, "score": 0.89, "sector": "Technology", "event_type": "earnings_release", "event_direction": "bullish", "guidance_status": "raised", "event_timestamp": "2026-01-08T21:00:00+00:00", "filing_time_bucket": "post_market", "reaction_date": date.isoformat(), "reaction_day_return": 0.08, "close_location": 0.92, "volume_ratio_20d": 1.8, "gap_size": 0.01, "parse_confidence_overall": 0.70, "parse_confidence_event_direction": 0.70, "parse_confidence_guidance": 0.70, "document_quality_score": 0.60, "signal_strength_score": 0.60, "guidance_direction_score": 0.5, "oneoff_penalty": 0.05, "avg_dollar_volume": 7_500_000.0, "atr_14": 4.0, }, ] }, bars_by_symbol_date={}, ) manifest = ExperimentManifest( experiment_name="attention_ranking_fields_test", dataset_snapshot_id="test_snapshot", base_config="configs/backtest/defaults.json", overrides={}, strategy_engines=[ StrategyEngineConfig( engine_id="same_day_long_attention", event_types=["earnings_release"], timing_class="same_day", direction="long_only", entry_timing_policy="reaction_close", attention_max_wiki_spike_10d=2.0, ) ], ) config = _make_config(strategy_engines=manifest.strategy_engines) config.signal.scoring_model = "return_max_long_v2" config.signal.score_threshold = 0.45 config.signal.max_candidates_per_day = 1 config.signal.ranking_fields = ["-close_location", "-score"] runner = BacktestRunner(manifest=manifest, config=config, store=store) def _fake_attention(candidate): return EventAttentionResponse( ticker=candidate.symbol, event_date=date.isoformat(), entity=EntityInfo( ticker=candidate.symbol, canonical_name=candidate.symbol, resolver_confidence=0.9, ), wiki=WikiFeatures(spike_10d=1.0, zscore_20d=0.5), news=NewsFeatures(), metadata={}, ) runner._get_event_attention = _fake_attention # type: ignore[method-assign] selected = runner._select_candidates_for_date(date) assert [candidate.symbol for candidate in selected] == ["CLOSEWIN"] def test_return_max_long_strategy_schedules_add_on_and_writes_benchmark_metrics(self, tmp_path): from apps.backtester.run import BacktestRunner from libs.backtest.domain import ( BacktestConfig, EventTypeProfile, ExecutionConfig, ExperimentManifest, ReportingConfig, RiskConfig, SignalConfig, StrategyEngineConfig, UniverseConfig, ) from libs.backtest.snapshot_store import SnapshotStore import json import pyarrow.parquet as pq dates = [dt.date(2026, 1, d) for d in [5, 6, 7, 8]] store = SnapshotStore( candidates_by_exec_date={ dt.date(2026, 1, 5): [ { "event_id": "EVT::LONG::001", "symbol": "NVDA", "execution_date": dt.date(2026, 1, 5), "entry_date": "2026-01-05", "event_date": "2026-01-05", "event_close": 101.0, "entry_price": 101.0, "score": 0.82, "sector": "Technology", "event_type": "earnings_release", "event_timestamp": "2026-01-05T21:00:00+00:00", "filing_time_bucket": "post_market", "reaction_date": "2026-01-05", "reaction_day_return": 0.08, "close_location": 0.82, "volume_ratio_20d": 1.8, "gap_size": 0.01, "event_direction": "bullish", "guidance_status": "raised", "parse_confidence_overall": 0.9, "parse_confidence_event_direction": 0.85, "parse_confidence_guidance": 0.8, "document_quality_score": 0.8, "guidance_direction_score": 1.0, "oneoff_penalty": 0.1, "avg_dollar_volume": 120_000_000.0, "market_cap_proxy": 10_000_000_000.0, "atr_14": 3.0, } ], dt.date(2026, 1, 8): [], }, bars_by_symbol_date={ "NVDA": { dt.date(2026, 1, 5): {"date": dt.date(2026, 1, 5), "open": 100.0, "high": 102.0, "low": 99.0, "close": 101.0, "volume": 2_000_000}, dt.date(2026, 1, 6): {"date": dt.date(2026, 1, 6), "open": 102.0, "high": 108.0, "low": 101.0, "close": 107.0, "volume": 1_900_000}, dt.date(2026, 1, 7): {"date": dt.date(2026, 1, 7), "open": 108.0, "high": 112.0, "low": 107.0, "close": 111.0, "volume": 1_800_000}, dt.date(2026, 1, 8): {"date": dt.date(2026, 1, 8), "open": 112.0, "high": 115.0, "low": 111.0, "close": 114.0, "volume": 1_700_000}, } }, macro_by_date={ dates[0]: {"spy_close": 500.0, "spy_sma_20": 495.0, "qqq_close": 420.0, "qqq_sma_20": 415.0}, dates[1]: {"spy_close": 503.0, "spy_sma_20": 496.0, "qqq_close": 425.0, "qqq_sma_20": 416.0}, dates[2]: {"spy_close": 505.0, "spy_sma_20": 497.0, "qqq_close": 430.0, "qqq_sma_20": 417.0}, dates[3]: {"spy_close": 507.0, "spy_sma_20": 498.0, "qqq_close": 435.0, "qqq_sma_20": 418.0}, }, ) manifest = ExperimentManifest( experiment_name="return_max_long_v1", dataset_snapshot_id="midlarge-liquid-long-v1", base_config="configs/backtest/return_max_long_v1.json", overrides={}, strategy_engines=[ StrategyEngineConfig( engine_id="reaction_close_long_core", event_types=["earnings_release", "guidance_update"], timing_class="same_day", direction="long_only", entry_timing_policy="reaction_close", max_holding_days=20, engine_risk_budget_pct=0.5, reaction_day_return_min=0.04, reaction_day_return_max=0.12, close_location_min=0.70, volume_ratio_min=1.5, score_threshold_override=0.62, ), StrategyEngineConfig( engine_id="delayed_add_on_long", event_types=["earnings_release", "guidance_update"], timing_class="any", direction="long_only", entry_timing_policy="next_open", synthetic_only=True, max_holding_days=15, engine_risk_budget_pct=1.0, ), ], ) config = BacktestConfig( strategy_name="return_max_long_v1", dataset_snapshot_id="midlarge-liquid-long-v1", universe=UniverseConfig( min_price=15.0, min_avg_dollar_volume=75_000_000.0, min_market_cap_proxy=2_000_000_000.0, ), signal=SignalConfig( score_threshold=0.62, max_candidates_per_day=6, scoring_model="return_max_long_v1", a_tier_score_threshold=0.75, ), risk=RiskConfig( per_trade_risk_pct=0.004, per_trade_risk_pct_a_tier=0.005, max_daily_new_risk_pct=0.015, max_positions=6, max_positions_per_sector=2, macro_regime_enabled=True, macro_regime_mode="spy_qqq_scaler", macro_regime_neutral_size_scaler=0.6, macro_regime_risk_off_size_scaler=0.35, macro_regime_risk_off_a_tier_only=True, stop_atr_multiplier=2.25, veto_oneoff_penalty=0.4, veto_parse_confidence_min=0.6, veto_unknown_direction=True, veto_bearish_direction=True, ), execution=ExecutionConfig( entry_fill_model="next_open", exit_fill_model="daily_bar_approximation", slippage_bps_base=0.0, commission_per_share=0.0, same_bar_priority="stop_first_conservative", target_model="fixed_r", target_1_r=1.5, target_1_fraction=0.5, use_tiered_targets=True, a_tier_target_1_r=2.0, a_tier_target_1_fraction=0.25, non_a_tier_target_1_r=1.5, non_a_tier_target_1_fraction=0.5, trailing_model="pct_6", trailing_warmup_days=3, max_holding_days=15, early_failure_close_below_entry_and_reaction_close=True, early_failure_no_progress_days=2, early_failure_no_progress_r=0.5, early_failure_no_progress_fraction=0.5, ), reporting=ReportingConfig( write_trade_blotter=True, write_equity_curve=True, write_metrics_summary=True, generate_plots=False, ), event_type_profiles={ "earnings_release": EventTypeProfile(enabled=True, max_holding_days_override=20), "guidance_update": EventTypeProfile(enabled=True, max_holding_days_override=12), "unknown": EventTypeProfile(enabled=False), }, strategy_engines=manifest.strategy_engines, strategy_engine_selection_mode="interleave", ) runner = BacktestRunner(manifest=manifest, config=config, store=store, initial_equity=100_000.0) result = runner.run(output_root=tmp_path) run_dir = tmp_path / result.run_id trade_blotter = pq.read_table(run_dir / "artifacts" / "trade_blotter.parquet").to_pylist() assert any(bool(row["is_add_on"]) for row in trade_blotter) add_on_rows = [row for row in trade_blotter if row["engine_id"] == "delayed_add_on_long"] assert add_on_rows assert all(bool(row["is_add_on"]) for row in add_on_rows) assert all(row["parent_position_id"] is not None for row in add_on_rows) metrics_summary = json.loads((run_dir / "metrics" / "metrics_summary.json").read_text()) assert metrics_summary["qqq_benchmark_return_pct"] is not None assert metrics_summary["excess_vs_qqq_pct"] is not None assert metrics_summary["long_pnl_contribution_pct"] is not None def test_residual_priority_engine_excludes_selected_names_from_core_queue(self): from apps.backtester.run import BacktestRunner from libs.backtest.domain import ( BacktestConfig, ExecutionConfig, ExperimentManifest, ReportingConfig, RiskConfig, SignalConfig, StrategyEngineConfig, UniverseConfig, ) from libs.backtest.snapshot_store import SnapshotStore date = dt.date(2026, 1, 5) store = SnapshotStore( candidates_by_exec_date={ date: [ { "event_id": "EVT::A", "symbol": "AAPL", "execution_date": date, "entry_date": "2026-01-05", "event_date": "2026-01-05", "event_close": 101.0, "entry_price": 101.0, "score": 0.60, "sector": "Technology", "event_type": "earnings_release", "event_timestamp": "2026-01-05T21:00:00+00:00", "filing_time_bucket": "regular_hours", "reaction_date": "2026-01-05", "reaction_day_return": 0.10, "close_location": 0.75, "volume_ratio_20d": 2.5, "gap_size": 0.03, "event_direction": "bullish", "guidance_status": "raised", "parse_confidence_overall": 0.90, "parse_confidence_event_direction": 0.85, "parse_confidence_guidance": 0.80, "document_quality_score": 0.80, "guidance_direction_score": 1.0, "oneoff_penalty": 0.1, "avg_dollar_volume": 120_000_000.0, "market_cap_proxy": 10_000_000_000.0, "atr_14": 3.0, }, { "event_id": "EVT::B", "symbol": "MSFT", "execution_date": date, "entry_date": "2026-01-05", "event_date": "2026-01-05", "event_close": 201.0, "entry_price": 201.0, "score": 0.58, "sector": "Technology", "event_type": "earnings_release", "event_timestamp": "2026-01-05T21:00:00+00:00", "filing_time_bucket": "regular_hours", "reaction_date": "2026-01-05", "reaction_day_return": 0.07, "close_location": 0.72, "volume_ratio_20d": 2.1, "gap_size": 0.02, "event_direction": "bullish", "guidance_status": "raised", "parse_confidence_overall": 0.88, "parse_confidence_event_direction": 0.84, "parse_confidence_guidance": 0.79, "document_quality_score": 0.79, "guidance_direction_score": 1.0, "oneoff_penalty": 0.1, "avg_dollar_volume": 150_000_000.0, "market_cap_proxy": 20_000_000_000.0, "atr_14": 4.0, }, ], }, bars_by_symbol_date={}, ) manifest = ExperimentManifest( experiment_name="residual_priority_test", dataset_snapshot_id="test_snapshot", base_config="configs/backtest/return_max_long_v1.json", overrides={}, strategy_engines=[ StrategyEngineConfig( engine_id="priority", event_types=["earnings_release"], event_directions=["bullish"], guidance_statuses=["raised"], timing_class="same_day", direction="long_only", entry_timing_policy="reaction_close", reaction_day_return_min=0.08, reaction_day_return_max=0.18, volume_ratio_min=2.0, residual_reserve_selected=True, score_threshold_override=0.50, ), StrategyEngineConfig( engine_id="core", event_types=["earnings_release"], timing_class="same_day", direction="long_only", entry_timing_policy="reaction_close", reaction_day_return_min=0.03, reaction_day_return_max=0.18, volume_ratio_min=1.0, score_threshold_override=0.50, ), ], ) config = BacktestConfig( strategy_name="return_max_long_v1", dataset_snapshot_id="test_snapshot", universe=UniverseConfig(min_price=15.0, min_avg_dollar_volume=75_000_000.0, min_market_cap_proxy=2_000_000_000.0), signal=SignalConfig( score_threshold=0.50, max_candidates_per_day=2, scoring_model="return_max_long_v2", ), risk=RiskConfig(max_positions=4, max_positions_per_sector=4), execution=ExecutionConfig(), reporting=ReportingConfig(generate_plots=False), strategy_engines=manifest.strategy_engines, strategy_engine_selection_mode="interleave", ) runner = BacktestRunner(manifest=manifest, config=config, store=store) selected = runner._select_candidates_for_date(date) assert [candidate.symbol for candidate in selected] == ["AAPL", "MSFT"] assert [candidate.engine_id for candidate in selected] == ["priority", "core"] def test_return_max_long_staged_add_on_can_scale_twice(self, tmp_path): from apps.backtester.run import BacktestRunner from libs.backtest.domain import ( BacktestConfig, EventTypeProfile, ExecutionConfig, ExperimentManifest, ReportingConfig, RiskConfig, SignalConfig, StrategyEngineConfig, UniverseConfig, ) from libs.backtest.snapshot_store import SnapshotStore import pyarrow.parquet as pq dates = [dt.date(2026, 1, d) for d in [5, 6, 7, 8, 9, 12]] store = SnapshotStore( candidates_by_exec_date={ dt.date(2026, 1, 5): [ { "event_id": "EVT::LONG::STAGED", "symbol": "NVDA", "execution_date": dt.date(2026, 1, 5), "entry_date": "2026-01-05", "event_date": "2026-01-05", "event_close": 101.0, "entry_price": 101.0, "score": 0.84, "sector": "Technology", "event_type": "earnings_release", "event_timestamp": "2026-01-05T21:00:00+00:00", "filing_time_bucket": "regular_hours", "reaction_date": "2026-01-05", "reaction_day_return": 0.08, "close_location": 0.82, "volume_ratio_20d": 1.8, "gap_size": 0.01, "event_direction": "bullish", "guidance_status": "raised", "parse_confidence_overall": 0.9, "parse_confidence_event_direction": 0.85, "parse_confidence_guidance": 0.8, "document_quality_score": 0.8, "guidance_direction_score": 1.0, "oneoff_penalty": 0.1, "avg_dollar_volume": 120_000_000.0, "market_cap_proxy": 10_000_000_000.0, "atr_14": 3.0, "reaction_day_high": 102.0, } ], dt.date(2026, 1, 12): [], }, bars_by_symbol_date={ "NVDA": { dt.date(2026, 1, 5): {"date": dt.date(2026, 1, 5), "open": 100.0, "high": 102.0, "low": 99.0, "close": 101.0, "volume": 2_000_000}, dt.date(2026, 1, 6): {"date": dt.date(2026, 1, 6), "open": 102.0, "high": 106.0, "low": 101.0, "close": 105.0, "volume": 1_900_000}, dt.date(2026, 1, 7): {"date": dt.date(2026, 1, 7), "open": 106.0, "high": 109.0, "low": 105.0, "close": 108.0, "volume": 1_850_000}, dt.date(2026, 1, 8): {"date": dt.date(2026, 1, 8), "open": 109.0, "high": 114.0, "low": 108.0, "close": 113.0, "volume": 1_800_000}, dt.date(2026, 1, 9): {"date": dt.date(2026, 1, 9), "open": 114.0, "high": 118.0, "low": 113.0, "close": 117.0, "volume": 1_750_000}, dt.date(2026, 1, 12): {"date": dt.date(2026, 1, 12), "open": 118.0, "high": 120.0, "low": 116.0, "close": 119.0, "volume": 1_700_000}, } }, macro_by_date={ day: {"spy_close": 500.0 + idx, "spy_sma_20": 490.0, "qqq_close": 420.0 + idx, "qqq_sma_20": 410.0} for idx, day in enumerate(dates) }, ) manifest = ExperimentManifest( experiment_name="return_max_long_v1_staged_add_on", dataset_snapshot_id="midlarge-liquid-long-v1", base_config="configs/backtest/return_max_long_v1.json", overrides={}, strategy_engines=[ StrategyEngineConfig( engine_id="reaction_close_long_core", event_types=["earnings_release"], timing_class="same_day", direction="long_only", entry_timing_policy="reaction_close", max_holding_days=20, engine_risk_budget_pct=1.0, reaction_day_return_min=0.04, reaction_day_return_max=0.12, close_location_min=0.70, volume_ratio_min=1.5, score_threshold_override=0.62, ), StrategyEngineConfig( engine_id="delayed_add_on_long", event_types=["earnings_release"], timing_class="any", direction="long_only", entry_timing_policy="next_open", synthetic_only=True, max_holding_days=15, engine_risk_budget_pct=1.0, add_on_parent_score_min=0.75, add_on_max_parent_days_held=4, add_on_schedule_days=[1, 3], add_on_progress_r_levels=[0.50, 1.50], add_on_max_count=2, add_on_size_fraction=0.25, add_on_close_location_min=0.70, add_on_require_above_reaction_high=True, ), ], ) config = BacktestConfig( strategy_name="return_max_long_v1", dataset_snapshot_id="midlarge-liquid-long-v1", universe=UniverseConfig(min_price=15.0, min_avg_dollar_volume=75_000_000.0, min_market_cap_proxy=2_000_000_000.0), signal=SignalConfig(score_threshold=0.62, max_candidates_per_day=6, scoring_model="return_max_long_v1", a_tier_score_threshold=0.75), risk=RiskConfig( per_trade_risk_pct=0.01, per_trade_risk_pct_a_tier=0.01, max_daily_new_risk_pct=0.20, max_positions=6, max_positions_per_sector=3, macro_regime_enabled=True, macro_regime_mode="spy_qqq_scaler", macro_regime_neutral_size_scaler=1.0, macro_regime_risk_off_size_scaler=1.0, macro_regime_risk_off_a_tier_only=False, stop_atr_multiplier=2.25, veto_oneoff_penalty=0.4, veto_parse_confidence_min=0.6, veto_unknown_direction=False, veto_bearish_direction=True, ), execution=ExecutionConfig( entry_fill_model="next_open", exit_fill_model="daily_bar_approximation", slippage_bps_base=0.0, commission_per_share=0.0, same_bar_priority="stop_first_conservative", target_model="fixed_r", target_1_r=10.0, target_1_fraction=0.0, trailing_model=None, trailing_warmup_days=20, max_holding_days=20, ), reporting=ReportingConfig(write_trade_blotter=True, write_equity_curve=True, write_metrics_summary=True, generate_plots=False), event_type_profiles={"earnings_release": EventTypeProfile(enabled=True, max_holding_days_override=20)}, strategy_engines=manifest.strategy_engines, strategy_engine_selection_mode="interleave", ) runner = BacktestRunner(manifest=manifest, config=config, store=store, initial_equity=100_000.0) result = runner.run(output_root=tmp_path) run_dir = tmp_path / result.run_id trade_blotter = pq.read_table(run_dir / "artifacts" / "trade_blotter.parquet").to_pylist() add_on_rows = [row for row in trade_blotter if row["engine_id"] == "delayed_add_on_long"] assert len(add_on_rows) >= 2 assert sorted(int(row["shares"]) for row in add_on_rows)[0] > 0 assert all(bool(row["is_add_on"]) for row in add_on_rows) def test_return_max_long_add_on_parent_score_gate_blocks_synthetic_child(self, tmp_path): from apps.backtester.run import BacktestRunner from libs.backtest.domain import ( BacktestConfig, EventTypeProfile, ExecutionConfig, ExperimentManifest, ReportingConfig, RiskConfig, SignalConfig, StrategyEngineConfig, UniverseConfig, ) from libs.backtest.snapshot_store import SnapshotStore import pyarrow.parquet as pq dates = [dt.date(2026, 1, d) for d in [5, 6, 7, 8]] store = SnapshotStore( candidates_by_exec_date={ dt.date(2026, 1, 5): [ { "event_id": "EVT::LONG::002", "symbol": "NVDA", "execution_date": dt.date(2026, 1, 5), "entry_date": "2026-01-05", "event_date": "2026-01-05", "event_close": 101.0, "entry_price": 101.0, "score": 0.70, "sector": "Technology", "event_type": "earnings_release", "event_timestamp": "2026-01-05T21:00:00+00:00", "filing_time_bucket": "post_market", "reaction_date": "2026-01-05", "reaction_day_return": 0.08, "close_location": 0.82, "volume_ratio_20d": 1.8, "gap_size": 0.01, "event_direction": "bullish", "guidance_status": "raised", "parse_confidence_overall": 0.9, "document_quality_score": 0.8, "guidance_direction_score": 1.0, "oneoff_penalty": 0.1, "avg_dollar_volume": 120_000_000.0, "market_cap_proxy": 10_000_000_000.0, "atr_14": 3.0, } ], dt.date(2026, 1, 8): [], }, bars_by_symbol_date={ "NVDA": { dt.date(2026, 1, 5): {"date": dt.date(2026, 1, 5), "open": 100.0, "high": 102.0, "low": 99.0, "close": 101.0, "volume": 2_000_000}, dt.date(2026, 1, 6): {"date": dt.date(2026, 1, 6), "open": 102.0, "high": 108.0, "low": 101.0, "close": 107.0, "volume": 1_900_000}, dt.date(2026, 1, 7): {"date": dt.date(2026, 1, 7), "open": 108.0, "high": 112.0, "low": 107.0, "close": 111.0, "volume": 1_800_000}, dt.date(2026, 1, 8): {"date": dt.date(2026, 1, 8), "open": 112.0, "high": 115.0, "low": 111.0, "close": 114.0, "volume": 1_700_000}, } }, macro_by_date={ dates[0]: {"spy_close": 500.0, "spy_sma_20": 495.0, "qqq_close": 420.0, "qqq_sma_20": 415.0}, dates[1]: {"spy_close": 503.0, "spy_sma_20": 496.0, "qqq_close": 425.0, "qqq_sma_20": 416.0}, dates[2]: {"spy_close": 505.0, "spy_sma_20": 497.0, "qqq_close": 430.0, "qqq_sma_20": 417.0}, dates[3]: {"spy_close": 507.0, "spy_sma_20": 498.0, "qqq_close": 435.0, "qqq_sma_20": 418.0}, }, ) manifest = ExperimentManifest( experiment_name="return_max_long_v1_add_on_parent_gate", dataset_snapshot_id="midlarge-liquid-long-v1", base_config="configs/backtest/return_max_long_v1.json", overrides={}, strategy_engines=[ StrategyEngineConfig( engine_id="reaction_close_long_core", event_types=["earnings_release"], timing_class="same_day", direction="long_only", entry_timing_policy="reaction_close", max_holding_days=20, engine_risk_budget_pct=1.0, reaction_day_return_min=0.04, reaction_day_return_max=0.12, close_location_min=0.70, volume_ratio_min=1.5, score_threshold_override=0.62, ), StrategyEngineConfig( engine_id="delayed_add_on_long", event_types=["earnings_release"], timing_class="any", direction="long_only", entry_timing_policy="next_open", synthetic_only=True, max_holding_days=15, engine_risk_budget_pct=1.0, add_on_parent_score_min=0.80, ), ], ) config = BacktestConfig( strategy_name="return_max_long_v1", dataset_snapshot_id="midlarge-liquid-long-v1", universe=UniverseConfig( min_price=15.0, min_avg_dollar_volume=75_000_000.0, min_market_cap_proxy=2_000_000_000.0, ), signal=SignalConfig( score_threshold=0.62, max_candidates_per_day=6, scoring_model="return_max_long_v1", a_tier_score_threshold=0.75, ), risk=RiskConfig( per_trade_risk_pct=0.004, per_trade_risk_pct_a_tier=0.005, max_daily_new_risk_pct=0.05, max_positions=6, max_positions_per_sector=2, macro_regime_enabled=True, macro_regime_mode="spy_qqq_scaler", macro_regime_neutral_size_scaler=0.6, macro_regime_risk_off_size_scaler=0.35, macro_regime_risk_off_a_tier_only=False, stop_atr_multiplier=2.25, veto_oneoff_penalty=0.4, veto_parse_confidence_min=0.6, veto_unknown_direction=False, veto_bearish_direction=True, ), execution=ExecutionConfig( entry_fill_model="next_open", exit_fill_model="daily_bar_approximation", slippage_bps_base=0.0, commission_per_share=0.0, same_bar_priority="stop_first_conservative", target_model="fixed_r", target_1_r=1.5, target_1_fraction=0.5, use_tiered_targets=True, a_tier_target_1_r=2.0, a_tier_target_1_fraction=0.25, non_a_tier_target_1_r=1.5, non_a_tier_target_1_fraction=0.5, trailing_model="pct_6", trailing_warmup_days=3, max_holding_days=15, early_failure_close_below_entry_and_reaction_close=True, early_failure_no_progress_days=2, early_failure_no_progress_r=0.5, early_failure_no_progress_fraction=0.5, ), reporting=ReportingConfig( write_trade_blotter=True, write_equity_curve=True, write_metrics_summary=True, generate_plots=False, ), event_type_profiles={ "earnings_release": EventTypeProfile(enabled=True, max_holding_days_override=20), "unknown": EventTypeProfile(enabled=False), }, strategy_engines=manifest.strategy_engines, strategy_engine_selection_mode="interleave", ) runner = BacktestRunner(manifest=manifest, config=config, store=store, initial_equity=100_000.0) result = runner.run(output_root=tmp_path) run_dir = tmp_path / result.run_id trade_blotter = pq.read_table(run_dir / "artifacts" / "trade_blotter.parquet").to_pylist() assert not any(bool(row["is_add_on"]) for row in trade_blotter) assert not any(row["engine_id"] == "delayed_add_on_long" for row in trade_blotter) def test_return_max_long_add_on_parent_engine_gate_blocks_non_matching_parent(self, tmp_path): from apps.backtester.run import BacktestRunner from libs.backtest.domain import ( BacktestConfig, EventTypeProfile, ExecutionConfig, ExperimentManifest, ReportingConfig, RiskConfig, SignalConfig, StrategyEngineConfig, UniverseConfig, ) from libs.backtest.snapshot_store import SnapshotStore import pyarrow.parquet as pq dates = [dt.date(2026, 1, d) for d in [5, 6, 7, 8]] store = SnapshotStore( candidates_by_exec_date={ dt.date(2026, 1, 5): [ { "event_id": "EVT::LONG::003", "symbol": "NVDA", "execution_date": dt.date(2026, 1, 5), "entry_date": "2026-01-05", "event_date": "2026-01-05", "event_close": 101.0, "entry_price": 101.0, "score": 0.72, "sector": "Technology", "event_type": "earnings_release", "event_timestamp": "2026-01-05T21:00:00+00:00", "filing_time_bucket": "post_market", "reaction_date": "2026-01-05", "reaction_day_return": 0.08, "close_location": 0.82, "volume_ratio_20d": 1.8, "gap_size": 0.01, "event_direction": "bullish", "guidance_status": "raised", "parse_confidence_overall": 0.9, "document_quality_score": 0.8, "guidance_direction_score": 1.0, "oneoff_penalty": 0.1, "avg_dollar_volume": 120_000_000.0, "market_cap_proxy": 10_000_000_000.0, "atr_14": 3.0, } ], dt.date(2026, 1, 8): [], }, bars_by_symbol_date={ "NVDA": { dt.date(2026, 1, 5): {"date": dt.date(2026, 1, 5), "open": 100.0, "high": 102.0, "low": 99.0, "close": 101.0, "volume": 2_000_000}, dt.date(2026, 1, 6): {"date": dt.date(2026, 1, 6), "open": 102.0, "high": 108.0, "low": 101.0, "close": 107.0, "volume": 1_900_000}, dt.date(2026, 1, 7): {"date": dt.date(2026, 1, 7), "open": 108.0, "high": 112.0, "low": 107.0, "close": 111.0, "volume": 1_800_000}, dt.date(2026, 1, 8): {"date": dt.date(2026, 1, 8), "open": 112.0, "high": 115.0, "low": 111.0, "close": 114.0, "volume": 1_700_000}, } }, macro_by_date={ dates[0]: {"spy_close": 500.0, "spy_sma_20": 495.0, "qqq_close": 420.0, "qqq_sma_20": 415.0}, dates[1]: {"spy_close": 503.0, "spy_sma_20": 496.0, "qqq_close": 425.0, "qqq_sma_20": 416.0}, dates[2]: {"spy_close": 505.0, "spy_sma_20": 497.0, "qqq_close": 430.0, "qqq_sma_20": 417.0}, dates[3]: {"spy_close": 507.0, "spy_sma_20": 498.0, "qqq_close": 435.0, "qqq_sma_20": 418.0}, }, ) manifest = ExperimentManifest( experiment_name="return_max_long_v1_add_on_parent_engine_gate", dataset_snapshot_id="midlarge-liquid-long-v1", base_config="configs/backtest/return_max_long_v1.json", overrides={}, strategy_engines=[ StrategyEngineConfig( engine_id="next_open_controlled_long", event_types=["earnings_release"], timing_class="any", direction="long_only", entry_timing_policy="next_open", max_holding_days=15, engine_risk_budget_pct=1.0, reaction_day_return_min=0.03, reaction_day_return_max=0.10, close_location_min=0.65, volume_ratio_min=1.5, score_threshold_override=0.60, ), StrategyEngineConfig( engine_id="delayed_add_on_long", event_types=["earnings_release"], timing_class="any", direction="long_only", entry_timing_policy="next_open", synthetic_only=True, max_holding_days=15, engine_risk_budget_pct=1.0, add_on_parent_engine_ids=["reaction_close_long_core"], ), ], ) config = BacktestConfig( strategy_name="return_max_long_v1", dataset_snapshot_id="midlarge-liquid-long-v1", universe=UniverseConfig( min_price=15.0, min_avg_dollar_volume=75_000_000.0, min_market_cap_proxy=2_000_000_000.0, ), signal=SignalConfig( score_threshold=0.55, max_candidates_per_day=6, scoring_model="return_max_long_v2", a_tier_score_threshold=0.68, ), risk=RiskConfig( per_trade_risk_pct=0.0045, per_trade_risk_pct_a_tier=0.0055, max_daily_new_risk_pct=0.04, max_positions=10, max_positions_per_sector=3, macro_regime_enabled=True, macro_regime_mode="spy_qqq_scaler", macro_regime_neutral_size_scaler=0.85, macro_regime_risk_off_size_scaler=0.60, macro_regime_risk_off_a_tier_only=False, stop_atr_multiplier=2.25, veto_oneoff_penalty=0.4, veto_parse_confidence_min=0.6, veto_unknown_direction=False, veto_bearish_direction=True, ), execution=ExecutionConfig( entry_fill_model="next_open", exit_fill_model="daily_bar_approximation", slippage_bps_base=0.0, commission_per_share=0.0, same_bar_priority="stop_first_conservative", target_model="fixed_r", target_1_r=1.5, target_1_fraction=0.5, use_tiered_targets=True, a_tier_target_1_r=2.0, a_tier_target_1_fraction=0.25, non_a_tier_target_1_r=1.5, non_a_tier_target_1_fraction=0.5, trailing_model="pct_6", trailing_warmup_days=3, max_holding_days=15, early_failure_close_below_entry_and_reaction_close=True, early_failure_no_progress_days=2, early_failure_no_progress_r=0.5, early_failure_no_progress_fraction=0.5, ), reporting=ReportingConfig( write_trade_blotter=True, write_equity_curve=True, write_metrics_summary=True, generate_plots=False, ), event_type_profiles={ "earnings_release": EventTypeProfile(enabled=True, max_holding_days_override=20), "unknown": EventTypeProfile(enabled=False), }, strategy_engines=manifest.strategy_engines, strategy_engine_selection_mode="interleave", ) runner = BacktestRunner(manifest=manifest, config=config, store=store, initial_equity=100_000.0) result = runner.run(output_root=tmp_path) run_dir = tmp_path / result.run_id trade_blotter = pq.read_table(run_dir / "artifacts" / "trade_blotter.parquet").to_pylist() assert any(row["engine_id"] == "next_open_controlled_long" for row in trade_blotter) assert not any(bool(row["is_add_on"]) for row in trade_blotter) assert not any(row["engine_id"] == "delayed_add_on_long" for row in trade_blotter) def test_evaluate_pending_open_exit_can_schedule_early_pop_giveback(self): from apps.backtester.run import BacktestRunner from libs.backtest.domain import ( BacktestConfig, Candidate, ExecutionConfig, ExperimentManifest, OpenPosition, PlannedOrder, ReportingConfig, RiskConfig, SignalConfig, UniverseConfig, ) from libs.backtest.snapshot_store import SnapshotStore store = SnapshotStore(candidates_by_exec_date={}, bars_by_symbol_date={}) manifest = ExperimentManifest( experiment_name="giveback_eval", dataset_snapshot_id="midlarge-liquid-long-v1", base_config="configs/backtest/return_max_long_v1.json", overrides={}, ) config = BacktestConfig( strategy_name="return_max_long_v1", dataset_snapshot_id="midlarge-liquid-long-v1", universe=UniverseConfig(min_price=15.0, min_avg_dollar_volume=75_000_000.0), signal=SignalConfig(score_threshold=0.45, max_candidates_per_day=10, scoring_model="return_max_long_v2"), risk=RiskConfig(per_trade_risk_pct=0.05, max_daily_new_risk_pct=0.5, max_positions=20), execution=ExecutionConfig( slippage_bps_base=0.0, commission_per_share=0.0, max_holding_days=25, early_pop_giveback_days_min=3, early_pop_giveback_days_max=5, early_pop_giveback_trigger_r=0.75, early_pop_giveback_min_r=0.40, early_pop_giveback_from_peak_pct=0.035, early_pop_giveback_fraction=1.0, ), reporting=ReportingConfig(generate_plots=False), ) runner = BacktestRunner(manifest=manifest, config=config, store=store, initial_equity=100_000.0) candidate = Candidate( event_id="EVT::GIVEBACK", symbol="AAPL", score=0.7, sector="Technology", event_type="earnings_release", event_timestamp=dt.datetime(2026, 1, 5, 21, 0, tzinfo=_UTC), event_date=dt.date(2026, 1, 5), filing_time_bucket="regular_hours", reaction_date=dt.date(2026, 1, 5), execution_date=dt.date(2026, 1, 6), entry_price_est=100.0, avg_dollar_volume=120_000_000.0, atr_14=2.0, score_bucket="high", engine_id="reaction_close_long_core", entry_timing_policy="reaction_close", features={"event_direction": "bullish", "guidance_status": "raised"}, ) plan = PlannedOrder( candidate=candidate, shares=100, entry_price_limit=100.0, stop_price=95.0, target_price=112.0, risk_dollars=500.0, ) position = OpenPosition( position_id="POS::1", plan=plan, entry_date=dt.date(2026, 1, 6), entry_price=100.0, entry_fill_slippage_bps=0.0, current_stop=95.0, target_price=112.0, peak_price=110.0, shares_open=100, shares_total=100, days_held=4, ) payload = runner._evaluate_pending_open_exit( position=position, bar={"date": dt.date(2026, 1, 10), "open": 101.0, "high": 103.0, "low": 100.0, "close": 101.0}, execution_config=config.execution, date=dt.date(2026, 1, 10), ) assert payload is not None assert payload["reason"] == "GIVEBACK" assert payload["fraction"] == pytest.approx(1.0) def test_global_score_selection_uses_custom_ranking_fields(self, tmp_path): from apps.backtester.run import BacktestRunner from libs.backtest.domain import ( BacktestConfig, ExecutionConfig, ExperimentManifest, ReportingConfig, RiskConfig, SignalConfig, StrategyEngineConfig, UniverseConfig, ) from libs.backtest.snapshot_store import SnapshotStore model_path = tmp_path / "ranker.json" model_path.write_text( json.dumps( { "model_type": "bucket_blend_v1", "global_mean": 0.0, "features": [ { "name": "direction_guidance_combo", "weight": 1.0, "values": { "bullish|raised": 0.25, "unknown|not_provided": 0.05, }, } ], } ) ) event_date = dt.date(2026, 1, 7) store = SnapshotStore( candidates_by_exec_date={ event_date: [ { "event_id": "EVT::PRIORITY", "symbol": "AAPL", "execution_date": event_date, "entry_date": event_date.isoformat(), "event_date": event_date.isoformat(), "event_close": 100.0, "entry_price": 100.0, "score": 0.60, "sector": "Technology", "event_type": "earnings_release", "event_direction": "bullish", "guidance_status": "raised", "event_timestamp": "2026-01-07T21:00:00+00:00", "filing_time_bucket": "post_market", "reaction_date": event_date.isoformat(), "reaction_day_return": 0.10, "close_location": 0.72, "volume_ratio_20d": 2.2, "gap_size": 0.03, "avg_dollar_volume": 5_000_000.0, "atr_14": 3.0, }, { "event_id": "EVT::CORE", "symbol": "MSFT", "execution_date": event_date, "entry_date": event_date.isoformat(), "event_date": event_date.isoformat(), "event_close": 200.0, "entry_price": 200.0, "score": 0.90, "sector": "Technology", "event_type": "earnings_release", "event_direction": "unknown", "guidance_status": "not_provided", "event_timestamp": "2026-01-07T21:00:00+00:00", "filing_time_bucket": "post_market", "reaction_date": event_date.isoformat(), "reaction_day_return": 0.09, "close_location": 0.74, "volume_ratio_20d": 2.1, "gap_size": 0.02, "avg_dollar_volume": 8_000_000.0, "atr_14": 4.0, }, ] }, bars_by_symbol_date={}, ) manifest = ExperimentManifest( experiment_name="test_global_ranker", dataset_snapshot_id="test_snapshot", base_config="configs/backtest/defaults.json", overrides={}, strategy_engines=[ StrategyEngineConfig( engine_id="priority", event_types=["earnings_release"], event_directions=["bullish"], guidance_statuses=["raised"], timing_class="same_day", direction="long_only", entry_timing_policy="reaction_close", enabled=True, ), StrategyEngineConfig( engine_id="core", event_types=["earnings_release"], timing_class="same_day", direction="long_only", entry_timing_policy="reaction_close", enabled=True, ), ], ) config = BacktestConfig( strategy_name="test_strategy", dataset_snapshot_id="test_snapshot", universe=UniverseConfig(min_price=5.0, min_avg_dollar_volume=100_000), signal=SignalConfig( score_threshold=0.5, max_candidates_per_day=1, ranking_model_path=str(model_path), ranking_fields=["-learned_rank_score", "-score"], ), risk=RiskConfig( per_trade_risk_pct=0.01, max_daily_new_risk_pct=0.05, max_positions=10, max_positions_per_sector=5, ), execution=ExecutionConfig(max_holding_days=5), reporting=ReportingConfig(), strategy_engines=manifest.strategy_engines, strategy_engine_selection_mode="global_score", ) runner = BacktestRunner(manifest=manifest, config=config, store=store, initial_equity=100_000.0) candidates = runner._select_candidates_for_date(event_date) assert [candidate.symbol for candidate in candidates] == ["AAPL"] def test_interleave_head_score_prioritizes_stronger_core_head(self, tmp_path): from apps.backtester.run import BacktestRunner from libs.backtest.domain import ( BacktestConfig, ExecutionConfig, ExperimentManifest, ReportingConfig, RiskConfig, SignalConfig, StrategyEngineConfig, UniverseConfig, ) from libs.backtest.snapshot_store import SnapshotStore event_date = dt.date(2026, 1, 7) store = SnapshotStore( candidates_by_exec_date={ event_date: [ { "event_id": "EVT::PRIORITY", "symbol": "AAPL", "execution_date": event_date, "entry_date": event_date.isoformat(), "event_date": event_date.isoformat(), "event_close": 100.0, "entry_price": 100.0, "score": 0.60, "sector": "Technology", "event_type": "earnings_release", "event_direction": "bullish", "guidance_status": "raised", "event_timestamp": "2026-01-07T21:00:00+00:00", "filing_time_bucket": "post_market", "reaction_date": event_date.isoformat(), "reaction_day_return": 0.10, "close_location": 0.72, "volume_ratio_20d": 2.2, "gap_size": 0.03, "avg_dollar_volume": 5_000_000.0, "atr_14": 3.0, }, { "event_id": "EVT::CORE", "symbol": "MSFT", "execution_date": event_date, "entry_date": event_date.isoformat(), "event_date": event_date.isoformat(), "event_close": 200.0, "entry_price": 200.0, "score": 0.90, "sector": "Technology", "event_type": "earnings_release", "event_direction": "unknown", "guidance_status": "not_provided", "event_timestamp": "2026-01-07T21:00:00+00:00", "filing_time_bucket": "post_market", "reaction_date": event_date.isoformat(), "reaction_day_return": 0.09, "close_location": 0.74, "volume_ratio_20d": 2.1, "gap_size": 0.02, "avg_dollar_volume": 8_000_000.0, "atr_14": 4.0, }, ] }, bars_by_symbol_date={}, ) manifest = ExperimentManifest( experiment_name="test_interleave_head_score", dataset_snapshot_id="test_snapshot", base_config="configs/backtest/defaults.json", overrides={}, strategy_engines=[ StrategyEngineConfig( engine_id="priority", event_types=["earnings_release"], event_directions=["bullish"], guidance_statuses=["raised"], timing_class="same_day", direction="long_only", entry_timing_policy="reaction_close", enabled=True, ), StrategyEngineConfig( engine_id="core", event_types=["earnings_release"], timing_class="same_day", direction="long_only", entry_timing_policy="reaction_close", enabled=True, ), ], ) config = BacktestConfig( strategy_name="test_strategy", dataset_snapshot_id="test_snapshot", universe=UniverseConfig(min_price=5.0, min_avg_dollar_volume=100_000), signal=SignalConfig( score_threshold=0.5, max_candidates_per_day=2, ), risk=RiskConfig( per_trade_risk_pct=0.01, max_daily_new_risk_pct=0.05, max_positions=10, max_positions_per_sector=5, ), execution=ExecutionConfig(max_holding_days=5), reporting=ReportingConfig(), strategy_engines=manifest.strategy_engines, strategy_engine_selection_mode="interleave_head_score", ) runner = BacktestRunner(manifest=manifest, config=config, store=store, initial_equity=100_000.0) candidates = runner._select_candidates_for_date(event_date) assert [candidate.symbol for candidate in candidates] == ["MSFT", "AAPL"] @pytest.mark.integration class TestWalkForwardRunIntegration: def test_run_walk_forward_generates_train_and_test_folds(self, tmp_path, monkeypatch): from apps.backtester.run import run_walk_forward from libs.backtest.domain import ExperimentManifest from libs.backtest.snapshot_store import SnapshotStore trading_dates = [ dt.date(2026, 1, 5), dt.date(2026, 1, 6), dt.date(2026, 1, 7), dt.date(2026, 1, 8), dt.date(2026, 1, 9), dt.date(2026, 1, 12), dt.date(2026, 1, 13), ] bars: dict[str, dict[dt.date, dict[str, float | dt.date | int]]] = {} candidates_by_date: dict[dt.date, list[dict[str, object]]] = {} for idx, exec_date in enumerate(trading_dates): symbol = f"SYM{idx}" candidates_by_date[exec_date] = [ { "event_id": f"EVT::{idx}", "symbol": symbol, "execution_date": exec_date, "entry_date": exec_date.isoformat(), "event_date": (exec_date - dt.timedelta(days=1)).isoformat(), "event_close": 100.0, "entry_price": 100.0, "score": 0.9, "sector": "Technology", "event_type": "earnings_release", "event_timestamp": f"{(exec_date - dt.timedelta(days=1)).isoformat()}T21:00:00+00:00", "filing_time_bucket": "post_market", "reaction_date": exec_date.isoformat(), "avg_dollar_volume": 5_000_000.0, "atr_14": 2.0, } ] bars[symbol] = { exec_date: { "date": exec_date, "open": 100.0, "high": 106.0, "low": 99.0, "close": 105.0, "volume": 1_000_000, }, exec_date + dt.timedelta(days=1): { "date": exec_date + dt.timedelta(days=1), "open": 105.0, "high": 110.0, "low": 104.0, "close": 109.0, "volume": 900_000, }, } store_map = { "train": SnapshotStore( candidates_by_exec_date={d: candidates_by_date[d] for d in trading_dates[:3]}, bars_by_symbol_date=bars, ), "valid": SnapshotStore( candidates_by_exec_date={d: candidates_by_date[d] for d in trading_dates[3:5]}, bars_by_symbol_date=bars, ), "test": SnapshotStore( candidates_by_exec_date={d: candidates_by_date[d] for d in trading_dates[5:]}, bars_by_symbol_date=bars, ), } def _fake_build_store(manifest, config, split_name, snapshot_dir_override=None): return store_map[split_name] merged_store = SnapshotStore( candidates_by_exec_date=candidates_by_date, bars_by_symbol_date=bars, ) monkeypatch.setattr("apps.backtester.run._build_store", _fake_build_store) monkeypatch.setattr("apps.backtester.run._build_merged_snapshot_store", lambda *args, **kwargs: merged_store) manifest = ExperimentManifest( experiment_name="wf_test_exp", dataset_snapshot_id="test_snapshot", base_config="configs/backtest/defaults.json", overrides={}, ) config = _make_config() summary = run_walk_forward( manifest=manifest, config=config, snapshot_dir_override=None, initial_equity=100_000.0, output_root=str(tmp_path), train_days=3, test_days=2, step_days=2, ) assert summary.fold_count == 2 assert summary.folds[0].train_end < summary.folds[0].test_start assert summary.folds[0].train_run_id assert summary.folds[0].test_run_id assert summary.test_aggregate.mean_return_pct is not None assert (tmp_path / "walk_forward" / "walk_forward_summary.json").exists() def test_run_walk_forward_respects_explicit_date_window(self, tmp_path, monkeypatch): from apps.backtester.run import run_walk_forward from libs.backtest.domain import ExperimentManifest from libs.backtest.snapshot_store import SnapshotStore trading_dates = [ dt.date(2026, 1, 5), dt.date(2026, 1, 6), dt.date(2026, 1, 7), dt.date(2026, 1, 8), dt.date(2026, 1, 9), dt.date(2026, 1, 12), dt.date(2026, 1, 13), ] bars: dict[str, dict[dt.date, dict[str, float | dt.date | int]]] = {} candidates_by_date: dict[dt.date, list[dict[str, object]]] = {} for idx, exec_date in enumerate(trading_dates): symbol = f"WFS{idx}" candidates_by_date[exec_date] = [ { "event_id": f"WFS::{idx}", "symbol": symbol, "execution_date": exec_date, "entry_date": exec_date.isoformat(), "event_date": (exec_date - dt.timedelta(days=1)).isoformat(), "event_close": 100.0, "entry_price": 100.0, "score": 0.9, "sector": "Technology", "event_type": "earnings_release", "event_timestamp": f"{(exec_date - dt.timedelta(days=1)).isoformat()}T21:00:00+00:00", "filing_time_bucket": "post_market", "reaction_date": exec_date.isoformat(), "avg_dollar_volume": 5_000_000.0, "atr_14": 2.0, } ] bars[symbol] = { exec_date: { "date": exec_date, "open": 100.0, "high": 106.0, "low": 99.0, "close": 105.0, "volume": 1_000_000, }, exec_date + dt.timedelta(days=1): { "date": exec_date + dt.timedelta(days=1), "open": 105.0, "high": 110.0, "low": 104.0, "close": 109.0, "volume": 900_000, }, } merged_store = SnapshotStore( candidates_by_exec_date=candidates_by_date, bars_by_symbol_date=bars, ) monkeypatch.setattr("apps.backtester.run._build_merged_snapshot_store", lambda *args, **kwargs: merged_store) manifest = ExperimentManifest( experiment_name="wf_window_test_exp", dataset_snapshot_id="test_snapshot", base_config="configs/backtest/defaults.json", overrides={}, ) config = _make_config() full_summary = run_walk_forward( manifest=manifest, config=config, snapshot_dir_override=None, initial_equity=100_000.0, output_root=str(tmp_path / "full"), train_days=3, test_days=2, step_days=2, ) window_summary = run_walk_forward( manifest=manifest, config=config, snapshot_dir_override=None, initial_equity=100_000.0, output_root=str(tmp_path / "window"), train_days=3, test_days=2, step_days=2, start_date=dt.date(2026, 1, 7), end_date=dt.date(2026, 1, 13), ) assert window_summary.fold_count < full_summary.fold_count assert window_summary.folds[0].train_start >= dt.date(2026, 1, 7) assert window_summary.folds[-1].test_end <= dt.date(2026, 1, 13) @pytest.mark.integration class TestRobustnessMatrixRunIntegration: def test_run_robustness_matrix_generates_summary(self, tmp_path, monkeypatch): from apps.backtester.run import run_robustness_matrix from libs.backtest.domain import ExperimentManifest from libs.backtest.snapshot_store import SnapshotStore trading_dates = [ dt.date(2026, 1, 5), dt.date(2026, 1, 6), dt.date(2026, 1, 7), dt.date(2026, 1, 8), dt.date(2026, 1, 9), dt.date(2026, 1, 12), dt.date(2026, 1, 13), ] bars: dict[str, dict[dt.date, dict[str, float | dt.date | int]]] = {} candidates_by_date: dict[dt.date, list[dict[str, object]]] = {} for idx, exec_date in enumerate(trading_dates): symbol = f"RM{idx}" candidates_by_date[exec_date] = [ { "event_id": f"RM::{idx}", "symbol": symbol, "execution_date": exec_date, "entry_date": exec_date.isoformat(), "event_date": (exec_date - dt.timedelta(days=1)).isoformat(), "event_close": 100.0, "entry_price": 100.0, "score": 0.9, "sector": "Technology", "event_type": "earnings_release", "event_timestamp": f"{(exec_date - dt.timedelta(days=1)).isoformat()}T21:00:00+00:00", "filing_time_bucket": "post_market", "reaction_date": exec_date.isoformat(), "avg_dollar_volume": 5_000_000.0, "atr_14": 2.0, } ] bars[symbol] = { exec_date: { "date": exec_date, "open": 100.0, "high": 106.0, "low": 99.0, "close": 105.0, "volume": 1_000_000, }, exec_date + dt.timedelta(days=1): { "date": exec_date + dt.timedelta(days=1), "open": 105.0, "high": 109.0, "low": 104.0, "close": 108.0, "volume": 900_000, }, } store_map = { "train": SnapshotStore( candidates_by_exec_date={d: candidates_by_date[d] for d in trading_dates[:3]}, bars_by_symbol_date=bars, ), "valid": SnapshotStore( candidates_by_exec_date={d: candidates_by_date[d] for d in trading_dates[3:5]}, bars_by_symbol_date=bars, ), "test": SnapshotStore( candidates_by_exec_date={d: candidates_by_date[d] for d in trading_dates[5:]}, bars_by_symbol_date=bars, ), } def _fake_build_store(manifest, config, split_name, snapshot_dir_override=None): return store_map[split_name] monkeypatch.setattr("apps.backtester.run._build_store", _fake_build_store) monkeypatch.setattr("apps.backtester.run._build_merged_snapshot_store", lambda *args, **kwargs: merged_store) manifest = ExperimentManifest( experiment_name="rm_test_exp", dataset_snapshot_id="test_snapshot", base_config="configs/backtest/defaults.json", overrides={}, ) config = _make_config() summary = run_robustness_matrix( manifest=manifest, config=config, snapshot_dir_override=None, initial_equity=100_000.0, output_root=str(tmp_path), horizons_days=[2, 4], step_days=2, ) assert summary.overall_window_count > 0 assert [item.horizon_days for item in summary.horizon_summaries] == [2, 4] assert all(item.window_count > 0 for item in summary.horizon_summaries) assert (tmp_path / "robustness_matrix" / "robustness_matrix_summary.json").exists() def test_run_robustness_matrix_respects_explicit_date_window(self, tmp_path, monkeypatch): from apps.backtester.run import run_robustness_matrix from libs.backtest.domain import ExperimentManifest from libs.backtest.snapshot_store import SnapshotStore trading_dates = [ dt.date(2026, 1, 5), dt.date(2026, 1, 6), dt.date(2026, 1, 7), dt.date(2026, 1, 8), dt.date(2026, 1, 9), dt.date(2026, 1, 12), dt.date(2026, 1, 13), ] bars: dict[str, dict[dt.date, dict[str, float | dt.date | int]]] = {} candidates_by_date: dict[dt.date, list[dict[str, object]]] = {} for idx, exec_date in enumerate(trading_dates): symbol = f"RMW{idx}" candidates_by_date[exec_date] = [ { "event_id": f"RMW::{idx}", "symbol": symbol, "execution_date": exec_date, "entry_date": exec_date.isoformat(), "event_date": (exec_date - dt.timedelta(days=1)).isoformat(), "event_close": 100.0, "entry_price": 100.0, "score": 0.9, "sector": "Technology", "event_type": "earnings_release", "event_timestamp": f"{(exec_date - dt.timedelta(days=1)).isoformat()}T21:00:00+00:00", "filing_time_bucket": "post_market", "reaction_date": exec_date.isoformat(), "avg_dollar_volume": 5_000_000.0, "atr_14": 2.0, } ] bars[symbol] = { exec_date: { "date": exec_date, "open": 100.0, "high": 106.0, "low": 99.0, "close": 105.0, "volume": 1_000_000, }, exec_date + dt.timedelta(days=1): { "date": exec_date + dt.timedelta(days=1), "open": 105.0, "high": 109.0, "low": 104.0, "close": 108.0, "volume": 900_000, }, } merged_store = SnapshotStore( candidates_by_exec_date=candidates_by_date, bars_by_symbol_date=bars, ) monkeypatch.setattr("apps.backtester.run._build_merged_snapshot_store", lambda *args, **kwargs: merged_store) manifest = ExperimentManifest( experiment_name="rm_window_test_exp", dataset_snapshot_id="test_snapshot", base_config="configs/backtest/defaults.json", overrides={}, ) config = _make_config() full_summary = run_robustness_matrix( manifest=manifest, config=config, snapshot_dir_override=None, initial_equity=100_000.0, output_root=str(tmp_path / "full"), horizons_days=[2, 4], step_days=2, ) window_summary = run_robustness_matrix( manifest=manifest, config=config, snapshot_dir_override=None, initial_equity=100_000.0, output_root=str(tmp_path / "window"), horizons_days=[2, 4], step_days=2, start_date=dt.date(2026, 1, 7), end_date=dt.date(2026, 1, 13), ) assert window_summary.overall_window_count < full_summary.overall_window_count assert all(item.window_count > 0 for item in window_summary.horizon_summaries) @pytest.mark.integration class TestRobustnessMatrixRunIntegration: def test_run_robustness_matrix_generates_summary(self, tmp_path, monkeypatch): from apps.backtester.run import run_robustness_matrix from libs.backtest.domain import ExperimentManifest from libs.backtest.snapshot_store import SnapshotStore trading_dates = [ dt.date(2026, 1, 5), dt.date(2026, 1, 6), dt.date(2026, 1, 7), dt.date(2026, 1, 8), dt.date(2026, 1, 9), dt.date(2026, 1, 12), dt.date(2026, 1, 13), ] bars: dict[str, dict[dt.date, dict[str, float | dt.date | int]]] = {} candidates_by_date: dict[dt.date, list[dict[str, object]]] = {} for idx, exec_date in enumerate(trading_dates): symbol = f"RB{idx}" candidates_by_date[exec_date] = [ { "event_id": f"EVT::RB::{idx}", "symbol": symbol, "execution_date": exec_date, "entry_date": exec_date.isoformat(), "event_date": (exec_date - dt.timedelta(days=1)).isoformat(), "event_close": 100.0, "entry_price": 100.0, "score": 0.9, "sector": "Technology", "event_type": "earnings_release", "event_timestamp": f"{(exec_date - dt.timedelta(days=1)).isoformat()}T21:00:00+00:00", "filing_time_bucket": "post_market", "reaction_date": exec_date.isoformat(), "avg_dollar_volume": 5_000_000.0, "atr_14": 2.0, } ] bars[symbol] = { exec_date: { "date": exec_date, "open": 100.0, "high": 106.0, "low": 99.0, "close": 105.0, "volume": 1_000_000, }, exec_date + dt.timedelta(days=1): { "date": exec_date + dt.timedelta(days=1), "open": 105.0, "high": 110.0, "low": 104.0, "close": 109.0, "volume": 900_000, }, } merged_store = SnapshotStore( candidates_by_exec_date=candidates_by_date, bars_by_symbol_date=bars, ) def _fake_build_store(manifest, config, split_name, snapshot_dir_override=None): return merged_store monkeypatch.setattr("apps.backtester.run._build_store", _fake_build_store) monkeypatch.setattr("apps.backtester.run._build_merged_snapshot_store", lambda *args, **kwargs: merged_store) manifest = ExperimentManifest( experiment_name="rb_test_exp", dataset_snapshot_id="test_snapshot", base_config="configs/backtest/defaults.json", overrides={}, ) config = _make_config() summary = run_robustness_matrix( manifest=manifest, config=config, snapshot_dir_override=None, initial_equity=100_000.0, output_root=str(tmp_path), horizons_days=[2, 4], step_days=2, ) assert summary.horizons_days == [2, 4] assert summary.step_days == 2 assert summary.overall_window_count > 0 assert len(summary.horizon_summaries) == 2 assert all(item.window_count > 0 for item in summary.horizon_summaries) assert all(item.mean_return_pct is not None for item in summary.horizon_summaries) assert (tmp_path / "robustness_matrix" / "robustness_matrix_summary.json").exists() def test_run_robustness_matrix_respects_explicit_date_window(self, tmp_path, monkeypatch): from apps.backtester.run import run_robustness_matrix from libs.backtest.domain import ExperimentManifest from libs.backtest.snapshot_store import SnapshotStore trading_dates = [ dt.date(2026, 1, 5), dt.date(2026, 1, 6), dt.date(2026, 1, 7), dt.date(2026, 1, 8), dt.date(2026, 1, 9), dt.date(2026, 1, 12), dt.date(2026, 1, 13), ] bars: dict[str, dict[dt.date, dict[str, float | dt.date | int]]] = {} candidates_by_date: dict[dt.date, list[dict[str, object]]] = {} for idx, exec_date in enumerate(trading_dates): symbol = f"RMWX{idx}" candidates_by_date[exec_date] = [ { "event_id": f"RMWX::{idx}", "symbol": symbol, "execution_date": exec_date, "entry_date": exec_date.isoformat(), "event_date": (exec_date - dt.timedelta(days=1)).isoformat(), "event_close": 100.0, "entry_price": 100.0, "score": 0.9, "sector": "Technology", "event_type": "earnings_release", "event_timestamp": f"{(exec_date - dt.timedelta(days=1)).isoformat()}T21:00:00+00:00", "filing_time_bucket": "post_market", "reaction_date": exec_date.isoformat(), "avg_dollar_volume": 5_000_000.0, "atr_14": 2.0, } ] bars[symbol] = { exec_date: { "date": exec_date, "open": 100.0, "high": 106.0, "low": 99.0, "close": 105.0, "volume": 1_000_000, }, exec_date + dt.timedelta(days=1): { "date": exec_date + dt.timedelta(days=1), "open": 105.0, "high": 109.0, "low": 104.0, "close": 108.0, "volume": 900_000, }, } merged_store = SnapshotStore( candidates_by_exec_date=candidates_by_date, bars_by_symbol_date=bars, ) monkeypatch.setattr("apps.backtester.run._build_merged_snapshot_store", lambda *args, **kwargs: merged_store) manifest = ExperimentManifest( experiment_name="rm_window_test_exp", dataset_snapshot_id="test_snapshot", base_config="configs/backtest/defaults.json", overrides={}, ) config = _make_config() full_summary = run_robustness_matrix( manifest=manifest, config=config, snapshot_dir_override=None, initial_equity=100_000.0, output_root=str(tmp_path / "full"), horizons_days=[2, 4], step_days=2, ) window_summary = run_robustness_matrix( manifest=manifest, config=config, snapshot_dir_override=None, initial_equity=100_000.0, output_root=str(tmp_path / "window"), horizons_days=[2, 4], step_days=2, start_date=dt.date(2026, 1, 7), end_date=dt.date(2026, 1, 13), ) assert window_summary.overall_window_count < full_summary.overall_window_count assert all(item.window_count > 0 for item in window_summary.horizon_summaries) def test_delayed_entry_can_use_shadow_only_source_candidates(self, tmp_path): from apps.backtester.run import BacktestRunner import pyarrow.parquet as pq from libs.backtest.domain import ( BacktestConfig, EventTypeProfile, ExperimentManifest, ExecutionConfig, ReportingConfig, RiskConfig, SignalConfig, StrategyEngineConfig, UniverseConfig, ) from libs.backtest.snapshot_store import SnapshotStore dates = [dt.date(2026, 1, d) for d in [5, 6, 7, 8, 9]] store = SnapshotStore( candidates_by_exec_date={ dates[0]: [ { "event_id": "EVT::DRIFT::001", "symbol": "AAPL", "execution_date": dates[0], "entry_date": dates[0].isoformat(), "event_date": dates[0].isoformat(), "event_close": 100.0, "entry_price": 100.0, "score": 0.82, "sector": "Technology", "event_type": "earnings_release", "event_timestamp": "2026-01-05T21:00:00+00:00", "filing_time_bucket": "post_market", "reaction_date": dates[0].isoformat(), "reaction_day_return": 0.08, "close_location": 0.82, "volume_ratio_20d": 2.2, "gap_size": 0.01, "avg_dollar_volume": 9_000_000.0, "atr_14": 3.0, } ], dates[1]: [], dates[2]: [], dates[3]: [], dates[4]: [], }, bars_by_symbol_date={ "AAPL": { dates[0]: {"date": dates[0], "open": 98.0, "high": 101.0, "low": 97.0, "close": 100.0, "volume": 1_000_000}, dates[1]: {"date": dates[1], "open": 101.0, "high": 104.0, "low": 100.0, "close": 103.0, "volume": 900_000}, dates[2]: {"date": dates[2], "open": 103.0, "high": 106.0, "low": 102.0, "close": 105.0, "volume": 850_000}, dates[3]: {"date": dates[3], "open": 105.0, "high": 109.0, "low": 104.0, "close": 108.0, "volume": 800_000}, dates[4]: {"date": dates[4], "open": 109.0, "high": 111.0, "low": 108.0, "close": 110.0, "volume": 780_000}, } }, ) manifest = ExperimentManifest( experiment_name="return_max_long_v1_shadow_delayed", dataset_snapshot_id="test_snapshot", base_config="configs/backtest/return_max_long_v1.json", overrides={}, strategy_engines=[ StrategyEngineConfig( engine_id="reaction_close_long_core", event_types=["earnings_release"], timing_class="same_day", direction="long_only", entry_timing_policy="reaction_close", max_holding_days=10, engine_risk_budget_pct=1.0, reaction_day_return_min=0.04, reaction_day_return_max=0.20, close_location_min=0.70, volume_ratio_min=1.5, score_threshold_override=0.60, shadow_only=True, ), StrategyEngineConfig( engine_id="delayed_primary_long_drift", event_types=["earnings_release"], timing_class="any", direction="long_only", entry_timing_policy="next_open", max_holding_days=10, engine_risk_budget_pct=1.0, delayed_entry_lookback_days=3, delayed_entry_source_engine_ids=["reaction_close_long_core"], delayed_entry_min_drift_pct=0.05, delayed_entry_close_location_min=0.50, score_threshold_override=0.0, per_trade_risk_pct_override=0.01, synthetic_only=True, ), ], ) config = BacktestConfig( strategy_name="return_max_long_v1", dataset_snapshot_id="test_snapshot", universe=UniverseConfig(min_price=15.0, min_avg_dollar_volume=100_000.0), signal=SignalConfig(score_threshold=0.60, max_candidates_per_day=6, scoring_model="patient_drift"), risk=RiskConfig( per_trade_risk_pct=0.01, per_trade_risk_pct_a_tier=0.01, max_daily_new_risk_pct=0.05, max_positions=6, max_positions_per_sector=3, ), execution=ExecutionConfig( entry_fill_model="next_open", exit_fill_model="daily_bar_approximation", slippage_bps_base=0.0, commission_per_share=0.0, same_bar_priority="stop_first_conservative", target_model="fixed_r", target_1_r=99.0, target_1_fraction=0.0, trailing_model=None, trailing_warmup_days=10, max_holding_days=10, ), reporting=ReportingConfig( write_trade_blotter=True, write_equity_curve=True, write_metrics_summary=True, generate_plots=False, ), event_type_profiles={"earnings_release": EventTypeProfile(enabled=True, max_holding_days_override=10)}, strategy_engines=manifest.strategy_engines, strategy_engine_selection_mode="interleave", ) runner = BacktestRunner(manifest=manifest, config=config, store=store, initial_equity=100_000.0) result = runner.run(output_root=tmp_path) run_dir = tmp_path / result.run_id trade_blotter = pq.read_table(run_dir / "artifacts" / "trade_blotter.parquet").to_pylist() engine_ids = [row["engine_id"] for row in trade_blotter] assert "reaction_close_long_core" not in engine_ids assert "delayed_primary_long_drift" in engine_ids def test_delayed_entry_preserves_parse_confidence_override(self, tmp_path): from apps.backtester.run import BacktestRunner import pyarrow.parquet as pq from libs.backtest.domain import ( BacktestConfig, EventTypeProfile, ExperimentManifest, ExecutionConfig, ReportingConfig, RiskConfig, SignalConfig, StrategyEngineConfig, UniverseConfig, ) from libs.backtest.snapshot_store import SnapshotStore dates = [dt.date(2026, 1, d) for d in [5, 6, 7, 8, 9]] store = SnapshotStore( candidates_by_exec_date={ dates[0]: [ { "event_id": "EVT::PARSE::001", "symbol": "AAPL", "execution_date": dates[0], "entry_date": dates[0].isoformat(), "event_date": dates[0].isoformat(), "event_close": 100.0, "entry_price": 100.0, "score": 0.82, "sector": "Technology", "event_type": "earnings_release", "event_timestamp": "2026-01-05T21:00:00+00:00", "filing_time_bucket": "post_market", "reaction_date": dates[0].isoformat(), "reaction_day_return": 0.06, "close_location": 0.70, "volume_ratio_20d": 2.0, "gap_size": 0.01, "parse_confidence_overall": 0.30, "avg_dollar_volume": 9_000_000.0, "atr_14": 3.0, } ], dates[1]: [], dates[2]: [], dates[3]: [], dates[4]: [], }, bars_by_symbol_date={ "AAPL": { dates[0]: {"date": dates[0], "open": 98.0, "high": 101.0, "low": 97.0, "close": 100.0, "volume": 1_000_000}, dates[1]: {"date": dates[1], "open": 101.0, "high": 102.0, "low": 100.0, "close": 101.5, "volume": 900_000}, dates[2]: {"date": dates[2], "open": 101.5, "high": 104.0, "low": 101.0, "close": 103.2, "volume": 850_000}, dates[3]: {"date": dates[3], "open": 103.0, "high": 106.0, "low": 102.0, "close": 105.0, "volume": 800_000}, dates[4]: {"date": dates[4], "open": 105.0, "high": 107.0, "low": 104.0, "close": 106.0, "volume": 780_000}, } }, ) manifest = ExperimentManifest( experiment_name="return_max_long_v1_shadow_delayed_parse", dataset_snapshot_id="test_snapshot", base_config="configs/backtest/return_max_long_v1.json", overrides={}, strategy_engines=[ StrategyEngineConfig( engine_id="reaction_close_long_core", event_types=["earnings_release"], timing_class="same_day", direction="long_only", entry_timing_policy="reaction_close", max_holding_days=10, engine_risk_budget_pct=1.0, reaction_day_return_min=0.04, reaction_day_return_max=0.20, close_location_min=0.60, volume_ratio_min=1.5, score_threshold_override=0.60, shadow_only=True, ), StrategyEngineConfig( engine_id="delayed_primary_long_parse", event_types=["earnings_release"], timing_class="any", direction="long_only", entry_timing_policy="next_open", max_holding_days=10, engine_risk_budget_pct=1.0, delayed_entry_lookback_days=2, delayed_entry_source_engine_ids=["reaction_close_long_core"], delayed_entry_min_drift_pct=0.02, delayed_entry_close_location_min=0.60, score_threshold_override=0.0, per_trade_risk_pct_override=0.01, veto_parse_confidence_min_override=0.20, synthetic_only=True, ), ], ) config = BacktestConfig( strategy_name="return_max_long_v1", dataset_snapshot_id="test_snapshot", universe=UniverseConfig(min_price=15.0, min_avg_dollar_volume=100_000.0), signal=SignalConfig(score_threshold=0.60, max_candidates_per_day=6, scoring_model="patient_drift"), risk=RiskConfig( per_trade_risk_pct=0.01, per_trade_risk_pct_a_tier=0.01, max_daily_new_risk_pct=0.05, max_positions=6, max_positions_per_sector=3, veto_parse_confidence_min=0.40, ), execution=ExecutionConfig( entry_fill_model="next_open", exit_fill_model="daily_bar_approximation", slippage_bps_base=0.0, commission_per_share=0.0, same_bar_priority="stop_first_conservative", target_model="fixed_r", target_1_r=99.0, target_1_fraction=0.0, trailing_model=None, trailing_warmup_days=10, max_holding_days=10, ), reporting=ReportingConfig( write_trade_blotter=True, write_equity_curve=True, write_metrics_summary=True, generate_plots=False, ), event_type_profiles={"earnings_release": EventTypeProfile(enabled=True, max_holding_days_override=10)}, strategy_engines=manifest.strategy_engines, strategy_engine_selection_mode="interleave", ) runner = BacktestRunner(manifest=manifest, config=config, store=store, initial_equity=100_000.0) result = runner.run(output_root=tmp_path) run_dir = tmp_path / result.run_id trade_blotter = pq.read_table(run_dir / "artifacts" / "trade_blotter.parquet").to_pylist() assert len(trade_blotter) == 1 assert trade_blotter[0]["engine_id"] == "delayed_primary_long_parse" def test_delayed_entry_preserves_no_progress_override(self, tmp_path): from apps.backtester.run import BacktestRunner import pyarrow.parquet as pq from libs.backtest.domain import ( BacktestConfig, EventTypeProfile, ExperimentManifest, ExecutionConfig, ReportingConfig, RiskConfig, SignalConfig, StrategyEngineConfig, UniverseConfig, ) from libs.backtest.snapshot_store import SnapshotStore dates = [dt.date(2026, 2, d) for d in [2, 3, 4, 5, 6, 9, 10]] store = SnapshotStore( candidates_by_exec_date={ dates[0]: [ { "event_id": "EVT::NP::001", "symbol": "AAPL", "execution_date": dates[0], "entry_date": dates[0].isoformat(), "event_date": dates[0].isoformat(), "event_close": 100.0, "entry_price": 100.0, "score": 0.82, "sector": "Technology", "event_type": "earnings_release", "event_timestamp": "2026-02-02T21:00:00+00:00", "filing_time_bucket": "post_market", "reaction_date": dates[0].isoformat(), "reaction_day_return": 0.06, "close_location": 0.70, "volume_ratio_20d": 2.0, "gap_size": 0.01, "parse_confidence_overall": 0.80, "avg_dollar_volume": 9_000_000.0, "atr_14": 3.0, } ], dates[1]: [], dates[2]: [], dates[3]: [], dates[4]: [], dates[5]: [], dates[6]: [], }, bars_by_symbol_date={ "AAPL": { dates[0]: {"date": dates[0], "open": 98.0, "high": 101.0, "low": 97.0, "close": 100.0, "volume": 1_000_000}, dates[1]: {"date": dates[1], "open": 100.5, "high": 101.0, "low": 99.8, "close": 100.8, "volume": 900_000}, dates[2]: {"date": dates[2], "open": 100.7, "high": 101.2, "low": 100.1, "close": 100.9, "volume": 850_000}, dates[3]: {"date": dates[3], "open": 101.0, "high": 101.8, "low": 100.7, "close": 101.3, "volume": 820_000}, dates[4]: {"date": dates[4], "open": 101.2, "high": 102.0, "low": 100.9, "close": 101.6, "volume": 810_000}, dates[5]: {"date": dates[5], "open": 101.7, "high": 102.4, "low": 101.3, "close": 102.0, "volume": 800_000}, dates[6]: {"date": dates[6], "open": 102.0, "high": 102.5, "low": 101.6, "close": 102.2, "volume": 790_000}, } }, ) manifest = ExperimentManifest( experiment_name="return_max_long_v1_shadow_delayed_np", dataset_snapshot_id="test_snapshot", base_config="configs/backtest/return_max_long_v1.json", overrides={}, strategy_engines=[ StrategyEngineConfig( engine_id="reaction_close_long_core", event_types=["earnings_release"], timing_class="same_day", direction="long_only", entry_timing_policy="reaction_close", max_holding_days=10, engine_risk_budget_pct=1.0, reaction_day_return_min=0.04, reaction_day_return_max=0.20, close_location_min=0.60, volume_ratio_min=1.5, score_threshold_override=0.60, shadow_only=True, ), StrategyEngineConfig( engine_id="delayed_primary_long_np", event_types=["earnings_release"], timing_class="any", direction="long_only", entry_timing_policy="next_open", max_holding_days=10, engine_risk_budget_pct=1.0, delayed_entry_lookback_days=2, delayed_entry_source_engine_ids=["reaction_close_long_core"], delayed_entry_min_drift_pct=0.005, delayed_entry_close_location_min=0.55, score_threshold_override=0.0, per_trade_risk_pct_override=0.01, early_failure_no_progress_days_override=5, early_failure_no_progress_r_override=0.1, early_failure_no_progress_fraction_override=1.0, synthetic_only=True, ), ], ) config = BacktestConfig( strategy_name="return_max_long_v1", dataset_snapshot_id="test_snapshot", universe=UniverseConfig(min_price=15.0, min_avg_dollar_volume=100_000.0), signal=SignalConfig(score_threshold=0.60, max_candidates_per_day=6, scoring_model="patient_drift"), risk=RiskConfig( per_trade_risk_pct=0.01, per_trade_risk_pct_a_tier=0.01, max_daily_new_risk_pct=0.05, max_positions=6, max_positions_per_sector=3, ), execution=ExecutionConfig( entry_fill_model="next_open", exit_fill_model="daily_bar_approximation", slippage_bps_base=0.0, commission_per_share=0.0, same_bar_priority="stop_first_conservative", target_model="fixed_r", target_1_r=99.0, target_1_fraction=0.0, trailing_model=None, trailing_warmup_days=10, max_holding_days=10, early_failure_no_progress_days=2, early_failure_no_progress_r=0.1, early_failure_no_progress_fraction=1.0, ), reporting=ReportingConfig( write_trade_blotter=True, write_equity_curve=True, write_metrics_summary=True, generate_plots=False, ), event_type_profiles={"earnings_release": EventTypeProfile(enabled=True, max_holding_days_override=10)}, strategy_engines=manifest.strategy_engines, strategy_engine_selection_mode="interleave", ) runner = BacktestRunner(manifest=manifest, config=config, store=store, initial_equity=100_000.0) result = runner.run(output_root=tmp_path) run_dir = tmp_path / result.run_id trade_blotter = pq.read_table(run_dir / "artifacts" / "trade_blotter.parquet").to_pylist() assert len(trade_blotter) == 1 assert trade_blotter[0]["engine_id"] == "delayed_primary_long_np" assert trade_blotter[0]["holding_days"] > 2 def test_selected_candidates_are_annotated_with_daily_breadth_features(self, tmp_path): from apps.backtester.run import BacktestRunner from libs.backtest.domain import ( BacktestConfig, Candidate, ExecutionConfig, ExperimentManifest, ReportingConfig, RiskConfig, SignalConfig, StrategyEngineConfig, UniverseConfig, ) store = _build_multi_engine_store() manifest = ExperimentManifest( experiment_name="breadth_annotation_smoke", dataset_snapshot_id="test_snapshot", base_config="configs/backtest/defaults.json", overrides={}, strategy_engines=[ StrategyEngineConfig( engine_id="same_day_short", event_types=["earnings_release"], timing_class="same_day", direction="short_only", entry_timing_policy="next_open", reaction_day_return_max=-0.05, score_threshold_override=0.50, ), StrategyEngineConfig( engine_id="after_close_long", event_types=["earnings_release"], timing_class="after_close", direction="long_only", entry_timing_policy="next_open", reaction_day_return_min=0.05, score_threshold_override=0.50, ), ], ) config = BacktestConfig( strategy_name="breadth_annotation_smoke", dataset_snapshot_id="test_snapshot", universe=UniverseConfig(min_price=5.0, min_avg_dollar_volume=100_000.0), signal=SignalConfig(score_threshold=0.50, max_candidates_per_day=5), risk=RiskConfig( per_trade_risk_pct=0.01, max_daily_new_risk_pct=0.05, max_positions=10, max_positions_per_sector=5, ), execution=ExecutionConfig( entry_fill_model="next_open", exit_fill_model="daily_bar_approximation", slippage_bps_base=0.0, commission_per_share=0.0, same_bar_priority="stop_first_conservative", max_holding_days=5, ), reporting=ReportingConfig( write_trade_blotter=True, write_equity_curve=True, write_metrics_summary=True, generate_plots=False, ), strategy_engines=manifest.strategy_engines, ) runner = BacktestRunner(manifest=manifest, config=config, store=store, initial_equity=100_000.0) selected = runner._select_candidates_for_date(dt.date(2026, 1, 7)) assert len(selected) == 2 for candidate in selected: assert candidate.features["daily_candidate_count_selected"] == 2 assert candidate.features["daily_unique_sector_count_selected"] == 2 assert candidate.features["daily_sector_candidate_count_selected"] == 1 assert candidate.features["daily_engine_candidate_count_selected"] == 1 def test_macro_bullish_engine_schedules_synthetic_qqq_candidate(self, tmp_path): from apps.backtester.run import BacktestRunner from libs.backtest.domain import ( BacktestConfig, Candidate, ExecutionConfig, ExperimentManifest, ReportingConfig, RiskConfig, SignalConfig, StrategyEngineConfig, UniverseConfig, ) from libs.backtest.snapshot_store import SnapshotStore signal_date = dt.date(2026, 1, 6) next_date = dt.date(2026, 1, 7) store = SnapshotStore( candidates_by_exec_date={next_date: []}, bars_by_symbol_date={ "QQQ": { dt.date(2026, 1, 5): {"date": dt.date(2026, 1, 5), "open": 100.0, "high": 101.0, "low": 99.0, "close": 100.0, "volume": 100}, signal_date: {"date": signal_date, "open": 102.0, "high": 107.0, "low": 101.5, "close": 106.0, "volume": 400}, next_date: {"date": next_date, "open": 106.5, "high": 108.0, "low": 105.0, "close": 107.5, "volume": 350}, } }, macro_by_date={signal_date: {"VIXCLS": 22.0}}, ) manifest = ExperimentManifest( experiment_name="macro_bullish_qqq_smoke", dataset_snapshot_id="test_snapshot", base_config="configs/backtest/defaults.json", overrides={}, strategy_engines=[ StrategyEngineConfig( engine_id="macro_bullish_qqq", synthetic_only=True, entry_timing_policy="next_open", max_holding_days=8, engine_risk_budget_pct=0.25, per_trade_risk_pct_override=0.01, stop_atr_multiplier_override=2.0, trailing_warmup_days_override=4, macro_long_symbol="QQQ", macro_long_reaction_day_return_min=0.015, macro_long_volume_ratio_min=2.0, macro_long_gap_size_min=0.01, macro_long_close_location_min=0.75, macro_long_min_daily_candidate_count=2, macro_long_min_unique_sector_count=2, macro_vix_max=30.0, ) ], ) config = BacktestConfig( strategy_name="macro_bullish_qqq_smoke", dataset_snapshot_id="test_snapshot", universe=UniverseConfig(min_price=5.0, min_avg_dollar_volume=100_000.0), signal=SignalConfig(score_threshold=0.50, max_candidates_per_day=5), risk=RiskConfig( per_trade_risk_pct=0.01, max_daily_new_risk_pct=0.05, max_positions=10, max_positions_per_sector=5, ), execution=ExecutionConfig( entry_fill_model="next_open", exit_fill_model="daily_bar_approximation", slippage_bps_base=0.0, commission_per_share=0.0, same_bar_priority="stop_first_conservative", max_holding_days=10, ), reporting=ReportingConfig( write_trade_blotter=True, write_equity_curve=True, write_metrics_summary=True, generate_plots=False, ), strategy_engines=manifest.strategy_engines, ) runner = BacktestRunner(manifest=manifest, config=config, store=store, initial_equity=100_000.0) runner._simulation_dates = [signal_date, next_date] runner._next_trading_day = {signal_date: next_date} runner._recent_scored_candidates[signal_date] = [ Candidate( event_id="BREADTH::1", symbol="AAPL", score=0.8, sector="Technology", event_type="earnings_release", event_timestamp=dt.datetime(2026, 1, 6, 21, 0, tzinfo=_UTC), filing_time_bucket="post_market", reaction_date=signal_date, execution_date=next_date, entry_price_est=100.0, avg_dollar_volume=1_000_000.0, atr_14=2.0, score_bucket="high", engine_id="core", ), Candidate( event_id="BREADTH::2", symbol="LLY", score=0.78, sector="Health Care", event_type="earnings_release", event_timestamp=dt.datetime(2026, 1, 6, 21, 0, tzinfo=_UTC), filing_time_bucket="post_market", reaction_date=signal_date, execution_date=next_date, entry_price_est=100.0, avg_dollar_volume=1_000_000.0, atr_14=2.0, score_bucket="high", engine_id="core", ), ] runner._schedule_macro_long_candidates(signal_date) scheduled = runner._scheduled_delayed_entries[next_date] assert len(scheduled) == 1 candidate = scheduled[0] assert candidate.symbol == "QQQ" assert candidate.event_type == "macro_bullish_event" assert candidate.features["macro_long_daily_candidate_count"] == 2 assert candidate.features["macro_long_daily_unique_sector_count"] == 2 assert candidate.features["macro_long_reaction_day_return"] == pytest.approx(0.06) def test_leader_follower_engine_schedules_preentry_peer_candidate(self, tmp_path): from apps.backtester.run import BacktestRunner from libs.backtest.domain import ( BacktestConfig, ExecutionConfig, ExperimentManifest, ReportingConfig, RiskConfig, SignalConfig, StrategyEngineConfig, UniverseConfig, ) from libs.backtest.snapshot_store import SnapshotStore signal_date = dt.date(2026, 1, 6) entry_date = dt.date(2026, 1, 7) pre_event_date = dt.date(2026, 1, 8) follower_event_date = dt.date(2026, 1, 9) follower_exec_date = dt.date(2026, 1, 12) store = SnapshotStore( candidates_by_exec_date={ entry_date: [ { "event_id": "LEADER::NVDA", "symbol": "NVDA", "execution_date": entry_date, "entry_date": str(entry_date), "event_date": str(signal_date), "event_close": 120.0, "entry_price": 121.0, "score": 0.86, "sector": "Technology", "event_type": "earnings_release", "event_direction": "bullish", "guidance_status": "raised", "event_timestamp": "2026-01-06T21:00:00+00:00", "filing_time_bucket": "post_market", "reaction_date": str(signal_date), "reaction_day_return": 0.14, "close_location": 0.88, "volume_ratio": 3.4, "gap_size": 0.07, "avg_dollar_volume": 50_000_000.0, "market_cap_proxy": 100_000_000_000.0, "document_quality_score": 0.80, "parse_confidence_overall": 0.82, "atr_14": 4.0, } ], follower_exec_date: [ { "event_id": "FOLLOWER::AMD", "symbol": "AMD", "execution_date": follower_exec_date, "entry_date": str(follower_exec_date), "event_date": str(follower_event_date), "event_close": 51.5, "entry_price": 52.0, "score": 0.40, "sector": "Technology", "event_type": "earnings_release", "event_direction": "unknown", "guidance_status": "not_provided", "event_timestamp": "2026-01-09T21:00:00+00:00", "filing_time_bucket": "post_market", "reaction_date": str(follower_event_date), "reaction_day_return": 0.03, "close_location": 0.55, "volume_ratio": 1.4, "gap_size": 0.01, "avg_dollar_volume": 30_000_000.0, "market_cap_proxy": 60_000_000_000.0, "document_quality_score": 0.60, "parse_confidence_overall": 0.60, "atr_14": 2.5, } ], }, bars_by_symbol_date={ "NVDA": { signal_date: {"date": signal_date, "open": 108.0, "high": 121.0, "low": 107.0, "close": 120.0, "volume": 4_000_000}, entry_date: {"date": entry_date, "open": 121.0, "high": 123.0, "low": 118.0, "close": 122.0, "volume": 3_000_000}, }, "AMD": { dt.date(2026, 1, 5): {"date": dt.date(2026, 1, 5), "open": 49.5, "high": 50.5, "low": 49.0, "close": 50.0, "volume": 1_000_000}, signal_date: {"date": signal_date, "open": 50.2, "high": 51.6, "low": 49.8, "close": 51.0, "volume": 1_100_000}, entry_date: {"date": entry_date, "open": 51.1, "high": 52.0, "low": 50.8, "close": 51.7, "volume": 1_050_000}, pre_event_date: {"date": pre_event_date, "open": 51.9, "high": 52.6, "low": 51.4, "close": 52.3, "volume": 1_030_000}, follower_event_date: {"date": follower_event_date, "open": 52.4, "high": 53.4, "low": 51.8, "close": 53.0, "volume": 1_200_000}, follower_exec_date: {"date": follower_exec_date, "open": 54.5, "high": 55.0, "low": 53.5, "close": 54.0, "volume": 1_250_000}, }, }, ) manifest = ExperimentManifest( experiment_name="leader_follower_preentry_smoke", dataset_snapshot_id="test_snapshot", base_config="configs/backtest/defaults.json", overrides={}, strategy_engines=[ StrategyEngineConfig( engine_id="leader_follower_preentry", synthetic_only=True, entry_timing_policy="next_open", event_types=["earnings_release"], event_directions=["bullish"], guidance_statuses=["raised"], filing_time_buckets=["post_market"], allowed_sectors=["Technology"], direction="long_only", max_holding_days=5, engine_risk_budget_pct=0.05, per_trade_risk_pct_override=0.01, reaction_day_return_min=0.10, close_location_min=0.75, volume_ratio_min=2.0, gap_size_min=0.04, min_market_cap_proxy=40_000_000_000.0, document_quality_score_min=0.5, parse_confidence_overall_min=0.5, leader_follower_lookahead_days=3, leader_follower_min_days_to_event=2, leader_follower_hold_buffer_days=1, proxy_reaction_day_return_max=0.04, proxy_gap_size_max=0.03, proxy_close_location_max=0.80, proxy_avg_dollar_volume_min=10_000_000.0, next_open_gap_cap_pct=0.08, stop_atr_multiplier_override=2.5, trailing_warmup_days_override=3, early_failure_no_progress_days_override=1, early_failure_no_progress_r_override=0.0, early_failure_no_progress_fraction_override=1.0, ) ], ) config = BacktestConfig( strategy_name="leader_follower_preentry_smoke", dataset_snapshot_id="test_snapshot", universe=UniverseConfig(min_price=5.0, min_avg_dollar_volume=100_000.0), signal=SignalConfig(score_threshold=0.50, max_candidates_per_day=5), risk=RiskConfig( per_trade_risk_pct=0.01, max_daily_new_risk_pct=0.05, max_positions=10, max_positions_per_sector=5, ), execution=ExecutionConfig( entry_fill_model="next_open", exit_fill_model="daily_bar_approximation", slippage_bps_base=0.0, commission_per_share=0.0, same_bar_priority="stop_first_conservative", max_holding_days=10, ), reporting=ReportingConfig( write_trade_blotter=True, write_equity_curve=True, write_metrics_summary=True, generate_plots=False, ), strategy_engines=manifest.strategy_engines, ) runner = BacktestRunner(manifest=manifest, config=config, store=store, initial_equity=100_000.0) runner._simulation_dates = [signal_date, entry_date, pre_event_date, follower_event_date, follower_exec_date] runner._next_trading_day = { signal_date: entry_date, entry_date: pre_event_date, pre_event_date: follower_event_date, follower_event_date: follower_exec_date, } runner._schedule_leader_follower_candidates(signal_date) scheduled = runner._scheduled_delayed_entries[entry_date] assert len(scheduled) == 1 candidate = scheduled[0] assert candidate.symbol == "AMD" assert candidate.source_symbol == "NVDA" assert candidate.event_type == "leader_follower_preearnings" assert candidate.engine_max_holding_days == 1 assert candidate.features["leader_follower_days_to_event"] == 2 assert candidate.features["leader_symbol"] == "NVDA" assert candidate.features["follower_symbol"] == "AMD" def test_leader_follower_prefers_calm_orderly_follower(self, tmp_path): from apps.backtester.run import BacktestRunner from libs.backtest.domain import ( BacktestConfig, ExecutionConfig, ExperimentManifest, ReportingConfig, RiskConfig, SignalConfig, StrategyEngineConfig, UniverseConfig, ) from libs.backtest.snapshot_store import SnapshotStore signal_date = dt.date(2026, 1, 6) entry_date = dt.date(2026, 1, 7) mid_date = dt.date(2026, 1, 8) crm_reaction_date = dt.date(2026, 1, 9) nvda_reaction_date = crm_reaction_date crm_exec_date = dt.date(2026, 1, 12) nvda_exec_date = dt.date(2026, 1, 13) store = SnapshotStore( candidates_by_exec_date={ entry_date: [ { "event_id": "LEADER::SNOW", "symbol": "SNOW", "execution_date": entry_date, "entry_date": str(entry_date), "event_date": str(signal_date), "event_close": 116.0, "entry_price": 116.5, "score": 0.86, "sector": "Technology", "event_type": "earnings_release", "event_direction": "bullish", "guidance_status": "raised", "event_timestamp": "2026-01-06T21:00:00+00:00", "filing_time_bucket": "post_market", "reaction_date": str(signal_date), "reaction_day_return": 0.16, "close_location": 0.87, "volume_ratio": 3.2, "gap_size": 0.06, "avg_dollar_volume": 90_000_000.0, "market_cap_proxy": 80_000_000_000.0, "document_quality_score": 0.8, "parse_confidence_overall": 0.8, "atr_14": 4.0, } ], crm_exec_date: [ { "event_id": "FOLLOWER::CRM", "symbol": "CRM", "execution_date": crm_exec_date, "entry_date": str(crm_exec_date), "event_date": str(crm_reaction_date), "event_close": 96.4, "entry_price": 96.6, "score": 0.4, "sector": "Technology", "event_type": "earnings_release", "event_direction": "unknown", "guidance_status": "not_provided", "event_timestamp": "2026-01-09T21:00:00+00:00", "filing_time_bucket": "post_market", "reaction_date": str(crm_reaction_date), "reaction_day_return": 0.03, "close_location": 0.55, "volume_ratio": 1.3, "gap_size": 0.01, "avg_dollar_volume": 50_000_000.0, "market_cap_proxy": 150_000_000_000.0, "document_quality_score": 0.7, "parse_confidence_overall": 0.7, "atr_14": 3.0, } ], nvda_exec_date: [ { "event_id": "FOLLOWER::NVDA", "symbol": "NVDA", "execution_date": nvda_exec_date, "entry_date": str(nvda_exec_date), "event_date": str(nvda_reaction_date), "event_close": 98.8, "entry_price": 99.0, "score": 0.4, "sector": "Technology", "event_type": "earnings_release", "event_direction": "unknown", "guidance_status": "not_provided", "event_timestamp": "2026-01-09T21:00:00+00:00", "filing_time_bucket": "post_market", "reaction_date": str(nvda_reaction_date), "reaction_day_return": 0.03, "close_location": 0.55, "volume_ratio": 1.3, "gap_size": 0.01, "avg_dollar_volume": 50_000_000.0, "market_cap_proxy": 300_000_000_000.0, "document_quality_score": 0.7, "parse_confidence_overall": 0.7, "atr_14": 3.0, } ], }, bars_by_symbol_date={ "SNOW": { dt.date(2026, 1, 5): {"date": dt.date(2026, 1, 5), "open": 100.0, "high": 101.0, "low": 99.0, "close": 100.0, "volume": 1_000_000}, signal_date: {"date": signal_date, "open": 106.0, "high": 117.0, "low": 105.5, "close": 116.0, "volume": 3_200_000}, entry_date: {"date": entry_date, "open": 116.5, "high": 118.0, "low": 114.0, "close": 117.0, "volume": 2_500_000}, }, "CRM": { dt.date(2026, 1, 5): {"date": dt.date(2026, 1, 5), "open": 100.0, "high": 101.0, "low": 99.0, "close": 100.0, "volume": 1_000_000}, signal_date: {"date": signal_date, "open": 98.4, "high": 101.0, "low": 95.5, "close": 96.4, "volume": 1_800_000}, entry_date: {"date": entry_date, "open": 96.0, "high": 97.0, "low": 95.0, "close": 96.5, "volume": 1_200_000}, }, "NVDA": { dt.date(2026, 1, 5): {"date": dt.date(2026, 1, 5), "open": 100.0, "high": 101.0, "low": 99.0, "close": 100.0, "volume": 1_000_000}, signal_date: {"date": signal_date, "open": 97.9, "high": 99.65, "low": 97.5, "close": 98.8, "volume": 950_000}, entry_date: {"date": entry_date, "open": 98.9, "high": 100.2, "low": 98.0, "close": 99.8, "volume": 1_100_000}, }, }, ) manifest = ExperimentManifest( experiment_name="leader_follower_prefers_orderly", dataset_snapshot_id="test_snapshot", base_config="configs/backtest/defaults.json", overrides={}, strategy_engines=[ StrategyEngineConfig( engine_id="leader_follower_preentry", synthetic_only=True, entry_timing_policy="next_open", event_types=["earnings_release"], event_directions=["bullish"], guidance_statuses=["raised"], filing_time_buckets=["post_market"], allowed_sectors=["Technology"], direction="long_only", max_holding_days=5, engine_risk_budget_pct=0.05, per_trade_risk_pct_override=0.01, reaction_day_return_min=0.10, close_location_min=0.75, volume_ratio_min=2.0, gap_size_min=0.04, min_market_cap_proxy=40_000_000_000.0, document_quality_score_min=0.5, parse_confidence_overall_min=0.5, leader_follower_lookahead_days=3, leader_follower_min_days_to_event=2, leader_follower_hold_buffer_days=1, proxy_reaction_day_return_max=0.05, proxy_gap_size_max=0.03, proxy_close_location_max=0.85, proxy_avg_dollar_volume_min=10_000_000.0, ) ], ) config = BacktestConfig( strategy_name="leader_follower_prefers_orderly", dataset_snapshot_id="test_snapshot", universe=UniverseConfig(min_price=5.0, min_avg_dollar_volume=100_000.0), signal=SignalConfig(score_threshold=0.50, max_candidates_per_day=5), risk=RiskConfig( per_trade_risk_pct=0.01, max_daily_new_risk_pct=0.05, max_positions=10, max_positions_per_sector=5, ), execution=ExecutionConfig( entry_fill_model="next_open", exit_fill_model="daily_bar_approximation", slippage_bps_base=0.0, commission_per_share=0.0, same_bar_priority="stop_first_conservative", max_holding_days=10, ), reporting=ReportingConfig( write_trade_blotter=True, write_equity_curve=True, write_metrics_summary=True, generate_plots=False, ), strategy_engines=manifest.strategy_engines, ) runner = BacktestRunner(manifest=manifest, config=config, store=store, initial_equity=100_000.0) runner._simulation_dates = [signal_date, entry_date, mid_date, crm_reaction_date, nvda_exec_date] runner._next_trading_day = { signal_date: entry_date, entry_date: mid_date, mid_date: crm_reaction_date, crm_reaction_date: nvda_exec_date, } runner._schedule_leader_follower_candidates(signal_date) scheduled = runner._scheduled_delayed_entries[entry_date] assert len(scheduled) == 2 assert scheduled[0].symbol == "NVDA" assert scheduled[1].symbol == "CRM" assert scheduled[0].score > scheduled[1].score def test_leader_follower_engine_uses_pit_calendar_when_available(self, tmp_path): from apps.backtester.run import BacktestRunner from libs.backtest.domain import ( BacktestConfig, ExecutionConfig, ExperimentManifest, ReportingConfig, RiskConfig, SignalConfig, StrategyEngineConfig, UniverseConfig, ) from libs.backtest.earnings_calendar import load_pit_earnings_calendar from libs.backtest.snapshot_store import SnapshotStore signal_date = dt.date(2026, 1, 6) entry_date = dt.date(2026, 1, 7) pre_event_date = dt.date(2026, 1, 8) follower_reaction_date = dt.date(2026, 1, 9) follower_exec_date = dt.date(2026, 1, 12) calendar_path = tmp_path / "earnings_calendar_pit.parquet" pq.write_table( pa.Table.from_pylist( [ { "symbol": "AMD", "as_of_date": signal_date.isoformat(), "expected_reaction_date": follower_reaction_date.isoformat(), "expected_event_date": pre_event_date.isoformat(), "filing_time_bucket": "post_market", "source": "unit_test", } ] ), calendar_path, ) load_pit_earnings_calendar.cache_clear() store = SnapshotStore( candidates_by_exec_date={ entry_date: [ { "event_id": "LEADER::NVDA", "symbol": "NVDA", "execution_date": entry_date, "entry_date": str(entry_date), "event_date": str(signal_date), "event_close": 120.0, "entry_price": 121.0, "score": 0.86, "sector": "Technology", "event_type": "earnings_release", "event_direction": "bullish", "guidance_status": "raised", "event_timestamp": "2026-01-06T21:00:00+00:00", "filing_time_bucket": "post_market", "reaction_date": str(signal_date), "reaction_day_return": 0.14, "close_location": 0.88, "volume_ratio": 3.4, "gap_size": 0.07, "avg_dollar_volume": 50_000_000.0, "market_cap_proxy": 100_000_000_000.0, "document_quality_score": 0.80, "parse_confidence_overall": 0.82, "atr_14": 4.0, } ], }, bars_by_symbol_date={ "NVDA": { signal_date: {"date": signal_date, "open": 108.0, "high": 121.0, "low": 107.0, "close": 120.0, "volume": 4_000_000}, entry_date: {"date": entry_date, "open": 121.0, "high": 123.0, "low": 118.0, "close": 122.0, "volume": 3_000_000}, }, "AMD": { dt.date(2026, 1, 5): {"date": dt.date(2026, 1, 5), "open": 49.5, "high": 50.5, "low": 49.0, "close": 50.0, "volume": 1_000_000}, signal_date: {"date": signal_date, "open": 50.2, "high": 51.6, "low": 49.8, "close": 51.0, "volume": 1_100_000}, entry_date: {"date": entry_date, "open": 51.1, "high": 52.0, "low": 50.8, "close": 51.7, "volume": 1_050_000}, pre_event_date: {"date": pre_event_date, "open": 51.9, "high": 52.6, "low": 51.4, "close": 52.3, "volume": 1_030_000}, follower_reaction_date: {"date": follower_reaction_date, "open": 52.4, "high": 53.4, "low": 51.8, "close": 53.0, "volume": 1_200_000}, follower_exec_date: {"date": follower_exec_date, "open": 54.5, "high": 55.0, "low": 53.5, "close": 54.0, "volume": 1_250_000}, }, }, ) manifest = ExperimentManifest( experiment_name="leader_follower_preentry_pit_smoke", dataset_snapshot_id="test_snapshot", base_config="configs/backtest/defaults.json", overrides={}, strategy_engines=[ StrategyEngineConfig( engine_id="leader_follower_preentry", synthetic_only=True, entry_timing_policy="next_open", event_types=["earnings_release"], event_directions=["bullish"], guidance_statuses=["raised"], filing_time_buckets=["post_market"], allowed_sectors=["Technology"], direction="long_only", max_holding_days=5, engine_risk_budget_pct=0.05, per_trade_risk_pct_override=0.01, reaction_day_return_min=0.10, close_location_min=0.75, volume_ratio_min=2.0, gap_size_min=0.04, min_market_cap_proxy=40_000_000_000.0, document_quality_score_min=0.5, parse_confidence_overall_min=0.5, leader_follower_lookahead_days=3, leader_follower_min_days_to_event=2, leader_follower_hold_buffer_days=1, leader_follower_calendar_mode="pit_calendar", proxy_reaction_day_return_max=0.04, proxy_gap_size_max=0.03, proxy_close_location_max=0.80, proxy_avg_dollar_volume_min=10_000_000.0, ) ], ) config = BacktestConfig( strategy_name="leader_follower_preentry_pit_smoke", dataset_snapshot_id="test_snapshot", earnings_calendar_pit_path=str(calendar_path), universe=UniverseConfig(min_price=5.0, min_avg_dollar_volume=100_000.0), signal=SignalConfig(score_threshold=0.50, max_candidates_per_day=5), risk=RiskConfig( per_trade_risk_pct=0.01, max_daily_new_risk_pct=0.05, max_positions=10, max_positions_per_sector=5, ), execution=ExecutionConfig( entry_fill_model="next_open", exit_fill_model="daily_bar_approximation", slippage_bps_base=0.0, commission_per_share=0.0, same_bar_priority="stop_first_conservative", max_holding_days=10, ), reporting=ReportingConfig( write_trade_blotter=True, write_equity_curve=True, write_metrics_summary=True, generate_plots=False, ), strategy_engines=manifest.strategy_engines, ) runner = BacktestRunner(manifest=manifest, config=config, store=store, initial_equity=100_000.0) runner._simulation_dates = [signal_date, entry_date, pre_event_date, follower_reaction_date, follower_exec_date] runner._next_trading_day = { signal_date: entry_date, entry_date: pre_event_date, pre_event_date: follower_reaction_date, follower_reaction_date: follower_exec_date, } runner._schedule_leader_follower_candidates(signal_date) scheduled = runner._scheduled_delayed_entries[entry_date] assert len(scheduled) == 1 candidate = scheduled[0] assert candidate.symbol == "AMD" assert candidate.source_symbol == "NVDA" assert candidate.features["leader_follower_upcoming_reaction_date"] == follower_reaction_date.isoformat() def test_leader_follower_engine_uses_oracle_pit_calendar_without_local_file(self): from apps.backtester.run import BacktestRunner from libs.backtest.domain import ( BacktestConfig, ExecutionConfig, ExperimentManifest, ReportingConfig, RiskConfig, SignalConfig, StrategyEngineConfig, UniverseConfig, ) from libs.backtest.snapshot_store import SnapshotStore class _FakeOracleCalendar: def get_known_upcoming_reaction_dates(self, as_of_date, allowed_reaction_dates, symbols=None): assert as_of_date == signal_date assert follower_reaction_date in allowed_reaction_dates assert "AMD" in (symbols or []) return {"AMD": follower_reaction_date} signal_date = dt.date(2026, 1, 6) entry_date = dt.date(2026, 1, 7) pre_event_date = dt.date(2026, 1, 8) follower_reaction_date = dt.date(2026, 1, 9) follower_exec_date = dt.date(2026, 1, 12) store = SnapshotStore( candidates_by_exec_date={ entry_date: [ { "event_id": "LEADER::NVDA", "symbol": "NVDA", "execution_date": entry_date, "entry_date": str(entry_date), "event_date": str(signal_date), "event_close": 120.0, "entry_price": 121.0, "score": 0.86, "sector": "Technology", "event_type": "earnings_release", "event_direction": "bullish", "guidance_status": "raised", "event_timestamp": "2026-01-06T21:00:00+00:00", "filing_time_bucket": "post_market", "reaction_date": str(signal_date), "reaction_day_return": 0.14, "close_location": 0.88, "volume_ratio": 3.4, "gap_size": 0.07, "avg_dollar_volume": 50_000_000.0, "market_cap_proxy": 100_000_000_000.0, "document_quality_score": 0.80, "parse_confidence_overall": 0.82, "atr_14": 4.0, } ], }, bars_by_symbol_date={ "NVDA": { signal_date: {"date": signal_date, "open": 108.0, "high": 121.0, "low": 107.0, "close": 120.0, "volume": 4_000_000}, entry_date: {"date": entry_date, "open": 121.0, "high": 123.0, "low": 118.0, "close": 122.0, "volume": 3_000_000}, }, "AMD": { dt.date(2026, 1, 5): {"date": dt.date(2026, 1, 5), "open": 49.5, "high": 50.5, "low": 49.0, "close": 50.0, "volume": 1_000_000}, signal_date: {"date": signal_date, "open": 50.2, "high": 51.6, "low": 49.8, "close": 51.0, "volume": 1_100_000}, entry_date: {"date": entry_date, "open": 51.1, "high": 52.0, "low": 50.8, "close": 51.7, "volume": 1_050_000}, pre_event_date: {"date": pre_event_date, "open": 51.9, "high": 52.6, "low": 51.4, "close": 52.3, "volume": 1_030_000}, follower_reaction_date: {"date": follower_reaction_date, "open": 52.4, "high": 53.4, "low": 51.8, "close": 53.0, "volume": 1_200_000}, follower_exec_date: {"date": follower_exec_date, "open": 54.5, "high": 55.0, "low": 53.5, "close": 54.0, "volume": 1_250_000}, }, }, ) manifest = ExperimentManifest( experiment_name="leader_follower_preentry_oracle_pit_smoke", dataset_snapshot_id="test_snapshot", base_config="configs/backtest/defaults.json", overrides={}, strategy_engines=[ StrategyEngineConfig( engine_id="leader_follower_preentry", synthetic_only=True, entry_timing_policy="next_open", event_types=["earnings_release"], event_directions=["bullish"], guidance_statuses=["raised"], filing_time_buckets=["post_market"], allowed_sectors=["Technology"], direction="long_only", max_holding_days=5, engine_risk_budget_pct=0.05, per_trade_risk_pct_override=0.01, reaction_day_return_min=0.10, close_location_min=0.75, volume_ratio_min=2.0, gap_size_min=0.04, min_market_cap_proxy=40_000_000_000.0, document_quality_score_min=0.5, parse_confidence_overall_min=0.5, leader_follower_lookahead_days=3, leader_follower_min_days_to_event=2, leader_follower_hold_buffer_days=1, leader_follower_calendar_mode="pit_calendar", proxy_reaction_day_return_max=0.04, proxy_gap_size_max=0.03, proxy_close_location_max=0.80, proxy_avg_dollar_volume_min=10_000_000.0, ) ], ) config = BacktestConfig( strategy_name="leader_follower_preentry_oracle_pit_smoke", dataset_snapshot_id="test_snapshot", universe=UniverseConfig(min_price=5.0, min_avg_dollar_volume=100_000.0), signal=SignalConfig(score_threshold=0.50, max_candidates_per_day=5), risk=RiskConfig( per_trade_risk_pct=0.01, max_daily_new_risk_pct=0.05, max_positions=10, max_positions_per_sector=5, ), execution=ExecutionConfig( entry_fill_model="next_open", exit_fill_model="daily_bar_approximation", slippage_bps_base=0.0, commission_per_share=0.0, same_bar_priority="stop_first_conservative", max_holding_days=10, ), reporting=ReportingConfig( write_trade_blotter=True, write_equity_curve=True, write_metrics_summary=True, generate_plots=False, ), strategy_engines=manifest.strategy_engines, ) runner = BacktestRunner(manifest=manifest, config=config, store=store, initial_equity=100_000.0) runner._pit_earnings_calendar = None runner._oracle_pit_earnings_calendar = _FakeOracleCalendar() runner._simulation_dates = [signal_date, entry_date, pre_event_date, follower_reaction_date, follower_exec_date] runner._next_trading_day = { signal_date: entry_date, entry_date: pre_event_date, pre_event_date: follower_reaction_date, follower_reaction_date: follower_exec_date, } runner._schedule_leader_follower_candidates(signal_date) scheduled = runner._scheduled_delayed_entries[entry_date] assert len(scheduled) == 1 candidate = scheduled[0] assert candidate.symbol == "AMD" assert candidate.source_symbol == "NVDA" assert candidate.features["leader_follower_upcoming_reaction_date"] == follower_reaction_date.isoformat() def test_leader_follower_legacy_mode_ignores_pit_calendar(self, tmp_path): from apps.backtester.run import BacktestRunner from libs.backtest.domain import ( BacktestConfig, ExecutionConfig, ExperimentManifest, ReportingConfig, RiskConfig, SignalConfig, StrategyEngineConfig, UniverseConfig, ) from libs.backtest.earnings_calendar import load_pit_earnings_calendar from libs.backtest.snapshot_store import SnapshotStore signal_date = dt.date(2026, 1, 6) entry_date = dt.date(2026, 1, 7) pre_event_date = dt.date(2026, 1, 8) follower_reaction_date = dt.date(2026, 1, 9) follower_exec_date = dt.date(2026, 1, 12) calendar_path = tmp_path / "earnings_calendar_pit.parquet" pq.write_table( pa.Table.from_pylist( [ { "symbol": "BABA", "as_of_date": signal_date.isoformat(), "expected_reaction_date": follower_reaction_date.isoformat(), "expected_event_date": pre_event_date.isoformat(), "filing_time_bucket": "post_market", "source": "unit_test", } ] ), calendar_path, ) load_pit_earnings_calendar.cache_clear() store = SnapshotStore( candidates_by_exec_date={ entry_date: [ { "event_id": "LEADER::NVDA", "symbol": "NVDA", "execution_date": entry_date, "entry_date": str(entry_date), "event_date": str(signal_date), "event_close": 120.0, "entry_price": 121.0, "score": 0.86, "sector": "Technology", "event_type": "earnings_release", "event_direction": "bullish", "guidance_status": "raised", "event_timestamp": "2026-01-06T21:00:00+00:00", "filing_time_bucket": "post_market", "reaction_date": str(signal_date), "reaction_day_return": 0.14, "close_location": 0.88, "volume_ratio": 3.4, "gap_size": 0.07, "avg_dollar_volume": 50_000_000.0, "market_cap_proxy": 100_000_000_000.0, "document_quality_score": 0.80, "parse_confidence_overall": 0.82, "atr_14": 4.0, } ], follower_exec_date: [ { "event_id": "FOLLOWER::AMD", "symbol": "AMD", "execution_date": follower_exec_date, "entry_date": str(follower_exec_date), "event_date": str(follower_reaction_date), "event_close": 51.5, "entry_price": 52.0, "score": 0.40, "sector": "Technology", "event_type": "earnings_release", "event_direction": "unknown", "guidance_status": "not_provided", "event_timestamp": "2026-01-09T21:00:00+00:00", "filing_time_bucket": "post_market", "reaction_date": str(follower_reaction_date), "reaction_day_return": 0.03, "close_location": 0.55, "volume_ratio": 1.4, "gap_size": 0.01, "avg_dollar_volume": 30_000_000.0, "market_cap_proxy": 60_000_000_000.0, "document_quality_score": 0.60, "parse_confidence_overall": 0.60, "atr_14": 2.5, } ], }, bars_by_symbol_date={ "NVDA": { signal_date: {"date": signal_date, "open": 108.0, "high": 121.0, "low": 107.0, "close": 120.0, "volume": 4_000_000}, entry_date: {"date": entry_date, "open": 121.0, "high": 123.0, "low": 118.0, "close": 122.0, "volume": 3_000_000}, }, "AMD": { dt.date(2026, 1, 5): {"date": dt.date(2026, 1, 5), "open": 49.5, "high": 50.5, "low": 49.0, "close": 50.0, "volume": 1_000_000}, signal_date: {"date": signal_date, "open": 50.2, "high": 51.6, "low": 49.8, "close": 51.0, "volume": 1_100_000}, entry_date: {"date": entry_date, "open": 51.1, "high": 52.0, "low": 50.8, "close": 51.7, "volume": 1_050_000}, pre_event_date: {"date": pre_event_date, "open": 51.9, "high": 52.6, "low": 51.4, "close": 52.3, "volume": 1_030_000}, follower_reaction_date: {"date": follower_reaction_date, "open": 52.4, "high": 53.4, "low": 51.8, "close": 53.0, "volume": 1_200_000}, follower_exec_date: {"date": follower_exec_date, "open": 54.5, "high": 55.0, "low": 53.5, "close": 54.0, "volume": 1_250_000}, }, }, ) manifest = ExperimentManifest( experiment_name="leader_follower_legacy_mode", dataset_snapshot_id="test_snapshot", base_config="configs/backtest/defaults.json", overrides={}, strategy_engines=[ StrategyEngineConfig( engine_id="leader_follower_preentry", synthetic_only=True, entry_timing_policy="next_open", event_types=["earnings_release"], event_directions=["bullish"], guidance_statuses=["raised"], filing_time_buckets=["post_market"], allowed_sectors=["Technology"], direction="long_only", reaction_day_return_min=0.10, close_location_min=0.75, volume_ratio_min=2.0, gap_size_min=0.04, min_market_cap_proxy=40_000_000_000.0, document_quality_score_min=0.5, parse_confidence_overall_min=0.5, leader_follower_lookahead_days=3, leader_follower_min_days_to_event=2, leader_follower_hold_buffer_days=1, proxy_reaction_day_return_max=0.04, proxy_gap_size_max=0.03, proxy_close_location_max=0.80, proxy_avg_dollar_volume_min=10_000_000.0, ) ], ) config = BacktestConfig( strategy_name="leader_follower_legacy_mode", dataset_snapshot_id="test_snapshot", earnings_calendar_pit_path=str(calendar_path), universe=UniverseConfig(min_price=5.0, min_avg_dollar_volume=100_000.0), signal=SignalConfig(score_threshold=0.50, max_candidates_per_day=5), risk=RiskConfig( per_trade_risk_pct=0.01, max_daily_new_risk_pct=0.05, max_positions=10, max_positions_per_sector=5, ), execution=ExecutionConfig( entry_fill_model="next_open", exit_fill_model="daily_bar_approximation", slippage_bps_base=0.0, commission_per_share=0.0, same_bar_priority="stop_first_conservative", max_holding_days=10, ), reporting=ReportingConfig( write_trade_blotter=True, write_equity_curve=True, write_metrics_summary=True, generate_plots=False, ), strategy_engines=manifest.strategy_engines, ) runner = BacktestRunner(manifest=manifest, config=config, store=store, initial_equity=100_000.0) runner._simulation_dates = [signal_date, entry_date, pre_event_date, follower_reaction_date, follower_exec_date] runner._next_trading_day = { signal_date: entry_date, entry_date: pre_event_date, pre_event_date: follower_reaction_date, follower_reaction_date: follower_exec_date, } runner._schedule_leader_follower_candidates(signal_date) scheduled = runner._scheduled_delayed_entries[entry_date] assert len(scheduled) == 1 assert scheduled[0].symbol == "AMD" def test_leader_follower_engine_supports_engine_local_peer_overrides(self): from apps.backtester.run import BacktestRunner from libs.backtest.domain import ( BacktestConfig, ExecutionConfig, ExperimentManifest, ReportingConfig, RiskConfig, SignalConfig, StrategyEngineConfig, UniverseConfig, ) from libs.backtest.snapshot_store import SnapshotStore signal_date = dt.date(2025, 11, 5) entry_date = dt.date(2025, 11, 6) pre_event_date = dt.date(2025, 11, 7) follower_reaction_date = dt.date(2025, 11, 10) follower_exec_date = dt.date(2025, 11, 11) store = SnapshotStore( candidates_by_exec_date={ entry_date: [ { "event_id": "LEADER::NVDA", "symbol": "NVDA", "execution_date": entry_date, "entry_date": str(entry_date), "event_date": str(signal_date), "event_close": 130.0, "entry_price": 131.0, "score": 0.90, "sector": "Technology", "event_type": "earnings_release", "event_direction": "bullish", "guidance_status": "raised", "event_timestamp": "2025-11-05T21:00:00+00:00", "filing_time_bucket": "post_market", "reaction_date": str(signal_date), "reaction_day_return": 0.15, "close_location": 0.86, "volume_ratio": 3.0, "gap_size": 0.06, "avg_dollar_volume": 200_000_000.0, "market_cap_proxy": 2_000_000_000_000.0, "document_quality_score": 0.85, "parse_confidence_overall": 0.90, "atr_14": 4.0, } ], }, bars_by_symbol_date={ "NVDA": { signal_date: {"date": signal_date, "open": 120.0, "high": 132.0, "low": 118.0, "close": 130.0, "volume": 3_000_000}, entry_date: {"date": entry_date, "open": 131.0, "high": 133.0, "low": 128.0, "close": 132.0, "volume": 2_500_000}, }, "COHR": { signal_date: {"date": signal_date, "open": 74.0, "high": 76.0, "low": 73.0, "close": 75.5, "volume": 500_000}, entry_date: {"date": entry_date, "open": 75.6, "high": 76.4, "low": 75.0, "close": 76.0, "volume": 480_000}, pre_event_date: {"date": pre_event_date, "open": 76.1, "high": 76.9, "low": 75.4, "close": 76.3, "volume": 470_000}, follower_reaction_date: {"date": follower_reaction_date, "open": 76.4, "high": 77.1, "low": 75.8, "close": 76.8, "volume": 510_000}, follower_exec_date: {"date": follower_exec_date, "open": 76.9, "high": 77.3, "low": 76.2, "close": 76.7, "volume": 450_000}, }, }, ) store._market_feature_cache[("COHR", signal_date)] = { "reaction_day_return": 0.01, "gap_size": 0.005, "close_location": 0.55, "volume_ratio_20d": 1.2, "avg_dollar_volume_20d": 120_000_000.0, "event_close": 75.5, "atr_14": 2.1, } class _FakeOracleCalendar: def get_known_upcoming_reaction_dates(self, *, as_of_date, allowed_reaction_dates, symbols): assert as_of_date == signal_date assert "COHR" in symbols return {"COHR": follower_reaction_date} manifest = ExperimentManifest( experiment_name="leader_follower_peer_override", dataset_snapshot_id="test_snapshot", base_config="configs/backtest/defaults.json", overrides={}, strategy_engines=[ StrategyEngineConfig( engine_id="leader_follower_preentry", synthetic_only=True, entry_timing_policy="next_open", event_types=["earnings_release"], event_directions=["bullish"], guidance_statuses=["raised"], filing_time_buckets=["post_market"], allowed_sectors=["Technology"], direction="long_only", reaction_day_return_min=0.10, close_location_min=0.75, volume_ratio_min=2.0, gap_size_min=0.04, min_market_cap_proxy=40_000_000_000.0, document_quality_score_min=0.5, parse_confidence_overall_min=0.5, leader_follower_lookahead_days=3, leader_follower_min_days_to_event=2, leader_follower_hold_buffer_days=1, leader_follower_calendar_mode="pit_calendar", leader_follower_extra_peer_symbols_by_leader={"NVDA": ["COHR"]}, leader_follower_allowed_peer_symbols=["COHR"], proxy_reaction_day_return_max=0.04, proxy_gap_size_max=0.03, proxy_close_location_max=0.80, proxy_avg_dollar_volume_min=10_000_000.0, ) ], ) config = BacktestConfig( strategy_name="leader_follower_peer_override", dataset_snapshot_id="test_snapshot", universe=UniverseConfig(min_price=5.0, min_avg_dollar_volume=100_000.0), signal=SignalConfig(score_threshold=0.50, max_candidates_per_day=5), risk=RiskConfig( per_trade_risk_pct=0.01, max_daily_new_risk_pct=0.05, max_positions=10, max_positions_per_sector=5, ), execution=ExecutionConfig( entry_fill_model="next_open", exit_fill_model="daily_bar_approximation", slippage_bps_base=0.0, commission_per_share=0.0, same_bar_priority="stop_first_conservative", max_holding_days=10, ), reporting=ReportingConfig( write_trade_blotter=True, write_equity_curve=True, write_metrics_summary=True, generate_plots=False, ), strategy_engines=manifest.strategy_engines, ) runner = BacktestRunner(manifest=manifest, config=config, store=store, initial_equity=100_000.0) runner._pit_earnings_calendar = None runner._oracle_pit_earnings_calendar = _FakeOracleCalendar() runner._simulation_dates = [signal_date, entry_date, pre_event_date, follower_reaction_date, follower_exec_date] runner._next_trading_day = { signal_date: entry_date, entry_date: pre_event_date, pre_event_date: follower_reaction_date, follower_reaction_date: follower_exec_date, } runner._schedule_leader_follower_candidates(signal_date) scheduled = runner._scheduled_delayed_entries[entry_date] assert len(scheduled) == 1 assert scheduled[0].symbol == "COHR" assert scheduled[0].source_symbol == "NVDA" def test_leader_follower_engine_fetches_missing_peer_market_data(self, monkeypatch): from apps.backtester.run import BacktestRunner from libs.backtest.domain import ( BacktestConfig, ExecutionConfig, ExperimentManifest, ReportingConfig, RiskConfig, SignalConfig, StrategyEngineConfig, UniverseConfig, ) from libs.backtest.snapshot_store import SnapshotStore signal_date = dt.date(2025, 11, 5) entry_date = dt.date(2025, 11, 6) pre_event_date = dt.date(2025, 11, 7) follower_reaction_date = dt.date(2025, 11, 10) follower_exec_date = dt.date(2025, 11, 11) store = SnapshotStore( candidates_by_exec_date={ entry_date: [ { "event_id": "LEADER::NVDA", "symbol": "NVDA", "execution_date": entry_date, "entry_date": str(entry_date), "event_date": str(signal_date), "event_close": 130.0, "entry_price": 131.0, "score": 0.90, "sector": "Technology", "event_type": "earnings_release", "event_direction": "bullish", "guidance_status": "raised", "event_timestamp": "2025-11-05T21:00:00+00:00", "filing_time_bucket": "post_market", "reaction_date": str(signal_date), "reaction_day_return": 0.15, "close_location": 0.86, "volume_ratio": 3.0, "gap_size": 0.06, "avg_dollar_volume": 200_000_000.0, "market_cap_proxy": 2_000_000_000_000.0, "document_quality_score": 0.85, "parse_confidence_overall": 0.90, "atr_14": 4.0, } ], }, bars_by_symbol_date={ "NVDA": { signal_date: {"date": signal_date, "open": 120.0, "high": 132.0, "low": 118.0, "close": 130.0, "volume": 3_000_000}, entry_date: {"date": entry_date, "open": 131.0, "high": 133.0, "low": 128.0, "close": 132.0, "volume": 2_500_000}, }, }, ) class _FakeOracleCalendar: def get_known_upcoming_reaction_dates(self, *, as_of_date, allowed_reaction_dates, symbols): assert as_of_date == signal_date assert "COHR" in symbols return {"COHR": follower_reaction_date} async def _fake_fetch_price_data(symbols, date_range, oracle_url, concurrency=8): assert "COHR" in symbols start_date, end_date = date_range current = start_date price = 60.0 bars: dict[dt.date, dict[str, float | int | dt.date]] = {} while current <= end_date: if current.weekday() < 5: bars[current] = { "date": current, "open": price, "high": price * 1.01, "low": price * 0.99, "close": price * 1.002, "volume": 2_000_000, } price += 0.2 current += dt.timedelta(days=1) return {"COHR": bars}, {"COHR": 120_000_000.0} monkeypatch.setattr(SnapshotStore, "_fetch_price_data", staticmethod(_fake_fetch_price_data)) manifest = ExperimentManifest( experiment_name="leader_follower_peer_fetch", dataset_snapshot_id="test_snapshot", base_config="configs/backtest/defaults.json", overrides={}, strategy_engines=[ StrategyEngineConfig( engine_id="leader_follower_preentry", synthetic_only=True, entry_timing_policy="next_open", event_types=["earnings_release"], event_directions=["bullish"], guidance_statuses=["raised"], filing_time_buckets=["post_market"], allowed_sectors=["Technology"], direction="long_only", reaction_day_return_min=0.10, close_location_min=0.75, volume_ratio_min=2.0, gap_size_min=0.04, min_market_cap_proxy=40_000_000_000.0, document_quality_score_min=0.5, parse_confidence_overall_min=0.5, leader_follower_lookahead_days=3, leader_follower_min_days_to_event=2, leader_follower_hold_buffer_days=1, leader_follower_calendar_mode="pit_calendar", leader_follower_extra_peer_symbols_by_leader={"NVDA": ["COHR"]}, leader_follower_allowed_peer_symbols=["COHR"], proxy_reaction_day_return_max=0.05, proxy_gap_size_max=0.04, proxy_close_location_min=0.3, proxy_close_location_max=0.85, proxy_avg_dollar_volume_min=10_000_000.0, ) ], ) config = BacktestConfig( strategy_name="leader_follower_peer_fetch", dataset_snapshot_id="test_snapshot", universe=UniverseConfig(min_price=5.0, min_avg_dollar_volume=100_000.0), signal=SignalConfig(score_threshold=0.50, max_candidates_per_day=5), risk=RiskConfig( per_trade_risk_pct=0.01, max_daily_new_risk_pct=0.05, max_positions=10, max_positions_per_sector=5, ), execution=ExecutionConfig( entry_fill_model="next_open", exit_fill_model="daily_bar_approximation", slippage_bps_base=0.0, commission_per_share=0.0, same_bar_priority="stop_first_conservative", max_holding_days=10, ), reporting=ReportingConfig( write_trade_blotter=True, write_equity_curve=True, write_metrics_summary=True, generate_plots=False, ), strategy_engines=manifest.strategy_engines, ) runner = BacktestRunner(manifest=manifest, config=config, store=store, initial_equity=100_000.0) runner._pit_earnings_calendar = None runner._oracle_pit_earnings_calendar = _FakeOracleCalendar() runner._simulation_dates = [signal_date, entry_date, pre_event_date, follower_reaction_date, follower_exec_date] runner._next_trading_day = { signal_date: entry_date, entry_date: pre_event_date, pre_event_date: follower_reaction_date, follower_reaction_date: follower_exec_date, } runner._schedule_leader_follower_candidates(signal_date) scheduled = runner._scheduled_delayed_entries[entry_date] assert len(scheduled) == 1 assert scheduled[0].symbol == "COHR" assert store.get_latest_bar_on_or_before("COHR", signal_date) is not None def test_macro_bullish_engine_respects_breadth_gate(self, tmp_path): from apps.backtester.run import BacktestRunner from libs.backtest.domain import ( BacktestConfig, Candidate, ExecutionConfig, ExperimentManifest, ReportingConfig, RiskConfig, SignalConfig, StrategyEngineConfig, UniverseConfig, ) from libs.backtest.snapshot_store import SnapshotStore signal_date = dt.date(2026, 1, 6) next_date = dt.date(2026, 1, 7) store = SnapshotStore( candidates_by_exec_date={next_date: []}, bars_by_symbol_date={ "QQQ": { dt.date(2026, 1, 5): {"date": dt.date(2026, 1, 5), "open": 100.0, "high": 101.0, "low": 99.0, "close": 100.0, "volume": 100}, signal_date: {"date": signal_date, "open": 102.0, "high": 107.0, "low": 101.5, "close": 106.0, "volume": 400}, next_date: {"date": next_date, "open": 106.5, "high": 108.0, "low": 105.0, "close": 107.5, "volume": 350}, } }, macro_by_date={signal_date: {"VIXCLS": 22.0}}, ) manifest = ExperimentManifest( experiment_name="macro_bullish_qqq_breadth_block", dataset_snapshot_id="test_snapshot", base_config="configs/backtest/defaults.json", overrides={}, strategy_engines=[ StrategyEngineConfig( engine_id="macro_bullish_qqq", synthetic_only=True, entry_timing_policy="next_open", macro_long_symbol="QQQ", macro_long_reaction_day_return_min=0.015, macro_long_volume_ratio_min=2.0, macro_long_gap_size_min=0.01, macro_long_close_location_min=0.75, macro_long_min_daily_candidate_count=3, macro_long_min_unique_sector_count=2, ) ], ) config = BacktestConfig( strategy_name="macro_bullish_qqq_breadth_block", dataset_snapshot_id="test_snapshot", universe=UniverseConfig(min_price=5.0, min_avg_dollar_volume=100_000.0), signal=SignalConfig(score_threshold=0.50, max_candidates_per_day=5), risk=RiskConfig( per_trade_risk_pct=0.01, max_daily_new_risk_pct=0.05, max_positions=10, max_positions_per_sector=5, ), execution=ExecutionConfig( entry_fill_model="next_open", exit_fill_model="daily_bar_approximation", slippage_bps_base=0.0, commission_per_share=0.0, same_bar_priority="stop_first_conservative", max_holding_days=10, ), reporting=ReportingConfig( write_trade_blotter=True, write_equity_curve=True, write_metrics_summary=True, generate_plots=False, ), strategy_engines=manifest.strategy_engines, ) runner = BacktestRunner(manifest=manifest, config=config, store=store, initial_equity=100_000.0) runner._simulation_dates = [signal_date, next_date] runner._next_trading_day = {signal_date: next_date} runner._recent_scored_candidates[signal_date] = [ Candidate( event_id="BREADTH::1", symbol="AAPL", score=0.8, sector="Technology", event_type="earnings_release", event_timestamp=dt.datetime(2026, 1, 6, 21, 0, tzinfo=_UTC), filing_time_bucket="post_market", reaction_date=signal_date, execution_date=next_date, entry_price_est=100.0, avg_dollar_volume=1_000_000.0, atr_14=2.0, score_bucket="high", engine_id="core", ), Candidate( event_id="BREADTH::2", symbol="LLY", score=0.78, sector="Health Care", event_type="earnings_release", event_timestamp=dt.datetime(2026, 1, 6, 21, 0, tzinfo=_UTC), filing_time_bucket="post_market", reaction_date=signal_date, execution_date=next_date, entry_price_est=100.0, avg_dollar_volume=1_000_000.0, atr_14=2.0, score_bucket="high", engine_id="core", ), ] runner._schedule_macro_long_candidates(signal_date) assert runner._scheduled_delayed_entries[next_date] == [] def test_macro_bullish_engine_uses_etf_basket_breadth(self, tmp_path): from apps.backtester.run import BacktestRunner from libs.backtest.domain import ( BacktestConfig, ExecutionConfig, ExperimentManifest, ReportingConfig, RiskConfig, SignalConfig, StrategyEngineConfig, UniverseConfig, ) from libs.backtest.snapshot_store import SnapshotStore signal_date = dt.date(2026, 1, 6) next_date = dt.date(2026, 1, 7) store = SnapshotStore( candidates_by_exec_date={next_date: []}, bars_by_symbol_date={ "SPY": { dt.date(2026, 1, 5): {"date": dt.date(2026, 1, 5), "open": 100.0, "high": 101.0, "low": 99.0, "close": 100.0, "volume": 100}, signal_date: {"date": signal_date, "open": 101.0, "high": 103.0, "low": 100.5, "close": 102.0, "volume": 130}, }, "QQQ": { dt.date(2026, 1, 5): {"date": dt.date(2026, 1, 5), "open": 100.0, "high": 101.0, "low": 99.0, "close": 100.0, "volume": 100}, signal_date: {"date": signal_date, "open": 102.0, "high": 107.0, "low": 101.5, "close": 106.0, "volume": 180}, next_date: {"date": next_date, "open": 106.5, "high": 108.0, "low": 105.0, "close": 107.5, "volume": 170}, }, "XLK": { dt.date(2026, 1, 5): {"date": dt.date(2026, 1, 5), "open": 200.0, "high": 201.0, "low": 199.0, "close": 200.0, "volume": 100}, signal_date: {"date": signal_date, "open": 202.0, "high": 209.0, "low": 201.5, "close": 208.0, "volume": 160}, }, "SMH": { dt.date(2026, 1, 5): {"date": dt.date(2026, 1, 5), "open": 300.0, "high": 301.0, "low": 299.0, "close": 300.0, "volume": 100}, signal_date: {"date": signal_date, "open": 304.0, "high": 314.0, "low": 303.0, "close": 312.0, "volume": 175}, }, "IWM": { dt.date(2026, 1, 5): {"date": dt.date(2026, 1, 5), "open": 150.0, "high": 151.0, "low": 149.0, "close": 150.0, "volume": 100}, signal_date: {"date": signal_date, "open": 149.5, "high": 151.0, "low": 148.0, "close": 149.0, "volume": 95}, }, }, macro_by_date={signal_date: {"VIXCLS": 22.0}}, ) manifest = ExperimentManifest( experiment_name="macro_bullish_etf_breadth", dataset_snapshot_id="test_snapshot", base_config="configs/backtest/defaults.json", overrides={}, strategy_engines=[ StrategyEngineConfig( engine_id="macro_bullish_qqq_etf_breadth", synthetic_only=True, entry_timing_policy="next_open", max_holding_days=8, engine_risk_budget_pct=0.25, per_trade_risk_pct_override=0.01, stop_atr_multiplier_override=2.0, trailing_warmup_days_override=4, macro_long_symbol="QQQ", macro_long_reaction_day_return_min=0.015, macro_long_gap_size_min=0.01, macro_long_close_location_min=0.75, macro_long_breadth_symbols=["QQQ", "XLK", "SMH", "IWM"], macro_long_min_breadth_count=2, macro_long_breadth_reaction_day_return_min=0.015, macro_long_breadth_close_location_min=0.70, macro_long_leadership_vs_spy_min=0.02, ) ], ) config = BacktestConfig( strategy_name="macro_bullish_etf_breadth", dataset_snapshot_id="test_snapshot", universe=UniverseConfig(min_price=5.0, min_avg_dollar_volume=100_000.0), signal=SignalConfig(score_threshold=0.50, max_candidates_per_day=5), risk=RiskConfig( per_trade_risk_pct=0.01, max_daily_new_risk_pct=0.05, max_positions=10, max_positions_per_sector=5, ), execution=ExecutionConfig( entry_fill_model="next_open", exit_fill_model="daily_bar_approximation", slippage_bps_base=0.0, commission_per_share=0.0, same_bar_priority="stop_first_conservative", max_holding_days=10, ), reporting=ReportingConfig( write_trade_blotter=True, write_equity_curve=True, write_metrics_summary=True, generate_plots=False, ), strategy_engines=manifest.strategy_engines, ) runner = BacktestRunner(manifest=manifest, config=config, store=store, initial_equity=100_000.0) runner._simulation_dates = [signal_date, next_date] runner._next_trading_day = {signal_date: next_date} runner._schedule_macro_long_candidates(signal_date) scheduled = runner._scheduled_delayed_entries[next_date] assert len(scheduled) == 1 candidate = scheduled[0] assert candidate.symbol == "QQQ" assert candidate.features["macro_long_breadth_count"] == 3 assert candidate.features["macro_long_breadth_symbols"] == ["QQQ", "XLK", "SMH"] assert candidate.features["macro_long_leadership_vs_spy"] == pytest.approx(0.04) assert candidate.features["macro_long_event_candidate_count"] == 0 def test_macro_bullish_engine_can_trade_strongest_breadth_etf(self, tmp_path): from apps.backtester.run import BacktestRunner from libs.backtest.domain import ( BacktestConfig, Candidate, DailyPortfolioState, ExecutionConfig, ExperimentManifest, OpenPosition, PlannedOrder, ReportingConfig, RiskConfig, SignalConfig, StrategyEngineConfig, UniverseConfig, ) from libs.backtest.snapshot_store import SnapshotStore signal_date = dt.date(2026, 1, 6) next_date = dt.date(2026, 1, 7) store = SnapshotStore( candidates_by_exec_date={next_date: []}, bars_by_symbol_date={ "SPY": { dt.date(2026, 1, 5): {"date": dt.date(2026, 1, 5), "open": 100.0, "high": 101.0, "low": 99.0, "close": 100.0, "volume": 100}, signal_date: {"date": signal_date, "open": 101.0, "high": 103.0, "low": 100.5, "close": 102.0, "volume": 130}, }, "QQQ": { dt.date(2026, 1, 5): {"date": dt.date(2026, 1, 5), "open": 100.0, "high": 101.0, "low": 99.0, "close": 100.0, "volume": 100}, signal_date: {"date": signal_date, "open": 101.0, "high": 105.0, "low": 100.5, "close": 104.0, "volume": 150}, next_date: {"date": next_date, "open": 104.5, "high": 106.0, "low": 103.0, "close": 105.0, "volume": 140}, }, "XLK": { dt.date(2026, 1, 5): {"date": dt.date(2026, 1, 5), "open": 200.0, "high": 201.0, "low": 199.0, "close": 200.0, "volume": 100}, signal_date: {"date": signal_date, "open": 205.0, "high": 219.0, "low": 204.0, "close": 217.0, "volume": 190}, next_date: {"date": next_date, "open": 217.5, "high": 221.0, "low": 216.0, "close": 220.0, "volume": 180}, }, "SMH": { dt.date(2026, 1, 5): {"date": dt.date(2026, 1, 5), "open": 300.0, "high": 301.0, "low": 299.0, "close": 300.0, "volume": 100}, signal_date: {"date": signal_date, "open": 303.0, "high": 309.0, "low": 302.0, "close": 307.0, "volume": 160}, next_date: {"date": next_date, "open": 307.5, "high": 310.0, "low": 306.0, "close": 309.0, "volume": 150}, }, }, macro_by_date={signal_date: {"VIXCLS": 22.0}}, ) manifest = ExperimentManifest( experiment_name="macro_bullish_etf_breadth_leader", dataset_snapshot_id="test_snapshot", base_config="configs/backtest/defaults.json", overrides={}, strategy_engines=[ StrategyEngineConfig( engine_id="macro_bullish_qqq_etf_breadth_leader", synthetic_only=True, entry_timing_policy="next_open", macro_long_symbol="QQQ", macro_long_trade_symbol_mode="leader", macro_long_reaction_day_return_min=0.015, macro_long_close_location_min=0.60, macro_long_breadth_symbols=["QQQ", "XLK", "SMH"], macro_long_min_breadth_count=2, macro_long_breadth_reaction_day_return_min=0.015, macro_long_breadth_close_location_min=0.60, macro_long_leadership_vs_spy_min=0.01, ) ], ) config = BacktestConfig( strategy_name="macro_bullish_etf_breadth_leader", dataset_snapshot_id="test_snapshot", universe=UniverseConfig(min_price=5.0, min_avg_dollar_volume=100_000.0), signal=SignalConfig(score_threshold=0.50, max_candidates_per_day=5), risk=RiskConfig( per_trade_risk_pct=0.01, max_daily_new_risk_pct=0.05, max_positions=10, max_positions_per_sector=5, ), execution=ExecutionConfig( entry_fill_model="next_open", exit_fill_model="daily_bar_approximation", slippage_bps_base=0.0, commission_per_share=0.0, same_bar_priority="stop_first_conservative", max_holding_days=10, ), reporting=ReportingConfig( write_trade_blotter=True, write_equity_curve=True, write_metrics_summary=True, generate_plots=False, ), strategy_engines=manifest.strategy_engines, ) runner = BacktestRunner(manifest=manifest, config=config, store=store, initial_equity=100_000.0) runner._simulation_dates = [signal_date, next_date] runner._next_trading_day = {signal_date: next_date} runner._schedule_macro_long_candidates(signal_date) scheduled = runner._scheduled_delayed_entries[next_date] assert len(scheduled) == 1 candidate = scheduled[0] assert candidate.symbol == "XLK" assert candidate.source_symbol == "QQQ" assert candidate.features["macro_long_trade_symbol"] == "XLK" assert candidate.features["macro_long_trade_symbol_mode"] == "leader" def test_capital_bucket_reserves_cash_for_macro_sleeve(self, tmp_path): from apps.backtester.run import BacktestRunner from libs.backtest.domain import ( BacktestConfig, Candidate, DailyPortfolioState, ExecutionConfig, ExperimentManifest, OpenPosition, PlannedOrder, ReportingConfig, RiskConfig, SignalConfig, StrategyEngineConfig, UniverseConfig, ) from libs.backtest.snapshot_store import SnapshotStore date = dt.date(2026, 1, 7) store = SnapshotStore( candidates_by_exec_date={}, bars_by_symbol_date={ "XLK": { date: {"date": date, "open": 210.0, "high": 212.0, "low": 209.0, "close": 211.0, "volume": 100}, } }, ) manifest = ExperimentManifest( experiment_name="macro_capital_bucket_reserve", dataset_snapshot_id="test_snapshot", base_config="configs/backtest/defaults.json", overrides={}, strategy_engines=[ StrategyEngineConfig(engine_id="core"), StrategyEngineConfig( engine_id="macro_bullish_qqq_etf_breadth", synthetic_only=True, capital_bucket_id="macro_etf", capital_bucket_allocation_pct=0.05, ), ], ) config = BacktestConfig( strategy_name="macro_capital_bucket_reserve", dataset_snapshot_id="test_snapshot", universe=UniverseConfig(min_price=5.0, min_avg_dollar_volume=100_000.0), signal=SignalConfig(score_threshold=0.50, max_candidates_per_day=5), risk=RiskConfig( per_trade_risk_pct=0.01, max_daily_new_risk_pct=0.05, max_positions=10, max_positions_per_sector=5, ), execution=ExecutionConfig( entry_fill_model="next_open", exit_fill_model="daily_bar_approximation", slippage_bps_base=0.0, commission_per_share=0.0, same_bar_priority="stop_first_conservative", max_holding_days=10, ), reporting=ReportingConfig( write_trade_blotter=True, write_equity_curve=True, write_metrics_summary=True, generate_plots=False, ), strategy_engines=manifest.strategy_engines, ) runner = BacktestRunner(manifest=manifest, config=config, store=store, initial_equity=100_000.0) portfolio_state = DailyPortfolioState( date=date, equity=100_000.0, sizing_equity=100_000.0, cash_available=100_000.0, gross_exposure=0.0, net_exposure=0.0, reserved_risk_budget=0.0, unrealized_pnl=0.0, realized_pnl=0.0, open_positions=[], daily_new_risk_used=0.0, peak_equity=100_000.0, current_drawdown_pct=0.0, ) core_candidate = Candidate( event_id="core::1", symbol="AAPL", score=0.7, sector="Technology", event_type="earnings_release", event_timestamp=dt.datetime(2026, 1, 6, 21, 0, tzinfo=_UTC), filing_time_bucket="post_market", reaction_date=date, execution_date=date, entry_price_est=100.0, avg_dollar_volume=1_000_000.0, atr_14=2.0, score_bucket="high", engine_id="core", ) macro_candidate = Candidate( event_id="macro::1", symbol="XLK", source_symbol="QQQ", score=0.7, sector="Technology", event_type="macro_bullish_event", event_timestamp=dt.datetime(2026, 1, 6, 21, 0, tzinfo=_UTC), filing_time_bucket="post_market", reaction_date=date, execution_date=date, entry_price_est=210.0, avg_dollar_volume=1_000_000.0, atr_14=4.0, score_bucket="high", engine_id="macro_bullish_qqq_etf_breadth", engine_capital_bucket_id="macro_etf", engine_capital_bucket_allocation_pct=0.05, ) active_bucket_ids = runner._active_capital_bucket_ids_for_candidates([core_candidate, macro_candidate]) core_state = runner._adjust_portfolio_state_for_candidate( date=date, candidate=core_candidate, portfolio_state=portfolio_state, active_bucket_ids=active_bucket_ids, ) macro_state = runner._adjust_portfolio_state_for_candidate( date=date, candidate=macro_candidate, portfolio_state=portfolio_state, active_bucket_ids=active_bucket_ids, ) assert core_state.cash_available == pytest.approx(95_000.0) assert macro_state.cash_available == pytest.approx(5_000.0) assert core_state.sizing_equity == pytest.approx(95_000.0) assert macro_state.sizing_equity == pytest.approx(5_000.0) macro_plan = PlannedOrder( candidate=macro_candidate, shares=10, entry_price_limit=210.0, stop_price=202.0, target_price=230.0, risk_dollars=80.0, event_date=date, timing_class="after_close", engine_id="macro_bullish_qqq_etf_breadth", entry_timing_policy="next_open", ) runner._open_positions = [ OpenPosition( position_id="macro-pos-1", plan=macro_plan, entry_date=date, entry_price=210.0, entry_fill_slippage_bps=0.0, current_stop=202.0, target_price=230.0, peak_price=211.0, shares_open=10, shares_total=10, ) ] core_state_after_fill = runner._adjust_portfolio_state_for_candidate( date=date, candidate=core_candidate, portfolio_state=portfolio_state, active_bucket_ids=active_bucket_ids, ) macro_state_after_fill = runner._adjust_portfolio_state_for_candidate( date=date, candidate=macro_candidate, portfolio_state=portfolio_state, active_bucket_ids=active_bucket_ids, ) assert core_state_after_fill.cash_available == pytest.approx(97_100.0) assert macro_state_after_fill.cash_available == pytest.approx(2_900.0) assert core_state_after_fill.sizing_equity == pytest.approx(94_990.0) assert macro_state_after_fill.sizing_equity == pytest.approx(5_010.0) def test_capital_bucket_does_not_reserve_when_bucket_inactive(self, tmp_path): from apps.backtester.run import BacktestRunner from libs.backtest.domain import ( BacktestConfig, Candidate, DailyPortfolioState, ExecutionConfig, ExperimentManifest, ReportingConfig, RiskConfig, SignalConfig, StrategyEngineConfig, UniverseConfig, ) from libs.backtest.snapshot_store import SnapshotStore date = dt.date(2026, 1, 7) store = SnapshotStore(candidates_by_exec_date={}, bars_by_symbol_date={}) manifest = ExperimentManifest( experiment_name="macro_capital_bucket_inactive", dataset_snapshot_id="test_snapshot", base_config="configs/backtest/defaults.json", overrides={}, strategy_engines=[ StrategyEngineConfig(engine_id="core"), StrategyEngineConfig( engine_id="macro_bullish_qqq_etf_breadth", synthetic_only=True, capital_bucket_id="macro_etf", capital_bucket_allocation_pct=0.05, ), ], ) config = BacktestConfig( strategy_name="macro_capital_bucket_inactive", dataset_snapshot_id="test_snapshot", universe=UniverseConfig(min_price=5.0, min_avg_dollar_volume=100_000.0), signal=SignalConfig(score_threshold=0.50, max_candidates_per_day=5), risk=RiskConfig( per_trade_risk_pct=0.01, max_daily_new_risk_pct=0.05, max_positions=10, max_positions_per_sector=5, ), execution=ExecutionConfig( entry_fill_model="next_open", exit_fill_model="daily_bar_approximation", slippage_bps_base=0.0, commission_per_share=0.0, same_bar_priority="stop_first_conservative", max_holding_days=10, ), reporting=ReportingConfig( write_trade_blotter=True, write_equity_curve=True, write_metrics_summary=True, generate_plots=False, ), strategy_engines=manifest.strategy_engines, ) runner = BacktestRunner(manifest=manifest, config=config, store=store, initial_equity=100_000.0) portfolio_state = DailyPortfolioState( date=date, equity=100_000.0, sizing_equity=100_000.0, cash_available=100_000.0, gross_exposure=0.0, net_exposure=0.0, reserved_risk_budget=0.0, unrealized_pnl=0.0, realized_pnl=0.0, open_positions=[], daily_new_risk_used=0.0, peak_equity=100_000.0, current_drawdown_pct=0.0, ) core_candidate = Candidate( event_id="core::inactive", symbol="AAPL", score=0.7, sector="Technology", event_type="earnings_release", event_timestamp=dt.datetime(2026, 1, 6, 21, 0, tzinfo=_UTC), filing_time_bucket="post_market", reaction_date=date, execution_date=date, entry_price_est=100.0, avg_dollar_volume=1_000_000.0, atr_14=2.0, score_bucket="high", engine_id="core", ) active_bucket_ids = runner._active_capital_bucket_ids_for_candidates([core_candidate]) adjusted_state = runner._adjust_portfolio_state_for_candidate( date=date, candidate=core_candidate, portfolio_state=portfolio_state, active_bucket_ids=active_bucket_ids, ) assert adjusted_state.cash_available == pytest.approx(100_000.0) assert adjusted_state.sizing_equity == pytest.approx(100_000.0) def test_parallel_sgov_marks_to_market_and_realizes_proportional_pnl(self, tmp_path): from apps.backtester.run import BacktestRunner from libs.backtest.domain import ( BacktestConfig, ExecutionConfig, ExperimentManifest, ReportingConfig, RiskConfig, SignalConfig, UniverseConfig, ) from libs.backtest.snapshot_store import SnapshotStore start = dt.date(2026, 1, 6) mark_date = dt.date(2026, 1, 7) store = SnapshotStore( candidates_by_exec_date={}, bars_by_symbol_date={}, macro_by_date={ start: {"sgov_close": 100.0}, mark_date: {"sgov_close": 101.0}, }, ) manifest = ExperimentManifest( experiment_name="parallel_sgov_mtm", dataset_snapshot_id="test_snapshot", base_config="configs/backtest/defaults.json", overrides={}, ) config = BacktestConfig( strategy_name="parallel_sgov_mtm", dataset_snapshot_id="test_snapshot", universe=UniverseConfig(min_price=5.0, min_avg_dollar_volume=100_000.0), signal=SignalConfig(score_threshold=0.50, max_candidates_per_day=5), risk=RiskConfig( per_trade_risk_pct=0.01, max_daily_new_risk_pct=0.05, max_positions=10, max_positions_per_sector=5, ), execution=ExecutionConfig( entry_fill_model="next_open", exit_fill_model="daily_bar_approximation", slippage_bps_base=0.0, commission_per_share=0.0, same_bar_priority="stop_first_conservative", max_holding_days=10, ), reporting=ReportingConfig( write_trade_blotter=True, write_equity_curve=True, write_metrics_summary=True, generate_plots=False, ), ) runner = BacktestRunner(manifest=manifest, config=config, store=store, initial_equity=100_000.0) runner._cash = 95_000.0 runner._parking_entry_date = start runner._allocate_parallel_sgov(date=start, amount=5_000.0, macro={"sgov_close": 100.0}) assert runner._get_parking_value(mark_date) == pytest.approx(5_050.0) runner._liquidate_parking_for_cash(mark_date, 1_000.0) assert runner._cash == pytest.approx(96_000.0) assert runner._parking_sgov_value == pytest.approx(4_050.0) assert runner._parking_sgov_entry_value == pytest.approx(4_009.9009901) assert runner._realized_pnl == pytest.approx(9.9009901) runner._liquidate_parking(mark_date) assert runner._cash == pytest.approx(100_050.0) assert runner._realized_pnl == pytest.approx(50.0) def test_macro_bullish_engine_respects_leadership_vs_spy_gate(self, tmp_path): from apps.backtester.run import BacktestRunner from libs.backtest.domain import ( BacktestConfig, ExecutionConfig, ExperimentManifest, ReportingConfig, RiskConfig, SignalConfig, StrategyEngineConfig, UniverseConfig, ) from libs.backtest.snapshot_store import SnapshotStore signal_date = dt.date(2026, 1, 6) next_date = dt.date(2026, 1, 7) store = SnapshotStore( candidates_by_exec_date={next_date: []}, bars_by_symbol_date={ "SPY": { dt.date(2026, 1, 5): {"date": dt.date(2026, 1, 5), "open": 100.0, "high": 101.0, "low": 99.0, "close": 100.0, "volume": 100}, signal_date: {"date": signal_date, "open": 102.0, "high": 105.0, "low": 101.5, "close": 104.0, "volume": 140}, }, "QQQ": { dt.date(2026, 1, 5): {"date": dt.date(2026, 1, 5), "open": 100.0, "high": 101.0, "low": 99.0, "close": 100.0, "volume": 100}, signal_date: {"date": signal_date, "open": 101.5, "high": 104.0, "low": 101.0, "close": 103.0, "volume": 150}, next_date: {"date": next_date, "open": 103.5, "high": 105.0, "low": 102.0, "close": 104.0, "volume": 140}, }, "XLK": { dt.date(2026, 1, 5): {"date": dt.date(2026, 1, 5), "open": 200.0, "high": 201.0, "low": 199.0, "close": 200.0, "volume": 100}, signal_date: {"date": signal_date, "open": 202.0, "high": 208.0, "low": 201.0, "close": 206.0, "volume": 150}, }, }, macro_by_date={signal_date: {"VIXCLS": 22.0}}, ) manifest = ExperimentManifest( experiment_name="macro_bullish_etf_breadth_leadership_block", dataset_snapshot_id="test_snapshot", base_config="configs/backtest/defaults.json", overrides={}, strategy_engines=[ StrategyEngineConfig( engine_id="macro_bullish_qqq_etf_breadth", synthetic_only=True, entry_timing_policy="next_open", macro_long_symbol="QQQ", macro_long_reaction_day_return_min=0.015, macro_long_close_location_min=0.60, macro_long_breadth_symbols=["QQQ", "XLK"], macro_long_min_breadth_count=2, macro_long_breadth_reaction_day_return_min=0.015, macro_long_breadth_close_location_min=0.60, macro_long_leadership_vs_spy_min=0.02, ) ], ) config = BacktestConfig( strategy_name="macro_bullish_etf_breadth_leadership_block", dataset_snapshot_id="test_snapshot", universe=UniverseConfig(min_price=5.0, min_avg_dollar_volume=100_000.0), signal=SignalConfig(score_threshold=0.50, max_candidates_per_day=5), risk=RiskConfig( per_trade_risk_pct=0.01, max_daily_new_risk_pct=0.05, max_positions=10, max_positions_per_sector=5, ), execution=ExecutionConfig( entry_fill_model="next_open", exit_fill_model="daily_bar_approximation", slippage_bps_base=0.0, commission_per_share=0.0, same_bar_priority="stop_first_conservative", max_holding_days=10, ), reporting=ReportingConfig( write_trade_blotter=True, write_equity_curve=True, write_metrics_summary=True, generate_plots=False, ), strategy_engines=manifest.strategy_engines, ) runner = BacktestRunner(manifest=manifest, config=config, store=store, initial_equity=100_000.0) runner._simulation_dates = [signal_date, next_date] runner._next_trading_day = {signal_date: next_date} runner._schedule_macro_long_candidates(signal_date) assert runner._scheduled_delayed_entries[next_date] == []