From b9b2ef1b269b04ef671442b73abd1a0ae6d7ae6b Mon Sep 17 00:00:00 2001 From: Adriano Dal Pastro Date: Sun, 26 Jul 2026 13:23:36 +0000 Subject: [PATCH] =?UTF-8?q?research:=20leave-one-out=20del=20book=20DE-LUC?= =?UTF-8?q?KATO=20sulle=20ancore=20=E2=80=94=20la=20classifica=20si=20riba?= =?UTF-8?q?lta?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Domanda: "quale sleeve terrei?". Il leave-one-out ovvio e' misurato all'ancora canonica di tutti e cinque gli sleeve, quindi NON e' credibile: un LOO e' un Delta, ed eredita la fortuna d'ancora come ogni Delta (lezione 26/07, altlib.anchor_luck_delta). Metodo: 2000 estrazioni uniformi indipendenti sullo spazio congiunto 24x10x7x23x5 = 193.200 configurazioni; book completo + 5 LOO alla STESSA configurazione; statistica = mediana delle differenze appaiate. Sanity bit-exact 5/5 (max|dif| = 0.0) contro gli sleeve di produzione. Quattro repliche ancorate riusate dagli audit 02/07-03/07, solo la fase di GTAA01 e' nuova (ed e' l'unica coperta da test dedicato). RISULTATI - Livello del book: la stima a occhio del 02/07 era ottimista su 3/3 metriche. FULL 2.222 (97.0 pctl) -> 1.95 | HOLD 2.364 (99.6 pctl) -> 1.54 [1.11, 1.91] | maxDD 6.07% (12.0 pctl) -> 6.85%. Solo 9 estrazioni su 2000 battono l'hold-out canonico. La somma delle fortune marginali NON e' la mediana congiunta: sbagliava di +0.82 di Sharpe. - SKH01 non e' il motore del book: l'ancora regala 2/3 del FULL, 70% dell'hold-out, 80% della protezione DD -> de-luckato e' il meno affidabile dei cinque. - GTAA01 e' l'unico positivo nel 100% delle estrazioni su tutte e tre le metriche e il miglior protettore di DD. Ma attribuzione != eseguibilita': sotto $3k resta non deployabile. - TP01 e' il maggior contributore (+0.390 FULL, 2000/2000) e il canonico lo SOTTOSTIMAVA. Il suo hold-out negativo non e' artefatto d'ancora (negativo nel 99.1%): e' la firma dell'assicurazione. Uno sleeve difensivo si giudica sul sinistro, non sul premio. - XS01: protezione DD esattamente zero (positiva nel 43% = moneta) -> diversificatore di rendimento, non di rischio. VRP01: 2a conferma di zero fortuna (canonico all'1.8 pctl). Corretto in sessione: un "pctl 100%" era arrotondamento di 99.55% con %.0f — un percentile a 0 decimali mente esattamente agli estremi, che sono l'unico posto dove lo si legge. Book, pesi, cron, config INVARIATI. Non e' un gate sui pesi (resta weights_tilt_null) e non e' evidenza out-of-sample: e' attribuzione, de-luckata. Co-Authored-By: Claude Opus 5 (1M context) --- CLAUDE.md | 59 ++++- docs/diary/2026-07-26-loo-deluck.md | 225 ++++++++++++++++ scripts/research/r0726_loo_deluck.py | 373 +++++++++++++++++++++++++++ tests/test_loo_deluck.py | 165 ++++++++++++ 4 files changed, 817 insertions(+), 5 deletions(-) create mode 100644 docs/diary/2026-07-26-loo-deluck.md create mode 100644 scripts/research/r0726_loo_deluck.py create mode 100644 tests/test_loo_deluck.py diff --git a/CLAUDE.md b/CLAUDE.md index 1410cc3..4fde981 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -84,11 +84,23 @@ 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`. + ⚠️ **ANCHOR-LUCK del book — MISURATO 2026-07-26 (la stima del 02/07 era ottimista su 3/3).** + I numeri canonici sono calcolati con TUTTI e cinque gli sleeve alla loro ancora canonica. Il + 02/07 la correzione fu **stimata a occhio** sommando le fortune marginali (HOLD ~1.9-2.1, FULL + ~2.0-2.2, "DD ~6% invariato"); il 26/07 è stata **misurata sullo spazio congiunto** (24×10×7×23×5 + = 193.200 configurazioni, 2000 estrazioni uniformi indipendenti, `r0726_loo_deluck.py`): + | | canonico | pctl | **stima onesta = mediana** | banda p10-p90 | + |---|---|---|---|---| + | Sharpe FULL | +2.222 | 97.0° | **+1.95** | [1.81, 2.12] | + | Sharpe HOLD-OUT | +2.364 | **99.6°** | **+1.54** | [1.11, 1.91] | + | maxDD FULL | 6.07% | 12.0° | **6.85%** | [5.99, 7.94] | + **Solo 9 estrazioni su 2000 battono l'hold-out canonico.** Il book resta positivo e diversificato + a ogni ancora, ma **i numeri da citare sono 1.95 / 1.54 / 6.85%**, non i canonici. + ⚠️ **Lezione: la somma delle fortune marginali NON è la mediana congiunta** — sbagliava di +0.82 + di Sharpe hold-out, più di quasi tutti gli uplift per cui in questo progetto si è discusso se + ammettere uno sleeve. Quando serve la banda di un AGGREGATO si campiona lo spazio congiunto; gli + audit uno-sleeve-alla-volta (02/07, 03/07) restano validi per giudicare *quello* sleeve. + Diari `2026-07-02-anchor-audit-xs01-skh01.md` (originale) e `2026-07-26-loo-deluck.md` (misura). - **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 @@ -746,6 +758,43 @@ Prima ondata di ricerca onesta su BTC/ETH certificati (5 track, harness condivis pre-registrato + cron); (d) il claim di multiple-testing di un agente va **ricontato**, non creduto (72 dichiarate, 729 reali); (e) quando un meccanismo non generalizza, **chiedersi PERCHE' vale piu' del fatto che non generalizzi**. +- ⚠️ **LEAVE-ONE-OUT DEL BOOK DE-LUCKATO (2026-07-26, 3° filone del giorno) — la classifica dei + contributi si RIBALTA su 2 metriche su 3, e la stima de-luckata del book era ottimista.** + Domanda: *"quale sleeve terrei?"*. Script `scripts/research/r0726_loo_deluck.py`, test + `tests/test_loo_deluck.py` (9), diario `2026-07-26-loo-deluck.md`. **Book/pesi/cron/config + INVARIATI** (non è un gate sui pesi: quello resta `weights_tilt_null`). + (1) **Il metodo:** un leave-one-out **è un Δ**, quindi eredita la fortuna d'ancora come ogni Δ + (lezione 26/07). Con 4 sleeve su 5 ancorati il problema è congiunto → 2000 estrazioni uniformi + indipendenti sullo spazio 24×10×7×23×5, book completo + 5 LOO **alla stessa configurazione**, + statistica = **mediana delle differenze appaiate**. Sanity bit-exact 5/5 (`max|dif| = 0.0`) sulle + repliche ancorate — quattro riusate dagli audit 02/07-03/07, solo la fase di GTAA01 è nuova. + (2) **Contributi de-luckati** (mediana, e **frazione di estrazioni positive** — che conta più + della mediana): **TP01 +0.390 FULL (100%)** = quasi 3× il secondo, e il canonico lo + **SOTTOSTIMAVA** (+0.280 al 5.8° pctl); SKH01 +0.142 (91%), XS01 +0.125 (99.9%), VRP01 +0.122 + (100%), GTAA01 +0.115 (100%). + (3) **Due giudizi ribaltati rispetto alla lente canonica.** **SKH01 non è il motore del book**: + l'ancora regala **2/3 del FULL** (+0.43→+0.14), **70% dell'hold-out** (+0.65→+0.19), **80% della + protezione DD** (+1.64pp→+0.32pp) → de-luckato è **il meno affidabile dei cinque**. **GTAA01 è + l'unico positivo nel 100% delle estrazioni su TUTTE E TRE le metriche** e il miglior protettore + di DD (+2.0pp, doppio del secondo), senza fortuna da restituire (canonico *sotto* la mediana su + FULL e DD). ⚠️ Ma **attribuzione ≠ eseguibilità**: sotto `GTAA_MIN_CAPITAL` $3k resta non + deployabile, e la nota del 25/07 (€0.50/g sopra il risk-free con maxDD 10% a $10k) è intatta. + (4) **TP01: hold-out negativo −0.200, e NON è artefatto d'ancora** (negativo nel **99.1%** delle + configurazioni) — mentre sul FULL è il maggior contributore. È la firma dell'assicurazione, + misurata: paga premio negli anni senza incendio, riprende tutto sul campione che contiene il 2022. + **Regola: uno sleeve difensivo si giudica sul sinistro, non sul premio** — l'hold-out negativo + non è un argomento per ridurlo. **XS01: protezione DD ESATTAMENTE ZERO** (mediana 0.000, positiva + nel 43% = moneta) → è un diversificatore di **rendimento, non di rischio**; conta perché sul + canale funded il vincolo binding è il DD (25/07 §4). **VRP01: 2ª conferma di zero fortuna** + (canonico al 1.8° pctl sul FULL = numeri di ammissione *conservativi*). + (5) **Contrappeso dovuto a SKH01:** il de-luck lo penalizza sul path **backtest**, ma il path + **live** è misurato migliore su entrambi i lati (ingressi +0.38, uscite +0.081) → **vale meno dei + suoi numeri di ammissione e più di questa tabella**; quantificarlo resta il follow-up bloccato + (serve la versione vol-targeted del path live). + ⚠️ **Errore di stampa catturato:** il primo output dava `pctl 100%` per l'hold-out del book — + arrotondamento di 99.55% con `%.0f`. **Un percentile stampato a 0 decimali mente esattamente agli + estremi, che sono l'unico posto dove lo si legge.** Verificato a mano (9 estrazioni su 2000 + battono la canonica), formato portato a 1 decimale. - **Soffitto strutturale BTC/ETH-direzionale ~1.3** superato SOLO espandendo a un meccanismo diverso: cross-sectional su universo Hyperliquid certificato (XS01) → portafoglio Sharpe ~1.55. - **Sweep "strategie alternative" (2026-06-20) — 104 ipotesi / 153 agenti / NIENTE di nuovo regge.** diff --git a/docs/diary/2026-07-26-loo-deluck.md b/docs/diary/2026-07-26-loo-deluck.md new file mode 100644 index 0000000..3b8411d --- /dev/null +++ b/docs/diary/2026-07-26-loo-deluck.md @@ -0,0 +1,225 @@ +# 2026-07-26 — "Quale strategia terrei?" — leave-one-out del book DE-LUCKATO sulle ancore + +**Richiesta:** *"dimmi qual strategia pensi di tenere"*. + +**Script:** `scripts/research/r0726_loo_deluck.py` — **test:** `tests/test_loo_deluck.py` (9). +**Book, pesi, cron, config: INVARIATI.** Nessuna decisione di allocazione cambia. +Cambia invece un **numero documentato in CLAUDE.md** (§4) e cambiano **due giudizi che avevo +dato un'ora prima nella stessa sessione** (§3). + +--- + +## 0. Perche' la risposta ovvia non vale + +La domanda "quanto vale ogni sleeve al suo posto nel book" ha una misura ovvia: togli lo sleeve, +guarda quanto cala il book. L'ho fatta, e alla prima lettura diceva questo: + +``` +BOOK COMPLETO FULL +2.222 HOLD +2.364 maxDD 6.07% +tolto dFULL dHOLD dDD +TP01 (33%) +0.280 -0.219 +0.24pp +XS01 (15%) +0.142 +0.640 +0.25pp +VRP01 (12%) +0.087 +0.064 +1.08pp +SKH01 (20%) +0.432 +0.652 +1.64pp +GTAA01 (20%) +0.120 +0.244 +1.70pp +``` + +Letta cosi', SKH01 e' il motore del book e TP01 e' un peso morto sull'hold-out. +**Entrambe le letture sono sbagliate**, e il motivo era gia' scritto nel progetto il giorno prima: + +> *se si de-lucka una strategia va de-luckato anche il suo DEGRADO* — ogni Δ fra due varianti +> misurato a un'ancora sola eredita la fortuna di quell'ancora. +> (lezione 26/07, codificata in `altlib.anchor_luck_delta`) + +Un leave-one-out **e' esattamente un Δ fra due varianti**. E qui il problema e' al quadrato: 4 +sleeve su 5 sono ancorati, e per 3 di loro l'ancora canonica era gia' stata misurata come +fortunata (TP01 92° pctl su 24 ancore, SKH01 93-98° su 23 offset, XS01 fase 0 al 15° pctl di DD). +VRP01 e' l'unico senza firma — la sua fase canonica e' la **peggiore** delle 7. + +--- + +## 1. Il metodo: estrazioni CONGIUNTE, non una griglia per sleeve + +Gli audit del 02/07 e del 03/07 hanno de-luckato **uno sleeve alla volta**, tenendo gli altri alla +loro ancora canonica. Va bene per giudicare quello sleeve; non basta per giudicare il **book**, +perche' i numeri del book sono calcolati con tutti e cinque alla canonica insieme. + +Spazio di luck congiunto: **24 (TP01, ancore orarie) x 10 (XS01, fasi H=10) x 7 (VRP01, fasi +settimanali) x 23 (SKH01, offset di griglia 230m/690m) x 5 (GTAA01, fasi della cadenza +settimanale) = 193.200 configurazioni.** Non enumerabile: si campiona. + +**2000 estrazioni uniformi e indipendenti** (seed 20260726). Uniformi-indipendenti e' il null +onesto: non avevamo nessuna ragione a priori di preferire un'ancora a un'altra — se ce l'avessimo +avuta, sarebbe stata parte della strategia. + +Per ogni estrazione: book completo + i 5 leave-one-out, **tutti alla stessa configurazione +d'ancora**. La statistica e' la **mediana delle differenze appaiate**, mai la differenza delle +mediane (l'errore che il 26/07 aveva ribaltato il verdetto sul recupero on-book di SKH01: le due +mediane cadono su ancore diverse). + +**Repliche riusate, non riscritte.** I quattro generatori ancorati vengono dagli audit che li +hanno gia' verificati bit-exact (`r0702_tp01_offset.daily_off`, `r0702_anchor_xs01.xs_phase`, +`r0703_vrpimp_anchor.combo_weekly`, `r0702_anchor_skh01.skh_port`). Riscriverli avrebbe voluto +dire rifare gli stessi errori di convenzione. L'unico pezzo nuovo e' la fase di GTAA01 +(`i % REBAL_EVERY == ph` invece di `== 0`), ed e' l'unico coperto da test dedicato. + +**Sanity obbligatorio prima di tutto** — ogni replica ancorata deve riprodurre lo sleeve di +**produzione** alla sua ancora canonica, altrimenti sto misurando un'altra strategia: + +``` +TP01 ancora 0 max|dif| = 0.0e+00 (n=2692) OK +XS01 ancora 0 max|dif| = 0.0e+00 (n=938) OK +VRP01 ancora 0 max|dif| = 0.0e+00 (n=1884) OK +SKH01 ancora 0 max|dif| = 0.0e+00 (n=2690) OK +GTAA01 ancora 0 max|dif| = 0.0e+00 (n=2690) OK +``` + +Costo totale: 69 serie, ~8s di precalcolo, 34s per le 2000 estrazioni. + +--- + +## 2. Il livello del book: i numeri pubblicati stanno in cima alla banda + +``` + canonico pctl MEDIANA p10 p90 fortuna +Sharpe FULL +2.222 97.0% +1.946 +1.807 +2.116 +0.276 +Sharpe HOLD-OUT +2.364 99.6% +1.542 +1.109 +1.910 +0.822 +maxDD FULL 6.07% 12.0% 6.85% 5.99% 7.94% -0.78% +``` + +**L'hold-out canonico sta al 99.6° percentile dello spazio d'ancora: solo 9 estrazioni su 2000 +fanno meglio.** La stima onesta e' **1.54**, con banda [1.11, 1.91]. + +⚠️ **Un "100%" che ho dovuto correggere.** La prima stampa dava `pctl 100%` — arrotondamento di +99.55% con un `%.0f`. In un artefatto di ricerca un 100% stampato significa "e' il massimo, punto" +e avrebbe fatto concludere a una sessione futura qualcosa di piu' forte del vero. Verificato a +mano (max fra le 2000 = +2.577, a `TP01=0 / XS01=7 / VRP01=1 / SKH01=0 / GTAA01=3`), formato +portato a 1 decimale. *Regola pratica: un percentile stampato senza decimali mente esattamente +agli estremi, che sono l'unico posto dove lo si legge.* + +--- + +## 3. Il leave-one-out de-luckato — e due giudizi miei ribaltati + +``` +dSharpe FULL canonico pctl MEDIANA >0 in + TP01 33% +0.280 5.8% +0.390 100.0% + SKH01 20% +0.432 99.0% +0.142 90.8% + XS01 15% +0.142 76.8% +0.125 99.9% + VRP01 12% +0.087 1.8% +0.122 100.0% + GTAA01 20% +0.120 63.5% +0.115 100.0% + +dSharpe HOLD-OUT canonico pctl MEDIANA >0 in + XS01 15% +0.640 63.4% +0.497 96.2% + GTAA01 20% +0.244 17.3% +0.267 100.0% + SKH01 20% +0.652 96.0% +0.190 85.5% + VRP01 12% +0.064 47.4% +0.065 84.2% + TP01 33% -0.219 39.5% -0.200 0.9% + +dMaxDD (pp, >0 = protegge) pctl MEDIANA >0 in + GTAA01 20% +1.695pp 23.8% +1.999pp 100.0% + VRP01 12% +1.075pp 55.6% +1.056pp 88.3% + SKH01 20% +1.636pp 89.3% +0.322pp 64.0% + TP01 33% +0.236pp 48.4% +0.274pp 62.1% + XS01 15% +0.251pp 65.0% +0.000pp 43.0% +``` + +**SKH01 non e' il motore del book.** Del suo contributo apparente, l'ancora canonica regala **2/3 +sul FULL** (+0.43 → +0.14), **70% sull'hold-out** (+0.65 → +0.19), **80% sulla protezione DD** +(+1.64pp → +0.32pp). De-luckato e' indistinguibile dagli altri sul FULL ed e' **il meno affidabile +dei cinque**: l'unico sotto il 91% di estrazioni positive su tutte e tre le metriche (91/86/64%). + +**GTAA01 non e' il primo da tagliare** (lo avevo detto un'ora prima, sulla lente canonica). E' +**l'unico sleeve positivo nel 100% delle estrazioni su tutte e tre le metriche** e il miglior +protettore di DD del book, al doppio del secondo. Non ha fortuna da restituire: il suo canonico +sta *sotto* la mediana su FULL e DD. ⚠️ L'altra gamba di quel giudizio resta pero' intatta e +indipendente: a $10k comprare ~€0.50/giorno sopra il risk-free con un maxDD del 10% e' caro, e +sotto `GTAA_MIN_CAPITAL` = $3k lo sleeve non e' deployabile. **Attribuzione ≠ eseguibilita'.** + +**TP01 e' il maggior contributore, e il canonico lo SOTTOSTIMAVA** (+0.280 al 5.8° pctl → +0.390 +de-luckato, positivo in 2000/2000, quasi 3x il secondo). Il suo hold-out negativo **non e' +artefatto d'ancora**: e' negativo nel **99.1%** delle configurazioni. Le due cose insieme sono la +storia dell'assicurazione, finalmente misurata invece che asserita — sul campione pieno (che +contiene il 2022) e' il contributore piu' grande; nella finestra 2025-26 e' stato fermo mentre gli +altri guadagnavano, e questo costa −0.20. **Giudicarlo sulla finestra in cui non e' servito +significa venderlo esattamente prima che serva.** + +**VRP01** conferma per la seconda volta di essere l'unico senza firma di fortuna: canonico al +**1.8° pctl** sul FULL, cioe' i numeri di ammissione erano *conservativi*. Secondo protettore di DD. + +**XS01** tiene il primo posto sull'hold-out anche de-luckato (+0.497), ma la sua protezione di DD +e' **esattamente zero** (mediana 0.000, positiva nel 43% = moneta). E' un diversificatore di +**rendimento, non di rischio** — e conta, perche' sul canale funded il vincolo binding e' il DD, +non il CAGR (25/07 §4). + +**Correlazioni:** stabili sotto de-luck, tutte quasi-nulle. La sola sopra 0.1 e' TP01-GTAA01 +(mediana +0.189, canonica +0.242). + +### Il contrappeso che SKH01 ha diritto ad avere + +Questo de-luck penalizza SKH01 sul path **backtest** (chiusura di bin). Ma le due misure del 26/07 +dicono che il **path live e' migliore del backtest su entrambi i lati**: ingressi +0.38 di Sharpe +mediano, uscite +0.081 di Sharpe FULL di book. Applicare il de-luck senza quel contrappeso lo +penalizzerebbe due volte. Verdetto onesto: **SKH01 vale meno dei suoi numeri di ammissione e piu' +di questa tabella.** Quanto esattamente resta il follow-up gia' dichiarato e bloccato (serve la +versione vol-targeted del path live di SKH01). + +--- + +## 4. Correzione a CLAUDE.md + +La stima del 02/07 era **a occhio** — sommava le fortune misurate sleeve-per-sleeve. Oggi e' +**misurata congiuntamente**, ed era ottimista su tutte e tre le metriche: + +| | stimato 02/07 | misurato 26/07 | +|---|---|---| +| HOLD-OUT | ~1.9-2.1 | **1.54** [1.11, 1.91] | +| FULL | ~2.0-2.2 | **1.95** [1.81, 2.12] | +| maxDD | "~6% invariato" | **6.85%** [5.99, 7.94] | + +L'errore non e' stato usare una stima: e' stato che una stima per somma di effetti marginali +**non e' la mediana congiunta**, e la differenza (+0.82 di hold-out) e' piu' grande di quasi +tutti gli uplift per cui in questo progetto si e' discusso se ammettere uno sleeve. + +--- + +## 5. Cosa NON e' questa misura + +- **Non e' evidenza out-of-sample.** Il book e' valutato sugli stessi anni in cui XS01 e SKH01 + sono stati scelti e affinati. E' attribuzione, de-luckata. +- **Non e' un gate sui pesi.** Ogni proposta di cambio pesi passa da `weights_tilt_null`. Qui non + si propone nulla: i pesi restano 33/15/12/20/20. +- **Non e' una lente di eseguibilita'.** Dice quanto uno sleeve contribuisce al book modellato, + non se lo si puo' comprare a $600 (GTAA01 sotto $3k e XS01 sotto ~$20k non si possono). + +--- + +## 6. Risposta alla domanda + +**TP01**, se ne va tenuta una. Maggior contributore de-luckato (+0.390, 2000/2000), l'unico +eseguibile davvero oggi a $600 senza haircut, e l'unico il cui valore dichiarato — il taglio del +DD ~6x contro il buy&hold — **non e' ancorato** e regge a tutte le ancore testate. Tutto il resto +dei suoi numeri e' gia' stato declassato (hold-out 0.31 → ~0.05); quello no. + +Le altre: **SKH01** si tiene al 25% e non un euro di piu'; **GTAA01** si tiene nel book modello +(miglior protettore di DD) ma non nel live finche' non ci sono $3k su IB; **VRP01** si tiene come +*metro* — e' l'unico i cui numeri sono onesti per costruzione; **XS01** non e' da tenere oggi, e' +da accendere quando arriva il capitale (~$20k, o un conto funded, dove vale piu' di tutte). + +Il book live che gira — **TP01 75 / SKH01 25** — e' gia' questa risposta. + +--- + +## 7. Regole trasferibili + +1. **Un leave-one-out e' un Δ, quindi va de-luckato come ogni Δ.** L'attribuzione all'ancora + canonica ha invertito la classifica su 2 metriche su 3. +2. **De-luckare uno sleeve alla volta non de-lucka il book.** La fortuna del book e' un fenomeno + *congiunto*: la somma delle fortune marginali ha sbagliato di +0.82 di Sharpe hold-out. Quando + serve la banda di un aggregato, si campiona lo spazio congiunto. +3. **Un percentile stampato a 0 decimali mente agli estremi**, che sono l'unico posto dove serve. +4. **La frazione di estrazioni positive dice piu' della mediana.** Fra +0.142 (SKH01, positivo nel + 91%) e +0.115 (GTAA01, positivo nel 100%) la mediana preferisce il primo e l'affidabilita' il + secondo — ed e' la seconda a decidere se ci si mette del capitale. +5. **Uno sleeve difensivo si giudica sul sinistro, non sul premio.** L'hold-out negativo di TP01 e' + robusto (99.1% delle ancore) e *non* e' un argomento per ridurlo. diff --git a/scripts/research/r0726_loo_deluck.py b/scripts/research/r0726_loo_deluck.py new file mode 100644 index 0000000..75fadc0 --- /dev/null +++ b/scripts/research/r0726_loo_deluck.py @@ -0,0 +1,373 @@ +#!/usr/bin/env python +"""r0726_loo_deluck.py — LEAVE-ONE-OUT del book 5-sleeve DE-LUCKATO sulle ancore. + +DOMANDA: "quale sleeve terrei?" — cioe' quanto vale OGNI sleeve alla sua posizione attuale +nel book. La risposta ovvia (togli lo sleeve, guarda quanto cala il book) e' misurata +all'ANCORA CANONICA di tutti e cinque, e per questo NON e' credibile: ogni Δ misurato su +griglia ancorata eredita la fortuna di quell'ancora (lezione codificata il 26/07 in +`altlib.anchor_luck_delta`; corollario: *se si de-lucka una strategia va de-luckato anche +il suo DEGRADO*). E qui il problema e' al quadrato — 4 sleeve su 5 sono ancorati, e per 3 +di loro l'ancora canonica e' gia' stata misurata come fortunata (TP01 92° pctl su 24 ancore, +SKH01 93-98° su 23 offset, XS01 fase 0 al 15° pctl di DD; VRP01 e' l'unico senza firma, la +sua fase canonica e' la PEGGIORE delle 7). + +COSA FA (nessun file di produzione toccato, nessun peso cambiato): + 0. SANITY OBBLIGATORIO — ogni replica parametrizzata sull'ancora deve riprodurre + BIT-EXACT lo sleeve di produzione alla sua ancora canonica. Senza questo il resto + misura un'altra strategia. + 1. Precalcola le 69 serie: TP01 x24 ancore orarie, XS01 x10 fasi, VRP01 x7 fasi, + SKH01 x23 offset di griglia, GTAA01 x5 fasi di cadenza settimanale. + 2. ESTRAZIONI CONGIUNTE: N configurazioni d'ancora, una per sleeve, uniformi e + INDIPENDENTI (il null onesto: non avevamo ragione di preferire nessuna ancora). + Lo spazio pieno e' 24x10x7x23x5 = 193.200 -> si campiona. + 3. Per ogni estrazione: book completo + i 5 book leave-one-out (combine_outer, pesi + rinormalizzati per-riga come in produzione) -> Δ APPAIATI per sleeve. + La statistica e' la MEDIANA DELLE DIFFERENZE APPAIATE, mai la differenza delle mediane + (l'errore ribaltante catturato il 26/07): le due mediane cadono su ancore diverse. + 4. Output: per sleeve, Δ canonico + il suo percentile nella banda, Δ mediano de-luckato, + banda [p10,p90], frazione di estrazioni in cui il contributo e' positivo. + Piu' il livello del book de-luckato (FULL/HOLD/DD mediani) — che aggiorna con una + MISURA la stima a occhio in CLAUDE.md ("HOLD ~1.9-2.1, FULL ~2.0-2.2"). + +COSA NON E': non e' un gate sui pesi (quello resta `weights_tilt_null`), e non e' evidenza +out-of-sample — il book e' misurato sugli stessi anni su cui XS01/SKH01 sono stati scelti e +affinati. E' attribuzione, de-luckata: dice quanto di cio' che vediamo e' struttura e quanto +e' l'ancora che ci e' capitata. + + uv run python scripts/research/r0726_loo_deluck.py [--draws N] +""" +from __future__ import annotations + +import argparse +import pickle +import sys +import time +from pathlib import Path + +import numpy as np +import pandas as pd + +ROOT = Path("/opt/docker/PythagorasGoal") +sys.path.insert(0, str(ROOT)) +sys.path.insert(0, str(ROOT / "scripts" / "research")) +sys.path.insert(0, str(ROOT / "scripts" / "research" / "alt")) + +from src.portfolio.portfolio import HOLDOUT, combine_outer, metrics, to_daily # noqa: E402 +from src.portfolio import gtaa as G # noqa: E402 +from src.portfolio.sleeves import (GTAA_DEFAULT_CAPITAL, VRP_CFG, # noqa: E402 + _gtaa_daily_returns, _skyhook_returns, + _tp01_returns, _vrp_combo_returns, + _xsec_returns, active_sleeves) + +# repliche ancorate GIA' verificate bit-exact nei rispettivi audit (02/07, 03/07): +# riusate, non riscritte — riscriverle significherebbe rifare gli stessi errori di convenzione. +import r0702_anchor_skh01 as A_SKH # noqa: E402 skh_port(off) +import r0702_anchor_xs01 as A_XS # noqa: E402 xs_phase(phase) +import r0702_tp01_offset as A_TP # noqa: E402 daily_off(asset, h) +import r0703_vrpimp_anchor as A_VRP # noqa: E402 combo_weekly(phase, f) + to_daily_lumped + +from src.strategies.trend_portfolio import CANONICAL, TrendPortfolio, simple_returns # noqa: E402 + +SEED = 20260726 +# cache rigenerabile delle 69 serie d'ancora (~8s a ricostruirla): gitignored via `*.pkl`. +# `--force` la ignora. Path stabile nel repo, non legato alla sessione che l'ha scritta. +CACHE = ROOT / "data" / "_cache" / "anchor_series.pkl" + +WEIGHTS = {"TP01_trend_1d": 0.33, "XS01_xsec_hl": 0.15, "VRP01_shortvol": 0.12, + "SKH01_skyhook": 0.20, "GTAA01_eq_trend": 0.20} +SHORT = {"TP01_trend_1d": "TP01", "XS01_xsec_hl": "XS01", "VRP01_shortvol": "VRP01", + "SKH01_skyhook": "SKH01", "GTAA01_eq_trend": "GTAA01"} + + +# =========================================================================== +# Generatori ancorati — uno per sleeve. Ogni serie esce GIA' normalizzata a +# giornaliera con `to_daily`, come fa Sleeve.daily() in produzione. +# =========================================================================== +def tp01_at(h: int) -> pd.Series: + """TP01 all'ancora oraria h. Replica ESATTA di sleeves._tp01_returns con il daily + risampionato a offset h invece che a 00:00 UTC.""" + tp = TrendPortfolio(**CANONICAL) + series = {} + for a in ("BTC", "ETH"): + df = A_TP.daily_off(a, h) + r = simple_returns(df["close"].values.astype(float)) + tgt = tp.target_series(df) + held = np.zeros(len(tgt)) + held[1:] = tgt[:-1] + net = held * r - tp.fee_side * np.abs(np.diff(held, prepend=0.0)) + net[0] = 0.0 + series[a] = pd.Series(np.clip(net, -0.99, None), index=pd.to_datetime(df["datetime"])) + J = pd.concat(series, axis=1, join="inner").fillna(0.0) + return to_daily(pd.Series(0.5 * J["BTC"].values + 0.5 * J["ETH"].values, index=J.index)) + + +def xs01_at(p: int) -> pd.Series: + return to_daily(A_XS.xs_phase(p)) + + +def vrp01_at(p: int) -> pd.Series: + return to_daily(A_VRP.to_daily_lumped(A_VRP.combo_weekly(p, f=VRP_CFG["f"]))) + + +def skh01_at(off: int) -> pd.Series: + return to_daily(A_SKH.skh_port(off)) + + +def _gtaa_leg_phase(sym: str, cap_leg: float, band_usd: float, every: int, ph: int) -> pd.Series: + """Replica di gtaa._gated_returns con la cadenza in fase `ph` (i % every == ph).""" + close = G._close(sym) + ex = np.nan_to_num(np.asarray(G._exposure(close).values, float)) + px = close.values.astype(float) + ret = np.zeros(len(px)) + ret[1:] = px[1:] / px[:-1] - 1.0 + held = np.empty(len(ex)) + comm = np.zeros(len(ex)) + cur = 0.0 + for i in range(len(ex)): + if i % every == ph: + notional = abs(ex[i] - cur) * cap_leg + if notional >= max(band_usd, G.IB_MIN_TRADE_USD): + comm[i] = G.ib_commission(notional, px[i]) / cap_leg + cur = ex[i] + held[i] = cur + pos = np.zeros(len(held)) + pos[1:] = held[:-1] + net = pos * ret - comm + net[0] = 0.0 + return pd.Series(net, index=close.index) + + +def gtaa01_at(ph: int) -> pd.Series: + """GTAA01 alla fase `ph` della cadenza settimanale (REBAL_EVERY=5 barre di borsa). + Replica ESATTA di sleeves._gtaa_daily_returns, incluse le convenzioni di calendario.""" + cap_leg = float(GTAA_DEFAULT_CAPITAL) / len(G.EQ_UNIVERSE) + cols = {a: _gtaa_leg_phase(a, cap_leg, G.REBAL_BAND_USD, G.REBAL_EVERY, ph) + for a in G.EQ_UNIVERSE} + r = pd.concat(cols, axis=1, sort=True).sort_index().mean(axis=1, skipna=True).dropna() + r = r.dropna().sort_index() + if r.index.tz is None: + r.index = r.index.tz_localize("UTC") + days = pd.date_range(r.index.min().normalize(), r.index.max().normalize(), freq="1D", tz="UTC") + r = r.reindex(days).fillna(0.0) + from src.portfolio.sleeves import GTAA_BOOK_ACTIVATION + return to_daily(r[r.index >= GTAA_BOOK_ACTIVATION]) + + +# nome -> (generatore, ancore a priori, ancora canonica, sleeve di produzione) +SPEC = { + "TP01_trend_1d": (tp01_at, tuple(range(24)), 0, _tp01_returns), + "XS01_xsec_hl": (xs01_at, tuple(range(10)), 0, _xsec_returns), + "VRP01_shortvol": (vrp01_at, tuple(range(7)), 0, _vrp_combo_returns), + "SKH01_skyhook": (skh01_at, tuple(range(0, 690, 30)), 0, _skyhook_returns), + "GTAA01_eq_trend": (gtaa01_at, tuple(range(5)), 0, _gtaa_daily_returns), +} + + +# =========================================================================== +# PART 0 — sanity: la replica ancorata == sleeve di produzione all'ancora canonica +# =========================================================================== +def sanity() -> None: + print("[SANITY] ogni replica ancorata deve riprodurre lo sleeve di produzione " + "alla sua ancora canonica") + for name, (gen, _anchors, canon, prod_fn) in SPEC.items(): + mine = gen(canon) + ref = to_daily(prod_fn()) + J = pd.concat({"m": mine, "r": ref}, axis=1, join="outer") + assert len(mine) == len(ref), ( + f"{name}: lunghezze diverse ({len(mine)} vs {len(ref)}) — non e' la stessa serie") + dmax = float(np.nanmax(np.abs(J["m"].values - J["r"].values))) + assert dmax < 1e-12, f"SANITY FAIL {name}: max|dif| = {dmax:.3e}" + print(f" {SHORT[name]:<7} ancora {canon:>3} max|dif| = {dmax:.1e} " + f"(n={len(mine)}) OK") + + +# =========================================================================== +# PART 1 — precalcolo delle 69 serie +# =========================================================================== +def build_all(force: bool = False) -> dict: + if CACHE.exists() and not force: + with CACHE.open("rb") as fh: + cached = pickle.load(fh) + if set(cached) == set(SPEC) and all(len(cached[n]) == len(SPEC[n][1]) for n in SPEC): + print(f"[CACHE] serie d'ancora lette da {CACHE.name}") + return cached + out = {} + for name, (gen, anchors, _c, _p) in SPEC.items(): + t0 = time.time() + out[name] = {a: gen(a) for a in anchors} + print(f" {SHORT[name]:<7} {len(anchors):>3} ancore in {time.time()-t0:5.1f}s") + CACHE.parent.mkdir(parents=True, exist_ok=True) + with CACHE.open("wb") as fh: + pickle.dump(out, fh) + return out + + +# =========================================================================== +# PART 2 — book + leave-one-out a una configurazione d'ancora +# =========================================================================== +def book_stats(cols: dict) -> tuple[float, float, float]: + """(Sharpe FULL, Sharpe HOLD-OUT, maxDD FULL) del book a pesi WEIGHTS ristretti a `cols`.""" + w = {k: WEIGHTS[k] for k in cols} + full = combine_outer(cols, w) + mf = metrics(full) + mh = metrics(full[full.index >= HOLDOUT]) + return mf["sharpe"], mh["sharpe"], mf["maxdd"] + + +def one_config(series: dict, cfg: dict) -> dict: + """Un'estrazione d'ancora -> book completo + 5 leave-one-out, tutti alla STESSA ancora.""" + cols = {n: series[n][cfg[n]] for n in SPEC} + bf, bh, bd = book_stats(cols) + res = {"book": (bf, bh, bd)} + for n in SPEC: + sub = {k: v for k, v in cols.items() if k != n} + f, h, d = book_stats(sub) + # Δ = contributo dello sleeve: >0 su Sharpe = togliendolo il book peggiora + # >0 su DD = togliendolo il drawdown peggiora + res[n] = (bf - f, bh - h, d - bd) + return res + + +def paired_deltas(draws: list[dict], name: str, j: int) -> np.ndarray: + """Vettore dei Δ APPAIATI di uno sleeve su una metrica (j: 0=FULL, 1=HOLD, 2=maxDD). + + Ogni elemento e' (book con X) - (book senza X) alla STESSA configurazione d'ancora. La + statistica corretta e' la mediana di QUESTO vettore — mai la differenza fra la mediana dei + book-con e quella dei book-senza, perche' le due mediane cadono su ancore DIVERSE e il + verdetto puo' ribaltarsi (errore catturato il 26/07 sul recupero on-book di SKH01, codificato + in `altlib.anchor_luck_delta`). Isolata qui apposta per essere testabile. + """ + return np.array([d[name][j] for d in draws], float) + + +def pctl_of(arr: np.ndarray, v: float) -> float: + return float((arr < v).mean() * 100.0) + + +def band(arr: np.ndarray) -> tuple[float, float, float]: + return float(np.median(arr)), float(np.percentile(arr, 10)), float(np.percentile(arr, 90)) + + +def main() -> None: + ap = argparse.ArgumentParser() + ap.add_argument("--draws", type=int, default=600) + ap.add_argument("--force", action="store_true", help="ricalcola le serie ignorando la cache") + args = ap.parse_args() + + t_start = time.time() + print("=" * 100) + print(" r0726 — LEAVE-ONE-OUT del book 5-sleeve, DE-LUCKATO sulle ancore") + print("=" * 100) + + sanity() + + print(f"\n[PRECALCOLO] {sum(len(v[1]) for v in SPEC.values())} serie d'ancora " + f"(spazio congiunto {np.prod([len(v[1]) for v in SPEC.values()]):,})") + S = build_all(force=args.force) + + # --- configurazione canonica (quella di tutti i numeri del progetto) --- + canon_cfg = {n: SPEC[n][2] for n in SPEC} + canon = one_config(S, canon_cfg) + + # --- estrazioni congiunte uniformi indipendenti --- + rng = np.random.default_rng(SEED) + print(f"\n[ESTRAZIONI] {args.draws} configurazioni d'ancora congiunte, " + f"uniformi indipendenti (seed {SEED})") + t0 = time.time() + draws = [] + for _ in range(args.draws): + cfg = {n: int(rng.choice(SPEC[n][1])) for n in SPEC} + draws.append(one_config(S, cfg)) + print(f" fatte in {time.time()-t0:.0f}s") + + # ------------------------------------------------------------------ + # Livello del book + # ------------------------------------------------------------------ + B = np.array([d["book"] for d in draws], float) + print("\n" + "=" * 100) + print(" (1) LIVELLO DEL BOOK — quanto dei numeri pubblicati e' l'ancora che ci e' capitata") + print("=" * 100) + print(f" {'':<22}{'canonico':>10}{'pctl':>7}{'MEDIANA':>10}{'p10':>9}{'p90':>9}" + f"{'fortuna':>10}") + for j, lab in enumerate(["Sharpe FULL", "Sharpe HOLD-OUT", "maxDD FULL"]): + med, lo, hi = band(B[:, j]) + can = canon["book"][j] + fmt = (lambda x: f"{x*100:8.2f}%") if j == 2 else (lambda x: f"{x:+8.3f}") + # pctl a 1 decimale: un "100%" arrotondato da 99.55% e' esattamente il numero che + # inganna una lettura futura (qui 9 estrazioni su 2000 battono la canonica). + print(f" {lab:<22}{fmt(can)}{pctl_of(B[:, j], can):6.1f}%{fmt(med)}" + f"{fmt(lo)}{fmt(hi)}{fmt(can - med)}") + print("\n (fortuna = canonico - mediana. Su maxDD un valore NEGATIVO e' fortuna:") + print(" il DD canonico e' piu' basso del tipico.)") + + # ------------------------------------------------------------------ + # Leave-one-out de-luckato + # ------------------------------------------------------------------ + print("\n" + "=" * 100) + print(" (2) LEAVE-ONE-OUT DE-LUCKATO — contributo di ogni sleeve, mediana delle") + print(" DIFFERENZE APPAIATE (book con X) - (book senza X) alla STESSA ancora") + print("=" * 100) + order = sorted(SPEC, key=lambda n: -float(np.median(paired_deltas(draws, n, 0)))) + for j, lab in enumerate(["dSharpe FULL", "dSharpe HOLD-OUT", "dMaxDD (pp, >0 = protegge)"]): + print(f"\n --- {lab} ---") + print(f" {'sleeve':<9}{'w':>6}{'canonico':>11}{'pctl':>7}{'MEDIANA':>11}" + f"{'p10':>10}{'p90':>10}{'>0 in':>9}") + for n in order: + arr = paired_deltas(draws, n, j) + med, lo, hi = band(arr) + can = canon[n][j] + k = 100.0 if j == 2 else 1.0 + u = "pp" if j == 2 else "" + print(f" {SHORT[n]:<9}{WEIGHTS[n]*100:5.0f}%{can*k:+10.3f}{u}" + f"{pctl_of(arr, can):6.1f}%{med*k:+10.3f}{u}{lo*k:+9.3f}{u}{hi*k:+9.3f}{u}" + f"{(arr > 0).mean()*100:7.1f}%") + + # ------------------------------------------------------------------ + # Quanto sposta il de-luck ogni contributo + # ------------------------------------------------------------------ + print("\n" + "=" * 100) + print(" (3) FORTUNA PER SLEEVE — canonico meno mediana, sul suo stesso contributo") + print("=" * 100) + print(f" {'sleeve':<9}{'dFULL':>22}{'dHOLD':>22}{'dDD':>22}") + print(f" {'':<9}{'can':>8}{'med':>7}{'luck':>7}{'can':>8}{'med':>7}{'luck':>7}" + f"{'can':>8}{'med':>7}{'luck':>7}") + for n in order: + row = f" {SHORT[n]:<9}" + for j in range(3): + arr = paired_deltas(draws, n, j) + med = float(np.median(arr)) + can = canon[n][j] + k = 100.0 if j == 2 else 1.0 + row += f"{can*k:+8.2f}{med*k:+7.2f}{(can-med)*k:+7.2f}" + print(row) + + # ------------------------------------------------------------------ + # Correlazioni: struttura o ancora? + # ------------------------------------------------------------------ + print("\n" + "=" * 100) + print(" (4) CORRELAZIONI FRA SLEEVE — banda sulle estrazioni (finestra comune)") + print("=" * 100) + rng2 = np.random.default_rng(SEED + 1) + pairs = [(a, b) for i, a in enumerate(SPEC) for b in list(SPEC)[i + 1:]] + acc = {p: [] for p in pairs} + for _ in range(min(120, args.draws)): + cfg = {n: int(rng2.choice(SPEC[n][1])) for n in SPEC} + D = pd.concat({n: S[n][cfg[n]] for n in SPEC}, axis=1, join="inner").fillna(0.0) + C = D.corr() + for a, b in pairs: + acc[(a, b)].append(float(C.loc[a, b])) + Dc = pd.concat({n: S[n][SPEC[n][2]] for n in SPEC}, axis=1, join="inner").fillna(0.0).corr() + print(f" {'coppia':<16}{'canonica':>10}{'MEDIANA':>10}{'p10':>9}{'p90':>9}") + for a, b in sorted(pairs, key=lambda p: -abs(np.median(acc[p]))): + arr = np.array(acc[(a, b)], float) + med, lo, hi = band(arr) + print(f" {SHORT[a]+'-'+SHORT[b]:<16}{Dc.loc[a, b]:+9.3f}{med:+10.3f}" + f"{lo:+9.3f}{hi:+9.3f}") + + print(f"\nFatto in {time.time()-t_start:.0f}s.") + print("NB: attribuzione de-luckata, NON evidenza out-of-sample — il book e' misurato") + print(" sugli stessi anni su cui XS01 e SKH01 sono stati scelti e affinati.") + + +if __name__ == "__main__": + main() diff --git a/tests/test_loo_deluck.py b/tests/test_loo_deluck.py new file mode 100644 index 0000000..d71e700 --- /dev/null +++ b/tests/test_loo_deluck.py @@ -0,0 +1,165 @@ +"""Test per r0726_loo_deluck.py — leave-one-out del book de-luckato sulle ancore. + +Copre i due punti dove questo studio puo' rompersi in silenzio: + (a) la REPLICA di fase di GTAA01 — l'unico generatore ancorato scritto nuovo (gli altri + quattro sono riusati dagli audit 02/07 e 03/07, gia' verificati bit-exact li'); + (b) la STATISTICA APPAIATA — l'invariante che il 26/07 ha ribaltato un verdetto: la mediana + delle differenze appaiate NON e' la differenza delle mediane. + +I test su (b) girano su strutture sintetiche: sono logica, non dati, e devono restare veloci. +""" +from __future__ import annotations + +import sys +from pathlib import Path + +import numpy as np +import pandas as pd +import pytest + +ROOT = Path(__file__).resolve().parents[1] +sys.path.insert(0, str(ROOT)) +sys.path.insert(0, str(ROOT / "scripts" / "research")) +sys.path.insert(0, str(ROOT / "scripts" / "research" / "alt")) + +import r0726_loo_deluck as R # noqa: E402 + + +# =========================================================================== +# (a) replica di fase di GTAA01 +# =========================================================================== +def test_gtaa_fase_canonica_replica_lo_sleeve_di_produzione(): + """La fase 0 deve riprodurre BIT-EXACT _gtaa_daily_returns(). Senza questo tutto lo + studio misura una strategia diversa da quella del book.""" + from src.portfolio.portfolio import to_daily + from src.portfolio.sleeves import _gtaa_daily_returns + + mine = R.gtaa01_at(0) + ref = to_daily(_gtaa_daily_returns()) + assert len(mine) == len(ref) + assert mine.index.equals(ref.index) + assert float(np.max(np.abs(mine.values - ref.values))) == 0.0 + + +def test_gtaa_le_fasi_sono_davvero_diverse(): + """Controllo POSITIVO: se `ph` non entrasse nel calcolo, il test sopra passerebbe lo stesso + e la banda d'ancora di GTAA01 sarebbe cinque copie della stessa serie (fortuna misurata 0 + per costruzione). Almeno una fase deve differire dalla canonica.""" + base = R.gtaa01_at(0) + diffs = [] + for ph in (1, 2, 3, 4): + other = R.gtaa01_at(ph) + J = pd.concat({"a": base, "b": other}, axis=1, join="inner") + diffs.append(float(np.max(np.abs(J["a"].values - J["b"].values)))) + assert max(diffs) > 1e-9, f"le 5 fasi di GTAA01 sono identiche: {diffs}" + + +def test_gtaa_fase_fuori_range_non_ribilancia_mai(): + """`i % 5 == 7` non e' mai vero -> la gamba resta a esposizione 0 per sempre. Serve a + dimostrare che e' davvero la CADENZA a essere parametrizzata (e non un no-op).""" + close = R.G._close("SPY") + leg = R._gtaa_leg_phase("SPY", 1000.0, R.G.REBAL_BAND_USD, R.G.REBAL_EVERY, 7) + assert len(leg) == len(close) + assert float(np.max(np.abs(leg.values))) == 0.0 + + +# =========================================================================== +# (b) statistica appaiata +# =========================================================================== +def _fake_draws(book_vals, loo_vals, name="TP01_trend_1d"): + """Costruisce la struttura che one_config() produce, con Δ = book - loo per estrazione.""" + return [{"book": (b, 0.0, 0.0), name: (b - l, 0.0, 0.0)} + for b, l in zip(book_vals, loo_vals)] + + +def test_mediana_appaiata_non_e_differenza_delle_mediane(): + """L'INVARIANTE del 26/07, con numeri costruiti perche' i due metodi abbiano segno OPPOSTO. + + book : [1.0, 2.0, 3.0] -> mediana 2.0 + senza : [0.5, 2.5, 2.9] -> mediana 2.5 + differenza delle mediane = -0.5 (direbbe: lo sleeve fa MALE) + mediana delle differenze appaiate = mediana([+0.5, -0.5, +0.1]) = +0.1 (fa BENE) + """ + book = [1.0, 2.0, 3.0] + loo = [0.5, 2.5, 2.9] + draws = _fake_draws(book, loo) + + appaiata = float(np.median(R.paired_deltas(draws, "TP01_trend_1d", 0))) + ingenua = float(np.median(book) - np.median(loo)) + + assert appaiata == pytest.approx(0.1) + assert ingenua == pytest.approx(-0.5) + assert np.sign(appaiata) != np.sign(ingenua), "il caso di test non dimostra piu' il ribaltamento" + + +def test_paired_deltas_preserva_ordine_e_metrica(): + draws = [{"book": (0, 0, 0), "X": (1.0, 10.0, 100.0)}, + {"book": (0, 0, 0), "X": (2.0, 20.0, 200.0)}] + assert list(R.paired_deltas(draws, "X", 0)) == [1.0, 2.0] + assert list(R.paired_deltas(draws, "X", 1)) == [10.0, 20.0] + assert list(R.paired_deltas(draws, "X", 2)) == [100.0, 200.0] + + +# =========================================================================== +# (c) semantica del leave-one-out sul book +# =========================================================================== +def _flat_series(vals, start="2020-01-01"): + idx = pd.date_range(start, periods=len(vals), freq="1D", tz="UTC") + return pd.Series(np.asarray(vals, float), index=idx) + + +def test_one_config_e_appaiato_per_costruzione(): + """res[n] deve essere il Δ calcolato alla STESSA configurazione, non a due configurazioni + diverse. Si verifica ricalcolando a mano il book completo e quello senza uno sleeve.""" + rng = np.random.default_rng(7) + n = 400 + series = {name: {0: _flat_series(rng.normal(0.0004, 0.01, n)), 1: _flat_series(rng.normal(0.0004, 0.01, n))} + for name in R.SPEC} + cfg = {name: (i % 2) for i, name in enumerate(R.SPEC)} + res = R.one_config(series, cfg) + + cols = {n_: series[n_][cfg[n_]] for n_ in R.SPEC} + bf, bh, bd = R.book_stats(cols) + assert res["book"] == (bf, bh, bd) + + for name in R.SPEC: + sub = {k: v for k, v in cols.items() if k != name} + f, h, d = R.book_stats(sub) + assert res[name][0] == pytest.approx(bf - f) + assert res[name][1] == pytest.approx(bh - h) + # sul maxDD il segno e' INVERTITO apposta: >0 = togliendolo il drawdown PEGGIORA + assert res[name][2] == pytest.approx(d - bd) + + +def test_sleeve_costante_a_zero_non_contribuisce_al_sharpe(): + """Controllo positivo sul segno: uno sleeve che non fa nulla deve dare Δ ~0 sul Sharpe, + non un contributo positivo per magia di rinormalizzazione.""" + rng = np.random.default_rng(11) + n = 500 + names = list(R.SPEC) + series = {nm: {0: _flat_series(rng.normal(0.0005, 0.008, n))} for nm in names} + dead = names[-1] + series[dead] = {0: _flat_series(np.zeros(n))} + cfg = {nm: 0 for nm in names} + res = R.one_config(series, cfg) + # lo sleeve morto abbassa il Sharpe del book (diluisce): il suo Δ dev'essere <= 0 + assert res[dead][0] <= 1e-9 + + +def test_book_stats_usa_i_pesi_di_produzione(): + """I pesi devono essere quelli del book attivo, e la somma dei cinque deve fare 1.0: + se qualcuno cambia active_sleeves() e non questo modulo, il test lo dice.""" + from src.portfolio.sleeves import active_sleeves + + live = {s.name: round(s.weight, 6) for s in active_sleeves()} + assert live == R.WEIGHTS, f"pesi disallineati da active_sleeves(): {live} vs {R.WEIGHTS}" + assert sum(R.WEIGHTS.values()) == pytest.approx(1.0) + + +def test_spec_copre_tutti_gli_sleeve_attivi_con_ancora_canonica_valida(): + from src.portfolio.sleeves import active_sleeves + + assert set(R.SPEC) == {s.name for s in active_sleeves()} + for name, (_gen, anchors, canon, _prod) in R.SPEC.items(): + assert canon in anchors, f"{name}: ancora canonica {canon} fuori dalla griglia" + assert len(anchors) >= 5, f"{name}: griglia d'ancora troppo piccola per una banda"