TradingAgents/tests/unit/test_vendor_failfast.py

201 lines
9.2 KiB
Python

"""Tests for fail-fast vendor routing (ADR 011).
Methods NOT in FALLBACK_ALLOWED must fail immediately when the primary vendor
fails, rather than silently falling back to a vendor with a different data contract.
"""
import pytest
from unittest.mock import patch, MagicMock
from tradingagents.dataflows.interface import route_to_vendor, FALLBACK_ALLOWED
from tradingagents.dataflows.alpha_vantage_common import AlphaVantageError
from tradingagents.dataflows.finnhub_common import FinnhubError
from tradingagents.dataflows.config import get_config
def _config_with_vendor(category: str, vendor: str):
"""Return a patched config dict that sets a specific vendor for a category."""
original = get_config()
return {
**original,
"data_vendors": {**original.get("data_vendors", {}), category: vendor},
}
class TestFailFastMethods:
"""Methods NOT in FALLBACK_ALLOWED must not fall back to other vendors."""
def test_news_fails_fast_no_fallback(self):
"""get_news configured for alpha_vantage should NOT fall back to yfinance."""
config = _config_with_vendor("news_data", "alpha_vantage")
with patch("tradingagents.dataflows.interface.get_config", return_value=config):
with patch(
"tradingagents.dataflows.alpha_vantage_common.requests.get",
side_effect=ConnectionError("AV down"),
):
with pytest.raises(RuntimeError, match="All vendors failed for 'get_news'"):
route_to_vendor("get_news", "AAPL", "2024-01-01", "2024-01-05")
def test_indicators_fail_fast_no_fallback(self):
"""get_indicators configured for alpha_vantage should NOT fall back to yfinance."""
from tradingagents.dataflows.interface import VENDOR_METHODS
config = _config_with_vendor("technical_indicators", "alpha_vantage")
original = VENDOR_METHODS["get_indicators"]["alpha_vantage"]
VENDOR_METHODS["get_indicators"]["alpha_vantage"] = MagicMock(
side_effect=AlphaVantageError("AV down")
)
try:
with patch("tradingagents.dataflows.interface.get_config", return_value=config):
with pytest.raises(RuntimeError, match="All vendors failed for 'get_indicators'"):
route_to_vendor("get_indicators", "AAPL", "SMA", "2024-01-01", 50)
finally:
VENDOR_METHODS["get_indicators"]["alpha_vantage"] = original
def test_fundamentals_fail_fast_no_fallback(self):
"""get_fundamentals configured for alpha_vantage should NOT fall back to yfinance."""
config = _config_with_vendor("fundamental_data", "alpha_vantage")
with patch("tradingagents.dataflows.interface.get_config", return_value=config):
with patch(
"tradingagents.dataflows.alpha_vantage_common.requests.get",
side_effect=ConnectionError("AV down"),
):
with pytest.raises(RuntimeError, match="All vendors failed for 'get_fundamentals'"):
route_to_vendor("get_fundamentals", "AAPL")
def test_insider_transactions_fail_fast_no_fallback(self):
"""get_insider_transactions configured for finnhub should NOT fall back."""
from tradingagents.dataflows.interface import VENDOR_METHODS
config = _config_with_vendor("news_data", "finnhub")
original = VENDOR_METHODS["get_insider_transactions"]["finnhub"]
VENDOR_METHODS["get_insider_transactions"]["finnhub"] = MagicMock(
side_effect=FinnhubError("Finnhub down")
)
try:
with patch("tradingagents.dataflows.interface.get_config", return_value=config):
with pytest.raises(RuntimeError, match="All vendors failed for 'get_insider_transactions'"):
route_to_vendor("get_insider_transactions", "AAPL")
finally:
VENDOR_METHODS["get_insider_transactions"]["finnhub"] = original
def test_topic_news_fail_fast_no_fallback(self):
"""get_topic_news should NOT fall back across vendors."""
from tradingagents.dataflows.interface import VENDOR_METHODS
config = _config_with_vendor("scanner_data", "finnhub")
original = VENDOR_METHODS["get_topic_news"]["finnhub"]
VENDOR_METHODS["get_topic_news"]["finnhub"] = MagicMock(
side_effect=FinnhubError("Finnhub down")
)
try:
with patch("tradingagents.dataflows.interface.get_config", return_value=config):
with pytest.raises(RuntimeError, match="All vendors failed for 'get_topic_news'"):
route_to_vendor("get_topic_news", "technology")
finally:
VENDOR_METHODS["get_topic_news"]["finnhub"] = original
def test_calendar_fail_fast_single_vendor(self):
"""get_earnings_calendar (Finnhub-only) fails fast."""
from tradingagents.dataflows.interface import VENDOR_METHODS
config = _config_with_vendor("calendar_data", "finnhub")
original = VENDOR_METHODS["get_earnings_calendar"]["finnhub"]
VENDOR_METHODS["get_earnings_calendar"]["finnhub"] = MagicMock(
side_effect=FinnhubError("Finnhub down")
)
try:
with patch("tradingagents.dataflows.interface.get_config", return_value=config):
with pytest.raises(RuntimeError, match="All vendors failed for 'get_earnings_calendar'"):
route_to_vendor("get_earnings_calendar", "2024-01-01", "2024-01-05")
finally:
VENDOR_METHODS["get_earnings_calendar"]["finnhub"] = original
class TestErrorChaining:
"""Verify error messages and exception chaining."""
def test_error_chain_preserved(self):
"""RuntimeError.__cause__ should be the original vendor exception."""
config = _config_with_vendor("news_data", "alpha_vantage")
with patch("tradingagents.dataflows.interface.get_config", return_value=config):
with patch(
"tradingagents.dataflows.alpha_vantage_common.requests.get",
side_effect=ConnectionError("network down"),
):
with pytest.raises(RuntimeError) as exc_info:
route_to_vendor("get_news", "AAPL", "2024-01-01", "2024-01-05")
assert exc_info.value.__cause__ is not None
assert isinstance(exc_info.value.__cause__, ConnectionError)
def test_error_message_includes_method_and_vendors(self):
"""Error message should include method name and vendors tried."""
config = _config_with_vendor("fundamental_data", "alpha_vantage")
with patch("tradingagents.dataflows.interface.get_config", return_value=config):
with patch(
"tradingagents.dataflows.alpha_vantage_common.requests.get",
side_effect=ConnectionError("down"),
):
with pytest.raises(RuntimeError) as exc_info:
route_to_vendor("get_fundamentals", "AAPL")
msg = str(exc_info.value)
assert "get_fundamentals" in msg
assert "alpha_vantage" in msg
def test_auth_error_propagates(self):
"""401/403 errors (wrapped as vendor errors) should not silently retry."""
config = _config_with_vendor("news_data", "alpha_vantage")
with patch("tradingagents.dataflows.interface.get_config", return_value=config):
with patch(
"tradingagents.dataflows.alpha_vantage_common.requests.get",
side_effect=AlphaVantageError("Invalid API key (401)"),
):
with pytest.raises(RuntimeError, match="All vendors failed"):
route_to_vendor("get_news", "AAPL", "2024-01-01", "2024-01-05")
class TestFallbackAllowedStillWorks:
"""Methods IN FALLBACK_ALLOWED should still get cross-vendor fallback."""
def test_stock_data_falls_back(self):
"""get_stock_data (in FALLBACK_ALLOWED) should fall back from AV to yfinance."""
import pandas as pd
config = _config_with_vendor("core_stock_apis", "alpha_vantage")
df = pd.DataFrame(
{"Open": [183.0], "High": [186.0], "Low": [182.5],
"Close": [185.0], "Volume": [45_000_000]},
index=pd.date_range("2024-01-04", periods=1, freq="B", tz="America/New_York"),
)
mock_ticker = MagicMock()
mock_ticker.history.return_value = df
with patch("tradingagents.dataflows.interface.get_config", return_value=config):
with patch(
"tradingagents.dataflows.alpha_vantage_common.requests.get",
side_effect=ConnectionError("AV down"),
):
with patch("tradingagents.dataflows.y_finance.yf.Ticker", return_value=mock_ticker):
result = route_to_vendor("get_stock_data", "AAPL", "2024-01-04", "2024-01-05")
assert isinstance(result, str)
assert "AAPL" in result
def test_fallback_allowed_set_contents(self):
"""Verify the FALLBACK_ALLOWED set contains exactly the expected methods."""
expected = {
"get_stock_data",
"get_market_indices",
"get_sector_performance",
"get_market_movers",
"get_industry_performance",
}
assert FALLBACK_ALLOWED == expected