Files
vault-dash/app/services/backtesting/service.py
2026-03-27 18:31:28 +01:00

74 lines
3.7 KiB
Python

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}")