Files
PythagorasGoal/tests/test_eq_crosscheck.py
Adriano Dal Pastro be4690375f 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
2026-07-26 23:01:54 +00:00

207 lines
9.1 KiB
Python

"""Test del cross-check equity contro il gemello (src/data/eq_crosscheck.py).
Il rilevatore nasce da un difetto VERO trovato il 2026-07-26: CSPX 2012-01-13 con open/high in
USD e low/close in EUR. La certificazione esistente non lo vedeva perche' la sua unica guardia
sui movimenti era `maxret > 50% -> SPIKE?` e una contaminazione EUR/USD vale ~22-28%.
I test che contano davvero sono i CONTROLLI su dati reali:
* il difetto noto viene segnalato (senza questo il rilevatore potrebbe essere semplicemente rotto);
* gli eventi di mercato VERI non vengono segnalati — IDTL marzo 2020 (liquidazione dei treasury)
e IGLN aprile 2013 (crollo dell'oro) sono andate-e-ritorni identici nella FORMA a una stampa
sbagliata, e una scansione fatta sui prezzi invece che sul rapporto col gemello li segnalava.
E il test `test_limite_dichiarato_*` congela il LIMITE noto: se qualcuno abbassa la soglia, il
test dice esattamente cosa e' cambiato invece di lasciar credere che il rilevatore sia completo.
"""
from __future__ import annotations
import sys
from pathlib import Path
import numpy as np
import pandas as pd
import pytest
ROOT = Path(__file__).resolve().parents[1]
sys.path.insert(0, str(ROOT))
from src.data.eq_crosscheck import ( # noqa: E402
MIN_DEV, MIN_UNEXPLAINED, REVERT_BARS, TWIN_MOVE_FLOOR,
detect_twin_outliers, repair_twin_outliers,
)
RAW = ROOT / "data" / "raw"
def _load(prefix: str, sym: str) -> pd.DataFrame:
p = RAW / f"{prefix}_{sym.lower()}_1d.parquet"
if not p.exists():
pytest.skip(f"{p.name} assente (data/raw e' gitignored)")
d = pd.read_parquet(p)
d.index = pd.to_datetime(d["timestamp"], unit="ms", utc=True)
return d.sort_index()
def _close(prefix: str, sym: str) -> pd.Series:
d = _load(prefix, sym)
return pd.Series(d["close"].astype(float).values, index=d.index)
def _serie(vals, start="2020-01-01"):
return pd.Series(np.asarray(vals, float),
index=pd.bdate_range(start, periods=len(vals)))
# ===========================================================================
# controlli su DATI REALI — un rilevatore che non segnala nulla puo' essere rotto
# ===========================================================================
def test_controllo_positivo_il_difetto_noto_di_cspx_viene_segnalato():
"""IL test. Se questo fallisce, il rilevatore non serve a niente."""
bad = detect_twin_outliers(_close("eqx", "CSPX"), _close("eq", "SPY"))
date = {pd.Timestamp(b["date"]).date().isoformat() for b in bad}
assert "2012-01-13" in date, f"difetto noto non rilevato (segnalate: {sorted(date)})"
def test_il_difetto_ha_la_firma_valutaria_attesa():
bad = [b for b in detect_twin_outliers(_close("eqx", "CSPX"), _close("eq", "SPY"))
if pd.Timestamp(b["date"]).date().isoformat() == "2012-01-13"]
assert len(bad) == 1
b = bad[0]
assert b["dev"] < -0.15, "la deviazione non ha la scala di una contaminazione valutaria"
assert abs(b["twin_move"]) < 0.01, "il gemello si era mosso: allora non e' inspiegato"
assert b["unexplained"] > 3 * MIN_UNEXPLAINED / 2
def test_controllo_negativo_gli_eventi_di_mercato_veri_non_sono_segnalati():
"""Marzo 2020 sui treasury e aprile 2013 sull'oro sono andate-e-ritorni VERI: nella forma
identici a una stampa sbagliata. Li distingue solo il fatto che li fa anche il gemello."""
for veic, gem, quando in [("IDTL", "TLT", "2020-03"), ("IGLN", "GLD", "2013-04")]:
bad = detect_twin_outliers(_close("eqx", veic), _close("eq", gem))
colpiti = [pd.Timestamp(b["date"]).strftime("%Y-%m") for b in bad]
assert quando not in colpiti, f"{veic}: evento di mercato vero scambiato per difetto"
def test_nessun_falso_positivo_sui_veicoli_puliti():
for veic, gem in [("EQQQ", "QQQ"), ("XRSU", "IWM"), ("R2US", "IWM"),
("IDTL", "TLT"), ("IGLN", "GLD"), ("IHYU", "HYG")]:
bad = detect_twin_outliers(_close("eqx", veic), _close("eq", gem))
assert bad == [], f"{veic}/{gem}: falsi positivi {[str(b['date']) for b in bad]}"
# ===========================================================================
# le tre condizioni, una alla volta
# ===========================================================================
def test_una_deviazione_che_il_gemello_SPIEGA_non_e_segnalata():
"""La condizione decisiva: un disallineamento d'orario non puo' superare il movimento del
mercato. Se il gemello si e' mosso quanto il veicolo, non c'e' niente da segnalare."""
n = 80
twin = _serie(np.full(n, 100.0))
twin.iloc[40] = 80.0 # il gemello CROLLA del 20%...
twin.iloc[41] = 100.0
veh = twin * 1.0 # ...e il veicolo fa lo stesso
veh.iloc[40] = 78.0 # con un piccolo scarto d'orario
assert detect_twin_outliers(veh, twin) == []
def test_una_divergenza_che_PERSISTE_non_e_una_stampa():
"""Un cambio di livello permanente (fusione, cambio di indice, ridenominazione) va tenuto:
scartarlo cancellerebbe un fatto invece di un errore."""
n = 80
twin = _serie(np.full(n, 100.0))
veh = _serie(np.concatenate([np.full(40, 100.0), np.full(n - 40, 78.0)]))
assert detect_twin_outliers(veh, twin) == []
def test_una_deviazione_troppo_piccola_non_e_segnalata():
n = 80
twin = _serie(np.full(n, 100.0))
veh = twin.copy()
veh.iloc[40] = 100.0 * (1 - MIN_DEV / 2)
assert detect_twin_outliers(veh, twin) == []
# ===========================================================================
# iniezione: cosa si cattura e cosa NO (il limite dichiarato, congelato in un test)
# ===========================================================================
def _inietta(fx: float):
n = 200
rng = np.random.default_rng(7)
base = 100 * np.exp(np.cumsum(rng.normal(0, 0.01, n)))
twin = _serie(base)
veh = _serie(base * 1.0)
veh.iloc[120] = veh.iloc[120] / fx
return veh, twin
def test_contaminazione_a_cambio_alto_gbp_viene_catturata():
veh, twin = _inietta(1.27)
bad = detect_twin_outliers(veh, twin)
assert len(bad) == 1 and pd.Timestamp(bad[0]["date"]) == veh.index[120]
def test_limite_dichiarato_contaminazione_a_cambio_basso_eur_sfugge_a_mercato_mosso():
"""⚠️ Questo test NON descrive un comportamento desiderato: congela un LIMITE noto.
EURUSD ~1.09 produce ~8% di deviazione, che sta dentro il rumore legittimo dei giorni
violenti (misurato 9.9% il 2025-04-09). Abbassare la soglia per catturarlo genererebbe
falsi positivi sui giorni veri. Il punto e' chiuso altrove misurando il DANNO
(r0726_gtaa_ucits.py sez. 5: dSharpe mediano -0.003, peggiore -0.06).
Se qualcuno cambia le soglie, questo test dice esattamente cosa e' cambiato.
"""
n = 200
rng = np.random.default_rng(11)
base = 100 * np.exp(np.cumsum(rng.normal(0, 0.01, n)))
twin = _serie(base)
veh = _serie(base.copy())
veh.iloc[120] /= 1.09
twin.iloc[120] *= 1.02 # il gemello quel giorno si muove: la deviazione diventa 'spiegabile'
assert detect_twin_outliers(veh, twin) == [], \
"il limite dichiarato non vale piu': aggiornare la docstring del modulo e il diario"
# ===========================================================================
# riparazione
# ===========================================================================
def test_la_riparazione_scarta_la_barra_e_non_ne_inventa_una():
d = _load("eqx", "CSPX")
pulito, bad = repair_twin_outliers(d, _close("eq", "SPY"))
assert len(bad) == 1
assert len(pulito) == len(d) - 1
rimasta = pd.to_datetime(pulito.index).tz_localize(None).normalize()
assert pd.Timestamp("2012-01-13") not in set(rimasta)
# nessun prezzo ricostruito: i close superstiti sono identici agli originali
orig = d["close"]
assert (pulito["close"].values == orig.loc[pulito.index].values).all()
def test_la_riparazione_rende_sano_il_rendimento_a_cavallo():
d = _load("eqx", "CSPX")
pulito, _ = repair_twin_outliers(d, _close("eq", "SPY"))
r = pulito["close"].pct_change()
w = r[(r.index >= "2012-01-10") & (r.index <= "2012-01-20")]
assert w.abs().max() < 0.05, f"resta un movimento assurdo: {w.abs().max():.1%}"
def test_riparazione_idempotente():
d = _load("eqx", "CSPX")
p1, b1 = repair_twin_outliers(d, _close("eq", "SPY"))
p2, b2 = repair_twin_outliers(p1, _close("eq", "SPY"))
assert b2 == [] and len(p2) == len(p1)
def test_serie_vuota_o_cortissima_non_esplode():
vuoto = pd.DataFrame({"close": []}, index=pd.DatetimeIndex([]))
assert repair_twin_outliers(vuoto, _serie([1.0, 2.0])) == (vuoto, [])
assert detect_twin_outliers(_serie([1.0] * 5), _serie([1.0] * 5)) == []
# ===========================================================================
# la taratura e' un valore, non un dettaglio
# ===========================================================================
def test_le_soglie_sono_congelate():
"""Tarate su misura: rumore massimo 8.1, difetto 42.7 -> soglia 18.0 equidistante in scala
logaritmica. Cambiarle senza rifare la taratura invalida ogni conclusione del 26/07."""
assert MIN_UNEXPLAINED == 18.0
assert MIN_DEV == 0.05
assert TWIN_MOVE_FLOOR == 0.005
assert REVERT_BARS == 5