fix: make SEC quality gating terminal-safe

This commit is contained in:
2026-08-04 07:58:18 +02:00
parent d1caac86b5
commit 4c0c0579f5
14 changed files with 629 additions and 186 deletions
+37 -15
View File
@@ -67,27 +67,23 @@ async def test_sec_quality_gate_is_inactive_before_cutover(db_session):
async def test_promoted_gap_is_blocked_during_queue_migration_bootstrap(db_session):
async def test_active_gap_is_blocked_until_a_later_filing_supersedes_it(db_session):
ticker = Ticker(symbol="HIST", cik="0000000043")
now = datetime.now(timezone.utc)
db_session.add_all([
ticker,
SystemSetting(
key="fundamental_data_sec_dolt_cutover_enabled",
value="true",
),
DataImportRun(
source="sec_facts",
status="promoted",
validation_json=json.dumps({
"missing_xbrl": [{"cik": ticker.cik, "accession": "HIST-Q"}],
}),
started_at=datetime.now(timezone.utc),
),
DataImportRun(
source="sec_facts",
status="promoted",
validation_json=json.dumps({"missing_xbrl": []}),
started_at=datetime.now(timezone.utc),
SecFilingGap(
cik=ticker.cik,
accession="HIST-Q",
form="10-Q",
index_date=date.today().replace(day=1),
reason="coregistrant_facts_rejected",
first_seen_at=now,
last_attempted_at=now,
),
])
await db_session.flush()
@@ -99,7 +95,7 @@ async def test_promoted_gap_is_blocked_during_queue_migration_bootstrap(db_sessi
db_session.add(
FundamentalSnapshot(
cik=ticker.cik,
accession="HIST-Q",
accession="LATER-Q",
form="10-Q",
filed_date=date.today(),
accepted_at=datetime.now(timezone.utc),
@@ -111,3 +107,29 @@ async def test_promoted_gap_is_blocked_during_queue_migration_bootstrap(db_sessi
await db_session.flush()
assert await fundamentals_quality_service.blocked_ticker_ids(db_session) == set()
async def test_ticker_quality_explains_no_xbrl_block(db_session):
ticker = Ticker(symbol="NEWREG", cik="0000000044")
db_session.add_all([
ticker,
SystemSetting(
key="fundamental_data_sec_dolt_cutover_enabled",
value="true",
),
DataImportRun(
source="sec_facts",
status="promoted",
validation_json=json.dumps({
"setup_blocked_ciks": [ticker.cik],
"no_xbrl_filings": [{"cik": ticker.cik}],
}),
started_at=datetime.now(timezone.utc),
),
])
await db_session.flush()
quality = await fundamentals_quality_service.ticker_quality(db_session, "NEWREG")
assert quality.eligible is False
assert quality.code == "no_xbrl_filings"
assert "CIK override" in (quality.message or "")
+35
View File
@@ -131,3 +131,38 @@ async def test_scan_skips_ticker_with_incomplete_sec_fundamentals(
monkeypatch.setattr(rr_scanner_service, "scan_ticker", _unexpected_scan)
assert await rr_scanner_service.scan_all_tickers(session) == []
async def test_scan_quality_failure_blocks_closed_and_emits_event(
session, monkeypatch
):
session.add(Ticker(symbol="BLOCKED"))
await session.commit()
async def _boom(db):
raise ValueError("bad quality metadata")
async def _unexpected_scan(*args, **kwargs):
raise AssertionError("ticker was scanned without a quality decision")
events: list[dict] = []
async def _capture_event(**kwargs):
events.append(kwargs)
monkeypatch.setattr(
rr_scanner_service.fundamentals_quality_service,
"blocked_ticker_ids",
_boom,
)
monkeypatch.setattr(rr_scanner_service, "scan_ticker", _unexpected_scan)
monkeypatch.setattr(
rr_scanner_service.system_event_service,
"log_event_standalone",
_capture_event,
)
assert await rr_scanner_service.scan_all_tickers(session) == []
assert [event["code"] for event in events] == [
"fundamentals_quality_unavailable"
]
@@ -481,6 +481,209 @@ async def test_unresolved_filing_stops_blocking_after_retry_window(engine):
assert await _count(factory, SecFilingGap) == 0
async def test_queued_gap_without_index_date_retries_without_wedging_and_escalates_once(
engine,
):
factory = _factory(engine)
await _seed(factory, ["AAPL"])
backfill = FakeSecClient(
tickers={"AAPL": 320193},
companyfacts={320193: _companyfacts([CF_K, CF_Q1], [SH_K, SH_Q1])},
submissions={320193: _submissions(SUB_FILINGS)},
latest_index=date(2026, 1, 31),
)
await run_import(_importer(backfill), engine=engine)
old = datetime(2026, 4, 1, tzinfo=timezone.utc)
async with factory() as db:
db.add(SecFilingGap(
cik="0000320193",
accession="DATELESS",
form="10-Q",
index_date=None,
reason="not_in_companyfacts",
first_seen_at=old,
last_attempted_at=old,
))
await db.commit()
missing = FakeSecClient(
tickers={"AAPL": 320193},
companyfacts={320193: _companyfacts([CF_K, CF_Q1], [SH_K, SH_Q1])},
submissions={320193: _submissions(SUB_FILINGS + [
_filing(
"DATELESS",
"10-Q",
"2026-03-28",
"2026-05-01",
"2026-05-01T10:01:00.000Z",
)
])},
latest_index=date(2026, 1, 31),
)
first = await run_import(
_importer(missing, today=date(2026, 5, 20)), engine=engine
)
second = await run_import(
_importer(missing, today=date(2026, 5, 21)), engine=engine
)
assert first.status == STATUS_PROMOTED
assert second.status == STATUS_PROMOTED
async with factory() as db:
gap = (await db.execute(select(SecFilingGap))).scalar_one()
events = (
await db.execute(
select(SystemEvent).where(SystemEvent.code == "filing_gap_aged")
)
).scalars().all()
assert gap.escalated_at is not None
assert len(events) == 1
async def test_queued_filing_reclassified_non_xbrl_is_removed(engine):
factory = _factory(engine)
await _seed(factory, ["AAPL"])
backfill = FakeSecClient(
tickers={"AAPL": 320193},
companyfacts={320193: _companyfacts([CF_K, CF_Q1], [SH_K, SH_Q1])},
submissions={320193: _submissions(SUB_FILINGS)},
latest_index=date(2026, 1, 31),
)
await run_import(_importer(backfill), engine=engine)
now = datetime.now(timezone.utc)
async with factory() as db:
db.add(SecFilingGap(
cik="0000320193",
accession="NONX",
form="10-Q/A",
index_date=date(2026, 5, 1),
reason="not_in_companyfacts",
first_seen_at=now,
last_attempted_at=now,
))
await db.commit()
client = FakeSecClient(
tickers={"AAPL": 320193},
companyfacts={320193: _companyfacts([CF_K, CF_Q1], [SH_K, SH_Q1])},
submissions={320193: _submissions(SUB_FILINGS + [
_filing(
"NONX",
"10-Q/A",
"2026-03-28",
"2026-05-01",
"2026-05-01T10:01:00.000Z",
is_xbrl=False,
)
])},
latest_index=date(2026, 1, 31),
)
run = await run_import(_importer(client, today=date(2026, 5, 20)), engine=engine)
assert run.status == STATUS_PROMOTED
assert await _count(factory, SecFilingGap) == 0
async def test_queued_parser_skip_stays_blocked_with_actionable_reason(
engine, monkeypatch
):
from app.services.sec_facts_parser import ParseResult
factory = _factory(engine)
await _seed(factory, ["AAPL"])
backfill = FakeSecClient(
tickers={"AAPL": 320193},
companyfacts={320193: _companyfacts([CF_K, CF_Q1], [SH_K, SH_Q1])},
submissions={320193: _submissions(SUB_FILINGS)},
latest_index=date(2026, 1, 31),
)
await run_import(_importer(backfill), engine=engine)
now = datetime.now(timezone.utc)
async with factory() as db:
db.add(SecFilingGap(
cik="0000320193",
accession="BADPARSE",
form="10-Q",
index_date=date(2026, 5, 1),
reason="not_in_companyfacts",
first_seen_at=now,
last_attempted_at=now,
))
await db.commit()
bad_fact = _rev(
"2025-09-28", "2026-03-28", 254940, 2026, "Q2", "BADPARSE"
)
bad_share = _shares("2026-04-17", 14687, "BADPARSE", 2026, "Q2")
client = FakeSecClient(
tickers={"AAPL": 320193},
companyfacts={
320193: _companyfacts(
[CF_K, CF_Q1, bad_fact], [SH_K, SH_Q1, bad_share]
)
},
submissions={320193: _submissions(SUB_FILINGS + [
_filing(
"BADPARSE",
"10-Q",
"2026-03-28",
"2026-05-01",
"2026-05-01T10:01:00.000Z",
)
])},
latest_index=date(2026, 1, 31),
)
def skip_parse(*args, **kwargs):
return ParseResult(skipped_filings=[{
"accession": "BADPARSE",
"reason": "unparseable",
}])
monkeypatch.setattr("app.services.sec_facts_parser.parse_snapshots", skip_parse)
run = await run_import(_importer(client, today=date(2026, 5, 20)), engine=engine)
assert run.status == STATUS_PROMOTED
async with factory() as db:
gap = (await db.execute(select(SecFilingGap))).scalar_one()
assert gap.reason == "parser_unusable"
async def test_validation_caps_details_but_keeps_complete_blocked_cik_set():
importer = SecFundamentalsImporter(today=date(2026, 5, 20))
importer._latest_index_date = date(2026, 5, 19)
staged = StagedFundamentals(
resolved=ResolvedUniverse(),
missing_xbrl=[
{
"cik": f"{i:010d}",
"accession": f"MISS-{i}",
"form": "10-Q",
"index_date": date(2026, 5, 1),
"age_days": 19,
"reason": "not_in_companyfacts",
}
for i in range(60)
],
no_xbrl_filings=[
{"cik": f"{i + 100:010d}", "name": f"New {i}"}
for i in range(60)
],
recovered=[
{"cik": f"{i:010d}", "accession": f"REC-{i}", "source_cik": "1"}
for i in range(60)
],
)
result = await importer.validate(None, staged)
assert len(result.summary["missing_xbrl"]) == 50
assert len(result.summary["no_xbrl_filings"]) == 50
assert len(result.summary["recovered_from_coregistrant"]) == 50
assert len(result.summary["setup_blocked_ciks"]) == 120
async def test_non_xbrl_amendment_skipped_not_failed(engine):
factory = _factory(engine)
await _seed(factory, ["AAPL"])