feat(BT-002): add historical snapshot provider
This commit is contained in:
@@ -1,7 +1,6 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
from dataclasses import dataclass
|
from dataclasses import dataclass
|
||||||
from datetime import date
|
|
||||||
|
|
||||||
from app.models.backtest import (
|
from app.models.backtest import (
|
||||||
BacktestDailyPoint,
|
BacktestDailyPoint,
|
||||||
@@ -9,21 +8,21 @@ from app.models.backtest import (
|
|||||||
BacktestSummaryMetrics,
|
BacktestSummaryMetrics,
|
||||||
TemplateBacktestResult,
|
TemplateBacktestResult,
|
||||||
)
|
)
|
||||||
from app.models.strategy_template import StrategyTemplate, TemplateLeg
|
from app.models.strategy_template import StrategyTemplate
|
||||||
from app.services.backtesting.historical_provider import DailyClosePoint, SyntheticHistoricalProvider
|
from app.services.backtesting.historical_provider import (
|
||||||
|
BacktestHistoricalProvider,
|
||||||
|
DailyClosePoint,
|
||||||
|
HistoricalOptionPosition,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
@dataclass
|
@dataclass
|
||||||
class OpenSyntheticPosition:
|
class OpenSyntheticPosition(HistoricalOptionPosition):
|
||||||
position_id: str
|
pass
|
||||||
leg: TemplateLeg
|
|
||||||
strike: float
|
|
||||||
expiry: date
|
|
||||||
quantity: float
|
|
||||||
|
|
||||||
|
|
||||||
class SyntheticBacktestEngine:
|
class SyntheticBacktestEngine:
|
||||||
def __init__(self, provider: SyntheticHistoricalProvider) -> None:
|
def __init__(self, provider: BacktestHistoricalProvider) -> None:
|
||||||
self.provider = provider
|
self.provider = provider
|
||||||
|
|
||||||
def run_template(
|
def run_template(
|
||||||
@@ -36,9 +35,9 @@ class SyntheticBacktestEngine:
|
|||||||
cash_balance = scenario.initial_portfolio.cash_balance
|
cash_balance = scenario.initial_portfolio.cash_balance
|
||||||
total_hedge_cost = 0.0
|
total_hedge_cost = 0.0
|
||||||
total_option_payoff_realized = 0.0
|
total_option_payoff_realized = 0.0
|
||||||
|
warnings: list[str] = []
|
||||||
open_positions = self._open_positions(scenario, template, history, start_day)
|
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(position.entry_price * position.quantity for position in open_positions)
|
||||||
opening_cost = sum(quote.mark * quote.quantity for quote in opening_quotes)
|
|
||||||
cash_balance -= opening_cost
|
cash_balance -= opening_cost
|
||||||
total_hedge_cost += opening_cost
|
total_hedge_cost += opening_cost
|
||||||
|
|
||||||
@@ -48,23 +47,23 @@ class SyntheticBacktestEngine:
|
|||||||
realized_option_cashflow = 0.0
|
realized_option_cashflow = 0.0
|
||||||
option_market_value = 0.0
|
option_market_value = 0.0
|
||||||
active_position_ids: list[str] = []
|
active_position_ids: list[str] = []
|
||||||
remaining_positions: list[OpenSyntheticPosition] = []
|
remaining_positions: list[HistoricalOptionPosition] = []
|
||||||
|
|
||||||
for position in open_positions:
|
for position in open_positions:
|
||||||
if day.date >= position.expiry:
|
valuation = self.provider.mark_position(
|
||||||
intrinsic = self.provider.intrinsic_value(
|
position,
|
||||||
option_type=position.leg.option_type,
|
symbol=scenario.symbol,
|
||||||
|
as_of_date=day.date,
|
||||||
spot=day.close,
|
spot=day.close,
|
||||||
strike=position.strike,
|
|
||||||
)
|
)
|
||||||
payoff = intrinsic * position.quantity
|
self._append_warning(warnings, valuation.warning)
|
||||||
cash_balance += payoff
|
if not valuation.is_active:
|
||||||
realized_option_cashflow += payoff
|
cash_balance += valuation.realized_cashflow
|
||||||
total_option_payoff_realized += payoff
|
realized_option_cashflow += valuation.realized_cashflow
|
||||||
|
total_option_payoff_realized += valuation.realized_cashflow
|
||||||
continue
|
continue
|
||||||
|
|
||||||
quote = self._mark_position(position, day)
|
option_market_value += valuation.mark * position.quantity
|
||||||
option_market_value += quote.mark * position.quantity
|
|
||||||
active_position_ids.append(position.position_id)
|
active_position_ids.append(position.position_id)
|
||||||
remaining_positions.append(position)
|
remaining_positions.append(position)
|
||||||
|
|
||||||
@@ -113,6 +112,7 @@ class SyntheticBacktestEngine:
|
|||||||
template_name=template.display_name,
|
template_name=template.display_name,
|
||||||
summary_metrics=summary,
|
summary_metrics=summary,
|
||||||
daily_path=tuple(daily_points),
|
daily_path=tuple(daily_points),
|
||||||
|
warnings=tuple(warnings),
|
||||||
)
|
)
|
||||||
|
|
||||||
def _open_positions(
|
def _open_positions(
|
||||||
@@ -121,30 +121,25 @@ class SyntheticBacktestEngine:
|
|||||||
template: StrategyTemplate,
|
template: StrategyTemplate,
|
||||||
history: list[DailyClosePoint],
|
history: list[DailyClosePoint],
|
||||||
start_day: DailyClosePoint,
|
start_day: DailyClosePoint,
|
||||||
) -> list[OpenSyntheticPosition]:
|
) -> list[HistoricalOptionPosition]:
|
||||||
positions: list[OpenSyntheticPosition] = []
|
positions: list[HistoricalOptionPosition] = []
|
||||||
for index, leg in enumerate(template.legs, start=1):
|
for index, leg in enumerate(template.legs, start=1):
|
||||||
expiry = self.provider.resolve_expiry(history, start_day.date, leg.target_expiry_days)
|
|
||||||
positions.append(
|
positions.append(
|
||||||
OpenSyntheticPosition(
|
self.provider.open_position(
|
||||||
position_id=f"{template.slug}-position-{index}",
|
symbol=scenario.symbol,
|
||||||
leg=leg,
|
leg=leg,
|
||||||
strike=start_day.close * leg.strike_rule.value,
|
position_id=f"{template.slug}-position-{index}",
|
||||||
expiry=expiry,
|
quantity=(
|
||||||
quantity=scenario.initial_portfolio.underlying_units
|
scenario.initial_portfolio.underlying_units * leg.allocation_weight * leg.target_coverage_pct
|
||||||
* leg.allocation_weight
|
),
|
||||||
* leg.target_coverage_pct,
|
as_of_date=start_day.date,
|
||||||
|
spot=start_day.close,
|
||||||
|
trading_days=history,
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
return positions
|
return positions
|
||||||
|
|
||||||
def _mark_position(self, position: OpenSyntheticPosition, day: DailyClosePoint):
|
@staticmethod
|
||||||
return self.provider.price_option(
|
def _append_warning(warnings: list[str], warning: str | None) -> None:
|
||||||
position_id=position.position_id,
|
if warning and warning not in warnings:
|
||||||
leg=position.leg,
|
warnings.append(warning)
|
||||||
spot=day.close,
|
|
||||||
strike=position.strike,
|
|
||||||
expiry=position.expiry,
|
|
||||||
quantity=position.quantity,
|
|
||||||
valuation_date=day.date,
|
|
||||||
)
|
|
||||||
|
|||||||
@@ -123,6 +123,7 @@ class TemplateBacktestResult:
|
|||||||
template_name: str
|
template_name: str
|
||||||
summary_metrics: BacktestSummaryMetrics
|
summary_metrics: BacktestSummaryMetrics
|
||||||
daily_path: tuple[BacktestDailyPoint, ...]
|
daily_path: tuple[BacktestDailyPoint, ...]
|
||||||
|
warnings: tuple[str, ...] = field(default_factory=tuple)
|
||||||
|
|
||||||
|
|
||||||
@dataclass(frozen=True)
|
@dataclass(frozen=True)
|
||||||
|
|||||||
@@ -207,16 +207,13 @@ def _render_event_comparison_page(workspace_id: str | None = None) -> None:
|
|||||||
try:
|
try:
|
||||||
preview_units = float(units_input.value or 0.0)
|
preview_units = float(units_input.value or 0.0)
|
||||||
if workspace_id and config is not None and reseed_units:
|
if workspace_id and config is not None and reseed_units:
|
||||||
preview_scenario = service.preview_scenario(
|
preview_entry_spot = service.derive_entry_spot(
|
||||||
preset_slug=str(option["slug"]),
|
preset_slug=str(option["slug"]),
|
||||||
template_slugs=template_slugs,
|
template_slugs=template_slugs,
|
||||||
underlying_units=1.0,
|
|
||||||
loan_amount=float(loan_input.value or 0.0),
|
|
||||||
margin_call_ltv=float(ltv_input.value or 0.0),
|
|
||||||
)
|
)
|
||||||
preview_units = asset_quantity_from_workspace_config(
|
preview_units = asset_quantity_from_workspace_config(
|
||||||
config,
|
config,
|
||||||
entry_spot=float(preview_scenario.initial_portfolio.entry_spot),
|
entry_spot=preview_entry_spot,
|
||||||
symbol="GLD",
|
symbol="GLD",
|
||||||
)
|
)
|
||||||
syncing_controls["value"] = True
|
syncing_controls["value"] = True
|
||||||
@@ -536,7 +533,7 @@ def _render_event_comparison_page(workspace_id: str | None = None) -> None:
|
|||||||
if syncing_controls["value"]:
|
if syncing_controls["value"]:
|
||||||
return
|
return
|
||||||
validation_label.set_text("")
|
validation_label.set_text("")
|
||||||
preview_error = refresh_preview(reset_templates=True, reseed_units=True)
|
preview_error = refresh_preview(reset_templates=True, reseed_units=False)
|
||||||
if preview_error:
|
if preview_error:
|
||||||
validation_label.set_text(preview_error)
|
validation_label.set_text(preview_error)
|
||||||
render_result_state("Scenario validation failed", preview_error, tone="warning")
|
render_result_state("Scenario validation failed", preview_error, tone="warning")
|
||||||
|
|||||||
@@ -55,11 +55,130 @@ class SyntheticOptionQuote:
|
|||||||
raise ValueError("mark must be non-negative")
|
raise ValueError("mark must be non-negative")
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class DailyOptionSnapshot:
|
||||||
|
contract_key: str
|
||||||
|
symbol: str
|
||||||
|
snapshot_date: date
|
||||||
|
expiry: date
|
||||||
|
option_type: str
|
||||||
|
strike: float
|
||||||
|
mid: float
|
||||||
|
|
||||||
|
def __post_init__(self) -> None:
|
||||||
|
if not self.contract_key:
|
||||||
|
raise ValueError("contract_key is required")
|
||||||
|
if not self.symbol:
|
||||||
|
raise ValueError("symbol is required")
|
||||||
|
if self.option_type not in {"put", "call"}:
|
||||||
|
raise ValueError("unsupported option_type")
|
||||||
|
if self.strike <= 0:
|
||||||
|
raise ValueError("strike must be positive")
|
||||||
|
if self.mid < 0:
|
||||||
|
raise ValueError("mid must be non-negative")
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class HistoricalOptionPosition:
|
||||||
|
position_id: str
|
||||||
|
leg_id: str
|
||||||
|
contract_key: str
|
||||||
|
option_type: str
|
||||||
|
strike: float
|
||||||
|
expiry: date
|
||||||
|
quantity: float
|
||||||
|
entry_price: float
|
||||||
|
current_mark: float
|
||||||
|
last_mark_date: date
|
||||||
|
source_snapshot_date: date
|
||||||
|
|
||||||
|
def __post_init__(self) -> None:
|
||||||
|
for field_name in ("position_id", "leg_id", "contract_key"):
|
||||||
|
value = getattr(self, field_name)
|
||||||
|
if not isinstance(value, str) or not value:
|
||||||
|
raise ValueError(f"{field_name} is required")
|
||||||
|
if self.option_type not in {"put", "call"}:
|
||||||
|
raise ValueError("unsupported option_type")
|
||||||
|
for field_name in ("strike", "quantity", "entry_price", "current_mark"):
|
||||||
|
value = getattr(self, field_name)
|
||||||
|
if not isinstance(value, (int, float)) or isinstance(value, bool) or not isfinite(float(value)):
|
||||||
|
raise TypeError(f"{field_name} must be a finite number")
|
||||||
|
if self.strike <= 0:
|
||||||
|
raise ValueError("strike must be positive")
|
||||||
|
if self.quantity <= 0:
|
||||||
|
raise ValueError("quantity must be positive")
|
||||||
|
if self.entry_price < 0:
|
||||||
|
raise ValueError("entry_price must be non-negative")
|
||||||
|
if self.current_mark < 0:
|
||||||
|
raise ValueError("current_mark must be non-negative")
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class HistoricalOptionMark:
|
||||||
|
contract_key: str
|
||||||
|
mark: float
|
||||||
|
source: str
|
||||||
|
is_active: bool
|
||||||
|
realized_cashflow: float = 0.0
|
||||||
|
warning: str | None = None
|
||||||
|
|
||||||
|
def __post_init__(self) -> None:
|
||||||
|
if not self.contract_key:
|
||||||
|
raise ValueError("contract_key is required")
|
||||||
|
for field_name in ("mark", "realized_cashflow"):
|
||||||
|
value = getattr(self, field_name)
|
||||||
|
if not isinstance(value, (int, float)) or isinstance(value, bool) or not isfinite(float(value)):
|
||||||
|
raise TypeError(f"{field_name} must be a finite number")
|
||||||
|
if self.mark < 0:
|
||||||
|
raise ValueError("mark must be non-negative")
|
||||||
|
if self.realized_cashflow < 0:
|
||||||
|
raise ValueError("realized_cashflow must be non-negative")
|
||||||
|
|
||||||
|
|
||||||
class HistoricalPriceSource(Protocol):
|
class HistoricalPriceSource(Protocol):
|
||||||
def load_daily_closes(self, symbol: str, start_date: date, end_date: date) -> list[DailyClosePoint]:
|
def load_daily_closes(self, symbol: str, start_date: date, end_date: date) -> list[DailyClosePoint]:
|
||||||
raise NotImplementedError
|
raise NotImplementedError
|
||||||
|
|
||||||
|
|
||||||
|
class OptionSnapshotSource(Protocol):
|
||||||
|
def load_option_chain(self, symbol: str, snapshot_date: date) -> list[DailyOptionSnapshot]:
|
||||||
|
raise NotImplementedError
|
||||||
|
|
||||||
|
|
||||||
|
class BacktestHistoricalProvider(Protocol):
|
||||||
|
provider_id: str
|
||||||
|
pricing_mode: str
|
||||||
|
|
||||||
|
def load_history(self, symbol: str, start_date: date, end_date: date) -> list[DailyClosePoint]:
|
||||||
|
raise NotImplementedError
|
||||||
|
|
||||||
|
def validate_provider_ref(self, provider_ref: ProviderRef) -> None:
|
||||||
|
raise NotImplementedError
|
||||||
|
|
||||||
|
def open_position(
|
||||||
|
self,
|
||||||
|
*,
|
||||||
|
symbol: str,
|
||||||
|
leg: TemplateLeg,
|
||||||
|
position_id: str,
|
||||||
|
quantity: float,
|
||||||
|
as_of_date: date,
|
||||||
|
spot: float,
|
||||||
|
trading_days: list[DailyClosePoint],
|
||||||
|
) -> HistoricalOptionPosition:
|
||||||
|
raise NotImplementedError
|
||||||
|
|
||||||
|
def mark_position(
|
||||||
|
self,
|
||||||
|
position: HistoricalOptionPosition,
|
||||||
|
*,
|
||||||
|
symbol: str,
|
||||||
|
as_of_date: date,
|
||||||
|
spot: float,
|
||||||
|
) -> HistoricalOptionMark:
|
||||||
|
raise NotImplementedError
|
||||||
|
|
||||||
|
|
||||||
class YFinanceHistoricalPriceSource:
|
class YFinanceHistoricalPriceSource:
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def _normalize_daily_close_row(*, row_date: object, close: object) -> DailyClosePoint | None:
|
def _normalize_daily_close_row(*, row_date: object, close: object) -> DailyClosePoint | None:
|
||||||
@@ -121,6 +240,79 @@ class SyntheticHistoricalProvider:
|
|||||||
return day.date
|
return day.date
|
||||||
return target_date
|
return target_date
|
||||||
|
|
||||||
|
def open_position(
|
||||||
|
self,
|
||||||
|
*,
|
||||||
|
symbol: str,
|
||||||
|
leg: TemplateLeg,
|
||||||
|
position_id: str,
|
||||||
|
quantity: float,
|
||||||
|
as_of_date: date,
|
||||||
|
spot: float,
|
||||||
|
trading_days: list[DailyClosePoint],
|
||||||
|
) -> HistoricalOptionPosition:
|
||||||
|
expiry = self.resolve_expiry(trading_days, as_of_date, leg.target_expiry_days)
|
||||||
|
strike = spot * leg.strike_rule.value
|
||||||
|
quote = self.price_option(
|
||||||
|
position_id=position_id,
|
||||||
|
leg=leg,
|
||||||
|
spot=spot,
|
||||||
|
strike=strike,
|
||||||
|
expiry=expiry,
|
||||||
|
quantity=quantity,
|
||||||
|
valuation_date=as_of_date,
|
||||||
|
)
|
||||||
|
return HistoricalOptionPosition(
|
||||||
|
position_id=position_id,
|
||||||
|
leg_id=leg.leg_id,
|
||||||
|
contract_key=f"{symbol}-{expiry.isoformat()}-{leg.option_type}-{strike:.4f}",
|
||||||
|
option_type=leg.option_type,
|
||||||
|
strike=strike,
|
||||||
|
expiry=expiry,
|
||||||
|
quantity=quantity,
|
||||||
|
entry_price=quote.mark,
|
||||||
|
current_mark=quote.mark,
|
||||||
|
last_mark_date=as_of_date,
|
||||||
|
source_snapshot_date=as_of_date,
|
||||||
|
)
|
||||||
|
|
||||||
|
def mark_position(
|
||||||
|
self,
|
||||||
|
position: HistoricalOptionPosition,
|
||||||
|
*,
|
||||||
|
symbol: str,
|
||||||
|
as_of_date: date,
|
||||||
|
spot: float,
|
||||||
|
) -> HistoricalOptionMark:
|
||||||
|
if as_of_date >= position.expiry:
|
||||||
|
intrinsic = self.intrinsic_value(option_type=position.option_type, spot=spot, strike=position.strike)
|
||||||
|
return HistoricalOptionMark(
|
||||||
|
contract_key=position.contract_key,
|
||||||
|
mark=0.0,
|
||||||
|
source="intrinsic_expiry",
|
||||||
|
is_active=False,
|
||||||
|
realized_cashflow=intrinsic * position.quantity,
|
||||||
|
)
|
||||||
|
|
||||||
|
quote = self.price_option_by_type(
|
||||||
|
position_id=position.position_id,
|
||||||
|
leg_id=position.leg_id,
|
||||||
|
option_type=position.option_type,
|
||||||
|
spot=spot,
|
||||||
|
strike=position.strike,
|
||||||
|
expiry=position.expiry,
|
||||||
|
quantity=position.quantity,
|
||||||
|
valuation_date=as_of_date,
|
||||||
|
)
|
||||||
|
position.current_mark = quote.mark
|
||||||
|
position.last_mark_date = as_of_date
|
||||||
|
return HistoricalOptionMark(
|
||||||
|
contract_key=position.contract_key,
|
||||||
|
mark=quote.mark,
|
||||||
|
source="synthetic_bs_mid",
|
||||||
|
is_active=True,
|
||||||
|
)
|
||||||
|
|
||||||
def price_option(
|
def price_option(
|
||||||
self,
|
self,
|
||||||
*,
|
*,
|
||||||
@@ -131,6 +323,29 @@ class SyntheticHistoricalProvider:
|
|||||||
expiry: date,
|
expiry: date,
|
||||||
quantity: float,
|
quantity: float,
|
||||||
valuation_date: date,
|
valuation_date: date,
|
||||||
|
) -> SyntheticOptionQuote:
|
||||||
|
return self.price_option_by_type(
|
||||||
|
position_id=position_id,
|
||||||
|
leg_id=leg.leg_id,
|
||||||
|
option_type=leg.option_type,
|
||||||
|
spot=spot,
|
||||||
|
strike=strike,
|
||||||
|
expiry=expiry,
|
||||||
|
quantity=quantity,
|
||||||
|
valuation_date=valuation_date,
|
||||||
|
)
|
||||||
|
|
||||||
|
def price_option_by_type(
|
||||||
|
self,
|
||||||
|
*,
|
||||||
|
position_id: str,
|
||||||
|
leg_id: str,
|
||||||
|
option_type: str,
|
||||||
|
spot: float,
|
||||||
|
strike: float,
|
||||||
|
expiry: date,
|
||||||
|
quantity: float,
|
||||||
|
valuation_date: date,
|
||||||
) -> SyntheticOptionQuote:
|
) -> SyntheticOptionQuote:
|
||||||
remaining_days = max(1, expiry.toordinal() - valuation_date.toordinal())
|
remaining_days = max(1, expiry.toordinal() - valuation_date.toordinal())
|
||||||
mark = black_scholes_price_and_greeks(
|
mark = black_scholes_price_and_greeks(
|
||||||
@@ -140,13 +355,13 @@ class SyntheticHistoricalProvider:
|
|||||||
time_to_expiry=remaining_days / 365.0,
|
time_to_expiry=remaining_days / 365.0,
|
||||||
risk_free_rate=self.risk_free_rate,
|
risk_free_rate=self.risk_free_rate,
|
||||||
volatility=self.implied_volatility,
|
volatility=self.implied_volatility,
|
||||||
option_type=leg.option_type,
|
option_type=option_type,
|
||||||
valuation_date=valuation_date,
|
valuation_date=valuation_date,
|
||||||
)
|
)
|
||||||
).price
|
).price
|
||||||
return SyntheticOptionQuote(
|
return SyntheticOptionQuote(
|
||||||
position_id=position_id,
|
position_id=position_id,
|
||||||
leg_id=leg.leg_id,
|
leg_id=leg_id,
|
||||||
spot=spot,
|
spot=spot,
|
||||||
strike=strike,
|
strike=strike,
|
||||||
expiry=expiry,
|
expiry=expiry,
|
||||||
@@ -161,3 +376,149 @@ class SyntheticHistoricalProvider:
|
|||||||
if option_type == "call":
|
if option_type == "call":
|
||||||
return max(spot - strike, 0.0)
|
return max(spot - strike, 0.0)
|
||||||
raise ValueError(f"Unsupported option type: {option_type}")
|
raise ValueError(f"Unsupported option type: {option_type}")
|
||||||
|
|
||||||
|
|
||||||
|
class EmptyOptionSnapshotSource:
|
||||||
|
def load_option_chain(self, symbol: str, snapshot_date: date) -> list[DailyOptionSnapshot]:
|
||||||
|
return []
|
||||||
|
|
||||||
|
|
||||||
|
class DailyOptionsSnapshotProvider:
|
||||||
|
provider_id = "daily_snapshots_v1"
|
||||||
|
pricing_mode = "snapshot_mid"
|
||||||
|
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
price_source: HistoricalPriceSource | None = None,
|
||||||
|
snapshot_source: OptionSnapshotSource | None = None,
|
||||||
|
) -> None:
|
||||||
|
self.price_source = price_source or YFinanceHistoricalPriceSource()
|
||||||
|
self.snapshot_source = snapshot_source or EmptyOptionSnapshotSource()
|
||||||
|
|
||||||
|
def load_history(self, symbol: str, start_date: date, end_date: date) -> list[DailyClosePoint]:
|
||||||
|
rows = self.price_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 historical snapshot engine: "
|
||||||
|
f"{provider_ref.provider_id}/{provider_ref.pricing_mode}"
|
||||||
|
)
|
||||||
|
|
||||||
|
def open_position(
|
||||||
|
self,
|
||||||
|
*,
|
||||||
|
symbol: str,
|
||||||
|
leg: TemplateLeg,
|
||||||
|
position_id: str,
|
||||||
|
quantity: float,
|
||||||
|
as_of_date: date,
|
||||||
|
spot: float,
|
||||||
|
trading_days: list[DailyClosePoint],
|
||||||
|
) -> HistoricalOptionPosition:
|
||||||
|
del trading_days # selection must use only the entry-day snapshot, not future state
|
||||||
|
selected_snapshot = self._select_entry_snapshot(symbol=symbol, leg=leg, as_of_date=as_of_date, spot=spot)
|
||||||
|
return HistoricalOptionPosition(
|
||||||
|
position_id=position_id,
|
||||||
|
leg_id=leg.leg_id,
|
||||||
|
contract_key=selected_snapshot.contract_key,
|
||||||
|
option_type=selected_snapshot.option_type,
|
||||||
|
strike=selected_snapshot.strike,
|
||||||
|
expiry=selected_snapshot.expiry,
|
||||||
|
quantity=quantity,
|
||||||
|
entry_price=selected_snapshot.mid,
|
||||||
|
current_mark=selected_snapshot.mid,
|
||||||
|
last_mark_date=as_of_date,
|
||||||
|
source_snapshot_date=as_of_date,
|
||||||
|
)
|
||||||
|
|
||||||
|
def mark_position(
|
||||||
|
self,
|
||||||
|
position: HistoricalOptionPosition,
|
||||||
|
*,
|
||||||
|
symbol: str,
|
||||||
|
as_of_date: date,
|
||||||
|
spot: float,
|
||||||
|
) -> HistoricalOptionMark:
|
||||||
|
if as_of_date >= position.expiry:
|
||||||
|
intrinsic = SyntheticHistoricalProvider.intrinsic_value(
|
||||||
|
option_type=position.option_type,
|
||||||
|
spot=spot,
|
||||||
|
strike=position.strike,
|
||||||
|
)
|
||||||
|
return HistoricalOptionMark(
|
||||||
|
contract_key=position.contract_key,
|
||||||
|
mark=0.0,
|
||||||
|
source="intrinsic_expiry",
|
||||||
|
is_active=False,
|
||||||
|
realized_cashflow=intrinsic * position.quantity,
|
||||||
|
)
|
||||||
|
|
||||||
|
exact_snapshot = next(
|
||||||
|
(
|
||||||
|
snapshot
|
||||||
|
for snapshot in self.snapshot_source.load_option_chain(symbol, as_of_date)
|
||||||
|
if snapshot.contract_key == position.contract_key
|
||||||
|
),
|
||||||
|
None,
|
||||||
|
)
|
||||||
|
if exact_snapshot is not None:
|
||||||
|
position.current_mark = exact_snapshot.mid
|
||||||
|
position.last_mark_date = as_of_date
|
||||||
|
return HistoricalOptionMark(
|
||||||
|
contract_key=position.contract_key,
|
||||||
|
mark=exact_snapshot.mid,
|
||||||
|
source="snapshot_mid",
|
||||||
|
is_active=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
if position.current_mark < 0:
|
||||||
|
raise ValueError(f"Missing historical mark for {position.contract_key} on {as_of_date.isoformat()}")
|
||||||
|
return HistoricalOptionMark(
|
||||||
|
contract_key=position.contract_key,
|
||||||
|
mark=position.current_mark,
|
||||||
|
source="carry_forward",
|
||||||
|
is_active=True,
|
||||||
|
warning=(
|
||||||
|
f"Missing historical mark for {position.contract_key} on {as_of_date.isoformat()}; "
|
||||||
|
f"carrying forward prior mark from {position.last_mark_date.isoformat()}."
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
def _select_entry_snapshot(
|
||||||
|
self,
|
||||||
|
*,
|
||||||
|
symbol: str,
|
||||||
|
leg: TemplateLeg,
|
||||||
|
as_of_date: date,
|
||||||
|
spot: float,
|
||||||
|
) -> DailyOptionSnapshot:
|
||||||
|
target_expiry = date.fromordinal(as_of_date.toordinal() + leg.target_expiry_days)
|
||||||
|
target_strike = spot * leg.strike_rule.value
|
||||||
|
chain = [
|
||||||
|
snapshot
|
||||||
|
for snapshot in self.snapshot_source.load_option_chain(symbol, as_of_date)
|
||||||
|
if snapshot.symbol.strip().upper() == symbol.strip().upper() and snapshot.option_type == leg.option_type
|
||||||
|
]
|
||||||
|
eligible_expiries = [snapshot for snapshot in chain if snapshot.expiry >= target_expiry]
|
||||||
|
if not eligible_expiries:
|
||||||
|
raise ValueError(
|
||||||
|
f"No eligible historical option snapshots found for {symbol} on {as_of_date.isoformat()} "
|
||||||
|
f"at or beyond target expiry {target_expiry.isoformat()}"
|
||||||
|
)
|
||||||
|
selected_expiry = min(
|
||||||
|
eligible_expiries,
|
||||||
|
key=lambda snapshot: ((snapshot.expiry - target_expiry).days, snapshot.expiry),
|
||||||
|
).expiry
|
||||||
|
expiry_matches = [snapshot for snapshot in eligible_expiries if snapshot.expiry == selected_expiry]
|
||||||
|
return min(
|
||||||
|
expiry_matches, key=lambda snapshot: self._strike_sort_key(snapshot.strike, target_strike, leg.option_type)
|
||||||
|
)
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _strike_sort_key(strike: float, target_strike: float, option_type: str) -> tuple[float, float]:
|
||||||
|
if option_type == "put":
|
||||||
|
return (abs(strike - target_strike), -strike)
|
||||||
|
return (abs(strike - target_strike), strike)
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ from math import isclose
|
|||||||
from app.backtesting.engine import SyntheticBacktestEngine
|
from app.backtesting.engine import SyntheticBacktestEngine
|
||||||
from app.models.backtest import BacktestRunResult, BacktestScenario
|
from app.models.backtest import BacktestRunResult, BacktestScenario
|
||||||
from app.models.strategy_template import StrategyTemplate
|
from app.models.strategy_template import StrategyTemplate
|
||||||
from app.services.backtesting.historical_provider import SyntheticHistoricalProvider
|
from app.services.backtesting.historical_provider import BacktestHistoricalProvider, SyntheticHistoricalProvider
|
||||||
from app.services.strategy_templates import StrategyTemplateService
|
from app.services.strategy_templates import StrategyTemplateService
|
||||||
|
|
||||||
|
|
||||||
@@ -15,7 +15,7 @@ class BacktestService:
|
|||||||
|
|
||||||
def __init__(
|
def __init__(
|
||||||
self,
|
self,
|
||||||
provider: SyntheticHistoricalProvider | None = None,
|
provider: BacktestHistoricalProvider | None = None,
|
||||||
template_service: StrategyTemplateService | None = None,
|
template_service: StrategyTemplateService | None = None,
|
||||||
) -> None:
|
) -> None:
|
||||||
self.provider = provider or SyntheticHistoricalProvider()
|
self.provider = provider or SyntheticHistoricalProvider()
|
||||||
@@ -59,15 +59,15 @@ class BacktestService:
|
|||||||
|
|
||||||
return BacktestRunResult(scenario_id=scenario.scenario_id, template_results=tuple(template_results))
|
return BacktestRunResult(scenario_id=scenario.scenario_id, template_results=tuple(template_results))
|
||||||
|
|
||||||
@staticmethod
|
def _validate_template_for_mvp(self, template: StrategyTemplate) -> None:
|
||||||
def _validate_template_for_mvp(template: StrategyTemplate) -> None:
|
provider_label = (
|
||||||
if template.contract_mode != "continuous_units":
|
"historical snapshot engine" if self.provider.pricing_mode == "snapshot_mid" else "synthetic MVP engine"
|
||||||
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.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:
|
if template.entry_policy.stagger_days is not None:
|
||||||
raise ValueError("Unsupported entry_policy configuration for synthetic MVP engine")
|
raise ValueError(f"Unsupported entry_policy configuration for {provider_label}")
|
||||||
|
|||||||
@@ -15,6 +15,8 @@ from app.models.backtest import (
|
|||||||
)
|
)
|
||||||
from app.services.backtesting.historical_provider import (
|
from app.services.backtesting.historical_provider import (
|
||||||
DailyClosePoint,
|
DailyClosePoint,
|
||||||
|
HistoricalOptionMark,
|
||||||
|
HistoricalOptionPosition,
|
||||||
SyntheticHistoricalProvider,
|
SyntheticHistoricalProvider,
|
||||||
SyntheticOptionQuote,
|
SyntheticOptionQuote,
|
||||||
)
|
)
|
||||||
@@ -88,6 +90,12 @@ class FixtureBoundHistoricalProvider:
|
|||||||
def price_option(self, **kwargs: object) -> SyntheticOptionQuote:
|
def price_option(self, **kwargs: object) -> SyntheticOptionQuote:
|
||||||
return self.base_provider.price_option(**kwargs)
|
return self.base_provider.price_option(**kwargs)
|
||||||
|
|
||||||
|
def open_position(self, **kwargs: object) -> HistoricalOptionPosition:
|
||||||
|
return self.base_provider.open_position(**kwargs)
|
||||||
|
|
||||||
|
def mark_position(self, position: HistoricalOptionPosition, **kwargs: object) -> HistoricalOptionMark:
|
||||||
|
return self.base_provider.mark_position(position, **kwargs)
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def intrinsic_value(*, option_type: str, spot: float, strike: float) -> float:
|
def intrinsic_value(*, option_type: str, spot: float, strike: float) -> float:
|
||||||
return SyntheticHistoricalProvider.intrinsic_value(option_type=option_type, spot=spot, strike=strike)
|
return SyntheticHistoricalProvider.intrinsic_value(option_type=option_type, spot=spot, strike=strike)
|
||||||
|
|||||||
@@ -135,6 +135,18 @@ class EventComparisonPageService:
|
|||||||
preset = self.event_preset_service.get_preset(preset_slug)
|
preset = self.event_preset_service.get_preset(preset_slug)
|
||||||
return tuple(preset.scenario_overrides.default_template_slugs)
|
return tuple(preset.scenario_overrides.default_template_slugs)
|
||||||
|
|
||||||
|
def derive_entry_spot(self, *, preset_slug: str, template_slugs: tuple[str, ...]) -> float:
|
||||||
|
if not template_slugs:
|
||||||
|
raise ValueError("Select at least one strategy template.")
|
||||||
|
scenario = self.comparison_service.preview_scenario_from_inputs(
|
||||||
|
preset_slug=preset_slug,
|
||||||
|
template_slugs=template_slugs,
|
||||||
|
underlying_units=1.0,
|
||||||
|
loan_amount=0.0,
|
||||||
|
margin_call_ltv=0.75,
|
||||||
|
)
|
||||||
|
return float(scenario.initial_portfolio.entry_spot)
|
||||||
|
|
||||||
def preview_scenario(
|
def preview_scenario(
|
||||||
self,
|
self,
|
||||||
*,
|
*,
|
||||||
|
|||||||
66
docs/BT-002_HISTORICAL_OPTIONS_SNAPSHOT_PROVIDER.md
Normal file
66
docs/BT-002_HISTORICAL_OPTIONS_SNAPSHOT_PROVIDER.md
Normal file
@@ -0,0 +1,66 @@
|
|||||||
|
# BT-002 Historical Options Snapshot Provider
|
||||||
|
|
||||||
|
## What shipped
|
||||||
|
|
||||||
|
BT-002 adds a point-in-time historical options snapshot provider for backtests.
|
||||||
|
|
||||||
|
The new provider lives in `app/services/backtesting/historical_provider.py` and plugs into the same `BacktestService` / engine flow as the existing synthetic provider.
|
||||||
|
|
||||||
|
## Provider contract
|
||||||
|
|
||||||
|
The snapshot provider exposes the same backtest-facing behaviors as the synthetic provider:
|
||||||
|
|
||||||
|
- load underlying daily closes for the scenario window
|
||||||
|
- validate `ProviderRef`
|
||||||
|
- open positions at scenario start using only the entry-day snapshot
|
||||||
|
- mark open positions later using the exact same contract identity
|
||||||
|
|
||||||
|
This lets backtests swap:
|
||||||
|
|
||||||
|
- synthetic pricing: `synthetic_v1 / synthetic_bs_mid`
|
||||||
|
- observed snapshot pricing: `daily_snapshots_v1 / snapshot_mid`
|
||||||
|
|
||||||
|
## Contract-selection rules
|
||||||
|
|
||||||
|
The provider uses explicit, deterministic, point-in-time rules:
|
||||||
|
|
||||||
|
1. filter to the entry-day option chain only
|
||||||
|
2. keep contracts with expiry at or beyond the target expiry date
|
||||||
|
3. choose the nearest eligible expiry
|
||||||
|
4. within that expiry, choose the nearest strike to the target strike
|
||||||
|
5. on equal-distance strike ties:
|
||||||
|
- puts prefer the higher strike
|
||||||
|
- calls prefer the lower strike
|
||||||
|
|
||||||
|
These rules avoid lookahead bias because later snapshots are not consulted for entry selection.
|
||||||
|
|
||||||
|
## Daily mark-to-market rules
|
||||||
|
|
||||||
|
After entry, the provider marks positions using the exact same `contract_key`.
|
||||||
|
|
||||||
|
It does **not** silently substitute a different strike or expiry when the original contract is missing.
|
||||||
|
|
||||||
|
Current fallback policy:
|
||||||
|
|
||||||
|
1. use the exact same contract from the same-day snapshot
|
||||||
|
2. if missing before expiry, carry forward the previous mark for that same contract and emit a warning
|
||||||
|
3. if the valuation date is at or after expiry, settle to intrinsic value and close the position
|
||||||
|
|
||||||
|
## Data-quality tradeoffs
|
||||||
|
|
||||||
|
The current BT-002 slice intentionally keeps the data model simple:
|
||||||
|
|
||||||
|
- snapshots are assumed to provide a precomputed daily `mid`
|
||||||
|
- the provider does not currently derive mids from bid/ask pairs
|
||||||
|
- missing exact-contract marks are explicit warnings, not silent substitutions
|
||||||
|
- the engine currently still supports `continuous_units` sizing for snapshot-backed runs
|
||||||
|
|
||||||
|
## Known limitations / follow-up
|
||||||
|
|
||||||
|
This slice does **not** yet include:
|
||||||
|
|
||||||
|
- file-backed or external ingestion of real historical snapshot datasets
|
||||||
|
- listed-contract rounding / contract-size-aware position sizing
|
||||||
|
- persistent run-status objects beyond template-level warnings
|
||||||
|
|
||||||
|
Those follow-ups should remain explicit roadmap work rather than being implied by BT-002.
|
||||||
@@ -700,7 +700,8 @@ If this changes later, it must be a scenario-level parameter.
|
|||||||
## 5. Continuous-vs-listed quantity must be explicit
|
## 5. Continuous-vs-listed quantity must be explicit
|
||||||
|
|
||||||
MVP synthetic runs may use `continuous_units`.
|
MVP synthetic runs may use `continuous_units`.
|
||||||
BT-002 listed snapshot runs should support `listed_contracts` with contract-size rounding.
|
The shipped BT-002 provider slice also remains `continuous_units`-only.
|
||||||
|
`listed_contracts` with contract-size rounding is deferred to follow-up slice `BT-002A`.
|
||||||
|
|
||||||
Do not hide rounding rules inside providers.
|
Do not hide rounding rules inside providers.
|
||||||
They belong in the position sizing logic and must be recorded in the result.
|
They belong in the position sizing logic and must be recorded in the result.
|
||||||
@@ -714,9 +715,9 @@ Do not collapse the entire hedge economics into end-of-period payoff only.
|
|||||||
|
|
||||||
Any missing snapshot/mark fallback must add:
|
Any missing snapshot/mark fallback must add:
|
||||||
|
|
||||||
- a run warning
|
- a run or template warning
|
||||||
- a template validation note
|
- a template validation note
|
||||||
- a deterministic result status if the template becomes incomplete
|
- and, in a fuller follow-up slice, a deterministic result status if the template becomes incomplete
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|||||||
@@ -13,7 +13,6 @@ notes:
|
|||||||
- Pre-alpha policy: we may cut or replace old features without backward compatibility until alpha is declared.
|
- Pre-alpha policy: we may cut or replace old features without backward compatibility until alpha is declared.
|
||||||
- Alpha migration policy: once alpha is declared, compatibility only needs to move forward; backward migrations are not required.
|
- Alpha migration policy: once alpha is declared, compatibility only needs to move forward; backward migrations are not required.
|
||||||
priority_queue:
|
priority_queue:
|
||||||
- BT-002
|
|
||||||
- BT-001C
|
- BT-001C
|
||||||
- EXEC-001
|
- EXEC-001
|
||||||
- EXEC-002
|
- EXEC-002
|
||||||
@@ -21,7 +20,9 @@ priority_queue:
|
|||||||
- DATA-001A
|
- DATA-001A
|
||||||
- OPS-001
|
- OPS-001
|
||||||
- BT-003
|
- BT-003
|
||||||
|
- BT-002A
|
||||||
recently_completed:
|
recently_completed:
|
||||||
|
- BT-002
|
||||||
- PORT-003
|
- PORT-003
|
||||||
- BT-003B
|
- BT-003B
|
||||||
- CORE-001D
|
- CORE-001D
|
||||||
@@ -43,9 +44,9 @@ states:
|
|||||||
- OPS-001
|
- OPS-001
|
||||||
- EXEC-001
|
- EXEC-001
|
||||||
- EXEC-002
|
- EXEC-002
|
||||||
- BT-002
|
|
||||||
- BT-003
|
- BT-003
|
||||||
- BT-001C
|
- BT-001C
|
||||||
|
- BT-002A
|
||||||
in_progress: []
|
in_progress: []
|
||||||
done:
|
done:
|
||||||
- DATA-001
|
- DATA-001
|
||||||
@@ -61,6 +62,7 @@ states:
|
|||||||
- EXEC-001A
|
- EXEC-001A
|
||||||
- BT-001
|
- BT-001
|
||||||
- BT-001A
|
- BT-001A
|
||||||
|
- BT-002
|
||||||
- BT-003A
|
- BT-003A
|
||||||
- BT-003B
|
- BT-003B
|
||||||
- CORE-001A
|
- CORE-001A
|
||||||
|
|||||||
@@ -1,14 +0,0 @@
|
|||||||
id: BT-002
|
|
||||||
title: Historical Daily Options Snapshot Provider
|
|
||||||
status: backlog
|
|
||||||
priority: P2
|
|
||||||
effort: L
|
|
||||||
depends_on:
|
|
||||||
- BT-001
|
|
||||||
tags: [backtesting, data]
|
|
||||||
summary: Support real daily historical options premiums in backtests.
|
|
||||||
acceptance_criteria:
|
|
||||||
- Historical provider abstraction supports point-in-time daily option snapshots.
|
|
||||||
- Backtests can swap synthetic pricing for observed historical premiums.
|
|
||||||
- Contract selection avoids lookahead bias.
|
|
||||||
- Provider/data-quality tradeoffs are documented.
|
|
||||||
@@ -0,0 +1,16 @@
|
|||||||
|
id: BT-002A
|
||||||
|
title: Snapshot Ingestion and Listed Contract Sizing
|
||||||
|
status: backlog
|
||||||
|
priority: P3
|
||||||
|
effort: M
|
||||||
|
depends_on:
|
||||||
|
- BT-002
|
||||||
|
tags:
|
||||||
|
- backtesting
|
||||||
|
- data
|
||||||
|
summary: Extend BT-002 from provider support to file-backed/external snapshot ingestion and listed-contract sizing semantics.
|
||||||
|
acceptance_criteria:
|
||||||
|
- Historical snapshot data can be loaded from a documented file-backed or external source, not only injected in-memory fixtures.
|
||||||
|
- Snapshot-backed runs can size positions in listed contract units with explicit contract-size rounding rules.
|
||||||
|
- Snapshot data-quality warnings and incomplete-run behavior are persisted/reportable, not only template-local warnings.
|
||||||
|
- Provider configuration and snapshot-source assumptions are documented for reproducible runs.
|
||||||
20
docs/roadmap/done/BT-002-historical-options-snapshots.yaml
Normal file
20
docs/roadmap/done/BT-002-historical-options-snapshots.yaml
Normal file
@@ -0,0 +1,20 @@
|
|||||||
|
id: BT-002
|
||||||
|
title: Historical Daily Options Snapshot Provider
|
||||||
|
status: done
|
||||||
|
priority: P2
|
||||||
|
effort: L
|
||||||
|
depends_on:
|
||||||
|
- BT-001
|
||||||
|
tags:
|
||||||
|
- backtesting
|
||||||
|
- data
|
||||||
|
summary: Backtests can now use a point-in-time historical options snapshot provider with exact-contract mark-to-market instead of synthetic-only option pricing.
|
||||||
|
completed_notes:
|
||||||
|
- Added shared historical position/mark provider hooks in `app/services/backtesting/historical_provider.py` so `BacktestService` can swap provider implementations while preserving the backtest engine flow.
|
||||||
|
- Snapshot-backed runs still fail closed on `listed_contracts`; BT-002 ships observed snapshot pricing for `continuous_units` only, with listed-contract sizing explicitly deferred to `BT-002A`.
|
||||||
|
- Added `DailyOptionsSnapshotProvider` with deterministic entry-day contract selection, exact-contract mark-to-market, and explicit carry-forward warnings when later marks are missing.
|
||||||
|
- Updated `app/backtesting/engine.py` and `app/services/backtesting/service.py` so snapshot-backed runs and synthetic runs share the same scenario execution path.
|
||||||
|
- Added focused regression coverage in `tests/test_backtesting_snapshots.py` for entry-day-only selection, observed snapshot marks, and no-substitution missing-mark fallback behavior.
|
||||||
|
- Added provider/data-quality documentation in `docs/BT-002_HISTORICAL_OPTIONS_SNAPSHOT_PROVIDER.md`, including current limitations around precomputed mids, continuous-units sizing, and follow-up ingestion work.
|
||||||
|
- Docker-served browser validation still passed on the affected historical routes after the engine/provider seam changes: `/health` returned OK and `tests/test_e2e_playwright.py` passed against the local Docker app.
|
||||||
|
- While closing that browser loop, `/{workspace_id}/event-comparison` preset changes were corrected to preserve user-edited underlying units and only reset preset-driven template selection, matching the UI copy and stale-state behavior.
|
||||||
260
tests/test_backtesting_snapshots.py
Normal file
260
tests/test_backtesting_snapshots.py
Normal file
@@ -0,0 +1,260 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from datetime import date
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from app.models.backtest import BacktestPortfolioState, BacktestScenario, ProviderRef, TemplateRef
|
||||||
|
from app.models.strategy_template import StrategyTemplate, StrikeRule, TemplateLeg
|
||||||
|
from app.services.backtesting.historical_provider import (
|
||||||
|
DailyClosePoint,
|
||||||
|
DailyOptionSnapshot,
|
||||||
|
DailyOptionsSnapshotProvider,
|
||||||
|
)
|
||||||
|
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)
|
||||||
|
|
||||||
|
|
||||||
|
class FakeOptionSnapshotSource:
|
||||||
|
def __init__(self, chains: dict[date, list[DailyOptionSnapshot]]) -> None:
|
||||||
|
self.chains = chains
|
||||||
|
self.calls: list[tuple[str, date]] = []
|
||||||
|
|
||||||
|
def load_option_chain(self, symbol: str, snapshot_date: date) -> list[DailyOptionSnapshot]:
|
||||||
|
self.calls.append((symbol, snapshot_date))
|
||||||
|
return list(self.chains.get(snapshot_date, []))
|
||||||
|
|
||||||
|
|
||||||
|
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 _snapshot(
|
||||||
|
contract_key: str,
|
||||||
|
*,
|
||||||
|
snapshot_date: date,
|
||||||
|
expiry: date,
|
||||||
|
strike: float,
|
||||||
|
mid: float,
|
||||||
|
option_type: str = "put",
|
||||||
|
) -> DailyOptionSnapshot:
|
||||||
|
return DailyOptionSnapshot(
|
||||||
|
contract_key=contract_key,
|
||||||
|
symbol="GLD",
|
||||||
|
snapshot_date=snapshot_date,
|
||||||
|
expiry=expiry,
|
||||||
|
option_type=option_type,
|
||||||
|
strike=strike,
|
||||||
|
mid=mid,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
SNAPSHOT_CHAINS = {
|
||||||
|
date(2024, 1, 2): [
|
||||||
|
_snapshot(
|
||||||
|
"GLD-2024-12-20-P-100",
|
||||||
|
snapshot_date=date(2024, 1, 2),
|
||||||
|
expiry=date(2024, 12, 20),
|
||||||
|
strike=100.0,
|
||||||
|
mid=5.5,
|
||||||
|
),
|
||||||
|
_snapshot(
|
||||||
|
"GLD-2025-01-03-P-95",
|
||||||
|
snapshot_date=date(2024, 1, 2),
|
||||||
|
expiry=date(2025, 1, 3),
|
||||||
|
strike=95.0,
|
||||||
|
mid=2.5,
|
||||||
|
),
|
||||||
|
_snapshot(
|
||||||
|
"GLD-2025-01-03-P-100",
|
||||||
|
snapshot_date=date(2024, 1, 2),
|
||||||
|
expiry=date(2025, 1, 3),
|
||||||
|
strike=100.0,
|
||||||
|
mid=4.0,
|
||||||
|
),
|
||||||
|
],
|
||||||
|
date(2024, 1, 3): [
|
||||||
|
_snapshot(
|
||||||
|
"GLD-2025-01-03-P-100",
|
||||||
|
snapshot_date=date(2024, 1, 3),
|
||||||
|
expiry=date(2025, 1, 3),
|
||||||
|
strike=100.0,
|
||||||
|
mid=6.0,
|
||||||
|
),
|
||||||
|
],
|
||||||
|
date(2024, 1, 4): [
|
||||||
|
_snapshot(
|
||||||
|
"GLD-2025-01-03-P-95",
|
||||||
|
snapshot_date=date(2024, 1, 4),
|
||||||
|
expiry=date(2025, 1, 3),
|
||||||
|
strike=95.0,
|
||||||
|
mid=8.5,
|
||||||
|
),
|
||||||
|
],
|
||||||
|
date(2024, 1, 5): [
|
||||||
|
_snapshot(
|
||||||
|
"GLD-2025-01-03-P-100",
|
||||||
|
snapshot_date=date(2024, 1, 5),
|
||||||
|
expiry=date(2025, 1, 3),
|
||||||
|
strike=100.0,
|
||||||
|
mid=11.0,
|
||||||
|
),
|
||||||
|
],
|
||||||
|
date(2024, 1, 8): [
|
||||||
|
_snapshot(
|
||||||
|
"GLD-2025-01-03-P-100",
|
||||||
|
snapshot_date=date(2024, 1, 8),
|
||||||
|
expiry=date(2025, 1, 3),
|
||||||
|
strike=100.0,
|
||||||
|
mid=15.0,
|
||||||
|
),
|
||||||
|
],
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _build_snapshot_scenario() -> BacktestScenario:
|
||||||
|
return BacktestScenario(
|
||||||
|
scenario_id="gld-snapshot-selloff-1",
|
||||||
|
display_name="GLD snapshot selloff",
|
||||||
|
symbol="GLD",
|
||||||
|
start_date=date(2024, 1, 2),
|
||||||
|
end_date=date(2024, 1, 8),
|
||||||
|
initial_portfolio=BacktestPortfolioState(
|
||||||
|
currency="USD",
|
||||||
|
underlying_units=1000.0,
|
||||||
|
entry_spot=100.0,
|
||||||
|
loan_amount=68_000.0,
|
||||||
|
margin_call_ltv=0.75,
|
||||||
|
),
|
||||||
|
template_refs=(TemplateRef(slug="protective-put-atm-12m", version=1),),
|
||||||
|
provider_ref=ProviderRef(provider_id="daily_snapshots_v1", pricing_mode="snapshot_mid"),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_daily_snapshot_provider_selects_entry_contract_from_entry_day_only() -> None:
|
||||||
|
history_source = FakeHistorySource(FIXTURE_HISTORY)
|
||||||
|
snapshot_source = FakeOptionSnapshotSource(SNAPSHOT_CHAINS)
|
||||||
|
provider = DailyOptionsSnapshotProvider(price_source=history_source, snapshot_source=snapshot_source)
|
||||||
|
history = provider.load_history("GLD", date(2024, 1, 2), date(2024, 1, 8))
|
||||||
|
tie_leg = TemplateLeg(
|
||||||
|
leg_id="leg-1",
|
||||||
|
side="long",
|
||||||
|
option_type="put",
|
||||||
|
allocation_weight=1.0,
|
||||||
|
strike_rule=StrikeRule(rule_type="spot_pct", value=0.975),
|
||||||
|
target_expiry_days=365,
|
||||||
|
quantity_rule="target_coverage_pct",
|
||||||
|
)
|
||||||
|
|
||||||
|
position = provider.open_position(
|
||||||
|
symbol="GLD",
|
||||||
|
leg=tie_leg,
|
||||||
|
position_id="pos-1",
|
||||||
|
quantity=1000.0,
|
||||||
|
as_of_date=date(2024, 1, 2),
|
||||||
|
spot=100.0,
|
||||||
|
trading_days=history,
|
||||||
|
)
|
||||||
|
|
||||||
|
assert position.contract_key == "GLD-2025-01-03-P-100"
|
||||||
|
assert position.expiry == date(2025, 1, 3)
|
||||||
|
assert position.strike == 100.0
|
||||||
|
assert position.entry_price == 4.0
|
||||||
|
assert snapshot_source.calls == [("GLD", date(2024, 1, 2))]
|
||||||
|
|
||||||
|
|
||||||
|
def test_backtest_service_uses_observed_snapshot_marks() -> None:
|
||||||
|
provider = DailyOptionsSnapshotProvider(
|
||||||
|
price_source=FakeHistorySource(FIXTURE_HISTORY),
|
||||||
|
snapshot_source=FakeOptionSnapshotSource(SNAPSHOT_CHAINS),
|
||||||
|
)
|
||||||
|
service = BacktestService(provider=provider, template_service=StrategyTemplateService())
|
||||||
|
|
||||||
|
result = service.run_scenario(_build_snapshot_scenario())
|
||||||
|
|
||||||
|
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.total_hedge_cost == 4_000.0
|
||||||
|
assert template_result.daily_path[0].premium_cashflow == -4_000.0
|
||||||
|
assert template_result.daily_path[1].option_market_value == 6_000.0
|
||||||
|
assert template_result.daily_path[-1].option_market_value == 15_000.0
|
||||||
|
assert summary.end_value_hedged_net == 96_000.0
|
||||||
|
assert summary.end_value_hedged_net > summary.end_value_unhedged
|
||||||
|
assert template_result.warnings == (
|
||||||
|
"Missing historical mark for GLD-2025-01-03-P-100 on 2024-01-04; carrying forward prior mark from 2024-01-03.",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_backtest_snapshot_provider_does_not_substitute_new_contract_when_mark_missing() -> None:
|
||||||
|
provider = DailyOptionsSnapshotProvider(
|
||||||
|
price_source=FakeHistorySource(FIXTURE_HISTORY),
|
||||||
|
snapshot_source=FakeOptionSnapshotSource(SNAPSHOT_CHAINS),
|
||||||
|
)
|
||||||
|
service = BacktestService(provider=provider, template_service=StrategyTemplateService())
|
||||||
|
|
||||||
|
result = service.run_scenario(_build_snapshot_scenario())
|
||||||
|
|
||||||
|
template_result = result.template_results[0]
|
||||||
|
carry_forward_day = next(point for point in template_result.daily_path if point.date == date(2024, 1, 4))
|
||||||
|
|
||||||
|
assert carry_forward_day.option_market_value == 6_000.0
|
||||||
|
assert carry_forward_day.active_position_ids == ("protective-put-atm-12m-position-1",)
|
||||||
|
assert template_result.warnings[0].startswith("Missing historical mark for GLD-2025-01-03-P-100")
|
||||||
|
|
||||||
|
|
||||||
|
def test_daily_snapshot_provider_rejects_unsupported_provider_ref() -> None:
|
||||||
|
provider = DailyOptionsSnapshotProvider(
|
||||||
|
price_source=FakeHistorySource(FIXTURE_HISTORY),
|
||||||
|
snapshot_source=FakeOptionSnapshotSource(SNAPSHOT_CHAINS),
|
||||||
|
)
|
||||||
|
|
||||||
|
with pytest.raises(ValueError, match="Unsupported provider/pricing combination"):
|
||||||
|
provider.validate_provider_ref(ProviderRef(provider_id="synthetic_v1", pricing_mode="synthetic_bs_mid"))
|
||||||
|
|
||||||
|
|
||||||
|
def test_snapshot_backtest_rejects_listed_contract_templates_until_bt_002a() -> None:
|
||||||
|
provider = DailyOptionsSnapshotProvider(
|
||||||
|
price_source=FakeHistorySource(FIXTURE_HISTORY),
|
||||||
|
snapshot_source=FakeOptionSnapshotSource(SNAPSHOT_CHAINS),
|
||||||
|
)
|
||||||
|
template_service = StrategyTemplateService()
|
||||||
|
service = BacktestService(provider=provider, template_service=template_service)
|
||||||
|
template = template_service.get_template("protective-put-atm-12m")
|
||||||
|
listed_template = StrategyTemplate(
|
||||||
|
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="listed_contracts",
|
||||||
|
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,
|
||||||
|
)
|
||||||
|
|
||||||
|
with pytest.raises(ValueError, match="Unsupported contract_mode"):
|
||||||
|
service._validate_template_for_mvp(listed_template)
|
||||||
@@ -148,6 +148,21 @@ def test_event_comparison_page_service_preview_uses_same_materialization_path()
|
|||||||
assert [ref.slug for ref in scenario.template_refs] == ["protective-put-atm-12m"]
|
assert [ref.slug for ref in scenario.template_refs] == ["protective-put-atm-12m"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_event_comparison_page_service_derives_entry_spot_without_caller_collateral_validation() -> None:
|
||||||
|
service = EventComparisonPageService()
|
||||||
|
|
||||||
|
entry_spot = service.derive_entry_spot(
|
||||||
|
preset_slug="gld-jan-2024-drawdown",
|
||||||
|
template_slugs=(
|
||||||
|
"protective-put-atm-12m",
|
||||||
|
"ladder-50-50-atm-95pct-12m",
|
||||||
|
"ladder-33-33-33-atm-95pct-90pct-12m",
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
assert entry_spot == 100.0
|
||||||
|
|
||||||
|
|
||||||
def test_event_comparison_page_service_rejects_undercollateralized_historical_start() -> None:
|
def test_event_comparison_page_service_rejects_undercollateralized_historical_start() -> None:
|
||||||
service = EventComparisonPageService()
|
service = EventComparisonPageService()
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user