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:
Adriano Dal Pastro
2026-07-26 23:01:54 +00:00
parent 6664575e1c
commit be4690375f
8 changed files with 1481 additions and 16 deletions
+134
View File
@@ -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