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

@@ -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( summary = BacktestSummaryMetrics(
start_value=scenario.initial_portfolio.start_value, start_value=scenario.initial_portfolio.start_value,
end_value_unhedged=daily_points[-1].underlying_value, end_value_unhedged=daily_points[-1].underlying_value,
@@ -99,8 +101,10 @@ class SyntheticBacktestEngine:
total_option_payoff_realized=total_option_payoff_realized, total_option_payoff_realized=total_option_payoff_realized,
max_ltv_unhedged=max(point.ltv_unhedged for point in daily_points), max_ltv_unhedged=max(point.ltv_unhedged for point in daily_points),
max_ltv_hedged=max(point.ltv_hedged 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_call_days_unhedged=margin_call_days_unhedged,
margin_threshold_breached_hedged=any(point.margin_call_hedged for point in daily_points), 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( return TemplateBacktestResult(
template_slug=template.slug, template_slug=template.slug,

View File

@@ -1,11 +1,14 @@
"""Application domain models.""" """Application domain models."""
from .event_preset import EventPreset, EventScenarioOverrides
from .option import Greeks, OptionContract, OptionMoneyness from .option import Greeks, OptionContract, OptionMoneyness
from .portfolio import LombardPortfolio from .portfolio import LombardPortfolio
from .strategy import HedgingStrategy, ScenarioResult, StrategyType from .strategy import HedgingStrategy, ScenarioResult, StrategyType
from .strategy_template import EntryPolicy, RollPolicy, StrategyTemplate, TemplateLeg from .strategy_template import EntryPolicy, RollPolicy, StrategyTemplate, TemplateLeg
__all__ = [ __all__ = [
"EventPreset",
"EventScenarioOverrides",
"Greeks", "Greeks",
"HedgingStrategy", "HedgingStrategy",
"LombardPortfolio", "LombardPortfolio",

View File

@@ -3,6 +3,8 @@ from __future__ import annotations
from dataclasses import dataclass, field from dataclasses import dataclass, field
from datetime import date from datetime import date
from app.models.event_preset import EventPreset
@dataclass(frozen=True) @dataclass(frozen=True)
class BacktestPortfolioState: class BacktestPortfolioState:
@@ -107,6 +109,8 @@ class BacktestSummaryMetrics:
total_option_payoff_realized: float total_option_payoff_realized: float
max_ltv_unhedged: float max_ltv_unhedged: float
max_ltv_hedged: float max_ltv_hedged: float
margin_call_days_unhedged: int
margin_call_days_hedged: int
margin_threshold_breached_unhedged: bool margin_threshold_breached_unhedged: bool
margin_threshold_breached_hedged: bool margin_threshold_breached_hedged: bool
@@ -125,3 +129,23 @@ class TemplateBacktestResult:
class BacktestRunResult: class BacktestRunResult:
scenario_id: str scenario_id: str
template_results: tuple[TemplateBacktestResult, ...] 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

105
app/models/event_preset.py Normal file
View File

@@ -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"])),
)

View File

@@ -1,6 +1,12 @@
"""Backtesting services and historical market-data adapters.""" """Backtesting services and historical market-data adapters."""
from .comparison import EventComparisonService
from .historical_provider import SyntheticHistoricalProvider, YFinanceHistoricalPriceSource from .historical_provider import SyntheticHistoricalProvider, YFinanceHistoricalPriceSource
from .service import BacktestService from .service import BacktestService
__all__ = ["BacktestService", "SyntheticHistoricalProvider", "YFinanceHistoricalPriceSource"] __all__ = [
"BacktestService",
"EventComparisonService",
"SyntheticHistoricalProvider",
"YFinanceHistoricalPriceSource",
]

View File

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

View File

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

70
config/event_presets.json Normal file
View File

@@ -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"
}
]
}

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