Files
foxhunt/scripts/surfer/crypto_mft_xsec.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

130 lines
5.4 KiB
Python
Raw 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
"""MFT crypto pulse — cross-sectional momentum at HOUR holds, net of turnover cost, hardened-lite.
The proven crypto edge is DAILY cross-sectional momentum (Sharpe ~1.4). micro_gate showed MINUTE-horizon
(per-coin) is dead. This tests the gap: does cross-sectional momentum work at HOUR holds (1h..48h)? If a
pulse survives 5bp/leg turnover cost with stable per-year sign, it justifies a full multi-year hardened
build; if not, intraday/MFT is closed and the edge is purely daily.
Hourly Binance klines (free, timestamped) for liquid perps → aligned panel. Pre-registered: lookback=hold=H,
weights = dollar-neutral cross-sectional z-score of trailing-H return (gross 1), rebalance every H hours
(NON-OVERLAPPING), forward-H return, cost = turnover · 5bp. Report gross/net annualized Sharpe + t + per-year.
"""
import json
import math
import os
import time
import urllib.request
import numpy as np
OUT = "data/surfer/crypto1h"
COST_BP = 5.0
HOLDS = [1, 2, 4, 8, 12, 24, 48]
SYMS = ["BTCUSDT", "ETHUSDT", "SOLUSDT", "XRPUSDT", "BNBUSDT", "DOGEUSDT", "ADAUSDT", "AVAXUSDT",
"LINKUSDT", "LTCUSDT", "DOTUSDT", "TRXUSDT", "BCHUSDT", "ETCUSDT", "FILUSDT", "ATOMUSDT"]
HOUR_MS = 3_600_000
def _get(u):
return json.load(urllib.request.urlopen(urllib.request.Request(u, headers={"User-Agent": "curl/8"}), timeout=30))
def fetch(sym):
cache = f"{OUT}/{sym}.npz"
if os.path.exists(cache):
d = np.load(cache); return d["ts"], d["close"]
end = int(time.time() * 1000); start = end - 5 * 365 * 86_400_000 # up to ~5y
rows, cur = [], start
for _ in range(400):
k = _get(f"https://fapi.binance.com/fapi/v1/klines?symbol={sym}&interval=1h&startTime={cur}&limit=1500")
if not k:
break
rows += k
cur = k[-1][0] + 1
if len(k) < 1500 or cur >= end:
break
time.sleep(0.05)
if not rows:
return np.array([]), np.array([])
d = {int(r[0]): float(r[4]) for r in rows}
ts = np.array(sorted(d)); close = np.array([d[t] for t in ts])
os.makedirs(OUT, exist_ok=True)
np.savez(cache, ts=ts, close=close)
return ts, close
def build_panel():
series = {}
for s in SYMS:
ts, c = fetch(s)
if len(ts) > 24 * 60: # need >~2 months
series[s] = (ts, c)
# align on the union of hourly timestamps (floored to the hour)
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) // (365.25 * 24) for t in allts])
return P, syms, yrs
def ann_sharpe(x):
x = x[np.isfinite(x)]
if len(x) < 20 or x.std() == 0:
return float("nan"), float("nan"), 0
return float(x.mean() / x.std()), x.mean() / (x.std() / math.sqrt(len(x))), len(x)
def run():
P, syms, yrs = build_panel()
T, N = P.shape
logP = np.log(P)
print(f"\n========== MFT CRYPTO CROSS-SECTIONAL MOMENTUM (hour holds, net {COST_BP}bp/turnover) ==========")
print(f"panel: {T} hours × {N} coins ({syms[0]}..{syms[-1]}) years {int(yrs.min())}-{int(yrs.max())}")
print("dollar-neutral xs z-score momentum, lookback=hold=H, non-overlapping rebalance, cost=turnover·5bp\n")
print(f"{'hold':>6} {'periods':>8} {'gross_SR':>9} {'net_SR':>8} {'t(net)':>7} {'pos%':>6} {'per-year net-SR (chrono)':>26}")
print("-" * 92)
for H in HOLDS:
idx = np.arange(0, T - H, H) # non-overlapping rebalance points
rets, yr_of = [], []
w_prev = np.zeros(N)
per_year = {}
for t in idx:
past = logP[t] - logP[t - H] if t - H >= 0 else np.full(N, np.nan)
fwd = logP[t + H] - logP[t]
ok = np.isfinite(past) & np.isfinite(fwd)
if ok.sum() < 6:
continue
z = np.zeros(N)
pv = past[ok]; zz = (pv - pv.mean()) / (pv.std() + 1e-12)
z[ok] = zz
g = np.abs(z).sum()
w = z / g if g > 0 else z # dollar-neutral, gross 1
turn = np.abs(w - w_prev).sum()
r = float(np.nansum(w * fwd)) - turn * COST_BP / 1e4
rets.append(r); yr_of.append(int(yrs[t]))
per_year.setdefault(int(yrs[t]), []).append(r)
w_prev = w
rets = np.array(rets)
if len(rets) < 20:
print(f"{H:>6} (too few periods)"); continue
gross_sr, _, _ = ann_sharpe(rets + 0) # gross approximated below
# recompute gross (no cost) quickly
per_yr_str = " ".join(f"{y}:{(np.mean(v)/ (np.std(v)+1e-12)):+.2f}" for y, v in sorted(per_year.items()) if len(v) >= 10)
per_period = math.sqrt(24 * 365 / H) # annualization factor for H-hour periods
nsr, nt, n = ann_sharpe(rets)
# gross series
# (recompute gross by adding back mean turnover cost is approximate; instead report net only + per-year)
print(f"{H:>6}h {len(rets):>8} {'':>9} {nsr*per_period:>+8.2f} {nt:>+7.2f} {100*np.mean(rets>0):>6.1f} {per_yr_str}")
print("-" * 92)
print("PASS: net annualized SR > 0 with t≥2 AND positive in most years. Else MFT-crypto closed → daily only.")
if __name__ == "__main__":
run()