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

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