"""Re-derive every stored SEC snapshot with the current parser. Snapshots are immutable per accession, so a parser fix does not reach rows that are already stored: a normal import skips them and only logs a ``snapshot_discrepancy``. This script is the deliberate, manual exception -- it restages every accession from SEC Company Facts and rewrites the rows whose reconstruction changed. **Dry run by default.** Nothing is written unless ``--apply`` is passed. The dry run stages and validates exactly as the real run does (both are read-only) and reports the full blast radius: how many rows would change, which fields, and per-symbol before/after samples. Cost: a reparse cannot be served from the database -- the facts a fixed parser now accepts were never stored -- so it refetches Company Facts for every tracked issuer under the SEC fair-access throttle. Expect a long run and a lot of network. Scope note: this rewrites ``fundamental_snapshots`` only. As of the A5 gate those rows feed the fundamentals API/UI and the parity report; scoring still reads the legacy ``fundamental_data`` table, so a reparse does not move composite scores or backtests until the cutover happens. Examples -------- # dry run: report what would change, write nothing python scripts/reparse_fundamentals.py # dry run, showing more per-field detail python scripts/reparse_fundamentals.py --samples 40 # actually rewrite the changed rows python scripts/reparse_fundamentals.py --apply """ from __future__ import annotations import argparse import asyncio import sys from collections import Counter from pathlib import Path sys.path.insert(0, str(Path(__file__).resolve().parents[1])) from app.database import async_session_factory # noqa: E402 from app.services.data_import import run_import # noqa: E402 from app.services.sec_fundamentals_importer import SecFundamentalsImporter # noqa: E402 def _parse_args() -> argparse.Namespace: ap = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter) ap.add_argument("--apply", action="store_true", help="rewrite changed rows (default: dry run, writes nothing)") ap.add_argument("--samples", type=int, default=20, help="how many changed accessions to show in detail (default 20)") return ap.parse_args() async def _dry_run(samples: int) -> int: importer = SecFundamentalsImporter(reparse=True) async with async_session_factory() as db: print("staging every tracked issuer from SEC Company Facts (this is the slow part)...") revision = await importer.detect_revision(db) staged = await importer.stage(db) result = await importer.validate(db, staged) print(f"\nrevision : {revision}") print(f"issuers fetched : {staged.issuers_fetched}") print(f"rows reconstructed : {len(staged.rows)}") print(f"already stored : {len(staged.existing_accessions)}") print(f"WOULD BE REWRITTEN : {len(staged.discrepancies)}") print(f"new inserts : {len(staged.rows) - len(staged.existing_accessions)}") print(f"validation ok : {result.ok}") if not result.ok: print(f"validation messages : {result.messages}") if staged.discrepancies: field_counts = Counter(f for d in staged.discrepancies for f in d["fields"]) print("\nchanged fields (accession count per field):") for name, count in field_counts.most_common(): print(f" {name:28s} {count}") by_accession = {r.accession: r for r in staged.rows} print(f"\nfirst {min(samples, len(staged.discrepancies))} changed accessions:") for d in staged.discrepancies[:samples]: row = by_accession.get(d["accession"]) where = f"{row.cik} {row.fiscal_year} {row.fiscal_period}" if row else "?" print(f" {d['accession']} {where:28s} {', '.join(d['fields'])}") print( "\nDRY RUN -- nothing was written." "\nCheck that the changes are the *kinds* you expect (recovered nulls," "\ncorrected values) and sample issuers you did not anticipate before" "\nre-running with --apply." ) return 0 if result.ok else 1 async def _apply() -> int: # force=True: the revision tracks SEC, which has not changed — the staleness # is on our side, so the normal no-op gate would skip this. run = await run_import(SecFundamentalsImporter(reparse=True), force=True) if run is None: print("another sec_facts import holds the lock; nothing done") return 1 print(f"run {run.id}: status={run.status}") print(f" revision : {run.revision}") print(f" row_counts : {run.row_counts_json}") if run.error_details: print(f" error : {run.error_details}") return 0 if run.status == "promoted" else 1 def main() -> int: args = _parse_args() return asyncio.run(_apply() if args.apply else _dry_run(args.samples)) if __name__ == "__main__": raise SystemExit(main())