Yahoo purges delisted members: the S&P research panel was missing 209 ever-members (AGN/CELG/ESRX/ATVI/TWTR/XLNX/...), the production store 89, and the S&P 400/600 panels ~180 more. The gap inflated momentum-family factors (overnight_mom holdout 16.9->14.8%/yr, rec_126 14.7->10.7 on the research panel; factor_panel_check). research/lab/fetch_tiingo.py pulls full EOD histories (free tier: 500 unique symbols/month, 50/hour; paced, resume-safe, key never logged). research/lab/tiingo_merge.py overlays the caches on the research S&P panel at build time (>=50% PIT-coverage gate, median-overlap cross-check, stub-column replacement, rename splices GPS<-GAP, DISCA<-WBD, WLTW<-WTW, ADS<-BFH, CCE<-CCEP, ESV<-VAL, FBHS<-FBIN, HFC<-DINO) and can apply the same to the production store (--production [--apply]; data/us*.csv, .bak-tiingo backups, under the panel lock). load_us() rebuilds when the overlay changes (_tiingo_sig); fetch_us_mid.load_bars falls back to converted CSVs. Round 1 (2026-10-08): 165/209 S&P names and 183 mid/small PIT members restored (2011-19 snapshots were 15-20% short of PIT members). prod_impact A/B on registered replays: overnight_recovery_top10 23.8%/0.83/-38.3% vs 27.8%/0.88/-47.3% backfill-off; lowrisk_top20 6.1%/0.58 vs 8.6%/0.60 - honest long returns with shallower drawdowns; trend_rider_v10 (ETF path) unchanged at 55.0%/1.64. Official precompute refreshed (backup at data/precomputed/backtest_results.json.pretiingo). midsmall_scan on the corrected panel: no factor beats the EW benchmark (0.60 holdout Sharpe vs 0.51-0.52 best) - no mid/small sleeve.
332 lines
13 KiB
Python
332 lines
13 KiB
Python
"""Overlay Tiingo EOD caches (data/research/tiingo/) onto the US research panels.
|
|
|
|
S&P 500 -- ``apply_sp500(raw)``: fills the Yahoo-built panel's gaps, whole
|
|
missing columns for delisted names and late tails, keeping Yahoo cells where
|
|
both sources have data. Safety per ticker: >=50% of its PIT membership
|
|
sessions must be covered; where both sources overlap (>=20 sessions) the
|
|
median Tiingo/Yahoo close ratio must sit within 2% (same total-return
|
|
adjustment basis; a recycled ticker fails this). Two rename splices
|
|
(GPS<-GAP, DISCA<-WBD) backfill pre-rename eras from the successor ticker.
|
|
Read-only on the Yahoo artifact: deleting data/research/tiingo/ reverts it.
|
|
The merge prints an audit report incl. terminal gaps (last bar vs membership
|
|
end), which is the delisted-terminal check.
|
|
|
|
Mid/small -- ``write_yahoo_mid()``: converts the caches to the yahoo_mid CSV
|
|
format (split-adjusted OHLC with a Stock Splits column so load_bars can
|
|
recover the as-traded close, Adj Close as served) under
|
|
data/research/ext/tiingo_mid/, which fetch_us_mid.load_bars falls back to.
|
|
|
|
PYTHONPATH=. uv run python -m research.lab.tiingo_merge --check
|
|
PYTHONPATH=. uv run python -m research.lab.tiingo_merge --yahoo-mid
|
|
"""
|
|
from __future__ import annotations
|
|
|
|
import json
|
|
import os
|
|
import sys
|
|
|
|
import numpy as np
|
|
import pandas as pd
|
|
|
|
DIR = os.path.join("data", "research", "tiingo")
|
|
MID_DIR = os.path.join("data", "research", "ext", "tiingo_mid")
|
|
FIELDS = ("open", "high", "low", "close", "volume")
|
|
SPLICE = {"GPS": "GAP", "DISCA": "WBD"}
|
|
MIN_COVERAGE = 0.5
|
|
MIN_OVERLAP = 20
|
|
RATIO_TOL = 0.02
|
|
TERMINAL_GAP_DAYS = 45
|
|
# Pure ticker changes where the successor's series is the same security's
|
|
# continuous price history (Yahoo/Tiingo migrate it on rename). NOT for
|
|
# merger-shell cases (RTN->RTX pre-2020 is United Technologies, not Raytheon).
|
|
SPLICE.update({"WLTW": "WTW", "ADS": "BFH", "CCE": "CCEP",
|
|
"ESV": "VAL", "FBHS": "FBIN", "HFC": "DINO"})
|
|
|
|
_intervals_cache: dict | None = None
|
|
|
|
|
|
def _intervals() -> dict:
|
|
global _intervals_cache
|
|
if _intervals_cache is None:
|
|
with open(os.path.join("data", "sp500_history.json")) as fh:
|
|
_intervals_cache = json.load(fh)["intervals"]
|
|
return _intervals_cache
|
|
|
|
|
|
def _cached(t: str) -> pd.DataFrame:
|
|
h = pd.read_csv(os.path.join(DIR, f"{t.upper()}.csv"), index_col=0, parse_dates=True)
|
|
return h[~h.index.duplicated(keep="last")].sort_index()
|
|
|
|
|
|
def cached_tickers() -> list[str]:
|
|
if not os.path.isdir(DIR):
|
|
return []
|
|
return sorted(f[:-4] for f in os.listdir(DIR) if f.endswith(".csv"))
|
|
|
|
|
|
def _frame(t: str) -> pd.DataFrame:
|
|
"""Adjusted OHLCV in the panel convention (auto_adjust=True)."""
|
|
h = _cached(t)
|
|
out = pd.DataFrame({f: h["adj" + f[0].upper() + f[1:]] for f in FIELDS})
|
|
return out
|
|
|
|
|
|
def _member_mask(t: str, dates: pd.DatetimeIndex) -> np.ndarray:
|
|
m = np.zeros(len(dates), bool)
|
|
for s, e in _intervals().get(t, []):
|
|
si = dates.searchsorted(pd.Timestamp(s) if s else dates[0], side="left")
|
|
ei = dates.searchsorted(pd.Timestamp(e) if e else dates[-1] + pd.Timedelta(days=1),
|
|
side="left")
|
|
m[si:ei] = True
|
|
return m
|
|
|
|
|
|
def _coverage(t: str, frame: pd.DataFrame, dates: pd.DatetimeIndex) -> float:
|
|
mask = _member_mask(t, dates)
|
|
if not mask.any():
|
|
return 0.0
|
|
have = frame["close"].reindex(dates).notna().to_numpy()
|
|
return float((mask & have).sum()) / float(mask.sum())
|
|
|
|
|
|
def _cross_check(yahoo: pd.Series, tiingo: pd.Series) -> float | None:
|
|
"""Median Tiingo/Yahoo close ratio over the overlap; None if too small."""
|
|
y = yahoo.reindex(tiingo.index)
|
|
both = y.notna() & tiingo["close"].notna()
|
|
if both.sum() < MIN_OVERLAP:
|
|
return None
|
|
return float((tiingo.loc[both, "close"] / y[both]).median())
|
|
|
|
|
|
def apply_sp500(raw: dict, *, verbose: bool = True) -> dict:
|
|
"""Return a copy of the raw panel dict with Tiingo cells filled in."""
|
|
dates = raw["close"].index
|
|
out = {k: v.copy() for k, v in raw.items()}
|
|
merged, extended, replaced, skipped, rejected, spliced, terminals = [], [], [], [], [], [], []
|
|
frames = {t: _frame(t) for t in cached_tickers()}
|
|
|
|
def fill_column(t: str, frame: pd.DataFrame, mask: np.ndarray | None = None):
|
|
added = t not in out["close"].columns
|
|
for f in FIELDS:
|
|
col = frame[f].reindex(dates)
|
|
if mask is not None:
|
|
col = col.where(mask)
|
|
if added:
|
|
out[f][t] = col
|
|
else:
|
|
out[f][t] = out[f][t].where(out[f][t].notna(), col)
|
|
return added
|
|
|
|
for t, frame in frames.items():
|
|
cov = _coverage(t, frame, dates)
|
|
if cov < MIN_COVERAGE:
|
|
skipped.append((t, round(cov, 3)))
|
|
continue
|
|
replace = False
|
|
if t in out["close"].columns:
|
|
mask = _member_mask(t, dates)
|
|
have = out["close"][t].reindex(dates).notna().to_numpy()
|
|
if mask.any() and (mask & have).sum() / mask.sum() < MIN_COVERAGE:
|
|
# a recycled ticker or a broken stub sits in the column: the
|
|
# full series wins wherever it has data
|
|
replace = True
|
|
else:
|
|
med = _cross_check(out["close"][t], frame)
|
|
if med is not None and not (1 - RATIO_TOL < med < 1 + RATIO_TOL):
|
|
rejected.append((t, round(med, 4)))
|
|
continue
|
|
if replace:
|
|
for f in FIELDS:
|
|
col = frame[f].reindex(dates)
|
|
out[f][t] = col.where(col.notna(), out[f][t])
|
|
replaced.append(t)
|
|
else:
|
|
added = fill_column(t, frame)
|
|
(merged if added else extended).append(t)
|
|
# terminal check: last bar vs membership end within the panel window
|
|
col = out["close"][t].dropna()
|
|
ends = [pd.Timestamp(e) if e else dates[-1] + pd.Timedelta(days=1)
|
|
for _, e in _intervals().get(t, [])]
|
|
mend = min(max(ends), dates[-1]) if ends else None
|
|
if mend is not None and len(col):
|
|
gap = (mend - col.index[-1]).days
|
|
if gap > TERMINAL_GAP_DAYS:
|
|
terminals.append((t, str(col.index[-1].date()), str(mend.date()), gap))
|
|
|
|
for old, new in SPLICE.items():
|
|
frame = frames.get(new)
|
|
if frame is None and new in out["close"].columns:
|
|
frame = pd.DataFrame({f: out[f][new] for f in FIELDS})
|
|
if frame is None:
|
|
continue
|
|
mask = _member_mask(old, dates)
|
|
if not mask.any():
|
|
continue
|
|
have = out["close"][old].reindex(dates).notna().to_numpy() if old in out["close"] else np.zeros(len(dates), bool)
|
|
if (mask & have).sum() / mask.sum() >= MIN_COVERAGE:
|
|
continue
|
|
fill_column(old, frame, mask)
|
|
spliced.append(f"{old}<-{new}")
|
|
|
|
if verbose:
|
|
print(f"tiingo overlay: {len(frames)} cached files; merged {len(merged)}, "
|
|
f"extended {len(extended)}, replaced {len(replaced)}, spliced {len(spliced)}, "
|
|
f"skipped {len(skipped)}, rejected {len(rejected)}")
|
|
if merged:
|
|
print(f" merged: {', '.join(merged)}")
|
|
if extended:
|
|
print(f" extended: {', '.join(extended)}")
|
|
if replaced:
|
|
print(f" replaced (column was a stub/recycled): {', '.join(replaced)}")
|
|
if spliced:
|
|
print(f" spliced: {', '.join(spliced)}")
|
|
if skipped:
|
|
print(f" skipped (coverage<{MIN_COVERAGE}): "
|
|
+ ", ".join(f"{t}:{c}" for t, c in skipped[:30])
|
|
+ (" ..." if len(skipped) > 30 else ""))
|
|
if rejected:
|
|
print(f" rejected (ratio off >{RATIO_TOL:.0%}): "
|
|
+ ", ".join(f"{t}:{r}" for t, r in rejected))
|
|
if terminals:
|
|
print(f" terminal gaps >{TERMINAL_GAP_DAYS}d: "
|
|
+ ", ".join(f"{t} last={d} member_end={m} ({g}d)" for t, d, m, g in terminals))
|
|
return out
|
|
|
|
|
|
PROD_FIELDS = ("open", "high", "low", "volume")
|
|
|
|
|
|
def _production_plan() -> tuple:
|
|
"""Which cached tickers to merge into the production store, and how."""
|
|
import data_manager
|
|
base = data_manager.load_raw("us")
|
|
if base is None:
|
|
raise FileNotFoundError("data/us.csv not found")
|
|
dates = base.index
|
|
frames = {t: _frame(t) for t in cached_tickers()}
|
|
add, extend, replace, skip, rejected = [], [], [], [], []
|
|
for t, fr in frames.items():
|
|
cov = _coverage(t, fr, dates)
|
|
if cov < MIN_COVERAGE:
|
|
skip.append((t, round(cov, 3)))
|
|
continue
|
|
if t in base.columns:
|
|
mask = _member_mask(t, dates)
|
|
have = base[t].reindex(dates).notna().to_numpy()
|
|
if mask.any() and (mask & have).sum() / mask.sum() < MIN_COVERAGE:
|
|
replace.append(t)
|
|
continue
|
|
med = _cross_check(base[t], fr)
|
|
if med is not None and not (1 - RATIO_TOL < med < 1 + RATIO_TOL):
|
|
rejected.append((t, round(med, 4)))
|
|
continue
|
|
n = int(base[t].notna().sum())
|
|
(extend if n else add).append(t)
|
|
else:
|
|
add.append(t)
|
|
for old, new in SPLICE.items():
|
|
if old in add:
|
|
continue
|
|
source = frames.get(new)
|
|
if source is None and new in base.columns:
|
|
source = pd.DataFrame({f: data_manager.load_raw("us", f)[new]
|
|
for f in ("close",) + PROD_FIELDS})
|
|
if source is None:
|
|
continue
|
|
mask = _member_mask(old, dates)
|
|
if not mask.any():
|
|
continue
|
|
have = (base[old].reindex(dates).notna().to_numpy() if old in base.columns
|
|
else np.zeros(len(dates), bool))
|
|
if (mask & have).sum() / mask.sum() < MIN_COVERAGE:
|
|
add.append(old)
|
|
fr = source.reindex(dates)
|
|
frames[old] = fr.where(np.broadcast_to(mask[:, None], fr.shape))
|
|
return base, frames, add, extend, replace, skip, rejected
|
|
|
|
|
|
def apply_production(*, dry_run: bool = False) -> None:
|
|
"""Merge Tiingo frames into data/us*.csv (backup .bak-tiingo on first write)."""
|
|
import data_manager
|
|
from data_manager import _data_path, _panel_lock, _write_atomic
|
|
base, frames, add, extend, replace, skip, rejected = _production_plan()
|
|
names = add + extend + replace
|
|
print(f"production merge: {len(frames)} cached; add {len(add)}, extend {len(extend)}, "
|
|
f"replace {len(replace)}, skip {len(skip)}, rejected {len(rejected)}")
|
|
if add:
|
|
print(f" add: {', '.join(add)}")
|
|
if extend:
|
|
print(f" extend: {', '.join(extend)}")
|
|
if replace:
|
|
print(f" replace: {', '.join(replace)}")
|
|
if rejected:
|
|
print(f" rejected: {', '.join(f'{t}:{r}' for t, r in rejected)}")
|
|
if dry_run or not names:
|
|
return
|
|
with _panel_lock("us"):
|
|
fields = {f: data_manager.load_raw("us", f) for f in PROD_FIELDS}
|
|
repl = set(replace)
|
|
for f in FIELDS:
|
|
panel = base if f == "close" else fields[f]
|
|
if panel is None:
|
|
continue
|
|
for t in names:
|
|
col = frames[t][f].reindex(panel.index)
|
|
if t not in panel.columns:
|
|
panel[t] = col
|
|
elif t in repl:
|
|
panel[t] = col.where(col.notna(), panel[t])
|
|
else:
|
|
panel[t] = panel[t].where(panel[t].notna(), col)
|
|
path = _data_path("us", f)
|
|
if not os.path.exists(path + ".bak-tiingo"):
|
|
import shutil
|
|
shutil.copy2(path, path + ".bak-tiingo")
|
|
_write_atomic(panel, path)
|
|
print(f"production store updated for {len(names)} tickers "
|
|
f"(backups: data/us*.csv.bak-tiingo)")
|
|
|
|
|
|
def write_yahoo_mid(tickers: list[str] | None = None) -> None:
|
|
"""Convert caches to yahoo_mid-format CSVs (split-adjusted OHLC + Stock
|
|
Splits + as-traded close recoverable via load_bars)."""
|
|
os.makedirs(MID_DIR, exist_ok=True)
|
|
names = tickers if tickers is not None else cached_tickers()
|
|
written = 0
|
|
for t in names:
|
|
h = _cached(t)
|
|
if not {"close", "adjClose", "splitFactor"}.issubset(h.columns):
|
|
print(f"{t}: missing columns; refetch with the current fetch_tiingo")
|
|
continue
|
|
splits = h["splitFactor"].replace(0, 1.0).fillna(1.0)
|
|
later = splits[::-1].cumprod()[::-1].shift(-1).fillna(1.0)
|
|
out = pd.DataFrame({
|
|
"Open": h["open"] / later, "High": h["high"] / later,
|
|
"Low": h["low"] / later, "Close": h["close"] / later,
|
|
"Adj Close": h["adjClose"], "Volume": h["adjVolume"],
|
|
"Dividends": h.get("divCash", pd.Series(0.0, index=h.index)).fillna(0.0),
|
|
"Stock Splits": splits,
|
|
})
|
|
out.index.name = "Date"
|
|
out.to_csv(os.path.join(MID_DIR, f"{t.upper()}.csv"))
|
|
written += 1
|
|
print(f"wrote {written} converted CSVs to {MID_DIR}")
|
|
|
|
|
|
def main() -> None:
|
|
if "--yahoo-mid" in sys.argv:
|
|
args = [a for a in sys.argv[1:] if not a.startswith("--")]
|
|
write_yahoo_mid(args or None)
|
|
return
|
|
if "--production" in sys.argv:
|
|
apply_production(dry_run="--apply" not in sys.argv)
|
|
return
|
|
import pickle
|
|
with open(os.path.join("data", "research", "us_ohlcv.pkl"), "rb") as fh:
|
|
raw = pickle.load(fh)
|
|
apply_sp500(raw)
|
|
|
|
|
|
if __name__ == "__main__":
|
|
main()
|