feat(BT-003A): add event comparison page

This commit is contained in:
Bu5hm4nn
2026-03-24 19:20:35 +01:00
parent 68cb2aa51a
commit ff4e565ee6
8 changed files with 672 additions and 43 deletions

View File

@@ -11,7 +11,7 @@ from app.models.backtest import (
TemplateRef,
)
from app.models.event_preset import EventPreset
from app.services.backtesting.historical_provider import SyntheticHistoricalProvider
from app.services.backtesting.historical_provider import DailyClosePoint, SyntheticHistoricalProvider
from app.services.backtesting.service import BacktestService
from app.services.event_presets import EventPresetService
from app.services.strategy_templates import StrategyTemplateService
@@ -48,6 +48,111 @@ class EventComparisonService:
template_slugs=template_slugs,
provider_ref=provider_ref,
)
return self._compare_materialized_event(preset=preset, scenario=scenario)
def compare_event_from_inputs(
self,
*,
preset_slug: str,
underlying_units: float,
loan_amount: float,
margin_call_ltv: float,
template_slugs: tuple[str, ...] | None = None,
currency: str = "USD",
cash_balance: float = 0.0,
financing_rate: float = 0.0,
provider_ref: ProviderRef | None = None,
) -> EventComparisonReport:
preset = self.event_preset_service.get_preset(preset_slug)
scenario = self.preview_scenario_from_inputs(
preset_slug=preset_slug,
underlying_units=underlying_units,
loan_amount=loan_amount,
margin_call_ltv=margin_call_ltv,
template_slugs=template_slugs,
currency=currency,
cash_balance=cash_balance,
financing_rate=financing_rate,
provider_ref=provider_ref,
)
return self._compare_materialized_event(preset=preset, scenario=scenario)
def preview_scenario_from_inputs(
self,
*,
preset_slug: str,
underlying_units: float,
loan_amount: float,
margin_call_ltv: float,
template_slugs: tuple[str, ...] | None = None,
currency: str = "USD",
cash_balance: float = 0.0,
financing_rate: float = 0.0,
provider_ref: ProviderRef | None = None,
) -> BacktestScenario:
preset = self.event_preset_service.get_preset(preset_slug)
history = self._load_preset_history(preset)
entry_spot = history[0].close
initial_portfolio = BacktestPortfolioState(
currency=currency,
underlying_units=underlying_units,
entry_spot=entry_spot,
loan_amount=loan_amount,
margin_call_ltv=margin_call_ltv,
cash_balance=cash_balance,
financing_rate=financing_rate,
)
return self.materialize_scenario(
preset,
initial_portfolio=initial_portfolio,
template_slugs=template_slugs,
provider_ref=provider_ref,
history=history,
)
def materialize_scenario(
self,
preset: EventPreset,
*,
initial_portfolio: BacktestPortfolioState,
template_slugs: tuple[str, ...] | None = None,
provider_ref: ProviderRef | None = None,
history: list[DailyClosePoint] | 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")
resolved_history = history or self._load_preset_history(preset)
scenario_portfolio = BacktestPortfolioState(
currency=initial_portfolio.currency,
underlying_units=initial_portfolio.underlying_units,
entry_spot=resolved_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=resolved_history[0].date,
end_date=resolved_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,
),
)
def _compare_materialized_event(self, *, preset: EventPreset, scenario: BacktestScenario) -> EventComparisonReport:
run_result = self.backtest_service.run_scenario(scenario)
ranked_results = sorted(
run_result.template_results,
@@ -65,6 +170,7 @@ class EventComparisonService:
template_slug=result.template_slug,
template_name=result.template_name,
survived_margin_call=not result.summary_metrics.margin_threshold_breached_hedged,
margin_call_days_hedged=result.summary_metrics.margin_call_days_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,
@@ -79,48 +185,10 @@ class EventComparisonService:
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")
def _load_preset_history(self, preset: EventPreset) -> list[DailyClosePoint]:
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,
),
)
return history

View File

