Live Binance fapi now lists tokenized-stock perps (MSTR/CRCL/INTC/AMD/SOXL, underlyingType= EQUITY) which dominated the high-funding qualifiers (12 of 19) but were NEVER in the validated crypto_pit backtest. Filter to exchangeInfo underlyingType==COIN (crypto-native) to keep the paper-forward test measuring the edge we actually proved. Post-filter: 7 of 212 liquid qualify (thin = current deleverage regime, filter sitting mostly in cash as designed). Tokenized-stock funding may be a separate unvalidated carry; not contaminating the Phase-1 gate. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
132 lines
5.3 KiB
Python
132 lines
5.3 KiB
Python
#!/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 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
|
|
if x["symbol"].endswith("USDT") and x["symbol"] in native 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()
|