diff --git a/CLAUDE.md b/CLAUDE.md index 51f6282..7065a7b 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -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 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`. + ⚠️ **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 / 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 @@ -738,6 +765,17 @@ Prima ondata di ricerca onesta su BTC/ETH certificati (5 track, harness condivis 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 @@ -1629,6 +1667,29 @@ 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 = **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`. +- **CATENA OPZIONI REALE (cerbero-bite mainnet) — integrata e certificata 2026-07-30.** + `/opt/docker/cerbero-bite` accumula dal 2026-06-09 la catena Deribit **mainnet** (BTC+ETH, + entrambe le ali, scadenze 1g→3 mesi, ORARIA, con `book_depth_top3`). Estrazione+certificazione: + `scripts/analysis/fetch_cb_chain.py` → `data/raw/cb_chain.parquet` (gitignored, ~1.23M righe); + harness `scripts/research/cblib.py`. **È l'unica fonte di prezzi opzioni VERI del progetto** e + serve a sostituire il premio modellato BS-su-DVOL (vedi il f di VRP01, misurato il 30/07). + **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**. Il fix lato bite (cron `:00`→`:20`) è **scritto ma non attivo** (`strategy.yaml` è + copiato nell'immagine, serve rebuild). **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 diff --git a/docs/diary/2026-07-30-vrp-quote-reali.md b/docs/diary/2026-07-30-vrp-quote-reali.md new file mode 100644 index 0000000..0e6b401 --- /dev/null +++ b/docs/diary/2026-07-30-vrp-quote-reali.md @@ -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) diff --git a/scripts/analysis/fetch_cb_chain.py b/scripts/analysis/fetch_cb_chain.py new file mode 100644 index 0000000..8f1049a --- /dev/null +++ b/scripts/analysis/fetch_cb_chain.py @@ -0,0 +1,202 @@ +"""FETCH + CERTIFY della catena opzioni REALE accumulata da cerbero-bite. + +Perche' esiste: VRP01 prezza le sue gambe con BS su DVOL ATM (`src/portfolio/sleeves.VRP_CFG`, +f=1.0). L'unico modo per sapere se quel prezzo e' quello del mercato e' confrontarlo con quote +vere; cerbero-bite (/opt/docker/cerbero-bite) accumula la catena Deribit mainnet ora per ora, +entrambe le ali, scadenze 1g..3mesi. Una catena opzioni NON e' ricostruibile a posteriori +(Deribit non serve book storici) -> il dato va estratto e conservato, non richiesto quando serve. + +Come `fetch_hyperliquid.py`: estrae, CERTIFICA, e scrive solo se la certificazione ha un esito +dichiarato. Le soglie sono qui sotto e sono decisioni, non default. + +CERTIFICAZIONE — quattro difetti, ognuno con la sua diagnostica: + 1. QUOTE VUOTE bid/ask NULL con lo strumento presente: il collettore persiste la riga anche + quando il ticker fallisce (rate-limit) -> il CONTEGGIO RIGHE resta identico e qualunque + controllo di copertura basato sul numero di righe dice "tutto bene". E' il difetto che il + 29/07 ha portato il tasso da ~0.4% a ~50% senza che nulla lo segnalasse. + 2. BOOK INCROCIATO bid > ask (quote incoerenti). + 3. MONOTONIA a parita' di scadenza/istante il premio di una put deve essere non-decrescente + nello strike; una violazione e' una quota stantia o corrotta. + 4. PROFONDITA' ZERO book_depth_top3 == 0 e' AMBIGUO by design (chiamata fallita e book + davvero vuoto danno lo stesso valore) -> si riporta, non si ripara. + + uv run python scripts/analysis/fetch_cb_chain.py # docker cp dal container + uv run python scripts/analysis/fetch_cb_chain.py --db /percorso/state.sqlite +""" +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" +OUT = RAW / "cb_chain.parquet" +CONTAINER = "cerbero-bite-cerbero-bite-1" +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") + df["ts"] = pd.to_datetime(df["timestamp"], utc=True, errors="coerce") + df["exp"] = pd.to_datetime(df["expiry"], utc=True, errors="coerce") + df["dte"] = (df["exp"] - df["ts"]).dt.total_seconds() / 86400.0 + return df.dropna(subset=["ts", "exp", "strike"]).drop(columns=["timestamp", "expiry"]) + + +# ------------------------------------------------------------------ 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="state.sqlite (default: docker cp)") + ap.add_argument("--out", type=Path, default=OUT) + args = ap.parse_args() + + tmp = None + db = args.db + if db is None: + tmp = Path(tempfile.mkdtemp()) / "state.sqlite" + print(f" docker cp {CONTAINER}:{DB_IN_CONTAINER} ...") + db = _pull_db(tmp) + df = read_chain(db) + + print("=" * 100) + print(" CATENA OPZIONI cerbero-bite — certificazione") + 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 by design)") + print(f" STATUS: {r['status']}") + + args.out.parent.mkdir(parents=True, exist_ok=True) + df.to_parquet(args.out, index=False) + print(f"\n scritto {args.out} ({len(df):,} righe)") + if any(r["status"] != "OK" for r in rep): + print(" 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.") + if tmp is not None: + tmp.unlink(missing_ok=True) + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/scripts/research/cblib.py b/scripts/research/cblib.py new file mode 100644 index 0000000..4a09d5d --- /dev/null +++ b/scripts/research/cblib.py @@ -0,0 +1,189 @@ +"""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" +CHAIN = RAW / "cb_chain.parquet" + +# 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: + if not CHAIN.exists(): + raise FileNotFoundError( + f"{CHAIN} assente — estrai prima con scripts/analysis/fetch_cb_chain.py" + ) + df = pd.read_parquet(CHAIN) + df["ts"] = pd.to_datetime(df["ts"], utc=True) + df["exp"] = pd.to_datetime(df["exp"], utc=True) + return df + + +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) diff --git a/scripts/research/r0730_vrp_real_quotes.py b/scripts/research/r0730_vrp_real_quotes.py new file mode 100644 index 0000000..33ce87d --- /dev/null +++ b/scripts/research/r0730_vrp_real_quotes.py @@ -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()) diff --git a/tests/test_cb_chain_vrp.py b/tests/test_cb_chain_vrp.py new file mode 100644 index 0000000..85e11f6 --- /dev/null +++ b/tests/test_cb_chain_vrp.py @@ -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 fetch_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" + )