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