@@ -0,0 +1,164 @@
from __future__ import annotations
from dataclasses import dataclass
from datetime import date
from app.models.backtest import BacktestScenario, EventComparisonReport
from app.services.backtesting.comparison import EventComparisonService
from app.services.backtesting.historical_provider import DailyClosePoint, SyntheticHistoricalProvider
from app.services.event_presets import EventPresetService
from app.services.strategy_templates import StrategyTemplateService
SUPPORTED_EVENT_COMPARISON_SYMBOL = "GLD"
DETERMINISTIC_EVENT_COMPARISON_FIXTURE_HISTORY: tuple[DailyClosePoint, ...] = (
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),
DailyClosePoint(date=date(2024, 1, 8), close=85.0),
)
FIXTURE_HISTORY_START = DETERMINISTIC_EVENT_COMPARISON_FIXTURE_HISTORY[0].date
FIXTURE_HISTORY_END = DETERMINISTIC_EVENT_COMPARISON_FIXTURE_HISTORY[-1].date
class EventComparisonFixtureHistoricalPriceSource:
def load_daily_closes(self, symbol: str, start_date: date, end_date: date) -> list[DailyClosePoint]:
normalized_symbol = symbol.strip().upper()
if normalized_symbol != SUPPORTED_EVENT_COMPARISON_SYMBOL:
raise ValueError(
"BT-003A deterministic fixture data only supports GLD event-comparison presets on this page"
)
if start_date < FIXTURE_HISTORY_START or end_date > FIXTURE_HISTORY_END:
raise ValueError(
"BT-003A deterministic fixture data only supports the seeded 2024-01-02 through 2024-01-08 window"
)
return [
point for point in DETERMINISTIC_EVENT_COMPARISON_FIXTURE_HISTORY if start_date <= point.date <= end_date
]
@dataclass(frozen=True)
class EventComparisonChartSeries:
name: str
values: tuple[float, ...]
@dataclass(frozen=True)
class EventComparisonChartModel:
dates: tuple[str, ...]
series: tuple[EventComparisonChartSeries, ...]
class EventComparisonPageService:
def __init__(
self,
comparison_service: EventComparisonService | None = None,
event_preset_service: EventPresetService | None = None,
template_service: StrategyTemplateService | None = None,
) -> None:
self.event_preset_service = event_preset_service or EventPresetService()
self.template_service = template_service or StrategyTemplateService()
if comparison_service is None:
provider = SyntheticHistoricalProvider(source=EventComparisonFixtureHistoricalPriceSource())
comparison_service = EventComparisonService(
provider=provider,
event_preset_service=self.event_preset_service,
template_service=self.template_service,
)
self.comparison_service = comparison_service
def preset_options(self, symbol: str = SUPPORTED_EVENT_COMPARISON_SYMBOL) -> list[dict[str, object]]:
return [
{
"slug": preset.slug,
"label": preset.display_name,
"description": preset.description,
"default_template_slugs": list(preset.scenario_overrides.default_template_slugs),
}
for preset in self.event_preset_service.list_presets(symbol)
]
def template_options(self, symbol: str = SUPPORTED_EVENT_COMPARISON_SYMBOL) -> list[dict[str, object]]:
return [
{
"slug": template.slug,
"label": template.display_name,
"description": template.description,
}
for template in self.template_service.list_active_templates(symbol)
]
def default_template_selection(self, preset_slug: str) -> tuple[str, ...]:
preset = self.event_preset_service.get_preset(preset_slug)
return tuple(preset.scenario_overrides.default_template_slugs)
def preview_scenario(
self,
*,
preset_slug: str,
template_slugs: tuple[str, ...],
underlying_units: float,
loan_amount: float,
margin_call_ltv: float,
) -> BacktestScenario:
return self.comparison_service.preview_scenario_from_inputs(
preset_slug=preset_slug,
template_slugs=template_slugs,
underlying_units=underlying_units,
loan_amount=loan_amount,
margin_call_ltv=margin_call_ltv,
)
def run_read_only_comparison(
self,
*,
preset_slug: str,
template_slugs: tuple[str, ...],
underlying_units: float,
loan_amount: float,
margin_call_ltv: float,
) -> EventComparisonReport:
if not preset_slug:
raise ValueError("Preset selection is required")
if underlying_units <= 0:
raise ValueError("Underlying units must be positive")
if loan_amount < 0:
raise ValueError("Loan amount must be non-negative")
if not 0 < margin_call_ltv < 1:
raise ValueError("Margin call LTV must be between 0 and 1")
preset = self.event_preset_service.get_preset(preset_slug)
normalized_symbol = preset.symbol.strip().upper()
if normalized_symbol != SUPPORTED_EVENT_COMPARISON_SYMBOL:
raise ValueError("BT-003A event comparison is currently limited to GLD on this page")
return self.comparison_service.compare_event_from_inputs(
preset_slug=preset.slug,
template_slugs=tuple(template_slugs or preset.scenario_overrides.default_template_slugs),
underlying_units=underlying_units,
loan_amount=loan_amount,
margin_call_ltv=margin_call_ltv,
)
@staticmethod
def chart_model(report: EventComparisonReport, max_ranked_series: int = 3) -> EventComparisonChartModel:
ranked = report.rankings[:max_ranked_series]
if not ranked:
return EventComparisonChartModel(dates=(), series=())
dates = tuple(point.date.isoformat() for point in ranked[0].result.daily_path)
series = [
EventComparisonChartSeries(
name="Unhedged collateral baseline",
values=tuple(round(point.underlying_value, 2) for point in ranked[0].result.daily_path),
)
]
for item in ranked:
series.append(
EventComparisonChartSeries(
name=item.template_name,
values=tuple(round(point.net_portfolio_value, 2) for point in item.result.daily_path),
)
)
return EventComparisonChartModel(dates=dates, series=tuple(series))