feat(BT-002): add historical snapshot provider
This commit is contained in:
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user