Files
vault-dash/app/backtesting/engine.py
Bu5hm4nn 6b8336ab7e feat: add Portfolio Value, Option Value, and Contracts columns to daily results
- Add option_contracts field to BacktestDailyPoint (number of contracts held)
- Update engine to calculate total option contracts from positions
- Update job serialization to include underlying_value, option_market_value, net_portfolio_value, option_contracts
- Update both render_result and render_job_result tables to show:
  - Low, High, Close (from previous commit)
  - Portfolio value (net_portfolio_value)
  - Option value (option_market_value)
  - Contracts (option_contracts)
  - LTV hedged
  - Margin call status
2026-04-05 08:54:38 +02:00

167 lines
7.2 KiB
Python

from __future__ import annotations
from dataclasses import dataclass
from app.models.backtest import (
BacktestDailyPoint,
BacktestScenario,
BacktestSummaryMetrics,
TemplateBacktestResult,
)
from app.models.strategy_template import StrategyTemplate
from app.services.backtesting.historical_provider import (
BacktestHistoricalProvider,
DailyClosePoint,
HistoricalOptionPosition,
)
@dataclass
class OpenSyntheticPosition(HistoricalOptionPosition):
pass
class SyntheticBacktestEngine:
def __init__(self, provider: BacktestHistoricalProvider) -> None:
self.provider = provider
def run_template(
self,
scenario: BacktestScenario,
template: StrategyTemplate,
history: list[DailyClosePoint],
) -> TemplateBacktestResult:
start_day = history[0]
cash_balance = scenario.initial_portfolio.cash_balance
total_hedge_cost = 0.0
total_option_payoff_realized = 0.0
warnings: list[str] = []
open_positions = self._open_positions(scenario, template, history, start_day)
opening_cost = sum(position.entry_price * position.quantity for position in open_positions)
cash_balance -= opening_cost
total_hedge_cost += opening_cost
daily_points: list[BacktestDailyPoint] = []
for day in history:
premium_cashflow = -opening_cost if day.date == start_day.date else 0.0
realized_option_cashflow = 0.0
option_market_value = 0.0
active_position_ids: list[str] = []
remaining_positions: list[HistoricalOptionPosition] = []
for position in open_positions:
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
option_market_value += valuation.mark * position.quantity
active_position_ids.append(position.position_id)
remaining_positions.append(position)
open_positions = remaining_positions
# Use closing price for portfolio value calculations
underlying_value_close = scenario.initial_portfolio.underlying_units * day.close
net_portfolio_value_close = underlying_value_close + option_market_value + cash_balance
# Use day's low for margin call evaluation (worst case during the day)
# If low is not available, fall back to close
worst_price = day.low if day.low is not None else day.close
underlying_value_worst = scenario.initial_portfolio.underlying_units * worst_price
net_portfolio_value_worst = underlying_value_worst + option_market_value + cash_balance
# LTVs for display (end-of-day at close)
ltv_unhedged = scenario.initial_portfolio.loan_amount / underlying_value_close
ltv_hedged = scenario.initial_portfolio.loan_amount / net_portfolio_value_close
# Margin calls use worst-case (low price) scenario
ltv_unhedged_worst = scenario.initial_portfolio.loan_amount / underlying_value_worst
ltv_hedged_worst = scenario.initial_portfolio.loan_amount / net_portfolio_value_worst
# Total option contracts held
option_contracts = sum(p.quantity for p in open_positions)
daily_points.append(
BacktestDailyPoint(
date=day.date,
spot_close=day.close,
spot_low=day.low if day.low is not None else day.close,
spot_high=day.high if day.high is not None else day.close,
underlying_value=underlying_value_close,
option_market_value=option_market_value,
premium_cashflow=premium_cashflow,
realized_option_cashflow=realized_option_cashflow,
net_portfolio_value=net_portfolio_value_close,
loan_amount=scenario.initial_portfolio.loan_amount,
ltv_unhedged=ltv_unhedged,
ltv_hedged=ltv_hedged,
margin_call_unhedged=ltv_unhedged_worst >= scenario.initial_portfolio.margin_call_ltv,
margin_call_hedged=ltv_hedged_worst >= scenario.initial_portfolio.margin_call_ltv,
option_contracts=option_contracts,
active_position_ids=tuple(active_position_ids),
)
)
margin_call_days_unhedged = sum(1 for point in daily_points if point.margin_call_unhedged)
margin_call_days_hedged = sum(1 for point in daily_points if point.margin_call_hedged)
summary = BacktestSummaryMetrics(
start_value=scenario.initial_portfolio.start_value,
end_value_unhedged=daily_points[-1].underlying_value,
end_value_hedged_net=daily_points[-1].net_portfolio_value,
total_hedge_cost=total_hedge_cost,
total_option_payoff_realized=total_option_payoff_realized,
max_ltv_unhedged=max(point.ltv_unhedged for point in daily_points),
max_ltv_hedged=max(point.ltv_hedged for point in daily_points),
margin_call_days_unhedged=margin_call_days_unhedged,
margin_call_days_hedged=margin_call_days_hedged,
margin_threshold_breached_unhedged=margin_call_days_unhedged > 0,
margin_threshold_breached_hedged=margin_call_days_hedged > 0,
)
return TemplateBacktestResult(
template_slug=template.slug,
template_id=template.template_id,
template_version=template.version,
template_name=template.display_name,
summary_metrics=summary,
daily_path=tuple(daily_points),
warnings=tuple(warnings),
)
def _open_positions(
self,
scenario: BacktestScenario,
template: StrategyTemplate,
history: list[DailyClosePoint],
start_day: DailyClosePoint,
) -> list[HistoricalOptionPosition]:
positions: list[HistoricalOptionPosition] = []
for index, leg in enumerate(template.legs, start=1):
positions.append(
self.provider.open_position(
symbol=scenario.symbol,
leg=leg,
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
@staticmethod
def _append_warning(warnings: list[str], warning: str | None) -> None:
if warning and warning not in warnings:
warnings.append(warning)