"""Financial Modeling Prep (FMP) fundamentals provider using httpx. Uses the stable API endpoints (https://financialmodelingprep.com/stable/) which replaced the legacy /api/v3/ endpoints deprecated in Aug 2025. """ from __future__ import annotations import logging import os from datetime import datetime, timezone from pathlib import Path import httpx from app.exceptions import ProviderError, RateLimitError from app.providers.protocol import FundamentalData logger = logging.getLogger(__name__) _FMP_STABLE_URL = "https://financialmodelingprep.com/stable" # Resolve CA bundle for explicit httpx verify _CA_BUNDLE = os.environ.get("SSL_CERT_FILE", "") if not _CA_BUNDLE or not Path(_CA_BUNDLE).exists(): _CA_BUNDLE_PATH: str | bool = True # use system default else: _CA_BUNDLE_PATH = _CA_BUNDLE class FMPFundamentalProvider: """Fetches fundamental data from Financial Modeling Prep REST API.""" def __init__(self, api_key: str) -> None: if not api_key: raise ProviderError("FMP API key is required") self._api_key = api_key # Mapping from FMP endpoint name to the FundamentalData field it populates _ENDPOINT_FIELD_MAP: dict[str, str] = { "ratios-ttm": "pe_ratio", "financial-growth": "revenue_growth", "earnings": "earnings_surprise", } async def fetch_fundamentals(self, ticker: str) -> FundamentalData: """Fetch P/E, revenue growth, earnings surprise, and market cap. Fetches from multiple stable endpoints. If a supplementary endpoint (ratios, growth, earnings) returns 402 (paid tier), we gracefully degrade and return partial data rather than failing entirely, and record the affected field in ``unavailable_fields``. """ try: endpoints_402: set[str] = set() async with httpx.AsyncClient(timeout=30.0, verify=_CA_BUNDLE_PATH) as client: params = {"symbol": ticker, "apikey": self._api_key} # Profile is the primary source — must succeed profile = await self._fetch_json(client, "profile", params, ticker) # Supplementary sources — degrade gracefully on 402 ratios, was_402 = await self._fetch_json_optional(client, "ratios-ttm", params, ticker) if was_402: endpoints_402.add("ratios-ttm") growth, was_402 = await self._fetch_json_optional(client, "financial-growth", params, ticker) if was_402: endpoints_402.add("financial-growth") earnings, was_402 = await self._fetch_json_optional(client, "earnings", params, ticker) if was_402: endpoints_402.add("earnings") pe_ratio = self._safe_float(ratios.get("priceToEarningsRatioTTM")) revenue_growth = self._safe_float(growth.get("revenueGrowth")) market_cap = self._safe_float(profile.get("marketCap")) earnings_surprise = self._compute_earnings_surprise(earnings) # Build unavailable_fields from 402 endpoints unavailable_fields: dict[str, str] = { self._ENDPOINT_FIELD_MAP[ep]: "requires paid plan" for ep in endpoints_402 if ep in self._ENDPOINT_FIELD_MAP } return FundamentalData( ticker=ticker, pe_ratio=pe_ratio, revenue_growth=revenue_growth, earnings_surprise=earnings_surprise, market_cap=market_cap, fetched_at=datetime.now(timezone.utc), unavailable_fields=unavailable_fields, ) except (ProviderError, RateLimitError): raise except Exception as exc: logger.error("FMP provider error for %s: %s", ticker, exc) raise ProviderError(f"FMP provider error for {ticker}: {exc}") from exc async def _fetch_json( self, client: httpx.AsyncClient, endpoint: str, params: dict, ticker: str, ) -> dict: """Fetch a stable endpoint and return the first item (or empty dict).""" url = f"{_FMP_STABLE_URL}/{endpoint}" resp = await client.get(url, params=params) self._check_response(resp, ticker, endpoint) data = resp.json() if isinstance(data, list): return data[0] if data else {} return data if isinstance(data, dict) else {} async def _fetch_json_optional( self, client: httpx.AsyncClient, endpoint: str, params: dict, ticker: str, ) -> tuple[dict, bool]: """Fetch a stable endpoint, returning ``({}, True)`` on 402 (paid tier). Returns a tuple of (data_dict, was_402) so callers can track which endpoints required a paid plan. """ url = f"{_FMP_STABLE_URL}/{endpoint}" resp = await client.get(url, params=params) if resp.status_code == 402: logger.warning("FMP %s requires paid plan — skipping for %s", endpoint, ticker) return {}, True self._check_response(resp, ticker, endpoint) data = resp.json() if isinstance(data, list): return (data[0] if data else {}, False) return (data if isinstance(data, dict) else {}, False) def _compute_earnings_surprise(self, earnings_data: dict) -> float | None: """Compute earnings surprise % from the most recent actual vs estimated EPS.""" actual = self._safe_float(earnings_data.get("epsActual")) estimated = self._safe_float(earnings_data.get("epsEstimated")) if actual is None or estimated is None or estimated == 0: return None return ((actual - estimated) / abs(estimated)) * 100 def _check_response( self, resp: httpx.Response, ticker: str, endpoint: str ) -> None: """Raise appropriate errors for non-200 responses.""" if resp.status_code == 429: raise RateLimitError(f"FMP rate limit hit for {ticker} ({endpoint})") if resp.status_code == 403: raise ProviderError( f"FMP {endpoint} access denied for {ticker}: HTTP 403 — check API key validity and plan tier" ) if resp.status_code != 200: raise ProviderError( f"FMP {endpoint} error for {ticker}: HTTP {resp.status_code}" ) @staticmethod def _safe_float(value: object) -> float | None: """Convert a value to float, returning None on failure.""" if value is None: return None try: return float(value) except (TypeError, ValueError): return None