"""TP01 nel LIVE: la barra GIORNALIERA e' PARZIALE. Quanto conta? (ondata 2026-07-26-bis, T1) CONTESTO. Il 26/07 ho misurato che il live di SKH01 valuta il segnale su una barra 230m parziale (uscite e ingressi) e che il backtest, modellandolo a chiusura di bin, lo sottostimava su entrambi i lati. Questo script chiede la STESSA cosa allo sleeve che pesa il **75% del book Deribit**. IL FATTO (verificato, non dedotto): `resample_tf(..., label="left", closed="left")` NON scarta il giorno in corso, e `TrendPortfolio.current_target` prende `target_series(df)[-1]` — cioe' **l'ultima barra, che e' parziale**. Il feed certificato viene ricostruito da `cron_daily` alle 00:30 UTC, quindi per tutta la giornata il live vede il giorno corrente come una barra da **1 ora su 24** (misurato oggi: `barre1h=1/24`). Il docstring di `current_target` dice "ultima barra CHIUSA": e' un'assunzione sul chiamante, e nel live NON e' verificata. DUE CANALI, che vanno separati perche' hanno segno atteso opposto: (a) ESECUZIONE RITARDATA — il modello ribilancia al confine 00:00, il live al primo giro di cron utile dopo il rebuild (~01:00). Un'ora di ritardo su uno sleeve daily: atteso ~rumore. (b) BARRA PARZIALE — il target del live usa c[t] = prezzo delle 01:00 al posto della chiusura piena del giorno, e r[t] = un rendimento di UN'ORA contato come rendimento GIORNALIERO dentro la finestra di vol a 30 barre. La vol realizzata esce **sottostimata** -> `target_vol/vol` esce **sovrastimata** -> il live **sovra-leva** in modo sistematico. Direzione attesa: negativa. TRE PATH, che isolano i due canali (differenza fra due path = un solo grado di liberta'): MODEL tgt_full[t-1] tenuto in [t 00:00, t+1 00:00) = il backtest canonico CLOSED@+1h tgt_full[t-1] tenuto in [t 01:00, t+1 01:00) = scarta la parziale, stessa ora del live LIVE tgt_live[t] tenuto in [t 01:00, t+1 01:00) = cio' che gira oggi LIVE - CLOSED = effetto puro della BARRA PARZIALE CLOSED - MODEL = effetto puro del RITARDO d'esecuzione BANDA D'ANCORA. TP01 e' gia' noto per anchor timing-luck (02/07: l'ancora 00:00 e' la migliore delle 24). Lezione codificata stamattina: *se si de-lucka una strategia va de-luckato anche il suo degrado*. Quindi ogni Δ e' riportato sulle 24 ancore, con la **mediana delle DIFFERENZE APPAIATE** (non la differenza delle mediane: gli offset sono coppie). Tutto su griglia ORARIA: i tre path hanno confini diversi e solo l'orario li rende confrontabili. """ 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)) from src.data.downloader import load_data # noqa: E402 from src.strategies.trend_portfolio import CANONICAL, TrendPortfolio # noqa: E402 ASSETS = ("BTC", "ETH") HOLDOUT = "2025-01-01" MS_H = 3_600_000 MS_D = 86_400_000 FEE_SIDE = float(CANONICAL.get("fee_side", 0.0005)) EXEC_LAG_H = 1 # il primo giro di cron_book utile dopo il rebuild delle 00:30 # --------------------------------------------------------------------------- dati def hourly(asset: str) -> tuple[np.ndarray, np.ndarray]: df = load_data(asset, "1h") return (df["timestamp"].values.astype(np.int64), df["close"].values.astype(float)) def daily_from_hourly(ts: np.ndarray, c: np.ndarray, off_h: int) -> dict: """Barre giornaliere ancorate a `off_h` (UTC), piu' il prezzo alla PRIMA ora del giorno. Ritorna: day_start (ms), close pieno, close parziale (dopo 1 ora), n. di barre 1h per giorno. """ day = (ts - off_h * MS_H) // MS_D s = pd.DataFrame({"day": day, "ts": ts, "c": c}) g = s.groupby("day", sort=True) full = g["c"].last().values first = g["c"].first().values # close della PRIMA barra 1h -> la "parziale" a +1h n = g["c"].size().values start = (g["ts"].min().values // MS_H) * MS_H return dict(start=start, full=full, partial=first, n=n) # --------------------------------------------------------------------------- target def targets_full(cf: np.ndarray, tp: TrendPortfolio) -> np.ndarray: """target_series su barre giornaliere PIENE (bpd=1 -> orizzonti in giorni).""" d = pd.DataFrame({"close": cf, "datetime": pd.to_datetime(np.arange(len(cf)) * MS_D, unit="ms", utc=True)}) return tp.target_series(d) def targets_live(cf: np.ndarray, cp: np.ndarray, tp: TrendPortfolio) -> np.ndarray: """Ricostruisce cio' che il live calcola: giorni 0..t-1 PIENI + giorno t PARZIALE, indice [-1]. Riproduce a mano la formula di `target_series` sostituendo il SOLO ultimo elemento — che e' esattamente cio' che cambia fra la vista del live e quella del backtest. """ n = len(cf) vw = tp.vol_win_days # bpd=1 su barre giornaliere hz = tuple(tp.horizons_days) bpy = 365.25 r_full = np.zeros(n) r_full[1:] = cf[1:] / cf[:-1] - 1.0 out = np.zeros(n) for t in range(n): # direzione: sign(c[t]/c[t-h]) con c[t] = PARZIALE acc = cnt = 0.0 for h in hz: if t - h >= 0: acc += np.sign(cp[t] / cf[t - h] - 1.0) cnt += 1 if cnt == 0: continue direction = acc / cnt if tp.long_only: direction = max(direction, 0.0) # vol: finestra a vw barre che finisce in t, con r[t] = rendimento di UN'ORA a = max(0, t - vw + 1) w = r_full[a:t + 1].copy() if t >= 1: w[-1] = cp[t] / cf[t - 1] - 1.0 if len(w) < max(1, vw // 2): continue sd = float(np.std(w, ddof=1)) if len(w) > 1 else 0.0 vol = sd * np.sqrt(bpy) if not np.isfinite(vol) or vol <= 0: continue out[t] = float(np.clip(direction * (tp.target_vol / vol), -tp.leverage, tp.leverage)) return out # --------------------------------------------------------------------------- path orari def hourly_position(ts: np.ndarray, dstart: np.ndarray, tgt: np.ndarray, shift_h: int) -> np.ndarray: """Posizione oraria: `tgt[k]` in vigore da (dstart[k] + shift_h ore) fino al successivo.""" edges = dstart + shift_h * MS_H j = np.searchsorted(edges, ts, side="right") - 1 pos = np.where(j >= 0, tgt[np.clip(j, 0, len(tgt) - 1)], 0.0) return pos def path_returns(ts: np.ndarray, c: np.ndarray, pos: np.ndarray) -> pd.Series: """Rendimenti orari netti fee sul turnover, indicizzati per timestamp.""" r = np.zeros(len(c)) r[1:] = c[1:] / c[:-1] - 1.0 held = np.zeros(len(pos)) held[1:] = pos[:-1] # tenuta durante l'ora i = decisa a i-1 turn = np.abs(np.diff(held, prepend=0.0)) net = held * r - FEE_SIDE * turn return pd.Series(net, index=pd.to_datetime(ts, unit="ms", utc=True)) def sharpe(s: pd.Series) -> float: x = s.values[np.isfinite(s.values)] if len(x) < 50 or np.std(x) == 0: return 0.0 return float(np.mean(x) / np.std(x) * np.sqrt(24 * 365.25)) def maxdd(s: pd.Series) -> float: eq = np.cumprod(1.0 + np.clip(s.values, -0.99, None)) return float(np.max((np.maximum.accumulate(eq) - eq) / np.maximum.accumulate(eq))) # --------------------------------------------------------------------------- valutazione def evaluate(off_h: int) -> dict: """I tre path per una data ancora, combinati 50/50 BTC+ETH come fa lo sleeve.""" tp = TrendPortfolio(**CANONICAL) legs: dict[str, list] = {"MODEL": [], "CLOSED": [], "LIVE": []} lev: dict[str, list] = {"MODEL": [], "LIVE": []} for a in ASSETS: ts, c = hourly(a) d = daily_from_hourly(ts, c, off_h) cf, cp, dstart = d["full"], d["partial"], d["start"] tf = targets_full(cf, tp) tl = targets_live(cf, cp, tp) # MODEL: tgt[t-1] tenuto dal confine del giorno t -> shift di 1 giorno = tgt spostato avanti tf_prev = np.concatenate([[0.0], tf[:-1]]) legs["MODEL"].append(path_returns(ts, c, hourly_position(ts, dstart, tf_prev, 0))) legs["CLOSED"].append(path_returns(ts, c, hourly_position(ts, dstart, tf_prev, EXEC_LAG_H))) legs["LIVE"].append(path_returns(ts, c, hourly_position(ts, dstart, tl, EXEC_LAG_H))) lev["MODEL"].append(tf_prev) lev["LIVE"].append(tl) out = {} for k, v in legs.items(): J = pd.concat(v, axis=1, join="inner").fillna(0.0) out[k] = 0.5 * J.iloc[:, 0] + 0.5 * J.iloc[:, 1] # diagnostica di leva: quanto sovra-leva il live rispetto al modello? ml = np.concatenate(lev["MODEL"]); ll = np.concatenate(lev["LIVE"]) m = (ml > 0) & (ll > 0) out["_lev_ratio"] = float(np.median(ll[m] / ml[m])) if m.any() else float("nan") return out def report(off_h: int, res: dict) -> dict: row = dict(off=off_h, lev=res["_lev_ratio"]) for k in ("MODEL", "CLOSED", "LIVE"): s = res[k] row[f"{k}_full"] = sharpe(s) row[f"{k}_hold"] = sharpe(s[s.index >= HOLDOUT]) row[f"{k}_dd"] = maxdd(s) return row def main() -> None: print("=" * 100) print(" TP01 nel LIVE — la barra giornaliera e' PARZIALE (1h su 24). Quanto conta?") print("=" * 100) print("\n MODEL = tgt[t-1] dal confine 00:00 (backtest canonico)") print(" CLOSED@+1h = tgt[t-1] dal confine +1h (scarta la parziale, ora del live)") print(" LIVE = tgt[t] da barra PARZIALE, +1h (cio' che gira oggi)") print(" LIVE-CLOSED = effetto BARRA PARZIALE | CLOSED-MODEL = effetto RITARDO\n") rows = [report(o, evaluate(o)) for o in range(24)] df = pd.DataFrame(rows) print("-" * 100) print(" ancora canonica (offset 0) — quella su cui sono calcolati TUTTI i numeri di TP01") print("-" * 100) r0 = df[df.off == 0].iloc[0] print(f" {'path':<12}{'FULL':>8}{'HOLD':>8}{'maxDD':>9}") for k in ("MODEL", "CLOSED", "LIVE"): print(f" {k:<12}{r0[f'{k}_full']:>8.2f}{r0[f'{k}_hold']:>8.2f}{r0[f'{k}_dd']*100:>8.1f}%") print(f"\n leva del LIVE / leva del MODEL (mediana sui giorni entrambi investiti): " f"{r0['lev']:.3f}") print("\n" + "-" * 100) print(" BANDA DELLE 24 ANCORE — mediana delle DIFFERENZE APPAIATE (non differenza di mediane)") print("-" * 100) for lab, a, b in (("BARRA PARZIALE (LIVE - CLOSED)", "LIVE", "CLOSED"), ("RITARDO 1h (CLOSED - MODEL)", "CLOSED", "MODEL"), ("TOTALE (LIVE - MODEL)", "LIVE", "MODEL")): for w in ("full", "hold"): d = df[f"{a}_{w}"] - df[f"{b}_{w}"] print(f" {lab:<34} {w.upper():<5} mediana {d.median():+.3f} " f"[{d.min():+.3f},{d.max():+.3f}] positivo in {int((d > 0).sum())}/24") print(f"\n leva LIVE/MODEL sulla banda: mediana {df['lev'].median():.3f} " f"[{df['lev'].min():.3f},{df['lev'].max():.3f}]") print("\n" + "-" * 100) print(" per-ancora (FULL)") print("-" * 100) print(f" {'off':>4}{'MODEL':>8}{'CLOSED':>8}{'LIVE':>8}{'ΔparzialeF':>12}{'ΔparzialeH':>12}{'lev':>7}") for _, r in df.iterrows(): print(f" {int(r.off):>4}{r.MODEL_full:>8.2f}{r.CLOSED_full:>8.2f}{r.LIVE_full:>8.2f}" f"{r.LIVE_full - r.CLOSED_full:>+12.3f}{r.LIVE_hold - r.CLOSED_hold:>+12.3f}" f"{r.lev:>7.3f}") print() if __name__ == "__main__": main()