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,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
|
||||
+30
-9
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user