11 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
Adriano Dal Pastro 647e3e565f fix(safety): il boot-check dell'audit tollera il lag benigno dell'anchor
L'anchor dell'audit (system_state.last_audit_hash) e' persistito best-effort:
sotto contesa di lock SQLite il mirror puo' restare indietro rispetto al log,
che invece cresce in avanti integro. Il check di boot armava il kill-switch
su qualsiasi disuguaglianza anchor!=coda, scambiando questo lag per
manomissione.

- audit_log: nuova tail_continues_from() — True solo se l'anchor e' un
  antenato valido della coda (presente in catena + chain forward integra a EOF)
- orchestrator._verify_audit_anchor: se lag benigno -> re-sync anchor + warning;
  solo anchor assente o chain rotta (troncamento/tamper) -> critical -> arma

Verificato live: anchor in ritardo -> re-sync senza armare; anchor bogus -> arma.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-05-29 19:55:19 +00:00
Adriano Dal Pastro a5a39a6d27 feat(entry): finestra evento macro configurabile, ridotta a 1g (config v1.6.0)
Il gate macro riusava structure.dte_target (18g) per decidere entro quanti
giorni un evento high-severity blocca l'entrata, accoppiando la protezione
di rischio-evento alla scelta delle scadenze opzioni. Disaccoppiato con un
parametro dedicato.

- schema: nuovo entry.exclude_macro_within_days (default 18 = comportamento storico)
- entry_validator: il gate macro usa il nuovo campo; rimosso structure_cfg inutilizzato
- entry_cycle: la fetch del calendario macro guarda avanti exclude_macro_within_days giorni
- strategy.yaml: exclude_macro_within_days=1, config_version 1.5.0->1.6.0, hash rigenerato

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-05-29 19:55:19 +00:00
Adriano Dal Pastro 839285d75b fix(mcp): allinea il client all'interfaccia unificata MCP V2
Cerbero MCP V2 ha spostato get_instruments/get_historical/get_indicators
sul router unificato /mcp (rimossi dagli endpoint per-exchange) e ha
rinominato get_technical_indicators in get_indicators. Il client chiamava
ancora questi tool su /mcp-deribit -> 404 -> kill-switch armato, raccolta
option-chain ferma e bias direzionale rotto.

