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.
135 lines
5.3 KiB
Python
135 lines
5.3 KiB
Python
"""Quantify price gaps against PIT membership in the US research panels and
|
|
emit a Tiingo backfill plan (free tier: 500 unique symbols/month, 50/hour).
|
|
|
|
For S&P 500 ever-members: names with no data, a late start or an early end
|
|
relative to their membership intervals, in both the production books
|
|
(data/us.csv) and the research panel (data/research/us_ohlcv.pkl). For the
|
|
mid/small panels: the Yahoo-missing tickers (data/research/ext/yahoo_mid/
|
|
missing.json) ranked by snapshot weight and recency, since only the top of
|
|
that list fits the monthly quota.
|
|
|
|
Writes data/research/tiingo_plan.json.
|
|
|
|
PYTHONPATH=. uv run python -m research.lab.panel_gaps
|
|
"""
|
|
from __future__ import annotations
|
|
|
|
import json
|
|
import os
|
|
import pickle
|
|
|
|
import numpy as np
|
|
import pandas as pd
|
|
|
|
TOL_DAYS = 45
|
|
PLAN = os.path.join("data", "research", "tiingo_plan.json")
|
|
|
|
|
|
def sp500_intervals() -> dict:
|
|
with open(os.path.join("data", "sp500_history.json")) as fh:
|
|
return json.load(fh)["intervals"]
|
|
|
|
|
|
def classify(px: pd.DataFrame, intervals: dict) -> pd.DataFrame:
|
|
"""Per ever-member: membership vs data presence within the panel window."""
|
|
rows = []
|
|
pstart, pend = px.index[0], px.index[-1]
|
|
for t, iv in intervals.items():
|
|
iv = [(pd.Timestamp(s) if s else None, pd.Timestamp(e) if e else None) for s, e in iv]
|
|
mstart = max(min((s for s, _ in iv if s), default=pstart), pstart)
|
|
mend = min(max((e for _, e in iv if e), default=pend), pend)
|
|
if mend <= pstart or mstart >= pend:
|
|
continue
|
|
col = px[t].dropna() if t in px.columns else pd.Series(dtype=float)
|
|
first = col.index[0] if len(col) else None
|
|
last = col.index[-1] if len(col) else None
|
|
n_mem = int(((px.index >= mstart) & (px.index < mend)).sum())
|
|
n_px = int(((col.index >= mstart) & (col.index < mend)).sum()) if len(col) else 0
|
|
flags = []
|
|
if not len(col):
|
|
flags.append("no_data")
|
|
else:
|
|
if first > mstart + pd.Timedelta(days=TOL_DAYS):
|
|
flags.append("late_start")
|
|
if last < mend - pd.Timedelta(days=TOL_DAYS):
|
|
flags.append("early_end")
|
|
rows.append(dict(ticker=t, m_first=mstart.date(), m_last=mend.date(),
|
|
px_first=first.date() if first is not None else None,
|
|
px_last=last.date() if last is not None else None,
|
|
n_mem=n_mem, n_px=n_px,
|
|
cov=round(n_px / n_mem, 3) if n_mem else np.nan,
|
|
flags=",".join(flags)))
|
|
return pd.DataFrame(rows)
|
|
|
|
|
|
def mid_small_plan() -> pd.DataFrame:
|
|
"""Rank the Yahoo-missing mid/small tickers: total snapshot weight, with
|
|
recent years weighted fully and older ones decayed."""
|
|
from research.lab.fetch_us_mid import load_holdings
|
|
|
|
miss_path = os.path.join("data", "research", "ext", "yahoo_mid", "missing.json")
|
|
missing = set(json.load(open(miss_path)))
|
|
h = load_holdings()
|
|
h = h[h["ticker"].isin(missing)]
|
|
if h.empty:
|
|
return h
|
|
recency = pd.Series(1.0, index=h.index)
|
|
recency[h["date"] < "2014-01-01"] = 0.25
|
|
recency[(h["date"] >= "2014-01-01") & (h["date"] < "2019-01-01")] = 0.5
|
|
h = h.assign(score=h["weight"] * recency)
|
|
g = h.groupby("ticker")
|
|
out = pd.DataFrame({
|
|
"n_snaps": g.size(),
|
|
"w_sum": g["weight"].sum().round(4),
|
|
"score": g["score"].sum().round(4),
|
|
"first": g["date"].min().dt.date,
|
|
"last": g["date"].max().dt.date,
|
|
"name": g["name"].first(),
|
|
}).sort_values("score", ascending=False)
|
|
return out
|
|
|
|
|
|
def main() -> None:
|
|
intervals = sp500_intervals()
|
|
|
|
import data_manager
|
|
prod = data_manager.load_raw("us")
|
|
print(f"production data/us.csv: {prod.shape[1]} cols {prod.index[0].date()}..{prod.index[-1].date()}")
|
|
|
|
from research.lab.tiingo_merge import apply_sp500
|
|
with open(os.path.join("data", "research", "us_ohlcv.pkl"), "rb") as fh:
|
|
raw = pickle.load(fh)
|
|
res = apply_sp500(raw, verbose=False)["close"]
|
|
print(f"research us_ohlcv.pkl + tiingo: {res.shape[1]} cols "
|
|
f"{res.index[0].date()}..{res.index[-1].date()}")
|
|
|
|
plan: dict[str, list[str]] = {}
|
|
for label, px in (("production", prod), ("research", res)):
|
|
df = classify(px, intervals)
|
|
bad = df[df["flags"] != ""].sort_values(["n_mem"], ascending=False)
|
|
print(f"\n[{label}] ever-members with intervals in window: {len(df)}; with flags: {len(bad)}")
|
|
for fl in ("no_data", "late_start", "early_end"):
|
|
sub = bad[bad["flags"].str.contains(fl)]
|
|
print(f" {fl}: {len(sub)}")
|
|
cols = ["ticker", "m_first", "m_last", "px_first", "px_last", "cov"]
|
|
if len(sub):
|
|
print(sub[cols].head(25).to_string(index=False))
|
|
names = sorted(set(bad[bad["flags"].str.contains("no_data|early_end")]["ticker"]))
|
|
plan[f"sp500_{label}"] = names
|
|
print(f" -> {len(names)} no_data/early_end names")
|
|
|
|
ms = mid_small_plan()
|
|
print(f"\n[mid/small] missing tickers with snapshot weight: {len(ms)}")
|
|
print(ms.head(30).to_string())
|
|
print(f" weight coverage reachable in quota (top 500): "
|
|
f"{ms['w_sum'].head(500).sum() / ms['w_sum'].sum():.1%}")
|
|
plan["mid_small"] = list(ms.index)
|
|
|
|
with open(PLAN, "w") as fh:
|
|
json.dump(plan, fh, indent=1)
|
|
print(f"\nSaved {PLAN}")
|
|
|
|
|
|
if __name__ == "__main__":
|
|
main()
|