Files
All-in-one-Financial-Analysis/atlas-terminal/server/services/fmp_client.py
T
shawnkim1997andClaude Opus 4.6 51cbaf7f8d feat: major codebase audit — 21 routers, 37 services, 12 pages fully documented
- Add missing numpy, scipy, dbnomics to requirements.txt (fixes ImportError on fresh install)
- Sync claude.md with actual codebase: §3 file structure (37 services, 21 routers),
  §5 API endpoints (92 routes), §6 frontend pages (12), §13 TODO status
- Update README.md with current architecture (92 API routes, 21 routers, 37 services),
  multi-asset overview, research grid, macro dashboard, screener+backtest,
  multi-jurisdiction filings, and 2026-03-26 changelog entry
- Add new routers: dart, edinet, fmp, macro, research
- Add new services: cache, dart_fetcher, dart_filing_service, economic_calendar,
  ecos_fetcher, edinet_filing_service, fmp_client, global_macro_quadrant,
  kpi_history_service, macro_cycle, macro_fetcher, oecd_cycle,
  peer_comparison_service, research_dashboard, smart_money_service, yield_fx_service
- Add new frontend: macro page, screener+backtest, research grid components,
  overview (Equity/ETF/Commodity), filings (SEC/DART/EDINET), error boundaries
- Remove 6 unused services: copilot_context, crypto_fetcher, fx_fetcher,
  gemini_analysis, market_data, technical_analysis
- Remove obsolete docs: .agent/, AGENT.md, ATLAS_EVALUATION.md, docs/

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-26 21:39:07 +00:00

221 lines
6.5 KiB
Python

