feat(BT-003): add event preset backtest comparison

This commit is contained in:
Bu5hm4nn
2026-03-24 17:49:58 +01:00
parent d4dc34d5ab
commit 8566cc203f
9 changed files with 741 additions and 3 deletions

View 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,
),
)