f24e5070ee
Root cause of "price plan needed in bulk but fine on manual reload": on free tiers FMP returns only market cap (others 402) and the chain merged that as a partial success — so when the Finnhub/Alpha Vantage fallbacks were rate-limited during a bulk run, the chain silently returned market-cap-only and the collector's backoff never engaged. Manual single fetches worked because the fallbacks weren't throttled at that moment. Fixes: - Chain distinguishes RateLimitError from other failures: if a fallback is rate-limited and fields are still missing, raise RateLimitError (unless allow_partial=True) so the collector backs off and retries. - Bulk job paces requests (fundamental_request_spacing_seconds, default 3s) to stay under Finnhub's ~60/min, and on retry-exhaustion stores partial data and continues instead of aborting the whole run. - Manual fetch passes allow_partial=True so a lone 429 doesn't fail the refresh. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
156 lines
5.4 KiB
Python
156 lines
5.4 KiB
Python
"""Unit tests for chained fundamentals provider fallback behavior."""
|
|
|
|
from __future__ import annotations
|
|
|
|
from datetime import datetime, timezone
|
|
|
|
import pytest
|
|
|
|
from app.exceptions import ProviderError, RateLimitError
|
|
from app.providers.fundamentals_chain import ChainedFundamentalProvider
|
|
from app.providers.protocol import FundamentalData
|
|
|
|
|
|
class _FailProvider:
|
|
def __init__(self, message: str) -> None:
|
|
self._message = message
|
|
|
|
async def fetch_fundamentals(self, ticker: str) -> FundamentalData:
|
|
raise ProviderError(f"{self._message} ({ticker})")
|
|
|
|
|
|
class _RateLimitedProvider:
|
|
async def fetch_fundamentals(self, ticker: str) -> FundamentalData:
|
|
raise RateLimitError(f"rate limit hit for {ticker}")
|
|
|
|
|
|
class _DataProvider:
|
|
def __init__(self, data: FundamentalData) -> None:
|
|
self._data = data
|
|
|
|
async def fetch_fundamentals(self, ticker: str) -> FundamentalData:
|
|
return FundamentalData(
|
|
ticker=ticker,
|
|
pe_ratio=self._data.pe_ratio,
|
|
revenue_growth=self._data.revenue_growth,
|
|
earnings_surprise=self._data.earnings_surprise,
|
|
market_cap=self._data.market_cap,
|
|
fetched_at=self._data.fetched_at,
|
|
unavailable_fields=self._data.unavailable_fields,
|
|
)
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_chained_provider_uses_fallback_provider_on_primary_failure():
|
|
fallback_data = FundamentalData(
|
|
ticker="AAPL",
|
|
pe_ratio=25.0,
|
|
revenue_growth=None,
|
|
earnings_surprise=None,
|
|
market_cap=1_000_000.0,
|
|
fetched_at=datetime.now(timezone.utc),
|
|
unavailable_fields={},
|
|
)
|
|
|
|
provider = ChainedFundamentalProvider([
|
|
("primary", _FailProvider("primary down")),
|
|
("fallback", _DataProvider(fallback_data)),
|
|
])
|
|
|
|
result = await provider.fetch_fundamentals("AAPL")
|
|
|
|
assert result.pe_ratio == 25.0
|
|
assert result.market_cap == 1_000_000.0
|
|
assert result.unavailable_fields.get("source_pe_ratio") == "fallback"
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_chained_provider_merges_fields_across_providers():
|
|
"""Primary supplies only market cap; fallback fills P/E and earnings."""
|
|
primary_data = FundamentalData(
|
|
ticker="AAPL", pe_ratio=None, revenue_growth=None, earnings_surprise=None,
|
|
market_cap=2_000_000.0, fetched_at=datetime.now(timezone.utc), unavailable_fields={},
|
|
)
|
|
fallback_data = FundamentalData(
|
|
ticker="AAPL", pe_ratio=18.0, revenue_growth=12.0, earnings_surprise=4.0,
|
|
market_cap=999.0, fetched_at=datetime.now(timezone.utc), unavailable_fields={},
|
|
)
|
|
|
|
provider = ChainedFundamentalProvider([
|
|
("fmp", _DataProvider(primary_data)),
|
|
("finnhub", _DataProvider(fallback_data)),
|
|
])
|
|
|
|
result = await provider.fetch_fundamentals("AAPL")
|
|
|
|
# market cap from primary (first to supply it), the rest from fallback
|
|
assert result.market_cap == 2_000_000.0
|
|
assert result.pe_ratio == 18.0
|
|
assert result.revenue_growth == 12.0
|
|
assert result.earnings_surprise == 4.0
|
|
assert result.unavailable_fields.get("source_market_cap") == "fmp"
|
|
assert result.unavailable_fields.get("source_pe_ratio") == "finnhub"
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_chained_provider_raises_when_all_providers_fail():
|
|
provider = ChainedFundamentalProvider([
|
|
("p1", _FailProvider("p1 failed")),
|
|
("p2", _FailProvider("p2 failed")),
|
|
])
|
|
|
|
with pytest.raises(ProviderError) as exc:
|
|
await provider.fetch_fundamentals("MSFT")
|
|
|
|
assert "All fundamentals providers failed" in str(exc.value)
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_rate_limited_fallback_raises_when_incomplete():
|
|
"""FMP gives market cap; the fallback is rate-limited → chain signals it so
|
|
the collector can back off instead of storing a degraded record."""
|
|
primary_data = FundamentalData(
|
|
ticker="AAPL", pe_ratio=None, revenue_growth=None, earnings_surprise=None,
|
|
market_cap=2_000_000.0, fetched_at=datetime.now(timezone.utc), unavailable_fields={},
|
|
)
|
|
provider = ChainedFundamentalProvider([
|
|
("fmp", _DataProvider(primary_data)),
|
|
("finnhub", _RateLimitedProvider()),
|
|
])
|
|
|
|
with pytest.raises(RateLimitError):
|
|
await provider.fetch_fundamentals("AAPL")
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_rate_limited_fallback_allows_partial():
|
|
"""With allow_partial=True the chain returns the market cap it did get."""
|
|
primary_data = FundamentalData(
|
|
ticker="AAPL", pe_ratio=None, revenue_growth=None, earnings_surprise=None,
|
|
market_cap=2_000_000.0, fetched_at=datetime.now(timezone.utc), unavailable_fields={},
|
|
)
|
|
provider = ChainedFundamentalProvider([
|
|
("fmp", _DataProvider(primary_data)),
|
|
("finnhub", _RateLimitedProvider()),
|
|
])
|
|
|
|
result = await provider.fetch_fundamentals("AAPL", allow_partial=True)
|
|
assert result.market_cap == 2_000_000.0
|
|
assert result.pe_ratio is None
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_rate_limited_but_complete_does_not_raise():
|
|
"""If every field is filled, a rate limit on a later (unused) provider is moot."""
|
|
full = FundamentalData(
|
|
ticker="AAPL", pe_ratio=20.0, revenue_growth=10.0, earnings_surprise=2.0,
|
|
market_cap=5.0, fetched_at=datetime.now(timezone.utc), unavailable_fields={},
|
|
)
|
|
provider = ChainedFundamentalProvider([
|
|
("fmp", _DataProvider(full)),
|
|
("finnhub", _RateLimitedProvider()),
|
|
])
|
|
|
|
result = await provider.fetch_fundamentals("AAPL")
|
|
assert result.pe_ratio == 20.0
|