SPB Git

spb/findata

Public
5.8 KB · 110 lines python
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
Files · main
Menu

Theme