95 lines
3.5 KiB
Python
95 lines
3.5 KiB
Python
"""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
|