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
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
|