"""Fix documents and events with NULL symbol_id / issuer_id. The filing_poller stores ticker in document_id as TICKER::{ticker}. When issuer_sync hadn't run before filing_poller, the lookup maps were empty and symbol_id / issuer_id were inserted as NULL. This script: 1. Extracts ticker from document_id for NULL-symbol docs 2. Looks up correct symbol_id / issuer_id from symbol_master / issuer_master 3. Updates documents and events Usage: python -m apps.tools.fix_null_symbols [--dry-run] """ from __future__ import annotations import argparse import asyncio from sqlalchemy import select, text, update from libs.common.config import get_settings from libs.common.logging import configure_logging, get_logger from libs.db.models import Document, Event, IssuerMaster, SymbolMaster from libs.db.session import get_session logger = get_logger(__name__) async def fix_null_symbols(dry_run: bool = False) -> dict[str, int]: stats = {"docs_total_null": 0, "docs_fixed": 0, "docs_no_match": 0, "events_fixed": 0, "events_no_match": 0} async with get_session() as session: # 1. Build ticker lookup maps from current DB issuer_rows = await session.execute(select(IssuerMaster)) ticker_to_issuer: dict[str, str] = {} for im in issuer_rows.scalars().all(): if im.ticker: ticker_to_issuer[im.ticker.upper()] = im.issuer_id symbol_rows = await session.execute( select(SymbolMaster).where(SymbolMaster.is_primary == True) # noqa: E712 ) ticker_to_symbol: dict[str, str] = {} for sm in symbol_rows.scalars().all(): if sm.ticker: ticker_to_symbol[sm.ticker.upper()] = sm.symbol_id print(f"Lookup maps: {len(ticker_to_issuer)} issuers, {len(ticker_to_symbol)} symbols") # 2. Find documents with NULL symbol_id null_docs = await session.execute( select(Document).where(Document.symbol_id.is_(None)) ) docs = null_docs.scalars().all() stats["docs_total_null"] = len(docs) print(f"Documents with NULL symbol_id: {len(docs)}") # 3. Extract ticker from document_id and update no_match_tickers: set[str] = set() for doc in docs: # document_id format: DOC::sec::TICKER::{ticker}::{date}::{accession} parts = doc.document_id.split("::") if len(parts) >= 4 and parts[2] == "TICKER": ticker = parts[3].upper() else: logger.warning("unexpected_doc_id_format", document_id=doc.document_id) stats["docs_no_match"] += 1 continue sym_id = ticker_to_symbol.get(ticker) iss_id = ticker_to_issuer.get(ticker) if sym_id: doc.symbol_id = sym_id doc.issuer_id = iss_id # may still be None if issuer doesn't exist stats["docs_fixed"] += 1 else: no_match_tickers.add(ticker) stats["docs_no_match"] += 1 if no_match_tickers: print(f"No symbol_master match for {len(no_match_tickers)} tickers: " f"{sorted(no_match_tickers)[:20]}{'...' if len(no_match_tickers) > 20 else ''}") # 4. Fix events with NULL symbol_id (join via primary_document_id) null_events = await session.execute( select(Event).where(Event.symbol_id.is_(None)) ) events = null_events.scalars().all() print(f"Events with NULL symbol_id: {len(events)}") for evt in events: # Look up the parent document doc_result = await session.execute( select(Document).where(Document.document_id == evt.primary_document_id) ) parent_doc = doc_result.scalar_one_or_none() if parent_doc and parent_doc.symbol_id: evt.symbol_id = parent_doc.symbol_id evt.issuer_id = parent_doc.issuer_id stats["events_fixed"] += 1 else: stats["events_no_match"] += 1 # 5. Commit or rollback if dry_run: print("\n[DRY RUN] Rolling back — no changes saved.") await session.rollback() else: await session.flush() print("\nChanges committed.") # Report print(f"\n{'='*50}") print("Fix NULL Symbols Report") print(f"{'='*50}") print(f" Documents with NULL symbol_id: {stats['docs_total_null']}") print(f" Documents fixed: {stats['docs_fixed']}") print(f" Documents no match: {stats['docs_no_match']}") print(f" Events fixed: {stats['events_fixed']}") print(f" Events no match: {stats['events_no_match']}") print(f"{'='*50}") return stats def main() -> None: parser = argparse.ArgumentParser(description="Fix NULL symbol_id in documents and events") parser.add_argument("--dry-run", action="store_true", help="Preview changes without saving") args = parser.parse_args() settings = get_settings() configure_logging(settings.log_level) asyncio.run(fix_null_symbols(dry_run=args.dry_run)) if __name__ == "__main__": main()