#!/usr/bin/env python """r0726_skh_live_book.py — IL BOOK SUL PATH LIVE DI SKH01, de-luckato. Chiude il follow-up. FOLLOW-UP CHE CHIUDE (portato avanti bloccato dal 26/07, registrato in CLAUDE.md): > il book ricalcolato sul path live e' bloccato da una **incompatibilita' di lenti** — il > simulatore SKH01 compone per-trade a nozionale unitario, lo sleeve e' vol-targeted; combinarle > darebbe un numero preciso e falso. Serve la versione vol-targeted del path live di SKH01. ⚠️ **LA PREMESSA ERA FALSA e il blocco non esisteva.** Verificato in tre modi: 1. `sleeves._skyhook_returns` chiama `backtest_signals(..., leverage=1.0, position_size=1.0)`: **nessun vol-targeting**, compounding per-trade a nozionale pieno — la STESSA lente del simulatore. Il 20.7% di vol realizzata dello sleeve e' un prodotto della strategia (uscite % asimmetriche + poco tempo a mercato), non un target; 2. `grep` di `target_vol|vol_target|realized_vol` nel sorgente dello sleeve: nessuna occorrenza; 3. `r0702_anchor_skh01.sim_equity(mode='canonical')` riproduce `backtest_signals` con **max|diff| = 0.0** — le due lenti sono gia' la stessa cosa. Il follow-up non era bloccato: era mal diagnosticato. Questo script fa la misura. COSA MISURA. Due path identici in tutto (livelli, uscite intra-barra, cap `max_per_day`, fee, non-overlap) tranne **quando si valuta l'ingresso**: * LIVE = a ogni confine orario dentro il bin 230m (cio' che il cron fa davvero); * BACKTEST = solo alla chiusura del bin (cio' che il backtest assume). Riusa la macchina a stati fedele gia' scritta e testata il 26/07 (`r0726_skh_partial_entry .simulate`) — che pero' `main()` di quello script **non chiamava mai**: i numeri headline del suo diario (3 offset) vennero da una corsa ad hoc mai committata. Qui il driver e' committato. DE-LUCK. Il 26/07 la misura era su **3 offset**; la lezione dello stesso giorno dice che *se si de-lucka una strategia va de-luckato anche il suo DEGRADO*. Qui: **8 offset** (sottocampione uniforme a priori, `r0726_skh_sigcache.OFFSETS_SUB`) x 2 asset, e il salto a livello di BOOK con gli altri 4 sleeve estratti sulle loro ancore -> **mediana delle differenze appaiate**. USO: la cache dei segnali va costruita prima (~9 min per asset x offset): uv run python scripts/research/r0726_skh_sigcache.py uv run python scripts/research/r0726_skh_live_book.py [--draws N] """ from __future__ import annotations import argparse 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)) sys.path.insert(0, str(ROOT / "scripts" / "research")) sys.path.insert(0, str(ROOT / "scripts" / "research" / "alt")) import r0702_anchor_skh01 as r02 # noqa: E402 import r0726_loo_deluck as L # noqa: E402 import r0726_skh_partial_entry as SP # noqa: E402 import r0726_skh_sigcache as SC # noqa: E402 from src.portfolio.portfolio import HOLDOUT, combine_outer, metrics, to_daily # noqa: E402 ASSETS = ("BTC", "ETH") SKH = "SKH01_skyhook" DPY = 365.25 def _sh(s: pd.Series) -> float: r = np.asarray(s.dropna().values, float) return float(r.mean() / r.std() * np.sqrt(DPY)) if len(r) > 2 and r.std() > 0 else 0.0 def paths_for(asset: str, off: int) -> dict: """(daily LIVE, daily BACKTEST, diagnostica) per un asset a un offset.""" tab = SC.get_signal_table(asset, off, verbose=False) _, _, ltf, _ = r02.run_asset(asset, off) df5 = r02.get5m(asset) ts5 = df5["timestamp"].values.astype(np.int64) h5 = df5["high"].values.astype(float) l5 = df5["low"].values.astype(float) c5 = df5["close"].values.astype(float) live = SP.simulate(tab, ltf, ts5, h5, l5, c5, intra_entry=True) bt = SP.simulate(tab, ltf, ts5, h5, l5, c5, intra_entry=False) return dict(live=live["daily"], bt=bt["daily"], n_live=live["n_trades"], n_bt=bt["n_trades"], n_false=live["n_false"]) def skh_book(dailies: dict) -> pd.Series: """50/50 BTC+ETH, convenzione di _skyhook_returns (inner join sulla griglia giornaliera).""" J = pd.concat(dailies, axis=1, join="inner").fillna(0.0) return pd.Series(0.5 * J[ASSETS[0]].values + 0.5 * J[ASSETS[1]].values, index=J.index) def book_metrics(cols: dict, weights: dict) -> tuple[float, float, float]: w = {k: weights[k] for k in cols} s = combine_outer(cols, w) m = metrics(s) r = np.asarray(s.dropna().values, float) return m["sharpe"], metrics(s[s.index >= HOLDOUT])["sharpe"], float(r.mean() * DPY) def main() -> None: ap = argparse.ArgumentParser() ap.add_argument("--draws", type=int, default=200, help="estrazioni delle ancore degli ALTRI 4 sleeve, per offset SKH") args = ap.parse_args() have = [o for o in SC.OFFSETS_SUB if all(SC.cache_path(a, o).exists() for a in ASSETS)] if len(have) < 3: print(f"[STOP] servono almeno 3 offset in cache, presenti {len(have)}: {have}") print(" gira prima: uv run python scripts/research/r0726_skh_sigcache.py") sys.exit(1) print("=" * 100) print(" r0726 — IL BOOK SUL PATH LIVE DI SKH01 (de-luckato). Chiude il follow-up.") print("=" * 100) print(f" offset in cache: {have} ({len(have)}/{len(SC.OFFSETS_SUB)} del sottocampione " f"a priori, {len(SC.OFFSETS_SUB)}/23 della griglia piena)") # ---------------- 0. SANITY: la ricostruzione a fine bin == backtest -------------- print("\n" + "-" * 100) print(" 0. SANITY — a chiusura di bin la ricostruzione deve dare il segnale del backtest") print(" (campionata sugli EVENTI: gli ingressi sono ~2% dei bin, a caso il check e' vacuo)") print("-" * 100) for a in ASSETS: r = SP.selfcheck(a, off=0) ok = r["sig_ok"] == r["sig_tot"] print(f" {a}: bin CON ingresso {r['sig_ok']}/{r['sig_tot']} " f"{'OK' if ok else '*** DIVERGE ***'} | flat {r['flat_ok']}/{r['flat_tot']} " f"(+{r['flat_capped']} con segnale soppresso da max_per_day: ATTESO, non divergenza)") if not ok: sys.exit(1) # ---------------- 1. livello SLEEVE, per offset ----------------------------------- print("\n" + "-" * 100) print(" 1. SLEEVE SKH01 — Sharpe del path LIVE vs BACKTEST, per offset") print("-" * 100) print(f" {'off':>5}{'CANON':>9}{'ShBT':>9}{'ShLIVE':>9}{'dSharpe':>10}" f"{'trade LIVE':>12}{'trade BT':>10}{'falsi':>8}") P = {} rows = [] can_v, bt_v, live_v, per_asset = [], [], [], [] for off in have: d = {a: paths_for(a, off) for a in ASSETS} live = skh_book({a: d[a]["live"] for a in ASSETS}) bt = skh_book({a: d[a]["bt"] for a in ASSETS}) P[off] = (live, bt) s_c, s_l, s_b = _sh(r02.skh_port(off)), _sh(live), _sh(bt) rows.append(s_l - s_b) can_v.append(s_c); bt_v.append(s_b); live_v.append(s_l) for a in ASSETS: # per-asset: la grandezza citata il 26/07 per_asset.append((off, a, _sh(d[a]["live"]) - _sh(d[a]["bt"]))) print(f" {off:>5}{s_c:+9.3f}{s_b:+9.3f}{s_l:+9.3f}{s_l - s_b:+10.3f}" f"{sum(d[a]['n_live'] for a in ASSETS):>12}" f"{sum(d[a]['n_bt'] for a in ASSETS):>10}" f"{sum(d[a]['n_false'] for a in ASSETS):>8}") arr = np.array(rows, float) print(f"\n SLEEVE (50/50) mediana delle differenze APPAIATE: {np.median(arr):+.3f} " f"positive {int((arr > 0).sum())}/{len(arr)} banda [{arr.min():+.3f}, {arr.max():+.3f}]") pa = np.array([x[2] for x in per_asset], float) print(f" PER-ASSET (la grandezza citata il 26/07) : {np.median(pa):+.3f} " f"positive {int((pa > 0).sum())}/{len(pa)} banda [{pa.min():+.3f}, {pa.max():+.3f}]") print(" ⚠️ le due righe NON sono la stessa cosa: il 26/07 cito' la mediana dei Δ") print(" PER-ASSET (+0.38 su 3 offset x 2 asset). Il numero che entra nel BOOK e'") print(" quello dello SLEEVE 50/50, dove la diversificazione BTC/ETH cambia il") print(" denominatore. Vanno confrontati like-with-like.") # ---- 1b. DISPERSIONE fra offset: chi e' davvero esposto alla fortuna di griglia? ---- print("\n" + "-" * 100) print(" 1b. DISPERSIONE FRA OFFSET — la timing-luck di griglia di SKH01 e' del BACKTEST") print(" o della STRATEGIA? (il live non e' ancorato al confine di bin: valuta ogni ora)") print("-" * 100) print(f" {'path':<28}{'min':>9}{'mediana':>10}{'max':>9}{'range':>9}{'std':>9}") for lab, v in (("CANONICO (chiusura bin)", can_v), ("BT-sim (chiusura bin)", bt_v), ("LIVE-sim (intra-bin)", live_v)): v = np.array(v, float) print(f" {lab:<28}{v.min():+9.3f}{np.median(v):+10.3f}{v.max():+9.3f}" f"{v.max()-v.min():9.3f}{v.std(ddof=1):9.3f}") rc = np.array(can_v, float); rl = np.array(live_v, float) if rc.std(ddof=1) > 0: ratio = rl.std(ddof=1) / rc.std(ddof=1) print(f"\n rapporto di dispersione LIVE/CANONICO: {ratio:.2f}x") if ratio < 1.0: print(" <1 -> il path live e' MENO sensibile alla fase della griglia: parte della") print(" 'grid timing-luck' del 02/07 sarebbe un artefatto della lente a") print(" chiusura-di-bin, non una fragilita' della strategia live.") else: print(" >=1 -> IPOTESI REFUTATA. Il path live e' PIU' disperso fra le fasi della") print(" griglia, non meno: la grid timing-luck di SKH01 (audit 02/07) NON e'") print(" un artefatto della lente a chiusura-di-bin. L'audit resta valido") print(" com'e'. (Ipotesi nata su 2 offset, refutata a 4, confermata a 8.)") # ---------------- 2. livello BOOK, de-luckato ------------------------------------- print("\n" + "-" * 100) print(" 2. BOOK 5-sleeve — effetto degli INGRESSI live, con gli altri 4 sleeve estratti") print("-" * 100) S = L.build_all() others = [n for n in L.SPEC if n != SKH] rng = np.random.default_rng(L.SEED) d_full, d_hold, d_drift = [], [], [] for off in have: live, bt = P[off] for _ in range(args.draws): cfg = {n: int(rng.choice(L.SPEC[n][1])) for n in others} base = {n: S[n][cfg[n]] for n in others} fl, hl, dl = book_metrics({**base, SKH: to_daily(live)}, L.WEIGHTS) fb, hb, db = book_metrics({**base, SKH: to_daily(bt)}, L.WEIGHTS) d_full.append(fl - fb) d_hold.append(hl - hb) d_drift.append(dl - db) for lab, v in (("dSharpe FULL", d_full), ("dSharpe HOLD-OUT", d_hold), ("d drift annuo", d_drift)): v = np.array(v, float) k = 100.0 if "drift" in lab else 1.0 u = "pp" if "drift" in lab else "" print(f" {lab:<20} mediana {np.median(v)*k:+7.3f}{u} " f"p10 {np.percentile(v, 10)*k:+7.3f}{u} p90 {np.percentile(v, 90)*k:+7.3f}{u} " f">0 in {(v > 0).mean()*100:5.1f}%") # ---------------- 3. il fattore de-luck ------------------------------------------ print("\n" + "=" * 100) print(" 3. IL FATTORE DE-LUCK ×0.6 — decomposto e ricomposto") print("=" * 100) dd = np.median(np.array(d_drift, float)) print(" (a) fortuna d'ancora sul drift del book 5-sleeve : ×0.874 (r0726_deluck_factor)") print(f" (b) path live SKH01, componente INGRESSI : {dd*100:+.3f}pp di drift di book") print(" (b) path live SKH01, componente USCITE : +0.081 Sharpe FULL (23/23 off.)") print(" (b) path live TP01, barra parziale : trascurabile (24 ancore)") drift_can = 0.1733 # drift canonico del book 5-sleeve (r0726_deluck_factor) f_lo = 0.874 # ancora sola: ignora il live (conservativo) f_hi = 0.874 * (1.0 + dd / drift_can) # ancora + componente INGRESSI misurata qui print(f"\n Il ×0.6 implica ×0.687 di residuo attribuito al live OLTRE l'ancora.") print(" Nessuna misura del progetto sostiene un residuo <1: tutte le componenti") print(" misurate del path live sono NON-NEGATIVE.") print(f"\n >>> FATTORE ONESTO: ×{f_lo:.2f} - ×{f_hi:.2f} (contro il ×0.60 in uso)") print(f" ×{f_lo:.2f} = sola fortuna d'ancora, ignorando il live (conservativo)") print(f" ×{f_hi:.2f} = ancora + componente INGRESSI ({dd*100:+.2f}pp su {drift_can*100:.2f}%)") print(" La componente USCITE (+0.081 Sharpe) spingerebbe oltre, ma e' misurata in") print(" Sharpe e non in drift: non la converto per non inventare precisione.") print(f" => il ×0.6 e' troppo severo di circa il {(1 - 0.6/f_lo)*100:.0f}-{(1 - 0.6/f_hi)*100:.0f}%.") if __name__ == "__main__": main()