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