"""TDD for :mod:`cerbero_bite.core.exit_decision`. Spec: ``docs/01-strategy-rules.md §7`` and ``docs/03-algorithms.md §6``. The eight mandatory cases listed in §6 are exercised here, with the ``mark=30% credit`` case re-interpreted to satisfy the time-stop exception consistently with rule 1 (which always fires earlier when mark ≤ 50% credit). """ from __future__ import annotations from datetime import UTC, datetime, timedelta from decimal import Decimal from uuid import uuid4 import pytest from cerbero_bite.config import SpreadType, StrategyConfig, golden_config from cerbero_bite.core.exit_decision import PositionSnapshot, evaluate from cerbero_bite.core.types import OptionLeg @pytest.fixture def cfg() -> StrategyConfig: return golden_config() def _legs(spread_type: SpreadType = "bull_put", size: int = 1) -> list[OptionLeg]: expiry = datetime(2026, 5, 15, 8, 0, tzinfo=UTC) if spread_type == "bull_put": return [ OptionLeg( instrument="ETH-X-2475-P", side="SELL", strike=Decimal("2475"), expiry=expiry, type="P", size=size, mid_price_eth=Decimal("0.020"), delta=Decimal("-0.12"), gamma=Decimal("0.001"), theta=Decimal("-0.0005"), vega=Decimal("0.10"), ), OptionLeg( instrument="ETH-X-2350-P", side="BUY", strike=Decimal("2350"), expiry=expiry, type="P", size=size, mid_price_eth=Decimal("0.005"), delta=Decimal("-0.08"), gamma=Decimal("0.001"), theta=Decimal("-0.0003"), vega=Decimal("0.07"), ), ] return [ OptionLeg( instrument="ETH-X-3525-C", side="SELL", strike=Decimal("3525"), expiry=expiry, type="C", size=size, mid_price_eth=Decimal("0.020"), delta=Decimal("0.12"), gamma=Decimal("0.001"), theta=Decimal("-0.0005"), vega=Decimal("0.10"), ), OptionLeg( instrument="ETH-X-3645-C", side="BUY", strike=Decimal("3645"), expiry=expiry, type="C", size=size, mid_price_eth=Decimal("0.005"), delta=Decimal("0.08"), gamma=Decimal("0.001"), theta=Decimal("-0.0003"), vega=Decimal("0.07"), ), ] def _snapshot( *, spread_type: SpreadType = "bull_put", credit_received_eth: str = "0.030", mark_combo_now_eth: str = "0.020", dvol_at_entry: str = "50", dvol_now: str = "50", delta_short_now: str | None = None, return_4h_now: str = "0", days_to_expiry: int = 14, spot_at_entry: str = "3000", spot_now: str = "3000", eth_price_usd_now: str = "3000", ) -> PositionSnapshot: now = datetime(2026, 4, 27, 14, 0, tzinfo=UTC) expiry = now + timedelta(days=days_to_expiry) if delta_short_now is None: delta_short_now = "-0.12" if spread_type == "bull_put" else "0.12" legs = _legs(spread_type=spread_type) return PositionSnapshot( proposal_id=uuid4(), spread_type=spread_type, legs=legs, credit_received_eth=Decimal(credit_received_eth), credit_received_usd=Decimal(credit_received_eth) * Decimal(eth_price_usd_now), spot_at_entry=Decimal(spot_at_entry), dvol_at_entry=Decimal(dvol_at_entry), expiry=expiry, opened_at=now - timedelta(days=4), eth_price_usd_now=Decimal(eth_price_usd_now), spot_now=Decimal(spot_now), dvol_now=Decimal(dvol_now), mark_combo_now_eth=Decimal(mark_combo_now_eth), delta_short_now=Decimal(delta_short_now), return_4h_now=Decimal(return_4h_now), now=now, ) # --------------------------------------------------------------------------- # Mandatory cases from docs/03-algorithms.md §6 # --------------------------------------------------------------------------- def test_mark_at_50pct_credit_triggers_close_profit(cfg: StrategyConfig) -> None: snap = _snapshot(credit_received_eth="0.030", mark_combo_now_eth="0.015") res = evaluate(snap, cfg) assert res.action == "CLOSE_PROFIT" def test_mark_at_250pct_credit_triggers_close_stop(cfg: StrategyConfig) -> None: snap = _snapshot(credit_received_eth="0.030", mark_combo_now_eth="0.075") res = evaluate(snap, cfg) assert res.action == "CLOSE_STOP" def test_dvol_increase_above_band_triggers_close_vol(cfg: StrategyConfig) -> None: snap = _snapshot( credit_received_eth="0.030", mark_combo_now_eth="0.020", # 67% credit, no profit/stop dvol_at_entry="50", dvol_now="62", # +12, above +10 trigger ) res = evaluate(snap, cfg) assert res.action == "CLOSE_VOL" def test_six_days_left_mark_above_skip_threshold_triggers_close_time( cfg: StrategyConfig, ) -> None: # mark = 80% credit > 70% credit (skip threshold) → CLOSE_TIME snap = _snapshot( credit_received_eth="0.030", mark_combo_now_eth="0.024", days_to_expiry=6, ) res = evaluate(snap, cfg) assert res.action == "CLOSE_TIME" def test_six_days_left_mark_in_skip_zone_holds(cfg: StrategyConfig) -> None: # mark = 60% credit ∈ (50%, 70%] → close to profit, time stop is skipped. snap = _snapshot( credit_received_eth="0.030", mark_combo_now_eth="0.018", days_to_expiry=6, ) res = evaluate(snap, cfg) assert res.action == "HOLD" def test_short_delta_breach_triggers_close_delta(cfg: StrategyConfig) -> None: snap = _snapshot( credit_received_eth="0.030", mark_combo_now_eth="0.020", delta_short_now="-0.32", ) res = evaluate(snap, cfg) assert res.action == "CLOSE_DELTA" def test_4h_adverse_move_bull_put_triggers_close_averse(cfg: StrategyConfig) -> None: snap = _snapshot( spread_type="bull_put", credit_received_eth="0.030", mark_combo_now_eth="0.020", return_4h_now="-0.07", ) res = evaluate(snap, cfg) assert res.action == "CLOSE_AVERSE" def test_4h_adverse_move_bear_call_triggers_close_averse(cfg: StrategyConfig) -> None: snap = _snapshot( spread_type="bear_call", credit_received_eth="0.030", mark_combo_now_eth="0.020", return_4h_now="0.07", ) res = evaluate(snap, cfg) assert res.action == "CLOSE_AVERSE" def test_neutral_state_returns_hold(cfg: StrategyConfig) -> None: snap = _snapshot( credit_received_eth="0.030", mark_combo_now_eth="0.020", ) res = evaluate(snap, cfg) assert res.action == "HOLD" # --------------------------------------------------------------------------- # Trigger ordering — first match wins # --------------------------------------------------------------------------- def test_profit_wins_over_vol_stop(cfg: StrategyConfig) -> None: # mark = 30% credit AND DVOL +12 → profit takes precedence snap = _snapshot( credit_received_eth="0.030", mark_combo_now_eth="0.009", dvol_at_entry="50", dvol_now="62", ) res = evaluate(snap, cfg) assert res.action == "CLOSE_PROFIT" def test_stop_wins_over_delta_breach(cfg: StrategyConfig) -> None: snap = _snapshot( credit_received_eth="0.030", mark_combo_now_eth="0.075", # 250% → stop delta_short_now="-0.40", # would also be CLOSE_DELTA ) res = evaluate(snap, cfg) assert res.action == "CLOSE_STOP" # --------------------------------------------------------------------------- # PnL reporting # --------------------------------------------------------------------------- def test_pnl_in_eth_and_usd_reflect_credit_minus_debit(cfg: StrategyConfig) -> None: snap = _snapshot( credit_received_eth="0.030", mark_combo_now_eth="0.018", eth_price_usd_now="3100", ) res = evaluate(snap, cfg) # pnl_eth = 0.030 - 0.018 = 0.012; pnl_usd = 0.012 × 3100 = 37.2 assert res.pnl_estimate_eth == Decimal("0.012") assert res.pnl_estimate_usd == Decimal("37.2") def test_iron_condor_adverse_move_either_direction(cfg: StrategyConfig) -> None: snap = _snapshot( spread_type="iron_condor", credit_received_eth="0.030", mark_combo_now_eth="0.020", return_4h_now="-0.06", ) res = evaluate(snap, cfg) assert res.action == "CLOSE_AVERSE"