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
+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: