feat(crypto): consolidate funding harvest into a local CLI (snapshot/run/status/gate/log)
Replace the agent/cloud approach with one self-contained local CLI over Binance's public API. Subcommands: snapshot (live liveness check), run/paper (daily step, cron-compatible), status (book + cumulative), gate (Phase-1 -> Phase-2 assessment vs backtest band), log. No key, no capital, no agents. Crypto-native filter retained. Cron 'paper' alias preserved. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -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__":
|
||||
|
||||
Reference in New Issue
Block a user