"""HARNESS DI RICERCA ONESTO — BTC/ETH, v2.0.0 (Fase 0). Dopo che l'intera libreria precedente si è rivelata artefatto di feed/harness disonesti, la prima cosa di cui fidarsi NON è una strategia ma il banco di prova. Questo modulo è quel banco: causale per costruzione, netto fee, con baseline e null model. MODELLO CANONICO = SERIE DI POSIZIONE. Una strategia è una funzione signal(df, **params) -> pd.Series/np.array che dà la posizione target per barra in [-1, +1]. REGOLA: position[i] è decisa con dati FINO a close[i] (mai oltre) e GUADAGNA il rendimento close[i] -> close[i+1]. L'engine moltiplica position[i] * fwd[i] (fwd strettamente futuro rispetto alla decisione) -> niente look-ahead per costruzione, e niente fill sull'estremo di candela (si entra al close). La fee è addebitata sul TURNOVER |Δposition| (un round-trip 0->1->0 = 2 unità = fee_rt intera). GATE (vedi CLAUDE.md): ingresso eseguibile (qui per costruzione), netto fee 0.10% RT, OOS held-out, robustezza su griglia, onestà statistica (null model + buy&hold), walk-forward per i modelli fittati, liquidità (BTC/ETH ok). uv run python scripts/analysis/research_lab.py # self-test del banco """ from __future__ import annotations import sys from dataclasses import dataclass from pathlib import Path PROJECT_ROOT = Path(__file__).resolve().parents[2] sys.path.insert(0, str(PROJECT_ROOT)) import numpy as np import pandas as pd from src.data.downloader import load_data FEE_RT = 0.001 # 0.10% round-trip taker Deribit (0.05%/lato) BARS_PER_YEAR = {"5m": 105192.0, "15m": 35064.0, "1h": 8766.0, "4h": 2191.5, "12h": 730.5, "1d": 365.25} def load_tf(asset: str, tf: str): """Carica un TF certificato. 5m/15m/1h diretti; 4h/12h/1d DERIVATI per resample dal 1h (confini 00:00 UTC). >=12h e' il regime raccomandato (sotto, costi+overfit dominano).""" if tf in ("5m", "15m", "1h"): return load_data(asset, tf) rule = {"4h": "4h", "12h": "12h", "1d": "1D"}[tf] df = load_data(asset, "1h").copy() df.index = pd.to_datetime(df["timestamp"], unit="ms", utc=True) out = df.resample(rule, label="left", closed="left").agg( {"open": "first", "high": "max", "low": "min", "close": "last", "volume": "sum"}).dropna(subset=["open"]) epoch = pd.Timestamp("1970-01-01", tz="UTC") out["timestamp"] = ((out.index - epoch) // pd.Timedelta(milliseconds=1)).astype("int64") return out.reset_index(drop=True)[["timestamp", "open", "high", "low", "close", "volume"]] # Hold-out FINALE bloccato: NIENTE ricerca/tuning lo tocca finché non è il verdetto (Fase 3). HOLDOUT_START = "2025-01-01" # Finestra di validazione OOS usata in ricerca (out-of-sample ma PRE hold-out). VAL_START = "2023-01-01" def ts(df) -> pd.Series: return pd.to_datetime(df["timestamp"], unit="ms", utc=True) def window_mask(df, lo: str | None = None, hi: str | None = None) -> np.ndarray: t = ts(df) m = np.ones(len(df), bool) if lo is not None: m &= (t >= pd.Timestamp(lo, tz="UTC")).values if hi is not None: m &= (t < pd.Timestamp(hi, tz="UTC")).values return m @dataclass class BT: n: int ret: float # rendimento composto sulla finestra (pos 1x, leva 1x) cagr: float sharpe: float # annualizzato maxdd: float # % (positivo) exposure: float # |pos| medio turnover: float # Σ|Δpos| / anno ntrades: float # round-trip equivalenti / anno def line(self, label="") -> str: return (f" {label:<22s} Sh {self.sharpe:>6.2f} | ret {self.ret*100:>+8.1f}% " f"CAGR {self.cagr*100:>+6.1f}% | DD {self.maxdd*100:>5.1f}% | " f"expo {self.exposure:>4.2f} trd/y {self.ntrades:>6.1f} | n {self.n}") def _net_series(df, position, fee_rt=FEE_RT): """Ritorna (net, gross, fwd, pos) per barra. net[i] = pos[i]*fwd[i] - fee sul cambio a i.""" c = df["close"].values.astype(float) pos = np.nan_to_num(np.asarray(position, float), nan=0.0) pos = np.clip(pos, -1.0, 1.0) n = len(c) fwd = np.zeros(n) fwd[:-1] = c[1:] / c[:-1] - 1.0 # rendimento close[i]->close[i+1] (futuro vs decisione a i) gross = pos * fwd dpos = np.abs(np.diff(np.concatenate([[0.0], pos]))) # cambio di posizione a i (si tradea al close[i]) fee = dpos * (fee_rt / 2.0) # fee_rt = round-trip (2 unità di turnover); /2 per unità net = gross - fee return net, gross, fwd, pos def backtest(df, position, tf="1h", fee_rt=FEE_RT, lo=None, hi=None) -> BT: net, gross, fwd, pos = _net_series(df, position, fee_rt) m = window_mask(df, lo, hi) net_w, pos_w = net[m], pos[m] dpos_w = np.abs(np.diff(np.concatenate([[0.0], pos_w]))) bpy = BARS_PER_YEAR[tf] n = int(m.sum()) if n < 2: return BT(n, 0, float("nan"), 0, 0, 0, 0, 0) eq = np.cumprod(1.0 + net_w) total = float(eq[-1] - 1.0) years = n / bpy cagr = float((1 + total) ** (1 / years) - 1) if years > 0 and total > -1 else float("nan") mu, sd = float(net_w.mean()), float(net_w.std()) sharpe = mu / sd * np.sqrt(bpy) if sd > 0 else 0.0 peak = np.maximum.accumulate(eq) maxdd = float(np.max((peak - eq) / peak)) if n else 0.0 expo = float(np.mean(np.abs(pos_w))) turn_y = float(dpos_w.sum() / years) if years > 0 else 0.0 return BT(n, total, cagr, sharpe, maxdd, expo, turn_y, turn_y / 2.0) def buy_hold(df, tf="1h", fee_rt=FEE_RT, lo=None, hi=None) -> BT: return backtest(df, np.ones(len(df)), tf, fee_rt, lo, hi) def mc_pvalue(df, position, tf="1h", fee_rt=FEE_RT, n=500, lo=None, hi=None, seed=0): """Null model a ROTAZIONE CIRCOLARE: ruota la serie di posizione di un offset casuale. Preserva ESATTAMENTE exposure, turnover e distribuzione degli holding; distrugge solo l'allineamento col mercato. p = P(Sharpe_ruotato >= Sharpe_reale). p alto = il timing non batte il caso (nessuna skill).""" pos = np.nan_to_num(np.asarray(position, float)) base = backtest(df, pos, tf, fee_rt, lo, hi).sharpe N = len(pos) if np.abs(np.diff(pos)).sum() == 0: # posizione costante -> rotazione degenere return base, float("nan"), float("nan"), float("nan") rng = np.random.default_rng(seed) sims = np.empty(n) for k in range(n): off = int(rng.integers(1, N)) sims[k] = backtest(df, np.roll(pos, off), tf, fee_rt, lo, hi).sharpe p = float((np.sum(sims >= base) + 1) / (n + 1)) return base, p, float(sims.mean()), float(sims.std()) def report(name, df, position, tf="1h", fee_rt=FEE_RT, mc_n=400): """Stampa il verdetto onesto: FULL / OOS-VAL / vs buy&hold / null p-value / sweep fee.""" print(f"\n === {name} ({tf}) ===") print(backtest(df, position, tf, fee_rt).line("FULL")) print(backtest(df, position, tf, fee_rt, lo=VAL_START, hi=HOLDOUT_START).line(f"OOS-VAL {VAL_START[:4]}-24")) print(buy_hold(df, tf, fee_rt).line("buy&hold FULL")) base, p, msh, ssd = mc_pvalue(df, position, tf, fee_rt, n=mc_n) verdict = "RUMORE" if (np.isnan(p) or p > 0.05) else "batte il null" print(f" null (rotazione, n={mc_n}): Sharpe reale {base:.2f} vs random {msh:.2f}±{ssd:.2f} " f"-> p={p if not np.isnan(p) else float('nan'):.3f} [{verdict}]") print(" sweep fee RT:", " ".join( f"{f*100:.2f}%→Sh{backtest(df, position, tf, f).sharpe:.2f}" for f in (0.0, 0.0005, 0.001, 0.002))) # ============================ SELF-TEST DEL BANCO ============================ def self_test(): """Valida l'HARNESS, non una strategia. Tre prove: (1) buy&hold: Sharpe positivo, DD grande (sanity dei numeri). (2) CHEAT look-ahead (pos = segno del rendimento FUTURO): Sharpe enorme, p≈0 -> l'engine SA vedere un edge quando esiste davvero. (3) NOISE causale (pos da rumore del passato): Sharpe≈0, p≈0.5 -> l'engine NON inventa edge dal nulla (niente leak).""" print("=" * 78) print(" SELF-TEST HARNESS — deve: vedere il cheat, NON vedere il rumore") print("=" * 78) df = load_data("BTC", "1h") t = ts(df) c = df["close"].values.astype(float) bh = buy_hold(df, "1h") print(bh.line("(1) buy&hold BTC")) assert bh.sharpe > 0, "buy&hold dovrebbe avere Sharpe>0 sullo storico BTC" # (2) CHEAT: posizione = segno del rendimento del prossimo bar (USA IL FUTURO) fwd = np.zeros(len(c)); fwd[:-1] = c[1:] / c[:-1] - 1.0 cheat = np.sign(fwd) bt_cheat = backtest(df, cheat, "1h") _, p_cheat, _, _ = mc_pvalue(df, cheat, "1h", n=200, seed=1) print(bt_cheat.line("(2) CHEAT look-ahead")) print(f" -> null p={p_cheat:.4f} (atteso ≈0: l'edge finto È enorme e battibile dal caso ~mai)") assert bt_cheat.sharpe > 20, "il cheat dovrebbe dare Sharpe enorme se l'engine è corretto" assert p_cheat < 0.02, "il cheat dovrebbe battere il null in modo schiacciante" # (3) NOISE causale a BASSO turnover (blocchi ~50 barre): isola la SKILL dalla fee-death. # Posizione casuale (non usa il futuro) tenuta a blocchi -> turnover basso -> se l'engine non # inventa edge dal nulla, Sharpe≈0 e il null p≈0.5 (random rotazioni indistinguibili). rng = np.random.default_rng(42) blk = 50 raw = np.sign(rng.standard_normal(len(c) // blk + 1)) noise_pos = np.repeat(raw, blk)[:len(c)] noise_pos = pd.Series(noise_pos).shift(1).fillna(0).values # solo passato bt_noise = backtest(df, noise_pos, "1h") base_n, p_noise, msh, ssd = mc_pvalue(df, noise_pos, "1h", n=400, seed=2) print(bt_noise.line("(3) NOISE causale")) print(f" -> null p={p_noise:.3f} (atteso alto/≈0.5: nessuna skill, indistinguibile dal caso)") assert bt_noise.sharpe < 2.0, "il rumore causale non deve sembrare SKILLATO (Sharpe positivo grande = leak)" assert p_noise > 0.10, "il rumore causale non deve battere il null (p basso = edge spurio/leak)" print("\n ✓ HARNESS VALIDATO: vede il cheat (Sharpe enorme, p≈0), non inventa edge dal rumore (p alto).") print(f" Hold-out finale BLOCCATO da {HOLDOUT_START} (non usato in ricerca). OOS-VAL: {VAL_START}→hold-out.") if __name__ == "__main__": self_test()