feat(surfer): minute-horizon gate — deep ML (Mamba2/CfC/TLOB) has no fuel
Before building deep sequence models, tested whether cost-surviving signal exists at the minute horizon for them to extract. 12 perps, 60d free 1m klines (OFI proxy from takerBuyQuote/quoteVol), features -> forward 1/5/15min returns net of 5bp taker. ALL features negative net (best gross ~0.2bp vs 5bp cost = 25x gap); reversal IC -0.027 and vol-continuation +0.009 are real but ~25x too small; OFI/taker-imbalance ~zero predictive power. -> No minute-horizon signal -> Mamba2/CfC/TLOB have nothing to extract -> don't build (no model lifts IC 25x). Crossing-cost wall holds in crypto (sec-ES 100x, min-crypto 25x); maker=adverse-selection wall, full-LOB=colocation territory. Gate saved a multi-week GPU build for $0/10min. Product remains the daily two-sleeve book. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
105
scripts/surfer/micro_gate.py
Normal file
105
scripts/surfer/micro_gate.py
Normal file
@@ -0,0 +1,105 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Phase A — minute-horizon crypto signal gate (does deep ML have anything to extract?).
|
||||
|
||||
Before training Mamba2/CfC/TLOB, prove there's cost-surviving predictability at the minute
|
||||
horizon. Free Binance 1m klines carry an OFI proxy (takerBuyQuote/quote). Test per-coin
|
||||
short-horizon features -> forward returns, pooled, net of taker cost. If even simple features
|
||||
show net edge, deep sequence models can plausibly extract more; if not, same wall as ES.
|
||||
|
||||
Features (info up to t): r1,r5,r15 (momentum/reversal), ofi (taker imbalance), ofi5 (smoothed),
|
||||
vol_z. Targets: forward log-return over h in {1,5,15} min. Cost: 5bp round-trip taker.
|
||||
Reports per-feature IC + best simple strategy per-trade net edge (bps) vs cost. GPU compute.
|
||||
"""
|
||||
import json
|
||||
import math
|
||||
import os
|
||||
import sys
|
||||
import time
|
||||
import urllib.request
|
||||
|
||||
import numpy as np
|
||||
|
||||
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
|
||||
import torch # noqa: E402
|
||||
|
||||
DEV = "cuda" if torch.cuda.is_available() else "cpu"
|
||||
OUT = "data/surfer/crypto1m"
|
||||
SYMS = ["BTCUSDT", "ETHUSDT", "SOLUSDT", "XRPUSDT", "BNBUSDT", "DOGEUSDT",
|
||||
"ADAUSDT", "AVAXUSDT", "LINKUSDT", "LTCUSDT", "SUIUSDT", "NEARUSDT"]
|
||||
DAYS = 60
|
||||
COST_BP = 5.0
|
||||
|
||||
|
||||
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["close"], d["qv"], d["tbq"]
|
||||
end = int(time.time() * 1000); start = end - DAYS * 86_400_000
|
||||
rows = []
|
||||
cur = start
|
||||
for _ in range(200):
|
||||
k = _get(f"https://fapi.binance.com/fapi/v1/klines?symbol={sym}&interval=1m&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)
|
||||
d = {int(r[0]): (float(r[4]), float(r[7]), float(r[10])) for r in rows} # close, quoteVol, takerBuyQuote
|
||||
ts = sorted(d)
|
||||
close = np.array([d[t][0] for t in ts]); qv = np.array([d[t][1] for t in ts]); tbq = np.array([d[t][2] for t in ts])
|
||||
os.makedirs(OUT, exist_ok=True)
|
||||
np.savez(cache, close=close, qv=qv, tbq=tbq)
|
||||
return close, qv, tbq
|
||||
|
||||
|
||||
def roll_mean(x, w):
|
||||
c = np.concatenate([[0], np.cumsum(x)])
|
||||
out = np.full_like(x, np.nan); out[w - 1:] = (c[w:] - c[:-w]) / w; return out
|
||||
|
||||
|
||||
def main():
|
||||
feats_all = {k: [] for k in ["r1", "r5", "r15", "ofi", "ofi5", "vol_z"]}
|
||||
fwd_all = {h: [] for h in [1, 5, 15]}
|
||||
print(f"fetching 1m klines ({len(SYMS)} perps, {DAYS}d)...")
|
||||
for s in SYMS:
|
||||
close, qv, tbq = fetch(s)
|
||||
lc = np.log(close)
|
||||
r1 = np.full_like(lc, np.nan); r1[1:] = lc[1:] - lc[:-1]
|
||||
r5 = np.full_like(lc, np.nan); r5[5:] = lc[5:] - lc[:-5]
|
||||
r15 = np.full_like(lc, np.nan); r15[15:] = lc[15:] - lc[:-15]
|
||||
ofi = np.where(qv > 0, 2 * tbq / qv - 1.0, 0.0) # taker buy imbalance in [-1,1]
|
||||
ofi5 = roll_mean(ofi, 5)
|
||||
vz = (qv - roll_mean(qv, 30)) / (roll_mean(qv, 30) + 1e-9)
|
||||
feats = {"r1": r1, "r5": r5, "r15": r15, "ofi": ofi, "ofi5": ofi5, "vol_z": vz}
|
||||
for k in feats_all:
|
||||
feats_all[k].append(feats[k])
|
||||
for h in fwd_all:
|
||||
fwd = np.full_like(lc, np.nan); fwd[:-h] = lc[h:] - lc[:-h]
|
||||
fwd_all[h].append(fwd)
|
||||
F = {k: np.concatenate(v) for k, v in feats_all.items()}
|
||||
FW = {h: np.concatenate(v) for h, v in fwd_all.items()}
|
||||
|
||||
print(f"\n===== MINUTE-HORIZON GATE — {len(SYMS)} perps, {DAYS}d, cost={COST_BP}bp round-trip =====")
|
||||
print("IC = corr(feature_t, forward-return); per-trade NET = mean(sign(feat)*fwd) - cost, in bps")
|
||||
print(f"{'feature':>8} " + " ".join(f"h={h}:IC/netbp" for h in [1, 5, 15]))
|
||||
for fk, fv in F.items():
|
||||
cells = []
|
||||
for h, fw in FW.items():
|
||||
m = np.isfinite(fv) & np.isfinite(fw)
|
||||
a, b = fv[m], fw[m]
|
||||
ic = float(np.corrcoef(a, b)[0, 1]) if len(a) > 1000 else float("nan")
|
||||
net_bp = (float(np.mean(np.sign(a) * b)) - COST_BP / 1e4) * 1e4
|
||||
cells.append(f"{ic:+.3f}/{net_bp:+.1f}")
|
||||
print(f"{fk:>8} " + " ".join(cells))
|
||||
print("\nVERDICT: any feature with positive per-trade NET bps (edge > cost) => minute-horizon signal")
|
||||
print("exists for Mamba2/CfC/TLOB to extract -> build GPU model POC. All negative => same wall as ES.")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
Reference in New Issue
Block a user