Files
vault-dash/app/strategies/protective_put.py

173 lines
6.4 KiB
Python

from __future__ import annotations
import math
from dataclasses import dataclass
from datetime import date, timedelta
from app.core.pricing.black_scholes import (
BlackScholesInputs,
black_scholes_price_and_greeks,
)
from app.domain.instruments import gld_ounces_per_share
from app.models.option import Greeks, OptionContract
from app.models.strategy import HedgingStrategy
from app.strategies.base import BaseStrategy, StrategyConfig
DEFAULT_SCENARIO_CHANGES = (
-0.6,
-0.5,
-0.4,
-0.3,
-0.2,
-0.1,
0.0,
0.1,
0.2,
0.3,
0.4,
0.5,
)
@dataclass(frozen=True)
class ProtectivePutSpec:
label: str
strike_pct: float
months: int = 12
class ProtectivePutStrategy(BaseStrategy):
"""Single-leg protective put strategy using ATM or configurable OTM strikes."""
def __init__(self, config: StrategyConfig, spec: ProtectivePutSpec) -> None:
super().__init__(config)
self.spec = spec
@property
def name(self) -> str:
return f"protective_put_{self.spec.label.lower()}"
@property
def hedge_units(self) -> float:
"""Gold ounces to hedge (canonical portfolio weight)."""
return self.config.portfolio.gold_ounces
@property
def strike(self) -> float:
return self.config.spot_price * self.spec.strike_pct
@property
def term_years(self) -> float:
return self.spec.months / 12.0
@property
def gld_backing(self) -> float:
"""GLD ounces per share for contract count calculation."""
return float(gld_ounces_per_share())
@property
def contract_count(self) -> int:
"""Number of GLD option contracts needed.
GLD options cover 100 shares each. Each share represents ~0.0919 oz
(expense-ratio adjusted). Formula: ceil(gold_ounces / (100 * backing)).
"""
return math.ceil(self.hedge_units / (100 * self.gld_backing))
def build_contract(self) -> OptionContract:
pricing = black_scholes_price_and_greeks(
BlackScholesInputs(
spot=self.config.spot_price,
strike=self.strike,
time_to_expiry=self.term_years,
risk_free_rate=self.config.risk_free_rate,
volatility=self.config.volatility,
option_type="put",
)
)
return OptionContract(
option_type="put",
strike=self.strike,
expiry=date.today() + timedelta(days=max(1, round(365 * self.term_years))),
premium=pricing.price,
quantity=float(self.contract_count),
contract_size=100 * self.gld_backing,
underlying_price=self.config.spot_price,
greeks=Greeks(
delta=pricing.delta,
gamma=pricing.gamma,
theta=pricing.theta,
vega=pricing.vega,
rho=pricing.rho,
),
)
def build_hedging_strategy(self) -> HedgingStrategy:
return HedgingStrategy(
strategy_type="single_put",
long_contracts=(self.build_contract(),),
description=f"{self.spec.label} protective put",
)
def calculate_cost(self) -> dict:
contract = self.build_contract()
total_cost = contract.total_premium
return {
"strategy": self.name,
"label": self.spec.label,
"strike": round(contract.strike, 2),
"strike_pct": self.spec.strike_pct,
"premium_per_share": round(contract.premium, 4),
"total_cost": round(total_cost, 2),
"cost_pct_of_portfolio": round(total_cost / self.config.portfolio.gold_value, 6),
"term_months": self.spec.months,
"annualized_cost": round(total_cost / self.term_years, 2),
"annualized_cost_pct": round((total_cost / self.term_years) / self.config.portfolio.gold_value, 6),
}
def calculate_protection(self) -> dict:
contract = self.build_contract()
threshold_price = self.config.portfolio.margin_call_price()
payoff_at_threshold = contract.payoff(threshold_price)
hedged_value_at_threshold = self.config.portfolio.gold_value_at_price(threshold_price) + payoff_at_threshold
protected_ltv = self.config.portfolio.loan_amount / hedged_value_at_threshold
floor_value = contract.strike * contract.notional_units
return {
"strategy": self.name,
"threshold_price": round(threshold_price, 2),
"strike": round(contract.strike, 2),
"portfolio_floor_value": round(floor_value, 2),
"unhedged_ltv_at_threshold": round(self.config.portfolio.ltv_at_price(threshold_price), 6),
"hedged_ltv_at_threshold": round(protected_ltv, 6),
"payoff_at_threshold": round(payoff_at_threshold, 2),
"maintains_margin_call_buffer": protected_ltv < self.config.portfolio.margin_call_ltv,
}
def get_scenarios(self) -> list[dict]:
strategy = self.build_hedging_strategy()
scenarios: list[dict] = []
for change in DEFAULT_SCENARIO_CHANGES:
price = self.config.spot_price * (1 + change)
if price <= 0:
continue
gold_value = self.config.portfolio.gold_value_at_price(price)
option_payoff = strategy.gross_payoff(price)
hedged_collateral = gold_value + option_payoff
scenarios.append(
{
"price_change_pct": round(change, 2),
"gld_price": round(price, 2),
"gold_value": round(gold_value, 2),
"option_payoff": round(option_payoff, 2),
"hedge_cost": round(strategy.hedge_cost, 2),
"net_portfolio_value": round(gold_value + option_payoff - strategy.hedge_cost, 2),
"unhedged_ltv": round(self.config.portfolio.loan_amount / gold_value, 6),
"hedged_ltv": round(self.config.portfolio.loan_amount / hedged_collateral, 6),
"margin_call_without_hedge": (self.config.portfolio.loan_amount / gold_value)
>= self.config.portfolio.margin_call_ltv,
"margin_call_with_hedge": (self.config.portfolio.loan_amount / hedged_collateral)
>= self.config.portfolio.margin_call_ltv,
}
)
return scenarios