From 378f448450ef2b692522fb832955e60676304ad2 Mon Sep 17 00:00:00 2001 From: Adriano Dal Pastro Date: Mon, 27 Jul 2026 11:25:20 +0000 Subject: [PATCH] research(gtaa): ZPRR e' R2US (stessa ISIN) + correzione sul verso del costo FX MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit L'operatore ha verificato su Revolut: R2US assente, ZPRR e SPY4 presenti. (1) ZPRR (Xetra EUR) == R2US (Londra USD): stessa ISIN IE00BJ38QD84, stessa classe di quote, stesso NAV. La gamba small cap e' risolta. Regola: si cerca per ISIN, non per ticker — lo stesso fondo ha ticker diversi su borse diverse. (2) SPY4 IE00B4YBJ215 NON e' small cap ne' S&P 500: e' SPDR S&P 400 MID cap (l'S&P 500 e' SPY5). Misurato come ripiego su 6.5 anni: Sharpe 0.86 vs 0.86, corr fra i due sleeve 0.991, peggiore in 3/7 anni = moneta. Sulla finestra corta di 3.2 anni sembrava -0.15: era rumore. Ripiego accettabile per ragione meccanica (small e mid USA correlano ~0.95 giornaliero), NON validato — e' proprio perche' non si distinguono che la scelta non conta. (3) CORREZIONE a un'indicazione data ieri. Avevo scritto "una linea in EUR aggiunge la conversione per ordine". Vero solo per un conto in USD: per un conto in EURO vale l'opposto. L'esposizione economica e' identica (il fondo detiene attivi USD, nessuna delle due linee e' coperta) — la valuta di quotazione non copre nulla, decide solo se serve una conversione. Regola giusta: prendere la linea nella valuta del proprio saldo. Misurato: turnover lordo $13.655/anno su $10k -> 25bps = -0.07 Sharpe = $34/anno = ~$1.6 per ordine, contro una soglia di $18.90. Regola generale: un costo di conversione non e' una proprieta' dello strumento ma della coppia strumento-CONTO. Book, pesi, cron, config INVARIATI. 447 test verdi (+3). Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01XBbYmiXqbUNuGpK9sfbsGp --- CLAUDE.md | 27 ++++++++-- docs/diary/2026-07-26-gtaa-ucits.md | 67 +++++++++++++++++++++++ scripts/research/fetch_ib_ucits.py | 5 +- scripts/research/r0727_gtaa_broker.py | 78 +++++++++++++++++++++++++-- tests/test_gtaa_broker.py | 39 ++++++++++++++ 5 files changed, 208 insertions(+), 8 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index aa76117..3559ae5 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -1113,10 +1113,29 @@ Prima ondata di ricerca onesta su BTC/ETH certificati (5 track, harness condivis (d) **Cosa cercare sul proprio conto — per ISIN, non per ticker** (da contract details IB; tutti domiciliati IE, quotati a **Londra in USD**): **VUAA** `IE00BFMXXD54` ($143.80) · **XNAS** `IE00BMFKG444` ($65.74) · **R2US** `IE00BJ38QD84` ($86.35) · **IDTL** `IE00BSKRJZ44` ($3.09) · - **IGLN** `IE00B4ND3602` ($79.06) · **IHYU** `IE00B4PY7Y77` ($94.12). ⚠️ Le stesse ISIN esistono - in EUR su altre borse: la conversione per ordine a 25bps costa **~0.15 di Sharpe**, piu' del - costo per ordine di qualunque broker a questa cadenza → **la valuta della linea conta piu' del - broker**. Nessuna azione oggi (decisione venue: 100% Deribit fino a $20k). + **IGLN** `IE00B4ND3602` ($79.06) · **IHYU** `IE00B4PY7Y77` ($94.12). + Nessuna azione oggi (decisione venue: 100% Deribit fino a $20k). + ⚠️ **CORREZIONE 27/07 A QUESTO PUNTO.** Avevo scritto "una linea in EUR aggiunge la conversione + per ordine → prendere quella in USD". E' vero **solo per un conto in USD**. Per un conto in EURO + (retail italiano su Revolut/Degiro) vale l'**OPPOSTO**: la linea USD costringe a convertire a ogni + ordine, quella EUR no. L'esposizione economica e' identica (il fondo detiene attivi USD e + **nessuna delle due linee e' coperta**): la valuta di quotazione non copre nulla, decide solo se + serve una conversione. **REGOLA GIUSTA: prendere la linea nella valuta del proprio saldo.** + Misurato (turnover lordo **$13.655/anno** su $10k, 21 ordini): 15bps −0.04 Sh/$20 a., 25bps + **−0.07 Sh/$34 a.**, 50bps −0.14/$68 → a 25bps equivale a **~$1.6 in piu' per ordine**, piccolo + rispetto alla soglia di $18.90. **REGOLA: un costo di conversione non e' una proprieta' dello + strumento ma della coppia strumento-CONTO.** + (f) **Equivalenze e ripieghi, verificati su contract details IB (27/07).** + **ZPRR (Xetra EUR) == R2US (Londra USD): stessa ISIN `IE00BJ38QD84`, stesso fondo, stesso NAV** → + se un broker non quota la linea di Londra, quella di Xetra e' identica (e su conto in euro, + migliore). **REGOLA: si cerca per ISIN, non per ticker** — lo stesso fondo ha ticker diversi su + borse diverse. ⚠️ **SPY4 `IE00B4YBJ215` NON e' small cap ne' S&P 500**: e' SPDR **S&P 400 MID + cap** (l'S&P 500 e' SPY5 `IE00B6YX5C33`). Misurato come ripiego della gamba small: su 6.5 anni + Sharpe **0.86 vs 0.86**, CAGR 4.26% vs 4.20%, **corr fra i due sleeve 0.991**, peggiore in 3/7 + anni = moneta (sulla finestra corta di 3.2a sembrava −0.15: rumore) → **ripiego accettabile**, per + ragione meccanica (small e mid USA correlano ~0.95 giornaliero, un TSMOM si accende negli stessi + giorni). Non e' "validato": 6.5 anni non distinguono due gambe cosi' simili, ed e' esattamente + perche' la scelta non conta. Se c'e' una linea Russell 2000, si usa quella. **LEZIONE: una raccomandazione poggiata su un fatto non verificato sul conto reale vale quanto quel fatto** — due volte in due giorni un'assunzione sul conto ha cambiato la conclusione (prima la negoziabilita' PRIIPs, poi quale conto e' davvero operativo). diff --git a/docs/diary/2026-07-26-gtaa-ucits.md b/docs/diary/2026-07-26-gtaa-ucits.md index 7be4510..af913cf 100644 --- a/docs/diary/2026-07-26-gtaa-ucits.md +++ b/docs/diary/2026-07-26-gtaa-ucits.md @@ -458,3 +458,70 @@ punto in cui la si riaprirebbe comunque.** **Una capacità mancante si valuta sul costo di non averla, non sulla sua assenza.** Qui l'assenza di API sembra squalificante e vale −0.02 di Sharpe se si esegue entro un giorno; ciò che vale davvero (−0.27) è un rischio operativo che si copre con una notifica. + +--- + +# Addendum 2026-07-27 (3) — "r2us in revolut non c'è, ma ho zprr e spy4" + +## ZPRR **è** R2US + +| ticker | borsa | valuta | ISIN | +|---|---|---|---| +| R2US | Londra | USD | `IE00BJ38QD84` | +| **ZPRR** | **Xetra** | **EUR** | **`IE00BJ38QD84`** | + +Stessa ISIN = stessa classe di quote = **stesso fondo, stesso NAV**. Cambia solo dove e in che +valuta si negozia. La gamba small cap è risolta: **compra ZPRR**, non si perde niente. + +> **Regola: si cerca per ISIN, non per ticker.** Lo stesso fondo ha ticker diversi su borse diverse, +> e un ticker copiato da una lista può essere un altro prodotto. + +## ⚠️ SPY4 non è quello che sembra + +`IE00B4YBJ215` = SPDR **S&P 400 US Mid Cap**. Non è small cap, e non è l'S&P 500 (quello è SPY5, +`IE00B6YX5C33`). Non serve, dato che ZPRR c'è — ma misurato come ripiego, su 6.5 anni: + +| gamba small cap | Sharpe | CAGR | maxDD | +|---|---|---|---| +| R2US (small) | 0.86 | 4.20% | −8.2% | +| SPY4 (mid) | 0.86 | 4.26% | −8.3% | + +Correlazione dei due sleeve **0.991**, SPY4 peggiore in **3/7 anni** = moneta. *(Sulla finestra +corta di 3.2 anni sembrava −0.15 di Sharpe: era rumore.)* + +**Ripiego accettabile**, e il motivo è meccanico: small e mid cap USA hanno correlazione giornaliera +~0.95, e un TSMOM su due serie così vicine si accende quasi negli stessi giorni. ⚠️ Non è +"validato" — 6.5 anni non bastano a distinguere due gambe così simili, ed è **esattamente per +questo** che la scelta fra le due non conta. + +## ⚠️ Correzione a un'indicazione data ieri: sulla valuta avevo ragionato al contrario + +Ieri ho scritto che *"una linea in EUR aggiunge la conversione per ordine"*. È vero **solo per un +conto in USD**. Per un conto in **euro** — cioè un retail italiano su Revolut o Degiro — vale +l'opposto: comprare la linea in USD costringe a convertire a ogni ordine, la linea in EUR no. + +L'esposizione economica è identica nei due casi: il fondo detiene attivi in USD e **nessuna delle +due linee è coperta**. La valuta di quotazione non copre niente — decide solo se serve una +conversione. + +> **Regola giusta: prendere la linea nella valuta del proprio saldo.** + +Quanto vale, misurato (turnover lordo **$13.655/anno** su $10.000, 21 ordini): + +| bps FX per ordine | ΔSharpe | costo/anno | +|---|---|---| +| 15 bps | −0.04 | $20 | +| 25 bps | −0.07 | $34 | +| 50 bps | −0.14 | $68 | + +A 25bps la conversione equivale a ~**$1.6 in più su ciascuno dei 21 ordini**: reale, ma piccolo +rispetto alla soglia di $18.90/ordine a $10.000. Quindi **ZPRR in EUR su un conto in euro è la +scelta migliore**, non un compromesso. + +## Lezione + +**Un costo di conversione non è una proprietà dello strumento ma della coppia strumento-conto.** +Avevo dedotto "linea in EUR = costo in più" da un caso (conto in USD) e l'avevo scritta come regola +generale; per il conto che l'operatore usa davvero il segno è invertito. È la terza volta in due +giorni che un'assunzione non verificata sul conto reale cambia la conclusione — dopo la +negoziabilità PRIIPs e quale conto è operativo. diff --git a/scripts/research/fetch_ib_ucits.py b/scripts/research/fetch_ib_ucits.py index dfb078b..f2185ad 100644 --- a/scripts/research/fetch_ib_ucits.py +++ b/scripts/research/fetch_ib_ucits.py @@ -54,7 +54,10 @@ EXCH, CUR = "LSEETF", "USD" 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) + "IWM": ("XRSU", "R2US", "IDP6", "SPY4"), # Russell 2000: XRSU $438 · R2US $86 (piu' economico E lungo) + # SPY4 = S&P 400 MID cap: indice DIVERSO, tenuto solo + # per misurare il costo di una sostituzione forzata + # (e' cio' che alcuni broker offrono al posto dello small cap) "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 diff --git a/scripts/research/r0727_gtaa_broker.py b/scripts/research/r0727_gtaa_broker.py index af238de..bb99011 100644 --- a/scripts/research/r0727_gtaa_broker.py +++ b/scripts/research/r0727_gtaa_broker.py @@ -46,6 +46,15 @@ ISIN = {"VUAA": ("IE00BFMXXD54", "Vanguard S&P 500 UCITS (Acc, USD)"), "IGLN": ("IE00B4ND3602", "iShares Physical Gold ETC"), "IHYU": ("IE00B4PY7Y77", "iShares $ High Yield Corp Bond UCITS")} +# Stesso fondo, ticker diverso su borse diverse. Verificato su contract details IB: se le ISIN +# coincidono e' la STESSA classe di quote, quindi lo stesso NAV — cambia solo dove si negozia e in +# che valuta. Serve perche' un broker puo' non quotare la linea di Londra e quotare quella di Xetra. +EQUIVALENZE = [("R2US", "ZPRR", "IE00BJ38QD84", "Xetra EUR")] + +# Ripiego misurato: se un broker non ha NESSUNA linea Russell 2000, l'S&P 400 mid cap e' l'unica +# alternativa comune. NON e' lo stesso indice (mid, non small) — vedi sezione (6) per il costo. +RIPIEGO_SMALLCAP = ("SPY4", "IE00B4YBJ215", "SPDR S&P 400 US Mid Cap UCITS") + def ordini_anno(veic, cal, capital, band, every) -> float: n = 0 @@ -238,9 +247,72 @@ def main() -> None: t = U.SET_DEPLOY[s] isin, d = ISIN[t] print(f" {s:7s}{t:8s}{isin:16s}{float(U._norm(dep[s]).iloc[-1]):>9.2f} {d}") - print("\n Tutti su LONDRA in USD. Le stesse ISIN esistono su altre borse e in altre valute:") - print(" una linea in EUR aggiunge la conversione per ordine, che a 25bps costa ~0.15 di") - print(" Sharpe — piu' del costo per ordine di qualunque broker a questa cadenza.") + print("\n ⚠️ SI CERCA PER ISIN, NON PER TICKER — e il motivo non e' la pignoleria: lo STESSO") + print(" fondo ha ticker diversi su borse diverse. Verificato su contract details IB:") + for a, b, isin, nota in EQUIVALENZE: + print(f" {a:6s} (Londra USD) == {b:6s} ({nota:12s}) ISIN {isin}") + print(" Sono la stessa classe di quote: NAV identico, cambia solo dove e in che valuta si") + print(" negozia. Cercando per ISIN si trova qualunque linea il proprio broker offra.") + print("\n ⚠️ CORREZIONE (2026-07-27) A UN'INDICAZIONE DATA IERI. Avevo scritto che 'una linea in") + print(" EUR aggiunge la conversione per ordine'. E' vero solo per un conto in USD. Per un conto") + print(" in EURO — che e' il caso di un retail italiano su Revolut/Degiro — vale l'OPPOSTO:") + print(" comprare la linea in USD costringe a convertire a ogni ordine, la linea in EUR no.") + print(" L'esposizione economica e' identica in entrambi i casi (il fondo detiene attivi in USD") + print(" e nessuna delle due linee e' coperta): la valuta di quotazione non copre nulla, decide") + print(" solo se serve una conversione. **La regola giusta e': prendere la linea nella valuta") + print(" del proprio saldo.**") + turnover = 0.0 + for k in dep: + _, pos = U.leg_returns(dep[k], dep[k], cal, CAP / 6.0, band_c, can_every, + U.cost_form(fixed=1.0)) + turnover += float(pos.diff().abs().sum()) * CAP / 6.0 + turnover /= (len(cal) / 252.0) + print(f"\n Quanto vale la conversione, misurato: turnover lordo ${turnover:,.0f}/anno su " + f"${CAP:,.0f}") + print(f" {'bps FX/ordine':>15s}{'Sharpe':>9s}{'dSharpe':>10s}{'costo/anno':>13s}") + b0 = None + for p in (0.0, 15.0, 25.0, 50.0): + m = U.met(U.sleeve(dep, dep, cal, CAP, band_c, can_every, U.cost_form(prop_bps=p))) + b0 = m["sharpe"] if b0 is None else b0 + print(f" {format(p,'.0f')+' bps':>15s}{m['sharpe']:>9.2f}{m['sharpe']-b0:>+10.2f}" + f"{'$'+format(turnover*p/1e4,',.0f'):>13s}") + print(" Cioe' a 25bps la conversione equivale a ~$1.6 in piu' su ciascuno dei 21 ordini:") + print(" reale, ma piccolo rispetto alla soglia (${:.2f}/ordine a ${:,.0f}).".format( + float(soglia_per_ordine(dep, cal, CAP, band_c, can_every)[1:]), int(CAP))) + + # ---------- (6) ripiego sulla gamba small cap ---------- + print("\n" + "=" * 100) + print(" (6) SE IL BROKER NON HA NESSUNA LINEA RUSSELL 2000") + print("=" * 100) + tic, isin_r, desc_r = RIPIEGO_SMALLCAP + print(f" Ripiego comune: {tic} {isin_r} — {desc_r}. NON e' lo stesso indice (MID cap, non") + print(" small): va misurato, non assunto equivalente.") + alt = dict(dep) + alt["IWM"], _ = U.close_uc(tic, us["IWM"]) + # finestra lunga: veicoli STORIA sulle altre 5 gambe, cosi' il campione non e' limitato da XNAS + st_set = {s: U.close_uc(u, us[s])[0] for s, u in U.SET_STORIA.items()} + small = dict(st_set) + small["IWM"], _ = U.close_uc("R2US", us["IWM"]) + mid = dict(st_set) + mid["IWM"] = alt["IWM"] + cl = U.common_cal(list(small.values()) + [mid["IWM"]]) + st = max(U._norm(x).index[0] for x in list(small.values()) + [mid["IWM"]]) + cl = cl[cl >= st + np.timedelta64(380, "D")] + print(f"\n finestra {cl[0].date()} -> {cl[-1].date()} ({len(cl)/252:.1f} anni)") + s1 = U.sleeve(small, small, cl, CAP, BAND_FRAC * CAP / 6.0, can_every, U.cost_form(fixed=1.0)) + s2 = U.sleeve(mid, mid, cl, CAP, BAND_FRAC * CAP / 6.0, can_every, U.cost_form(fixed=1.0)) + for nome, s in [("R2US small cap", s1), ("SPY4 mid cap ", s2)]: + m = U.met(s) + print(f" {nome} Sharpe {m['sharpe']:.2f} CAGR {m['cagr']:.2%} maxDD {m['dd']:.1%}") + peggio = sum(1 for y, g in s2.groupby(s2.index.year) + if len(g) > 100 and U.met(g)["sharpe"] < U.met(s1[s1.index.year == y])["sharpe"]) + tot = sum(1 for y, g in s2.groupby(s2.index.year) if len(g) > 100) + print(f" correlazione dei due sleeve {s1.corr(s2):.4f} · SPY4 peggiore in {peggio}/{tot} anni") + print("\n -> RIPIEGO ACCETTABILE, e il motivo e' meccanico: small e mid cap USA hanno") + print(" correlazione giornaliera ~0.95, e un segnale TSMOM su due serie cosi' vicine si") + print(" accende quasi negli stessi giorni. ⚠️ NON e' 'validato': 6.5 anni non bastano a") + print(" distinguere due gambe cosi' simili, ed e' esattamente per questo che la scelta fra") + print(" le due non e' importante. Se c'e' una linea Russell 2000, si usa quella.") print("\n" + "=" * 100) diff --git a/tests/test_gtaa_broker.py b/tests/test_gtaa_broker.py index 29f71e2..1f5890d 100644 --- a/tests/test_gtaa_broker.py +++ b/tests/test_gtaa_broker.py @@ -118,3 +118,42 @@ def test_le_isin_sono_ben_formate_e_coprono_le_sei_gambe(): def test_le_soglie_di_lettura_sono_dichiarate(): assert B.BAND_FRAC == 0.25 assert B.ESPOSTA_SANA == 0.60 + + +# =========================================================================== +# equivalenze fra listini — la stessa ISIN su borse diverse e' lo STESSO fondo +# =========================================================================== +def test_le_equivalenze_dichiarate_condividono_la_isin_del_veicolo_scelto(): + """ZPRR (Xetra EUR) e R2US (Londra USD) sono la stessa classe di quote: se le ISIN non + coincidono l'equivalenza e' falsa e l'operatore comprerebbe un altro fondo.""" + for a, b, isin, _ in B.EQUIVALENZE: + assert a in B.ISIN, f"{a} non e' un veicolo del set DEPLOY" + assert B.ISIN[a][0] == isin, f"{a}: la ISIN dell'equivalenza non e' quella del veicolo" + assert re.fullmatch(r"IE00[A-Z0-9]{8}", isin) + assert b not in B.ISIN, f"{b} e' un ALIAS di {a}, non un veicolo distinto" + + +def test_il_ripiego_smallcap_e_dichiarato_come_indice_DIVERSO(): + """SPY4 e' S&P 400 MID cap: e' un ripiego misurato, non un equivalente. Il test esiste + perche' la differenza fra 'equivalente' e 'ripiego accettabile' e' proprio cio' che si + perde quando si copiano ticker da una lista.""" + tic, isin, desc = B.RIPIEGO_SMALLCAP + assert re.fullmatch(r"IE00[A-Z0-9]{8}", isin) + assert isin != B.ISIN["R2US"][0], "un ripiego con la stessa ISIN sarebbe un'equivalenza" + assert "Mid" in desc + + +def test_il_ripiego_smallcap_e_indistinguibile_dalla_gamba_scelta(ctx): + """Il finding della sezione (6): sostituire mid a small lascia lo sleeve al 99% uguale. + Se un giorno smettesse di essere vero, la raccomandazione 'usa SPY4 se non trovi R2US' + andrebbe ritirata.""" + dep, cal = ctx + us = {s: U.close_us(s) for s in U.US} + mid = dict(dep) + mid["IWM"], _ = U.close_uc(B.RIPIEGO_SMALLCAP[0], us["IWM"]) + cl = U.common_cal(list(dep.values()) + [mid["IWM"]]) + cl = cl[cl >= cal[0]] + band = B.BAND_FRAC * 10_000.0 / 6.0 + a = U.sleeve(dep, dep, cl, 10_000.0, band, 5, U.cost_form(fixed=1.0)) + b = U.sleeve(mid, mid, cl, 10_000.0, band, 5, U.cost_form(fixed=1.0)) + assert a.corr(b) > 0.95, f"il ripiego non e' piu' equivalente: corr {a.corr(b):.3f}"