"""Integration tests for the full backtest pipeline (no DB/HTTP — uses SnapshotStore directly).""" from __future__ import annotations import datetime as dt from pathlib import Path from zoneinfo import ZoneInfo import pytest _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_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() assert (run_dir / "resolved_config.json").exists() assert (run_dir / "metrics" / "metrics_summary.json").exists() assert (run_dir / "plots").exists() # empty dir 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_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_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_fraction == pytest.approx(0.33) assert effective_exec.trailing_model == "pct_10" assert effective_exec.trailing_warmup_days == 2 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._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._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