Compare commits
5 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| e9a4538054 | |||
| 444b804415 | |||
| d55eb13533 | |||
| 8c18e82f1a | |||
| 7d64dd4c2b |
@@ -76,6 +76,8 @@ data/paper_xsr/
|
|||||||
data/paper_dvolspread/
|
data/paper_dvolspread/
|
||||||
# stato della sorveglianza fee (ultima lettura del tier Deribit, per rilevarne i cambiamenti)
|
# stato della sorveglianza fee (ultima lettura del tier Deribit, per rilevarne i cambiamenti)
|
||||||
data/fee_watch/
|
data/fee_watch/
|
||||||
|
# battuta di cuore del collettore catena (stato runtime, come gli altri monitor)
|
||||||
|
data/chain_collect/
|
||||||
|
|
||||||
# log esecuzioni del book live (stato runtime, contiene fill/fee del conto reale)
|
# log esecuzioni del book live (stato runtime, contiene fill/fee del conto reale)
|
||||||
data/live/
|
data/live/
|
||||||
|
|||||||
@@ -151,6 +151,33 @@ Prima ondata di ricerca onesta su BTC/ETH certificati (5 track, harness condivis
|
|||||||
nuova regola: riprezzare term-structure-consistent prima di credere a un gate vol su strutture
|
nuova regola: riprezzare term-structure-consistent prima di credere a un gate vol su strutture
|
||||||
BS-flat. Book/pesi INVARIATI. Diario `2026-07-03-vrp-improve-dd.md`; script `scripts/research/r0703_vrpimp_*.py` (7 file).
|
BS-flat. Book/pesi INVARIATI. Diario `2026-07-03-vrp-improve-dd.md`; script `scripts/research/r0703_vrpimp_*.py` (7 file).
|
||||||
Diario `2026-06-20-financeold-analysis-vrp-v2.md`.
|
Diario `2026-06-20-financeold-analysis-vrp-v2.md`.
|
||||||
|
⚠️ **f MISURATO SULLE QUOTE REALI = 0.73, NON 1.0 (2026-07-30) — i numeri di ammissione vanno
|
||||||
|
citati col f.** Primo confronto del prezzatore del sleeve con la catena Deribit mainnet vera
|
||||||
|
(`scripts/research/r0730_vrp_real_quotes.py`, dati cerbero-bite). Su 8/8 scadenze settimanali con
|
||||||
|
ENTRAMBE le gambe quotate (delta realizzati −0.270/−0.099 contro target −0.280/−0.100), agli
|
||||||
|
STESSI strike: **f del credito NETTO 0.73** (IC95% bootstrap [0.698, 0.780], **0/15 osservazioni
|
||||||
|
≥1.0**; 0.80 a mid). **Meccanismo, non rumore:** IV(corta)−DVOL **+0.8pp** ma IV(lunga)−DVOL
|
||||||
|
**+7.5pp** → il modello prezza a vol ATM anche l'ala che si COMPRA, che costa **~2.3× il modello**
|
||||||
|
→ il credito netto si comprime del 27%. **Il difetto non è nel premio incassato ma nella
|
||||||
|
protezione comprata**, cioè proprio il "(a) defined-risk" per cui v2 fu promosso.
|
||||||
|
**Conseguenza standalone (solo f cambiato):** f=1.00 → FULL 1.08 / HOLD **+0.58**; f=0.80 → 0.51 /
|
||||||
|
**−0.02**; f=0.73 → 0.31 / **−0.23**. **Book 5-sleeve** (VRP01 12%, ancore canoniche): FULL
|
||||||
|
2.215→2.146 (Δ−0.069), HOLD 2.347→2.244 (Δ−0.103), DD invariato → **dentro la banda d'ancora**,
|
||||||
|
ma il LOO del 26/07 dava a VRP01 +0.122 di FULL: **~metà era il prezzo che il modello si faceva da
|
||||||
|
solo** (l'audit d'ancora corregge la fortuna di calendario, non l'ottimismo di prezzo).
|
||||||
|
⚠️ **La calibrazione del 20/06 NON è contraddetta, è replicata:** misurava la **sola gamba corta**
|
||||||
|
(qui 1.02, implementazione indipendente). **REGOLA: il f di una struttura multi-gamba non è il f
|
||||||
|
di una sua gamba — misurare la sola gamba venduta dà la risposta sbagliata con segno
|
||||||
|
rassicurante.** Congelata in `tests/test_cb_chain_vrp.py`.
|
||||||
|
**`VRP_CFG["f"]` NON cambiato** (15 osservazioni, 7 settimane, e per giunta **0/8 settimane
|
||||||
|
passano il gate IV-rank>0.30**: il f è misurato nel regime in cui il sleeve sta FLAT, DVOL max
|
||||||
|
raccolto al 24°/28° pctl della storia) → **caveat quantificato, non nuovo parametro**; il criterio
|
||||||
|
del 19/06 ("rivalutare quando cerbero-bite cattura un crash") è **intatto**.
|
||||||
|
⚠️ **Il DD 12% è un DD di CHIUSURE settimanali:** con le standing quotes (~164 riquotazioni/trade)
|
||||||
|
il peggior mark INFRA-settimana è mediana −6.9% / minimo **−81.7%** del capitale su un trade finito
|
||||||
|
in utile, e il 50%-profit-take sarebbe scattato in **13/15** settimane. Stessa classe della lente
|
||||||
|
wick accoppiata (25/07): *un rischio valutato sul minimo non si misura sulle chiusure*.
|
||||||
|
Diario `2026-07-30-vrp-quote-reali.md`.
|
||||||
- **Universo Hyperliquid: ESPANDERLO NON aiuta XS01** (provato): 52-asset / top-liquidità dinamico /
|
- **Universo Hyperliquid: ESPANDERLO NON aiuta XS01** (provato): 52-asset / top-liquidità dinamico /
|
||||||
trend-multi-asset → tutti peggiori (small-cap/memecoin diluiscono il momentum relativo; il trend
|
trend-multi-asset → tutti peggiori (small-cap/memecoin diluiscono il momentum relativo; il trend
|
||||||
multi-asset è ridondante con TP01, corr 0.74). I margini su XS sono nella STRUTTURA DEL SEGNALE
|
multi-asset è ridondante con TP01, corr 0.74). I margini su XS sono nella STRUTTURA DEL SEGNALE
|
||||||
@@ -711,6 +738,51 @@ Prima ondata di ricerca onesta su BTC/ETH certificati (5 track, harness condivis
|
|||||||
**Scelta dichiarata: ALLERTA, NON blocca** — bloccare fermerebbe anche TP01 (nettato sullo stesso
|
**Scelta dichiarata: ALLERTA, NON blocca** — bloccare fermerebbe anche TP01 (nettato sullo stesso
|
||||||
strumento) per un guasto di rete, e forzare SKH flat chiuderebbe posizioni buone su un glitch.
|
strumento) per un guasto di rete, e forzare SKH flat chiuderebbe posizioni buone su un glitch.
|
||||||
Test `tests/test_skh_feed_freshness.py` (11 casi). Strategia/pesi/cadenza INVARIATI.
|
Test `tests/test_skh_feed_freshness.py` (11 casi). Strategia/pesi/cadenza INVARIATI.
|
||||||
|
⚠️ **SEGUITO 2026-07-29 — l'allerta ha funzionato, la sua CAUSA era una riga CABLATA.** Diario
|
||||||
|
`2026-07-29-feed-skh-causa.md`; test 11 → **16**. **Book/pesi/config/strategia INVARIATI.**
|
||||||
|
Il 29/07 (dopo il riavvio VPS delle 04:11) il feed e' ricaduto sul certificato in **6 giri orari
|
||||||
|
su 8** fra le 05:00 e le 12:00: eta' **265→685 min** (+60 a ogni giro = firma esatta del fallback)
|
||||||
|
→ latenza d'uscita SKH01 da ~1h a **~11h**; book flat, nessuna posizione esposta. L'allerta ha
|
||||||
|
segnalato 6/6 — poi ha stampato un perche' che **non aveva misurato**: la nota *"fetch pubblico
|
||||||
|
KO"* era cablata, identica in ogni caso, **compreso quello in cui la coda fresca E' attaccata e il
|
||||||
|
vecchio e' il certificato stesso** (= feed-freeze 14/07 in altra veste). E la causa vera non era
|
||||||
|
recuperabile a posteriori **per costruzione**: `_fetch_recent_5m` ingoia l'eccezione di pagina con
|
||||||
|
un `break` e a prima pagina fallita ritorna un frame vuoto, indistinguibile da "il venue non ha
|
||||||
|
barre"; alle 12:14, a mano, `fresh_5m` rispondeva in 1.7s senza errori.
|
||||||
|
✅ **Cablato:** `livefeed.last_fetch_error()` (+`_note_error`, registra E logga nel punto in cui
|
||||||
|
l'errore viene ingoiato) → `book_report.skh_feed_errors` (per asset) → allerta con la causa; e
|
||||||
|
stesso buco chiuso sul ramo gemello **"conto offline"**, che aveva gia' la ragione in `mark_src`
|
||||||
|
e non la stampava mai. Tre guasti ora distinti: eccezione / risposta vuota / **certificato
|
||||||
|
vecchio con coda attaccata**. Blindati anche il caso a **meta' paginazione** (coda parziale
|
||||||
|
attaccata: la paginazione va in avanti → mancano le barre PIU' RECENTI) e la non-sopravvivenza
|
||||||
|
della causa a una chiamata riuscita.
|
||||||
|
⚠️ **La causa del 29/07 resta IGNOTA, e va citata cosi'.** Solo circostanziale: giri falliti
|
||||||
|
lunghi quanto i riusciti (**16-46s → errore immediato, non timeout**); finestra aperta col riavvio
|
||||||
|
ma non chiusa da esso (11:00 ok, 12:00 no); stesso giorno il percorso del **conto** (rete diversa
|
||||||
|
via `cerbero-mcp`, stesso venue a valle) dava `ReadTimeout/404/502`; i due percorsi falliscono **in
|
||||||
|
alternanza**, non insieme. Una prima stesura scriveva nei commenti "*rate limit Deribit per-IP
|
||||||
|
saturato da un altro progetto sulla stessa VPS*" **come fatto**: rimossa — sarebbe stato lo stesso
|
||||||
|
difetto che stavo correggendo, scritto meglio. **Cron spostato `0 * * * *` → `7 * * * *`**
|
||||||
|
(ipotesi contesa al minuto tondo): ripiego da **UNA** osservazione, costo zero, **dichiarato tale
|
||||||
|
in testa a `cron_book.sh`** perche' la riga di crontab vive fuori dal repo.
|
||||||
|
✅ **SEGUITO 2026-07-30 — il MECCANISMO ipotizzato e poi rimosso e' ora MISURATO; il "perche'
|
||||||
|
adesso" NO.** Analizzando `/opt/docker/cerbero-bite` (stessa VPS, stesso IP pubblico): il suo
|
||||||
|
collettore full-chain gira **al minuto :00** e produce **12.186 risposte 429** in 26 ore, di cui
|
||||||
|
**11.700 (96%) nel minuto :00** — ~770 chiamate ticker + ~770 orderbook in ~26s da un IP solo,
|
||||||
|
senza backoff → **si auto-satura** il rate limit Deribit per-IP (ticker: 5.738 respinte su
|
||||||
|
20.029). Co-timing **esatto** con l'incidente: le sue quote passano da ~0.4% a ~50% vuote alle
|
||||||
|
**05:00 del 29/07** e non sono ancora rientrate. ⚠️ **Ma la stessa disciplina vale due volte: il
|
||||||
|
carico di bite e' INVARIATO da settimane** (6.100-6.400 righe BTC/giorno prima e dopo, immagine
|
||||||
|
ferma al 09/06) e i log MCP non precedono il riavvio → le 429 spiegano **come** si perdono le
|
||||||
|
chiamate, **non perche' proprio quel giorno**. La causa del cambiamento resta ignota. Nessuna
|
||||||
|
azione: `cron_book` e' gia' a `:07`, fuori dalla finestra di ~26s. Diario `2026-07-30-vrp-quote-reali.md`.
|
||||||
|
**REGOLE:** (a) **una nota di diagnosi cablata e' peggio di nessuna nota** — nessuna manda a
|
||||||
|
guardare i dati, una sbagliata manda sulla pista sbagliata e sembra una misura; (b) se un errore
|
||||||
|
si ingoia per non bloccare, **si registra nel punto in cui lo si ingoia** (non c'e' un secondo
|
||||||
|
momento buono: quando la diagnosi serve, il guasto e' rientrato); (c) un'allerta risponde a **due**
|
||||||
|
domande — *cosa* (decide) e *perche'* (ripara): misurare solo la prima costa un'intera occorrenza
|
||||||
|
del guasto; (d) distinguere guasti diversi **anche quando l'azione e' la stessa** (tre cause → tre
|
||||||
|
riparazioni); (e) una mitigazione da un'osservazione sola si applica pure, ma **si scrive che lo e'**.
|
||||||
✅ **FOLLOW-UP CHIUSO 2026-07-26 — la misura dedicata sugli INGRESSI e' fatta: NON e' un difetto,
|
✅ **FOLLOW-UP CHIUSO 2026-07-26 — la misura dedicata sugli INGRESSI e' fatta: NON e' un difetto,
|
||||||
il verso e' LASCIARLO.** `scripts/research/r0726_skh_partial_entry.py`, test
|
il verso e' LASCIARLO.** `scripts/research/r0726_skh_partial_entry.py`, test
|
||||||
`tests/test_skh_partial_entry.py` (10), diario `2026-07-26-skh-partial-entry.md`.
|
`tests/test_skh_partial_entry.py` (10), diario `2026-07-26-skh-partial-entry.md`.
|
||||||
@@ -1595,6 +1667,69 @@ df = load_data("BTC", "1h") # OK. load_data("SOL", ...) -> FileNotFoundError (
|
|||||||
< 365g reali (es. **AXS 83% sintetico → fuori**), (3) gata i gap vol=0 interni. Universo certificato
|
< 365g reali (es. **AXS 83% sintetico → fuori**), (3) gata i gap vol=0 interni. Universo certificato
|
||||||
= **51** (era 52). I **19 major di XS01 hanno 0 backfill → invariati** (strategia live non toccata).
|
= **51** (era 52). I **19 major di XS01 hanno 0 backfill → invariati** (strategia live non toccata).
|
||||||
Verificato direttamente su cerbero MCP. Diario `2026-06-20-cerbero-backfill-fix.md`.
|
Verificato direttamente su cerbero MCP. Diario `2026-06-20-cerbero-backfill-fix.md`.
|
||||||
|
- **CATENA OPZIONI REALE — RACCOLTA PROPRIA dal 2026-07-30 (cerbero-bite ASSORBITO e dismesso).**
|
||||||
|
`/opt/docker/cerbero-bite` (progetto separato) accumulava dal 2026-06-09 la catena Deribit
|
||||||
|
**mainnet** BTC+ETH; **viene eliminato, e la raccolta è passata dentro PythagorasGoal**:
|
||||||
|
- **raccolta:** `scripts/live/collect_chain.py`, cron **`25 * * * *`** (`scripts/cron_chain.sh`) →
|
||||||
|
`data/raw/cb_chain/YYYY-MM-DD.parquet`. Entrambe le ali, scadenze ≤95g, OI≥100, ~570 strumenti
|
||||||
|
a giro, ~3 min. **Minuto :25 scelto, non arbitrario:** :00 era la raffica di bite, :07 è
|
||||||
|
`cron_book` (feed 5m di SKH01, la cosa che non deve trovare l'IP occupato).
|
||||||
|
- **archivio ereditato:** `scripts/analysis/import_cb_archive.py` (una-tantum) →
|
||||||
|
`cb_chain/bite_archive.parquet` (1.23M righe, 2026-05-01+) e `cb_market_snapshots.parquet`
|
||||||
|
(17.402 righe, 2026-03-26+: DVOL, RV30, funding perp e cross, **dealer net gamma**, gamma flip,
|
||||||
|
**rischio liquidazioni**, giorni all'evento macro — dati che il progetto non ha altrove).
|
||||||
|
Snapshot sqlite integrale in `/opt/docker/backups/manual/cerbero-bite-20260730/` (SHA256).
|
||||||
|
- **certificazione:** `scripts/analysis/certify_cb_chain.py`; **harness** `scripts/research/cblib.py`
|
||||||
|
(`load_chain()` unisce archivio + raccolta, dedup su `(ts, strumento)`).
|
||||||
|
- **battuta di cuore:** ogni giro scrive `data/chain_collect/runs.jsonl`, sorvegliato da
|
||||||
|
`monitor_health` (cadenza 1h, `max_age_h=3`) — **un collettore fermo non produce niente, e il
|
||||||
|
niente si legge come "nessun dato quel giorno"**.
|
||||||
|
- **BACKUP (aggiunto 2026-07-30):** `data/raw/` è gitignored → la catena **non è in git**, e il
|
||||||
|
backup rotativo della VPS non copriva PythagorasGoal. Aggiunta `do_pythagoras` a
|
||||||
|
`/opt/docker/scripts/backup.sh` (daily 04:00, retention 7/28/185 g): salva **solo il dato non
|
||||||
|
ricostruibile** — catena + contesto, `data/paper_*` e `data/chain_collect` (serie forward-only
|
||||||
|
che alimentano i gate pre-registrati: **non sono ricalcolabili**), `data/options_daily`,
|
||||||
|
`data/live`, `venue_watch`, `fee_watch`, `config/live.json`. ~28 MB. **Esclusi di proposito**
|
||||||
|
i ~110 MB ricostruibili (`rebuild_history.py`, `fetch_dvol.py`, `fetch_hyperliquid.py`,
|
||||||
|
`fetch_ib_equities.py`, cache). La funzione **fallisce rumorosamente** se la catena è assente o
|
||||||
|
vuota, e verifica il tar prodotto (controllo positivo provato in entrambi i versi).
|
||||||
|
⚠️ `/opt/docker/scripts` **non è un repo git**: quella modifica vive solo su disco.
|
||||||
|
- **Dopo un riavvio della VPS la raccolta riprende da sola** (cron di sistema `enabled`+`active`,
|
||||||
|
nessuna dipendenza da docker o da cerbero-mcp: API pubblica Deribit diretta). Verificato con
|
||||||
|
`env -i` che il giro funzioni nell'ambiente nudo di cron. Finestra scoperta: fino al `:25`
|
||||||
|
successivo, **senza recupero** — un'ora persa resta persa (il collettore non fa catch-up).
|
||||||
|
**TRE DIFETTI DI BITE NON REPLICATI** (misurati il 30/07): (a) **una chiamata per strumento** —
|
||||||
|
`get_order_book?depth=3` dà già quote+greche+IV+OI+book+underlying, bite ne faceva due con
|
||||||
|
rischio di disallineamento; (b) **pacing** (token bucket 4/s + backoff) invece della raffica —
|
||||||
|
il carico non è mai stato il problema (~570 chiamate/ora = 0.16/s **distribuite**), bite le
|
||||||
|
sparava in ~26s (~44/s) auto-saturandosi il rate limit per-IP; misurato sul nostro giro:
|
||||||
|
**574 chiamate, 0 risposte 429**; (c) **`quote_status` esplicito** in {`ok`, `no_quote`, `error`}
|
||||||
|
— "book vuoto" (fatto di mercato) e "chiamata fallita" (fatto di infrastruttura) sono cose
|
||||||
|
diverse, e `book_depth_top3` è **NULL su errore, mai 0**. ⚠️ Le righe ereditate da bite hanno
|
||||||
|
`quote_status='unknown'`: bite non registrava il perché, e si dichiara l'ignoranza invece di
|
||||||
|
inventare uno stato.
|
||||||
|
**NON assorbito, e perché:** il motore credit-spread ETH (il progetto ha già la regola "niente
|
||||||
|
short-vol da modello in deploy"), la GUI, kill switch/dead-man/audit (PythagorasGoal ha
|
||||||
|
`venue_watch`/`edge_watch`/`monitor_health`/`fee_watch`), `dvol_history` (`fetch_dvol.py` ha
|
||||||
|
storia **più lunga**: 2020+ contro 2026-05), `decisions`/`positions` (59 righe a capitale $52,
|
||||||
|
0 posizioni). Test `tests/test_collect_chain.py` (11) + `tests/test_cb_chain_vrp.py` (13).
|
||||||
|
**Perché si MEMORIZZA invece di interrogarla:** una catena opzioni non è ricostruibile a
|
||||||
|
posteriori — Deribit non serve book storici, un'ora non raccolta è persa per sempre, e non c'è un
|
||||||
|
secondo venue da cui recuperarla.
|
||||||
|
**Certifica 4 difetti:** quote vuote, book incrociato, premio non monotono nello strike,
|
||||||
|
`depth==0` (ambiguo **by design**: chiamata fallita e book vuoto danno lo stesso valore → si
|
||||||
|
riporta, non si ripara).
|
||||||
|
⚠️ **GUASTO IN CORSO dal 2026-07-29 05:00 UTC — status `QUOTE-VUOTE`.** Il collettore persiste la
|
||||||
|
riga anche quando il ticker fallisce (rate-limit Deribit **per-IP**: ~650 risposte 429 in ~26s a
|
||||||
|
ogni giro, **96% al minuto :00**, generate dalla spazzata full-chain che si auto-satura) →
|
||||||
|
`bid`/`ask`/`iv`/`delta` NULL e **conteggio righe INVARIATO** (13k/giorno prima e dopo). Tasso di
|
||||||
|
quote vuote per settimana: 0.4·0.7·2.2·1.5·0.5·0.4·0.3 → **22%**; giorno peggiore **51.7% BTC /
|
||||||
|
30.6% ETH**. ✅ **Risolto dal cambio di collettore** (30/07): la raccolta propria è paced e ha
|
||||||
|
fatto 574 chiamate con **0 risposte 429**. **REGOLA: una riga presente non è un dato presente** —
|
||||||
|
contare ciò che è QUOTATO, non ciò che è SCRITTO (3ª occorrenza dopo `paper_dvolspread` e
|
||||||
|
`fresh_5m`). **REGOLA: un guasto IN CORSO si misura al GIORNO PEGGIORE, non in media** — la prima
|
||||||
|
stesura confrontava "tutto" con "ultimi 7g" e diluiva un guasto di 2 giorni al 13.5%, commettendo
|
||||||
|
a 7 giorni lo stesso errore che dichiarava di evitare a 3 mesi. Diario `2026-07-30-vrp-quote-reali.md`.
|
||||||
|
|
||||||
## Metodologia obbligatoria per ogni nuova strategia
|
## Metodologia obbligatoria per ogni nuova strategia
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,142 @@
|
|||||||
|
# 2026-07-29 — L'allerta funzionava, la sua CAUSA era una riga cablata
|
||||||
|
|
||||||
|
**Fatto operativo del giorno.** Il 29/07 il feed 5m che alimenta il segnale SKH01 e' ricaduto sul
|
||||||
|
feed certificato in **6 giri orari su 8** fra le 05:00 e le 12:00 UTC. L'allerta cablata il 26/07 ha
|
||||||
|
segnalato **ogni volta** — ha fatto esattamente il suo lavoro. Poi ha stampato una causa che non
|
||||||
|
aveva misurato.
|
||||||
|
|
||||||
|
**Codice:** `src/live/livefeed.py` (`last_fetch_error`), `src/live/book.py` (`skh_feed_errors` nel
|
||||||
|
report), `scripts/live/book_execute.py` (allerta con la causa), `scripts/cron_book.sh` (nota sul
|
||||||
|
minuto). **Test:** `tests/test_skh_feed_freshness.py` (11 → **16**). **Book, pesi, config, strategia:
|
||||||
|
INVARIATI.** Nessuna posizione aperta durante l'incidente (book flat: TP01 risk-off, SKH01 flat).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. Cosa e' successo, misurato
|
||||||
|
|
||||||
|
Dal log `logs/cron_book.log` (la VPS si e' riavviata alle **04:11 UTC**, kernel 6.8.0-134 →
|
||||||
|
6.8.0-136; il giro delle 04:00 non e' partito perche' la macchina era gia' giu' dalle 03:55):
|
||||||
|
|
||||||
|
```
|
||||||
|
giro (UTC) eta' ultima barra 5m durata del giro
|
||||||
|
05:00 265 min 46 s
|
||||||
|
06:00 325 24
|
||||||
|
07:00 385 29
|
||||||
|
08:00 445 26
|
||||||
|
09:00 505 34
|
||||||
|
10:00 565 25
|
||||||
|
11:00 0 (fresco) 16 <- ma qui e' fallita la lettura del CONTO
|
||||||
|
12:00 685 25
|
||||||
|
12:07 0 (fresco) 10
|
||||||
|
```
|
||||||
|
|
||||||
|
L'eta' cresce di **60 minuti a ogni giro**: e' la firma esatta del fallback, cioe' l'ultima barra
|
||||||
|
resta quella del feed certificato (rebuild giornaliero delle 00:30) mentre l'orologio avanza. Al
|
||||||
|
picco la latenza d'uscita di SKH01 e' passata da **~1 ora a ~11 ore**.
|
||||||
|
|
||||||
|
## 2. Cosa ha funzionato e cosa no
|
||||||
|
|
||||||
|
**Ha funzionato:** l'instrumentazione del 26/07. `feed_age_minutes` → `skh_feed_age_min` → allerta
|
||||||
|
Telegram, 6 volte su 6, con la scelta dichiarata allora (**allerta, non blocca** — bloccare
|
||||||
|
fermerebbe anche il ribilancio di TP01, nettato sullo stesso strumento, per un guasto di rete).
|
||||||
|
|
||||||
|
**Non ha funzionato:** la riga che diceva *perche'*. Era questa, **cablata**:
|
||||||
|
|
||||||
|
```python
|
||||||
|
"nota": "fresh_5m e' ricaduto sul feed certificato (fetch pubblico KO)"
|
||||||
|
```
|
||||||
|
|
||||||
|
Non veniva da nessuna misura: veniva stampata identica in ogni caso, compreso quello in cui la coda
|
||||||
|
fresca **era** attaccata e il vecchio era il certificato stesso. E' una presunzione con l'aspetto di
|
||||||
|
un dato — la forma di difetto che questo progetto ha gia' pagato tre volte in docstring di
|
||||||
|
produzione (26/07, tre correzioni in due giorni).
|
||||||
|
|
||||||
|
Peggio: la causa vera **non era recuperabile a posteriori**, per costruzione. In
|
||||||
|
`_fetch_recent_5m` l'eccezione di pagina viene ingoiata con un `break` e la funzione ritorna quel
|
||||||
|
che ha raccolto; se a fallire e' la **prima** pagina il risultato e' un DataFrame vuoto, che a valle
|
||||||
|
e' indistinguibile da "il venue non ha barre". Quando il guasto e' rientrato, non resta niente da
|
||||||
|
interrogare — e infatti alle 12:14, provando a mano, `fresh_5m` rispondeva in 1.7 s senza errori.
|
||||||
|
|
||||||
|
## 3. La correzione: registrare la causa, non indovinarla
|
||||||
|
|
||||||
|
`livefeed._LAST_ERROR` + `last_fetch_error()`. Il fallback resta **silenzioso come comportamento**
|
||||||
|
(mai operare a cieco > mai operare, scelta del 26/07 confermata): quello che smette di essere
|
||||||
|
silenzioso e' il **perche'**. La causa risale fino al report (`skh_feed_errors`, per asset) e fino
|
||||||
|
all'allerta, che ora dice cosa e' successo invece di ipotizzarlo.
|
||||||
|
|
||||||
|
Tre guasti che prima erano la stessa riga, e ora sono tre righe diverse:
|
||||||
|
|
||||||
|
| caso | cosa si vede oggi |
|
||||||
|
|---|---|
|
||||||
|
| eccezione al fetch (rete, rate limit, venue) | `RuntimeError: 429 ... (pagina 1, BTC/USD:BTC)` |
|
||||||
|
| risposta senza barre, nessuna eccezione | `nessuna barra restituita da BTC/USD:BTC (risposta vuota...)` |
|
||||||
|
| **coda attaccata, ma il certificato e' vecchio** | `coda fresca attaccata: il feed certificato stesso e' vecchio` |
|
||||||
|
|
||||||
|
La terza riga e' quella che la nota cablata **negava**: e' il caso in cui il colpevole non e' la rete
|
||||||
|
ma il rebuild giornaliero, cioe' il feed-freeze del 14/07 in un'altra veste.
|
||||||
|
|
||||||
|
⚠️ Il caso intermedio, che un test pigro salta: l'errore a **meta' paginazione**. La coda parziale
|
||||||
|
viene attaccata comunque — quindi il feed non e' "caduto", e' solo piu' vecchio del dovuto — e
|
||||||
|
siccome la paginazione va **in avanti**, cio' che manca sono le barre **piu' recenti**. E' proprio
|
||||||
|
il caso in cui l'eta' da sola direbbe poco. Blindato in
|
||||||
|
`test_causa_registrata_anche_con_coda_TRONCATA`.
|
||||||
|
|
||||||
|
Blindato anche che la causa **non sopravviva a una chiamata riuscita**: un errore appiccicato
|
||||||
|
farebbe allertare su un guasto gia' rientrato, e un'allerta che grida senza motivo e' un'allerta che
|
||||||
|
si smette di leggere.
|
||||||
|
|
||||||
|
## 4. Sull'onestà della diagnosi: la causa del 29/07 resta IGNOTA
|
||||||
|
|
||||||
|
Ed e' importante scriverlo, perche' la tentazione era chiudere il cerchio con una spiegazione
|
||||||
|
plausibile. Cio' che si sa e' solo **circostanziale**:
|
||||||
|
|
||||||
|
- i giri falliti durano quanto quelli riusciti (**16-46 s in entrambi gli stati**) → errore
|
||||||
|
**immediato**, non timeout;
|
||||||
|
- la finestra si apre subito dopo il **riavvio delle 04:11**, ma non si chiude con esso (alle 11:00
|
||||||
|
funziona, alle 12:00 no);
|
||||||
|
- lo stesso giorno il percorso del **conto** — rete diversa, via `cerbero-mcp`, stesso venue a valle
|
||||||
|
— rispondeva `ReadTimeout(15s)` / `404` / `502`;
|
||||||
|
- i due percorsi falliscono **in alternanza**, non insieme (11:00 feed ok + conto ko; 12:00 conto ok
|
||||||
|
+ feed ko).
|
||||||
|
|
||||||
|
Ipotesi compatibili e **non distinguibili a posteriori**: rate limit per-IP del venue, contesa di
|
||||||
|
rete/CPU sulla VPS al minuto tondo, degrado post-riavvio. Una prima stesura di questa modifica
|
||||||
|
scriveva nei commenti "*la causa vera era il rate limit Deribit per-IP saturato da un altro progetto
|
||||||
|
sulla stessa VPS*" come se fosse un fatto: **rimossa**. Sarebbe stato lo stesso difetto della riga
|
||||||
|
che stavo correggendo, scritto meglio.
|
||||||
|
|
||||||
|
## 5. Il ripiego sul cron, dichiarato come tale
|
||||||
|
|
||||||
|
Il job e' stato spostato da `0 * * * *` a **`7 * * * *`** durante l'incidente (ultimo giro al minuto
|
||||||
|
tondo: 12:00; primo al :07: 12:07, riuscito). Motivo: l'ipotesi della contesa al minuto tondo, che e'
|
||||||
|
quando parte tutto il resto della macchina.
|
||||||
|
|
||||||
|
⚠️ **Non e' un fix verificato: e' un ripiego da UNA osservazione**, preso perche' costa zero. Un
|
||||||
|
giro riuscito al :07 contro sei falliti al :00 non e' un esperimento — e' un punto. Se il feed torna
|
||||||
|
stantio anche al :07, l'ipotesi e' morta; e in entrambi i casi la risposta la dara' `skh_feed_errors`
|
||||||
|
alla prossima occorrenza, non un altro ragionamento. La nota sta in testa a `scripts/cron_book.sh`,
|
||||||
|
perche' la riga di crontab vive fuori dal repo e una mitigazione invisibile e' una mitigazione che
|
||||||
|
verra' rimossa per sbaglio.
|
||||||
|
|
||||||
|
## 6. Sottoprodotto: anche "conto offline" diceva solo che era offline
|
||||||
|
|
||||||
|
Stesso buco, altro ramo. Quando `online` e' falso la ragione c'e' gia' — sta in `mark_src`
|
||||||
|
(`"fallback close (<Eccezione>)"`) — ma non veniva **mai** stampata: l'allerta diceva *"conto
|
||||||
|
offline, salto l'esecuzione"*, che e' vero e inutile. Ora l'allerta porta `mark_src` per asset. Il
|
||||||
|
comportamento (fermarsi, non operare a cieco) e' invariato.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Regole
|
||||||
|
|
||||||
|
1. **Una nota di diagnosi cablata e' peggio di nessuna nota.** Nessuna nota manda a guardare i dati;
|
||||||
|
una nota sbagliata manda a guardare la pista sbagliata, e sembra una misura.
|
||||||
|
2. **Se un errore viene ingoiato per non bloccare, va registrato nello stesso punto in cui lo si
|
||||||
|
ingoia.** Fra il `break` e la fine dell'incidente non c'e' un secondo momento buono: quando la
|
||||||
|
diagnosi diventa urgente, il guasto e' gia' rientrato.
|
||||||
|
3. **Un'allerta risponde a due domande diverse — *cosa* e *perche'*.** La prima decide (si opera o
|
||||||
|
no), la seconda ripara. Misurare solo la prima costa un'intera occorrenza del guasto.
|
||||||
|
4. **Una mitigazione basata su un'osservazione sola si applica pure, ma si scrive che lo e'** —
|
||||||
|
altrimenti fra tre mesi e' una scelta di progetto di cui nessuno ricorda la fragilita'.
|
||||||
|
5. **Distinguere i guasti anche quando l'azione e' la stessa.** "Rete ko", "venue senza barre" e
|
||||||
|
"certificato vecchio" portano tutti a "non mi fido del segnale", ma a tre riparazioni diverse.
|
||||||
@@ -0,0 +1,122 @@
|
|||||||
|
# 2026-07-30 (2º filone) — cerbero-bite viene eliminato: assorbita la raccolta, non il motore
|
||||||
|
|
||||||
|
**Decisione dell'operatore:** `/opt/docker/cerbero-bite` viene smantellato e il suo lavoro passa a
|
||||||
|
PythagorasGoal. **Esito:** book, pesi, config e strategia INVARIATI. Cambia solo *chi* raccoglie
|
||||||
|
la catena opzioni — e cambia in meglio su tre assi misurati.
|
||||||
|
|
||||||
|
## Cosa di bite valeva la pena, e cosa no
|
||||||
|
|
||||||
|
L'unica parte **irreversibile** è il dato: una catena opzioni non si ricostruisce a posteriori
|
||||||
|
(Deribit non serve book storici, non esiste un secondo venue). Tutto il resto è codice, e il codice
|
||||||
|
si riscrive.
|
||||||
|
|
||||||
|
| parte | assorbita? | perché |
|
||||||
|
|---|---|---|
|
||||||
|
| `option_chain_snapshots` (1.23M righe) | **sì** | ha appena falsificato il *f* di VRP01; irrecuperabile |
|
||||||
|
| `market_snapshots` (17.402 righe, 26/03+) | **sì** | dealer net gamma, gamma flip, rischio liquidazioni, funding cross: dati che il progetto non ha altrove |
|
||||||
|
| raccolta continua | **sì, riscritta** | senza, l'archivio si congela e "aspettare il crash" muore |
|
||||||
|
| `dvol_history` | no | `fetch_dvol.py` ha storia **più lunga** (2020+ vs 2026-05): copiarla sarebbe una seconda copia peggiore |
|
||||||
|
| motore credit-spread ETH | no | il progetto ha già la regola *niente short-vol da modello in deploy*; e il conto era a $52 contro un minimo di $720 |
|
||||||
|
| GUI, kill switch, dead-man, audit chain | no | PythagorasGoal ha già `venue_watch`, `edge_watch`, `monitor_health`, `fee_watch` |
|
||||||
|
| `decisions` / `positions` | no | 59 valutazioni d'ingresso, 0 posizioni |
|
||||||
|
|
||||||
|
Prima di toccare qualsiasi cosa: snapshot `VACUUM INTO` del DB (461 MB) + `audit.log` + config in
|
||||||
|
`/opt/docker/backups/manual/cerbero-bite-20260730/`, con SHA256.
|
||||||
|
|
||||||
|
## Il collettore nuovo — tre difetti di bite non replicati
|
||||||
|
|
||||||
|
`scripts/live/collect_chain.py`, cron **`25 * * * *`**.
|
||||||
|
|
||||||
|
**1. Una chiamata per strumento invece di due.** `public/get_order_book?depth=3` restituisce già
|
||||||
|
quote, greche, IV, open interest, volume, book **e** `underlying_price`. Bite chiamava ticker e
|
||||||
|
orderbook separatamente: doppio costo, e i due potevano riferirsi a istanti diversi.
|
||||||
|
Un `get_book_summary_by_currency` iniziale dà l'OI di tutta la catena in **una** chiamata e
|
||||||
|
prefiltra sotto soglia: 551 → **~290 chiamate per asset**.
|
||||||
|
|
||||||
|
**2. Pacing invece di raffica.** ⚠️ **Il carico non è mai stato il problema.** ~570 chiamate/ora
|
||||||
|
sono **0.16/s** se distribuite; bite le sparava in ~26 secondi (**~44/s**) e si auto-saturava il
|
||||||
|
rate limit per-IP — 12.186 risposte 429 in 26 ore, il 96% nel minuto `:00`. Token bucket a 4/s +
|
||||||
|
backoff: primo giro reale **574 chiamate, 0 risposte 429**.
|
||||||
|
|
||||||
|
Il minuto `:25` è una scelta, non un default: `:00` era la raffica di bite, `:07` è `cron_book`
|
||||||
|
(da lì passa il feed 5m di SKH01, la cosa che non deve trovare l'IP occupato).
|
||||||
|
|
||||||
|
**3. Stato esplicito della quota.** `quote_status` ∈ {`ok`, `no_quote`, `error`}:
|
||||||
|
- `no_quote` = il venue ha risposto, il book è vuoto → **fatto di mercato**
|
||||||
|
- `error` = la chiamata è fallita → **fatto di infrastruttura**
|
||||||
|
|
||||||
|
Bite li faceva collassare entrambi su "riga con bid/ask NULL", ed è per questo che il guasto del
|
||||||
|
29/07 (50% di quote perse per 38 ore) non ha prodotto **nessun** segnale: il conteggio righe non
|
||||||
|
cambiava. Per lo stesso motivo `book_depth_top3` ora è **NULL su errore, mai 0** — bite scriveva 0,
|
||||||
|
e "chiamata fallita" diventava indistinguibile da "book vuoto".
|
||||||
|
|
||||||
|
⚠️ Le righe ereditate portano `quote_status='unknown'`. Bite non registrava il perché e **a
|
||||||
|
posteriori non è ricostruibile**: si dichiara l'ignoranza invece di inventare uno stato.
|
||||||
|
|
||||||
|
## Il silenzio, di nuovo
|
||||||
|
|
||||||
|
Un collettore fermo non produce niente, e il niente si legge come "nessun dato quel giorno" invece
|
||||||
|
che come "raccolta rotta" — su una serie irrecuperabile è il modo più caro di sbagliare. Ogni giro
|
||||||
|
scrive una riga in `data/chain_collect/runs.jsonl`, **anche quando fallisce**, e
|
||||||
|
`monitor_health` la sorveglia con cadenza 1h e `max_age_h=3` (due giri persi). È la stessa lezione
|
||||||
|
di `paper_dvolspread` e di `fresh_5m`, applicata prima che serva invece che dopo.
|
||||||
|
|
||||||
|
## Un difetto trovato per strada: due formati ISO nella stessa colonna
|
||||||
|
|
||||||
|
L'import del contesto restituiva **17.310 righe dal 2026-05-01** contro le 17.402 dal **2026-03-26**
|
||||||
|
del database. 92 righe di backfill sono scritte senza microsecondi
|
||||||
|
(`2026-03-26T12:00:00+00:00`), le altre con (`2026-05-01T15:45:00.062918+00:00`).
|
||||||
|
`pd.to_datetime` senza `format` **inferisce un formato solo** dal primo elemento e manda gli altri
|
||||||
|
a `NaT`, che il `dropna` a valle rimuoveva **in silenzio**: la serie perdeva i suoi 5 settimane più
|
||||||
|
vecchi e la data d'inizio slittava di un mese senza un messaggio.
|
||||||
|
|
||||||
|
Corretto con `format="ISO8601"` **e** con uno scarto rumoroso (`_drop_unparsed` stampa quante righe
|
||||||
|
cadono e perché). **REGOLA: `dropna` dopo un parsing è un rilevatore di difetti travestito da
|
||||||
|
pulizia — se toglie righe deve dirlo.** Un import silenzioso non è un import pulito, è un import
|
||||||
|
che non sai se ha funzionato.
|
||||||
|
|
||||||
|
## Cosa resta da fare fuori dal repo
|
||||||
|
|
||||||
|
Il container di bite è **ancora acceso** mentre scrivo: la sovrapposizione fra le due raccolte è
|
||||||
|
voluta (nessun buco alla consegna). Quando verrà spento, l'unica cosa che si ferma è la raffica
|
||||||
|
al minuto `:00`.
|
||||||
|
|
||||||
|
## File
|
||||||
|
|
||||||
|
- `scripts/live/collect_chain.py` + `scripts/cron_chain.sh` — la raccolta (cron `25 * * * *`)
|
||||||
|
- `scripts/analysis/import_cb_archive.py` — import una-tantum dell'archivio
|
||||||
|
- `scripts/analysis/certify_cb_chain.py` — certificazione dello store (era `fetch_cb_chain.py`)
|
||||||
|
- `scripts/research/cblib.py` — `load_chain()` ora unisce archivio + raccolta
|
||||||
|
- `src/live/monitor_health.py` — nuovo spec `collect_chain`
|
||||||
|
- `tests/test_collect_chain.py` (11 casi)
|
||||||
|
|
||||||
|
## Addendum (stessa sera) — spegnimento, backup, ripresa dopo un riavvio
|
||||||
|
|
||||||
|
**Spegnimento.** Prima di fermare i container ho preso uno snapshot **finale** (bite aveva raccolto
|
||||||
|
fino alle 20:15, oltre il backup delle 19:55) e ho reimportato: archivio a **1.232.212 righe**.
|
||||||
|
Consegna verificata: ultima riga di bite 20:15:00, prima nostra 20:00:18 → **sovrapposizione, nessun
|
||||||
|
buco**. `docker compose stop` (non `down -v`: i volumi restano). Effetto misurato: **0 risposte 429**
|
||||||
|
su cerbero-mcp nei minuti successivi, contro ~650 per ogni giro orario.
|
||||||
|
|
||||||
|
**Backup.** `data/raw/` è gitignored e il backup rotativo della VPS non copriva PythagorasGoal: dopo
|
||||||
|
lo spegnimento di bite l'archivio esisteva in due copie **sullo stesso disco**. Aggiunta
|
||||||
|
`do_pythagoras` a `/opt/docker/scripts/backup.sh` con un criterio dichiarato: **si salva ciò che non
|
||||||
|
si può riscaricare**. Dentro: catena + contesto, `data/paper_*` e `data/chain_collect` (serie
|
||||||
|
forward-only che alimentano i gate pre-registrati — non ricalcolabili per costruzione),
|
||||||
|
`options_daily`, `live`, `venue_watch`, `fee_watch`, `config/live.json` → **28 MB**. Fuori: i ~110 MB
|
||||||
|
che si riscaricano dai venue.
|
||||||
|
|
||||||
|
Due guardie, entrambe provate **nei due versi**: la funzione fallisce se la catena è assente o
|
||||||
|
vuota (invece di produrre un archivio che sembra a posto), e verifica che il tar prodotto contenga
|
||||||
|
davvero la catena. Il caso negativo produce **0 file**. Per poterlo provare la radice è
|
||||||
|
sovrascrivibile via `PYG_ROOT` — *una guardia che non si riesce a far scattare non è distinguibile
|
||||||
|
da una rotta.*
|
||||||
|
|
||||||
|
⚠️ `/opt/docker/scripts` **non è un repo git**: quella modifica vive solo su disco, e questo diario
|
||||||
|
è l'unico posto in cui è scritta.
|
||||||
|
|
||||||
|
**Riavvio.** La raccolta riprende da sola: `cron` è `enabled` e `active`, e il collettore non
|
||||||
|
dipende da docker né da cerbero-mcp (API pubblica Deribit diretta) — il contrario di bite, che era un
|
||||||
|
container. Verificato girando `cron_chain.sh` con `env -i PATH=/usr/bin:/bin`: 574 chiamate, 0
|
||||||
|
errori, dato e battuta di cuore scritti. ⚠️ **Finestra scoperta: fino al `:25` successivo, senza
|
||||||
|
recupero.** Il collettore non fa catch-up e non potrebbe: le quote passate non sono richiedibili.
|
||||||
@@ -0,0 +1,157 @@
|
|||||||
|
# 2026-07-30 — VRP01 contro le quote REALI: il f del credito netto è 0.73, non 1.0
|
||||||
|
|
||||||
|
**Esito:** nessun cambio a book, pesi, cron o config. Un'ipotesi di VRP01 è **falsificata con
|
||||||
|
misura**, e il dataset che l'ha falsificata entra nel progetto (estrattore + certificazione +
|
||||||
|
harness + test). Il muro dichiarato per il deploy — il *f* di stress — resta in piedi, intatto.
|
||||||
|
|
||||||
|
## Da dove viene il dato
|
||||||
|
|
||||||
|
`/opt/docker/cerbero-bite` accumula dal 2026-06-09 la catena opzioni Deribit **mainnet**: entrambe
|
||||||
|
le ali, scadenze 1g→3 mesi, ora per ora, con la profondità top-3 del book. È la fonte che il
|
||||||
|
progetto cita dal 20/06 (`options_vrp_calibrate.py`) e che finora era stata usata una volta sola,
|
||||||
|
via export manuale in `/tmp`.
|
||||||
|
|
||||||
|
Il motivo per cui quel dato si **memorizza** invece di richiederlo quando serve: una catena opzioni
|
||||||
|
non è ricostruibile a posteriori — Deribit non serve book storici. Un'ora non raccolta è persa per
|
||||||
|
sempre. Stessa asimmetria dello storico spot certificato, con l'aggravante che qui non esiste un
|
||||||
|
secondo venue da cui recuperarla.
|
||||||
|
|
||||||
|
## La domanda
|
||||||
|
|
||||||
|
`src/portfolio/sleeves.py:136` — `VRP_CFG` gira a **`f=1.0`**, applicato al credito **NETTO**:
|
||||||
|
|
||||||
|
```python
|
||||||
|
net_prem = (_bs_put(S0, Ks, T, sig) - _bs_put(S0, Kl, T, sig)) * cfg["f"]
|
||||||
|
```
|
||||||
|
|
||||||
|
cioè assume che il mercato paghi esattamente ciò che Black-Scholes su DVOL-ATM dice, **per entrambe
|
||||||
|
le gambe**. Il caveat di ammissione ("premio MODELLATO su DVOL ATM, skew non esplicito") era
|
||||||
|
dichiarato dal 19/06 e mai quantificato, per mancanza di quote vere.
|
||||||
|
|
||||||
|
## D1 — copertura: la struttura c'è
|
||||||
|
|
||||||
|
Finestra settimanale 4-10 DTE: **8 scadenze su 8** hanno **entrambe** le gambe quotate, per
|
||||||
|
entrambi gli asset. Delta realizzati **−0.270** (target −0.280) e **−0.099** (target −0.100): la
|
||||||
|
struttura misurata è quella del sleeve, non una sua approssimazione.
|
||||||
|
|
||||||
|
## D2 — il numero: f = 0.73
|
||||||
|
|
||||||
|
Confronto agli **stessi strike** (così il rapporto isola l'errore di *prezzo*, non la scelta dello
|
||||||
|
strike), fill conservativo (vendi al bid, compri all'ask):
|
||||||
|
|
||||||
|
| grandezza | mediana | p25 | p75 |
|
||||||
|
|---|---|---|---|
|
||||||
|
| f gamba **corta** (venduta) | **1.02** | 0.88 | 1.12 |
|
||||||
|
| f gamba **lunga** (comprata) | **2.30** | 1.68 | 2.87 |
|
||||||
|
| **f credito NETTO** ← quello che entra nel book | **0.73** | 0.71 | 0.82 |
|
||||||
|
| f credito netto, a mid | 0.80 | 0.79 | 0.89 |
|
||||||
|
|
||||||
|
IC95% bootstrap sulla mediana **[0.698, 0.780]**; **0 osservazioni su 15** arrivano a 1.0.
|
||||||
|
|
||||||
|
⚠️ **La calibrazione del 20/06 non è contraddetta, è replicata**: misurava la **sola gamba corta** e
|
||||||
|
trovava f ≈ 1.0; qui la stessa quantità, con implementazione indipendente, dà **1.02**. La
|
||||||
|
differenza è che VRP01 è uno *spread*, e il problema sta nell'altra gamba.
|
||||||
|
|
||||||
|
## Il meccanismo (senza il quale il numero sarebbe un artefatto)
|
||||||
|
|
||||||
|
**IV(corta) − DVOL = +0.8 pp**, **IV(lunga) − DVOL = +7.5 pp**. Il modello prezza entrambe le gambe
|
||||||
|
a vol ATM; l'ala più OTM — quella che si **compra** — sta 8 punti sopra per skew, quindi costa
|
||||||
|
**~2.3× il modello**, e il credito netto si comprime del 27%.
|
||||||
|
|
||||||
|
Colpisce esattamente il punto su cui VRP01-v2 era stato promosso: *"(a) defined-risk taglia la coda
|
||||||
|
(worst-week −16.6%→−7.4%)"*. Nel modello quell'assicurazione è comprata a vol ATM, cioè quasi
|
||||||
|
gratis. Nel mercato costa più del doppio. **Il difetto non è nel premio incassato, è nella
|
||||||
|
protezione comprata.**
|
||||||
|
|
||||||
|
## Conseguenza, misurata
|
||||||
|
|
||||||
|
VRP01 standalone sostituendo **solo** `f` (replica sana a f=1.0: 1.08 / 0.58 / −11.8% contro i
|
||||||
|
1.10 / 0.60 / 12% pubblicati):
|
||||||
|
|
||||||
|
| f | Sh FULL | DD FULL | CAGR | Sh HOLD-OUT |
|
||||||
|
|---|---|---|---|---|
|
||||||
|
| **1.00** (assunto) | 1.08 | −11.8% | 8.2% | **+0.58** |
|
||||||
|
| **0.80** (misurato a mid) | 0.51 | −14.4% | 3.6% | **−0.02** |
|
||||||
|
| **0.73** (misurato, fill conservativo) | 0.31 | −15.3% | 2.0% | **−0.23** |
|
||||||
|
|
||||||
|
Book a 5 sleeve (VRP01 al 12%), ancore canoniche: FULL 2.215 → **2.146** (Δ −0.069),
|
||||||
|
HOLD 2.347 → **2.244** (Δ −0.103), DD invariato.
|
||||||
|
|
||||||
|
**Lettura onesta: il book non crolla** — l'effetto è dentro la banda d'ancora (canonico 2.22 contro
|
||||||
|
mediana de-luckata 1.95). Ma il LOO del 26/07 attribuiva a VRP01 **+0.122 di FULL**: **circa metà di
|
||||||
|
quel contributo era il prezzo che il modello si faceva da solo.** L'audit d'ancora aveva corretto la
|
||||||
|
fortuna di calendario; l'ottimismo di *prezzo* non l'aveva corretto nessuno.
|
||||||
|
|
||||||
|
## D3 — ciò che solo questo dato permette
|
||||||
|
|
||||||
|
Le stesse due gambe sono riquotate **~164 volte per trade** (orarie) fino a scadenza. Da qui:
|
||||||
|
|
||||||
|
- il **50%-profit-take scatterebbe in 13/15 settimane** → la regola di gestione diventa testabile;
|
||||||
|
- **peggior mark infra-settimana** (N=13 trade con path): mediana **−6.9%** del capitale, minimo
|
||||||
|
**−81.7%**, su un trade che è finito **in utile**.
|
||||||
|
|
||||||
|
⚠️ Il sleeve marca solo a scadenza: quel minimo **non esiste nei suoi numeri**. È la stessa classe
|
||||||
|
di errore della lente wick accoppiata del 25/07 — *un rischio valutato sul minimo non si misura
|
||||||
|
sulle chiusure*. Il DD 12% di VRP01 è un DD di chiusure settimanali.
|
||||||
|
|
||||||
|
## D4 — ciò che il dato NON dice, ed è la ragione per cui si raccoglie
|
||||||
|
|
||||||
|
**Nella finestra raccolta VRP01 non avrebbe aperto una sola posizione.** IV-rank mediano 0.12 (ETH)
|
||||||
|
e 0.08 (BTC) contro il gate >0.30: **0 settimane su 8** per entrambi. Il massimo di DVOL raccolto
|
||||||
|
sta al **24° percentile** (ETH) e **28°** (BTC) della storia.
|
||||||
|
|
||||||
|
Quindi il f = 0.73 è misurato nel regime in cui il sleeve **sta flat**. Lo skew è strutturale e in
|
||||||
|
stress si irripidisce, quindi la direzione è robusta — ma **la taglia in stress resta non
|
||||||
|
misurata**, e il criterio dichiarato il 19/06 ("rivalutare quando cerbero-bite cattura un crash")
|
||||||
|
non è stato toccato da questo lavoro.
|
||||||
|
|
||||||
|
## Il difetto trovato nel dato, e la certificazione che ora c'è
|
||||||
|
|
||||||
|
Il feed ha un guasto **in corso** dal 29/07 05:00 UTC: il collettore persiste la riga anche quando
|
||||||
|
il ticker fallisce (rate-limit per-IP), con `bid`/`ask`/`iv`/`delta` NULL. Il **conteggio righe non
|
||||||
|
cambia** — 13.000/giorno prima e dopo — quindi ogni controllo di copertura basato sulle righe dice
|
||||||
|
"tutto bene". Tasso di quote vuote per settimana: 0.4 · 0.7 · 2.2 · 1.5 · 0.5 · 0.4 · 0.3 → **22%**.
|
||||||
|
|
||||||
|
`scripts/analysis/fetch_cb_chain.py` certifica quattro difetti (quote vuote, book incrociato,
|
||||||
|
premio non monotono nello strike, `depth==0` ambiguo by design) ed è il posto dove è stata
|
||||||
|
imparata la lezione seguente.
|
||||||
|
|
||||||
|
⚠️ **La prima stesura della certificazione confrontava "tutto il campione" con "ultimi 7 giorni" —
|
||||||
|
e diluiva un guasto di 2 giorni al 13.5%**, cioè commetteva a 7 giorni lo stesso errore che
|
||||||
|
dichiarava di voler evitare a 3 mesi. Con la statistica giusta (**giorno peggiore** della finestra)
|
||||||
|
lo stesso dato dice **51.7%** e lo status passa da `DEGRADATO` a `QUOTE-VUOTE`.
|
||||||
|
**REGOLA: un guasto IN CORSO non si misura con una media su una finestra — si misura al suo giorno
|
||||||
|
peggiore. Qualunque finestra è abbastanza lunga da nascondere un guasto abbastanza giovane.**
|
||||||
|
Congelata in `test_giorno_peggiore_non_si_lascia_diluire_dalla_media`.
|
||||||
|
|
||||||
|
## Cosa NON è stato fatto, e perché
|
||||||
|
|
||||||
|
**`VRP_CFG["f"]` non è stato cambiato.** La misura sta su 15 osservazioni, 7 settimane, un solo
|
||||||
|
regime — per giunta il regime in cui il sleeve è flat. Sostituire 1.0 con 0.73 significherebbe
|
||||||
|
propagare in tutto il book un numero misurato dove il book non opera. La mossa corretta è il
|
||||||
|
**caveat quantificato**: i numeri 1.10 / 0.60 / 12% si citano d'ora in poi con "a f=1.0; f misurato
|
||||||
|
sulle quote reali 0.73 [0.70, 0.78], a cui l'hold-out standalone è −0.23".
|
||||||
|
|
||||||
|
Che VRP01 stia al 12% e non sia deployato è, retroattivamente, la decisione giusta **per il motivo
|
||||||
|
giusto**: non "short-vol da modello per prudenza", ma "il modello sovrastima il credito del 27% e
|
||||||
|
lo fa sulla gamba che doveva proteggere".
|
||||||
|
|
||||||
|
## Regole nuove
|
||||||
|
|
||||||
|
1. **Il *f* di una struttura multi-gamba non è il *f* di una sua gamba.** Misurare la sola gamba
|
||||||
|
venduta dà la risposta sbagliata **con segno rassicurante** (1.02 invece di 0.73). Ogni futura
|
||||||
|
calibrazione su quote reali di una struttura a N gambe si fa sul **netto**.
|
||||||
|
Congelata in `test_il_f_della_gamba_corta_non_e_il_f_dello_spread` (3 ali, identità algebrica
|
||||||
|
`f_net = (1 − f_long·k)/(1 − k)` — una soglia sola sarebbe stata una fixture fortunata).
|
||||||
|
2. **Un guasto in corso si misura al giorno peggiore, non in media** (sopra).
|
||||||
|
3. **Una riga presente non è un dato presente.** Il difetto delle quote vuote è invisibile a
|
||||||
|
qualunque controllo che conti righe: va contato ciò che è **quotato**, non ciò che è **scritto**.
|
||||||
|
È la terza occorrenza della stessa famiglia dopo la contabilità a 3 stati di `paper_dvolspread`
|
||||||
|
(26/07) e il silenzio letto come zero di `fresh_5m` (26/07).
|
||||||
|
|
||||||
|
## File
|
||||||
|
|
||||||
|
- `scripts/analysis/fetch_cb_chain.py` — estrazione + certificazione → `data/raw/cb_chain.parquet`
|
||||||
|
- `scripts/research/cblib.py` — harness (loader, `pick_legs`, `f_factors`, `close_cost_path`)
|
||||||
|
- `scripts/research/r0730_vrp_real_quotes.py` — lo studio (D1-D4, meccanismo, sweep f, `--book`)
|
||||||
|
- `tests/test_cb_chain_vrp.py` — 14 casi (rilevatori + controlli positivi + guardia di convenzione)
|
||||||
@@ -0,0 +1,207 @@
|
|||||||
|
"""CERTIFICAZIONE della catena opzioni in `data/raw/cb_chain/` (archivio bite + raccolta propria).
|
||||||
|
|
||||||
|
Storia: nato il 30/07 come `fetch_cb_chain.py` per estrarre la catena dal container cerbero-bite.
|
||||||
|
Con lo smantellamento di bite l'estrazione e' diventata un import una-tantum
|
||||||
|
(`import_cb_archive.py`) e la raccolta e' passata a `scripts/live/collect_chain.py`; qui resta
|
||||||
|
cio' che serve per sempre: **il giudizio sul dato**.
|
||||||
|
|
||||||
|
CERTIFICAZIONE — quattro difetti, ognuno con la sua diagnostica:
|
||||||
|
1. QUOTE VUOTE la riga c'e' ma la quota no. E' il difetto che il 29/07 ha portato il tasso da
|
||||||
|
~0.4% a ~50% per 38 ore senza un segnale: il CONTEGGIO RIGHE resta identico, quindi ogni
|
||||||
|
controllo di copertura basato sulle righe dice "tutto bene". Sulle righe raccolte da
|
||||||
|
`collect_chain.py` si legge anche il PERCHE' (`quote_status`: no_quote vs error); sulle righe
|
||||||
|
ereditate da bite no (`unknown`), perche' bite non lo registrava.
|
||||||
|
2. BOOK INCROCIATO bid > ask (quote incoerenti).
|
||||||
|
3. MONOTONIA a parita' di scadenza/istante il premio di una put e' non-decrescente nello
|
||||||
|
strike: e' il payoff, non un modello -> una violazione e' un difetto di dato.
|
||||||
|
4. PROFONDITA' ZERO nell'archivio bite `book_depth_top3 == 0` e' AMBIGUO by design (chiamata
|
||||||
|
fallita e book vuoto danno lo stesso valore) -> si riporta, non si ripara. Nelle righe nuove
|
||||||
|
l'ambiguita' non esiste: su errore la profondita' e' NULL.
|
||||||
|
|
||||||
|
uv run python scripts/analysis/certify_cb_chain.py
|
||||||
|
uv run python scripts/analysis/certify_cb_chain.py --db /percorso/state.sqlite # legacy
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import argparse
|
||||||
|
import sqlite3
|
||||||
|
import subprocess
|
||||||
|
import sys
|
||||||
|
import tempfile
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
import pandas as pd
|
||||||
|
|
||||||
|
PROJECT_ROOT = Path(__file__).resolve().parents[2]
|
||||||
|
sys.path.insert(0, str(PROJECT_ROOT))
|
||||||
|
|
||||||
|
RAW = PROJECT_ROOT / "data" / "raw"
|
||||||
|
STORE = RAW / "cb_chain"
|
||||||
|
CONTAINER = "cerbero-bite-cerbero-bite-1" # legacy: il container non esiste piu'
|
||||||
|
DB_IN_CONTAINER = "/app/data/state.sqlite"
|
||||||
|
|
||||||
|
# --- soglie di certificazione (decise, non default) ---
|
||||||
|
HOLLOW_WARN = 0.05 # >5% di quote vuote su una finestra = DEGRADATO
|
||||||
|
HOLLOW_FAIL = 0.30 # >30% = il dato non descrive piu' il mercato
|
||||||
|
RECENT_DAYS = 7 # la finestra recente conta a parte: un guasto in corso non va diluito
|
||||||
|
# in 3 mesi di storia sana (media su tutto il campione = guasto invisibile)
|
||||||
|
|
||||||
|
|
||||||
|
def _pull_db(dest: Path) -> Path:
|
||||||
|
"""Copia il DB dal container. Il volume docker non e' leggibile senza root."""
|
||||||
|
cmd = ["docker", "cp", f"{CONTAINER}:{DB_IN_CONTAINER}", str(dest)]
|
||||||
|
subprocess.run(cmd, check=True, capture_output=True)
|
||||||
|
return dest
|
||||||
|
|
||||||
|
|
||||||
|
def read_chain(db: Path) -> pd.DataFrame:
|
||||||
|
con = sqlite3.connect(f"file:{db}?mode=ro", uri=True)
|
||||||
|
try:
|
||||||
|
df = pd.read_sql_query(
|
||||||
|
"""select timestamp, asset, instrument_name, strike, expiry, option_type,
|
||||||
|
bid, ask, mid, iv, delta, gamma, theta, vega,
|
||||||
|
open_interest, volume_24h, book_depth_top3, source
|
||||||
|
from option_chain_snapshots""",
|
||||||
|
con,
|
||||||
|
)
|
||||||
|
finally:
|
||||||
|
con.close()
|
||||||
|
for c in ("strike", "bid", "ask", "mid", "iv", "delta", "gamma", "theta", "vega"):
|
||||||
|
df[c] = pd.to_numeric(df[c], errors="coerce")
|
||||||
|
# formati ISO MISTI nella stessa colonna (con e senza microsecondi): senza `format` pandas
|
||||||
|
# ne inferisce uno solo e manda gli altri a NaT, che il dropna sotto toglierebbe in SILENZIO.
|
||||||
|
df["ts"] = pd.to_datetime(df["timestamp"], utc=True, format="ISO8601", errors="coerce")
|
||||||
|
df["exp"] = pd.to_datetime(df["expiry"], utc=True, format="ISO8601", errors="coerce")
|
||||||
|
df["dte"] = (df["exp"] - df["ts"]).dt.total_seconds() / 86400.0
|
||||||
|
n0 = len(df)
|
||||||
|
df = df.dropna(subset=["ts", "exp", "strike"]).drop(columns=["timestamp", "expiry"])
|
||||||
|
if len(df) != n0:
|
||||||
|
print(f" ATTENZIONE: {n0 - len(df)} righe scartate (timestamp illeggibile) su {n0}")
|
||||||
|
return df
|
||||||
|
|
||||||
|
|
||||||
|
# ------------------------------------------------------------------ certificazione
|
||||||
|
|
||||||
|
def hollow_rate(df: pd.DataFrame) -> float:
|
||||||
|
"""Frazione di righe presenti ma SENZA quota. Non e' 'dati mancanti': la riga c'e'."""
|
||||||
|
if df.empty:
|
||||||
|
return float("nan")
|
||||||
|
return float(df["bid"].isna().mean())
|
||||||
|
|
||||||
|
|
||||||
|
def worst_day_hollow(df: pd.DataFrame, days: int) -> tuple[float, object]:
|
||||||
|
"""Tasso di quote vuote del GIORNO PEGGIORE nella finestra recente.
|
||||||
|
|
||||||
|
La media sulla finestra non basta: un guasto iniziato 2 giorni fa, mediato su 7, si legge
|
||||||
|
13% invece del 50% reale — la stessa diluizione che questo modulo dichiara di voler evitare
|
||||||
|
quando confronta 'ultimi 7g' con 'tutto'. Un guasto IN CORSO si misura al suo giorno peggiore.
|
||||||
|
"""
|
||||||
|
if df.empty:
|
||||||
|
return float("nan"), None
|
||||||
|
recent = df[df["ts"] >= df["ts"].max() - pd.Timedelta(days=days)]
|
||||||
|
if recent.empty:
|
||||||
|
return float("nan"), None
|
||||||
|
per_day = recent.assign(d=recent["ts"].dt.date).groupby("d")["bid"].apply(lambda s: s.isna().mean())
|
||||||
|
return float(per_day.max()), per_day.idxmax()
|
||||||
|
|
||||||
|
|
||||||
|
def crossed_rate(df: pd.DataFrame) -> float:
|
||||||
|
q = df.dropna(subset=["bid", "ask"])
|
||||||
|
if q.empty:
|
||||||
|
return float("nan")
|
||||||
|
return float((q["bid"] > q["ask"]).mean())
|
||||||
|
|
||||||
|
|
||||||
|
def monotonicity_violations(df: pd.DataFrame) -> float:
|
||||||
|
"""Frazione di coppie strike-adiacenti in cui il premio put DIMINUISCE al salire dello strike.
|
||||||
|
|
||||||
|
Su un singolo (istante, scadenza) il premio di una put e' non-decrescente nello strike: e' una
|
||||||
|
proprieta' del payoff, non del modello -> una violazione e' un difetto di dato, non un regime.
|
||||||
|
"""
|
||||||
|
puts = df[(df["option_type"] == "P")].dropna(subset=["mid"])
|
||||||
|
if puts.empty:
|
||||||
|
return float("nan")
|
||||||
|
bad = tot = 0
|
||||||
|
for _, g in puts.groupby(["ts", "asset", "exp"], sort=False):
|
||||||
|
if len(g) < 2:
|
||||||
|
continue
|
||||||
|
m = g.sort_values("strike")["mid"].to_numpy()
|
||||||
|
d = m[1:] - m[:-1]
|
||||||
|
bad += int((d < 0).sum())
|
||||||
|
tot += len(d)
|
||||||
|
return float(bad / tot) if tot else float("nan")
|
||||||
|
|
||||||
|
|
||||||
|
def certify(df: pd.DataFrame) -> list[dict]:
|
||||||
|
"""Un verdetto per asset. La finestra recente e' valutata a parte (vedi RECENT_DAYS)."""
|
||||||
|
out = []
|
||||||
|
if df.empty:
|
||||||
|
return out
|
||||||
|
now = df["ts"].max()
|
||||||
|
for asset, g in df.groupby("asset"):
|
||||||
|
rec = g[g["ts"] >= now - pd.Timedelta(days=RECENT_DAYS)]
|
||||||
|
h_all, h_rec = hollow_rate(g), hollow_rate(rec)
|
||||||
|
h_day, day_worst = worst_day_hollow(g, RECENT_DAYS)
|
||||||
|
worst = max(h_all, h_rec, h_day) # il verdetto segue il giorno peggiore, non la media
|
||||||
|
status = "OK" if worst <= HOLLOW_WARN else ("DEGRADATO" if worst <= HOLLOW_FAIL else "QUOTE-VUOTE")
|
||||||
|
out.append({
|
||||||
|
"asset": asset,
|
||||||
|
"righe": len(g),
|
||||||
|
"da": g["ts"].min(),
|
||||||
|
"a": g["ts"].max(),
|
||||||
|
"scadenze": g["exp"].nunique(),
|
||||||
|
"vuote_tutto": h_all,
|
||||||
|
f"vuote_{RECENT_DAYS}g": h_rec,
|
||||||
|
"vuote_giorno_peggiore": h_day,
|
||||||
|
"giorno_peggiore": day_worst,
|
||||||
|
"incrociate": crossed_rate(g),
|
||||||
|
"non_monotone": monotonicity_violations(g),
|
||||||
|
"depth_zero": float((g["book_depth_top3"] == 0).mean()),
|
||||||
|
"settimanali_4_10dte": int(((g["dte"] >= 4) & (g["dte"] <= 10)).sum()),
|
||||||
|
"status": status,
|
||||||
|
})
|
||||||
|
return out
|
||||||
|
|
||||||
|
|
||||||
|
def main() -> int:
|
||||||
|
ap = argparse.ArgumentParser()
|
||||||
|
ap.add_argument("--db", type=Path, default=None,
|
||||||
|
help="legacy: certifica direttamente uno state.sqlite di bite invece dello store")
|
||||||
|
args = ap.parse_args()
|
||||||
|
|
||||||
|
if args.db is not None:
|
||||||
|
df = read_chain(args.db)
|
||||||
|
titolo = f"catena da {args.db}"
|
||||||
|
else:
|
||||||
|
sys.path.insert(0, str(PROJECT_ROOT / "scripts" / "research"))
|
||||||
|
from cblib import load_chain
|
||||||
|
df = load_chain()
|
||||||
|
titolo = f"store {STORE}"
|
||||||
|
|
||||||
|
print("=" * 100)
|
||||||
|
print(f" CATENA OPZIONI — certificazione ({titolo})")
|
||||||
|
print("=" * 100)
|
||||||
|
rep = certify(df)
|
||||||
|
for r in rep:
|
||||||
|
print(f"\n {r['asset']} {r['righe']:,} righe {r['da']:%Y-%m-%d} -> {r['a']:%Y-%m-%d} "
|
||||||
|
f"({r['scadenze']} scadenze, {r['settimanali_4_10dte']:,} righe a 4-10 DTE)")
|
||||||
|
print(f" quote VUOTE (riga presente, bid/ask NULL): tutto {100*r['vuote_tutto']:.1f}% "
|
||||||
|
f"| ultimi {RECENT_DAYS}g {100*r[f'vuote_{RECENT_DAYS}g']:.1f}% "
|
||||||
|
f"| GIORNO PEGGIORE {100*r['vuote_giorno_peggiore']:.1f}% ({r['giorno_peggiore']})")
|
||||||
|
print(f" book incrociato {100*r['incrociate']:.2f}% | premio non monotono nello strike "
|
||||||
|
f"{100*r['non_monotone']:.2f}% | depth==0 {100*r['depth_zero']:.1f}% (ambiguo solo "
|
||||||
|
f"nell'archivio bite)")
|
||||||
|
print(f" STATUS: {r['status']}")
|
||||||
|
|
||||||
|
if "quote_status" in df.columns:
|
||||||
|
print("\n stato dichiarato della quota (solo le righe raccolte da noi lo hanno):")
|
||||||
|
for k, v in df["quote_status"].value_counts().items():
|
||||||
|
print(f" {k:9s} {v:>9,}")
|
||||||
|
if any(r["status"] != "OK" for r in rep):
|
||||||
|
print("\n ATTENZIONE: almeno un asset non e' OK — le quote vuote NON sono righe mancanti,")
|
||||||
|
print(" il conteggio righe resta identico. Ogni misura su questa finestra va pesata.")
|
||||||
|
return 0
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
raise SystemExit(main())
|
||||||
@@ -0,0 +1,132 @@
|
|||||||
|
"""IMPORT dell'archivio cerbero-bite dentro PythagorasGoal — una volta sola, prima della sua
|
||||||
|
eliminazione (2026-07-30).
|
||||||
|
|
||||||
|
cerbero-bite viene smantellato; la sua raccolta continua in `scripts/live/collect_chain.py`. Questo
|
||||||
|
script prende cio' che NON e' ricostruibile e lo porta dentro:
|
||||||
|
|
||||||
|
* `option_chain_snapshots` -> data/raw/cb_chain/bite_archive.parquet (1.23M righe, 2026-05-01+)
|
||||||
|
* `market_snapshots` -> data/raw/cb_market_snapshots.parquet (17k righe, 2026-03-26+:
|
||||||
|
spot, DVOL, RV30, IV-RV, funding perp e cross-exchange, dealer net gamma, gamma flip level,
|
||||||
|
OI delta 4h, rischio liquidazioni long/short, giorni all'evento macro)
|
||||||
|
|
||||||
|
NON importato e perche':
|
||||||
|
* `dvol_history` — il progetto ha gia' `fetch_dvol.py` con storia PIU' LUNGA (2020+ contro
|
||||||
|
2026-05): reimportarlo aggiungerebbe una seconda copia peggiore della prima.
|
||||||
|
* `decisions` / `positions` — 59 righe di valutazioni d'ingresso a capitale $52, 0 posizioni:
|
||||||
|
e' il log di una strategia che il progetto ha gia' deciso di non deployare (regola
|
||||||
|
"niente short-vol da modello in deploy").
|
||||||
|
* `audit.log` — conservato come file nel backup, non e' una serie storica.
|
||||||
|
|
||||||
|
⚠️ LO STATO DELLA QUOTA NELL'ARCHIVIO E' `unknown`, NON `ok`. Bite scriveva la riga anche quando
|
||||||
|
la chiamata falliva, con bid/ask NULL, senza registrare il perche': "book vuoto" e "chiamata
|
||||||
|
fallita" sono indistinguibili a posteriori. Il collettore nuovo li separa (`no_quote` / `error`);
|
||||||
|
per lo storico si dichiara l'ignoranza invece di inventare uno stato. Chi misura sull'archivio
|
||||||
|
deve sapere che le righe senza quota sono ~1.5% fino al 28/07 e ~50% dal 29/07 (guasto di
|
||||||
|
rate-limit, vedi diario 2026-07-30).
|
||||||
|
|
||||||
|
uv run python scripts/analysis/import_cb_archive.py --db /opt/docker/backups/manual/cerbero-bite-20260730/state.sqlite
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import argparse
|
||||||
|
import sqlite3
|
||||||
|
import sys
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
import pandas as pd
|
||||||
|
|
||||||
|
PROJECT_ROOT = Path(__file__).resolve().parents[2]
|
||||||
|
sys.path.insert(0, str(PROJECT_ROOT))
|
||||||
|
|
||||||
|
RAW = PROJECT_ROOT / "data" / "raw"
|
||||||
|
STORE = RAW / "cb_chain"
|
||||||
|
ARCHIVE = STORE / "bite_archive.parquet"
|
||||||
|
MARKET = RAW / "cb_market_snapshots.parquet"
|
||||||
|
DEFAULT_DB = Path("/opt/docker/backups/manual/cerbero-bite-20260730/state.sqlite")
|
||||||
|
|
||||||
|
|
||||||
|
def parse_ts(s: pd.Series) -> pd.Series:
|
||||||
|
"""Timestamp ISO con formati MISTI nella stessa colonna.
|
||||||
|
|
||||||
|
bite scrive sia '2026-03-26T12:00:00+00:00' (backfill) sia
|
||||||
|
'2026-05-01T15:45:00.062918+00:00' (runtime). `pd.to_datetime` senza `format` inferisce UN
|
||||||
|
formato dal primo elemento e manda gli altri a NaT: con un `dropna` a valle spariscono in
|
||||||
|
silenzio (qui: 92 righe, e la data d'inizio della serie slittava dal 26/03 al 01/05).
|
||||||
|
`format="ISO8601"` accetta entrambe le varianti.
|
||||||
|
"""
|
||||||
|
return pd.to_datetime(s, utc=True, format="ISO8601", errors="coerce")
|
||||||
|
|
||||||
|
|
||||||
|
def _drop_unparsed(df: pd.DataFrame, cols: list[str], etichetta: str) -> pd.DataFrame:
|
||||||
|
"""Scarta le righe con timestamp illeggibile DICHIARANDOLO. Uno scarto silenzioso in un
|
||||||
|
import e' indistinguibile da un dato che non e' mai esistito."""
|
||||||
|
before = len(df)
|
||||||
|
out = df.dropna(subset=cols)
|
||||||
|
if len(out) != before:
|
||||||
|
print(f" ATTENZIONE [{etichetta}]: {before - len(out)} righe scartate "
|
||||||
|
f"(timestamp illeggibile) su {before}")
|
||||||
|
return out
|
||||||
|
|
||||||
|
|
||||||
|
def import_chain(db: Path) -> pd.DataFrame:
|
||||||
|
con = sqlite3.connect(f"file:{db}?mode=ro", uri=True)
|
||||||
|
try:
|
||||||
|
df = pd.read_sql_query(
|
||||||
|
"""select timestamp, asset, instrument_name, strike, expiry, option_type,
|
||||||
|
bid, ask, mid, iv, delta, gamma, theta, vega,
|
||||||
|
open_interest, volume_24h, book_depth_top3, source
|
||||||
|
from option_chain_snapshots""", con)
|
||||||
|
finally:
|
||||||
|
con.close()
|
||||||
|
for c in ("strike", "bid", "ask", "mid", "iv", "delta", "gamma", "theta", "vega",
|
||||||
|
"open_interest", "volume_24h", "book_depth_top3"):
|
||||||
|
df[c] = pd.to_numeric(df[c], errors="coerce")
|
||||||
|
df["ts"] = parse_ts(df["timestamp"])
|
||||||
|
df["exp"] = parse_ts(df["expiry"])
|
||||||
|
df = _drop_unparsed(df.drop(columns=["timestamp", "expiry"]), ["ts", "exp", "strike"], "catena")
|
||||||
|
# bite non distingue "book vuoto" da "chiamata fallita": si dichiara, non si indovina.
|
||||||
|
df["quote_status"] = df["bid"].notna().map({True: "ok", False: "unknown"})
|
||||||
|
df["underlying_price"] = pd.NA
|
||||||
|
df["index_price"] = pd.NA
|
||||||
|
df["source"] = "bite:" + df["source"].astype(str)
|
||||||
|
return df
|
||||||
|
|
||||||
|
|
||||||
|
def import_market(db: Path) -> pd.DataFrame:
|
||||||
|
con = sqlite3.connect(f"file:{db}?mode=ro", uri=True)
|
||||||
|
try:
|
||||||
|
df = pd.read_sql_query("select * from market_snapshots", con)
|
||||||
|
finally:
|
||||||
|
con.close()
|
||||||
|
df["ts"] = parse_ts(df["timestamp"])
|
||||||
|
return _drop_unparsed(df.drop(columns=["timestamp"]), ["ts"], "contesto")
|
||||||
|
|
||||||
|
|
||||||
|
def main() -> int:
|
||||||
|
ap = argparse.ArgumentParser()
|
||||||
|
ap.add_argument("--db", type=Path, default=DEFAULT_DB)
|
||||||
|
args = ap.parse_args()
|
||||||
|
if not args.db.exists():
|
||||||
|
print(f" DB assente: {args.db}")
|
||||||
|
return 1
|
||||||
|
|
||||||
|
STORE.mkdir(parents=True, exist_ok=True)
|
||||||
|
ch = import_chain(args.db)
|
||||||
|
ch.to_parquet(ARCHIVE, index=False)
|
||||||
|
st = ch["quote_status"].value_counts().to_dict()
|
||||||
|
print(f" catena -> {ARCHIVE}")
|
||||||
|
print(f" {len(ch):,} righe {ch['ts'].min():%Y-%m-%d} -> {ch['ts'].max():%Y-%m-%d} "
|
||||||
|
f"| ok {st.get('ok', 0):,} unknown {st.get('unknown', 0):,} "
|
||||||
|
f"({100*st.get('unknown', 0)/len(ch):.1f}%)")
|
||||||
|
|
||||||
|
mk = import_market(args.db)
|
||||||
|
mk.to_parquet(MARKET, index=False)
|
||||||
|
print(f" contesto -> {MARKET}")
|
||||||
|
print(f" {len(mk):,} righe {mk['ts'].min():%Y-%m-%d} -> {mk['ts'].max():%Y-%m-%d} "
|
||||||
|
f"| colonne: {', '.join(c for c in mk.columns if c not in ('ts', 'asset'))[:110]}...")
|
||||||
|
print("\n NON importati (motivo nel docstring): dvol_history, decisions, positions, audit.log")
|
||||||
|
return 0
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
raise SystemExit(main())
|
||||||
@@ -4,6 +4,12 @@
|
|||||||
# riconcilia al target NETTO corrente (se non cambia nulla -> HOLD). Il feed 5m fresco per il
|
# riconcilia al target NETTO corrente (se non cambia nulla -> HOLD). Il feed 5m fresco per il
|
||||||
# segnale SKH e' preso IN MEMORIA dentro book_execute (livefeed.fresh_5m): NON tocca i dati
|
# segnale SKH e' preso IN MEMORIA dentro book_execute (livefeed.fresh_5m): NON tocca i dati
|
||||||
# certificati su disco. Esecuzione reale gated da config/live.json (execution_enabled) + --execute.
|
# certificati su disco. Esecuzione reale gated da config/live.json (execution_enabled) + --execute.
|
||||||
|
#
|
||||||
|
# INSTALLATO AL MINUTO :07, NON :00 (`7 * * * *`, spostato il 2026-07-29 durante l'incidente del
|
||||||
|
# feed 5m). Motivo: IPOTESI di contesa al minuto tondo (l'ora esatta e' quando parte tutto il
|
||||||
|
# resto della VPS). ⚠️ NON e' un fix verificato — e' un ripiego da un'osservazione sola, preso
|
||||||
|
# perche' costa zero. Se il feed torna stantio anche al :07, l'ipotesi e' morta e la causa vera
|
||||||
|
# la dira' `skh_feed_errors` nel report (instrumentazione del 29/07, vedi src/live/livefeed.py).
|
||||||
export PATH="/home/adriano/.local/bin:$PATH"
|
export PATH="/home/adriano/.local/bin:$PATH"
|
||||||
cd /opt/docker/PythagorasGoal || exit 1
|
cd /opt/docker/PythagorasGoal || exit 1
|
||||||
mkdir -p logs
|
mkdir -p logs
|
||||||
|
|||||||
Executable
+24
@@ -0,0 +1,24 @@
|
|||||||
|
#!/bin/bash
|
||||||
|
# RACCOLTA CATENA OPZIONI Deribit — oraria. Successore di cerbero-bite (smantellato il 2026-07-30).
|
||||||
|
#
|
||||||
|
# Perche' e' un cron e non un container: la raccolta e' un giro di ~3 minuti ogni ora, senza stato
|
||||||
|
# fra un giro e l'altro. cerbero-bite girava come servizio con scheduler interno; l'unica parte che
|
||||||
|
# serviva davvero era questa.
|
||||||
|
#
|
||||||
|
# MINUTO :25, e la scelta NON e' arbitraria:
|
||||||
|
# :00 cerbero-bite sparava la sua spazzata full-chain (~44 chiamate/s) e saturava il rate limit
|
||||||
|
# Deribit per-IP — 12.186 risposte 429 in 26 ore, il 96% nel minuto tondo.
|
||||||
|
# :07 cron_book.sh (esecuzione del book live). Il feed 5m di SKH01 passa da li': e' la cosa che
|
||||||
|
# NON deve trovare l'IP occupato.
|
||||||
|
# :25 nessun altro job. Il giro dura ~3 minuti a 4 chiamate/s, quindi finisce ben prima del :30.
|
||||||
|
#
|
||||||
|
# Il pacing e' nello script (token bucket + backoff sul 429): un giro lento non costa niente,
|
||||||
|
# una raffica costa il dato di tutti gli altri.
|
||||||
|
export PATH="/home/adriano/.local/bin:$PATH"
|
||||||
|
cd /opt/docker/PythagorasGoal || exit 1
|
||||||
|
mkdir -p logs
|
||||||
|
{
|
||||||
|
echo "===== $(date -u '+%Y-%m-%dT%H:%M:%SZ') cron_chain ====="
|
||||||
|
uv run python scripts/live/collect_chain.py
|
||||||
|
echo "===== done $(date -u '+%H:%M:%SZ') ====="
|
||||||
|
} >> logs/cron_chain.log 2>&1
|
||||||
@@ -106,20 +106,34 @@ def _run():
|
|||||||
if skh_age is None:
|
if skh_age is None:
|
||||||
print(" ⚠️ freschezza feed SKH NON MISURATA (segnale su feed certificato?)")
|
print(" ⚠️ freschezza feed SKH NON MISURATA (segnale su feed certificato?)")
|
||||||
elif skh_age > max_skh_age:
|
elif skh_age > max_skh_age:
|
||||||
|
# La CAUSA e' misurata (livefeed.last_fetch_error), non piu' presunta: fino al 29/07 questa
|
||||||
|
# nota diceva sempre "fetch pubblico KO" a prescindere — una presunzione stampata come se
|
||||||
|
# fosse una misura. Quel giorno il feed e' stato stantio in 6 giri orari su 8 (fino a 685
|
||||||
|
# min) e la causa NON e' stata stabilita, proprio perche' l'unica riga disponibile era
|
||||||
|
# questa e non veniva dai dati.
|
||||||
|
errs = r.get("skh_feed_errors") or {}
|
||||||
|
causa = "; ".join(f"{a}: {e}" for a, e in sorted(errs.items())) if errs else \
|
||||||
|
"coda fresca attaccata: il feed certificato stesso e' vecchio (rebuild giornaliero?)"
|
||||||
print(f" ⚠️ FEED SKH STANTIO: ultima barra 5m di {skh_age:.0f} min fa "
|
print(f" ⚠️ FEED SKH STANTIO: ultima barra 5m di {skh_age:.0f} min fa "
|
||||||
f"(soglia {max_skh_age:.0f}) -> le uscite SKH sono in ritardo, NON blocco.")
|
f"(soglia {max_skh_age:.0f}) -> le uscite SKH sono in ritardo, NON blocco.")
|
||||||
|
print(f" causa: {causa}")
|
||||||
if do_execute:
|
if do_execute:
|
||||||
notify("⚠️ BOOK — feed SKH stantio (uscite in ritardo)",
|
notify("⚠️ BOOK — feed SKH stantio (uscite in ritardo)",
|
||||||
{"eta_min": round(skh_age), "soglia_min": round(max_skh_age),
|
{"eta_min": round(skh_age), "soglia_min": round(max_skh_age),
|
||||||
"effetto": "SL/TP di SKH01 rilevati in ritardo",
|
"effetto": "SL/TP di SKH01 rilevati in ritardo",
|
||||||
"nota": "fresh_5m e' ricaduto sul feed certificato (fetch pubblico KO)"})
|
"causa": causa[:300]})
|
||||||
else:
|
else:
|
||||||
print(f" feed SKH : fresco ({skh_age:.0f} min)")
|
print(f" feed SKH : fresco ({skh_age:.0f} min)")
|
||||||
|
|
||||||
if not r["online"]:
|
if not r["online"]:
|
||||||
print(" conto non leggibile (offline) -> stop, non eseguo a cieco.")
|
# `online` e' falso quando il mark di BTC non viene da mainnet: la ragione sta in
|
||||||
|
# `mark_src` ("fallback close (<Eccezione>)") ma non veniva MAI stampata, quindi
|
||||||
|
# l'allerta diceva solo "conto offline" — vero e inutile. Stesso buco del feed SKH.
|
||||||
|
srcs = "; ".join(f"{a['asset']}: {a.get('mark_src')}" for a in r["assets"])
|
||||||
|
print(f" conto non leggibile (offline) -> stop, non eseguo a cieco.\n mark: {srcs}")
|
||||||
if do_execute:
|
if do_execute:
|
||||||
notify("⚠️ BOOK LIVE — conto offline", {"nota": "salto l'esecuzione, non opero a cieco"})
|
notify("⚠️ BOOK LIVE — conto offline", {"nota": "salto l'esecuzione, non opero a cieco",
|
||||||
|
"mark": srcs[:300]})
|
||||||
return
|
return
|
||||||
|
|
||||||
if r.get("pos_error"): # ONLINE ma posizione IGNOTA (read fallita -> assunta flat)
|
if r.get("pos_error"): # ONLINE ma posizione IGNOTA (read fallita -> assunta flat)
|
||||||
|
|||||||
@@ -0,0 +1,261 @@
|
|||||||
|
"""COLLETTORE CATENA OPZIONI — successore di cerbero-bite, dentro PythagorasGoal (2026-07-30).
|
||||||
|
|
||||||
|
Perche' esiste: cerbero-bite viene eliminato, e con esso si fermerebbe l'unica raccolta di prezzi
|
||||||
|
opzioni REALI del progetto. Una catena non e' ricostruibile a posteriori (Deribit non serve book
|
||||||
|
storici, non c'e' un secondo venue) -> l'ora non raccolta e' persa per sempre. Il valore
|
||||||
|
dell'archivio sta negli eventi RARI: e' la rete stesa in attesa del regime di vol alta che
|
||||||
|
promuovera' o uccidera' VRP01 (criterio dichiarato il 19/06, gate IV-rank>0.30 mai attivo nella
|
||||||
|
finestra raccolta finora).
|
||||||
|
|
||||||
|
TRE DIFFERENZE DA BITE, tutte misurate il 30/07 e tutte deliberate:
|
||||||
|
|
||||||
|
1. UNA CHIAMATA PER STRUMENTO, non due. `public/get_order_book?depth=3` restituisce gia' quote,
|
||||||
|
greche, IV, open interest, volume, book E underlying_price. Bite chiamava ticker + orderbook
|
||||||
|
separatamente: doppio costo e possibilita' di disallineamento fra i due (quote di un istante,
|
||||||
|
book di un altro).
|
||||||
|
|
||||||
|
2. PACING, non raffica. Il carico non e' mai stato il problema: ~570 chiamate/ora = 0.16/s se
|
||||||
|
distribuite. Bite le sparava in ~26 secondi (~44/s) e si auto-saturava il rate limit per-IP
|
||||||
|
(12.186 risposte 429 in 26 ore, 96% nel minuto :00), con l'effetto collaterale di disturbare
|
||||||
|
il feed 5m del book live sulla stessa VPS. Qui: token bucket a `--rps` (default 4/s, ~2.5
|
||||||
|
minuti per giro) + backoff sul 429. Un giro lento non costa nulla; una raffica costa il dato.
|
||||||
|
|
||||||
|
3. STATO ESPLICITO DELLA QUOTA. Bite persisteva la riga anche quando la chiamata falliva, con
|
||||||
|
bid/ask NULL: il conteggio righe restava identico e nessun controllo di copertura se ne
|
||||||
|
accorgeva (il 29/07 il 50% delle quote e' diventato vuoto per 38 ore senza un segnale). Qui
|
||||||
|
ogni riga porta `quote_status` in {ok, no_quote, error}:
|
||||||
|
ok = il venue ha risposto e c'e' almeno un lato del book
|
||||||
|
no_quote = il venue ha risposto e il book e' vuoto da entrambi i lati (fatto di mercato)
|
||||||
|
error = la chiamata e' fallita (fatto di infrastruttura)
|
||||||
|
Sono cose diverse e non vanno mai confuse. Per lo stesso motivo `book_depth_top3` e' NULL su
|
||||||
|
errore, MAI 0: bite scriveva 0 e "chiamata fallita" diventava indistinguibile da "book vuoto".
|
||||||
|
|
||||||
|
uv run python scripts/live/collect_chain.py # un giro, entrambi gli asset
|
||||||
|
uv run python scripts/live/collect_chain.py --assets ETH --rps 8
|
||||||
|
uv run python scripts/live/collect_chain.py --dry-run # non scrive
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import argparse
|
||||||
|
import json
|
||||||
|
import sys
|
||||||
|
import time
|
||||||
|
from dataclasses import dataclass, field
|
||||||
|
from datetime import UTC, datetime
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
import pandas as pd
|
||||||
|
import requests
|
||||||
|
|
||||||
|
PROJECT_ROOT = Path(__file__).resolve().parents[2]
|
||||||
|
sys.path.insert(0, str(PROJECT_ROOT))
|
||||||
|
|
||||||
|
API = "https://www.deribit.com/api/v2/public"
|
||||||
|
STORE = PROJECT_ROOT / "data" / "raw" / "cb_chain"
|
||||||
|
ASSETS = ("BTC", "ETH")
|
||||||
|
EXPIRY_MAX_DAYS = 95 # 1g..3mesi, come la finestra di bite (continuita' della serie)
|
||||||
|
OI_MIN = 100.0 # come bite: sotto questa soglia lo strumento e' rumore
|
||||||
|
DEFAULT_RPS = 4.0
|
||||||
|
TIMEOUT = 15
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class Budget:
|
||||||
|
"""Token bucket + contabilita' del giro. Il 429 non e' un dettaglio: e' la cosa da non fare."""
|
||||||
|
rps: float
|
||||||
|
_next: float = 0.0
|
||||||
|
calls: int = 0
|
||||||
|
errors: int = 0
|
||||||
|
rate_limited: int = 0
|
||||||
|
waited_s: float = 0.0
|
||||||
|
err_samples: list[str] = field(default_factory=list)
|
||||||
|
|
||||||
|
def wait(self) -> None:
|
||||||
|
now = time.monotonic()
|
||||||
|
if now < self._next:
|
||||||
|
time.sleep(self._next - now)
|
||||||
|
self.waited_s += self._next - now
|
||||||
|
self._next = max(now, self._next) + 1.0 / self.rps
|
||||||
|
|
||||||
|
def note_error(self, msg: str) -> None:
|
||||||
|
self.errors += 1
|
||||||
|
if len(self.err_samples) < 5:
|
||||||
|
self.err_samples.append(msg[:160])
|
||||||
|
|
||||||
|
|
||||||
|
def _get(path: str, params: dict, budget: Budget, tries: int = 3) -> dict | None:
|
||||||
|
"""GET con pacing e backoff. Ritorna None se la chiamata non e' andata a buon fine."""
|
||||||
|
for k in range(tries):
|
||||||
|
budget.wait()
|
||||||
|
budget.calls += 1
|
||||||
|
try:
|
||||||
|
r = requests.get(f"{API}/{path}", params=params, timeout=TIMEOUT)
|
||||||
|
except Exception as exc: # rete: si registra QUI, non dopo
|
||||||
|
budget.note_error(f"{path}: {type(exc).__name__}: {exc}")
|
||||||
|
time.sleep(1.5 * (k + 1))
|
||||||
|
continue
|
||||||
|
if r.status_code == 429:
|
||||||
|
budget.rate_limited += 1
|
||||||
|
time.sleep(2.0 * (k + 1)) # backoff: il venue ha detto di rallentare
|
||||||
|
continue
|
||||||
|
if r.status_code != 200:
|
||||||
|
budget.note_error(f"{path}: HTTP {r.status_code}")
|
||||||
|
time.sleep(1.0 * (k + 1))
|
||||||
|
continue
|
||||||
|
try:
|
||||||
|
return r.json()["result"]
|
||||||
|
except Exception as exc:
|
||||||
|
budget.note_error(f"{path}: payload illeggibile: {exc}")
|
||||||
|
return None
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def instruments(asset: str, budget: Budget, now: datetime) -> list[dict]:
|
||||||
|
res = _get("get_instruments", {"currency": asset, "kind": "option", "expired": "false"}, budget)
|
||||||
|
if not res:
|
||||||
|
return []
|
||||||
|
horizon = now.timestamp() * 1000 + EXPIRY_MAX_DAYS * 86400_000
|
||||||
|
return [i for i in res if i.get("expiration_timestamp", 0) <= horizon]
|
||||||
|
|
||||||
|
|
||||||
|
def _depth_top3(side: list) -> float | None:
|
||||||
|
if side is None:
|
||||||
|
return None
|
||||||
|
return float(sum(row[1] for row in side[:3] if isinstance(row, (list, tuple)) and len(row) >= 2))
|
||||||
|
|
||||||
|
|
||||||
|
def snapshot_row(inst: dict, ob: dict | None, ts: datetime) -> dict:
|
||||||
|
"""Una riga per strumento — SEMPRE, ma con lo stato della quota dichiarato."""
|
||||||
|
name = inst["instrument_name"]
|
||||||
|
base = {
|
||||||
|
"ts": ts, "asset": inst["base_currency"], "instrument_name": name,
|
||||||
|
"strike": float(inst["strike"]), "option_type": "P" if inst["option_type"] == "put" else "C",
|
||||||
|
"exp": pd.Timestamp(inst["expiration_timestamp"], unit="ms", tz="UTC"),
|
||||||
|
"bid": None, "ask": None, "mid": None, "iv": None,
|
||||||
|
"delta": None, "gamma": None, "theta": None, "vega": None,
|
||||||
|
"open_interest": None, "volume_24h": None, "book_depth_top3": None,
|
||||||
|
"underlying_price": None, "index_price": None,
|
||||||
|
"quote_status": "error", "source": "pyg",
|
||||||
|
}
|
||||||
|
if ob is None:
|
||||||
|
return base # errore: depth resta NULL, mai 0
|
||||||
|
g = ob.get("greeks") or {}
|
||||||
|
stats = ob.get("stats") or {}
|
||||||
|
bid, ask = ob.get("best_bid_price"), ob.get("best_ask_price")
|
||||||
|
bid = float(bid) if bid else None # Deribit manda 0.0 per "nessun lato"
|
||||||
|
ask = float(ask) if ask else None
|
||||||
|
db, da = _depth_top3(ob.get("bids")), _depth_top3(ob.get("asks"))
|
||||||
|
base.update({
|
||||||
|
"bid": bid, "ask": ask,
|
||||||
|
"mid": (bid + ask) / 2 if (bid is not None and ask is not None) else None,
|
||||||
|
"iv": float(ob["mark_iv"]) if ob.get("mark_iv") is not None else None,
|
||||||
|
"delta": g.get("delta"), "gamma": g.get("gamma"),
|
||||||
|
"theta": g.get("theta"), "vega": g.get("vega"),
|
||||||
|
"open_interest": ob.get("open_interest"), "volume_24h": stats.get("volume"),
|
||||||
|
"book_depth_top3": (db or 0.0) + (da or 0.0),
|
||||||
|
"underlying_price": ob.get("underlying_price"), "index_price": ob.get("index_price"),
|
||||||
|
# il venue ha risposto: se non c'e' nessun lato e' un fatto di MERCATO, non un guasto
|
||||||
|
"quote_status": "ok" if (bid is not None or ask is not None) else "no_quote",
|
||||||
|
})
|
||||||
|
return base
|
||||||
|
|
||||||
|
|
||||||
|
def open_interest_map(asset: str, budget: Budget) -> dict[str, float] | None:
|
||||||
|
"""OI di TUTTA la catena in UNA chiamata (`get_book_summary_by_currency`).
|
||||||
|
|
||||||
|
Serve a non spendere una chiamata per scoprire che uno strumento e' sotto soglia: il prefiltro
|
||||||
|
dimezza il giro (551 -> ~300 chiamate su ETH). None = la chiamata e' fallita, e allora si
|
||||||
|
raccoglie TUTTO invece di filtrare su un dato che non si ha: un filtro su dati mancanti
|
||||||
|
scarterebbe strumenti buoni fingendo che fossero illiquidi.
|
||||||
|
"""
|
||||||
|
res = _get("get_book_summary_by_currency", {"currency": asset, "kind": "option"}, budget)
|
||||||
|
if not res:
|
||||||
|
return None
|
||||||
|
return {r["instrument_name"]: float(r.get("open_interest") or 0.0) for r in res}
|
||||||
|
|
||||||
|
|
||||||
|
def sweep(asset: str, budget: Budget, now: datetime) -> pd.DataFrame:
|
||||||
|
insts = [i for i in instruments(asset, budget, now) if float(i.get("strike") or 0) > 0]
|
||||||
|
oi = open_interest_map(asset, budget)
|
||||||
|
if oi is not None:
|
||||||
|
insts = [i for i in insts if oi.get(i["instrument_name"], 0.0) >= OI_MIN]
|
||||||
|
rows = []
|
||||||
|
for inst in insts:
|
||||||
|
ob = _get("get_order_book", {"instrument_name": inst["instrument_name"], "depth": 3}, budget)
|
||||||
|
rows.append(snapshot_row(inst, ob, now))
|
||||||
|
return pd.DataFrame(rows)
|
||||||
|
|
||||||
|
|
||||||
|
def heartbeat(now: datetime, df: pd.DataFrame, budget: Budget) -> None:
|
||||||
|
"""Una riga per giro in `data/chain_collect/runs.jsonl`, letta da `monitor_health`.
|
||||||
|
|
||||||
|
Serve perche' un collettore fermo non produce NIENTE, e il niente si legge come "nessun dato
|
||||||
|
quel giorno" invece che come "raccolta rotta" — con una serie irrecuperabile e' il modo piu'
|
||||||
|
caro di sbagliare. La battuta di cuore esiste anche quando il giro fallisce.
|
||||||
|
"""
|
||||||
|
d = PROJECT_ROOT / "data" / "chain_collect"
|
||||||
|
d.mkdir(parents=True, exist_ok=True)
|
||||||
|
st = df["quote_status"].value_counts().to_dict() if not df.empty else {}
|
||||||
|
riga = {"ts": int(now.timestamp() * 1000), "righe": int(len(df)),
|
||||||
|
"ok": int(st.get("ok", 0)), "no_quote": int(st.get("no_quote", 0)),
|
||||||
|
"error": int(st.get("error", 0)), "chiamate": budget.calls,
|
||||||
|
"rate_limited": budget.rate_limited}
|
||||||
|
with (d / "runs.jsonl").open("a") as fh:
|
||||||
|
fh.write(json.dumps(riga) + "\n")
|
||||||
|
|
||||||
|
|
||||||
|
def write(df: pd.DataFrame, day: datetime) -> Path:
|
||||||
|
"""Un parquet per giorno: append-friendly e nessun file che cresce senza fine."""
|
||||||
|
STORE.mkdir(parents=True, exist_ok=True)
|
||||||
|
p = STORE / f"{day:%Y-%m-%d}.parquet"
|
||||||
|
if p.exists():
|
||||||
|
df = pd.concat([pd.read_parquet(p), df], ignore_index=True)
|
||||||
|
df = df.drop_duplicates(subset=["ts", "instrument_name"], keep="last")
|
||||||
|
df.to_parquet(p, index=False)
|
||||||
|
return p
|
||||||
|
|
||||||
|
|
||||||
|
def main() -> int:
|
||||||
|
ap = argparse.ArgumentParser()
|
||||||
|
ap.add_argument("--assets", nargs="+", default=list(ASSETS))
|
||||||
|
ap.add_argument("--rps", type=float, default=DEFAULT_RPS, help="chiamate/secondo (pacing)")
|
||||||
|
ap.add_argument("--dry-run", action="store_true")
|
||||||
|
args = ap.parse_args()
|
||||||
|
|
||||||
|
now = datetime.now(UTC).replace(microsecond=0)
|
||||||
|
budget = Budget(rps=args.rps)
|
||||||
|
t0 = time.monotonic()
|
||||||
|
frames = []
|
||||||
|
for a in args.assets:
|
||||||
|
df = sweep(a, budget, now)
|
||||||
|
frames.append(df)
|
||||||
|
if df.empty:
|
||||||
|
print(f" {a}: NESSUNA riga — il giro e' fallito, non e' un mercato vuoto")
|
||||||
|
continue
|
||||||
|
st = df["quote_status"].value_counts().to_dict()
|
||||||
|
print(f" {a}: {len(df):4d} strumenti | ok {st.get('ok', 0)} | "
|
||||||
|
f"no_quote {st.get('no_quote', 0)} | error {st.get('error', 0)}")
|
||||||
|
out = pd.concat(frames, ignore_index=True) if frames else pd.DataFrame(columns=["quote_status"])
|
||||||
|
dur = time.monotonic() - t0
|
||||||
|
print(f" {budget.calls} chiamate in {dur:.0f}s ({budget.calls/max(dur,1):.1f}/s) | "
|
||||||
|
f"429: {budget.rate_limited} | errori: {budget.errors}")
|
||||||
|
for e in budget.err_samples:
|
||||||
|
print(f" errore: {e}")
|
||||||
|
if not args.dry_run:
|
||||||
|
heartbeat(now, out, budget) # anche a giro fallito: il silenzio non e' un dato
|
||||||
|
if out.empty:
|
||||||
|
print(" NIENTE DA SCRIVERE — giro fallito")
|
||||||
|
return 1
|
||||||
|
bad = float((out["quote_status"] == "error").mean())
|
||||||
|
if bad > 0.20:
|
||||||
|
print(f" ATTENZIONE: {100*bad:.0f}% di quote in ERRORE — la riga c'e' ma il dato no.")
|
||||||
|
if args.dry_run:
|
||||||
|
print(" --dry-run: non scrivo")
|
||||||
|
return 0
|
||||||
|
p = write(out, now)
|
||||||
|
print(f" scritto {p} ({len(out)} righe)")
|
||||||
|
return 0
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
raise SystemExit(main())
|
||||||
@@ -0,0 +1,201 @@
|
|||||||
|
"""CBLIB — harness della catena opzioni REALE (cerbero-bite), come altlib/eqlib per gli altri lati.
|
||||||
|
|
||||||
|
Legge la cache su disco `data/raw/cb_chain.parquet` (scritta una volta da
|
||||||
|
`scripts/analysis/fetch_cb_chain.py`), MAI dal container.
|
||||||
|
|
||||||
|
Serve a rispondere a una domanda che il progetto pone dal 19/06 e non aveva mai potuto misurare:
|
||||||
|
il premio che VRP01 incassa nel backtest e' quello che il mercato paga? Il sleeve prezza ENTRAMBE
|
||||||
|
le gambe con BS su DVOL ATM (`sleeves.VRP_CFG`, f=1.0 applicato al credito NETTO); qui si misura
|
||||||
|
f = credito reale / credito modellato
|
||||||
|
sugli STESSI strike, cosi' il rapporto isola l'errore di PREZZO e non la scelta degli strike.
|
||||||
|
|
||||||
|
Regola che vale per ogni misura fatta con questo modulo: il f della sola gamba corta NON e' il f
|
||||||
|
dello spread. Il modello sbaglia soprattutto sull'ala che si COMPRA (piu' OTM, IV piu' alta per
|
||||||
|
skew, prezzata dal modello a vol ATM) -> misurare una gamba sola da' la risposta sbagliata con
|
||||||
|
segno rassicurante.
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import sys
|
||||||
|
from functools import lru_cache
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
import numpy as np
|
||||||
|
import pandas as pd
|
||||||
|
from scipy.stats import norm
|
||||||
|
|
||||||
|
ROOT = Path(__file__).resolve().parents[2]
|
||||||
|
sys.path.insert(0, str(ROOT))
|
||||||
|
RAW = ROOT / "data" / "raw"
|
||||||
|
STORE = RAW / "cb_chain" # bite_archive.parquet (una-tantum) + YYYY-MM-DD.parquet (nostri)
|
||||||
|
|
||||||
|
# finestra "settimanale" di VRP01 (tenor_d=7): si accettano 4..10 DTE come in options_vrp_calibrate
|
||||||
|
DTE_LO, DTE_HI = 4.0, 10.0
|
||||||
|
SHORT_DELTA, LONG_DELTA = -0.28, -0.10
|
||||||
|
|
||||||
|
|
||||||
|
# ------------------------------------------------------------------ dati
|
||||||
|
|
||||||
|
@lru_cache(maxsize=4)
|
||||||
|
def load_chain() -> pd.DataFrame:
|
||||||
|
"""Tutta la catena: archivio ereditato da cerbero-bite + raccolta propria, in una serie sola.
|
||||||
|
|
||||||
|
Le due fonti restano distinguibili dalla colonna `source` (`bite:live`, `bite:research`, `pyg`)
|
||||||
|
e da `quote_status` (`unknown` per l'archivio, che non registrava il perche' di una quota
|
||||||
|
assente). La deduplica su (ts, instrument_name) tiene l'ULTIMA occorrenza: se un giorno e'
|
||||||
|
coperto da entrambe le fonti vince la piu' recente, cioe' la nostra.
|
||||||
|
"""
|
||||||
|
if not STORE.exists():
|
||||||
|
raise FileNotFoundError(
|
||||||
|
f"{STORE} assente — importa l'archivio con scripts/analysis/import_cb_archive.py "
|
||||||
|
f"e raccogli con scripts/live/collect_chain.py"
|
||||||
|
)
|
||||||
|
parts = sorted(STORE.glob("*.parquet"))
|
||||||
|
if not parts:
|
||||||
|
raise FileNotFoundError(f"{STORE} vuoto")
|
||||||
|
df = pd.concat([pd.read_parquet(p) for p in parts], ignore_index=True)
|
||||||
|
df["ts"] = pd.to_datetime(df["ts"], utc=True)
|
||||||
|
df["exp"] = pd.to_datetime(df["exp"], utc=True)
|
||||||
|
df["dte"] = (df["exp"] - df["ts"]).dt.total_seconds() / 86400.0
|
||||||
|
return df.drop_duplicates(subset=["ts", "instrument_name"], keep="last").reset_index(drop=True)
|
||||||
|
|
||||||
|
|
||||||
|
def puts(asset: str, df: pd.DataFrame | None = None) -> pd.DataFrame:
|
||||||
|
d = load_chain() if df is None else df
|
||||||
|
return d[(d["asset"] == asset) & (d["option_type"] == "P")]
|
||||||
|
|
||||||
|
|
||||||
|
@lru_cache(maxsize=8)
|
||||||
|
def spot_series(asset: str) -> pd.Series:
|
||||||
|
from scripts.analysis.research_lab import load_tf
|
||||||
|
px = load_tf(asset, "1h")
|
||||||
|
s = pd.Series(px["close"].values.astype(float),
|
||||||
|
index=pd.to_datetime(px["timestamp"], unit="ms", utc=True)).sort_index()
|
||||||
|
s.index = pd.DatetimeIndex(s.index).as_unit("ns") # le quote bite hanno microsecondi
|
||||||
|
return s
|
||||||
|
|
||||||
|
|
||||||
|
@lru_cache(maxsize=8)
|
||||||
|
def dvol_series(asset: str) -> pd.Series:
|
||||||
|
d = pd.read_parquet(RAW / f"dvol_{asset.lower()}.parquet")
|
||||||
|
s = pd.Series(d["close"].values.astype(float),
|
||||||
|
index=pd.to_datetime(d["timestamp"], unit="ms", utc=True)).sort_index()
|
||||||
|
s.index = pd.DatetimeIndex(s.index).as_unit("ns")
|
||||||
|
return s
|
||||||
|
|
||||||
|
|
||||||
|
# ------------------------------------------------------------------ prezzo
|
||||||
|
|
||||||
|
def bs_put(S: float, K: float, T: float, sig: float) -> float:
|
||||||
|
"""Identica a `sleeves._bs_put` (r=0): il confronto dev'essere col prezzatore del sleeve."""
|
||||||
|
if T <= 0 or sig <= 0:
|
||||||
|
return max(K - S, 0.0)
|
||||||
|
d1 = (np.log(S / K) + 0.5 * sig**2 * T) / (sig * np.sqrt(T))
|
||||||
|
return K * norm.cdf(-(d1 - sig * np.sqrt(T))) - S * norm.cdf(-d1)
|
||||||
|
|
||||||
|
|
||||||
|
# ------------------------------------------------------------------ struttura
|
||||||
|
|
||||||
|
def pick_legs(snap: pd.DataFrame, short_delta: float = SHORT_DELTA,
|
||||||
|
long_delta: float = LONG_DELTA) -> dict | None:
|
||||||
|
"""Dalle put di UNO snapshot+scadenza: gamba corta ~short_delta e lunga ~long_delta.
|
||||||
|
|
||||||
|
Ritorna None se non e' costruibile un credit spread: serve che ENTRAMBE siano quotate
|
||||||
|
(bid>0, ask>0, non incrociate) e che lo strike corto sia sopra quello lungo. Una gamba sola
|
||||||
|
quotata non e' "meta' struttura": e' un'altra strategia, e va scartata.
|
||||||
|
"""
|
||||||
|
ok = snap.dropna(subset=["bid", "ask", "delta"])
|
||||||
|
ok = ok[(ok["bid"] > 0) & (ok["ask"] > 0) & (ok["ask"] >= ok["bid"])]
|
||||||
|
if len(ok) < 2:
|
||||||
|
return None
|
||||||
|
s = ok.iloc[[int((ok["delta"] - short_delta).abs().to_numpy().argmin())]].iloc[0]
|
||||||
|
l = ok.iloc[[int((ok["delta"] - long_delta).abs().to_numpy().argmin())]].iloc[0]
|
||||||
|
if s["strike"] <= l["strike"]:
|
||||||
|
return None
|
||||||
|
return {
|
||||||
|
"inst_short": s["instrument_name"], "inst_long": l["instrument_name"],
|
||||||
|
"k_short": float(s["strike"]), "k_long": float(l["strike"]),
|
||||||
|
"d_short": float(s["delta"]), "d_long": float(l["delta"]),
|
||||||
|
"iv_short": float(s["iv"]) if pd.notna(s["iv"]) else np.nan,
|
||||||
|
"iv_long": float(l["iv"]) if pd.notna(l["iv"]) else np.nan,
|
||||||
|
"bid_short": float(s["bid"]), "ask_short": float(s["ask"]),
|
||||||
|
"bid_long": float(l["bid"]), "ask_long": float(l["ask"]),
|
||||||
|
"mid_short": float(s["mid"]) if pd.notna(s["mid"]) else np.nan,
|
||||||
|
"mid_long": float(l["mid"]) if pd.notna(l["mid"]) else np.nan,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def f_factors(legs: dict, spot: float, dvol_frac: float, dte_days: float) -> dict:
|
||||||
|
"""f = reale/modellato per gamba e per credito NETTO, agli STESSI strike.
|
||||||
|
|
||||||
|
Convenzione di fill CONSERVATIVA: si vende al bid e si compra all'ask (si attraversa lo
|
||||||
|
spread in entrambe le direzioni). Il `_mid` e' la stessa cosa a meta' spread.
|
||||||
|
I premi Deribit sono quotati nel sottostante -> moltiplicati per lo spot passano a USD.
|
||||||
|
"""
|
||||||
|
T = dte_days / 365.25
|
||||||
|
mod_s = bs_put(spot, legs["k_short"], T, dvol_frac)
|
||||||
|
mod_l = bs_put(spot, legs["k_long"], T, dvol_frac)
|
||||||
|
cred_mod = mod_s - mod_l
|
||||||
|
cred_real = (legs["bid_short"] - legs["ask_long"]) * spot
|
||||||
|
cred_mid = (legs["mid_short"] - legs["mid_long"]) * spot
|
||||||
|
return {
|
||||||
|
"mod_short": mod_s, "mod_long": mod_l, "cred_mod": cred_mod,
|
||||||
|
"cred_real": cred_real, "cred_mid": cred_mid,
|
||||||
|
"f_short": legs["bid_short"] * spot / mod_s if mod_s > 0 else np.nan,
|
||||||
|
"f_long": legs["ask_long"] * spot / mod_l if mod_l > 0 else np.nan,
|
||||||
|
"f_net": cred_real / cred_mod if cred_mod > 0 else np.nan,
|
||||||
|
"f_net_mid": cred_mid / cred_mod if cred_mod > 0 else np.nan,
|
||||||
|
"width": legs["k_short"] - legs["k_long"],
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def weekly_entries(asset: str, df: pd.DataFrame | None = None, target_dte: float = 7.0) -> pd.DataFrame:
|
||||||
|
"""Un ingresso per scadenza: lo snapshot con DTE piu' vicino a `target_dte` in cui ENTRAMBE
|
||||||
|
le gambe sono quotate. Ritorna anche f_* e il contesto (spot, DVOL, IV-rank causale)."""
|
||||||
|
p = puts(asset, df)
|
||||||
|
wk = p[(p["dte"] >= DTE_LO) & (p["dte"] <= DTE_HI)]
|
||||||
|
S, V = spot_series(asset), dvol_series(asset)
|
||||||
|
rows = []
|
||||||
|
for exp, g in wk.groupby("exp"):
|
||||||
|
cand = sorted(g["ts"].unique(),
|
||||||
|
key=lambda t: abs((exp - pd.Timestamp(t)).total_seconds() / 86400.0 - target_dte))
|
||||||
|
for ts in cand:
|
||||||
|
legs = pick_legs(g[g["ts"] == ts])
|
||||||
|
if legs is None:
|
||||||
|
continue
|
||||||
|
t = pd.Timestamp(ts).as_unit("ns")
|
||||||
|
dte = (exp - pd.Timestamp(ts)).total_seconds() / 86400.0
|
||||||
|
spot = float(S.asof(t))
|
||||||
|
dvol = float(V.asof(t))
|
||||||
|
hist = V[V.index < t]
|
||||||
|
ivr = float((hist < dvol).mean()) if len(hist) else np.nan
|
||||||
|
rows.append({"asset": asset, "ts": t, "exp": exp, "dte": dte,
|
||||||
|
"spot": spot, "dvol_pct": dvol, "ivrank": ivr,
|
||||||
|
**legs, **f_factors(legs, spot, dvol / 100.0, dte)})
|
||||||
|
break
|
||||||
|
return pd.DataFrame(rows).sort_values("ts").reset_index(drop=True)
|
||||||
|
|
||||||
|
|
||||||
|
def close_cost_path(entry: pd.Series, df: pd.DataFrame | None = None) -> pd.DataFrame:
|
||||||
|
"""Costo di CHIUDERE lo spread, ora per ora, dalle standing quotes delle stesse due gambe.
|
||||||
|
|
||||||
|
Chiudere = ricomprare la corta all'ask e rivendere la lunga al bid (di nuovo conservativo).
|
||||||
|
E' cio' che il backtest modellato non ha: tiene a scadenza, quindi non vede ne' il
|
||||||
|
50%-profit-take ne' il drawdown dentro la settimana.
|
||||||
|
"""
|
||||||
|
d = load_chain() if df is None else df
|
||||||
|
seg = d[(d["asset"] == entry["asset"]) & (d["ts"] > entry["ts"]) & (d["ts"] <= entry["exp"])
|
||||||
|
& (d["instrument_name"].isin([entry["inst_short"], entry["inst_long"]]))]
|
||||||
|
seg = seg.dropna(subset=["bid", "ask"])
|
||||||
|
S = spot_series(entry["asset"])
|
||||||
|
out = []
|
||||||
|
for ts2, g2 in seg.groupby("ts"):
|
||||||
|
s2 = g2[g2["instrument_name"] == entry["inst_short"]]
|
||||||
|
l2 = g2[g2["instrument_name"] == entry["inst_long"]]
|
||||||
|
if s2.empty or l2.empty:
|
||||||
|
continue # una gamba sola non prezza lo spread
|
||||||
|
sp2 = float(S.asof(pd.Timestamp(ts2).as_unit("ns")))
|
||||||
|
costo = (float(s2["ask"].iloc[0]) - float(l2["bid"].iloc[0])) * sp2
|
||||||
|
out.append({"ts": ts2, "costo_chiusura": costo,
|
||||||
|
"pnl_aperto": entry["cred_real"] - costo})
|
||||||
|
return pd.DataFrame(out)
|
||||||
@@ -0,0 +1,176 @@
|
|||||||
|
"""VRP01 CONTRO LE QUOTE REALI — il f del credito NETTO, misurato invece che assunto (2026-07-30).
|
||||||
|
|
||||||
|
Il sleeve VRP01 (`src/portfolio/sleeves.VRP_CFG`) usa **f=1.0** applicato al credito NETTO:
|
||||||
|
net_prem = (bs(Ks) - bs(Kl)) * f
|
||||||
|
cioe' assume che il mercato paghi esattamente cio' che BS-su-DVOL-ATM dice, per ENTRAMBE le gambe.
|
||||||
|
Il caveat di ammissione ("premio MODELLATO su DVOL ATM, skew non esplicito") era dichiarato ma mai
|
||||||
|
quantificato, per mancanza di quote vere. cerbero-bite le accumula dal 2026-06-09 (catena piena,
|
||||||
|
entrambe le ali, orarie) -> qui si misura.
|
||||||
|
|
||||||
|
Quattro domande, nell'ordine in cui contano:
|
||||||
|
D1 COPERTURA quante settimane hanno ENTRAMBE le gambe (-0.28/-0.10) sulla stessa scadenza?
|
||||||
|
D2 f REALE credito reale / credito modellato, agli STESSI strike (isola il prezzo, non lo strike)
|
||||||
|
D3 GESTIONE le stesse gambe sono riquotate fino a scadenza? (50%-profit-take e DD infra-settimana)
|
||||||
|
D4 REGIME la finestra contiene il regime in cui VRP01 TRADA davvero (gate IV-rank>0.30)?
|
||||||
|
|
||||||
|
uv run python scripts/research/r0730_vrp_real_quotes.py
|
||||||
|
uv run python scripts/research/r0730_vrp_real_quotes.py --book # + delta sul book (lento)
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import argparse
|
||||||
|
import sys
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
import numpy as np
|
||||||
|
import pandas as pd
|
||||||
|
|
||||||
|
ROOT = Path(__file__).resolve().parents[2]
|
||||||
|
sys.path.insert(0, str(ROOT))
|
||||||
|
sys.path.insert(0, str(ROOT / "scripts" / "research"))
|
||||||
|
|
||||||
|
from cblib import ( # noqa: E402
|
||||||
|
DTE_HI, DTE_LO, close_cost_path, dvol_series, load_chain, puts, weekly_entries,
|
||||||
|
)
|
||||||
|
|
||||||
|
ASSETS = ("ETH", "BTC")
|
||||||
|
HOLDOUT = pd.Timestamp("2025-01-01", tz="UTC")
|
||||||
|
GATE_IVR = 0.30 # il gate di VRP01: sotto questa soglia il sleeve sta FLAT
|
||||||
|
FEE_FRAC = 0.125 # stessa fee del modello (cap Deribit, worst-case)
|
||||||
|
|
||||||
|
|
||||||
|
def _met(r: pd.Series) -> tuple[float, float, float]:
|
||||||
|
r = r.dropna()
|
||||||
|
if len(r) < 10 or r.std() == 0:
|
||||||
|
return float("nan"), float("nan"), float("nan")
|
||||||
|
sh = float(r.mean() / r.std() * np.sqrt(365.25))
|
||||||
|
eq = (1 + r).cumprod()
|
||||||
|
return sh, float((eq / eq.cummax() - 1).min()), float(eq.iloc[-1] ** (365.25 / len(r)) - 1)
|
||||||
|
|
||||||
|
|
||||||
|
def boot_ci_median(x: np.ndarray, n: int = 20000, seed: int = 7) -> tuple[float, float]:
|
||||||
|
rng = np.random.default_rng(seed)
|
||||||
|
med = [np.median(rng.choice(x, size=len(x), replace=True)) for _ in range(n)]
|
||||||
|
return float(np.percentile(med, 2.5)), float(np.percentile(med, 97.5))
|
||||||
|
|
||||||
|
|
||||||
|
def main() -> int:
|
||||||
|
ap = argparse.ArgumentParser()
|
||||||
|
ap.add_argument("--book", action="store_true", help="ricalcola anche il book a 5 sleeve (lento)")
|
||||||
|
args = ap.parse_args()
|
||||||
|
|
||||||
|
chain = load_chain()
|
||||||
|
E = pd.concat([weekly_entries(a, chain) for a in ASSETS], ignore_index=True)
|
||||||
|
|
||||||
|
print("=" * 100)
|
||||||
|
print(" VRP01 CONTRO LE QUOTE REALI (cerbero-bite mainnet)")
|
||||||
|
print("=" * 100)
|
||||||
|
|
||||||
|
# ---------------- D1 ----------------
|
||||||
|
print("\n [D1] COPERTURA")
|
||||||
|
for a in ASSETS:
|
||||||
|
p = puts(a, chain)
|
||||||
|
wk = p[(p["dte"] >= DTE_LO) & (p["dte"] <= DTE_HI)]
|
||||||
|
n = int((E["asset"] == a).sum())
|
||||||
|
print(f" {a}: {len(p):,} righe put ({p['ts'].min():%Y-%m-%d}->{p['ts'].max():%Y-%m-%d}), "
|
||||||
|
f"{100*(1-p['bid'].isna().mean()):.1f}% quotate | finestra 4-10 DTE: {wk['exp'].nunique()} "
|
||||||
|
f"scadenze -> {n} ingressi con ENTRAMBE le gambe")
|
||||||
|
|
||||||
|
# ---------------- D2 ----------------
|
||||||
|
ok = E[E["cred_real"] > 0].copy() # credito negativo = struttura non eseguibile a credito
|
||||||
|
scart = len(E) - len(ok)
|
||||||
|
lo, hi = boot_ci_median(ok["f_net"].to_numpy())
|
||||||
|
nota_scarti = f", {scart} scartati per credito negativo" if scart else ""
|
||||||
|
print(f"\n [D2] f = credito reale / credito modellato (N={len(ok)}{nota_scarti})")
|
||||||
|
print(f" {'grandezza':44s} {'mediana':>9s} {'p25':>8s} {'p75':>8s}")
|
||||||
|
for lab, col in (("f gamba CORTA (venduta al bid)", "f_short"),
|
||||||
|
("f gamba LUNGA (comprata all'ask)", "f_long"),
|
||||||
|
("f CREDITO NETTO <- quello che entra nel book", "f_net"),
|
||||||
|
("f credito netto, a mid", "f_net_mid")):
|
||||||
|
v = ok[col].replace([np.inf, -np.inf], np.nan).dropna()
|
||||||
|
print(f" {lab:44s} {v.median():9.2f} {v.quantile(.25):8.2f} {v.quantile(.75):8.2f}")
|
||||||
|
print(f" f netto: IC95% bootstrap [{lo:.3f}, {hi:.3f}] "
|
||||||
|
f"osservazioni >= 1.0: {int((ok['f_net'] >= 1).sum())}/{len(ok)}")
|
||||||
|
|
||||||
|
ok["skew_short"] = ok["iv_short"] - ok["dvol_pct"]
|
||||||
|
ok["skew_long"] = ok["iv_long"] - ok["dvol_pct"]
|
||||||
|
print(f"\n MECCANISMO (senza il quale il numero sopra e' un artefatto):")
|
||||||
|
print(f" IV(corta) - DVOL = {ok['skew_short'].median():+.1f} pp "
|
||||||
|
f"IV(lunga) - DVOL = {ok['skew_long'].median():+.1f} pp")
|
||||||
|
print(f" -> il modello prezza entrambe le gambe a vol ATM; l'ala che si COMPRA sta "
|
||||||
|
f"{ok['skew_long'].median():.0f} pp sopra")
|
||||||
|
print(f" quindi costa ~{ok['f_long'].median():.1f}x il modello e il credito netto si comprime.")
|
||||||
|
print(f" delta realizzati: corta {ok['d_short'].median():.3f} (target -0.280), "
|
||||||
|
f"lunga {ok['d_long'].median():.3f} (target -0.100) -> non e' un artefatto di strike")
|
||||||
|
|
||||||
|
# ---------------- D3 ----------------
|
||||||
|
print(f"\n [D3] GESTIONE — path a quote reali (il modello tiene a scadenza e non lo vede)")
|
||||||
|
marks, tocca50, worst = [], 0, []
|
||||||
|
for _, e in ok.iterrows():
|
||||||
|
P = close_cost_path(e, chain)
|
||||||
|
marks.append(len(P))
|
||||||
|
if P.empty:
|
||||||
|
continue
|
||||||
|
if (P["pnl_aperto"] >= 0.5 * e["cred_real"]).any():
|
||||||
|
tocca50 += 1
|
||||||
|
worst.append(P["pnl_aperto"].min() / e["width"])
|
||||||
|
marks = np.array(marks)
|
||||||
|
print(f" riquotazioni con ENTRAMBE le gambe: mediana {np.median(marks):.0f} per trade "
|
||||||
|
f"(min {marks.min()}, max {marks.max()})")
|
||||||
|
print(f" settimane in cui il 50%-profit-take sarebbe scattato: {tocca50}/{len(ok)}")
|
||||||
|
if worst:
|
||||||
|
w = np.array(worst)
|
||||||
|
# N < len(ok): gli ingressi piu' recenti non hanno ancora un path (trade in corso)
|
||||||
|
print(f" peggior mark INFRA-settimana (% del capitale=width), su N={len(w)} trade con "
|
||||||
|
f"path: mediana {100*np.median(w):.1f}%, minimo {100*w.min():.1f}%")
|
||||||
|
print(f" -> il sleeve marca solo a scadenza: quel minimo non esiste nei suoi numeri")
|
||||||
|
|
||||||
|
# ---------------- D4 ----------------
|
||||||
|
print(f"\n [D4] REGIME — la finestra contiene i trade che VRP01 farebbe?")
|
||||||
|
for a in ASSETS:
|
||||||
|
V = dvol_series(a)
|
||||||
|
g = E[E["asset"] == a]
|
||||||
|
win = V[(V.index >= g["ts"].min()) & (V.index <= g["ts"].max())]
|
||||||
|
pas = int((g["ivrank"] > GATE_IVR).sum())
|
||||||
|
print(f" {a}: DVOL finestra [{win.min():.1f}, {win.max():.1f}] vs storia "
|
||||||
|
f"[{V.min():.1f}, {V.max():.1f}] — il MAX raccolto sta al {100*(V < win.max()).mean():.0f}° pctl")
|
||||||
|
print(f" IV-rank mediano {g['ivrank'].median():.2f} — settimane che passano il gate "
|
||||||
|
f">{GATE_IVR}: {pas}/{len(g)}")
|
||||||
|
print(f" -> il f e' misurato nel regime in cui il sleeve sta FLAT. Lo skew e' strutturale")
|
||||||
|
print(f" (in stress si irripidisce), ma la TAGLIA in stress resta non misurata.")
|
||||||
|
|
||||||
|
# ---------------- conseguenza sul modello ----------------
|
||||||
|
from src.portfolio import sleeves as SL
|
||||||
|
print(f"\n CONSEGUENZA — VRP01 standalone sostituendo SOLO f (f=1.0 = cio' che il book usa oggi)")
|
||||||
|
print(f" {'f':>6s} {'Sh FULL':>9s} {'DD FULL':>9s} {'CAGR':>8s} {'Sh HOLD-OUT':>12s}")
|
||||||
|
f_mis = float(ok["f_net"].median())
|
||||||
|
f_mid = float(ok["f_net_mid"].median())
|
||||||
|
for f in (1.00, round(f_mid, 2), round(f_mis, 2)):
|
||||||
|
SL.VRP_CFG["f"] = f
|
||||||
|
s = SL._vrp_combo_returns()
|
||||||
|
shF, ddF, cg = _met(s)
|
||||||
|
shH, _, _ = _met(s[s.index >= HOLDOUT])
|
||||||
|
tag = " <- assunto" if f == 1.0 else (" <- misurato a mid" if f == round(f_mid, 2)
|
||||||
|
else " <- misurato, fill conservativo")
|
||||||
|
print(f" {f:6.2f} {shF:9.2f} {100*ddF:8.1f}% {100*cg:7.1f}% {shH:12.2f}{tag}")
|
||||||
|
SL.VRP_CFG["f"] = 1.0
|
||||||
|
|
||||||
|
if args.book:
|
||||||
|
from src.portfolio.portfolio import StrategyPortfolio
|
||||||
|
print(f"\n BOOK a 5 sleeve (VRP01 al 12%) — livelli ancora-canonici, conta il DELTA")
|
||||||
|
base = None
|
||||||
|
for f in (1.00, round(f_mis, 2)):
|
||||||
|
SL.VRP_CFG["f"] = f
|
||||||
|
r = StrategyPortfolio(SL.active_sleeves()).combined_daily()
|
||||||
|
shF, ddF, _ = _met(r)
|
||||||
|
shH, _, _ = _met(r[r.index >= HOLDOUT])
|
||||||
|
if base is None:
|
||||||
|
base = (shF, shH)
|
||||||
|
d = "" if f == 1.0 else f" (Δ {shF-base[0]:+.3f} FULL / {shH-base[1]:+.3f} HOLD)"
|
||||||
|
print(f" f={f:.2f} FULL {shF:.3f} HOLD {shH:.3f} DD {100*ddF:.2f}%{d}")
|
||||||
|
SL.VRP_CFG["f"] = 1.0
|
||||||
|
return 0
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
raise SystemExit(main())
|
||||||
+13
-2
@@ -194,16 +194,23 @@ def book_report(offline: bool = False, equity_override: float | None = None,
|
|||||||
cap = _cap(equity=equity, real_equity=sh.get("real_equity"), eq_fallback=sh.get("eq_fallback"))
|
cap = _cap(equity=equity, real_equity=sh.get("real_equity"), eq_fallback=sh.get("eq_fallback"))
|
||||||
load5m = None
|
load5m = None
|
||||||
feed_ages: dict[str, float | None] = {}
|
feed_ages: dict[str, float | None] = {}
|
||||||
|
feed_errors: dict[str, str] = {}
|
||||||
if live_feed:
|
if live_feed:
|
||||||
from src.live.livefeed import feed_age_minutes, fresh_5m
|
from src.live.livefeed import feed_age_minutes, fresh_5m, last_fetch_error
|
||||||
|
|
||||||
def load5m(a: str):
|
def load5m(a: str):
|
||||||
"""Wrapper che MISURA la freschezza del feed effettivamente usato per il segnale.
|
"""Wrapper che MISURA la freschezza del feed effettivamente usato per il segnale.
|
||||||
`fresh_5m` ricade sul certificato in SILENZIO se il fetch pubblico fallisce: senza
|
`fresh_5m` ricade sul certificato in SILENZIO se il fetch pubblico fallisce: senza
|
||||||
questa misura la latenza d'uscita di SKH01 passerebbe da ~1h a ~1 giorno senza che
|
questa misura la latenza d'uscita di SKH01 passerebbe da ~1h a ~1 giorno senza che
|
||||||
nulla lo segnali (vedi feed_age_minutes)."""
|
nulla lo segnali (vedi feed_age_minutes). Raccoglie anche la CAUSA del fallback:
|
||||||
|
l'eta' dice CHE il feed e' vecchio, non PERCHE' — e senza il perche' la diagnosi
|
||||||
|
non e' rifacibile a posteriori, perche' quando la si prova il guasto e' rientrato
|
||||||
|
(29/07: 6 giri stantii su 8, causa mai stabilita -> vedi livefeed._LAST_ERROR)."""
|
||||||
df = fresh_5m(a)
|
df = fresh_5m(a)
|
||||||
feed_ages[a] = feed_age_minutes(df)
|
feed_ages[a] = feed_age_minutes(df)
|
||||||
|
err = last_fetch_error()
|
||||||
|
if err is not None:
|
||||||
|
feed_errors[a] = err
|
||||||
return df
|
return df
|
||||||
skh_error = None
|
skh_error = None
|
||||||
try:
|
try:
|
||||||
@@ -247,5 +254,9 @@ def book_report(offline: bool = False, equity_override: float | None = None,
|
|||||||
# e' stantio, il segnale netto e' sospetto.
|
# e' stantio, il segnale netto e' sospetto.
|
||||||
skh_feed_age_min=(max((v for v in feed_ages.values() if v is not None), default=None)
|
skh_feed_age_min=(max((v for v in feed_ages.values() if v is not None), default=None)
|
||||||
if feed_ages else None),
|
if feed_ages else None),
|
||||||
|
# PERCHE' la coda fresca non e' stata attaccata, per asset ({} = nessun fallback).
|
||||||
|
# Accompagna skh_feed_age_min: senza, l'allerta puo' solo TIRARE A INDOVINARE la causa
|
||||||
|
# (ed e' esattamente cio' che faceva, con una nota cablata, fino al 29/07).
|
||||||
|
skh_feed_errors=dict(feed_errors),
|
||||||
flat=all(abs(x["net_target"]) < FLAT_USD for x in assets),
|
flat=all(abs(x["net_target"]) < FLAT_USD for x in assets),
|
||||||
)
|
)
|
||||||
|
|||||||
+57
-3
@@ -14,6 +14,7 @@ TP01 è giornaliero e gira bene sul feed certificato.
|
|||||||
"""
|
"""
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import logging
|
||||||
import time
|
import time
|
||||||
|
|
||||||
import pandas as pd
|
import pandas as pd
|
||||||
@@ -25,6 +26,42 @@ from src.data.downloader import load_data
|
|||||||
DERIBIT_SYMBOL = {"BTC": "BTC/USD:BTC", "ETH": "ETH/USD:ETH"}
|
DERIBIT_SYMBOL = {"BTC": "BTC/USD:BTC", "ETH": "ETH/USD:ETH"}
|
||||||
SCHEMA = ["timestamp", "open", "high", "low", "close", "volume"]
|
SCHEMA = ["timestamp", "open", "high", "low", "close", "volume"]
|
||||||
|
|
||||||
|
_LOG = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
# CAUSA dell'ultimo fallback. Il fallback resta SILENZIOSO come comportamento (mai operare a cieco
|
||||||
|
# > mai operare, scelta del 26/07): quello che NON deve restare silenzioso e' il PERCHE'.
|
||||||
|
#
|
||||||
|
# Perche' esiste (2026-07-29): dopo il riavvio della VPS delle 04:11 UTC il feed e' ricaduto sul
|
||||||
|
# certificato in 6 giri orari su 8 fra le 05:00 e le 12:00 -> fino a 685 min di eta', cioe' la
|
||||||
|
# latenza d'uscita di SKH01 passata da ~1h a ~11h. L'allerta del 26/07 ha segnalato ogni volta
|
||||||
|
# (ha funzionato), ma la CAUSA non era recuperabile dai log: l'eccezione di pagina viene ingoiata
|
||||||
|
# con un `break` e `fresh_5m` ritorna il certificato senza lasciare traccia. L'unica riga sulla
|
||||||
|
# causa era CABLATA nell'allerta ("fetch pubblico KO") = una presunzione stampata come misura.
|
||||||
|
# ⚠️ La causa di QUEL giorno resta IGNOTA e tale deve restare scritta: le prove sono solo
|
||||||
|
# circostanziali (i giri falliti durano quanto quelli riusciti, 17-47s -> errore immediato, non
|
||||||
|
# timeout; nello stesso giorno il percorso del CONTO, rete diversa ma stesso venue a valle, dava
|
||||||
|
# ReadTimeout/404/502). Ipotesi non distinguibili a posteriori: rate limit per-IP del venue,
|
||||||
|
# contesa di rete/CPU sulla VPS al minuto tondo, degrado post-riavvio.
|
||||||
|
# Questa variabile serve a che la PROSSIMA occorrenza sia un dato e non un'ipotesi.
|
||||||
|
_LAST_ERROR: str | None = None
|
||||||
|
|
||||||
|
|
||||||
|
def last_fetch_error() -> str | None:
|
||||||
|
"""Perche' l'ultima `fresh_5m` non ha attaccato una coda fresca COMPLETA.
|
||||||
|
|
||||||
|
`None` = coda attaccata senza errori. Non-None = fallback al certificato **oppure** coda
|
||||||
|
troncata a meta' paginazione (la paginazione va in avanti: se cade a pagina N mancano le
|
||||||
|
barre piu' RECENTI, quindi in entrambi i casi il feed e' piu' vecchio di quanto sembri).
|
||||||
|
Chi decide guarda `feed_age_minutes`; questa dice il perche'."""
|
||||||
|
return _LAST_ERROR
|
||||||
|
|
||||||
|
|
||||||
|
def _note_error(msg: str) -> None:
|
||||||
|
"""Registra E logga la ragione del fallback. Non solleva: il chiamante prosegue sul certificato."""
|
||||||
|
global _LAST_ERROR
|
||||||
|
_LAST_ERROR = msg
|
||||||
|
_LOG.warning("fresh_5m: coda fresca non attaccata, fallback al feed certificato — %s", msg)
|
||||||
|
|
||||||
|
|
||||||
def _fetch_recent_5m(symbol: str, lookback_days: int) -> pd.DataFrame:
|
def _fetch_recent_5m(symbol: str, lookback_days: int) -> pd.DataFrame:
|
||||||
"""Coda recente di 5m da Deribit pubblico (ccxt). Paginazione in avanti. Solo letture pubbliche."""
|
"""Coda recente di 5m da Deribit pubblico (ccxt). Paginazione in avanti. Solo letture pubbliche."""
|
||||||
@@ -38,7 +75,12 @@ def _fetch_recent_5m(symbol: str, lookback_days: int) -> pd.DataFrame:
|
|||||||
guard += 1
|
guard += 1
|
||||||
try:
|
try:
|
||||||
r = ex.fetch_ohlcv(symbol, "5m", since=since, limit=1000)
|
r = ex.fetch_ohlcv(symbol, "5m", since=since, limit=1000)
|
||||||
except Exception:
|
except Exception as e:
|
||||||
|
# E' QUI che il guasto diventa invisibile: l'errore di pagina viene ingoiato e la
|
||||||
|
# funzione ritorna comunque cio' che ha raccolto finora. Se a fallire e' la PRIMA
|
||||||
|
# pagina il risultato e' un DataFrame VUOTO, che a valle e' indistinguibile da
|
||||||
|
# "Deribit non ha barre" — ed e' il ramo che il 29/07 ha cancellato la diagnosi.
|
||||||
|
_note_error(f"{type(e).__name__}: {e} (pagina {guard}, {symbol})")
|
||||||
break
|
break
|
||||||
r = [x for x in r if int(x[0]) >= since]
|
r = [x for x in r if int(x[0]) >= since]
|
||||||
if not r:
|
if not r:
|
||||||
@@ -94,13 +136,25 @@ def fresh_5m(asset: str, lookback_days: int = 12) -> pd.DataFrame:
|
|||||||
|
|
||||||
NB: il fallback e' SILENZIOSO per scelta (mai operare a cieco > mai operare). Chi esegue deve
|
NB: il fallback e' SILENZIOSO per scelta (mai operare a cieco > mai operare). Chi esegue deve
|
||||||
misurare la freschezza di cio' che riceve con `feed_age_minutes` — vedi book.book_report, che
|
misurare la freschezza di cio' che riceve con `feed_age_minutes` — vedi book.book_report, che
|
||||||
la espone come `skh_feed_age_min`, e book_execute, che allerta."""
|
la espone come `skh_feed_age_min`, e book_execute, che allerta. La RAGIONE del fallback resta
|
||||||
|
disponibile in `last_fetch_error()` fino alla chiamata successiva."""
|
||||||
|
global _LAST_ERROR
|
||||||
|
_LAST_ERROR = None
|
||||||
base = load_data(asset, "5m")
|
base = load_data(asset, "5m")
|
||||||
sym = DERIBIT_SYMBOL.get(asset)
|
sym = DERIBIT_SYMBOL.get(asset)
|
||||||
if sym is None:
|
if sym is None:
|
||||||
|
_note_error(f"nessun simbolo Deribit per l'asset {asset}")
|
||||||
return base
|
return base
|
||||||
try:
|
try:
|
||||||
tail = _fetch_recent_5m(sym, lookback_days)
|
tail = _fetch_recent_5m(sym, lookback_days)
|
||||||
except Exception:
|
except Exception as e:
|
||||||
|
_note_error(f"{type(e).__name__}: {e} ({sym})")
|
||||||
|
return base
|
||||||
|
if tail is None or len(tail) == 0:
|
||||||
|
# Coda vuota senza eccezione propagata: o _fetch_recent_5m ha gia' registrato l'errore
|
||||||
|
# di pagina, o Deribit ha davvero risposto senza barre. Sono due guasti diversi e vanno
|
||||||
|
# detti diversi, senno' si ricade nel "non vedo = va tutto bene" gia' pagato due volte.
|
||||||
|
if _LAST_ERROR is None:
|
||||||
|
_note_error(f"nessuna barra restituita da {sym} (risposta vuota, nessuna eccezione)")
|
||||||
return base
|
return base
|
||||||
return merge_tail(base, tail)
|
return merge_tail(base, tail)
|
||||||
|
|||||||
@@ -74,6 +74,11 @@ MONITORS: tuple[MonitorSpec, ...] = (
|
|||||||
# calendario di BORSA: le gambe GTAA vengono da IB, il weekend non e' un guasto.
|
# calendario di BORSA: le gambe GTAA vengono da IB, il weekend non e' un guasto.
|
||||||
MonitorSpec("paper_combo", "paper_combo", "equity.csv", 24.0,
|
MonitorSpec("paper_combo", "paper_combo", "equity.csv", 24.0,
|
||||||
calendar="equity", max_age_h=120.0),
|
calendar="equity", max_age_h=120.0),
|
||||||
|
# Raccolta catena opzioni (successore di cerbero-bite): ORARIA. Non alimenta un gate a data
|
||||||
|
# fissa ma una serie IRRECUPERABILE — un'ora non raccolta non si recupera da nessuna fonte,
|
||||||
|
# quindi la soglia d'eta' e' stretta (3h = due giri persi).
|
||||||
|
MonitorSpec("collect_chain", "chain_collect", "runs.jsonl", 1.0, max_age_h=3.0,
|
||||||
|
gate="serie irrecuperabile — la catena opzioni non si ricostruisce a posteriori"),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,198 @@
|
|||||||
|
"""Test della catena opzioni reale (cerbero-bite) e della misura di f per VRP01.
|
||||||
|
|
||||||
|
Due famiglie:
|
||||||
|
A. RILEVATORI di difetto del dato (quote vuote / book incrociato / non monotonia). Ognuno ha il
|
||||||
|
suo CONTROLLO POSITIVO: un rilevatore che non segnala mai e' indistinguibile da uno rotto.
|
||||||
|
B. MISURA di f. Il punto non e' "calcola un rapporto" ma che il rapporto sia quello GIUSTO:
|
||||||
|
f dello spread, non della gamba corta — e' la distinzione che ha ribaltato la conclusione
|
||||||
|
del 30/07 rispetto alla calibrazione del 20/06.
|
||||||
|
|
||||||
|
Nessun test tocca il DB o la rete: tutte le fixture sono sintetiche.
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import sys
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
import numpy as np
|
||||||
|
import pandas as pd
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
ROOT = Path(__file__).resolve().parents[1]
|
||||||
|
sys.path.insert(0, str(ROOT))
|
||||||
|
sys.path.insert(0, str(ROOT / "scripts" / "research"))
|
||||||
|
sys.path.insert(0, str(ROOT / "scripts" / "analysis"))
|
||||||
|
|
||||||
|
from cblib import bs_put, f_factors, pick_legs # noqa: E402
|
||||||
|
from certify_cb_chain import ( # noqa: E402
|
||||||
|
crossed_rate, hollow_rate, monotonicity_violations, worst_day_hollow,
|
||||||
|
)
|
||||||
|
|
||||||
|
TS = pd.Timestamp("2026-07-24T08:00:00Z")
|
||||||
|
EXP = pd.Timestamp("2026-07-31T08:00:00Z")
|
||||||
|
|
||||||
|
|
||||||
|
def _riga(strike, bid, ask, delta, mid=None, iv=50.0, ts=TS, tipo="P"):
|
||||||
|
return {
|
||||||
|
"ts": ts, "asset": "ETH", "instrument_name": f"ETH-31JUL26-{int(strike)}-{tipo}",
|
||||||
|
"strike": float(strike), "exp": EXP, "option_type": tipo,
|
||||||
|
"bid": bid, "ask": ask, "mid": (bid + ask) / 2 if mid is None and bid is not None else mid,
|
||||||
|
"iv": iv, "delta": delta, "book_depth_top3": 100, "source": "research",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _catena() -> pd.DataFrame:
|
||||||
|
"""Catena put plausibile: premio crescente nello strike, delta piu' negativo vicino allo spot."""
|
||||||
|
return pd.DataFrame([
|
||||||
|
_riga(1500, 0.0020, 0.0025, -0.05),
|
||||||
|
_riga(1600, 0.0050, 0.0055, -0.10),
|
||||||
|
_riga(1700, 0.0110, 0.0120, -0.20),
|
||||||
|
_riga(1750, 0.0160, 0.0175, -0.28),
|
||||||
|
_riga(1800, 0.0240, 0.0260, -0.40),
|
||||||
|
])
|
||||||
|
|
||||||
|
|
||||||
|
# --------------------------------------------------------------- A. rilevatori
|
||||||
|
|
||||||
|
def test_quote_vuote_contate_e_non_confuse_con_righe_mancanti():
|
||||||
|
df = _catena()
|
||||||
|
df.loc[[0, 1], ["bid", "ask", "mid", "iv", "delta"]] = np.nan # ticker fallito: la riga RESTA
|
||||||
|
assert len(df) == 5, "una quota vuota non riduce il numero di righe — e' il punto del difetto"
|
||||||
|
assert hollow_rate(df) == pytest.approx(2 / 5)
|
||||||
|
|
||||||
|
|
||||||
|
def test_controllo_positivo_catena_sana_non_segnala():
|
||||||
|
df = _catena()
|
||||||
|
assert hollow_rate(df) == 0.0
|
||||||
|
assert crossed_rate(df) == 0.0
|
||||||
|
assert monotonicity_violations(df) == 0.0
|
||||||
|
|
||||||
|
|
||||||
|
def test_book_incrociato_segnalato():
|
||||||
|
df = _catena()
|
||||||
|
df.loc[2, "bid"] = df.loc[2, "ask"] + 0.01
|
||||||
|
assert crossed_rate(df) == pytest.approx(1 / 5)
|
||||||
|
|
||||||
|
|
||||||
|
def test_premio_non_monotono_nello_strike_segnalato():
|
||||||
|
"""Il premio di una put e' non-decrescente nello strike: e' il payoff, non un modello."""
|
||||||
|
df = _catena()
|
||||||
|
df.loc[3, "mid"] = 0.0001 # quota stantia/corrotta
|
||||||
|
v = monotonicity_violations(df)
|
||||||
|
assert v > 0, "una put che costa MENO di una piu' OTM e' un difetto, va segnalato"
|
||||||
|
|
||||||
|
|
||||||
|
def test_giorno_peggiore_non_si_lascia_diluire_dalla_media():
|
||||||
|
"""Il guasto del 29/07 mediato su 7 giorni si legge 13%: la statistica giusta e' il giorno."""
|
||||||
|
righe = []
|
||||||
|
for g in range(7):
|
||||||
|
ts = TS + pd.Timedelta(days=g)
|
||||||
|
vuote = 8 if g == 6 else 0 # guasto solo nell'ultimo giorno
|
||||||
|
for i in range(10):
|
||||||
|
r = _riga(1500 + 50 * i, 0.01, 0.011, -0.2, ts=ts)
|
||||||
|
if i < vuote:
|
||||||
|
r["bid"] = r["ask"] = r["mid"] = None
|
||||||
|
righe.append(r)
|
||||||
|
df = pd.DataFrame(righe)
|
||||||
|
media = hollow_rate(df)
|
||||||
|
peggiore, giorno = worst_day_hollow(df, days=7)
|
||||||
|
assert media == pytest.approx(8 / 70) # ~11%: il guasto sembra piccolo
|
||||||
|
assert peggiore == pytest.approx(0.8) # 80%: il guasto com'e' davvero
|
||||||
|
assert giorno == (TS + pd.Timedelta(days=6)).date()
|
||||||
|
|
||||||
|
|
||||||
|
# --------------------------------------------------------------- B. struttura e f
|
||||||
|
|
||||||
|
def test_pick_legs_prende_le_gambe_piu_vicine_ai_delta_bersaglio():
|
||||||
|
legs = pick_legs(_catena())
|
||||||
|
assert legs is not None
|
||||||
|
assert legs["k_short"] == 1750 and legs["d_short"] == pytest.approx(-0.28)
|
||||||
|
assert legs["k_long"] == 1600 and legs["d_long"] == pytest.approx(-0.10)
|
||||||
|
|
||||||
|
|
||||||
|
def test_una_gamba_sola_quotata_non_e_meta_struttura():
|
||||||
|
"""Con una gamba sola non esiste un credit spread: va scartato, non prezzato a meta'."""
|
||||||
|
df = _catena()
|
||||||
|
df.loc[df["strike"] != 1750, ["bid", "ask"]] = np.nan
|
||||||
|
assert pick_legs(df) is None
|
||||||
|
|
||||||
|
|
||||||
|
def test_gambe_incrociate_rifiutate():
|
||||||
|
"""Se la 'corta' ha strike sotto la 'lunga' la struttura e' invertita: None, non un credito."""
|
||||||
|
df = _catena().copy()
|
||||||
|
df["delta"] = [-0.28, -0.20, -0.10, -0.05, -0.02] # delta bersaglio sugli strike sbagliati
|
||||||
|
assert pick_legs(df) is None
|
||||||
|
|
||||||
|
|
||||||
|
def test_f_uguale_a_uno_quando_il_mercato_quota_esattamente_il_modello():
|
||||||
|
"""Controllo di round-trip: quote costruite DAL modello devono dare f = 1 su tutto."""
|
||||||
|
spot, dvol, dte = 1800.0, 0.50, 7.0
|
||||||
|
T = dte / 365.25
|
||||||
|
ks, kl = 1750.0, 1600.0
|
||||||
|
ps, pl = bs_put(spot, ks, T, dvol), bs_put(spot, kl, T, dvol)
|
||||||
|
legs = {"k_short": ks, "k_long": kl,
|
||||||
|
"bid_short": ps / spot, "ask_short": ps / spot,
|
||||||
|
"bid_long": pl / spot, "ask_long": pl / spot,
|
||||||
|
"mid_short": ps / spot, "mid_long": pl / spot}
|
||||||
|
f = f_factors(legs, spot, dvol, dte)
|
||||||
|
assert f["f_short"] == pytest.approx(1.0, rel=1e-9)
|
||||||
|
assert f["f_long"] == pytest.approx(1.0, rel=1e-9)
|
||||||
|
assert f["f_net"] == pytest.approx(1.0, rel=1e-9)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize("k_long", [1700.0, 1650.0, 1600.0])
|
||||||
|
def test_il_f_della_gamba_corta_non_e_il_f_dello_spread(k_long):
|
||||||
|
"""IL test che vale il filone: con la sola ala comprata piu' cara del modello (skew), la gamba
|
||||||
|
corta resta a f~1 e il CREDITO NETTO si comprime. Misurare una gamba sola da' la risposta
|
||||||
|
sbagliata con segno rassicurante — e' cio' che era successo con la calibrazione del 20/06.
|
||||||
|
|
||||||
|
La compressione segue l'identita' f_net = (1 - f_long*k) / (1 - k), k = premio_lungo/premio_corto:
|
||||||
|
quanto piu' l'ala comprata pesa nel credito, tanto piu' il credito netto crolla a parita' di
|
||||||
|
f_long. Per questo il test gira su tre ali: una soglia sola sarebbe stata una fixture fortunata.
|
||||||
|
"""
|
||||||
|
spot, dvol, dte, f_long = 1800.0, 0.50, 7.0, 2.3
|
||||||
|
T = dte / 365.25
|
||||||
|
ks = 1750.0
|
||||||
|
ps, pl = bs_put(spot, ks, T, dvol), bs_put(spot, k_long, T, dvol)
|
||||||
|
legs = {"k_short": ks, "k_long": k_long,
|
||||||
|
"bid_short": ps / spot, "ask_short": ps / spot,
|
||||||
|
"bid_long": f_long * pl / spot, "ask_long": f_long * pl / spot,
|
||||||
|
"mid_short": ps / spot, "mid_long": f_long * pl / spot}
|
||||||
|
f = f_factors(legs, spot, dvol, dte)
|
||||||
|
k = pl / ps
|
||||||
|
assert f["f_short"] == pytest.approx(1.0, rel=1e-9), "la gamba venduta e' prezzata bene"
|
||||||
|
assert f["f_long"] == pytest.approx(f_long, rel=1e-9)
|
||||||
|
assert f["f_net"] == pytest.approx((1 - f_long * k) / (1 - k), rel=1e-9)
|
||||||
|
assert f["f_net"] < f["f_short"], "il f dello spread e' SEMPRE sotto quello della gamba corta"
|
||||||
|
assert f["f_net"] < 1.0
|
||||||
|
|
||||||
|
|
||||||
|
def test_fill_conservativo_non_e_mai_migliore_del_mid():
|
||||||
|
"""Attraversare lo spread non puo' aumentare il credito: guardia di verso, non di valore."""
|
||||||
|
legs = pick_legs(_catena())
|
||||||
|
f = f_factors(legs, spot=1800.0, dvol_frac=0.50, dte_days=7.0)
|
||||||
|
assert f["cred_real"] <= f["cred_mid"] + 1e-12
|
||||||
|
assert f["f_net"] <= f["f_net_mid"] + 1e-12
|
||||||
|
|
||||||
|
|
||||||
|
# --------------------------------------------------------------- C. guardia sulla convenzione
|
||||||
|
|
||||||
|
def test_nel_sleeve_f_moltiplica_il_credito_NETTO():
|
||||||
|
"""Tutta l'aritmetica del 30/07 poggia su questa convenzione: `f` scala il credito NETTO
|
||||||
|
(`net_prem = (bs(Ks) - bs(Kl)) * f`). Se qualcuno cambia dove entra `f`, il f misurato sulle
|
||||||
|
quote reali non e' piu' il parametro giusto da sostituire e le conclusioni del diario
|
||||||
|
2026-07-30 vanno rifatte. Questo test deve rompersi PRIMA che questo accada."""
|
||||||
|
import inspect
|
||||||
|
|
||||||
|
from src.portfolio import sleeves as SL
|
||||||
|
|
||||||
|
src = inspect.getsource(SL._vrp_weekly_asset)
|
||||||
|
righe = [r.strip() for r in src.splitlines() if "net_prem" in r and "cfg[\"f\"]" in r]
|
||||||
|
assert righe, "non trovo piu' `net_prem = (...) * cfg['f']` in _vrp_weekly_asset"
|
||||||
|
r = righe[0]
|
||||||
|
assert r.count("_bs_put") == 2 and r.index("cfg[\"f\"]") > r.rindex("_bs_put"), (
|
||||||
|
"`f` deve moltiplicare la DIFFERENZA dei due premi, non una gamba sola"
|
||||||
|
)
|
||||||
|
assert SL.VRP_CFG["f"] == 1.0, (
|
||||||
|
"il sleeve gira ancora a f=1.0: se cambia, aggiornare il diario 2026-07-30 e CLAUDE.md"
|
||||||
|
)
|
||||||
@@ -0,0 +1,139 @@
|
|||||||
|
"""Test del collettore catena opzioni (successore di cerbero-bite).
|
||||||
|
|
||||||
|
Il collettore esiste per NON ripetere tre difetti misurati su bite il 2026-07-30. I test
|
||||||
|
sorvegliano esattamente quei tre, perche' sono difetti SILENZIOSI: nessuno di loro fa fallire un
|
||||||
|
giro, tutti e tre corrompono una serie irrecuperabile.
|
||||||
|
|
||||||
|
1. una chiamata fallita non deve diventare una riga che sembra un dato;
|
||||||
|
2. "book vuoto" e "chiamata fallita" non devono collassare sullo stesso valore;
|
||||||
|
3. il pacing deve esserci davvero (bite si auto-saturava il rate limit per-IP).
|
||||||
|
|
||||||
|
Nessun test tocca la rete.
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import sys
|
||||||
|
import time
|
||||||
|
from datetime import UTC, datetime
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
import pandas as pd
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
ROOT = Path(__file__).resolve().parents[1]
|
||||||
|
sys.path.insert(0, str(ROOT))
|
||||||
|
sys.path.insert(0, str(ROOT / "scripts" / "live"))
|
||||||
|
|
||||||
|
from collect_chain import Budget, _depth_top3, snapshot_row, write # noqa: E402
|
||||||
|
|
||||||
|
TS = datetime(2026, 7, 30, 20, 0, tzinfo=UTC)
|
||||||
|
INST = {
|
||||||
|
"instrument_name": "ETH-31JUL26-1750-P", "base_currency": "ETH",
|
||||||
|
"strike": 1750.0, "option_type": "put",
|
||||||
|
"expiration_timestamp": int(pd.Timestamp("2026-07-31T08:00:00Z").timestamp() * 1000),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _ob(bid=0.016, ask=0.0175, bids=((0.016, 10.0),), asks=((0.0175, 12.0),)):
|
||||||
|
return {
|
||||||
|
"best_bid_price": bid, "best_ask_price": ask, "mark_iv": 52.3,
|
||||||
|
"greeks": {"delta": -0.28, "gamma": 0.001, "theta": -1.2, "vega": 0.9},
|
||||||
|
"open_interest": 500.0, "stats": {"volume": 31.0},
|
||||||
|
"bids": [list(b) for b in bids], "asks": [list(a) for a in asks],
|
||||||
|
"underlying_price": 1923.4, "index_price": 1922.7,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
# ------------------------------------------------- 1. una chiamata fallita non e' un dato
|
||||||
|
|
||||||
|
def test_chiamata_fallita_marcata_error_e_senza_quota():
|
||||||
|
r = snapshot_row(INST, None, TS)
|
||||||
|
assert r["quote_status"] == "error"
|
||||||
|
assert r["bid"] is None and r["ask"] is None and r["iv"] is None
|
||||||
|
assert r["instrument_name"] == INST["instrument_name"], (
|
||||||
|
"la riga si scrive lo stesso: cio' che cambia e' che dichiara di non avere il dato"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_profondita_e_NULL_su_errore_mai_zero():
|
||||||
|
"""Il difetto di bite: depth 0 su chiamata fallita = indistinguibile da 'book vuoto'.
|
||||||
|
Qui su errore la profondita' non esiste, e un book davvero vuoto vale 0."""
|
||||||
|
assert snapshot_row(INST, None, TS)["book_depth_top3"] is None
|
||||||
|
vuoto = snapshot_row(INST, _ob(bid=0.0, ask=0.0, bids=(), asks=()), TS)
|
||||||
|
assert vuoto["book_depth_top3"] == 0.0
|
||||||
|
assert vuoto["quote_status"] == "no_quote"
|
||||||
|
|
||||||
|
|
||||||
|
def test_book_vuoto_e_chiamata_fallita_sono_stati_DIVERSI():
|
||||||
|
"""Controllo positivo dell'idea stessa del collettore: se questi due collassassero, la
|
||||||
|
diagnostica del 29/07 (50% di quote perse per rate-limit) sarebbe di nuovo impossibile."""
|
||||||
|
fallita = snapshot_row(INST, None, TS)["quote_status"]
|
||||||
|
vuoto = snapshot_row(INST, _ob(bid=0.0, ask=0.0, bids=(), asks=()), TS)["quote_status"]
|
||||||
|
assert fallita == "error" and vuoto == "no_quote" and fallita != vuoto
|
||||||
|
|
||||||
|
|
||||||
|
def test_un_solo_lato_quotato_e_comunque_ok():
|
||||||
|
"""Un book con solo il bid e' un mercato reale (illiquido), non un guasto."""
|
||||||
|
r = snapshot_row(INST, _ob(ask=0.0, asks=()), TS)
|
||||||
|
assert r["quote_status"] == "ok"
|
||||||
|
assert r["bid"] is not None and r["ask"] is None
|
||||||
|
assert r["mid"] is None, "senza un lato il mid non esiste: non si inventa"
|
||||||
|
|
||||||
|
|
||||||
|
def test_campi_valorizzati_quando_la_quota_c_e():
|
||||||
|
r = snapshot_row(INST, _ob(), TS)
|
||||||
|
assert r["quote_status"] == "ok"
|
||||||
|
assert r["mid"] == pytest.approx((0.016 + 0.0175) / 2)
|
||||||
|
assert r["delta"] == pytest.approx(-0.28) and r["iv"] == pytest.approx(52.3)
|
||||||
|
assert r["book_depth_top3"] == pytest.approx(22.0)
|
||||||
|
assert r["underlying_price"] == pytest.approx(1923.4)
|
||||||
|
assert r["option_type"] == "P"
|
||||||
|
|
||||||
|
|
||||||
|
def test_depth_top3_somma_solo_i_primi_tre_livelli():
|
||||||
|
assert _depth_top3([[1, 5], [2, 5], [3, 5], [4, 100]]) == pytest.approx(15.0)
|
||||||
|
assert _depth_top3([]) == 0.0
|
||||||
|
assert _depth_top3(None) is None
|
||||||
|
|
||||||
|
|
||||||
|
# ------------------------------------------------- 3. il pacing deve esistere
|
||||||
|
|
||||||
|
def test_il_budget_impone_davvero_una_cadenza():
|
||||||
|
"""Bite non aveva pacing: ~44 chiamate/s per 26s. Qui la cadenza e' una proprieta' misurabile."""
|
||||||
|
b = Budget(rps=20.0)
|
||||||
|
t0 = time.monotonic()
|
||||||
|
for _ in range(6):
|
||||||
|
b.wait()
|
||||||
|
dur = time.monotonic() - t0
|
||||||
|
assert dur >= 5 / 20.0 * 0.8, f"6 chiamate a 20/s non possono durare {dur:.3f}s"
|
||||||
|
|
||||||
|
|
||||||
|
def test_il_budget_conta_i_rate_limit_e_gli_errori():
|
||||||
|
b = Budget(rps=100.0)
|
||||||
|
b.note_error("boom")
|
||||||
|
b.rate_limited += 2
|
||||||
|
assert b.errors == 1 and b.rate_limited == 2 and b.err_samples == ["boom"]
|
||||||
|
|
||||||
|
|
||||||
|
# ------------------------------------------------- scrittura
|
||||||
|
|
||||||
|
def test_write_deduplica_e_tiene_l_ultima_osservazione(tmp_path, monkeypatch):
|
||||||
|
import collect_chain as CC
|
||||||
|
monkeypatch.setattr(CC, "STORE", tmp_path)
|
||||||
|
a = pd.DataFrame([snapshot_row(INST, _ob(bid=0.010), TS)])
|
||||||
|
b = pd.DataFrame([snapshot_row(INST, _ob(bid=0.020), TS)]) # stesso (ts, strumento)
|
||||||
|
CC.write(a, TS)
|
||||||
|
p = CC.write(b, TS)
|
||||||
|
out = pd.read_parquet(p)
|
||||||
|
assert len(out) == 1, "stesso istante e stesso strumento = una riga sola"
|
||||||
|
assert out.iloc[0]["bid"] == pytest.approx(0.020), "vince l'osservazione piu' recente"
|
||||||
|
|
||||||
|
|
||||||
|
def test_write_separa_i_giorni(tmp_path, monkeypatch):
|
||||||
|
import collect_chain as CC
|
||||||
|
monkeypatch.setattr(CC, "STORE", tmp_path)
|
||||||
|
CC.write(pd.DataFrame([snapshot_row(INST, _ob(), TS)]), TS)
|
||||||
|
dopo = TS.replace(day=31)
|
||||||
|
CC.write(pd.DataFrame([snapshot_row(INST, _ob(), dopo)]), dopo)
|
||||||
|
assert sorted(p.name for p in tmp_path.glob("*.parquet")) == \
|
||||||
|
["2026-07-30.parquet", "2026-07-31.parquet"]
|
||||||
@@ -21,6 +21,7 @@ sys.path.insert(0, str(ROOT))
|
|||||||
|
|
||||||
from src.live.monitor_health import ( # noqa: E402
|
from src.live.monitor_health import ( # noqa: E402
|
||||||
MIN_COVERAGE,
|
MIN_COVERAGE,
|
||||||
|
MONITORS,
|
||||||
MonitorSpec,
|
MonitorSpec,
|
||||||
alerts_from,
|
alerts_from,
|
||||||
assess,
|
assess,
|
||||||
@@ -170,6 +171,8 @@ def test_i_monitor_reali_sono_leggibili_e_giudicati():
|
|||||||
"""Non asserisce che siano sani (dipende da quando gira il test): asserisce che la guardia
|
"""Non asserisce che siano sani (dipende da quando gira il test): asserisce che la guardia
|
||||||
li trova e si pronuncia su ognuno — il fallimento silenzioso sarebbe una lista vuota."""
|
li trova e si pronuncia su ognuno — il fallimento silenzioso sarebbe una lista vuota."""
|
||||||
rows = check_all(ROOT, now=NOW)
|
rows = check_all(ROOT, now=NOW)
|
||||||
assert len(rows) == 6
|
# legato al REGISTRO, non a una costante: aggiungere un monitor e' normale (30/07:
|
||||||
|
# collect_chain), dimenticarne uno per strada no.
|
||||||
|
assert len(rows) == len(MONITORS) >= 6
|
||||||
assert all(r["status"] in ("OK", "NUOVO", "FERMO", "BUCATO", "ASSENTE") for r in rows)
|
assert all(r["status"] in ("OK", "NUOVO", "FERMO", "BUCATO", "ASSENTE") for r in rows)
|
||||||
assert {"paper_xsr", "paper_statarb", "paper_dvolspread"} <= {r["name"] for r in rows}
|
assert {"paper_xsr", "paper_statarb", "paper_dvolspread"} <= {r["name"] for r in rows}
|
||||||
|
|||||||
@@ -1,12 +1,20 @@
|
|||||||
"""Test della misura di FRESCHEZZA del feed 5m usato dal segnale SKH01 (2026-07-26).
|
"""Test della misura di FRESCHEZZA del feed 5m usato dal segnale SKH01 (2026-07-26).
|
||||||
|
|
||||||
Perche' esiste questo file: `src.live.livefeed.fresh_5m` ricade sul feed certificato **in
|
Perche' esiste questo file: `src.live.livefeed.fresh_5m` ricade sul feed certificato **in
|
||||||
silenzio** se il fetch pubblico Deribit fallisce — non solleva, non logga. Il feed certificato si
|
silenzio** se il fetch pubblico Deribit fallisce — non solleva. Il feed certificato si
|
||||||
rigenera una volta al giorno, quindi in quel caso la latenza d'uscita di SKH01 passa da ~1 ora a
|
rigenera una volta al giorno, quindi in quel caso la latenza d'uscita di SKH01 passa da ~1 ora a
|
||||||
~1 giorno senza che nulla lo segnali. Ed e' proprio la latenza d'uscita dove vive la qualita' del
|
~1 giorno senza che nulla lo segnali. Ed e' proprio la latenza d'uscita dove vive la qualita' del
|
||||||
path live (misura del 2026-07-26: modellarla male sottostimava il book di +0.081 Sharpe FULL).
|
path live (misura del 2026-07-26: modellarla male sottostimava il book di +0.081 Sharpe FULL).
|
||||||
|
|
||||||
I test blindano che la misura ci sia, sia corretta nei casi limite, e arrivi fino al report.
|
I test blindano che la misura ci sia, sia corretta nei casi limite, e arrivi fino al report.
|
||||||
|
|
||||||
|
AGGIUNTA 2026-07-29 — il fallback resta silenzioso come COMPORTAMENTO, ma non piu' come CAUSA.
|
||||||
|
Quel giorno il feed e' ricaduto sul certificato in 6 giri orari su 8 (fino a 685 min di eta') e
|
||||||
|
l'allerta incolpava il "fetch pubblico KO" solo perche' quella nota era CABLATA in book_execute:
|
||||||
|
la causa vera non e' mai stata stabilita, perche' l'eccezione era gia' stata ingoiata e a
|
||||||
|
guasto rientrato non c'era piu' niente da interrogare. I test in coda blindano che la ragione
|
||||||
|
venga REGISTRATA, che distingua guasti diversi, e che non sopravviva a una chiamata riuscita
|
||||||
|
(senno' l'allerta riporterebbe un guasto gia' rientrato).
|
||||||
"""
|
"""
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
@@ -21,6 +29,7 @@ sys.path.insert(0, str(ROOT))
|
|||||||
pytest.importorskip("pandas")
|
pytest.importorskip("pandas")
|
||||||
import pandas as pd # noqa: E402
|
import pandas as pd # noqa: E402
|
||||||
|
|
||||||
|
import src.live.livefeed as lf # noqa: E402
|
||||||
from src.live.livefeed import SCHEMA, feed_age_minutes, merge_tail # noqa: E402
|
from src.live.livefeed import SCHEMA, feed_age_minutes, merge_tail # noqa: E402
|
||||||
|
|
||||||
|
|
||||||
@@ -87,6 +96,111 @@ def test_book_report_espone_la_chiave(monkeypatch):
|
|||||||
assert r["skh_feed_age_min"] is None
|
assert r["skh_feed_age_min"] is None
|
||||||
|
|
||||||
|
|
||||||
|
def _stub_certificato(monkeypatch):
|
||||||
|
"""Feed 'certificato' finto e vecchio di 10 ore. Evita di leggere i 22MB reali da disco:
|
||||||
|
questi test riguardano la CAUSA del fallback, non i dati."""
|
||||||
|
old = NOW - 10 * 60 * 60_000
|
||||||
|
monkeypatch.setattr(lf, "load_data", lambda a, tf: _df([old - 300_000, old]))
|
||||||
|
|
||||||
|
|
||||||
|
def test_causa_registrata_quando_il_fetch_solleva(monkeypatch):
|
||||||
|
"""La forma del guasto del 29/07: l'errore cade sulla PRIMA pagina. `_fetch_recent_5m` lo
|
||||||
|
ingoia con `break` e ritorna un frame vuoto, quindi `fresh_5m` non vede nessuna eccezione ->
|
||||||
|
senza registrazione esplicita la ragione sparisce e resta solo 'il feed e' vecchio'.
|
||||||
|
(Il 429 qui e' un errore d'esempio: la causa reale di quel giorno e' rimasta ignota.)"""
|
||||||
|
ccxt = pytest.importorskip("ccxt")
|
||||||
|
_stub_certificato(monkeypatch)
|
||||||
|
|
||||||
|
class Boom:
|
||||||
|
def fetch_ohlcv(self, *a, **k):
|
||||||
|
raise RuntimeError("429 Too Many Requests")
|
||||||
|
|
||||||
|
monkeypatch.setattr(ccxt, "deribit", lambda *a, **k: Boom())
|
||||||
|
lf.fresh_5m("BTC")
|
||||||
|
err = lf.last_fetch_error()
|
||||||
|
assert err is not None, "la causa del fallback dev'essere registrata, non presunta"
|
||||||
|
assert "429" in err and "BTC/USD:BTC" in err, err
|
||||||
|
|
||||||
|
|
||||||
|
def test_causa_distingue_risposta_vuota_da_eccezione(monkeypatch):
|
||||||
|
"""Deribit che risponde SENZA barre e Deribit che solleva sono guasti diversi (il primo non
|
||||||
|
e' un errore di rete). Se la nota fosse la stessa, la diagnosi ripartirebbe da zero."""
|
||||||
|
ccxt = pytest.importorskip("ccxt")
|
||||||
|
_stub_certificato(monkeypatch)
|
||||||
|
|
||||||
|
class Vuoto:
|
||||||
|
def fetch_ohlcv(self, *a, **k):
|
||||||
|
return []
|
||||||
|
|
||||||
|
monkeypatch.setattr(ccxt, "deribit", lambda *a, **k: Vuoto())
|
||||||
|
lf.fresh_5m("BTC")
|
||||||
|
err = lf.last_fetch_error()
|
||||||
|
assert err is not None and "429" not in err, err
|
||||||
|
assert "vuota" in err or "nessuna barra" in err, err
|
||||||
|
|
||||||
|
|
||||||
|
def test_causa_registrata_anche_con_coda_TRONCATA(monkeypatch):
|
||||||
|
"""Il caso intermedio, quello che un test pigro salta: l'errore cade a META' paginazione.
|
||||||
|
La coda parziale VIENE attaccata (meglio di niente) -> il feed non e' 'caduto', e' solo
|
||||||
|
piu' vecchio del dovuto. Siccome la paginazione va IN AVANTI, cio' che manca sono le barre
|
||||||
|
PIU' RECENTI: e' esattamente il caso in cui l'eta' da sola direbbe poco e la causa serve."""
|
||||||
|
ccxt = pytest.importorskip("ccxt")
|
||||||
|
_stub_certificato(monkeypatch)
|
||||||
|
primo = NOW - 3 * 60 * 60_000 # coda attaccata, ma ferma a 3 ore fa
|
||||||
|
|
||||||
|
class MezzaStrada:
|
||||||
|
def __init__(self):
|
||||||
|
self._servito = False
|
||||||
|
|
||||||
|
def fetch_ohlcv(self, *a, **k):
|
||||||
|
if self._servito:
|
||||||
|
raise RuntimeError("ConnectionResetError alla seconda pagina")
|
||||||
|
self._servito = True
|
||||||
|
return [[primo, 1.0, 1.0, 1.0, 1.0, 1.0]]
|
||||||
|
|
||||||
|
monkeypatch.setattr(ccxt, "deribit", lambda *a, **k: MezzaStrada())
|
||||||
|
df = lf.fresh_5m("BTC")
|
||||||
|
err = lf.last_fetch_error()
|
||||||
|
assert err is not None and "pagina 2" in err, err
|
||||||
|
# la coda parziale c'e' davvero (l'ultima barra e' quella della pagina 1, non del certificato)
|
||||||
|
assert int(df["timestamp"].iloc[-1]) == primo
|
||||||
|
# ...e l'eta' resta fuori soglia: la causa spiega un feed vecchio, non ne salva uno.
|
||||||
|
assert feed_age_minutes(df, now_ms=NOW) == pytest.approx(175.0)
|
||||||
|
|
||||||
|
|
||||||
|
def test_causa_non_sopravvive_a_una_chiamata_riuscita(monkeypatch):
|
||||||
|
"""Coda attaccata -> nessuna causa residua: un errore vecchio che resta appiccicato farebbe
|
||||||
|
allertare su un guasto gia' rientrato (rumore che spegne l'attenzione sull'allerta vera)."""
|
||||||
|
ccxt = pytest.importorskip("ccxt")
|
||||||
|
_stub_certificato(monkeypatch)
|
||||||
|
recente = NOW - 5 * 60_000
|
||||||
|
|
||||||
|
class Ok:
|
||||||
|
def __init__(self):
|
||||||
|
self._servito = False
|
||||||
|
|
||||||
|
def fetch_ohlcv(self, *a, **k):
|
||||||
|
if self._servito:
|
||||||
|
return []
|
||||||
|
self._servito = True
|
||||||
|
return [[recente, 1.0, 1.0, 1.0, 1.0, 1.0]]
|
||||||
|
|
||||||
|
monkeypatch.setattr(ccxt, "deribit", lambda *a, **k: Ok())
|
||||||
|
lf._LAST_ERROR = "residuo di una chiamata precedente"
|
||||||
|
df = lf.fresh_5m("BTC")
|
||||||
|
assert lf.last_fetch_error() is None, lf.last_fetch_error()
|
||||||
|
assert feed_age_minutes(df, now_ms=NOW) == pytest.approx(0.0)
|
||||||
|
|
||||||
|
|
||||||
|
def test_book_report_espone_le_cause():
|
||||||
|
"""La causa dev'essere nel report: senza, `book_execute` non puo' allertare con la ragione
|
||||||
|
vera e ricade sulla nota cablata — il difetto che ha tenuto nascosto il guasto del 29/07."""
|
||||||
|
from src.live import book as bk
|
||||||
|
r = bk.book_report(offline=True, equity_override=600.0)
|
||||||
|
assert "skh_feed_errors" in r
|
||||||
|
assert r["skh_feed_errors"] == {}, "senza live_feed non c'e' nessun fallback da spiegare"
|
||||||
|
|
||||||
|
|
||||||
def test_soglia_di_config_presente_e_sensata():
|
def test_soglia_di_config_presente_e_sensata():
|
||||||
"""La soglia dev'essere piu' larga della cadenza del cron (orario) e molto piu' stretta
|
"""La soglia dev'essere piu' larga della cadenza del cron (orario) e molto piu' stretta
|
||||||
di un giorno, sennò non distingue 'fresco' da 'ricaduto sul certificato'."""
|
di un giorno, sennò non distingue 'fresco' da 'ricaduto sul certificato'."""
|
||||||
|
|||||||
Reference in New Issue
Block a user