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
This commit is contained in:
2026-08-21 17:44:04 +02:00
co-authored by Claude Opus 5
parent c15b51439e
commit 83fe76c506
6 changed files with 203 additions and 2 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) first_seen_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False)
last_attempted_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) 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()) return list((await db.execute(stmt)).scalars().all())
async def _gap_exempt_ciks( async def gap_exempt_ciks(
db: AsyncSession, gaps: list[SecFilingGap] db: AsyncSession, gaps: list[SecFilingGap]
) -> set[str]: ) -> set[str]:
"""CIKs whose gaps have stopped pausing setups (see GAP_GATE_RECENT_FILING_DAYS). """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 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. 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) by_cik: dict[str, list[SecFilingGap]] = defaultdict(list)
for gap in gaps: for gap in gaps:
@@ -135,7 +138,7 @@ async def blocked_reasons_by_cik(
gaps = await active_gaps(db, ciks) gaps = await active_gaps(db, ciks)
# Escalated gaps on issuers that still have recent fundamentals no longer # Escalated gaps on issuers that still have recent fundamentals no longer
# pause setups, on either path below — the summary mirrors the same filings. # 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 = { reasons = {
gap.cik: "sec_filing_gap" for gap in gaps if gap.cik not in exempt gap.cik: "sec_filing_gap" for gap in gaps if gap.cik not in exempt
} }
+54
View File
@@ -776,6 +776,60 @@ class SecFundamentalsImporter:
.values(escalated_at=now) .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 # Recovered rows are real data from an unexpected place — record where they
# came from, so a wrong recovery is auditable rather than invisible. # came from, so a wrong recovery is auditable rather than invisible.
if staged.recovered: if staged.recovered:
@@ -257,3 +257,25 @@ async def test_summary_path_does_not_reblock_an_exempt_cik(db_session):
await db_session.flush() await db_session.flush()
assert await fundamentals_quality_service.blocked_ticker_ids(db_session) == set() 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
}
@@ -1388,3 +1388,79 @@ async def test_reparse_never_restamps_a_collision_onto_the_co_registrant(engine)
async with factory() as s: async with factory() as s:
row = (await s.execute(select(FundamentalSnapshot))).scalar_one() row = (await s.execute(select(FundamentalSnapshot))).scalar_one()
assert row.cik == "0000906107" # still the issuer that filed it 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