"""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 def _to_api_symbol(symbol: str) -> str: """Convert internal symbol format (BRK-B) to API format (BRK.B). Finnhub and Alpha Vantage use dot-separated share class notation. """ return symbol.replace("-", ".") 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] = {} api_symbol = _to_api_symbol(ticker) 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": api_symbol, "token": self._api_key}, ) metric_resp = await client.get( f"{self._base_url}/stock/metric", params={"symbol": api_symbol, "metric": "all", "token": self._api_key}, ) earnings_resp = await client.get( f"{self._base_url}/stock/earnings", params={"symbol": api_symbol, "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] = {} api_symbol = _to_api_symbol(ticker) 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": api_symbol, "apikey": self._api_key}, ) earnings_resp = await client.get( self._base_url, params={"function": "EARNINGS", "symbol": api_symbol, "apikey": self._api_key}, ) income_resp = await client.get( self._base_url, params={"function": "INCOME_STATEMENT", "symbol": api_symbol, "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, ) _FUNDAMENTAL_FIELDS = ("pe_ratio", "revenue_growth", "earnings_surprise", "market_cap") class ChainedFundamentalProvider: """Merge fundamentals across providers, filling gaps from later sources. A single provider rarely covers everything on free tiers — FMP's free plan, for example, returns only market cap (the ratios/growth/earnings endpoints 402). Rather than stop at the first provider with *any* field, we take each field from the first provider that supplies it, so FMP's market cap is combined with Finnhub's P/E and earnings surprise. """ 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: merged: dict[str, float | None] = {f: None for f in _FUNDAMENTAL_FIELDS} field_source: dict[str, str] = {} errors: list[str] = [] for provider_name, provider in self._providers: if all(merged[f] is not None for f in _FUNDAMENTAL_FIELDS): break try: data = await provider.fetch_fundamentals(ticker) except Exception as exc: errors.append(f"{provider_name}: {type(exc).__name__}: {exc}") continue for field in _FUNDAMENTAL_FIELDS: if merged[field] is None: value = getattr(data, field) if value is not None: merged[field] = value field_source[field] = provider_name if all(merged[f] is None for f in _FUNDAMENTAL_FIELDS): attempts = "; ".join(errors[:6]) if errors else "no usable metrics from any provider" raise ProviderError(f"All fundamentals providers failed for {ticker}. Attempts: {attempts}") unavailable: dict[str, str] = { field: "not available from any configured provider" for field in _FUNDAMENTAL_FIELDS if merged[field] is None } # Record which provider supplied each field for transparency. for field, src in field_source.items(): unavailable[f"source_{field}"] = src return FundamentalData( ticker=ticker, pe_ratio=merged["pe_ratio"], revenue_growth=merged["revenue_growth"], earnings_surprise=merged["earnings_surprise"], market_cap=merged["market_cap"], fetched_at=datetime.now(timezone.utc), unavailable_fields=unavailable, ) 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)