From 8566cc203f59a76c3ddd5774e82a062eae2ef63b Mon Sep 17 00:00:00 2001 From: Bu5hm4nn Date: Tue, 24 Mar 2026 17:49:58 +0100 Subject: [PATCH] feat(BT-003): add event preset backtest comparison --- app/backtesting/engine.py | 8 +- app/models/__init__.py | 3 + app/models/backtest.py | 24 +++ app/models/event_preset.py | 105 +++++++++ app/services/backtesting/__init__.py | 8 +- app/services/backtesting/comparison.py | 126 +++++++++++ app/services/event_presets.py | 116 ++++++++++ config/event_presets.json | 70 ++++++ tests/test_event_comparison.py | 284 +++++++++++++++++++++++++ 9 files changed, 741 insertions(+), 3 deletions(-) create mode 100644 app/models/event_preset.py create mode 100644 app/services/backtesting/comparison.py create mode 100644 app/services/event_presets.py create mode 100644 config/event_presets.json create mode 100644 tests/test_event_comparison.py diff --git a/app/backtesting/engine.py b/app/backtesting/engine.py index 736b90f..447ee9b 100644 --- a/app/backtesting/engine.py +++ b/app/backtesting/engine.py @@ -91,6 +91,8 @@ class SyntheticBacktestEngine: ) ) + margin_call_days_unhedged = sum(1 for point in daily_points if point.margin_call_unhedged) + margin_call_days_hedged = sum(1 for point in daily_points if point.margin_call_hedged) summary = BacktestSummaryMetrics( start_value=scenario.initial_portfolio.start_value, end_value_unhedged=daily_points[-1].underlying_value, @@ -99,8 +101,10 @@ class SyntheticBacktestEngine: total_option_payoff_realized=total_option_payoff_realized, max_ltv_unhedged=max(point.ltv_unhedged for point in daily_points), max_ltv_hedged=max(point.ltv_hedged for point in daily_points), - margin_threshold_breached_unhedged=any(point.margin_call_unhedged for point in daily_points), - margin_threshold_breached_hedged=any(point.margin_call_hedged for point in daily_points), + margin_call_days_unhedged=margin_call_days_unhedged, + margin_call_days_hedged=margin_call_days_hedged, + margin_threshold_breached_unhedged=margin_call_days_unhedged > 0, + margin_threshold_breached_hedged=margin_call_days_hedged > 0, ) return TemplateBacktestResult( template_slug=template.slug, diff --git a/app/models/__init__.py b/app/models/__init__.py index 97df178..86665c6 100644 --- a/app/models/__init__.py +++ b/app/models/__init__.py @@ -1,11 +1,14 @@ """Application domain models.""" +from .event_preset import EventPreset, EventScenarioOverrides from .option import Greeks, OptionContract, OptionMoneyness from .portfolio import LombardPortfolio from .strategy import HedgingStrategy, ScenarioResult, StrategyType from .strategy_template import EntryPolicy, RollPolicy, StrategyTemplate, TemplateLeg __all__ = [ + "EventPreset", + "EventScenarioOverrides", "Greeks", "HedgingStrategy", "LombardPortfolio", diff --git a/app/models/backtest.py b/app/models/backtest.py index 1d7a7e6..c9c6b16 100644 --- a/app/models/backtest.py +++ b/app/models/backtest.py @@ -3,6 +3,8 @@ from __future__ import annotations from dataclasses import dataclass, field from datetime import date +from app.models.event_preset import EventPreset + @dataclass(frozen=True) class BacktestPortfolioState: @@ -107,6 +109,8 @@ class BacktestSummaryMetrics: total_option_payoff_realized: float max_ltv_unhedged: float max_ltv_hedged: float + margin_call_days_unhedged: int + margin_call_days_hedged: int margin_threshold_breached_unhedged: bool margin_threshold_breached_hedged: bool @@ -125,3 +129,23 @@ class TemplateBacktestResult: class BacktestRunResult: scenario_id: str template_results: tuple[TemplateBacktestResult, ...] + + +@dataclass(frozen=True) +class EventComparisonRanking: + rank: int + template_slug: str + template_name: str + survived_margin_call: bool + max_ltv_hedged: float + hedge_cost: float + final_equity: float + result: TemplateBacktestResult + + +@dataclass(frozen=True) +class EventComparisonReport: + event_preset: EventPreset + scenario: BacktestScenario + rankings: tuple[EventComparisonRanking, ...] + run_result: BacktestRunResult diff --git a/app/models/event_preset.py b/app/models/event_preset.py new file mode 100644 index 0000000..3d490e4 --- /dev/null +++ b/app/models/event_preset.py @@ -0,0 +1,105 @@ +from __future__ import annotations + +from dataclasses import dataclass, field +from datetime import date, datetime, timezone +from typing import Literal + +EventType = Literal["selloff", "recovery", "stress_test"] + + +@dataclass(frozen=True) +class EventScenarioOverrides: + lookback_days: int | None = None + recovery_days: int | None = None + default_template_slugs: tuple[str, ...] = field(default_factory=tuple) + + def __post_init__(self) -> None: + if self.lookback_days is not None and self.lookback_days < 0: + raise ValueError("lookback_days must be non-negative") + if self.recovery_days is not None and self.recovery_days < 0: + raise ValueError("recovery_days must be non-negative") + if any(not slug for slug in self.default_template_slugs): + raise ValueError("default_template_slugs must not contain empty values") + + def to_dict(self) -> dict[str, object]: + return { + "lookback_days": self.lookback_days, + "recovery_days": self.recovery_days, + "default_template_slugs": list(self.default_template_slugs), + } + + @classmethod + def from_dict(cls, payload: dict[str, object] | None) -> EventScenarioOverrides: + if payload is None: + return cls() + return cls( + lookback_days=payload.get("lookback_days"), # type: ignore[arg-type] + recovery_days=payload.get("recovery_days"), # type: ignore[arg-type] + default_template_slugs=tuple(payload.get("default_template_slugs", [])), # type: ignore[arg-type] + ) + + +@dataclass(frozen=True) +class EventPreset: + event_preset_id: str + slug: str + display_name: str + symbol: str + window_start: date + window_end: date + anchor_date: date | None + event_type: EventType + tags: tuple[str, ...] = field(default_factory=tuple) + description: str = "" + scenario_overrides: EventScenarioOverrides = field(default_factory=EventScenarioOverrides) + created_at: datetime = field(default_factory=lambda: datetime.now(timezone.utc)) + + def __post_init__(self) -> None: + if not self.event_preset_id: + raise ValueError("event_preset_id is required") + if not self.slug: + raise ValueError("slug is required") + if not self.display_name: + raise ValueError("display_name is required") + if not self.symbol: + raise ValueError("symbol is required") + if self.window_start > self.window_end: + raise ValueError("window_start must be on or before window_end") + if self.anchor_date is not None and not (self.window_start <= self.anchor_date <= self.window_end): + raise ValueError("anchor_date must fall inside the event window") + if self.event_type not in {"selloff", "recovery", "stress_test"}: + raise ValueError("unsupported event_type") + + def to_dict(self) -> dict[str, object]: + return { + "event_preset_id": self.event_preset_id, + "slug": self.slug, + "display_name": self.display_name, + "symbol": self.symbol, + "window_start": self.window_start.isoformat(), + "window_end": self.window_end.isoformat(), + "anchor_date": self.anchor_date.isoformat() if self.anchor_date is not None else None, + "event_type": self.event_type, + "tags": list(self.tags), + "description": self.description, + "scenario_overrides": self.scenario_overrides.to_dict(), + "created_at": self.created_at.isoformat(), + } + + @classmethod + def from_dict(cls, payload: dict[str, object]) -> EventPreset: + anchor_date = payload.get("anchor_date") + return cls( + event_preset_id=str(payload["event_preset_id"]), + slug=str(payload["slug"]), + display_name=str(payload["display_name"]), + symbol=str(payload["symbol"]), + window_start=date.fromisoformat(str(payload["window_start"])), + window_end=date.fromisoformat(str(payload["window_end"])), + anchor_date=date.fromisoformat(str(anchor_date)) if anchor_date else None, + event_type=payload["event_type"], # type: ignore[arg-type] + tags=tuple(payload.get("tags", [])), # type: ignore[arg-type] + description=str(payload.get("description", "")), + scenario_overrides=EventScenarioOverrides.from_dict(payload.get("scenario_overrides")), # type: ignore[arg-type] + created_at=datetime.fromisoformat(str(payload["created_at"])), + ) diff --git a/app/services/backtesting/__init__.py b/app/services/backtesting/__init__.py index b1cc2e1..bcf6dd9 100644 --- a/app/services/backtesting/__init__.py +++ b/app/services/backtesting/__init__.py @@ -1,6 +1,12 @@ """Backtesting services and historical market-data adapters.""" +from .comparison import EventComparisonService from .historical_provider import SyntheticHistoricalProvider, YFinanceHistoricalPriceSource from .service import BacktestService -__all__ = ["BacktestService", "SyntheticHistoricalProvider", "YFinanceHistoricalPriceSource"] +__all__ = [ + "BacktestService", + "EventComparisonService", + "SyntheticHistoricalProvider", + "YFinanceHistoricalPriceSource", +] diff --git a/app/services/backtesting/comparison.py b/app/services/backtesting/comparison.py new file mode 100644 index 0000000..70323ed --- /dev/null +++ b/app/services/backtesting/comparison.py @@ -0,0 +1,126 @@ +from __future__ import annotations + +from datetime import timedelta + +from app.models.backtest import ( + BacktestPortfolioState, + BacktestScenario, + EventComparisonRanking, + EventComparisonReport, + ProviderRef, + TemplateRef, +) +from app.models.event_preset import EventPreset +from app.services.backtesting.historical_provider import SyntheticHistoricalProvider +from app.services.backtesting.service import BacktestService +from app.services.event_presets import EventPresetService +from app.services.strategy_templates import StrategyTemplateService + + +class EventComparisonService: + def __init__( + self, + provider: SyntheticHistoricalProvider | None = None, + template_service: StrategyTemplateService | None = None, + event_preset_service: EventPresetService | None = None, + backtest_service: BacktestService | None = None, + ) -> None: + self.provider = provider or SyntheticHistoricalProvider() + self.template_service = template_service or StrategyTemplateService() + self.event_preset_service = event_preset_service or EventPresetService() + self.backtest_service = backtest_service or BacktestService( + provider=self.provider, + template_service=self.template_service, + ) + + def compare_event( + self, + *, + preset_slug: str, + initial_portfolio: BacktestPortfolioState, + template_slugs: tuple[str, ...] | None = None, + provider_ref: ProviderRef | None = None, + ) -> EventComparisonReport: + preset = self.event_preset_service.get_preset(preset_slug) + scenario = self.materialize_scenario( + preset, + initial_portfolio=initial_portfolio, + template_slugs=template_slugs, + provider_ref=provider_ref, + ) + run_result = self.backtest_service.run_scenario(scenario) + ranked_results = sorted( + run_result.template_results, + key=lambda result: ( + result.summary_metrics.margin_call_days_hedged, + result.summary_metrics.max_ltv_hedged, + result.summary_metrics.total_hedge_cost, + -result.summary_metrics.end_value_hedged_net, + result.template_slug, + ), + ) + rankings = tuple( + EventComparisonRanking( + rank=index, + template_slug=result.template_slug, + template_name=result.template_name, + survived_margin_call=not result.summary_metrics.margin_threshold_breached_hedged, + max_ltv_hedged=result.summary_metrics.max_ltv_hedged, + hedge_cost=result.summary_metrics.total_hedge_cost, + final_equity=result.summary_metrics.end_value_hedged_net, + result=result, + ) + for index, result in enumerate(ranked_results, start=1) + ) + return EventComparisonReport( + event_preset=preset, + scenario=scenario, + rankings=rankings, + run_result=run_result, + ) + + def materialize_scenario( + self, + preset: EventPreset, + *, + initial_portfolio: BacktestPortfolioState, + template_slugs: tuple[str, ...] | None = None, + provider_ref: ProviderRef | None = None, + ) -> BacktestScenario: + selected_template_slugs = tuple(template_slugs or preset.scenario_overrides.default_template_slugs) + if not selected_template_slugs: + raise ValueError("Event comparison requires at least one template slug") + + requested_start = preset.window_start - timedelta(days=preset.scenario_overrides.lookback_days or 0) + requested_end = preset.window_end + timedelta(days=preset.scenario_overrides.recovery_days or 0) + history = self.provider.load_history(preset.symbol, requested_start, requested_end) + if not history: + raise ValueError(f"No historical prices found for event preset: {preset.slug}") + + scenario_portfolio = BacktestPortfolioState( + currency=initial_portfolio.currency, + underlying_units=initial_portfolio.underlying_units, + entry_spot=history[0].close, + loan_amount=initial_portfolio.loan_amount, + margin_call_ltv=initial_portfolio.margin_call_ltv, + cash_balance=initial_portfolio.cash_balance, + financing_rate=initial_portfolio.financing_rate, + ) + template_refs = tuple( + TemplateRef(slug=slug, version=self.template_service.get_template(slug).version) + for slug in selected_template_slugs + ) + return BacktestScenario( + scenario_id=f"event-{preset.slug}", + display_name=preset.display_name, + symbol=preset.symbol, + start_date=history[0].date, + end_date=history[-1].date, + initial_portfolio=scenario_portfolio, + template_refs=template_refs, + provider_ref=provider_ref + or ProviderRef( + provider_id=self.provider.provider_id, + pricing_mode=self.provider.pricing_mode, + ), + ) diff --git a/app/services/event_presets.py b/app/services/event_presets.py new file mode 100644 index 0000000..11bd2c2 --- /dev/null +++ b/app/services/event_presets.py @@ -0,0 +1,116 @@ +from __future__ import annotations + +import json +from datetime import date +from pathlib import Path + +from app.models.event_preset import EventPreset, EventScenarioOverrides + +DEFAULT_EVENT_PRESET_FILE = Path(__file__).resolve().parents[2] / "config" / "event_presets.json" + + +def default_event_presets() -> list[EventPreset]: + return [ + EventPreset( + event_preset_id="gld-jan-2024-selloff-v1", + slug="gld-jan-2024-selloff", + display_name="GLD January 2024 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=("system", "selloff", "macro"), + description="Short January 2024 selloff window for deterministic synthetic event comparisons.", + scenario_overrides=EventScenarioOverrides( + default_template_slugs=( + "protective-put-atm-12m", + "protective-put-95pct-12m", + "protective-put-90pct-12m", + "ladder-50-50-atm-95pct-12m", + ) + ), + ), + EventPreset( + event_preset_id="gld-jan-2024-drawdown-v1", + slug="gld-jan-2024-drawdown", + display_name="GLD January 2024 Drawdown", + symbol="GLD", + window_start=date(2024, 1, 2), + window_end=date(2024, 1, 8), + anchor_date=date(2024, 1, 5), + event_type="selloff", + tags=("system", "drawdown"), + description="January 2024 drawdown preset for deterministic synthetic event comparison runs.", + scenario_overrides=EventScenarioOverrides( + lookback_days=0, + recovery_days=0, + default_template_slugs=( + "protective-put-atm-12m", + "ladder-50-50-atm-95pct-12m", + "ladder-33-33-33-atm-95pct-90pct-12m", + ), + ), + ), + EventPreset( + event_preset_id="gld-jan-2024-stress-window-v1", + slug="gld-jan-2024-stress-window", + display_name="GLD January 2024 Stress Window", + symbol="GLD", + window_start=date(2024, 1, 2), + window_end=date(2024, 1, 8), + anchor_date=None, + event_type="stress_test", + tags=("system", "stress_test"), + description="Stress-window preset with a modest warmup and recovery tail for report scaffolding.", + scenario_overrides=EventScenarioOverrides( + lookback_days=0, + recovery_days=0, + default_template_slugs=( + "protective-put-atm-12m", + "protective-put-95pct-12m", + ), + ), + ), + ] + + +class FileEventPresetRepository: + def __init__(self, path: str | Path = DEFAULT_EVENT_PRESET_FILE) -> None: + self.path = Path(path) + + def list_presets(self) -> list[EventPreset]: + self._ensure_seeded() + payload = json.loads(self.path.read_text()) + return [EventPreset.from_dict(item) for item in payload.get("presets", [])] + + def get_by_slug(self, slug: str) -> EventPreset | None: + return next((preset for preset in self.list_presets() if preset.slug == slug), None) + + def save_all(self, presets: list[EventPreset]) -> None: + self.path.parent.mkdir(parents=True, exist_ok=True) + payload = {"presets": [preset.to_dict() for preset in presets]} + self.path.write_text(json.dumps(payload, indent=2) + "\n") + + def _ensure_seeded(self) -> None: + if self.path.exists(): + return + self.save_all(default_event_presets()) + + +class EventPresetService: + def __init__(self, repository: FileEventPresetRepository | None = None) -> None: + self.repository = repository or FileEventPresetRepository() + + def list_presets(self, symbol: str | None = None) -> list[EventPreset]: + presets = self.repository.list_presets() + if symbol is None: + return presets + normalized_symbol = symbol.strip().upper() + return [preset for preset in presets if preset.symbol.strip().upper() == normalized_symbol] + + def get_preset(self, slug: str) -> EventPreset: + preset = self.repository.get_by_slug(slug) + if preset is None: + raise KeyError(f"Unknown event preset: {slug}") + return preset diff --git a/config/event_presets.json b/config/event_presets.json new file mode 100644 index 0000000..e8a5fad --- /dev/null +++ b/config/event_presets.json @@ -0,0 +1,70 @@ +{ + "presets": [ + { + "event_preset_id": "gld-jan-2024-selloff-v1", + "slug": "gld-jan-2024-selloff", + "display_name": "GLD January 2024 Selloff", + "symbol": "GLD", + "window_start": "2024-01-02", + "window_end": "2024-01-08", + "anchor_date": "2024-01-04", + "event_type": "selloff", + "tags": ["system", "selloff", "macro"], + "description": "Short January 2024 selloff window for deterministic synthetic event comparisons.", + "scenario_overrides": { + "lookback_days": null, + "recovery_days": null, + "default_template_slugs": [ + "protective-put-atm-12m", + "protective-put-95pct-12m", + "protective-put-90pct-12m", + "ladder-50-50-atm-95pct-12m" + ] + }, + "created_at": "2026-03-24T00:00:00+00:00" + }, + { + "event_preset_id": "gld-jan-2024-drawdown-v1", + "slug": "gld-jan-2024-drawdown", + "display_name": "GLD January 2024 Drawdown", + "symbol": "GLD", + "window_start": "2024-01-02", + "window_end": "2024-01-08", + "anchor_date": "2024-01-05", + "event_type": "selloff", + "tags": ["system", "drawdown"], + "description": "January 2024 drawdown preset for deterministic synthetic event comparison runs.", + "scenario_overrides": { + "lookback_days": 0, + "recovery_days": 0, + "default_template_slugs": [ + "protective-put-atm-12m", + "ladder-50-50-atm-95pct-12m", + "ladder-33-33-33-atm-95pct-90pct-12m" + ] + }, + "created_at": "2026-03-24T00:00:00+00:00" + }, + { + "event_preset_id": "gld-jan-2024-stress-window-v1", + "slug": "gld-jan-2024-stress-window", + "display_name": "GLD January 2024 Stress Window", + "symbol": "GLD", + "window_start": "2024-01-02", + "window_end": "2024-01-08", + "anchor_date": null, + "event_type": "stress_test", + "tags": ["system", "stress_test"], + "description": "Stress-window preset with a modest warmup and recovery tail for report scaffolding.", + "scenario_overrides": { + "lookback_days": 0, + "recovery_days": 0, + "default_template_slugs": [ + "protective-put-atm-12m", + "protective-put-95pct-12m" + ] + }, + "created_at": "2026-03-24T00:00:00+00:00" + } + ] +} diff --git a/tests/test_event_comparison.py b/tests/test_event_comparison.py new file mode 100644 index 0000000..2fc3686 --- /dev/null +++ b/tests/test_event_comparison.py @@ -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, + ), + )