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.

249 lines
8.9 KiB
Python

from __future__ import annotations
import datetime as dt
from pathlib import Path
from zoneinfo import ZoneInfo
from apps.backtester.run import BacktestRunner, _extend_store_to_requested_window
from libs.backtest.domain import FilledTrade
from libs.backtest.domain import BacktestConfig
from libs.backtest.domain import Candidate, ExitReason, OpenPosition, PlannedOrder
from libs.backtest.snapshot_store import SnapshotStore
def _make_config() -> BacktestConfig:
config = BacktestConfig(
strategy_name="test_strategy",
dataset_snapshot_id="snap",
)
config.risk.cash_parking_enabled = True
config.risk.cash_parking_preset = None
return config
def test_extend_store_to_requested_window_backfills_macro_prefix(monkeypatch, tmp_path: Path) -> None:
snapshot_dir = tmp_path / "snap"
snapshot_dir.mkdir(parents=True)
(snapshot_dir / "manifest.json").write_text("{}")
(snapshot_dir / "train.parquet").write_text("x")
(snapshot_dir / "valid.parquet").write_text("x")
(snapshot_dir / "test.parquet").write_text("x")
store = SnapshotStore(
candidates_by_exec_date={},
bars_by_symbol_date={},
macro_by_date={
dt.date(2022, 3, 1): {"spy_close": 430.0, "qqqm_close": 153.0, "sgov_close": 100.0},
dt.date(2022, 3, 2): {"spy_close": 431.0, "qqqm_close": 154.0, "sgov_close": 100.0},
},
)
async def _fake_fetch_macro(date_range, _db_dsn):
assert date_range == (dt.date(2022, 1, 1), dt.date(2022, 2, 28))
return {
dt.date(2022, 1, 3): {"VIXCLS": 20.0},
dt.date(2022, 1, 4): {"VIXCLS": 21.0},
}
async def _fake_fetch_spy_macro(date_range, _oracle_url):
assert date_range == (dt.date(2022, 1, 1), dt.date(2022, 2, 28))
return {
dt.date(2022, 1, 3): {"spy_close": 470.0, "qqqm_close": 180.0, "sgov_close": 100.1},
dt.date(2022, 1, 4): {"spy_close": 471.0, "qqqm_close": 180.5, "sgov_close": 100.1},
}
monkeypatch.setattr("apps.backtester.run.resolve_snapshot_path", lambda *_args, **_kwargs: snapshot_dir)
monkeypatch.setattr(SnapshotStore, "_fetch_macro", staticmethod(_fake_fetch_macro))
monkeypatch.setattr(SnapshotStore, "_fetch_spy_macro", staticmethod(_fake_fetch_spy_macro))
extended = _extend_store_to_requested_window(
store=store,
config=_make_config(),
start_date=dt.date(2022, 1, 1),
end_date=dt.date(2022, 4, 1),
)
assert min(extended._macro) == dt.date(2022, 1, 3)
assert extended.all_trading_days()[0] == dt.date(2022, 1, 3)
assert extended.get_macro_for_date(dt.date(2022, 1, 3))["qqqm_close"] == 180.0
def test_extend_store_to_requested_window_reuses_cached_macro_window(monkeypatch, tmp_path: Path) -> None:
snapshot_dir = tmp_path / "snap"
snapshot_dir.mkdir(parents=True)
(snapshot_dir / "manifest.json").write_text("{}")
(snapshot_dir / "train.parquet").write_text("x")
(snapshot_dir / "valid.parquet").write_text("x")
(snapshot_dir / "test.parquet").write_text("x")
cache_file = snapshot_dir / "macro_window_2022-01-01_2022-02-28.pkl"
cached_store = SnapshotStore(
candidates_by_exec_date={},
bars_by_symbol_date={},
macro_by_date={
dt.date(2022, 3, 1): {"spy_close": 430.0, "qqqm_close": 153.0, "sgov_close": 100.0},
},
)
async def _initial_fetch_macro(date_range, _db_dsn):
return {dt.date(2022, 1, 3): {"VIXCLS": 20.0}}
async def _initial_fetch_spy_macro(date_range, _oracle_url):
return {dt.date(2022, 1, 3): {"spy_close": 470.0, "qqqm_close": 180.0, "sgov_close": 100.1}}
monkeypatch.setattr("apps.backtester.run.resolve_snapshot_path", lambda *_args, **_kwargs: snapshot_dir)
monkeypatch.setattr(SnapshotStore, "_fetch_macro", staticmethod(_initial_fetch_macro))
monkeypatch.setattr(SnapshotStore, "_fetch_spy_macro", staticmethod(_initial_fetch_spy_macro))
_extend_store_to_requested_window(
store=cached_store,
config=_make_config(),
start_date=dt.date(2022, 1, 1),
end_date=dt.date(2022, 2, 28),
)
assert cache_file.exists()
store = SnapshotStore(
candidates_by_exec_date={},
bars_by_symbol_date={},
macro_by_date={
dt.date(2022, 3, 1): {"spy_close": 430.0, "qqqm_close": 153.0, "sgov_close": 100.0},
},
)
async def _unexpected_fetch_macro(*_args, **_kwargs):
raise AssertionError("macro fetch should not run when cached macro window exists")
async def _unexpected_fetch_spy_macro(*_args, **_kwargs):
raise AssertionError("spy macro fetch should not run when cached macro window exists")
monkeypatch.setattr(SnapshotStore, "_fetch_macro", staticmethod(_unexpected_fetch_macro))
monkeypatch.setattr(SnapshotStore, "_fetch_spy_macro", staticmethod(_unexpected_fetch_spy_macro))
extended = _extend_store_to_requested_window(
store=store,
config=_make_config(),
start_date=dt.date(2022, 1, 1),
end_date=dt.date(2022, 2, 28),
)
assert min(extended._macro) == dt.date(2022, 1, 3)
assert extended.get_macro_for_date(dt.date(2022, 1, 3))["sgov_close"] == 100.1
def test_backtest_runner_uses_requested_window_for_simulation_dates() -> None:
store = SnapshotStore(
candidates_by_exec_date={
dt.date(2022, 3, 3): [{"event_id": "evt", "symbol": "AAPL"}],
},
bars_by_symbol_date={},
macro_by_date={
dt.date(2022, 1, 3): {"spy_close": 470.0, "qqqm_close": 180.0, "sgov_close": 100.1},
dt.date(2022, 3, 3): {"spy_close": 430.0, "qqqm_close": 153.0, "sgov_close": 100.0},
},
)
setattr(store, "_requested_start_date", dt.date(2022, 1, 3))
setattr(store, "_requested_end_date", dt.date(2022, 3, 4))
runner = BacktestRunner.__new__(BacktestRunner)
runner.store = store
runner.config = _make_config()
runner._active_strategy_engines = []
simulation_dates = runner._get_simulation_dates()
assert simulation_dates[0] == dt.date(2022, 1, 3)
assert simulation_dates[-1] == dt.date(2022, 3, 4)
def test_record_parking_trade_keeps_actual_sgov_share_count() -> None:
runner = BacktestRunner.__new__(BacktestRunner)
runner._parking_entry_date = dt.date(2022, 1, 3)
runner._parking_trade_counter = 0
runner._closed_trades = []
runner._record_parking_trade(
exit_date=dt.date(2022, 4, 12),
symbol="sgov",
shares=117,
entry_price=85.16,
exit_price=85.19,
)
assert len(runner._closed_trades) == 1
trade: FilledTrade = runner._closed_trades[0]
assert trade.symbol == "SGOV"
assert trade.shares == 117
assert round(trade.net_pnl, 2) == round((85.19 - 85.16) * 117, 2)
def test_force_close_all_uses_requested_end_bar_and_end_of_backtest_reason() -> None:
utc = ZoneInfo("UTC")
candidate = Candidate(
event_id="EVT::TEST",
symbol="ENB",
score=0.9,
sector="Energy",
event_type="earnings",
event_timestamp=dt.datetime(2026, 3, 23, 21, 0, tzinfo=utc),
event_date=dt.date(2026, 3, 23),
filing_time_bucket="post_market",
reaction_date=dt.date(2026, 3, 24),
execution_date=dt.date(2026, 3, 25),
entry_price_est=52.9,
avg_dollar_volume=10_000_000.0,
atr_14=1.2,
score_bucket="high",
)
plan = PlannedOrder(
candidate=candidate,
shares=100,
entry_price_limit=52.9,
stop_price=50.0,
target_price=60.0,
risk_dollars=290.0,
)
position = OpenPosition(
position_id="pos-1",
plan=plan,
entry_date=dt.date(2026, 3, 25),
entry_price=52.9,
entry_fill_slippage_bps=10.0,
current_stop=50.0,
target_price=60.0,
peak_price=53.5,
shares_open=100,
shares_total=100,
)
store = SnapshotStore(
candidates_by_exec_date={dt.date(2026, 3, 31): [{"event_id": "evt"}]},
bars_by_symbol_date={
"ENB": {
dt.date(2026, 3, 31): {"close": 53.75},
dt.date(2026, 4, 1): {"close": 54.25},
}
},
macro_by_date={
dt.date(2026, 3, 31): {"spy_close": 560.0},
dt.date(2026, 4, 1): {"spy_close": 562.0},
},
)
runner = BacktestRunner.__new__(BacktestRunner)
runner.store = store
runner.config = _make_config()
runner._open_positions = [position]
runner._closed_trades = []
runner._candidate_map = {}
runner._realized_pnl = 0.0
runner._cash = 0.0
runner._force_close_all(dt.date(2026, 4, 1), reason="end_of_backtest")
assert not runner._open_positions
assert len(runner._closed_trades) == 1
trade = runner._closed_trades[0]
assert trade.exit_date == dt.date(2026, 4, 1)
assert trade.exit_reason == ExitReason.END_OF_BACKTEST
assert trade.exit_price > 54.0