Compare commits

5 Commits

Author SHA1 Message Date
Adriano Dal Pastro e9a4538054 chore(gitignore): la battuta di cuore del collettore e' stato runtime, non sorgente
data/chain_collect/runs.jsonl era finito tracciato nel commit d55eb13: e' lo
stato del collettore (una riga per giro, oraria), della stessa famiglia di
data/paper_*, data/live, data/venue_watch, data/fee_watch — tutti gia'
gitignored. Tracciato avrebbe sporcato ogni commit futuro con una riga di
heartbeat e messo in git un file che il monitor riscrive da solo.

Rimosso dall'indice (il file su disco resta: e' la serie che monitor_health
sorveglia) e la cartella aggiunta a .gitignore.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-30 20:34:08 +00:00
Adriano Dal Pastro 444b804415 ops(backup): PythagorasGoal nel backup rotativo, solo il dato non ricostruibile
data/raw/ e' gitignored e /opt/docker/scripts/backup.sh non copriva questo
progetto: dopo lo spegnimento di cerbero-bite la catena opzioni esisteva in
due copie SULLO STESSO DISCO.

Aggiunta do_pythagoras con criterio dichiarato — si salva cio' che non si
puo' riscaricare:
  dentro (28 MB): catena + contesto, data/paper_* e data/chain_collect
    (serie forward-only che alimentano i gate pre-registrati: non sono
    ricalcolabili, sono un registro di cosa si sapeva e quando),
    options_daily, live, venue_watch, fee_watch, config/live.json
  fuori (~110 MB): quanto si riscarica dai venue (rebuild_history, fetch_dvol,
    fetch_hyperliquid, fetch_ib_equities) + cache

Due guardie provate nei DUE versi: fallisce se la catena e' assente o vuota
invece di produrre un archivio che sembra a posto, e verifica che il tar
contenga davvero la catena (caso negativo: 0 file prodotti). La radice e'
sovrascrivibile via PYG_ROOT solo per poter far scattare la guardia.

Verificato che dopo un riavvio la raccolta riprenda da sola: cron enabled +
active, nessuna dipendenza da docker o cerbero-mcp; giro provato con env -i
(574 chiamate, 0 errori). Finestra scoperta fino al :25 successivo, senza
catch-up.

/opt/docker/scripts NON e' un repo git: la modifica vive solo su disco, il
diario e' l'unico posto in cui e' scritta.

Book, pesi, config, strategia INVARIATI.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-30 20:25:08 +00:00
Adriano Dal Pastro d55eb13533 feat(chain): assorbita la raccolta catena opzioni, cerbero-bite dismesso
cerbero-bite viene eliminato. L'unica sua parte irreversibile e' il DATO:
una catena opzioni non si ricostruisce a posteriori (Deribit non serve book
storici, non c'e' un secondo venue). Il codice si riscrive; le ore non
raccolte no.

ASSORBITO
- scripts/live/collect_chain.py + scripts/cron_chain.sh (cron 25 * * * *):
  raccolta propria, ~570 strumenti/giro, ~3 min.
- scripts/analysis/import_cb_archive.py: archivio 1.23M righe (2026-05-01+)
  + market_snapshots 17.402 righe (2026-03-26+: dealer gamma, gamma flip,
  rischio liquidazioni, funding cross — dati che non abbiamo altrove).
- snapshot sqlite integrale in /opt/docker/backups/manual/ (SHA256).

