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
@@ -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
}
@@ -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