Files
PythagorasGoal/scripts/research/r0822_flow_squeeze.py
T

643 lines
33 KiB
Python

"""r0822_flow_squeeze.py — filone FLOW-SQUEEZE (ondata 2026-08-22).
DOMANDA
Il posizionamento affollato (variazione rapida dell'open interest) ACCOPPIATO al rischio
di liquidazione produce un movimento prevedibile — squeeze o cascata — nelle ore successive?
E' l'unico angolo di "flusso" mai chiuso: il filone funding e' chiuso su 3 lati (carry CC01,
time-series, cross-sectional) ma sempre come LIVELLO del costo di carry, mai come PROXY DI
AFFOLLAMENTO accoppiato al rischio di liquidazione.
PERIMETRO (dichiarato: un altro filone dell'ondata lavora su dealer_net_gamma/gamma_flip_level,
che qui NON si toccano)
data/raw/cb_market_snapshots.parquet -> oi_delta_pct_4h, liquidation_long_risk,
liquidation_short_risk, funding_perp_annualized, funding_cross_annualized, iv_minus_rv
data/raw/hlfund_<sym>_1h.parquet -> funding orario Hyperliquid (BTC/ETH, ~3 anni)
data/raw/cb_chain/ -> open interest per strike (per la RICOSTRUIBILITA')
IPOTESI A PRIORI, REGISTRATA PRIMA DI MISURARE
Attesa: NULLA di deployabile. (a) il dataset e' MORTO il 2026-07-30 (progetto esterno
dismesso) e ha ~3 mesi utili -> verdetto massimo LEAD; (b) il progetto ha gia' chiuso il
funding su 3 lati; (c) il sospetto principale e' che un eventuale effetto sull'estremo di
oi_delta sia "il prezzo e' appena crollato" travestito, cioe' momentum/mean-reversion di
prezzo gia' coperta. Cio' che NON mi aspettavo: che meta' dell'ipotesi (il rischio di
liquidazione) fosse **inesistente nel dato**.
TEST DI POTENZA — DICHIARATO PRIMA DI LEGGERE I RISULTATI (sezione 2)
Il campione utile e' ~90 giorni x 2 asset. La MDE (minimum detectable effect a 2 SE) si
calcola sul numero di finestre NON sovrapposte, non sulle righe orarie. Un effetto sotto la
MDE NON e' un fatto stabilito, comunque venga il p-value di un bootstrap su dati sovrapposti.
COME SI LEGGE IL RISULTATO
Le sezioni 1 e 5 (validazione e ricostruibilita') valgono piu' delle 2-4: se una colonna e'
costante non ha potenza, e se non sappiamo piu' produrla il segnale e' morto alla nascita.
USO: nice -n 19 timeout 900 uv run python scripts/research/r0822_flow_squeeze.py
"""
from __future__ import annotations
import bisect
import glob
import sys
from pathlib import Path
import numpy as np
import pandas as pd
ROOT = Path(__file__).resolve().parents[2]
sys.path.insert(0, str(ROOT))
sys.path.insert(0, str(ROOT / "scripts" / "research" / "alt"))
import altlib as A # noqa: E402
RAW = ROOT / "data" / "raw"
SNAP_FILE = RAW / "cb_market_snapshots.parquet"
ASSETS = ("BTC", "ETH")
# finestra utile: prima del 01/05 gli snapshot sono ~1/giorno (vedi sezione 1)
W0 = pd.Timestamp("2026-05-01", tz="UTC")
HOURS = (4, 12, 24)
# GRIGLIA DICHIARATA (conteggio al RIALZO, incluse le varianti scartate)
GRID_DECLARED = (
10 * 3 * 2 # 60 decili di oi_delta x 3 orizzonti x 2 lenti (demean per asset / per mese)
+ 4 * 2 # 8 2x2 affollamento (OI alto/basso x funding alto/basso) x 2 orizzonti
+ 4 * 2 # 8 specificazioni di regressione x 2 orizzonti
+ 10 * 2 # 20 decili di funding HL x 2 orizzonti
+ 3 * 3 * 2 # 18 griglia del segnale: soglia x orizzonte di holding x segno
+ 2 # 2 null del segnale (sempre-short, timing casuale)
+ 2 # 2 ricostruibilita' (2 asset)
) # = 118
def hdr(t: str) -> None:
print("\n" + "=" * 78 + f"\n{t}\n" + "=" * 78)
# ---------------------------------------------------------------------------
# dati
# ---------------------------------------------------------------------------
def load_snapshots() -> pd.DataFrame:
s = pd.read_parquet(SNAP_FILE)
# epoca ESPLICITA in ms: .view("int64") su tz-aware non-ns e' un look-ahead noto
s["ts_ms"] = s["ts"].astype("datetime64[ms, UTC]").astype("int64")
return s.sort_values("ts_ms").reset_index(drop=True)
def snap_num(snap: pd.DataFrame, asset: str, cols: list[str]) -> pd.DataFrame:
d = snap[snap.asset == asset][["ts_ms"] + cols].copy()
for c in cols:
d[c] = pd.to_numeric(d[c], errors="coerce")
return d.sort_values("ts_ms").reset_index(drop=True)
def merge_causal(df: pd.DataFrame, right: pd.DataFrame) -> pd.DataFrame:
"""Snapshot noto alla CHIUSURA della barra i (= timestamp[i] + 1h), backward, tolleranza 2h.
La posizione decisa a i e' poi tenuta durante i+1 (lo shift lo fa eval_weights)."""
left = pd.DataFrame({"ts_ms": (df["timestamp"].astype("int64") + 3_600_000).values})
m = pd.merge_asof(left, right, on="ts_ms", direction="backward",
tolerance=2 * 3600 * 1000)
return m.drop(columns=["ts_ms"])
def panel(snap: pd.DataFrame, cols: list[str], start=W0) -> pd.DataFrame:
out = []
for a in ASSETS:
df = A.get(a, "1h")
df = df[df.datetime >= start - pd.Timedelta(days=7)].reset_index(drop=True)
df = pd.concat([df, merge_causal(df, snap_num(snap, a, cols))], axis=1)
cc = df["close"].values.astype(float)
for H in HOURS:
f = np.full(len(cc), np.nan)
f[:-H] = cc[H:] / cc[:-H] - 1.0
df[f"f{H}"] = f
p4 = np.full(len(cc), np.nan)
p4[4:] = cc[4:] / cc[:-4] - 1.0
df["p4"] = p4
df["asset"] = a
out.append(df)
P = pd.concat(out, ignore_index=True)
return P[P.datetime >= start].reset_index(drop=True)
# ---------------------------------------------------------------------------
# 1. VALIDAZIONE DEL DATO
# ---------------------------------------------------------------------------
def sezione1(snap: pd.DataFrame) -> None:
hdr("1. VALIDAZIONE DEL DATO — le colonne vengono da un progetto esterno DISMESSO")
print(f"righe={len(snap)} ts {snap.ts.min()} -> {snap.ts.max()}")
cnt = snap[snap.asset == "BTC"].set_index("ts").resample("1D").size()
fasce = pd.cut(cnt, [-1, 0, 10, 50, 90, 400]).value_counts().sort_index()
print("\ngiorni per numero di snapshot/giorno (BTC):")
for k, v in fasce.items():
print(f" {str(k):>14s} : {v:3d} giorni")
pieni = cnt[cnt >= 90]
print(f" -> primo giorno a cadenza piena (>=90/g): {pieni.index.min().date()}"
f" ; giorni pieni = {len(pieni)}")
print(" ATTENZIONE: la copertura dichiarata parte dal 2026-03-26 ma 36 giorni hanno <=10")
print(" snapshot: la finestra USABILE a risoluzione oraria e' 2026-05-01 -> 2026-07-30.")
for a in ASSETS:
d = snap[snap.asset == a]
print(f"\n--- {a} (n={len(d)}) ---")
ok = d.fetch_ok.value_counts().to_dict()
print(f" fetch_ok: {ok} -> fallite {100*(1-d.fetch_ok.mean()):.1f}%")
for c in ("liquidation_long_risk", "liquidation_short_risk"):
vc = d[c].value_counts(dropna=False).to_dict()
nun = d[c].nunique()
flag = " <<< COSTANTE: ZERO POTENZA" if nun <= 1 else ""
print(f" {c:26s} categorie={nun} {vc}{flag}")
for c in ("oi_delta_pct_4h", "funding_perp_annualized", "funding_cross_annualized",
"iv_minus_rv", "macro_days_to_event"):
s = pd.to_numeric(d[c], errors="coerce")
mode_share = s.value_counts(normalize=True).iloc[0] if s.notna().any() else np.nan
print(f" {c:26s} nan={100*s.isna().mean():5.1f}% nuniq={s.nunique():5d} "
f"moda={s.mode().iloc[0] if s.notna().any() else float('nan'):+.4f} "
f"({100*mode_share:.1f}% delle righe) "
f"[{s.min():+.3f}, {s.median():+.3f}, {s.max():+.3f}]")
s = pd.to_numeric(d.oi_delta_pct_4h, errors="coerce").dropna()
same = (s.diff() == 0)
print(f" oi_delta_pct_4h: congelato {100*same.mean():.2f}% delle volte, "
f"autocorr(15m)={s.autocorr(1):+.3f} autocorr(4h)={s.autocorr(16):+.3f}")
print(" -> autocorr alta a 15m e ~0 a 4h = firma di una VERA finestra mobile a 4h "
"(non un valore inventato/congelato)")
print("\n>>> ESITO SEZIONE 1")
print(" (a) liquidation_long_risk e liquidation_short_risk valgono 'low' nel 100% delle")
print(" righe non-nulle, su 17.406 righe, 4 mesi, entrambi gli asset: UNA sola")
print(" categoria. META' DELL'IPOTESI E' UNTESTABILE PER COSTRUZIONE — non 'debole',")
print(" proprio senza varianza. E' il risultato piu' importante del filone.")
print(" (b) funding_perp_annualized e' ANCORATO al tasso base (0.01%/8h = 0.1095/anno)")
print(" nel ~49% (BTC) / ~60% (ETH) delle righe: come proxy di affollamento e' muto")
print(" per meta' del campione.")
print(" (c) oi_delta_pct_4h e' l'unica colonna del perimetro che si comporta da serie vera.")
# ---------------------------------------------------------------------------
# 2. IL FATTO
# ---------------------------------------------------------------------------
def bboot_p(x: np.ndarray, nb: int = 2000, block: int = 24, seed: int = 7) -> float:
x = np.asarray(x, float)
x = x[np.isfinite(x)]
n = len(x)
if n < block * 3:
return float("nan")
rng = np.random.default_rng(seed)
k = int(np.ceil(n / block))
starts = rng.integers(0, n - block, size=(nb, k))
means = np.array([np.concatenate([x[s:s + block] for s in starts[i]])[:n].mean()
for i in range(nb)])
return float((means >= 0).mean() if x.mean() < 0 else (means <= 0).mean())
def ols_bboot(d: pd.DataFrame, y: str, cols: list[str], nb: int = 500,
blk: int = 48, seed: int = 11):
X = np.column_stack([np.ones(len(d))] + [d[c].values for c in cols])
Y = d[y].values
b = np.linalg.lstsq(X, Y, rcond=None)[0]
rng = np.random.default_rng(seed)
n = len(Y)
k = int(np.ceil(n / blk))
bs = []
for _ in range(nb):
st = rng.integers(0, n - blk, size=k)
idx = np.concatenate([np.arange(s, s + blk) for s in st])[:n]
try:
bs.append(np.linalg.lstsq(X[idx], Y[idx], rcond=None)[0])
except np.linalg.LinAlgError:
pass
se = np.array(bs).std(0)
return b, se
def sezione2(snap: pd.DataFrame) -> pd.DataFrame:
hdr("2. IL FATTO — ritorno futuro condizionato all'OI (potenza DICHIARATA PRIMA)")
P = panel(snap, ["oi_delta_pct_4h", "funding_perp_annualized",
"funding_cross_annualized", "iv_minus_rv"])
d = P.dropna(subset=["oi_delta_pct_4h", "f24", "p4"]).copy()
print(f"campione: {len(d)} barre orarie ({d.datetime.min()} -> {d.datetime.max()})")
print("\n--- TEST DI POTENZA (dichiarato prima di leggere qualunque media) ---")
mde = {}
for H in HOURS:
n_ind = len(d) / H
sd = d[f"f{H}"].std()
mde[H] = 2 * sd / np.sqrt(n_ind / 10)
print(f" H={H:2d}h finestre NON sovrapposte={n_ind:6.0f} sd={100*sd:.2f}% "
f"n/decile={n_ind/10:5.0f} MDE(2 SE)={100*mde[H]:.3f}%")
print(" REGOLA PRE-REGISTRATA: un effetto di decile sotto la propria MDE NON e' un fatto")
print(" stabilito, qualunque p-value dia un bootstrap su osservazioni sovrapposte.")
for a in ASSETS:
m = d.asset == a
for H in HOURS:
d.loc[m, f"x{H}"] = d.loc[m, f"f{H}"] - d.loc[m, f"f{H}"].mean()
d["mese"] = d.datetime.dt.strftime("%Y-%m")
for H in HOURS:
d[f"y{H}"] = d[f"f{H}"] - d.groupby(["asset", "mese"])[f"f{H}"].transform("mean")
d["odec"] = d.groupby("asset").oi_delta_pct_4h.transform(
lambda s: pd.qcut(s, 10, labels=False))
drift = {a: {H: round(100 * d.loc[d.asset == a, f"f{H}"].mean(), 3) for H in HOURS}
for a in ASSETS}
print(f"\ndrift INCONDIZIONATO della finestra (%): {drift}")
print(" -> la finestra e' ribassista: ogni media condizionata va letta DEMEANATA,")
print(" altrimenti 'stare short' sembra un segnale.")
print("\n--- eccesso di ritorno futuro (%) per decile di oi_delta_pct_4h ---")
print(" lente 1 = demean per ASSET ; lente 2 = demean per (ASSET,MESE)")
g = d.groupby("odec").agg(n=("x4", "size"), oi=("oi_delta_pct_4h", "mean"),
x4=("x4", "mean"), x24=("x24", "mean"),
y4=("y4", "mean"), y24=("y24", "mean"),
av4=("f4", lambda s: s.abs().mean()),
av24=("f24", lambda s: s.abs().mean()))
for c in ("x4", "x24", "y4", "y24", "av4", "av24"):
g[c] *= 100
g["p_x4"] = [bboot_p(d[d.odec == k].x4.values) for k in g.index]
g["p_x24"] = [bboot_p(d[d.odec == k].x24.values, block=48) for k in g.index]
print(g.round(3).to_string())
print(" av4/av24 = |ret| futuro medio: e' la gamba 'squeeze/cascata' dell'ipotesi")
print(" (piu' vol dopo un estremo di OI). E' PIATTA: nessuna cascata prevedibile.")
print("\n--- CONTROLLO: e' solo 'il prezzo si e' appena mosso'? ---")
z = lambda s: (s - s.mean()) / s.std() # noqa: E731
for c, n in [("oi_delta_pct_4h", "zoi"), ("p4", "zp4"),
("funding_perp_annualized", "zfund"), ("iv_minus_rv", "ziv")]:
d[n] = d.groupby("asset")[c].transform(z)
dr = d.dropna(subset=["zoi", "zp4", "zfund", "ziv"]).reset_index(drop=True)
print(f" corr(oi_delta, ritorno 4h passato) = {dr.oi_delta_pct_4h.corr(dr.p4):+.3f}"
f" (con 24h passato = {dr.oi_delta_pct_4h.corr(dr.f24.shift(24)):+.3f})")
for y in ("x4", "x24"):
for cols in (["zoi"], ["zp4"], ["zoi", "zp4"], ["zoi", "zp4", "zfund", "ziv"]):
b, se = ols_bboot(dr, y, cols)
txt = " ".join(f"{c}={100*b[i+1]:+.4f}%(t={b[i+1]/max(se[i+1],1e-12):+.2f})"
for i, c in enumerate(cols))
print(f" {y:4s} ~ {str(cols):36s} {txt}")
print(" -> la PENDENZA LINEARE di oi_delta e' nulla (|t|<1.3 in ogni specificazione).")
print(" Quel poco che c'e' vive SOLO nella coda estrema, e non e' momentum di prezzo")
print(" (corr con il ritorno passato ~0.02): e' un'altra cosa, ma e' una CODA.")
print("\n--- il decile 0 (OI che CROLLA) — l'unica cella non piatta ---")
for H in (4, 24):
obs = d[d.odec == 0][f"y{H}"].mean()
rng = np.random.default_rng(3)
nulls = []
for _ in range(2000):
sh = []
for a in ASSETS:
sub = d[d.asset == a]
k = rng.integers(1, len(sub) - 1)
sh.append(sub[f"y{H}"].values[np.roll((sub.odec == 0).values, k)])
nulls.append(np.concatenate(sh).mean())
nulls = np.array(nulls)
print(f" H={H:2d}h osservato={100*obs:+.3f}% MDE={100*mde[H]:.3f}% "
f"null circolare: mediana={100*np.median(nulls):+.3f}% "
f"p5={100*np.percentile(nulls,5):+.3f}% p={float((nulls<=obs).mean()):.3f}")
for a in ASSETS:
sub = d[(d.asset == a) & (d.odec == 0)]
cond = (d[d.asset == a].odec == 0).values
ep = int(np.sum(cond[1:] & ~cond[:-1])) + int(cond[0])
print(f" {a}: ore in decile0={len(sub)} episodi distinti={ep} "
f"y4={100*sub.y4.mean():+.3f}% y24={100*sub.y24.mean():+.3f}%")
print("\n scomposizione PER MESE (demean mensile) — il test che il progetto impone")
print(" dopo il caso SOL ('un contributo positivo si scompone prima di crederci'):")
tab = d[d.odec == 0].groupby("mese")[["y4", "y24"]].agg(["size", "mean"])
for m_, r in tab.iterrows():
print(f" {m_}: n={int(r[('y4','size')]):4d} "
f"y4={100*r[('y4','mean')]:+.3f}% y24={100*r[('y24','mean')]:+.3f}%")
print("\n--- 2x2 AFFOLLAMENTO (l'ipotesi originale, col funding al posto della liquidazione) ---")
for c in ("oi_delta_pct_4h", "funding_perp_annualized"):
d[c + "_hi"] = d.groupby("asset")[c].transform(lambda s: s >= s.quantile(.8))
d[c + "_lo"] = d.groupby("asset")[c].transform(lambda s: s <= s.quantile(.2))
combos = [("OI su & funding ALTO (long affollati -> cascata?)",
d["oi_delta_pct_4h_hi"] & d["funding_perp_annualized_hi"]),
("OI su & funding BASSO (short affollati -> squeeze?)",
d["oi_delta_pct_4h_hi"] & d["funding_perp_annualized_lo"]),
("OI giu & funding ALTO ",
d["oi_delta_pct_4h_lo"] & d["funding_perp_annualized_hi"]),
("OI giu & funding BASSO",
d["oi_delta_pct_4h_lo"] & d["funding_perp_annualized_lo"])]
base = 100 * d.x4.abs().mean()
for nm, mask in combos:
sub = d[mask]
if len(sub) < 20:
print(f" {nm:52s} n={len(sub)} TROPPO PICCOLO")
continue
print(f" {nm:52s} n={len(sub):5d} x4={100*sub.x4.mean():+.3f}%(p={bboot_p(sub.x4.values):.3f})"
f" x24={100*sub.x24.mean():+.3f}%(p={bboot_p(sub.x24.values,block=48):.3f})"
f" |x4|={100*sub.x4.abs().mean():.3f}%")
print(f" |x4| di riferimento su tutto il campione = {base:.3f}%")
print(" -> 4 celle, 8 test: la sola sotto 0.05 (OI su & funding basso, x24) e' UNA cella su")
print(" 8 con ~8 finestre 24h indipendenti. Non e' un fatto.")
return d
# ---------------------------------------------------------------------------
# 3. FUNDING HL — l'unica gamba del perimetro con storia lunga
# ---------------------------------------------------------------------------
def sezione3() -> None:
hdr("3. FUNDING HYPERLIQUID BTC/ETH — 3 ANNI: l'unica gamba del perimetro con POTENZA")
res = []
for a in ASSETS:
f = RAW / f"hlfund_{a.lower()}_1h.parquet"
if not f.exists():
print(f" {f} assente")
return
h = pd.read_parquet(f)
h.index = pd.DatetimeIndex(h.index).tz_convert("UTC").floor("h")
h = h[~h.index.duplicated()]
df = A.get(a, "1h")
df = df.set_index(pd.DatetimeIndex(df.datetime))
j = df[["close"]].join(h[["funding", "premium"]], how="inner")
for H in (4, 24):
j[f"f{H}"] = j.close.shift(-H) / j.close - 1.0
j["asset"] = a
res.append(j)
J = pd.concat(res).dropna(subset=["funding", "f24"])
print(f"n={len(J)} barre orarie, {J.index.min().date()} -> {J.index.max().date()}")
print(" ATTENZIONE: il feed funding HL si ferma al 2026-06-22 (non e' aggiornato a oggi).")
base = J.funding.value_counts(normalize=True).iloc[0]
print(f" il funding e' ANCORATO al tasso base nel {100*base:.1f}% delle ore "
f"(valore {J.funding.mode().iloc[0]:.6f}/h) -> anche qui meta' campione e' muto.")
J["ye"] = J.index.year
J["fdec"] = J.groupby("asset").funding.transform(
lambda s: pd.qcut(s.rank(method="first"), 10, labels=False))
for H in (4, 24):
J[f"y{H}"] = J[f"f{H}"] - J.groupby(["asset", "ye"])[f"f{H}"].transform("mean")
g = J.groupby("fdec").agg(n=("y4", "size"), fund=("funding", "mean"),
y4=("y4", "mean"), y24=("y24", "mean"),
av4=("f4", lambda s: s.abs().mean()),
av24=("f24", lambda s: s.abs().mean()))
g["fund"] *= 100 * 24 * 365
for c in ("y4", "y24", "av4", "av24"):
g[c] *= 100
print("\n--- eccesso su drift ANNUALE (%) e |ret| futuro per decile di funding HL ---")
print(g.round(3).to_string())
print("\nestremi per ANNO (y24, %):")
t = J[J.fdec.isin([0, 9])].groupby(["fdec", "ye"]).y24.agg(["size", "mean"])
for k, r in t.iterrows():
print(f" dec={k[0]} anno={k[1]} n={int(r['size']):5d} y24={100*r['mean']:+.3f}%")
print("\n>>> ESITO SEZIONE 3: su 53.430 osservazioni e 3 anni — cioe' DOVE LA POTENZA C'E' —")
print(" il funding come proxy di affollamento non predice ne' la DIREZIONE (|eccesso|")
print(" <=0.35% a 24h, segno instabile fra anni) ne' la VOLATILITA' (|ret| futuro piatto")
print(" e non monotono). Il filone funding, gia' chiuso su 3 lati come livello di carry,")
print(" e' chiuso anche come proxy di affollamento.")
# ---------------------------------------------------------------------------
# 4. IL SEGNALE CAUSALE + I NULL + I GATE
# ---------------------------------------------------------------------------
def pct_expanding(x: np.ndarray, minobs: int = 200) -> np.ndarray:
"""Percentile ESPANDENTE causale: al bar i usa solo i valori <= i-1."""
out = np.full(len(x), np.nan)
seen: list[float] = []
for i, v in enumerate(x):
if np.isfinite(v):
if len(seen) >= minobs:
out[i] = bisect.bisect_left(seen, v) / len(seen)
bisect.insort(seen, v)
return out
class Signal:
"""SHORT (o LONG) quando il percentile causale di oi_delta_pct_4h e' sotto soglia,
tenuto H barre, vol-targeted 20%."""
def __init__(self, snap: pd.DataFrame):
self.SN = {a: snap_num(snap, a, ["oi_delta_pct_4h"]).dropna() for a in ASSETS}
def oi(self, df, asset):
return merge_causal(df, self.SN[asset])["oi_delta_pct_4h"].values.astype(float)
def make(self, thr: float, H: int, sign: int):
def fn(df, asset):
p = pct_expanding(self.oi(df, asset))
trig = np.where(np.isfinite(p), (p <= thr).astype(float), 0.0)
held = pd.Series(trig).rolling(H, min_periods=1).max().values
return A.vol_target(sign * held, df, target_vol=0.20, leverage_cap=2.0)
return fn
def book_net(target_by_asset) -> pd.Series:
nets = []
for a in ASSETS:
df = A.get(a, "1h")
m = (df.datetime >= W0).values
d2 = df[m].reset_index(drop=True)
ev = A.eval_weights(d2, np.asarray(target_by_asset(df, a))[m])
nets.append(pd.Series(ev["net"], index=ev["idx"]))
J = pd.concat(nets, axis=1, join="inner").fillna(0.0)
return 0.5 * J.iloc[:, 0] + 0.5 * J.iloc[:, 1]
def stats(net: pd.Series) -> dict:
if net.std() == 0:
return dict(sharpe=0.0, dd=0.0, ret=0.0, cagr=0.0)
eq = (1 + net).cumprod()
dd = float(((eq.cummax() - eq) / eq.cummax()).max())
yrs = (net.index[-1] - net.index[0]).total_seconds() / (365.25 * 86400)
return dict(sharpe=float(net.mean() / net.std() * np.sqrt(24 * 365.25)),
dd=dd, ret=float(eq.iloc[-1] - 1),
cagr=float(eq.iloc[-1] ** (1 / max(yrs, 1e-9)) - 1))
def sezione4(snap: pd.DataFrame) -> None:
hdr("4. IL SEGNALE CAUSALE — griglia 18 celle, i null, i gate")
S = Signal(snap)
rows = []
for thr in (0.10, 0.20, 0.30):
for H in (4, 12, 24):
for sign in (-1, +1):
st = stats(book_net(S.make(thr, H, sign)))
rows.append(dict(thr=thr, H=H, sign=sign, **st))
print(f" thr={thr:.2f} H={H:2d} sign={sign:+d} "
f"Sharpe(90g)={st['sharpe']:+.2f} maxDD={100*st['dd']:5.2f}% "
f"ret={100*st['ret']:+6.2f}%")
R = pd.DataFrame(rows)
best = R.loc[R.sharpe.idxmax()]
print(f"\n miglior cella: thr={best.thr} H={int(best.H)} sign={int(best.sign):+d} "
f"-> Sharpe {best.sharpe:.2f}")
print(f" SE(Sharpe) su 90 giorni = sqrt(365/90) = {np.sqrt(365/90):.2f} -> ogni Sharpe")
print(" sotto ~4 e' indistinguibile da zero su questa finestra. Non esiste hold-out:")
print(" la cella e' scelta sull'UNICO campione (select_cell_insample non applicabile).")
thr, H, sign = float(best.thr), int(best.H), int(best.sign)
tgt = S.make(thr, H, sign)
net = book_net(tgt)
st = stats(net)
print("\n--- NULL 1: 'sei solo short in una finestra ribassista?' ---")
always = lambda df, a: A.vol_target( # noqa: E731
sign * np.ones(len(df)), df, target_vol=0.20, leverage_cap=2.0)
st_a = stats(book_net(always))
tim = np.mean([np.mean(np.asarray(tgt(A.get(a, "1h"), a))[
(A.get(a, "1h").datetime >= W0).values] != 0) for a in ASSETS])
print(f" candidato Sharpe={st['sharpe']:+.2f} (tempo a mercato {tim:.3f}) "
f"sempre-esposto Sharpe={st_a['sharpe']:+.2f}")
print(" -> il candidato NON e' la statica travestita: il timing porta qualcosa.")
print("\n--- NULL 2: timing CASUALE a pari esposizione (shift circolare, 300 estrazioni) ---")
base_trig = {}
for a in ASSETS:
df = A.get(a, "1h")
base_trig[a] = (np.asarray(tgt(df, a)), (df.datetime >= W0).values)
rng = np.random.default_rng(21)
nulls = []
for _ in range(300):
shifted = {}
for a in ASSETS:
v, m = base_trig[a]
v2 = v.copy()
idx = np.where(m)[0]
v2[idx] = np.roll(v2[idx], rng.integers(1, len(idx) - 1))
shifted[a] = v2
nulls.append(stats(book_net(lambda df, a: shifted[a]))["sharpe"])
nulls = np.array(nulls)
pctl = float((nulls < st["sharpe"]).mean())
print(f" null: mediana={np.median(nulls):+.2f} p90={np.percentile(nulls,90):+.2f} "
f"p99={np.percentile(nulls,99):+.2f} -> candidato al {100*pctl:.1f}o pctl")
print(f" ma la cella e' il MASSIMO di 18: P(almeno una cella oltre quel pctl per caso) "
f"= 1-{pctl:.3f}^18 = {1-pctl**18:.2f} -> il null non e' superato dopo il conto dei trial.")
print("\n--- GATE ---")
print(f" causality_ok : {A.causality_ok(tgt, tf='1h')}")
cd = A.candidate_daily(tgt, tf="1h")
cd = cd[cd.index >= W0]
sr = float(cd.mean() / cd.std() * np.sqrt(365.25))
rep = A.marginal_vs_tp01(cd)
print(f" candidate_daily : n={len(cd)} giorni, Sharpe={sr:.2f}")
print(f" marginal_vs_tp01 : verdetto={rep.get('marginal_verdict')} "
f"corr_beta_tp01={rep.get('beta_to_tp01')} has_insample_edge={rep.get('has_insample_edge')} "
f"is_hedge={rep.get('is_hedge')} robust_oos={rep.get('robust_oos')} "
f"beats_noise_null={rep.get('beats_noise_null')} null_pctl_full={rep.get('null_pctl_full')}")
print(" (robust_oos=False non e' un difetto del candidato: con 90 giorni NON ESISTE il")
print(" multi-cut che il gate richiede. Il gate e' girato e il suo esito e' NEUTRAL.)")
all_sr = list(R.sharpe.values)
d18 = A.deflated_sharpe(sr, all_sr, cd)
d_all = A.deflated_sharpe(sr, all_sr * 7, cd)
print(f" deflated_sharpe(18) : DSR={d18[0]:.3f} (Sharpe atteso del massimo sotto il null "
f"= {d18[1]:.2f}) -> {'PASS' if d18[0]>=0.95 else 'FAIL'}")
print(f" deflated_sharpe({GRID_DECLARED}) : DSR={d_all[0]:.3f} (null max {d_all[1]:.2f}) "
f"-> {'PASS' if d_all[0]>=0.95 else 'FAIL'}")
print(" con 114 giorni il MASSIMO atteso PER CASO su 18 celle e' Sharpe ~5.4:")
print(" il candidato (3.6) sta SOTTO il proprio null. Non serve altro.")
imp = A.implausible_sharpe(cd)
print(f" implausible_sharpe : {imp['implausible']} {imp['reasons']}")
print(" null de-levering : NON GIRATO — il candidato non fa alcun claim di riduzione")
print(" di drawdown (e' uno stream di ritorno, non un overlay).")
print(" anchor_luck_band : NON GIRATO — il segnale non e' ancorato a un'ora di")
print(" ribilanciamento (gira su ogni barra oraria).")
for a in ASSETS:
df = A.get(a, "1h")
m = (df.datetime >= W0).values
sc = A.eval_weights_smallcap(df[m].reset_index(drop=True),
np.asarray(tgt(df, a))[m], capital=600)
print(f" smallcap $600 {a} : haircut Sharpe={sc['sharpe_haircut']:+.3f} "
f"(reale {sc['realistic']['sharpe']:.2f} vs modellato {sc['modeled']['sharpe']:.2f}, "
f"{sc['n_executed_trades']} trade eseguiti)")
print(" -> l'ESEGUIBILITA' non e' il vincolo (2 gambe BTC/ETH perp, haircut ~0.02):")
print(" come per SOL, il candidato muore sull'evidenza, non sulla taglia del conto.")
print("\n--- CONCENTRAZIONE (dove sta davvero il P&L) ---")
mm = cd.groupby(cd.index.strftime("%Y-%m")).agg(n="size", ret=lambda s: 100 * ((1 + s).prod() - 1))
print(mm.round(2).to_string())
tot = 100 * ((1 + cd).prod() - 1)
top3 = 100 * cd.sort_values().tail(3).sum()
print(f" somma dei 3 giorni migliori = {top3:.2f}% su un totale di {tot:.2f}% "
f"({100*top3/max(tot,1e-9):.0f}% del P&L in 3 giorni su 114)")
# ---------------------------------------------------------------------------
# 5. RICOSTRUIBILITA'
# ---------------------------------------------------------------------------
def sezione5(snap: pd.DataFrame) -> None:
hdr("5. RICOSTRUIBILITA' — il dato e' MORTO il 2026-07-30: sapremmo rifarlo oggi?")
cols = ["asset", "instrument_name", "open_interest", "ts"]
parts = []
arch = RAW / "cb_chain" / "bite_archive.parquet"
if arch.exists():
parts.append(pd.read_parquet(arch, columns=cols))
for f in sorted(glob.glob(str(RAW / "cb_chain" / "2026-*.parquet"))):
parts.append(pd.read_parquet(f, columns=cols))
C = pd.concat(parts, ignore_index=True)
del parts
C["ts"] = pd.to_datetime(C.ts, utc=True).dt.floor("h")
C = C.drop_duplicates(["ts", "instrument_name"])
print(f"catena: {len(C)} righe {C.ts.min()} -> {C.ts.max()}")
sn = snap.copy()
sn["h"] = pd.to_datetime(sn.ts, utc=True).dt.floor("h")
for asset in ASSETS:
s = C[C.asset == asset]
piv = s.pivot_table(index="ts", columns="instrument_name",
values="open_interest", aggfunc="last")
piv = piv.reindex(pd.date_range(piv.index.min(), piv.index.max(), freq="h"))
V = piv.values
dl = np.full(len(piv), np.nan)
for i in range(4, len(piv)):
m = np.isfinite(V[i]) & np.isfinite(V[i - 4])
if m.sum() < 30:
continue
b = V[i - 4][m].sum()
if b > 0:
dl[i] = 100 * (V[i][m].sum() / b - 1)
chain = pd.Series(dl, index=piv.index, name="chain")
ref = pd.to_numeric(sn[sn.asset == asset].groupby("h").oi_delta_pct_4h.last(),
errors="coerce").rename("snap")
j = pd.concat([ref, chain], axis=1).dropna()
print(f" {asset}: ore sovrapposte={len(j)} "
f"corr Pearson={j.snap.corr(j.chain):+.3f} "
f"Spearman={j.snap.corr(j.chain, method='spearman'):+.3f} "
f"(sd snap={j.snap.std():.3f} vs sd catena={j.chain.std():.3f})")
del piv, V
print("\n Nota: l'OI della catena e' l'OI delle OPZIONI a strumenti APPAIATI (stessi")
print(" strumenti a t e t-4h, cosi' le scadenze non creano salti). La correlazione con")
print(" oi_delta_pct_4h e' ~0 in rango: NON e' la stessa grandezza (lo snapshot misurava")
print(" l'OI dei PERPETUAL/futures via il progetto dismesso).")
print("\n>>> ESITO SEZIONE 5 — colonna per colonna, cosa sappiamo produrre OGGI:")
print(" oi_delta_pct_4h : NO. La catena da' OI di OPZIONI (rango ~0.05 con la")
print(" colonna). L'OI perp Deribit e' leggibile dall'API")
print(" pubblica ma NON lo raccogliamo: la storia parte da")
print(" zero il giorno che si accende un collettore.")
print(" liquidation_long/short : NO, e non importa: la colonna e' costante 'low'.")
print(" Nessuna fonte nostra la produce, e non c'e' niente")
print(" da produrre.")
print(" funding_perp/cross : SI ma degradato — funding HL c'e' (fermo al 22/06/2026),")
print(" funding Deribit non lo salviamo.")
print(" iv_minus_rv : SI, ricostruibile da dvol_*.parquet + barre certificate.")
# ---------------------------------------------------------------------------
def main() -> None:
print(__doc__)
print(f"GRIGLIA DICHIARATA (conteggio al rialzo) = {GRID_DECLARED} celle/trial")
snap = load_snapshots()
sezione1(snap)
sezione2(snap)
sezione3()
sezione4(snap)
sezione5(snap)
hdr("6. VERDETTO")
print("""SCARTATO.
1. META' DELL'IPOTESI NON ESISTE NEL DATO: liquidation_long_risk e liquidation_short_risk
valgono 'low' nel 100% delle 17.229 righe non-nulle. Una variabile con UNA categoria non
ha potenza: l'accoppiamento affollamento x rischio-di-liquidazione e' untestabile, non
debole. (Ed e' esattamente la parte che rendeva l'angolo NUOVO rispetto al filone funding.)
2. IL FATTO NON REGGE LA PROPRIA SOGLIA DI POTENZA, dichiarata prima di guardare: l'unico
effetto e' il decile piu' basso di oi_delta (-0.40% a 24h) e la MDE a 24h e' 1.06%.
La pendenza lineare e' nulla (|t|<1.3 in 4 specificazioni su 4); il |ritorno| futuro e'
quasi piatto fra i decili (0.59-0.75% a 4h, solo il decile 0 leggermente sopra) -> la
'cascata' non c'e'; e il segno e' ASSENTE nel primo dei tre mesi (maggio y24=+0.02%,
giugno -0.64%, luglio -0.74%): 2 mesi su 3 non sono un plateau, sono due mesi.
3. IL SEGNALE E' UN MASSIMO DI 18 CELLE SU 114 GIORNI: Sharpe 3.6 con SE(Sharpe)=2.0,
deflated-Sharpe 0.10 (18 celle) / 0.004 (118 trial) contro un massimo atteso PER CASO di
Sharpe 5.4; implausible_sharpe=True; marginal_vs_tp01 = NEUTRAL; e il P&L e' per il 71%
di giugno e per il 60% concentrato in 3 giorni su 114.
4. ED E' COMUNQUE MORTO ALLA NASCITA: la colonna su cui poggia non e' ricostruibile con i dati
che raccogliamo (l'OI della catena e' quello delle OPZIONI, rango ~0.05 con la colonna) e
la sorgente e' stata dismessa il 2026-07-30.
Sottoprodotto con potenza vera (53.430 ore, 3 anni): il funding HL non predice ne' direzione
ne' volatilita' -> il filone funding, gia' chiuso su 3 lati come livello di carry, si puo'
dichiarare chiuso anche come PROXY DI AFFOLLAMENTO.""")
if __name__ == "__main__":
main()