1045 lines
56 KiB
Python
1045 lines
56 KiB
Python
#!/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()
|