An escalated gap stops pausing setups while the issuer's own fundamentals are still recent. That reprieve ends on its own — the stored filings age past GAP_GATE_RECENT_FILING_DAYS, or a newer gap arrives and the all-escalated condition fails — and nothing reported either, because filing_gap_aged only escalates gaps whose escalated_at is NULL and so never fires twice for the same gap. For the 43 issuers behind the previous commit that lands around 2026-10-26, when their late-April filings age out together. sec_filing_gaps.exempted_at (migration 034) makes the transition observable: stamped quietly while the issuer is exempt, cleared when the exemption lapses, and the clear is what raises filing_gap_repaused — once per lapse, re-arming if the issuer's data recovers and ages out again. A gap that was never exempt has no transition and stays silent; it is simply still paused, which filing_gap_aged already said. gap_exempt_ciks is public so the importer alerts on membership changes in exactly the set the gate reads, rather than restating the rule. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_016Lo99z3jWqu9X9ueBU3Z3D
282 lines
9.2 KiB
Python
282 lines
9.2 KiB
Python
from __future__ import annotations
|
|
|
|
import json
|
|
from datetime import date, datetime, timedelta, timezone
|
|
|
|
from app.models.data_import_run import DataImportRun
|
|
from app.models.fundamental_snapshot import FundamentalSnapshot
|
|
from app.models.sec_filing_gap import SecFilingGap
|
|
from app.models.ticker import Ticker
|
|
from app.services import fundamentals_quality_service
|
|
|
|
|
|
async def test_latest_sec_validation_blocks_deferred_and_no_history_ciks(
|
|
db_session,
|
|
):
|
|
missing = Ticker(symbol="MISSING", cik="0000000001")
|
|
no_history = Ticker(symbol="NEWREG", cik="0000000002")
|
|
healthy = Ticker(symbol="HEALTHY", cik="0000000003")
|
|
db_session.add_all([missing, no_history, healthy])
|
|
await db_session.flush()
|
|
db_session.add(
|
|
DataImportRun(
|
|
source="sec_facts",
|
|
status="deferred",
|
|
validation_json=json.dumps({
|
|
"missing_xbrl": [{"cik": missing.cik, "accession": "MISSING-Q"}],
|
|
"no_xbrl_filings": [{"cik": no_history.cik}],
|
|
}),
|
|
started_at=datetime.now(timezone.utc),
|
|
)
|
|
)
|
|
await db_session.flush()
|
|
|
|
assert await fundamentals_quality_service.blocked_ticker_ids(db_session) == {
|
|
missing.id,
|
|
no_history.id,
|
|
}
|
|
|
|
|
|
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,
|
|
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()
|
|
|
|
assert await fundamentals_quality_service.blocked_ticker_ids(db_session) == {
|
|
ticker.id
|
|
}
|
|
|
|
db_session.add(
|
|
FundamentalSnapshot(
|
|
cik=ticker.cik,
|
|
accession="LATER-Q",
|
|
form="10-Q",
|
|
filed_date=date.today(),
|
|
accepted_at=datetime.now(timezone.utc),
|
|
period_end=date.today(),
|
|
fiscal_year=date.today().year,
|
|
fiscal_period="Q2",
|
|
)
|
|
)
|
|
await db_session.flush()
|
|
|
|
assert await fundamentals_quality_service.blocked_ticker_ids(db_session) == set()
|
|
|
|
|
|
async def test_gap_without_index_date_uses_first_seen_date_for_supersession(
|
|
db_session,
|
|
):
|
|
ticker = Ticker(symbol="DATELESS", cik="0000000045")
|
|
first_seen = datetime(2026, 5, 1, 12, tzinfo=timezone.utc)
|
|
db_session.add_all([
|
|
ticker,
|
|
SecFilingGap(
|
|
cik=ticker.cik,
|
|
accession="DATELESS-Q",
|
|
form="10-Q",
|
|
index_date=None,
|
|
reason="not_in_companyfacts",
|
|
first_seen_at=first_seen,
|
|
last_attempted_at=first_seen,
|
|
),
|
|
])
|
|
await db_session.flush()
|
|
|
|
assert await fundamentals_quality_service.blocked_ticker_ids(db_session) == {
|
|
ticker.id
|
|
}
|
|
|
|
db_session.add(
|
|
FundamentalSnapshot(
|
|
cik=ticker.cik,
|
|
accession="LATER-DATELESS-Q",
|
|
form="10-Q",
|
|
filed_date=date(2026, 5, 2),
|
|
accepted_at=datetime(2026, 5, 2, 12, tzinfo=timezone.utc),
|
|
period_end=date(2026, 3, 31),
|
|
fiscal_year=2026,
|
|
fiscal_period="Q1",
|
|
)
|
|
)
|
|
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,
|
|
DataImportRun(
|
|
source="sec_facts",
|
|
status="promoted",
|
|
validation_json=json.dumps({
|
|
"setup_blocked_ciks": [ticker.cik],
|
|
"no_xbrl_ciks": [ticker.cik],
|
|
"no_xbrl_filings": [],
|
|
}),
|
|
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 "")
|
|
assert await fundamentals_quality_service.ticker_is_eligible(
|
|
db_session, ticker.id
|
|
) is False
|
|
|
|
|
|
def _escalated_gap(cik: str, *, escalated: bool = True) -> SecFilingGap:
|
|
first_seen = datetime.now(timezone.utc) - timedelta(days=24)
|
|
return SecFilingGap(
|
|
cik=cik,
|
|
accession=f"{cik}-STALE-Q",
|
|
form="10-Q",
|
|
index_date=(first_seen.date()),
|
|
reason="not_in_companyfacts",
|
|
first_seen_at=first_seen,
|
|
last_attempted_at=datetime.now(timezone.utc),
|
|
escalated_at=(
|
|
datetime.now(timezone.utc) - timedelta(days=10) if escalated else None
|
|
),
|
|
)
|
|
|
|
|
|
def _prior_quarter(cik: str, *, age_days: int) -> FundamentalSnapshot:
|
|
"""The issuer's last successfully ingested filing, older than the gap so it
|
|
cannot supersede it — exactly the production shape of a stale companyfacts
|
|
file: Q1 stored, Q2 missing."""
|
|
filed = date.today() - timedelta(days=age_days)
|
|
return FundamentalSnapshot(
|
|
cik=cik,
|
|
accession=f"{cik}-PRIOR-Q",
|
|
form="10-Q",
|
|
filed_date=filed,
|
|
accepted_at=datetime.now(timezone.utc) - timedelta(days=age_days),
|
|
period_end=filed,
|
|
fiscal_year=filed.year,
|
|
fiscal_period="Q1",
|
|
)
|
|
|
|
|
|
async def test_escalated_gap_stops_blocking_when_fundamentals_are_recent(db_session):
|
|
ticker = Ticker(symbol="STALEFACTS", cik="0000000046")
|
|
db_session.add(ticker)
|
|
db_session.add(_escalated_gap(ticker.cik))
|
|
await db_session.flush()
|
|
assert await fundamentals_quality_service.blocked_ticker_ids(db_session) == {
|
|
ticker.id
|
|
}
|
|
|
|
# The alert has run and the issuer still has last quarter to score on.
|
|
db_session.add(_prior_quarter(ticker.cik, age_days=120))
|
|
await db_session.flush()
|
|
|
|
assert await fundamentals_quality_service.blocked_ticker_ids(db_session) == set()
|
|
# ...but the filing is still queued, so the importer keeps retrying it.
|
|
assert len(await fundamentals_quality_service.active_gaps(db_session)) == 1
|
|
|
|
|
|
async def test_escalated_gap_keeps_blocking_when_fundamentals_are_stale(db_session):
|
|
ticker = Ticker(symbol="NOTHINGFRESH", cik="0000000047")
|
|
db_session.add_all([
|
|
ticker,
|
|
_escalated_gap(ticker.cik),
|
|
_prior_quarter(
|
|
ticker.cik,
|
|
age_days=fundamentals_quality_service.GAP_GATE_RECENT_FILING_DAYS + 30,
|
|
),
|
|
])
|
|
await db_session.flush()
|
|
|
|
assert await fundamentals_quality_service.blocked_ticker_ids(db_session) == {
|
|
ticker.id
|
|
}
|
|
|
|
|
|
async def test_unescalated_gap_still_blocks_alongside_an_escalated_one(db_session):
|
|
ticker = Ticker(symbol="TWOGAPS", cik="0000000048")
|
|
fresh = datetime.now(timezone.utc)
|
|
db_session.add_all([
|
|
ticker,
|
|
_escalated_gap(ticker.cik),
|
|
SecFilingGap(
|
|
cik=ticker.cik,
|
|
accession="TWOGAPS-FRESH-Q",
|
|
form="10-Q",
|
|
index_date=date.today(),
|
|
reason="not_in_companyfacts",
|
|
first_seen_at=fresh,
|
|
last_attempted_at=fresh,
|
|
),
|
|
_prior_quarter(ticker.cik, age_days=120),
|
|
])
|
|
await db_session.flush()
|
|
|
|
assert await fundamentals_quality_service.blocked_ticker_ids(db_session) == {
|
|
ticker.id
|
|
}
|
|
|
|
|
|
async def test_summary_path_does_not_reblock_an_exempt_cik(db_session):
|
|
"""The run summary mirrors the same filings as the queue — it must honour the
|
|
same hand-off, or the bound is inert in production."""
|
|
ticker = Ticker(symbol="MIRRORED", cik="0000000049")
|
|
db_session.add(ticker)
|
|
db_session.add(_escalated_gap(ticker.cik))
|
|
db_session.add(_prior_quarter(ticker.cik, age_days=120))
|
|
await db_session.flush()
|
|
db_session.add(
|
|
DataImportRun(
|
|
source="sec_facts",
|
|
status="promoted",
|
|
validation_json=json.dumps({
|
|
"setup_blocked_ciks": [ticker.cik],
|
|
"missing_xbrl": [
|
|
{"cik": ticker.cik, "accession": f"{ticker.cik}-STALE-Q"}
|
|
],
|
|
}),
|
|
started_at=datetime.now(timezone.utc),
|
|
)
|
|
)
|
|
await db_session.flush()
|
|
|
|
assert await fundamentals_quality_service.blocked_ticker_ids(db_session) == set()
|
|
|
|
|
|
async def test_a_newer_gap_ends_the_exemption(db_session):
|
|
"""Production's second exit path: Q3 also fails to ingest, so an un-escalated
|
|
gap joins the escalated one and the issuer pauses again immediately."""
|
|
ticker = Ticker(symbol="NEWGAP", cik="0000000050")
|
|
db_session.add_all([
|
|
ticker, _escalated_gap(ticker.cik), _prior_quarter(ticker.cik, age_days=120)
|
|
])
|
|
await db_session.flush()
|
|
assert await fundamentals_quality_service.blocked_ticker_ids(db_session) == set()
|
|
|
|
fresh = datetime.now(timezone.utc)
|
|
db_session.add(SecFilingGap(
|
|
cik=ticker.cik, accession="NEWGAP-Q3", form="10-Q", index_date=date.today(),
|
|
reason="not_in_companyfacts", first_seen_at=fresh, last_attempted_at=fresh,
|
|
))
|
|
await db_session.flush()
|
|
|
|
assert await fundamentals_quality_service.blocked_ticker_ids(db_session) == {
|
|
ticker.id
|
|
}
|