1c6baaee83
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>
177 lines
5.7 KiB
Python
177 lines
5.7 KiB
Python
"""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")
|