"""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 date, datetime, timedelta, 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) today = date.today() 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}, ) calendar_resp = await client.get( f"{self._base_url}/calendar/earnings", params={ "symbol": api_symbol, "from": today.isoformat(), "to": (today + timedelta(days=120)).isoformat(), "token": self._api_key, }, ) for resp, endpoint in ( (profile_resp, "profile2"), (metric_resp, "stock/metric"), (earnings_resp, "stock/earnings"), (calendar_resp, "calendar/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")) next_earnings_date = self._next_earnings(calendar_resp) 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), next_earnings_date=next_earnings_date, unavailable_fields=unavailable, ) @staticmethod def _next_earnings(resp: httpx.Response) -> date | None: """Earliest upcoming earnings date from Finnhub's calendar payload.""" try: payload = resp.json() if resp.text else {} except ValueError: return None entries = payload.get("earningsCalendar", []) if isinstance(payload, dict) else [] dates: list[date] = [] today = date.today() for entry in entries if isinstance(entries, list) else []: raw = entry.get("date") if isinstance(entry, dict) else None if not raw: continue try: parsed = date.fromisoformat(raw) except ValueError: continue if parsed >= today: dates.append(parsed) return min(dates) if dates else None 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, allow_partial: bool = False) -> FundamentalData: """Merge fundamentals across providers. ``allow_partial`` controls behaviour when a fallback provider is *rate limited* and we end up with missing fields. By default we raise RateLimitError so the caller (the bulk collector) can back off and retry the ticker once the window frees — otherwise a transient 429 on Finnhub would be silently stored as market-cap-only. Pass ``allow_partial=True`` (manual single fetches, or the collector's final give-up attempt) to accept whatever was gathered instead of raising. """ merged: dict[str, float | None] = {f: None for f in _FUNDAMENTAL_FIELDS} field_source: dict[str, str] = {} errors: list[str] = [] rate_limited = False next_earnings_date = None for provider_name, provider in self._providers: if all(merged[f] is not None for f in _FUNDAMENTAL_FIELDS) and next_earnings_date: break try: data = await provider.fetch_fundamentals(ticker) except RateLimitError as exc: rate_limited = True errors.append(f"{provider_name}: RateLimitError: {exc}") continue except Exception as exc: errors.append(f"{provider_name}: {type(exc).__name__}: {exc}") continue if next_earnings_date is None and data.next_earnings_date is not None: next_earnings_date = data.next_earnings_date 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 missing = [f for f in _FUNDAMENTAL_FIELDS if merged[f] is None] # A rate limit left data incomplete: signal it (unless partial is OK) so # the collector backs off rather than persisting a degraded record. if rate_limited and missing and not allow_partial: attempts = "; ".join(errors[:6]) raise RateLimitError( f"Fundamentals incomplete for {ticker} due to provider rate limits " f"(missing {', '.join(missing)}). Attempts: {attempts}" ) 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), next_earnings_date=next_earnings_date, 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)