from __future__ import annotations from math import isclose from app.backtesting.engine import SyntheticBacktestEngine from app.models.backtest import BacktestRunResult, BacktestScenario from app.models.strategy_template import StrategyTemplate from app.services.backtesting.historical_provider import BacktestHistoricalProvider, SyntheticHistoricalProvider from app.services.strategy_templates import StrategyTemplateService class BacktestService: ENTRY_SPOT_ABS_TOLERANCE = 0.01 ENTRY_SPOT_REL_TOLERANCE = 1e-6 def __init__( self, provider: BacktestHistoricalProvider | None = None, template_service: StrategyTemplateService | None = None, ) -> None: self.provider = provider or SyntheticHistoricalProvider() self.template_service = template_service or StrategyTemplateService() self.engine = SyntheticBacktestEngine(self.provider) def run_scenario(self, scenario: BacktestScenario) -> BacktestRunResult: self.provider.validate_provider_ref(scenario.provider_ref) scenario_symbol = scenario.symbol.strip().upper() history = self.provider.load_history(scenario_symbol, scenario.start_date, scenario.end_date) if not history: raise ValueError("No historical prices found for scenario window") if history[0].date != scenario.start_date: raise ValueError( "Scenario start_date must match the first available historical price point for " "entry_timing='scenario_start_close'" ) if not isclose( scenario.initial_portfolio.entry_spot, history[0].close, rel_tol=self.ENTRY_SPOT_REL_TOLERANCE, abs_tol=self.ENTRY_SPOT_ABS_TOLERANCE, ): raise ValueError( "initial_portfolio.entry_spot must match the first historical close used for entry " "when entry_timing='scenario_start_close'" ) template_results = [] for template_ref in scenario.template_refs: template = self.template_service.get_template(template_ref.slug) if template.version != template_ref.version: raise ValueError( f"Template version mismatch for {template_ref.slug}: expected {template_ref.version}, got {template.version}" ) template_symbol = template.underlying_symbol.strip().upper() if template_symbol not in {scenario_symbol, "*"}: raise ValueError(f"Template {template.slug} does not support symbol {scenario_symbol}") self._validate_template_for_mvp(template) template_results.append(self.engine.run_template(scenario, template, history)) return BacktestRunResult(scenario_id=scenario.scenario_id, template_results=tuple(template_results)) def _validate_template_for_mvp(self, template: StrategyTemplate) -> None: provider_label = ( "historical snapshot engine" if self.provider.pricing_mode == "snapshot_mid" else "synthetic MVP engine" ) if template.contract_mode != "continuous_units": raise ValueError(f"Unsupported contract_mode for {provider_label}: {template.contract_mode}") if template.roll_policy.policy_type != "hold_to_expiry": raise ValueError(f"Unsupported roll_policy for {provider_label}: {template.roll_policy.policy_type}") if template.entry_policy.entry_timing != "scenario_start_close": raise ValueError(f"Unsupported entry_timing for {provider_label}: {template.entry_policy.entry_timing}") if template.entry_policy.stagger_days is not None: raise ValueError(f"Unsupported entry_policy configuration for {provider_label}")