69e3517f1b
Provato l'angolo basis/cash-and-carry (l'altro proposto; il funding cross-sectional FC01 era gia' SCARTATO 2026-06-22). Delta-neutral long-spot/short-perp -> r=funding, zero esposizione prezzo. Premio di funding REALE (~+8-14%/anno aggregato, positivo ogni anno in-sample, ortogonale a TP01). MA Sharpe modellato 11-13 = ARTEFATTO: rischi di coda fuori dal dataset (manca il 2022; procyclico +23% 2024 -> +1.7% 2026; liquidazione/slippage non modellati). Il mark-to-market della base (premium col) sgonfia solo 13->11 -> il basis-from-data non e' il rischio vero. NON eseguibile a $600 (spot+perp, funding HL non Deribit) -> STAT-MODE. LEAD da rivedere a scala, non uno sleeve. Sottoprodotto: CC01 passa ogni gate del marginal scorer -> punto cieco (manca un gate 'Sharpe implausibile -> rischio nascosto'). - scripts/research/cash_carry_hl.py (CC-static/gated, BTC-ETH + 19-major, basis MtM, reality-check) - tests/test_cash_carry.py (lock del fatto economico, non dello Sharpe) - docs/diary/2026-06-26-cash-carry-hl.md - CLAUDE.md: riga nella sintesi di ricerca Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
174 lines
8.6 KiB
Python
174 lines
8.6 KiB
Python
"""CC01 — CASH-AND-CARRY (basis trade) delta-neutral su Hyperliquid. Backtest onesto, STAT-MODE.
|
|
|
|
DIVERSO da FC01 (funding cross-sectional, gia' SCARTATO 2026-06-22). Qui NON si pickano
|
|
vincitori/perdenti cross-section: si HARVESTA il premio di funding AGGREGATO restando
|
|
delta-neutral sullo STESSO asset.
|
|
|
|
MECCANISMO. Long spot + short perp (stesso asset, stessa size):
|
|
long spot -> +price_ret
|
|
short perp -> -price_ret + funding (lo short INCASSA il funding quando f>0)
|
|
netto -> +funding (il prezzo si cancella -> zero esposizione direzionale)
|
|
Il ritorno della gamba delta-neutral = il funding realizzato, meno fee. Entrambe le gambe sono
|
|
lineari nel prezzo => restano matchate in nozionale senza ribilanciare (delta ~neutro da solo);
|
|
i costi reali sono entry/exit + spread, modellati come drag annuo fisso.
|
|
|
|
VARIANTI:
|
|
CC-static -> sempre long-spot/short-perp (basis trade classico): r = funding. Perde se f<0.
|
|
CC-gated -> harvest solo quando il funding trailing CAUSALE > 0 (esci dai regimi a funding
|
|
negativo invece di pagarli). r = funding se trail>0 else 0.
|
|
UNIVERSI: BTC/ETH (sottoinsieme rilevante per l'esecuzione) e i 19 major (basket pieno).
|
|
|
|
GIUDIZIO: standalone (Sharpe/DD/anni) + marginal_vs_tp01. CAVEAT ONESTI (pre-risultato):
|
|
- NON eseguibile a $600: serve spot+perp per gamba (BTC/ETH = 4 posizioni; 19 = 38). Su Deribit
|
|
lo storico funding e' bloccato e non operiamo spot HL -> STAT-MODE.
|
|
- Il modello "r=funding" IGNORA il rischio di base (perp != spot), la liquidazione dello short in
|
|
uno squeeze, e l'inversione brusca del funding. La vol modellata SOTTOSTIMA la coda.
|
|
- Lo storico funding parte 2023-05 -> NON contiene il deleveraging 2022 (il regime peggiore per il
|
|
carry). Edge potenzialmente sovrastimato.
|
|
|
|
uv run python scripts/research/cash_carry_hl.py
|
|
"""
|
|
from __future__ import annotations
|
|
import sys
|
|
from pathlib import Path
|
|
|
|
import numpy as np
|
|
import pandas as pd
|
|
|
|
ROOT = Path(__file__).resolve().parents[2]
|
|
sys.path.insert(0, str(ROOT))
|
|
sys.path.insert(0, str(ROOT / "scripts" / "research" / "alt"))
|
|
|
|
from src.portfolio.sleeves import XS_UNIVERSE, _HL_DIR
|
|
from altlib import marginal_vs_tp01 # type: ignore
|
|
|
|
SQ365 = np.sqrt(365.25)
|
|
ANNUAL_COST = 0.02 # drag annuo fisso: entry/exit + spread + borrow (generoso per un hold continuo)
|
|
|
|
|
|
def load_funding_panel(universe):
|
|
"""FUND, PREM [date x asset]: funding giornaliero (somma oraria) e premium/basis (ultimo del
|
|
giorno) per gli asset con dati. PREM serve a iniettare il mark-to-market della BASE (perp!=spot)."""
|
|
fund, prem = {}, {}
|
|
for sym in universe:
|
|
fp = _HL_DIR / f"hlfund_{sym.lower()}_1h.parquet"
|
|
if not fp.exists():
|
|
continue
|
|
df = pd.read_parquet(fp)
|
|
fund[sym] = df["funding"].resample("1D").sum()
|
|
prem[sym] = df["premium"].resample("1D").last()
|
|
FUND = pd.concat(fund, axis=1).sort_index()
|
|
PREM = pd.concat(prem, axis=1).sort_index().reindex(FUND.index)
|
|
if FUND.index.tz is None:
|
|
FUND.index = FUND.index.tz_localize("UTC"); PREM.index = PREM.index.tz_localize("UTC")
|
|
return FUND, PREM
|
|
|
|
|
|
def cc_returns(universe, mode="static", trail=14, cost=ANNUAL_COST, with_basis=False) -> pd.Series:
|
|
"""Ritorno giornaliero del basket cash-and-carry equal-weight, netto drag annuo.
|
|
with_basis=True inietta il mark-to-market reale della base: r = funding - Δpremium (lo short
|
|
perp marca contro l'allargamento del basis). E' il rischio che 'r=funding' nasconde."""
|
|
FUND, PREM = load_funding_panel(universe)
|
|
daily_cost = cost / 365.25
|
|
leg_raw = FUND - PREM.diff() if with_basis else FUND # per-asset daily mark
|
|
if mode == "gated":
|
|
sig = FUND.rolling(trail, min_periods=trail // 2).mean().shift(1) # funding trailing causale
|
|
active = (sig > 0).astype(float)
|
|
n_active = active.sum(axis=1).replace(0, np.nan)
|
|
gross = ((leg_raw * active).sum(axis=1) / n_active).fillna(0.0)
|
|
drag = (active.sum(axis=1) > 0).astype(float) * daily_cost
|
|
else:
|
|
gross = leg_raw.mean(axis=1)
|
|
drag = daily_cost
|
|
return (gross - drag).dropna()
|
|
|
|
|
|
def metrics(daily: pd.Series) -> dict:
|
|
r = daily.values
|
|
sh = float(np.mean(r) / np.std(r) * SQ365) if np.std(r) > 0 else 0.0
|
|
eq = np.cumprod(1.0 + np.clip(r, -0.99, None))
|
|
pk = np.maximum.accumulate(eq)
|
|
dd = float(np.max((pk - eq) / pk)) if len(eq) else 0.0
|
|
yrs = (daily.index[-1] - daily.index[0]).days / 365.25 if len(daily) > 1 else 1.0
|
|
cagr = eq[-1] ** (1 / yrs) - 1 if yrs > 0 and len(eq) and eq[-1] > 0 else -1.0
|
|
s = pd.Series(eq, index=daily.index)
|
|
yearly = {}
|
|
for y, g in s.groupby(s.index.year):
|
|
if len(g) > 1:
|
|
v = g.values; p = np.maximum.accumulate(v)
|
|
yearly[int(y)] = (float(g.iloc[-1] / g.iloc[0] - 1), float(np.max((p - v) / p)))
|
|
return dict(sharpe=sh, dd=dd, cagr=cagr, yearly=yearly, gross_ann=float(np.mean(r) * 365.25))
|
|
|
|
|
|
def main():
|
|
print("=" * 92)
|
|
print(" CC01 — CASH-AND-CARRY (basis trade) delta-neutral su Hyperliquid")
|
|
print(" long spot + short perp -> r = funding (zero esposizione prezzo). Netto drag 2%/anno.")
|
|
print("=" * 92)
|
|
|
|
configs = [
|
|
("BTC/ETH CC-static", ["BTC", "ETH"], "static"),
|
|
("BTC/ETH CC-gated", ["BTC", "ETH"], "gated"),
|
|
("19-major CC-static", XS_UNIVERSE, "static"),
|
|
("19-major CC-gated", XS_UNIVERSE, "gated"),
|
|
]
|
|
print("\n [A] MODELLO INGENUO (r = funding) — IGNORA il rischio di base:")
|
|
series = {}
|
|
for label, uni, mode in configs:
|
|
r = cc_returns(uni, mode=mode)
|
|
series[label] = r
|
|
m = metrics(r)
|
|
ys = " ".join(f"{y}:{p*100:+.0f}%" for y, (p, d) in sorted(m['yearly'].items()))
|
|
print(f"\n --- {label} --- ({r.index[0].date()} -> {r.index[-1].date()}, {len(r)}g)")
|
|
print(f" Sharpe {m['sharpe']:+.2f} CAGR {m['cagr']*100:+.1f}% maxDD {m['dd']*100:.1f}% "
|
|
f"carry lordo {m['gross_ann']*100:+.1f}%/anno | per-anno: {ys}")
|
|
|
|
print("\n [B] CON MARK-TO-MARKET DELLA BASE (r = funding - Δpremium) — il rischio nascosto:")
|
|
series_b = {}
|
|
for label, uni, mode in configs:
|
|
r = cc_returns(uni, mode=mode, with_basis=True)
|
|
series_b[label] = r
|
|
m = metrics(r)
|
|
ys = " ".join(f"{y}:{p*100:+.0f}%" for y, (p, d) in sorted(m['yearly'].items()))
|
|
print(f"\n --- {label} (basis) ---")
|
|
print(f" Sharpe {m['sharpe']:+.2f} CAGR {m['cagr']*100:+.1f}% maxDD {m['dd']*100:.1f}% "
|
|
f"| per-anno: {ys}")
|
|
|
|
print("\n" + "=" * 92)
|
|
print(" REALITY CHECK — perche' uno Sharpe 11-13 e' un ARTEFATTO, non un edge")
|
|
print("=" * 92)
|
|
FUND, _ = load_funding_panel(["BTC", "ETH"])
|
|
agg = FUND.mean(axis=1)
|
|
neg = float((agg < 0).mean())
|
|
by_year = agg.groupby(agg.index.year).apply(lambda x: float(x.sum()))
|
|
print(f" funding aggregato BTC/ETH: giorni a funding NEGATIVO {neg*100:.0f}% | "
|
|
f"per-anno (somma): " + " ".join(f"{y}:{v*100:+.1f}%" for y, v in by_year.items()))
|
|
print(" -> il carry e' PROCYCLICO: +23% nel toro 2024, ~+1% nel 2026 (si comprime nel bear).")
|
|
print(" -> lo storico funding parte 2023-05: ASSENTE il deleveraging 2022 (funding -, basis blow-out),")
|
|
print(" il regime che farebbe il vero drawdown. + assenti: liquidazione short in squeeze, slippage")
|
|
print(" su spot+perp a $600. Sharpe reale di un basis-trade ~1-3 con code brusche, NON 13.")
|
|
|
|
print("\n" + "=" * 92)
|
|
print(" SCORING MARGINALE vs TP01 (sul modello ONESTO con rischio di base [B])")
|
|
print(" NB: ADDS/robust_oos qui ESPONGONO un punto cieco dello scorer — si fida della vol")
|
|
print(" riportata e non ha un gate 'Sharpe implausibile -> rischio nascosto'.")
|
|
print("=" * 92)
|
|
for label, r in series_b.items():
|
|
if r.std() == 0:
|
|
print(f"\n[{label}] flat — skip"); continue
|
|
m = marginal_vs_tp01(r)
|
|
b = m.get("blends", {}).get("w25", {})
|
|
print(f"\n[{label}]")
|
|
print(f" verdict={m.get('marginal_verdict')} corr->TP01 full={m.get('corr_full')} "
|
|
f"hold={m.get('corr_hold')} is_hedge={m.get('is_hedge')} "
|
|
f"has_insample_edge={m.get('has_insample_edge')} (cand IS Sharpe {m.get('cand_insample_sharpe')})")
|
|
print(f" cand Sharpe full={m.get('cand_full_sharpe')} hold={m.get('cand_hold_sharpe')} | "
|
|
f"blend25 full {b.get('full')} (upl {b.get('uplift_full')}) "
|
|
f"hold {b.get('hold')} (upl {b.get('uplift_hold')}) DD {b.get('dd')}")
|
|
print(f" multicut persistente={m.get('multicut_persistent')} robust_oos={m.get('robust_oos')} "
|
|
f"hedge-check up/down {m.get('uplift_tp01_up')}/{m.get('uplift_tp01_down')}")
|
|
|
|
|
|
if __name__ == "__main__":
|
|
main()
|