Compare commits
2
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
8453b87290 | ||
|
|
83fe76c506 |
@@ -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")
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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.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.
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user