Files
PythagorasGoal/scripts/research/r0726_skh_live_book.py
T
Adriano Dal Pastro 842420ce7b research: follow-up SKH01 chiuso — il blocco non esisteva, il de-luck x0.6 era troppo severo
Il follow-up "book sul path live" era fermo da tre sessioni su questa premessa:
"il simulatore compone per-trade a nozionale unitario, LO SLEEVE E' VOL-TARGETED".

LA PREMESSA ERA FALSA. Verificato in tre modi: _skyhook_returns chiama backtest_signals
con leverage=1.0/position_size=1.0; nessun target_vol/vol_target/realized_vol nel
sorgente; sim_equity(canonical) riproduce backtest_signals a max|diff| = 0.0. Il 20.7%
di vol realizzata dello sleeve e' un PRODOTTO della strategia (uscite % asimmetriche +
poco tempo a mercato), non un parametro: SKH01 e' l'unica delle 5 a NON essere
vol-targeted, il contrario di quanto si credeva. Test permanente cablato.

MISURA (ingressi live vs backtest, de-luckata su 8 offset a priori; sanity 120/120 bin
con ingresso ricostruiti):
- il numero del 26/07 era ~4x troppo grande: like-with-like (mediana PER-ASSET) +0.38 su
  3 offset -> +0.097 su 8, e "6/6 non negativi" -> 13/16. Sleeve 50/50: +0.112, 8/8.
- a livello di BOOK lo Sharpe e' una monetina (FULL +0.048, HOLD +0.051) ma il DRIFT e'
  +0.73pp positivo nel 100% delle estrazioni: l'ingresso intra-bin prende un prezzo
  migliore, i falsi ingressi aggiungono churn, vol e ritorno salgono insieme.

IL FATTORE x0.6 DECOMPOSTO E MISURATO:
  (a) fortuna d'ancora sul DRIFT: x0.874 (5-sleeve) / x0.890 (book live). La vol e'
      invariata fra le ancore (7.80->7.76%): la fortuna sta tutta nel drift.
  (b) path live: NON-NEGATIVO ovunque (uscite +0.081 Sh, ingressi +0.73pp, TP01 ~0).
Il x0.6 implicava un residuo x0.687 attribuito al live oltre l'ancora, che nessuna misura
sostiene -> FATTORE ONESTO x0.87-0.91, troppo severo del 31-34%. Il sospetto registrato
il 25/07 ("conta due volte la degradazione SKH01") e' confermato quantitativamente.

MURI DI CAPITALE ricalcolati con la stessa macchineria del 25/07: perpetua 6.00% ->
10.91%, muro per 50 EUR/g $494.758 -> $272.061 (-45%) a leva 1.0. La conclusione
STRUTTURALE non cambia: $272k restano ~453x il conto di oggi.

IPOTESI MIA REFUTATA nella stessa sessione: che la grid timing-luck di SKH01 fosse un
artefatto della lente a chiusura-di-bin. Dispersione LIVE/CANONICO = 1.59x -> il live e'
PIU' disperso. L'audit del 02/07 resta valido com'e'. (Nata su 2 offset, chiusa a 8.)

Trovato per strada: simulate() in r0726_skh_partial_entry.py non era mai chiamata da
main() — i numeri headline di quel diario venivano da una corsa mai committata.

Book, pesi, cron, config INVARIATI.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-26 16:55:01 +00:00

240 lines
13 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
#!/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()