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.entrypoints=websecure
- traefik.http.routers.cerbero-bite.tls.certresolver=mytlschallenge - traefik.http.routers.cerbero-bite.tls.certresolver=mytlschallenge
- traefik.http.services.cerbero-bite.loadbalancer.server.port=8765 - 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 Si seleziona lo strike short più vicino a delta target, in valore
assoluto. assoluto.
| Parametro | Valore | | Parametro | Valore (profilo B, v1.7.0) |
|---|---| |---|---|
| Delta short target | 0.12 | | Delta short target | 0.18 |
| Tolleranza delta | [0.10, 0.15] | | Tolleranza delta | [0.12, 0.22] |
| Distanza minima OTM | 15% (anche se delta è dentro tolleranza) | | Distanza minima OTM | 10% (anche se delta è dentro tolleranza) |
| Distanza massima OTM | 25% | | 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 **Variante dinamica per regime DVOL (Phase 5, opt-in).** Il campo
`short_strike.delta_by_dvol` (lista step-function) sostituisce il `short_strike.delta_by_dvol` (lista step-function) sostituisce il
delta target singolo con bande ordinate per `dvol_under`: a DVOL 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 | | dvol_under | delta_target | delta_min | delta_max |
|---|---|---|---| |---|---|---|---|
| 50 | 0.15 | 0.13 | 0.17 | | 50 | 0.22 | 0.18 | 0.25 |
| 70 | 0.12 | 0.10 | 0.15 | | 70 | 0.18 | 0.12 | 0.22 |
| 90 | 0.10 | 0.08 | 0.12 | | 90 | 0.15 | 0.10 | 0.18 |
Se nessuno strike disponibile rientra in entrambe le tolleranze → no entry. 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 spread (in % di spot) | 4% |
| Larghezza minima accettabile | 3% | | 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 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 ### 3.4 Rapporto credito/larghezza
Il credito netto incassato (mid-price del combo) deve essere ≥ **30%** Il credito netto incassato (mid-price del combo) deve essere ≥ **8%**
della larghezza dello spread. Sotto questa soglia il rapporto della larghezza dello spread (profilo B). → altrimenti no entry.
rischio/rendimento è inferiore al minimo accettabile. → 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) ## 4. Liquidità (filtro hard pre-entry)
+35 -12
View File
@@ -229,12 +229,23 @@ NULL = engine attivo.
### `option_chain_snapshots` ### `option_chain_snapshots`
Snapshot della catena opzioni Deribit prelevata in continuo Snapshot della catena opzioni Deribit. La tabella è alimentata da
(cron `*/15 * * * *`, allineato a `market_snapshots`). Crypto è **due collettori distinti**, separati dalla colonna `source`:
24/7: l'accumulo dataset deve essere continuo, non gateato sulla
settimana. Ogni tick contiene un quote per strumento entro la * `source='live'` — collettore operativo (cron `*/15 * * * *`,
finestra `[dte_min, dte_max]` di config; tutti i quote prelevati allineato a `market_snapshots`). Ogni tick contiene un quote per
nello stesso tick condividono ``timestamp``. Migration `0005`. 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 ```sql
CREATE TABLE option_chain_snapshots ( CREATE TABLE option_chain_snapshots (
@@ -255,15 +266,19 @@ CREATE TABLE option_chain_snapshots (
open_interest INTEGER, open_interest INTEGER,
volume_24h INTEGER, volume_24h INTEGER,
book_depth_top3 INTEGER, book_depth_top3 INTEGER,
source TEXT NOT NULL DEFAULT 'live',
PRIMARY KEY (timestamp, instrument_name) PRIMARY KEY (timestamp, instrument_name)
) WITHOUT ROWID; ) WITHOUT ROWID;
``` ```
Indici: `(asset, timestamp DESC)` per listing recenti, `(asset, Indici: `(asset, timestamp DESC)` per listing recenti, `(asset,
expiry)` per query per scadenza specifica. ``book_depth_top3`` è expiry)` per query per scadenza specifica, `(asset, source,
NULL by design — il collector non chiama l'order book per ogni timestamp DESC)` per separare live/research nelle query di backtest.
strike per non saturare l'API; lo legge il liquidity gate live solo ``book_depth_top3`` è NULL sulle righe `live` (il collector operativo
sugli strike candidati al picker. 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` **Sblocca**: il backtest non-stilizzato (modulo `core/backtest.py`
con prezzi reali invece di Black-Scholes), la calibrazione empirica 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` | | 3 | `0003_market_snapshots.sql` | tabella `market_snapshots` |
| 4 | `0004_auto_pause.sql` | `system_state.auto_pause_until / _reason` | | 4 | `0004_auto_pause.sql` | `system_state.auto_pause_until / _reason` |
| 5 | `0005_option_chain_snapshots.sql` | tabella `option_chain_snapshots` | | 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
Backup orari di `state.sqlite` in Backup **giornalieri** di `state.sqlite` in
`data/backups/state-YYYYMMDD-HH.sqlite`, ritenuti per 30 giorni. `data/backups/state-YYYYMMDD-HH.sqlite`, ritenuti per 30 giorni.
Operazione idempotente con SQLite `VACUUM INTO`. Lo job è registrato 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 disponibile anche come CLI `python scripts/backup.py` per backup
ad-hoc. 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 Il dead-man (`scripts/dead_man.sh`) sorveglia che `HEALTH_OK` venga
scritto: silenzio > 15 min → kill switch via SQLite e alert. 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 ## Flusso 5b — Manual actions consumer
Trigger: cron `*/1 * * * *` (job APScheduler `manual_actions`). Trigger: cron `*/1 * * * *` (job APScheduler `manual_actions`).
@@ -226,6 +234,8 @@ proposed
| `0 12 1 * *` | Kelly recalibration | Mensile | | `0 12 1 * *` | Kelly recalibration | Mensile |
| `*/5 * * * *` | Health check | 5 min | | `*/5 * * * *` | Health check | 5 min |
| `*/15 * * * *` | Market snapshot (calibrazione soglie) | 15 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 0 * * *` | Backup SQLite + rotation log | Giornaliero |
| `0 8 * * *` | Daily digest Telegram | 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-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 `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 | | `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 | | 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 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` | | `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) | | Comando manuale via `cerbero-bite kill-switch arm` | Sì | `cli.py kill_switch_arm` | Severity HIGH (operator-initiated) |
### Disarm ### 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 ```bash
cerbero-bite kill-switch disarm --reason "<motivo>" \ cerbero-bite kill-switch disarm --reason "<motivo>" \
--db data/state.sqlite \ --db data/state.sqlite \
--audit data/audit.log --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` + L'operazione è transazionale: SQLite `system_state.kill_switch = 0` +
una linea `KILL_SWITCH_DISARMED` nella audit chain con il motivo. Il una linea `KILL_SWITCH_DISARMED` nella audit chain con il motivo. Il
disarm non riavvia automaticamente lo scheduler; è il prossimo tick disarm non riavvia automaticamente lo scheduler; è il prossimo tick
naturale (entry giornaliero o monitor 12h) a far ripartire la 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) ## 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 mancante) viene armato il kill switch CRITICAL prima che qualsiasi
ciclo trading parta. 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 ## Dry-run mode
Il comando `cerbero-bite dry-run --cycle entry|monitor|health` esegue Il comando `cerbero-bite dry-run --cycle entry|monitor|health` esegue
+30 -6
View File
@@ -55,19 +55,43 @@ structure:
dte_min: 14 dte_min: 14
dte_max: 21 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: short_strike:
delta_target: 0.12 delta_target: 0.18
delta_min: 0.10 delta_min: 0.12
delta_max: 0.15 delta_max: 0.22
distance_otm_pct_min: 0.15 distance_otm_pct_min: 0.10
distance_otm_pct_max: 0.25 distance_otm_pct_max: 0.25
spread_width: spread_width:
target_pct_of_spot: 0.04 target_pct_of_spot: 0.04
min_pct_of_spot: 0.03 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 === # === 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") @option_chain.command(name="analyze")
@click.option( @click.option(
"--strategy", "--strategy",
+29
View File
@@ -359,6 +359,31 @@ class McpConfig(_LooseSection): ...
class TelegramConfig(_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 # Root
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
@@ -383,6 +408,10 @@ class StrategyConfig(BaseModel):
kelly_recalibration: KellyConfig = Field(default_factory=KellyConfig) kelly_recalibration: KellyConfig = Field(default_factory=KellyConfig)
auto_pause: AutoPauseConfig = Field(default_factory=AutoPauseConfig) auto_pause: AutoPauseConfig = Field(default_factory=AutoPauseConfig)
research_collector: ResearchCollectorConfig = Field(
default_factory=ResearchCollectorConfig
)
execution: ExecutionConfig = Field(default_factory=ExecutionConfig) execution: ExecutionConfig = Field(default_factory=ExecutionConfig)
monitoring: MonitoringConfig = Field(default_factory=MonitoringConfig) monitoring: MonitoringConfig = Field(default_factory=MonitoringConfig)
storage: StorageConfig = Field(default_factory=StorageConfig) 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 ( from cerbero_bite.runtime.option_chain_snapshot_cycle import (
collect_option_chain_snapshot, 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.monitor_cycle import MonitorCycleResult, run_monitor_cycle
from cerbero_bite.runtime.recovery import recover_state from cerbero_bite.runtime.recovery import recover_state
from cerbero_bite.runtime.scheduler import JobSpec, build_scheduler 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_ENTRY = "0 */2 * * *" # crypto 24/7: valutazione ogni 2h; i gate decidono se entrare
_CRON_MONITOR = "0 * * * *" # stop/take-profit check ogni ora _CRON_MONITOR = "0 * * * *" # stop/take-profit check ogni ora
_CRON_HEALTH = "*/5 * * * *" _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_MANUAL_ACTIONS = "*/1 * * * *"
_CRON_MARKET_SNAPSHOT = "*/15 * * * *" _CRON_MARKET_SNAPSHOT = "*/15 * * * *"
_CRON_OPTION_CHAIN_SNAPSHOT = "*/15 * * * *" # crypto è 24/7: cadenza continua allineata a market_snapshot _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) 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] = [ jobs: list[JobSpec] = [
JobSpec(name="health", cron=health_cron, coro_factory=_health), JobSpec(name="health", cron=health_cron, coro_factory=_health),
JobSpec(name="backup", cron=backup_cron, coro_factory=_backup), JobSpec(name="backup", cron=backup_cron, coro_factory=_backup),
@@ -354,6 +364,21 @@ class Orchestrator:
coro_factory=_option_chain_snapshot, 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: else:
_log.warning( _log.warning(
"data analysis disabled (CERBERO_BITE_ENABLE_DATA_ANALYSIS=" "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 open_interest: int | None = None
volume_24h: int | None = None volume_24h: int | None = None
book_depth_top3: 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): class ManualAction(BaseModel):
+12 -2
View File
@@ -547,6 +547,7 @@ class Repository:
q.open_interest, q.open_interest,
q.volume_24h, q.volume_24h,
q.book_depth_top3, q.book_depth_top3,
q.source,
) )
for q in quotes for q in quotes
] ]
@@ -554,8 +555,8 @@ class Repository:
"INSERT OR REPLACE INTO option_chain_snapshots(" "INSERT OR REPLACE INTO option_chain_snapshots("
"timestamp, asset, instrument_name, strike, expiry, option_type, " "timestamp, asset, instrument_name, strike, expiry, option_type, "
"bid, ask, mid, iv, delta, gamma, theta, vega, " "bid, ask, mid, iv, delta, gamma, theta, vega, "
"open_interest, volume_24h, book_depth_top3) " "open_interest, volume_24h, book_depth_top3, source) "
"VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)", "VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)",
rows, rows,
) )
return len(rows) return len(rows)
@@ -569,10 +570,14 @@ class Repository:
end: datetime | None = None, end: datetime | None = None,
expiry_from: datetime | None = None, expiry_from: datetime | None = None,
expiry_to: datetime | None = None, expiry_to: datetime | None = None,
source: str | None = None,
limit: int = 50000, limit: int = 50000,
) -> list[OptionChainQuoteRecord]: ) -> list[OptionChainQuoteRecord]:
clauses: list[str] = ["asset = ?"] clauses: list[str] = ["asset = ?"]
params: list[Any] = [asset] params: list[Any] = [asset]
if source is not None:
clauses.append("source = ?")
params.append(source)
if start is not None: if start is not None:
clauses.append("timestamp >= ?") clauses.append("timestamp >= ?")
params.append(_enc_dt(start)) 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 if row["book_depth_top3"] is not None
else 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** # 2× via "ETH + BTC" indicato in `📚 Strategia` è una **stima ex-ante**
# di cosa otterresti DOPO quel lavoro di codice. # di cosa otterresti DOPO quel lavoro di codice.
config_version: "1.4.0-aggressiva" config_version: "1.5.0-aggressiva"
config_hash: "7fa9b0be5b56517293421bc19838b700da595725360fe018a1be13b802dea859" config_hash: "a5e23c289315d738289f79e6b8c0e05e880e07c6ef878b013fc9849918e8b37a"
last_review: "2026-04-26" last_review: "2026-06-09"
last_reviewer: "Adriano" last_reviewer: "Adriano"
asset: asset:
@@ -83,31 +83,52 @@ entry:
vol_of_vol_lookback_hours: 24 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: structure:
dte_target: 18 dte_target: 18
dte_min: 14 dte_min: 14
dte_max: 21 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: short_strike:
delta_target: "0.12" delta_target: "0.18"
delta_min: "0.10" delta_min: "0.12"
delta_max: "0.15" delta_max: "0.22"
distance_otm_pct_min: "0.15" distance_otm_pct_min: "0.10"
distance_otm_pct_max: "0.25" distance_otm_pct_max: "0.25"
# §3.2 (A): step-function delta-target per regime DVOL. # §3.2 (A): step-function delta-target per regime DVOL.
# DVOL bassa (≤50) → più premio; alta (>70) → più safety. # DVOL bassa (≤50) → più premio; alta (>70) → più safety.
delta_by_dvol: delta_by_dvol:
- {dvol_under: "50", delta_target: "0.15", delta_min: "0.13", delta_max: "0.17"} - {dvol_under: "50", delta_target: "0.22", delta_min: "0.18", delta_max: "0.25"}
- {dvol_under: "70", delta_target: "0.12", delta_min: "0.10", delta_max: "0.15"} - {dvol_under: "70", delta_target: "0.18", delta_min: "0.12", delta_max: "0.22"}
- {dvol_under: "90", delta_target: "0.10", delta_min: "0.08", delta_max: "0.12"} - {dvol_under: "90", delta_target: "0.15", delta_min: "0.10", delta_max: "0.18"}
spread_width: spread_width:
target_pct_of_spot: "0.04" target_pct_of_spot: "0.04"
min_pct_of_spot: "0.03" 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: liquidity:
open_interest_min: 100 open_interest_min: 100
+34 -9
View File
@@ -6,9 +6,9 @@
# config hash), and lands as a separate commit with the motivation in # config hash), and lands as a separate commit with the motivation in
# the commit message. # the commit message.
config_version: "1.6.0" config_version: "1.7.1"
config_hash: "67df85f84ce6148b88f7eb9d96346145c1b9892a7250f5fc42619da3178dac86" config_hash: "720a202e9dde4ccff7f79a8bc9521de62dc30115f11f486c55ad6f3986858dcb"
last_review: "2026-05-29" last_review: "2026-06-09"
last_reviewer: "Adriano" last_reviewer: "Adriano"
asset: asset:
@@ -59,24 +59,49 @@ entry:
iv_minus_rv_window_min_days: 30 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: structure:
dte_target: 18 dte_target: 18
dte_min: 14 dte_min: 14
dte_max: 21 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: short_strike:
delta_target: "0.12" delta_target: "0.18"
delta_min: "0.10" delta_min: "0.12"
delta_max: "0.15" delta_max: "0.22"
distance_otm_pct_min: "0.15" distance_otm_pct_min: "0.10"
distance_otm_pct_max: "0.25" distance_otm_pct_max: "0.25"
spread_width: spread_width:
target_pct_of_spot: "0.04" target_pct_of_spot: "0.04"
min_pct_of_spot: "0.03" 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: liquidity:
open_interest_min: 100 open_interest_min: 100