refactor(data): unify vendor errors under a VendorError hierarchy

Every condition where a vendor cannot return usable data now derives from a
single VendorError base (errors.py): NoMarketDataError, VendorRateLimitError,
and VendorNotConfiguredError (still a ValueError for back-compat). Vendor-named
errors subclass the generic bases, and the router catches the base types, so a
new vendor needs no new except clause. Not-configured now has explicit
try-next-vendor handling instead of falling through the generic catch-all. The
number of error types tracks the number of distinct router reactions, not the
number of causes.
This commit is contained in:
Yijia-Xiao
2026-06-14 07:10:15 +00:00
parent db059034a2
commit 7df18fc912
7 changed files with 226 additions and 64 deletions

View File

@@ -81,3 +81,8 @@ ignore = ["E501"]
[tool.ruff.lint.per-file-ignores] [tool.ruff.lint.per-file-ignores]
"**/__init__.py" = ["F401"] # intentional re-exports "**/__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

105
tests/test_vendor_errors.py Normal file
View File

@@ -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()

View File

@@ -1,10 +1,13 @@
import os
import requests
import pandas as pd
import json import json
import os
from datetime import datetime from datetime import datetime
from io import StringIO from io import StringIO
import pandas as pd
import requests
from .errors import VendorNotConfiguredError, VendorRateLimitError
API_BASE_URL = "https://www.alphavantage.co/query" API_BASE_URL = "https://www.alphavantage.co/query"
# Network timeout (seconds) so a stalled Alpha Vantage request can't hang the # 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 REQUEST_TIMEOUT = 30
class AlphaVantageNotConfiguredError(ValueError): class AlphaVantageNotConfiguredError(VendorNotConfiguredError):
"""Raised when Alpha Vantage is selected but no API key is configured. """Raised when Alpha Vantage is selected but no API key is configured.
Subclasses ValueError for backward compatibility with callers that A VendorNotConfiguredError (and thus still a ValueError), so the routing
already catch ValueError, while letting the routing layer distinguish a layer's "vendor unavailable" handling and existing ValueError callers both
"vendor unavailable" condition from a genuine data error. keep working.
""" """
pass pass
@@ -46,14 +49,14 @@ def format_datetime_for_api(date_input) -> str:
dt = datetime.strptime(date_input, "%Y-%m-%d %H:%M") dt = datetime.strptime(date_input, "%Y-%m-%d %H:%M")
return dt.strftime("%Y%m%dT%H%M") return dt.strftime("%Y%m%dT%H%M")
except ValueError: 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): elif isinstance(date_input, datetime):
return date_input.strftime("%Y%m%dT%H%M") return date_input.strftime("%Y%m%dT%H%M")
else: else:
raise ValueError(f"Date must be string or datetime object, got {type(date_input)}") raise ValueError(f"Date must be string or datetime object, got {type(date_input)}")
class AlphaVantageRateLimitError(Exception): class AlphaVantageRateLimitError(VendorRateLimitError):
"""Exception raised when Alpha Vantage API rate limit is exceeded.""" """Raised when the Alpha Vantage API rate limit is exceeded."""
pass pass
def _make_api_request(function_name: str, params: dict) -> dict | str: def _make_api_request(function_name: str, params: dict) -> dict | str:

View File

@@ -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".
"""

View File

@@ -14,6 +14,8 @@ from datetime import datetime, timedelta
import requests import requests
from .errors import VendorNotConfiguredError
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
FRED_API_BASE = "https://api.stlouisfed.org/fred" 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. """Raised when FRED is selected but no API key is configured.
Subclasses ValueError so callers already catching ValueError keep working, A VendorNotConfiguredError (and thus still a ValueError), so the routing
while the routing layer can distinguish a "vendor unavailable" condition layer's "vendor unavailable" handling and existing ValueError callers both
from a genuine data error (same contract as AlphaVantageNotConfiguredError). keep working.
""" """

View File

@@ -1,35 +1,34 @@
import logging 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 ( 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_balance_sheet as get_alpha_vantage_balance_sheet,
get_cashflow as get_alpha_vantage_cashflow, 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_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_insider_transactions as get_alpha_vantage_insider_transactions,
get_news as get_alpha_vantage_news, 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 .fred import get_macro_data as get_fred_macro_data
from .polymarket import get_prediction_markets as get_polymarket_prediction_markets from .polymarket import get_prediction_markets as get_polymarket_prediction_markets
from .symbol_utils import NoMarketDataError from .y_finance import (
get_balance_sheet as get_yfinance_balance_sheet,
# Configuration and routing logic get_cashflow as get_yfinance_cashflow,
from .config import get_config 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__) logger = logging.getLogger(__name__)
@@ -194,9 +193,14 @@ def route_to_vendor(method: str, *args, **kwargs):
try: try:
return impl_func(*args, **kwargs) return impl_func(*args, **kwargs)
except AlphaVantageRateLimitError: except VendorRateLimitError:
logger.warning("Vendor %r rate-limited for %s; trying next vendor.", vendor, method) logger.warning("Vendor %r rate-limited for %s; trying next vendor.", vendor, method)
continue 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: except NoMarketDataError as e:
last_no_data = e # No data here; another configured vendor may have it last_no_data = e # No data here; another configured vendor may have it
continue continue
@@ -224,10 +228,14 @@ def route_to_vendor(method: str, *args, **kwargs):
sym = last_no_data.symbol sym = last_no_data.symbol
canonical = last_no_data.canonical canonical = last_no_data.canonical
resolved = "" if canonical == sym else f" (resolved to '{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 ( return (
f"NO_DATA_AVAILABLE: No market data found for '{sym}'{resolved} from " f"NO_DATA_AVAILABLE: No usable market data for '{sym}'{resolved} from "
f"any configured vendor. The symbol may be invalid, delisted, or not " f"any configured vendor{reason}. The symbol may be invalid, delisted, "
f"covered by Yahoo Finance / Alpha Vantage. Do not estimate or " f"not covered, or the vendor returned stale data. Do not estimate or "
f"fabricate values — report that data is unavailable for this symbol." f"fabricate values — report that data is unavailable for this symbol."
) )

View File

@@ -23,29 +23,13 @@ from __future__ import annotations
import logging import logging
import re 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__) 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 # 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 # six-letter symbol whose halves are BOTH in this set is treated as a spot
# forex pair and given Yahoo's ``=X`` suffix. # forex pair and given Yahoo's ``=X`` suffix.