The A5 cutover has been on and observed in production, so SEC Company Facts + DoltHub earnings are already the live source for `fundamental_data`. This removes everything the legacy path still occupied. Gone: the three providers and their config/env keys; the weekly `fundamental_collector` job; the cutover toggle (SEC + Dolt is now the unconditional path, so `off` can no longer silently freeze scoring inputs); the A5 parity report, whose deltas became structurally zero once the candidate builder started writing the table it compared against; and the FMP tier of universe bootstrap. Two behavioral notes: - Disabling **SEC Fundamentals Import** now stops the SEC network fetch only. The local cache refresh moved outside the job-enable check, because candidates also derive from daily closes and earnings events — freezing those on an ingestion pause would stale scoring with no fallback left to recover from. - `/ingestion/fetch?sources=fundamentals` still accepts the key and reports `skipped`; there is no per-ticker fetch any more. Migration 029 does not blanket-delete the leftover settings rows. Migrations run before the service restart, and pre-A6 code reads an absent `job_*_enabled` row as *enabled* — so the two behavior-bearing keys become tombstones pinned to safe values (hidden in Admin) and only the inert three are deleted. Removing the provider keys from the production `.env` is the matching rollout step. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
121 lines
4.9 KiB
Python
121 lines
4.9 KiB
Python
"""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. Those rows now feed both
|
|
the fundamentals API/UI *and* — through the nightly ``fundamental_data`` refresh —
|
|
the fundamental dimension of the composite score, so a reparse does move scores
|
|
and backtests. Run it deliberately.
|
|
|
|
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())
|