Files
Cerbero-Bite/tests/unit/test_exit_decision.py
T
root 1c6baaee83 feat(strategy): F+D+A miglioramenti — auto-pause, vol-harvest, delta dinamico
Implementa tre miglioramenti dalla roadmap di "📚 Strategia" + scaffolding del quarto.
Tutti retro-compatibili: i defaults della golden config disabilitano le nuove funzioni
così il comportamento attuale resta invariato finché l'operatore non le accende
esplicitamente in `strategy.yaml`. Il profilo `strategy.aggressiva.yaml` opta-in
agli incrementi più impattanti.

**F — Auto-pause su drawdown rolling (§7-bis)**

Circuit breaker sopra il kill-switch tecnico. Quando le ultime N posizioni
chiuse hanno cumulato perdite oltre `max_drawdown_pct × capitale_attuale`,
l'engine si auto-mette in pausa per `pause_weeks` settimane. Difende dai
regime change non rilevati dai filtri quant — se i filtri stanno fallendo
sistematicamente, fermarsi è meglio che continuare a sanguinare.

- `AutoPauseConfig` + `cfg.auto_pause` (top-level, default disabled).
- Migrazione SQL `0004_auto_pause.sql`: `system_state.auto_pause_until`
  e `auto_pause_reason` (NULL = engine attivo).
- Nuovo modulo puro `runtime/auto_pause.py` con `is_paused()` (gate I/O-free)
  e `evaluate_drawdown_breach()` (decide se armare).
- `entry_cycle` consulta `is_paused` subito dopo il kill-switch e arma
  la pausa dopo aver calcolato il capitale; nuovo status `_STATUS_AUTO_PAUSED`.
- Repository: `set_auto_pause`, `recent_closed_position_pnls_usd`.
- 12 test unitari: gate filter on/off, lookback insufficiente, soglia
  esatta, capitale non valido, transizioni paused → not-paused.

**D — Vol-collapse harvest (§7-bis)**

Exit opportunistica: quando DVOL è scesa di tot punti rispetto all'entry
e siamo in profit, esce subito. Edge IV-RV catturato, non c'è motivo di
tenere fino al profit-take. Nuovo `ExitAction = "CLOSE_VOL_HARVEST"`,
gate `exit.vol_harvest_dvol_decrease` (default 0 = off). 5 test unitari.

**A — Delta target dinamico per regime DVOL (§3.2)**

Strike short adattivo alla volatilità: a DVOL bassa il margine OTM è
generoso ⇒ posso prendere più premio (delta 0.15); a DVOL alta voglio
più safety distance (delta 0.10). Nuovo `DeltaByDvolBand` (step
function); quando `delta_by_dvol` è popolato, `_select_short` legge
la prima banda ascending con `dvol_now ≤ dvol_under`. Default vuoto =
comportamento invariato. `select_strikes` accetta nuovo kwarg
`dvol_now`, propagato da `entry_cycle`. 4 test unitari.

**C — Scaffolding profit-take graduale (§7.1bis)**

Schema in place ma runtime non ancora wirato. Aggiunge `PartialProfitLevel`
e `exit.profit_take_partial_levels` (default vuoto). Nuovo
`ExitAction = "CLOSE_PROFIT_PARTIAL"` nella Literal. La pipeline di
chiusure parziali nel runtime (entry_cycle / repository / clients)
richiede refactor del position model — lasciato come TODO per un PR
dedicato. La schema è pronta a recepire la config futura senza altri
breaking change.

**Profili aggiornati**

- `strategy.yaml` (golden, 1.2.0): tutto disabilitato by default.
- `strategy.conservativa.yaml` (1.2.0-cons): identico al golden.
- `strategy.aggressiva.yaml` (1.2.0-aggr): A+D+F enabled
  (delta_by_dvol 0.15/0.12/0.10, vol_harvest a 15 pt vol,
  auto_pause @ 15% DD su 5 trade, 2 settimane pausa).

Bump versioni 1.1.0 → 1.2.0, hash ricalcolati, test pinning aggiornato.

Suite: 426 passed.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-01 20:07:25 +00:00

362 lines
11 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"
# ---------------------------------------------------------------------------
# §7-bis (D): vol-collapse harvest
# ---------------------------------------------------------------------------
def _harvest_cfg(
cfg: StrategyConfig, *, threshold: str = "15"
) -> StrategyConfig:
"""Clona la golden config con la soglia di vol-harvest abilitata."""
from cerbero_bite.config import ExitConfig
return cfg.model_copy(
update={
"exit": ExitConfig(
**{
**cfg.exit.model_dump(),
"vol_harvest_dvol_decrease": Decimal(threshold),
}
)
}
)
def test_vol_harvest_disabled_by_default_does_not_fire(cfg: StrategyConfig) -> None:
# Default: vol_harvest_dvol_decrease = 0 ⇒ filtro disabilitato.
snap = _snapshot(
credit_received_eth="0.030",
mark_combo_now_eth="0.022", # in profit (debit < credit)
dvol_at_entry="60",
dvol_now="40", # crollato di 20 punti
)
res = evaluate(snap, cfg)
assert res.action == "HOLD"
def test_vol_harvest_fires_when_dvol_collapsed_in_profit(
cfg: StrategyConfig,
) -> None:
harvest = _harvest_cfg(cfg, threshold="15")
snap = _snapshot(
credit_received_eth="0.030",
mark_combo_now_eth="0.022", # in profit ma sopra profit_take 50%
dvol_at_entry="60",
dvol_now="42", # 18, supera la soglia 15
)
res = evaluate(snap, harvest)
assert res.action == "CLOSE_VOL_HARVEST"
assert "harvest" in res.reason
def test_vol_harvest_does_not_fire_when_in_loss(cfg: StrategyConfig) -> None:
# Anche se DVOL crolla, se siamo in perdita non vogliamo harvest:
# è una funzione di "esci con il profitto in mano", non un panico.
harvest = _harvest_cfg(cfg, threshold="15")
snap = _snapshot(
credit_received_eth="0.030",
mark_combo_now_eth="0.040", # debit > credit ⇒ in perdita
dvol_at_entry="60",
dvol_now="42",
)
res = evaluate(snap, harvest)
assert res.action != "CLOSE_VOL_HARVEST"
def test_vol_harvest_does_not_fire_below_threshold(cfg: StrategyConfig) -> None:
harvest = _harvest_cfg(cfg, threshold="15")
snap = _snapshot(
credit_received_eth="0.030",
mark_combo_now_eth="0.022",
dvol_at_entry="60",
dvol_now="50", # 10, sotto la soglia 15
)
res = evaluate(snap, harvest)
assert res.action == "HOLD"
def test_profit_take_wins_over_vol_harvest(cfg: StrategyConfig) -> None:
# Quando il profit-take è già colpito, non passiamo per vol-harvest.
harvest = _harvest_cfg(cfg, threshold="15")
snap = _snapshot(
credit_received_eth="0.030",
mark_combo_now_eth="0.014", # ≤ 50% credit ⇒ profit-take
dvol_at_entry="60",
dvol_now="42",
)
res = evaluate(snap, harvest)
assert res.action == "CLOSE_PROFIT"