diff --git a/pyproject.toml b/pyproject.toml index 35c0c1d1e..9791c5be1 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -81,3 +81,8 @@ ignore = ["E501"] [tool.ruff.lint.per-file-ignores] "**/__init__.py" = ["F401"] # intentional re-exports + +[tool.ruff.lint.isort] +# Keep multiple aliased names from one module in a single combined import block +# (e.g. the vendor re-exports in interface.py) instead of one statement per name. +combine-as-imports = true diff --git a/tests/test_vendor_errors.py b/tests/test_vendor_errors.py new file mode 100644 index 000000000..9df641e0d --- /dev/null +++ b/tests/test_vendor_errors.py @@ -0,0 +1,105 @@ +"""The vendor data-error hierarchy: every "vendor couldn't return usable data" +condition derives from VendorError, so the router catches base types and any +vendor slots in without new handling. +""" +import copy +import unittest +from unittest import mock + +import pytest + +import tradingagents.dataflows.config as config_module +import tradingagents.default_config as default_config +from tradingagents.dataflows import interface +from tradingagents.dataflows.alpha_vantage_common import ( + AlphaVantageNotConfiguredError, + AlphaVantageRateLimitError, +) +from tradingagents.dataflows.config import set_config +from tradingagents.dataflows.errors import ( + NoMarketDataError, + VendorError, + VendorNotConfiguredError, + VendorRateLimitError, +) +from tradingagents.dataflows.fred import FredNotConfiguredError + + +@pytest.mark.unit +class HierarchyTests(unittest.TestCase): + def test_all_conditions_derive_from_vendor_error(self): + for cls in (NoMarketDataError, VendorRateLimitError, VendorNotConfiguredError): + self.assertTrue(issubclass(cls, VendorError)) + + def test_not_configured_is_still_a_value_error(self): + # Back-compat: existing `except ValueError` callers keep working. + self.assertTrue(issubclass(VendorNotConfiguredError, ValueError)) + + def test_vendor_named_errors_subclass_the_generic_bases(self): + self.assertTrue(issubclass(AlphaVantageRateLimitError, VendorRateLimitError)) + self.assertTrue(issubclass(AlphaVantageNotConfiguredError, VendorNotConfiguredError)) + self.assertTrue(issubclass(FredNotConfiguredError, VendorNotConfiguredError)) + # ... and therefore still ValueErrors + self.assertTrue(issubclass(FredNotConfiguredError, ValueError)) + + def test_symbol_utils_reexports_no_market_data_error(self): + from tradingagents.dataflows.symbol_utils import ( + NoMarketDataError as ReExported, + ) + self.assertIs(ReExported, NoMarketDataError) + + +@pytest.mark.unit +class RouterHandlesBaseTypesTests(unittest.TestCase): + def setUp(self): + config_module._config = copy.deepcopy(default_config.DEFAULT_CONFIG) + + def tearDown(self): + config_module._config = copy.deepcopy(default_config.DEFAULT_CONFIG) + + def test_rate_limit_subclass_caught_by_base(self): + # A vendor-named rate-limit error skips to the next vendor in the chain. + set_config({"data_vendors": {"core_stock_apis": "alpha_vantage,yfinance"}}) + + def _throttled(*a, **k): + raise AlphaVantageRateLimitError("slow down") + + with mock.patch.dict( + interface.VENDOR_METHODS, + {"get_stock_data": {"alpha_vantage": _throttled, "yfinance": lambda *a, **k: "YF"}}, + clear=False, + ): + out = interface.route_to_vendor("get_stock_data", "AAPL", "2026-01-01", "2026-01-10") + self.assertEqual(out, "YF") + + def test_not_configured_falls_through_to_next_vendor(self): + set_config({"data_vendors": {"core_stock_apis": "alpha_vantage,yfinance"}}) + + def _unconfigured(*a, **k): + raise AlphaVantageNotConfiguredError("no key") + + with mock.patch.dict( + interface.VENDOR_METHODS, + {"get_stock_data": {"alpha_vantage": _unconfigured, "yfinance": lambda *a, **k: "YF"}}, + clear=False, + ): + out = interface.route_to_vendor("get_stock_data", "AAPL", "2026-01-01", "2026-01-10") + self.assertEqual(out, "YF") + + def test_sole_unconfigured_vendor_surfaces_the_error(self): + # With no fallback, the not-configured condition must surface (not vanish). + set_config({"data_vendors": {"core_stock_apis": "alpha_vantage"}}) + + def _unconfigured(*a, **k): + raise AlphaVantageNotConfiguredError("no key") + + with mock.patch.dict( + interface.VENDOR_METHODS, + {"get_stock_data": {"alpha_vantage": _unconfigured}}, + clear=False, + ), self.assertRaises(AlphaVantageNotConfiguredError): + interface.route_to_vendor("get_stock_data", "AAPL", "2026-01-01", "2026-01-10") + + +if __name__ == "__main__": + unittest.main() diff --git a/tradingagents/dataflows/alpha_vantage_common.py b/tradingagents/dataflows/alpha_vantage_common.py index f8898f3ef..237ff54ce 100644 --- a/tradingagents/dataflows/alpha_vantage_common.py +++ b/tradingagents/dataflows/alpha_vantage_common.py @@ -1,10 +1,13 @@ -import os -import requests -import pandas as pd import json +import os from datetime import datetime from io import StringIO +import pandas as pd +import requests + +from .errors import VendorNotConfiguredError, VendorRateLimitError + API_BASE_URL = "https://www.alphavantage.co/query" # Network timeout (seconds) so a stalled Alpha Vantage request can't hang the @@ -12,12 +15,12 @@ API_BASE_URL = "https://www.alphavantage.co/query" REQUEST_TIMEOUT = 30 -class AlphaVantageNotConfiguredError(ValueError): +class AlphaVantageNotConfiguredError(VendorNotConfiguredError): """Raised when Alpha Vantage is selected but no API key is configured. - Subclasses ValueError for backward compatibility with callers that - already catch ValueError, while letting the routing layer distinguish a - "vendor unavailable" condition from a genuine data error. + A VendorNotConfiguredError (and thus still a ValueError), so the routing + layer's "vendor unavailable" handling and existing ValueError callers both + keep working. """ pass @@ -46,19 +49,19 @@ def format_datetime_for_api(date_input) -> str: dt = datetime.strptime(date_input, "%Y-%m-%d %H:%M") return dt.strftime("%Y%m%dT%H%M") except ValueError: - raise ValueError(f"Unsupported date format: {date_input}") + raise ValueError(f"Unsupported date format: {date_input}") from None elif isinstance(date_input, datetime): return date_input.strftime("%Y%m%dT%H%M") else: raise ValueError(f"Date must be string or datetime object, got {type(date_input)}") -class AlphaVantageRateLimitError(Exception): - """Exception raised when Alpha Vantage API rate limit is exceeded.""" +class AlphaVantageRateLimitError(VendorRateLimitError): + """Raised when the Alpha Vantage API rate limit is exceeded.""" pass def _make_api_request(function_name: str, params: dict) -> dict | str: """Helper function to make API requests and handle responses. - + Raises: AlphaVantageRateLimitError: When API rate limit is exceeded """ @@ -69,17 +72,17 @@ def _make_api_request(function_name: str, params: dict) -> dict | str: "apikey": get_api_key(), "source": "trading_agents", }) - + # Handle entitlement parameter if present in params or global variable current_entitlement = globals().get('_current_entitlement') entitlement = api_params.get("entitlement") or current_entitlement - + if entitlement: api_params["entitlement"] = entitlement elif "entitlement" in api_params: # Remove entitlement if it's None or empty api_params.pop("entitlement", None) - + response = requests.get(API_BASE_URL, params=api_params, timeout=REQUEST_TIMEOUT) response.raise_for_status() diff --git a/tradingagents/dataflows/errors.py b/tradingagents/dataflows/errors.py new file mode 100644 index 000000000..2ce6a0c46 --- /dev/null +++ b/tradingagents/dataflows/errors.py @@ -0,0 +1,55 @@ +"""Vendor data-error taxonomy. + +A single hierarchy so the routing layer reacts by *behavior*, not by vendor: +every condition where a vendor cannot return usable data derives from +``VendorError``, and the router catches the base types. A new vendor raises +these (or a thin vendor-named subclass) and needs no new ``except`` clause. + + VendorError + ├── NoMarketDataError no usable rows (empty result OR stale data) + ├── VendorRateLimitError transient throttle -> skip to next vendor + └── VendorNotConfiguredError missing API key/config -> vendor unavailable + +The number of types is the number of distinct router reactions, not the number +of human-describable causes: empty and stale data get identical handling, so +they share ``NoMarketDataError`` and differ only in the free-text ``detail``. +""" + +from __future__ import annotations + + +class VendorError(Exception): + """Base for any condition where a vendor could not return usable data.""" + + +class NoMarketDataError(VendorError): + """A vendor returned no usable rows for a symbol (empty result or stale data). + + Carries both the symbol the user requested and the canonical symbol the + vendor was actually queried with, plus a free-text ``detail``, so callers + can build a clear message instead of emitting a vendor-specific empty + string into the data channel. + """ + + def __init__(self, symbol: str, canonical: str | None = None, detail: str = ""): + self.symbol = symbol + self.canonical = canonical or symbol + self.detail = detail + msg = f"No market data for {symbol!r}" + if canonical and canonical != symbol: + msg += f" (queried as {canonical!r})" + if detail: + msg += f": {detail}" + super().__init__(msg) + + +class VendorRateLimitError(VendorError): + """A vendor throttled the request; the router skips to the next vendor.""" + + +class VendorNotConfiguredError(VendorError, ValueError): + """A vendor was selected but its API key/configuration is missing. + + Also a ``ValueError`` so existing callers that catch ``ValueError`` keep + working while the routing layer can treat it as "vendor unavailable". + """ diff --git a/tradingagents/dataflows/fred.py b/tradingagents/dataflows/fred.py index 1d7ad1315..a7bf4a350 100644 --- a/tradingagents/dataflows/fred.py +++ b/tradingagents/dataflows/fred.py @@ -14,6 +14,8 @@ from datetime import datetime, timedelta import requests +from .errors import VendorNotConfiguredError + logger = logging.getLogger(__name__) FRED_API_BASE = "https://api.stlouisfed.org/fred" @@ -70,12 +72,12 @@ MACRO_SERIES = { } -class FredNotConfiguredError(ValueError): +class FredNotConfiguredError(VendorNotConfiguredError): """Raised when FRED is selected but no API key is configured. - Subclasses ValueError so callers already catching ValueError keep working, - while the routing layer can distinguish a "vendor unavailable" condition - from a genuine data error (same contract as AlphaVantageNotConfiguredError). + A VendorNotConfiguredError (and thus still a ValueError), so the routing + layer's "vendor unavailable" handling and existing ValueError callers both + keep working. """ diff --git a/tradingagents/dataflows/interface.py b/tradingagents/dataflows/interface.py index 892abdbde..094d83bd1 100644 --- a/tradingagents/dataflows/interface.py +++ b/tradingagents/dataflows/interface.py @@ -1,35 +1,34 @@ import logging -from typing import Annotated -# Import from vendor-specific modules -from .y_finance import ( - get_YFin_data_online, - get_stock_stats_indicators_window, - get_fundamentals as get_yfinance_fundamentals, - get_balance_sheet as get_yfinance_balance_sheet, - get_cashflow as get_yfinance_cashflow, - get_income_statement as get_yfinance_income_statement, - get_insider_transactions as get_yfinance_insider_transactions, -) -from .yfinance_news import get_news_yfinance, get_global_news_yfinance from .alpha_vantage import ( - get_stock as get_alpha_vantage_stock, - get_indicator as get_alpha_vantage_indicator, - get_fundamentals as get_alpha_vantage_fundamentals, get_balance_sheet as get_alpha_vantage_balance_sheet, get_cashflow as get_alpha_vantage_cashflow, + get_fundamentals as get_alpha_vantage_fundamentals, + get_global_news as get_alpha_vantage_global_news, get_income_statement as get_alpha_vantage_income_statement, + get_indicator as get_alpha_vantage_indicator, get_insider_transactions as get_alpha_vantage_insider_transactions, get_news as get_alpha_vantage_news, - get_global_news as get_alpha_vantage_global_news, + get_stock as get_alpha_vantage_stock, +) +from .config import get_config +from .errors import ( + NoMarketDataError, + VendorNotConfiguredError, + VendorRateLimitError, ) -from .alpha_vantage_common import AlphaVantageRateLimitError from .fred import get_macro_data as get_fred_macro_data from .polymarket import get_prediction_markets as get_polymarket_prediction_markets -from .symbol_utils import NoMarketDataError - -# Configuration and routing logic -from .config import get_config +from .y_finance import ( + get_balance_sheet as get_yfinance_balance_sheet, + get_cashflow as get_yfinance_cashflow, + get_fundamentals as get_yfinance_fundamentals, + get_income_statement as get_yfinance_income_statement, + get_insider_transactions as get_yfinance_insider_transactions, + get_stock_stats_indicators_window, + get_YFin_data_online, +) +from .yfinance_news import get_global_news_yfinance, get_news_yfinance logger = logging.getLogger(__name__) @@ -194,9 +193,14 @@ def route_to_vendor(method: str, *args, **kwargs): try: return impl_func(*args, **kwargs) - except AlphaVantageRateLimitError: + except VendorRateLimitError: logger.warning("Vendor %r rate-limited for %s; trying next vendor.", vendor, method) continue + except VendorNotConfiguredError as e: + logger.warning("Vendor %r not configured for %s; trying next vendor.", vendor, method) + if first_error is None: + first_error = e # Surface it if no other vendor can serve the call. + continue except NoMarketDataError as e: last_no_data = e # No data here; another configured vendor may have it continue @@ -224,10 +228,14 @@ def route_to_vendor(method: str, *args, **kwargs): sym = last_no_data.symbol canonical = last_no_data.canonical resolved = "" if canonical == sym else f" (resolved to '{canonical}')" + # Surface the typed error's detail (e.g. "latest row is 2025-06-11 ... + # stale") so the agent sees the specific reason — invalid symbol, no + # coverage, or stale data — not just a generic "unavailable". + reason = f" ({last_no_data.detail})" if last_no_data.detail else "" return ( - f"NO_DATA_AVAILABLE: No market data found for '{sym}'{resolved} from " - f"any configured vendor. The symbol may be invalid, delisted, or not " - f"covered by Yahoo Finance / Alpha Vantage. Do not estimate or " + f"NO_DATA_AVAILABLE: No usable market data for '{sym}'{resolved} from " + f"any configured vendor{reason}. The symbol may be invalid, delisted, " + f"not covered, or the vendor returned stale data. Do not estimate or " f"fabricate values — report that data is unavailable for this symbol." ) @@ -236,4 +244,4 @@ def route_to_vendor(method: str, *args, **kwargs): if first_error is not None: raise first_error - raise RuntimeError(f"No available vendor for '{method}'") \ No newline at end of file + raise RuntimeError(f"No available vendor for '{method}'") diff --git a/tradingagents/dataflows/symbol_utils.py b/tradingagents/dataflows/symbol_utils.py index 9a8e61881..af46a1f02 100644 --- a/tradingagents/dataflows/symbol_utils.py +++ b/tradingagents/dataflows/symbol_utils.py @@ -23,29 +23,13 @@ from __future__ import annotations import logging import re +# NoMarketDataError lives in the vendor-error taxonomy (errors.py); re-exported +# here for the many call sites that import it alongside normalize_symbol. +from .errors import NoMarketDataError as NoMarketDataError + logger = logging.getLogger(__name__) -class NoMarketDataError(Exception): - """Raised when a vendor returns no rows/records for a symbol. - - Carries both the symbol the user requested and the canonical symbol the - vendor was actually queried with, so callers can build a clear message - instead of emitting a vendor-specific empty string into the data channel. - """ - - def __init__(self, symbol: str, canonical: str | None = None, detail: str = ""): - self.symbol = symbol - self.canonical = canonical or symbol - self.detail = detail - msg = f"No market data for {symbol!r}" - if canonical and canonical != symbol: - msg += f" (queried as {canonical!r})" - if detail: - msg += f": {detail}" - super().__init__(msg) - - # ISO-4217 codes common enough to appear in retail forex pairs. A bare # six-letter symbol whose halves are BOTH in this set is treated as a spot # forex pair and given Yahoo's ``=X`` suffix.