Files
quant-trading/research/midsmall_scan.py
Gahow Wang 92af4e5ac0 feat(research): Tiingo backfill of delisted names; honest PIT panels
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.
2026-10-08 09:48:05 -07:00

132 lines
5.0 KiB
Python

"""Mid/small factor scan with a survivorship experiment.
Compact top-N next-open backtests (full phase averaging, 20bp/side) on the
PIT S&P 400/600 union Panel, run against BOTH builds: the Yahoo-only panel
(pre-Tiingo backup, delisted names silently missing) and the Tiingo-extended
rebuild. A factor whose conclusion flips between the two was a survivorship
artifact; one that survives both is actionable.
Windows: design 2007-01..2016-12, holdout 2017-01..2026-09. Scores are
computed on the full history and the index is masked to PIT members, so
pre-admission bars inform indicators but never selection.
The overnight composite approximates the production robust_momentum sleeve
(overnight-return momentum + distance from the 126d low), not its exact code.
PYTHONPATH=. uv run python -m research.midsmall_scan --panel both
"""
from __future__ import annotations
import argparse
import os
import pickle
import warnings
import numpy as np
import pandas as pd
from research.lab.engine import simulate
from research.lab.report import forward_returns, ic_series, ic_summary, stats
warnings.filterwarnings("ignore")
DESIGN = ("2007-01-01", "2016-12-31")
HOLDOUT = ("2017-01-01", "2026-09-24")
RESULTS = os.path.join("data", "research", "results")
def load(panel: str):
if panel == "new":
from research.lab.panel_mid import load_us_mid
return load_us_mid("midsmall")
with open(os.path.join("data", "research", "us_midsmall.panel.pre_tiingo.pkl"), "rb") as fh:
return pickle.load(fh)
def factors(p) -> dict:
c = p.close.where(p.member)
o = p.open.where(p.member)
vol = c.pct_change(fill_method=None).rolling(60, min_periods=40).std()
onr = ((o / c.shift(1) - 1).where(lambda x: x.abs() < 0.5)
.rolling(63, min_periods=40).mean())
dist126 = c / c.rolling(126, min_periods=100).min() - 1
hi252 = c / c.rolling(252, min_periods=200).max()
pr = lambda x: x.rank(axis=1, pct=True)
return {
"mom_12_1": c.shift(21) / c.shift(252) - 1,
"mom_6_1": c.shift(21) / c.shift(126) - 1,
"rev_1m": -(c / c.shift(21) - 1),
"rev_1w": -(c / c.shift(5) - 1),
"lowvol": -vol,
"hi252": hi252,
"dist126": dist126,
"onr63": onr,
"orm_comp": pr(onr) + pr(dist126),
}
def phase_avg(p, s, window, top_n, every) -> dict:
a, b = window
w0 = p.dates.searchsorted(pd.Timestamp(a))
sp = p.slice(p.dates[max(w0 - 5, 0)], b)
rows = []
for off in range(every):
r = simulate(sp, s, top_n=top_n, every=every, offset=off, warmup=5)
rows.append(stats(r.equity.loc[a:b], r.turnover))
return pd.DataFrame(rows).mean().to_dict()
def run(panel: str, top_n: int, every: int) -> pd.DataFrame:
p = load(panel)
fwd5 = forward_returns(p, 5)
rows = []
for name, s in factors(p).items():
row = {"panel": panel, "factor": name, "top_n": top_n, "every": every}
for wname, window in (("design", DESIGN), ("holdout", HOLDOUT)):
st = phase_avg(p, s, window, top_n, every)
ic = ic_summary(ic_series(s.loc[window[0]:window[1]], fwd5.loc[window[0]:window[1]],
p.member.loc[window[0]:window[1]], 5))
row.update({f"{wname}_cagr": st["cagr"], f"{wname}_sharpe": st["sharpe"],
f"{wname}_mdd": st["mdd"], f"{wname}_turn": st["turnover"],
f"{wname}_ic5": ic["ic"], f"{wname}_ic_t": ic["t"]})
rows.append(row)
print(f"{name:10s} D cagr {row['design_cagr']:6.1%} sh {row['design_sharpe']:5.2f} "
f"ic {row['design_ic5']:+.3f} | H cagr {row['holdout_cagr']:6.1%} "
f"sh {row['holdout_sharpe']:5.2f} ic {row['holdout_ic5']:+.3f}", flush=True)
return pd.DataFrame(rows)
def ew_universe(p) -> None:
r = p.close.pct_change(fill_method=None).where(p.member.shift(1, fill_value=False))
ew = (1 + r.mean(axis=1).fillna(0.0)).cumprod()
b = p.benchmark
for wname, (a, bnd) in (("design", DESIGN), ("holdout", HOLDOUT)):
se = stats(ew.loc[a:bnd])
sb = stats(b.loc[a:bnd])
print(f" EW members {wname}: cagr {se['cagr']:6.1%} sh {se['sharpe']:5.2f} mdd {se['mdd']:6.1%}"
f" | ETF blend: cagr {sb['cagr']:6.1%} sh {sb['sharpe']:5.2f}")
def main() -> None:
ap = argparse.ArgumentParser()
ap.add_argument("--panel", default="both", choices=["old", "new", "both"])
ap.add_argument("--top-n", type=int, default=20)
ap.add_argument("--every", type=int, default=10)
args = ap.parse_args()
panels = ["old", "new"] if args.panel == "both" else [args.panel]
out = []
for panel in panels:
print(f"\n=== {panel} panel ({args.top_n} names, every {args.every}) ===")
p = load(panel)
ew_universe(p)
out.append(run(panel, args.top_n, args.every))
df = pd.concat(out, ignore_index=True)
os.makedirs(RESULTS, exist_ok=True)
path = os.path.join(RESULTS, f"midsmall_scan_{args.every}.csv")
df.to_csv(path, index=False)
print(f"saved {path}")
if __name__ == "__main__":
main()