diff --git a/scripts/surfer/crypto_funding_paper.py b/scripts/surfer/crypto_funding_paper.py new file mode 100644 index 000000000..399bd93d4 --- /dev/null +++ b/scripts/surfer/crypto_funding_paper.py @@ -0,0 +1,123 @@ +#!/usr/bin/env python3 +"""Paper-forward harness for the crypto funding harvest (Phase 1 of the deployable spec). + +Daily, no-capital forward test. Pulls LIVE Binance USDT-perp funding (public API, no key), +applies the validated filter (liquid + trailing-30d mean daily funding > 5bp), computes the +intended equal-weight delta-neutral book, and tracks the funding that WOULD have accrued on +yesterday's book -> builds a genuine forward track record on unseen future data. + +Modes: paper (run + log + persist state) | status (show current book/P&L). +Cron (daily, after the 00:00 UTC funding settlement): + 17 1 * * * /usr/bin/python3 /home/jgrusewski/Work/foxhunt/scripts/surfer/crypto_funding_paper.py paper >> /home/jgrusewski/Work/foxhunt/data/surfer/funding_paper_runs.log 2>&1 + +Honest scope: tracks GROSS funding carry (the edge) + turnover (cost awareness). Does NOT model +basis/tracking-error vol (needs live fills) or counterparty risk (un-simulable). It validates the +ONE open quantitative question: does positive funding carry persist on unseen forward data? +""" +import datetime +import json +import os +import sys +import time +import urllib.request + +_REPO = os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) +STATE = os.path.join(_REPO, "data/surfer/funding_paper_state.json") +FAPI = "https://fapi.binance.com" + +HURDLE = 0.0005 # 5 bp/day trailing-30d mean funding (validated filter) +EXIT_HURDLE = 0.0003 # hysteresis: keep held coins until they fall below 3 bp/day +LIQ_USD = 5e6 # >$5M/day quote volume +COST_RT = 0.0010 # 10 bp round-trip (for net-of-cost awareness) +INTERVALS_PER_DAY = 3 # Binance funding settles every 8h + + +def get(url, tries=4): + for a in range(tries): + try: + req = urllib.request.Request(url, headers={"User-Agent": "curl/8"}) + return json.loads(urllib.request.urlopen(req, timeout=30).read()) + except Exception as e: + if a == tries - 1: + raise + time.sleep(2 * (a + 1)) + + +def universe(): + """Liquid USDT perps: symbol -> 24h quote volume (USD).""" + t = get(f"{FAPI}/fapi/v1/ticker/24hr") + return {x["symbol"]: float(x["quoteVolume"]) for x in t + if x["symbol"].endswith("USDT") and float(x["quoteVolume"]) > LIQ_USD} + + +def funding_hist(sym, limit=90): + """Last `limit` 8h funding rates for sym (90 ~= 30 days).""" + h = get(f"{FAPI}/fapi/v1/fundingRate?symbol={sym}&limit={limit}") + return [float(x["fundingRate"]) for x in h] + + +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} + + +def main(): + mode = sys.argv[1] if len(sys.argv) > 1 else "paper" + st = load_state() + if mode == "status": + print(f"funding-paper state: day {st['days']}, {len(st['positions'])} positions, " + f"cum gross {100*st['cum_gross']:+.2f}% cum net {100*st['cum_net']:+.2f}%") + for c, w in sorted(st["positions"].items(), key=lambda kv: -kv[1])[:15]: + print(f" {c:>14} w={w:.3f}") + return + + today = datetime.datetime.now(datetime.timezone.utc).strftime("%Y-%m-%d") + liq = universe() + prev = st["positions"] + need = set(liq) | set(prev) # fetch hist for new-universe + held coins + tf30, last24 = {}, {} + for i, sym in enumerate(sorted(need)): + try: + h = funding_hist(sym) + except Exception: + continue + if len(h) < 30: + continue + tf30[sym] = (sum(h) / len(h)) * INTERVALS_PER_DAY # trailing mean DAILY funding + last24[sym] = sum(h[-INTERVALS_PER_DAY:]) # last ~24h funding (3 settlements) + if i % 50 == 49: + time.sleep(0.5) + + # realized funding on YESTERDAY's book (paper P&L for the period just elapsed) + realized = sum(w * last24.get(c, 0.0) for c, w in prev.items()) + + # new book: hysteresis filter (enter >HURDLE, keep if >EXIT_HURDLE) + qual = {} + for c in liq: + t = tf30.get(c) + if t is None: + continue + if t > HURDLE or (c in prev and t > EXIT_HURDLE): + qual[c] = t + n = len(qual) + newpos = {c: 1.0 / n for c in qual} if n else {} + turnover = sum(abs(newpos.get(c, 0) - prev.get(c, 0)) for c in set(newpos) | set(prev)) + net = realized - turnover * (COST_RT / 2) + + st["positions"] = newpos + st["cum_gross"] += realized + st["cum_net"] += net + st["days"] += 1 + os.makedirs(os.path.dirname(STATE), exist_ok=True) + json.dump(st, open(STATE, "w")) + + top = sorted(qual.items(), key=lambda kv: -kv[1])[:5] + topstr = " ".join(f"{c}:{1e4*t:.0f}bp" for c, t in top) + print(f"{today} day={st['days']} | qualifying={n} liquid={len(liq)} | " + f"realized24h gross={100*realized:+.3f}% net={100*net:+.3f}% turn={turnover:.2f} | " + f"cum gross={100*st['cum_gross']:+.2f}% net={100*st['cum_net']:+.2f}% | top: {topstr}") + + +if __name__ == "__main__": + main()