Files
Cerbero-Bite/src/cerbero_bite/core/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

177 lines
5.7 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.
"""Exit-decision rule engine (``docs/03-algorithms.md §6``).
Pure function over a :class:`PositionSnapshot`. Triggers are evaluated
in the documented order; the first match wins. ``HOLD`` is returned
when no rule fires. The time-stop ``skip_if_close_to_profit`` exception
is interpreted as ``mark ≤ 70% × credit_received`` (re-using the same
units as the profit gate), which is the only interpretation that yields
non-trivial behaviour: rule 1 takes everything below 50% credit; the
exception covers the (50%, 70%] credit band where we wait for rule 1
to fire next cycle.
"""
from __future__ import annotations
from datetime import datetime
from decimal import Decimal
from typing import Literal
from uuid import UUID
from pydantic import BaseModel, ConfigDict
from cerbero_bite.config import SpreadType, StrategyConfig
from cerbero_bite.core.types import OptionLeg
__all__ = ["ExitAction", "ExitDecisionResult", "PositionSnapshot", "evaluate"]
ExitAction = Literal[
"HOLD",
"CLOSE_PROFIT",
"CLOSE_PROFIT_PARTIAL",
"CLOSE_STOP",
"CLOSE_VOL",
"CLOSE_VOL_HARVEST",
"CLOSE_TIME",
"CLOSE_DELTA",
"CLOSE_AVERSE",
]
class PositionSnapshot(BaseModel):
"""Inputs for :func:`evaluate`. All ETH amounts are *totals* for the position."""
model_config = ConfigDict(frozen=True, extra="forbid")
proposal_id: UUID
spread_type: SpreadType
legs: list[OptionLeg]
credit_received_eth: Decimal
credit_received_usd: Decimal
spot_at_entry: Decimal
dvol_at_entry: Decimal
expiry: datetime
opened_at: datetime
eth_price_usd_now: Decimal
spot_now: Decimal
dvol_now: Decimal
mark_combo_now_eth: Decimal
delta_short_now: Decimal
return_4h_now: Decimal
now: datetime
class ExitDecisionResult(BaseModel):
"""Action + human-readable reason + PnL estimate at the moment of decision."""
model_config = ConfigDict(frozen=True, extra="forbid")
action: ExitAction
reason: str
pnl_estimate_eth: Decimal
pnl_estimate_usd: Decimal
def _days_to_expiry(snapshot: PositionSnapshot) -> Decimal:
delta = snapshot.expiry - snapshot.now
return Decimal(str(delta.total_seconds())) / Decimal("86400")
def _adverse_move(spread_type: SpreadType, return_4h: Decimal, threshold: Decimal) -> bool:
if spread_type == "bull_put":
return return_4h <= -threshold
if spread_type == "bear_call":
return return_4h >= threshold
# iron_condor: adverse on either side
return return_4h.copy_abs() >= threshold
def evaluate(snapshot: PositionSnapshot, cfg: StrategyConfig) -> ExitDecisionResult:
"""Return the exit action for the given position snapshot."""
ec = cfg.exit
credit = snapshot.credit_received_eth
debit = snapshot.mark_combo_now_eth
pnl_eth = credit - debit
pnl_usd = pnl_eth * snapshot.eth_price_usd_now
profit_take_thresh = credit * ec.profit_take_pct_of_credit
stop_thresh = credit * ec.stop_loss_mark_x_credit
skip_time_thresh = credit * ec.time_stop_skip_if_close_to_profit_pct
days_left = _days_to_expiry(snapshot)
def _result(action: ExitAction, reason: str) -> ExitDecisionResult:
return ExitDecisionResult(
action=action,
reason=reason,
pnl_estimate_eth=pnl_eth,
pnl_estimate_usd=pnl_usd,
)
# 1. Profit take
if debit <= profit_take_thresh:
return _result(
"CLOSE_PROFIT",
f"mark {debit}{ec.profit_take_pct_of_credit:.0%} of credit {credit}",
)
# 1bis. Vol-collapse harvest (D): siamo IN profit (debit < credit) e
# la DVOL è scesa di tot punti rispetto all'entry. Edge IV-RV già
# catturato, non c'è motivo di tenere fino a profit_take. Esce
# opportunisticamente quando il regime di vol che giustificava
# l'entry non c'è più.
if (
ec.vol_harvest_dvol_decrease > 0
and debit < credit
and snapshot.dvol_now <= snapshot.dvol_at_entry - ec.vol_harvest_dvol_decrease
):
return _result(
"CLOSE_VOL_HARVEST",
f"DVOL {snapshot.dvol_now} ≤ entry {snapshot.dvol_at_entry} "
f"{ec.vol_harvest_dvol_decrease}, harvest while in profit",
)
# 2. Stop loss
if debit >= stop_thresh:
return _result(
"CLOSE_STOP",
f"mark {debit}{ec.stop_loss_mark_x_credit}× credit {credit}",
)
# 3. Vol stop
if snapshot.dvol_now >= snapshot.dvol_at_entry + ec.vol_stop_dvol_increase:
return _result(
"CLOSE_VOL",
f"DVOL {snapshot.dvol_now} ≥ entry {snapshot.dvol_at_entry} "
f"+ {ec.vol_stop_dvol_increase}",
)
# 4. Time stop with "close to profit" exception
if days_left <= ec.time_stop_dte_remaining and debit > skip_time_thresh:
return _result(
"CLOSE_TIME",
f"DTE {days_left:.2f}{ec.time_stop_dte_remaining} and mark "
f"{debit} above skip threshold {skip_time_thresh}",
)
# When DTE ≤ 7 but mark is in the (50%, 70%]-credit "close to profit"
# zone, we deliberately fall through; rule 1 will fire next cycle.
# 5. Strike tested
if snapshot.delta_short_now.copy_abs() >= ec.delta_breach_threshold:
return _result(
"CLOSE_DELTA",
f"|delta_short| {snapshot.delta_short_now.copy_abs()}"
f"{ec.delta_breach_threshold}",
)
# 6. Explosive adverse move
if _adverse_move(snapshot.spread_type, snapshot.return_4h_now, ec.adverse_move_4h_pct):
return _result(
"CLOSE_AVERSE",
f"4h return {snapshot.return_4h_now} adverse for {snapshot.spread_type}",
)
return _result("HOLD", "all triggers within tolerance")