175 lines
6.6 KiB
Python
175 lines
6.6 KiB
Python
"""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
|