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()