# 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 + spot per asset ad ogni evaluation. Utile per il
calcolo di `return_4h` durante il monitor (vedi
`runtime/monitor_cycle.py _fetch_return_4h`, che filtra `asset='ETH'`)
e per analisi post-mortem comparate fra ETH e BTC. Lo schema è
multi-asset dal migration `0006_dvol_history_multi_asset.sql`; le
righe storiche pre-migration sono state backfillate con
`asset='ETH'`.

```sql
CREATE TABLE dvol_history (
    timestamp     TEXT    NOT NULL,
    asset         TEXT    NOT NULL,            -- "ETH", "BTC"
    dvol          NUMERIC NOT NULL,
    spot          NUMERIC NOT NULL,
    PRIMARY KEY (timestamp, asset)
);

CREATE INDEX idx_dvol_history_asset_ts
    ON dvol_history(asset, timestamp DESC);
```

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

```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 (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

```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:

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