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.

142 lines
5.2 KiB
Python

"""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()