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