- 01-strategy-rules.md:
* §2.8 (filtri quant: dealer gamma + liquidation risk)
* §2.9 (IV richness gate, opt-in, default disabled)
* §3.2 — variante delta_by_dvol step-function
* §7-bis.1 (vol-collapse harvest D)
* §7-bis.2 (graduated profit-take C — scaffolding)
* §7-bis.3 (auto-pause su drawdown F)
- 05-data-model.md:
* `system_state.auto_pause_until / _reason` (migration 0004)
* Nuova tabella `option_chain_snapshots` (migration 0005)
* Tabella migrations completa (1→5)
- 13-strategia-spiegata.md:
* §4-quinquies — catena opzioni storica (Phase 5):
cosa raccoglie, cosa sblocca, CLI `option-chain
trigger|analyze`.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
14 KiB
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 insystem_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).
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.
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.
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.
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 * * * *).
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).
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.
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
{
"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:
<iso-ts>|<event>|<json-payload>|prev_hash=<hex>|hash=<hex>
con hash = SHA256("<iso-ts>|<event>|<json-payload>|<prev_hash>").
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:
- (Opzionale) Audit append per gli eventi tracciati nella chain.
with transaction(conn):→repository.update_position_status(...)orepository.create_*(...).- 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_<name>.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.