#!/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()