from __future__ import annotations from dataclasses import dataclass from datetime import date, timedelta import math from typing import Any, Literal try: # pragma: no cover - optional dependency import QuantLib as ql except ImportError: # pragma: no cover - optional dependency ql = None OptionType = Literal["call", "put"] DEFAULT_GOLD_PRICE_PER_OUNCE: float = 4600.0 DEFAULT_GLD_PRICE: float = 460.0 DEFAULT_RISK_FREE_RATE: float = 0.045 DEFAULT_VOLATILITY: float = 0.16 DEFAULT_DIVIDEND_YIELD: float = 0.0 @dataclass(frozen=True) class BlackScholesInputs: """Inputs for European Black-Scholes pricing.""" spot: float = DEFAULT_GLD_PRICE strike: float = DEFAULT_GLD_PRICE time_to_expiry: float = 0.25 risk_free_rate: float = DEFAULT_RISK_FREE_RATE volatility: float = DEFAULT_VOLATILITY option_type: OptionType = "put" dividend_yield: float = DEFAULT_DIVIDEND_YIELD valuation_date: date | None = None @dataclass(frozen=True) class PricingResult: """European option price and Greeks.""" price: float delta: float gamma: float theta: float vega: float rho: float @dataclass(frozen=True) class HedgingCost: """Annualized hedging cost summary.""" premium_paid: float annual_cost_dollars: float annual_cost_pct: float def _validate_option_type(option_type: str) -> OptionType: option = option_type.lower() if option not in {"call", "put"}: raise ValueError("option_type must be either 'call' or 'put'") return option # type: ignore[return-value] def _to_quantlib_option_type(option_type: OptionType) -> Any: if ql is None: raise RuntimeError("QuantLib is not installed") return ql.Option.Call if option_type == "call" else ql.Option.Put def _build_dates(time_to_expiry: float, valuation_date: date | None) -> tuple[Any, Any]: if time_to_expiry <= 0.0: raise ValueError("time_to_expiry must be positive") if ql is None: return (None, None) valuation = valuation_date or date.today() maturity = valuation + timedelta(days=max(1, round(time_to_expiry * 365))) return ( ql.Date(valuation.day, valuation.month, valuation.year), ql.Date(maturity.day, maturity.month, maturity.year), ) def _norm_pdf(value: float) -> float: return math.exp(-(value**2) / 2.0) / math.sqrt(2.0 * math.pi) def _norm_cdf(value: float) -> float: return 0.5 * (1.0 + math.erf(value / math.sqrt(2.0))) def _analytic_black_scholes(params: BlackScholesInputs, option_type: OptionType) -> PricingResult: if params.spot <= 0 or params.strike <= 0 or params.time_to_expiry <= 0 or params.volatility <= 0: raise ValueError("spot, strike, time_to_expiry, and volatility must be positive") t = params.time_to_expiry sigma = params.volatility sqrt_t = math.sqrt(t) d1 = ( math.log(params.spot / params.strike) + (params.risk_free_rate - params.dividend_yield + 0.5 * sigma**2) * t ) / (sigma * sqrt_t) d2 = d1 - sigma * sqrt_t disc_r = math.exp(-params.risk_free_rate * t) disc_q = math.exp(-params.dividend_yield * t) pdf_d1 = _norm_pdf(d1) if option_type == "call": price = params.spot * disc_q * _norm_cdf(d1) - params.strike * disc_r * _norm_cdf(d2) delta = disc_q * _norm_cdf(d1) theta = ( -(params.spot * disc_q * pdf_d1 * sigma) / (2 * sqrt_t) - params.risk_free_rate * params.strike * disc_r * _norm_cdf(d2) + params.dividend_yield * params.spot * disc_q * _norm_cdf(d1) ) rho = params.strike * t * disc_r * _norm_cdf(d2) else: price = params.strike * disc_r * _norm_cdf(-d2) - params.spot * disc_q * _norm_cdf(-d1) delta = disc_q * (_norm_cdf(d1) - 1.0) theta = ( -(params.spot * disc_q * pdf_d1 * sigma) / (2 * sqrt_t) + params.risk_free_rate * params.strike * disc_r * _norm_cdf(-d2) - params.dividend_yield * params.spot * disc_q * _norm_cdf(-d1) ) rho = -params.strike * t * disc_r * _norm_cdf(-d2) gamma = (disc_q * pdf_d1) / (params.spot * sigma * sqrt_t) vega = params.spot * disc_q * pdf_d1 * sqrt_t return PricingResult(price=float(price), delta=float(delta), gamma=float(gamma), theta=float(theta), vega=float(vega), rho=float(rho)) def black_scholes_price_and_greeks(params: BlackScholesInputs) -> PricingResult: """Price a European option with QuantLib when available, otherwise analytic BSM.""" option_type = _validate_option_type(params.option_type) if ql is None: return _analytic_black_scholes(params, option_type) valuation_ql, maturity_ql = _build_dates(params.time_to_expiry, params.valuation_date) ql.Settings.instance().evaluationDate = valuation_ql day_count = ql.Actual365Fixed() calendar = ql.NullCalendar() spot_handle = ql.QuoteHandle(ql.SimpleQuote(params.spot)) dividend_curve = ql.YieldTermStructureHandle( ql.FlatForward(valuation_ql, params.dividend_yield, day_count) ) risk_free_curve = ql.YieldTermStructureHandle( ql.FlatForward(valuation_ql, params.risk_free_rate, day_count) ) volatility = ql.BlackVolTermStructureHandle( ql.BlackConstantVol(valuation_ql, calendar, params.volatility, day_count) ) process = ql.BlackScholesMertonProcess(spot_handle, dividend_curve, risk_free_curve, volatility) payoff = ql.PlainVanillaPayoff(_to_quantlib_option_type(option_type), params.strike) exercise = ql.EuropeanExercise(maturity_ql) option = ql.VanillaOption(payoff, exercise) option.setPricingEngine(ql.AnalyticEuropeanEngine(process)) return PricingResult( price=float(option.NPV()), delta=float(option.delta()), gamma=float(option.gamma()), theta=float(option.theta()), vega=float(option.vega()), rho=float(option.rho()), ) def margin_call_threshold_price( portfolio_value: float, loan_amount: float, current_price: float = DEFAULT_GLD_PRICE, margin_call_ltv: float = 0.75, ) -> float: """Calculate the underlying price where a margin call is triggered.""" if portfolio_value <= 0 or loan_amount <= 0 or current_price <= 0: raise ValueError("portfolio_value, loan_amount, and current_price must be positive") if not 0 < margin_call_ltv < 1: raise ValueError("margin_call_ltv must be between 0 and 1") units = portfolio_value / current_price return loan_amount / (margin_call_ltv * units) def annual_hedging_cost( premium_per_share: float, shares_hedged: float, portfolio_value: float, hedge_term_years: float, ) -> HedgingCost: """Annualize the premium cost of a hedging program.""" if premium_per_share < 0 or shares_hedged <= 0 or portfolio_value <= 0 or hedge_term_years <= 0: raise ValueError( "premium_per_share must be non-negative and shares_hedged, portfolio_value, " "and hedge_term_years must be positive" ) premium_paid = premium_per_share * shares_hedged annual_cost_dollars = premium_paid / hedge_term_years annual_cost_pct = annual_cost_dollars / portfolio_value return HedgingCost( premium_paid=premium_paid, annual_cost_dollars=annual_cost_dollars, annual_cost_pct=annual_cost_pct, )