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:
@@ -8,11 +8,14 @@ Espone: universi (SECTORS/BROAD), load_eq(sym), panel(universe) allineato, e riu
|
||||
indurito di altlib (_sh, _dd_ret, _to_daily, marginal_vs_tp01) per giudicare i candidati con la
|
||||
stessa disciplina del lato crypto.
|
||||
"""
|
||||
import sys
|
||||
from functools import lru_cache
|
||||
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 repair_splits # noqa: E402
|
||||
RAW = ROOT / "data" / "raw"
|
||||
|
||||
# 11 SPDR settoriali. I 9 "classici" (sotto SECTORS_CLASSIC) partono 1998; XLRE 2015, XLC 2018.
|
||||
@@ -29,6 +32,9 @@ def load_eq(sym: str) -> pd.DataFrame:
|
||||
raise FileNotFoundError(f"{p} assente — gira: uv run --with ib_async python scripts/research/fetch_ib_equities.py")
|
||||
d = pd.read_parquet(p).copy()
|
||||
d.index = pd.to_datetime(d["timestamp"], unit="ms", utc=True)
|
||||
d = d.sort_index()
|
||||
# split NON aggiustati da IB (IWM/EFA 2005-06-09) — vedi src/data/eq_splits.py
|
||||
d, _ = repair_splits(d)
|
||||
return d[["open", "high", "low", "close", "volume"]]
|
||||
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -0,0 +1,119 @@
|
||||
"""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
|
||||
@@ -14,6 +14,8 @@ from functools import lru_cache
|
||||
from pathlib import Path
|
||||
import numpy as np, pandas as pd
|
||||
|
||||
from src.data.eq_splits import repair_splits
|
||||
|
||||
RAW = Path(__file__).resolve().parents[2] / "data" / "raw"
|
||||
EQ_UNIVERSE = ("SPY", "QQQ", "IWM", "TLT", "GLD", "HYG")
|
||||
HORIZONS = (21, 63, 126, 252)
|
||||
@@ -28,8 +30,12 @@ def _close(sym: str) -> pd.Series:
|
||||
if not p.exists():
|
||||
raise FileNotFoundError(f"{p} assente — gira scripts/research/fetch_ib_equities.py")
|
||||
d = pd.read_parquet(p)
|
||||
return pd.Series(d["close"].astype(float).values,
|
||||
index=pd.to_datetime(d["timestamp"], unit="ms", utc=True)).sort_index()
|
||||
d.index = pd.to_datetime(d["timestamp"], unit="ms", utc=True)
|
||||
d = d.sort_index()
|
||||
# IB ADJUSTED_LAST non aggiusta SEMPRE gli split: IWM (gamba di questo sleeve) e EFA hanno
|
||||
# uno split non aggiustato il 2005-06-09. Vedi src/data/eq_splits.py. Riparato in lettura.
|
||||
d, _ = repair_splits(d)
|
||||
return pd.Series(d["close"].astype(float).values, index=d.index)
|
||||
|
||||
|
||||
def _exposure(close: pd.Series) -> pd.Series:
|
||||
@@ -55,7 +61,7 @@ def _gated_returns(sym: str) -> pd.Series:
|
||||
def gtaa_returns(universe=EQ_UNIVERSE) -> pd.Series:
|
||||
"""Rendimenti netti daily del GTAA: EW dei rendimenti trend-gated sugli asset disponibili."""
|
||||
cols = {a: _gated_returns(a) for a in universe}
|
||||
return pd.concat(cols, axis=1).sort_index().mean(axis=1, skipna=True).dropna()
|
||||
return pd.concat(cols, axis=1, sort=True).sort_index().mean(axis=1, skipna=True).dropna()
|
||||
|
||||
|
||||
def gtaa_weights(universe=EQ_UNIVERSE) -> dict:
|
||||
|
||||
@@ -0,0 +1,106 @@
|
||||
"""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"
|
||||
Reference in New Issue
Block a user