feat(sec): reparse path, CIK overrides, resolution validation

Operational plumbing to land the parser fixes and to make silent resolution
failures visible.

- Reparse: SecFundamentalsImporter(reparse=True) restages every accession with
  the current parser and rewrites the ones that now reconstruct differently,
  writing the full column set so a row is never half old-parse. Snapshots stay
  immutable with respect to SEC; the stored row is our reconstruction, and after
  a parser fix keeping it is a stale cache, not history. run_import(force=True)
  bypasses the unchanged-revision no-op, since the staleness is on our side, not
  the source's. Exposed as scripts/reparse_fundamentals.py, dry-run by default.
- CIK overrides: sec_universe reads a {symbol: cik} pin from
  SystemSetting['sec_cik_overrides'], applied ahead of company_tickers.json, for
  when SEC maps a ticker to a successor shell with no filings (XOM -> a zero-
  filing "ExxonMobil Holdings Corp" while every 10-K/Q is under CIK 34088).
- Resolution validation: a tracked issuer resolving to a registrant with no XBRL
  filings now records no_xbrl_filings and raises a warning naming the CIKs and
  the override setting, instead of silently yielding nothing on every run.
- _diff_fields compares datetime instants, not representations: accepted_at
  round-trips naive from SQLite but tz-aware from Postgres, which otherwise made
  a reparse of identical data report every row as changed (and false-positived
  the pre-existing discrepancy warning).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-07-24 10:24:05 +02:00
co-authored by Claude Opus 4.8
parent 921f3d06fb
commit e54f03cba6
5 changed files with 449 additions and 12 deletions
+120
View File
@@ -0,0 +1,120 @@
"""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())