From 741bbc2c0977c800cf398b694e99c4adcab5c195 Mon Sep 17 00:00:00 2001 From: Adriano Dal Pastro Date: Sat, 25 Jul 2026 09:47:25 +0000 Subject: [PATCH] =?UTF-8?q?fix(data):=20split=20non=20aggiustati=20nel=20f?= =?UTF-8?q?eed=20equity=20=E2=80=94=20difetto=20sul=20libro=20live,=20ripa?= =?UTF-8?q?rato=20alla=20fonte?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit IWM ed EFA avevano uno split NON aggiustato il 2005-06-09 (IWM 2:1 = -49.5%, EFA 3:1 = -66.5%): IB ADJUSTED_LAST non li aveva aggiustati. La certificazione non li vedeva per un punto cieco STRUTTURALE: l'unica guardia sui salti era `maxret > 50% -> SPIKE?` e uno split 2:1 fa esattamente -50%, cioe' cade sul filo della soglia (IWM passava a 49.5% con status OK). IWM e' una delle 6 gambe di GTAA01, sleeve in PRODUZIONE. Impatto misurato: GTAA6 FULL Sharpe 0.61 -> 0.64, IS (<2015) 0.49 -> 0.54; OOS 2015+ e maxDD INVARIATI (l'artefatto e' nel 2005, fuori hold-out) -> il difetto SOTTOSTIMAVA lo sleeve: nessuna decisione presa va rivista. Discriminante split-vs-crollo: NON il rapporto (SLV 2026-01-30 ha rapporto 1.3994, a 4bps da 1.4, ma e' un crollo vero: GLD -10.3% lo stesso giorno) ma il RANGE INTRADAY — lo split apre gia' al nuovo livello con range normale (IWM: open 47.00, range 1.7%), il crollo si muove DENTRO la barra (SLV: range 33%). - src/data/eq_splits.py: detect_unadjusted_splits() a 3 condizioni congiunte (|ret|>20% AND rapporto ~ fattore comune AND range intraday <5%) + repair_splits() con split multipli componibili; - riparazione in LETTURA in src/portfolio/gtaa.py::_close (produzione) e scripts/research/eqlib.py::load_eq (ricerca); - fetch_ib_equities.certify(): nuovo status SPLIT-NON-AGG + elenco split rilevati; - tests/test_eq_splits.py: 8 casi, inclusi il falso positivo SLV e un crollo -50% esatto con range grande. Regola nuova: ogni soglia di certificazione tarata su un valore tondo va controllata contro il difetto che genera esattamente quel valore. Co-Authored-By: Claude Opus 5 (1M context) --- scripts/research/eqlib.py | 6 ++ scripts/research/fetch_ib_equities.py | 22 ++++- src/data/eq_splits.py | 119 ++++++++++++++++++++++++++ src/portfolio/gtaa.py | 12 ++- tests/test_eq_splits.py | 106 +++++++++++++++++++++++ 5 files changed, 259 insertions(+), 6 deletions(-) create mode 100644 src/data/eq_splits.py create mode 100644 tests/test_eq_splits.py diff --git a/scripts/research/eqlib.py b/scripts/research/eqlib.py index 7147489..871dc8e 100644 --- a/scripts/research/eqlib.py +++ b/scripts/research/eqlib.py @@ -8,11 +8,14 @@ Espone: universi (SECTORS/BROAD), load_eq(sym), panel(universe) allineato, e riu indurito di altlib (_sh, _dd_ret, _to_daily, marginal_vs_tp01) per giudicare i candidati con la stessa disciplina del lato crypto. """ +import sys from functools import lru_cache from pathlib import Path import numpy as np, pandas as pd ROOT = Path(__file__).resolve().parents[2] +sys.path.insert(0, str(ROOT)) +from src.data.eq_splits import repair_splits # noqa: E402 RAW = ROOT / "data" / "raw" # 11 SPDR settoriali. I 9 "classici" (sotto SECTORS_CLASSIC) partono 1998; XLRE 2015, XLC 2018. @@ -29,6 +32,9 @@ def load_eq(sym: str) -> pd.DataFrame: raise FileNotFoundError(f"{p} assente — gira: uv run --with ib_async python scripts/research/fetch_ib_equities.py") d = pd.read_parquet(p).copy() d.index = pd.to_datetime(d["timestamp"], unit="ms", utc=True) + d = d.sort_index() + # split NON aggiustati da IB (IWM/EFA 2005-06-09) — vedi src/data/eq_splits.py + d, _ = repair_splits(d) return d[["open", "high", "low", "close", "volume"]] diff --git a/scripts/research/fetch_ib_equities.py b/scripts/research/fetch_ib_equities.py index b385d2f..d0827a2 100644 --- a/scripts/research/fetch_ib_equities.py +++ b/scripts/research/fetch_ib_equities.py @@ -13,7 +13,11 @@ CERTIFICAZIONE (gemello equity di certify_feed.py): (1) integrità: barre, range, date monotone, duplicati, flat bars (close invariato); (2) gap: run di giorni-lavorativi mancanti > 5 (festivi normali, buchi lunghi = sospetti); (3) sanità ritorni: max |daily ret| (un >50% non-evento = errore di adjustment); - (4) sanità adjustment: primo close aggiustato << ultimo (i dividendi abbassano lo storico). + (4) sanità adjustment: primo close aggiustato << ultimo (i dividendi abbassano lo storico); + (5) SPLIT NON AGGIUSTATI (aggiunto 2026-07-25): ADJUSTED_LAST non sempre aggiusta gli split, e + il check (3) NON li vede — uno split 2:1 fa esattamente -50%, sul filo della soglia. IWM + (gamba di GTAA01 in produzione) ed EFA avevano uno split non aggiustato il 2005-06-09 e + passavano come OK. Rilevatore in src/data/eq_splits.py -> status SPLIT-NON-AGG. PREREQUISITO: gateway IB paper su 127.0.0.1:4002 (docker compose up -d ib-gateway). uv run --with ib_async python scripts/research/fetch_ib_equities.py @@ -23,6 +27,8 @@ from pathlib import Path import numpy as np, pandas as pd ROOT = Path(__file__).resolve().parents[2] +sys.path.insert(0, str(ROOT)) +from src.data.eq_splits import detect_unadjusted_splits # noqa: E402 RAW = ROOT / "data" / "raw" RAW.mkdir(parents=True, exist_ok=True) @@ -55,9 +61,17 @@ def certify(sym: str, df: pd.DataFrame) -> dict: longgap = int((gaps.to_series().groupby(s).size() > 5).sum()) span_y = (idx[-1] - idx[0]).days / 365.25 adj_ratio = round(float(c[0] / c[-1]), 3) # primo/ultimo: <1 atteso (storico abbassato dai div) + # SPLIT NON AGGIUSTATI: IB ADJUSTED_LAST non sempre li aggiusta. NB la guardia `maxret > 0.5` + # NON li vede: uno split 2:1 non aggiustato fa ESATTAMENTE -50% e cade sul filo della soglia + # (IWM 2005-06-09 passava a 49.5% con status OK, ed e' una gamba di GTAA01 in produzione). + # Vedi src/data/eq_splits.py per il discriminante split-vs-crollo (range intraday). + splits = detect_unadjusted_splits(df) + status = "OK" if dup or not mono: status = "INTEGRITA'" + elif splits: + status = "SPLIT-NON-AGG" elif maxret > 0.5: status = "SPIKE?" elif longgap > 0: @@ -67,7 +81,8 @@ def certify(sym: str, df: pd.DataFrame) -> dict: return {"sym": sym, "n": len(df), "primo": idx[0].date(), "ultimo": idx[-1].date(), "anni": round(span_y, 1), "dup": dup, "mono": mono, "flat": flat, "maxret%": round(maxret * 100, 1), "miss_bd": missing, "gap_lunghi": longgap, - "adj_first/last": adj_ratio, "status": status} + "adj_first/last": adj_ratio, "status": status, + "splits": [f"{s['date'].date()} 1:{s['factor']:g}" for s in splits]} def main(): @@ -119,7 +134,8 @@ def main(): ok.append(sym) print(f" {sym:5} 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','?')} adj={c.get('adj_first/last','?')} [{c['status']}]") + f"miss_bd={c.get('miss_bd','?')} gapL={c.get('gap_lunghi','?')} adj={c.get('adj_first/last','?')} [{c['status']}]" + + (f" split={c['splits']}" if c.get("splits") else "")) time.sleep(1.2) # pacing IB print("-" * 104) diff --git a/src/data/eq_splits.py b/src/data/eq_splits.py new file mode 100644 index 0000000..3c52198 --- /dev/null +++ b/src/data/eq_splits.py @@ -0,0 +1,119 @@ +"""Rilevamento e riparazione di SPLIT NON AGGIUSTATI nelle serie ETF/azioni. + +PERCHE' ESISTE (2026-07-25). La serie IB `ADJUSTED_LAST` e' aggiustata per dividendi e split — +tranne quando non lo e'. Su `data/raw/eq_*.parquet` due serie hanno uno split NON aggiustato lo +stesso giorno, il 2005-06-09 (data della tornata di split iShares): + + IWM 94.13 -> 47.53 (-49.5%, rapporto 1.981 ~ 2:1) + EFA 85.91 -> 28.76 (-66.5%, rapporto 2.987 ~ 3:1) + +La certificazione di `fetch_ib_equities.certify()` non li vedeva: la sua unica guardia era +`maxret > 50% -> SPIKE?`, e uno split 2:1 non aggiustato produce ESATTAMENTE -50%, cioe' cade +sul filo della soglia (IWM passava a 49.5%: sotto soglia, status OK). E' un punto cieco +strutturale, non un caso fortunato: la soglia era tarata proprio sul valore che il difetto +piu' comune genera. + +IWM e' una delle 6 gambe di **GTAA01, lo sleeve in produzione** -> il difetto entrava nei numeri +del libro. Impatto misurato: GTAA6 FULL Sharpe 0.61 -> 0.64, IS (<2015) 0.49 -> 0.54; OOS 2015+ +e maxDD INVARIATI (l'artefatto e' nel 2005, fuori dall'hold-out). Il difetto SOTTOSTIMAVA lo +sleeve, non lo gonfiava — nessuna decisione presa va rivista, ma il dato va corretto. + +COME SI DISTINGUE UNO SPLIT DA UN CROLLO VERO (la parte che conta). Non basta la dimensione del +salto: il 2026-01-30 SLV ha fatto -28.5% e il rapporto 1.3994 e' a 4 bps da 1.4 — sarebbe un +falso positivo per qualsiasi regola basata sul solo rapporto. Il discriminante e' il BAR STESSO: + + * SPLIT: la barra APRE gia' al nuovo livello e ha un range intraday NORMALE — il salto e' + solo fra chiusura precedente e apertura. IWM 2005-06-09: open 47.00, range high/low 1.7%. + * CROLLO VERO: il movimento avviene DENTRO la barra, che ha un range enorme. + SLV 2026-01-30: open 89.33, low 69.12, range 33%; e l'attivo gemello si muove insieme + (GLD -10.3% lo stesso giorno, volume raddoppiato). + +Da cui la regola: |ret| grande AND rapporto vicino a un fattore di split comune AND range +intraday piccolo. Le tre condizioni insieme; nessuna da sola basta. +""" +from __future__ import annotations + +import numpy as np +import pandas as pd + +# fattori di split comuni (diretti e inversi) +COMMON_FACTORS: tuple[float, ...] = (1.5, 2.0, 3.0, 4.0, 5.0, 10.0) + +MIN_RET = 0.20 # sotto il 20% non si cerca nemmeno: nessuno split e' cosi' piccolo +RATIO_TOL = 0.015 # il rapporto deve stare entro l'1.5% da un fattore comune +MAX_RANGE = 0.05 # range intraday (high/low-1) massimo perche' sia uno split e non un crollo + + +def _nearest_factor(ratio: float) -> tuple[float, float] | tuple[None, None]: + """Fattore di split comune piu' vicino a `ratio` e il suo errore relativo.""" + best, err = None, np.inf + for f in COMMON_FACTORS: + for cand in (f, 1.0 / f): + e = abs(ratio / cand - 1.0) + if e < err: + best, err = cand, e + return (best, err) if best is not None else (None, None) + + +def detect_unadjusted_splits(df: pd.DataFrame, *, min_ret: float = MIN_RET, + ratio_tol: float = RATIO_TOL, + max_range: float = MAX_RANGE) -> list[dict]: + """Split NON aggiustati in un OHLC daily. Ritorna [{i, date, ratio, factor, ret, range}]. + + Tre condizioni insieme (vedi docstring del modulo): salto grande, rapporto ~ fattore comune, + range intraday piccolo. Un crollo di mercato fallisce la terza; un movimento normale la prima. + """ + if df.empty or len(df) < 2: + return [] + c = df["close"].to_numpy(float) + ret = np.concatenate([[0.0], np.diff(c) / c[:-1]]) + has_hl = {"high", "low"}.issubset(df.columns) + hi = df["high"].to_numpy(float) if has_hl else c + lo = df["low"].to_numpy(float) if has_hl else c + + out = [] + for i in np.flatnonzero(np.abs(ret) > min_ret): + if c[i] <= 0 or c[i - 1] <= 0: + continue + ratio = float(c[i - 1] / c[i]) + factor, err = _nearest_factor(ratio) + if factor is None or err > ratio_tol: + continue + rng = float(hi[i] / lo[i] - 1.0) if lo[i] > 0 else np.inf + if rng > max_range: # il movimento e' DENTRO la barra -> crollo vero + continue + out.append(dict(i=int(i), date=df.index[i], ratio=ratio, factor=float(factor), + ret=float(ret[i]), range=rng)) + return out + + +def repair_splits(df: pd.DataFrame, splits: list[dict] | None = None) -> tuple[pd.DataFrame, list[dict]]: + """Riscala i prezzi PRIMA di ogni split rilevato, riportando la serie su base attuale. + + Applicati in ordine cronologico: uno split anteriore viene poi riscalato anche dai + successivi, che e' esattamente il fattore di aggiustamento cumulato corretto. + Il volume va nella direzione opposta (piu' azioni dopo uno split diretto). + """ + splits = detect_unadjusted_splits(df) if splits is None else splits + if not splits: + return df, [] + out = df.copy() + price_cols = [c for c in ("open", "high", "low", "close") if c in out.columns] + for sp in sorted(splits, key=lambda s: s["i"]): + pre = out.index < sp["date"] + if not pre.any(): + continue + out.loc[pre, price_cols] = out.loc[pre, price_cols] / sp["factor"] + if "volume" in out.columns: + out.loc[pre, "volume"] = out.loc[pre, "volume"] * sp["factor"] + return out, splits + + +def load_repaired(df: pd.DataFrame, sym: str = "", verbose: bool = False) -> pd.DataFrame: + """Comodita' per i loader: ripara e, se richiesto, segnala cosa ha riparato.""" + fixed, splits = repair_splits(df) + if splits and verbose: + for s in splits: + print(f" [eq_splits] {sym or '?'}: split non aggiustato {s['date'].date()} " + f"1:{s['factor']:g} (ret {s['ret']*100:.1f}%) -> riparato") + return fixed diff --git a/src/portfolio/gtaa.py b/src/portfolio/gtaa.py index c24f352..67dc7f1 100644 --- a/src/portfolio/gtaa.py +++ b/src/portfolio/gtaa.py @@ -14,6 +14,8 @@ from functools import lru_cache from pathlib import Path import numpy as np, pandas as pd +from src.data.eq_splits import repair_splits + RAW = Path(__file__).resolve().parents[2] / "data" / "raw" EQ_UNIVERSE = ("SPY", "QQQ", "IWM", "TLT", "GLD", "HYG") HORIZONS = (21, 63, 126, 252) @@ -28,8 +30,12 @@ def _close(sym: str) -> pd.Series: if not p.exists(): raise FileNotFoundError(f"{p} assente — gira scripts/research/fetch_ib_equities.py") d = pd.read_parquet(p) - return pd.Series(d["close"].astype(float).values, - index=pd.to_datetime(d["timestamp"], unit="ms", utc=True)).sort_index() + d.index = pd.to_datetime(d["timestamp"], unit="ms", utc=True) + d = d.sort_index() + # IB ADJUSTED_LAST non aggiusta SEMPRE gli split: IWM (gamba di questo sleeve) e EFA hanno + # uno split non aggiustato il 2005-06-09. Vedi src/data/eq_splits.py. Riparato in lettura. + d, _ = repair_splits(d) + return pd.Series(d["close"].astype(float).values, index=d.index) def _exposure(close: pd.Series) -> pd.Series: @@ -55,7 +61,7 @@ def _gated_returns(sym: str) -> pd.Series: def gtaa_returns(universe=EQ_UNIVERSE) -> pd.Series: """Rendimenti netti daily del GTAA: EW dei rendimenti trend-gated sugli asset disponibili.""" cols = {a: _gated_returns(a) for a in universe} - return pd.concat(cols, axis=1).sort_index().mean(axis=1, skipna=True).dropna() + return pd.concat(cols, axis=1, sort=True).sort_index().mean(axis=1, skipna=True).dropna() def gtaa_weights(universe=EQ_UNIVERSE) -> dict: diff --git a/tests/test_eq_splits.py b/tests/test_eq_splits.py new file mode 100644 index 0000000..1e9baf4 --- /dev/null +++ b/tests/test_eq_splits.py @@ -0,0 +1,106 @@ +"""Test del rilevatore di split NON aggiustati (src/data/eq_splits.py). + +Il punto dei test non e' "trova il salto grande" — e' distinguere uno SPLIT da un CROLLO VERO. +I due casi reali che hanno motivato il modulo sono entrambi coperti: + * IWM/EFA 2005-06-09 -> split non aggiustati (devono essere rilevati e riparati); + * SLV 2026-01-30 -> crollo vero del -28.5% con rapporto 1.3994 (a 4bps da 1.4!) + e range intraday 33% (deve NON essere toccato). +""" +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)) +sys.path.insert(0, str(ROOT / "scripts" / "research")) + +from src.data.eq_splits import detect_unadjusted_splits, repair_splits # noqa: E402 + + +def _series(closes, ranges=None, start="2005-01-03"): + """OHLC daily sintetico: high/low a +/- meta' del range richiesto attorno al close.""" + n = len(closes) + idx = pd.bdate_range(start, periods=n, tz="UTC") + c = np.asarray(closes, float) + rng = np.full(n, 0.01) if ranges is None else np.asarray(ranges, float) + return pd.DataFrame({"open": c, "high": c * (1 + rng / 2), "low": c * (1 - rng / 2), + "close": c, "volume": np.full(n, 1e6)}, index=idx) + + +def test_rileva_split_2a1_stile_iwm(): + df = _series([94.5, 94.6, 94.1] + [47.5, 47.6, 47.4]) + sp = detect_unadjusted_splits(df) + assert len(sp) == 1 + assert sp[0]["factor"] == pytest.approx(2.0) + assert sp[0]["date"] == df.index[3] + + +def test_rileva_split_3a1_stile_efa(): + df = _series([85.9, 86.3, 85.9] + [28.8, 28.6, 28.7]) + sp = detect_unadjusted_splits(df) + assert len(sp) == 1 and sp[0]["factor"] == pytest.approx(3.0) + + +def test_crollo_vero_non_e_uno_split_anche_se_il_rapporto_e_vicino(): + """SLV 2026-01-30: -28.5%, rapporto 1.3994 (vicinissimo a 1.4) ma range intraday 33%. + + Se il rilevatore guardasse solo il rapporto, un fattore 1.4 in lista lo distruggerebbe. + La guardia vera e' il range: il movimento avviene DENTRO la barra.""" + df = _series([98.3, 101.6, 105.6, 105.6, 75.4, 72.4], + ranges=[0.10, 0.07, 0.06, 0.12, 0.33, 0.09]) + assert detect_unadjusted_splits(df) == [] + + +def test_crollo_con_rapporto_esatto_ma_range_grande_non_e_split(): + """Anche un -50% ESATTO non e' uno split se la barra ha range enorme (crollo intraday).""" + df = _series([100.0, 100.0, 100.0, 50.0, 51.0], ranges=[0.01, 0.01, 0.01, 0.40, 0.05]) + assert detect_unadjusted_splits(df) == [] + + +def test_riparazione_rende_continua_la_serie(): + df = _series([94.5, 94.6, 94.1] + [47.5, 47.6, 47.4]) + fixed, sp = repair_splits(df) + assert len(sp) == 1 + r = fixed["close"].pct_change().abs() + assert r.max() < 0.05, "dopo la riparazione non deve restare nessun salto anomalo" + # i prezzi POST-split non si toccano; quelli PRE si dimezzano + assert fixed["close"].iloc[-1] == pytest.approx(47.4) + assert fixed["close"].iloc[0] == pytest.approx(94.5 / 2) + # il volume va nella direzione opposta + assert fixed["volume"].iloc[0] == pytest.approx(1e6 * 2) + + +def test_split_multipli_si_compongono(): + """Due split (3:1 poi 2:1): i prezzi piu' antichi vanno divisi per 6, non per 3 o 2.""" + df = _series([90.0, 90.0] + [30.0, 30.0] + [15.0, 15.0]) + fixed, sp = repair_splits(df) + assert len(sp) == 2 + assert fixed["close"].iloc[0] == pytest.approx(90.0 / 6) + assert fixed["close"].iloc[2] == pytest.approx(30.0 / 2) + assert fixed["close"].iloc[-1] == pytest.approx(15.0) + + +def test_serie_pulita_resta_identica(): + rng = np.random.default_rng(0) + c = 100 * np.cumprod(1 + rng.normal(0, 0.01, 500)) + df = _series(c) + assert detect_unadjusted_splits(df) == [] + fixed, sp = repair_splits(df) + assert sp == [] + pd.testing.assert_frame_equal(fixed, df) + + +def test_dati_reali_iwm_efa_riparati_dai_loader(): + """Verifica end-to-end sui parquet certificati, se presenti.""" + eqlib = pytest.importorskip("eqlib", reason="scripts/research non nel path") + for sym in ("IWM", "EFA"): + try: + df = eqlib.load_eq(sym) + except FileNotFoundError: + pytest.skip(f"eq_{sym.lower()}_1d.parquet assente") + assert df["close"].pct_change().abs().max() < 0.5, f"{sym}: salto da split ancora presente"