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