Files
foxhunt/scripts/surfer/crypto_cascade_reversion.py
jgrusewski f4bd1e2432 research(surfer): crypto edge-falsification probes — intraday/cascade dead, stablecoin peg-reversion validated
- crypto_mft_xsec / mft_*: intraday/MFT XS price edge FALSIFIED (fee-trap)
- crypto_cascade_reversion: liquidation-cascade reversion FALSIFIED (continuation)
- crypto_trend_sizing: TS-trend return-engine reconfirmed (Sharpe ~1.25)
- crypto_stablecoin_dislocation/harden/intraday: short-rich peg-reversion VALIDATED (bounded, uncorrelated); long-cheap = death-spiral trap

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-21 10:40:34 +02:00

117 lines
5.2 KiB
Python
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
#!/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()