fix(data): split non aggiustati nel feed equity — difetto sul libro live, riparato alla fonte

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) <noreply@anthropic.com>
This commit is contained in:
Adriano Dal Pastro
2026-07-25 09:47:25 +00:00
parent d606cd3548
commit 741bbc2c09
5 changed files with 259 additions and 6 deletions
+6
View File
@@ -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"]]
+19 -3
View File
@@ -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)
+119
View File
@@ -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
+9 -3
View File
@@ -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:
+106
View File
@@ -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"