diff --git a/scripts/surfer/crypto_funding_liveness.py b/scripts/surfer/crypto_funding_liveness.py new file mode 100644 index 000000000..d1212e8fc --- /dev/null +++ b/scripts/surfer/crypto_funding_liveness.py @@ -0,0 +1,97 @@ +#!/usr/bin/env python3 +"""Decay/liveness: is the crypto funding carry still ALIVE now, or competed to death? + +The harvest was +12 Sharpe 2020-24 but negative 2022, 2025, 2026. Decisive question: is that +(a) STRUCTURAL COMPRESSION (funding yield shrinking year-over-year = arbitraged away = dead) or +(b) REGIME (funding flips negative in deleverage, recovers = dormant)? And does a regime filter +(only harvest reliably-positive funding, else sit in cash) rescue the recent period? + +Diagnostics: (1) funding-yield decay table by year; (2) quarterly net-harvest liveness timeline; +(3) regime-filtered vs unfiltered in the recent window. +""" +import datetime +import math +import os +import sys + +import numpy as np + +sys.path.insert(0, os.path.dirname(os.path.abspath(__file__))) +import pit_sweep # noqa: E402 + + +def sharpe(r): + r = r[np.isfinite(r)] + return float("nan") if len(r) < 20 or r.std() == 0 else r.mean() / r.std() * math.sqrt(365) + + +def harvest(fund, elig, hurdle, c_round, trail=None): + T, N = fund.shape + cond = fund > hurdle + if trail: # regime filter: trailing-mean funding > hurdle + tf = np.full_like(fund, np.nan) + for t in range(trail, T): + tf[t] = np.nanmean(fund[t - trail:t], axis=0) + cond = tf > hurdle + sig = np.zeros((T, N)) + sig[1:] = np.where(elig[1:] & cond[:-1], 1.0, 0.0) # causal + w = sig / np.maximum(sig.sum(1, keepdims=True), 1) + f = np.nan_to_num(fund) + gross = np.sum(w[:-1] * f[1:], axis=1) + turn = np.sum(np.abs(w[1:] - w[:-1]), axis=1) + net = gross - turn * (c_round / 2) + inmkt = sig.sum(1)[1:] > 0 # days actually positioned + return net, inmkt + + +def main(): + syms, days, close, qv, fund = pit_sweep.load() + T, N = fund.shape + year = (1970 + days / 365.25).astype(int) + qv30 = np.full_like(qv, np.nan) + for t in range(30, T): + qv30[t] = np.nanmean(qv[t - 30:t], axis=0) + elig = np.isfinite(qv30) & (qv30 > 5e6) & np.isfinite(fund) + + print("\n===== (1) FUNDING-YIELD DECAY by year (compression vs regime) =====") + print(f"{'year':>6} {'#coins':>7} {'frac_pos':>9} {'mean_all(bp)':>13} {'mean_pos(bp)':>13}") + for y in range(2019, 2027): + m = year == y + e = elig[m] + fy = fund[m][e] + if len(fy) < 100: + continue + ncoin = int(e.sum(1).mean()) if e.ndim == 2 else int(e.mean() * N) + fpos = float((fy > 0).mean()) + print(f"{y:>6} {ncoin:>7} {fpos:>9.2f} {1e4*fy.mean():>13.2f} {1e4*fy[fy > 0].mean():>13.2f}") + + print("\n===== (2) QUARTERLY net-harvest liveness (broad, thr=0, 10bp) =====") + net, _ = harvest(fund, elig, 0.0, 0.0010) + q = ((days[1:] - days[1]) / 91.3).astype(int) + dd = days[1:] + print(f"{'quarter':>10} {'netAPR%':>8} {'Sharpe':>7}") + for qq in sorted(set(q)): + seg = net[q == qq] + d0 = datetime.date.fromordinal(int(dd[q == qq][0]) + 719163) + if d0.year >= 2024: # focus recent + print(f"{str(d0):>10} {100 * seg.sum() * (365 / max(len(seg), 1)) / 1:>8.1f} {sharpe(seg):>7.1f}") + + print("\n===== (3) REGIME FILTER rescue test (recent: 2025-2026) =====") + recent = year[1:] >= 2025 + variants = [("unfiltered thr=0 10bp", harvest(fund, elig, 0.0, 0.0010)[0]), + ("filtered tf14>3bp 10bp", harvest(fund, elig, 0.0003, 0.0010, trail=14)[0]), + ("filtered tf14>5bp 10bp", harvest(fund, elig, 0.0005, 0.0010, trail=14)[0]), + ("filtered tf30>5bp 10bp", harvest(fund, elig, 0.0005, 0.0010, trail=30)[0])] + print(f"{'variant':>24} {'2025-26 APR%':>13} {'2025-26 Sharpe':>15} {'full Sharpe':>12}") + for nm, r in variants: + rr = r[recent] + apr = 100 * np.nansum(rr) * 365 / max(np.isfinite(rr).sum(), 1) + print(f"{nm:>24} {apr:>13.1f} {sharpe(rr):>15.1f} {sharpe(r):>12.1f}") + + print("\nVERDICT: if mean_pos(bp) stable across years but frac_pos dipped 2025-26 = REGIME (dormant, recoverable).") + print("If mean_pos shrinks monotonically = COMPRESSION (competed to death). If a filter turns 2025-26 positive") + print("= alive-but-needs-regime-timing. If even filtered 2025-26 is negative/zero = the edge is GONE now.") + + +if __name__ == "__main__": + main()