"""TDD for :mod:`cerbero_bite.core.sizing_engine`. Spec: ``docs/01-strategy-rules.md §5`` and ``docs/03-algorithms.md §3``. The five mandatory test cases listed in §3 are reproduced here verbatim. """ from __future__ import annotations from decimal import Decimal import pytest from cerbero_bite.config import StrategyConfig, golden_config from cerbero_bite.core.sizing_engine import SizingContext, compute_contracts # Standard fixture: EUR/USD = 1.075 (cap 200 EUR ≈ 215 USD, 1000 EUR ≈ 1075 USD) EUR_USD = Decimal("1.075") def _ctx( *, capital_usd: str = "1500", max_loss_per_contract_usd: str = "93", dvol_now: str = "40", open_engagement_usd: str = "0", eur_to_usd: Decimal = EUR_USD, other_open_positions: int = 0, ) -> SizingContext: return SizingContext( capital_usd=Decimal(capital_usd), max_loss_per_contract_usd=Decimal(max_loss_per_contract_usd), dvol_now=Decimal(dvol_now), open_engagement_usd=Decimal(open_engagement_usd), eur_to_usd=eur_to_usd, other_open_positions=other_open_positions, ) @pytest.fixture def cfg() -> StrategyConfig: return golden_config() # --------------------------------------------------------------------------- # Mandatory examples from docs/03-algorithms.md §3 # --------------------------------------------------------------------------- def test_minimum_capital_dvol_40_yields_one_contract(cfg: StrategyConfig) -> None: res = compute_contracts(_ctx(capital_usd="720", dvol_now="40"), cfg) assert res.n_contracts == 1 assert res.reason_if_zero is None def test_capital_1500_dvol_50_yields_one_contract(cfg: StrategyConfig) -> None: # 13% × 1500 = 195; adj 0.85 → 165.75; floor(165.75/93) = 1 res = compute_contracts(_ctx(capital_usd="1500", dvol_now="50"), cfg) assert res.n_contracts == 1 def test_capital_1500_dvol_40_yields_two_contracts(cfg: StrategyConfig) -> None: # 13% × 1500 = 195; cap 215 USD; adj 1.0 → 195; floor(195/93) = 2 res = compute_contracts(_ctx(capital_usd="1500", dvol_now="40"), cfg) assert res.n_contracts == 2 def test_capital_5000_dvol_40_capped_at_two_contracts(cfg: StrategyConfig) -> None: # 13% × 5000 = 650; cap 215 USD wins; floor(215/93) = 2 res = compute_contracts(_ctx(capital_usd="5000", dvol_now="40"), cfg) assert res.n_contracts == 2 def test_capital_100000_dvol_40_capped_at_two_contracts(cfg: StrategyConfig) -> None: res = compute_contracts(_ctx(capital_usd="100000", dvol_now="40"), cfg) assert res.n_contracts == 2 # --------------------------------------------------------------------------- # DVOL adjustment bands and no-entry threshold # --------------------------------------------------------------------------- def test_dvol_in_60_to_80_band_uses_0_65_multiplier(cfg: StrategyConfig) -> None: # 13% × 5000 = 650; cap 215; adj 0.65 → 139.75; floor(139.75/93) = 1 res = compute_contracts(_ctx(capital_usd="5000", dvol_now="65"), cfg) assert res.n_contracts == 1 def test_dvol_at_80_returns_zero(cfg: StrategyConfig) -> None: res = compute_contracts(_ctx(capital_usd="5000", dvol_now="80"), cfg) assert res.n_contracts == 0 assert res.reason_if_zero is not None assert "dvol" in res.reason_if_zero.lower() def test_dvol_above_no_entry_threshold_returns_zero(cfg: StrategyConfig) -> None: res = compute_contracts(_ctx(capital_usd="5000", dvol_now="85"), cfg) assert res.n_contracts == 0 # --------------------------------------------------------------------------- # Aggregate engagement constraint # --------------------------------------------------------------------------- def test_aggregate_cap_reduces_contracts(cfg: StrategyConfig) -> None: # cap_aggregate ≈ 1075 USD. With 1000 USD already engaged and ML=93, # only floor((1075-1000)/93) = 0 new contracts should fit. res = compute_contracts( _ctx( capital_usd="100000", dvol_now="40", open_engagement_usd="1000", ), cfg, ) assert res.n_contracts == 0 assert res.reason_if_zero is not None def test_aggregate_cap_partially_reduces(cfg: StrategyConfig) -> None: # 800 already engaged → free 275; floor(275/93)=2 but also kelly cap may bind. # 13% × 100000 = 13000; cap 215 → 215; floor(215/93)=2 contracts. # 2*93 = 186; 186+800=986 ≤ 1075 ✓ → 2. res = compute_contracts( _ctx(capital_usd="100000", dvol_now="40", open_engagement_usd="800"), cfg, ) assert res.n_contracts == 2 def test_aggregate_cap_at_975_reduces_to_one(cfg: StrategyConfig) -> None: # 975 engaged + 2*93 = 1161 > 1075 → drop to 1: 975 + 93 = 1068 ≤ 1075 ✓ res = compute_contracts( _ctx(capital_usd="100000", dvol_now="40", open_engagement_usd="975"), cfg, ) assert res.n_contracts == 1 # --------------------------------------------------------------------------- # Concurrent positions guard # --------------------------------------------------------------------------- def test_concurrent_positions_at_cap_returns_zero(cfg: StrategyConfig) -> None: res = compute_contracts(_ctx(capital_usd="1500", other_open_positions=1), cfg) assert res.n_contracts == 0 assert res.reason_if_zero is not None assert "position" in res.reason_if_zero.lower() # --------------------------------------------------------------------------- # Edge cases # --------------------------------------------------------------------------- def test_below_one_contract_returns_zero(cfg: StrategyConfig) -> None: # capital 600 → 13% * 600 = 78; floor(78/93)=0 → undersize res = compute_contracts(_ctx(capital_usd="600", dvol_now="40"), cfg) assert res.n_contracts == 0 assert "undersize" in (res.reason_if_zero or "").lower() def test_max_contracts_per_trade_cap_binds(cfg: StrategyConfig) -> None: # very high capital + tiny ML → would compute > 4 → cap at 4. res = compute_contracts( _ctx( capital_usd="100000", max_loss_per_contract_usd="1", dvol_now="40", eur_to_usd=Decimal("100"), # cap 20000 USD per trade → no cap ), cfg, ) assert res.n_contracts == cfg.sizing.max_contracts_per_trade def test_zero_max_loss_per_contract_returns_zero(cfg: StrategyConfig) -> None: res = compute_contracts(_ctx(max_loss_per_contract_usd="0"), cfg) assert res.n_contracts == 0 assert res.reason_if_zero is not None