total_debt read LongTermDebt, else LongTermDebtNoncurrent/Current, plus one of ShortTermBorrowings/CommercialPaper. That misses two whole tagging styles, and it feeds net_debt -> net_debt_to_ebitda -> the categorical leverage read, so the misses were not absences but confident wrong answers: Coca-Cola scored on 0.25bn of commercial paper against ~39bn of debt, Verizon on 21.78bn of current maturities against ~165bn, AT&T and Exxon produced no value at all against 134bn and 33bn tagged. Measured over 19 large caps and 14 REITs, 11 were wrong or absent and the rest are unchanged. Each concept's span is now respected. LongTermDebt already includes current maturities (Apple tags all three: 71.34 + 11.01 = 82.30), so only true short-term borrowing is added. LongTermDebtAndCapitalLeaseObligations — what KO, HD, T, XOM and CVX tag, and nothing read before — is noncurrent and takes a current complement, and DebtCurrent *is* that whole complement rather than an addition to it. The REIT branch needed disambiguating: NotesPayable is not the same line across issuers. MAA tags NotesPayable 5.66bn = UnsecuredDebt 5.30bn + SecuredDebt 0.36bn exactly, so there it is the total and adding the secured side double-counts; EQR tags it alongside a larger SecuredDebt, where it is only the unsecured component. UnsecuredDebt's presence separates them. A component alone is no longer reported as a total. Chevron tags full debt only in its 10-K, so its 10-Q carried 0.40bn of short-term borrowing; Boston Properties tags SecuredDebt 4.28bn against ~15bn real. net_debt needs both sides and yields nothing when either is missing, so None costs a leverage read where the fragment produced a confidently wrong one. Snapshots are immutable, so this corrects new filings only; stored history needs scripts/reparse_fundamentals.py, which cannot complete until the EQR/931182 collision is retired. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_016Lo99z3jWqu9X9ueBU3Z3D
14 KiB
SEC fundamentals alerts, 2026-08-21
Two sec_facts warnings, investigated against live SEC data. Both originate in SEC's
own published data — a stale per-company Company-Facts file (1) and a stale
ticker→CIK mapping (2) — and neither is a parser defect: no stored fundamental value
is wrong. Every SEC-side probe below reproduces offline from public endpoints; the
four database facts used are quoted where they appear.
1. filing_gap_aged — 43 gaps, all not_in_companyfacts
Root cause: SEC's per-company Company-Facts files are stale for these issuers,
while the same filings are present in SEC's own frames aggregation.
All ten named filings are real 10-Qs filed 2026-07-28/29, present in the issuer's
submissions with isXBRL=1, with complete R-files and XBRL in the EDGAR archive
— and absent from companyfacts/CIK*.json:
| CIK | issuer | accession | filed | in companyfacts |
newest fact in file |
|---|---|---|---|---|---|
| 0000001800 | Abbott | 0001628280-26-050134 | 2026-07-28 | no | 2026-04-29 |
| 0000021344 | Coca-Cola | 0001628280-26-050503 | 2026-07-29 | no | 2026-04-30 |
| 0000024741 | Corning | 0000024741-26-000255 | 2026-07-29 | no | 2026-05-01 |
| 0000029989 | Omnicom | 0000029989-26-000019 | 2026-07-29 | no | 2026-04-29 |
| 0000037996 | Ford | 0000037996-26-000156 | 2026-07-29 | no | 2026-04-30 |
| 0000040533 | General Dynamics | 0000040533-26-000032 | 2026-07-29 | no | 2026-07-01 |
| 0000048898 | Hubbell | 0001628280-26-050405 | 2026-07-29 | no | 2026-06-04 |
| 0000049071 | Humana | 0000049071-26-000050 | 2026-07-29 | no | 2026-04-29 |
| 0000049196 | Huntington Bancshares | 0000049196-26-000066 | 2026-07-28 | no | 2026-04-30 |
| 0000062996 | Masco | 0000062996-26-000027 | 2026-07-29 | no | 2026-04-22 |
Ruled out, with evidence:
- Not a global SEC outage. Company Facts is current for other issuers filing the
same days — MSFT
0001193125-26-323660@2026-07-29, AAPL @2026-07-31, P&G @2026-08-04, Chevron @2026-08-06, JPMorgan @2026-08-20. - Not a CDN/cache artifact. A cache-busted request with
Cache-Control: no-cachereturns the identical stale 3.39 MB payload; the response carries no cache headers. - Not our filter. The scan covers every taxonomy/concept/unit in the payload.
- Not a metadata discriminator. Gap and non-gap filings are identical on
isXBRL,isInlineXBRL,reportDate,primaryDocDescription. - SEC does have the facts.
frames/us-gaap/Assets/USD/CY2026Q2I.jsonlists Abbott at exactly the missing accession0001628280-26-050134, and Coca-Cola and Ford at theirs. The per-company endpoints are the degraded ones:companyconcept/CIK0000001800/us-gaap/Assets.jsonreturns"units":{"USD":{}}.
Consequence, and why the gate changed. Retrying companyfacts cannot recover
these — Abbott's file has been stale since April. And because active_gaps
supersedes a gap only on a successfully ingested later filing, a stale file also
swallows Q3: the pause was open-ended, not seasonal, on 43 large caps.
Fix (app/services/fundamentals_quality_service.py): once filing_gap_aged has
escalated a gap (escalated_at), it stops pausing setups if the issuer's own
newest stored 10-K/10-Q is under GAP_GATE_RECENT_FILING_DAYS (180) old. Pause hands
off to the alert; an issuer with nothing that recent stays paused. active_gaps is
deliberately untouched, so _retry_backlog keeps retrying and a recovered filing
still resolves normally. The bound is applied to the queue path and the
validation_json summary path, which mirrors the same filings — bounding only one
leaves the behaviour unchanged in production.
This is a bounded reprieve, not a removal — know the two ways it ends. Abbott's
newest ingested filing is 0001628280-26-028357, filed 2026-04-29, so its recency
window closes around 2026-10-26; most of the 43 sit on late-April filings and
turn back to paused within days of each other. That crossing is silent: the
importer escalates only gaps with escalated_at IS NULL, so filing_gap_aged does
not re-fire for a gap it has already reported. Separately, a Q3 10-Q that also fails
to ingest creates a new un-escalated gap on the same CIK, which re-pauses it at
once (that one does raise its own filing_gap_aged 14 days later). Whether the
silent re-block deserves a re-escalation signal is an open call, deliberately not
made here — "one actionable escalation rather than a daily warning" is the existing
design intent.
Not done, with reasons. A frames-backed recovery source was considered and
rejected: frames are calendar-aligned with a tolerance (off-fiscal filers drop out)
and carry one fact per issuer per period, so amendment/restatement semantics differ
from Company Facts — lossy as a snapshot source, not merely expensive. Parsing the
filing's own inline-XBRL instance is the authoritative alternative but is a new
subsystem (contexts, dimensions, unit refs) duplicating the parser's fact model.
2. snapshot_discrepancy — 0000906107-15-000012 / -000016
Root cause: two tracked tickers claim the same filing, because SEC's
company_tickers.json still points the old symbol at a non-traded co-registrant.
No stored value is wrong and no reparse is warranted.
CIK 0000906107 is Vivmark Residential (VMRK, formerly Equity Residential). Both alerted accessions are combined EQR + ERP Operating LP 10-Qs — one accession, two registrants (0000906107 and 0000931182) — the pattern behind the existing co-registrant recovery path.
The stored rows are byte-identical to what the current parser reconstructs from
EQR's own Company Facts — every column, verified: cik (0000906107), form,
filed_date, accepted_at, both period dates, fiscal_year/fiscal_period,
revenue, net_income, operating_income, diluted_eps, cfo, the two nulls,
cash_and_st_investments, total_debt (340,900,000 / null),
shares_outstanding, shares_outstanding_date, weighted_avg_diluted_shares. Both
carry import_run_id = 6, and CIK 0000906107 holds all 69 of its filings across runs
6–30, so the issuer's own history is complete.
Run 63 (2026-08-19) recorded
fields: ["cik"] for both accessions, and the universe explains it:
tickers: VMRK -> 0000906107 (Vivmark Residential, ex-Equity Residential)
EQR -> 0000931182 (ERP Operating Ltd Partnership)
SEC's own company_tickers.json carries {"cik_str": 931182, "ticker": "EQR", "title": "ERP OPERATING LTD PARTNERSHIP"} — after the rename, the old symbol stayed
attached to the non-traded operating partnership, the co-registrant on those
combined 10-Qs. resolve_ciks reads active_only tickers and follows SEC, so
0000931182 is tracked. Its Company Facts holds 7 accessions, exactly 2 of them
EQR-prefixed, so its backfill reconstructs exactly those two rows, stamps them
cik=0000931182, and collides with the rows already stored under 0000906107 —
identical in every fact, differing only in attribution.
It cannot self-heal. The collision loser never stores a row (the insert is skipped as
immutable), so _ciks_with_snapshots never sees 0000931182, and it is full-history
backfilled — refetching every submissions shard and its companyfacts — on every
run, re-raising the warning each time. fundamental_snapshots for 0000906107 holds
all 69 filings across runs 6–30, so the issuer's own history is complete and correct.
Fixes
Code (sec_fundamentals_importer.py): a cik-only difference is no longer
reported as a reconstruction discrepancy. It raises accession_cik_collision, naming
both CIKs and pointing at sec_cik_overrides, because the fix is the universe, not
the parser. The reparse path also excludes these from its rewrite set — rewriting a
cik-only difference would re-stamp the filing onto the co-registrant and take it from
the issuer that filed it. (A reparse run while both CIKs are tracked fails validation
on duplicate accession in staged snapshots instead, which is a safe stop.)
Data — needs an operator, and the alert repeats daily until then. EQR is a stale
symbol: the security now trades as VMRK, which is already tracked at the correct
CIK. Retiring the EQR ticker ends the loop. A sec_cik_overrides pin of
EQR -> 906107 would silence the collision but leave two tickers on one security,
double-counting the issuer in scans — retirement is the right action.
Not fixed, deliberately: the permanent-backfill loop itself. A tracked CIK whose
only parseable filings belong to another CIK is re-backfilled every run; ending that
in code means teaching _ciks_with_snapshots about foreign-owned accessions, which is
more state for a condition that is now loudly and specifically reported.
Separate observation: total_debt on this issuer looks wrong
Independent of the alert, and unchanged by any fix here: the parser reconstructs
total_debt = 340,900,000 for EQR's 2015 Q1 and null for Q2, while the REIT carried
roughly $10bn of debt. _compose_debt returns the short-term component alone when
every _LONG_TERM_DEBT_AGG concept and the LongTermDebtNoncurrent/Current
pair miss — which is what happened here, and Q2 matched neither. Worth checking
against a current REIT filer before trusting total_debt for that sector.
3. Follow-ups from the two alerts above
3a. The reprieve in (1) ended silently — now it doesn't
The hand-off in section 1 is a bounded reprieve. It ends two ways, and neither
said anything: the issuer's stored filings age past GAP_GATE_RECENT_FILING_DAYS
(for the 43, their last good filings are late April, so ~2026-10-26), or a newer
filing gap arrives and the all-escalated condition fails. filing_gap_aged cannot
report either, because it only escalates gaps whose escalated_at is NULL and so
never fires twice for the same gap.
sec_filing_gaps.exempted_at (migration 034) makes the transition observable: set
quietly while the issuer is exempt, cleared when the exemption lapses, and the clear
is what raises filing_gap_repaused. Once per lapse, re-arming if the issuer's data
recovers and ages out again. A gap that was never exempt has no transition and stays
silent — it is simply still paused, which filing_gap_aged already said.
The exemption rule itself is not duplicated: fundamentals_quality_service.gap_exempt_ciks
is now public and the importer alerts on membership changes in exactly the set the
gate reads.
3b. total_debt was materially wrong for a third of large caps
The EQR observation in section 2 was not a REIT edge case. Measured over 19 large
caps, the old composition — LongTermDebt, else LongTermDebtNoncurrent/Current,
plus one of ShortTermBorrowings/CommercialPaper — missed two whole tagging styles:
| issuer | before | after | what was missed |
|---|---|---|---|
| T | None | 143.95b | LongTermDebtAndCapitalLeaseObligations |
| XOM | None | 47.66b | same |
| VZ | 21.78b | 165.23b | same (read only the current maturities) |
| KO | 0.25b | 39.31b | same (read only commercial paper) |
| HD | 3.50b | 48.33b | same |
| O | 1.40b | 26.53b | REIT parts (NotesPayable + SecuredDebt) |
| VMRK | 1.50b | 9.09b | same |
| CVX | 0.40b | None | partial suppressed — see below |
| PFE | 63.10b | 63.19b | DebtCurrent is the completer current side |
| 10 others | — | unchanged | already composed correctly |
total_debt feeds net_debt → net_debt_to_ebitda → the peer percentile and the
categorical leverage read, so Coca-Cola at 0.25bn of debt was not a missing value —
it was a confident "conservative leverage" on an issuer carrying ~39bn.
The composition now spans four mutually exclusive styles, with each concept's span
respected: LongTermDebt already includes current maturities (Apple tags all three
and 71.34 + 11.01 = 82.30 confirms it), LongTermDebtAndCapitalLeaseObligations is
noncurrent and needs a current complement, and DebtCurrent is that whole
complement rather than an addition to it.
A short-term component alone is no longer reported as a total. Chevron tags full
debt only in its 10-K, so its 10-Q carries 0.40bn of short-term borrowing and nothing
else. _net_debt needs both sides and yields nothing when either is missing, so None
costs a leverage read where the partial value produced a confidently wrong one.
The REIT branch needed disambiguating, because NotesPayable does not mean the same
thing across issuers (measured over 14 REITs): MAA tags NotesPayable 5.66bn =
UnsecuredDebt 5.30bn + SecuredDebt 0.36bn exactly, so there it is the total and
adding the secured side double-counts — while EQR tags it alongside a larger
SecuredDebt (5.38bn vs 6.38bn in 2013), where it is only the unsecured component.
UnsecuredDebt's presence separates the two: where tagged it is the unambiguous
unsecured side and NotesPayable is ignored; where absent, NotesPayable is that
side. Both sides are required, which is also what stops the branch inventing a total
from a fragment.
| REIT | before | after | |
|---|---|---|---|
| MAA | None | 5.66b | matches its own NotesPayable total exactly |
| KIM | None | 8.74b | |
| O / VMRK | 1.40b / 1.50b | 26.53b / 9.09b | |
| BXP | 0.75b | None | tagged only SecuredDebt + paper against ~15bn real debt |
| VTR | 0.27b | None | same shape |
| 8 others | — | unchanged | already composed correctly |
Known limit: where EQR tags both the parts and the aggregate, the parts sum 2.6–12.2% below it, so this branch approximates. It is last in line — any issuer tagging an aggregate never reaches it — and the alternative there is no value at all.
Sequencing the history fix
Snapshots are immutable, so 3b corrects new filings only; every stored quarter
keeps its old total_debt. scripts/reparse_fundamentals.py exists for exactly this
("after a parser fix, keeping the stored row is preserving a stale cache").
Retire the EQR ticker before reparsing. A reparse backfills every tracked CIK,
so while both 0000906107 and 0000931182 are tracked, both stage the same two 2015
accessions and the run fails validation on duplicate accession in staged snapshots.
That is a safe stop — nothing is written — but the reparse will not complete until the
collision is gone.