"""Financial Modeling Prep (FMP) fundamentals provider using httpx.""" from __future__ import annotations import logging from datetime import datetime, timezone import httpx from app.exceptions import ProviderError, RateLimitError from app.providers.protocol import FundamentalData logger = logging.getLogger(__name__) _FMP_BASE_URL = "https://financialmodelingprep.com/api/v3" 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 async def fetch_fundamentals(self, ticker: str) -> FundamentalData: """Fetch P/E, revenue growth, earnings surprise, and market cap.""" try: async with httpx.AsyncClient(timeout=30.0) as client: profile = await self._fetch_profile(client, ticker) earnings = await self._fetch_earnings_surprise(client, ticker) pe_ratio = self._safe_float(profile.get("pe")) revenue_growth = self._safe_float(profile.get("revenueGrowth")) market_cap = self._safe_float(profile.get("mktCap")) earnings_surprise = self._safe_float(earnings) 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), ) 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_profile(self, client: httpx.AsyncClient, ticker: str) -> dict: """Fetch company profile (P/E, revenue growth, market cap).""" url = f"{_FMP_BASE_URL}/profile/{ticker}" resp = await client.get(url, params={"apikey": self._api_key}) self._check_response(resp, ticker, "profile") data = resp.json() if isinstance(data, list) and data: return data[0] return data if isinstance(data, dict) else {} async def _fetch_earnings_surprise( self, client: httpx.AsyncClient, ticker: str ) -> float | None: """Fetch the most recent earnings surprise percentage.""" url = f"{_FMP_BASE_URL}/earnings-surprises/{ticker}" resp = await client.get(url, params={"apikey": self._api_key}) self._check_response(resp, ticker, "earnings-surprises") data = resp.json() if isinstance(data, list) and data: return self._safe_float(data[0].get("actualEarningResult")) return None 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 != 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