feat(BT-003): add event preset backtest comparison
This commit is contained in:
@@ -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,
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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
105
app/models/event_preset.py
Normal 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"])),
|
||||
)
|
||||
@@ -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",
|
||||
]
|
||||
|
||||
126
app/services/backtesting/comparison.py
Normal file
126
app/services/backtesting/comparison.py
Normal 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,
|
||||
),
|
||||
)
|
||||
116
app/services/event_presets.py
Normal file
116
app/services/event_presets.py
Normal 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
|
||||
Reference in New Issue
Block a user