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>
This commit is contained in:
@@ -13,7 +13,11 @@ CERTIFICAZIONE (gemello equity di certify_feed.py):
|
||||
(1) integrità: barre, range, date monotone, duplicati, flat bars (close invariato);
|
||||
(2) gap: run di giorni-lavorativi mancanti > 5 (festivi normali, buchi lunghi = sospetti);
|
||||
(3) sanità ritorni: max |daily ret| (un >50% non-evento = errore di adjustment);
|
||||
(4) sanità adjustment: primo close aggiustato << ultimo (i dividendi abbassano lo storico).
|
||||
(4) sanità adjustment: primo close aggiustato << ultimo (i dividendi abbassano lo storico);
|
||||
(5) SPLIT NON AGGIUSTATI (aggiunto 2026-07-25): ADJUSTED_LAST non sempre aggiusta gli split, e
|
||||
il check (3) NON li vede — uno split 2:1 fa esattamente -50%, sul filo della soglia. IWM
|
||||
(gamba di GTAA01 in produzione) ed EFA avevano uno split non aggiustato il 2005-06-09 e
|
||||
passavano come OK. Rilevatore in src/data/eq_splits.py -> status SPLIT-NON-AGG.
|
||||
PREREQUISITO: gateway IB paper su 127.0.0.1:4002 (docker compose up -d ib-gateway).
|
||||
|
||||
uv run --with ib_async python scripts/research/fetch_ib_equities.py
|
||||
@@ -23,6 +27,8 @@ from pathlib import Path
|
||||
import numpy as np, pandas as pd
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[2]
|
||||
sys.path.insert(0, str(ROOT))
|
||||
from src.data.eq_splits import detect_unadjusted_splits # noqa: E402
|
||||
RAW = ROOT / "data" / "raw"
|
||||
RAW.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
@@ -55,9 +61,17 @@ def certify(sym: str, df: pd.DataFrame) -> dict:
|
||||
longgap = int((gaps.to_series().groupby(s).size() > 5).sum())
|
||||
span_y = (idx[-1] - idx[0]).days / 365.25
|
||||
adj_ratio = round(float(c[0] / c[-1]), 3) # primo/ultimo: <1 atteso (storico abbassato dai div)
|
||||
# SPLIT NON AGGIUSTATI: IB ADJUSTED_LAST non sempre li aggiusta. NB la guardia `maxret > 0.5`
|
||||
# NON li vede: uno split 2:1 non aggiustato fa ESATTAMENTE -50% e cade sul filo della soglia
|
||||
# (IWM 2005-06-09 passava a 49.5% con status OK, ed e' una gamba di GTAA01 in produzione).
|
||||
# Vedi src/data/eq_splits.py per il discriminante split-vs-crollo (range intraday).
|
||||
splits = detect_unadjusted_splits(df)
|
||||
|
||||
status = "OK"
|
||||
if dup or not mono:
|
||||
status = "INTEGRITA'"
|
||||
elif splits:
|
||||
status = "SPLIT-NON-AGG"
|
||||
elif maxret > 0.5:
|
||||
status = "SPIKE?"
|
||||
elif longgap > 0:
|
||||
@@ -67,7 +81,8 @@ def certify(sym: str, df: pd.DataFrame) -> dict:
|
||||
return {"sym": sym, "n": len(df), "primo": idx[0].date(), "ultimo": idx[-1].date(),
|
||||
"anni": round(span_y, 1), "dup": dup, "mono": mono, "flat": flat,
|
||||
"maxret%": round(maxret * 100, 1), "miss_bd": missing, "gap_lunghi": longgap,
|
||||
"adj_first/last": adj_ratio, "status": status}
|
||||
"adj_first/last": adj_ratio, "status": status,
|
||||
"splits": [f"{s['date'].date()} 1:{s['factor']:g}" for s in splits]}
|
||||
|
||||
|
||||
def main():
|
||||
@@ -119,7 +134,8 @@ def main():
|
||||
ok.append(sym)
|
||||
print(f" {sym:5} n={c.get('n',0):>5} {str(c.get('primo','')):>10}->{str(c.get('ultimo',''))} "
|
||||
f"{c.get('anni','?')}y flat={c.get('flat','?')} maxret={c.get('maxret%','?')}% "
|
||||
f"miss_bd={c.get('miss_bd','?')} gapL={c.get('gap_lunghi','?')} adj={c.get('adj_first/last','?')} [{c['status']}]")
|
||||
f"miss_bd={c.get('miss_bd','?')} gapL={c.get('gap_lunghi','?')} adj={c.get('adj_first/last','?')} [{c['status']}]"
|
||||
+ (f" split={c['splits']}" if c.get("splits") else ""))
|
||||
time.sleep(1.2) # pacing IB
|
||||
|
||||
print("-" * 104)
|
||||
|
||||
Reference in New Issue
Block a user