diff --git a/CLAUDE.md b/CLAUDE.md index 7c00008..441f1e1 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -57,6 +57,13 @@ Prima ondata di ricerca onesta su BTC/ETH certificati (5 track, harness condivis regime compatto). Standalone FULL Sh **1.50** / HOLD 1.71 / DD 11%, plateau robusto (lookback, gate p15-35). **Caveat:** storia ~2.5 anni; STAT-MODE (book a 19 gambe non eseguibile a 2k, serve ~20k) → monitor forward. NB il gate concentra XS nei regimi dispersi (2025-26 = hold-out alta-dispersione). + ⚠️ **PHASE TIMING-LUCK (2026-07-02):** i numeri headline sono sulla fase 0 del ciclo H=10, che è + al **15° pctl di DD** (10.8% vs ~15.5% fase tipica, 29% peggiore) e 85° di FULL fra le 10 fasi + (HOLD solo 65°, non estremo); P(spike per caso)≈0.91-0.94. Lens onesta = **ensemble di fase: + FULL 1.25 / HOLD 1.31 / DD 10.9%**; a fase mediana FULL 1.08/HOLD 1.10/DD 21%. La decisione di + ammissione @15% regge (0 fasi negative, 8/10 FULL≥1.0), i numeri 1.50/1.71/11% no → citarli con + banda di fase. Ora-del-giorno NON testabile (solo 1d HL). Script `scripts/research/r0702_anchor_xs01.py`; + diario `2026-07-02-anchor-audit-xs01-skh01.md`. Ricerca `scripts/portfolio/{xsec_research,xsec_blend,xsec_dispgate}.py`. Diari `2026-06-19-hyperliquid-xsec` / `-xsec-blend` / `-xsec-dispgate` / `-xsec-universe-expansion` / `-trend-multiasset`. - **PORTAFOGLIO ATTIVO = TP01 (33%) + XS01 (15%) + VRP01 (12%) + SKH01 (20%) + GTAA01 (20%)** @@ -75,6 +82,11 @@ Prima ondata di ricerca onesta su BTC/ETH certificati (5 track, harness condivis Test `tests/test_gtaa_sleeve.py`; diario `2026-07-01-strategy-wave-6threads.md` (addendum GTAA). Report `scripts/portfolio/run_portfolio.py`. Sleeve a date d'inizio diverse → outer-join con pesi rinormalizzati (TP01/SKH01/GTAA dal 2019*, VRP dal 2021, XS dal 2024; *GTAA troncato all'era book). + ⚠️ **ANCHOR-LUCK del book (2026-07-02):** l'HOLD 2.46 è calcolato con TUTTI gli sleeve ancorati + alla loro ancora canonica, che per TP01/XS01/SKH01 è al top della rispettiva banda (eredità di + fortuna ~+0.10/+0.17/+0.5 HOLD). **Stima de-luckata onesta: HOLD ~1.9-2.1, FULL ~2.0-2.2, DD ~6% + invariato** — il book resta positivo e diversificato a ogni ancora testata, ma 2.46 è un massimo + di configurazioni d'ancora, non la stima centrale. Diario `2026-07-02-anchor-audit-xs01-skh01.md`. - **SKH01-V2-DD "Skyhook" — DIVERSIFICATORE quasi-ortogonale (research)** — `src/strategies/skyhook.SKH01_V2_DD`, sleeve `src/portfolio/sleeves._skyhook_returns`. Sistema dual-TF (segnale 690m / exec 230m) regime (BuzVola/BuzVolume tipo-Chande) AND pattern (Donchian breakout), NON trend-follower, L/S. Vincitrice @@ -84,6 +96,16 @@ Prima ondata di ricerca onesta su BTC/ETH certificati (5 track, harness condivis robust_oos multicut 7/7, is_hedge=False); blend 0.75·TP01+0.25·SKH **hold-out 0.31→1.17**. Verificato leak-free + 2 scettici. **CAVEAT:** equity daily-step (Sharpe lens), ETH DD margine sottile, book 230m (costi ribilanciamento da verificare a deploy) → research win, forward-monitor. Diario `2026-06-23-skyhook.md`. + ⚠️ **GRID TIMING-LUCK (2026-07-02, più forte di TP01):** i numeri headline sono sull'offset 0 della + griglia 230m/690m, al **93-98° pctl dei 23 offset a priori** — minHold +1.26, blend 1.17 e book + HOLD 2.44 sono il MASSIMO dei 23 (mediane: minHold +0.39, blend 0.72, book 1.96); spike, non + plateau (±30m crolla); P(spike)≈0.70. **Il gate DD<30% (criterio di selezione di V2-DD) fallisce + in 15/23 offset** (mediana ETH 29.2%). Regge de-luckato: uplift blend positivo a TUTTE le 23 fasi + (min +0.18, med +0.42) + corr 0.05-0.11 → ADDS sopravvive ridimensionato. **LIVE (SKH=25% del book + Deribit):** path reale cron orario + exit software → book 50/50 FULL 1.46→1.19 / HOLD 1.64→1.15 / + DD 18→25%; nei crash gap-through-stop reale (sl2% modellato → −11/−23% realizzato). Pesi/book + INVARIATI (ogni cambio passa weights_tilt_null); follow-up: cadenza 230m, peso live da rivedere. + Script `scripts/research/r0702_anchor_skh01.py`; diario `2026-07-02-anchor-audit-xs01-skh01.md`. - **VRP01 Options Short-Vol — DIVERSIFICATORE da FinanceOld/OptionsAgent** — `src/portfolio/sleeves._vrp_combo_returns`. Put credit spread settimanale (vendi put -0.28, compra put -0.10) gated su IV-rank. Idee portate da `../FinanceOld/OptionsAgent` (Bear Call Spread + gate d'ingresso). Migliora il lead VRP nudo @@ -209,7 +231,9 @@ Prima ondata di ricerca onesta su BTC/ETH certificati (5 track, harness condivis netto ~0 → SCARTATO. (3) **Anchor timing-luck TP01 + tranching**: finding confermato (dettagli nel bullet TP01); tranching K=2/4 = sola riduzione della varianza della STIMA (ΔSharpe n.s., ΔDD ~0.5pt), NO deploy a $600 (il min-order lo degenera in K=1; serve feed intraday fuori path certificato) — - rivalutare a ≥5-10k; ⚠️ audit d'ancora PENDENTE su XS01 (rebalance 10g) e SKH01 (fase 230m/690m). + rivalutare a ≥5-10k. **Audit d'ancora ESEGUITO su XS01 e SKH01 (stesso giorno): il finding si + replica su 3/3 sleeve ancorati** — vedi ⚠️ nei rispettivi bullet e la stima de-luckata del book + nel bullet portafoglio; diario `2026-07-02-anchor-audit-xs01-skh01.md`. (4) **Clock lenti (2-7g) + bande isteresi**: fee drag di TP01 = ~0.4%/anno = tetto di ogni risparmio; il lag costa più del risparmio (HOLD ensemble 0.34→0.11 da N=2 a 7); a $600 **il min-order $5 è GIÀ la banda ottimale** (ordini −74% a costo ~0) → nessun cambio al book. (5) **Velocità trend diff --git a/docs/diary/2026-07-02-anchor-audit-xs01-skh01.md b/docs/diary/2026-07-02-anchor-audit-xs01-skh01.md new file mode 100644 index 0000000..e1c37d2 --- /dev/null +++ b/docs/diary/2026-07-02-anchor-audit-xs01-skh01.md @@ -0,0 +1,132 @@ +# 2026-07-02 — Audit anchor timing-luck su XS01 e SKH01 (chiusura del pendente dell'ondata TIMING) + +**Contesto.** L'ondata TIMING+CRT dello stesso giorno (`2026-07-02-timing-crt-wave.md`) ha stabilito +che l'hold-out di TP01 (0.31) è la **migliore delle 24 ancore daily possibili** (mediana 0.04, +P~0.86 di uno spike così per caso). Lo scettico ha flaggato l'audit come **pendente** sugli altri +due sleeve a ribilanciamento/griglia ancorata: **XS01** (fase del ciclo di ribilanciamento H=10 +giorni) e **SKH01** (offset della griglia dual-TF 230m/690m, `origin='epoch'`). Questo diario +chiude entrambi. Due agenti indipendenti, metodologia identica allo scettico TP01 +(`r0702_skeptic_offset.py`): ancore dichiarate **a priori**, parametri **identici** in ogni +fase/offset (zero tuning per-ancora), **sanity replica bit-exact** della sleeve canonica prima di +qualsiasi numero, block-bootstrap congiunto per P(spike). + +**Script:** `scripts/research/r0702_anchor_xs01.py`, `scripts/research/r0702_anchor_skh01.py`. +Nessun file di produzione toccato dagli agenti (solo docstring/narrativa aggiornate a valle, qui). + +--- + +## Verdetto in una riga + +**Il finding TP01 si replica su TUTTI e tre gli sleeve ancorati — su SKH01 più forte che su TP01 — +e i numeri headline del book (HOLD 2.46) sono in parte best-of-anchors.** Ciò che sopravvive +de-luckato: XS01 resta un diversificatore ammissibile (ensemble di fase FULL 1.25 / HOLD 1.31 / +DD 11%); SKH01 resta ADDS (uplift del blend positivo a **tutte** le 23 fasi, corr ~0.08) ma i suoi +numeri headline e — cruciale — **il gate DD<30% che l'ha selezionata** non reggono alla fase +mediana. Stima onesta de-luckata del book 5-sleeve: **HOLD ~1.9–2.1, FULL ~2.0–2.2** (vs 2.46/2.24 +dichiarati). + +--- + +## XS01 — fase del ciclo di ribilanciamento H=10 (10 fasi) + +Sanity: replica di `_xsec_returns` con parametro `phase` → **max|Δ|=0.0 bit-exact** vs la canonica. +NB: la canonica sui dati di oggi fa FULL 1.367/HOLD 1.479/DD 10.8% (i dichiarati 1.50/1.71/11% +erano del 2026-06-19; deriva dati, confronto interno coerente). + +| metrica | min | mediana | max | canonica (f0) | pctl f0 | +|---|---|---|---|---|---| +| Sh FULL | 0.41 | 1.21 | 1.64 | 1.37 | 85° | +| Sh HOLD | 0.15 | 1.17 | 1.71 | 1.48 | 65° | +| maxDD | 10.0% | 15.5% | 29.1% | 10.8% | **15°** | + +- **Firma diversa da TP01: la fortuna NON è nell'hold-out** (65° pctl, non estremo) **ma nel + DD** (15° pctl: il "DD 11%" dichiarato è uno dei più bassi delle 10 fasi; la fase tipica fa + ~15.5%, la peggiore 29%) e nel FULL (85°). +- **Bootstrap**: spike osservato +0.38 su hold-out; **P(una fase qualsiasi ≥ +0.38) = 0.91–0.94** + (block 10/20/40, B=4000) → la canonica è il massimo atteso di 10 stime correlate (corr mediana + fra fasi 0.71). +- **Ensemble 10 fasi** (lens di reporting onesta): **FULL 1.25 / HOLD 1.31 / DD 10.9%** — la + diversificazione di fase riporta il DD al livello canonico con Sharpe ≈ mediana. +- **Book 5-sleeve**: HOLD 2.44 (canonica) → 2.26 (fase mediana) → 1.75 (peggiore, marginalmente + PEGGIO del book senza XS01 = 1.77) → 2.35 (ensemble). **Eredità di fortuna ~+0.17 HOLD.** + FULL quasi insensibile (2.10–2.28). +- **Gate di ammissione a fase mediana**: FULL 1.08 / HOLD 1.10 / DD 20.7% — **i numeri dichiarati + non reggono** (Sharpe −0.3/−0.6, DD raddoppia). **La decisione però regge**: nessuna fase + negativa, 8/10 con FULL≥1.0, book migliore a ogni fase tranne la peggiore (dove è neutro) → + slot da diversificatore @15% difendibile; cambia la NARRATIVA (banda/ensemble, non 1.50/1.71/11%). +- **Caveat**: storia ~2.5 anni (CI95 Sh HOLD canonica **[−0.04, +2.72]** — nessuna stima puntuale + affidabile); dimensione **ora-del-giorno non testabile** (solo barre 1d HL: resta luck residua + 10 fasi × 24 ore non certificabile); XS01 è STAT-MODE → impatto su reporting/gate, non sul book + live Deribit. + +## SKH01-V2-DD — offset della griglia dual-TF 230m/690m (23 offset × 30m) + +Sanity: offset 0 riproduce `_skyhook_returns()` **bit-exact** (max|Δ|=0.0 su 2666 giorni); maxDD +harness BTC 21.4%/ETH 27.4% = identici al diario di ammissione; riuso diretto di +`skyhook_entries`/`htf_features`/`merge_htf_to_ltf` con `resample(origin='epoch', offset=...)`, +confini 690 ⊂ 230 verificati a ogni offset. + +| metrica (book 50/50) | min | mediana | max | off0 (canonico) | pctl off0 | +|---|---|---|---|---|---| +| Sh FULL | 0.78 | 1.05 | 1.48 | 1.46 | 93° | +| Sh HOLD | 0.44 | 0.93 | 1.64 | 1.64 | **98° (max)** | +| minFull (2 asset) | 0.35 | 0.67 | 1.01 | 0.99 | 93° | +| minHold | −0.10 | 0.39 | 1.25 | 1.25 | **98° (max)** | +| blend 0.75·TP01+0.25·SKH, HOLD | 0.49 | 0.72 | 1.16 | 1.16 | **98° (max)** | + +- **È uno spike, non un plateau**: gli offset adiacenti ±30m crollano (book HOLD 1.64 → 0.88 a + off30). Firma identica all'ancora TP01, ma **più forte**: il canonico è al 93–98° pctl di OGNI + metrica; minHold +1.26 e blend "0.31→1.17" sono il **massimo dei 23**. +- **Gate DD<30% — il criterio per cui V2-DD fu selezionata — FALLISCE in 15/23 offset** + (mediana DD: BTC 23.5%, ETH **29.2%** — pass per 0.8pt; peggiori 35.9%/42.1%). Il margine ETH + "già sottile" (27.4%) è in realtà **sotto la mediana di fase**: la proprietà DD<30% è in larga + parte fortuna di fase. +- **Ciò che regge de-luckato**: **uplift del blend positivo a TUTTE le 23 fasi** (min +0.18, + mediana +0.42, canonico +0.86) e corr→TP01 in [0.05, 0.11] a ogni fase → il verdetto + ADDS/diversificatore sopravvive, ridimensionato. Alla fase peggiore invece non regge + (minHold −0.10, DD fail). +- **Bootstrap**: spike blend +0.44; **P(un offset qualsiasi ≥ +0.44) ≈ 0.70** (block 10/20/40); + P(uplift≤0) = 0.03–0.04 → l'uplift è reale, l'ampiezza è best-of-23. +- **Book 5-sleeve**: HOLD 2.44 (off0 = max dei 23) → mediana **1.96** → ensemble 2.12; FULL 2.24 → + mediana 2.01. **Eredità di fortuna ~+0.5 HOLD** — molto più dei ~+0.10 dell'ancora TP01. +- **Rilevanza LIVE (SKH = 25% del book Deribit reale)**: il cron è orario, i confini 230m no → + ritardo uniforme {0..50} min (media 25). Re-sim del path reale (fill al close 5m del prossimo + run orario, exit SOFTWARE come da `book.py`): book 50/50 **FULL 1.46→1.19, HOLD 1.64→1.15, + DD 18.1%→24.6%**. La maggior parte del gap è l'assunzione fill-al-livello del backtest, non il + ritardo orario in sé: nei crash le barre 230m **gappano attraverso lo SL software** (short + sl2% modellato −2% → realizzato **−11/−23%**: ETH 2021-05-19, 2021-01-04, COVID 2020-03-13). + Il disaster-SL on-book −30% copre solo la coda estrema della posizione netta. +- **Caveat**: equity daily-step (lens Sharpe, come il canonico); l'ensemble di offset NON è + eseguibile live (gira una sola griglia) → serve solo come stima de-luckata; sim hourly senza + slippage/parziali. + +## Book 5-sleeve — stima de-luckata congiunta (approssimata) + +Ogni audit varia UN solo sleeve (gli altri restano canonici), quindi gli effetti non si sommano +esattamente; ordine di grandezza dell'eredità di fortuna sull'HOLD 2.46: TP01-ancora ~+0.10, +XS01-fase ~+0.17, SKH01-offset ~+0.5. **Stima onesta: HOLD ~1.9–2.1, FULL ~2.0–2.2, DD ~6% +(invariato)** — il book resta largamente positivo e diversificato a ogni combinazione testata, +ma il 2.46 è un massimo di ~24×10×23 configurazioni di ancora, non la stima centrale. + +## Regole codificate in narrativa (CLAUDE.md aggiornato) + +1. **I numeri headline di OGNI sleeve ancorato si citano con la banda d'ancora** (o come ensemble + di fase): vale già per TP01, ora anche per XS01 e SKH01 e per l'HOLD del book. +2. **Un gate di selezione (es. DD<30%) va verificato sulla banda di fase, non sull'ancora + canonica** — SKH01-V2-DD fu selezionata per una proprietà che tiene in 8/23 fasi. +3. Il candidato gate `anchor_luck_band()` in altlib sale di priorità: tre sleeve su tre ancorati + mostrano la stessa firma, e il deflated-Sharpe non conta questo multiple-testing. + +## Follow-up (non eseguiti, da valutare) + +- **Rivalutare il peso 25% di SKH01 nel book live Deribit**: l'aspettativa onesta della gamba SKH + è ≈ fase-mediana + path orario (blend HOLD ~0.72, non 1.17) con gap-through-stop reale nei + crash. L'uplift resta positivo a ogni fase → non c'è motivo di rimuoverla, ma il sizing fu + deciso su numeri best-of-23. +- Cadenza cron a 230m (o trigger sui confini di barra) per chiudere il gap di path orario. +- `anchor_luck_band()` in `altlib.py` come gate standard per candidati ancorati. +- Audit ora-del-giorno di XS01 quando esisterà un feed HL intraday certificato. + +**Stato:** XS01/SKH01/pesi/book live INVARIATI (nessun cambio operativo da questo audit — è un +audit di reporting e di gate, e ogni cambio pesi dovrà passare `weights_tilt_null` + +selection-on-holdout). Test suite 168 verdi. Script audit committati. diff --git a/scripts/research/r0702_anchor_skh01.py b/scripts/research/r0702_anchor_skh01.py new file mode 100644 index 0000000..5b3428b --- /dev/null +++ b/scripts/research/r0702_anchor_skh01.py @@ -0,0 +1,513 @@ +#!/usr/bin/env python +"""r0702_anchor_skh01.py — AUDIT timing-luck della FASE della griglia dual-TF di SKH01-V2-DD. + +CONTESTO (diario 2026-07-02-timing-crt-wave.md): l'hold-out di TP01 (Sharpe 0.31) si e' +rivelato la MIGLIORE delle 24 ancore orarie possibili (mediana 0.04) — timing-luck +dell'ancora, dimensione di multiple-testing non contata dal deflated-Sharpe. SKH01 e' nel +BOOK LIVE Deribit (TP01+SKH01 75/25) -> audit analogo, con rilevanza operativa diretta. + +SPAZIO DI LUCK: l'origine della griglia. SKH01 resampla il 5m certificato a 230m (exec) e +690m (segnale) con origin='epoch'. Shiftando l'origin di k x 5m si spostano COERENTEMENTE +entrambe le griglie (690 = 3 x 230 -> i confini HTF restano sottoinsieme dei LTF); la +struttura congiunta si ripete con periodo 690m = 138 step da 5m. Campioniamo 23 OFFSET +UNIFORMI (ogni 30m) su [0, 690), dichiarati A PRIORI — offset 0 = canonico. Parametri +IDENTICI (SKH01_V2_DD) su tutti gli offset; nessuna selezione. + +NB strutturale: 230m e 690m NON dividono 24h -> la griglia MIGRA attraverso la giornata +(nessun offset "possiede" un'ora del giorno) — l'ancora e' meno "speciale" a priori di +quella daily di TP01, ma va misurato. + +COSA FA: + 1. SANITY: a offset 0 riproduce ESATTAMENTE la serie daily di _skyhook_returns() + (max|dif| ~0) e i confini HTF c LTF per ogni offset. + 2. Per 23 offset x {BTC,ETH}: Sharpe FULL/IS/HOLD (equity daily-step, convenzione + canonica), maxDD (equity harness), n trade -> tabella + min/med/max + pctl di off 0. + 3. GATE ammissione ri-valutati alla mediana/peggiore: (a) maxDD<30% entrambi gli asset, + (b) minFull ~0.99 / minHold ~1.26, (c) blend 0.75*TP01+0.25*SKH hold-out (baseline + al.tp01_baseline_daily; claim 0.31->1.17), (d) corr a TP01 (~0.09). + 4. BOOK 5-sleeve 33/15/12/20/20 (combine_outer, outer-join rinormalizzato, era crypto): + HOLD/FULL con SKH a ogni offset + ensemble degli offset (lens de-luckato, NON eseguibile). + 5. BOOTSTRAP (block ~20g, maniera scettico r0702_skeptic_offset): P(un offset qualsiasi + mostri uno spike >= quello del canonico sull'hold-out del blend). + 6. RILEVANZA LIVE: il cron del book e' ORARIO (crontab 0 * * * * -> scripts/cron_book.sh) + ma i confini 230m NON sono allineati all'ora (230 mod 60 = 50 -> ciclo di 6 barre, + ritardo confine->prossima-ora in {0,10,20,30,40,50} min). Quantifica la distribuzione + e l'impatto: re-sim con fill al close 5m del PROSSIMO multiplo orario dopo la chiusura + della barra di segnale/exit (entry E exit software ritardati; detection dei trade + INVARIATA -> isola il puro effetto prezzo-esecuzione). Sanity: la modalita' canonica + della re-sim riproduce backtest_signals bit-exact. + +CAVEAT DICHIARATI: + - equity daily-step (lens Sharpe) come il canonico — non e' mark-to-market intrabar; + - costi di ribilanciamento del book 230m gia' flaggati a deploy (diario skyhook); + - l'ensemble di offset NON e' eseguibile live (una sola griglia gira): serve SOLO come + stima de-luckata; + - la sim 'hourly' modella fill a close 5m del multiplo orario (cron a minuto 0, runtime + del job trascurato) e NON modella slippage/parziali. + +TECNICA: mai .view('int64') su tz-aware (epoca esplicita in ms); htf_features/merge_htf_to_ltf +RIUSATE via skyhook_entries (importate, non riscritte). Vincoli: nessun file toccato fuori da +questo script; niente commit. Runtime ~3-6 min (46 run skyhook + book + bootstrap). +""" +from __future__ import annotations + +import sys +import time +from functools import lru_cache +from pathlib import Path + +import numpy as np +import pandas as pd + +ROOT = Path("/opt/docker/PythagorasGoal") +sys.path.insert(0, str(ROOT / "scripts" / "research" / "alt")) +sys.path.insert(0, str(ROOT)) + +import altlib as al # noqa: E402 +from src.backtest.harness import backtest_signals # noqa: E402 +from src.data.downloader import load_data # noqa: E402 +from src.portfolio.portfolio import combine_outer, to_daily # noqa: E402 +from src.strategies.skyhook import ( # noqa: E402 + HTF_MIN, LTF_MIN, SKH01_V2_DD, skyhook_entries) + +HOLDOUT = al.HOLDOUT +ASSETS = ("BTC", "ETH") +OFFSETS = tuple(range(0, 690, 30)) # 23 offset a priori (ogni 30m su [0,690)), 0 = canonico +MS5 = 300_000 +MSH = 3_600_000 +MS_LTF = LTF_MIN * 60_000 +BLEND_W = {"TP": 0.75, "SKH": 0.25} +BOOK_W = {"TP": 0.33, "XS": 0.15, "VRP": 0.12, "SKH": 0.20, "GTAA": 0.20} +B_BOOT = 4000 + + +# --------------------------------------------------------------------------- +# Dati + resample con fase spostata (identico a skyhook.resample_5m + offset) +# --------------------------------------------------------------------------- +@lru_cache(maxsize=4) +def get5m(asset: str) -> pd.DataFrame: + df = load_data(asset, "5m").reset_index(drop=True) + if "datetime" not in df.columns: + df["datetime"] = pd.to_datetime(df["timestamp"], unit="ms", utc=True) + return df + + +def resample_off(df5: pd.DataFrame, minutes: int, off: int) -> pd.DataFrame: + """Identico a skyhook.resample_5m ma con griglia spostata di `off` minuti: + origin='epoch' + offset -> confini a epoch + off + n*minutes. Con `off` comune a + 230m e 690m (690=3x230) i confini HTF restano sottoinsieme dei confini LTF.""" + g = df5[["timestamp", "open", "high", "low", "close", "volume"]].copy() + g.index = pd.to_datetime(g["timestamp"], unit="ms", utc=True) + out = (g.resample(f"{minutes}min", label="left", closed="left", origin="epoch", + offset=pd.Timedelta(minutes=off)) + .agg({"open": "first", "high": "max", "low": "min", + "close": "last", "volume": "sum"}) + .dropna(subset=["open"])) + out["datetime"] = out.index + 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", "datetime"]] + + +# --------------------------------------------------------------------------- +# Run per (asset, offset) — pipeline IDENTICA a sleeves._skyhook_returns +# --------------------------------------------------------------------------- +_CACHE: dict = {} + + +def run_asset(asset: str, off: int): + """(daily equity-step series, Metrics, ltf, entries) per un asset a fase `off`.""" + key = (asset, off) + if key in _CACHE: + return _CACHE[key] + df5 = get5m(asset) + ltf = resample_off(df5, LTF_MIN, off) + htf = resample_off(df5, HTF_MIN, off) + # confini HTF c confini LTF: vale per TUTTE le barre tranne al piu' la PRIMA (parziale: + # se il feed parte a meta' di un bin 690m il sub-bin 230m con la stessa label puo' essere + # vuoto). Identico al comportamento del canonico build_frames (origin='epoch'). + assert np.isin(htf["timestamp"].values[1:], ltf["timestamp"].values).all(), \ + f"confini HTF NON sottoinsieme dei LTF (asset={asset}, off={off})" + ent = skyhook_entries(ltf, htf, SKH01_V2_DD) # riusa htf_features/merge_htf_to_ltf + m = backtest_signals(ltf, ent, fee_rt=0.001, leverage=1.0, asset=asset, tf="230m") + s = pd.Series(m.equity, index=pd.DatetimeIndex(pd.to_datetime(m.eq_index, utc=True))) + daily = s.resample("1D").last().ffill().pct_change().dropna() + _CACHE[key] = (daily, m, ltf, ent) + return _CACHE[key] + + +@lru_cache(maxsize=32) +def skh_port(off: int) -> pd.Series: + """Book 50/50 BTC+ETH daily-step alla fase `off` (convenzione di _skyhook_returns).""" + series = {a: run_asset(a, off)[0] for a in ASSETS} + J = pd.concat(series, axis=1, join="inner").fillna(0.0) + return pd.Series(0.5 * J["BTC"].values + 0.5 * J["ETH"].values, index=J.index) + + +def sh3(s: pd.Series) -> tuple[float, float, float]: + return (al._sh(s), al._sh(s[s.index < HOLDOUT]), al._sh(s[s.index >= HOLDOUT])) + + +def pctl_of_first(v: np.ndarray) -> float: + return float((v < v[0]).mean() + 0.5 * (v == v[0]).mean()) * 100 + + +# --------------------------------------------------------------------------- +# (1) SANITY +# --------------------------------------------------------------------------- +def sanity() -> None: + from src.portfolio.sleeves import _skyhook_returns + mine = skh_port(0) + ref = _skyhook_returns() + assert len(mine) == len(ref), f"sanity len: {len(mine)} vs {len(ref)}" + dmax = float(np.max(np.abs(mine.values - ref.values))) + assert dmax < 1e-12, f"sanity offset 0: max|dif| = {dmax:.2e}" + f, i, h = sh3(mine) + print(f"[SANITY] offset 0 == _skyhook_returns(): max|dif| = {dmax:.2e} " + f"su {len(mine)} giorni (FULL {f:.3f} / IS {i:.3f} / HOLD {h:.3f})") + for a in ASSETS: + _, m, _, _ = run_asset(a, 0) + print(f"[SANITY] {a} off=0: maxDD harness {m.max_dd:.1%}, trade {m.n_trades} " + f"(diario: BTC 21.4% / ETH 27.4%)") + + +# --------------------------------------------------------------------------- +# (6) LIVE — re-sim con fill al prossimo multiplo orario (path del cron) +# --------------------------------------------------------------------------- +def sim_equity(ltf: pd.DataFrame, ent: list, mode: str, + ts5_close: np.ndarray | None = None, + c5: np.ndarray | None = None) -> np.ndarray: + """Replica del loop di backtest_signals con prezzi d'esecuzione iniettabili. + mode='canonical': entry a close[i], exit AL LIVELLO sl/tp (SL prioritario) o close + -> DEVE riprodurre backtest_signals bit-exact (sanity della re-sim). + mode='barclose': stessi trade (stessa detection), fill entry/exit al CLOSE della barra + 230m di segnale/trigger -> quota dell'ottimismo 'fill al livello' senza cron. + mode='hourly': fill entry/exit al close 5m del PROSSIMO multiplo orario dopo la + chiusura della barra di segnale/trigger (path reale del cron 0 * * * *). + In tutti i modi le TRADE BOUNDARIES (barra entry, barra trigger, non-overlap) sono + identiche al canonico: cambia solo il prezzo d'esecuzione.""" + c = ltf["close"].values.astype(float) + h = ltf["high"].values.astype(float) + l = ltf["low"].values.astype(float) + n = len(c) + close_ts = ltf["timestamp"].values.astype(np.int64) + MS_LTF + + def px_hour(t: int) -> float: + hb = ((t + MSH - 1) // MSH) * MSH # prossimo multiplo orario >= t + j = np.searchsorted(ts5_close, hb, side="left") + return c5[min(j, len(c5) - 1)] + + initial = 1000.0 + capital = initial + equity = np.full(n, capital, dtype=float) + busy_until = -1 + for i in range(n): + e = ent[i] if i < len(ent) else None + if e is None or e.get("dir", 0) == 0: + equity[i] = capital + continue + if i <= busy_until: + equity[i] = capital + continue + direction = int(e["dir"]) + tp = e.get("tp"); sl = e.get("sl") + max_bars = int(e.get("max_bars") or 24) + + # entry price per modalita' + if mode == "canonical": + entry = c[i] + elif mode == "barclose": + entry = c[i] + else: # hourly + entry = px_hour(close_ts[i]) + + exit_idx = min(i + max_bars, n - 1) + exit_lvl = c[exit_idx] # default: time exit a close + hit_kind = "time" + for j in range(i + 1, min(i + max_bars + 1, n)): + hit_sl = sl is not None and ( + (direction == 1 and l[j] <= sl) or (direction == -1 and h[j] >= sl)) + hit_tp = tp is not None and ( + (direction == 1 and h[j] >= tp) or (direction == -1 and l[j] <= tp)) + if hit_sl: + exit_lvl, exit_idx, hit_kind = sl, j, "sl" + break + if hit_tp: + exit_lvl, exit_idx, hit_kind = tp, j, "tp" + break + exit_lvl, exit_idx = c[j], j + if mode == "canonical": + exit_price = exit_lvl # fill al livello (come harness) + elif mode == "barclose": + exit_price = c[exit_idx] # fill al close della barra trigger + else: + exit_price = px_hour(close_ts[exit_idx]) # fill al prossimo multiplo orario + + gross = (exit_price - entry) / entry * direction + net = gross - 0.001 # fee_rt 0.10%, leverage 1 + capital += capital * net + capital = max(capital, 1.0) + equity[i:exit_idx + 1] = capital + busy_until = exit_idx + _ = hit_kind + + # stessa forward-fill robusta del harness + last = initial + for k in range(n): + if equity[k] != last and equity[k] != initial: + last = equity[k] + else: + equity[k] = last + return equity + + +def live_delay_section() -> None: + print("\n" + "=" * 100) + print("(6) RILEVANZA LIVE — cron ORARIO (0 * * * *) vs confini 230m non allineati all'ora") + print("=" * 100) + # distribuzione del ritardo confine-230m -> prossima ora (struttura: 230 mod 60 = 50) + _, _, ltf0, _ = run_asset("BTC", 0) + close_ts = ltf0["timestamp"].values.astype(np.int64) + MS_LTF + delay_min = ((MSH - (close_ts % MSH)) % MSH) // 60_000 + vals, cnts = np.unique(delay_min, return_counts=True) + tot = cnts.sum() + print("ritardo chiusura-barra-230m -> prossimo run orario del cron:") + for v, cn in zip(vals, cnts): + print(f" {int(v):>3} min : {cn / tot:>6.1%}") + print(f" media {float(delay_min.mean()):.1f} min, max {int(delay_min.max())} min " + f"(ciclo di 6 barre = 23h: la griglia migra attraverso la giornata)") + + # impatto: re-sim canonico (sanity bit-exact) / barclose / hourly + print(f"\nimpatto sull'equity daily-step (offset 0, canonico; fill hourly = close 5m del " + f"prossimo multiplo orario dopo la chiusura della barra di segnale/exit):") + print(f"{'asset':<5} {'modo':<10} {'ShFULL':>7} {'ShIS':>7} {'ShHOLD':>7} {'maxDD':>7}") + dailies: dict[str, dict[str, pd.Series]] = {m: {} for m in ("canonical", "barclose", "hourly")} + for a in ASSETS: + _, m0, ltf, ent = run_asset(a, 0) + df5 = get5m(a) + ts5_close = df5["timestamp"].values.astype(np.int64) + MS5 + c5 = df5["close"].values.astype(float) + for mode in ("canonical", "barclose", "hourly"): + eq = sim_equity(ltf, ent, mode, ts5_close=ts5_close, c5=c5) + if mode == "canonical": + dmax = float(np.max(np.abs(eq - m0.equity))) + assert dmax < 1e-6, f"re-sim canonica != harness ({a}): max|dif|={dmax:.2e}" + print(f" [sanity] {a}: re-sim canonica == backtest_signals " + f"(max|dif equity| = {dmax:.2e})") + s = pd.Series(eq, index=pd.DatetimeIndex(pd.to_datetime(ltf["datetime"], utc=True))) + d = s.resample("1D").last().ffill().pct_change().dropna() + dailies[mode][a] = d + f, i_, h = sh3(d) + dd = al._dd_ret(d) + print(f"{a:<5} {mode:<10} {f:>7.3f} {i_:>7.3f} {h:>7.3f} {dd:>6.1%}") + print("\nbook 50/50 BTC+ETH per modo:") + print(f"{'modo':<10} {'ShFULL':>7} {'ShIS':>7} {'ShHOLD':>7} {'maxDD':>7}") + port_mode = {} + for mode in ("canonical", "barclose", "hourly"): + J = pd.concat(dailies[mode], axis=1, join="inner").fillna(0.0) + p = pd.Series(0.5 * J["BTC"].values + 0.5 * J["ETH"].values, index=J.index) + port_mode[mode] = p + f, i_, h = sh3(p) + print(f"{mode:<10} {f:>7.3f} {i_:>7.3f} {h:>7.3f} {al._dd_ret(p):>6.1%}") + dfull = al._sh(port_mode['hourly']) - al._sh(port_mode['canonical']) + dhold = (al._sh(port_mode['hourly'][port_mode['hourly'].index >= HOLDOUT]) + - al._sh(port_mode['canonical'][port_mode['canonical'].index >= HOLDOUT])) + print(f"\n-> delta hourly vs canonico: FULL {dfull:+.3f}, HOLD {dhold:+.3f} " + f"(barclose isola il fill-al-livello; hourly aggiunge il ritardo 0-50 min)") + + +# --------------------------------------------------------------------------- +# Main +# --------------------------------------------------------------------------- +def main() -> None: + t0 = time.time() + print("=" * 100) + print("r0702 — SKH01-V2-DD: timing-luck della FASE della griglia dual-TF 230m/690m") + print(f"23 offset a priori (ogni 30m su [0,690)), parametri IDENTICI, fee 0.10% RT, " + f"HOLD-OUT >= {HOLDOUT.date()}") + print("=" * 100) + + sanity() + + # ---- (2) per-offset x asset ------------------------------------------- + rows = [] + for off in OFFSETS: + rec = {"off": off} + for a in ASSETS: + d, m, _, _ = run_asset(a, off) + f, i_, h = sh3(d) + rec[f"{a}_full"] = f; rec[f"{a}_is"] = i_; rec[f"{a}_hold"] = h + rec[f"{a}_dd"] = m.max_dd; rec[f"{a}_ntr"] = m.n_trades + p = skh_port(off) + f, i_, h = sh3(p) + rec["P_full"] = f; rec["P_is"] = i_; rec["P_hold"] = h + rec["P_dd"] = al._dd_ret(p) + rec["minFull"] = min(rec["BTC_full"], rec["ETH_full"]) + rec["minHold"] = min(rec["BTC_hold"], rec["ETH_hold"]) + rows.append(rec) + print(f" [{time.time()-t0:5.0f}s] offset {off:>3}m fatto " + f"(minFull {rec['minFull']:+.2f}, minHold {rec['minHold']:+.2f}, " + f"DD {rec['BTC_dd']:.0%}/{rec['ETH_dd']:.0%})") + T = pd.DataFrame(rows).set_index("off") + + print("\n--- (2) PER-OFFSET (equity daily-step, come il canonico) ---") + for a in ASSETS: + print(f"\n{a}:") + print(f"{'off':>4} {'ShFULL':>7} {'ShIS':>7} {'ShHOLD':>7} {'maxDD':>7} {'trade':>6}") + for off, r in T.iterrows(): + tag = " <- canonico" if off == 0 else "" + print(f"{off:>4} {r[f'{a}_full']:>7.3f} {r[f'{a}_is']:>7.3f} " + f"{r[f'{a}_hold']:>7.3f} {r[f'{a}_dd']:>6.1%} {int(r[f'{a}_ntr']):>6}{tag}") + print(" min/med/max [pctl canonico]:") + for col, lbl in ((f"{a}_full", "ShFULL"), (f"{a}_is", "ShIS"), + (f"{a}_hold", "ShHOLD"), (f"{a}_dd", "maxDD")): + v = T[col].values + print(f" {lbl:<7} {v.min():>7.3f} / {np.median(v):>7.3f} / {v.max():>7.3f} " + f" [off0 = {v[0]:.3f}, {pctl_of_first(v):.0f} pctl]") + + print(f"\nBOOK 50/50 (portafoglio SKH01 standalone):") + print(f"{'off':>4} {'ShFULL':>7} {'ShIS':>7} {'ShHOLD':>7} {'maxDD':>7}") + for off, r in T.iterrows(): + tag = " <- canonico" if off == 0 else "" + print(f"{off:>4} {r.P_full:>7.3f} {r.P_is:>7.3f} {r.P_hold:>7.3f} {r.P_dd:>6.1%}{tag}") + for col, lbl in (("P_full", "ShFULL"), ("P_is", "ShIS"), ("P_hold", "ShHOLD"), + ("P_dd", "maxDD"), ("minFull", "minFull"), ("minHold", "minHold")): + v = T[col].values + print(f" {lbl:<8} min {v.min():>7.3f} / med {np.median(v):>7.3f} / max {v.max():>7.3f} " + f" [off0 = {v[0]:.3f}, {pctl_of_first(v):.0f} pctl]") + + # ---- (3) gate di ammissione ------------------------------------------ + print("\n--- (3) GATE DI AMMISSIONE ri-valutati sui 23 offset ---") + viol_b = T["BTC_dd"] >= 0.30 + viol_e = T["ETH_dd"] >= 0.30 + viol = viol_b | viol_e + print(f"(a) maxDD<30%: violazioni su 23 offset: BTC {int(viol_b.sum())}, " + f"ETH {int(viol_e.sum())}, almeno-un-asset {int(viol.sum())} " + f"({[int(o) for o in T.index[viol]]})") + print(f" mediana DD: BTC {T['BTC_dd'].median():.1%}, ETH {T['ETH_dd'].median():.1%}; " + f"peggiore: BTC {T['BTC_dd'].max():.1%} (off {int(T['BTC_dd'].idxmax())}), " + f"ETH {T['ETH_dd'].max():.1%} (off {int(T['ETH_dd'].idxmax())})") + print(f"(b) minFull (canonico +0.99): mediana {T['minFull'].median():+.2f}, " + f"peggiore {T['minFull'].min():+.2f} (off {int(T['minFull'].idxmin())})") + print(f" minHold (canonico +1.26): mediana {T['minHold'].median():+.2f}, " + f"peggiore {T['minHold'].min():+.2f} (off {int(T['minHold'].idxmin())})") + + # (c) blend 0.75 TP01 + 0.25 SKH — TP01 baseline canonico FISSO + B = al.tp01_baseline_daily() + b_hold = al._sh(B[B.index >= HOLDOUT]) + blends_hold, blends_full, corrs = [], [], [] + blend_series_hold = {} + for off in OFFSETS: + s = skh_port(off) + bl = combine_outer({"TP": B, "SKH": s}, BLEND_W) + bl = bl[bl.index >= B.index.min()] + blends_hold.append(al._sh(bl[bl.index >= HOLDOUT])) + blends_full.append(al._sh(bl)) + blend_series_hold[off] = bl[bl.index >= HOLDOUT] + J = pd.concat({"TP": B, "SKH": s}, axis=1, join="inner").dropna() + corrs.append(float(J["TP"].corr(J["SKH"]))) + bh = np.array(blends_hold); bf = np.array(blends_full); co = np.array(corrs) + print(f"(c) blend 0.75*TP01+0.25*SKH (TP01 h=0 canonico; suo HOLD da solo = {b_hold:.2f}):") + print(f" Sharpe HOLD blend: off0 {bh[0]:.2f} | min {bh.min():.2f} / med " + f"{np.median(bh):.2f} / max {bh.max():.2f} [off0 al {pctl_of_first(bh):.0f} pctl]" + f" (claim: 0.31 -> 1.17)") + print(f" uplift HOLD vs TP01 solo: off0 {bh[0]-b_hold:+.2f} | min {bh.min()-b_hold:+.2f} " + f"/ med {np.median(bh)-b_hold:+.2f} / max {bh.max()-b_hold:+.2f}") + print(f" Sharpe FULL blend: off0 {bf[0]:.2f} | min {bf.min():.2f} / med " + f"{np.median(bf):.2f} / max {bf.max():.2f}") + print(f"(d) corr(SKH, TP01) full: off0 {co[0]:+.3f} | min {co.min():+.3f} / med " + f"{np.median(co):+.3f} / max {co.max():+.3f} (claim ~0.09)") + + med_minfull = T["minFull"].median(); med_minhold = T["minHold"].median() + med_uplift = float(np.median(bh) - b_hold) + med_dd_ok = T["BTC_dd"].median() < 0.30 and T["ETH_dd"].median() < 0.30 + worst_ok = (not viol.any()) and T["minFull"].min() > 0 and (bh.min() - b_hold) > 0 + print("\nVERDETTO ammissione alla fase MEDIANA: " + f"DD<30% {'PASS' if med_dd_ok else 'FAIL'} alla mediana" + f" (violazioni puntuali {int(viol.sum())}/23), minFull {med_minfull:+.2f}, " + f"minHold {med_minhold:+.2f}, uplift blend HOLD {med_uplift:+.2f}, " + f"corr {np.median(co):+.2f}") + print(f" al PEGGIORE dei 23: DD {'PASS tutti' if not viol.any() else 'FAIL su ' + str(int(viol.sum())) + ' offset'}, " + f"minFull {T['minFull'].min():+.2f}, minHold {T['minHold'].min():+.2f}, " + f"uplift blend HOLD {bh.min()-b_hold:+.2f} " + f"-> {'regge anche al peggiore' if worst_ok else 'NON regge al peggiore'}") + + # ---- (4) book 5-sleeve ------------------------------------------------- + print("\n--- (4) BOOK 5-SLEEVE (TP 33 / XS 15 / VRP 12 / SKH 20 / GTAA 20) ---") + from src.portfolio.sleeves import (_gtaa_daily_returns, _tp01_returns, + _vrp_combo_returns, _xsec_returns) + tp_d = to_daily(_tp01_returns()) + cols_fixed = dict(TP=tp_d, XS=to_daily(_xsec_returns()), + VRP=to_daily(_vrp_combo_returns()), GTAA=to_daily(_gtaa_daily_returns())) + lo = tp_d.index.min() + bkh, bkf = [], [] + for off in OFFSETS: + s = combine_outer(dict(SKH=skh_port(off), **cols_fixed), BOOK_W, lo=lo) + bkh.append(al._sh(s[s.index >= HOLDOUT])); bkf.append(al._sh(s)) + bkh = np.array(bkh); bkf = np.array(bkf) + ens = pd.concat({o: skh_port(o) for o in OFFSETS}, axis=1).mean(axis=1) + s_ens = combine_outer(dict(SKH=ens, **cols_fixed), BOOK_W, lo=lo) + i_med_h = int(np.argsort(bkh)[len(bkh) // 2]); i_worst_h = int(np.argmin(bkh)) + print(f"HOLD: off0 {bkh[0]:.2f} | min {bkh.min():.2f} (off {OFFSETS[i_worst_h]}) / med " + f"{np.median(bkh):.2f} (off {OFFSETS[i_med_h]}) / max {bkh.max():.2f} " + f"[off0 al {pctl_of_first(bkh):.0f} pctl] | ENSEMBLE " + f"{al._sh(s_ens[s_ens.index >= HOLDOUT]):.2f}") + print(f"FULL: off0 {bkf[0]:.2f} | min {bkf.min():.2f} / med {np.median(bkf):.2f} / max " + f"{bkf.max():.2f} [off0 al {pctl_of_first(bkf):.0f} pctl] | ENSEMBLE {al._sh(s_ens):.2f}") + print("(l'ensemble di offset NON e' eseguibile live — una sola griglia gira: e' solo la " + "stima de-luckata)") + + # ---- (5) bootstrap alla maniera dello scettico ------------------------- + print("\n--- (5) BOOTSTRAP (block) — lo spike del canonico sull'hold-out del blend ---") + Mdf = pd.concat(blend_series_hold, axis=1, join="inner").dropna() + M = Mdf.values + def _sh_cols(R: np.ndarray) -> np.ndarray: + mu = R.mean(axis=1); sd = R.std(axis=1) + return np.where(sd > 0, mu / sd, 0.0) * np.sqrt(365.25) + sh_obs = np.array([al._sh(Mdf[c]) for c in Mdf.columns]) + g0_obs = float(sh_obs[0] - np.median(sh_obs[1:])) + corrM = np.corrcoef(M.T); iu = np.triu_indices(M.shape[1], 1) + print(f"hold-out: {M.shape[0]} giorni x {M.shape[1]} offset; Sh blend off0 {sh_obs[0]:.3f}, " + f"mediana altri {np.median(sh_obs[1:]):.3f}, spike osservato g0 = {g0_obs:+.3f}") + print(f"corr daily fra i 23 blend (hold-out): mediana {np.median(corrM[iu]):.3f}, " + f"min {corrM[iu].min():.3f}") + n, K = M.shape + for blk in (10, 20, 40): + rng = np.random.default_rng(42 + blk) + nblocks = int(np.ceil(n / blk)) + gmaxs, g0s = [], [] + done = 0 + while done < B_BOOT: + b = min(500, B_BOOT - done) + starts = rng.integers(0, n, size=(b, nblocks)) + idx = (starts[:, :, None] + np.arange(blk)[None, None, :]) % n + idx = idx.reshape(b, -1)[:, :n] + R = M[idx] # (b, n, K) + Sh = np.stack([_sh_cols(R[:, :, k]) for k in range(K)], axis=1) + med_others = np.empty_like(Sh) + for k in range(K): + med_others[:, k] = np.median(np.delete(Sh, k, axis=1), axis=1) + g = Sh - med_others + gmaxs.append(g.max(axis=1)); g0s.append(g[:, 0]) + done += b + gmax = np.concatenate(gmaxs); g0 = np.concatenate(g0s) + print(f" block={blk:>2}: P(spike di UN offset qualsiasi >= {g0_obs:+.2f}) = " + f"{float(np.mean(gmax >= g0_obs)):.3f} | P(g0<=0) = {float(np.mean(g0 <= 0)):.3f} " + f"| CI95 g0 [{np.percentile(g0, 2.5):+.2f},{np.percentile(g0, 97.5):+.2f}]") + + # ---- (6) live ---------------------------------------------------------- + live_delay_section() + + # ---- (7) caveat -------------------------------------------------------- + print("\n--- (7) CAVEAT ---") + print("- equity daily-step (lens Sharpe), stessa convenzione del canonico;") + print("- costi di ribilanciamento del book 230m gia' flaggati a deploy (diario skyhook);") + print("- ensemble di offset NON eseguibile live (una sola griglia gira) -> solo stima de-luckata;") + print("- sim 'hourly': fill a close 5m del multiplo orario (cron minuto 0), niente slippage/parziali;") + print("- nessuna selezione: 23 offset uniformi dichiarati a priori, parametri identici ovunque.") + print(f"\nFatto in {time.time()-t0:.0f}s.") + + +if __name__ == "__main__": + main() diff --git a/scripts/research/r0702_anchor_xs01.py b/scripts/research/r0702_anchor_xs01.py new file mode 100644 index 0000000..4c30d88 --- /dev/null +++ b/scripts/research/r0702_anchor_xs01.py @@ -0,0 +1,378 @@ +#!/usr/bin/env python +"""r0702_anchor_xs01.py — TIMING-LUCK della FASE del ciclo di ribilanciamento di XS01. + +XS01 (`src/portfolio/sleeves._xsec_returns`, XS_CFG H=10) ribilancia ogni 10 giorni quando +`i % H == 0` con `i` = indice di riga della matrice prezzi inner-joined (start 2024-01-01). +La FASE del ciclo (quale dei 10 giorni possibili) e' quindi un artefatto della prima riga +dei dati — esattamente come l'ancora oraria di TP01 (vedi r0702_tp01_offset.py + +r0702_skeptic_offset.py, diario 2026-07-02-timing-crt-wave.md). Spazio di luck: 10 fasi. + +Questo script (metodologia identica all'audit TP01, ZERO tuning per-fase): + 1. REPLICA `_xsec_returns` con parametro `phase` (i % H == phase). SANITY OBBLIGATORIO: + phase=0 deve riprodurre ESATTAMENTE la serie di `_xsec_returns()` (max|dif| ~ 0). + Il gate di dispersione (percentile ESPANDENTE causale) e' ricalcolato PER FASE: + disp_hist accumula solo nei giorni di ribilanciamento di QUELLA fase (replica fedele). + 2. Tabella delle 10 fasi: Sharpe/CAGR/maxDD FULL (serie dal 2024) e HOLD-OUT 2025-26; + min/mediana/max + percentile della fase canonica (phase=0). + 3. ENSEMBLE delle 10 fasi (media dei ritorni = 1/10 del capitale per fase) vs canonica. + 4. IMPATTO SUL BOOK 5-sleeve (TP 33 / XS 15 / VRP 12 / SKH 20 / GTAA 20, combine_outer + con pesi rinormalizzati, attivazione era crypto): XS canonica vs fase mediana vs fase + peggiore vs ensemble vs senza-XS -> HOLD e FULL del book. + 5. GATE DI AMMISSIONE: plateau (lookback singoli/blend, disp_pct 15-35) ricalcolato alla + fase MEDIANA e alla canonica: i numeri di ammissione (FULL 1.50/HOLD 1.71/DD 11%) reggono? + 6. BOOTSTRAP alla maniera dello scettico: block-bootstrap congiunto delle 10 fasi + sull'hold-out -> P(una fase qualsiasi mostri uno spike >= quello della canonica) + + CI95 (storia ~2.5y -> quantifica l'ampiezza). + +Tecnica: mai .view su tz-aware (epoca via pd.to_datetime unit='ms', come l'originale); +posizioni shiftate come nell'originale (gross[1:] = W[:-1]*dret[1:]); vol-target rolling +ricalcolato per fase sulla PROPRIA serie netta. Nessun file toccato fuori da questo script. +Runtime ~2-4 min (il grosso e' SKH01 dal 5m per il book). +""" +from __future__ import annotations + +import sys +from pathlib import Path + +import numpy as np +import pandas as pd + +ROOT = Path("/opt/docker/PythagorasGoal") +sys.path.insert(0, str(ROOT)) + +from src.portfolio.portfolio import HOLDOUT, combine_outer, to_daily # noqa: E402 +from src.portfolio.sleeves import (_HL_DIR, XS_CFG, XS_UNIVERSE, # noqa: E402 + _xsec_returns) + +RNG_SEED = 42 +B_BOOT = 4000 +H = XS_CFG["H"] # 10 -> 10 fasi possibili +FULL_START = pd.Timestamp("2024-01-01", tz="UTC") + + +# --------------------------------------------------------------------------- +# Metriche (convenzioni identiche ad altlib._sh/_dd_ret e a r0702_tp01_offset.dmetrics) +# --------------------------------------------------------------------------- +def _sh(s) -> float: + r = np.asarray(pd.Series(s).dropna().values, float) + return float(np.mean(r) / np.std(r) * np.sqrt(365.25)) if len(r) > 2 and np.std(r) > 0 else 0.0 + + +def _dd(s) -> float: + eq = np.cumprod(1.0 + np.asarray(pd.Series(s).dropna().values, float)) + pk = np.maximum.accumulate(eq) + return float(np.max((pk - eq) / pk)) if len(eq) else 0.0 + + +def _cagr(s) -> float: + r = np.asarray(pd.Series(s).dropna().values, float) + eq = float(np.prod(1.0 + r)) + yrs = len(r) / 365.25 + return eq ** (1 / yrs) - 1 if eq > 0 and yrs > 0 else -1.0 + + +def dmetrics(s: pd.Series) -> dict: + s = s.dropna() + ho = s[s.index >= HOLDOUT] + return dict(sh_full=_sh(s), cagr_full=_cagr(s), dd_full=_dd(s), + sh_hold=_sh(ho), cagr_hold=_cagr(ho), dd_hold=_dd(ho), n=len(s)) + + +def pctl_of(v: np.ndarray, x: float) -> float: + return float((v < x).mean() + 0.5 * (v == x).mean()) * 100.0 + + +# --------------------------------------------------------------------------- +# Prezzi: caricamento IDENTICO a _xsec_returns (stesso ordine di operazioni) +# --------------------------------------------------------------------------- +_PX_CACHE: pd.DataFrame | None = None + + +def load_prices() -> pd.DataFrame: + global _PX_CACHE + if _PX_CACHE is not None: + return _PX_CACHE + cols = {} + for sym in XS_UNIVERSE: + p = _HL_DIR / f"hl_{sym.lower()}_1d.parquet" + if not p.exists(): + continue + d = pd.read_parquet(p) + cols[sym] = pd.Series(d["close"].values.astype(float), + index=pd.to_datetime(d["timestamp"], unit="ms", utc=True)) + if len(cols) < 10: + raise FileNotFoundError("universo Hyperliquid XS01 incompleto") + _PX_CACHE = pd.concat(cols, axis=1, join="inner").sort_index().dropna() + return _PX_CACHE + + +# --------------------------------------------------------------------------- +# REPLICA di _xsec_returns con parametro `phase` (+ override cfg per il plateau). +# Codice copiato 1:1 da src/portfolio/sleeves._xsec_returns; UNICA differenza: +# `i % H == 0` -> `i % H == phase`. Il disp_hist (percentile espandente causale) +# accumula SOLO nei giorni di ribilanciamento della fase -> ricalcolato per fase. +# --------------------------------------------------------------------------- +def xs_phase(phase: int, lookbacks=None, disp_pct=None) -> pd.Series: + cfg = dict(XS_CFG) + if lookbacks is not None: + cfg["lookbacks"] = lookbacks + if disp_pct is not None: + cfg["disp_pct"] = disp_pct + C = load_prices() + px = C.values + n, A = px.shape + lookbacks_, Hc, k, mode, tv = (cfg["lookbacks"], cfg["H"], cfg["k"], + cfg["mode"], cfg["target_vol"]) + disp_pct_ = cfg.get("disp_pct", 0) + minhist = cfg.get("disp_minhist", 20) + mlb = max(lookbacks_) + dret = np.vstack([np.zeros(A), px[1:] / px[:-1] - 1.0]) + W = np.zeros((n, A)) + w = np.zeros(A) + disp_hist = [] + for i in range(n): + if i >= mlb and i % Hc == phase: + rLs = [px[i] / px[i - L] - 1.0 for L in lookbacks_] + disp_i = float(np.mean([r.std() for r in rLs])) + thr = (np.percentile(disp_hist, disp_pct_) + if (disp_pct_ > 0 and len(disp_hist) >= minhist) else -np.inf) + if disp_i >= thr: + score = np.zeros(A) + cnt = 0 + for rL in rLs: + sd = rL.std() + if sd > 0: + score += (rL - rL.mean()) / sd + cnt += 1 + if cnt: + score /= cnt + order = np.argsort(score) + w = np.zeros(A) + lo, hi = order[:k], order[-k:] + if mode == "mom": + w[hi] = 0.5 / k + w[lo] = -0.5 / k + else: + w[lo] = 0.5 / k + w[hi] = -0.5 / k + else: + w = np.zeros(A) + disp_hist.append(disp_i) + W[i] = w + gross = np.zeros(n) + gross[1:] = np.sum(W[:-1] * dret[1:], axis=1) # posizioni SHIFTATE (held t+1) + turn = np.zeros(n) + turn[0] = np.abs(W[0]).sum() + turn[1:] = np.abs(np.diff(W, axis=0)).sum(axis=1) + net = gross - turn * (0.001 / 2.0) + s = pd.Series(net, index=C.index) + rv = s.rolling(30, min_periods=15).std().shift(1) * np.sqrt(365.25) + scale = np.clip(np.nan_to_num(tv / rv.replace(0, np.nan).values, nan=0.0), 0, 3.0) + return pd.Series(s.values * scale, index=C.index) + + +# --------------------------------------------------------------------------- +# 1. SANITY: replica a fase canonica == _xsec_returns() (bit-exact) +# --------------------------------------------------------------------------- +def sanity() -> None: + ref = _xsec_returns() + mine = xs_phase(0) + assert len(mine) == len(ref) and mine.index.equals(ref.index), "SANITY index mismatch" + dmax = float(np.max(np.abs(mine.values - ref.values))) + assert dmax == 0.0, f"SANITY FAIL: replica phase=0 != _xsec_returns (max|dif|={dmax:.3e})" + m = dmetrics(ref) + print(f"[SANITY] replica phase=0 == _xsec_returns(): OK (max|dif| = {dmax:.1e}, " + f"n={len(ref)}, start {ref.index.min().date()})") + print(f"[SANITY] canonica oggi: FULL Sh {m['sh_full']:.3f} / HOLD {m['sh_hold']:.3f} / " + f"DD {m['dd_full']:.1%} (dichiarati all'ammissione: 1.50 / 1.71 / 11% — dati mossi da allora)") + + +# --------------------------------------------------------------------------- +# 6. BOOTSTRAP alla maniera dello scettico (r0702_skeptic_offset.block_boot_stats) +# --------------------------------------------------------------------------- +def _sh_mat(R: np.ndarray) -> np.ndarray: + mu = R.mean(axis=1) + sd = R.std(axis=1) + return np.where(sd > 0, mu / sd, 0.0) * np.sqrt(365.25) + + +def block_boot(M: np.ndarray, B: int, block: int, rng) -> dict: + n, K = M.shape + nblocks = int(np.ceil(n / block)) + g0s, gmaxs, meds, sh0s = [], [], [], [] + done = 0 + while done < B: + b = min(500, B - done) + starts = rng.integers(0, n, size=(b, nblocks)) + idx = (starts[:, :, None] + np.arange(block)[None, None, :]) % n + idx = idx.reshape(b, -1)[:, :n] + R = M[idx] # (b, n, K) + Sh = np.stack([_sh_mat(R[:, :, kk]) for kk in range(K)], axis=1) + gg = np.empty_like(Sh) + for h in range(K): + gg[:, h] = Sh[:, h] - np.median(np.delete(Sh, h, axis=1), axis=1) + g0s.append(gg[:, 0]) + gmaxs.append(gg.max(axis=1)) + meds.append(np.median(Sh, axis=1)) + sh0s.append(Sh[:, 0]) + done += b + return dict(g0=np.concatenate(g0s), gmax=np.concatenate(gmaxs), + med=np.concatenate(meds), sh0=np.concatenate(sh0s)) + + +# --------------------------------------------------------------------------- +# Main +# --------------------------------------------------------------------------- +def main() -> None: + print("=" * 100) + print("r0702 — XS01 rebalance PHASE timing-luck: 10 fasi del ciclo H=10 " + "(metodologia = audit ancore TP01)") + print(f"XS_CFG={XS_CFG} universo={len(load_prices().columns)} major HL " + f"HOLD-OUT >= {HOLDOUT.date()}") + print("=" * 100) + + sanity() + + # ---- (2) tabella delle 10 fasi ---------------------------------------- + phases = {p: xs_phase(p) for p in range(H)} + T = pd.DataFrame({p: dmetrics(s) for p, s in phases.items()}).T + print("\n--- (2) PER-FASE (parametri IDENTICI, zero tuning; fase = i % 10 del ciclo) ---") + print(f"{'fase':>4} {'ShFULL':>7} {'CAGRf':>7} {'DDfull':>7} {'ShHOLD':>7} " + f"{'CAGRh':>7} {'DDhold':>7}") + for p, r in T.iterrows(): + tag = " <- canonica" if p == 0 else "" + print(f"{p:>4} {r.sh_full:>7.3f} {r.cagr_full:>6.1%} {r.dd_full:>6.1%} " + f"{r.sh_hold:>7.3f} {r.cagr_hold:>6.1%} {r.dd_hold:>6.1%}{tag}") + print("\nDistribuzione fra le 10 fasi (min / mediana / max / std) [percentile della canonica]:") + for col, lbl in (("sh_full", "Sharpe FULL"), ("sh_hold", "Sharpe HOLD"), + ("cagr_full", "CAGR FULL"), ("dd_full", "maxDD FULL"), + ("dd_hold", "maxDD HOLD")): + v = T[col].values.astype(float) + print(f" {lbl:<14} {v.min():>7.3f} / {np.median(v):>7.3f} / {v.max():>7.3f} " + f"/ std {v.std():.3f} canonica al {pctl_of(v, v[0]):.0f}° pctl (val {v[0]:.3f})") + + hold_v = T["sh_hold"].values.astype(float) + full_v = T["sh_full"].values.astype(float) + med_hold = float(np.median(hold_v)) + p_med = int(min(range(H), key=lambda p: (abs(hold_v[p] - med_hold), p))) + p_worst = int(np.argmin(hold_v)) + p_best = int(np.argmax(hold_v)) + print(f"\n fase MEDIANA (per ShHOLD) = {p_med} | PEGGIORE = {p_worst} | MIGLIORE = {p_best}") + + # correlazione fra fasi (contestualizza il bootstrap) + Jf = pd.concat(phases, axis=1, join="inner").dropna() + Mh = Jf[Jf.index >= HOLDOUT].values + cor = np.corrcoef(Mh.T) + iu = np.triu_indices(H, 1) + print(f" correlazione daily fra fasi (hold-out): mediana {np.median(cor[iu]):.3f}, " + f"min {cor[iu].min():.3f}") + + # ---- (3) ensemble delle 10 fasi ---------------------------------------- + ens = pd.Series(Jf.values.mean(axis=1), index=Jf.index) + me, m0 = dmetrics(ens), dmetrics(phases[0]) + print("\n--- (3) ENSEMBLE (media dei ritorni delle 10 fasi = 1/10 capitale per fase) ---") + print(f"{'config':<18} {'ShFULL':>7} {'CAGRf':>7} {'DDfull':>7} {'ShHOLD':>7} {'DDhold':>7}") + for name, m in (("canonica (f0)", m0), ("ensemble 10 fasi", me)): + print(f"{name:<18} {m['sh_full']:>7.3f} {m['cagr_full']:>6.1%} {m['dd_full']:>6.1%} " + f"{m['sh_hold']:>7.3f} {m['dd_hold']:>6.1%}") + + # ---- (4) impatto sul book 5-sleeve ------------------------------------- + print("\n--- (4) BOOK 5-sleeve (TP 33 / XS 15 / VRP 12 / SKH 20 / GTAA 20, combine_outer, " + "attivazione era crypto) ---") + from src.portfolio.sleeves import (_gtaa_daily_returns, _skyhook_returns, + _tp01_returns, _vrp_combo_returns) + TPd = to_daily(_tp01_returns()) + fixed = dict(TP=TPd, VRP=to_daily(_vrp_combo_returns()), + SKH=to_daily(_skyhook_returns()), GTAA=to_daily(_gtaa_daily_returns())) + Wt = dict(TP=0.33, XS=0.15, VRP=0.12, SKH=0.20, GTAA=0.20) + lo = TPd.index.min() + + def book(xs: pd.Series | None) -> pd.Series: + cols = dict(fixed) + if xs is not None: + cols["XS"] = to_daily(xs) + wt = {k: v for k, v in Wt.items() if k in cols} + return combine_outer(cols, wt, lo=lo) + + rows = [("XS canonica (f0)", phases[0]), (f"XS fase mediana ({p_med})", phases[p_med]), + (f"XS fase peggiore ({p_worst})", phases[p_worst]), + (f"XS fase migliore ({p_best})", phases[p_best]), + ("XS ensemble 10f", ens), ("senza XS01 (rinorm.)", None)] + print(f"{'book con':<24} {'ShHOLD':>7} {'ShFULL':>7} {'DDfull':>7} {'DDhold':>7}") + book_stats = {} + for name, xs in rows: + b = book(xs) + bh = b[b.index >= HOLDOUT] + book_stats[name] = (_sh(bh), _sh(b)) + print(f"{name:<24} {_sh(bh):>7.3f} {_sh(b):>7.3f} {_dd(b):>6.1%} {_dd(bh):>6.1%}") + bh0 = book_stats["XS canonica (f0)"][0] + print(f"\n fortuna di fase ereditata dal book HOLD: canonica {bh0:.3f} vs mediana " + f"{book_stats[f'XS fase mediana ({p_med})'][0]:.3f} " + f"(delta {bh0 - book_stats[f'XS fase mediana ({p_med})'][0]:+.3f}) | vs peggiore " + f"{book_stats[f'XS fase peggiore ({p_worst})'][0]:.3f} " + f"(delta {bh0 - book_stats[f'XS fase peggiore ({p_worst})'][0]:+.3f}) | vs senza-XS " + f"{book_stats['senza XS01 (rinorm.)'][0]:.3f}") + + # ---- (5) gate di ammissione alla fase mediana: plateau ------------------ + print("\n--- (5) GATE DI AMMISSIONE — plateau ricalcolato alla fase MEDIANA " + f"({p_med}) vs canonica (0) ---") + print("(ammissione XS01: FULL 1.50 / HOLD 1.71 / DD 11%, plateau lookback + disp_pct 15-35)") + print(f"{'variante':<28} {'f0 FULL':>8} {'f0 HOLD':>8} {'f0 DD':>7} " + f"{'fMED FULL':>9} {'fMED HOLD':>9} {'fMED DD':>8}") + grid = ([(f"disp_pct={dp} (lb 30,90)", dict(disp_pct=dp)) for dp in (15, 20, 25, 30, 35)] + + [(f"lookbacks={lb} (p30)", dict(lookbacks=lb)) + for lb in ((30,), (60,), (90,), (30, 60, 90))]) + for name, kw in grid: + a = dmetrics(xs_phase(0, **kw)) + b = dmetrics(xs_phase(p_med, **kw)) + star = " *" if kw == dict(disp_pct=30) or kw.get("lookbacks") == (30, 90) else "" + print(f"{name:<28} {a['sh_full']:>8.3f} {a['sh_hold']:>8.3f} {a['dd_full']:>6.1%} " + f"{b['sh_full']:>9.3f} {b['sh_hold']:>9.3f} {b['dd_full']:>7.1%}{star}") + mmed = dmetrics(phases[p_med]) + print(f"\n cella canonica alla fase mediana: FULL {mmed['sh_full']:.3f} " + f"(ammesso con 1.50) / HOLD {mmed['sh_hold']:.3f} (ammesso con 1.71) / " + f"DD {mmed['dd_full']:.1%} (ammesso con 11%)") + n_full_pos = int((full_v >= 1.0).sum()) + n_hold_pos = int((hold_v >= 1.0).sum()) + print(f" fasi con FULL>=1.0: {n_full_pos}/10 | fasi con HOLD>=1.0: {n_hold_pos}/10 | " + f"fasi con HOLD<=0: {int((hold_v <= 0).sum())}/10") + + # ---- (6) bootstrap: la canonica e' speciale? ---------------------------- + print("\n--- (6) BOOTSTRAP (block, congiunto sulle 10 fasi, hold-out) ---") + sh_hold_obs = _sh_mat(Mh.T) + g0_obs = float(sh_hold_obs[0] - np.median(sh_hold_obs[1:])) + print(f"hold-out: {Mh.shape[0]} giorni, 10 fasi; Sh canonica {sh_hold_obs[0]:.3f}, " + f"mediana altre {np.median(sh_hold_obs[1:]):.3f}, spike osservato g0 = {g0_obs:+.3f}") + for blk in (10, 20, 40): + bs = block_boot(Mh, B_BOOT, blk, np.random.default_rng(RNG_SEED + blk)) + p_any = float(np.mean(bs["gmax"] >= g0_obs)) + ci_g0 = np.percentile(bs["g0"], [2.5, 97.5]) + ci_med = np.percentile(bs["med"], [2.5, 97.5]) + ci_sh0 = np.percentile(bs["sh0"], [2.5, 97.5]) + print(f" block={blk:>2}: P(spike di UNA QUALSIASI fase >= {g0_obs:+.2f}) = {p_any:.3f} | " + f"CI95 g0 [{ci_g0[0]:+.2f},{ci_g0[1]:+.2f}] | CI95 Sh mediana-fasi " + f"[{ci_med[0]:+.2f},{ci_med[1]:+.2f}] | CI95 Sh canonica [{ci_sh0[0]:+.2f},{ci_sh0[1]:+.2f}]") + + # CI sulla FULL della canonica (storia corta -> quanto sono larghi?) + Mf = Jf.values + bsf = block_boot(Mf, B_BOOT, 20, np.random.default_rng(RNG_SEED)) + ci_f0 = np.percentile(bsf["sh0"], [2.5, 97.5]) + ci_fmed = np.percentile(bsf["med"], [2.5, 97.5]) + print(f" FULL (block=20): CI95 Sh canonica [{ci_f0[0]:+.2f},{ci_f0[1]:+.2f}] | " + f"CI95 Sh mediana-fasi [{ci_fmed[0]:+.2f},{ci_fmed[1]:+.2f}] " + f"(~2.5 anni di storia: intervalli larghi)") + + # ---- (7) caveat --------------------------------------------------------- + print("\n--- (7) CAVEAT DICHIARATI ---") + print(" (a) storia corta ~2.5y (914g, hold-out ~548g): i CI95 qui sopra quantificano " + "l'incertezza — nessuna stima puntuale di Sharpe e' affidabile a +/-1.") + print(" (b) dimensione ORA-DEL-GIORNO non auditata: le barre HL in data/raw sono solo 1d " + "(ancora daily fissa del feed) -> luck residua non testabile con i dati certificati.") + print(" (c) XS01 e' STAT-MODE (19 gambe, non eseguito live): l'impatto e' sulla STIMA del " + "book (reporting/ammissione), non sull'operativita' del book live Deribit.") + print("\nFatto. Nessuna selezione per-fase: parametri identici, giudizio su distribuzione " + "delle fasi + ensemble; l'hold-out e' solo riportato.") + + +if __name__ == "__main__": + main() diff --git a/src/strategies/skyhook.py b/src/strategies/skyhook.py index de2842c..57ff949 100644 --- a/src/strategies/skyhook.py +++ b/src/strategies/skyhook.py @@ -10,6 +10,12 @@ Architettura (dal brief): Entrambi resampled dal feed 5m certificato con origin='epoch' -> i confini 690 sono un SOTTOINSIEME dei confini 230, quindi una barra HTF chiude esattamente su una chiusura LTF. +NB GRID TIMING-LUCK (2026-07-02): i numeri headline (minHold +1.26, blend 0.31->1.17, maxDD<30%) +sono sull'offset 0 della griglia epoch, il MIGLIORE dei 23 offset a priori (93-98mo pctl; mediane: +minHold +0.39, blend 0.72; il gate DD<30% fallisce in 15/23 offset). Regge de-luckato: uplift del +blend positivo a TUTTE le 23 fasi (min +0.18) + corr ~0.08 -> diversificatore ADDS ridimensionato. +Vedi docs/diary/2026-07-02-anchor-audit-xs01-skh01.md e scripts/research/r0702_anchor_skh01.py. + Pipeline per barra (evaluate_bar): barre -> indicatori -> fasce regime -> pattern -> composer -> ingresso/uscita -> SkyhookDecision 1. INDICATORI (sul HTF, tipo-Chande, normalizzati 0-100):