"""Chained fundamentals provider with fallback adapters. Order: 1) FMP (if configured) 2) Finnhub (if configured) 3) Alpha Vantage (if configured) """ from __future__ import annotations import logging import os from datetime import datetime, timezone from pathlib import Path import httpx from app.config import settings from app.exceptions import ProviderError, RateLimitError from app.providers.fmp import FMPFundamentalProvider from app.providers.protocol import FundamentalData, FundamentalProvider logger = logging.getLogger(__name__) _CA_BUNDLE = os.environ.get("SSL_CERT_FILE", "") if not _CA_BUNDLE or not Path(_CA_BUNDLE).exists(): _CA_BUNDLE_PATH: str | bool = True else: _CA_BUNDLE_PATH = _CA_BUNDLE def _safe_float(value: object) -> float | None: if value is None: return None try: return float(value) except (TypeError, ValueError): return None class FinnhubFundamentalProvider: """Fundamentals provider backed by Finnhub free endpoints.""" def __init__(self, api_key: str) -> None: if not api_key: raise ProviderError("Finnhub API key is required") self._api_key = api_key self._base_url = "https://finnhub.io/api/v1" async def fetch_fundamentals(self, ticker: str) -> FundamentalData: unavailable: dict[str, str] = {} async with httpx.AsyncClient(timeout=30.0, verify=_CA_BUNDLE_PATH) as client: profile_resp = await client.get( f"{self._base_url}/stock/profile2", params={"symbol": ticker, "token": self._api_key}, ) metric_resp = await client.get( f"{self._base_url}/stock/metric", params={"symbol": ticker, "metric": "all", "token": self._api_key}, ) earnings_resp = await client.get( f"{self._base_url}/stock/earnings", params={"symbol": ticker, "limit": 1, "token": self._api_key}, ) for resp, endpoint in ( (profile_resp, "profile2"), (metric_resp, "stock/metric"), (earnings_resp, "stock/earnings"), ): if resp.status_code == 429: raise RateLimitError(f"Finnhub rate limit hit for {ticker} ({endpoint})") if resp.status_code in (401, 403): raise ProviderError(f"Finnhub access denied for {ticker} ({endpoint}): HTTP {resp.status_code}") if resp.status_code != 200: raise ProviderError(f"Finnhub error for {ticker} ({endpoint}): HTTP {resp.status_code}") profile_payload = profile_resp.json() if profile_resp.text else {} metric_payload = metric_resp.json() if metric_resp.text else {} earnings_payload = earnings_resp.json() if earnings_resp.text else [] metrics = metric_payload.get("metric", {}) if isinstance(metric_payload, dict) else {} market_cap = _safe_float((profile_payload or {}).get("marketCapitalization")) pe_ratio = _safe_float(metrics.get("peTTM") or metrics.get("peNormalizedAnnual")) revenue_growth = _safe_float(metrics.get("revenueGrowthTTMYoy") or metrics.get("revenueGrowth5Y")) earnings_surprise = None if isinstance(earnings_payload, list) and earnings_payload: first = earnings_payload[0] if isinstance(earnings_payload[0], dict) else {} earnings_surprise = _safe_float(first.get("surprisePercent")) if pe_ratio is None: unavailable["pe_ratio"] = "not available from provider payload" if revenue_growth is None: unavailable["revenue_growth"] = "not available from provider payload" if earnings_surprise is None: unavailable["earnings_surprise"] = "not available from provider payload" if market_cap is None: unavailable["market_cap"] = "not available from provider payload" 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, ) class AlphaVantageFundamentalProvider: """Fundamentals provider backed by Alpha Vantage free endpoints.""" def __init__(self, api_key: str) -> None: if not api_key: raise ProviderError("Alpha Vantage API key is required") self._api_key = api_key self._base_url = "https://www.alphavantage.co/query" async def fetch_fundamentals(self, ticker: str) -> FundamentalData: unavailable: dict[str, str] = {} async with httpx.AsyncClient(timeout=30.0, verify=_CA_BUNDLE_PATH) as client: overview_resp = await client.get( self._base_url, params={"function": "OVERVIEW", "symbol": ticker, "apikey": self._api_key}, ) earnings_resp = await client.get( self._base_url, params={"function": "EARNINGS", "symbol": ticker, "apikey": self._api_key}, ) income_resp = await client.get( self._base_url, params={"function": "INCOME_STATEMENT", "symbol": ticker, "apikey": self._api_key}, ) for resp, endpoint in ( (overview_resp, "OVERVIEW"), (earnings_resp, "EARNINGS"), (income_resp, "INCOME_STATEMENT"), ): if resp.status_code == 429: raise RateLimitError(f"Alpha Vantage rate limit hit for {ticker} ({endpoint})") if resp.status_code != 200: raise ProviderError(f"Alpha Vantage error for {ticker} ({endpoint}): HTTP {resp.status_code}") overview = overview_resp.json() if overview_resp.text else {} earnings = earnings_resp.json() if earnings_resp.text else {} income = income_resp.json() if income_resp.text else {} if isinstance(overview, dict) and overview.get("Information"): raise ProviderError(f"Alpha Vantage unavailable for {ticker}: {overview.get('Information')}") if isinstance(overview, dict) and overview.get("Note"): raise RateLimitError(f"Alpha Vantage rate limit for {ticker}: {overview.get('Note')}") pe_ratio = _safe_float((overview or {}).get("PERatio")) market_cap = _safe_float((overview or {}).get("MarketCapitalization")) earnings_surprise = None quarterly = earnings.get("quarterlyEarnings", []) if isinstance(earnings, dict) else [] if isinstance(quarterly, list) and quarterly: first = quarterly[0] if isinstance(quarterly[0], dict) else {} earnings_surprise = _safe_float(first.get("surprisePercentage")) revenue_growth = None annual = income.get("annualReports", []) if isinstance(income, dict) else [] if isinstance(annual, list) and len(annual) >= 2: curr = _safe_float((annual[0] or {}).get("totalRevenue")) prev = _safe_float((annual[1] or {}).get("totalRevenue")) if curr is not None and prev not in (None, 0): revenue_growth = ((curr - prev) / abs(prev)) * 100.0 if pe_ratio is None: unavailable["pe_ratio"] = "not available from provider payload" if revenue_growth is None: unavailable["revenue_growth"] = "not available from provider payload" if earnings_surprise is None: unavailable["earnings_surprise"] = "not available from provider payload" if market_cap is None: unavailable["market_cap"] = "not available from provider payload" 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, ) class ChainedFundamentalProvider: """Try multiple fundamental providers in order until one succeeds.""" def __init__(self, providers: list[tuple[str, FundamentalProvider]]) -> None: if not providers: raise ProviderError("No fundamental providers configured") self._providers = providers async def fetch_fundamentals(self, ticker: str) -> FundamentalData: errors: list[str] = [] for provider_name, provider in self._providers: try: data = await provider.fetch_fundamentals(ticker) has_any_metric = any( value is not None for value in (data.pe_ratio, data.revenue_growth, data.earnings_surprise, data.market_cap) ) if not has_any_metric: errors.append(f"{provider_name}: no usable metrics returned") continue unavailable = dict(data.unavailable_fields) unavailable["provider"] = provider_name return FundamentalData( ticker=data.ticker, pe_ratio=data.pe_ratio, revenue_growth=data.revenue_growth, earnings_surprise=data.earnings_surprise, market_cap=data.market_cap, fetched_at=data.fetched_at, unavailable_fields=unavailable, ) except Exception as exc: errors.append(f"{provider_name}: {type(exc).__name__}: {exc}") attempts = "; ".join(errors[:6]) if errors else "no provider attempts" raise ProviderError(f"All fundamentals providers failed for {ticker}. Attempts: {attempts}") def build_fundamental_provider_chain() -> FundamentalProvider: providers: list[tuple[str, FundamentalProvider]] = [] if settings.fmp_api_key: providers.append(("fmp", FMPFundamentalProvider(settings.fmp_api_key))) if settings.finnhub_api_key: providers.append(("finnhub", FinnhubFundamentalProvider(settings.finnhub_api_key))) if settings.alpha_vantage_api_key: providers.append(("alpha_vantage", AlphaVantageFundamentalProvider(settings.alpha_vantage_api_key))) if not providers: raise ProviderError( "No fundamentals provider configured. Set one of FMP_API_KEY, FINNHUB_API_KEY, ALPHA_VANTAGE_API_KEY" ) logger.info("Fundamentals provider chain configured: %s", [name for name, _ in providers]) return ChainedFundamentalProvider(providers)