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>
176 lines
5.5 KiB
Python
176 lines
5.5 KiB
Python
"""Auto-pause circuit breaker (§7-bis F).
|
||
|
||
Pure-function evaluation that consults `system_state.auto_pause_until`
|
||
and the rolling P/L of the last N closed positions to decide whether
|
||
the engine should skip an entry cycle.
|
||
|
||
Two responsibilities, both deterministic at call time:
|
||
|
||
* :func:`is_paused` — returns ``True`` when the persisted
|
||
``auto_pause_until`` is in the future. Independent from the kill
|
||
switch, which targets technical errors.
|
||
* :func:`evaluate_drawdown_breach` — given the last N closed P/Ls and
|
||
the current capital, returns whether the rolling drawdown breached
|
||
the configured ``max_drawdown_pct`` threshold. The orchestrator
|
||
layer is the one that flips the persisted state on breach (this
|
||
module stays I/O-free for testability).
|
||
|
||
The two are separated on purpose: ``is_paused`` is the cheap,
|
||
read-only gate consulted at the start of every entry cycle; the
|
||
breach evaluation runs once per cycle right after the entry
|
||
filtering, before the entry is actually placed.
|
||
"""
|
||
|
||
from __future__ import annotations
|
||
|
||
from dataclasses import dataclass
|
||
from datetime import datetime, timedelta
|
||
from decimal import Decimal
|
||
|
||
from cerbero_bite.config.schema import AutoPauseConfig
|
||
from cerbero_bite.state.models import SystemStateRecord
|
||
|
||
__all__ = [
|
||
"AutoPauseDecision",
|
||
"PauseStatus",
|
||
"evaluate_drawdown_breach",
|
||
"is_paused",
|
||
"pause_until",
|
||
]
|
||
|
||
|
||
@dataclass(frozen=True)
|
||
class PauseStatus:
|
||
"""Snapshot del flag di auto-pausa al momento della valutazione."""
|
||
|
||
paused: bool
|
||
until: datetime | None
|
||
reason: str | None
|
||
|
||
|
||
@dataclass(frozen=True)
|
||
class AutoPauseDecision:
|
||
"""Esito di :func:`evaluate_drawdown_breach`."""
|
||
|
||
should_pause: bool
|
||
cumulative_pnl_usd: Decimal
|
||
drawdown_pct: Decimal
|
||
threshold_pct: Decimal
|
||
reason: str | None
|
||
|
||
|
||
def is_paused(
|
||
state: SystemStateRecord | None, *, now: datetime
|
||
) -> PauseStatus:
|
||
"""Restituisce lo stato della pausa rispetto a ``now``.
|
||
|
||
``state == None`` o ``auto_pause_until == None`` o
|
||
``auto_pause_until <= now`` ⇒ engine attivo.
|
||
"""
|
||
if state is None or state.auto_pause_until is None:
|
||
return PauseStatus(paused=False, until=None, reason=None)
|
||
until = state.auto_pause_until
|
||
if until.tzinfo is not None and now.tzinfo is None:
|
||
# Coerenza: se il valore persistito è tz-aware, normalizziamo.
|
||
return PauseStatus(
|
||
paused=until > now.replace(tzinfo=until.tzinfo),
|
||
until=until,
|
||
reason=state.auto_pause_reason,
|
||
)
|
||
return PauseStatus(
|
||
paused=until > now,
|
||
until=until,
|
||
reason=state.auto_pause_reason,
|
||
)
|
||
|
||
|
||
def pause_until(now: datetime, weeks: int) -> datetime:
|
||
"""Calcola la scadenza della pausa (``now + weeks``).
|
||
|
||
Estratto in funzione separata per facilitare i test e per ricordare
|
||
che la pausa è espressa in **settimane** (la strategia ha cron
|
||
settimanale; pause più corte non avrebbero modo di evitare una
|
||
settimana di entry).
|
||
"""
|
||
return now + timedelta(weeks=max(1, weeks))
|
||
|
||
|
||
def evaluate_drawdown_breach(
|
||
*,
|
||
cfg: AutoPauseConfig,
|
||
recent_pnl_usd: list[Decimal],
|
||
capital_usd: Decimal,
|
||
) -> AutoPauseDecision:
|
||
"""Decide se la pausa va armata ora dato il rolling P/L.
|
||
|
||
Regola: se la somma dei P/L delle ultime ``cfg.lookback_trades``
|
||
posizioni chiuse è negativa e in valore assoluto eccede
|
||
``cfg.max_drawdown_pct × capital_usd``, ritorna
|
||
``should_pause=True``. Tutte le altre condizioni → False.
|
||
|
||
``cfg.enabled=False`` → ritorna sempre False (filtro disabilitato).
|
||
Lookback insufficiente → ritorna False (non scattiamo finché non
|
||
abbiamo abbastanza storia per giudicare).
|
||
"""
|
||
threshold_pct = cfg.max_drawdown_pct
|
||
cumulative = sum((p for p in recent_pnl_usd), start=Decimal("0"))
|
||
|
||
if not cfg.enabled:
|
||
return AutoPauseDecision(
|
||
should_pause=False,
|
||
cumulative_pnl_usd=cumulative,
|
||
drawdown_pct=Decimal("0"),
|
||
threshold_pct=threshold_pct,
|
||
reason=None,
|
||
)
|
||
|
||
if len(recent_pnl_usd) < cfg.lookback_trades:
|
||
return AutoPauseDecision(
|
||
should_pause=False,
|
||
cumulative_pnl_usd=cumulative,
|
||
drawdown_pct=Decimal("0"),
|
||
threshold_pct=threshold_pct,
|
||
reason=None,
|
||
)
|
||
|
||
if capital_usd <= 0:
|
||
return AutoPauseDecision(
|
||
should_pause=False,
|
||
cumulative_pnl_usd=cumulative,
|
||
drawdown_pct=Decimal("0"),
|
||
threshold_pct=threshold_pct,
|
||
reason=None,
|
||
)
|
||
|
||
# Solo perdite ci interessano: vincite cumulate non scattano la pausa.
|
||
if cumulative >= 0:
|
||
return AutoPauseDecision(
|
||
should_pause=False,
|
||
cumulative_pnl_usd=cumulative,
|
||
drawdown_pct=cumulative / capital_usd,
|
||
threshold_pct=threshold_pct,
|
||
reason=None,
|
||
)
|
||
|
||
drawdown_pct = (-cumulative) / capital_usd
|
||
if drawdown_pct >= threshold_pct:
|
||
return AutoPauseDecision(
|
||
should_pause=True,
|
||
cumulative_pnl_usd=cumulative,
|
||
drawdown_pct=drawdown_pct,
|
||
threshold_pct=threshold_pct,
|
||
reason=(
|
||
f"rolling DD {drawdown_pct:.2%} ≥ {threshold_pct:.2%} "
|
||
f"(last {cfg.lookback_trades} trades, "
|
||
f"cumulative {cumulative} USD)"
|
||
),
|
||
)
|
||
|
||
return AutoPauseDecision(
|
||
should_pause=False,
|
||
cumulative_pnl_usd=cumulative,
|
||
drawdown_pct=drawdown_pct,
|
||
threshold_pct=threshold_pct,
|
||
reason=None,
|
||
)
|