2 Commits
Author SHA1 Message Date
dennisthiessenandClaude Opus 5 8453b87290 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
2026-08-21 17:44:18 +02:00
dennisthiessenandClaude Opus 5 83fe76c506 fix(sec): alert when a filing gap's reprieve lapses instead of re-pausing quietly
An escalated gap stops pausing setups while the issuer's own fundamentals are
still recent. That reprieve ends on its own — the stored filings age past
GAP_GATE_RECENT_FILING_DAYS, or a newer gap arrives and the all-escalated
condition fails — and nothing reported either, because filing_gap_aged only
escalates gaps whose escalated_at is NULL and so never fires twice for the same
gap. For the 43 issuers behind the previous commit that lands around
2026-10-26, when their late-April filings age out together.

sec_filing_gaps.exempted_at (migration 034) makes the transition observable:
stamped 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.

gap_exempt_ciks is public so the importer alerts on membership changes in
exactly the set the gate reads, rather than restating the rule.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016Lo99z3jWqu9X9ueBU3Z3D
2026-08-21 17:44:04 +02:00
9 changed files with 443 additions and 12 deletions
@@ -0,0 +1,41 @@
"""Track when a filing gap stops pausing setups
Revision ID: 034
Revises: 033
Create Date: 2026-08-21 00:00:00.000000
An escalated gap stops pausing setups while the issuer's own fundamentals are
still recent (``GAP_GATE_RECENT_FILING_DAYS``). That reprieve is not permanent:
the stored filings age out, or a newer gap appears, and the pause returns —
silently, because ``filing_gap_aged`` only escalates gaps whose ``escalated_at``
is NULL and so never fires twice for the same gap.
``exempted_at`` is the state marker that makes the transition observable. It is
set (quietly) while the issuer is exempt and cleared when the exemption lapses,
which is when ``filing_gap_repaused`` fires — once per lapse, re-arming if the
issuer's data recovers and ages out again.
Nullable, and carrying no meaning of its own beyond that state: an existing gap
starts NULL and is stamped on the next import that finds it exempt.
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
revision: str = "034"
down_revision: Union[str, None] = "033"
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
op.add_column(
"sec_filing_gaps",
sa.Column("exempted_at", sa.DateTime(timezone=True), nullable=True),
)
def downgrade() -> None:
op.drop_column("sec_filing_gaps", "exempted_at")
+5
View File
@@ -30,3 +30,8 @@ class SecFilingGap(Base):
first_seen_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False)
last_attempted_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False)
escalated_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
# Set while this gap's issuer is exempt from the setup pause (escalated, and
# its own fundamentals still recent — see fundamentals_quality_service).
# Cleared when the exemption lapses, which is the moment the pause silently
# comes back and the only moment worth alerting on.
exempted_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
+5 -2
View File
@@ -70,13 +70,16 @@ async def active_gaps(
return list((await db.execute(stmt)).scalars().all())
async def _gap_exempt_ciks(
async def gap_exempt_ciks(
db: AsyncSession, gaps: list[SecFilingGap]
) -> set[str]:
"""CIKs whose gaps have stopped pausing setups (see GAP_GATE_RECENT_FILING_DAYS).
Every one of a CIK's active gaps must be escalated: one fresh gap alongside an
old one still means a filing we might yet ingest, which is worth pausing for.
Public because the importer alerts on this exact transition (a CIK dropping
out of this set is a pause coming back on) and the rule must not exist twice.
"""
by_cik: dict[str, list[SecFilingGap]] = defaultdict(list)
for gap in gaps:
@@ -135,7 +138,7 @@ async def blocked_reasons_by_cik(
gaps = await active_gaps(db, ciks)
# Escalated gaps on issuers that still have recent fundamentals no longer
# pause setups, on either path below — the summary mirrors the same filings.
exempt = await _gap_exempt_ciks(db, gaps)
exempt = await gap_exempt_ciks(db, gaps)
reasons = {
gap.cik: "sec_filing_gap" for gap in gaps if gap.cik not in exempt
}
+63 -10
View File
@@ -113,8 +113,41 @@ _WEIGHTED_AVG_SHARE_CONCEPTS = [
# us-gaap instant (balance-sheet) concepts, at end == reportDate.
_CASH = ["CashAndCashEquivalentsAtCarryingValue"]
_ST_INVESTMENTS = ["ShortTermInvestments", "MarketableSecuritiesCurrent"] # pick one
# Debt is tagged in four mutually exclusive styles across large filers, and
# composing a total means knowing which span each concept covers (measured
# 2026-08 over a 20-issuer sample; the counts below are from it).
#
# ``LongTermDebt`` already spans current + noncurrent maturities — Apple tags all
# three and 71.34bn + 11.01bn = 82.30bn confirms it — so its complement is only
# genuinely short-term borrowing.
_LONG_TERM_DEBT_AGG = ["LongTermDebt"]
_LONG_TERM_DEBT_PARTS = ["LongTermDebtNoncurrent", "LongTermDebtCurrent"]
# Noncurrent-only balance-sheet lines, needing a current complement added.
# ``LongTermDebtAndCapitalLeaseObligations`` is what KO, HD, T, XOM and CVX tag
# and nothing read it before: AT&T reported no total_debt at all against 134bn
# tagged, and Coca-Cola reported 0.25bn of commercial paper against 39bn.
_LONG_TERM_DEBT_NONCURRENT = [
"LongTermDebtNoncurrent",
"LongTermDebtAndCapitalLeaseObligations",
]
_LONG_TERM_DEBT_CURRENT = ["LongTermDebtCurrent"]
# REITs that tag no aggregate at all, carrying a secured and an unsecured side
# instead. Both sides are required, because ``NotesPayable`` does not mean the
# same thing across issuers (measured 2026-08 over 14 REITs):
# - MAA tags NotesPayable 5.66bn = UnsecuredDebt 5.30bn + SecuredDebt 0.36bn
# exactly, so there it IS the total and adding SecuredDebt double-counts.
# - EQR/VMRK tags NotesPayable alongside a *larger* SecuredDebt (5.38bn vs
# 6.38bn in 2013), so there it is only the unsecured component.
# ``UnsecuredDebt`` is what separates them: where it is tagged it is the
# unambiguous unsecured side and NotesPayable is ignored; where it is absent,
# NotesPayable is that side. Requiring both sides is also what keeps this branch
# from inventing a total out of a fragment — Boston Properties tags SecuredDebt
# 4.28bn and nothing else against ~15bn of real debt, and Regency tags an
# UnsecuredDebt of 0.03bn that is a credit-line draw, not its 5bn of notes.
_SECURED_DEBT = ["SecuredDebt"]
_UNSECURED_DEBT = ["UnsecuredDebt", "NotesPayable"] # first present wins
# ``DebtCurrent`` spans short-term borrowing AND current maturities, so it is the
# whole current complement where present and must never be added alongside them.
_ALL_CURRENT_DEBT = ["DebtCurrent"]
_SHORT_TERM_DEBT = ["ShortTermBorrowings", "CommercialPaper"] # pick one
@@ -459,15 +492,35 @@ def _compose_cash(facts: list[Fact], report_date: date) -> float | None:
def _compose_debt(facts: list[Fact], report_date: date) -> float | None:
long_term = _select_instant(facts, _LONG_TERM_DEBT_AGG, report_date)
if long_term is None:
nc = _select_instant(facts, ["LongTermDebtNoncurrent"], report_date)
cur = _select_instant(facts, ["LongTermDebtCurrent"], report_date)
long_term = None if nc is None and cur is None else (nc or 0.0) + (cur or 0.0)
short_term = _select_instant(facts, _SHORT_TERM_DEBT, report_date)
if long_term is None and short_term is None:
return None
return (long_term or 0.0) + (short_term or 0.0)
"""Total debt at ``report_date``, or None when no long-term component is found.
**A short-term component alone is never a total.** Chevron tags its full debt
only in the 10-K, so its 10-Q carries ``ShortTermBorrowings`` of 0.40bn and
nothing else; returning that as total debt reads as a near-unlevered issuer
carrying 50bn. Since ``_net_debt`` needs both sides and yields nothing when
either is missing, None costs a leverage read while the partial value
produces a confidently wrong one.
"""
# An aggregate spanning current + noncurrent: only true short-term is missing.
total = _select_instant(facts, _LONG_TERM_DEBT_AGG, report_date)
if total is not None:
return total + (_select_instant(facts, _SHORT_TERM_DEBT, report_date) or 0.0)
noncurrent = _select_instant(facts, _LONG_TERM_DEBT_NONCURRENT, report_date)
if noncurrent is None:
secured = _select_instant(facts, _SECURED_DEBT, report_date)
unsecured = _select_instant(facts, _UNSECURED_DEBT, report_date)
if secured is None or unsecured is None:
return None # one side of a REIT's debt is not its total
noncurrent = secured + unsecured
current = _select_instant(facts, _ALL_CURRENT_DEBT, report_date)
if current is None:
current = (
(_select_instant(facts, _LONG_TERM_DEBT_CURRENT, report_date) or 0.0)
+ (_select_instant(facts, _SHORT_TERM_DEBT, report_date) or 0.0)
)
return noncurrent + current
def _select_shares(
+54
View File
@@ -776,6 +776,60 @@ class SecFundamentalsImporter:
.values(escalated_at=now)
)
# The escalation above fires once per gap, so nothing would report the
# *end* of the reprieve it grants. An escalated gap stops pausing setups
# while the issuer's own fundamentals are still recent, and that lapses
# on its own — the stored filings age past the window, or a newer gap
# appears — putting the pause back on with no alert anywhere. Track the
# exemption as state and alert on the transition, once per lapse.
current_gaps = await fundamentals_quality_service.active_gaps(db)
escalated_gaps = [g for g in current_gaps if g.escalated_at is not None]
if escalated_gaps:
exempt_ciks = await fundamentals_quality_service.gap_exempt_ciks(
db, escalated_gaps
)
newly_exempt = [
g for g in escalated_gaps
if g.cik in exempt_ciks and g.exempted_at is None
]
lapsed = [
g for g in escalated_gaps
if g.cik not in exempt_ciks and g.exempted_at is not None
]
if newly_exempt:
# Silent on purpose: filing_gap_aged already announced this gap,
# and setups resuming is the behaviour that alert describes.
await db.execute(
update(SecFilingGap)
.where(SecFilingGap.id.in_([g.id for g in newly_exempt]))
.values(exempted_at=now)
)
if lapsed:
named = ", ".join(
f"{gap.cik}/{gap.accession}" for gap in lapsed[:10]
)
db.add(SystemEvent(
severity="warning",
source="sec_facts",
code="filing_gap_repaused",
message=(
f"{len(lapsed)} SEC filing gap(s) pause setups again: the "
"issuer's own fundamentals have aged out of the "
f"{fundamentals_quality_service.GAP_GATE_RECENT_FILING_DAYS}"
"-day window, or a newer gap arrived, so there is nothing "
f"recent left to score on: {named}"
)[:4000],
dedup_key=f"sec_facts:filing_gap_repaused:{run_id}",
created_at=now,
))
# Cleared, not stamped: the issuer can recover and age out again,
# and each lapse is worth its own alert.
await db.execute(
update(SecFilingGap)
.where(SecFilingGap.id.in_([g.id for g in lapsed]))
.values(exempted_at=None)
)
# Recovered rows are real data from an unexpected place — record where they
# came from, so a wrong recovery is auditable rather than invisible.
if staged.recovered:
@@ -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.
@@ -257,3 +257,25 @@ async def test_summary_path_does_not_reblock_an_exempt_cik(db_session):
await db_session.flush()
assert await fundamentals_quality_service.blocked_ticker_ids(db_session) == set()
async def test_a_newer_gap_ends_the_exemption(db_session):
"""Production's second exit path: Q3 also fails to ingest, so an un-escalated
gap joins the escalated one and the issuer pauses again immediately."""
ticker = Ticker(symbol="NEWGAP", cik="0000000050")
db_session.add_all([
ticker, _escalated_gap(ticker.cik), _prior_quarter(ticker.cik, age_days=120)
])
await db_session.flush()
assert await fundamentals_quality_service.blocked_ticker_ids(db_session) == set()
fresh = datetime.now(timezone.utc)
db_session.add(SecFilingGap(
cik=ticker.cik, accession="NEWGAP-Q3", form="10-Q", index_date=date.today(),
reason="not_in_companyfacts", first_seen_at=fresh, last_attempted_at=fresh,
))
await db_session.flush()
assert await fundamentals_quality_service.blocked_ticker_ids(db_session) == {
ticker.id
}
+85
View File
@@ -538,3 +538,88 @@ def test_a_wrong_declared_year_end_no_longer_collides_two_periods():
keys = {(r.fiscal_year, r.fiscal_period) for r in res.rows}
assert len(keys) == 2, f"periods collided on one key: {keys}"
assert keys == {(2026, "Q1"), (2026, "Q2")}
# --- debt composition across the tagging styles large filers actually use ----
# Values are the real shapes measured 2026-08; before this composition, seven of
# nineteen sampled large caps carried a materially wrong or absent total_debt.
_RD = date(2026, 3, 28)
def _f(concept, val):
return Fact("us-gaap", concept, "USD", None, _RD, val, 2026, "Q2")
def test_debt_from_a_noncurrent_lease_aggregate_adds_its_current_side():
"""KO/HD/T/XOM/CVX tag LongTermDebtAndCapitalLeaseObligations, which nothing
read before — AT&T reported no debt at all against 134bn tagged."""
facts = [_f("LongTermDebtAndCapitalLeaseObligations", 134_630), _f("DebtCurrent", 9_320)]
assert _compose_debt(facts, _RD) == 143_950
def test_debt_current_is_the_whole_current_side_not_an_addition():
"""DebtCurrent already spans short-term borrowing AND current maturities, so
adding commercial paper on top would count it twice."""
facts = [
_f("LongTermDebtNoncurrent", 22_840),
_f("DebtCurrent", 11_300),
_f("LongTermDebtCurrent", 6_460),
_f("CommercialPaper", 4_840),
]
assert _compose_debt(facts, _RD) == 34_140
def test_debt_falls_back_to_the_split_current_parts():
facts = [
_f("LongTermDebtNoncurrent", 36_890),
_f("LongTermDebtCurrent", 3_900),
_f("ShortTermBorrowings", 10_670),
]
assert _compose_debt(facts, _RD) == 51_460
def test_notes_payable_is_the_unsecured_side_when_nothing_names_it():
"""Realty Income and VMRK tag a secured and an unsecured side, no aggregate."""
facts = [_f("NotesPayable", 25_090), _f("SecuredDebt", 40), _f("CommercialPaper", 1_400)]
assert _compose_debt(facts, _RD) == 26_530
def test_an_explicit_unsecured_side_wins_over_notes_payable():
"""MAA tags NotesPayable 5.66bn = UnsecuredDebt 5.30bn + SecuredDebt 0.36bn, so
NotesPayable is the total there and adding SecuredDebt to it double-counts.
Preferring the explicit unsecured side reproduces the total either way."""
facts = [_f("NotesPayable", 5_660), _f("UnsecuredDebt", 5_300), _f("SecuredDebt", 360)]
assert _compose_debt(facts, _RD) == 5_660
def test_one_side_of_a_reits_debt_is_not_a_total():
"""Boston Properties tags SecuredDebt 4.28bn and commercial paper against ~15bn
of real debt; Ventas the same shape. Composing from one side invents a total."""
assert _compose_debt([_f("SecuredDebt", 4_280), _f("CommercialPaper", 750)], _RD) is None
assert _compose_debt([_f("UnsecuredDebt", 5_300)], _RD) is None
def test_current_maturities_alone_are_not_a_total():
"""LongTermDebtCurrent used to stand in for the whole long-term side, which
reports the slice due within a year as if it were the debt."""
assert _compose_debt([_f("LongTermDebtCurrent", 6_460)], _RD) is None
def test_an_aggregate_beats_the_reit_parts():
"""AvalonBay tags all three; summing the parts would understate the total."""
facts = [_f("LongTermDebt", 9_020), _f("SecuredDebt", 700), _f("UnsecuredDebt", 7_410),
_f("CommercialPaper", 920)]
assert _compose_debt(facts, _RD) == 9_940
def test_a_short_term_only_filing_reports_no_total_at_all():
"""Chevron tags its full debt only in the 10-K, so a 10-Q carries 0.40bn of
short-term borrowing alone — reporting that as *total* debt reads as a
near-unlevered issuer carrying 50bn. None costs a leverage read; the partial
value produces a confidently wrong one."""
assert _compose_debt([_f("ShortTermBorrowings", 401)], _RD) is None
def test_no_debt_facts_at_all_is_still_none():
assert _compose_debt([_f("CashAndCashEquivalentsAtCarryingValue", 100)], _RD) is None
@@ -1388,3 +1388,79 @@ async def test_reparse_never_restamps_a_collision_onto_the_co_registrant(engine)
async with factory() as s:
row = (await s.execute(select(FundamentalSnapshot))).scalar_one()
assert row.cik == "0000906107" # still the issuer that filed it
# --- the reprieve ending: an exemption that lapses must not do so silently ---
def _stale_gap(cik, *, exempted: bool):
now = datetime.now(timezone.utc)
return SecFilingGap(
cik=cik, accession=f"{cik}-AGED-Q", form="10-Q",
index_date=(now - timedelta(days=30)).date(), reason="not_in_companyfacts",
first_seen_at=now - timedelta(days=30), last_attempted_at=now,
escalated_at=now - timedelta(days=16),
exempted_at=(now - timedelta(days=16)) if exempted else None,
)
def _snapshot(cik, *, age_days):
filed = date.today() - timedelta(days=age_days)
return FundamentalSnapshot(
cik=cik, accession=f"{cik}-PRIOR", form="10-Q", filed_date=filed,
accepted_at=datetime.now(timezone.utc) - timedelta(days=age_days),
period_end=filed, fiscal_year=filed.year, fiscal_period="Q1",
)
async def _promote_only(engine, seed):
"""Run promote() alone against seeded gap/snapshot state."""
factory = _factory(engine)
async with factory() as s:
for obj in seed:
s.add(obj)
await s.commit()
importer = _importer(FakeSecClient(
tickers={}, companyfacts={}, submissions={}, latest_index=date(2026, 3, 1)
))
async with factory() as db:
await importer.promote(db, StagedFundamentals(resolved=ResolvedUniverse()), run_id=77)
await db.commit()
async with factory() as s:
gaps = (await s.execute(select(SecFilingGap))).scalars().all()
events = (await s.execute(select(SystemEvent))).scalars().all()
return gaps, events
async def test_a_lapsed_exemption_raises_its_own_alert(engine):
"""filing_gap_aged fires once and never again, so nothing else would say the
pause came back when the issuer's own fundamentals aged out."""
cik = "0000000060"
gaps, events = await _promote_only(
engine, [_stale_gap(cik, exempted=True), _snapshot(cik, age_days=400)]
)
repaused = [e for e in events if e.code == "filing_gap_repaused"]
assert len(repaused) == 1
assert f"{cik}/{cik}-AGED-Q" in repaused[0].message
# Cleared, so a later recovery can re-arm and lapse again.
assert gaps[0].exempted_at is None
async def test_an_exemption_taking_effect_is_stamped_silently(engine):
"""Setups resuming is what filing_gap_aged already described — stamping the
state must not raise a second alert for it."""
cik = "0000000061"
gaps, events = await _promote_only(
engine, [_stale_gap(cik, exempted=False), _snapshot(cik, age_days=120)]
)
assert [e.code for e in events if e.code.startswith("filing_gap")] == []
assert gaps[0].exempted_at is not None
async def test_a_still_paused_gap_is_not_reported_as_lapsing(engine):
"""It never became exempt, so there is no transition to report."""
cik = "0000000062"
gaps, events = await _promote_only(
engine, [_stale_gap(cik, exempted=False), _snapshot(cik, age_days=400)]
)
assert [e for e in events if e.code == "filing_gap_repaused"] == []
assert gaps[0].exempted_at is None