From 4f73e9922507d95d3f1f1adc0a9dff9d46265044 Mon Sep 17 00:00:00 2001 From: jgrusewski Date: Sun, 7 Jun 2026 17:32:35 +0200 Subject: [PATCH] feat(crypto): cross-venue funding-arb scanner + paper-forward (solves hedgeability) Short high-funding-venue perp + long low-funding-venue perp (same coin) = price-neutral, collect the funding DIFFERENCE, NO spot leg needed -> fixes the single-venue hedgeability block. Binance/ Bybit/Hyperliquid bulk funding, normalized to daily (HL is hourly!). Sanity-filtered (liquid >$10M both legs, exclude |funding|>50bp/day distress/artifacts -- raw scan top was 9000%/yr delisting junk). Clean spreads: ETH ~20%, ARB/ONDO ~20%, up to ZEC 167%; 16 coins >5bp/day. scan/run/status, idempotent daily, cron 9/12/15/18 UTC. Decisive open question = PERSISTENCE (snapshot != edge; paper-forward measures whether spreads last long enough to collect net of ~10bp fees). Co-Authored-By: Claude Opus 4.8 (1M context) --- scripts/surfer/cross_venue_funding.py | 151 ++++++++++++++++++++++++++ 1 file changed, 151 insertions(+) create mode 100644 scripts/surfer/cross_venue_funding.py diff --git a/scripts/surfer/cross_venue_funding.py b/scripts/surfer/cross_venue_funding.py new file mode 100644 index 000000000..aeee64ef5 --- /dev/null +++ b/scripts/surfer/cross_venue_funding.py @@ -0,0 +1,151 @@ +#!/usr/bin/env python3 +"""Cross-venue crypto funding-arb scanner + paper-forward (price-neutral, no spot leg needed). + +Funding differs across venues. For a coin on >=2 liquid venues: SHORT the highest-funding venue +perp + LONG the lowest-funding venue perp (same coin) -> price-neutral (both perps, opposite), +collect the funding DIFFERENCE (max-min). No spot leg -> solves the single-venue hedgeability block. + +Funding intervals differ (Binance/Bybit 8h, Hyperliquid 1h) -> normalize to DAILY before comparing. +No agents, no key, no capital. Venues: Binance, Bybit, Hyperliquid (all bulk-fetch). + + python3 cross_venue_funding.py scan live top cross-venue spreads + python3 cross_venue_funding.py run book daily carry on the top-K book, persist (cron) + python3 cross_venue_funding.py status cumulative paper track record + (alias: paper == run) +""" +import datetime +import json +import math +import os +import sys +import urllib.request + +_REPO = os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) +STATE = os.path.join(_REPO, "data/surfer/crossvenue_state.json") +LIQ = 10e6 # >$10M/day on BOTH legs (clean, fungible) +MAXF = 0.005 # exclude legs with |daily funding| > 50bp/day = distress/artifact (un-tradeable) +TOPK = 10 # book the top-K spreads +COST_RT = 0.0010 # ~10bp round-trip (2 perp legs, maker) +HURDLE = 0.0005 # 5bp/day spread to bother + + +def get(url, post=None): + data = json.dumps(post).encode() if post else None + h = {"User-Agent": "Mozilla/5.0", "Accept": "application/json"} + if post: + h["Content-Type"] = "application/json" + for a in range(3): + try: + return json.loads(urllib.request.urlopen(urllib.request.Request(url, data=data, headers=h), timeout=25).read()) + except Exception: + if a == 2: + raise + return None + + +def base(sym): + for q in ("USDT", "USDC"): + if sym.endswith(q): + return sym[:-len(q)] + return sym + + +def binance(): # {coin: (daily_funding, daily_vol)} + fund = {x["symbol"]: float(x["lastFundingRate"]) for x in get("https://fapi.binance.com/fapi/v1/premiumIndex")} + try: + itv = {x["symbol"]: float(x.get("fundingIntervalHours", 8)) for x in get("https://fapi.binance.com/fapi/v1/fundingInfo")} + except Exception: + itv = {} + vol = {x["symbol"]: float(x["quoteVolume"]) for x in get("https://fapi.binance.com/fapi/v1/ticker/24hr")} + return {base(s): (f * (24.0 / itv.get(s, 8)), vol.get(s, 0.0)) for s, f in fund.items() if s.endswith("USDT")} + + +def bybit(): + r = get("https://api.bybit.com/v5/market/tickers?category=linear")["result"]["list"] + return {base(x["symbol"]): (float(x["fundingRate"]) * 3, float(x.get("turnover24h", 0.0))) + for x in r if x["symbol"].endswith("USDT") and x.get("fundingRate")} + + +def hyperliquid(): + r = get("https://api.hyperliquid.xyz/info", post={"type": "metaAndAssetCtxs"}) + meta, ctxs = r[0], r[1] + out = {} + for u, c in zip(meta["universe"], ctxs): + if c.get("funding") is not None: + out[u["name"]] = (float(c["funding"]) * 24, float(c.get("dayNtlVlm", 0.0))) + return out + + +def spreads(): + V = {} + for nm, fn in [("Binance", binance), ("Bybit", bybit), ("HL", hyperliquid)]: + try: + V[nm] = fn() + except Exception as e: + print(f" ({nm} fetch failed: {str(e)[:40]})") + coins = set().union(*[set(v) for v in V.values()]) + rows = [] + for c in coins: + pts = {nm: V[nm][c] for nm in V if c in V[nm] and V[nm][c][1] > LIQ and abs(V[nm][c][0]) <= MAXF} + if len(pts) < 2: + continue + f = {nm: pts[nm][0] for nm in pts} + hi = max(f, key=f.get); lo = min(f, key=f.get) + rows.append({"coin": c, "spread": f[hi] - f[lo], "short": hi, "long": lo, "f": f}) + rows.sort(key=lambda r: -r["spread"]) + return rows, list(V) + + +def load_state(): + if os.path.exists(STATE): + return json.load(open(STATE)) + return {"positions": {}, "cum_gross": 0.0, "cum_net": 0.0, "days": 0, "last_run_date": ""} + + +def cmd_scan(): + rows, venues = spreads() + print(f"cross-venue funding scan {datetime.date.today()} (venues: {', '.join(venues)}; liquid both legs >${LIQ/1e6:.0f}M)") + n_ok = sum(1 for r in rows if r["spread"] > HURDLE) + print(f" {len(rows)} coins on >=2 venues | {n_ok} with spread > {HURDLE*1e4:.0f}bp/day") + print(f" {'coin':>8} {'spread/day':>11} {'ann%':>7} short -> long (daily funding)") + for r in rows[:15]: + leg = " ".join(f"{k}{1e4*v:+.1f}" for k, v in sorted(r["f"].items(), key=lambda kv: -kv[1])) + print(f" {r['coin']:>8} {1e4*r['spread']:>9.1f}bp {100*r['spread']*365:>6.0f}% short {r['short']}->long {r['long']} [{leg}]") + + +def cmd_run(): + st = load_state() + today = datetime.datetime.now(datetime.timezone.utc).date().isoformat() + if st.get("last_run_date") == today: + print(f"already booked {today} (day {st['days']}); skipping"); return + rows, _ = spreads() + cur = {r["coin"]: r["spread"] for r in rows} + prev = st["positions"] + realized = sum(w * cur.get(c, 0.0) for c, w in prev.items()) # carry on yesterday's book at today's spreads + qual = [r for r in rows if r["spread"] > HURDLE][:TOPK] + newpos = {r["coin"]: 1.0 / len(qual) for r in qual} if qual else {} + turn = sum(abs(newpos.get(c, 0) - prev.get(c, 0)) for c in set(newpos) | set(prev)) + net = realized - turn * (COST_RT / 2) + st.update(positions=newpos, days=st["days"] + 1, last_run_date=today) + st["cum_gross"] += realized; st["cum_net"] += net + os.makedirs(os.path.dirname(STATE), exist_ok=True); json.dump(st, open(STATE, "w")) + top = " ".join(f"{r['coin']}:{1e4*r['spread']:.0f}bp({r['short'][:2]}>{r['long'][:2]})" for r in qual[:5]) + print(f"{today} day={st['days']} | qualifying={len(qual)} | realized24h gross={100*realized:+.3f}% net={100*net:+.3f}% " + f"| cum net={100*st['cum_net']:+.2f}% | top: {top}") + + +def cmd_status(): + st = load_state() + print(f"cross-venue funding paper — day {st['days']} (through {st.get('last_run_date') or 'n/a'}), " + f"{len(st['positions'])} pairs, cum gross {100*st['cum_gross']:+.2f}% net {100*st['cum_net']:+.2f}%") + for c, w in sorted(st["positions"].items(), key=lambda kv: -kv[1]): + print(f" {c:>8} w={w:.3f}") + + +def main(): + cmd = sys.argv[1] if len(sys.argv) > 1 else "scan" + {"scan": cmd_scan, "run": cmd_run, "paper": cmd_run, "status": cmd_status}.get(cmd, cmd_scan)() + + +if __name__ == "__main__": + main()