Compare commits
11 Commits
16d73c309a
..
main
| Author | SHA1 | Date | |
|---|---|---|---|
| a80c17f2bc | |||
| 097e764f6a | |||
| 8a1fb823dc | |||
| 4d7b02b6cb | |||
| 1a46abd5cf | |||
| 1eeb53650e | |||
| 589d003e5c | |||
| ce475da173 | |||
| 647e3e565f | |||
| a5a39a6d27 | |||
| 839285d75b |
+4
-1
@@ -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
@@ -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
@@ -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.
|
||||||
|
|||||||
@@ -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 |
|
||||||
|
|
||||||
|
|||||||
@@ -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
@@ -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 ===
|
||||||
|
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -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]:
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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)"
|
||||||
|
|||||||
@@ -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"))
|
||||||
|
|
||||||
|
|||||||
@@ -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"))
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -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,16 +163,39 @@ 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:
|
||||||
await self._ctx.alert_manager.critical(
|
return
|
||||||
source="orchestrator.boot",
|
# The anchor is persisted best-effort (see build_runtime): under
|
||||||
message=(
|
# SQLite write contention the mirror can fall behind the log while
|
||||||
f"audit log anchor mismatch: anchor="
|
# the log itself keeps growing forward, intact. Treat that benign
|
||||||
f"{state.last_audit_hash[:12]}…, file tail="
|
# lag — anchor is a valid ancestor of the current tail — as a
|
||||||
f"{actual_tail[:12]}… — possible tampering or truncation"
|
# re-sync, not tampering. Only a missing anchor or a broken
|
||||||
),
|
# post-anchor chain (truncation/tampering) arms the kill switch.
|
||||||
component="safety.audit_log",
|
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(
|
||||||
|
source="orchestrator.boot",
|
||||||
|
message=(
|
||||||
|
f"audit log anchor mismatch: anchor="
|
||||||
|
f"{state.last_audit_hash[:12]}…, file tail="
|
||||||
|
f"{actual_tail[:12]}… — possible tampering or truncation"
|
||||||
|
),
|
||||||
|
component="safety.audit_log",
|
||||||
|
)
|
||||||
|
|
||||||
async def run_entry(
|
async def run_entry(
|
||||||
self, *, now: datetime | None = None
|
self, *, now: datetime | None = None
|
||||||
@@ -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="
|
||||||
|
|||||||
@@ -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;
|
||||||
@@ -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):
|
||||||
|
|||||||
@@ -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
@@ -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
@@ -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:
|
||||||
|
|||||||
Reference in New Issue
Block a user