8 Commits

Author SHA1 Message Date
Adriano Dal Pastro a80c17f2bc config: abilita research_collector (v1.7.1)
research_collector.enabled false -> true: il collettore full-chain
gira ora orario (cron 0 * * * *), catturando tutte le scadenze <=95g
ed entrambe le ali con book_depth_top3 popolato. config_version
1.7.0 -> 1.7.1, config_hash rigenerato.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-09 08:40:12 +00:00
Adriano Dal Pastro 097e764f6a feat(research): comando CLI research-trigger + flag force
- option_chain_research_cycle: parametro force=True bypassa il gate
  enabled, per testare la raccolta prima di accendere il cron.
- CLI `option-chain research-trigger`: esegue UNA volta il collector
  full-chain (source='research', book_depth popolato), come il gemello
  `trigger` per il collettore live.

Verificato: 324 quote ETH su 7 scadenze (DTE 1..80), book_depth su tutte.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-09 08:40:11 +00:00
Adriano Dal Pastro 8a1fb823dc docs: profilo B, collettore research, colonna source, backup giornaliero
Allinea la documentazione alle modifiche di v1.7.0.

- 01-strategy-rules: §3.2 delta short 0.18 (banda 0.12-0.22, OTM>=10%)
  con la motivazione dell'analisi (credit/width ~6% irraggiungibile a
  delta basso); delta_by_dvol aggressiva ritarata; §3.3 width fino a 6%
  (griglia strike 100pt); §3.4 credit/width min 30% -> 8%.
- 05-data-model: option_chain_snapshots alimentata da due collettori
  (live/research) via colonna source; CREATE TABLE + indice aggiornati;
  book_depth_top3 popolato sulle righe research; migrazioni 6 e 7 in
  tabella; backup orario -> giornaliero con nota storica.
- 10-config-spec: valori structure profilo B + nuovo blocco
  research_collector.
- 06-operational-flow: cron summary con i due collettori opzioni.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-09 08:31:59 +00:00
Adriano Dal Pastro 4d7b02b6cb chore(backup): cron giornaliero invece di orario
Il backup faceva uno snapshot full del DB ogni ora: ~16GB accumulati per
~98MB di dato unico (218k righe option_chain ricopiate ~337 volte), con
retention 30g. Porta il cron a giornaliero (0 0 * * *): -24x lo spazio a
parita' di retention. Rilevante prima di accendere il collettore research
(che 2-4x la crescita del DB e quindi dei backup).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-09 08:00:39 +00:00
Adriano Dal Pastro 1a46abd5cf feat(config): profilo B credit-spread + blocco research_collector (v1.7.0)
Due modifiche di config, raggruppate perche' condividono i file YAML e il
config_hash (rigenerato sullo stato finale di ciascun profilo).

1) Profilo B credit-spread. L'analisi su 3.689 snapshot (1mag-8giu) mostra che
   con delta short 0.10-0.15 il miglior credit/width fisicamente ottenibile e'
   ~6%, quindi credit_to_width_ratio_min=0.30 era irraggiungibile: 0 spread
   fattibili. La sola leva efficace e' vendere piu' vicino.
   - short_strike: delta 0.12-0.22 (target 0.18), distance_otm_pct_min 0.10
   - spread_width.max_pct_of_spot 0.05 -> 0.06 (la griglia strike ETH a 100pt
     sotto i $1500 e' ~6% a spot <$2k: con cap 5% nessuna gamba long in banda)
   - credit_to_width_ratio_min 0.30 -> 0.08 (allineato al realizzabile)
   - aggressiva: ritarata anche la delta_by_dvol step-function
   Atteso: ~1,5 trade/mese (tetto dato da posizione-singola + tenuta 18g).
   Nota rischio: delta ~0.18-0.22 alza la prob. di assegnazione da ~12% a ~18-22%.

2) Blocco research_collector in entrambi i profili (enabled=false, opt-in):
   wiring del collettore full-chain introdotto nel commit precedente.

config_version 1.6.0 -> 1.7.0 (conservativo), 1.4.0 -> 1.5.0-aggressiva;
config_hash rigenerato e verificato stabile su entrambi.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-09 07:45:05 +00:00
Adriano Dal Pastro 1eeb53650e feat(research): collettore full-chain opzioni con book_depth e colonna source
Aggiunge un secondo collettore opzioni indipendente dal live, pensato per
trasformare il dataset da "skew/premi medi" a backtest opzioni vero
(per-trade e standing put).

- option_chain_research_cycle.py: cattura tutte le scadenze <= expiry_max_days
  (1g..3mesi) ed entrambe le ali (OI>=100, filtro moneyness opzionale),
  popolando book_depth_top3 via orderbook_depth_top3 (concorrenza limitata)
  cosi' lo slippage reale e' modellabile. Best-effort come il collettore live.
- migrazione 0007: colonna `source` su option_chain_snapshots ('live' default
  per le righe storiche, 'research' per il nuovo collettore) + indice
  (asset, source, timestamp). user_version 6 -> 7.
- ResearchCollectorConfig (schema): blocco `research_collector`, enabled=false
  di default (costo API non trascurabile); cron orario, expiry_max_days=95.
- orchestrator: job `option_chain_research` schedulato solo se data-analysis
  attiva E research_collector.enabled.
- repository: insert+mapper estesi con `source`; list_option_chain_snapshots
  accetta un filtro `source` per le query di backtest.

Verificato in isolamento (immagine + interprete reali): import, migrazione,
round-trip repo. Suite: 527 passed, zero regressioni vs baseline pristina.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-09 07:44:47 +00:00
Adriano Dal Pastro 589d003e5c chore(compose): disabilita watchtower auto-update
Niente aggiornamenti automatici dell'immagine per un bot di trading:
i deploy devono essere deliberati (build + up manuali), non innescati
da un push del registry.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-05 09:23:18 +00:00
Adriano Dal Pastro ce475da173 docs(safety): disarm via coda manual_actions, gap storici audit, trigger _safe
Aggiornamenti post-incidente 31/05 (kill switch armato 6 giorni da un
singolo McpTimeoutError transitorio) e post-ripristino 05/06:

