Files
signal-platform/app/providers/fmp.py
Dennis Thiessen 61ab24490d
Some checks failed
Deploy / lint (push) Failing after 7s
Deploy / test (push) Has been skipped
Deploy / deploy (push) Has been skipped
first commit
2026-02-20 17:31:01 +01:00

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