feat(BT-001): add synthetic historical backtesting engine
This commit is contained in:
5
app/backtesting/__init__.py
Normal file
5
app/backtesting/__init__.py
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
"""Backtesting subsystem for historical hedge simulation."""
|
||||||
|
|
||||||
|
from .engine import SyntheticBacktestEngine
|
||||||
|
|
||||||
|
__all__ = ["SyntheticBacktestEngine"]
|
||||||
146
app/backtesting/engine.py
Normal file
146
app/backtesting/engine.py
Normal file
@@ -0,0 +1,146 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from dataclasses import dataclass
|
||||||
|
from datetime import date
|
||||||
|
|
||||||
|
from app.models.backtest import (
|
||||||
|
BacktestDailyPoint,
|
||||||
|
BacktestScenario,
|
||||||
|
BacktestSummaryMetrics,
|
||||||
|
TemplateBacktestResult,
|
||||||
|
)
|
||||||
|
from app.models.strategy_template import StrategyTemplate, TemplateLeg
|
||||||
|
from app.services.backtesting.historical_provider import DailyClosePoint, SyntheticHistoricalProvider
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class OpenSyntheticPosition:
|
||||||
|
position_id: str
|
||||||
|
leg: TemplateLeg
|
||||||
|
strike: float
|
||||||
|
expiry: date
|
||||||
|
quantity: float
|
||||||
|
|
||||||
|
|
||||||
|
class SyntheticBacktestEngine:
|
||||||
|
def __init__(self, provider: SyntheticHistoricalProvider) -> None:
|
||||||
|
self.provider = provider
|
||||||
|
|
||||||
|
def run_template(
|
||||||
|
self,
|
||||||
|
scenario: BacktestScenario,
|
||||||
|
template: StrategyTemplate,
|
||||||
|
history: list[DailyClosePoint],
|
||||||
|
) -> TemplateBacktestResult:
|
||||||
|
start_day = history[0]
|
||||||
|
cash_balance = scenario.initial_portfolio.cash_balance
|
||||||
|
total_hedge_cost = 0.0
|
||||||
|
total_option_payoff_realized = 0.0
|
||||||
|
open_positions = self._open_positions(scenario, template, history, start_day)
|
||||||
|
opening_quotes = [self._mark_position(position, start_day) for position in open_positions]
|
||||||
|
opening_cost = sum(quote.mark * quote.quantity for quote in opening_quotes)
|
||||||
|
cash_balance -= opening_cost
|
||||||
|
total_hedge_cost += opening_cost
|
||||||
|
|
||||||
|
daily_points: list[BacktestDailyPoint] = []
|
||||||
|
for day in history:
|
||||||
|
premium_cashflow = -opening_cost if day.date == start_day.date else 0.0
|
||||||
|
realized_option_cashflow = 0.0
|
||||||
|
option_market_value = 0.0
|
||||||
|
active_position_ids: list[str] = []
|
||||||
|
remaining_positions: list[OpenSyntheticPosition] = []
|
||||||
|
|
||||||
|
for position in open_positions:
|
||||||
|
if day.date >= position.expiry:
|
||||||
|
intrinsic = self.provider.intrinsic_value(
|
||||||
|
option_type=position.leg.option_type,
|
||||||
|
spot=day.close,
|
||||||
|
strike=position.strike,
|
||||||
|
)
|
||||||
|
payoff = intrinsic * position.quantity
|
||||||
|
cash_balance += payoff
|
||||||
|
realized_option_cashflow += payoff
|
||||||
|
total_option_payoff_realized += payoff
|
||||||
|
continue
|
||||||
|
|
||||||
|
quote = self._mark_position(position, day)
|
||||||
|
option_market_value += quote.mark * position.quantity
|
||||||
|
active_position_ids.append(position.position_id)
|
||||||
|
remaining_positions.append(position)
|
||||||
|
|
||||||
|
open_positions = remaining_positions
|
||||||
|
underlying_value = scenario.initial_portfolio.underlying_units * day.close
|
||||||
|
net_portfolio_value = underlying_value + option_market_value + cash_balance
|
||||||
|
ltv_unhedged = scenario.initial_portfolio.loan_amount / underlying_value
|
||||||
|
ltv_hedged = scenario.initial_portfolio.loan_amount / net_portfolio_value
|
||||||
|
daily_points.append(
|
||||||
|
BacktestDailyPoint(
|
||||||
|
date=day.date,
|
||||||
|
spot_close=day.close,
|
||||||
|
underlying_value=underlying_value,
|
||||||
|
option_market_value=option_market_value,
|
||||||
|
premium_cashflow=premium_cashflow,
|
||||||
|
realized_option_cashflow=realized_option_cashflow,
|
||||||
|
net_portfolio_value=net_portfolio_value,
|
||||||
|
loan_amount=scenario.initial_portfolio.loan_amount,
|
||||||
|
ltv_unhedged=ltv_unhedged,
|
||||||
|
ltv_hedged=ltv_hedged,
|
||||||
|
margin_call_unhedged=ltv_unhedged >= scenario.initial_portfolio.margin_call_ltv,
|
||||||
|
margin_call_hedged=ltv_hedged >= scenario.initial_portfolio.margin_call_ltv,
|
||||||
|
active_position_ids=tuple(active_position_ids),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
summary = BacktestSummaryMetrics(
|
||||||
|
start_value=scenario.initial_portfolio.start_value,
|
||||||
|
end_value_unhedged=daily_points[-1].underlying_value,
|
||||||
|
end_value_hedged_net=daily_points[-1].net_portfolio_value,
|
||||||
|
total_hedge_cost=total_hedge_cost,
|
||||||
|
total_option_payoff_realized=total_option_payoff_realized,
|
||||||
|
max_ltv_unhedged=max(point.ltv_unhedged for point in daily_points),
|
||||||
|
max_ltv_hedged=max(point.ltv_hedged for point in daily_points),
|
||||||
|
margin_threshold_breached_unhedged=any(point.margin_call_unhedged for point in daily_points),
|
||||||
|
margin_threshold_breached_hedged=any(point.margin_call_hedged for point in daily_points),
|
||||||
|
)
|
||||||
|
return TemplateBacktestResult(
|
||||||
|
template_slug=template.slug,
|
||||||
|
template_id=template.template_id,
|
||||||
|
template_version=template.version,
|
||||||
|
template_name=template.display_name,
|
||||||
|
summary_metrics=summary,
|
||||||
|
daily_path=tuple(daily_points),
|
||||||
|
)
|
||||||
|
|
||||||
|
def _open_positions(
|
||||||
|
self,
|
||||||
|
scenario: BacktestScenario,
|
||||||
|
template: StrategyTemplate,
|
||||||
|
history: list[DailyClosePoint],
|
||||||
|
start_day: DailyClosePoint,
|
||||||
|
) -> list[OpenSyntheticPosition]:
|
||||||
|
positions: list[OpenSyntheticPosition] = []
|
||||||
|
for index, leg in enumerate(template.legs, start=1):
|
||||||
|
expiry = self.provider.resolve_expiry(history, start_day.date, leg.target_expiry_days)
|
||||||
|
positions.append(
|
||||||
|
OpenSyntheticPosition(
|
||||||
|
position_id=f"{template.slug}-position-{index}",
|
||||||
|
leg=leg,
|
||||||
|
strike=start_day.close * leg.strike_rule.value,
|
||||||
|
expiry=expiry,
|
||||||
|
quantity=scenario.initial_portfolio.underlying_units
|
||||||
|
* leg.allocation_weight
|
||||||
|
* leg.target_coverage_pct,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
return positions
|
||||||
|
|
||||||
|
def _mark_position(self, position: OpenSyntheticPosition, day: DailyClosePoint):
|
||||||
|
return self.provider.price_option(
|
||||||
|
position_id=position.position_id,
|
||||||
|
leg=position.leg,
|
||||||
|
spot=day.close,
|
||||||
|
strike=position.strike,
|
||||||
|
expiry=position.expiry,
|
||||||
|
quantity=position.quantity,
|
||||||
|
valuation_date=day.date,
|
||||||
|
)
|
||||||
127
app/models/backtest.py
Normal file
127
app/models/backtest.py
Normal file
@@ -0,0 +1,127 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from dataclasses import dataclass, field
|
||||||
|
from datetime import date
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class BacktestPortfolioState:
|
||||||
|
currency: str
|
||||||
|
underlying_units: float
|
||||||
|
entry_spot: float
|
||||||
|
loan_amount: float
|
||||||
|
margin_call_ltv: float
|
||||||
|
cash_balance: float = 0.0
|
||||||
|
financing_rate: float = 0.0
|
||||||
|
|
||||||
|
def __post_init__(self) -> None:
|
||||||
|
if self.currency.upper() != "USD":
|
||||||
|
raise ValueError("USD is the only supported currency in the MVP")
|
||||||
|
if self.underlying_units <= 0:
|
||||||
|
raise ValueError("underlying_units must be positive")
|
||||||
|
if self.entry_spot <= 0:
|
||||||
|
raise ValueError("entry_spot must be positive")
|
||||||
|
if self.loan_amount < 0:
|
||||||
|
raise ValueError("loan_amount must be non-negative")
|
||||||
|
if not 0 < self.margin_call_ltv < 1:
|
||||||
|
raise ValueError("margin_call_ltv must be between 0 and 1")
|
||||||
|
if self.loan_amount >= self.underlying_units * self.entry_spot:
|
||||||
|
raise ValueError("loan_amount must be less than initial collateral value")
|
||||||
|
|
||||||
|
@property
|
||||||
|
def start_value(self) -> float:
|
||||||
|
return self.underlying_units * self.entry_spot
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class TemplateRef:
|
||||||
|
slug: str
|
||||||
|
version: int
|
||||||
|
|
||||||
|
def __post_init__(self) -> None:
|
||||||
|
if not self.slug:
|
||||||
|
raise ValueError("template slug is required")
|
||||||
|
if self.version <= 0:
|
||||||
|
raise ValueError("template version must be positive")
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class ProviderRef:
|
||||||
|
provider_id: str
|
||||||
|
pricing_mode: str
|
||||||
|
|
||||||
|
def __post_init__(self) -> None:
|
||||||
|
if not self.provider_id:
|
||||||
|
raise ValueError("provider_id is required")
|
||||||
|
if not self.pricing_mode:
|
||||||
|
raise ValueError("pricing_mode is required")
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class BacktestScenario:
|
||||||
|
scenario_id: str
|
||||||
|
display_name: str
|
||||||
|
symbol: str
|
||||||
|
start_date: date
|
||||||
|
end_date: date
|
||||||
|
initial_portfolio: BacktestPortfolioState
|
||||||
|
template_refs: tuple[TemplateRef, ...]
|
||||||
|
provider_ref: ProviderRef
|
||||||
|
|
||||||
|
def __post_init__(self) -> None:
|
||||||
|
if not self.scenario_id:
|
||||||
|
raise ValueError("scenario_id is required")
|
||||||
|
if not self.display_name:
|
||||||
|
raise ValueError("display_name is required")
|
||||||
|
if not self.symbol:
|
||||||
|
raise ValueError("symbol is required")
|
||||||
|
if self.start_date > self.end_date:
|
||||||
|
raise ValueError("start_date must be on or before end_date")
|
||||||
|
if not self.template_refs:
|
||||||
|
raise ValueError("at least one template ref is required")
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class BacktestDailyPoint:
|
||||||
|
date: date
|
||||||
|
spot_close: float
|
||||||
|
underlying_value: float
|
||||||
|
option_market_value: float
|
||||||
|
premium_cashflow: float
|
||||||
|
realized_option_cashflow: float
|
||||||
|
net_portfolio_value: float
|
||||||
|
loan_amount: float
|
||||||
|
ltv_unhedged: float
|
||||||
|
ltv_hedged: float
|
||||||
|
margin_call_unhedged: bool
|
||||||
|
margin_call_hedged: bool
|
||||||
|
active_position_ids: tuple[str, ...] = field(default_factory=tuple)
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class BacktestSummaryMetrics:
|
||||||
|
start_value: float
|
||||||
|
end_value_unhedged: float
|
||||||
|
end_value_hedged_net: float
|
||||||
|
total_hedge_cost: float
|
||||||
|
total_option_payoff_realized: float
|
||||||
|
max_ltv_unhedged: float
|
||||||
|
max_ltv_hedged: float
|
||||||
|
margin_threshold_breached_unhedged: bool
|
||||||
|
margin_threshold_breached_hedged: bool
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class TemplateBacktestResult:
|
||||||
|
template_slug: str
|
||||||
|
template_id: str
|
||||||
|
template_version: int
|
||||||
|
template_name: str
|
||||||
|
summary_metrics: BacktestSummaryMetrics
|
||||||
|
daily_path: tuple[BacktestDailyPoint, ...]
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class BacktestRunResult:
|
||||||
|
scenario_id: str
|
||||||
|
template_results: tuple[TemplateBacktestResult, ...]
|
||||||
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")
|
||||||
238
tests/test_backtesting.py
Normal file
238
tests/test_backtesting.py
Normal file
@@ -0,0 +1,238 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from datetime import date
|
||||||
|
|
||||||
|
import pandas as pd
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from app.models.backtest import BacktestPortfolioState, BacktestScenario, ProviderRef, TemplateRef
|
||||||
|
from app.models.strategy_template import EntryPolicy, RollPolicy, StrategyTemplate
|
||||||
|
from app.services.backtesting.historical_provider import (
|
||||||
|
DailyClosePoint,
|
||||||
|
SyntheticHistoricalProvider,
|
||||||
|
YFinanceHistoricalPriceSource,
|
||||||
|
)
|
||||||
|
from app.services.backtesting.service import BacktestService
|
||||||
|
from app.services.strategy_templates import StrategyTemplateService
|
||||||
|
|
||||||
|
|
||||||
|
class FakeHistorySource:
|
||||||
|
def __init__(self, rows: list[DailyClosePoint]) -> None:
|
||||||
|
self.rows = rows
|
||||||
|
self.calls: list[tuple[str, date, date]] = []
|
||||||
|
|
||||||
|
def load_daily_closes(self, symbol: str, start_date: date, end_date: date) -> list[DailyClosePoint]:
|
||||||
|
self.calls.append((symbol, start_date, end_date))
|
||||||
|
return list(self.rows)
|
||||||
|
|
||||||
|
|
||||||
|
FIXTURE_HISTORY = [
|
||||||
|
DailyClosePoint(date=date(2024, 1, 8), close=85.0),
|
||||||
|
DailyClosePoint(date=date(2024, 1, 2), close=100.0),
|
||||||
|
DailyClosePoint(date=date(2024, 1, 3), close=96.0),
|
||||||
|
DailyClosePoint(date=date(2024, 1, 4), close=92.0),
|
||||||
|
DailyClosePoint(date=date(2024, 1, 5), close=88.0),
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
def test_synthetic_historical_provider_sorts_and_filters_daily_closes() -> None:
|
||||||
|
source = FakeHistorySource(FIXTURE_HISTORY)
|
||||||
|
provider = SyntheticHistoricalProvider(source=source, implied_volatility=0.35, risk_free_rate=0.01)
|
||||||
|
|
||||||
|
series = provider.load_history(symbol="GLD", start_date=date(2024, 1, 3), end_date=date(2024, 1, 5))
|
||||||
|
|
||||||
|
assert [(point.date.isoformat(), point.close) for point in series] == [
|
||||||
|
("2024-01-03", 96.0),
|
||||||
|
("2024-01-04", 92.0),
|
||||||
|
("2024-01-05", 88.0),
|
||||||
|
]
|
||||||
|
assert source.calls == [("GLD", date(2024, 1, 3), date(2024, 1, 5))]
|
||||||
|
|
||||||
|
|
||||||
|
def _build_scenario(
|
||||||
|
*,
|
||||||
|
provider_ref: ProviderRef | None = None,
|
||||||
|
symbol: str = "GLD",
|
||||||
|
entry_spot: float = 100.0,
|
||||||
|
) -> BacktestScenario:
|
||||||
|
return BacktestScenario(
|
||||||
|
scenario_id="gld-selloff-1",
|
||||||
|
display_name="GLD selloff",
|
||||||
|
symbol=symbol,
|
||||||
|
start_date=date(2024, 1, 2),
|
||||||
|
end_date=date(2024, 1, 8),
|
||||||
|
initial_portfolio=BacktestPortfolioState(
|
||||||
|
currency="USD",
|
||||||
|
underlying_units=1000.0,
|
||||||
|
entry_spot=entry_spot,
|
||||||
|
loan_amount=68_000.0,
|
||||||
|
margin_call_ltv=0.75,
|
||||||
|
),
|
||||||
|
template_refs=(TemplateRef(slug="protective-put-atm-12m", version=1),),
|
||||||
|
provider_ref=provider_ref or ProviderRef(provider_id="synthetic_v1", pricing_mode="synthetic_bs_mid"),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_backtest_service_runs_template_backtest_with_daily_points() -> None:
|
||||||
|
source = FakeHistorySource(FIXTURE_HISTORY)
|
||||||
|
provider = SyntheticHistoricalProvider(source=source, implied_volatility=0.35, risk_free_rate=0.0)
|
||||||
|
service = BacktestService(provider=provider, template_service=StrategyTemplateService())
|
||||||
|
|
||||||
|
result = service.run_scenario(_build_scenario())
|
||||||
|
|
||||||
|
assert result.scenario_id == "gld-selloff-1"
|
||||||
|
assert len(result.template_results) == 1
|
||||||
|
|
||||||
|
template_result = result.template_results[0]
|
||||||
|
summary = template_result.summary_metrics
|
||||||
|
|
||||||
|
assert summary.start_value == 100_000.0
|
||||||
|
assert summary.end_value_unhedged == 85_000.0
|
||||||
|
assert summary.end_value_hedged_net > summary.end_value_unhedged
|
||||||
|
assert summary.total_hedge_cost > 0.0
|
||||||
|
assert summary.max_ltv_hedged < summary.max_ltv_unhedged
|
||||||
|
assert summary.margin_threshold_breached_unhedged is True
|
||||||
|
assert summary.margin_threshold_breached_hedged is False
|
||||||
|
|
||||||
|
assert [point.date.isoformat() for point in template_result.daily_path] == [
|
||||||
|
"2024-01-02",
|
||||||
|
"2024-01-03",
|
||||||
|
"2024-01-04",
|
||||||
|
"2024-01-05",
|
||||||
|
"2024-01-08",
|
||||||
|
]
|
||||||
|
assert template_result.daily_path[0].premium_cashflow < 0.0
|
||||||
|
assert template_result.daily_path[-1].margin_call_unhedged is True
|
||||||
|
assert template_result.daily_path[-1].margin_call_hedged is False
|
||||||
|
|
||||||
|
|
||||||
|
def test_backtest_keeps_long_dated_option_open_past_scenario_end() -> None:
|
||||||
|
source = FakeHistorySource(FIXTURE_HISTORY)
|
||||||
|
provider = SyntheticHistoricalProvider(source=source, implied_volatility=0.35, risk_free_rate=0.0)
|
||||||
|
service = BacktestService(provider=provider, template_service=StrategyTemplateService())
|
||||||
|
|
||||||
|
result = service.run_scenario(_build_scenario())
|
||||||
|
|
||||||
|
template_result = result.template_results[0]
|
||||||
|
final_day = template_result.daily_path[-1]
|
||||||
|
|
||||||
|
assert final_day.date == date(2024, 1, 8)
|
||||||
|
assert final_day.realized_option_cashflow == 0.0
|
||||||
|
assert final_day.option_market_value > 0.0
|
||||||
|
assert final_day.active_position_ids == ("protective-put-atm-12m-position-1",)
|
||||||
|
assert template_result.summary_metrics.total_option_payoff_realized == 0.0
|
||||||
|
|
||||||
|
|
||||||
|
def test_backtest_rejects_unsupported_provider_pricing_combination() -> None:
|
||||||
|
source = FakeHistorySource(FIXTURE_HISTORY)
|
||||||
|
provider = SyntheticHistoricalProvider(source=source, implied_volatility=0.35, risk_free_rate=0.0)
|
||||||
|
service = BacktestService(provider=provider, template_service=StrategyTemplateService())
|
||||||
|
scenario = _build_scenario(provider_ref=ProviderRef(provider_id="daily_snapshots_v1", pricing_mode="snapshot_mid"))
|
||||||
|
|
||||||
|
with pytest.raises(ValueError, match="Unsupported provider/pricing combination"):
|
||||||
|
service.run_scenario(scenario)
|
||||||
|
|
||||||
|
|
||||||
|
def test_backtest_rejects_scenario_start_close_when_history_starts_after_scenario_start() -> None:
|
||||||
|
delayed_history = [row for row in FIXTURE_HISTORY if row.date >= date(2024, 1, 3)]
|
||||||
|
source = FakeHistorySource(delayed_history)
|
||||||
|
provider = SyntheticHistoricalProvider(source=source, implied_volatility=0.35, risk_free_rate=0.0)
|
||||||
|
service = BacktestService(provider=provider, template_service=StrategyTemplateService())
|
||||||
|
|
||||||
|
with pytest.raises(ValueError, match="Scenario start_date must match the first available historical price point"):
|
||||||
|
service.run_scenario(_build_scenario())
|
||||||
|
|
||||||
|
|
||||||
|
def test_backtest_rejects_mismatched_entry_spot_for_scenario_start_close() -> None:
|
||||||
|
source = FakeHistorySource(FIXTURE_HISTORY)
|
||||||
|
provider = SyntheticHistoricalProvider(source=source, implied_volatility=0.35, risk_free_rate=0.0)
|
||||||
|
service = BacktestService(provider=provider, template_service=StrategyTemplateService())
|
||||||
|
|
||||||
|
with pytest.raises(
|
||||||
|
ValueError,
|
||||||
|
match="initial_portfolio.entry_spot must match the first historical close used for entry",
|
||||||
|
):
|
||||||
|
service.run_scenario(_build_scenario(entry_spot=100.02))
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize(
|
||||||
|
("field", "value", "message"),
|
||||||
|
[
|
||||||
|
("contract_mode", "listed_contracts", "Unsupported contract_mode"),
|
||||||
|
(
|
||||||
|
"roll_policy",
|
||||||
|
RollPolicy(policy_type="roll_n_days_before_expiry", days_before_expiry=5),
|
||||||
|
"Unsupported roll_policy",
|
||||||
|
),
|
||||||
|
("entry_policy", EntryPolicy(entry_timing="scenario_start_close", stagger_days=1), None),
|
||||||
|
],
|
||||||
|
)
|
||||||
|
def test_backtest_rejects_unsupported_template_behaviors(field: str, value: object, message: str | None) -> None:
|
||||||
|
source = FakeHistorySource(FIXTURE_HISTORY)
|
||||||
|
provider = SyntheticHistoricalProvider(source=source, implied_volatility=0.35, risk_free_rate=0.0)
|
||||||
|
template_service = StrategyTemplateService()
|
||||||
|
service = BacktestService(provider=provider, template_service=template_service)
|
||||||
|
template = template_service.get_template("protective-put-atm-12m")
|
||||||
|
|
||||||
|
template_kwargs = {
|
||||||
|
"template_id": template.template_id,
|
||||||
|
"slug": template.slug,
|
||||||
|
"display_name": template.display_name,
|
||||||
|
"description": template.description,
|
||||||
|
"template_kind": template.template_kind,
|
||||||
|
"status": template.status,
|
||||||
|
"version": template.version,
|
||||||
|
"underlying_symbol": template.underlying_symbol,
|
||||||
|
"contract_mode": template.contract_mode,
|
||||||
|
"legs": template.legs,
|
||||||
|
"roll_policy": template.roll_policy,
|
||||||
|
"entry_policy": template.entry_policy,
|
||||||
|
"tags": template.tags,
|
||||||
|
"created_at": template.created_at,
|
||||||
|
"updated_at": template.updated_at,
|
||||||
|
}
|
||||||
|
template_kwargs[field] = value
|
||||||
|
unsupported_template = StrategyTemplate(**template_kwargs)
|
||||||
|
|
||||||
|
if field == "entry_policy":
|
||||||
|
unsupported_template = StrategyTemplate(
|
||||||
|
**{
|
||||||
|
**template_kwargs,
|
||||||
|
"entry_policy": EntryPolicy(entry_timing="scenario_start_close", stagger_days=1),
|
||||||
|
}
|
||||||
|
)
|
||||||
|
with pytest.raises(ValueError, match="Unsupported entry_policy configuration"):
|
||||||
|
service._validate_template_for_mvp(unsupported_template)
|
||||||
|
return
|
||||||
|
|
||||||
|
with pytest.raises(ValueError, match=message or "Unsupported"):
|
||||||
|
service._validate_template_for_mvp(unsupported_template)
|
||||||
|
|
||||||
|
|
||||||
|
def test_yfinance_price_source_treats_end_date_inclusively(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||||
|
calls: list[tuple[str, str, str]] = []
|
||||||
|
|
||||||
|
class FakeTicker:
|
||||||
|
def __init__(self, symbol: str) -> None:
|
||||||
|
self.symbol = symbol
|
||||||
|
|
||||||
|
def history(self, start: str, end: str, interval: str):
|
||||||
|
calls.append((start, end, interval))
|
||||||
|
return pd.DataFrame(
|
||||||
|
{"Close": [100.0, 101.0]},
|
||||||
|
index=pd.to_datetime(["2024-01-04", "2024-01-05"]),
|
||||||
|
)
|
||||||
|
|
||||||
|
class FakeYFinance:
|
||||||
|
Ticker = FakeTicker
|
||||||
|
|
||||||
|
monkeypatch.setattr("app.services.backtesting.historical_provider.yf", FakeYFinance())
|
||||||
|
|
||||||
|
source = YFinanceHistoricalPriceSource()
|
||||||
|
rows = source.load_daily_closes(symbol="GLD", start_date=date(2024, 1, 4), end_date=date(2024, 1, 5))
|
||||||
|
|
||||||
|
assert calls == [("2024-01-04", "2024-01-06", "1d")]
|
||||||
|
assert [(row.date.isoformat(), row.close) for row in rows] == [
|
||||||
|
("2024-01-04", 100.0),
|
||||||
|
("2024-01-05", 101.0),
|
||||||
|
]
|
||||||
Reference in New Issue
Block a user