fix(sec): compose total debt across the styles filers actually tag
Deploy / lint (push) Successful in 10s
Deploy / test (push) Successful in 1m20s
Deploy / deploy (push) Successful in 37s

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
This commit is contained in:
2026-08-21 17:44:18 +02:00
co-authored by Claude Opus 5
parent 83fe76c506
commit 8453b87290
3 changed files with 240 additions and 10 deletions
@@ -148,3 +148,95 @@ roughly $10bn of debt. `_compose_debt` returns the short-term component alone wh
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.612.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.