# 05 — Data Model Persistenza locale su SQLite (`data/state.sqlite`) + log append-only su file `.jsonl`. Niente database remoti, niente broker esterni. ## Filosofia - **Lo stato è la verità per ciò che è in volo.** Posizioni aperte, proposte in attesa di fill, snapshot DVOL. - **Il log è la verità per ciò che è successo.** Ogni evento (entry, exit, health, decisione di hold, alert) viene scritto in modo immutabile prima di toccare lo stato. - **L'audit chain è la verità per ciò che era stato registrato.** Hash chain SHA-256 su `data/audit.log`, ancorata in `system_state.last_audit_hash`: una manomissione del file viene rilevata al boot successivo dall'orchestrator. - Lo stato si può sempre **ricostruire dal broker** in caso di corruzione tramite `runtime/recovery.py`. ## Schema SQLite Migrazione gestita con file SQL semplici sotto `src/cerbero_bite/state/migrations/`, applicati in sequenza dal runner in `state/db.py` che incrementa `PRAGMA user_version`. Forward-only. Tutti i pragmi runtime (`foreign_keys = ON`, `journal_mode = WAL`, `synchronous = NORMAL`) vengono applicati a ogni connessione aperta da `state.db.connect`. ### `positions` Posizione Cerberus Bite (proposta, in attesa di fill, aperta, in chiusura, chiusa, cancellata). ```sql CREATE TABLE positions ( proposal_id TEXT PRIMARY KEY, -- UUID v4 spread_type TEXT NOT NULL, -- bull_put, bear_call, iron_condor asset TEXT NOT NULL DEFAULT 'ETH', expiry TEXT NOT NULL, -- ISO8601 UTC short_strike NUMERIC NOT NULL, long_strike NUMERIC NOT NULL, short_instrument TEXT NOT NULL, -- ETH-15MAY26-2475-P long_instrument TEXT NOT NULL, n_contracts INTEGER NOT NULL, spread_width_usd NUMERIC NOT NULL, spread_width_pct NUMERIC NOT NULL, credit_eth NUMERIC NOT NULL, -- ricevuto effettivo (post fill) credit_usd NUMERIC NOT NULL, max_loss_usd NUMERIC NOT NULL, spot_at_entry NUMERIC NOT NULL, dvol_at_entry NUMERIC NOT NULL, delta_at_entry NUMERIC NOT NULL, eth_price_at_entry NUMERIC NOT NULL, proposed_at TEXT NOT NULL, opened_at TEXT, -- NULL se non ancora confermato fill closed_at TEXT, -- NULL se aperta close_reason TEXT, -- ENUM exit action debit_paid_eth NUMERIC, -- pagato a chiusura pnl_eth NUMERIC, pnl_usd NUMERIC, status TEXT NOT NULL, -- proposed, awaiting_fill, open, closing, closed, cancelled created_at TEXT NOT NULL, updated_at TEXT NOT NULL ); CREATE INDEX idx_positions_status ON positions(status); CREATE INDEX idx_positions_closed_at ON positions(closed_at); ``` Stati validi: ``` proposed → riga creata, place_combo_order in volo awaiting_fill → ordine accettato da Deribit, in attesa di fill open → fill confermato, monitoring attivo closing → close combo inviata, in attesa di fill di chiusura closed → close fill confermato, P&L calcolato cancelled → ordine respinto dal broker o nessun fill nella finestra ``` I `Decimal` documentati come `NUMERIC` vengono persistiti come `TEXT` con `str(Decimal)`: la conversione avviene nel layer `Repository` per preservare la precisione e non scivolare mai su `float`. ### `instructions` Tracciamento di ogni ordine inviato a Deribit tramite `mcp-deribit.place_combo_order` o `cancel_order`. La tabella esiste per audit forense: a fronte di una posizione chiusa serve poter ricostruire la sequenza di ordini broker che hanno portato al fill. ```sql CREATE TABLE instructions ( instruction_id TEXT PRIMARY KEY, -- UUID Bite-side proposal_id TEXT NOT NULL REFERENCES positions(proposal_id), kind TEXT NOT NULL, -- open_combo, close_combo payload_json TEXT NOT NULL, -- JSON della response Deribit -- (combo_instrument, order_id, state, ...) sent_at TEXT NOT NULL, acknowledged_at TEXT, -- riservato a un futuro hook ack filled_at TEXT, -- compilato dal recovery o da un -- hook che monitora i fill cancelled_at TEXT, actual_fill_eth NUMERIC, -- prezzo medio fill effettivo actual_fees_eth NUMERIC ); CREATE INDEX idx_instructions_proposal ON instructions(proposal_id); ``` Cerbero Bite invia direttamente l'ordine al broker: il payload qui salvato è la response di `mcp-deribit.place_combo_order` (che a sua volta wrappa `private/create_combo` + `private/buy|sell`). Niente relazioni con un eventuale `cerbero-memory`: quel servizio non è parte del decision loop. ### `decisions` Storia delle valutazioni del decision loop (anche quando l'esito è HOLD o no_entry). Serve per audit e analisi delle scelte. ```sql CREATE TABLE decisions ( id INTEGER PRIMARY KEY AUTOINCREMENT, decision_type TEXT NOT NULL, -- entry_check, exit_check, kelly_recalib proposal_id TEXT, -- NULL per entry_check senza proposta timestamp TEXT NOT NULL, inputs_json TEXT NOT NULL, -- snapshot input al modulo core outputs_json TEXT NOT NULL, -- output del modulo core action_taken TEXT, -- HOLD, no_entry, propose_open, broker_error, ... notes TEXT ); CREATE INDEX idx_decisions_timestamp ON decisions(timestamp); CREATE INDEX idx_decisions_proposal ON decisions(proposal_id); ``` ### `dvol_history` Snapshot DVOL + ETH spot ad ogni evaluation. Utile per il calcolo di `return_4h` durante il monitor (vedi `runtime/monitor_cycle.py _fetch_return_4h`) e per analisi post-mortem. ```sql CREATE TABLE dvol_history ( timestamp TEXT PRIMARY KEY, dvol NUMERIC NOT NULL, eth_spot NUMERIC NOT NULL ); ``` ### `manual_actions` Coda di azioni manuali generate dalla GUI Streamlit (vedi `11-gui-streamlit.md`). La tabella è popolata dal layer `gui/data_layer.py` (`enqueue_arm_kill`, `enqueue_disarm_kill`) ed è drenata dal job APScheduler `manual_actions` (`runtime/manual_actions_consumer.consume_manual_actions`, cron `*/1 * * * *`). ```sql CREATE TABLE manual_actions ( id INTEGER PRIMARY KEY AUTOINCREMENT, kind TEXT NOT NULL, -- arm_kill, disarm_kill, -- force_close, approve_proposal, reject_proposal proposal_id TEXT, -- NULL se l'azione non è legata a una proposta payload_json TEXT, -- JSON con reason, conferma typed, ecc. created_at TEXT NOT NULL, consumed_at TEXT, -- NULL = ancora da processare consumed_by TEXT, -- "engine" quando applicata dal consumer result TEXT -- "ok" / "not_supported" / "error: ..." ); CREATE INDEX idx_manual_actions_unconsumed ON manual_actions(consumed_at); ``` Stato implementativo per `kind`: | `kind` | Implementato | Effetto | |---|---|---| | `arm_kill` | ✅ | `KillSwitch.arm(reason, source="manual_gui")` | | `disarm_kill` | ✅ | `KillSwitch.disarm(reason, source="manual_gui")` | | `force_close` | ⏳ | Marcato `result="not_supported"` finché l'orchestrator non espone `handle_force_close` | | `approve_proposal` / `reject_proposal` | ⏳ | Idem | Le `manual_actions` **non** bypassano i risk control: ogni azione di kill switch passa dalla classe `KillSwitch`, che valida lo stato e appende l'evento corrispondente alla audit chain. La typed confirmation lato GUI è gating prima dell'enqueue. ### `system_state` Singleton di stato globale (kill switch, last health check, anchor audit chain). ```sql CREATE TABLE system_state ( id INTEGER PRIMARY KEY CHECK (id = 1), kill_switch INTEGER NOT NULL DEFAULT 0, kill_reason TEXT, kill_at TEXT, last_health_check TEXT NOT NULL, last_kelly_calib TEXT, config_version TEXT NOT NULL, started_at TEXT NOT NULL, last_audit_hash TEXT, -- aggiunto dalla migration 0002 auto_pause_until TEXT, -- aggiunto dalla migration 0004 (§7-bis.3) auto_pause_reason TEXT -- aggiunto dalla migration 0004 ); ``` Il campo `last_audit_hash` viene aggiornato dal callback `AuditLog.on_append` registrato in `runtime/dependencies.build_runtime`. Al boot l'orchestrator confronta questo valore con il tail del file `audit.log`: discrepanza → kill switch CRITICAL, vedi `07-risk-controls.md`. I campi `auto_pause_until` / `auto_pause_reason` implementano il circuit breaker §7-bis.3 (pausa automatica su drawdown rolling). NULL = engine attivo. ### `option_chain_snapshots` Snapshot della catena opzioni Deribit prelevata settimanalmente (cron `55 13 * * MON`, 5 minuti prima del trigger entry). Ogni tick contiene un quote per strumento entro la finestra `[dte_min, dte_max]` di config; tutti i quote prelevati nello stesso tick condividono ``timestamp``. Migration `0005`. ```sql CREATE TABLE option_chain_snapshots ( timestamp TEXT NOT NULL, asset TEXT NOT NULL, instrument_name TEXT NOT NULL, strike TEXT NOT NULL, expiry TEXT NOT NULL, option_type TEXT NOT NULL CHECK (option_type IN ('C','P')), bid TEXT, ask TEXT, mid TEXT, iv TEXT, delta TEXT, gamma TEXT, theta TEXT, vega TEXT, open_interest INTEGER, volume_24h INTEGER, book_depth_top3 INTEGER, PRIMARY KEY (timestamp, instrument_name) ) WITHOUT ROWID; ``` Indici: `(asset, timestamp DESC)` per listing recenti, `(asset, expiry)` per query per scadenza specifica. ``book_depth_top3`` è NULL by design — il collector non chiama l'order book per ogni strike per non saturare l'API; lo legge il liquidity gate live solo sugli strike candidati al picker. **Sblocca**: il backtest non-stilizzato (modulo `core/backtest.py` con prezzi reali invece di Black-Scholes), la calibrazione empirica dello skew premium, la validazione ex-post dello strike picker. Volume atteso: ~50 strike × 3 scadenze × 1 snapshot/settimana × 17 colonne ≈ 12 KB/settimana, ~600 KB/anno. ## Log file Sotto `data/log/` un file per giorno: `cerbero-bite-YYYY-MM-DD.jsonl`. Ogni linea è un evento JSON arricchito automaticamente da `structlog.contextvars` con `cycle` e `cycle_id` quando il record è emesso dentro un ciclo (entry/monitor/health), in modo da poter filtrare tutti i log relativi a una specifica esecuzione. ### Schema evento ```json { "ts": "2026-04-27T14:00:01.234Z", "event": "entry placed", "level": "info", "logger": "cerbero_bite.runtime.entry", "cycle": "entry", "cycle_id": "9d2a8a6a-7f58-4f10-8b33-...", "proposal_id": "uuid-...", "data": { ... } } ``` ### Audit log Il file `data/audit.log` è separato dai log JSONL ed è alimentato esclusivamente da `safety.audit_log.AuditLog`. Ogni riga ha la forma: ``` |||prev_hash=|hash= ``` con `hash = SHA256("|||")`. La chain è ancorata in `system_state.last_audit_hash`. Il file è fsync'd ad ogni append. ### Eventi previsti | Event | Quando | |---|---| | `ENGINE_START` | Avvio engine completato (boot OK) | | `RECOVERY_DONE` | Fine del recovery loop | | `HEALTH_OK` / `HEALTH_DEGRADED` | Health check periodico | | `ENTRY_PLACED` | Combo aperto e fill (o awaiting_fill) confermato | | `HOLD` | Monitor ha valutato una posizione e nessun trigger ha fired | | `EXIT_FILLED` | Close confermato dal broker | | `KILL_SWITCH_ARMED` / `KILL_SWITCH_DISARMED` | Transizioni manuali o automatiche | | `ALERT` | Routing severity-based dell'alert manager | | `KELLY_RECALIBRATED` | Output del job mensile (Fase 5) | ### Rotation e ritenzione - File giornaliero, gzip dopo 30 giorni. - Ritenzione 365 giorni; oltre, archiviazione manuale. - Mai cancellati i log relativi a posizioni con `pnl != 0`: archivio permanente per fini fiscali. ## Stato in memoria L'engine non mantiene una cache delle posizioni aperte: ogni ciclo legge fresco da SQLite. Lo scheduler APScheduler è in-process e i job ricevono il `RuntimeContext` (con `Repository`, `AuditLog`, `KillSwitch`, alert manager, client MCP) dalla closure di `Orchestrator.install_scheduler`. Ogni mutazione dei `positions` segue il pattern: 1. (Opzionale) Audit append per gli eventi tracciati nella chain. 2. `with transaction(conn):` → `repository.update_position_status(...)` o `repository.create_*(...)`. 3. Notifica Telegram post-fact, dove pertinente. Il connessione SQLite è aperta e chiusa dentro la singola operazione, mai mantenuta long-lived: WAL e auto-commit garantiscono che letture da altri processi (es. CLI `state inspect`) non vedano stati parziali. ## Migrations Lo schema viene tracciato con il counter `PRAGMA user_version`. `state.db.run_migrations` applica in ordine ogni file `NNNN_.sql` con versione superiore a quella corrente, idempotente, forward-only: | Versione | File | Cosa aggiunge | |---|---|---| | 1 | `0001_init.sql` | tabelle base (positions, decisions, ...) | | 2 | `0002_audit_anchor.sql` | `system_state.last_audit_hash` | | 3 | `0003_market_snapshots.sql` | tabella `market_snapshots` | | 4 | `0004_auto_pause.sql` | `system_state.auto_pause_until / _reason` | | 5 | `0005_option_chain_snapshots.sql` | tabella `option_chain_snapshots` | ## Backup Backup orari di `state.sqlite` in `data/backups/state-YYYYMMDD-HH.sqlite`, ritenuti per 30 giorni. Operazione idempotente con SQLite `VACUUM INTO`. Lo job è registrato nello scheduler dell'orchestrator (`backup_cron = "0 * * * *"`); è disponibile anche come CLI `python scripts/backup.py` per backup ad-hoc.