"""r0701_funding_ts — FUNDING RATE come segnale TIME-SERIES direzionale su BTC/ETH (NON carry). 2026-07-01. Ipotesi: il funding orario Hyperliquid (proxy di POSIZIONAMENTO/sentiment dei perp) contiene informazione direzionale a orizzonte giornaliero su BTC/ETH. Famiglia (griglia modesta): - FADE : z-score del funding estremo-positivo = affollamento long -> SHORT (e viceversa) - FOLLOW : funding in espansione = domanda long persistente -> LONG (sentiment momentum) - GATE : trend TP01-like long-flat, FLAT quando il funding e' affollato (z>=thr, de-risk) - DIVERGE : momentum prezzo 20d con funding NON affollato -> follow; affollato -> fade Griglia: 4 forme x lookback z {7,14,30,60}g x soglia {0.5,1.0,1.5} = 48 celle, solo 1d. PRIOR ART (non ripetuto): FC01 carry cross-sectional delta-neutral -> SCARTATO (docs/diary/2026-06-22-funding-carry-hl.md); funding price-clock intraday -> FAIL (onda intraday). Qui il funding e' un SEGNALE time-series direzionale su BTC/ETH perp (2 gambe, eseguibile a ~$600), non un cashflow da incassare. DATI: data/raw/hlfund_{btc,eth}_1h.parquet (funding orario HL: 2023-05-12 -> 2026-06-22; primi ~27 giorni a cadenza 8h, poi oraria, 0 gap; certificato nel diario 2026-06-22). Prezzi certificati Deribit via altlib.get (1d resampled leak-free). CAUSALITA' (il punto delicato): le barre 1d sono OPEN-LABELED (datetime = 00:00 UTC del giorno D; il close della barra D e' noto alle 00:00 di D+1). Il feature-day D aggrega i SOLI stamp funding in [D 00:00, D+24h) — l'ultimo alle 23:00 — quindi tutto e' noto PRIMA della decisione al close della barra D. eval_weights poi shifta: target[D] e' tenuto durante la barra D+1. Nessun leak strutturale; in piu' prefix-check esplicito. VALUTAZIONE su finestra TRONCATA alla copertura funding (2023-05-12..2026-06-21), NON sul frame prezzi 2018+: fuori copertura il target sarebbe zero per costruzione e i giorni-zero (a) GONFIANO il T del deflated-Sharpe (anti-conservativo) e (b) DILUISCONO cand_insample_sharpe (gate has_insample_edge scatterebbe a vuoto). La logica di study_family_honest e' replicata ESATTAMENTE sui frame troncati coi primitivi altlib: selezione cella IN-SAMPLE-ONLY (mai sul hold-out) -> study_marginal gates (ADDS + robust_oos + has_insample_edge + not is_hedge) -> deflated-Sharpe >= 0.95 sull'INTERA griglia. Cross-check con study_marginal non-troncato riportato in coda. CAVEAT STORIA: funding solo dal 2023-05 (~3.1 anni). In-sample pre-HOLDOUT ~1.6 anni (meno il warmup z), hold-out 2025-01+ ~1.5 anni. Finestra corta: qualunque PASS andrebbe comunque in forward-monitor, e un FAIL su questa finestra non e' appellabile a "regime sfortunato". Run: cd /opt/docker/PythagorasGoal && uv run python scripts/research/r0701_funding_ts.py """ from __future__ import annotations import json import sys from functools import lru_cache from pathlib import Path import numpy as np import pandas as pd sys.path.insert(0, str(Path(__file__).resolve().parent / "alt")) import altlib as al # noqa: E402 ASSETS = ("BTC", "ETH") FORMS = ("fade", "follow", "gate", "diverge") LOOKBACKS = (7, 14, 30, 60) THRESHOLDS = (0.5, 1.0, 1.5) EARLY_8H_END = pd.Timestamp("2023-06-11", tz="UTC") # fino a qui cadenza 8h (3 stamp/giorno) # =========================================================================== # DATI FUNDING — aggregazione giornaliera causale # =========================================================================== @lru_cache(maxsize=16) def daily_funding(asset: str, back_h: int = 0) -> pd.DataFrame: """Funding giornaliero = SOMMA degli stamp orari nella finestra [D-back_h, D+24h-back_h). back_h=0 (default) = giorno UTC pieno [D, D+24h): tutti gli stamp (ultimo 23:00) sono noti al close della barra open-labeled D (= 00:00 di D+1) -> causale. back_h>0 sposta la finestra INDIETRO (sempre causale) — usato solo dal boundary-shift check. 'valid' = giorno con copertura piena (>=20 stamp orari; >=3 nell'era 8h iniziale).""" p = al.DATA_DIR / f"hlfund_{asset.lower()}_1h.parquet" d = pd.read_parquet(p) idx = pd.DatetimeIndex(pd.to_datetime(d.index, utc=True)) + pd.Timedelta(hours=back_h) day = idx.floor("1D") g = pd.Series(d["funding"].values.astype(float), index=day).groupby(level=0) out = pd.DataFrame({"fday": g.sum(), "n": g.count()}) early = out.index <= EARLY_8H_END out["valid"] = np.where(early, out["n"] >= 3, out["n"] >= 20) return out @lru_cache(maxsize=1) def fund_window() -> tuple: """Intersezione BTC/ETH dei giorni funding validi (a back_h=0).""" los, his = [], [] for a in ASSETS: v = daily_funding(a) vd = v.index[v["valid"].values] los.append(vd.min()); his.append(vd.max()) return max(los), min(his) @lru_cache(maxsize=8) def get_trunc(asset: str, tf: str = "1d") -> pd.DataFrame: """Prezzi certificati troncati alla copertura funding (vedi docstring modulo).""" lo, hi = fund_window() df = al.get(asset, tf) day = pd.DatetimeIndex(pd.to_datetime(df["datetime"], utc=True)).floor("1D") m = (day >= lo) & (day <= hi) return df.loc[m].reset_index(drop=True) def aligned_fday(df: pd.DataFrame, asset: str, back_h: int = 0) -> np.ndarray: """Funding giornaliero allineato alle barre di df (NaN dove manca/incompleto).""" fd = daily_funding(asset, back_h) day = pd.DatetimeIndex(pd.to_datetime(df["datetime"], utc=True)).floor("1D") return fd["fday"].where(fd["valid"]).reindex(day).values.astype(float) # =========================================================================== # FAMIGLIA DI SEGNALI — factory(tf, form, lb, thr) -> target_fn(df, asset) # =========================================================================== def make_target(tf: str = "1d", form: str = "fade", lb: int = 30, thr: float = 1.0, back_h: int = 0): def target(df: pd.DataFrame, asset: str) -> np.ndarray: f = aligned_fday(df, asset, back_h) z = al.zscore(f, lb) # causale: rolling fino a i incluso c = pd.Series(df["close"].values.astype(float)) if form == "fade": # affollamento long -> short (e viceversa) d = np.where(z >= thr, -1.0, np.where(z <= -thr, 1.0, 0.0)) elif form == "follow": # funding come sentiment momentum d = np.where(z >= thr, 1.0, np.where(z <= -thr, -1.0, 0.0)) elif form == "gate": # trend long-flat, flat se affollato m30 = np.nan_to_num(np.sign(c.pct_change(30).values)) m90 = np.nan_to_num(np.sign(c.pct_change(90).values)) trend = ((m30 + m90) > 0).astype(float) zz = np.where(np.isfinite(z), z, np.inf) # z ignoto -> conservativo: flat d = trend * (zz < thr).astype(float) elif form == "diverge": # mossa non affollata -> follow; affollata -> fade mom = np.nan_to_num(np.sign(c.pct_change(20).values)) d = np.where(z >= thr, -mom, np.where(z <= -thr, mom, 0.0)) else: raise ValueError(form) return al.vol_target(np.nan_to_num(d), df, 0.20, 30, 2.0) return target # =========================================================================== # VALUTAZIONE (replica study_family_honest su frame troncati) # =========================================================================== def cell_daily(target_fn, fee_side: float = al.FEE_SIDE) -> pd.Series: """Serie daily netta 50/50 BTC+ETH del candidato (convenzione candidate_daily).""" series = {} for a in ASSETS: df = get_trunc(a) ev = al.eval_weights(df, target_fn(df, a), fee_side=fee_side) series[a] = pd.Series(ev["net"], index=ev["idx"]) J = pd.concat(series, axis=1, join="inner").fillna(0.0) return al._to_daily(0.5 * J["BTC"] + 0.5 * J["ETH"]) def scan_family() -> list[dict]: rows = [] for form in FORMS: for lb in LOOKBACKS: for thr in THRESHOLDS: daily = cell_daily(make_target(form=form, lb=lb, thr=thr)) ins = daily[daily.index < al.HOLDOUT] hold = daily[daily.index >= al.HOLDOUT] rows.append(dict( form=form, lb=lb, thr=thr, insample_sharpe=round(al._sh(ins), 3) if len(ins) > 60 else float("nan"), full_sharpe=round(al._sh(daily), 3), hold_sharpe=round(al._sh(hold), 3) if len(hold) > 60 else float("nan"))) return rows def absolute_study(target_fn, name: str) -> dict: """study_weights-equivalente sui frame troncati (fee sweep 0.00-0.30% RT incluso).""" per_asset = {} fee_ok_all = True for a in ASSETS: df = get_trunc(a) tgt = target_fn(df, a) base = al.eval_weights(df, tgt, fee_side=al.FEE_SIDE) sweep = {f"{2 * f * 100:.2f}%RT": al.eval_weights(df, tgt, fee_side=f)["full"]["sharpe"] for f in al.FEE_SWEEP} fee_ok_all = fee_ok_all and sweep.get("0.20%RT", -9) > 0 per_asset[a] = dict(full=base["full"], holdout=base["holdout"], tim=base["time_in_market"], turnover=base["turnover_per_year"], fee_sweep=sweep, yearly=base["yearly"]) cells = [dict(tf="1d", per_asset=per_asset, min_asset_full_sharpe=round(min(per_asset[a]["full"]["sharpe"] for a in ASSETS), 3), min_asset_holdout_sharpe=round(min(per_asset[a]["holdout"].get("sharpe", 0.0) for a in ASSETS), 3), full_sharpe=round(float(np.mean([per_asset[a]["full"]["sharpe"] for a in ASSETS])), 3), fee_survives=fee_ok_all)] return dict(name=name, kind="weights", cells=cells, verdict=al._verdict(cells)) def prefix_check(target_fn, tail: int = 60) -> float: """Consistenza online (guardia look-ahead): il target ricalcolato su un prefisso troncato deve coincidere col target(full) sugli stessi indici. Ritorna il max scostamento.""" worst = 0.0 for a in ASSETS: df = get_trunc(a) full = np.nan_to_num(np.asarray(target_fn(df, a), float)) n = len(df) for cut in (int(n * 0.80), int(n * 0.92)): sub = df.iloc[:cut].reset_index(drop=True) s = np.nan_to_num(np.asarray(target_fn(sub, a), float)) worst = max(worst, float(np.max(np.abs(s[cut - tail:cut] - full[cut - tail:cut])))) return worst def boundary_check(form: str, lb: int, thr: float, offsets=(0, 3, 6, 9, 12)) -> dict: """Lezione day_boundary: sposto INDIETRO di back_h ore la finestra di aggregazione del funding (sempre causale). Un effetto di posizionamento reale non cambia segno.""" B = al.tp01_baseline_daily() out = {} for off in offsets: daily = cell_daily(make_target(form=form, lb=lb, thr=thr, back_h=off)) J = pd.concat({"B": B, "C": daily}, axis=1, join="inner").dropna() up = al._sh(0.75 * J["B"] + 0.25 * J["C"]) - al._sh(J["B"]) if len(J) > 30 else float("nan") out[off] = dict(full_sharpe=round(al._sh(daily), 3), uplift_w25=round(up, 3)) ups = [v["uplift_w25"] for v in out.values() if np.isfinite(v["uplift_w25"])] shs = [v["full_sharpe"] for v in out.values()] return dict(per_offset=out, sharpe_sign_stable=bool(min(shs) * max(shs) >= 0 or max(map(abs, shs)) < 0.1), uplift_spread=round(max(ups) - min(ups), 3) if ups else None) def trend_only_target(df: pd.DataFrame, asset: str = "") -> np.ndarray: """CONTROLLO DECISIVO (lezione TP01-DVOL-overlay): lo STESSO trend long-flat della forma 'gate' ma SENZA il gate funding. Se fa uguale/meglio, il funding non aggiunge nulla.""" c = pd.Series(df["close"].values.astype(float)) m30 = np.nan_to_num(np.sign(c.pct_change(30).values)) m90 = np.nan_to_num(np.sign(c.pct_change(90).values)) trend = ((m30 + m90) > 0).astype(float) return al.vol_target(trend, df, 0.20, 30, 2.0) def smallcap_check(target_fn) -> dict: out = {} for a in ASSETS: df = get_trunc(a) sc = al.eval_weights_smallcap(df, target_fn(df, a), capital=600.0, min_order=5.0) out[a] = dict(modeled_sh=sc["modeled"]["sharpe"], realistic_sh=sc["realistic"]["sharpe"], haircut=sc["sharpe_haircut"], n_trades=sc["n_executed_trades"]) return out # =========================================================================== def main(): print("=" * 88) print("r0701_funding_ts — funding HL come segnale TS direzionale su BTC/ETH (non carry)") print("=" * 88) # --- 1. data-first: qualita'/copertura -------------------------------------------- lo, hi = fund_window() print("\n[1] DATI FUNDING") for a in ASSETS: fd = daily_funding(a) v = fd["valid"] ann = fd.loc[v, "fday"].mean() * 365.25 * 100 print(f" {a}: giorni validi {int(v.sum())}/{len(fd)} " f"finestra {fd.index[0].date()} -> {fd.index[-1].date()} " f"funding medio {ann:+.1f}%/anno " f"std daily {fd.loc[v, 'fday'].std() * 1e4:.2f} bps") print(f" finestra comune valida: {lo.date()} -> {hi.date()} " f"({(hi - lo).days} giorni, ~{(hi - lo).days / 365.25:.1f} anni)") n_ins = (al.HOLDOUT - lo).days n_hold = (hi - al.HOLDOUT).days print(f" in-sample pre-HOLDOUT ~{n_ins}g ({n_ins / 365.25:.1f}a), " f"hold-out ~{n_hold}g ({n_hold / 365.25:.1f}a) <-- STORIA CORTA, caveat") # --- 2. scan famiglia (48 celle, selezione IN-SAMPLE-ONLY) ------------------------- print("\n[2] SCAN FAMIGLIA (4 forme x lb{7,14,30,60} x thr{0.5,1.0,1.5} = 48 celle, 1d)") rows = scan_family() valid = [r for r in rows if np.isfinite(r["insample_sharpe"])] valid.sort(key=lambda r: r["insample_sharpe"], reverse=True) print(f" celle valide {len(valid)}/{len(rows)}; top-8 per Sharpe IN-SAMPLE " f"(hold-out mostrato SOLO per trasparenza, mai per selezione):") print(f" {'form':8s} {'lb':>3s} {'thr':>4s} {'IS':>7s} {'FULL':>7s} {'HOLD':>7s}") for r in valid[:8]: print(f" {r['form']:8s} {r['lb']:3d} {r['thr']:4.1f} {r['insample_sharpe']:7.2f} " f"{r['full_sharpe']:7.2f} {r['hold_sharpe']:7.2f}") per_form = {f: max((r["insample_sharpe"] for r in valid if r["form"] == f), default=float("nan")) for f in FORMS} print(f" best IS per forma: {per_form}") chosen = valid[0] print(f"\n CELLA SCELTA (in-sample-only): {chosen['form']} lb={chosen['lb']} thr={chosen['thr']} " f"(IS {chosen['insample_sharpe']}, FULL {chosen['full_sharpe']}, HOLD {chosen['hold_sharpe']})") fn = make_target(form=chosen["form"], lb=chosen["lb"], thr=chosen["thr"]) daily = cell_daily(fn) # --- 3. deflated Sharpe sull'INTERA griglia ---------------------------------------- all_full = [r["full_sharpe"] for r in rows] dsr, sr0 = al.deflated_sharpe(al._sh(daily), all_full, daily) dsr_pass = bool(np.isfinite(dsr) and dsr >= 0.95) print(f"\n[3] DEFLATED SHARPE (griglia {len(rows)} celle): " f"DSR={dsr:.3f} (null-max atteso {sr0:.2f}) PASS(>=0.95)={dsr_pass}") # --- 4. assoluto + marginale (gates study_marginal) -------------------------------- print("\n[4] ASSOLUTO (frame troncati, fee sweep 0.00-0.30% RT)") absolute = absolute_study(fn, f"R0701-FUND-{chosen['form'].upper()}") print(al.fmt(absolute)) c0 = absolute["cells"][0] for a in ASSETS: pa = c0["per_asset"][a] print(f" {a}: TIM={pa['tim']} turnover/anno={pa['turnover']} fee_sweep={pa['fee_sweep']}") print("\n[5] MARGINALE vs TP01 (finestra comune col baseline)") marg = al.marginal_vs_tp01(daily) abs_grade = absolute["verdict"]["grade"] earns_slot = (abs_grade != "FAIL" and marg.get("marginal_verdict") == "ADDS" and marg.get("robust_oos", False) and marg.get("beats_noise_null", False) and not marg.get("is_hedge", False)) rep = dict(name=f"R0701-FUND {chosen['form']} lb{chosen['lb']} thr{chosen['thr']}", absolute=absolute, marginal=marg, abs_grade=abs_grade, marginal_verdict=marg.get("marginal_verdict"), earns_slot=earns_slot) print(al.fmt_marginal(rep)) earns_honest = bool(earns_slot and dsr_pass) print(f"\n EARNS_SLOT (marginal) = {earns_slot} EARNS_SLOT_HONEST (con DSR) = {earns_honest}") # --- 5bis. controllo di attribuzione: il funding aggiunge qualcosa al trend nudo? ---- print("\n[5bis] CONTROLLO DECISIVO — trend long-flat IDENTICO ma SENZA gate funding") d_tr = cell_daily(trend_only_target) tr_ins, tr_hold = d_tr[d_tr.index < al.HOLDOUT], d_tr[d_tr.index >= al.HOLDOUT] JJ = pd.concat({"G": daily, "T": d_tr}, axis=1, join="inner").dropna() print(f" trend NUDO: IS {al._sh(tr_ins):.2f} FULL {al._sh(d_tr):.2f} HOLD {al._sh(tr_hold):.2f}") print(f" trend+GATE: IS {chosen['insample_sharpe']:.2f} FULL {chosen['full_sharpe']:.2f} " f"HOLD {chosen['hold_sharpe']:.2f}") print(f" corr(gated, nudo) = {JJ['G'].corr(JJ['T']):.3f} " f"delta FULL = {al._sh(JJ['G']) - al._sh(JJ['T']):+.3f} " f"delta HOLD = {al._sh(JJ['G'][JJ.index >= al.HOLDOUT]) - al._sh(JJ['T'][JJ.index >= al.HOLDOUT]):+.3f}") attribution = dict(trend_nudo=dict(IS=round(al._sh(tr_ins), 3), FULL=round(al._sh(d_tr), 3), HOLD=round(al._sh(tr_hold), 3)), corr_gated_nudo=round(float(JJ["G"].corr(JJ["T"])), 3), delta_full=round(al._sh(JJ["G"]) - al._sh(JJ["T"]), 3), delta_hold=round(al._sh(JJ["G"][JJ.index >= al.HOLDOUT]) - al._sh(JJ["T"][JJ.index >= al.HOLDOUT]), 3)) # --- 5ter. la migliore cella PURO-funding (fade/follow/diverge, senza trend) --------- pure = [r for r in valid if r["form"] != "gate"] bp = pure[0] if pure else None if bp: print(f"\n[5ter] MIGLIOR CELLA PURO-FUNDING (no trend): {bp['form']} lb={bp['lb']} " f"thr={bp['thr']} IS {bp['insample_sharpe']} FULL {bp['full_sharpe']} " f"HOLD {bp['hold_sharpe']}") d_bp = cell_daily(make_target(form=bp["form"], lb=bp["lb"], thr=bp["thr"])) m_bp = al.marginal_vs_tp01(d_bp) print(f" marginale vs TP01: {m_bp.get('marginal_verdict')} corr {m_bp.get('corr_full')} " f"IS-edge {m_bp.get('cand_insample_sharpe')} " f"uplift w25 full {m_bp['blends']['w25']['uplift_full']:+.3f} / " f"hold {m_bp['blends']['w25']['uplift_hold']:+.3f}") # --- 6. realism: prefix / boundary / smallcap --------------------------------------- print("\n[6] REALISM CHECKS (cella scelta)") worst = prefix_check(fn) print(f" prefix-consistency (guardia look-ahead): max diff = {worst:.2e} " f"({'OK' if worst < 1e-9 else 'ATTENZIONE'})") bnd = boundary_check(chosen["form"], chosen["lb"], chosen["thr"]) print(f" boundary-shift (finestra funding -0/3/6/9/12h): {bnd['per_offset']}") print(f" sharpe_sign_stable={bnd['sharpe_sign_stable']} uplift_spread={bnd['uplift_spread']}") sc = smallcap_check(fn) print(f" smallcap $600 (min order $5): {sc}") # --- 7. cross-check non troncato (footnote) ----------------------------------------- print("\n[7] CROSS-CHECK study_marginal NON troncato (frame 2018+, diluito dagli zeri " "pre-copertura: footnote, non il giudizio primario)") sm_full = al.study_marginal(f"R0701-FUND-XCHK {chosen['form']}", fn, tf="1d") print(f" abs={sm_full['abs_grade']} marginal={sm_full['marginal_verdict']} " f"earns_slot={sm_full['earns_slot']}") # --- 8. verdetto -------------------------------------------------------------------- summary = dict( chosen=dict(form=chosen["form"], lb=chosen["lb"], thr=chosen["thr"]), insample_sharpe=chosen["insample_sharpe"], full_sharpe=chosen["full_sharpe"], hold_sharpe=chosen["hold_sharpe"], dsr=round(float(dsr), 3), dsr_pass=dsr_pass, abs_grade=abs_grade, marginal_verdict=marg.get("marginal_verdict"), corr_tp01_full=marg.get("corr_full"), cand_insample_sharpe=marg.get("cand_insample_sharpe"), has_insample_edge=marg.get("has_insample_edge"), is_hedge=marg.get("is_hedge"), robust_oos=marg.get("robust_oos"), multicut=marg.get("multicut_uplift"), earns_slot=earns_slot, earns_slot_honest=earns_honest, smallcap=sc, boundary_uplift_spread=bnd["uplift_spread"], attribution_vs_trend_nudo=attribution, best_pure_funding=(dict(form=bp["form"], lb=bp["lb"], thr=bp["thr"], IS=bp["insample_sharpe"], FULL=bp["full_sharpe"], HOLD=bp["hold_sharpe"]) if bp else None), n_cells=len(rows), history_years=round((hi - lo).days / 365.25, 1)) print("\n[8] SUMMARY JSON") print(json.dumps(summary, default=str)) return summary if __name__ == "__main__": main()