(1) Live cross_venue_funding.py paper harness now uses HYSTERESIS (enter >10bp/day, hold until spread decays <5bp) instead of naive daily top-K -> tracks the deployable low-turnover version (naive was cost-killed -4.5 Sharpe; hysteresis nets +10-14%/yr). State reset. (2) fetch_xvenue_hist2 now paginates Binance fundingRate -> full ~330d history (was 67d) so Binance-HL overlap = full year (330 days) for the regime test across ~11 months. Monitor armed for the full-year backtest. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
161 lines
7.0 KiB
Python
161 lines
7.0 KiB
Python
#!/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 show in scan
|
|
ENTRY = 0.0010 # hysteresis: only ENTER a new pair above 10bp/day
|
|
EXIT = 0.0005 # hysteresis: HOLD a pair until its spread decays below 5bp/day (cuts turnover)
|
|
|
|
|
|
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
|
|
# HYSTERESIS (the deployable, low-turnover version): hold winners until they decay, only enter strong fresh
|
|
held = [c for c in prev if cur.get(c, 0.0) > EXIT]
|
|
for r in sorted([r for r in rows if r["spread"] > ENTRY], key=lambda r: -r["spread"]):
|
|
if len(held) >= TOPK:
|
|
break
|
|
if r["coin"] not in held:
|
|
held.append(r["coin"])
|
|
qual = [r for r in rows if r["coin"] in held]
|
|
newpos = {c: 1.0 / len(held) for c in held} if held 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()
|