# 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-portfolio` / `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 | | 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` | Severity CRITICAL quando integrata nel boot | | Comando manuale via `cerbero-bite kill-switch arm` | Sì | `cli.py kill_switch_arm` | Severity HIGH (operator-initiated) | ### Disarm ```bash cerbero-bite kill-switch disarm --reason "" \ --db data/state.sqlite \ --audit data/audit.log ``` 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 settimanale o monitor 12h) a far ripartire la decisione. ## 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 (`|||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. ## 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: "" 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.