feat: add fundamental data providers (Alpha Vantage, FMP, Yahoo Finance) with rotation
This commit is contained in:
parent
2398e8faf6
commit
aa47e896dd
7 changed files with 704 additions and 0 deletions
6
shared/fundamentals/__init__.py
Normal file
6
shared/fundamentals/__init__.py
Normal file
|
|
@ -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"]
|
||||
79
shared/fundamentals/alpha_vantage.py
Normal file
79
shared/fundamentals/alpha_vantage.py
Normal file
|
|
@ -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()
|
||||
47
shared/fundamentals/base.py
Normal file
47
shared/fundamentals/base.py
Normal file
|
|
@ -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."""
|
||||
...
|
||||
64
shared/fundamentals/fmp.py
Normal file
64
shared/fundamentals/fmp.py
Normal file
|
|
@ -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()
|
||||
50
shared/fundamentals/rotating.py
Normal file
50
shared/fundamentals/rotating.py
Normal file
|
|
@ -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
|
||||
61
shared/fundamentals/yahoo.py
Normal file
61
shared/fundamentals/yahoo.py
Normal file
|
|
@ -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
|
||||
397
tests/test_fundamentals.py
Normal file
397
tests/test_fundamentals.py
Normal file
|
|
@ -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
|
||||
Loading…
Add table
Add a link
Reference in a new issue