Files
signal-platform/scripts/extend_snapshot_universe.py
T
dennisthiessenandClaude Opus 5 3e83d63b05 chore: decommission FMP, Finnhub and Alpha Vantage (A6)
The A5 cutover has been on and observed in production, so SEC Company Facts +
DoltHub earnings are already the live source for `fundamental_data`. This
removes everything the legacy path still occupied.

Gone: the three providers and their config/env keys; the weekly
`fundamental_collector` job; the cutover toggle (SEC + Dolt is now the
unconditional path, so `off` can no longer silently freeze scoring inputs); the
A5 parity report, whose deltas became structurally zero once the candidate
builder started writing the table it compared against; and the FMP tier of
universe bootstrap.

Two behavioral notes:

- Disabling **SEC Fundamentals Import** now stops the SEC network fetch only.
  The local cache refresh moved outside the job-enable check, because candidates
  also derive from daily closes and earnings events — freezing those on an
  ingestion pause would stale scoring with no fallback left to recover from.
- `/ingestion/fetch?sources=fundamentals` still accepts the key and reports
  `skipped`; there is no per-ticker fetch any more.

Migration 029 does not blanket-delete the leftover settings rows. Migrations run
before the service restart, and pre-A6 code reads an absent `job_*_enabled` row
as *enabled* — so the two behavior-bearing keys become tombstones pinned to safe
values (hidden in Admin) and only the inert three are deleted. Removing the
provider keys from the production `.env` is the matching rollout step.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-07 11:19:28 +02:00

