# 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.
