diff --git a/scripts/surfer/crypto_funding_paper.py b/scripts/surfer/crypto_funding_paper.py index ab7c2623e..b20f18977 100644 --- a/scripts/surfer/crypto_funding_paper.py +++ b/scripts/surfer/crypto_funding_paper.py @@ -1,18 +1,17 @@ #!/usr/bin/env python3 -"""Paper-forward harness for the crypto funding harvest (Phase 1 of the deployable spec). +"""fundcli — local CLI for the crypto funding-harvest strategy (Phase-1 paper-forward). -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. +No agents, no cloud. A self-contained local tool over Binance's public API (no key, no capital). +Delta-neutral funding harvest: hold long-spot/short-perp on CRYPTO-NATIVE coins whose trailing-30d +mean daily funding > 5bp; collect funding as market-neutral carry. Validated OOS (realistic +Sharpe ~2-3.6); this tool tracks the no-capital forward record. -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? + python3 crypto_funding_paper.py snapshot live liveness check (qualifying coins; no state change) + python3 crypto_funding_paper.py run daily step: book funding on prior positions, log, persist + python3 crypto_funding_paper.py status current book + cumulative track record + python3 crypto_funding_paper.py gate Phase-1 -> Phase-2 assessment vs backtest + python3 crypto_funding_paper.py log [N] last N run-log lines (default 25) + (alias: 'paper' == 'run', for the cron) """ import datetime import json @@ -23,13 +22,15 @@ 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") +LOG = os.path.join(_REPO, "data/surfer/funding_paper_runs.log") FAPI = "https://fapi.binance.com" -HURDLE = 0.0005 # 5 bp/day trailing-30d mean funding (validated filter) +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) +LIQ_USD = 5e6 # >$5M/day quote volume +COST_RT = 0.0010 # 10 bp round-trip (net-of-cost awareness) INTERVALS_PER_DAY = 3 # Binance funding settles every 8h +BACKTEST_APR = (15, 22) # validated APR band on deployed capital def get(url, tries=4): @@ -37,21 +38,18 @@ def get(url, tries=4): 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: + except Exception: if a == tries - 1: raise time.sleep(2 * (a + 1)) def crypto_native(): - """Symbols whose underlying is a crypto COIN (excludes EQUITY/KR_EQUITY/COMMODITY/INDEX/ - PREMARKET tokenized-stock perps) — matches the universe the edge was validated on.""" info = get(f"{FAPI}/fapi/v1/exchangeInfo") return {s["symbol"] for s in info["symbols"] if s.get("underlyingType") == "COIN"} def universe(): - """Liquid CRYPTO-NATIVE USDT perps: symbol -> 24h quote volume (USD).""" native = crypto_native() t = get(f"{FAPI}/fapi/v1/ticker/24hr") return {x["symbol"]: float(x["quoteVolume"]) for x in t @@ -59,31 +57,14 @@ def universe(): 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") +def scan(prev=None): + """Fetch universe + funding; return (liq, tf30, last24) for union of universe and prev positions.""" liq = universe() - prev = st["positions"] - need = set(liq) | set(prev) # fetch hist for new-universe + held coins + need = set(liq) | set(prev or {}) tf30, last24 = {}, {} for i, sym in enumerate(sorted(need)): try: @@ -92,39 +73,119 @@ def main(): 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) + tf30[sym] = (sum(h) / len(h)) * INTERVALS_PER_DAY + last24[sym] = sum(h[-INTERVALS_PER_DAY:]) if i % 50 == 49: time.sleep(0.5) + return liq, tf30, last24 - # 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 = {} +def qualify(liq, tf30, prev): + q = {} 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 {} + if t is not None and (t > HURDLE or (c in prev and t > EXIT_HURDLE)): + q[c] = t + return q + + +def regime(n): + return "ALIVE (healthy)" if n >= 15 else ("THIN / deleverage (mostly cash, by design)" if n >= 5 else "COMPRESSED (edge thin/decaying)") + + +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} + + +# ---- commands ---- +def cmd_snapshot(): + liq, tf30, _ = scan() + q = qualify(liq, tf30, {}) + top = sorted(q.items(), key=lambda kv: -kv[1]) + med = sorted(q.values())[len(q) // 2] if q else 0.0 + print(f"funding snapshot {datetime.date.today()} (crypto-native, live)") + print(f" liquid universe: {len(liq)} perps (>${LIQ_USD/1e6:.0f}M/day)") + print(f" qualifying (tf30 > {HURDLE*1e4:.0f}bp/day): {len(q)} regime: {regime(len(q))}") + print(f" median qualifying funding: {med*1e4:.1f} bp/day") + for c, t in top[:15]: + print(f" {c:>16} {t*1e4:5.1f} bp/day") + + +def cmd_run(): + st = load_state() + prev = st["positions"] + liq, tf30, last24 = scan(prev) + realized = sum(w * last24.get(c, 0.0) for c, w in prev.items()) + q = qualify(liq, tf30, prev) + n = len(q) + newpos = {c: 1.0 / n for c in q} 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.update(positions=newpos, days=st["days"] + 1) 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 = " ".join(f"{c}:{t*1e4:.0f}bp" for c, t in sorted(q.items(), key=lambda kv: -kv[1])[:5]) + line = (f"{datetime.date.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: {top}") + print(line) - 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}") + +def cmd_status(): + st = load_state() + print(f"funding-harvest 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]): + print(f" {c:>16} w={w:.3f}") + + +def cmd_gate(): + st = load_state() + d = st["days"] + if d == 0: + print("no data yet — run 'run' (or wait for cron) to start the track record."); return + apr = st["cum_net"] * 365 / d * 100 + print(f"=== Phase-1 gate assessment (day {d}, {d/7:.1f} weeks) ===") + print(f" cumulative net: {100*st['cum_net']:+.2f}% annualized: {apr:+.1f}% APR") + print(f" backtest target band: {BACKTEST_APR[0]}-{BACKTEST_APR[1]}% APR (gross carry, deployed capital)") + if d < 28: + rec = f"KEEP ACCUMULATING — need >=4 weeks ({28-d} days to go) before the gate is meaningful." + elif apr >= 10: + rec = "PROCEED-candidate -> Phase 2 (micro-live $1-3k, ONE tier-1 venue, low leverage). Carry holding forward." + elif apr > 0: + rec = "BORDERLINE -> extend paper-forward to 8 weeks; carry positive but below backtest band (thin regime)." + else: + rec = "DO NOT GO LIVE -> carry flat/negative forward; extend or stop. No capital risked." + print(f" recommendation: {rec}") + print(" caveats: GROSS carry only (realistic Sharpe ~ raw/2.5 after basis vol); counterparty/exchange") + print(" tail is the real -100% risk (un-modeled); current qualifiers may be small/niche coins.") + + +def cmd_log(n=25): + if not os.path.exists(LOG): + print(f"(no log yet at {LOG} — cron writes it nightly; 'run' appends when redirected)"); return + lines = open(LOG).read().splitlines() + print("\n".join(lines[-n:])) + + +def main(): + cmd = sys.argv[1] if len(sys.argv) > 1 else "snapshot" + if cmd in ("run", "paper"): + cmd_run() + elif cmd == "snapshot": + cmd_snapshot() + elif cmd == "status": + cmd_status() + elif cmd == "gate": + cmd_gate() + elif cmd == "log": + cmd_log(int(sys.argv[2]) if len(sys.argv) > 2 else 25) + else: + print(__doc__) if __name__ == "__main__":