6ff021fbf4
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>
366 lines
14 KiB
Markdown
366 lines
14 KiB
Markdown
# 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 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.
|