- Fix return type annotation for get_default_premium_for_product - Add type narrowing for Weight|Money union using _as_money helper - Add isinstance checks before float() calls for object types - Add type guard for Decimal.exponent comparison - Use _unit_typed and _currency_typed properties for type narrowing - Cast option_type to OptionType Literal after validation - Fix provider type hierarchy in backtesting services - Add types-requests to dev dependencies - Remove '|| true' from CI type-check job All 36 mypy errors resolved across 15 files.
187 lines
6.6 KiB
Python
187 lines
6.6 KiB
Python
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)
|