#!/usr/bin/env python3 """Cascade reversion — do crypto coins BOUNCE after an extreme forced-flow down-move? Liquidation cascades are forced deleveraging that overshoots; the harvestable consequence is the mean-reversion bounce. Binance killed the historical liquidation feed (live websocket only), so we test the PROXY already on disk: extreme negative trailing-H return = a cascade. Pre-registered: formation: trailing-H log return per coin (cross-sectional) signal: LONG the bottom-q fraction (biggest losers) — long-only on crashed names hold: next H hours, NON-OVERLAPPING rebalance cost: 5 bp / leg on actual weight turnover two builds: raw_long (equal-weight losers, has crypto beta) hedged (long losers − short equal-weight UNIVERSE basket; beta-neutral, short leg is the diversified basket NOT a single coin → dodges the short-melt-up ruin that killed generic XS reversal) KILL: net annualized SR < 0.5 OR not positive in most years (chrono) → cascade reversion closed. LIMITATION: npz panel has close only (no volume) → cannot condition on the volume spike that confirms a true liquidation cascade; the extreme-return tail is the proxy. Flagged, not hidden. """ import math import os import numpy as np OUT = "data/surfer/crypto1h" COST_BP = 5.0 SYMS = ["BTCUSDT", "ETHUSDT", "SOLUSDT", "XRPUSDT", "BNBUSDT", "DOGEUSDT", "ADAUSDT", "AVAXUSDT", "LINKUSDT", "LTCUSDT", "DOTUSDT", "TRXUSDT", "BCHUSDT", "ETCUSDT", "FILUSDT", "ATOMUSDT"] HOUR_MS = 3_600_000 HOLDS = [1, 2, 4, 8, 12, 24] # formation == hold, hours Q = 0.20 # bottom quintile = biggest losers def build_panel(): series = {} for s in SYMS: cache = f"{OUT}/{s}.npz" if not os.path.exists(cache): continue d = np.load(cache) if len(d["ts"]) > 24 * 60: series[s] = (d["ts"], d["close"]) allts = sorted(set().union(*[set((ts // HOUR_MS).tolist()) for ts, _ in series.values()])) tindex = {t: i for i, t in enumerate(allts)} syms = sorted(series) P = np.full((len(allts), len(syms)), np.nan) for j, s in enumerate(syms): ts, c = series[s] for t, px in zip(ts // HOUR_MS, c): P[tindex[int(t)], j] = px yrs = np.array([1970 + int(t) // int(365.25 * 24) for t in allts]) return P, syms, yrs def ann(rets, per_year_factor): rets = np.asarray(rets) if len(rets) < 20 or rets.std() == 0: return float("nan"), float("nan") sr = rets.mean() / rets.std() t = rets.mean() / (rets.std() / math.sqrt(len(rets))) return sr * per_year_factor, t def run(): P, syms, yrs = build_panel() T, N = P.shape logP = np.log(P) print(f"\n===== CRYPTO CASCADE REVERSION (long the crashed names, net {COST_BP}bp/leg) =====") print(f"panel: {T} hours x {N} coins years {int(yrs.min())}-{int(yrs.max())} q={Q} (bottom quintile)") print("LONG bottom-q losers; hedged = long losers - short equal-wt universe basket\n") hdr = f"{'hold':>5} {'periods':>8} {'build':>8} {'net_SR':>8} {'t(net)':>7} {'hit%':>6} {'per-year net-SR (chrono)':>30}" print(hdr); print("-" * len(hdr)) for H in HOLDS: pf = math.sqrt(24 * 365 / H) idx = np.arange(H, T - H, H) # need t-H for formation, t+H for fwd raw, hed, yr_raw, yr_hed = [], [], [], [] w_raw_prev = np.zeros(N); w_hed_prev = np.zeros(N) for t in idx: past = logP[t] - logP[t - H] fwd = logP[t + H] - logP[t] ok = np.isfinite(past) & np.isfinite(fwd) n_ok = int(ok.sum()) if n_ok < 6: continue okidx = np.where(ok)[0] k = max(1, int(round(Q * n_ok))) losers = okidx[np.argsort(past[okidx])[:k]] # most-negative trailing return # raw long w_raw = np.zeros(N); w_raw[losers] = 1.0 / k # hedged: long losers - short equal-weight universe basket w_hed = np.zeros(N); w_hed[losers] += 1.0 / k; w_hed[okidx] -= 1.0 / n_ok r_raw = float(np.nansum(w_raw * fwd)) - np.abs(w_raw - w_raw_prev).sum() * COST_BP / 1e4 r_hed = float(np.nansum(w_hed * fwd)) - np.abs(w_hed - w_hed_prev).sum() * COST_BP / 1e4 raw.append(r_raw); hed.append(r_hed) yr_raw.append(int(yrs[t])); yr_hed.append(int(yrs[t])) w_raw_prev = w_raw; w_hed_prev = w_hed if len(raw) < 20: print(f"{H:>5} (too few periods)"); continue for name, series, yrl in (("raw_long", raw, yr_raw), ("hedged", hed, yr_hed)): sr, t = ann(series, pf) hit = 100.0 * np.mean(np.asarray(series) > 0) py = {} for r, y in zip(series, yrl): py.setdefault(y, []).append(r) pystr = " ".join(f"{y}:{(np.mean(v)/(np.std(v)+1e-12)):+.2f}" for y, v in sorted(py.items()) if len(v) >= 10) tag = f"{H}h" if name == "raw_long" else "" print(f"{tag:>5} {len(series):>8} {name:>8} {sr:>+8.2f} {t:>+7.2f} {hit:>6.1f} {pystr}") print("-" * len(hdr)) print("PASS: hedged net SR > 0.5, t>=2, positive in most years. Else cascade reversion closed.") if __name__ == "__main__": run()