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

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

View File

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

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