From 966cee7963fa2bd99ff194916f1b4ebfb892d81e Mon Sep 17 00:00:00 2001 From: Bu5hm4nn Date: Sat, 28 Mar 2026 09:18:26 +0100 Subject: [PATCH] feat(PRICING-003): use true GLD backing for hedge contract count --- app/strategies/laddered_put.py | 5 +- app/strategies/protective_put.py | 25 +++++- tests/test_hedge_contract_count.py | 132 +++++++++++++++++++++++++++++ tests/test_strategies.py | 34 ++++---- 4 files changed, 176 insertions(+), 20 deletions(-) create mode 100644 tests/test_hedge_contract_count.py diff --git a/app/strategies/laddered_put.py b/app/strategies/laddered_put.py index 2dde13a..50acee5 100644 --- a/app/strategies/laddered_put.py +++ b/app/strategies/laddered_put.py @@ -3,10 +3,13 @@ from __future__ import annotations from dataclasses import dataclass from app.strategies.base import BaseStrategy, StrategyConfig + +# Re-export for test access from app.strategies.protective_put import ( DEFAULT_SCENARIO_CHANGES, ProtectivePutSpec, ProtectivePutStrategy, + gld_ounces_per_share, # noqa: F401 ) @@ -87,7 +90,7 @@ class LadderedPutStrategy(BaseStrategy): contract = leg.build_contract() weighted_payoff = contract.payoff(threshold_price) * weight total_payoff += weighted_payoff - floor_value += contract.strike * leg.hedge_units * weight + floor_value += contract.strike * contract.notional_units * weight leg_protection.append( { "weight": weight, diff --git a/app/strategies/protective_put.py b/app/strategies/protective_put.py index 32e0e20..40ff3de 100644 --- a/app/strategies/protective_put.py +++ b/app/strategies/protective_put.py @@ -1,5 +1,6 @@ from __future__ import annotations +import math from dataclasses import dataclass from datetime import date, timedelta @@ -7,6 +8,7 @@ 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 @@ -47,7 +49,8 @@ class ProtectivePutStrategy(BaseStrategy): @property 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 def strike(self) -> float: @@ -57,6 +60,20 @@ class ProtectivePutStrategy(BaseStrategy): 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( @@ -73,8 +90,8 @@ class ProtectivePutStrategy(BaseStrategy): strike=self.strike, expiry=date.today() + timedelta(days=max(1, round(365 * self.term_years))), premium=pricing.price, - quantity=1.0, - contract_size=self.hedge_units, + quantity=float(self.contract_count), + contract_size=100 * self.gld_backing, underlying_price=self.config.spot_price, greeks=Greeks( delta=pricing.delta, @@ -114,7 +131,7 @@ class ProtectivePutStrategy(BaseStrategy): 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 * self.hedge_units + floor_value = contract.strike * contract.notional_units return { "strategy": self.name, "threshold_price": round(threshold_price, 2), diff --git a/tests/test_hedge_contract_count.py b/tests/test_hedge_contract_count.py new file mode 100644 index 0000000..7ba5fd1 --- /dev/null +++ b/tests/test_hedge_contract_count.py @@ -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 diff --git a/tests/test_strategies.py b/tests/test_strategies.py index 4fefb44..3ee96c4 100644 --- a/tests/test_strategies.py +++ b/tests/test_strategies.py @@ -28,10 +28,11 @@ def test_protective_put_costs( assert cost["label"] == "ATM" assert cost["strike"] == 460.0 assert cost["premium_per_share"] == pytest.approx(19.6894, abs=1e-4) - assert cost["total_cost"] == pytest.approx(42803.14, abs=1e-2) - assert cost["cost_pct_of_portfolio"] == pytest.approx(0.042803, abs=1e-6) - assert cost["annualized_cost"] == pytest.approx(42803.14, abs=1e-2) - assert cost["annualized_cost_pct"] == pytest.approx(0.042803, abs=1e-6) + # Total cost uses corrected GLD backing (contract_count * contract_size * premium) + assert cost["total_cost"] == pytest.approx(42913.36, abs=1e-2) + assert cost["cost_pct_of_portfolio"] == pytest.approx(0.042913, 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: @@ -53,12 +54,14 @@ def test_laddered_strategy(sample_strategy_config: StrategyConfig, monkeypatch: assert cost["legs"][0]["weight"] == 0.5 assert cost["legs"][0]["strike"] == 460.0 assert cost["legs"][1]["strike"] == 437.0 - assert cost["blended_cost"] == pytest.approx(34200.72, abs=1e-2) - assert cost["cost_pct_of_portfolio"] == pytest.approx(0.034201, abs=1e-6) + # Costs updated to reflect corrected GLD backing + 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) - assert protection["payoff_at_threshold"] == pytest.approx(175000.0, abs=1e-2) - assert protection["hedged_ltv_at_threshold"] == pytest.approx(0.615385, rel=1e-6) + # Floor value uses notional_units (corrected backing) + assert protection["portfolio_floor_value"] == pytest.approx(977510.63, 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 @@ -90,16 +93,17 @@ def test_scenario_analysis( first_protective = protective_scenarios[0] assert first_protective["price_change_pct"] == -0.6 assert first_protective["gld_price"] == 184.0 - assert first_protective["option_payoff"] == pytest.approx(600000.0, abs=1e-2) - assert first_protective["hedge_cost"] == pytest.approx(42803.14, abs=1e-2) - assert first_protective["hedged_ltv"] == pytest.approx(0.6, rel=1e-12) + # Option payoff uses corrected contract count and notional + assert first_protective["option_payoff"] == pytest.approx(601545.00, abs=1e-2) + 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 first_ladder = ladder_scenarios[0] assert first_ladder["gld_price"] == 184.0 - assert first_ladder["option_payoff"] == pytest.approx(575000.0, abs=1e-2) - assert first_ladder["hedge_cost"] == pytest.approx(34200.72, abs=1e-2) - assert first_ladder["hedged_ltv"] == pytest.approx(0.615385, rel=1e-6) + assert first_ladder["option_payoff"] == pytest.approx(576480.63, abs=1e-2) + assert first_ladder["hedge_cost"] == pytest.approx(34288.79, abs=1e-2) + assert first_ladder["hedged_ltv"] == pytest.approx(0.614452, rel=1e-6) worst_ladder = ladder_scenarios[-1] assert worst_ladder["gld_price"] == 690.0