"""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 _make_config(): 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, ), ) @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