14522262e6
Reset del progetto su fondamenta verificate dopo la scoperta che l'intera libreria "validata OOS" era artefatto di feed contaminato (print fantasma del feed Cerbero TESTNET + storico Binance/USDT). - Storico ricostruito da Deribit MAINNET (ccxt pubblico, tokenless) e CERTIFICATO (certify_feed.py): BTC/ETH puliti su TUTTA la storia (mediana 2-6 bps vs Coinbase USD), integrita' OHLC + coerenza resample (maxΔ 0.00) + cross-venue OK. Alt esclusi (illiquidi/divergenti: LTC/DOGE 50-82% barre flat; XRP/BNB non certificabili). - Verdetto sul feed pulito: FADE / PAIRS / XS01 / TSM01 morti (ogni portafoglio Sharpe -2.3..-3.0, DD ~40%); solo SH01 e frammenti HONEST con segnale residuo, da ri-validare in isolamento. - Cleanup "restart pulito": strategie, stack live (src/live, src/portfolio, runner/executor, yml, docker), ~100 script ricerca/gate, waste/games/ portfolios, dati non certificati + cache e 60+ diari -> archiviati in Old/ (preservati, non cancellati). Diario consolidato in un unico documento. - Skeleton ricerca tenuto: Strategy ABC + indicatori + src/fractal + src/backtest/engine + load_data; tool dati certificati (rebuild_history, certify_feed, audit_feed, multi_source_check). - Universo dati ATTIVO: solo BTC/ETH (5m/15m/1h); guardrail fisico (load_data su alt -> FileNotFoundError). Esecuzione DISABILITATA, conto flat. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
232 lines
9.0 KiB
Python
232 lines
9.0 KiB
Python
"""FC01 — Funding-carry market-neutral (ricerca, 2026-06-10).
|
|
|
|
Idea: su Deribit i long pagano gli short quando il funding e' positivo (e
|
|
viceversa). W12 (scartata) shortava il perp su funding alto = direzionale.
|
|
Qui il meccanismo NUOVO e' il CARRY NEUTRALE: short della gamba con funding
|
|
alto / long della gamba con funding basso (BTC vs ETH, dollar-neutral),
|
|
incassando il DIFFERENZIALE di funding con esposizione residua = solo lo
|
|
spread ETH/BTC (correlazione ~0.95).
|
|
|
|
Dati REALI: data/regime/{btc,eth}_funding.parquet (orario, 2019-12 -> 2026-06,
|
|
interest_1h effettivo + index_price). Causale: decisione al close t con
|
|
funding noto fino a t; accrual dal bar t+1; fee 0.10% RT per GAMBA.
|
|
|
|
Varianti:
|
|
FC-A spread-carry 2 gambe (il candidato): entra quando lo spread di funding
|
|
smussato supera la soglia, esce quando rientra / max_bars.
|
|
FC-B single-asset carry direzionale (confronto onesto con W12): short se
|
|
funding smussato > thr, long se < -thr.
|
|
|
|
Protocollo: TRAIN fino a OOS_DATE (2023-11-01) per scegliere la config,
|
|
OOS dopo; griglia robustezza; sweep fee; breakdown annuale.
|
|
|
|
uv run python scripts/analysis/funding_carry_research.py
|
|
"""
|
|
from __future__ import annotations
|
|
|
|
import sys
|
|
from pathlib import Path
|
|
|
|
import numpy as np
|
|
import pandas as pd
|
|
|
|
PROJECT_ROOT = Path(__file__).resolve().parents[2]
|
|
sys.path.insert(0, str(PROJECT_ROOT))
|
|
|
|
FEE_RT = 0.001 # 0.10% RT per gamba (taker, baseline progetto)
|
|
OOS_DATE = "2023-11-01"
|
|
HRS_YEAR = 24 * 365
|
|
|
|
|
|
def load_panel():
|
|
btc = pd.read_parquet("data/regime/btc_funding.parquet")
|
|
eth = pd.read_parquet("data/regime/eth_funding.parquet")
|
|
for d in (btc, eth):
|
|
d["dt"] = pd.to_datetime(d["timestamp"], unit="ms")
|
|
m = btc.set_index("dt")[["interest_1h", "index_price"]].rename(
|
|
columns={"interest_1h": "f_btc", "index_price": "p_btc"}).join(
|
|
eth.set_index("dt")[["interest_1h", "index_price"]].rename(
|
|
columns={"interest_1h": "f_eth", "index_price": "p_eth"}),
|
|
how="inner").sort_index()
|
|
m = m.dropna()
|
|
return m
|
|
|
|
|
|
def explore(m):
|
|
print("=" * 96)
|
|
print(" [0] ESPLORAZIONE — funding orario reale Deribit, "
|
|
f"{m.index[0].date()} -> {m.index[-1].date()} ({len(m)} ore)")
|
|
print("=" * 96)
|
|
for a in ("btc", "eth"):
|
|
f = m[f"f_{a}"] * HRS_YEAR * 100 # annualizzato %
|
|
print(f" {a.upper()}: funding annualizzato mean {f.mean():+6.2f}% "
|
|
f"med {f.median():+6.2f}% p10 {f.quantile(.1):+7.2f}% "
|
|
f"p90 {f.quantile(.9):+7.2f}% %ore>0 {100*(f>0).mean():.0f}%")
|
|
sp = (m["f_eth"] - m["f_btc"]) * HRS_YEAR * 100
|
|
print(f" SPREAD ETH-BTC annualizzato: mean {sp.mean():+6.2f}% "
|
|
f"p10 {sp.quantile(.1):+7.2f}% p90 {sp.quantile(.9):+7.2f}%")
|
|
# persistenza: autocorr dello spread smussato 24h a vari lag
|
|
s24 = (m["f_eth"] - m["f_btc"]).rolling(24).mean()
|
|
for lag in (24, 72, 168):
|
|
c = s24.autocorr(lag)
|
|
print(f" autocorr spread(24h-smooth) lag {lag:>4}h: {c:+.3f}")
|
|
# quanto duramo sopra soglia? episodi |spread ann| > 10%
|
|
thr = 0.10 / HRS_YEAR
|
|
above = (s24.abs() > thr).astype(int)
|
|
runs = (above.groupby((above != above.shift()).cumsum()).sum())
|
|
runs = runs[runs > 0]
|
|
if len(runs):
|
|
print(f" episodi |spread|>10% ann: {len(runs)} durata mediana "
|
|
f"{runs.median():.0f}h p90 {runs.quantile(.9):.0f}h")
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Backtest FC-A: spread-carry 2 gambe
|
|
# ---------------------------------------------------------------------------
|
|
def carry_pair(m, smooth=72, thr_ann=10.0, exit_frac=0.0, max_bars=24 * 30,
|
|
fee_rt=FEE_RT, sl=None):
|
|
"""Entra quando |spread smussato| > thr (annualizzato %); short la gamba
|
|
col funding alto, long l'altra, 1x notional per gamba. Esce quando lo
|
|
spread smussato scende sotto exit_frac*thr (o cambia segno) o max_bars.
|
|
Ritorna array di net-return per trade + serie equity oraria (additiva)."""
|
|
f_sp = (m["f_eth"] - m["f_btc"]).rolling(smooth).mean().to_numpy()
|
|
fe = m["f_eth"].to_numpy()
|
|
fb = m["f_btc"].to_numpy()
|
|
pe = m["p_eth"].to_numpy()
|
|
pb = m["p_btc"].to_numpy()
|
|
n = len(m)
|
|
thr = thr_ann / 100 / HRS_YEAR
|
|
ex = exit_frac * thr
|
|
sli = m.index[:n] if sl is None else None
|
|
rets, lens, accs = [], [], []
|
|
eq = np.zeros(n)
|
|
i = smooth
|
|
while i < n - 1:
|
|
s = f_sp[i]
|
|
if not np.isfinite(s) or abs(s) <= thr:
|
|
i += 1
|
|
continue
|
|
d = -1 if s > 0 else 1 # s>0: ETH paga di piu' -> short ETH/long BTC
|
|
e_eth, e_btc = pe[i], pb[i]
|
|
acc = 0.0
|
|
j = i + 1
|
|
end = min(n - 1, i + max_bars)
|
|
while j <= end:
|
|
# accrual del funding sull'ora j: short riceve +f, long paga f
|
|
acc += (-d) * fe[j] + d * fb[j]
|
|
if abs(f_sp[j]) <= ex or np.sign(f_sp[j]) != np.sign(s):
|
|
break
|
|
j += 1
|
|
j = min(j, end)
|
|
price_leg = d * (pe[j] - e_eth) / e_eth - d * (pb[j] - e_btc) / e_btc
|
|
net = price_leg + acc - 2 * fee_rt
|
|
rets.append(net)
|
|
lens.append(j - i)
|
|
accs.append(acc)
|
|
eq[j] += net
|
|
i = j + 1
|
|
rets = np.array(rets)
|
|
eqs = pd.Series(eq, index=m.index).cumsum()
|
|
return rets, np.array(lens), np.array(accs), eqs
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Backtest FC-B: carry direzionale single-asset (confronto/W12 onesto)
|
|
# ---------------------------------------------------------------------------
|
|
def carry_single(m, asset="eth", smooth=72, thr_ann=20.0, exit_frac=0.0,
|
|
max_bars=24 * 30, fee_rt=FEE_RT):
|
|
f = m[f"f_{asset}"].rolling(smooth).mean().to_numpy()
|
|
fr = m[f"f_{asset}"].to_numpy()
|
|
p = m[f"p_{asset}"].to_numpy()
|
|
n = len(m)
|
|
thr = thr_ann / 100 / HRS_YEAR
|
|
ex = exit_frac * thr
|
|
rets = []
|
|
i = smooth
|
|
while i < n - 1:
|
|
s = f[i]
|
|
if not np.isfinite(s) or abs(s) <= thr:
|
|
i += 1
|
|
continue
|
|
d = -1 if s > 0 else 1 # funding alto -> short (incassa)
|
|
e = p[i]
|
|
acc = 0.0
|
|
j = i + 1
|
|
end = min(n - 1, i + max_bars)
|
|
while j <= end:
|
|
acc += (-d) * fr[j]
|
|
if abs(f[j]) <= ex or np.sign(f[j]) != np.sign(s):
|
|
break
|
|
j += 1
|
|
j = min(j, end)
|
|
net = d * (p[j] - e) / e + acc - fee_rt
|
|
rets.append(net)
|
|
i = j + 1
|
|
return np.array(rets)
|
|
|
|
|
|
def stats(rets, idx_len_hours, label="", lens=None, accs=None):
|
|
if len(rets) == 0:
|
|
return f" {label:<28s} 0 trade"
|
|
yrs = idx_len_hours / HRS_YEAR
|
|
pnl = rets.sum() * 100
|
|
win = (rets > 0).mean() * 100
|
|
tpy = len(rets) / yrs
|
|
sh = rets.mean() / (rets.std() + 1e-12) * np.sqrt(max(tpy, 1e-9))
|
|
extra = ""
|
|
if lens is not None and len(lens):
|
|
extra = f" | hold med {np.median(lens):.0f}h"
|
|
if accs is not None and len(accs):
|
|
extra += f" | carry quota {100*np.sum(accs)/max(np.sum(rets),1e-9):.0f}%"
|
|
return (f" {label:<28s} {len(rets):>4d} tr | win {win:>4.0f}% | "
|
|
f"PnL {pnl:>+7.1f}% | {tpy:>5.1f} tr/anno | Sh {sh:>5.2f}{extra}")
|
|
|
|
|
|
def main():
|
|
m = load_panel()
|
|
explore(m)
|
|
cut = m.index.searchsorted(pd.Timestamp(OOS_DATE))
|
|
mtr, moo = m.iloc[:cut], m.iloc[cut:]
|
|
print(f"\n TRAIN {m.index[0].date()} -> {OOS_DATE} | OOS -> {m.index[-1].date()}")
|
|
|
|
print("\n" + "=" * 96)
|
|
print(" [1] FC-A spread-carry 2 gambe (fee 0.10% RT x2 gambe) — griglia su TRAIN")
|
|
print("=" * 96)
|
|
grid = []
|
|
for smooth in (24, 72, 168):
|
|
for thr in (5.0, 10.0, 20.0):
|
|
r, ln, ac, _ = carry_pair(mtr, smooth=smooth, thr_ann=thr)
|
|
grid.append((smooth, thr, r))
|
|
print(stats(r, len(mtr), f"TRAIN s{smooth} thr{thr:.0f}%", ln, ac))
|
|
|
|
print("\n Le stesse config in OOS (mai usate per scegliere):")
|
|
for smooth in (24, 72, 168):
|
|
for thr in (5.0, 10.0, 20.0):
|
|
r, ln, ac, _ = carry_pair(moo, smooth=smooth, thr_ann=thr)
|
|
print(stats(r, len(moo), f"OOS s{smooth} thr{thr:.0f}%", ln, ac))
|
|
|
|
print("\n" + "=" * 96)
|
|
print(" [2] FC-B carry direzionale single-asset (confronto, fee 0.10% RT)")
|
|
print("=" * 96)
|
|
for a in ("btc", "eth"):
|
|
for thr in (10.0, 30.0):
|
|
rtr = carry_single(mtr, a, thr_ann=thr)
|
|
roo = carry_single(moo, a, thr_ann=thr)
|
|
print(stats(rtr, len(mtr), f"TRAIN {a} thr{thr:.0f}%"))
|
|
print(stats(roo, len(moo), f"OOS {a} thr{thr:.0f}%"))
|
|
|
|
print("\n" + "=" * 96)
|
|
print(" [3] FC-A: sweep fee (config mediana s72 thr10) e breakdown annuale")
|
|
print("=" * 96)
|
|
for fee in (0.0005, 0.001, 0.002):
|
|
r, ln, ac, _ = carry_pair(m, smooth=72, thr_ann=10.0, fee_rt=fee)
|
|
print(stats(r, len(m), f"FULL fee {fee*100:.2f}% RT/gamba", ln, ac))
|
|
_, _, _, eq = carry_pair(m, smooth=72, thr_ann=10.0)
|
|
yr = eq.groupby(eq.index.year).apply(lambda s: (s.iloc[-1] - s.iloc[0]) * 100)
|
|
print(" annuale (PnL additivo %):",
|
|
{int(k): round(float(v), 1) for k, v in yr.items()})
|
|
|
|
|
|
if __name__ == "__main__":
|
|
main()
|