feat(sec): cap how long the fundamentals import can stay deferred

MISSING_XBRL_RETRY_DAYS bounds how long ONE filing blocks promotion. It does
not bound the import as a whole, and the two come apart because a blocking
filing is only queued by promote(), which a deferred run never reaches. During
a rolling supply of unresolvable filings — earnings season, when SEC's
Company-Facts aggregation lags furthest — each new arrival restarts the 3-day
clock before the previous one clears, and nothing is written at all: not the
good rows, not the gap rows that would stop those filings blocking again.

Add an aggregate ceiling. Once promotions have been stale for
PROMOTION_CEILING_DAYS (7), every unresolved filing is aged past the retry
window in place, so promote() queues them all through the path that already
exists, source_max_date advances, and _missing() keeps queued rows aged-out on
later runs. The import self-heals instead of compounding.

Deliberately not the alternative of queueing gap rows on a deferred run: that
would drop the grace period to a single run for every filing, including the
common case of a Company-Facts lag that resolves in a day, and it needs a write
on a run that failed validation.

The per-filing window is untouched, a never-promoted source never trips (that
is initial setup, not a wedge), and affected symbols stay barred from setups
either way since setup_blocked_ciks ignores the window. A forced promotion
raises promotion_ceiling_forced so the safety valve is never silent.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
2026-08-11 16:58:46 +02:00
co-authored by Claude Opus 5
parent fbca38e144
commit 77570557db
2 changed files with 174 additions and 3 deletions
+77 -1
View File
@@ -6,7 +6,7 @@ from __future__ import annotations
import json
import os
import tempfile
from datetime import date, datetime, timezone
from datetime import date, datetime, timedelta, timezone
import pytest
from sqlalchemy import func, select
@@ -1128,3 +1128,79 @@ async def test_malformed_cik_override_is_ignored_not_fatal(engine):
resolved = await resolve_ciks(db, client)
assert resolved.symbol_to_cik["AAPL"] == 320193 # fell back to company_tickers
# --- aggregate deferral ceiling -------------------------------------------
def _blocking_staged(today: date) -> StagedFundamentals:
"""One filing still inside the per-filing retry window."""
return StagedFundamentals(
resolved=ResolvedUniverse(),
missing_xbrl=[{
"cik": "0000320193",
"accession": "YOUNG-1",
"form": "10-Q",
"index_date": today,
"age_days": 0,
"reason": "not_in_companyfacts",
}],
)
async def _add_run(factory, *, status: str, started_at: datetime) -> None:
from app.models.data_import_run import DataImportRun
async with factory() as db:
db.add(DataImportRun(
source="sec_facts", status=status, started_at=started_at,
))
await db.commit()
async def _validate_with_history(engine, *, promoted_days_ago: int | None):
factory = _factory(engine)
today = date(2026, 5, 20)
now = datetime(2026, 5, 20, 12, 0, tzinfo=timezone.utc)
if promoted_days_ago is not None:
await _add_run(
factory,
status=STATUS_PROMOTED,
started_at=now - timedelta(days=promoted_days_ago),
)
importer = SecFundamentalsImporter(today=today)
importer._latest_index_date = date(2026, 5, 19)
staged = _blocking_staged(today)
async with factory() as db:
return await importer.validate(db, staged), staged, importer
async def test_ceiling_forces_a_promotion_once_deferral_outlasts_it(engine):
"""The per-filing window bounds one filing; this bounds the whole import."""
result, staged, importer = await _validate_with_history(engine, promoted_days_ago=10)
assert result.ok is True
assert result.summary["missing_xbrl_blocking"] == 0
assert result.summary["promotion_ceiling_tripped"] == {"forced": 1, "unresolved": 1}
# Aged in place, so promote() re-derives the same verdict and queues it.
assert staged.missing_xbrl[0]["age_days"] > 3
# The symbol stays barred from setups — promoting is not trusting the data.
assert result.summary["setup_blocked_ciks"] == ["0000320193"]
async def test_a_recent_promotion_keeps_the_normal_block(engine):
result, staged, _ = await _validate_with_history(engine, promoted_days_ago=1)
assert result.ok is False
assert result.summary["missing_xbrl_blocking"] == 1
assert result.summary["promotion_ceiling_tripped"] is None
assert result.retryable is True
assert staged.missing_xbrl[0]["age_days"] == 0
async def test_ceiling_never_fires_before_a_first_promotion(engine):
"""No baseline means initial setup, not a wedge — forcing it through would
mask a misconfiguration instead of recovering from an SEC gap."""
result, _, _ = await _validate_with_history(engine, promoted_days_ago=None)
assert result.ok is False
assert result.summary["promotion_ceiling_tripped"] is None