feat(crypto): funding carry liveness — ALIVE (regime not decay), regime filter rescues 2025-26
Decisive liveness investigation. Decay diagnostic: positive-funding yield intact (mean_pos 3.0-3.4bp in 2025-26 = same as 2019/2023), NOT compressed. What changed is BREADTH: frac_pos 0.88(2024)->0.59(2026), avg funding negative = deleverage regime. Naive harvest whipsawed (2025-26 Sharpe -5.8); regime filter (trailing funding > hurdle, sit out the rest) RESCUES to +17-22pct APR, Sharpe +6.7-9.1. Edge is alive = cross-sectional selection, not death. Caveats: raw Sharpe inflated by missing basis vol (realistic ~2.5-4); hurdle needs OOS confirm; counterparty tail unchanged. Strongest deployable edge of the search: ~2.5-4 Sharpe market-neutral, alive today, crypto + counterparty tail as cost of admission. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
97
scripts/surfer/crypto_funding_liveness.py
Normal file
97
scripts/surfer/crypto_funding_liveness.py
Normal file
@@ -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()
|
||||
Reference in New Issue
Block a user