research(gtaa): la via UCITS e' aperta e costa ~zero — il broker non e' la variabile
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) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01XBbYmiXqbUNuGpK9sfbsGp
This commit is contained in:
@@ -0,0 +1,143 @@
|
||||
"""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"), # 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())
|
||||
@@ -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()
|
||||
Reference in New Issue
Block a user