"""Actionability gate for incomplete SEC fundamentals.""" from __future__ import annotations import json from dataclasses import dataclass from sqlalchemy import exists, func, select from sqlalchemy.ext.asyncio import AsyncSession 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 fundamental_data_refresh_service _SEC_FORMS = ("10-K", "10-Q", "10-K/A", "10-Q/A") @dataclass(frozen=True) class SetupQuality: eligible: bool code: str | None = None message: str | None = None async def active_gaps( db: AsyncSession, ciks: set[str] | None = None, ) -> list[SecFilingGap]: """Unresolved gaps that have not been superseded by a later filing.""" matching_snapshot = exists().where( FundamentalSnapshot.accession == SecFilingGap.accession ) gap_date = func.coalesce( SecFilingGap.index_date, func.date(SecFilingGap.first_seen_at), ) later_snapshot = exists().where( FundamentalSnapshot.cik == SecFilingGap.cik, FundamentalSnapshot.form.in_(_SEC_FORMS), FundamentalSnapshot.filed_date > gap_date, ) stmt = select(SecFilingGap).where( ~matching_snapshot, ~later_snapshot, ) if ciks is not None: if not ciks: return [] stmt = stmt.where(SecFilingGap.cik.in_(ciks)) return list((await db.execute(stmt)).scalars().all()) async def _latest_validation(db: AsyncSession) -> dict: payload = ( await db.execute( select(DataImportRun.validation_json) .where( DataImportRun.source == "sec_facts", DataImportRun.validation_json.is_not(None), ) .order_by(DataImportRun.id.desc()) .limit(1) ) ).scalar_one_or_none() if not payload: return {} try: summary = json.loads(payload) except (TypeError, ValueError): return {} return summary if isinstance(summary, dict) else {} async def blocked_reasons_by_cik( db: AsyncSession, ciks: set[str] | None = None, ) -> dict[str, str]: """Current SEC blocker code by CIK; no historical audit scan.""" if not await fundamental_data_refresh_service.is_enabled(db): return {} if ciks is not None and not ciks: return {} reasons = { gap.cik: "sec_filing_gap" for gap in await active_gaps(db, ciks) } summary = await _latest_validation(db) def wanted(cik: str) -> bool: return ciks is None or cik in ciks # New summaries carry the complete compact CIK set while the detailed lists # stay capped for audit readability. Detailed entries supply the reason. for cik in summary.get("setup_blocked_ciks") or []: normalized = str(cik) if cik else "" if normalized and wanted(normalized): reasons.setdefault(normalized, "sec_filing_gap") for item in summary.get("missing_xbrl") or []: normalized = str(item.get("cik") or "") if normalized and wanted(normalized): reasons.setdefault(normalized, "sec_filing_gap") for cik in summary.get("no_xbrl_ciks") or []: normalized = str(cik) if cik else "" if normalized and wanted(normalized): reasons[normalized] = "no_xbrl_filings" for item in summary.get("no_xbrl_filings") or []: normalized = str(item.get("cik") or "") if normalized and wanted(normalized): reasons[normalized] = "no_xbrl_filings" return reasons async def blocked_ciks(db: AsyncSession) -> set[str]: return set(await blocked_reasons_by_cik(db)) async def blocked_ticker_ids(db: AsyncSession) -> set[int]: ciks = await blocked_ciks(db) if not ciks: return set() rows = await db.execute(select(Ticker.id).where(Ticker.cik.in_(ciks))) return {int(ticker_id) for ticker_id in rows.scalars()} async def ticker_quality(db: AsyncSession, symbol: str) -> SetupQuality: ticker = ( await db.execute( select(Ticker).where(Ticker.symbol == symbol.strip().upper()) ) ).scalar_one_or_none() if ticker is None or not ticker.cik: return SetupQuality(eligible=True) reason = (await blocked_reasons_by_cik(db, {ticker.cik})).get(ticker.cik) if reason == "no_xbrl_filings": return SetupQuality( eligible=False, code=reason, message=( "No SEC 10-K/10-Q is available for this registrant, so new setups " "are paused. New registrants clear automatically after their first " "filing; a successor shell needs an SEC CIK override." ), ) if reason: return SetupQuality( eligible=False, code=reason, message=( "A recent SEC filing is still being reconciled, so new setups are " "paused. The scheduled fundamentals import retries it automatically." ), ) return SetupQuality(eligible=True) async def ticker_is_eligible(db: AsyncSession, ticker_id: int) -> bool: cik = ( await db.execute(select(Ticker.cik).where(Ticker.id == ticker_id)) ).scalar_one_or_none() if not cik: return True return cik not in await blocked_reasons_by_cik(db, {cik})