from __future__ import annotations import math from dataclasses import dataclass from datetime import date from decimal import Decimal from enum import Enum from app.domain.backtesting_math import AssetQuantity, PricePerAsset from app.domain.units import BaseCurrency, PricePerWeight, Weight, WeightUnit class Underlying(str, Enum): """Supported underlying instruments for options evaluation.""" GLD = "GLD" GC_F = "GC=F" def display_name(self) -> str: """Human-readable display name.""" return { Underlying.GLD: "SPDR Gold Shares ETF", Underlying.GC_F: "Gold Futures (COMEX)", }.get(self, self.value) def description(self) -> str: """Description of the underlying and data source.""" return { Underlying.GLD: "SPDR Gold Shares ETF (live data via yfinance)", Underlying.GC_F: "Gold Futures (coming soon)", }.get(self, "") # GLD expense ratio decay parameters (from docs/GLD_BASIS_RESEARCH.md) # Formula: ounces_per_share = 0.10 * e^(-0.004 * years_since_2004) GLD_INITIAL_OUNCES_PER_SHARE = Decimal("0.10") GLD_EXPENSE_DECAY_RATE = Decimal("0.004") # 0.4% annual decay GLD_LAUNCH_YEAR = 2004 GLD_LAUNCH_DATE = date(2004, 11, 18) # GLD IPO date on NYSE # GC=F contract specifications GC_F_OUNCES_PER_CONTRACT = Decimal("100") # 100 troy oz per contract GC_F_QUOTE_CURRENCY = BaseCurrency.USD def gld_ounces_per_share(reference_date: date | None = None) -> Decimal: """ Calculate GLD's gold backing per share for a specific date. GLD's expense ratio (0.40% annually) causes the gold backing per share to decay exponentially from the initial 0.10 oz/share at launch (November 18, 2004). Formula: ounces_per_share = 0.10 * e^(-0.004 * years_since_2004) Args: reference_date: Date to calculate backing for. Must be on or after GLD launch date (2004-11-18). Defaults to today. Returns: Decimal representing troy ounces of gold backing per GLD share. Raises: ValueError: If reference_date is before GLD launch (2004-11-18). Examples: >>> from datetime import date >>> # Launch date returns initial 0.10 oz/share >>> gld_ounces_per_share(date(2004, 11, 18)) Decimal('0.10') >>> # 2026 backing should be ~0.0916 oz/share (8.4% decay) >>> result = gld_ounces_per_share(date(2026, 1, 1)) >>> float(result) # doctest: +SKIP 0.0916... """ if reference_date is None: reference_date = date.today() if reference_date < GLD_LAUNCH_DATE: raise ValueError( f"GLD backing data unavailable before {GLD_LAUNCH_DATE}. " f"GLD launched on November 18, 2004." ) years_since_launch = Decimal(reference_date.year - GLD_LAUNCH_YEAR) decay_factor = Decimal(str(math.exp(-float(GLD_EXPENSE_DECAY_RATE * years_since_launch)))) return GLD_INITIAL_OUNCES_PER_SHARE * decay_factor @dataclass(frozen=True, slots=True) class InstrumentMetadata: symbol: str quote_currency: BaseCurrency | str weight_per_share: Weight def __post_init__(self) -> None: normalized_symbol = str(self.symbol).strip().upper() if not normalized_symbol: raise ValueError("Instrument symbol is required") object.__setattr__(self, "symbol", normalized_symbol) object.__setattr__(self, "quote_currency", BaseCurrency(self.quote_currency)) object.__setattr__(self, "weight_per_share", self.weight_per_share) def assert_symbol(self, symbol: str) -> InstrumentMetadata: normalized = str(symbol).strip().upper() if self.symbol != normalized: raise ValueError(f"Instrument symbol mismatch: {self.symbol} != {normalized}") return self def assert_currency(self, currency: BaseCurrency | str) -> InstrumentMetadata: normalized = BaseCurrency(currency) if self.quote_currency is not normalized: raise ValueError(f"Instrument currency mismatch: {self.quote_currency} != {normalized}") return self def price_per_weight_from_asset_price( self, price: PricePerAsset, *, per_unit: WeightUnit = WeightUnit.OUNCE_TROY, ) -> PricePerWeight: self.assert_symbol(price.symbol) self.assert_currency(price.currency) weight_per_share = self.weight_per_share.to_unit(per_unit) if weight_per_share.amount <= 0: raise ValueError("Instrument weight_per_share must be positive") return PricePerWeight( amount=price.amount / weight_per_share.amount, currency=price.currency, per_unit=per_unit, ) def weight_from_asset_quantity(self, quantity: AssetQuantity) -> Weight: self.assert_symbol(quantity.symbol) return Weight(amount=quantity.amount * self.weight_per_share.amount, unit=self.weight_per_share.unit) def asset_quantity_from_weight(self, weight: Weight) -> AssetQuantity: normalized_weight = weight.to_unit(self.weight_per_share._unit_typed) if self.weight_per_share.amount <= 0: raise ValueError("Instrument weight_per_share must be positive") return AssetQuantity(amount=normalized_weight.amount / self.weight_per_share.amount, symbol=self.symbol) _GLD = InstrumentMetadata( symbol="GLD", quote_currency=BaseCurrency.USD, weight_per_share=Weight(amount=gld_ounces_per_share(), unit=WeightUnit.OUNCE_TROY), ) _GC_F = InstrumentMetadata( symbol="GC=F", quote_currency=GC_F_QUOTE_CURRENCY, weight_per_share=Weight(amount=GC_F_OUNCES_PER_CONTRACT, unit=WeightUnit.OUNCE_TROY), ) _INSTRUMENTS: dict[str, InstrumentMetadata] = { _GLD.symbol: _GLD, _GC_F.symbol: _GC_F, } def supported_underlyings() -> list[Underlying]: """Return list of supported underlying instruments.""" return list(Underlying) def instrument_metadata(symbol: str) -> InstrumentMetadata: normalized = str(symbol).strip().upper() metadata = _INSTRUMENTS.get(normalized) if metadata is None: raise ValueError(f"Unsupported instrument metadata: {normalized or symbol!r}") return metadata def price_per_weight_from_asset_price( price: PricePerAsset, *, per_unit: WeightUnit = WeightUnit.OUNCE_TROY, ) -> PricePerWeight: return instrument_metadata(price.symbol).price_per_weight_from_asset_price(price, per_unit=per_unit) def weight_from_asset_quantity(quantity: AssetQuantity) -> Weight: return instrument_metadata(quantity.symbol).weight_from_asset_quantity(quantity) def asset_quantity_from_weight(symbol: str, weight: Weight) -> AssetQuantity: return instrument_metadata(symbol).asset_quantity_from_weight(weight)