fix: make SEC quality gating terminal-safe
This commit is contained in:
@@ -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 "")
|
||||
|
||||
@@ -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"])
|
||||
|
||||
Reference in New Issue
Block a user