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.
123 lines
4.9 KiB
Python
123 lines
4.9 KiB
Python
"""Fetch EOD prices from Tiingo (free tier: 500 unique symbols/month, 50/hour).
|
|
|
|
Used for: (1) completing delisted/missing names in the research panels,
|
|
(2) independent cross-validation of the Yahoo-sourced closes. The key comes
|
|
from TIINGO_API_KEY (environment or .env) and is never logged or written.
|
|
|
|
Writes data/research/tiingo/<ticker>.csv (resume-safe; a cached symbol never
|
|
counts against the monthly quota again). Columns: as-traded open/high/low/
|
|
close/volume (Tiingo's close is NOT split-adjusted), split/dividend-adjusted
|
|
adjOpen..adjVolume, plus splitFactor and divCash.
|
|
|
|
PYTHONPATH=. uv run python -m research.lab.fetch_tiingo TICKER [TICKER ...]
|
|
PYTHONPATH=. uv run python -m research.lab.fetch_tiingo --file symbols.txt
|
|
PYTHONPATH=. uv run python -m research.lab.fetch_tiingo --file l.txt --min-interval 75
|
|
"""
|
|
from __future__ import annotations
|
|
|
|
import argparse
|
|
import os
|
|
import re
|
|
import sys
|
|
import time
|
|
|
|
import pandas as pd
|
|
import requests
|
|
|
|
DIR = os.path.join("data", "research", "tiingo")
|
|
BASE = "https://api.tiingo.com/tiingo/daily/{t}/prices"
|
|
KEEP = ("date", "open", "high", "low", "close", "volume",
|
|
"adjOpen", "adjHigh", "adjLow", "adjClose", "adjVolume",
|
|
"splitFactor", "divCash")
|
|
|
|
|
|
def api_key() -> str:
|
|
key = os.environ.get("TIINGO_API_KEY")
|
|
if not key and os.path.exists(".env"):
|
|
for line in open(".env"):
|
|
m = re.match(r"\s*TIINGO_API_KEY\s*=\s*(.+)\s*$", line)
|
|
if m:
|
|
key = m.group(1).strip().strip("'\"")
|
|
break
|
|
if not key:
|
|
raise SystemExit("TIINGO_API_KEY not found (env or .env)")
|
|
return key
|
|
|
|
|
|
def fetch(ticker: str, key: str, start: str = "1990-01-01") -> pd.DataFrame | None:
|
|
"""One symbol's full EOD history; None when Tiingo does not have it (404).
|
|
Network errors back off and retry; a hard failure skips the ticker."""
|
|
for attempt in range(4):
|
|
try:
|
|
r = requests.get(BASE.format(t=ticker.lower()),
|
|
params={"startDate": start, "format": "json"},
|
|
headers={"Content-Type": "application/json",
|
|
"Authorization": f"Token {key}"}, timeout=60)
|
|
except requests.RequestException as exc:
|
|
print(f"{ticker}: {type(exc).__name__}; retry {attempt + 1}", flush=True)
|
|
time.sleep(20 * (attempt + 1))
|
|
continue
|
|
if r.status_code == 429:
|
|
print(f"{ticker}: rate limited; waiting 15 min", flush=True)
|
|
time.sleep(900)
|
|
continue
|
|
if r.status_code in (402, 403): # monthly symbol quota exhausted
|
|
raise SystemExit(f"{ticker}: HTTP {r.status_code} — quota reached; "
|
|
"remaining tickers stay uncached for the next run")
|
|
if r.status_code == 404:
|
|
return None
|
|
r.raise_for_status()
|
|
d = r.json()
|
|
if not d:
|
|
return None
|
|
df = pd.DataFrame(d)
|
|
df["date"] = pd.to_datetime(df["date"]).dt.tz_localize(None)
|
|
keep = [c for c in KEEP if c in df.columns]
|
|
return df[keep].set_index("date")
|
|
raise RuntimeError(f"{ticker}: network retries exhausted")
|
|
|
|
|
|
def main() -> None:
|
|
ap = argparse.ArgumentParser()
|
|
ap.add_argument("tickers", nargs="*")
|
|
ap.add_argument("--file", help="one ticker per line")
|
|
ap.add_argument("--min-interval", type=float, default=75.0,
|
|
help="seconds between network fetches (free tier allows 50/hour)")
|
|
ap.add_argument("--start", default="1990-01-01")
|
|
args = ap.parse_args()
|
|
tickers = [l.strip() for l in open(args.file) if l.strip()] if args.file else args.tickers
|
|
if not tickers:
|
|
raise SystemExit("usage: fetch_tiingo TICKER ... | --file list.txt")
|
|
|
|
os.makedirs(DIR, exist_ok=True)
|
|
key = api_key()
|
|
todo = [t for t in tickers if not os.path.exists(os.path.join(DIR, f"{t.upper()}.csv"))]
|
|
print(f"{len(tickers)} tickers, {len(todo)} to fetch", flush=True)
|
|
fetched = failed = 0
|
|
last = 0.0
|
|
for i, t in enumerate(todo):
|
|
wait = args.min_interval - (time.time() - last)
|
|
if wait > 0:
|
|
time.sleep(wait)
|
|
last = time.time()
|
|
try:
|
|
df = fetch(t, key, args.start)
|
|
except RuntimeError:
|
|
print(f"[{i + 1}/{len(todo)}] {t:8s} network fail; retried next run", flush=True)
|
|
continue
|
|
if df is None:
|
|
failed += 1
|
|
print(f"[{i + 1}/{len(todo)}] {t:8s} not found", flush=True)
|
|
continue
|
|
df.to_csv(os.path.join(DIR, f"{t.upper()}.csv"))
|
|
fetched += 1
|
|
eta_min = (len(todo) - i - 1) * args.min_interval / 60
|
|
print(f"[{i + 1}/{len(todo)}] {t:8s} {len(df)} rows "
|
|
f"{df.index.min().date()}..{df.index.max().date()} (ETA {eta_min:.0f}m)",
|
|
flush=True)
|
|
print(f"done: {fetched} fetched, {failed} not found", flush=True)
|
|
|
|
|
|
if __name__ == "__main__":
|
|
main()
|