Files
Adriano Dal Pastro 378f448450 research(gtaa): ZPRR e' R2US (stessa ISIN) + correzione sul verso del costo FX
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) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XBbYmiXqbUNuGpK9sfbsGp
2026-07-27 11:25:20 +00:00

147 lines
7.0 KiB
Python

"""FETCH + CERTIFY equivalenti UCITS dei 6 ETF di GTAA01 -> data/raw/eqx_<sym>_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", "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
}
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())