643 lines
33 KiB
Python
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()
|