|
|
"""
|
|
|
News v2 historical backfill — Finnhub (and future Alpaca catch-up).
|
|
|
|
|
|
Usage (inside the API container):
|
|
|
python scripts/news_backfill.py \\
|
|
|
--source finnhub \\
|
|
|
--tickers AAPL,MSFT,NVDA \\
|
|
|
--start 2025-04-25 \\
|
|
|
--end 2026-04-25 \\
|
|
|
--chunk monthly
|
|
|
|
|
|
python scripts/news_backfill.py --source finnhub \\
|
|
|
--tickers-file data/universe/v49_active.txt \\
|
|
|
--start 2025-04-25 --end 2026-04-25
|
|
|
|
|
|
Estimated runtime (Finnhub free, 60 calls/min, monthly chunks):
|
|
|
100 tickers × 12 months ≈ 1,200 calls ≈ 20 minutes.
|
|
|
|
|
|
Idempotent: re-runs hit ON CONFLICT DO NOTHING on
|
|
|
(source, source_id, ticker), so overlapping windows are safe.
|
|
|
"""
|
|
|
|
|
|
from __future__ import annotations
|
|
|
|
|
|
import argparse
|
|
|
import asyncio
|
|
|
import logging
|
|
|
import os
|
|
|
import sys
|
|
|
from calendar import monthrange
|
|
|
from datetime import date, datetime, timedelta
|
|
|
from pathlib import Path
|
|
|
|
|
|
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
|
|
|
|
|
|
logging.basicConfig(level=logging.INFO, format="%(asctime)s [%(levelname)s] %(message)s")
|
|
|
logger = logging.getLogger("news_backfill")
|
|
|
|
|
|
|
|
|
def _parse_date(s: str) -> date:
|
|
|
return datetime.strptime(s, "%Y-%m-%d").date()
|
|
|
|
|
|
|
|
|
def _load_tickers(args) -> list[str]:
|
|
|
tickers: list[str] = []
|
|
|
if args.tickers:
|
|
|
tickers.extend(t.strip().upper() for t in args.tickers.split(",") if t.strip())
|
|
|
if args.tickers_file:
|
|
|
path = Path(args.tickers_file)
|
|
|
if not path.exists():
|
|
|
raise SystemExit(f"--tickers-file not found: {path}")
|
|
|
for raw in path.read_text().splitlines():
|
|
|
t = raw.strip().upper()
|
|
|
if t and not t.startswith("#"):
|
|
|
tickers.append(t)
|
|
|
if not tickers:
|
|
|
raise SystemExit("Provide --tickers and/or --tickers-file")
|
|
|
# Dedup, preserve order
|
|
|
seen: set[str] = set()
|
|
|
out: list[str] = []
|
|
|
for t in tickers:
|
|
|
if t not in seen:
|
|
|
seen.add(t)
|
|
|
out.append(t)
|
|
|
return out
|
|
|
|
|
|
|
|
|
def _monthly_chunks(start: date, end: date) -> list[tuple[date, date]]:
|
|
|
"""Split [start, end] into per-month [chunk_start, chunk_end] inclusive bounds."""
|
|
|
chunks: list[tuple[date, date]] = []
|
|
|
cur = date(start.year, start.month, 1)
|
|
|
if cur < start:
|
|
|
cur = start
|
|
|
while cur <= end:
|
|
|
last_day_of_month = monthrange(cur.year, cur.month)[1]
|
|
|
chunk_end = min(end, date(cur.year, cur.month, last_day_of_month))
|
|
|
chunks.append((cur, chunk_end))
|
|
|
# Advance to first of next month
|
|
|
if cur.month == 12:
|
|
|
cur = date(cur.year + 1, 1, 1)
|
|
|
else:
|
|
|
cur = date(cur.year, cur.month + 1, 1)
|
|
|
return chunks
|
|
|
|
|
|
|
|
|
def _daily_chunks(start: date, end: date) -> list[tuple[date, date]]:
|
|
|
return [(start + timedelta(days=i), start + timedelta(days=i)) for i in range((end - start).days + 1)]
|
|
|
|
|
|
|
|
|
async def _backfill_finnhub(tickers: list[str], start: date, end: date, chunk: str) -> None:
|
|
|
from app.services.news.finnhub_client import FinnhubClient
|
|
|
from app.services.news.headline_ingest_service import (
|
|
|
finnhub_articles_to_rows,
|
|
|
insert_headline_rows,
|
|
|
)
|
|
|
|
|
|
client = FinnhubClient()
|
|
|
if not client.is_configured():
|
|
|
raise SystemExit("FINNHUB_API_KEY not set in environment")
|
|
|
|
|
|
if chunk == "monthly":
|
|
|
ranges = _monthly_chunks(start, end)
|
|
|
elif chunk == "daily":
|
|
|
ranges = _daily_chunks(start, end)
|
|
|
else:
|
|
|
raise SystemExit(f"Unknown --chunk: {chunk}")
|
|
|
|
|
|
total_calls = len(tickers) * len(ranges)
|
|
|
logger.info(
|
|
|
f"Finnhub backfill: {len(tickers)} tickers × {len(ranges)} {chunk} chunks "
|
|
|
f"= {total_calls} calls (~{total_calls / 60:.1f} min @ 60 cpm)"
|
|
|
)
|
|
|
|
|
|
grand_inserted = 0
|
|
|
grand_calls = 0
|
|
|
try:
|
|
|
for ticker in tickers:
|
|
|
ticker_inserted = 0
|
|
|
for (chunk_start, chunk_end) in ranges:
|
|
|
try:
|
|
|
payload = await client.fetch_company_news(ticker, chunk_start, chunk_end)
|
|
|
except Exception as e:
|
|
|
logger.error(f"Finnhub fetch failed {ticker} {chunk_start}..{chunk_end}: {e}")
|
|
|
continue
|
|
|
grand_calls += 1
|
|
|
if not payload:
|
|
|
continue
|
|
|
rows = finnhub_articles_to_rows(ticker, payload)
|
|
|
if not rows:
|
|
|
continue
|
|
|
inserted = await insert_headline_rows(rows)
|
|
|
ticker_inserted += inserted
|
|
|
grand_inserted += inserted
|
|
|
logger.info(f" {ticker}: +{ticker_inserted} new (cumulative {grand_inserted}, {grand_calls}/{total_calls} calls)")
|
|
|
finally:
|
|
|
await client.close()
|
|
|
|
|
|
logger.info(f"Finnhub backfill done — {grand_inserted} new headlines inserted in {grand_calls} calls")
|
|
|
|
|
|
|
|
|
def main() -> None:
|
|
|
parser = argparse.ArgumentParser(description="News v2 historical backfill")
|
|
|
parser.add_argument("--source", required=True, choices=["finnhub"], help="Vendor source")
|
|
|
parser.add_argument("--tickers", help="Comma-separated ticker list")
|
|
|
parser.add_argument("--tickers-file", help="File with one ticker per line (# comments OK)")
|
|
|
parser.add_argument("--start", required=True, type=_parse_date, help="YYYY-MM-DD inclusive")
|
|
|
parser.add_argument("--end", required=True, type=_parse_date, help="YYYY-MM-DD inclusive")
|
|
|
parser.add_argument("--chunk", default="monthly", choices=["monthly", "daily"], help="Per-call window size")
|
|
|
|
|
|
args = parser.parse_args()
|
|
|
|
|
|
if args.start > args.end:
|
|
|
raise SystemExit("--start must be <= --end")
|
|
|
|
|
|
tickers = _load_tickers(args)
|
|
|
logger.info(f"Loaded {len(tickers)} tickers")
|
|
|
|
|
|
if args.source == "finnhub":
|
|
|
asyncio.run(_backfill_finnhub(tickers, args.start, args.end, args.chunk))
|
|
|
|
|
|
|
|
|
if __name__ == "__main__":
|
|
|
main()
|