diff --git a/CLAUDE.md b/CLAUDE.md index e740152..7bb9bac 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -96,6 +96,42 @@ Prima ondata di ricerca onesta su BTC/ETH certificati (5 track, harness condivis regime-luck), calendar/seasonality (buy&hold travestito), volume/vol e momentum-reversal (negativi). - **MORTO/confermato artefatto:** mean-reversion / fade (negativo anche a fee zero — la vecchia libreria +201%/+1238% era contaminazione); trend 5m/15m (fee). +- **GAMMA SCALPING (long-vol) "scalping BTC/ETH con copertura in opzioni" — SCARTATO (2026-06-26)** — + `scripts/research/options_gamma_scalp.py`, test `tests/test_gamma_scalp.py`. È lo **specchio + esatto del VRP01** (long straddle ATM + delta-hedge: incassa **RV−IV**, dove VRP01 incassa IV−RV). + Perde **ogni anno, ogni variante, ogni frequenza** (Sharpe −3 a −6; nudo/cheap-gated/rich-skip; + rehedge 1d e 1h). Diagnostica strutturale: a 1d IV≈o>RV (BTC +4.9pp) → paghi il VRP; a 1h RV>IV + gross ma (a) gonfiata da microstruttura, (b) il rehedge orario paga **24× la fee di hedge** → + variante *peggiore* (−6). Marginale vs TP01 = **DILUTES**, non è nemmeno hedge (perde sia TP01-up + sia TP01-down). Muro eseguibilità: opzione BTC min $5.968 ≫ $600. Schiacciato tra due muri: + rehedge lento = premio, veloce = fee → **nessuna frequenza vince.** Regola gemella del VRP: + *niente long-vol scalp da modello in deploy*. Il VRP01 (lato short, gated) resta l'unico edge + opzioni — funziona perché sta sul lato *giusto* dello stesso premio. Diario `2026-06-26-gamma-scalp-options.md`. +- **CASH-AND-CARRY (basis trade) "CC01" — premio REALE, Sharpe ARTEFATTO, NON deployabile (2026-06-26)** — + `scripts/research/cash_carry_hl.py`, test `tests/test_cash_carry.py`. Diverso da FC01 (funding + cross-sectional, già scartato): qui delta-neutral long-spot/short-perp sullo *stesso* asset → + ritorno ≈ **+funding** (zero esposizione prezzo). Il premio di funding è **reale** (~+8-14%/anno + aggregato, positivo ogni anno in-sample, ortogonale a TP01 corr ~0.05). MA lo **Sharpe modellato + 11-13 (DD 0.3%) è un ARTEFATTO**: il modello cattura solo il cashflow liscio del funding e i + rischi di coda sono **strutturalmente fuori dal dataset** — (1) storico funding dal 2023-05 → + **manca il 2022** (deleveraging, funding −, basis blow-out); (2) **procyclico** (carry +23% toro + 2024 → +1.7% bear 2026, si comprime quando servirebbe); (3) liquidazione short/slippage non + modellati. Il mark-to-market della base (`premium` col → r=funding−Δpremium) sgonfia lo Sharpe + solo 13→11 → il basis-from-data NON è il rischio vero. Sharpe reale di un basis-trade ~1-3 con + code brusche. **NON eseguibile a $600** (spot+perp = 4-38 gambe, funding HL non Deribit) → STAT-MODE. + **LEAD da rivedere a scala (~20k+ e venue con funding eseguibile), non uno sleeve.** Sottoprodotto: + CC01 passa OGNI gate del marginal scorer → **punto cieco** (manca un gate "Sharpe implausibile → + rischio nascosto"; prossima indurita raccomandata). Diario `2026-06-26-cash-carry-hl.md`. +- **TP01 × DVOL vol-targeting — NON migliora (2026-06-26)** — `scripts/research/tp01_dvol_overlay.py`, + test `tests/test_tp01_dvol_overlay.py`. Angolo ESEGUIBILE (tocca il book live, non STAT-MODE): + usare il DVOL (vol implicita forward-looking) come denominatore del vol-target di TP01 invece + della vol realizzata. Su finestra comune 2021-2026: le varianti DVOL abbassano il DD (12.3%→9.2%) + ma anche Sharpe FULL (0.75→0.70) e CAGR (8%→6%). **Controllo decisivo:** il realized a target_vol + RIDOTTO (15%) eguaglia quel DD (9.4%) a Sharpe **più alto** (0.75) → il taglio di DD del DVOL è + solo **de-levering**, replicabile meglio con un semplice target_vol più basso. L'unico residuo + (hold-out +0.06) è single-window (storia DVOL <5 anni) → sotto la soglia multi-cut. Il gate + DVOL-spike de-risk è **ridondante col trend** (TP01 già flat nei crash, Δ 0.00). **Lezione: per meno + DD sul live la leva è `target_vol`, non un overlay DVOL** (20% resta canonico). Diario `2026-06-26-tp01-dvol-overlay.md`. - **Soffitto strutturale BTC/ETH-direzionale ~1.3** superato SOLO espandendo a un meccanismo diverso: cross-sectional su universo Hyperliquid certificato (XS01) → portafoglio Sharpe ~1.55. - **Sweep "strategie alternative" (2026-06-20) — 104 ipotesi / 153 agenti / NIENTE di nuovo regge.** diff --git a/docs/diary/2026-06-26-cash-carry-hl.md b/docs/diary/2026-06-26-cash-carry-hl.md new file mode 100644 index 0000000..dc8f054 --- /dev/null +++ b/docs/diary/2026-06-26-cash-carry-hl.md @@ -0,0 +1,69 @@ +# 2026-06-26 — Cash-and-carry (basis trade) delta-neutral su HL (CC01): premio REALE, Sharpe ARTEFATTO, NON deployabile + +**Goal utente:** dopo lo scarto del gamma scalping, "provare" gli altri due angoli proposti — +funding-carry cross-sectional e basis/cash-and-carry. Il **funding cross-sectional (FC01) è già +SCARTATO** (diario 2026-06-22: DILUTES, sign-flip su un nome). L'angolo non testato è il +**cash-and-carry delta-neutral (CC01)**. Branch `research/gamma-scalp-options`, +script `scripts/research/cash_carry_hl.py`. + +## Meccanismo (diverso da FC01) + +FC01 pickava cross-section (short alto-funding / long basso-funding) → scommessa relativa, fragile. +CC01 **non picka**: harvesta il premio di funding AGGREGATO restando delta-neutral sullo *stesso* +asset. Long spot + short perp (stessa size): le gambe lineari nel prezzo si cancellano → ritorno +≈ **+funding** (lo short incassa il funding quando f>0), zero esposizione direzionale. Dati: funding +orario reale HL dal 2023-05 (`hlfund_*_1h.parquet`), gli stessi 19 major di XS01. + +## Risultato grezzo — e perché NON va creduto + +| config | Sharpe | CAGR | maxDD | per-anno | +|---|---|---|---|---| +| BTC/ETH CC-static | **+12.9** | +13% | 0.8% | 2023:+10 2024:+23 2025:+8 2026:+1% | +| BTC/ETH CC-gated | +13.9 | +14% | 0.3% | tutti positivi | +| 19-major CC-gated | +11.6 | +16% | 0.4% | tutti positivi | + +**Uno Sharpe di 13 con DD 0.3% è un ALLARME, non un edge.** È la firma di un modello che cattura +solo un cashflow liscio (il funding) e azzera la volatilità reale. Il marginal scorer dà **ADDS / +robust_oos / has_insample_edge=True / multicut_persistent** su OGNI config — ma questo **espone un +punto cieco dello scorer**: si fida della vol riportata dal candidato e non ha un gate "Sharpe +implausibile → rischio nascosto". Un Sharpe di 13 dovrebbe squalificare-e-indagare, non passare. + +## Il rischio è NASCOSTO, non assente + +- **Mark-to-market della base** (`premium` col → `r = funding − Δpremium`): sgonfia lo Sharpe solo + 13→11. La colonna premium di HL è già lisciata/clampata → a risoluzione daily il basis-vol è + minimo. **Quindi il basis-from-data NON è il rischio vero.** +- **I rischi che giustificherebbero uno Sharpe realistico (~1-3) sono ASSENTI dal dataset:** + 1. **2022 mancante.** Lo storico funding parte 2023-05 → niente deleveraging LUNA/3AC/FTX, il + regime di funding profondamente negativo + basis blow-out che farebbe il vero drawdown. + 2. **Procyclicità.** Funding aggregato BTC/ETH: +23% nel toro 2024 → **+1.7% nel bear 2026** + (e 19-major CC-static 2026 = **−1%**). Solo 10% di giorni a funding negativo *in-sample* — ma + in un vero bear quella frazione esplode. Il carry **non è all-weather**: è un risk-on premium. + 3. **Liquidazione dello short** in uno squeeze e **slippage** su spot+perp: non modellati. +- **Sharpe reale di un crypto basis-trade ≈ 1-3 con code brusche** (Mar-2020, Mag-2021, FTX), non 13. + +## Eseguibilità a $600 — muro indipendente + +Serve spot **+** perp per gamba: BTC/ETH = 4 posizioni, 19-major = 38. Su Deribit (dove operiamo) lo +storico funding è **bloccato** (404) e non tradiamo spot HL. **STAT-MODE**, come XS01/VRP01 ma peggio +(doppia gamba per asset). Non deployabile a questo capitale. + +## Verdetto + +**CC01 = premio di funding REALE (~+8-14%/anno aggregato, positivo ogni anno in-sample) e +genuinamente ortogonale a TP01 (corr ~0.05, NON direzionale).** È la "fonte di ritorno diversa" più +concreta trovata finora. MA: +- lo **Sharpe headline è fiction** (rischi di coda strutturalmente fuori dal dataset); +- è **procyclico** (si comprime/inverte nel bear — proprio quando servirebbe); +- **non eseguibile** a $600 / sul nostro venue. + +→ **LEAD da rivedere a scala** (capitale ~20k+ e un venue con funding eseguibile), **non uno sleeve**. +Nessun impatto sul book live (branch separato). + +## Sottoprodotto metodologico (raccomandazione) + +CC01 ha passato ogni gate del marginal scorer pur essendo un'illusione di rischio. **Prossima +indurita dello scorer:** un gate di *plausibilità dello Sharpe* — uno standalone Sharpe ≫ 4 deve +declassare a "SUSPECT/INVESTIGATE" invece di alimentare ADDS, perché segnala vol sottostimata +(mark-to-cashflow, dati che escludono il regime di stress, leva nascosta). Documentato qui, non +implementato in questa passata per non toccare lo scorer condiviso su `main`. diff --git a/docs/diary/2026-06-26-gamma-scalp-options.md b/docs/diary/2026-06-26-gamma-scalp-options.md new file mode 100644 index 0000000..94b37c2 --- /dev/null +++ b/docs/diary/2026-06-26-gamma-scalp-options.md @@ -0,0 +1,74 @@ +# 2026-06-26 — "Scalping BTC/ETH con copertura in opzioni" = gamma scalping → SCARTATO + +**Domanda (goal utente):** analizzare lo scalping su BTC/ETH con copertura in opzioni come +possibile nuovo sleeve. **Esito: negativo, strutturale, su tutte le frequenze. Non è uno sleeve, +non è nemmeno un hedge.** Branch `research/gamma-scalp-options`, script +`scripts/research/options_gamma_scalp.py`. + +## Cos'è davvero "scalping con copertura in opzioni" + +L'interpretazione rigorosa è il **gamma scalping**: compri un'opzione (la *copertura* = long +gamma), delta-hedgi il sottostante a cadenza fissa (lo *scalp*), e il P&L netto è +≈ dollar-gamma × (vol realizzata² − vol implicita²). È lo **specchio esatto del VRP01** (short-vol): +VRP01 incassa IV−RV (positivo in media), il gamma scalping incassa **RV−IV** (negativo in media). + +Modello (mirror della struttura VRP per comparabilità): long straddle ATM, tenor 7g, IV = DVOL +Deribit (la stessa fonte del VRP), delta-hedge sui prezzi certificati, fee opzioni cap 12.5% del +premio + fee perp 0.05%/lato sull'hedge turnover, return-on-notional vol-targeted 20%. + +## La diagnostica strutturale (il cuore) + +| asset | IV (DVOL) | RV 1d | IV−RV 1d | RV 1h | IV−RV 1h | +|---|---|---|---|---|---| +| BTC | 60.9% | 56.0% | **+4.9pp** | 64.5% | −3.6pp | +| ETH | 74.9% | 76.0% | −1.0pp | 81.0% | −6.1pp | + +- A **rehedge giornaliero** l'IV ≈ o > la RV → il long gamma **paga il VRP** (sei sul lato sbagliato + del premio). Perde gross, prima di ogni fee. +- A **rehedge orario** la RV *supera* l'IV (BTC +3.6pp, ETH +6.1pp gross) — sembrerebbe un edge… + **ma è una trappola doppia:** (a) la RV_1h è gonfiata da microstruttura (bid-ask bounce), quindi + il "gross edge" è in parte fittizio; (b) il rehedge orario paga **24× la fee di hedge**, che + spazza via qualunque edge intraday reale. + +## I numeri (ogni variante, ogni anno, ogni frequenza: PERDE) + +| variante | rehedge | Sharpe | CAGR | per-anno | +|---|---|---|---|---| +| NAKED | 1d | **−3.99** | −56% | 2021..2026 tutti −25..−68% | +| CHEAP-GATED (IVr<0.30) | 1d | −3.05 | −47% | tutti negativi | +| RICH-SKIP (IVr>0.90) | 1d | −3.92 | −55% | tutti negativi | +| NAKED | 1h | **−6.06** | −71% | tutti −38..−81% (peggio: le fee) | +| CHEAP-GATED (IVr<0.30) | 1h | −4.96 | −64% | tutti negativi | + +Il gate "compra vol solo quando è a sconto" (lo specchio del gate vincente del VRP, *vendi vol solo +quando è cara*) **migliora ma non salva**: −3.05 invece di −3.99. Il segno resta negativo perché +l'IV-rank basso non garantisce RV > IV su 7 giorni. + +## Scoring marginale vs TP01 + +Tutte le varianti: **DILUTES**, `is_hedge=False`, `has_insample_edge=False` (IS Sharpe −3..−4.5), +blend-25 uplift hold −1.2/−1.5. Non aiuta nemmeno come hedge: perde sia quando TP01 è su +(uplift −1.2..−1.6) sia quando TP01 è giù (uplift −2.5..−3.2). Corr a TP01 ≈ 0 ma il drift negativo +domina la matematica di diversificazione. + +## Eseguibilità a ~$600 (muro indipendente) + +- **BTC**: contratto 1 BTC, min 0.1 BTC → notional minimo **$5.968** ≫ $600 → **NON eseguibile**. +- **ETH**: min 0.1 ETH → **$157** → marginalmente OK, ma un solo straddle = ~25% del conto in premio. + +Anche se l'edge esistesse, BTC è fuori e ETH è troppo grosso per diversificare. + +## Conclusione + +Lo "scalping con copertura in opzioni" è **schiacciato tra due muri**: rehedge lento → paghi il +premio (RV < IV); rehedge veloce → paghi le fee (24×/day). **Nessuna frequenza vince.** Conferma +due lezioni di prim'ordine del progetto, ora quantificate sul long-vol: +1. **Le fee uccidono lo scalping** (il rehedge orario, vera frequenza di scalping, è la variante + *peggiore*: −6 Sharpe). +2. **Vol-da-modello è fragile in entrambe le direzioni.** Regola gemella del VRP: *niente long-vol + scalp da modello in deploy*. + +**Il VRP01 (lato short, gated, rischio-definito) resta l'unico edge opzioni del progetto** — +e funziona proprio perché sta sul lato *giusto* dello stesso premio che il gamma scalp paga. + +SCARTATO. Script preservato come riferimento. Nessun impatto sul book live (branch separato). diff --git a/docs/diary/2026-06-26-tp01-dvol-overlay.md b/docs/diary/2026-06-26-tp01-dvol-overlay.md new file mode 100644 index 0000000..a79575f --- /dev/null +++ b/docs/diary/2026-06-26-tp01-dvol-overlay.md @@ -0,0 +1,66 @@ +# 2026-06-26 — TP01 × DVOL vol-targeting: NON migliora (il taglio di DD è solo de-levering) + +**Goal utente:** "continua" ad analizzare strategie. Dopo i tre angoli di diversificazione tutti +STAT-MODE (gamma scalp scartato, funding cross-sectional già morto, cash-and-carry lead non +deployabile), ho puntato sull'angolo a **più alto valore perché ESEGUIBILE**: migliorare il +risk-sizing del book **live** TP01 (BTC/ETH perp Deribit, già armato). Branch +`research/gamma-scalp-options`, script `scripts/research/tp01_dvol_overlay.py`. + +## Ipotesi + +TP01 vol-targeta sulla vol **realizzata** 30g (backward-looking). Il **DVOL** (vol implicita 30g +Deribit, forward-looking, che spesso anticipa i salti di vol) come denominatore → de-risking più +tempestivo prima dei crash → DD più basso / hold-out migliore senza affondare il FULL. + +Onestà: DVOL parte 2021-03 → confronto TUTTE le varianti sulla **finestra comune 2021-04 → 2026** +(1891g, perdo il toro 2019-2020 pre-DVOL). Causale, fee 0.10% RT, config canonica TP01. + +## Risultati (finestra comune, hold-out 2025+) + +| variante (denominatore vol) | FULL Sh | FULL DD | CAGR | HOLD Sh | +|---|---|---|---|---| +| **realized** (baseline) | **+0.75** | 12.3% | +8% | +0.30 | +| dvol (implicita) | +0.70 | 9.3% | +6% | +0.33 | +| blend 50/50 | +0.72 | 10.4% | +7% | +0.32 | +| max(realiz, dvol) | +0.70 | 9.2% | +6% | +0.36 | +| derisk (gate DVOL>p90 ×0.5) | +0.75 | 12.3% | +8% | +0.30 | + +Le varianti DVOL **abbassano il DD** (12.3% → ~9.2%) ma **anche** Sharpe FULL (−0.04/−0.05) e CAGR +(8% → 6%). Lo Sharpe è scale-invariant → sospetto: il taglio di DD è solo *posizioni più piccole* +(l'IV > RV per il VRP → denominatore più grande → leva minore), non vero timing. + +## Il controllo decisivo + +Confronto le varianti DVOL col **realized a target_vol RIDOTTO** (stesso de-levering, senza DVOL): + +| | FULL Sh | DD | CAGR | HOLD Sh | +|---|---|---|---|---| +| **max-DVOL** | +0.70 | 9.2% | +6% | +0.36 | +| **realized @ vol-tgt 15%** | **+0.75** | 9.4% | +6% | +0.30 | +| **realized @ vol-tgt 13%** | +0.75 | **8.1%** | +5% | +0.30 | + +**Conclusivo:** il realized a 15% eguaglia il DD del max-DVOL (9.4% ≈ 9.2%) a Sharpe **più alto** +(0.75 > 0.70) e stesso CAGR; a 13% fa DD ancora più basso (8.1%), sempre a 0.75. **Il taglio di DD +del DVOL è interamente "leva minore", replicabile MEGLIO con un semplice target_vol più basso.** + +## Verdetto + +**DVOL vol-targeting NON migliora TP01.** Risk-adjusted è marginalmente *peggiore* (FULL 0.70 vs +0.75 a parità di DD). L'unico residuo a favore — hold-out +0.06 (max 0.36 vs 0.30) — è **single-window** +su 5 anni scarsi di storia DVOL: sotto la soglia di **persistenza multi-cut** del progetto (la lezione +che ha ucciso 13/14 falsi positivi nello sweep alt). Il gate **DVOL-spike de-risk è RIDONDANTE col +trend**: quando il DVOL esplode (crash) TP01 è già flat (momentum negativo → posizione 0) → il gate +non tocca nulla (Δ 0.00 su tutto). Niente da deployare. + +## Lezioni + +1. **Per meno DD sul book live, la leva giusta è `target_vol`, non un overlay DVOL.** Abbassare TP01 + da vol-tgt 20% → 15% taglia il DD 12.3% → ~9.4% allo **stesso Sharpe** (0.75). Dial pulito e noto, + se mai si volesse un profilo più difensivo (oggi 20% è la scelta canonica — invariata). +2. **La vol implicita non batte la realizzata come denominatore di sizing per un trend long-flat**: + il segnale di trend gestisce già il de-risking direzionale; il denominatore fissa solo la + magnitudine, e la realizzata lo fa marginalmente meglio per unità di rischio. +3. Conferma indiretta del soffitto: anche un raffinamento "ovvio" e *eseguibile* su TP01 non produce + alpha; al massimo sposta il punto sulla stessa frontiera rischio/rendimento. + +SCARTATO come miglioria. Nessun impatto sul book live (branch separato, config canonica invariata). diff --git a/docs/diary/2026-06-26-vol-termstructure-feasibility.md b/docs/diary/2026-06-26-vol-termstructure-feasibility.md new file mode 100644 index 0000000..d41ea0f --- /dev/null +++ b/docs/diary/2026-06-26-vol-termstructure-feasibility.md @@ -0,0 +1,47 @@ +# 2026-06-26 — Calendar-vol / term-structure DVOL: il DATO STORICO NON ESISTE (data-first gate) + +**Goal utente:** "continua" — angolo scelto: scaricare e certificare la term-structure DVOL per +testare un calendar-vol (front IV vs back IV). **Esito: bloccato a monte dal gate "il dato prima +della strategia" (lezione v2.0.0).** Branch `research/gamma-scalp-options`. +Script: `scripts/research/probe_vol_termstructure.py` (scan) + `log_vol_termstructure.py` (forward). + +## Scan di fattibilità (prima di costruire qualunque backtest) + +Cosa l'API pubblica Deribit espone davvero per la vol per-scadenza: + +| dato | disponibile? | +|---|---| +| Snapshot CORRENTE della term-structure ATM (mark_iv per scadenza) | **SÌ** (book_summary_by_currency, tokenless) | +| DVOL storico | solo **30g** fisso — nessun indice 7g/60g/90g pubblico | +| Trade-history IV per strumento | solo per strumenti **VIVI** (gli scaduti spariscono) | +| **Serie storica continua front-vs-back IV** | **NO** — il front-month rotola/espira → irricostruibile | + +**Snapshot oggi (2026-06-26), per riferimento:** +- BTC spot $59.9k: 7g 45.6% · 30g 43.2% · 60g 42.7% · 90g 42.9% · 180g 44.5% — **contango** lieve. +- ETH spot $1.58k: 7g 57.6% · 30g 56.4% · 90g 57.5% · 180g 59.2% — contango (+1.6pp slope). +- (I valori sono interpolati a tenor fissi: il front a 1-giorno grezzo, 38%/54%, è rumore escluso.) + +## Verdetto + +**Un calendar-vol NON è backtestabile ora su dati certificati.** La storia per-scadenza non è +pubblica su Deribit (la nostra fonte di verità), e — coerentemente con la metodologia — **nessun +edge va creduto senza backtest OOS su dati certificati**. Costruire un calendar-vol su uno snapshot +singolo o su una storia ricostruita-a-pezzi sarebbe esattamente il tipo di scorciatoia che ha +prodotto la libreria fasulla v2.0.0. Quindi: **stop, niente backtest.** + +## L'unica via legittima: costruire il dato in avanti + +`scripts/research/log_vol_termstructure.py` — logger forward idempotente: ogni run prende lo snapshot +ATM, lo interpola ai tenor fissi {7,30,60,90,180}g e appende una riga/asset a +`data/raw/vol_term_.parquet`. Seminate le prime righe BTC/ETH oggi. Test offline +`tests/test_vol_termstructure.py` (interpolazione pura). + +**Non auto-cablato in cron** (è ricerca forward, e non tocco il cron live senza ok). Per accumulare: +aggiungere una riga giornaliera al crontab. Dopo **~6-12 mesi** → certificare (monotonia, spike, +cross-venue) e SOLO ALLORA testare front-vs-back. Prima, nulla. + +## Lezione + +Il data-first gate ha funzionato: ha fermato un backtest su un dato che non esiste, invece di +produrre un numero non falsificabile. La term-structure è in **contango** oggi (regime calmo) — +osservazione da forward-monitor, non un edge. Nessun impatto sul book live (branch separato). diff --git a/scripts/research/cash_carry_hl.py b/scripts/research/cash_carry_hl.py new file mode 100644 index 0000000..84b436f --- /dev/null +++ b/scripts/research/cash_carry_hl.py @@ -0,0 +1,173 @@ +"""CC01 — CASH-AND-CARRY (basis trade) delta-neutral su Hyperliquid. Backtest onesto, STAT-MODE. + +DIVERSO da FC01 (funding cross-sectional, gia' SCARTATO 2026-06-22). Qui NON si pickano +vincitori/perdenti cross-section: si HARVESTA il premio di funding AGGREGATO restando +delta-neutral sullo STESSO asset. + +MECCANISMO. Long spot + short perp (stesso asset, stessa size): + long spot -> +price_ret + short perp -> -price_ret + funding (lo short INCASSA il funding quando f>0) + netto -> +funding (il prezzo si cancella -> zero esposizione direzionale) +Il ritorno della gamba delta-neutral = il funding realizzato, meno fee. Entrambe le gambe sono +lineari nel prezzo => restano matchate in nozionale senza ribilanciare (delta ~neutro da solo); +i costi reali sono entry/exit + spread, modellati come drag annuo fisso. + +VARIANTI: + CC-static -> sempre long-spot/short-perp (basis trade classico): r = funding. Perde se f<0. + CC-gated -> harvest solo quando il funding trailing CAUSALE > 0 (esci dai regimi a funding + negativo invece di pagarli). r = funding se trail>0 else 0. +UNIVERSI: BTC/ETH (sottoinsieme rilevante per l'esecuzione) e i 19 major (basket pieno). + +GIUDIZIO: standalone (Sharpe/DD/anni) + marginal_vs_tp01. CAVEAT ONESTI (pre-risultato): + - NON eseguibile a $600: serve spot+perp per gamba (BTC/ETH = 4 posizioni; 19 = 38). Su Deribit + lo storico funding e' bloccato e non operiamo spot HL -> STAT-MODE. + - Il modello "r=funding" IGNORA il rischio di base (perp != spot), la liquidazione dello short in + uno squeeze, e l'inversione brusca del funding. La vol modellata SOTTOSTIMA la coda. + - Lo storico funding parte 2023-05 -> NON contiene il deleveraging 2022 (il regime peggiore per il + carry). Edge potenzialmente sovrastimato. + + uv run python scripts/research/cash_carry_hl.py +""" +from __future__ import annotations +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" / "alt")) + +from src.portfolio.sleeves import XS_UNIVERSE, _HL_DIR +from altlib import marginal_vs_tp01 # type: ignore + +SQ365 = np.sqrt(365.25) +ANNUAL_COST = 0.02 # drag annuo fisso: entry/exit + spread + borrow (generoso per un hold continuo) + + +def load_funding_panel(universe): + """FUND, PREM [date x asset]: funding giornaliero (somma oraria) e premium/basis (ultimo del + giorno) per gli asset con dati. PREM serve a iniettare il mark-to-market della BASE (perp!=spot).""" + fund, prem = {}, {} + for sym in universe: + fp = _HL_DIR / f"hlfund_{sym.lower()}_1h.parquet" + if not fp.exists(): + continue + df = pd.read_parquet(fp) + fund[sym] = df["funding"].resample("1D").sum() + prem[sym] = df["premium"].resample("1D").last() + FUND = pd.concat(fund, axis=1).sort_index() + PREM = pd.concat(prem, axis=1).sort_index().reindex(FUND.index) + if FUND.index.tz is None: + FUND.index = FUND.index.tz_localize("UTC"); PREM.index = PREM.index.tz_localize("UTC") + return FUND, PREM + + +def cc_returns(universe, mode="static", trail=14, cost=ANNUAL_COST, with_basis=False) -> pd.Series: + """Ritorno giornaliero del basket cash-and-carry equal-weight, netto drag annuo. + with_basis=True inietta il mark-to-market reale della base: r = funding - Δpremium (lo short + perp marca contro l'allargamento del basis). E' il rischio che 'r=funding' nasconde.""" + FUND, PREM = load_funding_panel(universe) + daily_cost = cost / 365.25 + leg_raw = FUND - PREM.diff() if with_basis else FUND # per-asset daily mark + if mode == "gated": + sig = FUND.rolling(trail, min_periods=trail // 2).mean().shift(1) # funding trailing causale + active = (sig > 0).astype(float) + n_active = active.sum(axis=1).replace(0, np.nan) + gross = ((leg_raw * active).sum(axis=1) / n_active).fillna(0.0) + drag = (active.sum(axis=1) > 0).astype(float) * daily_cost + else: + gross = leg_raw.mean(axis=1) + drag = daily_cost + return (gross - drag).dropna() + + +def metrics(daily: pd.Series) -> dict: + r = daily.values + sh = float(np.mean(r) / np.std(r) * SQ365) if np.std(r) > 0 else 0.0 + eq = np.cumprod(1.0 + np.clip(r, -0.99, None)) + pk = np.maximum.accumulate(eq) + dd = float(np.max((pk - eq) / pk)) if len(eq) else 0.0 + yrs = (daily.index[-1] - daily.index[0]).days / 365.25 if len(daily) > 1 else 1.0 + cagr = eq[-1] ** (1 / yrs) - 1 if yrs > 0 and len(eq) and eq[-1] > 0 else -1.0 + s = pd.Series(eq, index=daily.index) + yearly = {} + for y, g in s.groupby(s.index.year): + if len(g) > 1: + v = g.values; p = np.maximum.accumulate(v) + yearly[int(y)] = (float(g.iloc[-1] / g.iloc[0] - 1), float(np.max((p - v) / p))) + return dict(sharpe=sh, dd=dd, cagr=cagr, yearly=yearly, gross_ann=float(np.mean(r) * 365.25)) + + +def main(): + print("=" * 92) + print(" CC01 — CASH-AND-CARRY (basis trade) delta-neutral su Hyperliquid") + print(" long spot + short perp -> r = funding (zero esposizione prezzo). Netto drag 2%/anno.") + print("=" * 92) + + configs = [ + ("BTC/ETH CC-static", ["BTC", "ETH"], "static"), + ("BTC/ETH CC-gated", ["BTC", "ETH"], "gated"), + ("19-major CC-static", XS_UNIVERSE, "static"), + ("19-major CC-gated", XS_UNIVERSE, "gated"), + ] + print("\n [A] MODELLO INGENUO (r = funding) — IGNORA il rischio di base:") + series = {} + for label, uni, mode in configs: + r = cc_returns(uni, mode=mode) + series[label] = r + m = metrics(r) + ys = " ".join(f"{y}:{p*100:+.0f}%" for y, (p, d) in sorted(m['yearly'].items())) + print(f"\n --- {label} --- ({r.index[0].date()} -> {r.index[-1].date()}, {len(r)}g)") + print(f" Sharpe {m['sharpe']:+.2f} CAGR {m['cagr']*100:+.1f}% maxDD {m['dd']*100:.1f}% " + f"carry lordo {m['gross_ann']*100:+.1f}%/anno | per-anno: {ys}") + + print("\n [B] CON MARK-TO-MARKET DELLA BASE (r = funding - Δpremium) — il rischio nascosto:") + series_b = {} + for label, uni, mode in configs: + r = cc_returns(uni, mode=mode, with_basis=True) + series_b[label] = r + m = metrics(r) + ys = " ".join(f"{y}:{p*100:+.0f}%" for y, (p, d) in sorted(m['yearly'].items())) + print(f"\n --- {label} (basis) ---") + print(f" Sharpe {m['sharpe']:+.2f} CAGR {m['cagr']*100:+.1f}% maxDD {m['dd']*100:.1f}% " + f"| per-anno: {ys}") + + print("\n" + "=" * 92) + print(" REALITY CHECK — perche' uno Sharpe 11-13 e' un ARTEFATTO, non un edge") + print("=" * 92) + FUND, _ = load_funding_panel(["BTC", "ETH"]) + agg = FUND.mean(axis=1) + neg = float((agg < 0).mean()) + by_year = agg.groupby(agg.index.year).apply(lambda x: float(x.sum())) + print(f" funding aggregato BTC/ETH: giorni a funding NEGATIVO {neg*100:.0f}% | " + f"per-anno (somma): " + " ".join(f"{y}:{v*100:+.1f}%" for y, v in by_year.items())) + print(" -> il carry e' PROCYCLICO: +23% nel toro 2024, ~+1% nel 2026 (si comprime nel bear).") + print(" -> lo storico funding parte 2023-05: ASSENTE il deleveraging 2022 (funding -, basis blow-out),") + print(" il regime che farebbe il vero drawdown. + assenti: liquidazione short in squeeze, slippage") + print(" su spot+perp a $600. Sharpe reale di un basis-trade ~1-3 con code brusche, NON 13.") + + print("\n" + "=" * 92) + print(" SCORING MARGINALE vs TP01 (sul modello ONESTO con rischio di base [B])") + print(" NB: ADDS/robust_oos qui ESPONGONO un punto cieco dello scorer — si fida della vol") + print(" riportata e non ha un gate 'Sharpe implausibile -> rischio nascosto'.") + print("=" * 92) + for label, r in series_b.items(): + if r.std() == 0: + print(f"\n[{label}] flat — skip"); continue + m = marginal_vs_tp01(r) + b = m.get("blends", {}).get("w25", {}) + print(f"\n[{label}]") + print(f" verdict={m.get('marginal_verdict')} corr->TP01 full={m.get('corr_full')} " + f"hold={m.get('corr_hold')} is_hedge={m.get('is_hedge')} " + f"has_insample_edge={m.get('has_insample_edge')} (cand IS Sharpe {m.get('cand_insample_sharpe')})") + print(f" cand Sharpe full={m.get('cand_full_sharpe')} hold={m.get('cand_hold_sharpe')} | " + f"blend25 full {b.get('full')} (upl {b.get('uplift_full')}) " + f"hold {b.get('hold')} (upl {b.get('uplift_hold')}) DD {b.get('dd')}") + print(f" multicut persistente={m.get('multicut_persistent')} robust_oos={m.get('robust_oos')} " + f"hedge-check up/down {m.get('uplift_tp01_up')}/{m.get('uplift_tp01_down')}") + + +if __name__ == "__main__": + main() diff --git a/scripts/research/options_gamma_scalp.py b/scripts/research/options_gamma_scalp.py new file mode 100644 index 0000000..7653c99 --- /dev/null +++ b/scripts/research/options_gamma_scalp.py @@ -0,0 +1,296 @@ +"""GAMMA SCALPING (long-vol) — "scalping BTC/ETH con copertura in opzioni", valutato onestamente. + +Interpretazione rigorosa di "fare scalping con copertura in opzioni" = GAMMA SCALPING: +compri un'opzione (la COPERTURA = long gamma), delta-hedgi il sottostante a cadenza fissa +(lo SCALP), e il P&L netto e' ~ dollar-gamma * (vol realizzata^2 - vol implicita^2). E' lo +SPECCHIO ESATTO del VRP01 (short-vol): VRP01 incassa IV-RV (positivo in media), il gamma +scalping incassa RV-IV (negativo in media, ma CONVESSO -> paga nei crash). + +Modello (mirror della struttura VRP per comparabilita'): + - long STRADDLE ATM, tenor settimanale (7g), strike = spot all'ingresso. + - IV = DVOL Deribit (data/raw/dvol_*.parquet) — la stessa fonte IV del VRP. + - delta-hedge GIORNALIERO sui prezzi certificati 1d (BTC/ETH). + - P&L delta-hedged classico per step: DG_t * [(dS/S)^2 - sigma^2*dt], + DG_t = dollar-gamma dello straddle = phi(d1)*S/(sigma*sqrt(tau)). + - fee opzioni Deribit cap 12.5% del premio (come VRP) + fee perp sull'hedge turnover. + - return-on-notional (pnl/S0), poi VOL-TARGET 20% annuo per apples-to-apples con gli altri sleeve. + +Varianti testate: + NAKED -> sempre long gamma (baseline: deve perdere il premio in media). + CHEAP-GATED -> entra solo quando IV-rank < gate (compri vol a sconto = specchio del gate VRP). + RICH-SKIP -> entra sempre tranne quando IV-rank > skip (evita di pagare vol carissima). + +Output: metriche standalone + per-anno + SCORING MARGINALE vs TP01 (corr, blend uplift, is_hedge, +has_insample_edge) + il muro di ESEGUIBILITA' a $600 (min size opzioni Deribit). + + uv run python scripts/research/options_gamma_scalp.py + +ONESTA': premio MODELLATO su DVOL ATM (skew non esplicito) — stesso caveat del VRP. Long-vol da +modello: meno pericoloso dello short-vol (loss capped al premio), ma resta un LEAD da modello. +""" +from __future__ import annotations +import sys +from pathlib import Path +PROJECT_ROOT = Path(__file__).resolve().parents[2] +sys.path.insert(0, str(PROJECT_ROOT)) +sys.path.insert(0, str(PROJECT_ROOT / "scripts" / "research" / "alt")) + +import numpy as np +import pandas as pd +from scipy.stats import norm + +from src.data.downloader import load_data +from src.strategies.trend_portfolio import resample_1d +from altlib import marginal_vs_tp01 # type: ignore + +RAW = PROJECT_ROOT / "data" / "raw" +SQ365 = np.sqrt(365.25) +DT = 1.0 / 365.25 + +# fee model (mirror VRP): opzioni Deribit cap 12.5% del premio; perp taker 0.05%/lato sull'hedge. +OPT_FEE_FRAC = 0.125 +HEDGE_FEE_SIDE = 0.0005 +TENOR_D = 7 + + +def _bs_straddle(S, K, T, sig): + """Premio BS di uno straddle ATM (call+put), r=0.""" + if T <= 0 or sig <= 0: + return abs(S - K) + d1 = (np.log(S / K) + 0.5 * sig ** 2 * T) / (sig * np.sqrt(T)) + d2 = d1 - sig * np.sqrt(T) + call = S * norm.cdf(d1) - K * norm.cdf(d2) + put = K * norm.cdf(-d2) - S * norm.cdf(-d1) + return call + put + + +def _dvol(asset: str) -> pd.Series: + dv = pd.read_parquet(RAW / f"dvol_{asset.lower()}.parquet") + return pd.Series(dv["close"].values.astype(float), + index=pd.to_datetime(dv["timestamp"], unit="ms", utc=True)) / 100.0 + + +def _load(asset: str): + df = resample_1d(load_data(asset, "1h")) + s = pd.Series(df["close"].values.astype(float), index=pd.to_datetime(df["datetime"])) + if s.index.tz is None: + s.index = s.index.tz_localize("UTC") + J = pd.concat({"px": s, "dvol": _dvol(asset)}, axis=1, join="inner").sort_index().dropna() + return J["px"].values, J["dvol"].values / 1.0, J.index + + +def _load_hourly(asset: str): + df = load_data(asset, "1h") + s = pd.Series(df["close"].values.astype(float), + index=pd.to_datetime(df["timestamp"], unit="ms", utc=True)).sort_index() + return s + + +def rv_iv_diagnostic(asset: str): + """Il CUORE strutturale: vol realizzata (a vari campionamenti) vs vol implicita (DVOL). + Se RV < IV in media -> il long gamma PERDE gross, prima di ogni fee. E' il VRP, di segno opposto.""" + px, dvf, idx = _load(asset) + iv = float(np.mean(dvf)) + rdaily = np.diff(np.log(px)) + rv_daily = float(np.std(rdaily) * SQ365) + h = _load_hourly(asset) + rhour = np.diff(np.log(h.values)) + rv_hour = float(np.std(rhour) * np.sqrt(365.25 * 24)) + return dict(asset=asset, iv=iv, rv_daily=rv_daily, rv_hour=rv_hour, + spread_daily=iv - rv_daily, spread_hour=iv - rv_hour) + + +def gamma_scalp_hourly(asset: str, mode: str = "naked", + gate_cheap: float = 0.30) -> pd.Series: + """Gamma scalp a rehedge ORARIO (la 'vera' frequenza di scalping): cattura la RV intraday, + ma paga 24x la fee di hedge. Tenor 7g = 168 step orari. IV costante nel giorno (DVOL).""" + h = _load_hourly(asset) + dv = _dvol(asset).reindex(h.index.normalize(), method="ffill") + dv.index = h.index + J = pd.concat({"px": h, "dvol": dv}, axis=1).dropna() + px = J["px"].values; dvf = J["dvol"].values; idx = J.index + n = len(px); STEPS = TENOR_D * 24; dt = 1.0 / (365.25 * 24) + # IV-rank causale su DVOL giornaliero per il gate + daily_iv = _dvol(asset) + rets = {} + i = 24 * 60 + while i + STEPS < n: + S0 = px[i]; sig = dvf[i]; K = S0 + if mode == "cheap": + day = idx[i].normalize() + hist = daily_iv[daily_iv.index < day] + if len(hist) >= 60 and float((hist < sig).mean()) > gate_cheap: + rets[idx[i + STEPS]] = 0.0; i += STEPS; continue + T = TENOR_D / 365.25 + premium = _bs_straddle(S0, K, T, sig) + gamma_pnl = 0.0; hedge_fee = 0.0; prev_delta = None + for t in range(1, STEPS + 1): + tau = max((STEPS - (t - 1)) * dt, dt) + Sp = px[i + t - 1]; Sn = px[i + t] + r = Sn / Sp - 1.0 + d1 = (np.log(Sp / K) + 0.5 * sig ** 2 * tau) / (sig * np.sqrt(tau)) + dollar_gamma = norm.pdf(d1) * Sp / (sig * np.sqrt(tau)) + gamma_pnl += dollar_gamma * (r * r - sig * sig * dt) + delta = 2.0 * norm.cdf(d1) - 1.0 + if prev_delta is not None: + hedge_fee += HEDGE_FEE_SIDE * abs(delta - prev_delta) * Sp + prev_delta = delta + pnl = gamma_pnl - OPT_FEE_FRAC * premium - hedge_fee + rets[idx[i + STEPS]] = pnl / S0 + i += STEPS + out = pd.Series(rets) + out.index = out.index.normalize() + return out + + +def gamma_scalp_asset(asset: str, mode: str = "naked", + gate_cheap: float = 0.30, skip_rich: float = 0.90) -> pd.Series: + """Rendimenti settimanali (return-on-notional) di una catena di long-straddle delta-hedged, + lumpati sul giorno di scadenza. Causale: IV/strike/gate da dati <= entry; payoff sul path.""" + px, dvf, idx = _load(asset) + n = len(px) + rets = {} + i = 60 + while i + TENOR_D < n: + S0 = px[i]; sig = dvf[i]; K = S0 + skip = False + if i >= 60 and mode in ("cheap", "rich"): + ivr = float((dvf[:i] < dvf[i]).mean()) # IV-rank espandente causale + if mode == "cheap" and ivr > gate_cheap: # compra gamma solo se vol a SCONTO + skip = True + if mode == "rich" and ivr > skip_rich: # evita di pagare vol carissima + skip = True + if skip: + rets[idx[i + TENOR_D]] = 0.0; i += TENOR_D; continue + T = TENOR_D / 365.25 + premium = _bs_straddle(S0, K, T, sig) + gamma_pnl = 0.0; hedge_fee = 0.0; prev_delta = None + for t in range(1, TENOR_D + 1): + tau = (TENOR_D - (t - 1)) / 365.25 # tempo residuo a inizio step + Sp = px[i + t - 1]; Sn = px[i + t] + r = Sn / Sp - 1.0 + d1 = (np.log(Sp / K) + 0.5 * sig ** 2 * tau) / (sig * np.sqrt(tau)) + dollar_gamma = norm.pdf(d1) * Sp / (sig * np.sqrt(tau)) # DG straddle = phi(d1)*S/(sig*sqrt(tau)) + gamma_pnl += dollar_gamma * (r * r - sig * sig * DT) + delta = 2.0 * norm.cdf(d1) - 1.0 # delta straddle ATM + if prev_delta is not None: + hedge_fee += HEDGE_FEE_SIDE * abs(delta - prev_delta) * Sp + prev_delta = delta + opt_fee = OPT_FEE_FRAC * premium + pnl = gamma_pnl - opt_fee - hedge_fee + rets[idx[i + TENOR_D]] = pnl / S0 # return-on-notional + i += TENOR_D + return pd.Series(rets) + + +def to_daily_voltgt(weekly_btc: pd.Series, weekly_eth: pd.Series, tgt_vol: float = 0.20) -> pd.Series: + """Book 50/50 BTC+ETH su griglia giornaliera, poi scalato a tgt_vol annuo (apples-to-apples + con gli altri sleeve, tutti vol-targeted ~20%).""" + wk = pd.concat({"B": weekly_btc, "E": weekly_eth}, axis=1, join="inner").mean(axis=1).sort_index() + if wk.empty: + return wk + days = pd.date_range(wk.index.min().normalize(), wk.index.max().normalize(), freq="1D", tz="UTC") + daily = pd.Series(0.0, index=days) + daily.loc[wk.index.normalize()] = wk.values + sd = daily.std() + if sd > 0: + daily = daily * (tgt_vol / (sd * SQ365)) + return daily + + +def _metrics(daily: pd.Series) -> dict: + r = daily.values + sh = float(np.mean(r) / np.std(r) * SQ365) if np.std(r) > 0 else 0.0 + eq = np.cumprod(1.0 + np.clip(r, -0.99, None)) + pk = np.maximum.accumulate(eq) + dd = float(np.max((pk - eq) / pk)) + yrs = (daily.index[-1] - daily.index[0]).days / 365.25 + cagr = eq[-1] ** (1 / yrs) - 1 if yrs > 0 and eq[-1] > 0 else -1.0 + s = pd.Series(eq, index=daily.index) + yearly = {} + for y, g in s.groupby(s.index.year): + if len(g) > 1: + v = g.values; p = np.maximum.accumulate(v) + yearly[int(y)] = (float(g.iloc[-1] / g.iloc[0] - 1), float(np.max((p - v) / p))) + return dict(sharpe=sh, dd=dd, cagr=cagr, yearly=yearly) + + +def main(): + print("=" * 92) + print(" GAMMA SCALPING (long-vol) — scalping BTC/ETH con copertura in opzioni") + print(" Modello: long straddle ATM 7g, delta-hedge 1d, P&L = DG*(RV^2 - IV^2). Mirror del VRP01.") + print("=" * 92) + + print("\n DIAGNOSTICA STRUTTURALE — vol implicita (DVOL) vs realizzata (il segno dell'edge):") + for a in ("BTC", "ETH"): + d = rv_iv_diagnostic(a) + print(f" {a}: IV {d['iv']*100:5.1f}% | RV_1d {d['rv_daily']*100:5.1f}% " + f"(IV-RV {d['spread_daily']*100:+.1f}pp) RV_1h {d['rv_hour']*100:5.1f}% " + f"(IV-RV {d['spread_hour']*100:+.1f}pp)") + print(" -> IV-RV > 0 = il mercato PAGA per essere short-vol (VRP). Il long gamma e' su questo lato" + ", al ROVESCIO: paga il premio. RV_1h>RV_1d -> rehedge orario cattura piu' RV (test sotto).") + + variants = { + "NAKED (sempre long gamma)": dict(mode="naked"), + "CHEAP-GATED (IV-rank<0.30 = vol scont)": dict(mode="cheap", gate_cheap=0.30), + "CHEAP-GATED (IV-rank<0.50)": dict(mode="cheap", gate_cheap=0.50), + "RICH-SKIP (no entry se IV-rank>0.90)": dict(mode="rich", skip_rich=0.90), + } + + series = {} + for label, kw in variants.items(): + wB = gamma_scalp_asset("BTC", **kw) + wE = gamma_scalp_asset("ETH", **kw) + daily = to_daily_voltgt(wB, wE) + series[label] = daily + m = _metrics(daily) + ntr = int((wB != 0).sum() + (wE != 0).sum()) + print(f"\n--- {label} ---") + print(f" standalone (vol-tgt 20%): Sharpe {m['sharpe']:+.2f} CAGR {m['cagr']*100:+.1f}% " + f"maxDD {m['dd']*100:.1f}% trade {ntr}") + ys = " ".join(f"{y}:{p*100:+.0f}%" for y, (p, d) in sorted(m['yearly'].items())) + print(f" per-anno PnL: {ys}") + + print("\n" + "=" * 92) + print(" REHEDGE ORARIO (la 'vera' frequenza di scalping: cattura RV intraday, paga 24x hedge fee)") + print("=" * 92) + for label, kw in (("NAKED orario", dict(mode="naked")), + ("CHEAP-GATED orario (IV-rank<0.30)", dict(mode="cheap", gate_cheap=0.30))): + wB = gamma_scalp_hourly("BTC", **kw); wE = gamma_scalp_hourly("ETH", **kw) + daily = to_daily_voltgt(wB, wE) + m = _metrics(daily) + ys = " ".join(f"{y}:{p*100:+.0f}%" for y, (p, d) in sorted(m['yearly'].items())) + print(f"\n--- {label} ---") + print(f" Sharpe {m['sharpe']:+.2f} CAGR {m['cagr']*100:+.1f}% maxDD {m['dd']*100:.1f}%") + print(f" per-anno PnL: {ys}") + + print("\n" + "=" * 92) + print(" SCORING MARGINALE vs TP01 (lo standard del progetto: un nuovo sleeve si giudica QUI)") + print("=" * 92) + for label, daily in series.items(): + if daily.std() == 0: + print(f"\n[{label}] flat — skip"); continue + m = marginal_vs_tp01(daily) + b25 = m.get("blends", {}).get("w25", {}) + print(f"\n[{label}]") + print(f" verdict={m.get('marginal_verdict')} corr->TP01 full={m.get('corr_full')} " + f"hold={m.get('corr_hold')} is_hedge={m.get('is_hedge')} " + f"has_insample_edge={m.get('has_insample_edge')} (cand IS Sharpe {m.get('cand_insample_sharpe')})") + print(f" cand Sharpe full={m.get('cand_full_sharpe')} hold={m.get('cand_hold_sharpe')} | " + f"blend25 full {b25.get('full')} (upl {b25.get('uplift_full')}) " + f"hold {b25.get('hold')} (upl {b25.get('uplift_hold')}) DD {b25.get('dd')}") + print(f" hedge-check: uplift TP01-up {m.get('uplift_tp01_up')} / TP01-down {m.get('uplift_tp01_down')} " + f"yearly-corr {m.get('hedge_yearly_corr')}") + + print("\n" + "=" * 92) + print(" ESEGUIBILITA' a ~$600 (Deribit options min size)") + print("=" * 92) + pxB = _load("BTC")[0][-1]; pxE = _load("ETH")[0][-1] + for a, p, csz, minc in (("BTC", pxB, 1.0, 0.1), ("ETH", pxE, 1.0, 0.1)): + notion = p * csz * minc + print(f" {a}: spot ${p:,.0f} | contratto {csz} {a}, min {minc} {a} -> notional minimo " + f"${notion:,.0f} ({'OK' if notion < 600 else 'OLTRE i $600 -> NON eseguibile'})") + + +if __name__ == "__main__": + main() diff --git a/scripts/research/tp01_dvol_overlay.py b/scripts/research/tp01_dvol_overlay.py new file mode 100644 index 0000000..563753d --- /dev/null +++ b/scripts/research/tp01_dvol_overlay.py @@ -0,0 +1,159 @@ +"""TP01 × DVOL — la vol IMPLICITA (forward-looking) migliora il risk-sizing di TP01? (ESEGUIBILE) + +A differenza degli sleeve diversificatori (XS01/VRP01/carry = STAT-MODE, non eseguibili a $600), +questo TOCCA il book live: TP01 è BTC/ETH perp su Deribit, già armato. Oggi vol-targeta sulla vol +REALIZZATA 30g (backward-looking). Ipotesi: il DVOL (vol implicita 30g Deribit, forward-looking, +che spesso ANTICIPA i salti di vol realizzata) come denominatore del vol-target → de-risking più +tempestivo prima dei crash → hold-out migliore e/o DD più basso, SENZA peggiorare il FULL. + +Onestà: DVOL parte 2021-03 → confronto TUTTE le varianti sulla FINESTRA COMUNE 2021-2026 (perdo +2018-2021, incluso il toro 2021 pre-DVOL). Baseline ricalcolato sulla stessa finestra. Hold-out 2025+. +Tutto causale (vol/segnale ≤ close[i]), fee 0.10% RT, long-flat, leva cap 2x — config CANONICA TP01. + +VARIANTI (denominatore del vol-target): + REALIZED -> 30g realizzata (baseline canonica) + DVOL -> DVOL/100 (implicita) + BLEND -> 0.5·realizzata + 0.5·DVOL + MAX -> max(realizzata, DVOL) (sizing più difensivo: la più alta delle due) + DERISK -> realizzata, ma posizione ×0.5 quando DVOL > pctl espandente causale (gate crash) + + uv run python scripts/research/tp01_dvol_overlay.py +""" +from __future__ import annotations +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)) + +from src.data.downloader import load_data +from src.strategies.trend_portfolio import ( + resample_1d, simple_returns, realized_vol, tsmom_blend, CANONICAL, +) + +RAW = ROOT / "data" / "raw" +SQ = np.sqrt(365.25) +HOLDOUT = pd.Timestamp("2025-01-01", tz="UTC") +TGT_VOL = CANONICAL["target_vol"]; LEV = CANONICAL["leverage"]; FEE = CANONICAL["fee_side"] +HZ = CANONICAL["horizons_days"]; VW = CANONICAL["vol_win_days"] + + +def _components(asset: str): + df = resample_1d(load_data(asset, "1h")) + c = df["close"].values.astype(float) + idx = pd.to_datetime(df["datetime"]) + if idx.dt.tz is None: + idx = idx.dt.tz_localize("UTC") + idx = pd.DatetimeIndex(idx) # tz-aware (UTC) + r = simple_returns(c) + rv = realized_vol(r, VW, 365.25) # 30g realizzata annualizzata (bpd=1) + direction = np.clip(tsmom_blend(c, HZ), 0, None) # long-flat + dv = pd.read_parquet(RAW / f"dvol_{asset.lower()}.parquet") + dser = pd.Series(dv["close"].values.astype(float) / 100.0, + index=pd.to_datetime(dv["timestamp"], unit="ms", utc=True)).sort_index() + dvol = dser.reindex(idx, method="ffill").values + return c, r, idx, rv, direction, dvol + + +def _net_returns(asset: str, mode: str, tvol: float = TGT_VOL) -> pd.Series: + c, r, idx, rv, direction, dvol = _components(asset) + derisk = np.ones(len(c)) + if mode == "realized": + vol = rv + elif mode == "dvol": + vol = dvol + elif mode == "blend": + vol = 0.5 * rv + 0.5 * dvol + elif mode == "max": + vol = np.fmax(rv, dvol) + elif mode == "derisk": + vol = rv + # gate crash causale: DVOL sopra il suo percentile espandente (90%) -> dimezza l'esposizione + dd = pd.Series(dvol, index=idx) + rank = dd.expanding(min_periods=60).apply(lambda x: (x[:-1] < x[-1]).mean() if len(x) > 1 else 0.5, raw=True) + derisk = np.where(rank.values > 0.90, 0.5, 1.0) + else: + raise ValueError(mode) + with np.errstate(divide="ignore", invalid="ignore"): + scal = np.where((vol > 0) & np.isfinite(vol), tvol / vol, 0.0) + tgt = np.clip(direction * scal * derisk, -LEV, LEV) + tgt[~np.isfinite(tgt)] = 0.0 + pos = np.zeros(len(tgt)); pos[1:] = tgt[:-1] # decisa a close[t-1], tenuta in t + gross = pos * r + turn = np.abs(np.diff(pos, prepend=0.0)) + net = np.clip(gross - FEE * turn, -0.99, None); net[0] = 0.0 + return pd.Series(net, index=idx) + + +def portfolio(mode: str, tvol: float = TGT_VOL) -> pd.Series: + b = _net_returns("BTC", mode, tvol); e = _net_returns("ETH", mode, tvol) + J = pd.concat({"B": b, "E": e}, axis=1, join="inner").fillna(0.0) + return 0.5 * J["B"] + 0.5 * J["E"] + + +def metrics(daily: pd.Series, lo=None) -> dict: + if lo is not None: + daily = daily[daily.index >= lo] + r = daily.values + sh = float(np.mean(r) / np.std(r) * SQ) if np.std(r) > 0 else 0.0 + eq = np.cumprod(1.0 + r); pk = np.maximum.accumulate(eq) + dd = float(np.max((pk - eq) / pk)) if len(eq) else 0.0 + yrs = (daily.index[-1] - daily.index[0]).days / 365.25 if len(daily) > 1 else 1.0 + cagr = eq[-1] ** (1 / yrs) - 1 if yrs > 0 and len(eq) and eq[-1] > 0 else -1.0 + s = pd.Series(eq, index=daily.index); yearly = {} + for y, g in s.groupby(s.index.year): + if len(g) > 1: + yearly[int(y)] = float(g.iloc[-1] / g.iloc[0] - 1) + return dict(sharpe=sh, dd=dd, cagr=cagr, tot=float(eq[-1] - 1) if len(eq) else 0.0, yearly=yearly) + + +def main(): + modes = ["realized", "dvol", "blend", "max", "derisk"] + series = {m: portfolio(m) for m in modes} + # vero inizio DVOL (dove TUTTE le varianti hanno dati validi) — non il primo indice del prezzo + dstart = max(pd.read_parquet(RAW / f"dvol_{a.lower()}.parquet")["timestamp"].min() for a in ("BTC", "ETH")) + dstart = pd.Timestamp(dstart, unit="ms", tz="UTC") + pd.Timedelta(days=VW) # +warmup vol-win + series = {m: s[s.index >= dstart] for m, s in series.items()} + common = series["realized"].index + base = series["realized"] + + print("=" * 96) + print(f" TP01 × DVOL — vol-target con denominatore di vol diverso. Finestra COMUNE " + f"{common[0].date()} -> {common[-1].date()} ({len(base)}g). Hold-out 2025+.") + print("=" * 96) + print(f" {'variante':<10} {'FULL Sh':>8} {'FULL DD':>8} {'CAGR':>7} | " + f"{'HOLD Sh':>8} {'HOLD ret':>9} {'HOLD DD':>8} | per-anno PnL") + for m in modes: + s = series[m]; f = metrics(s); h = metrics(s, lo=HOLDOUT) + ys = " ".join(f"{y}:{p*100:+.0f}" for y, p in sorted(f['yearly'].items())) + tag = " (baseline)" if m == "realized" else "" + print(f" {m:<10} {f['sharpe']:>+8.2f} {f['dd']*100:>7.1f}% {f['cagr']*100:>+6.0f}% | " + f"{h['sharpe']:>+8.2f} {h['tot']*100:>+8.1f}% {h['dd']*100:>7.1f}% | {ys}{tag}") + + print("\n DELTA vs baseline (realized) sulla stessa finestra:") + bf = metrics(base); bh = metrics(base, lo=HOLDOUT) + for m in modes: + if m == "realized": + continue + f = metrics(series[m]); h = metrics(series[m], lo=HOLDOUT) + print(f" {m:<8}: ΔFULL Sh {f['sharpe']-bf['sharpe']:+.2f} ΔFULL DD {(f['dd']-bf['dd'])*100:+.1f}pp " + f"ΔHOLD Sh {h['sharpe']-bh['sharpe']:+.2f} ΔHOLD ret {(h['tot']-bh['tot'])*100:+.1f}pp") + + print("\n CONTROLLO DECISIVO — il taglio di DD del DVOL è 'posizioni più piccole' o vero timing?") + print(" Confronto le varianti DVOL con il realized a target_vol RIDOTTO (stesso de-levering, ma") + print(" senza DVOL). Se realized-ridotto eguaglia/batte il DVOL a parità di DD → DVOL non aggiunge.") + for tv in (0.15, 0.13): + s = portfolio("realized", tvol=tv); s = s[s.index >= dstart] + f = metrics(s); h = metrics(s, lo=HOLDOUT) + print(f" realized @ vol-tgt {tv*100:.0f}%: FULL Sh {f['sharpe']:+.2f} DD {f['dd']*100:.1f}% " + f"CAGR {f['cagr']*100:+.0f}% | HOLD Sh {h['sharpe']:+.2f}") + mx = metrics(series["max"]); mxh = metrics(series["max"], lo=HOLDOUT) + print(f" (vs max-DVOL: FULL Sh {mx['sharpe']:+.2f} DD {mx['dd']*100:.1f}% " + f"CAGR {mx['cagr']*100:+.0f}% | HOLD Sh {mxh['sharpe']:+.2f})") + + +if __name__ == "__main__": + main() diff --git a/tests/test_cash_carry.py b/tests/test_cash_carry.py new file mode 100644 index 0000000..2aaf2d6 --- /dev/null +++ b/tests/test_cash_carry.py @@ -0,0 +1,36 @@ +"""Lock della conclusione 2026-06-26 (CC01 cash-and-carry): il premio di funding e' REALE e +positivo, ma PROCYCLICO (si comprime nel bear) — NON un edge all-weather. Lo Sharpe headline ~13 e' +un artefatto (rischi di coda assenti dal dataset) -> non testiamo lo Sharpe, testiamo il FATTO +economico e la procyclicita'. Diario docs/diary/2026-06-26-cash-carry-hl.md.""" +import sys +from pathlib import Path + +ROOT = Path(__file__).resolve().parents[1] +sys.path.insert(0, str(ROOT)) +sys.path.insert(0, str(ROOT / "scripts" / "research")) + +from cash_carry_hl import cc_returns, load_funding_panel, metrics # type: ignore + + +def test_funding_premium_is_real_and_positive(): + """Il carry esiste: funding aggregato BTC/ETH positivo, CC-static con CAGR > 0.""" + r = cc_returns(["BTC", "ETH"], mode="static") + m = metrics(r) + assert m["gross_ann"] > 0.0 # premio di funding netto positivo + assert m["cagr"] > 0.0 + + +def test_basis_markup_does_not_explain_high_sharpe(): + """Aggiungere il mark-to-market della base (Δpremium) NON sgonfia lo Sharpe in modo materiale: + prova che il rischio vero e' FUORI dal dataset (2022/liquidazione), non nel basis dei dati.""" + naive = metrics(cc_returns(["BTC", "ETH"], mode="static"))["sharpe"] + basis = metrics(cc_returns(["BTC", "ETH"], mode="static", with_basis=True))["sharpe"] + assert basis > 0.7 * naive # cala poco -> il basis-from-data non e' il rischio nascosto + + +def test_carry_is_procyclical_fades_in_bear(): + """Il carry e' procyclico: il funding aggregato del toro 2024 supera quello del bear 2026.""" + FUND, _ = load_funding_panel(["BTC", "ETH"]) + agg = FUND.mean(axis=1) + by_year = agg.groupby(agg.index.year).sum() + assert by_year.get(2024, 0.0) > by_year.get(2026, 0.0) diff --git a/tests/test_gamma_scalp.py b/tests/test_gamma_scalp.py new file mode 100644 index 0000000..614db2a --- /dev/null +++ b/tests/test_gamma_scalp.py @@ -0,0 +1,50 @@ +"""Lock della conclusione 2026-06-26: il gamma scalping (long-vol) su BTC/ETH PERDE +strutturalmente — è lo specchio del VRP01, dal lato sbagliato del premio. Guardia contro +ri-litigare l'idea "scalping con copertura in opzioni" come edge. +Diario docs/diary/2026-06-26-gamma-scalp-options.md.""" +import sys +from pathlib import Path + +ROOT = Path(__file__).resolve().parents[1] +sys.path.insert(0, str(ROOT)) +sys.path.insert(0, str(ROOT / "scripts" / "research")) + +from options_gamma_scalp import ( # type: ignore # noqa: E402 + _bs_straddle, gamma_scalp_asset, rv_iv_diagnostic, to_daily_voltgt, _metrics, +) + + +def test_bs_straddle_positive_and_increasing_in_vol(): + """Premio straddle ATM > 0 e cresce con la vol implicita (sanity del pricer).""" + p_lo = _bs_straddle(100.0, 100.0, 7 / 365.25, 0.30) + p_hi = _bs_straddle(100.0, 100.0, 7 / 365.25, 0.90) + assert p_lo > 0 + assert p_hi > p_lo + + +def test_long_gamma_loses_when_rv_below_iv_synthetic(): + """Sintetico: con prezzo PIATTO (RV=0) e IV>0, il long gamma deve perdere (solo theta).""" + # P&L per step = DG*(r^2 - sigma^2 dt); con r=0 ogni step e' -DG*sigma^2*dt < 0. + from options_gamma_scalp import OPT_FEE_FRAC # noqa: F401 + sig = 0.60 + # se RV=0 il gamma_pnl e' strettamente negativo -> ritorno negativo. Verifico via il segno + # del contributo per-step a r=0. + r = 0.0 + contrib = (r * r - sig * sig * (1.0 / 365.25)) + assert contrib < 0 + + +def test_iv_exceeds_or_near_daily_rv_btc(): + """BTC: a campionamento giornaliero l'IV (DVOL) >= RV in media -> il long gamma paga il VRP.""" + d = rv_iv_diagnostic("BTC") + assert d["iv"] > 0 and d["rv_daily"] > 0 + assert d["spread_daily"] > 0.0 # IV - RV_1d > 0 su BTC (lato short paga) + + +def test_naked_gamma_scalp_standalone_sharpe_negative(): + """La conclusione: il book gamma-scalp nudo (BTC+ETH) ha Sharpe standalone NEGATIVO.""" + wB = gamma_scalp_asset("BTC", mode="naked") + wE = gamma_scalp_asset("ETH", mode="naked") + daily = to_daily_voltgt(wB, wE) + assert daily.std() > 0 + assert _metrics(daily)["sharpe"] < 0.0 diff --git a/tests/test_tp01_dvol_overlay.py b/tests/test_tp01_dvol_overlay.py new file mode 100644 index 0000000..6fabb35 --- /dev/null +++ b/tests/test_tp01_dvol_overlay.py @@ -0,0 +1,46 @@ +"""Lock della conclusione 2026-06-26: il DVOL come denominatore del vol-target NON migliora TP01 +risk-adjusted. Il taglio di DD delle varianti DVOL è solo de-levering, replicabile MEGLIO con un +target_vol più basso sul realizzato. Diario docs/diary/2026-06-26-tp01-dvol-overlay.md.""" +import sys +from pathlib import Path + +import pandas as pd + +ROOT = Path(__file__).resolve().parents[1] +sys.path.insert(0, str(ROOT)) +sys.path.insert(0, str(ROOT / "scripts" / "research")) + +from tp01_dvol_overlay import portfolio, metrics, RAW, VW # type: ignore + + +def _dstart(): + s = max(pd.read_parquet(RAW / f"dvol_{a.lower()}.parquet")["timestamp"].min() for a in ("BTC", "ETH")) + return pd.Timestamp(s, unit="ms", tz="UTC") + pd.Timedelta(days=VW) + + +def test_dvol_does_not_beat_realized_risk_adjusted(): + """Sulla finestra comune, il realized batte (o eguaglia) le varianti DVOL sullo Sharpe FULL.""" + lo = _dstart() + base = metrics(portfolio("realized")[portfolio("realized").index >= lo]) + mx = metrics(portfolio("max")[portfolio("max").index >= lo]) + dv = metrics(portfolio("dvol")[portfolio("dvol").index >= lo]) + assert base["sharpe"] >= mx["sharpe"] - 1e-9 + assert base["sharpe"] >= dv["sharpe"] - 1e-9 + + +def test_lower_target_vol_replicates_dd_cut_at_better_sharpe(): + """Il taglio di DD del DVOL è solo de-levering: realized @ vol-tgt 15% eguaglia il DD del + max-DVOL a Sharpe FULL non inferiore (anzi superiore).""" + lo = _dstart() + mx = metrics(portfolio("max")[portfolio("max").index >= lo]) + lo_tv = metrics(portfolio("realized", tvol=0.15)[portfolio("realized", tvol=0.15).index >= lo]) + assert lo_tv["dd"] <= mx["dd"] + 0.01 # stesso DD (entro 1pp) + assert lo_tv["sharpe"] >= mx["sharpe"] # a Sharpe non inferiore + + +def test_dvol_spike_gate_is_redundant_with_trend(): + """Il gate DVOL-spike de-risk non cambia nulla: TP01 è già flat nei crash (momentum<0).""" + lo = _dstart() + base = portfolio("realized"); dr = portfolio("derisk") + base = base[base.index >= lo]; dr = dr[dr.index >= lo] + assert abs(metrics(base)["sharpe"] - metrics(dr)["sharpe"]) < 0.02