Files
Cerbero-Bite/docs/07-risk-controls.md
T
Adriano Dal Pastro ce475da173 docs(safety): disarm via coda manual_actions, gap storici audit, trigger _safe
Aggiornamenti post-incidente 31/05 (kill switch armato 6 giorni da un
singolo McpTimeoutError transitorio) e post-ripristino 05/06:

- 07: tabella trigger — documentato orchestrator._safe (qualsiasi
  eccezione di tick → arm CRITICAL al primo errore) e la tolleranza
  al lag benigno dell'anchor (647e3e5)
- 07: sezione Disarm — percorso raccomandato via coda manual_actions
  a engine acceso; CLI solo a engine fermo (race CLI<->engine = gap
  permanente nella hash chain)
- 07: nuova sezione "Limiti noti" — vincolo single-writer e i 7 gap
  storici benigni del log di produzione (01/05 e 29/05); audit verify
  full-chain fallisce alla riga 11 by design
- 06: nota in Flusso 5 — la tolleranza "3 fallimenti" vale solo per i
  probe health, _safe arma al primo errore su ogni altro tick

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-05 09:23:07 +00:00

336 lines
16 KiB
Markdown
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.
# 07 — Risk Controls
Controlli di sicurezza trasversali che non sono parte della strategia
ma proteggono il sistema da bug, dati corrotti, fallimenti
infrastrutturali o decisioni umane fuori posto.
## Filosofia
- **Default deny**: in caso di dubbio, il sistema non fa nulla.
- **Disarm manuale**: ogni kill switch viene disarmato esplicitamente
da Adriano via CLI, mai automaticamente.
- **Visibilità**: ogni evento di sicurezza viene loggato e notificato
immediatamente.
- **No silent close**: una posizione viene chiusa solo a seguito di
decisione esplicita dei trigger di strategia, mai per "evento
sospetto".
## Kill switch
### Stato
`system_state.kill_switch ∈ {0, 1}`. Quando `= 1`, l'engine:
- continua i flussi di **sola lettura** (health check, monitoring di
stato, log)
- **non** invia istruzioni di apertura
- **non** invia istruzioni di chiusura **automatiche** (il monitor
cycle salta quando il kill switch è armato)
- continua a notificare via Telegram gli alert con la severity
appropriata (vedi escalation tree)
### Trigger automatici
| Causa | Auto-arm | Implementato | Note |
|---|---|---|---|
| MCP `cerbero-deribit` non risponde per 3 health check consecutivi | Sì | `runtime/health_check.py` | Severity HIGH |
| MCP `cerbero-macro` / `cerbero-hyperliquid` / `cerbero-sentiment` non risponde per 3 health check consecutivi | Sì | `runtime/health_check.py` | Severity HIGH |
| `mcp-deribit.environment_info.environment``strategy.execution.environment` | Sì | `runtime/orchestrator.boot` + health check | Severity CRITICAL al boot, HIGH a runtime |
| Mismatch tra il tail del file `data/audit.log` e `system_state.last_audit_hash` (truncation o tampering) | Sì | `runtime/orchestrator._verify_audit_anchor` | Severity CRITICAL al boot. Dal fix `647e3e5` il **lag benigno** dell'anchor (anchor indietro rispetto al tail, ma antenato genuino — vedi `safety/audit_log.tail_continues_from`) è tollerato e ri-sincronizzato senza armare |
| Eccezione non gestita in **qualsiasi** tick schedulato (`entry`, `monitor`, `health`, snapshot, backup, …) | Sì | `orchestrator._safe``alert_manager.critical` | Severity CRITICAL. Attenzione: anche un errore **transitorio una tantum** arma in modo permanente. Incidente 31/05/2026: un singolo `McpTimeoutError` su `sentiment.get_cross_exchange_funding` nel tick entry → bot fermo 6 giorni nonostante il servizio si fosse ripreso in pochi secondi. Possibile hardening futuro: tolleranza N-consecutivi o auto-pause temporanea per errori MCP transitori |
| Stato SQLite incoerente con il broker (recovery non risolutivo) | Sì | `runtime/recovery.py` | Severity CRITICAL al boot |
| `place_combo_order` di chiusura respinto dal broker | Sì | `runtime/monitor_cycle.py` | Severity CRITICAL; la posizione torna in `open` per ritentare |
| `place_combo_order` di apertura respinto dal broker | Sì | `runtime/entry_cycle.py` | Severity HIGH; la posizione viene marcata `cancelled` |
| Hash chain audit non verifica (`audit verify` fallisce) | Manuale per ora; CLI `audit verify` segnala l'anomalia con exit 2 | `cli.py audit verify` + `safety/audit_log.verify_chain` | ⚠️ Il log di produzione ha 7 gap storici benigni (vedi «Limiti noti» sotto): `audit verify` full-chain fallisce alla riga 11 by design. Non integrare il full-chain verify nel boot senza prima ruotare il log con un nuovo genesis |
| Comando manuale via `cerbero-bite kill-switch arm` | Sì | `cli.py kill_switch_arm` | Severity HIGH (operator-initiated) |
### Disarm
Due percorsi, a seconda che l'engine sia in esecuzione o fermo.
**Engine in esecuzione → coda `manual_actions` (raccomandato).**
Si accoda una riga `kind="disarm_kill"` (dalla GUI, o a mano via
SQLite); il job `manual_actions` (cron `*/1`) la consuma e chiama
`KillSwitch.disarm` **in-process** entro un minuto:
```sql
INSERT INTO manual_actions (kind, payload_json, created_at)
VALUES ('disarm_kill', '{"reason": "<motivo>"}',
strftime('%Y-%m-%dT%H:%M:%fZ', 'now'));
```
**Engine fermo → CLI.**
```bash
cerbero-bite kill-switch disarm --reason "<motivo>" \
--db data/state.sqlite \
--audit data/audit.log
```
⚠️ **Non usare il CLI con l'engine in esecuzione**: il CLI appende
all'audit log da un processo separato e va in race con gli append
dell'engine — il risultato è un fork/gap permanente nella hash chain
(`prev_hash` che non aggancia la riga precedente). I 7 gap storici
del log (01/05 e 29/05/2026, vedi «Limiti noti» sotto) sono stati
causati esattamente da questo pattern. Il percorso a coda non ha il
problema perché l'unico writer resta l'engine.
L'operazione è transazionale: SQLite `system_state.kill_switch = 0` +
una linea `KILL_SWITCH_DISARMED` nella audit chain con il motivo. Il
disarm non riavvia automaticamente lo scheduler; è il prossimo tick
naturale (entry giornaliero o monitor 12h) a far ripartire la
decisione. Il disarm **persiste attraverso i restart** del container,
così come l'arm (verificato 29/05 e 05/06/2026).
## Cap di rischio (oltre alle regole di strategia)
Questi cap sono ridondanti rispetto a quelli del §5 della strategia,
ma sono applicati in modo difensivo come **ultima linea**:
| Misura | Limite | Comportamento se superato |
|---|---|---|
| Notional combo singolo | 200 EUR | Sizing engine reject (`undersize`) |
| Engagement totale aperto | 1.000 EUR | Sizing engine reject |
| Posizioni concorrenti CB | 1 (default per la strategia ETH) | Entry cycle reject (`has_open_position`) |
| Trade aperti per giorno | 6 (intera Cerbero suite) | Non implementato — richiede integrazione cross-suite, lasciato a Cerbero core |
| Distance short strike | < 15 % spot OTM | Combo builder reject (`no_strike`) |
| Credit / width | < 0.30 | Combo builder reject (`no_strike`) |
| Slippage ≥ 8 % credito | Hard | Liquidity gate reject (`illiquid`) |
| DVOL fuori `[35, 90]` | Hard | Entry validator reject (`dvol`) |
| Funding ETH-PERP `|·| > 80%` annualizzato | Hard | Entry validator reject (`funding`) |
| ETH holdings > 30 % portfolio | Hard | Entry validator reject (`holdings`) |
| Macro evento `high` entro DTE | Hard | Entry validator reject (`macro`) |
| Dealer net gamma < `dealer_gamma_min` | Soft (gate disabilitabile) | Entry validator reject (`dealer short-gamma regime`) — vedi `01-strategy-rules.md §2.8` |
| `liquidation_squeeze_risk == "high"` | Soft (gate disabilitabile) | Entry validator reject (`imminent liquidation squeeze risk`) |
I primi sei cap sono applicati direttamente dai moduli `core/`; gli
altri tre filtri quant-grade (DVOL, holdings, dealer-gamma,
liquidation) sono applicati da `entry_validator.validate_entry` con
soglie esplicite in `strategy.yaml`.
## Single-instance lock
Cerbero Bite acquisisce `data/.lockfile` con `fcntl.flock(LOCK_EX |
LOCK_NB)` all'avvio dell'engine (`runtime/lockfile.EngineLock`). Un
secondo container che provasse a partire sullo stesso file di stato
fallirebbe immediatamente con `LockError`, prima di toccare SQLite o i
client MCP. Il lock viene rilasciato in modo automatico dal kernel
quando il processo termina, anche su crash, quindi non rimane mai
"appeso".
Caveat: `flock` è efficace solo all'interno dello stesso host
(filesystem locale del container o bind mount). Per uno scenario
distribuito multi-host servirebbe un lock service esterno (es. Redis
SET NX); al momento Cerbero Bite gira in un singolo container e il
lock locale è sufficiente.
## Dead-man switch
Se l'engine non scrive un evento `HEALTH_OK` per **15 minuti**
consecutivi:
1. Il processo separato `scripts/dead_man.sh` (cron in user crontab,
indipendente dall'engine) rileva il silenzio cercando l'ultimo
`HEALTH_OK` nel JSONL del giorno.
2. Invia un alert al canale Telegram di backup (variabile
`DEAD_MAN_ALERT_CMD` o file `data/log/dead-man-alert.txt`).
3. Marca SQLite con `system_state.kill_switch=1` direttamente via
sqlite3 CLI.
4. Adriano interviene manualmente.
Lo script è scritto in shell minimale (no dipendenze Python) per
sopravvivere a corruzioni dell'env Python. La presenza del binario
`sqlite3` è opzionale: in sua assenza il dead-man genera comunque
l'alert ma salta lo step di arming SQLite.
## Audit log immutabile
Oltre al log JSONL standard, ogni decisione di trading produce una
linea append-only in `data/audit.log` con il **digest SHA-256** della
linea precedente (chain di hash, stile blockchain semplificato). Il
file viene `flush + os.fsync` a ogni append.
Esempio:
```
2026-04-27T14:00:01+00:00|ENTRY_PLACED|{"proposal_id":"...","spread_type":"bull_put"}|prev_hash=abc123...|hash=def456...
```
Verificabile retroattivamente con `cerbero-bite audit verify`. La
verifica controlla:
* parsing della struttura (`<ts>|<event>|<json>|prev_hash=...|hash=...`);
* consistenza del JSON payload (oggetto, non lista o scalare);
* `prev_hash` di ogni linea uguale all'`hash` della precedente;
* `hash` ricalcolato uguale a quello memorizzato.
Una discrepanza è trattata come tampering e produce `exit 2` dal
comando CLI; in regime servirà che lo stesso check, integrato nel
ciclo di health, armi il kill switch CRITICAL.
### Anti-truncation
La chain così com'è descritta resta valida anche se il file viene
**troncato** alla fine: i restanti record verificano l'uno con
l'altro. Per coprire questo caso Cerbero Bite mantiene un *anchor*:
ogni `AuditLog.append` invoca un callback registrato in
`runtime/dependencies.build_runtime` che persiste l'hash appena
scritto in `system_state.last_audit_hash`. Al boot
`Orchestrator._verify_audit_anchor` confronta il valore persistito con
il tail del file: in caso di mismatch (truncation, sostituzione, file
mancante) viene armato il kill switch CRITICAL prima che qualsiasi
ciclo trading parta.
Dal fix `647e3e5` (29/05/2026) il check distingue il **lag benigno**
dal tampering: se l'anchor persistito è indietro rispetto al tail ma è
un *antenato genuino* (la chain dall'anchor al tail verifica —
`safety/audit_log.tail_continues_from`), il boot ri-sincronizza
l'anchor e prosegue senza armare. L'anchor è infatti persistito
best-effort e può restare indietro sotto write contention SQLite. Un
anchor assente dal file o una chain post-anchor rotta restano
tampering e armano CRITICAL (verificato con test negativo il 29/05).
### Limiti noti: gap da scritture concorrenti
La chain assume un **single writer** (l'engine). Un processo separato
che appende mentre l'engine gira (CLI `kill-switch arm/disarm`,
script di resync) legge il tail, calcola `prev_hash` e scrive — ma se
l'engine appende nel frattempo, una riga viene persa o la chain si
biforca: la riga successiva ha un `prev_hash` che non aggancia nulla.
Il file `data/audit.log` di produzione contiene **7 gap storici di
questo tipo, tutti benigni e attribuiti** (verifica completa del
05/06/2026):
| Righe | Data | Causa |
|---|---|---|
| 11, 16 | 01/05/2026, primo boot | write contention durante il caos env mismatch testnet/mainnet |
| 8130, 8262, 8287 | 29/05/2026 | restart/resync manuali durante il debug anchor (8287 ha persino timestamp fuori ordine) |
| 8301, 8323 | 29/05/2026 | disarm via CLI con engine in esecuzione (race CLI ↔ engine) |
Conseguenza operativa: `cerbero-bite audit verify` (full-chain dal
genesis) fallisce **per sempre** alla riga 11 — è atteso, non è
tampering. Il controllo operativo in vigore è quello dell'anchor al
boot (tail-continuity), che resta pienamente efficace per truncation
e tampering del tail. Eventuali gap **nuovi** (oltre ai 7 elencati)
vanno invece investigati. Mitigazione: usare la coda `manual_actions`
per arm/disarm a engine acceso (mai il CLI), così l'unico writer
resta l'engine.
## Dry-run mode
Il comando `cerbero-bite dry-run --cycle entry|monitor|health` esegue
**un singolo ciclo** senza avviare lo scheduler. Il ciclo usa lo
stesso codice di produzione (snapshot reali, `place_combo_order` reale
sul testnet), quindi non è "lettura sola" — è un'esecuzione one-shot.
Per testare flussi senza toccare il broker si usa il
`Cerbero_mcp` con `DERIBIT_TESTNET=true` (default), così
`mcp-deribit.environment_info` riporta `testnet` e gli ordini vanno
sul paper book.
`enforce_hash` è disattivato in dry-run per agevolare il debug; il
comando `start` invece carica `strategy.yaml` con
`enforce_hash=True`, quindi mismatch dell'hash producono exit 1
prima che l'engine tocchi qualsiasi stato.
## Versionamento config
Ogni `strategy.yaml` ha:
```yaml
config_version: "1.0.0"
config_hash: "<sha256 del file con il valore di config_hash sostituito da vuoto>"
last_review: "2026-04-26"
last_reviewer: "Adriano"
```
All'avvio di `cerbero-bite start` l'engine verifica che `config_hash`
corrisponda al contenuto del file (il calcolo esclude il valore stesso
del campo `config_hash`, vedi `config/loader.compute_config_hash`).
Mismatch → exit 1 prima del boot. La verifica protegge da modifiche
silenziose alla config, accidentali o malevole.
Nuovi campi proposti dalla migration di Fase 4 hardening:
```yaml
execution:
environment: "testnet" # testnet|mainnet — kill switch su mismatch broker
eur_to_usd: "1.075" # FX di sizing, override-able via CLI flag
entry:
dealer_gamma_min: "0" # filtro §2.8
dealer_gamma_filter_enabled: true
liquidation_filter_enabled: true
```
Ogni cambio richiede una nuova versione di `config_version`,
ricalcolo dell'hash via `cerbero-bite config hash` e commit con
giustificazione testuale nel messaggio.
## Escalation tree
```
Evento anomalo
├── Severity LOW (es. 1 health check fallito)
│ └── Append in audit chain (event=ALERT severity=low),
│ continua
├── Severity MEDIUM (es. snapshot dato mancante non bloccante)
│ ├── Append in audit chain
│ └── Telegram notify (priority=high), continua
├── Severity HIGH (es. 3 health check consecutivi falliti,
│ entry rejected dal broker)
│ ├── Append in audit chain
│ ├── Telegram notify_alert (priority=high)
│ ├── Kill switch ARM (idempotente)
│ └── Adriano interviene per disarmare
└── Severity CRITICAL (es. mismatch environment al boot,
hash chain rotta, close fallito su monitor)
├── Append in audit chain
├── Telegram notify_system_error (priority=critical)
├── Kill switch ARM (idempotente)
└── Engine resta in idle finché Adriano non disarma
```
L'implementazione vive in `runtime/alert_manager.AlertManager`;
ciascun modulo runtime accede al manager tramite
`RuntimeContext.alert_manager` e chiama
`am.low(...)` / `am.medium(...)` / `am.high(...)` / `am.critical(...)`
con `source` (modulo emittente) e `message` (descrizione human-friendly).
## Test di resilienza obbligatori
Prima del go-live e ad ogni release minor:
1. **Chaos test MCP**: simula timeout/errori su ogni MCP, verifica
che il comportamento documentato in `04-mcp-integration.md` sia
rispettato (retry, fallback, kill switch).
2. **State corruption test**: corrompi una riga `positions` e
verifica che il `recover_state` lo rilevi.
3. **Hash chain test**: modifica una linea audit e verifica che
`audit verify` fallisca; tronca il file e verifica che il check
anchor al boot armi il kill switch.
4. **Replay test**: rigioca una giornata storica via
`cerbero-bite replay` (Fase 5/6), confronta le decisioni con un
set golden.
5. **Cap saturation test**: simula posizioni concorrenti, verifica
che il sizing engine rifiuti.
I risultati sono documentati in `tests/golden/results-YYYY-MM-DD.md`.
## Cosa NON è un risk control
Per chiarezza, queste cose **non** sono cap né kill switch — sono
parte della strategia, gestite altrove:
- Profit take 50 %: regola di strategia.
- Stop loss 1.5×: regola di strategia.
- Vol stop +10 DVOL: regola di strategia.
- Time stop 7 DTE: regola di strategia.
I risk control proteggono il **sistema**. La strategia protegge il
**capitale**. Sono livelli diversi.