You cannot select more than 25 topics
Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
261 lines
10 KiB
Python
261 lines
10 KiB
Python
"""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
|