You cannot select more than 25 topics
Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
180 lines
7.2 KiB
Python
180 lines
7.2 KiB
Python
"""Backfill market_cap_proxy + exchange_proxy into existing FeatureSnapshot rows.
|
|
|
|
Patches market_v1 FeatureSnapshots that are missing market_cap_proxy in
|
|
their feature_json. This is needed for paper-trading backsim on events
|
|
that were feature-built before the feature-builder started persisting
|
|
company metadata.
|
|
|
|
Usage:
|
|
python -m apps.tools.backfill_market_cap
|
|
python -m apps.tools.backfill_market_cap --start 2026-01-01 --end 2026-03-31
|
|
python -m apps.tools.backfill_market_cap --dry-run
|
|
"""
|
|
from __future__ import annotations
|
|
|
|
import argparse
|
|
import asyncio
|
|
import datetime as dt
|
|
import os
|
|
from pathlib import Path
|
|
|
|
_ENV_FILE = Path(__file__).parent.parent.parent / ".env"
|
|
if _ENV_FILE.exists():
|
|
for _line in _ENV_FILE.read_text().splitlines():
|
|
_line = _line.strip()
|
|
if _line and not _line.startswith("#") and "=" in _line:
|
|
_k, _, _v = _line.partition("=")
|
|
os.environ.setdefault(_k.strip(), _v.strip())
|
|
|
|
from rich.console import Console
|
|
from rich.progress import track
|
|
|
|
console = Console(width=120)
|
|
|
|
|
|
async def run_backfill(
|
|
start_date: dt.date | None,
|
|
end_date: dt.date | None,
|
|
dry_run: bool,
|
|
) -> None:
|
|
from sqlalchemy import select, update
|
|
from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker, create_async_engine
|
|
|
|
from libs.db.models import Event, FeatureSnapshot, SymbolMaster
|
|
from libs.oracle_client import CompanyService, ScreenerService, make_oracle_client
|
|
|
|
db_dsn = os.environ.get("POSTGRES_DSN", "")
|
|
oracle_url = os.environ.get("STOCK_ORACLE_URL", "http://localhost:18001")
|
|
|
|
if not db_dsn:
|
|
console.print("[red]POSTGRES_DSN not set[/]")
|
|
return
|
|
|
|
engine = create_async_engine(db_dsn, echo=False)
|
|
async_session = async_sessionmaker(engine, class_=AsyncSession, expire_on_commit=False)
|
|
|
|
# ── Step 1: find market_v1 snapshots without market_cap_proxy ──────────
|
|
async with async_session() as session:
|
|
stmt = (
|
|
select(FeatureSnapshot, Event, SymbolMaster)
|
|
.join(Event, FeatureSnapshot.event_id == Event.event_id)
|
|
.outerjoin(SymbolMaster, Event.symbol_id == SymbolMaster.symbol_id)
|
|
.where(FeatureSnapshot.snapshot_name == "market_v1")
|
|
)
|
|
if start_date:
|
|
stmt = stmt.where(Event.event_date >= start_date)
|
|
if end_date:
|
|
stmt = stmt.where(Event.event_date <= end_date)
|
|
|
|
rows = (await session.execute(stmt)).all()
|
|
|
|
# Filter to those missing market_cap_proxy
|
|
missing = [
|
|
(snap, event, sym)
|
|
for snap, event, sym in rows
|
|
if not (snap.feature_json or {}).get("market_cap_proxy")
|
|
]
|
|
console.print(f"Found [bold]{len(rows)}[/] market_v1 snapshots, "
|
|
f"[yellow]{len(missing)}[/] missing market_cap_proxy")
|
|
|
|
if not missing or dry_run:
|
|
if dry_run:
|
|
console.print("[dim]Dry-run: no changes written.[/]")
|
|
return
|
|
|
|
# ── Step 2: collect unique tickers ─────────────────────────────────────
|
|
ticker_to_snap: dict[str, list[FeatureSnapshot]] = {}
|
|
for snap, event, sym in missing:
|
|
if sym and sym.ticker:
|
|
ticker_to_snap.setdefault(sym.ticker, []).append(snap)
|
|
|
|
tickers = sorted(ticker_to_snap)
|
|
console.print(f"Fetching company info for [bold]{len(tickers)}[/] unique tickers…")
|
|
|
|
# ── Step 3: batch-fetch via screener first, then per-symbol CompanyService ─
|
|
ticker_mcap: dict[str, float | None] = {t: None for t in tickers}
|
|
ticker_exchange: dict[str, str | None] = {t: None for t in tickers}
|
|
|
|
async with make_oracle_client() as client:
|
|
# Try screener first (batch — faster, one call per page)
|
|
try:
|
|
svc = ScreenerService(client)
|
|
stocks = await svc.search_all_stocks(
|
|
market_cap_min=500_000_000,
|
|
exchange="NYSE,NASDAQ,AMEX",
|
|
exclude_types="ETF,FUND,ADR,SPAC",
|
|
)
|
|
screener_lookup = {(s.symbol or "").upper(): s for s in stocks}
|
|
for t in tickers:
|
|
s = screener_lookup.get(t.upper())
|
|
if s:
|
|
ticker_mcap[t] = s.market_cap
|
|
ticker_exchange[t] = s.exchange
|
|
resolved = sum(1 for t in tickers if ticker_mcap.get(t) is not None)
|
|
console.print(f" Screener resolved {resolved}/{len(tickers)}")
|
|
except Exception as exc:
|
|
console.print(f" [yellow]Screener failed ({exc}), falling back to CompanyService[/]")
|
|
|
|
# Per-symbol CompanyService for anything still missing
|
|
unresolved = [t for t in tickers if ticker_mcap.get(t) is None]
|
|
if unresolved:
|
|
company_svc = CompanyService(client)
|
|
semaphore = asyncio.Semaphore(16)
|
|
|
|
async def _fetch_one(ticker: str) -> None:
|
|
async with semaphore:
|
|
try:
|
|
info = await company_svc.get_company(ticker)
|
|
ticker_mcap[ticker] = info.market_cap
|
|
ticker_exchange[ticker] = info.exchange
|
|
except Exception:
|
|
pass
|
|
|
|
await asyncio.gather(*(_fetch_one(t) for t in unresolved))
|
|
resolved2 = sum(1 for t in unresolved if ticker_mcap.get(t) is not None)
|
|
console.print(f" CompanyService resolved {resolved2}/{len(unresolved)} remaining")
|
|
|
|
# ── Step 4: update feature_json ─────────────────────────────────────────
|
|
updated = 0
|
|
skipped = 0
|
|
async with async_session() as session:
|
|
for ticker, snaps in track(ticker_to_snap.items(), description="Updating…"):
|
|
mcap = ticker_mcap.get(ticker)
|
|
exch = ticker_exchange.get(ticker)
|
|
if mcap is None and exch is None:
|
|
skipped += len(snaps)
|
|
continue
|
|
for snap in snaps:
|
|
fj = dict(snap.feature_json or {})
|
|
if mcap is not None:
|
|
fj["market_cap_proxy"] = mcap
|
|
if exch is not None:
|
|
fj["exchange_proxy"] = exch
|
|
await session.execute(
|
|
update(FeatureSnapshot)
|
|
.where(FeatureSnapshot.feature_snapshot_id == snap.feature_snapshot_id)
|
|
.values(feature_json=fj)
|
|
)
|
|
updated += 1
|
|
|
|
await session.commit()
|
|
|
|
await engine.dispose()
|
|
console.print(f"\n[bold green]Done.[/] Updated {updated} snapshots, skipped {skipped} (no data).")
|
|
|
|
|
|
def main() -> None:
|
|
parser = argparse.ArgumentParser(description="Backfill market_cap_proxy into FeatureSnapshots")
|
|
parser.add_argument("--start", default=None, metavar="YYYY-MM-DD")
|
|
parser.add_argument("--end", default=None, metavar="YYYY-MM-DD")
|
|
parser.add_argument("--dry-run", action="store_true", help="Show what would be done without writing")
|
|
args = parser.parse_args()
|
|
|
|
start = dt.date.fromisoformat(args.start) if args.start else None
|
|
end = dt.date.fromisoformat(args.end) if args.end else None
|
|
asyncio.run(run_backfill(start, end, args.dry_run))
|
|
|
|
|
|
if __name__ == "__main__":
|
|
main()
|