Files
Cerbero-Bite/tests/unit/test_exit_decision.py
T
Adriano fbb7753cc6 Phase 1: core algorithms
Implementa i sette algoritmi puri di docs/03-algorithms.md con
disciplina TDD: 112 test, copertura statement+branch al 100% su
core/ e config/, mypy --strict pulito, ruff pulito.

Moduli:
- config/schema.py: StrategyConfig Pydantic v2 con validatori di
  consistenza (kelly, delta, OTM, spread width, profit/stop).
- core/types.py: OptionQuote e OptionLeg condivisi.
- core/entry_validator.py: validate_entry (accumula motivi) e
  compute_bias (bull_put/bear_call/iron_condor/None).
- core/liquidity_gate.py: check OI/volume/spread/depth + slippage
  stimato in % del credito.
- core/sizing_engine.py: Quarter Kelly con cap 200/1000 EUR e
  bande DVOL.
- core/combo_builder.py: select_strikes (DTE/OTM/delta/width/credit)
  e build (ComboProposal con credit/max_loss/breakeven).
- core/greeks_aggregator.py: somma firmata BUY/SELL, theta in USD.
- core/exit_decision.py: 6 trigger ordinati con eccezione skip-time
  vicino a profit (mark in (50%,70%] credito).
- core/kelly_recalibration.py: full/quarter Kelly, confidence per
  sample size, blend medio in fascia 30-99 trade.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-27 10:14:06 +02:00

274 lines
8.5 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""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"