feat(BT-003): add event preset backtest comparison
This commit is contained in:
284
tests/test_event_comparison.py
Normal file
284
tests/test_event_comparison.py
Normal file
@@ -0,0 +1,284 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import date, datetime, timezone
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
from app.models.backtest import (
|
||||
BacktestPortfolioState,
|
||||
BacktestRunResult,
|
||||
BacktestSummaryMetrics,
|
||||
TemplateBacktestResult,
|
||||
)
|
||||
from app.models.event_preset import EventPreset, EventScenarioOverrides
|
||||
from app.services.backtesting.comparison import EventComparisonService
|
||||
from app.services.backtesting.historical_provider import DailyClosePoint, SyntheticHistoricalProvider
|
||||
from app.services.event_presets import EventPresetService, FileEventPresetRepository
|
||||
from app.services.strategy_templates import StrategyTemplateService
|
||||
|
||||
|
||||
class FakeHistorySource:
|
||||
def __init__(self, rows: list[DailyClosePoint]) -> None:
|
||||
self.rows = rows
|
||||
self.calls: list[tuple[str, date, date]] = []
|
||||
|
||||
def load_daily_closes(self, symbol: str, start_date: date, end_date: date) -> list[DailyClosePoint]:
|
||||
self.calls.append((symbol, start_date, end_date))
|
||||
return list(self.rows)
|
||||
|
||||
|
||||
class FakeBacktestService:
|
||||
def __init__(self, results: tuple[TemplateBacktestResult, ...]) -> None:
|
||||
self.results = results
|
||||
|
||||
def run_scenario(self, scenario) -> BacktestRunResult:
|
||||
return BacktestRunResult(scenario_id=scenario.scenario_id, template_results=self.results)
|
||||
|
||||
|
||||
FIXTURE_HISTORY = [
|
||||
DailyClosePoint(date=date(2024, 1, 8), close=85.0),
|
||||
DailyClosePoint(date=date(2024, 1, 2), close=100.0),
|
||||
DailyClosePoint(date=date(2024, 1, 3), close=96.0),
|
||||
DailyClosePoint(date=date(2024, 1, 4), close=92.0),
|
||||
DailyClosePoint(date=date(2024, 1, 5), close=88.0),
|
||||
]
|
||||
|
||||
|
||||
def test_file_event_preset_repository_seeds_default_presets(tmp_path: Path) -> None:
|
||||
repository = FileEventPresetRepository(tmp_path / "event_presets.json")
|
||||
|
||||
presets = repository.list_presets()
|
||||
|
||||
assert [preset.slug for preset in presets] == [
|
||||
"gld-jan-2024-selloff",
|
||||
"gld-jan-2024-drawdown",
|
||||
"gld-jan-2024-stress-window",
|
||||
]
|
||||
assert (tmp_path / "event_presets.json").exists() is True
|
||||
|
||||
|
||||
def test_event_comparison_materializes_scenario_from_preset_overrides() -> None:
|
||||
preset = EventPreset(
|
||||
event_preset_id="preset-1",
|
||||
slug="jan-selloff",
|
||||
display_name="January Selloff",
|
||||
symbol="GLD",
|
||||
window_start=date(2024, 1, 3),
|
||||
window_end=date(2024, 1, 5),
|
||||
anchor_date=date(2024, 1, 4),
|
||||
event_type="selloff",
|
||||
tags=("macro",),
|
||||
description="Test event window.",
|
||||
scenario_overrides=EventScenarioOverrides(
|
||||
lookback_days=1,
|
||||
recovery_days=3,
|
||||
default_template_slugs=("protective-put-atm-12m", "protective-put-95pct-12m"),
|
||||
),
|
||||
created_at=datetime(2026, 3, 24, tzinfo=timezone.utc),
|
||||
)
|
||||
provider = SyntheticHistoricalProvider(
|
||||
source=FakeHistorySource(FIXTURE_HISTORY),
|
||||
implied_volatility=0.35,
|
||||
risk_free_rate=0.0,
|
||||
)
|
||||
service = EventComparisonService(
|
||||
provider=provider,
|
||||
template_service=StrategyTemplateService(),
|
||||
event_preset_service=EventPresetService(repository=FileEventPresetRepository()),
|
||||
)
|
||||
|
||||
scenario = service.materialize_scenario(
|
||||
preset,
|
||||
initial_portfolio=BacktestPortfolioState(
|
||||
currency="USD",
|
||||
underlying_units=1000.0,
|
||||
entry_spot=130.0,
|
||||
loan_amount=68_000.0,
|
||||
margin_call_ltv=0.75,
|
||||
),
|
||||
)
|
||||
|
||||
assert scenario.scenario_id == "event-jan-selloff"
|
||||
assert scenario.display_name == "January Selloff"
|
||||
assert scenario.start_date == date(2024, 1, 2)
|
||||
assert scenario.end_date == date(2024, 1, 8)
|
||||
assert scenario.initial_portfolio.entry_spot == 100.0
|
||||
assert scenario.initial_portfolio.start_value == 100_000.0
|
||||
assert [ref.slug for ref in scenario.template_refs] == [
|
||||
"protective-put-atm-12m",
|
||||
"protective-put-95pct-12m",
|
||||
]
|
||||
|
||||
|
||||
def test_event_comparison_ranks_templates_by_survival_max_ltv_cost_then_final_equity(tmp_path: Path) -> None:
|
||||
repository = FileEventPresetRepository(tmp_path / "event_presets.json")
|
||||
repository.save_all(
|
||||
[
|
||||
EventPreset(
|
||||
event_preset_id="preset-1",
|
||||
slug="jan-selloff",
|
||||
display_name="January Selloff",
|
||||
symbol="GLD",
|
||||
window_start=date(2024, 1, 2),
|
||||
window_end=date(2024, 1, 8),
|
||||
anchor_date=date(2024, 1, 4),
|
||||
event_type="selloff",
|
||||
tags=("macro",),
|
||||
description="Test event window.",
|
||||
scenario_overrides=EventScenarioOverrides(
|
||||
default_template_slugs=(
|
||||
"protective-put-atm-12m",
|
||||
"ladder-50-50-atm-95pct-12m",
|
||||
"protective-put-95pct-12m",
|
||||
"ladder-33-33-33-atm-95pct-90pct-12m",
|
||||
"protective-put-90pct-12m",
|
||||
)
|
||||
),
|
||||
created_at=datetime(2026, 3, 24, tzinfo=timezone.utc),
|
||||
)
|
||||
]
|
||||
)
|
||||
provider = SyntheticHistoricalProvider(
|
||||
source=FakeHistorySource(FIXTURE_HISTORY),
|
||||
implied_volatility=0.35,
|
||||
risk_free_rate=0.0,
|
||||
)
|
||||
service = EventComparisonService(
|
||||
provider=provider,
|
||||
template_service=StrategyTemplateService(),
|
||||
event_preset_service=EventPresetService(repository=repository),
|
||||
)
|
||||
|
||||
report = service.compare_event(
|
||||
preset_slug="jan-selloff",
|
||||
initial_portfolio=BacktestPortfolioState(
|
||||
currency="USD",
|
||||
underlying_units=1000.0,
|
||||
entry_spot=100.0,
|
||||
loan_amount=68_000.0,
|
||||
margin_call_ltv=0.75,
|
||||
),
|
||||
)
|
||||
|
||||
assert report.event_preset.slug == "jan-selloff"
|
||||
assert [item.template_slug for item in report.rankings] == [
|
||||
"protective-put-atm-12m",
|
||||
"ladder-50-50-atm-95pct-12m",
|
||||
"protective-put-95pct-12m",
|
||||
"ladder-33-33-33-atm-95pct-90pct-12m",
|
||||
"protective-put-90pct-12m",
|
||||
]
|
||||
assert [item.rank for item in report.rankings] == [1, 2, 3, 4, 5]
|
||||
assert all(item.survived_margin_call is True for item in report.rankings)
|
||||
assert report.rankings[0].max_ltv_hedged < report.rankings[-1].max_ltv_hedged
|
||||
assert report.rankings[0].hedge_cost > report.rankings[-1].hedge_cost
|
||||
|
||||
|
||||
def test_event_comparison_ranking_prioritizes_fewer_hedged_margin_call_days() -> None:
|
||||
provider = SyntheticHistoricalProvider(
|
||||
source=FakeHistorySource(FIXTURE_HISTORY),
|
||||
implied_volatility=0.35,
|
||||
risk_free_rate=0.0,
|
||||
)
|
||||
summary_with_one_bad_day = BacktestSummaryMetrics(
|
||||
start_value=100_000.0,
|
||||
end_value_unhedged=85_000.0,
|
||||
end_value_hedged_net=90_000.0,
|
||||
total_hedge_cost=1_500.0,
|
||||
total_option_payoff_realized=0.0,
|
||||
max_ltv_unhedged=0.80,
|
||||
max_ltv_hedged=0.77,
|
||||
margin_call_days_unhedged=3,
|
||||
margin_call_days_hedged=1,
|
||||
margin_threshold_breached_unhedged=True,
|
||||
margin_threshold_breached_hedged=True,
|
||||
)
|
||||
summary_with_two_bad_days = BacktestSummaryMetrics(
|
||||
start_value=100_000.0,
|
||||
end_value_unhedged=85_000.0,
|
||||
end_value_hedged_net=92_000.0,
|
||||
total_hedge_cost=1_000.0,
|
||||
total_option_payoff_realized=0.0,
|
||||
max_ltv_unhedged=0.80,
|
||||
max_ltv_hedged=0.76,
|
||||
margin_call_days_unhedged=3,
|
||||
margin_call_days_hedged=2,
|
||||
margin_threshold_breached_unhedged=True,
|
||||
margin_threshold_breached_hedged=True,
|
||||
)
|
||||
service = EventComparisonService(
|
||||
provider=provider,
|
||||
template_service=StrategyTemplateService(),
|
||||
event_preset_service=EventPresetService(repository=FileEventPresetRepository()),
|
||||
backtest_service=FakeBacktestService(
|
||||
results=(
|
||||
TemplateBacktestResult(
|
||||
template_slug="one-bad-day",
|
||||
template_id="template-1",
|
||||
template_version=1,
|
||||
template_name="One bad day",
|
||||
summary_metrics=summary_with_one_bad_day,
|
||||
daily_path=(),
|
||||
),
|
||||
TemplateBacktestResult(
|
||||
template_slug="two-bad-days",
|
||||
template_id="template-2",
|
||||
template_version=1,
|
||||
template_name="Two bad days",
|
||||
summary_metrics=summary_with_two_bad_days,
|
||||
daily_path=(),
|
||||
),
|
||||
)
|
||||
),
|
||||
)
|
||||
|
||||
report = service.compare_event(
|
||||
preset_slug="gld-jan-2024-selloff",
|
||||
initial_portfolio=BacktestPortfolioState(
|
||||
currency="USD",
|
||||
underlying_units=1000.0,
|
||||
entry_spot=130.0,
|
||||
loan_amount=68_000.0,
|
||||
margin_call_ltv=0.75,
|
||||
),
|
||||
template_slugs=("protective-put-atm-12m", "protective-put-95pct-12m"),
|
||||
)
|
||||
|
||||
assert [item.template_slug for item in report.rankings] == ["one-bad-day", "two-bad-days"]
|
||||
assert [item.result.summary_metrics.margin_call_days_hedged for item in report.rankings] == [1, 2]
|
||||
|
||||
|
||||
def test_event_comparison_requires_template_selection() -> None:
|
||||
preset = EventPreset(
|
||||
event_preset_id="preset-2",
|
||||
slug="empty-templates",
|
||||
display_name="Empty Templates",
|
||||
symbol="GLD",
|
||||
window_start=date(2024, 1, 2),
|
||||
window_end=date(2024, 1, 8),
|
||||
anchor_date=None,
|
||||
event_type="selloff",
|
||||
tags=(),
|
||||
description="No templates configured.",
|
||||
scenario_overrides=EventScenarioOverrides(),
|
||||
created_at=datetime(2026, 3, 24, tzinfo=timezone.utc),
|
||||
)
|
||||
provider = SyntheticHistoricalProvider(
|
||||
source=FakeHistorySource(FIXTURE_HISTORY),
|
||||
implied_volatility=0.35,
|
||||
risk_free_rate=0.0,
|
||||
)
|
||||
service = EventComparisonService(provider=provider, template_service=StrategyTemplateService())
|
||||
|
||||
with pytest.raises(ValueError, match="at least one template slug"):
|
||||
service.materialize_scenario(
|
||||
preset,
|
||||
initial_portfolio=BacktestPortfolioState(
|
||||
currency="USD",
|
||||
underlying_units=1000.0,
|
||||
entry_spot=100.0,
|
||||
loan_amount=68_000.0,
|
||||
margin_call_ltv=0.75,
|
||||
),
|
||||
)
|
||||
Reference in New Issue
Block a user