diff --git a/app/backtesting/engine.py b/app/backtesting/engine.py index 447ee9b..d0af983 100644 --- a/app/backtesting/engine.py +++ b/app/backtesting/engine.py @@ -1,7 +1,6 @@ from __future__ import annotations from dataclasses import dataclass -from datetime import date from app.models.backtest import ( BacktestDailyPoint, @@ -9,21 +8,21 @@ from app.models.backtest import ( BacktestSummaryMetrics, TemplateBacktestResult, ) -from app.models.strategy_template import StrategyTemplate, TemplateLeg -from app.services.backtesting.historical_provider import DailyClosePoint, SyntheticHistoricalProvider +from app.models.strategy_template import StrategyTemplate +from app.services.backtesting.historical_provider import ( + BacktestHistoricalProvider, + DailyClosePoint, + HistoricalOptionPosition, +) @dataclass -class OpenSyntheticPosition: - position_id: str - leg: TemplateLeg - strike: float - expiry: date - quantity: float +class OpenSyntheticPosition(HistoricalOptionPosition): + pass class SyntheticBacktestEngine: - def __init__(self, provider: SyntheticHistoricalProvider) -> None: + def __init__(self, provider: BacktestHistoricalProvider) -> None: self.provider = provider def run_template( @@ -36,9 +35,9 @@ class SyntheticBacktestEngine: cash_balance = scenario.initial_portfolio.cash_balance total_hedge_cost = 0.0 total_option_payoff_realized = 0.0 + warnings: list[str] = [] 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) + opening_cost = sum(position.entry_price * position.quantity for position in open_positions) cash_balance -= opening_cost total_hedge_cost += opening_cost @@ -48,23 +47,23 @@ class SyntheticBacktestEngine: realized_option_cashflow = 0.0 option_market_value = 0.0 active_position_ids: list[str] = [] - remaining_positions: list[OpenSyntheticPosition] = [] + remaining_positions: list[HistoricalOptionPosition] = [] 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 + valuation = self.provider.mark_position( + position, + symbol=scenario.symbol, + as_of_date=day.date, + spot=day.close, + ) + self._append_warning(warnings, valuation.warning) + if not valuation.is_active: + cash_balance += valuation.realized_cashflow + realized_option_cashflow += valuation.realized_cashflow + total_option_payoff_realized += valuation.realized_cashflow continue - quote = self._mark_position(position, day) - option_market_value += quote.mark * position.quantity + option_market_value += valuation.mark * position.quantity active_position_ids.append(position.position_id) remaining_positions.append(position) @@ -113,6 +112,7 @@ class SyntheticBacktestEngine: template_name=template.display_name, summary_metrics=summary, daily_path=tuple(daily_points), + warnings=tuple(warnings), ) def _open_positions( @@ -121,30 +121,25 @@ class SyntheticBacktestEngine: template: StrategyTemplate, history: list[DailyClosePoint], start_day: DailyClosePoint, - ) -> list[OpenSyntheticPosition]: - positions: list[OpenSyntheticPosition] = [] + ) -> list[HistoricalOptionPosition]: + positions: list[HistoricalOptionPosition] = [] 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}", + self.provider.open_position( + symbol=scenario.symbol, 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, + position_id=f"{template.slug}-position-{index}", + quantity=( + scenario.initial_portfolio.underlying_units * leg.allocation_weight * leg.target_coverage_pct + ), + as_of_date=start_day.date, + spot=start_day.close, + trading_days=history, ) ) 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, - ) + @staticmethod + def _append_warning(warnings: list[str], warning: str | None) -> None: + if warning and warning not in warnings: + warnings.append(warning) diff --git a/app/models/backtest.py b/app/models/backtest.py index 4d907f0..40c34d8 100644 --- a/app/models/backtest.py +++ b/app/models/backtest.py @@ -123,6 +123,7 @@ class TemplateBacktestResult: template_name: str summary_metrics: BacktestSummaryMetrics daily_path: tuple[BacktestDailyPoint, ...] + warnings: tuple[str, ...] = field(default_factory=tuple) @dataclass(frozen=True) diff --git a/app/pages/event_comparison.py b/app/pages/event_comparison.py index 8f9a377..fb437fb 100644 --- a/app/pages/event_comparison.py +++ b/app/pages/event_comparison.py @@ -207,16 +207,13 @@ def _render_event_comparison_page(workspace_id: str | None = None) -> None: try: preview_units = float(units_input.value or 0.0) 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"]), 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( config, - entry_spot=float(preview_scenario.initial_portfolio.entry_spot), + entry_spot=preview_entry_spot, symbol="GLD", ) syncing_controls["value"] = True @@ -536,7 +533,7 @@ def _render_event_comparison_page(workspace_id: str | None = None) -> None: if syncing_controls["value"]: return 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: validation_label.set_text(preview_error) render_result_state("Scenario validation failed", preview_error, tone="warning") diff --git a/app/services/backtesting/historical_provider.py b/app/services/backtesting/historical_provider.py index 5927ca5..079916b 100644 --- a/app/services/backtesting/historical_provider.py +++ b/app/services/backtesting/historical_provider.py @@ -55,11 +55,130 @@ class SyntheticOptionQuote: 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): def load_daily_closes(self, symbol: str, start_date: date, end_date: date) -> list[DailyClosePoint]: 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: @staticmethod def _normalize_daily_close_row(*, row_date: object, close: object) -> DailyClosePoint | None: @@ -121,6 +240,79 @@ class SyntheticHistoricalProvider: return day.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( self, *, @@ -131,6 +323,29 @@ class SyntheticHistoricalProvider: expiry: date, quantity: float, 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: remaining_days = max(1, expiry.toordinal() - valuation_date.toordinal()) mark = black_scholes_price_and_greeks( @@ -140,13 +355,13 @@ class SyntheticHistoricalProvider: time_to_expiry=remaining_days / 365.0, risk_free_rate=self.risk_free_rate, volatility=self.implied_volatility, - option_type=leg.option_type, + option_type=option_type, valuation_date=valuation_date, ) ).price return SyntheticOptionQuote( position_id=position_id, - leg_id=leg.leg_id, + leg_id=leg_id, spot=spot, strike=strike, expiry=expiry, @@ -161,3 +376,149 @@ class SyntheticHistoricalProvider: if option_type == "call": return max(spot - strike, 0.0) 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) diff --git a/app/services/backtesting/service.py b/app/services/backtesting/service.py index efa0b83..3481588 100644 --- a/app/services/backtesting/service.py +++ b/app/services/backtesting/service.py @@ -5,7 +5,7 @@ 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.backtesting.historical_provider import BacktestHistoricalProvider, SyntheticHistoricalProvider from app.services.strategy_templates import StrategyTemplateService @@ -15,7 +15,7 @@ class BacktestService: def __init__( self, - provider: SyntheticHistoricalProvider | None = None, + provider: BacktestHistoricalProvider | None = None, template_service: StrategyTemplateService | None = None, ) -> None: self.provider = provider or SyntheticHistoricalProvider() @@ -59,15 +59,15 @@ class BacktestService: return BacktestRunResult(scenario_id=scenario.scenario_id, template_results=tuple(template_results)) - @staticmethod - def _validate_template_for_mvp(template: StrategyTemplate) -> None: + def _validate_template_for_mvp(self, template: StrategyTemplate) -> None: + provider_label = ( + "historical snapshot engine" if self.provider.pricing_mode == "snapshot_mid" else "synthetic MVP engine" + ) if template.contract_mode != "continuous_units": - raise ValueError(f"Unsupported contract_mode for synthetic MVP engine: {template.contract_mode}") + raise ValueError(f"Unsupported contract_mode for {provider_label}: {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}") + 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( - "Unsupported entry_timing for synthetic MVP engine: " f"{template.entry_policy.entry_timing}" - ) + raise ValueError(f"Unsupported entry_timing for {provider_label}: {template.entry_policy.entry_timing}") if template.entry_policy.stagger_days is not None: - raise ValueError("Unsupported entry_policy configuration for synthetic MVP engine") + raise ValueError(f"Unsupported entry_policy configuration for {provider_label}") diff --git a/app/services/backtesting/ui_service.py b/app/services/backtesting/ui_service.py index eb1c2b1..2d80dcf 100644 --- a/app/services/backtesting/ui_service.py +++ b/app/services/backtesting/ui_service.py @@ -15,6 +15,8 @@ from app.models.backtest import ( ) from app.services.backtesting.historical_provider import ( DailyClosePoint, + HistoricalOptionMark, + HistoricalOptionPosition, SyntheticHistoricalProvider, SyntheticOptionQuote, ) @@ -88,6 +90,12 @@ class FixtureBoundHistoricalProvider: def price_option(self, **kwargs: object) -> SyntheticOptionQuote: 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 def intrinsic_value(*, option_type: str, spot: float, strike: float) -> float: return SyntheticHistoricalProvider.intrinsic_value(option_type=option_type, spot=spot, strike=strike) diff --git a/app/services/event_comparison_ui.py b/app/services/event_comparison_ui.py index f27252a..27d9f9e 100644 --- a/app/services/event_comparison_ui.py +++ b/app/services/event_comparison_ui.py @@ -135,6 +135,18 @@ class EventComparisonPageService: preset = self.event_preset_service.get_preset(preset_slug) 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( self, *, diff --git a/docs/BT-002_HISTORICAL_OPTIONS_SNAPSHOT_PROVIDER.md b/docs/BT-002_HISTORICAL_OPTIONS_SNAPSHOT_PROVIDER.md new file mode 100644 index 0000000..04948f5 --- /dev/null +++ b/docs/BT-002_HISTORICAL_OPTIONS_SNAPSHOT_PROVIDER.md @@ -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. diff --git a/docs/EXEC-001A_BT-001_MVP_ARCHITECTURE.md b/docs/EXEC-001A_BT-001_MVP_ARCHITECTURE.md index ddebe2f..c0447f7 100644 --- a/docs/EXEC-001A_BT-001_MVP_ARCHITECTURE.md +++ b/docs/EXEC-001A_BT-001_MVP_ARCHITECTURE.md @@ -700,7 +700,8 @@ If this changes later, it must be a scenario-level parameter. ## 5. Continuous-vs-listed quantity must be explicit 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. 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: -- a run warning +- a run or template warning - 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 --- diff --git a/docs/roadmap/ROADMAP.yaml b/docs/roadmap/ROADMAP.yaml index 1f8e679..23f2cdb 100644 --- a/docs/roadmap/ROADMAP.yaml +++ b/docs/roadmap/ROADMAP.yaml @@ -13,7 +13,6 @@ notes: - 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. priority_queue: - - BT-002 - BT-001C - EXEC-001 - EXEC-002 @@ -21,7 +20,9 @@ priority_queue: - DATA-001A - OPS-001 - BT-003 + - BT-002A recently_completed: + - BT-002 - PORT-003 - BT-003B - CORE-001D @@ -43,9 +44,9 @@ states: - OPS-001 - EXEC-001 - EXEC-002 - - BT-002 - BT-003 - BT-001C + - BT-002A in_progress: [] done: - DATA-001 @@ -61,6 +62,7 @@ states: - EXEC-001A - BT-001 - BT-001A + - BT-002 - BT-003A - BT-003B - CORE-001A diff --git a/docs/roadmap/backlog/BT-002-historical-options-snapshots.yaml b/docs/roadmap/backlog/BT-002-historical-options-snapshots.yaml deleted file mode 100644 index 5b1bcd0..0000000 --- a/docs/roadmap/backlog/BT-002-historical-options-snapshots.yaml +++ /dev/null @@ -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. diff --git a/docs/roadmap/backlog/BT-002A-snapshot-ingestion-and-listed-sizing.yaml b/docs/roadmap/backlog/BT-002A-snapshot-ingestion-and-listed-sizing.yaml new file mode 100644 index 0000000..c9ae4fa --- /dev/null +++ b/docs/roadmap/backlog/BT-002A-snapshot-ingestion-and-listed-sizing.yaml @@ -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. diff --git a/docs/roadmap/done/BT-002-historical-options-snapshots.yaml b/docs/roadmap/done/BT-002-historical-options-snapshots.yaml new file mode 100644 index 0000000..1590c3e --- /dev/null +++ b/docs/roadmap/done/BT-002-historical-options-snapshots.yaml @@ -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. diff --git a/tests/test_backtesting_snapshots.py b/tests/test_backtesting_snapshots.py new file mode 100644 index 0000000..95d32fe --- /dev/null +++ b/tests/test_backtesting_snapshots.py @@ -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) diff --git a/tests/test_event_comparison_ui.py b/tests/test_event_comparison_ui.py index 1c0e4d1..323c002 100644 --- a/tests/test_event_comparison_ui.py +++ b/tests/test_event_comparison_ui.py @@ -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"] +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: service = EventComparisonPageService()