Fetched Bybit funding (OKX fetch failed, so Binance+Bybit) for 18 majors. Cross-venue DISPERSION & Binance-premium signals all negative. Only positive = carry_multivenue +0.84 (OOS+0.80, corr_mom+0.01, marg_t+2.58) -- BUT Binance-only control on the SAME 18 majors = +0.69, proving the edge is MAJOR-COIN SELECTION not the cross-venue angle (multi-venue adds only +0.15 noise-reduction). This is the known cherry-picked carry trap (majors +0.8 -> broad-clean -0.03). Almost re-fooled by the seductive number; the Binance-only control disconfirmed it. Orthogonality doesn't rescue universe-fragility. Databento has no crypto/ on-chain data either. Both orthogonal frontiers closed. Product = residual-momentum + VRP. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
162 lines
7.1 KiB
Python
162 lines
7.1 KiB
Python
#!/usr/bin/env python3
|
|
"""Frontier (a) — cross-exchange funding dispersion: edge distinct from momentum?
|
|
|
|
Fetch funding from Bybit + OKX (free) to pair with Binance funding + prices (crypto_pit).
|
|
Build cross-sectional signals among multi-venue coins: multi-venue-avg funding (robust carry),
|
|
cross-venue dispersion (positioning stress), Binance-premium. Validate each + correlation to
|
|
the full crypto momentum book + marginal-alpha regression. Realistic: Reff = return - Binance
|
|
funding (traded venue), death-excl, 10bp. Honest prior: low (cross-venue spreads arbitraged).
|
|
"""
|
|
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 pit_sweep # noqa: E402
|
|
from surfer_poc import compute_weights, CFG # noqa: E402
|
|
from signal_sweep import xs_weights, pnl_w, validate, sharpe_t # noqa: E402
|
|
import torch # noqa: E402
|
|
|
|
DEV = "cuda" if torch.cuda.is_available() else "cpu"
|
|
DAY_MS = 86_400_000
|
|
XF = "data/surfer/xfund"
|
|
COINS = ["BTC", "ETH", "SOL", "XRP", "DOGE", "ADA", "AVAX", "LINK", "LTC", "DOT",
|
|
"NEAR", "ATOM", "FIL", "ETC", "XLM", "UNI", "AAVE", "BNB"]
|
|
|
|
|
|
def _get(u):
|
|
try:
|
|
return json.load(urllib.request.urlopen(urllib.request.Request(u, headers={"User-Agent": "curl/8"}), timeout=30))
|
|
except Exception:
|
|
return None
|
|
|
|
|
|
def bybit(coin):
|
|
cache = f"{XF}/bybit_{coin}.json"
|
|
if os.path.exists(cache):
|
|
return {int(k): v for k, v in json.load(open(cache)).items()}
|
|
out, end = {}, int(time.time() * 1000)
|
|
for _ in range(40):
|
|
r = _get(f"https://api.bybit.com/v5/market/funding/history?category=linear&symbol={coin}USDT&endTime={end}&limit=200")
|
|
lst = (r or {}).get("result", {}).get("list", [])
|
|
if not lst:
|
|
break
|
|
for x in lst:
|
|
t = int(x["fundingRateTimestamp"]); out.setdefault(t // DAY_MS, 0.0)
|
|
out[t // DAY_MS] += float(x["fundingRate"])
|
|
end = min(int(x["fundingRateTimestamp"]) for x in lst) - 1
|
|
if len(lst) < 200:
|
|
break
|
|
time.sleep(0.06)
|
|
os.makedirs(XF, exist_ok=True); json.dump({str(k): v for k, v in out.items()}, open(cache, "w"))
|
|
return out
|
|
|
|
|
|
def okx(coin):
|
|
cache = f"{XF}/okx_{coin}.json"
|
|
if os.path.exists(cache):
|
|
return {int(k): v for k, v in json.load(open(cache)).items()}
|
|
out, before = {}, ""
|
|
for _ in range(60):
|
|
u = f"https://www.okx.com/api/v5/public/funding-rate-history?instId={coin}-USDT-SWAP&limit=100"
|
|
if before:
|
|
u += f"&after={before}"
|
|
r = _get(u); data = (r or {}).get("data", [])
|
|
if not data:
|
|
break
|
|
for x in data:
|
|
t = int(x["fundingTime"]); out.setdefault(t // DAY_MS, 0.0)
|
|
out[t // DAY_MS] += float(x["fundingRate"])
|
|
before = min(int(x["fundingTime"]) for x in data)
|
|
if len(data) < 100:
|
|
break
|
|
time.sleep(0.06)
|
|
os.makedirs(XF, exist_ok=True); json.dump({str(k): v for k, v in out.items()}, open(cache, "w"))
|
|
return out
|
|
|
|
|
|
def main():
|
|
syms, days, close, qv, fund = pit_sweep.load()
|
|
idx = {s: j for j, s in enumerate(syms)}
|
|
coins = [c for c in COINS if c + "USDT" in idx]
|
|
print(f"fetching Bybit+OKX funding for {len(coins)} coins...")
|
|
by = {c: bybit(c) for c in coins}
|
|
ok = {c: okx(c) for c in coins}
|
|
nby = sum(1 for c in coins if len(by[c]) > 100); nok = sum(1 for c in coins if len(ok[c]) > 100)
|
|
print(f" Bybit covered {nby}/{len(coins)}, OKX covered {nok}/{len(coins)}")
|
|
|
|
N = len(coins); T = len(days)
|
|
di = {int(days[t]): t for t in range(T)}
|
|
bn_f = np.full((T, N), np.nan); by_f = np.full((T, N), np.nan); ok_f = np.full((T, N), np.nan)
|
|
cl = np.full((T, N), np.nan)
|
|
for j, c in enumerate(coins):
|
|
col = idx[c + "USDT"]
|
|
cl[:, j] = close[:, col]; bn_f[:, j] = fund[:, col]
|
|
for d, v in by[c].items():
|
|
if d in di:
|
|
by_f[di[d], j] = v
|
|
for d, v in ok[c].items():
|
|
if d in di:
|
|
ok_f[di[d], j] = v
|
|
|
|
R = np.zeros((T, N)); R[1:] = np.log(cl)[1:] - np.log(cl)[:-1]; R = np.where(np.isfinite(R), R, 0.0)
|
|
bnf = np.where(np.isfinite(bn_f), bn_f, 0.0)
|
|
Reff = R - bnf # trade on Binance -> pay Binance funding
|
|
# cross-venue stack
|
|
stack = np.stack([bn_f, by_f, ok_f]) # [3,T,N]
|
|
avg_f = np.nanmean(stack, axis=0) # multi-venue avg funding
|
|
disp_f = np.nanstd(stack, axis=0) # cross-venue dispersion
|
|
prem = bn_f - np.nanmean(np.stack([by_f, ok_f]), axis=0) # Binance premium vs others
|
|
|
|
def roll_mean(A, L):
|
|
out = np.full_like(A, np.nan)
|
|
for t in range(L, len(A)):
|
|
out[t] = np.nanmean(A[t - L:t], axis=0)
|
|
return out
|
|
|
|
sigs = {
|
|
"carry_BINANCE_only": -roll_mean(np.where(np.isfinite(bn_f), bn_f, np.nan), 7), # control: is it just major-coin carry?
|
|
"carry_multivenue": -roll_mean(avg_f, 7), # long low avg funding (robust carry)
|
|
"venue_dispersion": -roll_mean(disp_f, 7), # low disagreement? test
|
|
"venue_dispersion+": roll_mean(disp_f, 7), # high disagreement
|
|
"binance_premium": -roll_mean(prem, 7), # fade Binance-crowded
|
|
}
|
|
valid = np.isfinite(cl) & np.isfinite(avg_f)
|
|
year = (1970 + days / 365.25).astype(int)
|
|
|
|
def book(sig):
|
|
s = sig.copy(); s[~valid] = np.nan
|
|
return pnl_w(xs_weights(s), Reff, cost_bp=10)
|
|
|
|
# full-universe momentum book for correlation
|
|
cw, _ = compute_weights(close, qv, days, CFG)
|
|
cR = np.zeros_like(close); cR[1:] = np.log(close)[1:] - np.log(close)[:-1]; cR = np.where(np.isfinite(cR), cR, 0.0)
|
|
cf = np.where(np.isfinite(fund), fund, 0.0)
|
|
mom = np.sum(cw[:-1] * (cR - cf)[1:], axis=1)
|
|
mom_by = {int(days[1:][i]): mom[i] for i in range(len(mom))}
|
|
|
|
print(f"\n===== CROSS-EXCHANGE FUNDING — {len(coins)} multi-venue coins, death-excl, 10bp, deflate N=55 =====")
|
|
print(f"{'signal':>18} {'full':>6} {'IS':>6} {'OOS':>6} {'CPCVmed':>8} {'DSR':>5} {'corr_mom':>8} {'marg_t':>7}")
|
|
pdays = days[1:]
|
|
for nm, sg in sigs.items():
|
|
p = book(sg); v = validate(p, days, 55)
|
|
pb = {int(pdays[i]): p[i] for i in range(len(p))}
|
|
common = sorted(set(pb) & set(mom_by))
|
|
a = np.array([mom_by[d] for d in common]); b = np.array([pb[d] for d in common])
|
|
m = np.isfinite(a) & np.isfinite(b); a, b = a[m], b[m]
|
|
corr = float(np.corrcoef(a, b)[0, 1]) if len(a) > 100 else float("nan")
|
|
beta1 = np.cov(a, b)[0, 1] / (np.var(a) + 1e-12); res = b - beta1 * a
|
|
mt = float(res.mean() / (res.std() / math.sqrt(len(res)) + 1e-12))
|
|
print(f"{nm:>18} {v['full']:>+6.2f} {v['is_']:>+6.2f} {v['oos']:>+6.2f} {v['med']:>+8.2f} {v['dsr']:>5.2f} {corr:>+8.2f} {mt:>+7.2f}")
|
|
print("\nVERDICT: any signal with full+OOS+CPCVmed>0, DSR>0.5, low |corr_mom|, AND marg_t>2 = real distinct edge.")
|
|
print("(marg_t = t-stat of marginal alpha vs momentum book). Honest prior: cross-venue spreads arbitraged -> expect fail.")
|
|
|
|
|
|
if __name__ == "__main__":
|
|
main()
|