"""TDD for :mod:`cerbero_bite.core.combo_builder`. Spec: ``docs/03-algorithms.md §4`` and ``docs/01-strategy-rules.md §3``. """ from __future__ import annotations from datetime import UTC, datetime, timedelta from decimal import Decimal from uuid import UUID import pytest from cerbero_bite.config import StrategyConfig, golden_config from cerbero_bite.core.combo_builder import build, select_strikes from cerbero_bite.core.types import OptionQuote @pytest.fixture def cfg() -> StrategyConfig: return golden_config() @pytest.fixture def now() -> datetime: return datetime(2026, 4, 27, 14, 0, tzinfo=UTC) def _quote( *, strike: str, delta: str, option_type: str = "P", expiry_dte: int = 18, mid: str | None = None, bid: str | None = None, ask: str | None = None, open_interest: int = 500, volume_24h: int = 100, book_depth_top3: int = 30, now_dt: datetime = datetime(2026, 4, 27, 14, 0, tzinfo=UTC), ) -> OptionQuote: expiry = (now_dt + timedelta(days=expiry_dte)).replace(hour=8, minute=0, second=0) if mid is None: # crude pricing: deeper OTM → smaller premium; absolute delta proxy mid = str((Decimal(delta).copy_abs() * Decimal("0.10")).quantize(Decimal("0.0001"))) mid_d = Decimal(mid) if bid is None: bid = str((mid_d * Decimal("0.98")).quantize(Decimal("0.000001"))) if ask is None: ask = str((mid_d * Decimal("1.02")).quantize(Decimal("0.000001"))) return OptionQuote( instrument=f"ETH-{expiry.strftime('%-d%b%y').upper()}-{strike}-{option_type}", strike=Decimal(strike), expiry=expiry, option_type=option_type, # type: ignore[arg-type] bid=Decimal(bid), ask=Decimal(ask), mid=mid_d, delta=Decimal(delta), gamma=Decimal("0.001"), theta=Decimal("-0.0005"), vega=Decimal("0.10"), open_interest=open_interest, volume_24h=volume_24h, book_depth_top3=book_depth_top3, ) # --------------------------------------------------------------------------- # select_strikes # --------------------------------------------------------------------------- def _bull_put_chain(now_dt: datetime) -> list[OptionQuote]: """Realistic bull-put chain around spot 3000. Mids are tuned so that the candidate (2475 short, 2350 long) yields credit_usd / width_usd ≈ 36% — comfortably above the 30% gate. """ # Distance OTM in [15%, 25%] of 3000 → strikes in [2250, 2550]. return [ # Way too close (delta too high) — should be ignored. _quote(strike="2700", delta="-0.30", mid="0.060", now_dt=now_dt), # In OTM range, delta in [0.10, 0.15] _quote(strike="2550", delta="-0.14", mid="0.022", now_dt=now_dt), _quote(strike="2475", delta="-0.12", mid="0.020", now_dt=now_dt), # closest to 0.12 _quote(strike="2400", delta="-0.10", mid="0.014", now_dt=now_dt), # Below OTM range (too far) — delta too small even if in range _quote(strike="2250", delta="-0.06", mid="0.006", now_dt=now_dt), # The long strike candidates (further OTM, ~4% width below short) _quote(strike="2370", delta="-0.09", mid="0.008", now_dt=now_dt), _quote(strike="2350", delta="-0.08", mid="0.005", now_dt=now_dt), ] def test_select_strikes_picks_short_closest_to_delta_target( cfg: StrategyConfig, now: datetime ) -> None: chain = _bull_put_chain(now) result = select_strikes( chain=chain, bias="bull_put", spot=Decimal("3000"), now=now, cfg=cfg, ) assert result is not None short, long_ = result assert short.strike == Decimal("2475") # |delta|=0.12 closest to 0.12 # width target = 4% × 3000 = 120 → long_strike target 2355; nearest at 2350 assert long_.strike == Decimal("2350") def test_select_strikes_returns_none_when_no_strike_in_otm_range( cfg: StrategyConfig, now: datetime ) -> None: # All strikes too close (< 15% OTM). chain = [ _quote(strike="2900", delta="-0.30", now_dt=now), _quote(strike="2800", delta="-0.20", now_dt=now), ] assert ( select_strikes(chain=chain, bias="bull_put", spot=Decimal("3000"), now=now, cfg=cfg) is None ) def test_select_strikes_returns_none_when_delta_out_of_band( cfg: StrategyConfig, now: datetime ) -> None: # OTM range OK but delta way off [0.10, 0.15]. chain = [ _quote(strike="2475", delta="-0.05", now_dt=now), _quote(strike="2400", delta="-0.04", now_dt=now), ] assert ( select_strikes(chain=chain, bias="bull_put", spot=Decimal("3000"), now=now, cfg=cfg) is None ) def test_select_strikes_returns_none_when_long_width_outside_range( cfg: StrategyConfig, now: datetime ) -> None: # Short OK; long candidates are all too close (< 3%) or too far (> 5%). short = _quote(strike="2475", delta="-0.12", mid="0.020", now_dt=now) too_close = _quote(strike="2470", delta="-0.115", mid="0.018", now_dt=now) # 0.2% width too_far = _quote(strike="2200", delta="-0.05", mid="0.005", now_dt=now) # 9.17% width chain = [short, too_close, too_far] assert ( select_strikes(chain=chain, bias="bull_put", spot=Decimal("3000"), now=now, cfg=cfg) is None ) def test_select_strikes_returns_none_when_credit_to_width_below_min( cfg: StrategyConfig, now: datetime ) -> None: # Width 125 USD; credit (mid_short - mid_long) only 0.0001 ETH ≈ 0.3 USD → 0.24%. short = _quote(strike="2475", delta="-0.12", mid="0.012", now_dt=now) long_ = _quote(strike="2350", delta="-0.08", mid="0.0119", now_dt=now) chain = [short, long_] assert ( select_strikes(chain=chain, bias="bull_put", spot=Decimal("3000"), now=now, cfg=cfg) is None ) def test_select_strikes_filters_out_options_outside_dte_window( cfg: StrategyConfig, now: datetime ) -> None: chain = [ _quote(strike="2475", delta="-0.12", mid="0.012", expiry_dte=5, now_dt=now), _quote(strike="2350", delta="-0.08", mid="0.007", expiry_dte=5, now_dt=now), _quote(strike="2475", delta="-0.12", mid="0.012", expiry_dte=40, now_dt=now), _quote(strike="2350", delta="-0.08", mid="0.007", expiry_dte=40, now_dt=now), ] assert ( select_strikes(chain=chain, bias="bull_put", spot=Decimal("3000"), now=now, cfg=cfg) is None ) def test_select_strikes_picks_expiry_closest_to_dte_target( cfg: StrategyConfig, now: datetime ) -> None: # Two valid expiries: ~16 DTE and ~21 DTE. Target=18 → 16 closer than 21. chain = [ _quote(strike="2475", delta="-0.12", mid="0.020", expiry_dte=17, now_dt=now), _quote(strike="2350", delta="-0.08", mid="0.005", expiry_dte=17, now_dt=now), _quote(strike="2475", delta="-0.12", mid="0.025", expiry_dte=22, now_dt=now), _quote(strike="2350", delta="-0.08", mid="0.008", expiry_dte=22, now_dt=now), ] result = select_strikes( chain=chain, bias="bull_put", spot=Decimal("3000"), now=now, cfg=cfg ) assert result is not None short, _ = result # 17 days requested, but expiry is set at 08:00 UTC and now at 14:00, so # the calendar-day delta is 16 days for the closer expiry, 21 for the far. picked_dte = (short.expiry - now).days assert picked_dte == 16 def test_select_strikes_bear_call_picks_calls_above_spot( cfg: StrategyConfig, now: datetime ) -> None: spot = Decimal("3000") chain = [ # OTM call range = [3450, 3750] (15% to 25% above). # Mids tuned so credit ≈ 36% of width (≥ 30% gate). _quote(strike="3525", delta="0.12", mid="0.020", option_type="C", now_dt=now), _quote(strike="3645", delta="0.09", mid="0.005", option_type="C", now_dt=now), _quote(strike="3450", delta="0.14", mid="0.024", option_type="C", now_dt=now), # noise: puts that should be ignored _quote(strike="2475", delta="-0.12", mid="0.020", option_type="P", now_dt=now), ] result = select_strikes(chain=chain, bias="bear_call", spot=spot, now=now, cfg=cfg) assert result is not None short, long_ = result assert short.option_type == "C" assert long_.option_type == "C" # short should be 3525 (delta 0.12, closest to target) assert short.strike == Decimal("3525") # width target = 120 above → 3645 assert long_.strike == Decimal("3645") def test_select_strikes_iron_condor_not_supported( cfg: StrategyConfig, now: datetime ) -> None: # IC needs a different orchestration; for now select_strikes returns None for IC. chain = _bull_put_chain(now) assert ( select_strikes(chain=chain, bias="iron_condor", spot=Decimal("3000"), now=now, cfg=cfg) is None ) def test_select_strikes_returns_none_when_chain_has_no_matching_type( cfg: StrategyConfig, now: datetime ) -> None: # Bull put requested but the chain only has calls within the DTE window. chain = [ _quote(strike="3525", delta="0.12", mid="0.020", option_type="C", now_dt=now), _quote(strike="3645", delta="0.08", mid="0.005", option_type="C", now_dt=now), ] assert ( select_strikes(chain=chain, bias="bull_put", spot=Decimal("3000"), now=now, cfg=cfg) is None ) def test_select_strikes_returns_none_when_no_long_candidate_exists( cfg: StrategyConfig, now: datetime ) -> None: # Only the short strike exists; nothing further OTM to pair as the long leg. chain = [_quote(strike="2475", delta="-0.12", mid="0.020", now_dt=now)] assert ( select_strikes(chain=chain, bias="bull_put", spot=Decimal("3000"), now=now, cfg=cfg) is None ) # --------------------------------------------------------------------------- # build # --------------------------------------------------------------------------- def test_build_returns_proposal_with_correct_legs_and_metrics( cfg: StrategyConfig, now: datetime ) -> None: short = _quote(strike="2475", delta="-0.12", mid="0.012", now_dt=now) long_ = _quote(strike="2350", delta="-0.08", mid="0.007", now_dt=now) proposal = build( short=short, long_=long_, n_contracts=2, spot=Decimal("3000"), dvol=Decimal("50"), cfg=cfg, now=now, spread_type="bull_put", ) # legs ordered short-first assert len(proposal.legs) == 2 assert proposal.legs[0].side == "SELL" assert proposal.legs[0].strike == Decimal("2475") assert proposal.legs[1].side == "BUY" assert proposal.legs[1].strike == Decimal("2350") assert proposal.legs[0].size == 2 assert proposal.legs[1].size == 2 # credit per contract = 0.012 - 0.007 = 0.005 ETH; n=2 → 0.010 ETH assert proposal.credit_target_eth == Decimal("0.010") # credit USD = 0.010 × 3000 = 30 assert proposal.credit_target_usd == Decimal("30") # width = 125 USD per contract; credit_per_contract_usd = 0.005×3000 = 15; # max_loss per contract = 125 - 15 = 110 USD; n=2 → 220. assert proposal.max_loss_usd == Decimal("220") assert proposal.spot_at_proposal == Decimal("3000") assert proposal.dvol_at_proposal == Decimal("50") assert proposal.spread_type == "bull_put" # breakeven (bull put) = short_strike - credit_per_contract_usd = 2475 - 15 = 2460 assert proposal.breakeven == Decimal("2460.000") assert isinstance(proposal.proposal_id, UUID) def test_build_bear_call_breakeven_above_short_strike( cfg: StrategyConfig, now: datetime ) -> None: short = _quote(strike="3525", delta="0.12", mid="0.012", option_type="C", now_dt=now) long_ = _quote(strike="3645", delta="0.08", mid="0.007", option_type="C", now_dt=now) proposal = build( short=short, long_=long_, n_contracts=1, spot=Decimal("3000"), dvol=Decimal("55"), cfg=cfg, now=now, spread_type="bear_call", ) # credit per contract = 0.005 ETH × 3000 = 15 USD # breakeven = 3525 + 15 = 3540 assert proposal.breakeven == Decimal("3540") assert proposal.spread_type == "bear_call" # --------------------------------------------------------------------------- # §3.2 (A): dynamic delta target by DVOL regime # --------------------------------------------------------------------------- def _cfg_with_delta_bands(cfg: StrategyConfig) -> StrategyConfig: """Profilo con step-function delta su DVOL. Vol bassa (≤50) → delta 0.15 (più premio), vol media (≤70) → 0.12 (default), vol alta (≤90) → 0.10 (più safety distance). """ from cerbero_bite.config.schema import ( DeltaByDvolBand, ShortStrikeSpec, StructureConfig, ) bands = [ DeltaByDvolBand( dvol_under=Decimal("50"), delta_target=Decimal("0.15"), delta_min=Decimal("0.13"), delta_max=Decimal("0.17"), ), DeltaByDvolBand( dvol_under=Decimal("70"), delta_target=Decimal("0.12"), delta_min=Decimal("0.10"), delta_max=Decimal("0.15"), ), DeltaByDvolBand( dvol_under=Decimal("90"), delta_target=Decimal("0.10"), delta_min=Decimal("0.08"), delta_max=Decimal("0.12"), ), ] new_short = ShortStrikeSpec( **{**cfg.structure.short_strike.model_dump(), "delta_by_dvol": bands} ) return cfg.model_copy( update={ "structure": StructureConfig( **{**cfg.structure.model_dump(exclude={"short_strike"}), "short_strike": new_short} ) } ) def _bull_put_chain_wide(now_dt: datetime) -> list[OptionQuote]: """Chain con shorts e longs per delta 0.10, 0.12, 0.15. I mid sono tarati per superare il credit/width ≥ 30% per ogni accoppiamento short→long testato (vedi commento §3.4). """ return [ # Shorts a delta 0.10 / 0.12 / 0.15 in OTM range [15-25%]. _quote(strike="2535", delta="-0.15", mid="0.026", now_dt=now_dt), _quote(strike="2475", delta="-0.12", mid="0.020", now_dt=now_dt), _quote(strike="2400", delta="-0.10", mid="0.015", now_dt=now_dt), # Long candidati ~4% sotto ciascuno short. _quote(strike="2415", delta="-0.10", mid="0.012", now_dt=now_dt), _quote(strike="2355", delta="-0.08", mid="0.006", now_dt=now_dt), _quote(strike="2280", delta="-0.06", mid="0.002", now_dt=now_dt), ] def test_dynamic_delta_low_dvol_picks_higher_delta( cfg: StrategyConfig, now: datetime ) -> None: """DVOL=40 → banda con delta_target=0.15.""" cfg_dyn = _cfg_with_delta_bands(cfg) chain = _bull_put_chain_wide(now) res = select_strikes( chain=chain, bias="bull_put", spot=Decimal("3000"), now=now, cfg=cfg_dyn, dvol_now=Decimal("40"), ) assert res is not None short, _ = res assert short.delta == Decimal("-0.15") def test_dynamic_delta_mid_dvol_picks_default_delta( cfg: StrategyConfig, now: datetime ) -> None: """DVOL=60 → banda con delta_target=0.12.""" cfg_dyn = _cfg_with_delta_bands(cfg) chain = _bull_put_chain_wide(now) res = select_strikes( chain=chain, bias="bull_put", spot=Decimal("3000"), now=now, cfg=cfg_dyn, dvol_now=Decimal("60"), ) assert res is not None short, _ = res assert short.delta == Decimal("-0.12") def test_dynamic_delta_high_dvol_picks_lower_delta( cfg: StrategyConfig, now: datetime ) -> None: """DVOL=85 → banda con delta_target=0.10 (più safety distance).""" cfg_dyn = _cfg_with_delta_bands(cfg) chain = _bull_put_chain_wide(now) res = select_strikes( chain=chain, bias="bull_put", spot=Decimal("3000"), now=now, cfg=cfg_dyn, dvol_now=Decimal("85"), ) assert res is not None short, _ = res assert short.delta == Decimal("-0.10") def test_dynamic_delta_disabled_default_uses_static_delta( cfg: StrategyConfig, now: datetime ) -> None: """delta_by_dvol vuoto (default) → comportamento invariato.""" chain = _bull_put_chain_wide(now) res = select_strikes( chain=chain, bias="bull_put", spot=Decimal("3000"), now=now, cfg=cfg, # golden config: delta_by_dvol=[] dvol_now=Decimal("40"), ) assert res is not None short, _ = res # Delta target statico = 0.12, quindi torna lo strike a -0.12. assert short.delta == Decimal("-0.12")