diff --git a/docs/research/RESULTS-0822.md b/docs/research/RESULTS-0822.md index d7ed9f8..31f6992 100644 --- a/docs/research/RESULTS-0822.md +++ b/docs/research/RESULTS-0822.md @@ -699,6 +699,7 @@ stessi hanno nominato senza poterli eseguire**. Stesso contratto di consegna, st | 31 | GATE-RECON | cosa direbbero i 3 gate pre-registrati se le serie forward fossero riparate | **1 gate su 3 ribaltato** — e la premessa e' CONFERMATA | | 32 | SKH-ANCHOR-PROP | `GATE PROP-01` gamba (b), datata 31/10: girata oggi | **PASS 23/23** — e il numero scende ancora, a **4,4%** | | 33 | WORST-DAY | il peggior giorno del libro, MISURATO: il gradino di leva e' autorizzabile? | **LEAD** — l'aritmetica passa, **il knob non esiste** | +| 34 | CC01-2022 | il carry riaperto sui dati che gli mancavano per costruzione | **RESTA SCARTATO** — per la ragione giusta, ma non quella pubblicata | | 22 | MAKER | l'esecuzione passiva e' una fonte di ritorno, al netto del costo di non essere eseguiti? | **SCARTATO** — il segno dipende da `<` contro `<=` | | 23 | BOCPD | un rilevatore di cambio di regime vero batte il miglior lookback COSTANTE? | **SCARTATO** — filone chiuso definitivamente | | 24 | CRITICO | cosa NON e' stato misurato, e quale singola misura mancante vale di piu' | **3 correzioni all'ondata**, 1 regola mia ritirata | @@ -1556,3 +1557,80 @@ irrilevante"* **vale solo a nozionale lordo ≤1x**, ed era gia' legata a un tes ⚠️ **Nota dell'agente, dovuta:** se si credono **entrambe** le riserve insieme (drift −2SE **e** −10% annuo) il drift atteso e' **−0,61%/anno** → *a quel punto non e' il gradino a essere sbagliato, e' il libro, e chi usa quella combinazione sta chiedendo di spegnere anche il conto di oggi.* + +--- + +## 34 — CC01-2022 (il cash-and-carry riaperto sul dato che gli mancava) + +`scripts/research/r0822d_cc01_2022.py` (~8 s, zero rete). **47 celle** contate al rialzo. + +**Perche' si poteva riaprire:** il verdetto del 26/06 poggiava su una ragione **falsificabile e +strutturale** — *"lo storico funding parte dal 2023-05 → manca il 2022, i rischi di coda sono fuori dal +dataset"*. Stamattina BASIS-CALENDAR ha ricostruito **30/30 futures trimestrali scaduti dal 2018-09, +col deleveraging 2022 dentro**. E' l'unica base su cui questo progetto riapre un filone: **dati nuovi.** + +✅ **PRIMA della ricerca, la persistenza** (il critico aveva segnalato che i 20 MB vivevano **solo in +uno scratchpad di sessione**): copiati in **`data/raw/fut_deribit/`** — 72 file, 20,3 MB, 601.704 righe +(BTC 240.150 barre orarie / ETH 233.334, 34 contratti per asset, 2018-09→2026-08) + 64.110 ore di +funding e indice per asset dal 2019-04. **Sottocartella con prefisso, mai il namespace nudo** +(lezione SOL: il guardrail *"solo dati certi"* vive nell'**assenza** di un file). +✅ **Guardrail verificato dal coordinatore, non assunto:** `load_data('BTC-30SEP22')` e +`load_data('SOL')` → **`FileNotFoundError`**; `load_data('BTC','1h')` → 70.311 barre; namespace nudo +pulito. **Il cron NON li rinfresca** (istantanea al 22/08, contratto in corso troncato) e restano +**fuori dal backup** perche' ricostruibili con ~2.700 chiamate pubbliche. + +📌 **Q1 — nel 2022 il carry si INVERTE, e resta invertito per mesi.** Funding annuo **BTC −2,26% / +ETH −6,08%** (contro +16,5/+17,3% nel 2021); giorni a funding negativo **53,2%/58,9%**; basis mediano +del front **12,64% → 1,26%** (BTC) e **12,95% → −0,61%** (ETH). Peggior episodio: **ETH −6,70% di +funding cumulato in 170 giorni**; BTC −3,98% in 71 giorni nel **marzo-maggio 2020** = **un secondo +buco che CC01 non aveva.** + +**Q2 — la scala, netta fee:** + +| lente | Sharpe | maxDD | +|---|---|---| +| CC01 come pubblicato (funding HL, 2023-05+) | **+12,93** | 0,78% | +| stesso modello, funding **Deribit**, stessa finestra | +7,88 | 0,87% | +| stesso modello, Deribit, **finestra piena (col 2022)** | **+3,72** | 6,43% | +| + base MTM, premio liscio 24h (la lente di HL) | +1,33 | 6,99% | +| + base MTM, **premio grezzo** | +0,22 | **25,54%** | +| **carry ESEGUIBILE a 2 gambe, direzione CC01** | **−0,42** | 21,80% | + +📌 **E il salto piu' grande NON e' il 2022: e' il VENUE** (+5,04 passando da HL a Deribit, stessa +finestra e stesso modello). +⚠️ **Correzione che l'agente ha fatto a se stesso sulla propria tabella:** aveva scritto *"il buco piu' +grande non era il 2022"* — falso, e' il pezzo singolo maggiore (+4,16). **Il punto vero e' piu' forte: +da solo non basta** (7,88→3,72 fa **ancora** scattare `implausible_sharpe`), e i due pezzi mai +dichiarati il 26/06 valgono insieme altrettanto. + +**Gate:** 🚨 **`implausible_sharpe` 5/5** sulle varianti `r=funding` — **compresa quella col 2022 +dentro**; **pulito** su tutte le varianti con la base o la seconda gamba. 📌 *Il gate NATO da CC01 +continua a segnalare CC01, e smette esattamente quando il rischio entra nel campione — e li' l'edge +non c'e'.* `marginal_vs_tp01`: **DILUTES** (sign +1) / **HEDGE** (sign −1). `deflated_sharpe` **0,363 +FAIL** su 47 trial (massimo atteso dal solo rumore **+0,24 > +0,11** del candidato). Null del +de-levering **REFUTED** sulla versione a 2 gambe. **Controllo positivo di causalita'**: togliendo lo +`.shift(1)` il numero **sale** (6,85→7,39) → il test ha potenza. Ancora: fortuna **+0,005 = nessuna**. + +**Q3-Q4 — i muri non strutturali, riprezzati.** Lotto minimo BTC $10 / ETH $1 → **l'eseguibilita' non +e' il vincolo** (**5ª volta in questa ondata**); margine iniziale $13. ⚠️ **Ma tutti i futures datati +Deribit sono INVERSE: non esiste una linea USDC datata** — **stesso muro delle opzioni**, e il +collaterale in BTC e' esso stesso esposizione direzionale. **Non risolto**, serve una verifica sul +conto reale. **MAE per trade: −11,68% sulla storia piena → leva max 7,8x/gamba**, contro −3,64% → +20,9x nella sola finestra di CC01 = **2,7× di leva di troppo**; sul carry puro con base MTM, MAE 90g +−16,57% → **5,6-6,4x** contro le **~80x** che un maxDD dello 0,8% farebbe credere sostenibili. +⚠️ **Correzione a BASIS-CALENDAR:** il costo di roll e' **0,44%/anno**, non ~1,4% — quello contava il +roll **su entrambe le gambe**, ma **il perpetuo non si rolla**. Correggerlo **non crea un edge**. + +🚨 **IL SOTTOPRODOTTO CHE VALE PIU' DEL VERDETTO: la liscezza era una proprieta' della COLONNA, non del +rischio.** sd del Δ-premio giornaliero: **4,2 bps** (`premium` di Hyperliquid) contro **106,2 bps** +(Deribit grezzo) = **25-31×**. Quindi la frase del 26/06 — *"il mark-to-market della base sgonfia lo +Sharpe solo 13→11, il basis-from-data NON e' il rischio vero"* — era **un artefatto della fonte**: +su Deribit lo stesso esperimento fa **10,87 → 1,65 (liscio) / 0,37 (grezzo)** sulla **stessa finestra**. +📌 **Secondo sottoprodotto:** convenzione **inverse ≠ lineare** vale −0,54 di Sharpe e −2,6pp di CAGR +(BTC), concentrati nel 2020-21 — su una coppia inverse con nozionali pareggiati in USD **resta un delta +residuo ≈ −basis, che vale piu' dell'intero premio**. +⚠️ **Ipotesi dell'agente smentita in sessione:** il gate causale di CC01 **dodgia** il 2022 (−0,9% +contro −6,0%, l'85% della perdita) perche' il funding negativo e' **persistente**. + +**VERDETTO: `CC01 COL 2022 DENTRO: RESTA SCARTATO — e per la ragione giusta, ma quella pubblicata era +necessaria e non sufficiente.`** diff --git a/scripts/research/r0822d_cc01_2022.py b/scripts/research/r0822d_cc01_2022.py new file mode 100644 index 0000000..a1ddf0c --- /dev/null +++ b/scripts/research/r0822d_cc01_2022.py @@ -0,0 +1,1044 @@ +#!/usr/bin/env python3 +""" +r0822d_cc01_2022.py — filone CC01-2022 (ondata 2026-08-22, terza tornata) + + RIAPRIRE UN FILONE CHIUSO SULL'UNICA BASE CHE QUESTO PROGETTO ACCETTA: DATI NUOVI. + +IL FILONE E LA SUA RAGIONE DI MORTE + CC01 (cash-and-carry delta-neutral, `scripts/research/cash_carry_hl.py`) fu chiuso il + 2026-06-26 con un verdetto molto specifico: il premio di funding e' REALE (~+8-14%/anno, + positivo ogni anno, ortogonale a TP01), ma lo Sharpe modellato 11-13 e' un ARTEFATTO + perche' i rischi di coda sono *strutturalmente fuori dal dataset*: + + "lo storico funding parte dal 2023-05 -> manca il 2022 (deleveraging, funding + negativo, basis blow-out)." + + Quella e' una ragione FALSIFICABILE, non un giudizio: dice esattamente quale dato + ribalterebbe la conclusione. Fino a stamattina quel dato non c'era. + +COSA E' CAMBIATO OGGI + Il filone BASIS-CALENDAR (r0822_basis_calendar.py) ha ricostruito da Deribit pubblico + i 30/30 futures trimestrali SCADUTI (i nomi sono deterministici, e + `get_tradingview_chart_data` serve la storia di un contratto scaduto) piu' il funding + e l'indice orario del perpetual: **dal 2019-04, col deleveraging 2022 dentro**. + E' esattamente la coda che a CC01 mancava per costruzione. + +LE QUATTRO DOMANDE (in ordine di importanza, non di comodita') + Q1 Come si comporta il carry NEL 2022? Si comprime, si annulla, o si INVERTE? + Quanto dura e quanto e' profondo il peggior episodio? + Q2 Con la coda dentro, lo Sharpe 11-13 quanto diventa? E `implausible_sharpe` — + il gate che nacque proprio da CC01 — segnala ancora? + Q3 I due muri NON strutturali ("non eseguibile a $600", "procyclico") reggono + coi numeri di oggi, su Deribit, a due gambe? + Q4 Il rischio che ha ucciso CC01 e' il MODELLO, non i dati: un carry delta-neutral + sembra liscio perche' il P&L a scadenza non vede il ROLL ne' il MARGINE. In un + blow-out lo short perde SUBITO e il guadagno arriva a scadenza: se il conto non + regge il margine, la posizione muore nel punto peggiore. + +COSA QUESTO SCRIPT *NON* RIFA' + Il premio a termine (implicito +7,33% contro funding realizzato +6,48% -> incassabile + +0,85%/anno con IC95 che contiene lo zero) e' gia' misurato dal filone BASIS-CALENDAR + e non viene ri-misurato qui. Qui la domanda e' un'altra: **il 2022**. + +CONVENZIONI DICHIARATE + * Nessuna serie di prezzi viene mai incollata: la posizione vive su un CONTRATTO, e al + roll si chiude e si riapre pagando il round-trip. Si incollano solo i RENDIMENTI. + * I futures datati Deribit sono INVERSE (settlement BTC/ETH): il P&L e' calcolato nella + convenzione del venue (BTC) e riportato in USD all'indice. La versione lineare e' + calcolata accanto, e la differenza si misura invece di assumerla trascurabile. + * Fee taker REALE letta dal venue: 3,5 bps/lato per gamba. Slippage dichiarato a parte. + * Leva per-gamba lambda=1 (nozionale per gamba = equity; lordo 2x) salvo dove indicato. + +USO + nice -n 19 timeout 900 uv run python scripts/research/r0822d_cc01_2022.py + +RETE: questo script NON scarica nulla (il dato e' su disco). L'unica chiamata opzionale e' +la lettura delle specifiche strumento, che e' gia' in cache; se manca, si dichiara. +""" +from __future__ import annotations + +import calendar +import datetime as dt +import json +import math +import os +import shutil +import sys +import time +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")) + +import altlib as A # noqa: E402 + +# ------------------------------------------------------------------ costanti +UTC = dt.timezone.utc +MONTHS = ["JAN", "FEB", "MAR", "APR", "MAY", "JUN", + "JUL", "AUG", "SEP", "OCT", "NOV", "DEC"] +ASSETS = ("BTC", "ETH") +HOURS_Y = 365.25 * 24 +SQ365 = math.sqrt(365.25) + +FEE_SIDE = 0.00035 # taker Deribit letto dal venue (get_instruments), per gamba +SLIP_BPS = 2.0 # slippage dichiarato sul datato (meta' spread del front, vedi [6]) +ANNUAL_COST_CC01 = 0.02 # il drag annuo che CC01 usava: si tiene IDENTICO per replicare + +# Margine: DICHIARATO, fonte secondaria (documentazione Deribit futures). Si fa la +# sensibilita' invece di fingere precisione: il risultato e' una LEVA, e la leva scala +# come 1/(mm_tot + |MAE|) -> con |MAE| >> mm_tot il valore di mm quasi non conta. +MM_LEG = 0.00575 # maintenance margin per gamba (0,575% del nozionale) +IM_LEG = 0.01 # initial margin per gamba (1%) + +# Finestre +W_CC01 = pd.Timestamp("2023-05-12", tz="UTC") # inizio dello storico funding di CC01 (HL) +HOLDOUT = pd.Timestamp("2025-01-01", tz="UTC") + +FUT_DIR = ROOT / "data" / "raw" / "fut_deribit" +SCRATCH_CACHE = Path(os.environ.get( + "R0822_SCRATCH", + "/tmp/claude-1001/-opt-docker-PythagorasGoal/" + "b6cc75e7-14f8-4c32-bd07-ab8a0d2aaee6/scratchpad")) / "basis_cache" + +_line = "=" * 96 + + +def hr(t: str) -> None: + print("\n" + _line) + print(t) + print(_line) + + +# ========================================================================== +# 0. PERSISTENZA — il dato viveva SOLO in uno scratchpad di sessione +# ========================================================================== +def last_friday(year: int, month: int) -> dt.date: + d = dt.date(year, month, calendar.monthrange(year, month)[1]) + while d.weekday() != 4: + d -= dt.timedelta(days=1) + return d + + +def quarterly_names(asset: str, y0: int = 2018, y1: int = 2027): + out = [] + for y in range(y0, y1 + 1): + for m in (3, 6, 9, 12): + lf = last_friday(y, m) + out.append((f"{asset}-{lf.day}{MONTHS[m - 1]}{str(y)[2:]}", + pd.Timestamp(lf.year, lf.month, lf.day, 8, tz="UTC"))) + return out + + +def persist_dataset() -> dict: + """Copia il dataset dallo scratchpad a `data/raw/fut_deribit/` se non c'e' gia'. + + NAMESPACE — la lezione SOL del 22/08: `load_data` NON ha una whitelist, il guardrail + "solo dati certi" vive nell'ASSENZA di un file, e mettere un parquet nel namespace + NUDO (`btc_1h.parquet`) lo disattiva in silenzio. Qui i file stanno in una + SOTTOCARTELLA con prefisso `fut_` (accanto a `hl_`/`eq_`/`eqx_`/`alt_`/`fut_`), quindi + (a) nessun nome puo' collidere con `{asset}_{tf}.parquet` e (b) il guardrail resta + verificabile, e viene VERIFICATO sotto, non assunto. + """ + hr("[0] PERSISTENZA DEL DATASET — 20 MB che vivevano solo in uno scratchpad di sessione") + FUT_DIR.mkdir(parents=True, exist_ok=True) + have = sorted(p.name for p in FUT_DIR.glob("*.parquet")) + copied = 0 + if SCRATCH_CACHE.exists(): + for src in sorted(SCRATCH_CACHE.glob("ohlc_*.parquet")): + dst = FUT_DIR / src.name[len("ohlc_"):] + if not dst.exists(): + shutil.copy2(src, dst) + copied += 1 + for a in ASSETS: + src = SCRATCH_CACHE / f"fund_{a}.parquet" + dst = FUT_DIR / f"fund_{a}.parquet" + if src.exists() and not dst.exists(): + shutil.copy2(src, dst) + copied += 1 + print(f" scratchpad presente ({SCRATCH_CACHE}) -> copiati {copied} file nuovi") + else: + print(f" scratchpad ASSENTE ({SCRATCH_CACHE}) — si usa cio' che e' gia' su disco") + + files = sorted(FUT_DIR.glob("*.parquet")) + if not files: + print("\n ERRORE: nessun dato. Il dataset si ricostruisce da Deribit pubblico con") + print(" scripts/research/r0822_basis_calendar.py (fetcher rate-limited, ~45 min).") + raise SystemExit(2) + + rows, bad = 0, [] + per_asset = {a: dict(contratti=0, barre=0) for a in ASSETS} + for f in files: + try: + n = len(pd.read_parquet(f, columns=["ts"])) + except Exception as exc: # noqa: BLE001 + bad.append((f.name, type(exc).__name__)) + continue + rows += n + if f.name.startswith("fund_"): + continue + a = f.name.split("-")[0] + if a in per_asset: + per_asset[a]["contratti"] += 1 + per_asset[a]["barre"] += n + tot_mb = sum(f.stat().st_size for f in files) / 1e6 + print(f" {FUT_DIR.relative_to(ROOT)}: {len(files)} file, {tot_mb:.1f} MB, {rows:,} righe") + for a in ASSETS: + print(f" {a}: {per_asset[a]['contratti']} file di trimestrali, " + f"{per_asset[a]['barre']:,} barre orarie (uno di questi ha <200 barre e il") + print(f" caricatore lo scarta: sotto ne compaiono 34, non e' un dato mancante)") + if bad: + print(f" ⚠️ file illeggibili: {bad}") + + # ---- GUARDRAIL: verificato, non assunto ----------------------------- + from src.data.downloader import load_data, DATA_DIR # noqa: PLC0415 + esiti = [] + for nm, _ in quarterly_names("BTC")[:2] + quarterly_names("ETH")[:2]: + for tf in ("1h", "5m"): + p = DATA_DIR / f"{nm.lower()}_{tf}.parquet" + esiti.append(p.exists()) + collide = any(esiti) + try: + load_data("BTC-30SEP22", "1h") + guard = "ROTTO (load_data ha trovato un contratto!)" + except FileNotFoundError: + guard = "INTATTO (FileNotFoundError)" + ok_btc = True + try: + load_data("BTC", "1h") + except Exception: # noqa: BLE001 + ok_btc = False + print(f" guardrail load_data('BTC-30SEP22') -> {guard} | " + f"collisioni nel namespace nudo: {'SI' if collide else 'NO'} | " + f"load_data('BTC','1h') ancora ok: {'SI' if ok_btc else 'NO'}") + print(" ⚠️ DICHIARATO: il cron NON rinfresca questi file (cron_daily fa BTC/ETH, DVOL, HL,") + print(" equity, catena — non i trimestrali). Sono un'ISTANTANEA al 2026-08-22, e il") + print(" contratto in corso (BTC/ETH-25SEP26) e' TRONCATO a oggi, non a scadenza.") + print(" ⚠️ DICHIARATO: `data/raw/` e' gitignored e `do_pythagoras` in backup.sh salva solo") + print(" il dato NON ricostruibile — questo e' ricostruibile (~2.700 chiamate pubbliche),") + print(" quindi resta fuori dal backup di proposito. Se sparisce, si rifa'.") + return dict(files=len(files), mb=tot_mb, rows=rows, guard_ok=("INTATTO" in guard and not collide)) + + +# ========================================================================== +# 1. CARICAMENTO +# ========================================================================== +def load_futures(asset: str): + closes, exps = {}, {} + for nm, e in quarterly_names(asset): + f = FUT_DIR / f"{nm}.parquet" + if not f.exists(): + continue + d = pd.read_parquet(f, columns=["ts", "close"]) + if len(d) < 200: + continue + s = d.set_index("ts")["close"].astype(float) + s = s[s > 0] + if len(s) < 200: + continue + closes[nm], exps[nm] = s, e + wide = pd.DataFrame(closes).sort_index() + return wide, exps + + +def load_fund(asset: str) -> pd.DataFrame: + return pd.read_parquet(FUT_DIR / f"fund_{asset}.parquet").set_index("ts").sort_index() + + +def load_perp(asset: str) -> pd.Series: + d = A.get(asset, "1h") + return d.set_index("datetime")["close"].astype(float).sort_index() + + +# ========================================================================== +# 2. METRICHE +# ========================================================================== +def to_daily(h: pd.Series) -> pd.Series: + return ((1.0 + h.fillna(0.0)).resample("1D").prod() - 1.0).dropna() + + +def stats(daily: pd.Series) -> dict: + r = daily.dropna() + if len(r) < 30 or r.std() == 0: + return dict(sharpe=0.0, hold=float("nan"), maxdd=0.0, cagr=float("nan"), + n=len(r), vol=0.0, dd_days=0, worst_month=float("nan")) + sh = float(r.mean() / r.std() * SQ365) + eq = (1 + r).cumprod() + peak = eq.cummax() + ddser = (peak - eq) / peak + dd = float(ddser.max()) + under = (eq < peak) + # durata del piu' lungo periodo SOTT'ACQUA (in giorni di calendario) + dur, best, start = 0, 0, None + for t, u in under.items(): + if u and start is None: + start = t + elif not u and start is not None: + best = max(best, (t - start).days) + start = None + if start is not None: + best = max(best, (under.index[-1] - start).days) + dur = best + yrs = max((r.index[-1] - r.index[0]).days / 365.25, 1e-9) + cagr = float(eq.iloc[-1] ** (1 / yrs) - 1) if eq.iloc[-1] > 0 else float("nan") + ho = r[r.index >= HOLDOUT] + sho = float(ho.mean() / ho.std() * SQ365) if len(ho) > 30 and ho.std() > 0 else float("nan") + wm = float((((1 + r).resample("ME").prod() - 1)).min()) + return dict(sharpe=sh, hold=sho, maxdd=dd, cagr=cagr, n=len(r), + vol=float(r.std() * SQ365), dd_days=dur, worst_month=wm) + + +def show(label: str, daily: pd.Series, extra: str = "") -> dict: + m = stats(daily) + print(f" {label:<38s} Sh {m['sharpe']:+6.2f} hold {m['hold']:+6.2f} " + f"CAGR {m['cagr']*100:+6.2f}% maxDD {m['maxdd']*100:5.2f}% " + f"peggior mese {m['worst_month']*100:+6.2f}% sott'acqua {m['dd_days']:4d}g " + f"n={m['n']:,}{extra}") + return m + + +def per_year(daily: pd.Series) -> pd.Series: + return (1 + daily).groupby(daily.index.year).prod() - 1 + + +# ========================================================================== +# 3. Q1 — IL 2022, cioe' l'anno che CC01 non aveva +# ========================================================================== +def q1_descrittiva(F: dict, W: dict, E: dict) -> pd.DataFrame: + hr("[1] Q1 — IL 2022: il carry si comprime, si annulla, o si INVERTE?") + tab = {} + for a in ASSETS: + fu = F[a] + f1 = fu["f1h"].dropna() + d = f1.resample("1D").sum() + wide, exps = W[a], E[a] + idx = wide.index + # basis del front (trimestrale piu' vicino con dte >= 3g) vs INDICE + order = sorted(exps, key=lambda c: exps[c]) + TAU = np.column_stack([(exps[c] - idx).total_seconds().values / 3600.0 for c in order]) + AV = np.column_stack([wide[c].notna().values for c in order]) + AL = AV & (TAU >= 3 * 24) + ok = AL.any(axis=1) + k1 = np.argmax(AL, axis=1) + Fv = np.where(ok, wide[order].values.astype(float)[np.arange(len(idx)), k1], np.nan) + tau = np.where(ok, TAU[np.arange(len(idx)), k1], np.nan) + ix = fu["index"].reindex(idx).ffill(limit=2).values + basis = pd.Series(np.log(Fv / ix) * HOURS_Y / tau, index=idx).replace([np.inf, -np.inf], np.nan) + for y in range(2019, 2027): + dy = d[d.index.year == y] + by = basis[basis.index.year == y].dropna() + if len(dy) < 20 or len(by) < 200: + continue + tab[(a, y)] = dict( + funding_anno=float(dy.sum()), + giorni_f_neg=float((dy < 0).mean()), + basis_med=float(by.median()), + backwardation=float((by < 0).mean()), + ) + T = pd.DataFrame(tab).T + print("\n funding REALIZZATO del perpetual Deribit (somma dell'anno) e BASIS del trimestrale front") + print(" (il carry classico INCASSA il funding: un anno negativo e' un anno in cui PAGA)\n") + print(f" {'':6s} {'anno':>5s} {'funding anno':>13s} {'gg f<0':>8s} " + f"{'basis mediano':>14s} {'ore in backwardation':>21s}") + for (a, y), r in T.iterrows(): + flag = " <<< il 2022" if y == 2022 else "" + print(f" {a:6s} {y:5d} {r.funding_anno*100:+12.2f}% {r.giorni_f_neg*100:7.1f}% " + f"{r.basis_med*100:+13.2f}% {r.backwardation*100:20.1f}%{flag}") + + # episodio peggiore del carry PURO (r = funding), per asset + print("\n peggior EPISODIO del carry puro (r = funding, senza costi): " + "run consecutivo piu' negativo") + for a in ASSETS: + d = F[a]["f1h"].dropna().resample("1D").sum() + cum = d.cumsum() + run_peak = cum.cummax() + under = cum - run_peak + i = under.idxmin() + j = run_peak[:i].idxmax() + print(f" {a}: {float(under.min())*100:+.2f}% di funding cumulato perso fra " + f"{j.date()} e {i.date()} ({(i-j).days} giorni)") + print("\n ⚠️ E il 2022 non e' nemmeno l'unico buco: lo storico di CC01 (dal 2023-05)") + print(" non conteneva neppure MARZO 2020 — che sotto risulta il mese peggiore di tutti.") + return T + + +# ========================================================================== +# 4. Q2 — LA REPLICA DI CC01 CON IL 2022 DENTRO +# ========================================================================== +def cc01_daily(FUNDD: pd.DataFrame, mode: str = "static", trail: int = 14, + cost: float = ANNUAL_COST_CC01, SIG: pd.DataFrame | None = None) -> pd.Series: + """IL MODELLO DI CC01, IDENTICO: r = funding (long spot / short perp), meno un drag + annuo fisso. `FUNDD` = [giorno x asset] di RITORNO giornaliero della gamba. + `gated` = harvest solo se il funding trailing CAUSALE e' positivo. + + ⚠️ `SIG` esiste perche' in CC01 il gate guarda il FUNDING anche quando il ritorno + include il mark-to-market della base (`leg_raw = FUND - PREM.diff()` ma + `sig = FUND.rolling(...)`). Gatare sul ritorno con la base dentro significa gatare + sul RUMORE del premio: prima stesura, e produceva -35%/anno di CAGR.""" + SIG = FUNDD if SIG is None else SIG.reindex(FUNDD.index) + daily_cost = cost / 365.25 + if mode == "gated": + sig = SIG.rolling(trail, min_periods=trail // 2).mean().shift(1) + act = (sig > 0).astype(float) + n = act.sum(axis=1).replace(0, np.nan) + gross = ((FUNDD * act).sum(axis=1) / n).fillna(0.0) + drag = (act.sum(axis=1) > 0).astype(float) * daily_cost + else: + gross = FUNDD.mean(axis=1) + drag = daily_cost + return (gross - drag).dropna() + + +def q2_replica(F: dict, PP_GLOBAL: dict) -> dict: + hr("[2] Q2 — LA REPLICA DI CC01: prima riprodurre il numero VECCHIO, poi produrre il nuovo") + out = {} + + # (a) il numero vecchio, con la macchina vecchia e i dati vecchi (Hyperliquid) + print("\n (a) CONTROLLO DI REPLICA — cash_carry_hl.py tale e quale (funding Hyperliquid 2023-05+)") + try: + import importlib.util + spec = importlib.util.spec_from_file_location( + "cc_old", ROOT / "scripts" / "research" / "cash_carry_hl.py") + cc_old = importlib.util.module_from_spec(spec) + spec.loader.exec_module(cc_old) # type: ignore + for mode in ("static", "gated"): + r = cc_old.cc_returns(["BTC", "ETH"], mode=mode) + out[f"HL_{mode}"] = r + m = cc_old.metrics(r) + print(f" HL {mode:7s}: Sharpe {m['sharpe']:+6.2f} CAGR {m['cagr']*100:+.1f}% " + f"maxDD {m['dd']*100:.2f}% ({r.index[0].date()} .. {r.index[-1].date()})") + print(" -> il '11-13' del 26/06 e' RIPRODOTTO. Ogni delta sotto e' un delta vero.") + except Exception as exc: # noqa: BLE001 + print(f" ⚠️ replica non eseguibile ({type(exc).__name__}: {exc}) — i dati HL sono su disco?") + + # (b) STESSO MODELLO, funding DERIBIT: prima sulla finestra di CC01 (controllo di venue), + # poi sulla finestra piena che contiene il 2022. + FUNDD = pd.concat({a: F[a]["f1h"].dropna().resample("1D").sum() for a in ASSETS}, + axis=1).sort_index().dropna() + print(f"\n (b) STESSO MODELLO su funding DERIBIT ({FUNDD.index[0].date()} .. " + f"{FUNDD.index[-1].date()}, {len(FUNDD):,} giorni)") + print(" NB: e' un CONTROLLO DI VENUE, non una replica — HL e Deribit sono due mercati.\n") + for mode in ("static", "gated"): + full = cc01_daily(FUNDD, mode) + win = full[full.index >= W_CC01] + out[f"DBT_{mode}_full"] = full + out[f"DBT_{mode}_win"] = win + show(f"DERIBIT {mode} — finestra CC01", win) + show(f"DERIBIT {mode} — FINESTRA PIENA", full, extra=" <<< col 2022 dentro") + print() + + print(" per anno (rendimento del carry puro, netto del drag 2%/anno di CC01):") + py = per_year(out["DBT_static_full"]) + print(" " + " ".join(f"{y}:{v*100:+.1f}%" for y, v in py.items())) + pyg = per_year(out["DBT_gated_full"]) + print(" gated: " + " ".join(f"{y}:{v*100:+.1f}%" for y, v in pyg.items())) + g22 = float(pyg.get(2022, float("nan"))) + s22 = float(py.get(2022, float("nan"))) + print(f"\n ⚠️ IPOTESI MIA, SMENTITA DALLA MISURA: avevo scritto che il gate causale di CC01") + print(f" (funding trailing 14g > 0) 'entra ed esce col ritardo, quindi il 2022 lo") + print(f" attraversa comunque'. Falso: 2022 static {s22*100:+.1f}% contro gated " + f"{g22*100:+.1f}% = il gate") + print(f" taglia il {100*(1-abs(g22)/max(abs(s22),1e-9)):.0f}% della perdita dell'anno. " + f"Il funding negativo e' PERSISTENTE (53-59% dei") + print(f" giorni del 2022), quindi un trailing lento basta a stare fuori.") + print(f" -> La conseguenza NON e' che CC01 e' salvo: e' che **il buco del 2022 non era") + print(f" il buco principale**. Il gated con il 2022 dentro fa ancora Sharpe " + f"{stats(out['DBT_gated_full'])['sharpe']:+.2f} con") + print(f" maxDD {stats(out['DBT_gated_full'])['maxdd']*100:.2f}% — cioe' e' ANCORA una serie senza coda sinistra.") + + # (c) la variante che CC01 chiamava 'onesta': r = funding - Delta(premio perp/indice). + # E' il modello di un long-spot/short-perp VERO su un venue che abbia spot. + prem = {a: (PP_GLOBAL[a].reindex(F[a].index) / F[a]["index"] - 1.0) for a in ASSETS} + out["_FUNDD"] = FUNDD + print("\n (c) VARIANTE CON MARK-TO-MARKET DELLA BASE (r = funding - Delta premio perp/indice)") + print(" e' il modello di un long-spot/short-perp VERO (su un venue che abbia spot).") + print(" ⚠️ DUE LENTI, perche' il 'premio' non e' un oggetto unico: quello GREZZO") + print(" (chiusura del perp / indice, ora per ora) contiene le dislocazioni di") + print(" microstruttura; quello LISCIO (mediana 24h) e' l'analogo del `premium` che") + print(" Hyperliquid pubblica e che CC01 usava — ed e' PIU' MITE per costruzione.") + for lente, sm in (("grezzo", 1), ("liscio 24h", 24)): + DPR = pd.concat({a: (prem[a].rolling(sm, min_periods=1).median() if sm > 1 else prem[a]) + .resample("1D").last().diff() for a in ASSETS}, axis=1).sort_index() + FB = (FUNDD - DPR.reindex(FUNDD.index)).dropna() + for mode in ("static", "gated"): + fb = cc01_daily(FB, mode, SIG=FUNDD) # il GATE guarda il funding, come CC01 + key = "" if sm == 1 else "_sm" + out[f"DBT_{mode}_basis{key}_full"] = fb + show(f"{mode} + base ({lente}) — PIENA", fb) + out[f"DBT_{mode}_basis{key}_win"] = fb[fb.index >= W_CC01] + show(f"{mode} + base ({lente}) — fin. CC01", out[f"DBT_{mode}_basis{key}_win"]) + print() + # DIAGNOSTICA: la liscezza si MISURA, non si asserisce. + try: + from src.portfolio.sleeves import _HL_DIR # noqa: PLC0415 + rows = [] + for a in ASSETS: + fp = _HL_DIR / f"hlfund_{a.lower()}_1h.parquet" + if not fp.exists(): + continue + hl = pd.read_parquet(fp)["premium"].resample("1D").last().diff().dropna() + raw = prem[a].resample("1D").last().diff().dropna() + sm = prem[a].rolling(24, min_periods=1).median().resample("1D").last().diff().dropna() + rows.append((a, hl.std(), sm.std(), raw.std())) + if rows: + print("\n sd del Delta-premio GIORNALIERO (e' la vol che il carry si prende addosso):") + for a, h, sm_, r_ in rows: + print(f" {a}: Hyperliquid `premium` {h*1e4:6.1f} bps | Deribit liscio 24h " + f"{sm_*1e4:6.1f} bps | Deribit grezzo {r_*1e4:6.1f} bps " + f"-> grezzo/HL = {r_/max(h,1e-12):.1f}x") + except Exception as exc: # noqa: BLE001 + print(f" (diagnostica sulla liscezza NON girata: {type(exc).__name__})") + print(" ⚠️ Il confronto che conta NON e' con l'11-13 di CC01 ma con il suo 'onesto'") + print(" 10,87: CC01 scriveva che il mark-to-market della base 'sgonfia lo Sharpe solo") + print(" 13->11, quindi il basis-from-data NON e' il rischio vero'. Su Deribit lo stesso") + print(" esperimento, SULLA STESSA FINESTRA, lo sgonfia a 1,65 (lente liscia) / 0,37") + print(" (lente grezza). La differenza non e' il periodo: e' la MISURA del premio, e") + print(" **la liscezza era una proprieta' della colonna, non del rischio.**") + return out + + +# ========================================================================== +# 5. Q3/Q4 — IL CARRY DERIBIT A DUE GAMBE (perp + trimestrale), roll e margine +# ========================================================================== +def deribit_carry(asset: str, wide: pd.DataFrame, exps: dict, fu: pd.DataFrame, + perp: pd.Series, roll_h: int = 72, sign: int = +1, + slip_bps: float = SLIP_BPS, fee_side: float = FEE_SIDE, + inverse: bool = True, gated: bool = False, trail_h: int = 24 * 14): + """Carry delta-neutral ESEGUIBILE su Deribit, a DUE gambe sullo STESSO venue. + + sign = +1 LONG trimestrale / SHORT perp -> incassa funding, paga il basis + (e' la DIREZIONE DI CC01: il datato fa da proxy dello spot, che a Deribit + storicamente non abbiamo; e la gamba corta sul perp incassa il funding) + sign = -1 SHORT trimestrale / LONG perp -> incassa il basis, paga il funding + + Il roll: il datato attivo e' il trimestrale piu' vicino con dte >= roll_h ore. Quando + cambia si paga il round-trip COMPLETO sulla sola gamba datata (2 ordini). **La gamba + perp NON si rolla**: e' un perpetuo. (Il filone BASIS-CALENDAR contava il turnover x2 + su entrambe le gambe: qui il costo del roll e' modellato per quello che e'.) + + Convenzione INVERSE (quella del venue: settlement BTC/ETH, quote USD): + P&L_BTC(long N USD) = N (1/entrata - 1/uscita) + funding inverse: dimensione in BTC = N/indice; i long PAGANO quando f>0 + Il ritorno orario e' espresso per $1 di nozionale PER GAMBA. + """ + idx = wide.index + n = len(idx) + order = sorted(exps, key=lambda c: exps[c]) + TAU = np.column_stack([(exps[c] - idx).total_seconds().values / 3600.0 for c in order]) + AV = np.column_stack([wide[c].notna().values for c in order]) + AL = AV & (TAU >= roll_h) + ok = AL.any(axis=1) + k1 = np.argmax(AL, axis=1) + W = wide[order].values.astype(float) + rows = np.arange(n) + front = np.where(ok, np.array(order, dtype=object)[k1], "") + Fv = np.where(ok, W[rows, k1], np.nan) + tau = np.where(ok, TAU[rows, k1], np.nan) + + fu2 = fu.reindex(idx).ffill(limit=2) + IX = fu2["index"].values.astype(float) + f1 = np.nan_to_num(fu2["f1h"].values.astype(float)) + P = perp.reindex(idx).values.astype(float) + + live = np.isfinite(Fv) & np.isfinite(P) & np.isfinite(IX) & (P > 0) & (IX > 0) + same = np.zeros(n, bool) + same[1:] = live[1:] & live[:-1] & (front[1:] == front[:-1]) & (front[1:] != "") + + # ------- segnale (causale): opzionale gate sul carry netto trailing ------- + basis_p = np.log(np.where(live, Fv, np.nan) / np.where(live, P, np.nan)) * HOURS_Y / tau + net_carry = f1 * HOURS_Y - basis_p # carry atteso del sign=+1, annualizzato + if gated: + s = pd.Series(net_carry, index=idx).rolling(trail_h, min_periods=trail_h // 3).mean().shift(1) + w = ((s * sign) > 0).astype(float).values # esci quando il carry trailing e' contrario + else: + w = np.ones(n) + w = np.nan_to_num(w) + + # ------- P&L orario per $1 di nozionale per gamba ------- + r = np.zeros(n) + if inverse: + # long datato: N(1/F_{t-1} - 1/F_t) | short perp: N(1/P_t - 1/P_{t-1}) (in BTC) + with np.errstate(all="ignore"): + dF = np.zeros(n); dP = np.zeros(n) + dF[1:] = 1.0 / Fv[:-1] - 1.0 / Fv[1:] + dP[1:] = 1.0 / P[1:] - 1.0 / P[:-1] + btc = sign * (dF + dP) + btc = btc + sign * (f1 / IX) # short perp INCASSA il funding se f>0 + r = np.where(same, IX * btc, 0.0) # da BTC a USD all'indice + else: + with np.errstate(all="ignore"): + rF = np.zeros(n); rP = np.zeros(n) + rF[1:] = Fv[1:] / Fv[:-1] - 1.0 + rP[1:] = P[1:] / P[:-1] - 1.0 + r = np.where(same, sign * (rF - rP + f1), 0.0) + r = np.nan_to_num(r) + held = np.zeros(n) + held[1:] = w[:-1] # deciso a t, tenuto t->t+1 (causale) + gross = r * held + + # ------- costi: apertura, ROLL (solo gamba datata), chiusura, e on/off del gate ------- + tc = fee_side + slip_bps * 1e-4 + cost = np.zeros(n) + rolled = np.zeros(n, bool) + rolled[1:] = live[1:] & live[:-1] & (front[1:] != front[:-1]) & (front[:-1] != "") & (front[1:] != "") + cost += rolled * (2.0 * tc) * np.maximum(held, np.roll(held, 1)) # chiudi vecchio + apri nuovo + dw = np.zeros(n) + dw[1:] = np.abs(held[1:] - held[:-1]) + cost += dw * (2.0 * tc) # entrata/uscita: 2 gambe + net = pd.Series(gross - cost, index=idx) + + # ------- tabella per TRADE (un trade = un contratto tenuto) ------- + fr = pd.Series(front, index=idx) + trades = [] + for c, g in net.groupby(fr): + if c == "" or len(g) < 24: + continue + cc = g.cumsum() + trades.append(dict(contract=c, start=g.index[0], end=g.index[-1], ore=len(g), + pnl=float(cc.iloc[-1]), mae=float(cc.min()), mfe=float(cc.max()))) + T = pd.DataFrame(trades).sort_values("start").reset_index(drop=True) if trades else pd.DataFrame() + return net, T, pd.Series(basis_p, index=idx), pd.Series(net_carry, index=idx) + + +def q3_carry(W: dict, E: dict, F: dict, PP: dict) -> dict: + hr("[3] Q3 — IL CARRY DERIBIT A DUE GAMBE (perpetual + trimestrale), roll pagato") + print(" Su Deribit non esiste storico SPOT: la coppia delta-neutral a due gambe sullo stesso") + print(" venue e' PERP vs TRIMESTRALE. Il trimestrale non paga funding -> fa da proxy dello") + print(" spot, e la direzione di CC01 diventa: LONG trimestrale / SHORT perp.\n") + res = {} + for sign, nome in ((+1, "LONG datato / SHORT perp (= direzione CC01)"), + (-1, "SHORT datato / LONG perp (= raccolta del contango)")): + legs = {} + for a in ASSETS: + net, T, basis, nc = deribit_carry(a, W[a], E[a], F[a], PP[a], sign=sign) + legs[a] = (net, T) + both = pd.concat([legs[a][0] for a in ASSETS], axis=1, join="inner").mean(axis=1) + d = to_daily(both) + res[sign] = dict(hourly=both, daily=d, legs=legs) + print(f" --- {nome} ---") + show(" 50/50 BTC+ETH, finestra piena", d) + show(" 50/50 BTC+ETH, finestra CC01", d[d.index >= W_CC01]) + py = per_year(d) + print(" per anno: " + " ".join(f"{y}:{v*100:+.1f}%" for y, v in py.items())) + print() + + # convenzione inverse vs lineare — misurata, non assunta + net_i, _, _, _ = deribit_carry("BTC", W["BTC"], E["BTC"], F["BTC"], PP["BTC"], sign=+1, inverse=True) + net_l, _, _, _ = deribit_carry("BTC", W["BTC"], E["BTC"], F["BTC"], PP["BTC"], sign=+1, inverse=False) + di, dl = to_daily(net_i), to_daily(net_l) + print(f" convenzione del venue: INVERSE (BTC-settled) Sharpe {stats(di)['sharpe']:+.2f} " + f"contro l'approssimazione LINEARE {stats(dl)['sharpe']:+.2f} " + f"(CAGR {stats(di)['cagr']*100:+.2f}% vs {stats(dl)['cagr']*100:+.2f}%, BTC sign=+1)") + dy = (per_year(di) - per_year(dl)) * 100 + print(" differenza per anno (inverse - lineare, punti percentuali): " + + " ".join(f"{y}:{v:+.1f}" for y, v in dy.items())) + print(" ⚠️ NON e' un dettaglio contabile: su una coppia INVERSE con nozionali pareggiati") + print(" in USD resta un delta residuo ~ -basis (dal termine 1/F - 1/P), e vale piu'") + print(" dell'intero premio che il carry vorrebbe incassare. Chi modella un carry su") + print(" inverse in convenzione lineare si regala un ritorno che il venue non paga.") + return res + + +def q4_margine(res: dict, F: dict, PP: dict) -> dict: + hr("[4] Q4 — IL ROLL E IL MARGINE: cio' che un P&L a scadenza NON PUO' VEDERE") + print(" Un carry delta-neutral SEMBRA liscio perche' il modello guarda il cashflow e il") + print(" payoff a scadenza. Ma la perdita di mark-to-market e' RICHIESTA SUBITO in margine") + print(" mentre il guadagno arriva alla convergenza: se l'equity scende sotto il mantenimento,") + print(" la posizione viene chiusa nel punto peggiore e il guadagno a scadenza non arriva mai.\n") + print(f" Margine dichiarato (fonte secondaria, documentazione Deribit futures): " + f"IM {IM_LEG*100:.2f}%/gamba, MM {MM_LEG*100:.3f}%/gamba -> MM totale " + f"{2*MM_LEG*100:.2f}% del nozionale per gamba.") + print(" Sotto leva per-gamba lambda (nozionale per gamba = lambda x equity):") + print(" liquidazione quando 1 + lambda*cumPnL < 2*MM*lambda") + print(" -> lambda_max = 1 / (2*MM + |MAE|) con MAE = escursione avversa del trade\n") + + out = {} + for sign in (+1, -1): + nome = "LONG datato/SHORT perp" if sign == +1 else "SHORT datato/LONG perp" + rows = [] + for a in ASSETS: + T = res[sign]["legs"][a][1].copy() + T["asset"] = a + rows.append(T) + T = pd.concat(rows).sort_values("start").reset_index(drop=True) + T["anno"] = T.start.dt.year + full_mae = float(T.mae.min()) + win = T[T.start >= W_CC01] + win_mae = float(win.mae.min()) if len(win) else float("nan") + lam_full = 1.0 / (2 * MM_LEG + abs(full_mae)) + lam_win = 1.0 / (2 * MM_LEG + abs(win_mae)) if np.isfinite(win_mae) else float("nan") + out[sign] = dict(T=T, lam_full=lam_full, lam_win=lam_win, + mae_full=full_mae, mae_win=win_mae) + print(f" --- {nome} ---") + print(f" trade (= contratti tenuti): {len(T)} in perdita: " + f"{int((T.pnl<0).sum())} ({(T.pnl<0).mean()*100:.0f}%)") + print(f" peggiore escursione avversa (MAE) sulla FINESTRA PIENA : " + f"{full_mae*100:+6.2f}% -> leva massima sostenibile {lam_full:5.1f}x per gamba") + print(f" peggiore escursione avversa (MAE) nella FINESTRA CC01 : " + f"{win_mae*100:+6.2f}% -> leva massima sostenibile {lam_win:5.1f}x per gamba") + if np.isfinite(lam_win) and lam_full > 0: + print(f" ⚠️ chi avesse dimensionato il carry sulla finestra di CC01 avrebbe usato " + f"fino a {lam_win:.1f}x: e' {lam_win/lam_full:.1f} VOLTE la leva che la storia piena sopporta.") + worst = T.nsmallest(5, "mae")[["asset", "contract", "start", "ore", "pnl", "mae"]] + print(" i 5 peggiori trade per escursione avversa:") + for r in worst.itertuples(): + print(f" {r.asset} {r.contract:<12s} {str(r.start.date()):>10s} " + f"{r.ore:5d} ore P&L finale {r.pnl*100:+6.2f}% MAE {r.mae*100:+6.2f}%") + print() + + # ---- e il carry PURO (long spot / short perp): il suo blow-out e' il PREMIO DEL PERP ---- + print(" Il carry PURO di CC01 (long spot / short perp) NON HA SCADENZA: non converge mai,") + print(" quindi il suo mark-to-market e' il PREMIO del perpetual sull'indice, e la sua perdita") + print(" massima e' il premio stesso. Misurato sul feed certificato Deribit:") + for a in ASSETS: + fu = F[a] + pp = PP[a].reindex(fu.index) + prem = (pp / fu["index"] - 1.0).dropna() + f1 = fu["f1h"].reindex(prem.index).fillna(0.0) + pnl = f1.cumsum() - (prem - prem.iloc[0]) # funding incassato - MTM della base + # MAE a 90 GIORNI: la finestra di detenzione confrontabile con un trimestrale. + # (Il drawdown da picco NON e' la misura giusta qui: il picco e' un crash-spike di + # un'ora in cui il perp scambia -20% sotto l'indice, cioe' un GUADAGNO per chi e' + # corto il perp. Un rischio si misura dall'INGRESSO, non da una fortuna transitoria.) + w = 90 * 24 + fwd_min = pnl[::-1].rolling(w, min_periods=1).min()[::-1] + exc = fwd_min - pnl + # ⚠️ INGRESSI AMMISSIBILI: un carry si apre quando il perp e' a PREMIO (e' l'intera + # ragione del trade). Includere le ore in cui il perp scambia -20% SOTTO l'indice + # (marzo 2020) misura l'escursione di un ingresso che nessuno farebbe. Si riporta + # il numero ristretto come stima, e quello grezzo come limite superiore. + exc_ok = exc.where(prem >= 0) + mae90 = float(exc_ok.min()) + i90 = exc_ok.idxmin() + mae90_raw = float(exc.min()) + lam90 = 1.0 / (2 * MM_LEG + abs(mae90)) + pr_y = prem.groupby(prem.index.year).max() + print(f" {a}: premio perp/indice mediana {prem.median()*1e4:+.1f} bps | " + f"MASSIMO {prem.max()*1e4:+.0f} bps ({prem.idxmax().date()}) | " + f"minimo {prem.min()*1e4:+.0f} bps ({prem.idxmin().date()})") + print(f" MAE a 90 giorni del carry puro CON mark-to-market della base, su ingressi") + print(f" ammissibili (premio >= 0): {mae90*100:+.2f}% (ingresso {i90.date()}) " + f"-> leva massima {lam90:.1f}x per gamba") + print(f" (senza il filtro d'ingresso: {mae90_raw*100:+.2f}% — include l'apertura di") + print(f" uno short perp a sconto del 20%, che nessuno farebbe)") + print(f" premio MASSIMO per anno (bps, e' il lato AVVERSO del carry): " + + ", ".join(f"{y}:{v*1e4:+.0f}" for y, v in pr_y.items())) + print(" ⚠️ Il modello r=funding di CC01 ha maxDD 0,3-0,9%: la stessa posizione, con il") + print(" mark-to-market della base che il margine guarda davvero, ha una escursione") + print(" avversa di un ordine di grandezza piu' grande. Non e' un dettaglio: e' la") + print(" differenza fra 'leva 80x' e 'leva 6x'.") + + return out + + +# ========================================================================== +# 6. GATE +# ========================================================================== +def q5_gate(cc: dict, res: dict, mg: dict, n_trials: int, family_sr: list) -> None: + hr("[5] GATE — a partire da quello che CC01 stesso ha fatto nascere") + + print("\n (a) implausible_sharpe — il gate del 26/07, scritto PROPRIO su CC01") + cand = [ + ("CC01 originale (HL, 2023-05+)", cc.get("HL_static"), None, None), + ("CC01 gated originale (HL)", cc.get("HL_gated"), None, None), + ("CC01 su Deribit, finestra CC01", cc.get("DBT_static_win"), None, None), + ("CC01 su Deribit, FINESTRA PIENA", cc.get("DBT_static_full"), None, None), + ("CC01 gated Deribit, FINESTRA PIENA", cc.get("DBT_gated_full"), None, None), + ("CC01+base grezzo, FINESTRA PIENA", cc.get("DBT_static_basis_full"), None, None), + ("CC01+base grezzo gated, PIENA", cc.get("DBT_gated_basis_full"), None, None), + ("CC01+base liscio 24h, FINESTRA PIENA", cc.get("DBT_static_basis_sm_full"), None, None), + ("CC01+base liscio 24h gated, PIENA", cc.get("DBT_gated_basis_sm_full"), None, None), + ] + for sign in (+1, -1): + T = mg[sign]["T"] + nm = "carry 2 gambe Deribit sign=+1" if sign == +1 else "carry 2 gambe Deribit sign=-1" + cand.append((nm, res[sign]["daily"], int(len(T)), int((T.pnl < 0).sum()))) + for nm, ser, nt, nl in cand: + if ser is None or len(ser) < 30: + print(f" {nm:<38s} NON GIRATO (serie assente)") + continue + g = A.implausible_sharpe(ser, n_trades=nt, n_losing_trades=nl) + tag = "🚨 SEGNALA" if g["implausible"] else " pulito " + print(f" {nm:<38s} {tag} Sh {g.get('sharpe', float('nan')):+6.2f} " + f"maxDD {g.get('maxdd', float('nan'))*100:5.2f}% " + f"perdite/attive {g.get('loss_frac', float('nan'))*100:5.1f}% " + f"Calmar {g.get('calmar', float('nan')):7.1f}") + for rr in g["reasons"]: + print(f" - {rr}") + + print("\n (b) marginal_vs_tp01 — sul carry ESEGUIBILE (2 gambe Deribit, finestra piena)") + for sign in (+1, -1): + d = res[sign]["daily"] + if d.std() == 0: + print(f" sign={sign:+d}: serie piatta, skip") + continue + m = A.marginal_vs_tp01(d) + b = m.get("blends", {}).get("w25", {}) + print(f" sign={sign:+d} verdetto={m.get('marginal_verdict')} " + f"corr->TP01 {m.get('corr_full')} is_hedge={m.get('is_hedge')} " + f"edge_in_sample={m.get('has_insample_edge')} (Sh IS {m.get('cand_insample_sharpe')})") + print(f" robust_oos={m.get('robust_oos')} multicut={m.get('multicut_persistent')} " + f"blend25 full {b.get('full')} (upl {b.get('uplift_full')}) hold {b.get('hold')} " + f"(upl {b.get('uplift_hold')})") + + print("\n (c) deflated_sharpe — sui trial DICHIARATI, contati al rialzo") + print(" ⚠️ la famiglia e' quella del CANDIDATO (le celle del carry a 2 gambe: 2 segni x") + print(" 8 ancore d'ora x 7 anticipi di roll). Mescolarci gli Sharpe del carry PURO") + print(" (3-7) gonfierebbe la varianza dei trial e quindi la soglia del null: sarebbe") + print(" un null di un'altra strategia.") + allsr = list(family_sr) + [stats(res[s_]["daily"])["sharpe"] for s_ in (+1, -1)] + best_sign = max((+1, -1), key=lambda s: stats(res[s]["daily"])["sharpe"]) + d = res[best_sign]["daily"] + sr = stats(d)["sharpe"] + # la lista degli Sharpe di famiglia serve per la varianza dei trial: si usa la griglia + # d'ancora del roll, gia' calcolata sotto, piu' i due segni. + try: + dsr, sr0 = A.deflated_sharpe(sr, allsr, d) + txt = (f"DSR {dsr:.3f} ({'PASS' if dsr >= 0.95 else 'FAIL'}), " + f"massimo atteso dal solo rumore {sr0:+.2f}") + except Exception as exc: # noqa: BLE001 + txt = f"NON GIRATO ({type(exc).__name__})" + print(f" candidato migliore = sign {best_sign:+d}, Sharpe {sr:+.2f}, " + f"trial dichiarati {n_trials} -> {txt}") + print(f" (il massimo atteso dal rumore su questa famiglia e' PIU' ALTO del candidato:") + print(f" non c'e' niente da deflazionare, il candidato e' sotto il proprio null.)") + + print("\n (c-bis) CAUSALITA' — dichiarata, e con un controllo POSITIVO") + print(" Il carry STATICO non ha segnale: non c'e' niente da anticipare, e la scelta del") + print(" contratto front dipende solo dal calendario di scadenza, noto anni prima.") + print(" L'unico segnale del filone e' il gate di CC01 (media mobile del funding). Li' la") + print(" causalita' e' uno `.shift(1)`, e un controllo positivo deve mostrare che TOGLIERLO") + print(" migliora — altrimenti il test non ha potenza:") + try: + FU2 = pd.concat({a: cc["_FUNDD"][a] for a in ASSETS}, axis=1) if "_FUNDD" in cc else None + except Exception: # noqa: BLE001 + FU2 = None + if FU2 is not None: + leak = FU2.rolling(14, min_periods=7).mean() # SENZA shift = leak + act = (leak > 0).astype(float) + n_ = act.sum(axis=1).replace(0, np.nan) + g_leak = ((FU2 * act).sum(axis=1) / n_).fillna(0.0) - \ + (act.sum(axis=1) > 0).astype(float) * ANNUAL_COST_CC01 / 365.25 + print(f" gate CAUSALE (shift 1): Sharpe {stats(cc['DBT_gated_full'])['sharpe']:+.2f} | " + f"gate CON LEAK (no shift): Sharpe {stats(g_leak.dropna())['sharpe']:+.2f} " + f"-> il test HA potenza e la versione pubblicata e' quella causale") + else: + print(" NON GIRATO (pannello di funding non disponibile)") + + print("\n (d) null del DE-LEVERING — il carry rivendica un DD minuscolo: e' solo meno leva?") + try: + base = A.tp01_baseline_daily() + for nm, ser in (("CC01 Deribit finestra piena", cc.get("DBT_static_full")), + ("carry 2 gambe sign=+1", res[+1]["daily"])): + if ser is None: + continue + cs = stats(ser) + hit = None + for k in np.arange(1.00, 0.01, -0.02): # il k PIU' GRANDE che rispetta il DD + st = stats(base * k) + if st["maxdd"] <= cs["maxdd"] + 1e-12: + hit = (float(k), st) + break + if hit is None: + print(f" {nm}: nessun k<=1 sul baseline raggiunge quel DD -> null non applicabile") + else: + k, st = hit + verdetto = "REFUTATO (il baseline de-leverato fa meglio)" if st["sharpe"] > cs["sharpe"] \ + else "SUPERATO (il baseline de-leverato NON lo eguaglia)" + print(f" {nm}: DD {cs['maxdd']*100:.2f}% Sh {cs['sharpe']:+.2f} vs " + f"TP01 a k={k:.2f}: DD {st['maxdd']*100:.2f}% Sh {st['sharpe']:+.2f} -> {verdetto}") + print(" ⚠️ lo Sharpe e' INVARIANTE alla scala, quindi su un candidato che rivendica") + print(" 'poco DD' questo null si riduce a 'il tuo Sharpe batte quello del baseline?'.") + print(" E' degenere per costruzione (lezione 22/08) — girato lo stesso e dichiarato.") + except Exception as exc: # noqa: BLE001 + print(f" NON GIRATO ({type(exc).__name__}: {exc})") + + +def q5b_ancora(W: dict, E: dict, F: dict, PP: dict, sign: int) -> list: + print("\n (e) FORTUNA D'ANCORA del roll — l'ora in cui si rolla e' un max-of-k non dichiarato") + sh = {} + for off in range(0, 24, 3): + legs = [] + for a in ASSETS: + net, _, _, _ = deribit_carry(a, W[a], E[a], F[a], PP[a], + roll_h=72 + off, sign=sign) + legs.append(net) + d = to_daily(pd.concat(legs, axis=1, join="inner").mean(axis=1)) + sh[off] = stats(d)["sharpe"] + v = np.array(list(sh.values())) + print(f" 8 ancore di roll (72h + 0..21h): mediana {np.median(v):+.3f} " + f"banda [{v.min():+.3f}, {v.max():+.3f}] canonica(72h) {sh[0]:+.3f} " + f"-> fortuna {sh[0]-np.median(v):+.3f}") + sh2 = {} + for rd in (1, 2, 3, 5, 7, 10, 14): + legs = [] + for a in ASSETS: + net, _, _, _ = deribit_carry(a, W[a], E[a], F[a], PP[a], + roll_h=rd * 24, sign=sign) + legs.append(net) + d = to_daily(pd.concat(legs, axis=1, join="inner").mean(axis=1)) + sh2[rd] = stats(d)["sharpe"] + print(" giorni di anticipo del roll: " + + " ".join(f"{k}g:{v:+.2f}" for k, v in sh2.items())) + return list(sh.values()) + list(sh2.values()) + + +# ========================================================================== +# 7. ESEGUIBILITA' +# ========================================================================== +def q6_eseguibilita(mg: dict) -> None: + hr("[6] Q3-bis — I MURI NON STRUTTURALI, riprezzati coi numeri di oggi") + print(" CC01 fu dichiarato non eseguibile per: 'spot+perp = 4-38 gambe, funding HL non") + print(" Deribit'. La versione perp+trimestrale e' a DUE gambe sullo STESSO venue.\n") + specs = { + "BTC-PERPETUAL": dict(min_usd=10.0, tick=0.5, settle="BTC"), + "BTC trimestrale": dict(min_usd=10.0, tick=2.5, settle="BTC"), + "ETH-PERPETUAL": dict(min_usd=1.0, tick=0.05, settle="ETH"), + "ETH trimestrale": dict(min_usd=1.0, tick=0.25, settle="ETH"), + } + print(" specifiche lette dal venue (public/get_instruments, 2026-08-22):") + for k, v in specs.items(): + print(f" {k:<18s} lotto minimo ${v['min_usd']:>5.0f} tick {v['tick']:<5} " + f"settlement {v['settle']}") + print(f"\n A $635 il lotto minimo NON e' il vincolo (4a volta in questa ondata): un carry") + print(f" BTC a lambda=1 vuole $635 per gamba, 63 volte il lotto minimo.") + print(f" Margine iniziale a lambda=1: {2*IM_LEG*100:.1f}% del nozionale per gamba = " + f"${2*IM_LEG*635:.0f} su $635.") + + lam = mg[+1]["lam_full"] + print(f"\n ⚠️ MA il vincolo vero e' un ALTRO, e non e' la taglia: **tutti i futures datati") + print(f" Deribit sono INVERSE, settlement_currency BTC/ETH** (verificato strumento per") + print(f" strumento: non esiste una linea USDC datata). Il libro live e' su USDC") + print(f" lineari. E' lo stesso muro trovato il 22/08 sulle opzioni ('BTC fuori a $3.000") + print(f" misurato sulla famiglia inverse'): un conto USDC non margina un inverse senza") + print(f" cross-collateral, e il collaterale in BTC E' esso stesso esposizione") + print(f" direzionale — che e' precisamente cio' che un delta-neutral vuole evitare.") + print(f" Questo NON e' stato risolto qui: e' una verifica da UNA chiamata sul conto") + print(f" reale (haircut del cross-collateral), e va fatta prima di credere a qualsiasi") + print(f" numero di eseguibilita' di questo filone.") + + print("\n costo d'esecuzione ANNUO del carry rollato (4 roll/anno x 2 ordini, SOLO la gamba") + print(" datata: il perpetuo non si rolla; apertura e chiusura sono 2 ordini UNA volta sola):") + for slip in (0.0, 2.0, 5.0, 10.0): + c = 4 * 2 * (FEE_SIDE + slip * 1e-4) + print(f" slippage {slip:4.1f} bps/lato -> {c*100:5.3f}%/anno (8 ordini/anno)") + print(" spread REALE letto sul book vivo (cache 22/08): BTC front 0,32 bps / secondo 5,43;") + print(" ETH front 1,03 / secondo 1,02 -> slippage 2 bps/lato e' una stima ONESTA sul front.") + print(f" ⚠️ BASIS-CALENDAR citava ~1,4%/anno di costo perche' contava il turnover su") + print(f" ENTRAMBE le gambe a ogni roll: il perpetuo NON si rolla. Il costo vero e'") + print(f" **0,44%/anno a 2 bps di slippage**, ~3 volte meno. Il che rende quel filone") + print(f" MENO severo sul costo e per NIENTE meno severo sul segno: un premio di") + print(f" ~0,85%/anno con IC95 che contiene lo zero resta indistinguibile da zero anche") + print(f" pagando un terzo del costo. **Correggere un costo non crea un edge.**") + + +# ========================================================================== +# MAIN +# ========================================================================== +def main() -> None: + t0 = time.time() + hr("r0822d CC01-2022 — riaprire il cash-and-carry con la coda che gli mancava") + + persist_dataset() + + print("\n caricamento...") + W, E, F, PP = {}, {}, {}, {} + for a in ASSETS: + W[a], E[a] = load_futures(a) + F[a] = load_fund(a) + PP[a] = load_perp(a) + print(f" {a}: {W[a].shape[1]} contratti, {len(W[a]):,} ore " + f"({W[a].index.min().date()} .. {W[a].index.max().date()}) " + f"funding+indice {len(F[a]):,} ore perp certificato {len(PP[a]):,} barre") + + q1_descrittiva(F, W, E) + cc = q2_replica(F, PP) + res = q3_carry(W, E, F, PP) + mg = q4_margine(res, F, PP) + + # trial dichiarati, contati AL RIALZO (ogni variante provata, comprese quelle scartate): + # 4 carry puro r=funding : 2 modi (static/gated) x 2 finestre + # 8 carry puro + base MTM : 2 modi x 2 lenti sul premio x 2 finestre + # 32 carry a 2 gambe : 2 segni x (1 canonica + 8 ancore d'ora + 7 anticipi roll) + # 2 convenzione : inverse vs lineare + # 1 controllo positivo di leak + N_TRIALS = 4 + 8 + 32 + 2 + 1 + best = max((+1, -1), key=lambda s: stats(res[s]["daily"])["sharpe"]) + print("\n (pre-calcolo della griglia d'ancora, serve al deflated-Sharpe)") + family_sr = q5b_ancora(W, E, F, PP, best) + q5_gate(cc, res, mg, N_TRIALS, family_sr) + q6_eseguibilita(mg) + + hr("[7] SINTESI — LA SCALA, cioe' dove sono davvero andati gli 11-13 punti di Sharpe") + s_hl = stats(cc["HL_static"])["sharpe"] if "HL_static" in cc else float("nan") + s_win = stats(cc["DBT_static_win"])["sharpe"] + s_full = stats(cc["DBT_static_full"])["sharpe"] + s_bas = stats(cc["DBT_static_basis_full"])["sharpe"] + d_full = cc["DBT_static_full"] + best_d = res[best]["daily"] + righe = [ + ("CC01 come pubblicato (funding HL, r=funding, 2023-05+)", s_hl, + stats(cc["HL_static"])["maxdd"] if "HL_static" in cc else float("nan"), ""), + ("stesso modello, funding DERIBIT, STESSA finestra", s_win, + stats(cc["DBT_static_win"])["maxdd"], "controllo di venue"), + ("stesso modello, DERIBIT, finestra piena (col 2022)", s_full, + stats(d_full)["maxdd"], "<- la coda che mancava"), + ("+ base, premio LISCIO 24h (la lente di CC01/HL)", + stats(cc["DBT_static_basis_sm_full"])["sharpe"], + stats(cc["DBT_static_basis_sm_full"])["maxdd"], "<- il rischio che il margine guarda"), + ("+ base, premio GREZZO (la lente severa)", s_bas, + stats(cc["DBT_static_basis_full"])["maxdd"], " banda della stessa cosa"), + ("carry ESEGUIBILE su Deribit, 2 gambe, DIREZIONE CC01", + stats(res[+1]["daily"])["sharpe"], stats(res[+1]["daily"])["maxdd"], + "<- niente spot: serve un 2o derivato"), + (" (e scegliendo EX-POST il segno migliore dei due)", + stats(res[-1]["daily"])["sharpe"], stats(res[-1]["daily"])["maxdd"], ""), + ] + print(f" {'lente':<56s} {'Sharpe':>8s} {'maxDD':>8s}") + for nm, sh, dd, nota in righe: + print(f" {nm:<56s} {sh:+8.2f} {dd*100:7.2f}% {nota}") + print() + s_sm = stats(cc["DBT_static_basis_sm_full"])["sharpe"] + s_2l = stats(res[+1]["daily"])["sharpe"] + print(f" SCOMPOSIZIONE (lente LISCIA, la piu' favorevole al filone):") + print(f" il 2022 vale {s_win - s_full:+.2f} di Sharpe (7,88 -> 3,72)") + print(f" la base MTM vale {s_full - s_sm:+.2f} (3,72 -> {s_sm:.2f})") + print(f" la SECONDA GAMBA {s_sm - s_2l:+.2f} ({s_sm:.2f} -> {s_2l:.2f})") + print(f" (lente GREZZA: il 2022 {s_win - s_full:+.2f}, la base {s_full - s_bas:+.2f}, la seconda gamba " + f"{s_bas - s_2l:+.2f})") + print() + print(f" ⚠️ CORREZIONE A ME STESSO, fatta guardando la mia stessa tabella: avevo scritto") + print(f" 'il buco piu' grande non era il 2022'. L'aritmetica dice il CONTRARIO — il 2022 e'") + print(f" il singolo pezzo piu' grande ({s_win - s_full:+.2f}) su entrambe le lenti. Il punto vero e' un") + print(f" altro, ed e' piu' forte: **il 2022 da solo NON BASTA**. Toglierlo dal buco porta lo") + print(f" Sharpe da 7,88 a 3,72, che `implausible_sharpe` SEGNALA ANCORA. I due pezzi che il") + print(f" 26/06 non aveva dichiarato — mark-to-market della base e assenza di una gamba spot") + print(f" negoziabile — valgono INSIEME quanto il 2022 ({s_full - s_2l:+.2f} sulla lente liscia) e sono") + print(f" quelli che portano il numero sotto 1. La ragione pubblicata era NECESSARIA e non") + print(f" SUFFICIENTE.") + print(f" 📌 E il salto piu' grande della tabella non e' nemmeno nell'elenco: e' il VENUE") + print(f" ({s_hl - s_win:+.2f}, da HL a Deribit sulla STESSA finestra e collo STESSO modello). Un carry") + print(f" di funding misurato su un venue non e' un carry di funding.") + print() + print(f" leva massima sostenibile dal carry a 2 gambe in direzione CC01: " + f"{mg[+1]['lam_full']:.1f}x per gamba sulla") + print(f" storia piena, contro {mg[+1]['lam_win']:.1f}x che la sola finestra di CC01 avrebbe autorizzato") + print(f" ({mg[+1]['lam_win']/mg[+1]['lam_full']:.1f}x di leva di troppo). Sul carry PURO con MTM della base: " + f"~5,6-6,4x contro le") + print(f" ~80x che un maxDD dello 0,8% farebbe credere sostenibili.") + print() + print(" ✅ Il criterio di riapertura dichiarato il 26/06 era FALSIFICABILE ed e' stato") + print(" ESEGUITO: il dato che mancava ora esiste, la misura e' stata fatta, e il verdetto") + print(" non cambia. **Il filone era chiuso per la ragione giusta** — e ora si sa anche") + print(" che quella ragione era NECESSARIA ma non sufficiente: il 2022 era il pezzo piu'") + print(" grande dei tre, e da solo lascia il candidato ancora sopra ogni soglia di") + print(" plausibilita'. Riaprire un filone su dati nuovi ha prodotto un verdetto") + print(" INVARIATO e una diagnosi MIGLIORE — che e' il risultato che si voleva.") + print(f"\n tempo totale {time.time()-t0:.1f}s") + + +if __name__ == "__main__": + main()