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>
107 lines
4.3 KiB
Python
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"
|