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.

216 lines
6.8 KiB
Python

from __future__ import annotations
import datetime as dt
from collections import defaultdict
from apps.backtester.run import BacktestRunner
from libs.backtest.domain import (
BacktestConfig,
ExperimentManifest,
SignalConfig,
StrategyEngineConfig,
)
class _DummyStore:
def __init__(self, rows_by_date: dict[dt.date, list[dict]], macro_by_date: dict[dt.date, dict]):
self._rows_by_date = rows_by_date
self._macro_by_date = macro_by_date
def get_candidates_for_date(self, date: dt.date) -> list[dict]:
return list(self._rows_by_date.get(date, []))
def get_candidates_for_reaction_date(self, date: dt.date) -> list[dict]:
return []
def get_macro_for_date(self, date: dt.date) -> dict:
return dict(self._macro_by_date.get(date, {}))
class _DummyAttentionService:
def engine_requires_attention(self, engine: StrategyEngineConfig) -> bool:
return False
def apply_filters(self, candidates, engine, signal):
return candidates
def _make_runner(
*,
engine: StrategyEngineConfig,
rows_by_date: dict[dt.date, list[dict]],
macro_by_date: dict[dt.date, dict],
simulation_dates: list[dt.date],
) -> BacktestRunner:
runner = BacktestRunner.__new__(BacktestRunner)
runner.manifest = ExperimentManifest(
experiment_name="test_volatility_crush",
dataset_snapshot_id="snap",
base_config="configs/backtest/return_max_long_v1.json",
)
runner.config = BacktestConfig(
strategy_name="test_strategy",
dataset_snapshot_id="snap",
signal=SignalConfig(score_threshold=0.45, max_candidates_per_day=5),
strategy_engines=[engine],
)
runner.store = _DummyStore(rows_by_date, macro_by_date)
runner._active_strategy_engines = [engine]
runner._shadow_strategy_engines = []
runner._strategy_engine_lookup = {engine.engine_id: engine}
runner._scheduled_add_ons = defaultdict(list)
runner._attention_service = _DummyAttentionService()
runner._simulation_dates = simulation_dates
runner._simulation_date_index = {
sim_date: idx for idx, sim_date in enumerate(simulation_dates)
}
return runner
def _make_row(**updates) -> dict:
row = {
"event_id": "EVT::AAPL::2026-01-06",
"symbol": "AAPL",
"issuer_id": "ISSUER::AAPL",
"sector": "Technology",
"event_type": "earnings_release",
"event_timestamp": "2026-01-05T21:00:00+00:00",
"event_date": "2026-01-05",
"reaction_date": "2026-01-05",
"entry_date": "2026-01-06",
"entry_price": 150.0,
"event_close": 149.0,
"reaction_day_low": 146.0,
"reaction_day_high": 151.0,
"score": 0.41,
"avg_dollar_volume": 25_000_000.0,
"avg_dollar_volume_20d": 25_000_000.0,
"atr_14": 3.5,
"filing_time_bucket": "post_market",
"entry_convention": "next_open_after_reaction_close",
"reaction_day_return": 0.05,
"close_location": 0.72,
"volume_ratio": 2.0,
"gap_size": 0.02,
"macro_vix": 33.0,
}
row.update(updates)
return row
def test_volatility_crush_state_computes_from_prev_day_macro() -> None:
engine = StrategyEngineConfig(
engine_id="test_engine",
event_types=["earnings_release"],
volatility_crush_vix_drop_pct_min=0.10,
volatility_crush_spy_return_min=0.0,
)
prev_date = dt.date(2026, 1, 5)
date = dt.date(2026, 1, 6)
runner = _make_runner(
engine=engine,
rows_by_date={},
macro_by_date={
prev_date: {"VIXCLS": 40.0, "spy_close": 500.0},
date: {"VIXCLS": 35.0, "spy_close": 505.0},
},
simulation_dates=[prev_date, date],
)
crush = runner._volatility_crush_state_for_date(date)
assert crush is not None
assert round(crush["vix_drop_pct"], 4) == 0.125
assert round(crush["spy_return"], 4) == 0.01
def test_select_candidates_for_date_applies_volatility_crush_overrides() -> None:
engine = StrategyEngineConfig(
engine_id="crush_next_open",
event_types=["earnings_release"],
direction="long_only",
entry_timing_policy="next_open",
score_threshold_override=0.45,
macro_vix_max=30.0,
volatility_crush_vix_drop_pct_min=0.10,
volatility_crush_spy_return_min=0.0,
volatility_crush_score_threshold_override=0.40,
volatility_crush_macro_vix_max_override=40.0,
volatility_crush_per_trade_risk_pct_override=0.08,
)
prev_date = dt.date(2026, 1, 5)
date = dt.date(2026, 1, 6)
rows = {date: [_make_row()]}
macro = {
prev_date: {"VIXCLS": 40.0, "spy_close": 500.0},
date: {"VIXCLS": 35.0, "spy_close": 505.0},
}
runner = _make_runner(
engine=engine,
rows_by_date=rows,
macro_by_date=macro,
simulation_dates=[prev_date, date],
)
selected = runner._select_candidates_for_date(date)
assert len(selected) == 1
assert selected[0].score == 0.41
assert selected[0].engine_per_trade_risk_pct == 0.08
assert selected[0].engine_id == "crush_next_open"
def test_select_candidates_for_date_uses_base_engine_without_crush() -> None:
engine = StrategyEngineConfig(
engine_id="crush_next_open",
event_types=["earnings_release"],
direction="long_only",
entry_timing_policy="next_open",
score_threshold_override=0.45,
macro_vix_max=30.0,
volatility_crush_vix_drop_pct_min=0.10,
volatility_crush_spy_return_min=0.0,
volatility_crush_score_threshold_override=0.40,
volatility_crush_macro_vix_max_override=40.0,
)
prev_date = dt.date(2026, 1, 5)
date = dt.date(2026, 1, 6)
rows = {date: [_make_row()]}
macro = {
prev_date: {"VIXCLS": 40.0, "spy_close": 500.0},
date: {"VIXCLS": 38.0, "spy_close": 499.0},
}
runner = _make_runner(
engine=engine,
rows_by_date=rows,
macro_by_date=macro,
simulation_dates=[prev_date, date],
)
selected = runner._select_candidates_for_date(date)
assert selected == []
def test_engine_allowed_for_date_blocks_crush_only_engine_when_condition_not_met() -> None:
engine = StrategyEngineConfig(
engine_id="crush_only_engine",
event_types=["earnings_release"],
volatility_crush_only=True,
volatility_crush_vix_drop_pct_min=0.10,
volatility_crush_spy_return_min=0.0,
)
prev_date = dt.date(2026, 1, 5)
date = dt.date(2026, 1, 6)
runner = _make_runner(
engine=engine,
rows_by_date={},
macro_by_date={
prev_date: {"VIXCLS": 40.0, "spy_close": 500.0},
date: {"VIXCLS": 38.0, "spy_close": 499.0},
},
simulation_dates=[prev_date, date],
)
assert runner._engine_allowed_for_date(engine, date) is False