- 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>
130 lines
5.4 KiB
Python
130 lines
5.4 KiB
Python
#!/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()
|