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>
160 lines
6.9 KiB
Python
160 lines
6.9 KiB
Python
"""Tracked-universe CIK/SIC resolution and the SEC importer's composite revision.
|
|
|
|
Resolves the app's tracked tickers to SEC issuers (CIK) and prepares
|
|
``tickers.cik/sic/sic_description`` back-fills. Also builds the **universe
|
|
fingerprint** in the importer's composite revision, so adding a ticker changes
|
|
the revision and forces a run instead of being ``no_op``'d away or starved
|
|
waiting for its issuer to file (A3 design, Decision 1 review fix).
|
|
|
|
**Transaction contract:** resolution is read-only — `resolve_ciks` and
|
|
`fetch_sic_updates` compute *proposed* updates and mutate nothing. They run in
|
|
the importer's `stage` (which must not write, or a failed validation would leak
|
|
changes on the framework's failure commit). The proposals are applied only in
|
|
`promote`, via `apply_ticker_updates`, atomically with the snapshot inserts.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import hashlib
|
|
import json
|
|
import logging
|
|
from dataclasses import dataclass, field
|
|
from typing import Iterable
|
|
|
|
from sqlalchemy import select, update
|
|
|
|
from app.models.ticker import Ticker
|
|
from app.services import settings_store
|
|
from app.services.earnings_alignment import normalise_symbol
|
|
from app.services.sec_client import SecClient
|
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
# JSON {symbol: cik} pinning a ticker to a specific registrant, overriding
|
|
# company_tickers.json. Needed when SEC maps a ticker to a successor entity that
|
|
# has not filed: XOM points at CIK 2115436 "ExxonMobil Holdings Corp" (zero XBRL
|
|
# filings) while every 10-K/10-Q — including one filed 2026-05-04 — is still under
|
|
# CIK 34088. Which registrant is the real filer is a judgement about a corporate
|
|
# event, so it is pinned explicitly rather than guessed. The importer's
|
|
# `no_xbrl_filings` warning is what tells you a pin is needed.
|
|
CIK_OVERRIDES_KEY = "sec_cik_overrides"
|
|
|
|
|
|
@dataclass
|
|
class ResolvedUniverse:
|
|
"""Read-only result of CIK resolution. `cik_updates` are proposed writes
|
|
(ticker_id → new cik string) applied later in promote."""
|
|
|
|
symbol_to_cik: dict[str, int] = field(default_factory=dict)
|
|
cik_to_ticker_ids: dict[int, list[int]] = field(default_factory=dict)
|
|
cik_updates: list[tuple[int, str]] = field(default_factory=list)
|
|
|
|
|
|
async def resolve_ciks(db, client: SecClient) -> ResolvedUniverse:
|
|
"""Resolve tracked tickers to CIKs via company_tickers.json. **Read-only** —
|
|
returns the mapping + proposed `tickers.cik` writes; mutates nothing."""
|
|
ticker_to_cik = await client.company_tickers()
|
|
overrides = await cik_overrides(db)
|
|
rows = (await db.execute(select(Ticker.id, Ticker.symbol, Ticker.cik))).all()
|
|
|
|
result = ResolvedUniverse()
|
|
for tid, symbol, current_cik in rows:
|
|
if not symbol:
|
|
continue
|
|
sym = normalise_symbol(symbol)
|
|
cik = overrides.get(sym) or ticker_to_cik.get(sym)
|
|
if cik is None:
|
|
continue # ADRs / non-SEC issuers — snapshots simply absent
|
|
result.symbol_to_cik[sym] = cik
|
|
result.cik_to_ticker_ids.setdefault(cik, []).append(tid)
|
|
if current_cik != f"{cik:010d}":
|
|
result.cik_updates.append((tid, f"{cik:010d}"))
|
|
logger.info(
|
|
"resolve_ciks: %d resolved, %d proposed cik updates",
|
|
len(result.symbol_to_cik),
|
|
len(result.cik_updates),
|
|
)
|
|
return result
|
|
|
|
|
|
async def cik_overrides(db) -> dict[str, int]:
|
|
"""Manual ``{symbol: cik}`` pins from ``SystemSetting[CIK_OVERRIDES_KEY]``.
|
|
|
|
A malformed setting must never take the importer down, so anything unparseable
|
|
is logged and ignored — the run then falls back to company_tickers.json.
|
|
"""
|
|
raw = await settings_store.get_value(db, CIK_OVERRIDES_KEY)
|
|
if not raw:
|
|
return {}
|
|
try:
|
|
loaded = json.loads(raw)
|
|
except (TypeError, ValueError):
|
|
logger.warning("%s is not valid JSON — ignoring CIK overrides", CIK_OVERRIDES_KEY)
|
|
return {}
|
|
if not isinstance(loaded, dict):
|
|
logger.warning("%s must be a {symbol: cik} object — ignoring", CIK_OVERRIDES_KEY)
|
|
return {}
|
|
out: dict[str, int] = {}
|
|
for symbol, cik in loaded.items():
|
|
try:
|
|
out[normalise_symbol(str(symbol))] = int(cik)
|
|
except (TypeError, ValueError):
|
|
logger.warning("%s: bad entry %r -> %r — ignoring", CIK_OVERRIDES_KEY, symbol, cik)
|
|
if out:
|
|
logger.info("resolve_ciks: %d CIK override(s) applied: %s", len(out), sorted(out))
|
|
return out
|
|
|
|
|
|
async def fetch_sic_updates(
|
|
client: SecClient, cik_to_ticker_ids: dict[int, Iterable[int]]
|
|
) -> list[tuple[int, str | None, str | None]]:
|
|
"""Fetch SIC for each CIK (recent-only submissions, no history shards) and
|
|
return proposed `(ticker_id, sic, sic_description)` writes. **Read-only** — no
|
|
DB mutation. Callers pass only the CIKs that need it (e.g. missing a SIC)."""
|
|
updates: list[tuple[int, str | None, str | None]] = []
|
|
for cik, ticker_ids in cik_to_ticker_ids.items():
|
|
sub = await client.submissions(cik, include_history=False)
|
|
sic = str(sub["sic"]) if sub.get("sic") else None
|
|
desc = sub.get("sic_description")
|
|
for tid in ticker_ids:
|
|
updates.append((tid, sic, desc))
|
|
return updates
|
|
|
|
|
|
async def apply_ticker_updates(
|
|
db,
|
|
resolved: ResolvedUniverse,
|
|
sic_updates: list[tuple[int, str | None, str | None]] | None = None,
|
|
) -> dict[str, int]:
|
|
"""Apply the proposed cik / sic writes. **The only writer** — call inside
|
|
promote so it commits atomically with the snapshot inserts."""
|
|
for tid, cik in resolved.cik_updates:
|
|
await db.execute(update(Ticker).where(Ticker.id == tid).values(cik=cik))
|
|
for tid, sic, desc in sic_updates or []:
|
|
await db.execute(
|
|
update(Ticker).where(Ticker.id == tid).values(sic=sic, sic_description=desc)
|
|
)
|
|
return {"cik_updates": len(resolved.cik_updates), "sic_updates": len(sic_updates or [])}
|
|
|
|
|
|
def universe_fingerprint(symbol_to_cik: dict[str, int]) -> str:
|
|
"""Stable short hash of the tracked symbol->CIK set. Changes whenever a ticker
|
|
is added/removed or its CIK mapping changes."""
|
|
canonical = ";".join(f"{sym}:{cik}" for sym, cik in sorted(symbol_to_cik.items()))
|
|
return hashlib.blake2b(canonical.encode("utf-8"), digest_size=12).hexdigest()
|
|
|
|
|
|
def index_content_hash(index_rows: Iterable[dict]) -> str:
|
|
"""Order-independent hash of the tracked index accessions consumed this run."""
|
|
keys = sorted(f"{r['cik']}/{r['accession']}" for r in index_rows)
|
|
return hashlib.blake2b("|".join(keys).encode("utf-8"), digest_size=12).hexdigest()
|
|
|
|
|
|
def compose_revision(index_date, content_hash: str, symbol_to_cik: dict[str, int]) -> str:
|
|
"""Composite revision = processed index date + index-content hash + universe
|
|
fingerprint. Equal across runs ⇒ nothing new ⇒ no_op. Rejects a missing index
|
|
date rather than emitting a `None:...` revision that could false-match."""
|
|
if index_date is None:
|
|
raise ValueError("compose_revision requires a non-null index date")
|
|
return f"{index_date}:{content_hash}:{universe_fingerprint(symbol_to_cik)}"
|