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:
@@ -51,10 +51,10 @@ import json
|
|||||||
import logging
|
import logging
|
||||||
from collections import Counter, defaultdict
|
from collections import Counter, defaultdict
|
||||||
from dataclasses import dataclass, field, replace
|
from dataclasses import dataclass, field, replace
|
||||||
from datetime import date, datetime, timedelta, timezone
|
from datetime import date, datetime, time, timedelta, timezone
|
||||||
from typing import Any, Callable
|
from typing import Any, Callable
|
||||||
|
|
||||||
from sqlalchemy import delete, select, update
|
from sqlalchemy import delete, exists, select, update
|
||||||
|
|
||||||
from app.database import insert_for_session
|
from app.database import insert_for_session
|
||||||
from app.models.data_import_run import DataImportRun
|
from app.models.data_import_run import DataImportRun
|
||||||
@@ -81,6 +81,26 @@ MIN_BACKFILL_COVERAGE = 0.5
|
|||||||
# three); past that it is misfiled, not late, and blocking forever costs more
|
# three); past that it is misfiled, not late, and blocking forever costs more
|
||||||
# than the missing filing does — see the unresolved-filing guardrail below.
|
# than the missing filing does — see the unresolved-filing guardrail below.
|
||||||
MISSING_XBRL_RETRY_DAYS = 3
|
MISSING_XBRL_RETRY_DAYS = 3
|
||||||
|
|
||||||
|
# Aggregate ceiling on deferral. MISSING_XBRL_RETRY_DAYS bounds how long ONE
|
||||||
|
# filing blocks; it does not bound how long the import as a whole can stay
|
||||||
|
# deferred. Those differ because a blocking filing is only queued by promote(),
|
||||||
|
# which a deferred run never reaches — so during a rolling supply of
|
||||||
|
# unresolvable filings (earnings season, when SEC's Company-Facts aggregation is
|
||||||
|
# furthest behind) each new arrival restarts the 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.
|
||||||
|
#
|
||||||
|
# Once promotions have been stale this long, every unresolved filing is treated
|
||||||
|
# as past the window. promote() then queues them all (see the _past_retry_window
|
||||||
|
# call there), source_max_date advances, and _missing() forces queued rows
|
||||||
|
# aged-out on later runs so they never block again — the import self-heals
|
||||||
|
# through the paths that already exist.
|
||||||
|
#
|
||||||
|
# Well above MISSING_XBRL_RETRY_DAYS so ordinary overlapping blocks never trip
|
||||||
|
# it. Affected symbols stay barred from setups either way: setup_blocked_ciks is
|
||||||
|
# built from every missing filing regardless of window.
|
||||||
|
PROMOTION_CEILING_DAYS = 7
|
||||||
FILING_GAP_ESCALATE_DAYS = 14
|
FILING_GAP_ESCALATE_DAYS = 14
|
||||||
# Share-count band a co-registrant-recovered row must land in, relative to the
|
# Share-count band a co-registrant-recovered row must land in, relative to the
|
||||||
# issuer's own last snapshot. Wide enough for buybacks/issuance, nowhere near
|
# issuer's own last snapshot. Wide enough for buybacks/issuance, nowhere near
|
||||||
@@ -157,6 +177,9 @@ class SecFundamentalsImporter:
|
|||||||
self._retry_rows: list[dict[str, Any]] = []
|
self._retry_rows: list[dict[str, Any]] = []
|
||||||
self._latest_index_date: date | None = None
|
self._latest_index_date: date | None = None
|
||||||
self._backfill = False
|
self._backfill = False
|
||||||
|
# Set by validate() when the aggregate ceiling forced the block open;
|
||||||
|
# read by promote() to alert that it did.
|
||||||
|
self._ceiling_tripped: dict[str, Any] | None = None
|
||||||
|
|
||||||
# -- SourceImporter protocol -------------------------------------------
|
# -- SourceImporter protocol -------------------------------------------
|
||||||
|
|
||||||
@@ -421,6 +444,24 @@ class SecFundamentalsImporter:
|
|||||||
# reconstructible by re-walking the index.
|
# reconstructible by re-walking the index.
|
||||||
blocking = _within_retry_window(staged.missing_xbrl)
|
blocking = _within_retry_window(staged.missing_xbrl)
|
||||||
aged_out = _past_retry_window(staged.missing_xbrl)
|
aged_out = _past_retry_window(staged.missing_xbrl)
|
||||||
|
|
||||||
|
# ...unless promotions have been stale past the aggregate ceiling, in
|
||||||
|
# which case the deferral has cost more than the filings it withholds.
|
||||||
|
# Ageing them here (not just locally) is deliberate: promote() re-derives
|
||||||
|
# the queue from the same list, so this is what gets them queued.
|
||||||
|
self._ceiling_tripped = None
|
||||||
|
if blocking and db is not None and await self._promotions_stale(db):
|
||||||
|
for item in staged.missing_xbrl:
|
||||||
|
item["age_days"] = max(
|
||||||
|
item.get("age_days", 0), MISSING_XBRL_RETRY_DAYS + 1
|
||||||
|
)
|
||||||
|
self._ceiling_tripped = {
|
||||||
|
"forced": len(blocking),
|
||||||
|
"unresolved": len(staged.missing_xbrl),
|
||||||
|
}
|
||||||
|
blocking = _within_retry_window(staged.missing_xbrl)
|
||||||
|
aged_out = _past_retry_window(staged.missing_xbrl)
|
||||||
|
|
||||||
if blocking:
|
if blocking:
|
||||||
messages.append(
|
messages.append(
|
||||||
f"{len(blocking)} tracked XBRL filing(s) unresolved within the "
|
f"{len(blocking)} tracked XBRL filing(s) unresolved within the "
|
||||||
@@ -464,6 +505,9 @@ class SecFundamentalsImporter:
|
|||||||
"missing_xbrl": staged.missing_xbrl[:50],
|
"missing_xbrl": staged.missing_xbrl[:50],
|
||||||
"missing_xbrl_count": len(staged.missing_xbrl),
|
"missing_xbrl_count": len(staged.missing_xbrl),
|
||||||
"missing_xbrl_blocking": len(blocking),
|
"missing_xbrl_blocking": len(blocking),
|
||||||
|
# Present only when the aggregate ceiling forced this run through, so
|
||||||
|
# a promoted run that carries known-unresolved filings says so.
|
||||||
|
"promotion_ceiling_tripped": self._ceiling_tripped,
|
||||||
"recovered_from_coregistrant": staged.recovered[:50],
|
"recovered_from_coregistrant": staged.recovered[:50],
|
||||||
"recovered_count": len(staged.recovered),
|
"recovered_count": len(staged.recovered),
|
||||||
# Complete compact gate input; detailed audit lists above stay capped.
|
# Complete compact gate input; detailed audit lists above stay capped.
|
||||||
@@ -616,6 +660,25 @@ class SecFundamentalsImporter:
|
|||||||
created_at=_now(),
|
created_at=_now(),
|
||||||
))
|
))
|
||||||
|
|
||||||
|
# A ceiling-forced promotion is the safety valve firing — it must be
|
||||||
|
# visible, or the import silently starts carrying known-unresolved
|
||||||
|
# filings. The affected symbols stay barred from setups regardless.
|
||||||
|
if self._ceiling_tripped:
|
||||||
|
db.add(SystemEvent(
|
||||||
|
severity="warning",
|
||||||
|
source="sec_facts",
|
||||||
|
code="promotion_ceiling_forced",
|
||||||
|
message=(
|
||||||
|
f"Promoted with {self._ceiling_tripped['unresolved']} unresolved "
|
||||||
|
f"filing(s) — {self._ceiling_tripped['forced']} still inside the "
|
||||||
|
f"{MISSING_XBRL_RETRY_DAYS}-day retry window — because nothing had "
|
||||||
|
f"promoted in {PROMOTION_CEILING_DAYS} days. They are queued for "
|
||||||
|
"retry and their symbols remain blocked from setups."
|
||||||
|
)[:4000],
|
||||||
|
dedup_key=f"sec_facts:promotion_ceiling_forced:{run_id}",
|
||||||
|
created_at=now,
|
||||||
|
))
|
||||||
|
|
||||||
# Persistent current gaps get one actionable escalation rather than a
|
# Persistent current gaps get one actionable escalation rather than a
|
||||||
# daily warning. The nullable marker makes this durable and noise-free.
|
# daily warning. The nullable marker makes this durable and noise-free.
|
||||||
escalation_cutoff = now - timedelta(days=FILING_GAP_ESCALATE_DAYS)
|
escalation_cutoff = now - timedelta(days=FILING_GAP_ESCALATE_DAYS)
|
||||||
@@ -757,6 +820,38 @@ class SecFundamentalsImporter:
|
|||||||
if accession not in resolved
|
if accession not in resolved
|
||||||
]
|
]
|
||||||
|
|
||||||
|
async def _promotions_stale(self, db) -> bool:
|
||||||
|
"""Has nothing promoted within ``PROMOTION_CEILING_DAYS``?
|
||||||
|
|
||||||
|
Only true for a source that HAS promoted before. A never-promoted import
|
||||||
|
is initial setup, not a wedge: forcing its first promotion through would
|
||||||
|
mask a misconfiguration rather than recover from a transient SEC gap.
|
||||||
|
|
||||||
|
Measured from ``self.today`` rather than the wall clock, so the ceiling
|
||||||
|
honors the same injected date that ages the filings it releases.
|
||||||
|
"""
|
||||||
|
cutoff = datetime.combine(
|
||||||
|
self.today - timedelta(days=PROMOTION_CEILING_DAYS),
|
||||||
|
time.min,
|
||||||
|
tzinfo=timezone.utc,
|
||||||
|
)
|
||||||
|
ever, recent = (
|
||||||
|
await db.execute(
|
||||||
|
select(
|
||||||
|
exists().where(
|
||||||
|
DataImportRun.source == SOURCE,
|
||||||
|
DataImportRun.status == STATUS_PROMOTED,
|
||||||
|
),
|
||||||
|
exists().where(
|
||||||
|
DataImportRun.source == SOURCE,
|
||||||
|
DataImportRun.status == STATUS_PROMOTED,
|
||||||
|
DataImportRun.started_at >= cutoff,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
).one()
|
||||||
|
return bool(ever) and not bool(recent)
|
||||||
|
|
||||||
async def _last_processed_index_date(self, db) -> date | None:
|
async def _last_processed_index_date(self, db) -> date | None:
|
||||||
return (
|
return (
|
||||||
await db.execute(
|
await db.execute(
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ from __future__ import annotations
|
|||||||
import json
|
import json
|
||||||
import os
|
import os
|
||||||
import tempfile
|
import tempfile
|
||||||
from datetime import date, datetime, timezone
|
from datetime import date, datetime, timedelta, timezone
|
||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
from sqlalchemy import func, select
|
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)
|
resolved = await resolve_ciks(db, client)
|
||||||
|
|
||||||
assert resolved.symbol_to_cik["AAPL"] == 320193 # fell back to company_tickers
|
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