1"""SEC EDGAR XBRL `companyfacts` connector — independent public-domain source.23Status: **pending configuration** until `SEC_EDGAR_USER_AGENT` (name + contact e-mail, per SEC fair-access policy)4is set. Parsing is implemented and fixture-tested; live fetches refuse to run without the User-Agent and respect5the 10 requests/second limit.67PIT: `companyfacts` provides `filed` (filing date), `accn` (accession), `form`, `fy`, `fp`, `start`, `end`, `frame`.8`available_at = filed + 1 day 00:00 ET`. Facts re-reported in later filings appear as distinct (accn, filed) rows9→ FinData keeps each as a revision ordered by filing date, so as-of queries naturally pick the latest filing10*available at* the query time, never a later restatement.11"""12from __future__ import annotations1314import time15from collections.abc import Iterator16from datetime import UTC, date, datetime, timedelta17from typing import Any18from zoneinfo import ZoneInfo1920import httpx21from findata_core.config import get_settings2223SOURCE_ID = "sec_edgar"24ET = ZoneInfo("America/New_York")25BASE = "https://data.sec.gov"2627# us-gaap concept -> FinData metric name (subset; others keep the concept name prefixed `xbrl:`)28CONCEPT_MAP = {29 "Revenues": "rev", "RevenueFromContractWithCustomerExcludingAssessedTax": "rev", "CostOfRevenue": "cogs",30 "GrossProfit": "gp", "OperatingIncomeLoss": "op_inc", "NetIncomeLoss": "net_inc",31 "EarningsPerShareBasic": "eps", "EarningsPerShareDiluted": "eps_d", "Assets": "assets_tot",32 "AssetsCurrent": "assets_c", "Liabilities": "liab_tot", "LiabilitiesCurrent": "liab_c",33 "StockholdersEquity": "equity", "CashAndCashEquivalentsAtCarryingValue": "cash",34 "NetCashProvidedByUsedInOperatingActivities": "cash_op", "NetCashProvidedByUsedInInvestingActivities": "cash_inv",35 "NetCashProvidedByUsedInFinancingActivities": "cash_fin", "PaymentsToAcquirePropertyPlantAndEquipment": "capex",36 "CommonStockSharesOutstanding": "shares", "WeightedAverageNumberOfDilutedSharesOutstanding": "shares_av_diluted",37 "WeightedAverageNumberOfSharesOutstandingBasic": "shares_av", "ResearchAndDevelopmentExpense": "research_dev",38 "LongTermDebt": "debt", "RetainedEarningsAccumulatedDeficit": "ret_earnings",39}404142def facts_from_companyfacts(payload: dict[str, Any], observed_at: datetime, only_mapped: bool = True) -> Iterator[dict[str, Any]]:43 cik = int(payload.get("cik", 0))44 entity_id = f"sec:CIK{cik:010d}"45 name = payload.get("entityName")46 for taxonomy, concepts in (payload.get("facts") or {}).items():47 if taxonomy not in ("us-gaap", "dei"):48 continue49 for concept, body in concepts.items():50 metric = CONCEPT_MAP.get(concept)51 if metric is None:52 if only_mapped:53 continue54 metric = f"xbrl:{concept}"55 for unit, vals in (body.get("units") or {}).items():56 for v in vals:57 end = date.fromisoformat(v["end"])58 start = date.fromisoformat(v["start"]) if v.get("start") else None59 filed = date.fromisoformat(v["filed"])60 fp = (v.get("fp") or "").upper()61 ptype = "a" if fp == "FY" or (start and (end - start).days > 300) else "q"62 if start and (end - start).days > 120 and ptype == "q":63 continue # year-to-date cumulative values are not quarterly facts64 yield {65 "entity_id": entity_id, "security_id": None, "metric": metric, "value": float(v["val"]),66 "unit": unit, "currency": "USD" if unit == "USD" else None,67 "fiscal_period_start": start, "fiscal_period_end": end, "fiscal_period_type": ptype,68 "fiscal_year": v.get("fy"), "fiscal_period": fp.lower() or None, "filing_date": filed,69 "published_at": None,70 "available_at": datetime.combine(filed + timedelta(days=1), datetime.min.time(), tzinfo=ET).astimezone(UTC),71 "observed_at": observed_at,72 "source_document": f"{v.get('form')} {v.get('accn')} ({name})", "source_id": SOURCE_ID,73 "accession": v.get("accn"), "form": v.get("form"), "frame": v.get("frame"),74 }757677class SecEdgarConnector:78 source_id = SOURCE_ID7980 def __init__(self, user_agent: str | None = None, transport: httpx.BaseTransport | None = None):81 self.user_agent = user_agent if user_agent is not None else get_settings().sec_edgar_user_agent82 self._client = httpx.Client(timeout=60.0, transport=transport, headers={"User-Agent": self.user_agent or "unset",83 "Accept-Encoding": "gzip"})84 self._last = 0.08586 @property87 def configured(self) -> bool:88 return bool(self.user_agent and "@" in self.user_agent)8990 def status(self) -> dict[str, Any]:91 return {"source_id": SOURCE_ID, "status": "active" if self.configured else "pending_configuration",92 "reason": None if self.configured else "SEC_EDGAR_USER_AGENT (name + e-mail) not set"}9394 def _get(self, url: str) -> dict[str, Any]:95 if not self.configured:96 raise RuntimeError("SEC EDGAR connector pending configuration: set SEC_EDGAR_USER_AGENT")97 wait = 0.11 - (time.monotonic() - self._last)98 if wait > 0:99 time.sleep(wait)100 self._last = time.monotonic()101 r = self._client.get(url)102 r.raise_for_status()103 return r.json()104105 def company_tickers(self) -> dict[str, Any]:106 return self._get("https://www.sec.gov/files/company_tickers.json")107108 def companyfacts(self, cik: int) -> dict[str, Any]:109 return self._get(f"{BASE}/api/xbrl/companyfacts/CIK{cik:010d}.json")110