741bbc2c09
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>
120 lines
5.7 KiB
Python
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
|