NON ASSORBITO, con motivo: motore credit-spread ETH (regola "niente
short-vol da modello in deploy", conto a $52 contro minimo $720), GUI, kill
switch/dead-man/audit (abbiamo venue_watch/edge_watch/monitor_health/
fee_watch), dvol_history (fetch_dvol.py ha storia PIU' LUNGA: 2020+ contro
2026-05), decisions/positions (0 posizioni).

TRE DIFETTI DI BITE NON REPLICATI, tutti misurati il 30/07:
1. una chiamata per strumento invece di due (get_order_book?depth=3 da' gia'
   quote+greche+IV+OI+book+underlying) + prefiltro OI in una chiamata sola:
   551 -> ~290 chiamate per asset;
2. pacing invece di raffica. Il carico non e' mai stato il problema: 570
   chiamate/ora = 0.16/s DISTRIBUITE; bite le sparava in 26s (~44/s) e si
   auto-saturava il rate limit per-IP (12.186 risposte 429 in 26h, 96% al
   minuto :00). Primo giro reale: 574 chiamate, 0 risposte 429. Il minuto :25
   e' scelto: :00 era la raffica, :07 e' cron_book (feed 5m di SKH01).
3. quote_status esplicito {ok, no_quote, error} e book_depth NULL su errore
   mai 0. "Book vuoto" e "chiamata fallita" sono cose diverse: e' per questo
   che il guasto del 29/07 (50% di quote perse, 38 ore) non produsse alcun
   segnale. Le righe ereditate restano 'unknown': bite non lo registrava e a
   posteriori non e' ricostruibile.

Battuta di cuore in data/chain_collect/runs.jsonl anche a giro fallito,
sorvegliata da monitor_health (1h, max_age 3h): un collettore fermo non
produce niente, e il niente si legge come "nessun dato quel giorno".

Difetto trovato per strada: due formati ISO nella stessa colonna (92 righe
di backfill senza microsecondi). pd.to_datetime senza `format` ne inferisce
uno solo e manda gli altri a NaT -> il dropna a valle li toglieva in
silenzio, e la serie di contesto perdeva 5 settimane slittando dal 26/03 al
01/05. Corretto con format="ISO8601" e scarto RUMOROSO.

Book, pesi, config, strategia INVARIATI. 537 test verdi.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-30 20:14:34 +00:00
Adriano Dal Pastro 8c18e82f1a research(vrp): il f del credito netto e' 0.73 sulle quote reali, non 1.0
Prima integrazione della catena opzioni Deribit mainnet accumulata da
cerbero-bite (/opt/docker/cerbero-bite, dal 2026-06-09: entrambe le ali,
1g-3mesi, oraria, con book_depth). E' l'unica fonte di prezzi opzioni VERI
del progetto, e non e' ricostruibile a posteriori: Deribit non serve book
storici, un'ora non raccolta e' persa.

VRP01 prezza entrambe le gambe con BS su DVOL ATM (VRP_CFG f=1.0 sul credito
NETTO). Misurato agli STESSI strike su 8/8 scadenze settimanali con entrambe
le gambe quotate (delta -0.270/-0.099 contro target -0.280/-0.100):

  f gamba corta   1.02   <- replica la calibrazione del 20/06
  f gamba lunga   2.30   <- l'ala che si COMPRA
  f credito NETTO 0.73   IC95% [0.698, 0.780], 0/15 osservazioni >= 1.0

Meccanismo, non rumore: IV(corta)-DVOL +0.8pp ma IV(lunga)-DVOL +7.5pp -> il
modello prezza a vol ATM anche l'ala comprata. Il difetto non e' nel premio
incassato ma nella protezione comprata, cioe' proprio il "defined-risk" per
cui v2 fu promosso.

Conseguenza standalone (solo f): 1.00 -> FULL 1.08 / HOLD +0.58; 0.80 -> 0.51
/ -0.02; 0.73 -> 0.31 / -0.23. Book 5-sleeve: FULL -0.069, HOLD -0.103, DD
invariato = dentro la banda d'ancora, ma ~meta' del contributo LOO di VRP01
era il prezzo che il modello si faceva da solo.

VRP_CFG["f"] NON cambiato: 15 osservazioni, 7 settimane, e 0/8 passano il
gate IV-rank>0.30 -> il f e' misurato nel regime in cui il sleeve sta FLAT.
Caveat quantificato, non nuovo parametro. Il criterio del 19/06 (rivalutare
quando cerbero-bite cattura un crash) e' intatto.

Book, pesi, cron, config INVARIATI.

Regole nuove congelate nei test:
- il f di una struttura multi-gamba non e' il f di una sua gamba (misurare la
  sola gamba venduta da' la risposta sbagliata con segno rassicurante);
- un guasto IN CORSO si misura al GIORNO PEGGIORE, non in media (la prima
  stesura della certificazione diluiva un guasto di 2 giorni da 51.7% a 13.5%);
- una riga presente non e' un dato presente.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-30 19:51:52 +00:00
Adriano Dal Pastro 7d64dd4c2b live(feed): l'allerta funzionava, la sua CAUSA era una riga cablata
Il 29/07 il feed 5m di SKH01 e' ricaduto sul certificato in 6 giri orari su 8
(eta' 265->685 min, +60 a ogni giro = firma esatta del fallback): latenza
d'uscita da ~1h a ~11h, book flat, nessuna posizione esposta. L'allerta del
26/07 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.

E la causa vera non era recuperabile 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".

Cablato: livefeed.last_fetch_error() (registra E logga nel punto in cui
l'errore viene ingoiato) -> book_report.skh_feed_errors -> allerta con la
causa. Stesso buco chiuso sul ramo gemello "conto offline", che la ragione
l'aveva gia' in mark_src e non la stampava mai.

La causa del 29/07 resta IGNOTA e va citata cosi': una prima stesura la
attribuiva a un rate limit per-IP come se fosse un fatto -> rimossa, sarebbe
stato lo stesso difetto che stavo correggendo scritto meglio. Cron spostato
al minuto :07 come ripiego da UNA osservazione, dichiarato tale.

Test 11 -> 16 (incluso il caso a meta' paginazione: coda parziale attaccata,
mancano le barre PIU' recenti). Book/pesi/config/strategia INVARIATI.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-29 12:20:42 +00:00
20 changed files with 2113 additions and 10 deletions
+2
View File
@@ -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/
+135
View File
@@ -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
+142
View File
@@ -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.
+157
View File
@@ -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)
+207
View File
@@ -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())
+132
View File
@@ -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())
+6
View File
@@ -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
+24
View File
@@ -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
+17 -3
View File
@@ -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)
+261
View File
@@ -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())
+201
View File
@@ -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)
+176
View File
@@ -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
View File
@@ -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
View File
@@ -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)
+5
View File
@@ -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"),
) )
+198
View File
@@ -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"
)
+139
View File
@@ -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"]
+4 -1
View File
@@ -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}
+115 -1
View File
@@ -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'."""