Files
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

107 lines
4.3 KiB
Python

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