Files
foxhunt/scripts/surfer/crypto_funding_paper.py
jgrusewski 107bcc6648 fix(crypto): MANDATORY hedgeability filter — funding edge was partly phantom
Phase-2 setup (orders command) revealed 0/7 qualifying coins were hedgeable: all perp-only (no
spot leg = cannot build the delta-neutral hedge). Diagnostic: of 191 liquid crypto-native perps,
135 hedgeable / 56 perp-only. HEDGEABLE median funding -0.12bp/day, 0 of 134 clear 5bp (arbed flat
by existing cash-and-carry). PERP-ONLY median +2.59bp/day, all 8 qualifiers live there. The carry
survives ONLY where it can't be hedged. crypto_pit backtest never checked hedgeability -> the
~2-3.6 Sharpe was inflated by un-capturable perp-only funding. Fix: universe() now requires a spot
market (hedgeable only). Current regime: 0/135 hedgeable qualify -> nothing to harvest (deleverage
arbed flat). Real edge = classic basis trade on hedgeable majors: regime-dependent (rich in bull
leverage, ~zero now), more competed, lower true Sharpe than backtest. Phase-2 deploy correctly
BLOCKED by reality, not process. State reset (prior bookings were on un-hedgeable coins).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-07 11:03:41 +02:00

243 lines
10 KiB
Python

#!/usr/bin/env python3
"""fundcli — local CLI for the crypto funding-harvest strategy (Phase-1 paper-forward).
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.
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 orders [USD] Phase-2: current book -> exact delta-neutral orders
python3 crypto_funding_paper.py log [N] last N run-log lines (default 25)
(alias: 'paper' == 'run', for the cron)
"""
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")
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)
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 (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):
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:
if a == tries - 1:
raise
time.sleep(2 * (a + 1))
def crypto_native():
info = get(f"{FAPI}/fapi/v1/exchangeInfo")
return {s["symbol"] for s in info["symbols"] if s.get("underlyingType") == "COIN"}
def spot_symbols():
"""Symbols with a Binance SPOT market — required to build the delta-neutral hedge.
Perp-only coins can't be cash-and-carry harvested (no spot leg); their high funding survives
PRECISELY because the arb is impossible, so they must be excluded."""
return {x["symbol"] for x in get("https://api.binance.com/api/v3/ticker/price")}
def universe():
"""Liquid, crypto-native, HEDGEABLE (spot+perp) USDT perps — the only ones that can be
delta-neutral harvested."""
native = crypto_native()
spot = spot_symbols()
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 x["symbol"] in spot
and float(x["quoteVolume"]) > LIQ_USD}
def funding_hist(sym, limit=90):
h = get(f"{FAPI}/fapi/v1/fundingRate?symbol={sym}&limit={limit}")
return [float(x["fundingRate"]) for x in h]
def scan(prev=None):
"""Fetch universe + funding; return (liq, tf30, last24) for union of universe and prev positions."""
liq = universe()
need = set(liq) | set(prev or {})
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
last24[sym] = sum(h[-INTERVALS_PER_DAY:])
if i % 50 == 49:
time.sleep(0.5)
return liq, tf30, last24
def qualify(liq, tf30, prev):
q = {}
for c in liq:
t = tf30.get(c)
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()
today = datetime.datetime.now(datetime.timezone.utc).date().isoformat()
if st.get("last_run_date") == today: # idempotent: one booking per UTC day
print(f"already booked {today} (day {st['days']}); skipping")
return
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.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"{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)
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_orders(capital):
"""Phase-2 bridge: turn the current paper book into exact delta-neutral orders for `capital`,
and flag coins that are perp-only (no spot leg = can't hedge cleanly on Binance)."""
st = load_state()
book = st.get("positions", {})
if not book:
print("no current book — run 'snapshot'/'run' first."); return
try:
spot_px = {x["symbol"]: float(x["price"]) for x in get("https://api.binance.com/api/v3/ticker/price")}
except Exception:
spot_px = {}
perp_px = {x["symbol"]: float(x["price"]) for x in get(f"{FAPI}/fapi/v1/ticker/price")}
LEV = 2.0 # conservative perp leverage (avoid liquidation)
n = len(book)
X = capital / (n * (1 + 1 / LEV)) # notional per leg per coin
print(f"=== Phase-2 delta-neutral orders for ${capital:.0f} across {n} coins (perp {LEV:.0f}x) ===")
print(f" per coin: ~${X:.0f} notional/leg, ~${X/LEV:.0f} perp margin, ~${X*(1+1/LEV):.0f} capital")
print(f"{'coin':>14} {'hedge':>6} {'SPOT buy (units @ px)':>26} {'PERP short (units @ px)':>26}")
ok = 0
for c in sorted(book):
if c in spot_px and c in perp_px:
print(f"{c:>14} {'YES':>6} {X/spot_px[c]:>14.4f} @ {spot_px[c]:<9.5g} {X/perp_px[c]:>14.4f} @ {perp_px[c]:<9.5g}")
ok += 1
else:
why = "no-spot" if c not in spot_px else "no-perp"
print(f"{c:>14} {'NO':>6} ({why}) cannot delta-neutral hedge on Binance — skip / alt-venue")
print(f"\n {ok}/{n} coins hedgeable on Binance (spot+perp both exist).")
print(" Place SPOT + PERP legs together to stay delta-neutral. Keep perp leverage low; never let the")
print(" perp leg liquidate while holding spot. API keys: ENV ONLY, never commit. Place manually for")
print(" micro-live to validate fills before any automation. DEPLOY ONLY AFTER the Phase-1 gate passes.")
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 == "orders":
cmd_orders(float(sys.argv[2]) if len(sys.argv) > 2 else 2000.0)
elif cmd == "log":
cmd_log(int(sys.argv[2]) if len(sys.argv) > 2 else 25)
else:
print(__doc__)
if __name__ == "__main__":
main()