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:
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user