feat(BT-001): add synthetic historical backtesting engine
This commit is contained in:
6
app/services/backtesting/__init__.py
Normal file
6
app/services/backtesting/__init__.py
Normal file
@@ -0,0 +1,6 @@
|
||||
"""Backtesting services and historical market-data adapters."""
|
||||
|
||||
from .historical_provider import SyntheticHistoricalProvider, YFinanceHistoricalPriceSource
|
||||
from .service import BacktestService
|
||||
|
||||
__all__ = ["BacktestService", "SyntheticHistoricalProvider", "YFinanceHistoricalPriceSource"]
|
||||
134
app/services/backtesting/historical_provider.py
Normal file
134
app/services/backtesting/historical_provider.py
Normal file
@@ -0,0 +1,134 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
from datetime import date, timedelta
|
||||
from typing import Protocol
|
||||
|
||||
from app.models.backtest import ProviderRef
|
||||
|
||||
try:
|
||||
import yfinance as yf
|
||||
except ImportError: # pragma: no cover - optional in tests
|
||||
yf = None
|
||||
|
||||
from app.core.pricing.black_scholes import BlackScholesInputs, black_scholes_price_and_greeks
|
||||
from app.models.strategy_template import TemplateLeg
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class DailyClosePoint:
|
||||
date: date
|
||||
close: float
|
||||
|
||||
def __post_init__(self) -> None:
|
||||
if self.close <= 0:
|
||||
raise ValueError("close must be positive")
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class SyntheticOptionQuote:
|
||||
position_id: str
|
||||
leg_id: str
|
||||
spot: float
|
||||
strike: float
|
||||
expiry: date
|
||||
quantity: float
|
||||
mark: float
|
||||
|
||||
|
||||
class HistoricalPriceSource(Protocol):
|
||||
def load_daily_closes(self, symbol: str, start_date: date, end_date: date) -> list[DailyClosePoint]:
|
||||
raise NotImplementedError
|
||||
|
||||
|
||||
class YFinanceHistoricalPriceSource:
|
||||
def load_daily_closes(self, symbol: str, start_date: date, end_date: date) -> list[DailyClosePoint]:
|
||||
if yf is None:
|
||||
raise RuntimeError("yfinance is required to load historical backtest prices")
|
||||
ticker = yf.Ticker(symbol)
|
||||
inclusive_end_date = end_date + timedelta(days=1)
|
||||
history = ticker.history(start=start_date.isoformat(), end=inclusive_end_date.isoformat(), interval="1d")
|
||||
rows: list[DailyClosePoint] = []
|
||||
for index, row in history.iterrows():
|
||||
close = row.get("Close")
|
||||
if close is None:
|
||||
continue
|
||||
rows.append(DailyClosePoint(date=index.date(), close=float(close)))
|
||||
return rows
|
||||
|
||||
|
||||
class SyntheticHistoricalProvider:
|
||||
provider_id = "synthetic_v1"
|
||||
pricing_mode = "synthetic_bs_mid"
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
source: HistoricalPriceSource | None = None,
|
||||
implied_volatility: float = 0.16,
|
||||
risk_free_rate: float = 0.045,
|
||||
) -> None:
|
||||
if implied_volatility <= 0:
|
||||
raise ValueError("implied_volatility must be positive")
|
||||
self.source = source or YFinanceHistoricalPriceSource()
|
||||
self.implied_volatility = implied_volatility
|
||||
self.risk_free_rate = risk_free_rate
|
||||
|
||||
def load_history(self, symbol: str, start_date: date, end_date: date) -> list[DailyClosePoint]:
|
||||
rows = self.source.load_daily_closes(symbol, start_date, end_date)
|
||||
filtered = [row for row in rows if start_date <= row.date <= end_date]
|
||||
return sorted(filtered, key=lambda row: row.date)
|
||||
|
||||
def validate_provider_ref(self, provider_ref: ProviderRef) -> None:
|
||||
if provider_ref.provider_id != self.provider_id or provider_ref.pricing_mode != self.pricing_mode:
|
||||
raise ValueError(
|
||||
"Unsupported provider/pricing combination for synthetic MVP engine: "
|
||||
f"{provider_ref.provider_id}/{provider_ref.pricing_mode}"
|
||||
)
|
||||
|
||||
def resolve_expiry(self, trading_days: list[DailyClosePoint], as_of_date: date, target_expiry_days: int) -> date:
|
||||
target_date = date.fromordinal(as_of_date.toordinal() + target_expiry_days)
|
||||
for day in trading_days:
|
||||
if day.date >= target_date:
|
||||
return day.date
|
||||
return target_date
|
||||
|
||||
def price_option(
|
||||
self,
|
||||
*,
|
||||
position_id: str,
|
||||
leg: TemplateLeg,
|
||||
spot: float,
|
||||
strike: float,
|
||||
expiry: date,
|
||||
quantity: float,
|
||||
valuation_date: date,
|
||||
) -> SyntheticOptionQuote:
|
||||
remaining_days = max(1, expiry.toordinal() - valuation_date.toordinal())
|
||||
mark = black_scholes_price_and_greeks(
|
||||
BlackScholesInputs(
|
||||
spot=spot,
|
||||
strike=strike,
|
||||
time_to_expiry=remaining_days / 365.0,
|
||||
risk_free_rate=self.risk_free_rate,
|
||||
volatility=self.implied_volatility,
|
||||
option_type=leg.option_type,
|
||||
valuation_date=valuation_date,
|
||||
)
|
||||
).price
|
||||
return SyntheticOptionQuote(
|
||||
position_id=position_id,
|
||||
leg_id=leg.leg_id,
|
||||
spot=spot,
|
||||
strike=strike,
|
||||
expiry=expiry,
|
||||
quantity=quantity,
|
||||
mark=mark,
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def intrinsic_value(*, option_type: str, spot: float, strike: float) -> float:
|
||||
if option_type == "put":
|
||||
return max(strike - spot, 0.0)
|
||||
if option_type == "call":
|
||||
return max(spot - strike, 0.0)
|
||||
raise ValueError(f"Unsupported option type: {option_type}")
|
||||
73
app/services/backtesting/service.py
Normal file
73
app/services/backtesting/service.py
Normal file
@@ -0,0 +1,73 @@
|
||||
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 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: SyntheticHistoricalProvider | 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))
|
||||
|
||||
@staticmethod
|
||||
def _validate_template_for_mvp(template: StrategyTemplate) -> None:
|
||||
if template.contract_mode != "continuous_units":
|
||||
raise ValueError(f"Unsupported contract_mode for synthetic MVP engine: {template.contract_mode}")
|
||||
if template.roll_policy.policy_type != "hold_to_expiry":
|
||||
raise ValueError("Unsupported roll_policy for synthetic MVP engine: " f"{template.roll_policy.policy_type}")
|
||||
if template.entry_policy.entry_timing != "scenario_start_close":
|
||||
raise ValueError(
|
||||
"Unsupported entry_timing for synthetic MVP engine: " f"{template.entry_policy.entry_timing}"
|
||||
)
|
||||
if template.entry_policy.stagger_days is not None:
|
||||
raise ValueError("Unsupported entry_policy configuration for synthetic MVP engine")
|
||||
Reference in New Issue
Block a user