482 lines
16 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""Extend a *copy* of the production backtest snapshot with broad-universe OHLCV.
Research only — never writes to production Postgres.
Pipeline
--------
1. Copy ``--source`` snapshot (default ``backtest_snapshots/prod.sqlite``) to
``--output`` (default ``backtest_snapshots/research.sqlite``).
2. Resolve symbol pool = nasdaq_all sp500 via ``ticker_universe_service``.
3. Fetch ~5y daily bars from Alpaca for symbols missing (or short) in the copy.
4. Insert new tickers + OHLCV; mark them in side table ``research_rank_only``
so the harness can feed signal IC without GTL/candidate replay.
5. Write a **completion manifest** (``<output>.manifest.json``) with ticker /
OHLCV / rank_only counts and finished-at. Breadth runners refuse to start
without a matching complete manifest — same class of guard as calendar
truncation (see 2026-07-18 21:14 race: orphaned +0.0575 on a partial pool).
Resume-friendly: re-running skips symbols that already have ≥ ``--min-bars``.
A ``--limit`` smoke run writes ``complete: false`` so breadth mode still refuses.
Example
-------
python scripts/extend_snapshot_universe.py \\
--source backtest_snapshots/prod.sqlite \\
--output backtest_snapshots/research.sqlite \\
--force-copy
# smoke: first 50 missing symbols only
python scripts/extend_snapshot_universe.py --limit 50
"""
from __future__ import annotations
import argparse
import asyncio
import shutil
import sys
import time
from datetime import date, datetime, timedelta, timezone
from pathlib import Path
from sqlalchemy import create_engine, text
ROOT = Path(__file__).resolve().parents[1]
if str(ROOT) not in sys.path:
sys.path.insert(0, str(ROOT))
from app.ssl_bootstrap import bootstrap_ssl # noqa: E402
bootstrap_ssl()
def _parse_args() -> argparse.Namespace:
p = argparse.ArgumentParser(description=__doc__)
p.add_argument(
"--source",
default="backtest_snapshots/prod.sqlite",
help="Existing prod snapshot to copy (read-only after copy).",
)
p.add_argument(
"--output",
default="backtest_snapshots/research.sqlite",
help="Research snapshot path (created/updated).",
)
p.add_argument(
"--force-copy",
action="store_true",
help="Overwrite output by re-copying from source first.",
)
p.add_argument(
"--history-days",
type=int,
default=1825,
help="OHLCV lookback days (~5y). Default 1825.",
)
p.add_argument(
"--min-bars",
type=int,
default=260,
help="Skip re-fetch when a symbol already has this many bars.",
)
p.add_argument(
"--limit",
type=int,
default=None,
help="Max *new* symbols to fetch (smoke tests).",
)
p.add_argument(
"--sleep",
type=float,
default=0.15,
help="Seconds between Alpaca symbol requests (rate-limit cushion).",
)
p.add_argument(
"--max-retries",
type=int,
default=5,
help="Retries per symbol on RateLimitError.",
)
p.add_argument(
"--source-symbols-only",
action="store_true",
help=(
"Refresh only symbols present in --source. Useful for repairing "
"per-symbol depth without re-fetching the broad rank-only pool."
),
)
p.add_argument("--quiet", action="store_true")
return p.parse_args()
def _ensure_rank_only_table(engine) -> None:
"""DDL in its own connection/transaction (don't share with ORM Session)."""
with engine.begin() as conn:
conn.execute(
text(
"""
CREATE TABLE IF NOT EXISTS research_rank_only (
ticker_id INTEGER PRIMARY KEY,
symbol TEXT NOT NULL UNIQUE
)
"""
)
)
async def _resolve_pool() -> tuple[list[str], dict[str, str]]:
"""Return sorted unique symbols and source labels.
Offline-safe: does **not** use production Postgres or SystemSetting cache
(those require a schema). Public sources first, then seeds.
"""
from app.services.ticker_universe_service import (
_SEED_UNIVERSES,
_fetch_universe_symbols_from_public,
_normalise_symbols,
)
sources: dict[str, str] = {}
symbols: set[str] = set()
for universe in ("nasdaq_all", "sp500"):
cleaned: list[str] = []
src = "none"
public_symbols, public_failures, public_source = (
await _fetch_universe_symbols_from_public(universe)
)
cleaned = _normalise_symbols(public_symbols)
if cleaned:
src = public_source or "public"
elif public_failures:
print(
f" WARNING: public fetch {universe}: "
f"{'; '.join(public_failures[:3])}"
)
if not cleaned:
cleaned = _normalise_symbols(_SEED_UNIVERSES.get(universe, []))
if cleaned:
src = "seed"
print(
f" WARNING: {universe} fell back to seed list "
f"({len(cleaned)} symbols) — not full universe"
)
if not cleaned:
print(f" WARNING: universe {universe} returned no symbols")
continue
sources[universe] = src
symbols.update(cleaned)
print(f" {universe}: {len(cleaned)} symbols (source={src})")
return sorted(symbols), sources
async def _fetch_symbol_bars(
provider,
symbol: str,
start: date,
end: date,
*,
max_retries: int,
sleep_s: float,
) -> list:
from app.exceptions import ProviderError, RateLimitError
for attempt in range(max_retries):
try:
bars = await provider.fetch_ohlcv(symbol, start, end)
if sleep_s > 0:
await asyncio.sleep(sleep_s)
return bars
except RateLimitError:
wait = min(60.0, 2.0 ** attempt)
print(f" rate limited on {symbol}; sleep {wait:.0f}s")
await asyncio.sleep(wait)
except ProviderError as exc:
if attempt + 1 >= max_retries:
raise
await asyncio.sleep(1.0)
_ = exc
return []
async def _main() -> None:
# ROOT is already on sys.path; keep the helper import path-local.
from research_snapshot_manifest import ( # type: ignore[import-not-found]
clear_manifest,
write_completion_manifest,
)
args = _parse_args()
source = Path(args.source)
output = Path(args.output)
if not source.exists():
raise SystemExit(f"Source snapshot not found: {source}")
source_engine = create_engine(
f"sqlite:///{source.resolve().as_posix()}", future=True
)
with source_engine.connect() as conn:
source_symbols = {
str(row[0]) for row in conn.execute(text("SELECT symbol FROM tickers"))
}
source_engine.dispose()
# Any rebuild/update invalidates prior completion until we finish cleanly.
clear_manifest(output)
if args.force_copy or not output.exists():
output.parent.mkdir(parents=True, exist_ok=True)
if output.exists():
output.unlink()
print(f"Copying {source}{output}")
shutil.copy2(source, output)
else:
print(f"Updating existing research snapshot: {output}")
from app.config import settings
from app.providers.alpaca import AlpacaOHLCVProvider
if not settings.alpaca_api_key or not settings.alpaca_api_secret:
raise SystemExit("ALPACA_API_KEY / ALPACA_API_SECRET required in .env")
provider = AlpacaOHLCVProvider(settings.alpaca_api_key, settings.alpaca_api_secret)
end = date.today()
start = end - timedelta(days=int(args.history_days))
print("Resolving universe pool (nasdaq_all sp500)…")
if args.source_symbols_only:
pool = sorted(source_symbols)
sources = {"pool": "source_snapshot"}
print(" source snapshot: symbol pool selected")
else:
pool, sources = await _resolve_pool()
print(f"Pool size: {len(pool)} (sources={sources})")
# Sync sqlite via raw SQL — one short transaction per symbol so a failed
# write never leaves the session in "transaction is inactive".
engine = create_engine(
f"sqlite:///{output.resolve().as_posix()}",
future=True,
)
_ensure_rank_only_table(engine)
with engine.connect() as conn:
existing_rows = conn.execute(
text("SELECT id, symbol FROM tickers")
).fetchall()
existing_ids = {str(sym): int(tid) for tid, sym in existing_rows}
prod_symbols = set(source_symbols)
bar_counts: dict[str, int] = {}
for sym, tid in existing_ids.items():
n = conn.execute(
text("SELECT COUNT(*) FROM ohlcv_records WHERE ticker_id = :tid"),
{"tid": tid},
).scalar_one()
bar_counts[sym] = int(n)
to_fetch: list[str] = []
for sym in pool:
if sym in existing_ids and bar_counts.get(sym, 0) >= args.min_bars:
continue
to_fetch.append(sym)
if args.limit is not None:
to_fetch = to_fetch[: max(0, int(args.limit))]
print(f"Symbols to fetch/extend: {len(to_fetch)}")
ok = 0
fail = 0
t0 = time.monotonic()
insert_ohlcv = text(
"""
INSERT INTO ohlcv_records
(ticker_id, date, open, high, low, close, volume, created_at)
VALUES
(:ticker_id, :date, :open, :high, :low, :close, :volume, :created_at)
"""
)
for index, sym in enumerate(to_fetch, 1):
try:
bars = await _fetch_symbol_bars(
provider,
sym,
start,
end,
max_retries=args.max_retries,
sleep_s=args.sleep,
)
except Exception as exc:
fail += 1
if not args.quiet:
print(f" [{index}/{len(to_fetch)}] {sym} FAIL {exc}")
continue
if not bars:
fail += 1
if not args.quiet:
print(f" [{index}/{len(to_fetch)}] {sym} empty")
continue
try:
with engine.begin() as write:
ticker_id = existing_ids.get(sym)
is_new = ticker_id is None
if is_new:
write.execute(
text(
"INSERT INTO tickers (symbol, name, created_at) "
"VALUES (:sym, NULL, :created)"
),
{
"sym": sym,
"created": datetime.now(timezone.utc).isoformat(),
},
)
ticker_id = int(
write.execute(
text("SELECT id FROM tickers WHERE symbol = :sym"),
{"sym": sym},
).scalar_one()
)
existing_ids[sym] = ticker_id
write.execute(
text(
"DELETE FROM ohlcv_records WHERE ticker_id = :tid "
"AND date >= :start AND date <= :end"
),
{
"tid": ticker_id,
"start": start.isoformat(),
"end": end.isoformat(),
},
)
now = datetime.now(timezone.utc).replace(tzinfo=None)
write.execute(
insert_ohlcv,
[
{
"ticker_id": ticker_id,
"date": b.date.isoformat(),
"open": float(b.open),
"high": float(b.high),
"low": float(b.low),
"close": float(b.close),
"volume": int(b.volume),
"created_at": now.isoformat(),
}
for b in bars
],
)
if is_new and sym not in prod_symbols:
write.execute(
text(
"INSERT OR REPLACE INTO research_rank_only "
"(ticker_id, symbol) VALUES (:tid, :sym)"
),
{"tid": ticker_id, "sym": sym},
)
except Exception as exc:
fail += 1
if not args.quiet:
print(f" [{index}/{len(to_fetch)}] {sym} WRITE FAIL {exc}")
continue
ok += 1
if not args.quiet and (index % 25 == 0 or index == len(to_fetch)):
elapsed = time.monotonic() - t0
print(
f" progress {index}/{len(to_fetch)} ok={ok} fail={fail} "
f"elapsed={elapsed/60:.1f}m last={sym} bars={len(bars)}"
)
benchmark_rows = 0
try:
benchmark_bars = await _fetch_symbol_bars(
provider,
"SPY",
start,
end,
max_retries=args.max_retries,
sleep_s=args.sleep,
)
with engine.begin() as write:
write.execute(
text(
"DELETE FROM benchmark_prices WHERE symbol='SPY' "
"AND date >= :start AND date <= :end"
),
{"start": start.isoformat(), "end": end.isoformat()},
)
if benchmark_bars:
write.execute(
text(
"INSERT INTO benchmark_prices(symbol, date, close) "
"VALUES ('SPY', :date, :close)"
),
[
{"date": bar.date.isoformat(), "close": float(bar.close)}
for bar in benchmark_bars
],
)
benchmark_rows = len(benchmark_bars)
except Exception as exc:
print(f" benchmark SPY refresh FAIL {exc}")
rank_only_n = conn.execute(
text("SELECT COUNT(*) FROM research_rank_only")
).scalar_one()
ticker_n = conn.execute(text("SELECT COUNT(*) FROM tickers")).scalar_one()
ohlcv_n = conn.execute(
text("SELECT COUNT(*) FROM ohlcv_records")
).scalar_one()
# Full planned work only when --limit is unset. Smoke runs stay incomplete
# so breadth mode cannot mythologize a 50-symbol toy pool.
is_complete = args.limit is None
manifest_path = write_completion_manifest(
output,
complete=is_complete,
sources=sources,
history_days=int(args.history_days),
min_bars=int(args.min_bars),
fetch_ok=ok,
fetch_fail=fail,
limit=args.limit,
extra={
"prod_symbols_at_start": len(prod_symbols),
"pool_size": len(pool),
"to_fetch": len(to_fetch),
"source_symbols_only": bool(args.source_symbols_only),
"benchmark_spy_rows": benchmark_rows,
},
)
print("Done.")
print(f" output: {output}")
print(f" tickers: {ticker_n}")
print(f" ohlcv rows: {ohlcv_n}")
print(f" research_rank_only: {rank_only_n}")
print(f" fetched ok/fail: {ok}/{fail}")
print(
f" completion manifest: {manifest_path} "
f"(complete={is_complete})"
)
if not is_complete:
print(
" NOTE: --limit set → complete=false; breadth runners will refuse "
"this snapshot until a full extend finishes."
)
if __name__ == "__main__":
asyncio.run(_main())