feat(PRICING-003): use true GLD backing for hedge contract count
This commit is contained in:
@@ -3,10 +3,13 @@ from __future__ import annotations
|
|||||||
from dataclasses import dataclass
|
from dataclasses import dataclass
|
||||||
|
|
||||||
from app.strategies.base import BaseStrategy, StrategyConfig
|
from app.strategies.base import BaseStrategy, StrategyConfig
|
||||||
|
|
||||||
|
# Re-export for test access
|
||||||
from app.strategies.protective_put import (
|
from app.strategies.protective_put import (
|
||||||
DEFAULT_SCENARIO_CHANGES,
|
DEFAULT_SCENARIO_CHANGES,
|
||||||
ProtectivePutSpec,
|
ProtectivePutSpec,
|
||||||
ProtectivePutStrategy,
|
ProtectivePutStrategy,
|
||||||
|
gld_ounces_per_share, # noqa: F401
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@@ -87,7 +90,7 @@ class LadderedPutStrategy(BaseStrategy):
|
|||||||
contract = leg.build_contract()
|
contract = leg.build_contract()
|
||||||
weighted_payoff = contract.payoff(threshold_price) * weight
|
weighted_payoff = contract.payoff(threshold_price) * weight
|
||||||
total_payoff += weighted_payoff
|
total_payoff += weighted_payoff
|
||||||
floor_value += contract.strike * leg.hedge_units * weight
|
floor_value += contract.strike * contract.notional_units * weight
|
||||||
leg_protection.append(
|
leg_protection.append(
|
||||||
{
|
{
|
||||||
"weight": weight,
|
"weight": weight,
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import math
|
||||||
from dataclasses import dataclass
|
from dataclasses import dataclass
|
||||||
from datetime import date, timedelta
|
from datetime import date, timedelta
|
||||||
|
|
||||||
@@ -7,6 +8,7 @@ from app.core.pricing.black_scholes import (
|
|||||||
BlackScholesInputs,
|
BlackScholesInputs,
|
||||||
black_scholes_price_and_greeks,
|
black_scholes_price_and_greeks,
|
||||||
)
|
)
|
||||||
|
from app.domain.instruments import gld_ounces_per_share
|
||||||
from app.models.option import Greeks, OptionContract
|
from app.models.option import Greeks, OptionContract
|
||||||
from app.models.strategy import HedgingStrategy
|
from app.models.strategy import HedgingStrategy
|
||||||
from app.strategies.base import BaseStrategy, StrategyConfig
|
from app.strategies.base import BaseStrategy, StrategyConfig
|
||||||
@@ -47,7 +49,8 @@ class ProtectivePutStrategy(BaseStrategy):
|
|||||||
|
|
||||||
@property
|
@property
|
||||||
def hedge_units(self) -> float:
|
def hedge_units(self) -> float:
|
||||||
return self.config.portfolio.gold_value / self.config.spot_price
|
"""Gold ounces to hedge (canonical portfolio weight)."""
|
||||||
|
return self.config.portfolio.gold_ounces
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def strike(self) -> float:
|
def strike(self) -> float:
|
||||||
@@ -57,6 +60,20 @@ class ProtectivePutStrategy(BaseStrategy):
|
|||||||
def term_years(self) -> float:
|
def term_years(self) -> float:
|
||||||
return self.spec.months / 12.0
|
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:
|
def build_contract(self) -> OptionContract:
|
||||||
pricing = black_scholes_price_and_greeks(
|
pricing = black_scholes_price_and_greeks(
|
||||||
BlackScholesInputs(
|
BlackScholesInputs(
|
||||||
@@ -73,8 +90,8 @@ class ProtectivePutStrategy(BaseStrategy):
|
|||||||
strike=self.strike,
|
strike=self.strike,
|
||||||
expiry=date.today() + timedelta(days=max(1, round(365 * self.term_years))),
|
expiry=date.today() + timedelta(days=max(1, round(365 * self.term_years))),
|
||||||
premium=pricing.price,
|
premium=pricing.price,
|
||||||
quantity=1.0,
|
quantity=float(self.contract_count),
|
||||||
contract_size=self.hedge_units,
|
contract_size=100 * self.gld_backing,
|
||||||
underlying_price=self.config.spot_price,
|
underlying_price=self.config.spot_price,
|
||||||
greeks=Greeks(
|
greeks=Greeks(
|
||||||
delta=pricing.delta,
|
delta=pricing.delta,
|
||||||
@@ -114,7 +131,7 @@ class ProtectivePutStrategy(BaseStrategy):
|
|||||||
payoff_at_threshold = contract.payoff(threshold_price)
|
payoff_at_threshold = contract.payoff(threshold_price)
|
||||||
hedged_value_at_threshold = self.config.portfolio.gold_value_at_price(threshold_price) + payoff_at_threshold
|
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
|
protected_ltv = self.config.portfolio.loan_amount / hedged_value_at_threshold
|
||||||
floor_value = contract.strike * self.hedge_units
|
floor_value = contract.strike * contract.notional_units
|
||||||
return {
|
return {
|
||||||
"strategy": self.name,
|
"strategy": self.name,
|
||||||
"threshold_price": round(threshold_price, 2),
|
"threshold_price": round(threshold_price, 2),
|
||||||
|
|||||||
132
tests/test_hedge_contract_count.py
Normal file
132
tests/test_hedge_contract_count.py
Normal file
@@ -0,0 +1,132 @@
|
|||||||
|
"""Tests for hedge contract count calculation using true GLD backing."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import math
|
||||||
|
from datetime import date
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from app.domain.instruments import gld_ounces_per_share
|
||||||
|
from app.models.portfolio import LombardPortfolio
|
||||||
|
from app.strategies.base import StrategyConfig
|
||||||
|
from app.strategies.protective_put import ProtectivePutSpec, ProtectivePutStrategy
|
||||||
|
|
||||||
|
|
||||||
|
class TestGLDBacking:
|
||||||
|
"""Test GLD backing calculation."""
|
||||||
|
|
||||||
|
def test_gld_backing_2026_is_approx_0_0919(self) -> None:
|
||||||
|
"""GLD backing in 2026 should be ~0.0919 oz/share (8.1% decay from 0.10)."""
|
||||||
|
backing = gld_ounces_per_share(date(2026, 1, 1))
|
||||||
|
assert 0.0915 <= float(backing) <= 0.0925
|
||||||
|
|
||||||
|
def test_gld_backing_decays_over_time(self) -> None:
|
||||||
|
"""GLD backing should decay as years pass."""
|
||||||
|
backing_2004 = gld_ounces_per_share(date(2004, 1, 1))
|
||||||
|
backing_2026 = gld_ounces_per_share(date(2026, 1, 1))
|
||||||
|
assert float(backing_2004) == 0.10
|
||||||
|
assert float(backing_2026) < float(backing_2004)
|
||||||
|
|
||||||
|
|
||||||
|
class TestContractCountCalculation:
|
||||||
|
"""Test contract count formula uses corrected GLD backing."""
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def sample_portfolio(self) -> LombardPortfolio:
|
||||||
|
return LombardPortfolio(
|
||||||
|
gold_ounces=919.0,
|
||||||
|
gold_price_per_ounce=2300.0,
|
||||||
|
loan_amount=1500000.0,
|
||||||
|
initial_ltv=0.71,
|
||||||
|
margin_call_ltv=0.75,
|
||||||
|
)
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def strategy_config(self, sample_portfolio: LombardPortfolio) -> StrategyConfig:
|
||||||
|
return StrategyConfig(
|
||||||
|
portfolio=sample_portfolio,
|
||||||
|
spot_price=2300.0,
|
||||||
|
volatility=0.16,
|
||||||
|
risk_free_rate=0.045,
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_contract_count_uses_gld_backing_not_naive_10_to_1(self, strategy_config: StrategyConfig) -> None:
|
||||||
|
"""Contract count should use gld_ounces_per_share(), not naive 10:1 ratio."""
|
||||||
|
strategy = ProtectivePutStrategy(
|
||||||
|
strategy_config,
|
||||||
|
ProtectivePutSpec(label="ATM", strike_pct=1.0, months=12),
|
||||||
|
)
|
||||||
|
|
||||||
|
# At backing ~0.091576: 919 / (100 * 0.091576) = 100.35... → ceil = 101
|
||||||
|
# Naive 10:1 would give: ceil(919 / 10) = 92 contracts (WRONG)
|
||||||
|
naive_count = math.ceil(919.0 / 10)
|
||||||
|
|
||||||
|
assert strategy.contract_count != naive_count, "Should not use naive 10:1 ratio"
|
||||||
|
# Verify formula: ceil(gold_ounces / (100 * backing))
|
||||||
|
expected = math.ceil(919.0 / (100 * strategy.gld_backing))
|
||||||
|
assert strategy.contract_count == expected
|
||||||
|
|
||||||
|
def test_contract_count_rounds_up(self, strategy_config: StrategyConfig) -> None:
|
||||||
|
"""Contract count should round up to ensure full coverage."""
|
||||||
|
strategy = ProtectivePutStrategy(
|
||||||
|
strategy_config,
|
||||||
|
ProtectivePutSpec(label="ATM", strike_pct=1.0, months=12),
|
||||||
|
)
|
||||||
|
# Verify rounding behavior
|
||||||
|
assert strategy.contract_count == math.ceil(
|
||||||
|
strategy_config.portfolio.gold_ounces / (100 * strategy.gld_backing)
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_contract_notional_equals_gold_ounces(self, strategy_config: StrategyConfig) -> None:
|
||||||
|
"""Contract notional (quantity * contract_size) should cover portfolio gold ounces."""
|
||||||
|
strategy = ProtectivePutStrategy(
|
||||||
|
strategy_config,
|
||||||
|
ProtectivePutSpec(label="ATM", strike_pct=1.0, months=12),
|
||||||
|
)
|
||||||
|
contract = strategy.build_contract()
|
||||||
|
|
||||||
|
# notional_units = quantity * contract_size
|
||||||
|
notional = contract.notional_units
|
||||||
|
|
||||||
|
# Should be >= gold_ounces (may slightly over-hedge due to rounding)
|
||||||
|
assert notional >= strategy.hedge_units
|
||||||
|
# But not excessively over-hedged (within one contract)
|
||||||
|
max_overhedge = 100 * strategy.gld_backing
|
||||||
|
assert notional - strategy.hedge_units < max_overhedge
|
||||||
|
|
||||||
|
|
||||||
|
class TestHedgeCostWithCorrectedBacking:
|
||||||
|
"""Test hedge cost calculations use corrected backing."""
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def portfolio(self) -> LombardPortfolio:
|
||||||
|
return LombardPortfolio(
|
||||||
|
gold_ounces=919.0,
|
||||||
|
gold_price_per_ounce=2300.0,
|
||||||
|
loan_amount=1500000.0,
|
||||||
|
initial_ltv=0.71,
|
||||||
|
margin_call_ltv=0.75,
|
||||||
|
)
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def config(self, portfolio: LombardPortfolio) -> StrategyConfig:
|
||||||
|
return StrategyConfig(
|
||||||
|
portfolio=portfolio,
|
||||||
|
spot_price=2300.0,
|
||||||
|
volatility=0.16,
|
||||||
|
risk_free_rate=0.045,
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_total_cost_scales_with_corrected_contract_count(self, config: StrategyConfig) -> None:
|
||||||
|
"""Total hedge cost should reflect corrected contract count."""
|
||||||
|
strategy = ProtectivePutStrategy(
|
||||||
|
config,
|
||||||
|
ProtectivePutSpec(label="ATM", strike_pct=1.0, months=12),
|
||||||
|
)
|
||||||
|
cost_info = strategy.calculate_cost()
|
||||||
|
|
||||||
|
# Total cost should be premium * notional_units
|
||||||
|
contract = strategy.build_contract()
|
||||||
|
assert cost_info["total_cost"] > 0
|
||||||
|
assert abs(contract.total_premium - cost_info["total_cost"]) < 0.01
|
||||||
@@ -28,10 +28,11 @@ def test_protective_put_costs(
|
|||||||
assert cost["label"] == "ATM"
|
assert cost["label"] == "ATM"
|
||||||
assert cost["strike"] == 460.0
|
assert cost["strike"] == 460.0
|
||||||
assert cost["premium_per_share"] == pytest.approx(19.6894, abs=1e-4)
|
assert cost["premium_per_share"] == pytest.approx(19.6894, abs=1e-4)
|
||||||
assert cost["total_cost"] == pytest.approx(42803.14, abs=1e-2)
|
# Total cost uses corrected GLD backing (contract_count * contract_size * premium)
|
||||||
assert cost["cost_pct_of_portfolio"] == pytest.approx(0.042803, abs=1e-6)
|
assert cost["total_cost"] == pytest.approx(42913.36, abs=1e-2)
|
||||||
assert cost["annualized_cost"] == pytest.approx(42803.14, abs=1e-2)
|
assert cost["cost_pct_of_portfolio"] == pytest.approx(0.042913, abs=1e-6)
|
||||||
assert cost["annualized_cost_pct"] == pytest.approx(0.042803, abs=1e-6)
|
assert cost["annualized_cost"] == pytest.approx(42913.36, abs=1e-2)
|
||||||
|
assert cost["annualized_cost_pct"] == pytest.approx(0.042913, abs=1e-6)
|
||||||
|
|
||||||
|
|
||||||
def test_laddered_strategy(sample_strategy_config: StrategyConfig, monkeypatch: pytest.MonkeyPatch) -> None:
|
def test_laddered_strategy(sample_strategy_config: StrategyConfig, monkeypatch: pytest.MonkeyPatch) -> None:
|
||||||
@@ -53,12 +54,14 @@ def test_laddered_strategy(sample_strategy_config: StrategyConfig, monkeypatch:
|
|||||||
assert cost["legs"][0]["weight"] == 0.5
|
assert cost["legs"][0]["weight"] == 0.5
|
||||||
assert cost["legs"][0]["strike"] == 460.0
|
assert cost["legs"][0]["strike"] == 460.0
|
||||||
assert cost["legs"][1]["strike"] == 437.0
|
assert cost["legs"][1]["strike"] == 437.0
|
||||||
assert cost["blended_cost"] == pytest.approx(34200.72, abs=1e-2)
|
# Costs updated to reflect corrected GLD backing
|
||||||
assert cost["cost_pct_of_portfolio"] == pytest.approx(0.034201, abs=1e-6)
|
assert cost["blended_cost"] == pytest.approx(34288.79, abs=1e-2)
|
||||||
|
assert cost["cost_pct_of_portfolio"] == pytest.approx(0.034289, abs=1e-6)
|
||||||
|
|
||||||
assert protection["portfolio_floor_value"] == pytest.approx(975000.0, rel=1e-12)
|
# Floor value uses notional_units (corrected backing)
|
||||||
assert protection["payoff_at_threshold"] == pytest.approx(175000.0, abs=1e-2)
|
assert protection["portfolio_floor_value"] == pytest.approx(977510.63, rel=1e-6)
|
||||||
assert protection["hedged_ltv_at_threshold"] == pytest.approx(0.615385, rel=1e-6)
|
assert protection["payoff_at_threshold"] == pytest.approx(175450.63, abs=1e-2)
|
||||||
|
assert protection["hedged_ltv_at_threshold"] == pytest.approx(0.615100, rel=1e-6)
|
||||||
assert protection["maintains_margin_call_buffer"] is True
|
assert protection["maintains_margin_call_buffer"] is True
|
||||||
|
|
||||||
|
|
||||||
@@ -90,16 +93,17 @@ def test_scenario_analysis(
|
|||||||
first_protective = protective_scenarios[0]
|
first_protective = protective_scenarios[0]
|
||||||
assert first_protective["price_change_pct"] == -0.6
|
assert first_protective["price_change_pct"] == -0.6
|
||||||
assert first_protective["gld_price"] == 184.0
|
assert first_protective["gld_price"] == 184.0
|
||||||
assert first_protective["option_payoff"] == pytest.approx(600000.0, abs=1e-2)
|
# Option payoff uses corrected contract count and notional
|
||||||
assert first_protective["hedge_cost"] == pytest.approx(42803.14, abs=1e-2)
|
assert first_protective["option_payoff"] == pytest.approx(601545.00, abs=1e-2)
|
||||||
assert first_protective["hedged_ltv"] == pytest.approx(0.6, rel=1e-12)
|
assert first_protective["hedge_cost"] == pytest.approx(42913.36, abs=1e-2)
|
||||||
|
assert first_protective["hedged_ltv"] == pytest.approx(0.599074, rel=1e-6)
|
||||||
assert first_protective["margin_call_with_hedge"] is False
|
assert first_protective["margin_call_with_hedge"] is False
|
||||||
|
|
||||||
first_ladder = ladder_scenarios[0]
|
first_ladder = ladder_scenarios[0]
|
||||||
assert first_ladder["gld_price"] == 184.0
|
assert first_ladder["gld_price"] == 184.0
|
||||||
assert first_ladder["option_payoff"] == pytest.approx(575000.0, abs=1e-2)
|
assert first_ladder["option_payoff"] == pytest.approx(576480.63, abs=1e-2)
|
||||||
assert first_ladder["hedge_cost"] == pytest.approx(34200.72, abs=1e-2)
|
assert first_ladder["hedge_cost"] == pytest.approx(34288.79, abs=1e-2)
|
||||||
assert first_ladder["hedged_ltv"] == pytest.approx(0.615385, rel=1e-6)
|
assert first_ladder["hedged_ltv"] == pytest.approx(0.614452, rel=1e-6)
|
||||||
|
|
||||||
worst_ladder = ladder_scenarios[-1]
|
worst_ladder = ladder_scenarios[-1]
|
||||||
assert worst_ladder["gld_price"] == 690.0
|
assert worst_ladder["gld_price"] == 690.0
|
||||||
|
|||||||
Reference in New Issue
Block a user