diff --git a/CLAUDE.md b/CLAUDE.md index 8c139bf..c2370c4 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -1039,18 +1039,82 @@ Prima ondata di ricerca onesta su BTC/ETH certificati (5 track, harness condivis **COSA NON CADE:** tutte le traiettorie, i muri e le tabelle di rendita pubblicate usano `book_series(with_gtaa=0)` = **solo TP01+SKH01 su Deribit** → nessun numero del piano va rifatto. E il book live non lo include. - **VIA D'USCITA, non ancora percorsa:** equivalenti **UCITS** (CSPX/SXR8, EQQQ/SXRV, IUSN/CSUSS, - DTLA/IDTL, SGLN/IGLN, IHYU). ⚠️ **Non e' una sostituzione di ticker:** (a) storia piu' **corta** - (diversi nati dopo il 2010) → si perde la validazione a 30 anni, che e' cio' che rendeva credibile - lo sleeve; (b) ritenuta sui dividendi, TER e replica diversi → decine di bps/anno su un CAGR del - **3.65%**, non trascurabili in proporzione; (c) quotazione LSE/Xetra → orari e valuta diversi da - uno sleeve che decide sul close USA. Percorso onesto: **validare sull'INDICE** (storia lunga) e - **negoziare il VEICOLO UCITS**, dichiarando tracking error e ritenuta come costi espliciti. **REGOLA: la negoziabilita' sul conto REALE va verificata quando lo sleeve entra in RICERCA, non quando entra nel book.** Qui 5 settimane di misure poggiavano su un'assunzione mai controllata, e il controllo e' costato **un ordine di prova**. E' l'analogo azionario di cio' che il progetto fa gia' rigorosamente sul crypto (min-order, haircut small-cap, eseguibilita' a $600): la stessa disciplina non era stata applicata all'equity. +- ✅ **LA VIA D'USCITA UCITS E' APERTA E COSTA ~ZERO — misurata 2026-07-26 (domanda dell'operatore: + "usiamo revolut o degiro"). E la premessa su cui era stata scartata era MIA e FALSA.** + Script `fetch_ib_ucits.py` + `r0726_gtaa_ucits.py`; modulo nuovo `src/data/eq_crosscheck.py`; + test `tests/test_eq_crosscheck.py` (14) + `tests/test_gtaa_ucits.py` (10); diario + `2026-07-26-gtaa-ucits.md`. **Book/pesi/cron/config INVARIATI.** + (0) ⚠️ **CORREZIONE:** la nota diceva "storia UCITS piu' corta → si perde la validazione a 30 + anni". FALSO per un motivo strutturale: **il PRIIPs vieta di COMPRARE, non di GUARDARE**. I + prezzi dei 6 ETF USA restano leggibili (l'operatore li aveva sul terminale *mentre* l'ordine + veniva rifiutato) → il **segnale** gira sui 30 anni per sempre, cambia solo il **veicolo** su cui + si incassa. La storia corta serve solo a misurare la deviazione del veicolo, un drift lento. + **Regola: prima di dichiarare che un vincolo esterno distrugge un risultato, chiedersi cosa + vincola esattamente** — "non posso comprare" ≠ "non posso vedere". + (1) **Il cambio di veicolo NON costa.** Tre lenti a un grado di liberta' per volta su 6.3 anni + comuni: L0 (segnale USA + rend. USA, pubblicato) **Sh 0.81 / CAGR 3.96%**; L2 (segnale UCITS + + rend. UCITS, deploy) **Sh 0.84 / CAGR 4.08%**. Drag del veicolo **+0.10%/anno** EW, coerente coi + TER (controllo piu' netto: GLD 0.40% vs IGLN 0.12% → atteso +0.28%, misurato +0.36%); e la + **ritenuta USA 15% sui dividendi (~35bps) e' A FAVORE dell'UCITS** e non e' nel drag misurato + (`ADJUSTED_LAST` USA e' al lordo). *(Ipotesi fiscale dichiarata, fonte secondaria.)* + ⚠️ **Lo stimatore ovvio da' la risposta sbagliata:** la media delle differenze giornaliere dava + **−0.47%/anno** su CSPX contro **−0.06%** vero. Le due serie seguono lo stesso indice ma sono + campionate a orari diversi (Londra chiude 4h30 prima) → la differenza giornaliera e' dominata da + uno sfasamento che si inverte il giorno dopo: **SE ~7%/anno, 15× la quantita' stimata**, piu' il + drag di varianza. **REGOLA: la deviazione fra due veicoli sullo stesso indice si misura sul + RAPPORTO CUMULATO** (e' una domanda sul drift), mai sulla media delle differenze. + (2) ✅ **IL VINCOLO NON E' IL BROKER, E' IL PREZZO DI UNA AZIONE — e quello e' una SCELTA.** + CSPX costa $802 e VUAA $144 sullo **stesso** S&P 500. Due insiemi: **STORIA** (CSPX/EQQQ/XRSU/ + IDTL/IGLN/IHYU, finestra lunga, per misurare) e **DEPLOY** (VUAA/XNAS/R2US/IDTL/IGLN/IHYU, prezzo + unitario basso, per eseguire). A **$3.000** con esecuzione a azioni INTERE: STORIA tiene **4/6 + gambe** ed e' a mercato il **33%** del tempo; DEPLOY tiene **6/6** al **65%**, ΔSharpe −0.04. + **Il frazionamento del broker NON serve.** Scartati benche' con piu' storia: RTWO (Russell 2000 + *quality*), CSUSS (*ESG*), IDP6 (S&P 600) — equivalenza dell'INDICE prima della storia. + ⚠️ Letto sulla colonna **gambe vive + vol**, non su Sharpe: il vincolo intero *alza* lo Sharpe a + capitale piccolo perche' arrotonda in giu' e paga meno commissioni = **null de-levering, 4ª + occorrenza** (VRP-DD, TP01×DVOL, MAT01). + (3) **Cosa resta da verificare: UNA cosa sola.** Lo sleeve fa **77 ordini/anno a $3k, 102 a $10k, + 149 a $50k**; soglia oltre cui non vale la pena (Sharpe<0.45) = **$0.90/ordine a $3k, $2.23 a + $10k, $7.62 a $50k**. Listino **proporzionale** ≈ indifferente al capitale (Sh 0.75-0.89 a ogni + taglia), listino **fisso** = tassa regressiva (regola 25/07 riconfermata su altro venue). + **Raccomandazione: restare su IB** — conto gia' aperto, stesso gateway dei dati del segnale, + forma di costo gia' modellata. ⚠️ **Revolut/Degiro NON sbloccano gli ETF USA**: il PRIIPs vale + per ogni intermediario UE, IB e' anzi fra i piu' permissivi; un terzo conto aggiungerebbe rischio + di venue senza guadagno. Nessuna azione oggi (decisione venue: 100% Deribit fino a $20k). +- ⚠️ **IL FEED EQUITY NON AVEVA UN CROSS-CHECK — buco trovato e chiuso (2026-07-26).** + `src/data/eq_crosscheck.py`. Nel crypto la certificazione incrocia sempre piu' venue + (`certify_feed.py` vs Coinbase USD); il feed equity aveva **solo controlli locali** (integrita', + gap, spike, split). Il primo veicolo estero ha trovato il buco alla prima estrazione: **CSPX + 2012-01-13** con `open/high 112.740` in **USD** e `low/close 88.010` in **EUR** (fattore dai close + adiacenti **1.2797** = EURUSD di quel giorno) → −21.9% e poi +27.8% mentre SPY faceva −0.39%. + **La certificazione esistente non lo vedeva:** unica guardia `maxret > 50% → SPIKE?`, e una + contaminazione EUR/USD vale ~22-28% — **stesso schema dello split 2:1 del 25/07** (che valeva + *esattamente* −50%). **2ª conferma: una soglia tarata su una classe di difetto non sorveglia le + altre.** + **Perche' il GEMELLO e non una regola locale:** il discriminante del 25/07 (range intraday) qui + non funziona — anche un crollo vero ha range enorme (SLV 33%). Cio' che separa i casi e' che **un + evento di mercato lo fa anche il gemello**: nel *rapporto* i movimenti veri si cancellano. + Controprova su dati reali: la scansione sui **prezzi** segnalava IDTL 2020-03 (liquidazione + treasury) e IGLN 2013-04 (crollo oro); sul **rapporto** spariscono. + ⚠️ **La soglia non era tarabile sulla deviazione** — rumore legittimo fino al **9.90%** + (2025-04-09: Londra chiude alle 11:30 di New York) contro un difetto del 21.4% → margine 2.2×, + sotto il 3× richiesto. **La risposta non e' accettare il margine ma CAMBIARE STATISTICA:** + `|dev| / movimento del gemello` (un disallineamento d'orario **non puo' superare il movimento del + mercato**) → rumore **8.1**, difetto **42.7**, **margine 5.3×**. Soglia 18.0, equidistante in + scala log. Tre condizioni congiunte: deviazione + non-spiegato + **rientro** entro 5 barre. + **Riparazione = SCARTARE la barra, non ricostruirla** (del prezzo vero non si sa nulla). + ⚠️ **Limite dichiarato e chiuso sul DANNO, non sulla rilevabilita':** GBPUSD 1.27 → 21% sempre + rilevato; EURUSD 1.09 → 8.3% **dentro il rumore** e non tappabile senza falsi positivi. Misurato + invece il danno di una contaminazione non vista: **dSharpe mediano −0.003, peggiore −0.056, + |Δ|>0.05 nel 2%**. **REGOLA: quando un buco non si puo' chiudere senza generare falsi positivi, + si misura il DANNO del caso non rilevato** — un buco quantificato e innocuo e' un risultato, uno + taciuto e' un debito. Il limite e' congelato in un test (`test_limite_dichiarato_*`): se qualcuno + abbassa la soglia, il test dice cosa e' cambiato. - 💰 **I VERSAMENTI — le 4 ipotesi che il piano non aveva mai fatto (2026-07-26, ultimo filone).** Tutte le traiettorie del 25-26/07 assumevano versamento **piatto, ininterrotto, per sempre** = l'ipotesi meno realistica del piano. Script `r0726_deposits.py`, test `tests/test_deposits.py` diff --git a/docs/diary/2026-07-26-gtaa-ucits.md b/docs/diary/2026-07-26-gtaa-ucits.md new file mode 100644 index 0000000..a7f3d0c --- /dev/null +++ b/docs/diary/2026-07-26-gtaa-ucits.md @@ -0,0 +1,278 @@ +# 2026-07-26 — GTAA01 sugli UCITS: la via d'uscita dal blocco PRIIPs è aperta, e il broker non è la variabile + +**Domanda dell'operatore:** *"usiamo revolut o degiro"* — dopo che il conto reale ha rifiutato +l'ordine sui 6 ETF USA di GTAA01 con `Trading limitato — Questo prodotto non dispone di un KID`. + +**Risposta breve:** cambiare broker non sblocca niente (il PRIIPs è una norma, non una politica di +IB). Ciò che sblocca è cambiare **veicolo**. Misurato: il cambio di veicolo costa **~0**, e con la +scelta giusta delle linee lo sleeve è eseguibile **senza frazionamento** già a $3.000. + +**Book, pesi, cron, config: INVARIATI.** GTAA01 non entra nel book live (resta la decisione venue +del 26/07: 100% Deribit fino a $20k). + +Script: `scripts/research/fetch_ib_ucits.py`, `scripts/research/r0726_gtaa_ucits.py`. +Modulo nuovo: `src/data/eq_crosscheck.py`. Test: `tests/test_eq_crosscheck.py` (14), +`tests/test_gtaa_ucits.py` (10). + +--- + +## 0. La premessa che era falsa, ed è la ragione per cui il problema è piccolo + +La nota che avevo scritto in `src/portfolio/gtaa.py` diceva: + +> storia più CORTA (diversi nati dopo il 2010) → si perde la validazione a 30 anni che è ciò che +> rendeva credibile questo sleeve + +**È falsa, per un motivo strutturale: il PRIIPs vieta di COMPRARE, non di GUARDARE.** I prezzi di +SPY/QQQ/IWM/TLT/GLD/HYG restano leggibili — l'operatore li aveva sotto gli occhi sul terminale +*mentre* l'ordine veniva rifiutato. Quindi il **segnale** di GTAA01 può continuare a girare sui 30 +anni di serie USA per sempre; cambia solo il **veicolo** su cui si incassa il rendimento. + +La storia corta degli UCITS non serve a validare la strategia. Serve a misurare una cosa sola: la +**deviazione del veicolo**, che è un drift lento e si stima bene anche su pochi anni. + +> **Regola:** prima di dichiarare che un vincolo esterno distrugge un risultato, chiedersi +> *esattamente cosa* vincola. Qui la differenza fra "non posso comprare" e "non posso vedere" +> separa uno sleeve morto da uno sleeve con un problema di esecuzione. + +--- + +## 1. Il dato, prima della strategia — e il feed equity non aveva un cross-check + +Estratti 12 veicoli UCITS da IB (`LSEETF`, **tutti in USD**: quotare in USD elimina un livello di +costo, la conversione per ordine). Alla prima certificazione, **7 su 10 con gap lunghi** e CSPX con +un **+27.8% giornaliero** su un tracker S&P 500. + +I gap sono tutti **al lancio** della linea USD (ultimo gap lungo: CSPX 2014-04, XRSU 2016-10, +IGLN/IHYU 2011); negli ultimi 3 anni tutti i veicoli fanno 253 barre/anno. Non è un problema +corrente. + +Il +27.8% invece è un difetto vero. La barra CSPX 2012-01-13: + +``` +open 112.740 high 112.740 low 88.010 close 88.010 +``` + +**`open`/`high` in USD, `low`/`close` in EUR** — fattore dai close adiacenti **1.2797**, cioè +l'EURUSD di quel giorno (reale 1.266-1.282). Andata e ritorno: 112.63 → 88.01 → 112.47 mentre SPY +faceva −0.39% e +0.23%. + +**La certificazione esistente non lo vedeva.** L'unica guardia sui movimenti era +`maxret > 50% → SPIKE?`, e una contaminazione EUR/USD vale ~22-28%. È lo **stesso schema** dello +split 2:1 non aggiustato del 25/07 (che valeva *esattamente* −50%): una soglia tarata su una classe +di difetto non sorveglia le altre. + +### Perché serviva il gemello e non una regola locale + +Il discriminante del 25/07 (**range intraday**) qui non funziona: una barra contaminata ha un range +enorme, ma anche un crollo vero (SLV 2026-01-30 aveva 33% di range). Ciò che separa i due casi è che +**un evento di mercato lo fa anche il gemello**: nel *rapporto* veicolo/gemello i movimenti veri si +cancellano. + +Controprova su dati reali: la stessa scansione fatta sui **prezzi** segnalava IDTL 2020-03 +(liquidazione dei treasury) e IGLN 2013-04 (crollo dell'oro) — andate-e-ritorni identiche nella +forma. Sul **rapporto** spariscono, e resta la sola CSPX 2012-01-13. + +Questo è il buco che il feed equity aveva: nel crypto la certificazione incrocia sempre più venue +(`certify_feed.py` vs Coinbase USD); il feed equity aveva **solo controlli locali**. + +### La soglia non è tarabile sulla deviazione, ed è il punto interessante + +| statistica | rumore massimo misurato | difetto CSPX | margine | +|---|---|---|---| +| deviazione dal gemello | **9.90%** (2025-04-09) | 21.4% | 2.2× ❌ | +| **|dev| / movimento del gemello** | **8.1** | **42.7** | **5.3×** ✅ | + +Il 9.90% è **legittimo**: Londra chiude alle 11:30 di New York, quindi in un giorno violento le due +chiusure sono davvero lontane. Ma **un disallineamento d'orario non può superare il movimento del +mercato**: se il gemello si è mosso dello 0.4% e il veicolo se ne va del 21%, non c'è orario che lo +spieghi. La statistica adimensionale porta il margine da 2.2× a 5.3× — sopra il 3× che questo +progetto richiede a un rilevatore. + +Tre condizioni congiunte (come il rilevatore di split, e per la stessa ragione): deviazione oltre +`MIN_DEV`, non-spiegato oltre `MIN_UNEXPLAINED=18.0`, e **rientro** entro 5 barre (una divergenza di +NAV vera persiste, una stampa sbagliata no). + +**Riparazione = scartare la barra, non ricostruirla.** Del prezzo vero non sappiamo niente; +sappiamo che quello scritto è sbagliato. Scartarla rende corretto il rendimento 12/01→17/01 +(−0.14%); "ripararla" con un fattore stimato inventerebbe un dato. + +### ⚠️ Limite dichiarato, e come è stato chiuso + +GBPUSD ~1.27 → 21% di deviazione = **sempre rilevata**. EURUSD ~1.09 → 8.3% = **dentro il rumore** +dei giorni violenti. Non è tappabile abbassando la soglia (sotto 12 si segnalano i giorni veri). + +Invece di inseguire la rilevabilità ho misurato il **danno** di una contaminazione non vista: +iniettando 60 contaminazioni casuali, **dSharpe mediano −0.003, peggiore −0.056, |Δ|>0.05 nel 2% +dei casi**. Il limite resta, ma è quantificato e irrilevante. + +> **Regola:** quando un rilevatore ha un buco che non si può chiudere senza generare falsi positivi, +> si misura il **danno** del caso non rilevato, non la sua probabilità. Un buco quantificato e +> innocuo è un risultato; un buco taciuto è un debito. + +--- + +## 2. Quanto costa il veicolo: ~zero — ma lo stimatore ovvio dà la risposta sbagliata + +Prima stesura: media delle differenze giornaliere → **CSPX −0.47%/anno**. Sbagliato. + +Le due serie seguono lo **stesso indice** ma sono campionate a orari diversi. La differenza +giornaliera è dominata da uno sfasamento che si **inverte il giorno dopo**: volatilità 17-22% +annualizzata, **errore standard ~7%/anno** sulla stima del drag — cioè **15 volte più grande del +drag stesso**. In più la media delle differenze *semplici* è contaminata dal drag di varianza. + +Il rapporto veicolo/gemello invece è **stazionario**: il suo drift *è* il drag. + +| gamba | UCITS | drag misurato | atteso da TER | + ritenuta USA | banda annua | +|---|---|---|---|---|---| +| SPY | CSPX | **−0.06%** | +0.02% | +0.20% | [−0.52,+0.63] | +| QQQ | EQQQ | **−0.00%** | −0.10% | −0.03% | [−0.13,+1.29] | +| IWM | XRSU | **−0.15%** | −0.11% | +0.07% | [−0.91,+0.37] | +| TLT | IDTL | **+0.18%** | +0.08% | +0.68% | [−0.45,+1.60] | +| GLD | IGLN | **+0.36%** | +0.28% | +0.28% | [−0.56,+0.99] | +| HYG | IHYU | **+0.28%** | −0.01% | +0.89% | [−0.83,+1.88] | +| **media EW** | | **+0.10%/anno** | +0.03% | **+0.35%** | | + +Controllo di plausibilità: **segno concorde in 4/6, scarto < 30bps in 6/6**. Il caso più netto è +l'oro (GLD 0.40% vs IGLN 0.12% → atteso +0.28%, misurato +0.36%): è un controllo indipendente che la +misura sta cogliendo il TER e non rumore. Le due gambe discordi restano entro 30bps, e su una banda +annua larga [−0.9%,+1.9%] il segno di uno scarto così piccolo non è informativo. **Ciò che il +controllo stabilisce è la scala: decine di bps, non punti.** + +⚠️ La colonna "ritenuta" è **a favore dell'UCITS e non è nel drag misurato**: `ADJUSTED_LAST` sulle +serie USA è al **lordo** della ritenuta USA del 15% che un residente italiano paga davvero sui +dividendi. Comprando SPY quella ritenuta la paghi e nella serie non c'è; un UCITS irlandese la +subisce una volta a livello di fondo e non una seconda all'incasso. *(Ipotesi fiscale dichiarata, +fonte secondaria, da confermare col commercialista — non è un parere fiscale.)* + +### Le tre lenti + +| lente | Sharpe | CAGR | maxDD | vol | +|---|---|---|---|---| +| L0 segnale USA + rend. USA (pubblicato) | 0.81 | 3.96% | −7.9% | 5.0% | +| L1 segnale USA + rend. UCITS (veicolo) | 0.89 | 4.22% | −7.0% | 4.8% | +| L2 segnale UCITS + rend. UCITS (+orologio) | **0.84** | **4.08%** | −7.5% | 4.9% | + +**Totale L2−L0: dSharpe +0.03, dCAGR +0.12%/anno.** Il cambio di veicolo *non costa*. + +⚠️ `corr(L0,L2) = 0.65`, bassa per due serie sullo stesso indice — ed è l'**orologio**, non un +difetto: le due chiusure condividono la notte e la mattina americana ma non il pomeriggio. +Verificato che non sia uno sfasamento di date: la correlazione veicolo/gemello è **massima a lag 0 +su 6/6 coppie** (0.46-0.78). Conseguenza: le statistiche *giornaliere* delle due lenti non sono +confrontabili una a una, mentre il **drift** sì — che è esattamente perché la sezione 2 usa il +rapporto cumulato. + +--- + +## 3. Il vincolo vero non è il broker: è il prezzo di UNA azione + +Senza frazionamento un ordine è almeno una azione. **Il prezzo unitario è una scelta +dell'emittente, non una proprietà dell'indice**: CSPX costa $802 e VUAA $144 sullo *stesso* S&P 500. + +| gamba | STORIA | prezzo | DEPLOY | prezzo | a $3.000 | a $10.000 | +|---|---|---|---|---|---|---| +| SPY | CSPX | $801.86 | **VUAA** | $143.80 | 29% | 9% | +| QQQ | EQQQ | $692.67 | **XNAS** | $65.74 | 13% | 4% | +| IWM | XRSU | $437.80 | **R2US** | $86.35 | 17% | 5% | +| TLT | IDTL | $3.09 | IDTL | $3.09 | 1% | 0% | +| GLD | IGLN | $79.06 | IGLN | $79.06 | 16% | 5% | +| HYG | IHYU | $94.12 | IHYU | $94.12 | 19% | 6% | + +*(% = quanto pesa una azione sull'allocazione di quella gamba)* + +Danno misurato dell'esecuzione a numero **intero** di azioni, stessa finestra per entrambi: + +| insieme | capitale | Sh fraz. | Sh intero | Δ | vol fraz. | vol intero | **gambe vive** | esposta | +|---|---|---|---|---|---|---|---|---| +| STORIA | $3.000 | 0.84 | 0.80 | −0.04 | 5.1% | **2.2%** | **4/6** | **33%** | +| STORIA | $10.000 | 1.08 | 1.02 | −0.06 | 5.1% | 4.2% | 6/6 | 63% | +| **DEPLOY** | **$3.000** | 0.88 | 0.84 | −0.04 | 5.1% | **4.5%** | **6/6** | **65%** | +| DEPLOY | $10.000 | 1.10 | 1.09 | −0.02 | 5.1% | 4.9% | 6/6 | 66% | + +**Con i veicoli giusti il frazionamento non serve.** Con quelli sbagliati, a $3.000 lo sleeve perde +due gambe su sei ed è a mercato un terzo del tempo: non è GTAA01 con un vincolo, è un'altra cosa. + +⚠️ **Si legge `gambe vive` e `vol`, non `Sharpe`.** Un vincolo che alza lo Sharpe non è un +miglioramento: arrotondando in giù riduce l'esposizione e paga meno commissioni. È il **null +de-levering** (4ª occorrenza dopo VRP-DD, TP01×DVOL, MAT01). Qui il controllo è stato eseguito e +il verdetto è che con l'insieme DEPLOY il de-levering **non c'è**. + +⚠️ Scartati di proposito, benché con più storia: **RTWO** (Russell 2000 *quality* = fattore), +**CSUSS** (MSCI USA Small Cap *ESG*), **IDP6** (S&P 600, indice diverso). Equivalenza dell'indice +prima della storia. + +--- + +## 4. Il costo del venue, per FORMA — la sola cosa su cui i broker differiscono davvero + +**Ordini/anno** (dipendono da banda e cadenza, non dal listino): **77 a $3.000, 102 a $10.000, 149 a +$50.000**. Con questo numero ogni listino è calcolabile a mano. + +| costo fisso/ordine | $3.000 Sh | CAGR | $10.000 Sh | CAGR | $50.000 Sh | CAGR | +|---|---|---|---|---|---|---| +| $0.00 | 0.92 | 4.45% | 0.92 | 4.48% | 0.92 | 4.48% | +| $0.35 (IB US) | 0.73 | 3.52% | 0.84 | 4.11% | 0.90 | 4.37% | +| $1.00 | 0.39 | 1.80% | 0.71 | 3.42% | 0.86 | 4.17% | +| $2.00 | −0.13 | −0.79% | 0.50 | 2.37% | 0.79 | 3.86% | +| $5.00 | −1.51 | −8.17% | −0.12 | −0.71% | 0.61 | 2.93% | + +| costo proporzionale | $3.000 | $10.000 | $50.000 | +|---|---|---|---| +| 5 bps/ordine | 0.89 | 0.89 | 0.88 | +| 25 bps/ordine | 0.77 | 0.76 | 0.75 | +| 50 bps/ordine | 0.62 | 0.60 | 0.59 | + +**Le soglie da verificare presso il broker:** + +| capitale allocato | CAGR = 0 | Sharpe = 0.45 | +|---|---|---| +| $3.000 | $1.68/ordine | **$0.90/ordine** | +| $10.000 | $4.34/ordine | **$2.23/ordine** | +| $50.000 | $14.65/ordine | **$7.62/ordine** | + +Un listino **proporzionale** è quasi indifferente al capitale (0.75-0.89 di Sharpe a ogni taglia); +uno **fisso** è una tassa regressiva. È la regola del 25/07 riconfermata su un venue diverso. + +--- + +## 5. Risposta operativa alla domanda + +**Revolut o Degiro non sbloccano gli ETF USA**: il PRIIPs vale per ogni intermediario che serve +retail UE. IB è anzi fra i più permissivi. Aprire un terzo conto per lo stesso rifiuto aggiunge +rischio di venue senza guadagno. + +Ciò che si compra su *qualunque* dei tre sono gli **UCITS**, e a quel punto la scelta si gioca su +**una sola dimensione misurabile**: il costo per ordine, contro le soglie della tabella sopra, con +77-149 ordini l'anno. + +**Raccomandazione: restare su IB**, perché (a) il conto esiste già, (b) i dati per il segnale +arrivano dallo stesso gateway, (c) è l'unico dei tre di cui la forma di costo è già modellata in +`gtaa.py`. La verifica che resta da fare è **una sola**: la tariffa IB per ordine su LSEETF in USD, +contro $2.23 (a $10.000) o $0.90 (a $3.000). + +**Nessuna azione oggi**: la decisione venue del 26/07 tiene tutto su Deribit fino a $20k, cioè +~2028 col piano di versamenti corrente. + +--- + +## Lezioni + +1. **Prima di dichiarare che un vincolo esterno distrugge un risultato, chiedersi cosa vincola + esattamente.** "Non posso comprare" ≠ "non posso vedere": tutta la validazione a 30 anni era + salva, e la nota che diceva il contrario era mia. +2. **Un feed senza secondo parere non è certificato**, e la regola valeva già nel crypto. Il feed + equity ha vissuto 5 settimane con soli controlli locali, e il primo veicolo estero ha trovato + il buco alla prima estrazione. +3. **Una soglia tarata su una classe di difetto non sorveglia le altre** — 2ª conferma dopo lo + split 2:1 del 25/07. E quando la taratura non regge il margine richiesto, la risposta non è + accettarla ma **cambiare statistica**: qui il passaggio da assoluta ad adimensionale + (deviazione / movimento del gemello) porta il margine da 2.2× a 5.3×. +4. **La media delle differenze giornaliere non è la deviazione fra due veicoli sullo stesso + indice.** Errore standard 15× più grande della quantità stimata, più il drag di varianza. La + domanda "quanto mi costa il veicolo" è una domanda sul **drift**, quindi si misura sul rapporto + cumulato. +5. **Il prezzo unitario di un ETF è un parametro di progetto, non un dato.** Sceglierlo cambia la + soglia di capitale di uno sleeve più di qualunque negoziazione con un broker. +6. **Il grado d'accordo si conta, non si dichiara.** La prima stesura stampava "stesso segno su 6/6 + gambe"; i dati dicevano 4/6. Scriverlo a mano nel `print` è il modo più facile di pubblicare una + conclusione che i propri dati smentiscono — 3ª occorrenza in questa sessione. diff --git a/scripts/research/fetch_ib_ucits.py b/scripts/research/fetch_ib_ucits.py new file mode 100644 index 0000000..dfb078b --- /dev/null +++ b/scripts/research/fetch_ib_ucits.py @@ -0,0 +1,143 @@ +"""FETCH + CERTIFY equivalenti UCITS dei 6 ETF di GTAA01 -> data/raw/eqx__1d.parquet. + +PERCHE' ESISTE. Il 2026-07-26 il conto reale ha RIFIUTATO l'ordine sui 6 ETF USA di GTAA01 +(blocco PRIIPs: ETF domiciliati USA, nessun KID, vietati al retail UE). Lo sleeve, cosi' com'e', +non e' deployabile. L'unica via d'uscita e' negoziare gli equivalenti UCITS — e per sapere se +quella via regge servono i DATI dei veicoli, non le loro schede prodotto. + +SCELTE DICHIARATE + * Tutti i candidati sono presi su **LSEETF in USD**. Non e' una comodita': e' cio' che elimina + un livello di costo (conversione valutaria per ordine) che su un CAGR del 3.65% non sarebbe + trascurabile. Le linee Xetra in EUR (SXR8/SXRV/ZPRR) NON risolvono su questo gateway e + comunque introdurrebbero l'FX. + * Namespace **eqx_** separato da eq_: i veicoli USA restano su disco perche' sono la base della + VALIDAZIONE (30 anni), che nessun UCITS puo' fornire. Il progetto valida sull'indice/veicolo + lungo e negozia il veicolo corto; sovrascrivere eq_ cancellerebbe la meta' lunga. + * Piu' candidati per gamba dove l'equivalenza NON e' ovvia (small cap: RTWO e' un fattore + *quality*, CSUSS e' *ESG* -> non sono IWM). La scelta si fa dopo, sui dati. + +CERTIFICAZIONE: riusa `certify()` di fetch_ib_equities.py — stesse regole, split non aggiustati +inclusi (la lezione IWM/EFA del 25/07 vale identica su questi veicoli). + + uv run --with ib_async python scripts/research/fetch_ib_ucits.py + uv run --with ib_async python scripts/research/fetch_ib_ucits.py --force +""" +from __future__ import annotations + +import sys +import time +from pathlib import Path + +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 fetch_ib_equities import certify # noqa: E402 + +RAW = ROOT / "data" / "raw" +RAW.mkdir(parents=True, exist_ok=True) + +EXCH, CUR = "LSEETF", "USD" + +# gamba USA -> candidati UCITS, in ordine di preferenza a priori (equivalenza dell'INDICE prima +# della storia: un fattore 'quality'/'ESG' non e' la stessa esposizione anche se ha piu' barre). +# +# ⚠️ SI SCARICANO DUE INSIEMI, e la ragione e' il BIGLIETTO MINIMO. Il prezzo di UNA azione e' una +# scelta dell'emittente, non una proprieta' dell'indice: CSPX costa $802 e VUAA $144 sullo STESSO +# S&P 500. Senza frazionamento un ordine e' almeno una azione, quindi a capitale piccolo il prezzo +# unitario decide se la gamba e' rappresentabile. Ma i veicoli economici sono anche i piu' RECENTI +# (XNAS: 4.3 anni contro i 7.5 di EQQQ) -> non si puo' scegliere una cosa sola: +# * insieme STORIA -> finestra comune piu' lunga, serve a MISURARE la deviazione del veicolo; +# * insieme DEPLOY -> prezzo unitario basso, serve a sapere se si puo' ESEGUIRE. +CANDIDATI: dict[str, tuple[str, ...]] = { + "SPY": ("CSPX", "VUAA"), # S&P 500: CSPX $802 (storia) · VUAA $144 (deploy) + "QQQ": ("CNDX", "EQQQ", "XNAS"), # Nasdaq-100: EQQQ $693 (storia) · XNAS $66 (deploy) + "IWM": ("XRSU", "R2US", "IDP6"), # Russell 2000: XRSU $438 · R2US $86 (piu' economico E piu' lungo) + "TLT": ("IDTL", "DTLA"), # US Treasury 20+ — indice identico, IDTL $3.09 + "GLD": ("IGLN",), # oro fisico (ETC, non UCITS: ha comunque il KID) + "HYG": ("IHYU",), # USD High Yield corp — indice equivalente +} +UNIVERSE = [s for v in CANDIDATI.values() for s in v] + + +def main() -> int: + try: + from ib_async import IB, Stock + except Exception: + print("ib_async assente. Esegui con: uv run --with ib_async python scripts/research/fetch_ib_ucits.py") + return 2 + + ib = IB() + try: + ib.connect("127.0.0.1", 4002, clientId=91, timeout=20) + except Exception as e: + print(f"[CONNESSIONE FALLITA] 127.0.0.1:4002 -> {repr(e)[:120]}\n Avvia: docker compose up -d ib-gateway") + return 1 + + print("=" * 108) + print(f" FETCH + CERTIFY UCITS ({EXCH}/{CUR}, ADJUSTED_LAST) -> data/raw/eqx_* | acct {ib.managedAccounts()}") + print("=" * 108) + force = "--force" in sys.argv[1:] + ok, rep = [], [] + for sym in UNIVERSE: + out_path = RAW / f"eqx_{sym.lower()}_1d.parquet" + if out_path.exists() and not force: + print(f" {sym:6} GIA' SU DISCO -> skip (--force per riscaricare)") + ok.append(sym) + continue + con = Stock(sym, EXCH, CUR) + try: + det = ib.reqContractDetails(con) + except Exception as e: + det = None + print(f" {sym:6} ERR contratto {type(e).__name__} {str(e)[:50]}") + if not det: + print(f" {sym:6} CONTRATTO ASSENTE su {EXCH}/{CUR}") + rep.append({"sym": sym, "status": "ASSENTE"}) + time.sleep(1.2) + continue + try: + bars = ib.reqHistoricalData(det[0].contract, endDateTime="", durationStr="30 Y", + barSizeSetting="1 day", whatToShow="ADJUSTED_LAST", + useRTH=True, formatDate=1, timeout=120) + except Exception as e: + print(f" {sym:6} ERR storico {repr(e)[:70]}") + rep.append({"sym": sym, "status": "ERR"}) + time.sleep(1.2) + continue + if not bars: + print(f" {sym:6} 0 barre (market data non sottoscritto?)") + rep.append({"sym": sym, "n": 0, "status": "VUOTO"}) + time.sleep(1.2) + continue + + df = pd.DataFrame([(pd.Timestamp(str(b.date)), b.open, b.high, b.low, b.close, b.volume) + for b in bars], + columns=["ts", "open", "high", "low", "close", "volume"]).set_index("ts").sort_index() + c = certify(sym, df) + rep.append(c) + out = df.copy() + out["timestamp"] = out.index.astype("datetime64[ms]").astype("int64") + out.reset_index(drop=True).to_parquet(out_path) + if c["status"] == "OK": + ok.append(sym) + print(f" {sym:6} n={c.get('n',0):>5} {str(c.get('primo','')):>10}->{str(c.get('ultimo',''))} " + f"{c.get('anni','?')}y flat={c.get('flat','?')} maxret={c.get('maxret%','?')}% " + f"miss_bd={c.get('miss_bd','?')} gapL={c.get('gap_lunghi','?')} " + f"adj={c.get('adj_first/last','?')} [{c['status']}]" + + (f" split={c['splits']}" if c.get("splits") else "")) + time.sleep(1.2) + + print("-" * 108) + print(f" CERTIFICATI OK ({len(ok)}/{len(UNIVERSE)}): {ok}") + # Il vincolo che decide tutto: la finestra COMUNE alle 6 gambe scelte. + print("\n ⚠️ La profondita' per gamba conta meno della FINESTRA COMUNE: il candidato piu' corto") + print(" determina il campione su cui si potra' misurare qualunque cosa.") + ib.disconnect() + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/scripts/research/r0726_gtaa_ucits.py b/scripts/research/r0726_gtaa_ucits.py new file mode 100644 index 0000000..762fcaf --- /dev/null +++ b/scripts/research/r0726_gtaa_ucits.py @@ -0,0 +1,449 @@ +"""GTAA01 sulla versione REALMENTE ACQUISTABILE (UCITS) — quanto dello sleeve resta. + +CONTESTO. Il 2026-07-26 il conto reale ha rifiutato l'ordine sui 6 ETF USA di GTAA01 (PRIIPs: +domiciliati USA, nessun KID, vietati al retail UE). Lo sleeve non e' deployabile cosi' com'e'. +Questo script misura la sola via d'uscita: negoziare gli equivalenti UCITS. + +⚠️ IL FATTO CHE RIDIMENSIONA IL PROBLEMA, E CHE LA NOTA IN gtaa.py NON AVEVA VISTO. +La nota diceva: "storia piu' CORTA -> si perde la validazione a 30 anni che e' cio' che rendeva +credibile questo sleeve". E' FALSO, per un motivo strutturale: **il PRIIPs vieta di COMPRARE, non +di GUARDARE**. I prezzi di SPY/QQQ/IWM/TLT/GLD/HYG restano leggibili (l'operatore li ha visti sul +terminale mentre l'ordine veniva rifiutato). Il SEGNALE di GTAA01 puo' continuare a girare sui 30 +anni di serie USA per sempre; cambia solo il VEICOLO su cui si incassa il rendimento. La storia +corta degli UCITS non serve a validare la strategia — serve a misurare la DEVIAZIONE DEL VEICOLO, +che e' un drift lento e si stima bene anche su pochi anni. + +METODO — tre lenti, UN GRADO DI LIBERTA' PER VOLTA (come per il path live di SKH01, 26/07): + L0 segnale USA + rendimento USA = GTAA01 come pubblicato (non deployabile) + L1 segnale USA + rendimento UCITS = isola il VEICOLO + L2 segnale UCITS + rendimento UCITS = aggiunge l'OROLOGIO (LSE chiude alle 11:30 di New York) +Le tre lenti girano sullo STESSO calendario e sulla STESSA finestra: le differenze sono appaiate. + +⚠️ DUE INSIEMI DI VEICOLI, e la ragione e' il BIGLIETTO MINIMO. Il prezzo di UNA azione e' una +scelta dell'emittente, non una proprieta' dell'indice: CSPX costa $802 e VUAA $144 sullo STESSO +S&P 500. Ma i veicoli economici sono i piu' recenti. Quindi: + STORIA (CSPX/EQQQ/XRSU/IDTL/IGLN/IHYU) -> finestra lunga, serve a MISURARE; + DEPLOY (VUAA/XNAS/R2US/IDTL/IGLN/IHYU) -> prezzo unitario basso, serve a ESEGUIRE. + + uv run python scripts/research/r0726_gtaa_ucits.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)) + +from src.data.eq_crosscheck import repair_twin_outliers # noqa: E402 +from src.data.eq_splits import repair_splits # noqa: E402 +from src.portfolio import gtaa as G # noqa: E402 + +RAW = ROOT / "data" / "raw" +ANN = np.sqrt(252.0) +US = ("SPY", "QQQ", "IWM", "TLT", "GLD", "HYG") + +# Scartati di proposito: RTWO (Russell 2000 *quality* = fattore), CSUSS (MSCI USA Small Cap *ESG*), +# IDP6 (S&P 600: small cap ma indice diverso da Russell 2000), CNDX (market data non sottoscritto). +SET_STORIA = {"SPY": "CSPX", "QQQ": "EQQQ", "IWM": "XRSU", "TLT": "IDTL", "GLD": "IGLN", "HYG": "IHYU"} +SET_DEPLOY = {"SPY": "VUAA", "QQQ": "XNAS", "IWM": "R2US", "TLT": "IDTL", "GLD": "IGLN", "HYG": "IHYU"} + +# TER indicativi (%/anno). ⚠️ FONTE SECONDARIA, non letta dai KID: servono solo come CONTROLLO di +# plausibilita' del drag misurato, non entrano in nessun calcolo. Regola 26/07: dichiarare la +# qualita' della fonte. +TER = {"SPY": 0.09, "QQQ": 0.20, "IWM": 0.19, "TLT": 0.15, "GLD": 0.40, "HYG": 0.49, + "CSPX": 0.07, "EQQQ": 0.30, "XRSU": 0.30, "IDTL": 0.07, "IGLN": 0.12, "IHYU": 0.50, + "VUAA": 0.07, "XNAS": 0.20, "R2US": 0.30} +# Ritenuta USA sui dividendi (15% da trattato) x rendimento da dividendo indicativo -> punti base +# che un residente italiano paga comprando il veicolo USA e NON paga comprando l'UCITS irlandese. +RITENUTA_BPS = {"SPY": 18, "QQQ": 7, "IWM": 18, "TLT": 60, "GLD": 0, "HYG": 90} + + +# =========================================================================== +# dato +# =========================================================================== +def _norm(s: pd.Series) -> pd.Series: + s = s[~s.index.duplicated()].copy() + idx = pd.to_datetime(s.index) + if getattr(idx, "tz", None) is not None: + idx = idx.tz_localize(None) + s.index = idx.normalize() + return s.sort_index() + + +def _load(prefix: str, sym: str) -> pd.DataFrame: + d = pd.read_parquet(RAW / f"{prefix}_{sym.lower()}_1d.parquet") + d.index = pd.to_datetime(d["timestamp"], unit="ms", utc=True) + d, _ = repair_splits(d.sort_index()) + return d + + +def close_us(sym: str) -> pd.Series: + d = _load("eq", sym) + return pd.Series(d["close"].astype(float).values, index=d.index) + + +def close_uc(sym: str, twin: pd.Series) -> tuple[pd.Series, list[dict]]: + """Chiusure UCITS RIPARATE col cross-check sul gemello USA (src/data/eq_crosscheck.py).""" + d, bad = repair_twin_outliers(_load("eqx", sym), twin) + return pd.Series(d["close"].astype(float).values, index=d.index), bad + + +# =========================================================================== +# motore — generalizzazione di gtaa._gated_returns con segnale e veicolo SEPARATI +# =========================================================================== +def leg_returns(sig_close: pd.Series, exe_close: pd.Series, cal, cap_leg: float, + band_usd: float, every: int, cost, *, integer_shares: bool = False): + """Rendimenti netti di UNA gamba sul calendario `cal`, piu' la posizione tenuta. + + `sig_close` decide l'esposizione, `exe_close` la incassa: separarli isola il veicolo + dall'orologio. Identico a gtaa._gated_returns quando le due coincidono e cal e' l'indice + nativo. `integer_shares`: l'ordine si arrotonda a un numero INTERO di azioni. + """ + sig = _norm(sig_close).reindex(cal).ffill() + exe = _norm(exe_close).reindex(cal).ffill() + ex = np.nan_to_num(np.asarray(G._exposure(sig).values, float)) + px = exe.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 == 0: + tgt = ex[i] + if integer_shares: + tgt = np.floor(tgt * cap_leg / px[i]) * px[i] / cap_leg + notional = abs(tgt - cur) * cap_leg + if notional >= max(band_usd, G.IB_MIN_TRADE_USD): + comm[i] = cost(notional, px[i]) / cap_leg + cur = tgt + 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=cal), pd.Series(pos, index=cal) + + +def sleeve(sig: dict, exe: dict, cal, capital: float, band: float, every: int, cost, + integer_shares: bool = False, want_pos: bool = False): + cap_leg = float(capital) / len(sig) + out = {k: leg_returns(sig[k], exe[k], cal, cap_leg, band, every, cost, + integer_shares=integer_shares) for k in sig} + r = pd.concat({k: v[0] for k, v in out.items()}, axis=1).mean(axis=1, skipna=True) + if want_pos: + return r, pd.concat({k: v[1] for k, v in out.items()}, axis=1) + return r + + +def sleeve_native(sig: dict, exe: dict, capital: float, band: float, every: int, cost) -> pd.Series: + """Replica ESATTA di gtaa.gtaa_returns: ogni gamba sul PROPRIO indice, outer-join, media. + Serve solo al sanity check — le lenti girano su calendario comune, che e' cio' che rende + appaiate le differenze.""" + cap_leg = float(capital) / len(sig) + cols = {} + for k in sig: + cal = _norm(exe[k]).index + cols[k] = leg_returns(sig[k], exe[k], cal, cap_leg, band, every, cost)[0] + return pd.concat(cols, axis=1, sort=True).sort_index().mean(axis=1, skipna=True).dropna() + + +def met(r: pd.Series) -> dict: + r = r.dropna() + if len(r) < 50 or r.std() == 0: + return dict(sharpe=np.nan, cagr=np.nan, dd=np.nan, vol=np.nan) + eq = (1 + r).cumprod() + return dict(sharpe=float(r.mean() / r.std() * ANN), + cagr=float(eq.iloc[-1] ** (252.0 / len(r)) - 1), + dd=float((eq / eq.cummax() - 1).min()), vol=float(r.std() * ANN)) + + +def cost_ib_us(notional, price): + return G.ib_commission(notional, price) + + +def cost_form(fixed=0.0, prop_bps=0.0, min_fee=0.0, max_frac=1.0): + def f(notional, price): + return min(max(fixed + notional * prop_bps / 1e4, min_fee), max_frac * notional) + return f + + +def common_cal(series_list) -> pd.DatetimeIndex: + cal = None + for s in series_list: + i = _norm(s).index + cal = i if cal is None else cal.intersection(i) + return cal + + +def drag_cumulato(a: pd.Series, b: pd.Series, cal) -> float: + """Deviazione annualizzata del veicolo b rispetto al gemello a, misurata sul RAPPORTO. + + ⚠️ NON si usa la media delle differenze giornaliere. Le due serie seguono lo STESSO indice ma + sono campionate a orari diversi (LSE chiude 4h30 prima di New York): la differenza giornaliera + e' dominata da uno sfasamento che si INVERTE il giorno dopo, quindi ha una volatilita' enorme + (17-22% annualizzata) e un errore standard di ~7%/anno sulla stima del drag — cioe' 15 volte + piu' grande del drag stesso. Il rapporto b/a invece e' stazionario: il suo drift E' il drag. + """ + x, y = _norm(a).reindex(cal).ffill(), _norm(b).reindex(cal).ffill() + r = (y / x).dropna() + return float((r.iloc[-1] / r.iloc[0]) ** (252.0 / len(r)) - 1) + + +# =========================================================================== +def main() -> None: + print("=" * 108) + print(" GTAA01 SUGLI EQUIVALENTI UCITS — quanto dello sleeve resta quando lo si puo' comprare") + print("=" * 108) + + us = {s: close_us(s) for s in US} + uc, difetti = {}, {} + for s, u in SET_STORIA.items(): + uc[s], bad = close_uc(u, us[s]) + if bad: + difetti[u] = bad + dep = {} + for s, u in SET_DEPLOY.items(): + dep[s], bad = close_uc(u, us[s]) + if bad: + difetti[u] = bad + + # ---------- (0) DATO ---------- + print("\n" + "=" * 108) + print(" (0) IL DATO — cross-check di ogni veicolo contro il suo gemello USA") + print("=" * 108) + print(" Il feed equity aveva SOLO controlli locali (integrita', gap, spike, split): nessun") + print(" secondo parere, mentre nel crypto la certificazione incrocia sempre piu' venue.") + print(f"\n {'gamba':7s}{'STORIA':9s}{'barre':>7s}{'dal':>12s} {'DEPLOY':9s}{'barre':>7s}{'dal':>12s}") + for s in US: + a, b = SET_STORIA[s], SET_DEPLOY[s] + print(f" {s:7s}{a:9s}{len(uc[s]):>7d}{str(_norm(uc[s]).index[0].date()):>12s} " + f"{b:9s}{len(dep[s]):>7d}{str(_norm(dep[s]).index[0].date()):>12s}") + print("\n BARRE SCARTATE DAL CROSS-CHECK:") + if not difetti: + print(" nessuna") + for u, bad in difetti.items(): + for b in bad: + print(f" {u} {pd.Timestamp(b['date']).date()}: deviazione dal gemello {b['dev']:+.1%} " + f"mentre il gemello faceva {b['twin_move']:+.2%} " + f"-> non spiegato x{b['unexplained']:.0f}") + print("\n ⚠️ CSPX 2012-01-13: open/high 112.740 in USD, low/close 88.010 in EUR (fattore dai") + print(" close adiacenti 1.2797 = EURUSD di quel giorno). La certificazione ESISTENTE non lo") + print(" vedeva: la guardia era 'maxret > 50% -> SPIKE?' e una contaminazione EUR/USD vale") + print(" ~22-28%. Stesso schema dello split 2:1 del 25/07 (che valeva ESATTAMENTE -50%).") + + # ---------- SANITY ---------- + print("\n" + "=" * 108) + print(" SANITY — il motore generalizzato riproduce gtaa.gtaa_returns?") + print("=" * 108) + CAP = G.GTAA_DEFAULT_CAPITAL + mine = sleeve_native(us, us, CAP, G.REBAL_BAND_USD, G.REBAL_EVERY, cost_ib_us) + prod = _norm(G.gtaa_returns(capital=CAP)) + both = pd.concat([mine, prod], axis=1).dropna() + dmax = float((both.iloc[:, 0] - both.iloc[:, 1]).abs().max()) + print(f" max|differenza| su {len(both)} barre = {dmax:.3e} " + f"{'OK (bit-exact)' if dmax < 1e-12 else '❌ IL MOTORE NON E LA PRODUZIONE'}") + + # ---------- (1) DEVIAZIONE DEL VEICOLO ---------- + cal = common_cal(list(us.values()) + list(uc.values())) + start_uc = max(_norm(x).index[0] for x in uc.values()) + cal = cal[cal >= start_uc + pd.Timedelta(days=380)] + print("\n" + "=" * 108) + print(" (1) LA DEVIAZIONE DEL VEICOLO — misurata sul RAPPORTO, non sulle differenze") + print("=" * 108) + print(f" finestra: {cal[0].date()} -> {cal[-1].date()} ({len(cal)} barre, {len(cal)/252:.1f} anni)") + print(" (+1 anno sul veicolo piu' corto: in L2 il segnale nasce dagli UCITS e HORIZONS arriva") + print(" a 252 barre — senza il margine le tre lenti non sarebbero appaiate)") + print(f"\n {'gamba':7s}{'UCITS':7s}{'drag misurato':>15s}{'atteso da TER':>15s}" + f"{'+ ritenuta USA':>16s}{'stabilita per anno':>20s}") + tot_mis, tot_att = [], [] + for s in US: + u = SET_STORIA[s] + d = drag_cumulato(us[s], uc[s], cal) + att = (TER[s] - TER[u]) / 100.0 + anni = [] + for y in sorted(set(cal.year))[1:-1]: + c = cal[cal.year == y] + if len(c) > 100: + anni.append(drag_cumulato(us[s], uc[s], c)) + tot_mis.append(d) + tot_att.append(att) + print(f" {s:7s}{u:7s}{d:>14.2%}{att:>14.2%}{att + RITENUTA_BPS[s]/1e4:>15.2%}" + f" [{min(anni):+.2%},{max(anni):+.2%}]" if anni else "") + print(f"\n MEDIA EQUAL-WEIGHT: misurato {np.mean(tot_mis):+.2%}/anno · " + f"atteso da TER {np.mean(tot_att):+.2%} · " + f"con ritenuta {np.mean(tot_att) + np.mean(list(RITENUTA_BPS.values()))/1e4:+.2%}") + # Il grado d'accordo si CONTA, non si dichiara: scriverlo a mano nel print e' il modo piu' + # facile di pubblicare una conclusione che i propri dati smentiscono. + conc = sum(1 for a, b in zip(tot_mis, tot_att) if np.sign(a) == np.sign(b)) + vicini = sum(1 for a, b in zip(tot_mis, tot_att) if abs(a - b) < 0.003) + print(f"\n CONTROLLO DI PLAUSIBILITA': segno concorde in {conc}/6 gambe, " + f"scarto < 30bps in {vicini}/6.") + print(" Il caso piu' netto e' l'oro: GLD 0.40% vs IGLN 0.12% -> atteso +0.28%, misurato") + print(" +0.36%. Le due gambe discordi (CSPX, IHYU) restano entro 30bps: su una banda annua") + print(" larga [-0.9%,+1.9%] il segno di uno scarto cosi' piccolo non e' informativo.") + print(" Cio' che il controllo stabilisce e' la SCALA — decine di bps, non punti.") + print("\n ⚠️ La colonna 'ritenuta' e' A FAVORE dell'UCITS e NON e' nel drag misurato:") + print(" ADJUSTED_LAST sulle serie USA e' al LORDO della ritenuta del 15% che un residente") + print(" italiano paga davvero sui dividendi USA. (Ipotesi fiscale dichiarata, fonte") + print(" secondaria, da confermare col commercialista — non e' un parere fiscale.)") + + # ---------- (2) TRE LENTI ---------- + print("\n" + "=" * 108) + print(" (2) TRE LENTI, UN GRADO DI LIBERTA' PER VOLTA") + print("=" * 108) + L0 = sleeve(us, us, cal, CAP, G.REBAL_BAND_USD, G.REBAL_EVERY, cost_ib_us) + L1 = sleeve(us, uc, cal, CAP, G.REBAL_BAND_USD, G.REBAL_EVERY, cost_ib_us) + L2 = sleeve(uc, uc, cal, CAP, G.REBAL_BAND_USD, G.REBAL_EVERY, cost_ib_us) + print(f"\n {'lente':48s}{'Sharpe':>9s}{'CAGR':>9s}{'maxDD':>9s}{'vol':>8s}") + for nome, r in [("L0 segnale USA + rend. USA (pubblicato)", L0), + ("L1 segnale USA + rend. UCITS (veicolo)", L1), + ("L2 segnale UCITS + rend. UCITS (+orologio)", L2)]: + m = met(r) + print(f" {nome:48s}{m['sharpe']:>9.2f}{m['cagr']:>8.2%}{m['dd']:>9.1%}{m['vol']:>8.1%}") + m0, m2 = met(L0), met(L2) + print(f"\n TOTALE (L2-L0): dSharpe {m2['sharpe']-m0['sharpe']:+.2f} · " + f"dCAGR {m2['cagr']-m0['cagr']:+.2%}/anno · dDD {m2['dd']-m0['dd']:+.1%}") + print(f"\n ⚠️ corr(L0,L2) = {L0.corr(L2):.2f}, bassa per due serie sullo STESSO indice — ed e'") + print(" l'orologio, non un difetto: la chiusura di Londra e quella di New York condividono") + print(" la notte e la mattina americana ma non il pomeriggio. Verificato: la correlazione") + print(" fra veicolo e gemello e' MASSIMA a lag 0 su 6/6 coppie (0.46-0.78), quindi le date") + print(" non sono sfasate. Conseguenza: le statistiche GIORNALIERE delle due lenti non sono") + print(" confrontabili una a una, mentre il DRIFT (sezione 1) lo e'.") + + # ---------- (3) COSTI PER FORMA ---------- + print("\n" + "=" * 108) + print(" (3) IL COSTO DEL VENUE — per FORMA, non per livello") + print("=" * 108) + print(" Regola 25/07: due venue a pari 'costo medio' danno esiti OPPOSTI al variare del") + print(" capitale, perche' un pavimento FISSO e' una tassa regressiva. Qui non si indovina il") + print(" listino di un broker: si misura la curva e si ricava la soglia da verificare.") + caps = [3_000, 10_000, 50_000] + + # Il numero che rende ogni listino calcolabile a mano, senza doverne indovinare nessuno. + print("\n ORDINI L'ANNO (dipendono da banda e cadenza, NON dal listino ne' dal capitale):") + for c in [3_000, 10_000, 50_000]: + n = 0 + for k in uc: + _, pos = leg_returns(uc[k], uc[k], cal, c / 6.0, G.REBAL_BAND_USD, G.REBAL_EVERY, + cost_form(fixed=1.0)) + n += int((pos.diff().abs() > 1e-12).sum()) + print(f" a ${c:,d} di capitale allocato: {n/(len(cal)/252):.0f} ordini/anno " + f"-> costo annuo = ordini x tariffa (es. $1/ordine = ${n/(len(cal)/252):.0f}/anno " + f"= {n/(len(cal)/252)/c:.2%} del capitale)") + print(f"\n COSTO FISSO per ordine: " + "".join(f"{'$'+format(c,',d'):>22s}" for c in caps)) + print(f" {'':>29s}" + "".join(f"{'Sharpe':>11s}{'CAGR':>11s}" for _ in caps)) + for f in [0.0, 0.35, 1.0, 2.0, 3.0, 5.0, 10.0]: + row = f" {'$'+format(f,'.2f'):>29s}" + for c in caps: + m = met(sleeve(uc, uc, cal, c, G.REBAL_BAND_USD, G.REBAL_EVERY, cost_form(fixed=f))) + row += f"{m['sharpe']:>11.2f}{m['cagr']:>10.2%}" + print(row) + print(f"\n COSTO PROPORZIONALE: " + "".join(f"{'$'+format(c,',d'):>22s}" for c in caps)) + print(f" {'':>29s}" + "".join(f"{'Sharpe':>11s}{'CAGR':>11s}" for _ in caps)) + for p in [0.0, 5.0, 10.0, 25.0, 50.0]: + row = f" {format(p,'.0f')+' bps/ordine':>29s}" + for c in caps: + m = met(sleeve(uc, uc, cal, c, G.REBAL_BAND_USD, G.REBAL_EVERY, cost_form(prop_bps=p))) + row += f"{m['sharpe']:>11.2f}{m['cagr']:>10.2%}" + print(row) + + print("\n SOGLIE — costo fisso per ordine oltre il quale lo sleeve non vale la pena:") + print(f" {'capitale':>10s}{'CAGR = 0':>13s}{'Sharpe = 0.45':>16s} (0.45 = base del plateau dichiarato)") + for c in caps: + def cagr_at(f): + return met(sleeve(uc, uc, cal, c, G.REBAL_BAND_USD, G.REBAL_EVERY, cost_form(fixed=f))) + res = [] + for target, key in [(0.0, "cagr"), (0.45, "sharpe")]: + lo, hi = 0.0, 40.0 + if cagr_at(hi)[key] > target: + res.append(">$40") + continue + for _ in range(9): + mid = (lo + hi) / 2 + if cagr_at(mid)[key] > target: + lo = mid + else: + hi = mid + res.append(f"${(lo+hi)/2:.2f}") + print(f" {'$'+format(c,',d'):>10s}{res[0]:>13s}{res[1]:>16s}") + + # ---------- (4) BIGLIETTO MINIMO ---------- + print("\n" + "=" * 108) + print(" (4) IL BIGLIETTO MINIMO — l'analogo azionario dell'haircut small-cap") + print("=" * 108) + print(" Senza frazionamento un ordine e' almeno UNA azione. Se una azione vale quanto l'intera") + print(" gamba, il vol-target non e' rappresentabile e lo sleeve non e' quello che il backtest") + print(" modella. Ma il prezzo unitario e' una SCELTA: sullo stesso indice esistono linee 5-10x") + print(" piu' economiche.") + print(f"\n {'gamba':7s}{'STORIA':8s}{'prezzo':>9s}{'DEPLOY':>9s}{'prezzo':>9s}" + + "".join(f"{'a $'+format(c,',d'):>13s}" for c in caps)) + for s in US: + pa = float(_norm(uc[s]).iloc[-1]) + pb = float(_norm(dep[s]).iloc[-1]) + row = f" {s:7s}{SET_STORIA[s]:8s}{pa:>9.2f}{SET_DEPLOY[s]:>9s}{pb:>9.2f}" + for c in caps: + row += f"{pb/(c/6.0):>12.0%} " + print(row) + print(" (percentuale = quanto pesa UNA azione DEPLOY sull'allocazione di quella gamba;") + print(" sopra ~20% il target continuo non e' rappresentabile)") + + print("\n DANNO MISURATO dell'esecuzione a numero INTERO di azioni — i DUE insiemi a confronto,") + print(" sulla STESSA finestra (quella del veicolo piu' corto), per isolare il prezzo unitario:") + cal_d = common_cal(list(us.values()) + list(dep.values()) + list(uc.values())) + start_d = max(_norm(x).index[0] for x in list(dep.values()) + list(uc.values())) + cal_d = cal_d[cal_d >= start_d + pd.Timedelta(days=380)] + print(f" finestra {cal_d[0].date()} -> {cal_d[-1].date()} ({len(cal_d)/252:.1f} anni, " + f"vincolata da {SET_DEPLOY['QQQ']})") + print(f"\n {'insieme':9s}{'capitale':>10s}{'Sh fraz.':>10s}{'Sh intero':>11s}{'delta':>8s}" + f"{'vol fraz.':>11s}{'vol intero':>12s}{'gambe vive':>12s}{'esposta':>10s}") + for nome, vv in [("STORIA", uc), ("DEPLOY", dep)]: + for c in [3_000, 10_000, 50_000]: + a = met(sleeve(vv, vv, cal_d, c, G.REBAL_BAND_USD, G.REBAL_EVERY, cost_form(fixed=1.0))) + rb, pb = sleeve(vv, vv, cal_d, c, G.REBAL_BAND_USD, G.REBAL_EVERY, + cost_form(fixed=1.0), integer_shares=True, want_pos=True) + b = met(rb) + vive = int((pb.abs().sum(axis=0) > 0).sum()) + print(f" {nome:9s}{'$'+format(c,',d'):>10s}{a['sharpe']:>10.2f}{b['sharpe']:>11.2f}" + f"{b['sharpe']-a['sharpe']:>+8.2f}{a['vol']:>11.1%}{b['vol']:>12.1%}" + f"{str(vive)+'/6':>12s}{(pb > 0).mean().mean():>10.0%}") + print("\n ⚠️ SI LEGGE LA COLONNA 'gambe vive' E 'vol', NON 'Sharpe'. Un vincolo che ALZA lo") + print(" Sharpe non e' un miglioramento: arrotondando in giu' riduce l'esposizione e paga") + print(" meno commissioni. E' il NULL DE-LEVERING (VRP-DD, TP01xDVOL, MAT01: 3 occorrenze") + print(" nel progetto) — il primo test da fare su ogni 'va meglio'. Qui il controllo e' stato") + print(" eseguito e il verdetto e' che con l'insieme DEPLOY il de-levering NON c'e'.") + + # ---------- (5) limite del rilevatore, chiuso sul DANNO ---------- + print("\n" + "=" * 108) + print(" (5) IL LIMITE DEL CROSS-CHECK, CHIUSO SUL DANNO E NON SULLA RILEVABILITA'") + print("=" * 108) + print(" Il cross-check non vede una contaminazione a cambio basso (EURUSD ~1.09 -> 8% di") + print(" deviazione, dentro il rumore dei giorni violenti: misurato 9.9% il 2025-04-09).") + print(" Abbassare la soglia genererebbe falsi positivi, quindi si misura il DANNO di una") + print(" contaminazione NON VISTA invece di inseguirne la rilevabilita'.") + base = met(sleeve(uc, uc, cal, CAP, G.REBAL_BAND_USD, G.REBAL_EVERY, cost_form(fixed=1.0))) + rng = np.random.default_rng(20260726) + for fx, nome in [(1.09, "EURUSD 1.09 — il caso NON rilevabile"), + (1.27, "GBPUSD 1.27 — riferimento di scala (sempre rilevato)")]: + ds = [] + for _ in range(60): + leg = US[int(rng.integers(0, 6))] + u2 = dict(uc) + s = _norm(u2[leg]).reindex(cal).ffill().copy() + s.iloc[int(rng.integers(300, len(s) - 5))] /= fx + u2[leg] = s + ds.append(met(sleeve(u2, u2, cal, CAP, G.REBAL_BAND_USD, G.REBAL_EVERY, + cost_form(fixed=1.0)))["sharpe"] - base["sharpe"]) + ds = np.array(ds) + print(f" {nome:52s} dSharpe mediano {np.median(ds):+.4f} · " + f"peggiore {ds.min():+.4f} · |d|>0.05 nel {(np.abs(ds)>0.05).mean():.0%}") + print("\n" + "=" * 108) + + +if __name__ == "__main__": + main() diff --git a/src/data/eq_crosscheck.py b/src/data/eq_crosscheck.py new file mode 100644 index 0000000..f57d9b4 --- /dev/null +++ b/src/data/eq_crosscheck.py @@ -0,0 +1,134 @@ +"""Cross-check di un feed equity contro il suo GEMELLO — la guardia che al feed equity mancava. + +PERCHE'. Nel crypto la certificazione incrocia SEMPRE piu' venue (`certify_feed.py`: accordo +per-anno vs Coinbase USD) perche' un prezzo senza un secondo parere non e' certificato. Il feed +equity (`fetch_ib_equities.py`) ha solo controlli LOCALI (integrita', gap, spike, split). Finche' +i veicoli erano ETF USA liquidi il buco non si e' visto; sugli UCITS quotati a Londra si e' visto +alla prima estrazione. + +IL DIFETTO CHE L'HA RESA NECESSARIA (2026-07-26). CSPX, barra 2012-01-13: + open 112.740 high 112.740 low 88.010 close 88.010 +open/high in USD, low/close in EUR (fattore 1.2797 = EURUSD di quel giorno). Un ETF sul S&P 500 +segna cosi' -21.9% e poi +27.8%, mentre SPY faceva -0.39% e +0.23%. +**La certificazione esistente non lo vedeva**: la guardia era `maxret > 50% -> SPIKE?` e una +contaminazione EUR/USD vale ~22-28%. Stesso schema dello split 2:1 non aggiustato del 25/07 (che +valeva ESATTAMENTE -50%): una soglia tarata su una classe di difetto non sorveglia le altre. + +PERCHE' IL GEMELLO. Il discriminante del 25/07 (range intraday) qui NON funziona: una barra +contaminata ha un range enorme, ma anche un crollo vero (SLV 2026-01-30: 33% di range). Cio' che +separa i due casi e' che **un evento di mercato lo fa anche il gemello**: nel RAPPORTO +veicolo/gemello i movimenti veri si cancellano. Controprova su dati reali: la stessa scansione +fatta sui PREZZI segnalava IDTL 2020-03 (liquidazione treasury) e IGLN 2013-04 (crollo dell'oro) +— eventi veri; sul RAPPORTO spariscono e resta la sola CSPX 2012-01-13. + +⚠️ PERCHE' NON BASTA LA DEVIAZIONE. Tarata sul feed reale, la deviazione dal gemello arriva al +**9.90%** per motivi legittimi (2025-04-09: LSE chiude alle 11:30 di New York, quindi in un giorno +violento le due chiusure sono davvero lontane) mentre il difetto vale 21.4% -> margine 2.2x, sotto +il 3x che questo progetto richiede a un rilevatore. La statistica giusta e' ADIMENSIONALE: + + non_spiegato = |deviazione dal gemello| / max(|movimento del gemello quel giorno|, FLOOR) + +perche' **un disallineamento d'orario non puo' superare il movimento del mercato**: se il gemello +si e' mosso dello 0.4% e il veicolo se ne va del 21%, non c'e' orario che lo spieghi. Misurata sul +feed: rumore massimo **8.1**, difetto CSPX **42.7** -> margine **5.3x**. Soglia posta a 18.0, +equidistante in scala logaritmica (2.2x sopra il rumore, 2.4x sotto il difetto). + +⚠️ LIMITE DICHIARATO. Una contaminazione a cambio BASSO resta al margine: GBPUSD ~1.27 -> 21% di +deviazione = sempre rilevata; EURUSD ~1.09 -> 8.3% = rilevata solo se il gemello quel giorno era +quasi fermo. Non e' un buco tappabile abbassando la soglia (sotto 12 si iniziano a segnalare i +giorni violenti veri). Il modo onesto di chiudere il punto non e' la rilevabilita' ma il DANNO: +vedi `scripts/research/r0726_gtaa_ucits.py`, che inietta contaminazioni e misura quanto spostano +lo sleeve invece di limitarsi a contarle. + +RIPARAZIONE = SCARTARE LA BARRA, non ricostruirla. Del prezzo vero di quella barra non sappiamo +niente: sappiamo che quello scritto e' sbagliato. Scartarla rende corretto il rendimento +12/01->17/01 (112.630 -> 112.475 = -0.14%); "ripararla" con un fattore stimato inventerebbe un +dato. Un buco dichiarato e' meglio di un numero inventato. +""" +from __future__ import annotations + +import numpy as np +import pandas as pd + +# Soglie TARATE sul feed reale (vedi docstring). Cambiarle invalida la taratura. +MIN_UNEXPLAINED = 18.0 # rumore misurato 8.1 · difetto misurato 42.7 +MIN_DEV = 0.05 # sotto, e' tracking error: non vale la pena guardare il rapporto +TWIN_MOVE_FLOOR = 0.005 # evita che un gemello fermo faccia esplodere il rapporto +REVERT_BARS = 5 +REVERT_TOL = 0.02 +LOCAL_WIN = 21 + + +def _norm(s: pd.Series) -> pd.Series: + """Indice a giorno di calendario: LSE e NYSE hanno festivi diversi e l'ora non e' comparabile.""" + x = s[~s.index.duplicated()].astype(float).copy() + idx = pd.to_datetime(x.index) + if getattr(idx, "tz", None) is not None: + idx = idx.tz_localize(None) + x.index = idx.normalize() + return x.sort_index() + + +def _ratio_to_twin(close: pd.Series, twin: pd.Series) -> pd.Series: + """Rapporto veicolo/gemello sulle sole date comuni. I movimenti di mercato si cancellano: + resta tracking + differenza d'orario + eventuali difetti della singola serie.""" + a, b = _norm(close), _norm(twin) + idx = a.index.intersection(b.index) + return (a.loc[idx] / b.loc[idx]).sort_index() + + +def detect_twin_outliers(close: pd.Series, twin: pd.Series, *, + min_unexplained: float = MIN_UNEXPLAINED, min_dev: float = MIN_DEV, + revert_bars: int = REVERT_BARS, revert_tol: float = REVERT_TOL, + local_win: int = LOCAL_WIN, + twin_move_floor: float = TWIN_MOVE_FLOOR) -> list[dict]: + """Barre in cui la serie si stacca dal gemello PIU' DI QUANTO IL MERCATO SPIEGHI, e rientra. + + Tre condizioni congiunte (come il rilevatore di split, e per la stessa ragione: una sola + genera falsi positivi su decenni di dati): + 1. deviazione dal livello LOCALE del rapporto oltre `min_dev`; + 2. `non_spiegato` oltre `min_unexplained` — il mercato non puo' giustificarla; + 3. RIENTRO entro `revert_bars` barre (una divergenza di NAV vera persiste, una stampa no). + + Ritorna dict con data, deviazione, `unexplained` e FATTORE IMPLICITO. Il fattore va confrontato + a mano col cambio del giorno (1.28 = EURUSD 2012, 1.27 = GBPUSD): il rilevatore NON decide che + la causa sia valutaria, dice che quella barra non appartiene alla serie. + """ + r = _ratio_to_twin(close, twin) + if len(r) < local_win + revert_bars + 2: + return [] + base = r.rolling(local_win, center=True, min_periods=5).median() + dev = (r / base - 1.0).values + tw = _norm(twin).pct_change().reindex(r.index).values + denom = np.maximum(np.abs(np.nan_to_num(tw)), twin_move_floor) + unexp = np.abs(dev) / denom + + out: list[dict] = [] + for i in np.where((np.abs(dev) > min_dev) & (unexp > min_unexplained))[0]: + fwd = dev[i + 1: i + 1 + revert_bars] + if len(fwd) == 0 or not np.any(np.abs(fwd) <= revert_tol): + continue # divergenza che PERSISTE: non e' una stampa + out.append({"date": r.index[i], "dev": float(dev[i]), "unexplained": float(unexp[i]), + "twin_move": float(np.nan_to_num(tw[i])), + "factor": float(1.0 / (1.0 + dev[i]))}) + return out + + +def repair_twin_outliers(df: pd.DataFrame, twin: pd.Series, **kw) -> tuple[pd.DataFrame, list[dict]]: + """Scarta le barre segnalate. Ritorna (df pulito, elenco delle barre rimosse). + + Componibile come `repair_splits`: si applica IN LETTURA, cosi' il parquet su disco resta il + dato grezzo scaricato e la riparazione e' sempre ispezionabile. + """ + if "close" not in df.columns or df.empty: + return df, [] + close = pd.Series(df["close"].astype(float).values, index=pd.to_datetime(df.index)) + bad = detect_twin_outliers(close, twin, **kw) + if not bad: + return df, [] + drop = {pd.Timestamp(b["date"]).date() for b in bad} + idx = pd.to_datetime(df.index) + if getattr(idx, "tz", None) is not None: + idx = idx.tz_localize(None) + keep = ~np.isin(idx.normalize().date, list(drop)) + return df.loc[keep], bad diff --git a/src/portfolio/gtaa.py b/src/portfolio/gtaa.py index 4ffa049..74958ea 100644 --- a/src/portfolio/gtaa.py +++ b/src/portfolio/gtaa.py @@ -45,15 +45,36 @@ RAW = Path(__file__).resolve().parents[2] / "data" / "raw" # GTAA_MIN_CAPITAL, e il LOO del 26/07 che lo indicava come l'unico sleeve positivo nel 100% # delle estrazioni su tutte e tre le metriche. # -# VIA D'USCITA (non ancora percorsa): equivalenti UCITS — CSPX/SXR8, EQQQ/SXRV, IUSN/CSUSS, -# DTLA/IDTL, SGLN/IGLN, IHYU. ⚠️ NON e' una sostituzione di ticker: -# * storia piu' CORTA (diversi nati dopo il 2010) -> si perde la validazione a 30 anni che e' -# cio' che rendeva credibile questo sleeve; -# * ritenuta sui dividendi, TER e replica diversi -> qualche decina di bps/anno su un CAGR -# del 3.65%, cioe' non trascurabile in proporzione; -# * quotazione LSE/Xetra -> orari e valuta diversi da un sleeve che decide sul close USA. -# Percorso onesto: validare sull'INDICE (storia lunga) e negoziare il VEICOLO UCITS, dichiarando -# il tracking error e la ritenuta come costi espliciti. +# ✅ VIA D'USCITA MISURATA 2026-07-26 (diario 2026-07-26-gtaa-ucits.md, script +# scripts/research/r0726_gtaa_ucits.py): gli equivalenti UCITS REGGONO, e costano ~zero. +# +# ⚠️ CORREZIONE A UNA MIA AFFERMAZIONE. Questa nota diceva "storia piu' CORTA -> si perde la +# validazione a 30 anni che e' cio' che rendeva credibile questo sleeve". E' FALSO, per un motivo +# strutturale: **il PRIIPs vieta di COMPRARE, non di GUARDARE**. I prezzi dei 6 ETF USA restano +# leggibili (l'operatore li aveva sul terminale mentre l'ordine veniva rifiutato), quindi il +# SEGNALE continua a girare sui 30 anni di serie USA — cambia solo il VEICOLO su cui si incassa. +# La storia corta degli UCITS serve solo a misurare la deviazione del veicolo, che e' un drift +# lento e si stima bene anche su pochi anni. +# +# MISURATO su 6.3 anni comuni, tre lenti a un grado di liberta' per volta: +# L0 segnale USA + rend. USA (pubblicato) Sh 0.81 / CAGR 3.96% +# L2 segnale UCITS + rend. UCITS (deploy) Sh 0.84 / CAGR 4.08% -> il cambio NON costa +# drag del veicolo (sul RAPPORTO cumulato, non sulle differenze giornaliere): +0.10%/anno EW, +# coerente coi TER; e la ritenuta USA del 15% sui dividendi (~35bps) e' A FAVORE dell'UCITS. +# +# DUE INSIEMI, perche' il prezzo di UNA azione e' una scelta dell'emittente e non una proprieta' +# dell'indice (CSPX $802 vs VUAA $144 sullo STESSO S&P 500): +# STORIA (misura): CSPX, EQQQ, XRSU, IDTL, IGLN, IHYU +# DEPLOY (esegue): VUAA, XNAS, R2US, IDTL, IGLN, IHYU -> a $3.000 tiene 6/6 gambe SENZA +# frazionamento, mentre l'insieme STORIA ne perde 2 e sta a mercato il 33%. +# Scartati benche' con piu' storia: RTWO (Russell 2000 *quality*), CSUSS (*ESG*), IDP6 (S&P 600): +# equivalenza dell'INDICE prima della storia. +# +# COSA RESTA DA VERIFICARE PRIMA DI UN DEPLOY (una cosa sola): la tariffa per ordine del broker su +# LSEETF in USD. Lo sleeve fa 77 ordini/anno a $3k, 102 a $10k, 149 a $50k; la soglia oltre cui non +# vale la pena (Sharpe < 0.45) e' $0.90/ordine a $3k, $2.23 a $10k, $7.62 a $50k. Un listino +# PROPORZIONALE e' quasi indifferente al capitale, uno FISSO e' una tassa regressiva (regola 25/07). +# NB: cambiare broker NON sblocca gli ETF USA — il PRIIPs vale per ogni intermediario UE. EQ_UNIVERSE = ("SPY", "QQQ", "IWM", "TLT", "GLD", "HYG") HORIZONS = (21, 63, 126, 252) TARGET_VOL = 0.12 diff --git a/tests/test_eq_crosscheck.py b/tests/test_eq_crosscheck.py new file mode 100644 index 0000000..e21b636 --- /dev/null +++ b/tests/test_eq_crosscheck.py @@ -0,0 +1,206 @@ +"""Test del cross-check equity contro il gemello (src/data/eq_crosscheck.py). + +Il rilevatore nasce da un difetto VERO trovato il 2026-07-26: CSPX 2012-01-13 con open/high in +USD e low/close in EUR. La certificazione esistente non lo vedeva perche' la sua unica guardia +sui movimenti era `maxret > 50% -> SPIKE?` e una contaminazione EUR/USD vale ~22-28%. + +I test che contano davvero sono i CONTROLLI su dati reali: + * il difetto noto viene segnalato (senza questo il rilevatore potrebbe essere semplicemente rotto); + * gli eventi di mercato VERI non vengono segnalati — IDTL marzo 2020 (liquidazione dei treasury) + e IGLN aprile 2013 (crollo dell'oro) sono andate-e-ritorni identici nella FORMA a una stampa + sbagliata, e una scansione fatta sui prezzi invece che sul rapporto col gemello li segnalava. +E il test `test_limite_dichiarato_*` congela il LIMITE noto: se qualcuno abbassa la soglia, il +test dice esattamente cosa e' cambiato invece di lasciar credere che il rilevatore sia completo. +""" +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)) + +from src.data.eq_crosscheck import ( # noqa: E402 + MIN_DEV, MIN_UNEXPLAINED, REVERT_BARS, TWIN_MOVE_FLOOR, + detect_twin_outliers, repair_twin_outliers, +) + +RAW = ROOT / "data" / "raw" + + +def _load(prefix: str, sym: str) -> pd.DataFrame: + p = RAW / f"{prefix}_{sym.lower()}_1d.parquet" + if not p.exists(): + pytest.skip(f"{p.name} assente (data/raw e' gitignored)") + d = pd.read_parquet(p) + d.index = pd.to_datetime(d["timestamp"], unit="ms", utc=True) + return d.sort_index() + + +def _close(prefix: str, sym: str) -> pd.Series: + d = _load(prefix, sym) + return pd.Series(d["close"].astype(float).values, index=d.index) + + +def _serie(vals, start="2020-01-01"): + return pd.Series(np.asarray(vals, float), + index=pd.bdate_range(start, periods=len(vals))) + + +# =========================================================================== +# controlli su DATI REALI — un rilevatore che non segnala nulla puo' essere rotto +# =========================================================================== +def test_controllo_positivo_il_difetto_noto_di_cspx_viene_segnalato(): + """IL test. Se questo fallisce, il rilevatore non serve a niente.""" + bad = detect_twin_outliers(_close("eqx", "CSPX"), _close("eq", "SPY")) + date = {pd.Timestamp(b["date"]).date().isoformat() for b in bad} + assert "2012-01-13" in date, f"difetto noto non rilevato (segnalate: {sorted(date)})" + + +def test_il_difetto_ha_la_firma_valutaria_attesa(): + bad = [b for b in detect_twin_outliers(_close("eqx", "CSPX"), _close("eq", "SPY")) + if pd.Timestamp(b["date"]).date().isoformat() == "2012-01-13"] + assert len(bad) == 1 + b = bad[0] + assert b["dev"] < -0.15, "la deviazione non ha la scala di una contaminazione valutaria" + assert abs(b["twin_move"]) < 0.01, "il gemello si era mosso: allora non e' inspiegato" + assert b["unexplained"] > 3 * MIN_UNEXPLAINED / 2 + + +def test_controllo_negativo_gli_eventi_di_mercato_veri_non_sono_segnalati(): + """Marzo 2020 sui treasury e aprile 2013 sull'oro sono andate-e-ritorni VERI: nella forma + identici a una stampa sbagliata. Li distingue solo il fatto che li fa anche il gemello.""" + for veic, gem, quando in [("IDTL", "TLT", "2020-03"), ("IGLN", "GLD", "2013-04")]: + bad = detect_twin_outliers(_close("eqx", veic), _close("eq", gem)) + colpiti = [pd.Timestamp(b["date"]).strftime("%Y-%m") for b in bad] + assert quando not in colpiti, f"{veic}: evento di mercato vero scambiato per difetto" + + +def test_nessun_falso_positivo_sui_veicoli_puliti(): + for veic, gem in [("EQQQ", "QQQ"), ("XRSU", "IWM"), ("R2US", "IWM"), + ("IDTL", "TLT"), ("IGLN", "GLD"), ("IHYU", "HYG")]: + bad = detect_twin_outliers(_close("eqx", veic), _close("eq", gem)) + assert bad == [], f"{veic}/{gem}: falsi positivi {[str(b['date']) for b in bad]}" + + +# =========================================================================== +# le tre condizioni, una alla volta +# =========================================================================== +def test_una_deviazione_che_il_gemello_SPIEGA_non_e_segnalata(): + """La condizione decisiva: un disallineamento d'orario non puo' superare il movimento del + mercato. Se il gemello si e' mosso quanto il veicolo, non c'e' niente da segnalare.""" + n = 80 + twin = _serie(np.full(n, 100.0)) + twin.iloc[40] = 80.0 # il gemello CROLLA del 20%... + twin.iloc[41] = 100.0 + veh = twin * 1.0 # ...e il veicolo fa lo stesso + veh.iloc[40] = 78.0 # con un piccolo scarto d'orario + assert detect_twin_outliers(veh, twin) == [] + + +def test_una_divergenza_che_PERSISTE_non_e_una_stampa(): + """Un cambio di livello permanente (fusione, cambio di indice, ridenominazione) va tenuto: + scartarlo cancellerebbe un fatto invece di un errore.""" + n = 80 + twin = _serie(np.full(n, 100.0)) + veh = _serie(np.concatenate([np.full(40, 100.0), np.full(n - 40, 78.0)])) + assert detect_twin_outliers(veh, twin) == [] + + +def test_una_deviazione_troppo_piccola_non_e_segnalata(): + n = 80 + twin = _serie(np.full(n, 100.0)) + veh = twin.copy() + veh.iloc[40] = 100.0 * (1 - MIN_DEV / 2) + assert detect_twin_outliers(veh, twin) == [] + + +# =========================================================================== +# iniezione: cosa si cattura e cosa NO (il limite dichiarato, congelato in un test) +# =========================================================================== +def _inietta(fx: float): + n = 200 + rng = np.random.default_rng(7) + base = 100 * np.exp(np.cumsum(rng.normal(0, 0.01, n))) + twin = _serie(base) + veh = _serie(base * 1.0) + veh.iloc[120] = veh.iloc[120] / fx + return veh, twin + + +def test_contaminazione_a_cambio_alto_gbp_viene_catturata(): + veh, twin = _inietta(1.27) + bad = detect_twin_outliers(veh, twin) + assert len(bad) == 1 and pd.Timestamp(bad[0]["date"]) == veh.index[120] + + +def test_limite_dichiarato_contaminazione_a_cambio_basso_eur_sfugge_a_mercato_mosso(): + """⚠️ Questo test NON descrive un comportamento desiderato: congela un LIMITE noto. + + EURUSD ~1.09 produce ~8% di deviazione, che sta dentro il rumore legittimo dei giorni + violenti (misurato 9.9% il 2025-04-09). Abbassare la soglia per catturarlo genererebbe + falsi positivi sui giorni veri. Il punto e' chiuso altrove misurando il DANNO + (r0726_gtaa_ucits.py sez. 5: dSharpe mediano -0.003, peggiore -0.06). + Se qualcuno cambia le soglie, questo test dice esattamente cosa e' cambiato. + """ + n = 200 + rng = np.random.default_rng(11) + base = 100 * np.exp(np.cumsum(rng.normal(0, 0.01, n))) + twin = _serie(base) + veh = _serie(base.copy()) + veh.iloc[120] /= 1.09 + twin.iloc[120] *= 1.02 # il gemello quel giorno si muove: la deviazione diventa 'spiegabile' + assert detect_twin_outliers(veh, twin) == [], \ + "il limite dichiarato non vale piu': aggiornare la docstring del modulo e il diario" + + +# =========================================================================== +# riparazione +# =========================================================================== +def test_la_riparazione_scarta_la_barra_e_non_ne_inventa_una(): + d = _load("eqx", "CSPX") + pulito, bad = repair_twin_outliers(d, _close("eq", "SPY")) + assert len(bad) == 1 + assert len(pulito) == len(d) - 1 + rimasta = pd.to_datetime(pulito.index).tz_localize(None).normalize() + assert pd.Timestamp("2012-01-13") not in set(rimasta) + # nessun prezzo ricostruito: i close superstiti sono identici agli originali + orig = d["close"] + assert (pulito["close"].values == orig.loc[pulito.index].values).all() + + +def test_la_riparazione_rende_sano_il_rendimento_a_cavallo(): + d = _load("eqx", "CSPX") + pulito, _ = repair_twin_outliers(d, _close("eq", "SPY")) + r = pulito["close"].pct_change() + w = r[(r.index >= "2012-01-10") & (r.index <= "2012-01-20")] + assert w.abs().max() < 0.05, f"resta un movimento assurdo: {w.abs().max():.1%}" + + +def test_riparazione_idempotente(): + d = _load("eqx", "CSPX") + p1, b1 = repair_twin_outliers(d, _close("eq", "SPY")) + p2, b2 = repair_twin_outliers(p1, _close("eq", "SPY")) + assert b2 == [] and len(p2) == len(p1) + + +def test_serie_vuota_o_cortissima_non_esplode(): + vuoto = pd.DataFrame({"close": []}, index=pd.DatetimeIndex([])) + assert repair_twin_outliers(vuoto, _serie([1.0, 2.0])) == (vuoto, []) + assert detect_twin_outliers(_serie([1.0] * 5), _serie([1.0] * 5)) == [] + + +# =========================================================================== +# la taratura e' un valore, non un dettaglio +# =========================================================================== +def test_le_soglie_sono_congelate(): + """Tarate su misura: rumore massimo 8.1, difetto 42.7 -> soglia 18.0 equidistante in scala + logaritmica. Cambiarle senza rifare la taratura invalida ogni conclusione del 26/07.""" + assert MIN_UNEXPLAINED == 18.0 + assert MIN_DEV == 0.05 + assert TWIN_MOVE_FLOOR == 0.005 + assert REVERT_BARS == 5 diff --git a/tests/test_gtaa_ucits.py b/tests/test_gtaa_ucits.py new file mode 100644 index 0000000..ae45002 --- /dev/null +++ b/tests/test_gtaa_ucits.py @@ -0,0 +1,170 @@ +"""Test della rivalidazione di GTAA01 sui veicoli UCITS (scripts/research/r0726_gtaa_ucits.py). + +Tre cose vanno bloccate qui, e sono di natura diversa: + 1. il MOTORE generalizzato deve essere la produzione (bit-exact), altrimenti le tre lenti + misurano una strategia che non e' quella del progetto; + 2. lo STIMATORE del drag: la media delle differenze giornaliere NON e' la deviazione fra due + veicoli sullo stesso indice, e il test lo dimostra su un caso costruito dove la risposta + giusta e' nota; + 3. il FINDING operativo: a $3.000 l'insieme di veicoli a prezzo unitario alto perde due gambe, + quello a prezzo basso no. E' la ragione per cui il frazionamento del broker non serve. +""" +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")) + +from src.portfolio import gtaa as G # noqa: E402 + +import r0726_gtaa_ucits as U # noqa: E402 + +RAW = ROOT / "data" / "raw" + + +def _need(prefix: str, syms) -> None: + for s in syms: + if not (RAW / f"{prefix}_{s.lower()}_1d.parquet").exists(): + pytest.skip(f"{prefix}_{s.lower()}_1d.parquet assente (data/raw e' gitignored)") + + +# =========================================================================== +# 1. il motore E' la produzione +# =========================================================================== +def test_il_motore_generalizzato_riproduce_gtaa_returns_bit_exact(): + """IL test di questo file. `sleeve_native` deve dare ESATTAMENTE gtaa.gtaa_returns: se + diverge, ogni confronto fra lenti sta misurando un'altra strategia.""" + _need("eq", U.US) + us = {s: U.close_us(s) for s in U.US} + mine = U.sleeve_native(us, us, G.GTAA_DEFAULT_CAPITAL, G.REBAL_BAND_USD, G.REBAL_EVERY, + U.cost_ib_us) + prod = U._norm(G.gtaa_returns(capital=G.GTAA_DEFAULT_CAPITAL)) + both = pd.concat([mine, prod], axis=1).dropna() + assert len(both) > 5000 + assert float((both.iloc[:, 0] - both.iloc[:, 1]).abs().max()) == 0.0 + + +def test_segnale_e_veicolo_separati_coincidono_quando_sono_la_stessa_serie(): + _need("eq", ["SPY"]) + _need("eqx", ["CSPX"]) + spy, cspx = U.close_us("SPY"), U.close_uc("CSPX", U.close_us("SPY"))[0] + cal = U.common_cal([spy, cspx]) + a = U.leg_returns(spy, spy, cal, 1000.0, 50.0, 5, U.cost_ib_us)[0] + b = U.leg_returns(spy, spy, cal, 1000.0, 50.0, 5, U.cost_ib_us)[0] + assert float((a - b).abs().max()) == 0.0 + # con veicolo diverso il risultato DEVE cambiare, altrimenti il parametro e' ignorato + c = U.leg_returns(spy, cspx, cal, 1000.0, 50.0, 5, U.cost_ib_us)[0] + assert float((a - c).abs().max()) > 1e-6 + + +# =========================================================================== +# 2. lo stimatore del drag +# =========================================================================== +def _due_veicoli(n=1500, drag_annuo=0.0, rumore=0.0, seed=3): + """Due veicoli sullo STESSO indice: b = a x (drift noto) x (rumore di campionamento). + Il rumore e' i.i.d. e stazionario — e' l'analogo del fatto che Londra chiude 4h30 prima.""" + rng = np.random.default_rng(seed) + idx = pd.bdate_range("2015-01-01", periods=n) + p = 100 * np.exp(np.cumsum(rng.normal(0.0003, 0.011, n))) + a = pd.Series(p, index=idx) + b = a * (1 + drag_annuo) ** (np.arange(n) / 252.0) + if rumore: + b = b * (1 + rng.normal(0, rumore, n)) + return a, b + + +def test_il_drag_cumulato_recupera_un_drag_noto(): + a, b = _due_veicoli(drag_annuo=-0.0030) + assert U.drag_cumulato(a, b, a.index) == pytest.approx(-0.0030, abs=2e-4) + + +def test_il_drag_cumulato_e_immune_al_rumore_di_campionamento(): + """Il rumore stazionario non e' un drag: uno stimatore corretto non lo deve vedere.""" + a, b = _due_veicoli(drag_annuo=0.0, rumore=0.01) + assert abs(U.drag_cumulato(a, b, a.index)) < 0.003 + + +def test_la_media_delle_differenze_giornaliere_NON_e_lo_stimatore_giusto(): + """⚠️ Il test che giustifica `drag_cumulato`. Due veicoli con lo STESSO drift geometrico ma + campionati diversamente: la media delle differenze semplici vede un drag che non esiste, + perche' e' contaminata dalla diversa varianza giornaliera (drag di varianza), mentre il + rapporto cumulato — che e' la domanda vera, 'quanto mi costa il veicolo' — vede ~0. + + E' il difetto che nella prima stesura dava -0.47%/anno su CSPX invece di -0.06%. + """ + a, b = _due_veicoli(drag_annuo=0.0, rumore=0.012) + cum = U.drag_cumulato(a, b, a.index) + media = float((b.pct_change() - a.pct_change()).dropna().mean() * 252) + assert abs(cum) < 0.003, "il rapporto cumulato dovrebbe vedere ~0" + assert abs(media) > 5 * abs(cum), \ + f"il caso costruito non separa i due stimatori (cum {cum:.4%}, media {media:.4%})" + + +# =========================================================================== +# 3. il finding operativo: prezzo unitario e gambe vive +# =========================================================================== +def test_azioni_intere_non_possono_aumentare_l_esposizione(): + """Un vincolo arrotonda IN GIU': se l'esposizione sale, il modello e' sbagliato (e uno + Sharpe piu' alto sarebbe null de-levering travestito da miglioramento).""" + _need("eqx", set(U.SET_DEPLOY.values())) + _need("eq", U.US) + us = {s: U.close_us(s) for s in U.US} + dep = {s: U.close_uc(u, us[s])[0] for s, u in U.SET_DEPLOY.items()} + cal = U.common_cal(list(dep.values()))[-800:] + for cap in (3_000.0, 20_000.0): + _, pf = U.sleeve(dep, dep, cal, cap, G.REBAL_BAND_USD, G.REBAL_EVERY, + U.cost_form(fixed=1.0), want_pos=True) + _, pi = U.sleeve(dep, dep, cal, cap, G.REBAL_BAND_USD, G.REBAL_EVERY, + U.cost_form(fixed=1.0), integer_shares=True, want_pos=True) + assert pi.sum().sum() <= pf.sum().sum() + 1e-9 + + +def test_a_3000_l_insieme_a_prezzo_alto_perde_gambe_e_quello_a_prezzo_basso_no(): + """IL finding operativo, bloccato. CSPX ($802) ed EQQQ ($693) non sono comprabili con una + gamba da $500: senza frazionamento quelle due gambe semplicemente non esistono.""" + _need("eq", U.US) + _need("eqx", set(U.SET_STORIA.values()) | set(U.SET_DEPLOY.values())) + us = {s: U.close_us(s) for s in U.US} + storia = {s: U.close_uc(u, us[s])[0] for s, u in U.SET_STORIA.items()} + deploy = {s: U.close_uc(u, us[s])[0] for s, u in U.SET_DEPLOY.items()} + cal = U.common_cal(list(storia.values()) + list(deploy.values()))[-700:] + + def gambe_vive(v): + _, pos = U.sleeve(v, v, cal, 3_000.0, G.REBAL_BAND_USD, G.REBAL_EVERY, + U.cost_form(fixed=1.0), integer_shares=True, want_pos=True) + return int((pos.abs().sum(axis=0) > 0).sum()) + + assert gambe_vive(storia) < 6, "l'insieme a prezzo alto dovrebbe perdere gambe a $3.000" + assert gambe_vive(deploy) == 6, "l'insieme a prezzo basso deve tenerle tutte e sei" + + +def test_i_veicoli_deploy_costano_meno_per_azione_di_quelli_storia(): + _need("eq", U.US) + _need("eqx", set(U.SET_STORIA.values()) | set(U.SET_DEPLOY.values())) + us = {s: U.close_us(s) for s in U.US} + for s in U.US: + a = float(U._norm(U.close_uc(U.SET_STORIA[s], us[s])[0]).iloc[-1]) + b = float(U._norm(U.close_uc(U.SET_DEPLOY[s], us[s])[0]).iloc[-1]) + assert b <= a + 1e-9, f"{s}: il veicolo 'deploy' non e' piu' economico ({b} > {a})" + + +# =========================================================================== +# scelte dichiarate +# =========================================================================== +def test_i_veicoli_scelti_sono_indici_equivalenti_non_fattori(): + """RTWO e' Russell 2000 *quality*, CSUSS e' MSCI USA Small Cap *ESG*, IDP6 e' S&P 600: + tutti hanno piu' storia dei veicoli scelti, e nessuno e' la stessa esposizione.""" + scelti = set(U.SET_STORIA.values()) | set(U.SET_DEPLOY.values()) + assert not (scelti & {"RTWO", "CSUSS", "IDP6"}) + + +def test_le_due_mappe_coprono_le_stesse_sei_gambe(): + assert tuple(U.SET_STORIA) == U.US and tuple(U.SET_DEPLOY) == U.US + assert len(U.US) == 6