161 lines
5.3 KiB
Python
161 lines
5.3 KiB
Python
"""Actionability gate for incomplete SEC fundamentals."""
|
|
|
|
from __future__ import annotations
|
|
|
|
import json
|
|
from dataclasses import dataclass
|
|
|
|
from sqlalchemy import exists, or_, 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
|
|
)
|
|
later_snapshot = exists().where(
|
|
FundamentalSnapshot.cik == SecFilingGap.cik,
|
|
FundamentalSnapshot.form.in_(_SEC_FORMS),
|
|
FundamentalSnapshot.filed_date > SecFilingGap.index_date,
|
|
)
|
|
stmt = select(SecFilingGap).where(
|
|
~matching_snapshot,
|
|
or_(SecFilingGap.index_date.is_(None), ~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})
|