61180637eb
Onda "nuova ricerca mirata". Unico meccanismo non coperto dalle 2 ondate: carry da funding (cashflow perp, delta-neutral). Scan dati: price-clock gia' FAIL (intraday), Deribit ccxt 0 righe, Cerbero solo candele -> fonte = API pubblica Hyperliquid. - fetch_hl_funding.py: 19 major, funding orario reale dal 2023-05, certificato (0 gap, cov 98-100%, ann +1.0% APT .. +21.6% NEAR). backoff anti-429. - funding_carry_hl.py: book dollar-neutral short-alto-funding/long-basso, causale come XS01, vol-target 20%, fee 0.05%/lato. Giudizio: marginal_vs_tp01 indurito + overlap XS01. VERDETTO: il premio esiste (carry >> anti) ma il book NON regge il gauntlet. FULL -0.12, HOLD -0.50, DILUTES vs TP01, in-sample edge <0.5, no multicut. Jackknife universo: FULL oscilla [-0.39,+0.30] togliendo UN asset -> FRAGILE/overfit. (preview a 17 asset era +0.62 ADDS: fortuna, mancavano NEAR/AAVE). corr XS01 -0.19 (ortogonale, non re-skin). Meccanismo: carry-vs-momentum, gli alto-funding pompano. -> NON entra in portafoglio, fetcher NON in cron. Diario completo. Infra IB (thread parallelo): gateway paper gnzsnz/ib-gateway (127.0.0.1:4002, READ_ONLY) in docker-compose + ib_probe.py. Esito dati basis CME micro: backtest NON fattibile (ContFuture back-adjusted, scaduti=1 barra). IB ok per esecuzione/forward, non ricerca. .env.ibgw gitignored (credenziali paper), template in .env.ibgw.example. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
171 lines
8.8 KiB
Python
171 lines
8.8 KiB
Python
"""FC01 — Funding-CARRY cross-sectional su Hyperliquid (backtest onesto, STAT-MODE).
|
|
|
|
IPOTESI. Il funding dei perp e' un CASHFLOW (long pagano short quando f>0). Un book
|
|
dollar-neutral che VENDE i perp ad alto funding e COMPRA quelli a basso funding INCASSA
|
|
il premio di funding (carry). E' una fonte di ritorno DIVERSA dal trend (TP01) e — forse —
|
|
dal momentum cross-sectional (XS01). Domanda chiave: e' un edge reale o solo XS01 travestito?
|
|
(gli asset ad alto funding sono spesso quelli pompati = forti = quelli che XS01 COMPRA; qui li
|
|
SHORTIAMO -> potenziale ANTI-correlazione con XS01, oppure il carry domina).
|
|
|
|
DATI (certificati). funding orario reale HL (scripts/research/fetch_hl_funding.py, dal 2023-05) +
|
|
prezzi HL 1d (data/raw/hl_*_1d.parquet, gli stessi di XS01). Universo = i 19 major di XS01.
|
|
|
|
COSTRUZIONE (causale, come XS01). Ogni H giorni: segnale = media causale del funding giornaliero
|
|
realizzato sugli ultimi L giorni (solo dati <= i-1). Rank cross-section; SHORT i k ad alto funding,
|
|
LONG i k a basso (dollar-neutral). Ritorno del perp per un LONG = price_ret - funding (chi e' long
|
|
paga il funding); quindi r_book[i] = sum_a w[i-1,a] * (price_ret[i,a] - funding_realizzato[i,a]),
|
|
meno fee sul turnover (0.05%/lato, come XS01), poi vol-target 20%.
|
|
|
|
GIUDIZIO (metodologia indurita). Standalone (FULL/in-sample/hold-out, DD, anni) + correlazione a
|
|
TP01/XS01/VRP01 + marginal_vs_tp01 (gate: edge in-sample>=0.5, persistenza multi-cut, hedge-vs-alpha,
|
|
noise-null) + UPLIFT vs XS01 (la domanda di overlap) + plateau su L/k/direzione.
|
|
|
|
CAVEAT ONESTO PRE-RISULTATO: e' market-neutral a 10 gambe -> NON eseguibile a $600 (STAT-MODE come
|
|
XS01/VRP01). Il deliverable e' "esiste un carry reale e ORTOGONALE a XS01?", non un deploy.
|
|
"""
|
|
import sys
|
|
from pathlib import Path
|
|
import numpy as np, 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, _xsec_returns, _vrp_combo_returns, _HL_DIR
|
|
import altlib as L
|
|
from altlib import _sh, _dd_ret, _to_daily, _uplift_series, HOLDOUT, marginal_vs_tp01
|
|
|
|
FEE_SIDE = 0.0005 # 0.10% RT / 2, come XS01
|
|
|
|
|
|
def _load_panel():
|
|
"""Ritorna (PX, FUND): due DataFrame daily allineati [date x asset] con prezzo e funding
|
|
giornaliero realizzato (somma oraria). Solo asset con ENTRAMBI i dati."""
|
|
px, fund = {}, {}
|
|
for sym in XS_UNIVERSE:
|
|
pp = _HL_DIR / f"hl_{sym.lower()}_1d.parquet"
|
|
fp = _HL_DIR / f"hlfund_{sym.lower()}_1h.parquet"
|
|
if not (pp.exists() and fp.exists()):
|
|
continue
|
|
dp = pd.read_parquet(pp)
|
|
px[sym] = pd.Series(dp["close"].values.astype(float),
|
|
index=pd.to_datetime(dp["timestamp"], unit="ms", utc=True)).resample("1D").last()
|
|
df = pd.read_parquet(fp) # index ts (orario, utc), col funding
|
|
fund[sym] = df["funding"].resample("1D").sum()
|
|
PX = pd.concat(px, axis=1).sort_index()
|
|
FUND = pd.concat(fund, axis=1).sort_index().reindex(PX.index)
|
|
# tieni solo le date con prezzi per tutti + funding noto (0 dove manca un giorno isolato)
|
|
common = PX.dropna(how="any").index.intersection(FUND.index)
|
|
return PX.loc[common], FUND.loc[common].fillna(0.0)
|
|
|
|
|
|
def carry_returns(L_lb=7, H=10, k=5, direction="carry", target_vol=0.20,
|
|
fee_side=FEE_SIDE) -> pd.Series:
|
|
"""Serie daily netta del book funding-carry cross-sectional. direction='carry' shorta l'alto
|
|
funding (incassa il premio); 'anti' lo compra."""
|
|
PX, FUND = _load_panel()
|
|
idx = PX.index
|
|
px = PX.values; fnd = FUND.values
|
|
n, A = px.shape
|
|
dret = np.vstack([np.zeros(A), px[1:] / px[:-1] - 1.0])
|
|
# segnale = media causale del funding giornaliero sugli ultimi L giorni (shiftato di 1)
|
|
sig = pd.DataFrame(fnd, index=idx).rolling(L_lb, min_periods=max(3, L_lb // 2)).mean().shift(1).values
|
|
W = np.zeros((n, A)); w = np.zeros(A)
|
|
for i in range(n):
|
|
if i >= L_lb + 1 and i % H == 0 and np.isfinite(sig[i]).sum() >= 2 * k:
|
|
s = sig[i].copy()
|
|
order = np.argsort(np.where(np.isfinite(s), s, np.nan))
|
|
order = order[np.isfinite(s[order])]
|
|
if len(order) >= 2 * k:
|
|
w = np.zeros(A)
|
|
lo, hi = order[:k], order[-k:] # lo=basso funding, hi=alto funding
|
|
if direction == "carry":
|
|
w[hi] = -0.5 / k; w[lo] = +0.5 / k # SHORT alto funding (incassa), LONG basso
|
|
else:
|
|
w[hi] = +0.5 / k; w[lo] = -0.5 / k
|
|
W[i] = w
|
|
# ritorno del perp per un LONG = price_ret - funding realizzato
|
|
perp_ret = dret - fnd
|
|
gross = np.zeros(n); gross[1:] = np.sum(W[:-1] * perp_ret[1:], axis=1)
|
|
turn = np.zeros(n); turn[0] = np.abs(W[0]).sum(); turn[1:] = np.abs(np.diff(W, axis=0)).sum(axis=1)
|
|
net = gross - turn * fee_side
|
|
s = pd.Series(net, index=idx)
|
|
rv = s.rolling(30, min_periods=15).std().shift(1) * np.sqrt(365.25)
|
|
scale = np.clip(np.nan_to_num(target_vol / rv.replace(0, np.nan).values, nan=0.0), 0, 3.0)
|
|
return pd.Series(s.values * scale, index=idx)
|
|
|
|
|
|
def _stats(s: pd.Series) -> dict:
|
|
s = _to_daily(s)
|
|
h = s[s.index >= HOLDOUT]; isamp = s[s.index < HOLDOUT]
|
|
yrs = {y: round(_sh(s[s.index.year == y]), 2) for y in sorted(set(s.index.year))}
|
|
return dict(n=len(s), full=round(_sh(s), 2), insample=round(_sh(isamp), 2) if len(isamp) > 30 else None,
|
|
hold=round(_sh(h), 2) if len(h) > 30 else None, dd=round(_dd_ret(s), 3),
|
|
ann_ret=round(float(s.mean() * 365.25), 3), yearly=yrs)
|
|
|
|
|
|
def main():
|
|
print("=" * 96)
|
|
print(" FC01 — FUNDING-CARRY cross-sectional su Hyperliquid (STAT-MODE)")
|
|
print("=" * 96)
|
|
PX, FUND = _load_panel()
|
|
print(f" panel: {PX.shape[1]} asset x {len(PX)} giorni [{PX.index[0].date()} .. {PX.index[-1].date()}]")
|
|
print(f" asset: {list(PX.columns)}")
|
|
# funding medio annualizzato per asset (dispersione = materia prima del carry)
|
|
fann = (FUND.mean() * 365.25 * 100).round(1).sort_values(ascending=False)
|
|
print(f" funding annualizzato% (carry teorico long-pays): "
|
|
f"max {fann.index[0]} {fann.iloc[0]:+.1f} min {fann.index[-1]} {fann.iloc[-1]:+.1f} "
|
|
f"mediana {fann.median():+.1f} spread {fann.iloc[0]-fann.iloc[-1]:.1f}")
|
|
|
|
base = carry_returns()
|
|
print("\n --- STANDALONE (config base L=7 H=10 k=5, direction=carry) ---")
|
|
st = _stats(base)
|
|
print(f" FULL Sh {st['full']} in-sample {st['insample']} HOLD {st['hold']} DD {st['dd']*100:.1f}% "
|
|
f"ann.ret {st['ann_ret']*100:+.1f}% ({st['n']}g)")
|
|
print(f" per anno: {st['yearly']}")
|
|
|
|
# correlazioni vs gli sleeve attivi
|
|
print("\n --- CORRELAZIONE vs sleeve attivi (daily, overlap comune) ---")
|
|
refs = {"TP01": L.tp01_baseline_daily(), "XS01": _to_daily(_xsec_returns()),
|
|
"VRP01": _to_daily(_vrp_combo_returns())}
|
|
bd = _to_daily(base)
|
|
for nm, r in refs.items():
|
|
J = pd.concat({"c": bd, "r": r}, axis=1, join="inner").dropna()
|
|
c = round(float(J["c"].corr(J["r"])), 3) if len(J) > 30 else None
|
|
print(f" corr(FC01, {nm}) = {c} (overlap {len(J)}g)")
|
|
|
|
# marginal vs TP01 (verdetto indurito completo)
|
|
print("\n --- MARGINAL vs TP01 (scorer indurito) ---")
|
|
m = marginal_vs_tp01(bd)
|
|
for key in ("marginal_verdict", "corr_full", "cand_full_sharpe", "cand_insample_sharpe",
|
|
"has_insample_edge", "multicut_persistent", "is_hedge", "null_pctl_insample"):
|
|
print(f" {key:22} = {m.get(key)}")
|
|
print(f" blend w25: {m.get('blends', {}).get('w25')}")
|
|
print(f" multicut_uplift: {m.get('multicut_uplift')}")
|
|
|
|
# UPLIFT vs XS01 (la domanda di overlap): XS01 da solo vs blend(XS01, FC01)
|
|
print("\n --- UPLIFT vs XS01 (aggiunge a XS01 o e' ridondante?) ---")
|
|
xs = refs["XS01"]
|
|
J = pd.concat({"xs": xs, "fc": bd}, axis=1, join="inner").dropna()
|
|
JH = J[J.index >= HOLDOUT]
|
|
for w in (0.25, 0.5):
|
|
bf = _sh((1 - w) * J["xs"] + w * J["fc"]) - _sh(J["xs"])
|
|
bh = (_sh((1 - w) * JH["xs"] + w * JH["fc"]) - _sh(JH["xs"])) if len(JH) > 30 else None
|
|
print(f" w={w}: uplift FULL {bf:+.3f} HOLD {bh:+.3f}" if bh is not None
|
|
else f" w={w}: uplift FULL {bf:+.3f}")
|
|
print(f" XS01 standalone: FULL {_sh(J['xs']):.2f} | FC01 standalone su overlap: {_sh(J['fc']):.2f}")
|
|
|
|
# PLATEAU su L, k, direzione
|
|
print("\n --- PLATEAU (FULL / in-sample / HOLD Sharpe) ---")
|
|
print(f" {'cfg':22} {'FULL':>6} {'in-s':>6} {'HOLD':>6} {'DD%':>6}")
|
|
for direction in ("carry", "anti"):
|
|
for Llb in (3, 7, 14, 30):
|
|
for k in (3, 5):
|
|
s = _stats(carry_returns(L_lb=Llb, k=k, direction=direction))
|
|
tag = f"{direction} L={Llb} k={k}"
|
|
print(f" {tag:22} {str(s['full']):>6} {str(s['insample']):>6} {str(s['hold']):>6} {s['dd']*100:>5.1f}")
|
|
|
|
|
|
if __name__ == "__main__":
|
|
main()
|