Files
Cerbero-Bite/docs/05-data-model.md
T
root 6ff021fbf4 feat(strategy): abbandono gating settimanale — entry daily 24/7
Crypto opera 24/7: la cadenza settimanale lunedì-only era un retaggio
TradFi senza giustificazione. La nuova cadenza è giornaliera (cron
0 14 * * *), con i gate quantitativi a decidere se entrare o saltare.

Cambiamenti principali:

* runtime/orchestrator.py — _CRON_ENTRY 0 14 * * * (era MON)
* runtime/auto_pause.py — pause_until(days=) (era weeks=); minimo
  clamp 1 giorno (era 1 settimana)
* core/backtest.py — MondayPick→DailyPick, monday_picks→daily_picks
  (1 pick per calendar-day all'ora target); Sharpe annualization su
  ~120 trade/anno (era 52)
* config/schema.py — default cron daily; max_concurrent_positions 1→5;
  AutoPauseConfig.pause_weeks→pause_days, default 14
* runtime/option_chain_snapshot_cycle.py + orchestrator — cron */15
  per accumulo continuo dataset di backtest empirico

Strategy yamls (config_version 1.3.0 → 1.4.0, hash rigenerati):

* strategy.yaml — max_concurrent 1→5, cap_aggregate coerente
* strategy.aggressiva.yaml — max_concurrent 2→8, cap_aggregate
  3200→6400, max_contracts_per_trade invariato a 16
* strategy.conservativa.yaml — max_concurrent 1→3
* tutti — pause_weeks→pause_days: 14

GUI (pages/7_📚_Strategia.py):

* slider Trade/anno: range 20-200 (era 8-30), default 110, help
  riallineato sulla math 365 candidature × pass-rate 30-40%
* card profili: versione letta dinamicamente da config_version invece
  che hard-coded "v1.2.0"
* warning "entrambi perdono soldi" ora valuta i P/L effettivi
  (cons['annual_pl'], aggr['annual_pl']) invece del win_rate grezzo;
  aggiunto stato intermedio quando solo conservativo è in perdita

Tests (450/450 passati):

* test_auto_pause: pause_days, clamp ≥1 giorno
* test_backtest: rinomina + ridisegno daily picks (assert su
  calendar-day dedupe e hour filter)
* test_sizing_engine: other_open_positions=5 per cap default
* test_config_loader: version 1.4.0

Docs (README + 9 file in docs/) — tutti i riferimenti weekly/lunedì
allineati a daily/24-7, volume option_chain ricalcolato per cron
*/15 (~1.1 MB/giorno, ~400 MB/anno).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-03 16:21:16 +00:00

14 KiB
Raw Blame History

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

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 in continuo (cron */15 * * * *, allineato a market_snapshots). Crypto è 24/7: l'accumulo dataset deve essere continuo, non gateato sulla settimana. 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 (cron */15 * * * *, ~96 snapshot/giorno): ~50 strike × 3 scadenze × 96 snap/giorno × 17 colonne ≈ ~1.1 MB/giorno, ~400 MB/anno. Considerare politiche di retention (archive trimestrale in parquet) se il bot gira a lungo.

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:

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