From be4690375f2ee285d43875518ce659007f323141 Mon Sep 17 00:00:00 2001 From: Adriano Dal Pastro Date: Sun, 26 Jul 2026 23:01:54 +0000 Subject: [PATCH] =?UTF-8?q?research(gtaa):=20la=20via=20UCITS=20e'=20apert?= =?UTF-8?q?a=20e=20costa=20~zero=20=E2=80=94=20il=20broker=20non=20e'=20la?= =?UTF-8?q?=20variabile?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Domanda dell'operatore: "usiamo revolut o degiro". Risposta misurata: cambiare broker non sblocca nulla (il PRIIPs e' una norma, non una politica di IB); cambiare VEICOLO si', e costa ~zero. CORREZIONE A UNA MIA AFFERMAZIONE. La nota in gtaa.py diceva che gli UCITS fanno perdere la validazione a 30 anni. Falso: il PRIIPs vieta di COMPRARE, non di GUARDARE — i prezzi dei 6 ETF USA restano leggibili, quindi il segnale gira sui 30 anni per sempre e cambia solo il veicolo su cui si incassa. Misure (3 lenti, un grado di liberta' per volta, 6.3 anni comuni): L0 segnale USA + rend. USA Sh 0.81 / CAGR 3.96% L2 segnale UCITS + rend. UCITS Sh 0.84 / CAGR 4.08% drag del veicolo +0.10%/anno EW, coerente coi TER; ritenuta USA ~35bps a FAVORE dell'UCITS e non inclusa nel drag. Lo stimatore ovvio sbagliava: la media delle differenze giornaliere dava -0.47%/anno su CSPX contro -0.06% vero (SE ~7%/anno = 15x la quantita' stimata, piu' drag di varianza). La deviazione fra veicoli sullo stesso indice si misura sul RAPPORTO CUMULATO. Il vincolo non e' il broker ma il prezzo di UNA azione, che e' una scelta: CSPX $802 vs VUAA $144 sullo stesso S&P 500. A $3.000 con azioni intere l'insieme STORIA tiene 4/6 gambe (a mercato il 33%), l'insieme DEPLOY 6/6 (65%) -> il frazionamento non serve. Letto su gambe-vive+vol, non su Sharpe: il vincolo intero ALZA lo Sharpe perche' de-leveraggia (null de-levering, 4a occorrenza). Resta da verificare una cosa sola: 77-149 ordini/anno contro soglie $0.90 ($3k) / $2.23 ($10k) / $7.62 ($50k) per ordine. Raccomandazione: restare su IB. Feed equity: aggiunto il CROSS-CHECK che mancava (src/data/eq_crosscheck.py). Il primo veicolo estero ha trovato subito CSPX 2012-01-13 con open/high in USD e low/close in EUR (fattore 1.2797 = EURUSD del giorno), invisibile alla guardia maxret>50% — stesso schema dello split 2:1 del 25/07. Soglia non tarabile sulla deviazione (rumore 9.90%, margine 2.2x): cambiata statistica in |dev|/movimento del gemello -> margine 5.3x. Limite EURUSD 1.09 dichiarato e chiuso sul DANNO (dSharpe mediano -0.003), congelato in un test. Book, pesi, cron, config INVARIATI. 435 test verdi (+24). Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01XBbYmiXqbUNuGpK9sfbsGp --- CLAUDE.md | 78 ++++- docs/diary/2026-07-26-gtaa-ucits.md | 278 +++++++++++++++++ scripts/research/fetch_ib_ucits.py | 143 +++++++++ scripts/research/r0726_gtaa_ucits.py | 449 +++++++++++++++++++++++++++ src/data/eq_crosscheck.py | 134 ++++++++ src/portfolio/gtaa.py | 39 ++- tests/test_eq_crosscheck.py | 206 ++++++++++++ tests/test_gtaa_ucits.py | 170 ++++++++++ 8 files changed, 1481 insertions(+), 16 deletions(-) create mode 100644 docs/diary/2026-07-26-gtaa-ucits.md create mode 100644 scripts/research/fetch_ib_ucits.py create mode 100644 scripts/research/r0726_gtaa_ucits.py create mode 100644 src/data/eq_crosscheck.py create mode 100644 tests/test_eq_crosscheck.py create mode 100644 tests/test_gtaa_ucits.py 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