Files
vault-dash/app/services/backtesting/comparison.py

219 lines
9.0 KiB
Python

from __future__ import annotations
from datetime import timedelta
from app.domain.backtesting_math import materialize_backtest_portfolio_state
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 DailyClosePoint, SyntheticHistoricalProvider
from app.services.backtesting.input_normalization import normalize_historical_scenario_inputs
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,
)
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:
normalized_inputs = normalize_historical_scenario_inputs(
underlying_units=underlying_units,
loan_amount=loan_amount,
margin_call_ltv=margin_call_ltv,
currency=currency,
cash_balance=cash_balance,
financing_rate=financing_rate,
)
preset = self.event_preset_service.get_preset(preset_slug)
scenario = self.preview_scenario_from_inputs(
preset_slug=preset_slug,
underlying_units=normalized_inputs.underlying_units,
loan_amount=normalized_inputs.loan_amount,
margin_call_ltv=normalized_inputs.margin_call_ltv,
template_slugs=template_slugs,
currency=normalized_inputs.currency,
cash_balance=normalized_inputs.cash_balance,
financing_rate=normalized_inputs.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:
normalized_inputs = normalize_historical_scenario_inputs(
underlying_units=underlying_units,
loan_amount=loan_amount,
margin_call_ltv=margin_call_ltv,
currency=currency,
cash_balance=cash_balance,
financing_rate=financing_rate,
)
preset = self.event_preset_service.get_preset(preset_slug)
history = self._load_preset_history(preset)
entry_spot = history[0].close
initial_portfolio = materialize_backtest_portfolio_state(
symbol=preset.symbol,
underlying_units=normalized_inputs.underlying_units,
entry_spot=entry_spot,
loan_amount=normalized_inputs.loan_amount,
margin_call_ltv=normalized_inputs.margin_call_ltv,
currency=normalized_inputs.currency,
cash_balance=normalized_inputs.cash_balance,
financing_rate=normalized_inputs.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(preset.scenario_overrides.default_template_slugs) if template_slugs is None else tuple(template_slugs)
)
if not selected_template_slugs:
raise ValueError("Event comparison requires at least one template slug")
resolved_history = self._load_preset_history(preset) if history is None else history
if not resolved_history:
raise ValueError("Event comparison history must not be empty")
scenario_portfolio = materialize_backtest_portfolio_state(
symbol=preset.symbol,
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,
currency=initial_portfolio.currency,
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,
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,
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,
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 _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}")
return history