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