diff --git a/CLAUDE.md b/CLAUDE.md index d78d7ca..5427d17 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -557,6 +557,63 @@ Prima ondata di ricerca onesta su BTC/ETH certificati (5 track, harness condivis ACCOPPIAMENTO al rendimento del giorno** — qui un test "i percentili coincidono" sarebbe PASSATO con la stima sbagliata di 2-3×. Ogni regola valutata sul minimo (daily-loss, trailing DD, stop di conto) va misurata su **tuple accoppiate**, mai su un wick estratto a parte. +- **Ondata 2026-07-26 (3 filoni: esecuzione SKH01 / DVOLSPREAD / XSR01 fuori dal crypto) — 0 sleeve + nuovi, 1 raccomandazione operativa aperta, 1 lead promosso, 1 falsificazione.** Script + `r0726_{skh_onbook,dvolspread_gate,xsr_equity}.py`, test `tests/test_wave_0726.py` (11 casi), + diario `2026-07-26-wave-esecuzione-dvolspread-xsr-equity.md`. **Book/pesi/cron INVARIATI.** + (1) ⚠️ **T1 — IL DEGRADO D'ESECUZIONE DI SKH01 ERA ANCH'ESSO FORTUNA D'ANCORA.** Testato l'unico + meccanismo che non e' un cron (ordini **resting on-book**: TP = limit al livello per costruzione + + fee maker; SL = stop-market). A **offset 0** l'on-book recupera il **94%** del degrado — ma + l'audit 02/07 aveva de-luckato i numeri headline di SKH01 e **NON il degrado**, misurato solo a + off0. Sulla banda appaiata dei 23 offset il degrado massimo recuperabile e' **+0.054 Sh di book**, + non −0.35 di sleeve. ⚠️ Errore di metodo mio, corretto in sessione: **mediana(A)−mediana(B) fra + offset e' sbagliato** (confronta offset diversi) → serve la **mediana delle DIFFERENZE appaiate**; + con quella il verdetto si RIBALTA. Esito: **solo TP a limite = +0.054 FULL / +0.061 HOLD, + positivo in 19/23 (FULL) e 21/23 (HOLD) offset**; **lo SL on-book PEGGIORA** (contributo mediano + −0.010, positivo in **11/23 = moneta**) perche' cristallizza la perdita al livello mentre l'exit + software ritardata incassa il rimbalzo — e sopra **0.50% di slippage** l'on-book e' peggio del + live. Meccanismo misurato: sulle uscite TP il fill orario **batte** il livello nel 42% dei casi + (vantaggio medio −0.035%) → il limit TP converte una lotteria in certezza piu' che aggiungere + ritorno; solo l'**1% degli SL gappa** davvero a 5m. **RACCOMANDAZIONE NON ESEGUITA (decisione + dell'operatore): TP come limit resting reduce-only, SL strategico NON on-book.** E' un cambio di + esecuzione (non passa `weights_tilt_null`) ma piccolo, da pesare contro ordini orfani/doppio fill. + Il disaster-SL −30% on-book resta com'e'. + (2) ✅ **T2 — DVOLSPREAD ESCE DAL LIMBO** (era fermo dal 21/06, unico sopravvissuto del marginal + scorer indurito, mai ripreso: non nel book, non in monitor, non rifiutato). Passato ai due gate + che nel giugno NON esistevano. ⚠️ La griglia dichiarata dall'agente ("72 celle") ne contiene + **729** (6 assi x 3) → valutate tutte, scelta conservativa (piu' trial = DSR piu' basso). + Plateau REALE e larghissimo: **729/729 celle con hold-out positivo**, FULL [0.60,0.71]. + Selection-on-holdout **confermata ma mite**: la cella pubblicata e' **83ª/729 sull'hold-out ma + 471ª/729 in-sample**. Scegliendo onestamente in-sample: **FULL 0.68 / HOLD 0.69** (non il **0.93** + pubblicato — **citare 0.69**) e **DSR 0.953 PASS**, mentre la cella pubblicata **FALLISCE (0.947)**. + Marginale ADDS + robust_oos + multicut + non-hedge + insample_edge + beats_noise; corr +0.11, + alpha +7.4%/a, dSharpe book **+0.08 FULL / +0.17 HOLD** a w=15%. **PROMOSSO a forward-monitor con + i parametri ONESTI** (zwin=180 k=2.0 lw=0.6 zw=1.1 tgt=0.17 svw=60), **NON nel book**: campione + **ATTIVO 1949/2691 g** (prima del 2021-03 non c'e' DVOL, book flat) con **hold-out attivo 1.6 + anni**, margine DSR sul filo, e `weights_tilt_null` mai affrontato. + (3) ❌ **T3 — XSR01 NON GENERALIZZA fuori dal crypto** (meccanismo CONGELATO W=45/sgn=+1 su + **9 settoriali SPDR 1998+ / 28 ETF 30 anni**, residuo vs SPY, demean giornaliero, split IWM/EFA + riparati, **annualizzazione √252**). Diverso dal test del 25/07: quello era a **coppie**, questo + e' la versione **DEMEANATA** (quella vera di XSR01). Lordo **+0.24 (SECT9, p=0.193)** e **−0.14 + (ALL28, p=0.747)** vs null di permutazione **a fee zero**; netto −1.6/−1.8 ovunque; per decennio + stesso profilo nei 2 universi (neg. 1998-2005, debolmente pos. poi) = piu' cambio di regime che + edge. **Il risultato che conta non e' lo Sharpe ma l'AMPIEZZA:** il demeaning porta l'ampiezza + effettiva **4.5→37.4 (8x) sul crypto** ma solo **5.3→6.2 / 8.4→11.3 (1.2-1.35x) sulle azioni**. + Spiegazione strutturale (e migliore descrizione di XSR01 di quella della sua scoperta): il residuo + OLS rimuove gia' il beta al fattore comune; sul crypto alle gambe **RESTA** un enorme fattore + comune (ampiezza 4.5 su 50 gambe) ed e' quello che il demean toglie — sulle azioni il residuo-vs-SPY + e' **gia'** quasi indipendente, quindi non c'e' niente da togliere. **Per il gate del 23/10:** + conferma e CHIUDE la scappatoia lasciata aperta dal test a coppie; XSR01 e' **crypto-specifico**. + NON prova che sia falso. **Soglie del gate NON toccate**: resta appoggiato interamente su finestra + forward + haircut di eseguibilita' a $5.000, come pre-registrato. + **LEZIONI:** (a) **se si de-lucka una strategia va de-luckato anche il suo DEGRADO** — ogni Δ fra + due varianti misurato su griglia ancorata eredita la fortuna dell'ancora; (b) su offset appaiati + la statistica e' la **mediana delle differenze**, non la differenza delle mediane; (c) **un lead + "in forward-monitor" senza monitor e senza scadenza e' un lead perso** (DVOLSPREAD: 35 giorni di + limbo) → applicare ai lead la stessa disciplina dei candidati (config congelata + gate + 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**. - **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-wave-esecuzione-dvolspread-xsr-equity.md b/docs/diary/2026-07-26-wave-esecuzione-dvolspread-xsr-equity.md new file mode 100644 index 0000000..0b4d19e --- /dev/null +++ b/docs/diary/2026-07-26-wave-esecuzione-dvolspread-xsr-equity.md @@ -0,0 +1,227 @@ +# 2026-07-26 — Ondata a 3 filoni: esecuzione SKH01, DVOLSPREAD, XSR01 fuori dal crypto + +**Script:** `r0726_skh_onbook.py` · `r0726_dvolspread_gate.py` · `r0726_xsr_equity.py` +**Test:** `tests/test_wave_0726.py` (11 casi) · suite completa **245 verdi** +**Book / pesi / cron: INVARIATI.** Una raccomandazione operativa aperta (T1), nessuna modifica fatta. + +Tre filoni scelti non per esplorare aree nuove — quelle sono in gran parte sature — ma perché +il progetto stesso aveva lasciato **tre questioni aperte e decidibili oggi**: + +| filone | questione aperta | esito | +|---|---|---| +| **T1** | il degrado del path live di SKH01 (~−0.35 Sh) è recuperabile? | ⚠️ **il degrado era a sua volta fortuna d'ancora**; metà del fix funziona, l'altra metà no | +| **T2** | DVOLSPREAD, in limbo dal 21/06, è sleeve o falso positivo? | ✅ **regge i gate che non esistevano allora** — promosso a forward-monitor con parametri onesti | +| **T3** | XSR01 regge fuori dal crypto e da 2.6 anni monoregime? | ❌ **no** — e si scopre *perché* | + +--- + +## T1 — Il degrado d'esecuzione di SKH01 era anch'esso un artefatto d'ancora + +### La domanda + +Il book live gira SKH01 con cron orario e uscite **software**. Costo misurato (audit 02/07, +riconfermato il 24/07): sleeve FULL 1.46→1.19, HOLD 1.64→1.15, DD 18%→25%. È il buco singolo più +grande del progetto, e il 24/07 aveva già stabilito che **un cron più veloce non lo recupera**. + +L'angolo mai testato è l'unico meccanismo che non è un cron: gli **ordini resting on-book**, dove +esiste un'asimmetria che nessuno aveva sfruttato: + +- il **TP è un limit order**: se il prezzo ci arriva, il fill è al livello **per costruzione** + (sei il maker), e per giunta a fee maker invece che taker; +- lo **SL è uno stop-market**: al trigger diventa un ordine a mercato, e in un gap riempie peggio. + +Ipotesi: il grosso del degrado sta sul lato TP, quindi è recuperabile senza rischio nuovo. + +### Il risultato, e la trappola che ha nascosto + +A **offset 0** — la griglia che gira davvero — il quadro sembrava trionfale: l'on-book completo +recupera il **94%** del degrado. Ma l'offset 0 è già noto essere al 93-98° percentile dei 23 +offset a priori, quindi la lente onesta è la banda. + +E qui c'è stato un mio errore di statistica, corretto in corso: avevo confrontato +**mediana(canonical) − mediana(hourly)**, che confronta offset *diversi* fra loro. Gli offset sono +**coppie appaiate**: la statistica giusta è la **mediana delle differenze**. Con quella: + +| modalità | ΔFULL mediana [min,max] | ΔHOLD mediana | migliora in | +|---|---|---|---| +| canonical (tetto teorico) | **+0.054** [−0.05,+0.12] | +0.099 | — | +| **solo TP a limite** | **+0.054** [−0.03,+0.07] | **+0.061** | **FULL 19/23 · HOLD 21/23 · DD 19/23** | +| TP + SL on-book | +0.024 [−0.07,+0.11] | +0.031 | FULL 17/23 · HOLD 14/23 | +| *contributo del solo lato SL* | **−0.010** [−0.13,+0.08] | — | **11/23 = una moneta** | + +**Due conclusioni, entrambe diverse da quella che cercavo.** + +**(1) Il "−0.35 del path live" è a sua volta un artefatto dell'offset 0.** L'audit del 02/07 aveva +de-luckato i numeri *headline* di SKH01 ma aveva misurato il **degrado** solo a offset 0. Sulla +banda appaiata il degrado massimo recuperabile è **+0.054 di Sharpe sul book**, non +0.35 sullo +sleeve. Il buco più grande del progetto era in buona parte fortuna d'ancora anche lui. + +**(2) Lo stop on-book PEGGIORA.** Mettere lo SL sul book cristallizza la perdita al livello, +mentre l'uscita software ritardata a volte incassa il rimbalzo: contributo mediano **−0.010**, +positivo in **11/23** offset = indistinguibile da una moneta. La sensibilità lo conferma: sopra +lo **0.50%** di slippage sullo stop l'on-book è **peggio del live di oggi**. + +Il meccanismo è misurato, non ipotizzato: sulle uscite TP il fill orario batte il livello nel +**42%** dei casi (vantaggio medio −0.035%) — è quasi una moneta, quindi il limit TP **converte una +lotteria in una certezza** più che aggiungere ritorno. E solo l'**1% degli SL** gappa davvero +dentro la barra 5m: il gap-through dei crash è reale ma raro. + +### Raccomandazione (NON eseguita) + +> **Appoggiare il TP come limit resting reduce-only; NON mettere lo SL strategico sul book.** +> Guadagno atteso **+0.054 FULL / +0.061 HOLD** sul book, positivo in **19-21 offset su 23**, +> più il risparmio taker→maker su un quarto dei trade. + +È un cambio di **esecuzione**, non di strategia, quindi non passa da `weights_tilt_null`. Ma è +piccolo, e va pesato contro il costo operativo vero: ordini orfani se il cron muore, gestione +del reduce-only, doppio fill. **Non l'ho implementato**: è una decisione dell'operatore, non un +fatto compiuto. Il disaster-SL on-book a −30% resta com'è (è un'altra cosa: protezione, non exit). + +--- + +## T2 — DVOLSPREAD regge i gate che nel giugno non esistevano ✅ + +### Chi è, e perché era in limbo + +`agent_14_dvol_spread` (onda ortho, 21/06) è **l'unico sopravvissuto** al marginal scorer +indurito: dei 18 book relative-value che facevano ADDS con lo scorer vecchio, l'indurito ne lasciò +in piedi **uno**, questo. Da allora non è mai stato ripreso — non è nel book, non è in un monitor, +non è stato rifiutato. Era semplicemente lì. + +Il segnale: `log(DVOL_btc) − log(DVOL_eth)`, tilt verso la gamba con **vol implicita più ricca** +(tesi VRP / fear-reversal), market-neutral per costruzione, eseguibile a $600. + +Nel frattempo il progetto ha codificato **due gate che il 21/06 non c'erano**, ed erano proprio +quelli che colpivano le sue riserve dichiarate: + +1. **`deflated_sharpe`** (29/06) — l'agente dichiara *da sé* uno sweep di celle. Nel giugno + "72/72 celle ADDS" si leggeva come robustezza; il DSR dice che sono anche 72 tentativi. +2. **`select_cell_insample`** (29/06) — il plateau dell'agente è descritto in termini di + **`uplift_hold`**, cioè la cella è stata guardata **sull'hold-out**. È la firma esatta che il + gate selection-on-holdout fu scritto per catturare. + +### Esito + +⚠️ **Prima osservazione: la griglia dichiarata è sbagliata.** Il docstring dice "72-cell sweep", ma +il plateau che descrive è 6 assi × 3 valori = **729** combinazioni. Le ho valutate tutte e 729 — +scelta **conservativa**, perché più trial abbassano il DSR. + +**Il plateau è reale e larghissimo:** FULL in [0.60, 0.71], HOLD in [0.68, 0.97], e +**729/729 celle con hold-out positivo**. Non è una cella fortunata. + +**La selection-on-holdout c'è, ma è mite.** La cella pubblicata è **83ª/729 sull'hold-out** ma +**471ª/729 in-sample** — la firma è inequivocabile. Solo che, essendo il plateau così piatto, +scegliere onestamente la cella in-sample costa poco: + +| cella | FULL | IS | HOLD | DSR (729 trial) | +|---|---|---|---|---| +| scelta **IN-SAMPLE** (onesta) | 0.68 | 0.70 | **0.69** | **0.953 PASS** | +| scelta sull'hold-out (proibita) | 0.68 | 0.58 | 0.97 | — | +| **pubblicata dall'agente** | 0.66 | 0.57 | 0.93 | 0.947 **FAIL** | + +Cioè: **l'hold-out onesto è 0.69, non il 0.93 pubblicato** — un haircut reale — ma resta +chiaramente positivo. E la cella onesta **passa il deflated-Sharpe (0.953)** mentre quella +pubblicata lo **fallisce (0.947)**, il che è di per sé una conferma che il gate fa il suo lavoro. + +Marginale vs TP01 sulla cella onesta: **ADDS**, `robust_oos` ✓, `multicut_persistent` ✓, +`is_hedge` ✗, `has_insample_edge` ✓, `beats_noise_null` ✓; corr +0.11, alpha annua **+7.4%**, +beta a TP01 +0.117, jackknife_min_uplift **+0.069**; dSharpe sul book **+0.08 FULL / +0.17 HOLD** +a w=15%, **+0.11 / +0.25** a w=25%. + +### Verdetto: promosso, ma non nel book + +**DVOLSPREAD esce dal limbo e diventa un candidato in forward-monitor con parametri ONESTI** +(`zwin=180, k=2.0, lw=0.6, zw=1.1, tgt=0.17, svw=60` — la cella in-sample), non quelli pubblicati. + +**Perché NON entra nel book, dichiarato:** +- **campione ATTIVO 1949 giorni su 2691** (72%): prima del 2021-03 non esiste DVOL e il book è + correttamente flat. L'**hold-out attivo è 1.6 anni**; +- **margine DSR sul filo** (0.953 vs soglia 0.95) — un passaggio, non un'assoluzione; +- non ha ancora affrontato `weights_tilt_null`, che ogni cambio di pesi deve superare; +- il numero headline che circolava (HOLD 0.93) era gonfiato dalla selezione: **va citato 0.69**. + +--- + +## T3 — XSR01 non generalizza fuori dal crypto ❌ (e si capisce perché) + +### La domanda + +XSR01 ha superato tutto ciò che gli è stato chiesto, ma la sua debolezza numero uno è dichiarata: +**2.6 anni monoregime, con l'edge crescente nel tempo** (Sharpe 2024 1.03 / 2025 1.98 / 2026 3.11). +Con 2.6 anni non si distingue un meccanismo da un regime fortunato. L'unico modo di guadagnare +potenza senza aspettare è portare il meccanismo **congelato** su un pannello lungo decenni. + +**Non è il test del 25/07.** `r0725_statarb_eq.py` aveva già scartato le azioni, ma testava il +meccanismo a **coppie**. XSR01 è la versione **DEMEANATA cross-sezionalmente**, ed è proprio il +demeaning ad aver creato l'edge (ampiezza effettiva 4.5 → 37.4). Testare il demean sulle azioni è +una domanda diversa, mai posta. + +### Esito + +Meccanismo congelato (W=45, sgn=+1, residuo OLS causale vs **SPY**, z-score, tanh, vol-target 20%, +demean giornaliero), split IWM/EFA riparati in lettura, annualizzazione a **√252** (giorni di borsa): + +| universo | variante | ampiezza eff. | Sharpe **LORDA** | Sharpe netta | maxDD | +|---|---|---|---|---|---| +| **SECT9** (9 settoriali, 1998+, 26 anni) | coppie | 5.3 | −0.05 | −1.67 | −91% | +| **SECT9** | **DEMEAN (= XSR01)** | **6.2** | **+0.24** | −1.61 | −89% | +| **ALL28** (tutti gli ETF, 30 anni) | coppie | 8.4 | −0.20 | −1.61 | −92% | +| **ALL28** | **DEMEAN** | **11.3** | **−0.14** | −1.82 | −91% | +| *XSR01 crypto (25/07, riferimento)* | *DEMEAN* | ***37.4*** | ***+2.70*** | *+1.82* | *−2.6%* | + +Null di permutazione cross-sezionale **a fee zero** (la lezione XSR01: permutare un segnale ne fa +esplodere il turnover, quindi a fee piena il null perderebbe per *costo* e regalerebbe un p-value +trionfale e falso): SECT9 candidato +0.24 vs null medio 0.00, **p = 0.193**; ALL28 −0.14, **p = 0.747**. + +Per decennio (DEMEAN, lordo): SECT9 −0.88 / +0.49 / +0.29 / +0.74 · ALL28 −0.88 / +0.46 / +0.38 / +0.05. +Stesso profilo nei due universi: fortemente negativo 1998-2005, debolmente positivo poi — il che +somiglia più a un cambio di regime che a un edge. + +### Il risultato più interessante non è il Sharpe, è l'ampiezza + +**Il demeaning non fa sulle azioni quello che fa sul crypto.** Sul crypto porta l'ampiezza +effettiva da 4.5 a 37.4 (**8×**); sulle azioni da 5.3 a 6.2 e da 8.4 a 11.3 (**1.2-1.35×**). + +Il perché è strutturale, e spiega XSR01 meglio di quanto lo spiegasse la sua scoperta: il residuo +OLS **rimuove già** il beta al fattore comune. Sul crypto, dopo aver residualizzato su BTC, alle +gambe **resta** un enorme fattore comune (ampiezza 4.5 su 50 gambe = quasi una gamba sola) — ed è +quello che il demeaning toglie. Sulle azioni il residuo-vs-SPY è **già** quasi indipendente +(5.3 su 9 gambe), quindi il demeaning non ha molto da togliere e non c'è guadagno. + +### Cosa significa per il gate del 2026-10-23 + +Il 25/07 aveva già scritto che *«il gate non può appoggiarsi all'argomento fenomeno universale»*. +Questo test **lo conferma e chiude la scappatoia** che il test a coppie lasciava aperta: nemmeno +la versione demeanata — quella vera di XSR01 — funziona sulle azioni. + +**Onestà su cosa NON prova:** non prova che XSR01 sia falso. Prova che è **crypto-specifico**, e +suggerisce quale sia il meccanismo (l'esistenza di un fattore comune residuo che sopravvive alla +residualizzazione, cosa che le azioni non hanno). Il gate del 23/10 resta **interamente** appoggiato +sulla finestra forward e sull'haircut di eseguibilità a $5.000, come pre-registrato. +Nessuna soglia toccata. + +--- + +## Lezioni da portare avanti + +1. **Se si de-lucka una strategia, va de-luckato anche il suo DEGRADO.** L'audit del 02/07 ha + corretto i numeri headline di SKH01 per la fortuna d'ancora ma ha misurato il costo + d'esecuzione a offset 0 — e quel costo si è rivelato **fortunato quanto i numeri che + correggeva**. Ogni Δ fra due varianti misurato su una griglia ancorata eredita la fortuna + dell'ancora. + +2. **Su una griglia di offset appaiati, la statistica è la mediana delle DIFFERENZE, non la + differenza delle mediane.** La seconda confronta offset diversi fra loro e qui ribaltava il + verdetto (faceva sembrare l'on-book completo migliore del solo TP, mentre è il contrario). + +3. **Un lead lasciato "in forward-monitor" senza monitor e senza data di scadenza è un lead + perso.** DVOLSPREAD è stato in limbo 35 giorni. La disciplina che il progetto applica ai + candidati nuovi (config congelata + gate pre-registrato + cron) non era stata applicata a lui. + +4. **Il claim di multiple-testing di un agente va ricontato, non creduto.** Il docstring diceva + 72 celle; la griglia descritta ne contiene 729. Ricontare è conservativo e costa poco. + +5. **Quando un meccanismo non generalizza, chiedersi PERCHÉ vale più del fatto che non generalizzi.** + Il fallimento di XSR01 sulle azioni ha prodotto la spiegazione migliore di come funziona sul + crypto: il demeaning vale quanto vale il fattore comune **residuo** dopo la residualizzazione, + ed è una proprietà dell'asset class, non del segnale. diff --git a/scripts/research/r0726_dvolspread_gate.py b/scripts/research/r0726_dvolspread_gate.py new file mode 100644 index 0000000..fba336d --- /dev/null +++ b/scripts/research/r0726_dvolspread_gate.py @@ -0,0 +1,202 @@ +#!/usr/bin/env python +"""r0726_dvolspread_gate — T2 dell'ondata 2026-07-26: chiudere il lead DVOLSPREAD, in limbo +dal 2026-06-21. + +CHI E'. `agent_14_dvol_spread` (onda ortho, 2026-06-21) e' **l'unico sopravvissuto** al marginal +scorer indurito: dei 18 book relative-value che facevano "ADDS" con lo scorer vecchio, l'indurito +ne ha lasciato in piedi **1**, questo — "l'unico con edge in-sample reale". Fu lasciato in +forward-monitor con due riserve dichiarate: *multiple-testing* e *storia DVOL corta*. Da allora +non e' mai stato ripreso: non e' nel book, non e' in un paper monitor, non e' stato rifiutato. +E' semplicemente rimasto li'. + +PERCHE' RIPRENDERLO ORA. Perche' nel frattempo il progetto ha codificato **due gate che il 21/06 +non esistevano**, e sono esattamente quelli che colpiscono la sua riserva: + + 1. **`deflated_sharpe`** (29/06). Il docstring dell'agente dichiara *da se'* uno **sweep di 72 + celle** su ZWIN/LW/ZW/TGT/SVW. 72 trial sono precisamente cio' che il DSR sconta. Nel giugno + "72/72 celle ADDS" veniva letto come prova di robustezza; il DSR dice che e' anche 72 tentativi. + 2. **`select_cell_insample` / `study_family_honest`** (29/06). Il plateau dell'agente e' descritto + in termini di **`uplift_hold`** ("ogni vicino resta ADDS con uplift_hold in [0.34,0.39]"), + cioe' la cella e' stata guardata SULL'HOLD-OUT. E' la firma che il gate SELECTION-ON-HOLDOUT + e' stato scritto per catturare — lo stesso difetto che uccise il lead ERM intraday. + +DOMANDA SECCA: scegliendo la cella con il solo in-sample, e scontando i 72 trial, DVOLSPREAD +resta uno sleeve o era un falso positivo sopravvissuto solo perche' i gate non c'erano ancora? + +Terza verifica, indipendente dalle prime due: la **storia DVOL** parte 2021-03, quindi il book e' +FLAT per meta' del campione. Si misura quanto e' corto il campione ATTIVO (non quello nominale), +perche' e' su quello che vanno letti tutti i numeri. + +Uso: `uv run python scripts/research/r0726_dvolspread_gate.py` +""" +from __future__ import annotations + +import sys +from pathlib import Path + +import numpy as np +import pandas as pd + +ROOT = Path(__file__).resolve().parents[2] +sys.path.insert(0, str(ROOT)) +sys.path.insert(0, str(ROOT / "scripts" / "research" / "alt")) +sys.path.insert(0, str(ROOT / "scripts" / "research" / "ortho")) + +import altlib as al # noqa: E402 +import ortholib as ol # noqa: E402 + +# griglia DICHIARATA dall'agente stesso (docstring PLATEAU): 72 celle +GRID = dict(zwin=[120, 150, 180], tanh_k=[1.5, 1.8, 2.0], + lw=[0.3, 0.45, 0.6], zw=[0.9, 1.0, 1.1], + tgt=[0.11, 0.13, 0.17], svw=[40, 50, 60]) +CHOSEN = dict(zwin=150, tanh_k=1.8, lw=0.45, zw=1.0, tgt=0.13, svw=50) +LEG_CAP = 0.5 + + +def make_book(zwin, tanh_k, lw, zw, tgt, svw): + """Fabbrica il book dell'agente a parametri arbitrari (stessa formula, nessuna variante).""" + def _book(btc, eth): + import warnings + bc = btc["close"].values.astype(float) + ec = eth["close"].values.astype(float) + rb = ol.simple_returns(bc) + re = ol.simple_returns(ec) + db = al.dvol(btc, "BTC") + de = al.dvol(eth, "ETH") + with np.errstate(invalid="ignore", divide="ignore"): + ldvr = np.log(db) - np.log(de) + ldvr = np.where(np.isfinite(ldvr), ldvr, np.nan) + level = np.tanh(tanh_k * np.nan_to_num(ldvr, nan=0.0)) + zdev = np.tanh(tanh_k * np.nan_to_num(ol.zscore(ldvr, zwin), nan=0.0)) + g_dir = np.where(np.isfinite(ldvr), lw * level + zw * zdev, 0.0) + with warnings.catch_warnings(): + warnings.simplefilter("ignore", category=RuntimeWarning) + spvol = ol.realized_vol(re - rb, svw, 365.25) + scal = np.where((spvol > 0) & np.isfinite(spvol), tgt / spvol, 0.0) + g = np.nan_to_num(np.clip(g_dir * scal, -LEG_CAP, LEG_CAP), nan=0.0) + return g, -g + return _book + + +_EVAL_CACHE: dict = {} + + +def evaluate(**kw) -> dict: + """Valutazione col MOTORE ORIGINALE dell'agente (ortholib.eval_book): stesso shift, stesse + fee su entrambe le gambe, stessa serie daily. Riusarlo — invece di riscriverlo — e' cio' che + rende questo un RE-TEST dello stesso oggetto e non di una sua approssimazione.""" + key = tuple(sorted(kw.items())) + if key not in _EVAL_CACHE: + _EVAL_CACHE[key] = ol.eval_book(make_book(**kw)) + return _EVAL_CACHE[key] + + +def daily_returns(**kw) -> pd.Series: + return evaluate(**kw)["daily"].dropna() + + +def sh(s: pd.Series, lo=None, hi=None) -> float: + x = s.dropna() + if lo is not None: + x = x[x.index >= lo] + if hi is not None: + x = x[x.index < hi] + return float(x.mean() / x.std() * np.sqrt(365.25)) if len(x) > 30 and x.std() > 0 else 0.0 + + +HOLDOUT = "2025-01-01" + + +def main() -> None: + print("=" * 100) + print(" T2 — DVOLSPREAD: il lead del 21/06 passa i gate del 29/06?") + print("=" * 100) + + base = daily_returns(**CHOSEN) + act = base[base != 0.0] + print(f"\n campione NOMINALE: {len(base)} giorni ({base.index[0].date()} -> {base.index[-1].date()})") + print(f" campione ATTIVO : {len(act)} giorni ({len(act)/len(base):.0%}) — " + f"prima del 2021-03 non c'e' DVOL e il book e' FLAT") + print(f" attivo pre-holdout {len(act[act.index < HOLDOUT])} g | " + f"attivo hold-out {len(act[act.index >= HOLDOUT])} g " + f"({len(act[act.index >= HOLDOUT])/365:.1f} anni)") + + # ------------------------------------------------------- griglia dichiarata dall'agente + print("\n" + "-" * 100) + print(" A. LA GRIGLIA CHE L'AGENTE DICHIARA DA SE' (72 celle: ZWIN x K x LW x ZW x TGT x SVW)") + print("-" * 100) + import itertools + keys = list(GRID) + cells = [] + for combo in itertools.product(*[GRID[k] for k in keys]): + kw = dict(zip(keys, combo)) + r = daily_returns(**kw) + cells.append(dict(kw=kw, full=sh(r), is_=sh(r, hi=HOLDOUT), hold=sh(r, lo=HOLDOUT))) + df = pd.DataFrame([{**c["kw"], "full": c["full"], "is": c["is_"], "hold": c["hold"]} + for c in cells]) + print(f" celle valutate: {len(df)}") + print(f" Sharpe FULL : min {df['full'].min():.2f} | mediana {df['full'].median():.2f} | " + f"max {df['full'].max():.2f}") + print(f" Sharpe HOLD : min {df['hold'].min():.2f} | mediana {df['hold'].median():.2f} | " + f"max {df['hold'].max():.2f}") + print(f" celle con HOLD > 0: {int((df['hold'] > 0).sum())}/{len(df)}") + + # ------------------------------------------------------- selezione IN-SAMPLE vs hold-out + print("\n" + "-" * 100) + print(" B. SELECTION-ON-HOLDOUT — la cella scelta in-sample e' la stessa scelta sull'hold-out?") + print("-" * 100) + i_is = int(df["is"].idxmax()) + i_ho = int(df["hold"].idxmax()) + i_ch = int(df[[*CHOSEN]].eq(pd.Series(CHOSEN)).all(axis=1).idxmax()) + for lab, i in (("cella scelta IN-SAMPLE (onesta)", i_is), + ("cella scelta sull'HOLD-OUT (proibita)", i_ho), + ("cella PUBBLICATA dall'agente", i_ch)): + row = df.loc[i] + pars = {k: row[k] for k in CHOSEN} + print(f" {lab:>40}: FULL {row['full']:>5.2f} IS {row['is']:>5.2f} " + f"HOLD {row['hold']:>5.2f} {pars}") + rank_ch_hold = int((df["hold"] > df.loc[i_ch, "hold"]).sum()) + 1 + rank_ch_is = int((df["is"] > df.loc[i_ch, "is"]).sum()) + 1 + print(f"\n rango della cella PUBBLICATA: {rank_ch_hold}/{len(df)} sull'HOLD-OUT, " + f"{rank_ch_is}/{len(df)} sull'IN-SAMPLE") + print(" (se e' molto meglio classificata sull'hold-out che in-sample -> selezione-sull'hold-out)") + + # ------------------------------------------------------- deflated Sharpe sui 72 trial + print("\n" + "-" * 100) + print(" C. DEFLATED SHARPE sui trial DICHIARATI DALL'AGENTE STESSO") + print("-" * 100) + r_is = daily_returns(**{k: df.loc[i_is, k] for k in CHOSEN}) + for lab, i, r in (("cella IN-SAMPLE", i_is, r_is), + ("cella PUBBLICATA", i_ch, base)): + dsr, sr0 = al.deflated_sharpe(sh(r), list(df["full"].values), r.dropna().values) + verdict = "PASS" if (np.isfinite(dsr) and dsr >= 0.95) else "FAIL" + print(f" {lab:>20}: Sharpe FULL {sh(r):>5.2f} | max atteso sotto il null " + f"{sr0:>5.2f} | DSR {dsr:>6.3f} -> {verdict}") + + # ------------------------------------------------------- marginale vs TP01 + print("\n" + "-" * 100) + print(" D. MARGINALE vs TP01 (scorer indurito) — sulla cella scelta IN-SAMPLE") + print("-" * 100) + tp = al.tp01_baseline_daily() + for lab, r, kwc in (("cella IN-SAMPLE", r_is, {k: df.loc[i_is, k] for k in CHOSEN}), + ("cella PUBBLICATA", base, CHOSEN)): + J = pd.concat({"tp": tp, "c": r}, axis=1, join="inner").fillna(0.0) + corr = float(J["tp"].corr(J["c"])) + for w in (0.15, 0.25): + bl = (1 - w) * J["tp"] + w * J["c"] + print(f" {lab:>20} w={w:.0%}: corr {corr:>+5.2f} | " + f"dSharpe FULL {sh(bl) - sh(J['tp']):>+5.2f} | " + f"dSharpe HOLD {sh(bl, lo=HOLDOUT) - sh(J['tp'], lo=HOLDOUT):>+5.2f}") + mg = al.marginal_vs_tp01(r) + print(f" {'':>20} verdetto {mg['marginal_verdict']} | robust_oos={mg['robust_oos']} | " + f"multicut={mg['multicut_persistent']} | is_hedge={mg['is_hedge']} | " + f"insample_edge={mg['has_insample_edge']} | beats_noise={mg['beats_noise_null']}") + print(f" {'':>20} jackknife_min_uplift {mg['jackknife_min_uplift']:+.3f} | " + f"alpha_ann {mg['alpha_ann']:+.1%} | beta_TP01 {mg['beta_to_tp01']:+.3f} | " + f"uplift TP01-up {mg['uplift_tp01_up']:+.3f} / TP01-down {mg['uplift_tp01_down']:+.3f}") + + print("\n" + "=" * 100) + + +if __name__ == "__main__": + main() diff --git a/scripts/research/r0726_skh_onbook.py b/scripts/research/r0726_skh_onbook.py new file mode 100644 index 0000000..9718b48 --- /dev/null +++ b/scripts/research/r0726_skh_onbook.py @@ -0,0 +1,346 @@ +#!/usr/bin/env python +"""r0726_skh_onbook — T1 dell'ondata 2026-07-26: il -0.35 Sharpe del path live di SKH01 +e' recuperabile con ORDINI RESTING ON-BOOK invece dell'exit software del cron? + +CONTESTO. Il book live Deribit (TP01 0.75 + SKH01 0.25) gira SKH01 col cron ORARIO e uscite +SOFTWARE: il codice guarda il prezzo una volta all'ora e, se il livello SL/TP e' stato superato, +chiude a mercato. Costo misurato (audit 2026-07-02, riconfermato il 24/07): alla lente 50/50 il +book passa FULL 1.46 -> 1.19, HOLD 1.64 -> 1.15, DD 18% -> 25%. E' il **buco singolo piu' grande +del progetto** (~ -0.35 di Sharpe), e il 24/07 ha gia' stabilito che **un cron piu' veloce non lo +recupera** ("nessun cron riporta l'exit software sul livello SL dentro una barra che gappa"; +allineare la cadenza a 230m vale ~ +0.01/+0.02 Sharpe = rumore). + +L'ANGOLO MAI TESTATO: l'unico meccanismo che non e' un cron — gli **ordini resting on-book**. +E li' c'e' un'ASIMMETRIA che nessuno ha sfruttato: + + * il **TP e' un LIMIT order** appoggiato sul book al livello. Se il prezzo ci arriva, il fill + e' AL LIVELLO **per costruzione** (sei tu il maker: il prezzo non puo' attraversare il tuo + limite senza eseguirlo). -> il lato TP e' **integralmente recuperabile**, e per giunta a + fee MAKER invece che taker. + * lo **SL e' uno STOP-MARKET**: al trigger diventa un ordine a mercato. In condizioni normali + riempie a ~pochi bps dal livello; **in un gap riempie molto peggio**. -> il lato SL e' + recuperabile solo IN PARTE, e il gap resta (com'e' giusto: e' un rischio vero, non un + artefatto di modello). + +Da cui l'ipotesi da falsificare: **il grosso del -0.35 e' sul lato TP, quindi e' recuperabile +senza prendersi nessun rischio nuovo.** Se invece il grosso e' sul lato SL, la conclusione del +24/07 ("il degrado e' strutturale") si estende anche agli ordini on-book e non c'e' niente da fare. + +MODALITA' SIMULATE (stesse trade boundaries, cambia SOLO il prezzo d'esecuzione) + canonical : entry a close[i], exit AL LIVELLO -> il BACKTEST (riferimento ottimista) + hourly : entry/exit al close 5m del prossimo multiplo orario -> il PATH LIVE DI OGGI + onbook : entry al prossimo multiplo orario (l'ingresso resta una decisione del cron: + e' un segnale a chiusura di barra, non si puo' appoggiare in anticipo), ma: + - TP -> fill AL LIVELLO, fee MAKER (limit resting) + - SL -> stop-market: fill al livello se la barra 5m del trigger lo ATTRAVERSA; + fill all'OPEN della barra 5m se la barra ci GAPPA dentro (open gia' + oltre il livello), in entrambi i casi meno SLIPPAGE dichiarato + - time-> resta al prossimo multiplo orario (e' il cron che decide) + onbook_tp : contro-fattuale che ISOLA il lato TP (TP on-book, SL e time come oggi) -> dice + quanta parte del recupero viene dal lato che NON aggiunge rischio. + +ONESTA' + * la rilevazione del gap e' a risoluzione **5m** (il gap vero e' piu' fine) -> il fill SL + modellato e' un OTTIMISMO residuo su quel lato; il lato TP invece non ha questo problema + (un limit resting non ha bisogno di risoluzione: o il prezzo tocca il livello o no). + * si ignora la POSIZIONE IN CODA sul limit TP: a $300 di nozionale su BTC perp e' una + semplificazione benigna (size irrilevante rispetto al book), ma e' dichiarata. + * risultati riportati sull'off 0 E sulla **banda dei 23 offset** a priori: il progetto ha gia' + stabilito che l'off 0 e' al 93-98 pctl (best-of-23) -> la mediana di banda e' la lente onesta. + * niente file di produzione toccato. Se il risultato regge, la modifica al live e' una + RACCOMANDAZIONE, non un fatto compiuto. + +Uso: `uv run python scripts/research/r0726_skh_onbook.py` (~5-10 min: 23 offset x 2 asset) +""" +from __future__ import annotations + +import sys +from pathlib import Path + +import numpy as np +import pandas as pd + +ROOT = Path(__file__).resolve().parents[2] +sys.path.insert(0, str(ROOT)) +sys.path.insert(0, str(ROOT / "scripts" / "research")) +sys.path.insert(0, str(ROOT / "scripts" / "research" / "alt")) + +import r0702_anchor_skh01 as r02 # noqa: E402 +from src.portfolio.portfolio import metrics # noqa: E402 + +MSH = 3_600_000 +MS_LTF = r02.MS_LTF +FEE_TAKER_SIDE = 0.0005 # Deribit taker ~0.05%/lato (il RT 0.10% del progetto) +FEE_MAKER_SIDE = 0.0000 # limit resting: maker (Deribit futures ~0, a volte rebate) +SL_SLIP = 0.0005 # slippage dichiarata sullo stop-market (5 bps) +HOLDOUT = "2025-01-01" + + +# --------------------------------------------------------------------------- simulazione + +def sim_equity_exec(ltf: pd.DataFrame, ent: list, mode: str, + ts5_close: np.ndarray, o5: np.ndarray, h5: np.ndarray, + l5: np.ndarray, c5: np.ndarray) -> np.ndarray: + """Come r0702.sim_equity ma con le modalita' d'esecuzione ON-BOOK. + + Le trade boundaries (barra d'ingresso, barra di trigger, non-overlap) sono IDENTICHE al + canonico in ogni modalita': cambia solo il prezzo a cui si esegue e la fee del lato. + """ + c = ltf["close"].values.astype(float) + h = ltf["high"].values.astype(float) + lo = 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 + j = np.searchsorted(ts5_close, hb, side="left") + return c5[min(j, len(c5) - 1)] + + def stop_fill(t_start: int, t_end: int, level: float, direction: int) -> float: + """Prezzo di fill di uno STOP-MARKET on-book fra due timestamp, a risoluzione 5m. + + Trova la prima barra 5m che tocca il livello: se ci GAPPA dentro (open gia' oltre) + il fill e' l'open (il gap e' un rischio vero, non un artefatto); altrimenti e' il + livello. In entrambi i casi meno la slippage dichiarata. + """ + a = int(np.searchsorted(ts5_close, t_start, side="left")) + b = int(np.searchsorted(ts5_close, t_end, side="right")) + for j in range(a, min(b + 1, len(c5))): + touched = (l5[j] <= level) if direction == 1 else (h5[j] >= level) + if touched: + gapped = (o5[j] < level) if direction == 1 else (o5[j] > level) + px = o5[j] if gapped else level + return px * (1.0 - SL_SLIP * direction) + return level * (1.0 - SL_SLIP * direction) # non trovato a 5m: cap al livello + + initial = 1000.0 + capital = initial + equity = np.full(n, capital, dtype=float) + busy_until = -1 + stats = dict(sl=0, tp=0, time=0, gap_sl=0) + for i in range(n): + e = ent[i] if i < len(ent) else None + if e is None or e.get("dir", 0) == 0 or 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 = c[i] if mode == "canonical" else px_hour(close_ts[i]) + fee_entry = FEE_TAKER_SIDE + + # --- detection identica al canonico (SL prioritario) + exit_idx = min(i + max_bars, n - 1) + exit_lvl = c[exit_idx] + kind = "time" + for j in range(i + 1, min(i + max_bars + 1, n)): + hit_sl = sl is not None and ( + (direction == 1 and lo[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 lo[j] <= tp)) + if hit_sl: + exit_lvl, exit_idx, kind = sl, j, "sl" + break + if hit_tp: + exit_lvl, exit_idx, kind = tp, j, "tp" + break + exit_lvl, exit_idx = c[j], j + stats[kind] += 1 + + # --- prezzo d'uscita per modalita' + if mode == "canonical": + exit_price, fee_exit = exit_lvl, FEE_TAKER_SIDE + elif mode == "hourly": + exit_price, fee_exit = px_hour(close_ts[exit_idx]), FEE_TAKER_SIDE + elif mode in ("onbook", "onbook_tp"): + if kind == "tp": + exit_price, fee_exit = tp, FEE_MAKER_SIDE # limit resting: al livello + elif kind == "sl" and mode == "onbook": + t0 = int(ltf["timestamp"].values[exit_idx]) + exit_price = stop_fill(t0, t0 + MS_LTF, sl, direction) + fee_exit = FEE_TAKER_SIDE + if (direction == 1 and exit_price < sl * (1 - SL_SLIP) - 1e-9) or \ + (direction == -1 and exit_price > sl * (1 + SL_SLIP) + 1e-9): + stats["gap_sl"] += 1 + else: # sl in onbook_tp, o time + exit_price, fee_exit = px_hour(close_ts[exit_idx]), FEE_TAKER_SIDE + else: + raise ValueError(mode) + + gross = (exit_price - entry) / entry * direction + net = gross - (fee_entry + fee_exit) + capital = max(capital + capital * net, 1.0) + equity[i:exit_idx + 1] = capital + busy_until = exit_idx + return equity, stats + + +def skh_series(off: int, mode: str) -> tuple[pd.Series, dict]: + """Rendimenti daily dello sleeve SKH01 (50/50 BTC+ETH) a un offset e una modalita'.""" + out = {} + agg = dict(sl=0, tp=0, time=0, gap_sl=0) + for asset in ("BTC", "ETH"): + _, _, ltf, ent = r02.run_asset(asset, off) + df5 = r02.get5m(asset) + ts5 = df5["timestamp"].values.astype(np.int64) + 300_000 + eq, st = sim_equity_exec(ltf, ent, mode, ts5, + df5["open"].values.astype(float), + df5["high"].values.astype(float), + df5["low"].values.astype(float), + df5["close"].values.astype(float)) + for k in agg: + agg[k] += st[k] + idx = pd.to_datetime(ltf["timestamp"].values + MS_LTF, unit="ms", utc=True) + s = pd.Series(eq, index=idx).resample("1D").last().ffill() + out[asset] = s.pct_change().fillna(0.0) + J = pd.concat(out, axis=1, join="inner").fillna(0.0) + return pd.Series(0.5 * J["BTC"].values + 0.5 * J["ETH"].values, index=J.index), agg + + +def book(skh: pd.Series, w: float) -> pd.Series: + tp = r02.al.tp01_baseline_daily() + tp = tp.dropna() + if tp.index.tz is None: + tp.index = tp.index.tz_localize("UTC") + tp.index = tp.index.floor("D") + s = skh.copy() + s.index = s.index.floor("D") + J = pd.concat({"tp": tp, "skh": s}, axis=1, join="inner").fillna(0.0) + return pd.Series((1 - w) * J["tp"].values + w * J["skh"].values, index=J.index) + + +def sh3(s: pd.Series) -> tuple[float, float, float, float]: + m_all = metrics(s) + m_is = metrics(s[s.index < HOLDOUT]) + m_ho = metrics(s[s.index >= HOLDOUT]) + return m_all["sharpe"], m_is["sharpe"], m_ho["sharpe"], m_all["maxdd"] + + +MODES = ("canonical", "hourly", "onbook_tp", "onbook") +LABEL = {"canonical": "canonical (backtest, fill-al-livello)", + "hourly": "hourly (PATH LIVE DI OGGI)", + "onbook_tp": "onbook_tp (solo TP a limite)", + "onbook": "onbook (TP limite + SL stop-market)"} + + +def main() -> None: + global SL_SLIP + print("=" * 100) + print(" T1 — SKH01: il degrado del path live e' recuperabile con ordini RESTING ON-BOOK?") + print("=" * 100) + print(f"\n fee: taker {FEE_TAKER_SIDE:.4%}/lato, maker {FEE_MAKER_SIDE:.4%}/lato | " + f"slippage stop-market {SL_SLIP:.2%} | hold-out {HOLDOUT}") + + # ------------------------------------------------ offset 0 (la griglia che gira davvero) + print("\n" + "-" * 100) + print(" A. OFFSET 0 — la griglia che gira davvero nel live") + print("-" * 100) + ser0 = {} + print(f" {'modalita':>40} {'SKH standalone':>28} {'book 75/25':>26}") + print(f" {'':>40} {'FULL':>6} {'IS':>6} {'HOLD':>6} {'DD':>6} " + f"{'FULL':>6} {'IS':>6} {'HOLD':>6} {'DD':>6}") + stats0 = {} + for m in MODES: + s, st = skh_series(0, m) + ser0[m] = s + stats0[m] = st + a = sh3(s) + b = sh3(book(s, 0.25)) + print(f" {LABEL[m]:>40} {a[0]:>6.2f} {a[1]:>6.2f} {a[2]:>6.2f} {a[3]:>5.1%} " + f"{b[0]:>6.2f} {b[1]:>6.2f} {b[2]:>6.2f} {b[3]:>5.1%}") + st = stats0["onbook"] + tot = st["sl"] + st["tp"] + st["time"] + print(f"\n composizione delle uscite (off 0, BTC+ETH): {tot} trade — " + f"SL {st['sl']} ({st['sl']/tot:.0%}) | TP {st['tp']} ({st['tp']/tot:.0%}) | " + f"time {st['time']} ({st['time']/tot:.0%})") + print(f" di cui SL con GAP dentro la barra 5m (fill peggiore del livello): " + f"{st['gap_sl']} = {st['gap_sl']/max(st['sl'],1):.0%} degli SL") + + # ------------------------------------------------ scomposizione del recupero + print("\n" + "-" * 100) + print(" B. DOVE VIVE IL DEGRADO — scomposizione del gap canonical -> live") + print("-" * 100) + b_can, b_hr = sh3(book(ser0["canonical"], 0.25)), sh3(book(ser0["hourly"], 0.25)) + b_tp, b_ob = sh3(book(ser0["onbook_tp"], 0.25)), sh3(book(ser0["onbook"], 0.25)) + gap_tot = b_can[0] - b_hr[0] + print(f" book 75/25, Sharpe FULL: canonical {b_can[0]:.3f} -> live {b_hr[0]:.3f} " + f"(degrado {gap_tot:+.3f})") + if abs(gap_tot) > 1e-9: + print(f" recupero col solo TP a limite : {b_tp[0]-b_hr[0]:+.3f} " + f"= {(b_tp[0]-b_hr[0])/gap_tot:>5.0%} del degrado [NESSUN rischio nuovo]") + print(f" recupero TP + SL on-book : {b_ob[0]-b_hr[0]:+.3f} " + f"= {(b_ob[0]-b_hr[0])/gap_tot:>5.0%} del degrado") + print(f" residuo NON recuperabile : {b_can[0]-b_ob[0]:+.3f} " + f"= {(b_can[0]-b_ob[0])/gap_tot:>5.0%} del degrado [gap + entry del cron]") + + # ------------------------------------------------ banda dei 23 offset (la lente onesta) + print("\n" + "-" * 100) + print(" C. BANDA DEI 23 OFFSET — la lente de-luckata (off 0 e' al 93-98 pctl, audit 02/07)") + print("-" * 100) + rows = {m: [] for m in MODES} + for off in r02.OFFSETS: + for m in MODES: + s, _ = skh_series(off, m) + rows[m].append(sh3(book(s, 0.25))) + print(f" {'modalita':>40} {'FULL med':>9} {'IS med':>8} {'HOLD med':>9} " + f"{'DD med':>7} {'HOLD min':>9} {'HOLD max':>9}") + med = {} + A = {m: np.array(rows[m]) for m in MODES} + for m in MODES: + med[m] = np.median(A[m], axis=0) + print(f" {LABEL[m]:>40} {med[m][0]:>9.2f} {med[m][1]:>8.2f} {med[m][2]:>9.2f} " + f"{med[m][3]:>6.1%} {A[m][:, 2].min():>9.2f} {A[m][:, 2].max():>9.2f}") + + # ⚠️ gli offset sono coppie APPAIATE: la statistica giusta e' la mediana delle DIFFERENZE + # per-offset, non la differenza delle mediane (che confronta offset diversi fra loro). + print("\n differenze APPAIATE per-offset (mediana [min, max] sui 23 offset):") + print(f" {'':>40} {'dFULL':>22} {'dHOLD':>22} {'dDD':>20}") + for m in ("canonical", "onbook_tp", "onbook"): + d0 = A[m][:, 0] - A["hourly"][:, 0] + d2 = A[m][:, 2] - A["hourly"][:, 2] + d3 = A[m][:, 3] - A["hourly"][:, 3] + print(f" {LABEL[m]:>40} " + f"{np.median(d0):>+7.3f} [{d0.min():+.2f},{d0.max():+.2f}] " + f"{np.median(d2):>+7.3f} [{d2.min():+.2f},{d2.max():+.2f}] " + f"{np.median(d3):>+6.2%} [{d3.min():+.1%},{d3.max():+.1%}]") + + dmax = np.median(A["canonical"][:, 0] - A["hourly"][:, 0]) + dob = np.median(A["onbook"][:, 0] - A["hourly"][:, 0]) + dtp = np.median(A["onbook_tp"][:, 0] - A["hourly"][:, 0]) + print(f"\n degrado recuperabile al massimo (canonical-hourly, mediana appaiata): {dmax:+.3f} FULL") + if abs(dmax) > 1e-9: + print(f" recuperato dal solo TP a limite: {dtp:+.3f} = {dtp/dmax:>5.0%}") + print(f" recuperato da TP + SL on-book : {dob:+.3f} = {dob/dmax:>5.0%}") + print("\n in quanti dei 23 offset la modalita' MIGLIORA il path live di oggi?") + for m in ("onbook_tp", "onbook"): + nf = int((A[m][:, 0] - A["hourly"][:, 0] > 0).sum()) + nh = int((A[m][:, 2] - A["hourly"][:, 2] > 0).sum()) + nd = int((A[m][:, 3] - A["hourly"][:, 3] < 0).sum()) + print(f" {LABEL[m]:>40}: FULL {nf}/23 HOLD {nh}/23 DD {nd}/23") + # il lato SL da solo = differenza fra onbook (TP+SL) e onbook_tp (solo TP) + dsl = A["onbook"][:, 0] - A["onbook_tp"][:, 0] + print(f"\n contributo del SOLO lato SL on-book (onbook - onbook_tp), FULL: " + f"mediana {np.median(dsl):+.3f} [{dsl.min():+.2f},{dsl.max():+.2f}], " + f"positivo in {int((dsl > 0).sum())}/23 offset") + + # ------------------------------------------------ robustezza alla slippage assunta + print("\n" + "-" * 100) + print(" D. SENSIBILITA' ALLA SLIPPAGE ASSUNTA sullo stop-market (off 0, book 75/25)") + print("-" * 100) + keep = SL_SLIP + print(f" {'slippage':>10} {'FULL':>7} {'HOLD':>7} {'DD':>7} (live di oggi: " + f"FULL {b_hr[0]:.2f} / HOLD {b_hr[2]:.2f})") + for slip in (0.0000, 0.0005, 0.0020, 0.0050, 0.0100): + SL_SLIP = slip + s, _ = skh_series(0, "onbook") + r = sh3(book(s, 0.25)) + flag = "" if r[0] > b_hr[0] else " <- peggio del live" + print(f" {slip:>9.2%} {r[0]:>7.2f} {r[2]:>7.2f} {r[3]:>6.1%}{flag}") + SL_SLIP = keep + print("\n" + "=" * 100) + + +if __name__ == "__main__": + main() diff --git a/scripts/research/r0726_xsr_equity.py b/scripts/research/r0726_xsr_equity.py new file mode 100644 index 0000000..98310f9 --- /dev/null +++ b/scripts/research/r0726_xsr_equity.py @@ -0,0 +1,214 @@ +#!/usr/bin/env python +"""r0726_xsr_equity — T3 dell'ondata 2026-07-26: XSR01 regge fuori dal crypto e fuori dai +2.6 anni monoregime? Test di FALSIFICAZIONE su 26+ anni di ETF settoriali. + +PERCHE'. XSR01 (candidato in forward-monitor dal 25/07, gate pre-registrato al 2026-10-23) ha +superato tutto quello che gli e' stato chiesto — marginale ADDS, deflated-Sharpe 0.985, corr ~0 +a tutti e 5 gli sleeve, tre null a fee-neutrale — MA la sua debolezza numero uno e' dichiarata: + + "storia 2.6 anni MONOREGIME e CRESCENTE (Sharpe 2024 1.03 / 2025 1.98 / 2026 3.11): + l'edge e' recente." + +Con 2.6 anni non si distingue un meccanismo reale da un regime fortunato. L'unico modo di +guadagnare potenza statistica senza aspettare anni e' portare il meccanismo CONGELATO su un +pannello **diverso per asset class e lungo decenni**, e vedere se sopravvive. Se regge su 26 anni +di settoriali US, l'ipotesi "e' un artefatto del bear alt 2024-2026" e' molto piu' difficile da +sostenere. Se non regge, il gate del 23/10 deve saperlo. + +NON E' IL TEST DEL 25/07. `r0725_statarb_eq.py` ha gia' portato il meccanismo sulle azioni ed e' +stato SCARTATO (paniere Sharpe -1.00 netta, -0.17 lorda) — ma quello era il meccanismo a **COPPIE** +(12 coppie a priori, ampiezza effettiva 9.9). XSR01 non e' un paniere di coppie: e' la versione +**DEMEANATA cross-sezionalmente**, ed e' proprio il demeaning che ha cambiato tutto (ampiezza +effettiva 4.5 -> 37.4) annullando algebricamente la gamba comune. Testare il demean su equity e' +quindi una domanda DIVERSA, mai posta. Il risultato lordo del 25/07 (-0.17, cioe' il residuo +REVERTE debolmente sulle azioni mentre CONTINUA sul crypto) e' l'ipotesi nulla di questo test. + +DISEGNO + * meccanismo CONGELATO importato da r0725_statarb_multi (W=45, sgn=+1, residuo OLS causale su + un fattore comune, z-score, tanh, vol-target 20%, cap 2x) — nessuna reimplementazione; + * demeaning cross-sezionale giornaliero, identico a r0725_statarb_basket_gate; + * fattore comune = **SPY** (analogo di BTC: il fattore che il demeaning deve annullare); + * due universi, entrambi a priori: + SECT9 = i 9 settoriali SPDR classici (1998+, ~26 anni) — cross-section OMOGENEA, il + parallelo piu' stretto degli alt (un solo fattore comune forte); + ALL28 = tutti gli ETF su disco tranne SPY — parallelo del "ALL50" di XSR01; + * split NON aggiustati riparati in lettura (eqlib.load_eq -> src/data/eq_splits) — senza questo + IWM/EFA avvelenerebbero il pannello (difetto trovato il 25/07); + * LORDO **e** netto: la lettura del 25/07 e' che sulle azioni il segno vive nel lordo e il + turnover se lo mangia. Un venue a PAVIMENTO FISSO (IB) e' anche peggio di una fee + proporzionale a questa taglia (lezione 25/07) -> il netto proporzionale e' gia' generoso; + * null di permutazione cross-sezionale **A FEE ZERO** (lezione XSR01: permutare un segnale ne + fa esplodere il turnover, quindi un null a fee piena perderebbe per COSTO invece che per + assenza d'informazione, e regalerebbe un p-value trionfale e falso); + * decomposizione per DECENNIO: 26 anni servono a questo, non a fare una media unica. + +Uso: `uv run python scripts/research/r0726_xsr_equity.py` +""" +from __future__ import annotations + +import sys +from pathlib import Path + +import numpy as np +import pandas as pd + +ROOT = Path(__file__).resolve().parents[2] +sys.path.insert(0, str(ROOT)) +sys.path.insert(0, str(ROOT / "scripts" / "research")) + +from eqlib import SECTORS_CLASSIC, load_eq # noqa: E402 +from r0725_statarb_multi import (FEE_LEG, SGN, W, _dd, _sh, signal) # noqa: E402 + +RAW = ROOT / "data" / "raw" +BASE = "SPY" +MIN_BARS = 400 +N_PERM = 300 +BLOCK = 20 +SEED = 20260726 +ANN = np.sqrt(252.0) # ⚠️ giorni di BORSA, non 365 (lezione 25/07 sull'annualizzazione) + + +def all_symbols() -> list[str]: + out = [] + for p in sorted(RAW.glob("eq_*_1d.parquet")): + s = p.stem.replace("eq_", "").replace("_1d", "").upper() + if s != BASE: + out.append(s) + return out + + +def _px(sym: str) -> pd.Series: + s = load_eq(sym)["close"].astype(float) + return s[~s.index.duplicated(keep="last")].sort_index() + + +def build(cols: list[str]) -> tuple[pd.DataFrame, pd.DataFrame]: + """Posizioni e spread per ogni gamba, col meccanismo congelato vs SPY.""" + base = _px(BASE) + P, S = {}, {} + for sym in cols: + try: + t = _px(sym) + except FileNotFoundError: + continue + ix = base.index.intersection(t.index) + if len(ix) < MIN_BARS: + continue + p, s = signal(base[ix], t[ix]) + P[sym] = pd.Series(p, index=ix) + S[sym] = pd.Series(s, index=ix) + return (pd.concat(P, axis=1, sort=True).sort_index(), + pd.concat(S, axis=1, sort=True).sort_index()) + + +def pnl_fee(pos: np.ndarray, spread: np.ndarray, fee_leg: float) -> np.ndarray: + held = np.concatenate([[0.0], pos[:-1]]) + turn = np.abs(np.diff(held, prepend=0.0)) + return held * spread - 2.0 * fee_leg * turn + + +def basket(P: pd.DataFrame, S: pd.DataFrame, demean: bool, fee_leg: float) -> pd.Series: + Q = P.sub(P.mean(axis=1), axis=0) if demean else P + out = {c: pd.Series(pnl_fee(Q[c].to_numpy(float), S[c].to_numpy(float), fee_leg), index=Q.index) + for c in Q.columns} + return pd.concat(out, axis=1, sort=True).mean(axis=1, skipna=True).dropna() + + +def eff_breadth(P: pd.DataFrame, S: pd.DataFrame, demean: bool) -> float: + Q = P.sub(P.mean(axis=1), axis=0) if demean else P + M = pd.concat({c: pd.Series(pnl_fee(Q[c].to_numpy(float), S[c].to_numpy(float), 0.0), + index=Q.index) for c in Q.columns}, axis=1) + C = M.corr().values + n = C.shape[0] + off = (C.sum() - np.trace(C)) / (n * (n - 1)) + return float(n / (1.0 + (n - 1) * off)) + + +def sh(r) -> float: + r = np.asarray(r, float) + r = r[np.isfinite(r)] + return float(r.mean() / r.std() * ANN) if len(r) > 30 and r.std() > 0 else 0.0 + + +def main() -> None: + print("=" * 100) + print(" T3 — XSR01 fuori dal crypto: il meccanismo congelato su 26 anni di ETF settoriali") + print("=" * 100) + print(f"\n meccanismo: W={W}, sgn={SGN:+d}, residuo OLS causale vs {BASE}, z-score, tanh,") + print(" vol-target 20%, cap 2x, demean cross-sezionale giornaliero [CONGELATO, importato]") + print(f" annualizzazione: sqrt(252) = giorni di BORSA (NON 365)") + + universes = { + "SECT9 9 settoriali SPDR (1998+)": SECTORS_CLASSIC, + "ALL28 tutti gli ETF su disco": all_symbols(), + } + + store = {} + for name, cols in universes.items(): + P, S = build(cols) + store[name] = (P, S) + print("\n" + "-" * 100) + print(f" {name} — {P.shape[1]} gambe, {len(P)} barre, " + f"{P.index[0].date()} -> {P.index[-1].date()}") + print("-" * 100) + print(f" {'variante':>28} {'ampiezza eff.':>14} {'Sharpe LORDA':>13} " + f"{'Sharpe NETTA':>13} {'maxDD':>8} {'ret/anno':>9}") + for lab, dm in (("coppie (no demean)", False), ("DEMEAN (= XSR01)", True)): + g = basket(P, S, dm, 0.0) + n = basket(P, S, dm, FEE_LEG) + eb = eff_breadth(P, S, dm) + cagr = float(np.nanmean(n.values)) * 252.0 + print(f" {lab:>28} {eb:>14.1f} {sh(g):>13.2f} {sh(n):>13.2f} " + f"{_dd(n.values):>7.1%} {cagr:>8.1%}") + + # ------------------------------------------------------------------ per decennio + print("\n" + "-" * 100) + print(" A. PER DECENNIO — 26 anni servono a questo (variante DEMEAN, la stessa di XSR01)") + print("-" * 100) + print(f" {'universo':>34} {'periodo':>14} {'anni':>5} {'Sh LORDA':>9} {'Sh NETTA':>9} {'giorni':>7}") + for name, (P, S) in store.items(): + g = basket(P, S, True, 0.0) + n = basket(P, S, True, FEE_LEG) + for lo, hi in (("1998", "2005"), ("2005", "2012"), ("2012", "2019"), ("2019", "2027")): + kg = (g.index >= lo) & (g.index < hi) + kn = (n.index >= lo) & (n.index < hi) + if kg.sum() < 250: + continue + print(f" {name.split()[0]:>34} {lo + '-' + hi:>14} {kg.sum() / 252:>5.1f} " + f"{sh(g[kg]):>9.2f} {sh(n[kn]):>9.2f} {int(kg.sum()):>7d}") + + # ------------------------------------------------------------------ null a fee ZERO + print("\n" + "-" * 100) + print(" B. NULL DI PERMUTAZIONE CROSS-SEZIONALE — a FEE ZERO (lezione XSR01)") + print("-" * 100) + print(" Permutare un segnale ne fa esplodere il turnover: a fee piena il null perderebbe") + print(" per COSTO invece che per assenza d'informazione -> p-value trionfale e falso.") + rng = np.random.default_rng(SEED) + print(f"\n {'universo':>34} {'candidato LORDO':>16} {'null medio':>11} {'null p95':>9} " + f"{'null max':>9} {'p-value':>8}") + for name, (P, S) in store.items(): + cand = sh(basket(P, S, True, 0.0)) + Pv = P.to_numpy(float) + draws = [] + for _ in range(N_PERM): + idx = rng.permutation(Pv.shape[1]) + Pp = pd.DataFrame(Pv[:, idx], index=P.index, columns=P.columns) + draws.append(sh(basket(Pp, S, True, 0.0))) + d = np.array(draws) + pval = float((d >= cand).mean()) + print(f" {name.split()[0]:>34} {cand:>16.2f} {d.mean():>11.2f} " + f"{np.percentile(d, 95):>9.2f} {d.max():>9.2f} {pval:>8.3f}") + + # ------------------------------------------------------------------ confronto col crypto + print("\n" + "-" * 100) + print(" C. CONFRONTO DIRETTO col candidato crypto (numeri del 25/07, non ricalcolati)") + print("-" * 100) + print(" XSR01 (50 alt HL, 2.6 anni): ampiezza eff. 37.4 | LORDA 2.70 | NETTA 1.82 | DD -2.6%") + print(" STATARB-EQ a COPPIE (25/07): ampiezza eff. 9.9 | LORDA -0.17 | NETTA -1.00") + print("\n La domanda del test: il DEMEAN — che sul crypto ha fatto 4.5 -> 37.4 di ampiezza e") + print(" ha creato l'edge — fa la stessa cosa sulle azioni, o li' il residuo resta reverting?") + print("\n" + "=" * 100) + + +if __name__ == "__main__": + main() diff --git a/tests/test_wave_0726.py b/tests/test_wave_0726.py new file mode 100644 index 0000000..bcfe29d --- /dev/null +++ b/tests/test_wave_0726.py @@ -0,0 +1,144 @@ +"""Test dell'ondata 2026-07-26 (T1 esecuzione SKH01, T2 DVOLSPREAD, T3 XSR01 su equity). + +Blindano le proprieta' da cui dipendono le conclusioni — non i numeri (che si muovono col feed), +ma le INVARIANTI che, se si rompessero, renderebbero i numeri privi di significato. +""" +from __future__ import annotations + +import sys +from pathlib import Path + +import numpy as np +import pytest + +ROOT = Path(__file__).resolve().parents[1] +for p in (ROOT, ROOT / "scripts" / "research", ROOT / "scripts" / "research" / "alt", + ROOT / "scripts" / "research" / "ortho"): + sys.path.insert(0, str(p)) + +pytest.importorskip("pandas") + + +# ============================================================ T1 — esecuzione SKH01 + +def test_t1_le_modalita_condividono_le_stesse_trade_boundaries(): + """Il confronto fra modalita' d'esecuzione ha senso SOLO se i trade sono gli stessi: + cambia il prezzo di fill, non quali trade si fanno. Se le boundaries divergessero + staremmo confrontando due strategie diverse e non due esecuzioni della stessa.""" + import r0702_anchor_skh01 as r02 + import r0726_skh_onbook as ob + _, _, ltf, ent = r02.run_asset("BTC", 0) + df5 = r02.get5m("BTC") + args = (df5["timestamp"].values.astype(np.int64) + 300_000, + df5["open"].values.astype(float), df5["high"].values.astype(float), + df5["low"].values.astype(float), df5["close"].values.astype(float)) + stats = {} + for m in ("canonical", "hourly", "onbook", "onbook_tp"): + _, st = ob.sim_equity_exec(ltf, ent, m, *args) + stats[m] = (st["sl"], st["tp"], st["time"]) + assert len(set(stats.values())) == 1, stats + + +def test_t1_canonical_riproduce_il_backtest_ufficiale(): + """La modalita' 'canonical' deve coincidere col backtest dello sleeve: e' l'ancora + di tutto il confronto. Tolleranza larga (il feed cresce ogni giorno).""" + import r0726_skh_onbook as ob + s, _ = ob.skh_series(0, "canonical") + full, _, hold, dd = ob.sh3(s) + assert 1.2 < full < 1.7, full # audit 02/07: ~1.46 + assert 1.3 < hold < 1.9, hold # audit 02/07: ~1.64 + assert 0.12 < dd < 0.24, dd # audit 02/07: ~18.1% + + +def test_t1_il_limit_tp_non_puo_riempire_peggio_del_livello(): + """Un limit resting o riempie AL livello o non riempie. Se il modello desse un prezzo + peggiore del livello sul lato TP, starebbe modellando un ordine a mercato.""" + import r0702_anchor_skh01 as r02 + import r0726_skh_onbook as ob + _, _, ltf, ent = r02.run_asset("BTC", 0) + df5 = r02.get5m("BTC") + eq_tp, _ = ob.sim_equity_exec( + ltf, ent, "onbook_tp", df5["timestamp"].values.astype(np.int64) + 300_000, + df5["open"].values.astype(float), df5["high"].values.astype(float), + df5["low"].values.astype(float), df5["close"].values.astype(float)) + assert np.all(np.isfinite(eq_tp)) and eq_tp[-1] > 0 + + +def test_t1_slippage_piu_alta_non_migliora_lo_stop(): + """Monotonia elementare: piu' slippage sullo stop-market non puo' dare un book migliore. + Se si rompe, il segno dello slippage e' invertito da qualche parte.""" + import r0726_skh_onbook as ob + keep = ob.SL_SLIP + try: + ob.SL_SLIP = 0.0 + lo = ob.sh3(ob.book(ob.skh_series(0, "onbook")[0], 0.25))[0] + ob.SL_SLIP = 0.02 + hi = ob.sh3(ob.book(ob.skh_series(0, "onbook")[0], 0.25))[0] + finally: + ob.SL_SLIP = keep + assert lo > hi, (lo, hi) + + +# ============================================================ T2 — DVOLSPREAD + +def test_t2_il_book_e_market_neutral_per_costruzione(): + """w_eth == -w_btc: e' l'unica ragione per cui il lead e' a bassa correlazione con TP01. + Se si rompesse, la bassa correlazione sarebbe un caso e non una proprieta'.""" + import r0726_dvolspread_gate as g + ev = g.evaluate(**g.CHOSEN) + assert abs(ev["net_beta"]) < 0.02, ev["net_beta"] + + +def test_t2_book_flat_prima_che_esista_il_dvol(): + """Prima del 2021-03 non c'e' DVOL: il book DEVE essere flat, altrimenti sta usando + un valore inventato (e il campione 'attivo' sarebbe una bugia).""" + import pandas as pd + import r0726_dvolspread_gate as g + r = g.daily_returns(**g.CHOSEN) + pre = r[r.index < pd.Timestamp("2021-03-01", tz="UTC")] + assert len(pre) > 100 + assert float(np.abs(pre.values).max()) < 1e-12 + + +def test_t2_eseguibile_al_capitale_reale(): + """Il lead vale solo se sta dentro il cap di $300/asset a $600 di capitale.""" + import r0726_dvolspread_gate as g + ev = g.evaluate(**g.CHOSEN) + assert ev["executable"], (ev["max_leg_frac"], ev["gross_lev"]) + + +# ============================================================ T3 — XSR01 su equity + +def test_t3_usa_il_meccanismo_congelato_non_una_copia(): + """Il test di falsificazione vale solo se gira LO STESSO meccanismo del candidato: + se qualcuno cambiasse W o il segno, il test smetterebbe di dire qualcosa su XSR01.""" + from r0725_statarb_multi import SGN, W + assert (W, SGN) == (45, +1) + + +def test_t3_il_demean_azzera_la_posizione_media(): + """Il demeaning cross-sezionale deve rendere la posizione media giornaliera nulla: + e' cio' che elimina algebricamente la gamba comune. Senza, non e' XSR01.""" + import r0726_xsr_equity as xe + from eqlib import SECTORS_CLASSIC + P, _ = xe.build(SECTORS_CLASSIC) + Q = P.sub(P.mean(axis=1), axis=0) + assert float(np.abs(np.nan_to_num(Q.mean(axis=1).values)).max()) < 1e-12 + + +def test_t3_lo_split_e_riparato_in_lettura(): + """IWM ha uno split non aggiustato il 2005-06-09 (difetto trovato il 25/07). Se il + pannello non lo riparasse, il test su 26 anni sarebbe avvelenato.""" + import pandas as pd + from eqlib import load_eq + d = load_eq("IWM") + r = d["close"].pct_change() + around = r[(r.index >= pd.Timestamp("2005-06-01", tz="UTC")) + & (r.index <= pd.Timestamp("2005-06-20", tz="UTC"))] + assert float(around.min()) > -0.20, float(around.min()) + + +def test_t3_annualizzazione_su_giorni_di_borsa(): + """Lezione 25/07: una serie su ~252 barre/anno annualizzata a 365 gonfia lo Sharpe di 1.2x.""" + import r0726_xsr_equity as xe + assert abs(xe.ANN - np.sqrt(252.0)) < 1e-9