SPB Git

spb/findata

Public
8.3 KB · 139 lines python
1"""FirstRate Data US Fundamentals (`/api-f/v1`). Verified 2026-11-01: auth via `userid` query parameter2(the page advertises `api_key` but that returns 403). JSON envelope `{status, data:{ticker, count, financial_data:[...]}}`.34Point-in-time policy5--------------------6The vendor supplies `filing_date` (SEC filing date) and one value per (ticker, period): no revision history.7FinData sets `available_at = filing_date 00:00 America/New_York + 1 day` (the filing is certainly public on the8next trading morning; EDGAR acceptance after 17:30 ET is disseminated the next business day). `published_at` is9unknown → NULL. Each ingestion run compares the vendor value with the latest stored revision; a change creates a10new revision with `restatement_flag = true` and supersedes the old one. Pre-ingestion restatements cannot be11reconstructed from this vendor (documented limitation).12"""13from __future__ import annotations1415import json16from collections.abc import Iterator17from datetime import UTC, date, datetime, time, timedelta18from typing import Any19from zoneinfo import ZoneInfo2021import httpx22from findata_core.config import get_settings23from findata_core.redact import redact2425BASE = "https://firstratedata.com/api-f"26SOURCE_ID = "firstrate_fundamentals"27ET = ZoneInfo("America/New_York")28DEFAULT_TRANSPORT: httpx.BaseTransport | None = None2930# metric -> (unit, kind)  kind: flow|stock|per_share|ratio|count31METRICS: dict[str, tuple[str, str]] = {32    "rev": ("USD", "flow"), "cogs": ("USD", "flow"), "gp": ("USD", "flow"), "sgna": ("USD", "flow"),33    "research_dev": ("USD", "flow"), "op_ex": ("USD", "flow"), "op_inc": ("USD", "flow"), "interest_exp": ("USD", "flow"),34    "ebt": ("USD", "flow"), "tax_expense": ("USD", "flow"), "net_inc": ("USD", "flow"), "net_inc_com": ("USD", "flow"),35    "net_inc_non_control": ("USD", "flow"), "net_loss_discontinued": ("USD", "flow"), "cos_inc": ("USD", "flow"),36    "ebit": ("USD", "flow"), "ebitda": ("USD", "flow"), "depr": ("USD", "flow"), "share_comp": ("USD", "flow"),37    "pref_divs": ("USD", "flow"), "eps": ("USD/share", "per_share"), "eps_d": ("USD/share", "per_share"),38    "dps": ("USD/share", "per_share"), "shares": ("shares", "count"), "shares_av": ("shares", "count"),39    "shares_av_diluted": ("shares", "count"),40    "assets_tot": ("USD", "stock"), "assets_c": ("USD", "stock"), "assets_nc": ("USD", "stock"), "cash": ("USD", "stock"),41    "receivables": ("USD", "stock"), "inventory": ("USD", "stock"), "inv_c": ("USD", "stock"), "inv_nc": ("USD", "stock"),42    "investments": ("USD", "stock"), "ppe": ("USD", "stock"), "intangibles": ("USD", "stock"), "tax_asset": ("USD", "stock"),43    "liab_tot": ("USD", "stock"), "liab_c": ("USD", "stock"), "liab_nc": ("USD", "stock"), "payables": ("USD", "stock"),44    "def_rev": ("USD", "stock"), "debt": ("USD", "stock"), "debt_c": ("USD", "stock"), "debt_nc": ("USD", "stock"),45    "tax_liability": ("USD", "stock"), "equity": ("USD", "stock"), "ret_earnings": ("USD", "stock"), "acc_oci": ("USD", "stock"),46    "cash_op": ("USD", "flow"), "cash_inv": ("USD", "flow"), "cash_fin": ("USD", "flow"), "capex": ("USD", "flow"),47    "cash_div": ("USD", "flow"), "cash_change": ("USD", "flow"), "cash_business_acq": ("USD", "flow"),48    "cash_inv_acq": ("USD", "flow"), "cash_debt_sec": ("USD", "flow"), "cash_equity": ("USD", "flow"),49    "fx_impact_cash": ("USD", "flow"),50    "roa": ("ratio", "ratio"), "roe": ("ratio", "ratio"), "roic": ("ratio", "ratio"), "gm": ("ratio", "ratio"),51    "om": ("ratio", "ratio"), "intr_cover": ("ratio", "ratio"),52}53NON_METRIC_FIELDS = {"period_end", "period_type", "filing_date", "fiscal_year", "fiscal_period", "actual_year", "actual_period"}545556def available_at_policy(filing_date: date | None, period_end: date) -> datetime:57    """PIT availability. Falls back to period_end + 90 days when the filing date is missing (flagged upstream)."""58    base = filing_date if filing_date else period_end + timedelta(days=90)59    return datetime.combine(base + timedelta(days=1), time(0, 0), tzinfo=ET).astimezone(UTC)606162def facts_from_financials(payload: dict[str, Any], observed_at: datetime, ticker: str | None = None) -> Iterator[dict[str, Any]]:63    """Explode one `/v1/financials/all` or `/single` JSON payload into long-format facts (dicts)."""64    data = payload.get("data") or {}65    tkr = (data.get("ticker") or ticker or "").upper()66    records = data.get("financial_data") or ([data] if "period_end" in data else [])67    for rec in records:68        pe = date.fromisoformat(rec["period_end"][:10])69        fd = date.fromisoformat(rec["filing_date"][:10]) if rec.get("filing_date") else None70        ptype = "a" if str(rec.get("period_type", "")).lower().startswith("a") or str(rec.get("fiscal_period", "")).lower() == "fy" else "q"71        avail = available_at_policy(fd, pe)72        start = None73        if ptype == "q":74            start = (pe.replace(day=1) - timedelta(days=60)).replace(day=1)  # approx. quarter start (vendor gives no start)75        else:76            start = (pe - timedelta(days=364)).replace(day=1)77        for k, v in rec.items():78            if k in NON_METRIC_FIELDS or k not in METRICS:79                continue80            unit, _kind = METRICS[k]81            yield {82                "entity_id": f"frd:{tkr}", "security_id": f"firstrate:stock:{tkr}", "metric": k,83                "value": None if v is None else float(v), "unit": unit, "currency": "USD" if unit.startswith("USD") else None,84                "fiscal_period_start": start, "fiscal_period_end": pe, "fiscal_period_type": ptype,85                "fiscal_year": rec.get("fiscal_year"), "fiscal_period": str(rec.get("fiscal_period") or "").lower() or None,86                "filing_date": fd, "published_at": None, "available_at": avail, "observed_at": observed_at,87                "source_document": f"SEC {'10-K' if ptype == 'a' else '10-Q'} via FirstRate (filing_date={fd})",88                "source_id": SOURCE_ID, "pit_fallback": fd is None,89            }909192class FirstRateFundamentalsConnector:93    source_id = SOURCE_ID9495    def __init__(self, userid: str | None = None, transport: httpx.BaseTransport | None = None):96        self._userid = userid if userid is not None else get_settings().frd_userid.get_secret_value()97        self._client = httpx.Client(timeout=60.0, transport=transport or DEFAULT_TRANSPORT,98                                    headers={"User-Agent": "FinDataOS/0.1 (+private research lakehouse)"})99100    def _get(self, path: str, params: dict[str, Any] | None = None) -> dict[str, Any]:101        if not self._userid:102            raise RuntimeError("FRD_USERID not configured")103        q = dict(params or {})104        q["type"] = "json"105        q["userid"] = self._userid106        r = self._client.get(f"{BASE}{path}", params=q)107        if r.status_code != 200:108            raise RuntimeError(redact(f"fundamentals HTTP {r.status_code} on {path}: {r.text[:200]}", [self._userid]))109        try:110            payload = r.json()111        except json.JSONDecodeError as e:112            raise RuntimeError(redact(f"fundamentals: non-JSON response on {path}", [self._userid])) from e113        if payload.get("status") not in (None, "success"):114            raise RuntimeError(redact(f"fundamentals: status={payload.get('status')} on {path}", [self._userid]))115        return payload116117    def discover(self) -> dict[str, Any]:118        p = self._get("/v1/financials/all/AAPL/annual")119        return {"source_id": SOURCE_ID, "probe": "financials/all/AAPL/annual", "count": p.get("data", {}).get("count"),120                "fields": sorted((p.get("data", {}).get("financial_data") or [{}])[0].keys())}121122    def financials_all(self, ticker: str, period: str = "quarterly") -> dict[str, Any]:123        assert period in ("annual", "quarterly")124        return self._get(f"/v1/financials/all/{ticker.upper()}/{period}")125126    def financials_single(self, ticker: str, period: str, year: int) -> dict[str, Any]:127        return self._get(f"/v1/financials/single/{ticker.upper()}", {"period": period, "year": year})128129    def earnings(self, ticker: str) -> list[dict[str, Any]]:130        p = self._get(f"/v1/earnings_release/t/{ticker.upper()}")131        return p.get("data", {}).get("earnings_release_data") or []132133    def metrics(self, ticker: str, start: date, end: date) -> list[dict[str, Any]]:134        p = self._get(f"/v1/metrics/t/{ticker.upper()}/{start.isoformat()}/{end.isoformat()}")135        return p.get("data", {}).get("metrics_data") or []136137    def close(self) -> None:138        self._client.close()139
Files · 6437fd798ded13a52b8fed8b7d8e78b700fbceec
Menu

Theme