"""Financial Modeling Prep (FMP) API client with TTL cache and Yahoo fallbacks.
Requires ``FMP_API_KEY`` in the environment for FMP calls. When absent or on
failure, :func:`fallback_valuation_snapshot` fills a minimal snapshot from
yfinance / yahooquery (see ``claude.md`` §2.3).
"""
from __future__ import annotations
import hashlib
import json
import os
from typing import Any, Dict, List, Optional
import httpx
from server.db.cache import cache_manager
from server.utils.safe_float import _safe_float
_FMP_BASE_V3 = "https://financialmodelingprep.com/api/v3"
_FMP_BASE_V4 = "https://financialmodelingprep.com/api/v4"
_DEFAULT_LIMIT = 10
_CACHE_MEM = 300
_CACHE_DB = 3600
def _fmp_api_key() -> Optional[str]:
key = (os.getenv("FMP_API_KEY") or "").strip()
return key or None
def fmp_is_configured() -> bool:
"""True when ``FMP_API_KEY`` is set (used by routers)."""
return _fmp_api_key() is not None
def _cache_key(prefix: str, *parts: str) -> str:
h = hashlib.sha256("|".join(parts).encode()).hexdigest()[:16]
return f"fmp:{prefix}:{h}"
async def _fmp_get_json(path: str, params: Dict[str, Any]) -> Optional[Any]:
"""GET JSON from FMP with memory+SQLite cache."""
api_key = _fmp_api_key()
if not api_key:
return None
q = dict(params)
q["apikey"] = api_key
cache_key = _cache_key(path, json.dumps(q, sort_keys=True))
cached = await cache_manager.get(cache_key)
if cached is not None:
return cached
url = f"{_FMP_BASE_V3}{path}"
try:
async with httpx.AsyncClient(timeout=45.0) as client:
r = await client.get(url, params=q)
if r.status_code != 200:
return None
data = r.json()
except (httpx.HTTPError, json.JSONDecodeError, ValueError):
return None
await cache_manager.set(cache_key, data, memory_ttl=_CACHE_MEM, db_ttl=_CACHE_DB)
return data
async def _fmp_get_v4(path: str, params: Dict[str, Any]) -> Optional[Any]:
key = _fmp_api_key()
if not key:
return None
q = dict(params)
q["apikey"] = key
cache_key = _cache_key("v4" + path, json.dumps(q, sort_keys=True))
cached = await cache_manager.get(cache_key)
if cached is not None:
return cached
url = f"{_FMP_BASE_V4}{path}"
try:
async with httpx.AsyncClient(timeout=45.0) as client:
r = await client.get(url, params=q)
if r.status_code != 200:
return None
data = r.json()
except (httpx.HTTPError, json.JSONDecodeError, ValueError):
return None
await cache_manager.set(cache_key, data, memory_ttl=_CACHE_MEM, db_ttl=_CACHE_DB)
return data
async def fetch_key_metrics(
ticker: str,
period: str = "annual",
limit: int = _DEFAULT_LIMIT,
) -> Optional[List[Dict[str, Any]]]:
"""FMP key-metrics time series (PE, PB, FCF yield, etc.)."""
t = ticker.strip().upper()
if not t:
return None
data = await _fmp_get_json(
f"/key-metrics/{t}",
{"period": period, "limit": limit},
)
if isinstance(data, list):
return data
return None
async def fetch_ratios(
ticker: str,
period: str = "annual",
limit: int = _DEFAULT_LIMIT,
) -> Optional[List[Dict[str, Any]]]:
"""FMP financial ratios time series."""
t = ticker.strip().upper()
if not t:
return None
data = await _fmp_get_json(
f"/ratios/{t}",
{"period": period, "limit": limit},
)
if isinstance(data, list):
return data
return None
async def fetch_economic_calendar(
date_from: str,
date_to: str,
) -> Optional[List[Dict[str, Any]]]:
"""FMP economic calendar between two ISO dates (requires API key)."""
data = await _fmp_get_json(
"/economic_calendar",
{"from": date_from, "to": date_to},
)
if isinstance(data, list):
return data
return None
async def fetch_earning_call_transcript(
ticker: str,
year: int,
quarter: int,
) -> Optional[List[Dict[str, Any]]]:
"""FMP earning call transcript (one quarter)."""
t = ticker.strip().upper()
if not t or year < 1990 or quarter not in (1, 2, 3, 4):
return None
data = await _fmp_get_v4(
"/earning_call_transcript",
{"symbol": t, "year": year, "quarter": quarter},
)
if isinstance(data, list):
return data
return None
def _yf_info_snapshot(ticker: str) -> Dict[str, Any]:
"""Best-effort valuation fields from yfinance ``info``."""
try:
import yfinance as yf # noqa: WPS433
info = yf.Ticker(ticker.upper()).info or {}
except Exception:
return {}
return {
"source": "yfinance",
"peRatio": _safe_float(info.get("trailingPE")),
"pegRatio": _safe_float(info.get("pegRatio")),
"priceToBookRatio": _safe_float(info.get("priceToBook")),
"enterpriseValueOverEBITDA": _safe_float(info.get("enterpriseToEbitda")),
"dividendYield": _safe_float(info.get("dividendYield")),
"marketCap": _safe_float(info.get("marketCap")),
}
def _yq_snapshot(ticker: str) -> Dict[str, Any]:
"""Best-effort fields from yahooquery ``summary_detail``."""
try:
from yahooquery import Ticker as YQTicker # noqa: WPS433
yq = YQTicker(ticker.upper())
d = yq.summary_detail
if not isinstance(d, dict):
return {}
row = d.get(ticker.upper()) or next(iter(d.values()), None)
if not isinstance(row, dict):
return {}
except Exception:
return {}
return {
"source": "yahooquery",
"peRatio": _safe_float(row.get("trailingPE") or row.get("forwardPE")),
"pegRatio": _safe_float(row.get("pegRatio")),
"priceToBookRatio": _safe_float(row.get("priceToBook")),
"enterpriseValueOverEBITDA": _safe_float(
row.get("enterpriseToRevenue")
), # YQ naming differs; best effort
"dividendYield": _safe_float(row.get("dividendYield")),
"marketCap": _safe_float(row.get("marketCap")),
}
async def fallback_valuation_snapshot(ticker: str) -> Dict[str, Any]:
"""When FMP is unavailable: merge yfinance and yahooquery snapshots."""
import asyncio
t = ticker.strip().upper()
if not t:
return {"ticker": "", "source": "none", "data": {}}
yf_part, yq_part = await asyncio.gather(
asyncio.to_thread(_yf_info_snapshot, t),
asyncio.to_thread(_yq_snapshot, t),
)
merged: Dict[str, Any] = {"ticker": t, "source": "fallback", "yfinance": yf_part, "yahooquery": yq_part}
return merged