- mcp_endpoints: nuovo endpoint `unified` (CERBERO_BITE_MCP_UNIFIED_URL,
  default http://cerbero-mcp:9000/mcp)
- deribit: i 3 tool dati passano dal client unificato con exchange="deribit",
  interval minuscolo (1D->1d) e parsing del nuovo shape (symbol + native.*);
  adx_14 usa get_indicators (indicators.adx.adx)
- dependencies/gui: iniettano il client unificato in DeribitClient
- docker-compose: default in-cluster per l'endpoint /mcp

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-05-29 19:55:04 +00:00
22 changed files with 768 additions and 100 deletions
+4 -1
View File
@@ -42,6 +42,9 @@ x-bite-env: &bite-env
CERBERO_BITE_MCP_HYPERLIQUID_URL: ${CERBERO_BITE_MCP_HYPERLIQUID_URL:-http://cerbero-mcp:9000/mcp-hyperliquid} CERBERO_BITE_MCP_HYPERLIQUID_URL: ${CERBERO_BITE_MCP_HYPERLIQUID_URL:-http://cerbero-mcp:9000/mcp-hyperliquid}
CERBERO_BITE_MCP_MACRO_URL: ${CERBERO_BITE_MCP_MACRO_URL:-http://cerbero-mcp:9000/mcp-macro} CERBERO_BITE_MCP_MACRO_URL: ${CERBERO_BITE_MCP_MACRO_URL:-http://cerbero-mcp:9000/mcp-macro}
CERBERO_BITE_MCP_SENTIMENT_URL: ${CERBERO_BITE_MCP_SENTIMENT_URL:-http://cerbero-mcp:9000/mcp-sentiment} CERBERO_BITE_MCP_SENTIMENT_URL: ${CERBERO_BITE_MCP_SENTIMENT_URL:-http://cerbero-mcp:9000/mcp-sentiment}
# MCP V2 unified interface (/mcp): get_instruments, get_historical,
# get_indicators — removed from the per-exchange routers.
CERBERO_BITE_MCP_UNIFIED_URL: ${CERBERO_BITE_MCP_UNIFIED_URL:-http://cerbero-mcp:9000/mcp}
services: services:
cerbero-bite: cerbero-bite:
@@ -129,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",
+59 -20
View File
@@ -120,12 +120,29 @@ def _to_decimal(value: Any) -> Decimal | None:
class DeribitClient: class DeribitClient:
SERVICE = "deribit" SERVICE = "deribit"
def __init__(self, http: HttpToolClient) -> None: def __init__(
self, http: HttpToolClient, unified: HttpToolClient | None = None
) -> None:
if http.service != self.SERVICE: if http.service != self.SERVICE:
raise ValueError( raise ValueError(
f"DeribitClient requires service '{self.SERVICE}', got '{http.service}'" f"DeribitClient requires service '{self.SERVICE}', got '{http.service}'"
) )
self._http = http self._http = http
# Cerbero MCP V2 moved the data tools (get_instruments,
# get_historical, get_indicators) to the unified ``/mcp`` router;
# they are no longer on ``/mcp-deribit``. This second client points
# at ``/mcp`` and is only needed by those three methods — diagnostic
# paths (e.g. ``environment_info`` for ping) leave it ``None``.
self._unified = unified
def _unified_client(self, tool: str) -> HttpToolClient:
if self._unified is None:
raise McpDataAnomalyError(
f"unified MCP client not configured; '{tool}' lives on /mcp",
service=self.SERVICE,
tool=tool,
)
return self._unified
# ------------------------------------------------------------------ # ------------------------------------------------------------------
# Environment / health # Environment / health
@@ -206,7 +223,16 @@ class DeribitClient:
limit: int = 500, limit: int = 500,
) -> list[InstrumentMeta]: ) -> list[InstrumentMeta]:
"""Return option instruments matching the filters as typed metadata.""" """Return option instruments matching the filters as typed metadata."""
body: dict[str, Any] = {"currency": currency, "kind": "option", "limit": limit} # MCP V2: get_instruments is unified-only (/mcp). The venue is
# selected via ``exchange`` and each row is normalized — the native
# symbol is ``symbol`` and Deribit-specific fields live under
# ``native``.
body: dict[str, Any] = {
"exchange": self.SERVICE,
"currency": currency,
"kind": "option",
"limit": limit,
}
if expiry_from is not None: if expiry_from is not None:
body["expiry_from"] = expiry_from.date().isoformat() body["expiry_from"] = expiry_from.date().isoformat()
if expiry_to is not None: if expiry_to is not None:
@@ -214,28 +240,31 @@ class DeribitClient:
if min_open_interest is not None: if min_open_interest is not None:
body["min_open_interest"] = min_open_interest body["min_open_interest"] = min_open_interest
raw = await self._http.call("get_instruments", body) raw = await self._unified_client("get_instruments").call(
"get_instruments", body
)
instruments = raw.get("instruments") or [] instruments = raw.get("instruments") or []
out: list[InstrumentMeta] = [] out: list[InstrumentMeta] = []
for entry in instruments: for entry in instruments:
if not isinstance(entry, dict): if not isinstance(entry, dict):
continue continue
name = entry.get("name") name = entry.get("symbol")
if not isinstance(name, str): if not isinstance(name, str):
continue continue
try: try:
strike, expiry, option_type = _parse_instrument(name) strike, expiry, option_type = _parse_instrument(name)
except McpDataAnomalyError: except McpDataAnomalyError:
continue continue
native = entry.get("native") if isinstance(entry.get("native"), dict) else {}
out.append( out.append(
InstrumentMeta( InstrumentMeta(
name=name, name=name,
strike=strike, strike=strike,
expiry=expiry, expiry=expiry,
option_type=option_type, option_type=option_type,
open_interest=_to_decimal(entry.get("open_interest")), open_interest=_to_decimal(native.get("open_interest")),
tick_size=_to_decimal(entry.get("tick_size")), tick_size=_to_decimal(entry.get("tick_size")),
min_trade_amount=_to_decimal(entry.get("min_trade_amount")), min_trade_amount=_to_decimal(native.get("min_trade_amount")),
) )
) )
return out return out
@@ -291,13 +320,17 @@ class DeribitClient:
in :func:`compute_bias`. Returns ``None`` when the chain has no in :func:`compute_bias`. Returns ``None`` when the chain has no
data in the window. data in the window.
""" """
raw = await self._http.call( # MCP V2: get_historical is unified-only (/mcp); it takes
# ``exchange`` + lowercase ``interval`` (e.g. "1d", "1h") instead of
# the old per-exchange ``resolution``.
raw = await self._unified_client("get_historical").call(
"get_historical", "get_historical",
{ {
"exchange": self.SERVICE,
"instrument": instrument, "instrument": instrument,
"start_date": start.date().isoformat(), "start_date": start.date().isoformat(),
"end_date": end.date().isoformat(), "end_date": end.date().isoformat(),
"resolution": resolution, "interval": resolution.lower(),
}, },
) )
candles = (raw or {}).get("candles") or [] candles = (raw or {}).get("candles") or []
@@ -422,28 +455,34 @@ class DeribitClient:
resolution: str = "1h", resolution: str = "1h",
) -> Decimal | None: ) -> Decimal | None:
"""Return the most recent ADX(14) value, or ``None`` when missing.""" """Return the most recent ADX(14) value, or ``None`` when missing."""
raw = await self._http.call( # MCP V2: indicators are computed by the unified ``get_indicators``
"get_technical_indicators", # tool (/mcp), which replaced the per-exchange
# ``get_technical_indicators``. Body takes ``exchange`` + lowercase
# ``interval``; the response nests each indicator under
# ``indicators`` (ADX as ``{"adx": <float>, "+di":…, "-di":…}``).
raw = await self._unified_client("get_indicators").call(
"get_indicators",
{ {
"exchange": self.SERVICE,
"instrument": instrument, "instrument": instrument,
"indicators": ["adx"], "indicators": ["adx"],
"start_date": start.date().isoformat(), "start_date": start.date().isoformat(),
"end_date": end.date().isoformat(), "end_date": end.date().isoformat(),
"resolution": resolution, "interval": resolution.lower(),
}, },
) )
if not isinstance(raw, dict): if not isinstance(raw, dict):
return None return None
# The MCP server returns either a top-level dict with the indicators = raw.get("indicators")
# indicator keyed by name, or a list of points. Be tolerant. if not isinstance(indicators, dict):
adx_payload = raw.get("adx") or raw.get("ADX") or raw.get("indicators", {}) return None
if isinstance(adx_payload, list) and adx_payload: adx_block = indicators.get("adx")
tail = adx_payload[-1] if isinstance(adx_block, dict):
value = tail.get("value") if isinstance(tail, dict) else tail value = adx_block.get("adx")
return None if value is None else Decimal(str(value))
if isinstance(adx_payload, dict):
value = adx_payload.get("latest") or adx_payload.get("value")
return None if value is None else Decimal(str(value)) return None if value is None else Decimal(str(value))
# Tolerate a flattened scalar shape just in case.
if adx_block is not None and not isinstance(adx_block, list):
return Decimal(str(adx_block))
return None return None
async def get_account_summary(self, currency: str = "USDC") -> dict[str, Any]: async def get_account_summary(self, currency: str = "USDC") -> dict[str, Any]:
+13
View File
@@ -62,6 +62,14 @@ DEFAULT_ENDPOINTS: dict[str, str] = {
} }
# Cerbero MCP V2 unified interface (``/mcp``). The data tools
# ``get_instruments`` / ``get_historical`` / ``get_indicators`` live here
# ONLY — they were removed from the per-exchange routers in MCP V2. The
# caller passes ``exchange="deribit"`` in the body to target one venue.
_UNIFIED_ENV = "CERBERO_BITE_MCP_UNIFIED_URL"
_DEFAULT_UNIFIED_URL = "http://cerbero-mcp:9000/mcp"
@dataclass(frozen=True) @dataclass(frozen=True)
class McpEndpoints: class McpEndpoints:
"""Resolved per-service URLs.""" """Resolved per-service URLs."""
@@ -70,6 +78,7 @@ class McpEndpoints:
hyperliquid: str hyperliquid: str
macro: str macro: str
sentiment: str sentiment: str
unified: str = _DEFAULT_UNIFIED_URL
def for_service(self, name: str) -> str: def for_service(self, name: str) -> str:
try: try:
@@ -85,6 +94,10 @@ def load_endpoints(env: dict[str, str] | None = None) -> McpEndpoints:
for name, (host, port, env_var) in MCP_SERVICES.items(): for name, (host, port, env_var) in MCP_SERVICES.items():
override = e.get(env_var) override = e.get(env_var)
resolved[name] = override.rstrip("/") if override else _default_url(host, port) resolved[name] = override.rstrip("/") if override else _default_url(host, port)
unified_override = e.get(_UNIFIED_ENV)
resolved["unified"] = (
unified_override.rstrip("/") if unified_override else _DEFAULT_UNIFIED_URL
)
return McpEndpoints(**resolved) return McpEndpoints(**resolved)
+34
View File
@@ -59,6 +59,11 @@ class EntryConfig(BaseModel):
no_position_concurrent: bool = True no_position_concurrent: bool = True
exclude_macro_severity: list[str] = Field(default_factory=lambda: ["high"]) exclude_macro_severity: list[str] = Field(default_factory=lambda: ["high"])
exclude_macro_countries: list[str] = Field(default_factory=lambda: ["US", "EU"]) exclude_macro_countries: list[str] = Field(default_factory=lambda: ["US", "EU"])
# Finestra (giorni) entro cui un evento macro high-severity blocca
# l'entry. Disaccoppiata da `structure.dte_target`: il filtro macro
# è una protezione di rischio evento, indipendente dalla scadenza
# scelta per le opzioni. Default 18 = comportamento storico.
exclude_macro_within_days: int = 18
# directional bias (§3.1) # directional bias (§3.1)
trend_window_days: int = 30 trend_window_days: int = 30
@@ -354,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
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
@@ -378,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)
+1 -2
View File
@@ -101,7 +101,6 @@ def validate_entry(ctx: EntryContext, cfg: StrategyConfig) -> EntryDecision:
""" """
reasons: list[str] = [] reasons: list[str] = []
entry_cfg = cfg.entry entry_cfg = cfg.entry
structure_cfg = cfg.structure
if ctx.has_open_position: if ctx.has_open_position:
reasons.append("open position already exists") reasons.append("open position already exists")
@@ -118,7 +117,7 @@ def validate_entry(ctx: EntryContext, cfg: StrategyConfig) -> EntryDecision:
if ( if (
ctx.next_macro_event_in_days is not None ctx.next_macro_event_in_days is not None
and ctx.next_macro_event_in_days <= structure_cfg.dte_target and ctx.next_macro_event_in_days <= entry_cfg.exclude_macro_within_days
): ):
reasons.append( reasons.append(
f"macro event within DTE window ({ctx.next_macro_event_in_days} days)" f"macro event within DTE window ({ctx.next_macro_event_in_days} days)"
+1 -1
View File
@@ -191,7 +191,7 @@ async def _fetch_balances_async(*, timeout_s: float = 8.0) -> BalancesSnapshot:
client=http_client, client=http_client,
) )
deribit = DeribitClient(_client("deribit")) deribit = DeribitClient(_client("deribit"), _client("unified"))
hl = HyperliquidClient(_client("hyperliquid")) hl = HyperliquidClient(_client("hyperliquid"))
macro = MacroClient(_client("macro")) macro = MacroClient(_client("macro"))
+1 -1
View File
@@ -158,7 +158,7 @@ def build_runtime(
telegram=telegram, audit_log=audit_log, kill_switch=kill_switch telegram=telegram, audit_log=audit_log, kill_switch=kill_switch
) )
deribit = DeribitClient(_client("deribit")) deribit = DeribitClient(_client("deribit"), _client("unified"))
macro = MacroClient(_client("macro")) macro = MacroClient(_client("macro"))
sentiment = SentimentClient(_client("sentiment")) sentiment = SentimentClient(_client("sentiment"))
hyperliquid = HyperliquidClient(_client("hyperliquid")) hyperliquid = HyperliquidClient(_client("hyperliquid"))
+1 -1
View File
@@ -145,7 +145,7 @@ async def _gather_snapshot(
) )
macro_t: asyncio.Task[int | None] = asyncio.create_task( macro_t: asyncio.Task[int | None] = asyncio.create_task(
macro.next_high_severity_within( macro.next_high_severity_within(
days=cfg.structure.dte_target, days=cfg.entry.exclude_macro_within_days,
countries=list(cfg.entry.exclude_macro_countries), countries=list(cfg.entry.exclude_macro_countries),
now=now, now=now,
) )
@@ -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
+54 -4
View File
@@ -37,10 +37,15 @@ 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
from cerbero_bite.safety.audit_log import tail_continues_from
from cerbero_bite.state import connect as connect_state from cerbero_bite.state import connect as connect_state
from cerbero_bite.state import transaction
__all__ = ["Orchestrator"] __all__ = ["Orchestrator"]
@@ -50,10 +55,10 @@ _log = logging.getLogger("cerbero_bite.runtime.orchestrator")
Environment = Literal["testnet", "mainnet"] Environment = Literal["testnet", "mainnet"]
# Default cron schedule (matches docs/06-operational-flow.md table). # Default cron schedule (matches docs/06-operational-flow.md table).
_CRON_ENTRY = "0 14 * * *" # crypto 24/7: candidatura giornaliera; i gate decidono se entrare _CRON_ENTRY = "0 */2 * * *" # crypto 24/7: valutazione ogni 2h; i gate decidono se entrare
_CRON_MONITOR = "0 2,14 * * *" _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
@@ -158,7 +163,30 @@ class Orchestrator:
if state is None or state.last_audit_hash is None: if state is None or state.last_audit_hash is None:
return # first boot, nothing to compare against return # first boot, nothing to compare against
actual_tail = self._ctx.audit_log.last_hash actual_tail = self._ctx.audit_log.last_hash
if actual_tail != state.last_audit_hash: if actual_tail == state.last_audit_hash:
return
# The anchor is persisted best-effort (see build_runtime): under
# SQLite write contention the mirror can fall behind the log while
# the log itself keeps growing forward, intact. Treat that benign
# lag — anchor is a valid ancestor of the current tail — as a
# re-sync, not tampering. Only a missing anchor or a broken
# post-anchor chain (truncation/tampering) arms the kill switch.
if tail_continues_from(self._ctx.audit_log.path, state.last_audit_hash):
conn = connect_state(self._ctx.db_path)
try:
with transaction(conn):
self._ctx.repository.set_last_audit_hash(
conn, hex_hash=actual_tail
)
finally:
conn.close()
_log.warning(
"audit anchor lagged behind tail; re-synced "
"(anchor=%s…, tail=%s…)",
state.last_audit_hash[:12],
actual_tail[:12],
)
return
await self._ctx.alert_manager.critical( await self._ctx.alert_manager.critical(
source="orchestrator.boot", source="orchestrator.boot",
message=( message=(
@@ -295,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),
@@ -329,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="
+50
View File
@@ -28,6 +28,7 @@ __all__ = [
"AuditChainError", "AuditChainError",
"AuditEntry", "AuditEntry",
"AuditLog", "AuditLog",
"tail_continues_from",
"verify_chain", "verify_chain",
] ]
@@ -157,6 +158,55 @@ def verify_chain(path: str | Path) -> int:
return count return count
def tail_continues_from(path: str | Path, anchor_hash: str) -> bool:
"""Return ``True`` when *anchor_hash* is a genuine ancestor of the tail.
That is: *anchor_hash* is the ``hash`` of some line in the log AND every
line after it forms a valid forward chain to EOF. This distinguishes a
**benign anchor lag** — the best-effort anchor persisted in
:func:`cerbero_bite.runtime.dependencies.build_runtime` fell behind the
file under SQLite write contention, yet the log grew forward
legitimately — from real **truncation/tampering** (anchor absent, or the
post-anchor chain broken).
Returns ``False`` if the file is missing/empty, the anchor is not found,
or the chain from the anchor to the tail does not verify. The single-
writer invariant of :class:`AuditLog` still holds; this only makes the
boot-time anchor check tolerant of the durability gap it documents.
"""
p = Path(path)
if not p.exists() or p.stat().st_size == 0:
return False
seen = False
expected_prev = ""
with p.open("r", encoding="utf-8") as fh:
for line in fh:
if not line.strip():
continue
try:
entry = _parse_line(line)
except AuditChainError:
if seen:
return False
continue
if seen:
if entry.prev_hash != expected_prev:
return False
recomputed = _compute_hash(
entry.timestamp.isoformat(),
entry.event,
_canonical_payload(entry.payload),
entry.prev_hash,
)
if recomputed != entry.hash:
return False
expected_prev = entry.hash
elif entry.hash == anchor_hash:
seen = True
expected_prev = entry.hash
return seen
def iter_entries(path: str | Path) -> Iterator[AuditEntry]: def iter_entries(path: str | Path) -> Iterator[AuditEntry]:
"""Yield each :class:`AuditEntry` from *path* without verifying.""" """Yield each :class:`AuditEntry` from *path* without verifying."""
p = Path(path) p = Path(path)
@@ -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
+49 -15
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.4.0" config_version: "1.7.1"
config_hash: "22182814216190331e0b69b3bc99493e6d69cc813f7ed937394986eecc1f5d11" config_hash: "720a202e9dde4ccff7f79a8bc9521de62dc30115f11f486c55ad6f3986858dcb"
last_review: "2026-04-26" last_review: "2026-06-09"
last_reviewer: "Adriano" last_reviewer: "Adriano"
asset: asset:
@@ -16,7 +16,7 @@ asset:
exchange: "deribit" exchange: "deribit"
entry: entry:
cron: "0 14 * * *" cron: "0 */2 * * *"
skip_holidays_country: "IT" skip_holidays_country: "IT"
capital_min_usd: "720" capital_min_usd: "720"
@@ -27,12 +27,15 @@ entry:
no_position_concurrent: true no_position_concurrent: true
exclude_macro_severity: ["high"] exclude_macro_severity: ["high"]
exclude_macro_countries: ["US", "EU"] exclude_macro_countries: ["US", "EU"]
# Finestra evento macro ridotta a 1 giorno: blocca l'entry solo se un
# evento high-severity cade entro 24h, invece dei 18gg (dte_target).
exclude_macro_within_days: 1
trend_window_days: 30 trend_window_days: 30
trend_bull_threshold_pct: "0.05" trend_bull_threshold_pct: "0.05"
trend_bear_threshold_pct: "-0.05" trend_bear_threshold_pct: "-0.05"
funding_bull_threshold_annualized: "0.20" funding_bull_threshold_annualized: "0.10"
funding_bear_threshold_annualized: "-0.20" funding_bear_threshold_annualized: "-0.10"
iron_condor_dvol_min: "55" iron_condor_dvol_min: "55"
iron_condor_adx_max: "20" iron_condor_adx_max: "20"
iron_condor_trend_neutral_band_pct: "0.05" iron_condor_trend_neutral_band_pct: "0.05"
@@ -43,11 +46,33 @@ entry:
# per vendere credit spread. Soglia conservativa, da rifinire dopo # per vendere credit spread. Soglia conservativa, da rifinire dopo
# paper trading. # paper trading.
dealer_gamma_min: "0" dealer_gamma_min: "0"
dealer_gamma_filter_enabled: true dealer_gamma_filter_enabled: false
liquidation_filter_enabled: true liquidation_filter_enabled: true
# IV richness gate (§2.9). Disabilitato di default. # IV richness gate (§2.9). Disabilitato di default.
iv_minus_rv_min: "0" iv_minus_rv_min: "0"
iv_minus_rv_filter_enabled: false iv_minus_rv_filter_enabled: true
# IV richness gate adattivo — soglia P25 rolling su 60 giorni
iv_minus_rv_adaptive_enabled: true
iv_minus_rv_percentile: "0.25"
iv_minus_rv_window_target_days: 60
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:
@@ -55,19 +80,28 @@ structure:
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
@@ -107,7 +141,7 @@ exit:
# atomica. Pipeline runtime non ancora attiva (hook futuro). # atomica. Pipeline runtime non ancora attiva (hook futuro).
profit_take_partial_levels: [] profit_take_partial_levels: []
monitor_cron: "0 2,14 * * *" monitor_cron: "0 * * * *"
user_confirmation_timeout_min: 30 user_confirmation_timeout_min: 30
escalate_on_timeout: escalate_on_timeout: