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) <noreply@anthropic.com>
This commit is contained in:
jgrusewski
2026-06-07 17:32:35 +02:00
parent 9bf67e731d
commit 4f73e99225

View File

@@ -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()