From aa47e896dda7e16c30661242b8fce3b1dbd22573 Mon Sep 17 00:00:00 2001 From: Viktor Barzin Date: Mon, 23 Feb 2026 21:41:16 +0000 Subject: [PATCH] feat: add fundamental data providers (Alpha Vantage, FMP, Yahoo Finance) with rotation --- shared/fundamentals/__init__.py | 6 + shared/fundamentals/alpha_vantage.py | 79 ++++++ shared/fundamentals/base.py | 47 ++++ shared/fundamentals/fmp.py | 64 +++++ shared/fundamentals/rotating.py | 50 ++++ shared/fundamentals/yahoo.py | 61 ++++ tests/test_fundamentals.py | 397 +++++++++++++++++++++++++++ 7 files changed, 704 insertions(+) create mode 100644 shared/fundamentals/__init__.py create mode 100644 shared/fundamentals/alpha_vantage.py create mode 100644 shared/fundamentals/base.py create mode 100644 shared/fundamentals/fmp.py create mode 100644 shared/fundamentals/rotating.py create mode 100644 shared/fundamentals/yahoo.py create mode 100644 tests/test_fundamentals.py diff --git a/shared/fundamentals/__init__.py b/shared/fundamentals/__init__.py new file mode 100644 index 0000000..330f56c --- /dev/null +++ b/shared/fundamentals/__init__.py @@ -0,0 +1,6 @@ +"""Fundamental data providers for stock financial metrics.""" + +from shared.fundamentals.base import FundamentalsProvider +from shared.fundamentals.rotating import RotatingProvider + +__all__ = ["FundamentalsProvider", "RotatingProvider"] diff --git a/shared/fundamentals/alpha_vantage.py b/shared/fundamentals/alpha_vantage.py new file mode 100644 index 0000000..8a3f5c0 --- /dev/null +++ b/shared/fundamentals/alpha_vantage.py @@ -0,0 +1,79 @@ +"""Alpha Vantage fundamental data provider.""" + +from __future__ import annotations + +import logging + +import httpx + +from shared.fundamentals.base import FundamentalsProvider, FundamentalsSnapshot + +logger = logging.getLogger(__name__) + +_BASE_URL = "https://www.alphavantage.co/query" + + +def _safe_float(val: str | None) -> float | None: + """Convert a string value to float, returning None for missing/invalid values.""" + if val is None or val in ("None", "-", ""): + return None + try: + return float(val) + except (ValueError, TypeError): + return None + + +def _safe_float_div100(val: str | None) -> float | None: + """Convert a string value to float and divide by 100 (e.g. D/E ratio).""" + f = _safe_float(val) + return f / 100.0 if f is not None else None + + +class AlphaVantageProvider(FundamentalsProvider): + """Fetches fundamental data from the Alpha Vantage OVERVIEW endpoint.""" + + def __init__(self, api_key: str, timeout: float = 10.0) -> None: + self._api_key = api_key + self._client = httpx.AsyncClient(timeout=timeout) + + async def fetch(self, ticker: str) -> FundamentalsSnapshot | None: + """Fetch company overview from Alpha Vantage.""" + try: + resp = await self._client.get( + _BASE_URL, + params={ + "function": "OVERVIEW", + "symbol": ticker, + "apikey": self._api_key, + }, + ) + resp.raise_for_status() + data = resp.json() + + # Rate-limit / error detection + if "Note" in data or "Information" in data: + msg = data.get("Note") or data.get("Information", "") + logger.warning("Alpha Vantage rate limit for %s: %s", ticker, msg) + return None + + # An empty or error response won't have "Symbol" + if "Symbol" not in data: + logger.warning("Alpha Vantage returned no data for %s", ticker) + return None + + return FundamentalsSnapshot( + ticker=ticker, + eps=_safe_float(data.get("EPS")), + pe_ratio=_safe_float(data.get("PERatio")), + peg_ratio=_safe_float(data.get("PEGRatio")), + revenue_growth=_safe_float(data.get("QuarterlyRevenueGrowthYOY")), + profit_margin=_safe_float(data.get("ProfitMargin")), + debt_to_equity=_safe_float_div100(data.get("DebtToEquity")), + market_cap=_safe_float(data.get("MarketCapitalization")), + ) + except Exception: + logger.exception("Alpha Vantage fetch failed for %s", ticker) + return None + + async def close(self) -> None: + await self._client.aclose() diff --git a/shared/fundamentals/base.py b/shared/fundamentals/base.py new file mode 100644 index 0000000..9f93fdc --- /dev/null +++ b/shared/fundamentals/base.py @@ -0,0 +1,47 @@ +"""Abstract base class for fundamental data providers.""" + +from __future__ import annotations + +from abc import ABC, abstractmethod +from datetime import UTC, datetime + +from pydantic import BaseModel, Field + + +# --------------------------------------------------------------------------- +# FundamentalsSnapshot — canonical schema +# +# NOTE: This is the authoritative definition until it is moved into +# ``shared.schemas.trading``. Once the parallel schema task lands, delete +# this class and update all imports to point at the schemas module. +# --------------------------------------------------------------------------- + + +class FundamentalsSnapshot(BaseModel): + """Point-in-time snapshot of a stock's fundamental financial metrics.""" + + ticker: str + eps: float | None = None + pe_ratio: float | None = None + peg_ratio: float | None = None + revenue_growth: float | None = None + profit_margin: float | None = None + debt_to_equity: float | None = None + market_cap: float | None = None + fetched_at: datetime = Field(default_factory=lambda: datetime.now(UTC)) + + model_config = {"from_attributes": True} + + +# --------------------------------------------------------------------------- +# Abstract provider +# --------------------------------------------------------------------------- + + +class FundamentalsProvider(ABC): + """Abstract base class for fundamental data providers.""" + + @abstractmethod + async def fetch(self, ticker: str) -> FundamentalsSnapshot | None: + """Fetch fundamental data for *ticker*. Returns None on failure/rate limit.""" + ... diff --git a/shared/fundamentals/fmp.py b/shared/fundamentals/fmp.py new file mode 100644 index 0000000..3eb4df2 --- /dev/null +++ b/shared/fundamentals/fmp.py @@ -0,0 +1,64 @@ +"""Financial Modeling Prep (FMP) fundamental data provider.""" + +from __future__ import annotations + +import logging + +import httpx + +from shared.fundamentals.base import FundamentalsProvider, FundamentalsSnapshot + +logger = logging.getLogger(__name__) + +_BASE_URL = "https://financialmodelingprep.com/api/v3/key-metrics-ttm" + + +def _safe_float(val: object) -> float | None: + """Convert a value to float, returning None for missing/invalid values.""" + if val is None: + return None + try: + return float(val) + except (ValueError, TypeError): + return None + + +class FMPProvider(FundamentalsProvider): + """Fetches fundamental data from Financial Modeling Prep key-metrics-ttm endpoint.""" + + def __init__(self, api_key: str, timeout: float = 10.0) -> None: + self._api_key = api_key + self._client = httpx.AsyncClient(timeout=timeout) + + async def fetch(self, ticker: str) -> FundamentalsSnapshot | None: + """Fetch TTM key metrics from FMP.""" + try: + resp = await self._client.get( + f"{_BASE_URL}/{ticker}", + params={"apikey": self._api_key}, + ) + resp.raise_for_status() + data = resp.json() + + if not isinstance(data, list) or len(data) == 0: + logger.warning("FMP returned empty response for %s", ticker) + return None + + item = data[0] + + return FundamentalsSnapshot( + ticker=ticker, + eps=_safe_float(item.get("netIncomePerShareTTM")), + pe_ratio=_safe_float(item.get("peRatioTTM")), + peg_ratio=_safe_float(item.get("pegRatioTTM")), + revenue_growth=_safe_float(item.get("revenueGrowth")), + profit_margin=_safe_float(item.get("netProfitMarginTTM")), + debt_to_equity=_safe_float(item.get("debtToEquityTTM")), + market_cap=_safe_float(item.get("marketCapTTM")), + ) + except Exception: + logger.exception("FMP fetch failed for %s", ticker) + return None + + async def close(self) -> None: + await self._client.aclose() diff --git a/shared/fundamentals/rotating.py b/shared/fundamentals/rotating.py new file mode 100644 index 0000000..da798e9 --- /dev/null +++ b/shared/fundamentals/rotating.py @@ -0,0 +1,50 @@ +"""Rotating (fallback) wrapper over multiple fundamental data providers.""" + +from __future__ import annotations + +import logging + +from shared.fundamentals.base import FundamentalsProvider, FundamentalsSnapshot + +logger = logging.getLogger(__name__) + + +class RotatingProvider(FundamentalsProvider): + """Tries each provider in order and returns the first successful result. + + If a provider raises an exception it is caught, logged, and the next + provider is attempted. Returns ``None`` only when every provider either + returned ``None`` or raised. + """ + + def __init__(self, providers: list[FundamentalsProvider]) -> None: + if not providers: + raise ValueError("RotatingProvider requires at least one provider") + self._providers = providers + + async def fetch(self, ticker: str) -> FundamentalsSnapshot | None: + """Try each provider in sequence; return the first non-None result.""" + for provider in self._providers: + provider_name = type(provider).__name__ + try: + result = await provider.fetch(ticker) + if result is not None: + logger.info( + "Provider %s succeeded for %s", + provider_name, + ticker, + ) + return result + logger.debug( + "Provider %s returned None for %s, trying next", + provider_name, + ticker, + ) + except Exception: + logger.exception( + "Provider %s raised for %s, trying next", + provider_name, + ticker, + ) + logger.warning("All providers exhausted for %s", ticker) + return None diff --git a/shared/fundamentals/yahoo.py b/shared/fundamentals/yahoo.py new file mode 100644 index 0000000..ad9a1c3 --- /dev/null +++ b/shared/fundamentals/yahoo.py @@ -0,0 +1,61 @@ +"""Yahoo Finance fundamental data provider (via yfinance).""" + +from __future__ import annotations + +import asyncio +import logging + +from shared.fundamentals.base import FundamentalsProvider, FundamentalsSnapshot + +logger = logging.getLogger(__name__) + + +def _safe_float(val: object) -> float | None: + """Convert a value to float, returning None for missing/invalid values.""" + if val is None: + return None + try: + return float(val) + except (ValueError, TypeError): + return None + + +def _safe_float_div100(val: object) -> float | None: + """Convert a value to float and divide by 100 (e.g. D/E ratio).""" + f = _safe_float(val) + return f / 100.0 if f is not None else None + + +def _fetch_info(ticker: str) -> dict: + """Synchronous helper — runs in a thread pool to avoid blocking the loop.""" + import yfinance # noqa: local import to avoid hard dependency + + return yfinance.Ticker(ticker).info + + +class YahooFinanceProvider(FundamentalsProvider): + """Fetches fundamental data from Yahoo Finance via the yfinance library.""" + + async def fetch(self, ticker: str) -> FundamentalsSnapshot | None: + """Fetch ticker info from Yahoo Finance in a thread pool.""" + try: + loop = asyncio.get_running_loop() + info = await loop.run_in_executor(None, _fetch_info, ticker) + + if not info: + logger.warning("Yahoo Finance returned empty info for %s", ticker) + return None + + return FundamentalsSnapshot( + ticker=ticker, + eps=_safe_float(info.get("trailingEps")), + pe_ratio=_safe_float(info.get("trailingPE")), + peg_ratio=_safe_float(info.get("pegRatio")), + revenue_growth=_safe_float(info.get("revenueGrowth")), + profit_margin=_safe_float(info.get("profitMargins")), + debt_to_equity=_safe_float_div100(info.get("debtToEquity")), + market_cap=_safe_float(info.get("marketCap")), + ) + except Exception: + logger.exception("Yahoo Finance fetch failed for %s", ticker) + return None diff --git a/tests/test_fundamentals.py b/tests/test_fundamentals.py new file mode 100644 index 0000000..2f684ad --- /dev/null +++ b/tests/test_fundamentals.py @@ -0,0 +1,397 @@ +"""Tests for fundamental data providers — parsing, error handling, and rotation.""" + +from __future__ import annotations + +from datetime import datetime, timezone +from unittest.mock import AsyncMock, MagicMock, patch + +import httpx +import pytest + +from shared.fundamentals.base import FundamentalsProvider, FundamentalsSnapshot +from shared.fundamentals.alpha_vantage import ( + AlphaVantageProvider, + _safe_float, + _safe_float_div100, +) +from shared.fundamentals.fmp import FMPProvider +from shared.fundamentals.yahoo import YahooFinanceProvider +from shared.fundamentals.rotating import RotatingProvider + + +# --------------------------------------------------------------------------- +# FundamentalsSnapshot schema tests +# --------------------------------------------------------------------------- + + +class TestFundamentalsSnapshot: + def test_create_with_all_fields(self) -> None: + snap = FundamentalsSnapshot( + ticker="AAPL", + eps=6.57, + pe_ratio=28.3, + peg_ratio=2.1, + revenue_growth=0.08, + profit_margin=0.26, + debt_to_equity=1.87, + market_cap=2_800_000_000_000.0, + ) + assert snap.ticker == "AAPL" + assert snap.eps == 6.57 + assert snap.pe_ratio == 28.3 + assert snap.peg_ratio == 2.1 + assert snap.revenue_growth == 0.08 + assert snap.profit_margin == 0.26 + assert snap.debt_to_equity == 1.87 + assert snap.market_cap == 2_800_000_000_000.0 + assert isinstance(snap.fetched_at, datetime) + + def test_create_with_optional_fields_none(self) -> None: + snap = FundamentalsSnapshot(ticker="XYZ") + assert snap.ticker == "XYZ" + assert snap.eps is None + assert snap.pe_ratio is None + assert snap.peg_ratio is None + assert snap.revenue_growth is None + assert snap.profit_margin is None + assert snap.debt_to_equity is None + assert snap.market_cap is None + + def test_serialization_round_trip(self) -> None: + snap = FundamentalsSnapshot(ticker="MSFT", eps=11.0, pe_ratio=35.0) + data = snap.model_dump() + rebuilt = FundamentalsSnapshot.model_validate(data) + assert rebuilt.ticker == "MSFT" + assert rebuilt.eps == 11.0 + assert rebuilt.pe_ratio == 35.0 + + +# --------------------------------------------------------------------------- +# Helper function tests +# --------------------------------------------------------------------------- + + +class TestHelpers: + def test_safe_float_valid(self) -> None: + assert _safe_float("3.14") == 3.14 + + def test_safe_float_none(self) -> None: + assert _safe_float(None) is None + + def test_safe_float_dash(self) -> None: + assert _safe_float("-") is None + + def test_safe_float_empty(self) -> None: + assert _safe_float("") is None + + def test_safe_float_none_string(self) -> None: + assert _safe_float("None") is None + + def test_safe_float_invalid(self) -> None: + assert _safe_float("abc") is None + + def test_safe_float_div100(self) -> None: + assert _safe_float_div100("150") == 1.5 + + def test_safe_float_div100_none(self) -> None: + assert _safe_float_div100(None) is None + + +# --------------------------------------------------------------------------- +# Alpha Vantage provider tests +# --------------------------------------------------------------------------- + + +def _mock_av_response(json_data: dict, status_code: int = 200) -> httpx.Response: + """Build a fake httpx.Response with the given JSON body.""" + return httpx.Response( + status_code=status_code, + json=json_data, + request=httpx.Request("GET", "https://www.alphavantage.co/query"), + ) + + +class TestAlphaVantageProvider: + @pytest.fixture + def provider(self) -> AlphaVantageProvider: + return AlphaVantageProvider(api_key="test-key") + + async def test_parse_valid_response(self, provider: AlphaVantageProvider) -> None: + mock_data = { + "Symbol": "AAPL", + "EPS": "6.57", + "PERatio": "28.3", + "PEGRatio": "2.1", + "QuarterlyRevenueGrowthYOY": "0.08", + "ProfitMargin": "0.26", + "DebtToEquity": "187", + "MarketCapitalization": "2800000000000", + } + provider._client = AsyncMock() + provider._client.get = AsyncMock(return_value=_mock_av_response(mock_data)) + + result = await provider.fetch("AAPL") + + assert result is not None + assert result.ticker == "AAPL" + assert result.eps == 6.57 + assert result.pe_ratio == 28.3 + assert result.peg_ratio == 2.1 + assert result.revenue_growth == 0.08 + assert result.profit_margin == 0.26 + assert result.debt_to_equity == pytest.approx(1.87) + assert result.market_cap == 2_800_000_000_000.0 + + async def test_rate_limit_note(self, provider: AlphaVantageProvider) -> None: + mock_data = { + "Note": "Thank you for using Alpha Vantage! Our API call frequency limit is 5 calls per minute." + } + provider._client = AsyncMock() + provider._client.get = AsyncMock(return_value=_mock_av_response(mock_data)) + + result = await provider.fetch("AAPL") + assert result is None + + async def test_rate_limit_information(self, provider: AlphaVantageProvider) -> None: + mock_data = { + "Information": "Please consider upgrading to a premium plan." + } + provider._client = AsyncMock() + provider._client.get = AsyncMock(return_value=_mock_av_response(mock_data)) + + result = await provider.fetch("AAPL") + assert result is None + + async def test_empty_response(self, provider: AlphaVantageProvider) -> None: + provider._client = AsyncMock() + provider._client.get = AsyncMock(return_value=_mock_av_response({})) + + result = await provider.fetch("INVALID") + assert result is None + + async def test_http_error(self, provider: AlphaVantageProvider) -> None: + provider._client = AsyncMock() + provider._client.get = AsyncMock( + return_value=_mock_av_response({}, status_code=500) + ) + + result = await provider.fetch("AAPL") + assert result is None + + async def test_network_exception(self, provider: AlphaVantageProvider) -> None: + provider._client = AsyncMock() + provider._client.get = AsyncMock(side_effect=httpx.ConnectError("timeout")) + + result = await provider.fetch("AAPL") + assert result is None + + async def test_partial_data(self, provider: AlphaVantageProvider) -> None: + """Provider should handle responses with only some fields populated.""" + mock_data = { + "Symbol": "GOOG", + "EPS": "5.80", + "PERatio": "None", + "PEGRatio": "-", + } + provider._client = AsyncMock() + provider._client.get = AsyncMock(return_value=_mock_av_response(mock_data)) + + result = await provider.fetch("GOOG") + assert result is not None + assert result.eps == 5.80 + assert result.pe_ratio is None + assert result.peg_ratio is None + + +# --------------------------------------------------------------------------- +# FMP provider tests +# --------------------------------------------------------------------------- + + +def _mock_fmp_response(json_data: list | dict, status_code: int = 200) -> httpx.Response: + return httpx.Response( + status_code=status_code, + json=json_data, + request=httpx.Request("GET", "https://financialmodelingprep.com/api/v3/key-metrics-ttm/AAPL"), + ) + + +class TestFMPProvider: + @pytest.fixture + def provider(self) -> FMPProvider: + return FMPProvider(api_key="test-key") + + async def test_parse_valid_response(self, provider: FMPProvider) -> None: + mock_data = [ + { + "netIncomePerShareTTM": 6.57, + "peRatioTTM": 28.3, + "pegRatioTTM": 2.1, + "revenueGrowth": 0.08, + "netProfitMarginTTM": 0.26, + "debtToEquityTTM": 1.87, + "marketCapTTM": 2800000000000, + } + ] + provider._client = AsyncMock() + provider._client.get = AsyncMock(return_value=_mock_fmp_response(mock_data)) + + result = await provider.fetch("AAPL") + + assert result is not None + assert result.ticker == "AAPL" + assert result.eps == 6.57 + assert result.pe_ratio == 28.3 + assert result.debt_to_equity == 1.87 + assert result.market_cap == 2_800_000_000_000.0 + + async def test_empty_array(self, provider: FMPProvider) -> None: + provider._client = AsyncMock() + provider._client.get = AsyncMock(return_value=_mock_fmp_response([])) + + result = await provider.fetch("INVALID") + assert result is None + + async def test_non_list_response(self, provider: FMPProvider) -> None: + provider._client = AsyncMock() + provider._client.get = AsyncMock( + return_value=_mock_fmp_response({"error": "not found"}) + ) + + result = await provider.fetch("INVALID") + assert result is None + + async def test_network_error(self, provider: FMPProvider) -> None: + provider._client = AsyncMock() + provider._client.get = AsyncMock(side_effect=httpx.ConnectError("refused")) + + result = await provider.fetch("AAPL") + assert result is None + + +# --------------------------------------------------------------------------- +# Yahoo Finance provider tests +# --------------------------------------------------------------------------- + + +class TestYahooFinanceProvider: + @pytest.fixture + def provider(self) -> YahooFinanceProvider: + return YahooFinanceProvider() + + async def test_parse_valid_info(self, provider: YahooFinanceProvider) -> None: + mock_info = { + "trailingEps": 6.57, + "trailingPE": 28.3, + "pegRatio": 2.1, + "revenueGrowth": 0.08, + "profitMargins": 0.26, + "debtToEquity": 187.0, + "marketCap": 2800000000000, + } + with patch( + "shared.fundamentals.yahoo._fetch_info", return_value=mock_info + ): + result = await provider.fetch("AAPL") + + assert result is not None + assert result.ticker == "AAPL" + assert result.eps == 6.57 + assert result.pe_ratio == 28.3 + assert result.peg_ratio == 2.1 + assert result.revenue_growth == 0.08 + assert result.profit_margin == 0.26 + assert result.debt_to_equity == pytest.approx(1.87) + assert result.market_cap == 2_800_000_000_000.0 + + async def test_empty_info(self, provider: YahooFinanceProvider) -> None: + with patch( + "shared.fundamentals.yahoo._fetch_info", return_value={} + ): + result = await provider.fetch("INVALID") + assert result is None + + async def test_exception(self, provider: YahooFinanceProvider) -> None: + with patch( + "shared.fundamentals.yahoo._fetch_info", + side_effect=Exception("yfinance error"), + ): + result = await provider.fetch("AAPL") + assert result is None + + +# --------------------------------------------------------------------------- +# RotatingProvider tests +# --------------------------------------------------------------------------- + + +class _StubProvider(FundamentalsProvider): + """Test stub that returns a fixed result or raises.""" + + def __init__( + self, + result: FundamentalsSnapshot | None = None, + error: Exception | None = None, + ) -> None: + self._result = result + self._error = error + + async def fetch(self, ticker: str) -> FundamentalsSnapshot | None: + if self._error: + raise self._error + return self._result + + +class TestRotatingProvider: + def test_requires_at_least_one_provider(self) -> None: + with pytest.raises(ValueError, match="at least one"): + RotatingProvider(providers=[]) + + async def test_returns_first_success(self) -> None: + snap = FundamentalsSnapshot(ticker="AAPL", eps=6.0) + p1 = _StubProvider(result=snap) + p2 = _StubProvider(result=FundamentalsSnapshot(ticker="AAPL", eps=99.0)) + + rp = RotatingProvider(providers=[p1, p2]) + result = await rp.fetch("AAPL") + + assert result is not None + assert result.eps == 6.0 # from p1, not p2 + + async def test_falls_back_on_none(self) -> None: + snap = FundamentalsSnapshot(ticker="AAPL", pe_ratio=30.0) + p1 = _StubProvider(result=None) + p2 = _StubProvider(result=snap) + + rp = RotatingProvider(providers=[p1, p2]) + result = await rp.fetch("AAPL") + + assert result is not None + assert result.pe_ratio == 30.0 # fell through to p2 + + async def test_returns_none_when_all_fail(self) -> None: + p1 = _StubProvider(result=None) + p2 = _StubProvider(result=None) + + rp = RotatingProvider(providers=[p1, p2]) + result = await rp.fetch("AAPL") + assert result is None + + async def test_handles_exceptions(self) -> None: + snap = FundamentalsSnapshot(ticker="AAPL", eps=5.0) + p1 = _StubProvider(error=RuntimeError("boom")) + p2 = _StubProvider(result=snap) + + rp = RotatingProvider(providers=[p1, p2]) + result = await rp.fetch("AAPL") + + assert result is not None + assert result.eps == 5.0 # fell through after p1 raised + + async def test_all_raise(self) -> None: + p1 = _StubProvider(error=RuntimeError("boom1")) + p2 = _StubProvider(error=ValueError("boom2")) + + rp = RotatingProvider(providers=[p1, p2]) + result = await rp.fetch("AAPL") + assert result is None