- 07: tabella trigger — documentato orchestrator._safe (qualsiasi
  eccezione di tick → arm CRITICAL al primo errore) e la tolleranza
  al lag benigno dell'anchor (647e3e5)
- 07: sezione Disarm — percorso raccomandato via coda manual_actions
  a engine acceso; CLI solo a engine fermo (race CLI<->engine = gap
  permanente nella hash chain)
- 07: nuova sezione "Limiti noti" — vincolo single-writer e i 7 gap
  storici benigni del log di produzione (01/05 e 29/05); audit verify
  full-chain fallisce alla riga 11 by design
- 06: nota in Flusso 5 — la tolleranza "3 fallimenti" vale solo per i
  probe health, _safe arma al primo errore su ogni altro tick

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-05 09:23:07 +00:00
15 changed files with 583 additions and 58 deletions
+1 -1
View File
@@ -132,4 +132,4 @@ services:
- traefik.http.routers.cerbero-bite.entrypoints=websecure
- traefik.http.routers.cerbero-bite.tls.certresolver=mytlschallenge
- traefik.http.services.cerbero-bite.loadbalancer.server.port=8765
- com.centurylinklabs.watchtower.enable=true
- com.centurylinklabs.watchtower.enable=false
+26 -12
View File
@@ -100,13 +100,20 @@ Risultato:
Si seleziona lo strike short più vicino a delta target, in valore
assoluto.
| Parametro | Valore |
| Parametro | Valore (profilo B, v1.7.0) |
|---|---|
| Delta short target | 0.12 |
| Tolleranza delta | [0.10, 0.15] |
| Distanza minima OTM | 15% (anche se delta è dentro tolleranza) |
| Delta short target | 0.18 |
| Tolleranza delta | [0.12, 0.22] |
| Distanza minima OTM | 10% (anche se delta è dentro tolleranza) |
| Distanza massima OTM | 25% |
> **Perché delta 0.18 e non 0.12.** A delta 0.10-0.15 il miglior
> `credit/width` fisicamente ottenibile su ETH (analisi 3.689 snapshot,
> 1mag-8giu '26) è ~6%, sotto il minimo richiesto: 0 spread fattibili.
> Per uno spread verticale `credit/width ≲ delta_short`, quindi l'unica
> leva che produce credito vendibile è vendere più vicino. Trade-off:
> la prob. di assegnazione sale da ~12% a ~18-22%.
**Variante dinamica per regime DVOL (Phase 5, opt-in).** Il campo
`short_strike.delta_by_dvol` (lista step-function) sostituisce il
delta target singolo con bande ordinate per `dvol_under`: a DVOL
@@ -117,9 +124,9 @@ classico col delta target sopra. Esempio bande nel profilo
| dvol_under | delta_target | delta_min | delta_max |
|---|---|---|---|
| 50 | 0.15 | 0.13 | 0.17 |
| 70 | 0.12 | 0.10 | 0.15 |
| 90 | 0.10 | 0.08 | 0.12 |
| 50 | 0.22 | 0.18 | 0.25 |
| 70 | 0.18 | 0.12 | 0.22 |
| 90 | 0.15 | 0.10 | 0.18 |
Se nessuno strike disponibile rientra in entrambe le tolleranze → no entry.
@@ -131,16 +138,23 @@ Si seleziona lo strike a distanza fissa dallo short:
|---|---|
| Larghezza spread (in % di spot) | 4% |
| Larghezza minima accettabile | 3% |
| Larghezza massima accettabile | 5% |
| Larghezza massima accettabile | 6% |
Se non esiste lo strike esatto a 4%, si prende il più vicino entro
[3%, 5%]; altrimenti no entry.
[3%, 6%]; altrimenti no entry. La banda arriva a 6% perché la griglia
strike ETH sotto i $1500 è a 100pt (~6% a spot <$2k): con cap 5%
nessuna gamba long rientrava in banda.
### 3.4 Rapporto credito/larghezza
Il credito netto incassato (mid-price del combo) deve essere ≥ **30%**
della larghezza dello spread. Sotto questa soglia il rapporto
rischio/rendimento è inferiore al minimo accettabile. → no entry.
Il credito netto incassato (mid-price del combo) deve essere ≥ **8%**
della larghezza dello spread (profilo B). → altrimenti no entry.
> Era **30%**, ma è fisicamente irraggiungibile con delta short ≤0.22
> in questo mercato: il miglior `credit/width` misurato su ETH è ~6%
> (anche vendendo a delta 0.30 non si superava il 20%). La soglia 8% è
> allineata al realizzabile a delta ~0.18 e mantiene un floor sul
> rapporto rischio/rendimento.
## 4. Liquidità (filtro hard pre-entry)
+35 -12
View File
@@ -229,12 +229,23 @@ 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`.
Snapshot della catena opzioni Deribit. La tabella è alimentata da
**due collettori distinti**, separati dalla colonna `source`:
* `source='live'` — collettore operativo (cron `*/15 * * * *`,
allineato a `market_snapshots`). Ogni tick contiene un quote per
strumento entro la finestra `[dte_min, dte_max]` di config (1
scadenza, ~13-15 strike); `book_depth_top3` NULL.
* `source='research'` — collettore full-chain (§13-bis, cron orario,
opt-in via `research_collector.enabled`). Cattura tutte le scadenze
`expiry_max_days` ed entrambe le ali, con `book_depth_top3`
popolato. Trasforma il dataset da "skew/premi medi" a backtest
opzioni vero (per-trade e standing put).
Crypto è 24/7: l'accumulo dataset deve essere continuo, non gateato
sulla settimana. Tutti i quote prelevati nello stesso tick
condividono ``timestamp``. Migration `0005` (tabella), `0007`
(colonna `source`).
```sql
CREATE TABLE option_chain_snapshots (
@@ -255,15 +266,19 @@ CREATE TABLE option_chain_snapshots (
open_interest INTEGER,
volume_24h INTEGER,
book_depth_top3 INTEGER,
source TEXT NOT NULL DEFAULT 'live',
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.
expiry)` per query per scadenza specifica, `(asset, source,
timestamp DESC)` per separare live/research nelle query di backtest.
``book_depth_top3`` è NULL sulle righe `live` (il collector operativo
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)
ed è **popolato sulle righe `research`** (1 call orderbook/strumento,
concorrenza limitata) per modellare lo slippage reale.
**Sblocca**: il backtest non-stilizzato (modulo `core/backtest.py`
con prezzi reali invece di Black-Scholes), la calibrazione empirica
@@ -363,12 +378,20 @@ idempotente, forward-only:
| 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` |
| 6 | `0006_dvol_history_multi_asset.sql` | `dvol_history` multi-asset |
| 7 | `0007_option_chain_source.sql` | colonna `source` (live/research) + indice |
## Backup
Backup orari di `state.sqlite` in
Backup **giornalieri** 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 * * * *"`); è
nello scheduler dell'orchestrator (`backup_cron = "0 0 * * *"`); è
disponibile anche come CLI `python scripts/backup.py` per backup
ad-hoc.
> Nota storica: il cron era orario (`0 * * * *`) e accumulava ~16 GB
> di snapshot full per ~98 MB di dato unico (la stessa catena opzioni
> ricopiata ~24×/giorno). Portato a giornaliero per ridurre il
> footprint di ~24× a parità di retention — rilevante prima di
> accendere il collettore research, che 2-4× la crescita del DB.
+10
View File
@@ -156,6 +156,14 @@ Trigger: ogni 5 minuti.
Il dead-man (`scripts/dead_man.sh`) sorveglia che `HEALTH_OK` venga
scritto: silenzio > 15 min → kill switch via SQLite e alert.
> **Nota — la tolleranza "3 fallimenti" vale solo per i probe del
> health check.** Tutti i tick schedulati (entry, monitor, snapshot,
> …) girano dentro `orchestrator._safe`, che escala **qualsiasi**
> eccezione non gestita a `alert_manager.critical` → kill switch
> armato al **primo** errore, anche transitorio. È il percorso che ha
> fermato il bot il 31/05/2026 (singolo `McpTimeoutError` nel tick
> entry). Vedi la tabella trigger in `07-risk-controls.md`.
## Flusso 5b — Manual actions consumer
Trigger: cron `*/1 * * * *` (job APScheduler `manual_actions`).
@@ -226,6 +234,8 @@ proposed
| `0 12 1 * *` | Kelly recalibration | Mensile |
| `*/5 * * * *` | Health check | 5 min |
| `*/15 * * * *` | Market snapshot (calibrazione soglie) | 15 min |
| `*/15 * * * *` | Option chain snapshot (live, finestra DTE strategia) | 15 min |
| `0 * * * *` | Option chain research (full-chain + book_depth, opt-in) | Orario |
| `0 0 * * *` | Backup SQLite + rotation log | Giornaliero |
| `0 8 * * *` | Daily digest Telegram | Giornaliero |
+64 -3
View File
@@ -36,26 +36,51 @@ infrastrutturali o decisioni umane fuori posto.
| MCP `cerbero-deribit` non risponde per 3 health check consecutivi | Sì | `runtime/health_check.py` | Severity HIGH |
| MCP `cerbero-macro` / `cerbero-hyperliquid` / `cerbero-sentiment` non risponde per 3 health check consecutivi | Sì | `runtime/health_check.py` | Severity HIGH |
| `mcp-deribit.environment_info.environment``strategy.execution.environment` | Sì | `runtime/orchestrator.boot` + health check | Severity CRITICAL al boot, HIGH a runtime |
| Mismatch tra il tail del file `data/audit.log` e `system_state.last_audit_hash` (truncation o tampering) | Sì | `runtime/orchestrator._verify_audit_anchor` | Severity CRITICAL al boot |
| Mismatch tra il tail del file `data/audit.log` e `system_state.last_audit_hash` (truncation o tampering) | Sì | `runtime/orchestrator._verify_audit_anchor` | Severity CRITICAL al boot. Dal fix `647e3e5` il **lag benigno** dell'anchor (anchor indietro rispetto al tail, ma antenato genuino — vedi `safety/audit_log.tail_continues_from`) è tollerato e ri-sincronizzato senza armare |
| Eccezione non gestita in **qualsiasi** tick schedulato (`entry`, `monitor`, `health`, snapshot, backup, …) | Sì | `orchestrator._safe``alert_manager.critical` | Severity CRITICAL. Attenzione: anche un errore **transitorio una tantum** arma in modo permanente. Incidente 31/05/2026: un singolo `McpTimeoutError` su `sentiment.get_cross_exchange_funding` nel tick entry → bot fermo 6 giorni nonostante il servizio si fosse ripreso in pochi secondi. Possibile hardening futuro: tolleranza N-consecutivi o auto-pause temporanea per errori MCP transitori |
| Stato SQLite incoerente con il broker (recovery non risolutivo) | Sì | `runtime/recovery.py` | Severity CRITICAL al boot |
| `place_combo_order` di chiusura respinto dal broker | Sì | `runtime/monitor_cycle.py` | Severity CRITICAL; la posizione torna in `open` per ritentare |
| `place_combo_order` di apertura respinto dal broker | Sì | `runtime/entry_cycle.py` | Severity HIGH; la posizione viene marcata `cancelled` |
| Hash chain audit non verifica (`audit verify` fallisce) | Manuale per ora; CLI `audit verify` segnala l'anomalia con exit 2 | `cli.py audit verify` + `safety/audit_log.verify_chain` | Severity CRITICAL quando integrata nel boot |
| Hash chain audit non verifica (`audit verify` fallisce) | Manuale per ora; CLI `audit verify` segnala l'anomalia con exit 2 | `cli.py audit verify` + `safety/audit_log.verify_chain` | ⚠️ Il log di produzione ha 7 gap storici benigni (vedi «Limiti noti» sotto): `audit verify` full-chain fallisce alla riga 11 by design. Non integrare il full-chain verify nel boot senza prima ruotare il log con un nuovo genesis |
| Comando manuale via `cerbero-bite kill-switch arm` | Sì | `cli.py kill_switch_arm` | Severity HIGH (operator-initiated) |
### Disarm
Due percorsi, a seconda che l'engine sia in esecuzione o fermo.
**Engine in esecuzione → coda `manual_actions` (raccomandato).**
Si accoda una riga `kind="disarm_kill"` (dalla GUI, o a mano via
SQLite); il job `manual_actions` (cron `*/1`) la consuma e chiama
`KillSwitch.disarm` **in-process** entro un minuto:
```sql
INSERT INTO manual_actions (kind, payload_json, created_at)
VALUES ('disarm_kill', '{"reason": "<motivo>"}',
strftime('%Y-%m-%dT%H:%M:%fZ', 'now'));
```
**Engine fermo → CLI.**
```bash
cerbero-bite kill-switch disarm --reason "<motivo>" \
--db data/state.sqlite \
--audit data/audit.log
```
⚠️ **Non usare il CLI con l'engine in esecuzione**: il CLI appende
all'audit log da un processo separato e va in race con gli append
dell'engine — il risultato è un fork/gap permanente nella hash chain
(`prev_hash` che non aggancia la riga precedente). I 7 gap storici
del log (01/05 e 29/05/2026, vedi «Limiti noti» sotto) sono stati
causati esattamente da questo pattern. Il percorso a coda non ha il
problema perché l'unico writer resta l'engine.
L'operazione è transazionale: SQLite `system_state.kill_switch = 0` +
una linea `KILL_SWITCH_DISARMED` nella audit chain con il motivo. Il
disarm non riavvia automaticamente lo scheduler; è il prossimo tick
naturale (entry giornaliero o monitor 12h) a far ripartire la
decisione.
decisione. Il disarm **persiste attraverso i restart** del container,
così come l'arm (verificato 29/05 e 05/06/2026).
## Cap di rischio (oltre alle regole di strategia)
@@ -156,6 +181,42 @@ il tail del file: in caso di mismatch (truncation, sostituzione, file
mancante) viene armato il kill switch CRITICAL prima che qualsiasi
ciclo trading parta.
Dal fix `647e3e5` (29/05/2026) il check distingue il **lag benigno**
dal tampering: se l'anchor persistito è indietro rispetto al tail ma è
un *antenato genuino* (la chain dall'anchor al tail verifica —
`safety/audit_log.tail_continues_from`), il boot ri-sincronizza
l'anchor e prosegue senza armare. L'anchor è infatti persistito
best-effort e può restare indietro sotto write contention SQLite. Un
anchor assente dal file o una chain post-anchor rotta restano
tampering e armano CRITICAL (verificato con test negativo il 29/05).
### Limiti noti: gap da scritture concorrenti
La chain assume un **single writer** (l'engine). Un processo separato
che appende mentre l'engine gira (CLI `kill-switch arm/disarm`,
script di resync) legge il tail, calcola `prev_hash` e scrive — ma se
l'engine appende nel frattempo, una riga viene persa o la chain si
biforca: la riga successiva ha un `prev_hash` che non aggancia nulla.
Il file `data/audit.log` di produzione contiene **7 gap storici di
questo tipo, tutti benigni e attribuiti** (verifica completa del
05/06/2026):
| Righe | Data | Causa |
|---|---|---|
| 11, 16 | 01/05/2026, primo boot | write contention durante il caos env mismatch testnet/mainnet |
| 8130, 8262, 8287 | 29/05/2026 | restart/resync manuali durante il debug anchor (8287 ha persino timestamp fuori ordine) |
| 8301, 8323 | 29/05/2026 | disarm via CLI con engine in esecuzione (race CLI ↔ engine) |
Conseguenza operativa: `cerbero-bite audit verify` (full-chain dal
genesis) fallisce **per sempre** alla riga 11 — è atteso, non è
tampering. Il controllo operativo in vigore è quello dell'anchor al
boot (tail-continuity), che resta pienamente efficace per truncation
e tampering del tail. Eventuali gap **nuovi** (oltre ai 7 elencati)
vanno invece investigati. Mitigazione: usare la coda `manual_actions`
per arm/disarm a engine acceso (mai il CLI), così l'unico writer
resta l'engine.
## Dry-run mode
Il comando `cerbero-bite dry-run --cycle entry|monitor|health` esegue
+30 -6
View File
@@ -55,19 +55,43 @@ structure:
dte_min: 14
dte_max: 21
# Profilo B (v1.7.0). L'analisi su 3.689 snapshot (1mag-8giu '26) ha
# mostrato che a delta 0.10-0.15 il miglior credit/width fisicamente
# ottenibile è ~6%, quindi il vecchio credit_to_width_ratio_min=0.30
# era irraggiungibile (0 spread fattibili). La sola leva efficace è
# vendere più vicino → delta alzato a ~0.18.
short_strike:
delta_target: 0.12
delta_min: 0.10
delta_max: 0.15
distance_otm_pct_min: 0.15
delta_target: 0.18
delta_min: 0.12
delta_max: 0.22
distance_otm_pct_min: 0.10
distance_otm_pct_max: 0.25
spread_width:
target_pct_of_spot: 0.04
min_pct_of_spot: 0.03
max_pct_of_spot: 0.05
# 0.06: la griglia strike ETH sotto i $1500 è a 100pt (~6% a spot
# <$2k); con cap 0.05 nessuna gamba long rientrava in banda.
max_pct_of_spot: 0.06
credit_to_width_ratio_min: 0.30
credit_to_width_ratio_min: 0.08 # era 0.30 (irraggiungibile a delta ≤0.22)
# === RESEARCH COLLECTOR (§13-bis) ===
# Collettore opzioni full-chain, indipendente dal live. Cattura tutte
# le scadenze ≤ expiry_max_days ed entrambe le ali, popolando
# book_depth_top3 (1 call orderbook/strumento) → backtest opzioni
# per-trade e standing. Disabilitato di default: costo API non
# trascurabile. Scrive righe con source='research'.
research_collector:
enabled: false
cron: "0 * * * *" # orario, indipendente dal */15 del live
expiry_max_days: 95 # 1g..3mesi
moneyness_band_pct: null # null = catena completa (entrambe le ali)
open_interest_min: 100
fetch_book_depth: true
book_depth_concurrency: 8 # concorrenza max delle call orderbook
assets: ["ETH", "BTC"]
# === LIQUIDITY GATE ===
+64
View File
@@ -1204,6 +1204,70 @@ def option_chain_trigger(
)
@option_chain.command(name="research-trigger")
@click.option(
"--strategy",
"strategy_path",
type=click.Path(exists=True, dir_okay=False, path_type=Path),
default=_DEFAULT_STRATEGY_PATH,
show_default=True,
)
@click.option(
"--db",
"db_path",
type=click.Path(dir_okay=False, path_type=Path),
default=_DEFAULT_DB_PATH,
show_default=True,
)
@click.option(
"--audit",
"audit_path",
type=click.Path(dir_okay=False, path_type=Path),
default=_DEFAULT_AUDIT_PATH,
show_default=True,
)
@click.option(
"--token",
type=str,
default=None,
help="MCP bearer token (override su CERBERO_BITE_MCP_TOKEN).",
)
@click.option("--asset", default="ETH", show_default=True)
def option_chain_research_trigger(
strategy_path: Path,
db_path: Path,
audit_path: Path,
token: str | None,
asset: str,
) -> None:
"""Esegue UNA volta il collector research full-chain (source='research').
Cattura tutte le scadenze ed entrambe le ali con book_depth_top3
popolato. ``force=True``: gira anche se ``research_collector.enabled``
è false, utile per testare prima di accendere il cron orario.
"""
from cerbero_bite.runtime.dependencies import build_runtime # noqa: PLC0415
from cerbero_bite.runtime.option_chain_research_cycle import ( # noqa: PLC0415
collect_option_chain_research,
)
cfg = load_strategy(strategy_path).config
ctx = build_runtime(
cfg=cfg,
endpoints=load_endpoints(),
token=load_token(value=token),
db_path=db_path,
audit_path=audit_path,
bot_tag=load_bot_tag(),
)
n = asyncio.run(collect_option_chain_research(ctx, asset=asset, force=True))
console.print(
f"[green]Persisted {n} research quote(s) for {asset}[/green]"
if n > 0
else "[yellow]No quotes persisted (chain empty or fetch failed)[/yellow]"
)
@option_chain.command(name="analyze")
@click.option(
"--strategy",
+29
View File
@@ -359,6 +359,31 @@ class McpConfig(_LooseSection): ...
class TelegramConfig(_LooseSection): ...
class ResearchCollectorConfig(BaseModel):
"""Collettore *research* full-chain (§13-bis).
Indipendente dal collettore operativo: cattura ogni ciclo tutte le
scadenze liquide entro ``expiry_max_days`` ed entrambe le ali,
popolando ``book_depth_top3`` (1 call orderbook/strumento). Trasforma
il dataset da "skew/premi medi" a backtest opzioni per-trade e
standing. Disabilitato di default: ha un costo API non trascurabile.
"""
model_config = ConfigDict(frozen=True, extra="forbid")
enabled: bool = False
cron: str = "0 * * * *" # orario, indipendente dal */15 del live
expiry_max_days: int = 95 # 1g..3mesi
# None = nessun filtro moneyness (catena completa, entrambe le ali).
# Valorizzato (es. 0.30) = tiene solo gli strike entro ±band dallo spot.
moneyness_band_pct: Decimal | None = None
open_interest_min: int = 100
fetch_book_depth: bool = True
# Concorrenza max delle call orderbook depth (bound sul rate-limit).
book_depth_concurrency: int = 8
assets: list[str] = Field(default_factory=lambda: ["ETH", "BTC"])
# ---------------------------------------------------------------------------
# Root
# ---------------------------------------------------------------------------
@@ -383,6 +408,10 @@ class StrategyConfig(BaseModel):
kelly_recalibration: KellyConfig = Field(default_factory=KellyConfig)
auto_pause: AutoPauseConfig = Field(default_factory=AutoPauseConfig)
research_collector: ResearchCollectorConfig = Field(
default_factory=ResearchCollectorConfig
)
execution: ExecutionConfig = Field(default_factory=ExecutionConfig)
monitoring: MonitoringConfig = Field(default_factory=MonitoringConfig)
storage: StorageConfig = Field(default_factory=StorageConfig)
@@ -0,0 +1,198 @@
"""Full-chain *research* option collector (§13-bis).
Diverso dal collettore operativo (`option_chain_snapshot_cycle`):
* finestra scadenze ``[now, now + expiry_max_days]`` cattura TUTTE le
scadenze liquide (1g/1sett/2sett/1mese/3mesi), non solo quella nella
finestra DTE della strategia;
* entrambe le ali (nessun filtro moneyness di default), oppure entro
``±moneyness_band_pct`` se configurato;
* popola ``book_depth_top3`` chiamando l'orderbook per ogni strumento
tenuto (1 call/strumento, concorrenza limitata da
``book_depth_concurrency``) così lo slippage reale è modellabile;
* scrive con ``source='research'`` per non confondersi con le righe
'live'.
Questo trasforma il dataset da "skew/premi medi" a backtest opzioni
vero, per-trade e standing. Best-effort come l'altro collettore: un
batch o un orderbook che falliscono non invalidano il resto.
"""
from __future__ import annotations
import asyncio
import logging
from datetime import UTC, datetime, timedelta
from decimal import Decimal
from typing import TYPE_CHECKING, Any
from cerbero_bite.state import connect, transaction
from cerbero_bite.state.models import OptionChainQuoteRecord
from cerbero_bite.runtime.option_chain_snapshot_cycle import (
DEFAULT_BATCH_SIZE,
_fetch_tickers_in_batches,
_to_decimal_or_none,
)
if TYPE_CHECKING:
from cerbero_bite.runtime.dependencies import RuntimeContext
__all__ = ["collect_option_chain_research"]
_log = logging.getLogger("cerbero_bite.runtime.option_chain_research")
def _underlying_price(ticker: dict[str, Any]) -> Decimal | None:
"""Spot/index dell'underlying dal ticker, per il filtro moneyness."""
for key in ("underlying_price", "index_price", "estimated_delivery_price"):
val = _to_decimal_or_none(ticker.get(key))
if val is not None and val > 0:
return val
return None
async def _depth_for(
ctx: RuntimeContext, name: str, sem: asyncio.Semaphore
) -> int | None:
"""Best-effort top-3 book depth per ``name`` (None se fallisce)."""
async with sem:
try:
return await ctx.deribit.orderbook_depth_top3(name)
except Exception as exc:
_log.debug("orderbook_depth_top3 failed for %s: %s", name, exc)
return None
async def collect_option_chain_research(
ctx: RuntimeContext,
*,
asset: str = "ETH",
now: datetime | None = None,
batch_size: int = DEFAULT_BATCH_SIZE,
force: bool = False,
) -> int:
"""Collect + persist un singolo snapshot full-chain ``research`` per
``asset``. Ritorna il numero di quote persistiti (0 su fallimento
best-effort o se il collettore è disabilitato).
``force=True`` bypassa il gate ``enabled`` (usato dal trigger CLI per
testare la raccolta prima di accendere il cron schedulato)."""
rc = getattr(ctx.cfg, "research_collector", None)
if rc is None or (not rc.enabled and not force):
return 0
when = (now or datetime.now(UTC)).astimezone(UTC)
expiry_from = when
expiry_to = when + timedelta(days=rc.expiry_max_days)
try:
chain = await ctx.deribit.options_chain(
currency=asset.upper(),
expiry_from=expiry_from,
expiry_to=expiry_to,
min_open_interest=int(rc.open_interest_min),
)
except Exception:
_log.exception("research option chain fetch failed")
return 0
if not chain:
_log.info("research option chain empty for %s in window", asset)
return 0
names = [meta.name for meta in chain]
tickers = await _fetch_tickers_in_batches(ctx, names, batch_size=batch_size)
band = rc.moneyness_band_pct # Decimal | None
# 1) costruisci i quote, applicando l'eventuale filtro moneyness.
kept: list[tuple[OptionChainQuoteRecord, dict[str, Any] | None]] = []
for meta in chain:
ticker = tickers.get(meta.name)
if band is not None and ticker is not None:
spot = _underlying_price(ticker)
if spot is not None:
moneyness = abs(meta.strike - spot) / spot
if moneyness > band:
continue # fuori dall'ala richiesta
if ticker is None:
rec = OptionChainQuoteRecord(
timestamp=when,
asset=asset.upper(),
instrument_name=meta.name,
strike=meta.strike,
expiry=meta.expiry,
option_type=meta.option_type,
open_interest=int(meta.open_interest)
if meta.open_interest is not None
else None,
source="research",
)
kept.append((rec, None))
continue
greeks = ticker.get("greeks") or {}
rec = OptionChainQuoteRecord(
timestamp=when,
asset=asset.upper(),
instrument_name=meta.name,
strike=meta.strike,
expiry=meta.expiry,
option_type=meta.option_type,
bid=_to_decimal_or_none(ticker.get("bid")),
ask=_to_decimal_or_none(ticker.get("ask")),
mid=_to_decimal_or_none(ticker.get("mark_price")),
iv=_to_decimal_or_none(ticker.get("mark_iv")),
delta=_to_decimal_or_none(greeks.get("delta")),
gamma=_to_decimal_or_none(greeks.get("gamma")),
theta=_to_decimal_or_none(greeks.get("theta")),
vega=_to_decimal_or_none(greeks.get("vega")),
open_interest=int(meta.open_interest)
if meta.open_interest is not None
else None,
volume_24h=(
int(ticker["volume_24h"])
if ticker.get("volume_24h") is not None
else None
),
source="research",
)
kept.append((rec, ticker))
# 2) popola book_depth_top3 (concorrenza limitata) sugli strumenti tenuti.
if rc.fetch_book_depth and kept:
sem = asyncio.Semaphore(max(1, int(rc.book_depth_concurrency)))
depths = await asyncio.gather(
*(_depth_for(ctx, rec.instrument_name, sem) for rec, _ in kept)
)
kept = [
(rec.model_copy(update={"book_depth_top3": depth}), tk)
for (rec, tk), depth in zip(kept, depths, strict=True)
]
quotes = [rec for rec, _ in kept]
persisted = 0
try:
conn = connect(ctx.db_path)
try:
with transaction(conn):
persisted = ctx.repository.record_option_chain_snapshot(conn, quotes)
finally:
conn.close()
except Exception:
_log.exception("persist research option chain snapshot failed")
return 0
_log.info(
"option_chain_research persisted %d quote(s) for %s (%d expiries window<=%dd)",
persisted,
asset.upper(),
len({q.expiry for q in quotes}),
rc.expiry_max_days,
)
return persisted
+26 -1
View File
@@ -37,6 +37,9 @@ from cerbero_bite.runtime.market_snapshot_cycle import (
from cerbero_bite.runtime.option_chain_snapshot_cycle import (
collect_option_chain_snapshot,
)
from cerbero_bite.runtime.option_chain_research_cycle import (
collect_option_chain_research,
)
from cerbero_bite.runtime.monitor_cycle import MonitorCycleResult, run_monitor_cycle
from cerbero_bite.runtime.recovery import recover_state
from cerbero_bite.runtime.scheduler import JobSpec, build_scheduler
@@ -55,7 +58,7 @@ Environment = Literal["testnet", "mainnet"]
_CRON_ENTRY = "0 */2 * * *" # crypto 24/7: valutazione ogni 2h; i gate decidono se entrare
_CRON_MONITOR = "0 * * * *" # stop/take-profit check ogni ora
_CRON_HEALTH = "*/5 * * * *"
_CRON_BACKUP = "0 * * * *"
_CRON_BACKUP = "0 0 * * *" # giornaliero (00:00 UTC): lo snapshot full orario gonfiava i backup (16GB per ~98MB di dato unico)
_CRON_MANUAL_ACTIONS = "*/1 * * * *"
_CRON_MARKET_SNAPSHOT = "*/15 * * * *"
_CRON_OPTION_CHAIN_SNAPSHOT = "*/15 * * * *" # crypto è 24/7: cadenza continua allineata a market_snapshot
@@ -320,6 +323,13 @@ class Orchestrator:
await _safe("option_chain_snapshot", _do)
async def _option_chain_research() -> None:
async def _do() -> None:
for asset in self._ctx.cfg.research_collector.assets:
await collect_option_chain_research(self._ctx, asset=asset)
await _safe("option_chain_research", _do)
jobs: list[JobSpec] = [
JobSpec(name="health", cron=health_cron, coro_factory=_health),
JobSpec(name="backup", cron=backup_cron, coro_factory=_backup),
@@ -354,6 +364,21 @@ class Orchestrator:
coro_factory=_option_chain_snapshot,
)
)
rc = self._ctx.cfg.research_collector
if rc.enabled:
jobs.append(
JobSpec(
name="option_chain_research",
cron=rc.cron,
coro_factory=_option_chain_research,
)
)
_log.info(
"research collector ENABLED (cron=%s, window<=%dd, depth=%s)",
rc.cron,
rc.expiry_max_days,
rc.fetch_book_depth,
)
else:
_log.warning(
"data analysis disabled (CERBERO_BITE_ENABLE_DATA_ANALYSIS="
@@ -0,0 +1,18 @@
-- 0007_option_chain_source.sql — distingue le righe del collettore
--
-- Due collettori scrivono ora su option_chain_snapshots:
-- * 'live' — collettore operativo, finestra DTE della strategia
-- (cfg.structure.dte_min..dte_max), 1 scadenza, no depth.
-- * 'research' — collettore full-chain (tutte le scadenze <=95g,
-- entrambe le ali, book_depth_top3 popolato) per il
-- backtest opzioni vero (per-trade e standing put).
--
-- Le righe storiche pre-migrazione sono tutte 'live' (DEFAULT). Il
-- backtest per-trade/standing filtra su source='research'.
ALTER TABLE option_chain_snapshots ADD COLUMN source TEXT NOT NULL DEFAULT 'live';
CREATE INDEX idx_option_chain_source
ON option_chain_snapshots(asset, source, timestamp DESC);
PRAGMA user_version = 7;
+3
View File
@@ -178,6 +178,9 @@ class OptionChainQuoteRecord(BaseModel):
open_interest: int | None = None
volume_24h: int | None = None
book_depth_top3: int | None = None
# 'live' = collettore operativo (finestra DTE strategia, no depth);
# 'research' = collettore full-chain con book_depth popolato.
source: str = "live"
class ManualAction(BaseModel):
+12 -2
View File
@@ -547,6 +547,7 @@ class Repository:
q.open_interest,
q.volume_24h,
q.book_depth_top3,
q.source,
)
for q in quotes
]
@@ -554,8 +555,8 @@ class Repository:
"INSERT OR REPLACE INTO option_chain_snapshots("
"timestamp, asset, instrument_name, strike, expiry, option_type, "
"bid, ask, mid, iv, delta, gamma, theta, vega, "
"open_interest, volume_24h, book_depth_top3) "
"VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)",
"open_interest, volume_24h, book_depth_top3, source) "
"VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)",
rows,
)
return len(rows)
@@ -569,10 +570,14 @@ class Repository:
end: datetime | None = None,
expiry_from: datetime | None = None,
expiry_to: datetime | None = None,
source: str | None = None,
limit: int = 50000,
) -> list[OptionChainQuoteRecord]:
clauses: list[str] = ["asset = ?"]
params: list[Any] = [asset]
if source is not None:
clauses.append("source = ?")
params.append(source)
if start is not None:
clauses.append("timestamp >= ?")
params.append(_enc_dt(start))
@@ -925,6 +930,11 @@ def _row_to_option_chain_quote(row: sqlite3.Row) -> OptionChainQuoteRecord:
if row["book_depth_top3"] is not None
else None
),
source=(
row["source"]
if "source" in row.keys() and row["source"] is not None
else "live"
),
)
+33 -12
View File
@@ -28,9 +28,9 @@
# 2× via "ETH + BTC" indicato in `📚 Strategia` è una **stima ex-ante**
# di cosa otterresti DOPO quel lavoro di codice.
config_version: "1.4.0-aggressiva"
config_hash: "7fa9b0be5b56517293421bc19838b700da595725360fe018a1be13b802dea859"
last_review: "2026-04-26"
config_version: "1.5.0-aggressiva"
config_hash: "a5e23c289315d738289f79e6b8c0e05e880e07c6ef878b013fc9849918e8b37a"
last_review: "2026-06-09"
last_reviewer: "Adriano"
asset:
@@ -83,31 +83,52 @@ entry:
vol_of_vol_lookback_hours: 24
# §13-bis: collettore research full-chain (vedi strategy.yaml). Cattura
# tutte le scadenze ed entrambe le ali con book_depth_top3 popolato per
# il backtest opzioni per-trade/standing. Disabilitato di default.
research_collector:
enabled: false
cron: "0 * * * *"
expiry_max_days: 95
moneyness_band_pct: null
open_interest_min: 100
fetch_book_depth: true
book_depth_concurrency: 8
assets: ["ETH", "BTC"]
structure:
dte_target: 18
dte_min: 14
dte_max: 21
# PROFILO B (tune 2026-06-09): stessa correzione del profilo
# conservativo. La delta step-function aggressiva (max 0.17 a DVOL<50)
# NON bastava: il credit/width satura a ~6% e 0.30 resta irraggiungibile
# (0 spread fattibili su 3.689 snapshot). Si alza il delta band.
short_strike:
delta_target: "0.12"
delta_min: "0.10"
delta_max: "0.15"
distance_otm_pct_min: "0.15"
delta_target: "0.18"
delta_min: "0.12"
delta_max: "0.22"
distance_otm_pct_min: "0.10"
distance_otm_pct_max: "0.25"
# §3.2 (A): step-function delta-target per regime DVOL.
# DVOL bassa (≤50) → più premio; alta (>70) → più safety.
delta_by_dvol:
- {dvol_under: "50", delta_target: "0.15", delta_min: "0.13", delta_max: "0.17"}
- {dvol_under: "70", delta_target: "0.12", delta_min: "0.10", delta_max: "0.15"}
- {dvol_under: "90", delta_target: "0.10", delta_min: "0.08", delta_max: "0.12"}
- {dvol_under: "50", delta_target: "0.22", delta_min: "0.18", delta_max: "0.25"}
- {dvol_under: "70", delta_target: "0.18", delta_min: "0.12", delta_max: "0.22"}
- {dvol_under: "90", delta_target: "0.15", delta_min: "0.10", delta_max: "0.18"}
spread_width:
target_pct_of_spot: "0.04"
min_pct_of_spot: "0.03"
max_pct_of_spot: "0.05"
# 0.06: griglia strike ETH a 100pt (~6% a spot <$2k); cap 0.05
# escludeva ogni gamba long.
max_pct_of_spot: "0.06"
credit_to_width_ratio_min: "0.30"
# Era 0.30 — fisicamente irraggiungibile. 0.08 allineato al realizzabile.
credit_to_width_ratio_min: "0.08"
liquidity:
open_interest_min: 100
+34 -9
View File
@@ -6,9 +6,9 @@
# config hash), and lands as a separate commit with the motivation in
# the commit message.
config_version: "1.6.0"
config_hash: "67df85f84ce6148b88f7eb9d96346145c1b9892a7250f5fc42619da3178dac86"
last_review: "2026-05-29"
config_version: "1.7.1"
config_hash: "720a202e9dde4ccff7f79a8bc9521de62dc30115f11f486c55ad6f3986858dcb"
last_review: "2026-06-09"
last_reviewer: "Adriano"
asset:
@@ -59,24 +59,49 @@ entry:
iv_minus_rv_window_min_days: 30
# §13-bis: collettore research full-chain. INDIPENDENTE dal collettore
# live (che resta sulla finestra DTE della strategia, 1 scadenza, no
# depth). Cattura tutte le scadenze <=expiry_max_days ed entrambe le
# ali, popolando book_depth_top3 → backtest opzioni per-trade e
# standing put + slippage reale. Disabilitato di default (costo API).
research_collector:
enabled: true
cron: "0 * * * *" # orario
expiry_max_days: 95 # 1g..3mesi
moneyness_band_pct: null # null = catena completa (entrambe le ali)
open_interest_min: 100
fetch_book_depth: true
book_depth_concurrency: 8
assets: ["ETH", "BTC"]
structure:
dte_target: 18
dte_min: 14
dte_max: 21
# PROFILO B (tune 2026-06-09): vendere più vicino sblocca credito
# realizzabile. Analisi su 3.689 snapshot (1mag-8giu): a delta
# 0.10-0.15 il miglior credit/width ottenibile è ~6%, quindi 0.30 era
# fisicamente irraggiungibile (0 spread fattibili). Alzando il delta a
# ~0.18-0.22 il ratio sale a ~8-10% e l'eleggibilità a ~11%.
short_strike:
delta_target: "0.12"
delta_min: "0.10"
delta_max: "0.15"
distance_otm_pct_min: "0.15"
delta_target: "0.18"
delta_min: "0.12"
delta_max: "0.22"
distance_otm_pct_min: "0.10"
distance_otm_pct_max: "0.25"
spread_width:
target_pct_of_spot: "0.04"
min_pct_of_spot: "0.03"
max_pct_of_spot: "0.05"
# 0.06: la griglia strike ETH sotto i $1500 è a 100pt (~6% a spot
# <$2k). Con cap 0.05 nessuna gamba long rientrava in banda.
max_pct_of_spot: "0.06"
credit_to_width_ratio_min: "0.30"
# Era 0.30 — irraggiungibile con delta <=0.30 in questo mercato.
# 0.08 = allineato al credit/width fisicamente realizzabile a delta ~0.18.
credit_to_width_ratio_min: "0.08"
liquidity:
open_interest_min: 100