Files
PythagorasGoal/src/data/eq_splits.py
T
Adriano Dal Pastro 741bbc2c09 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>
2026-07-25 09:47:25 +00:00

120 lines
5.7 KiB
Python

"""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