74 lines
3.7 KiB
Python
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}")
|