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