mirror of
https://github.com/TauricResearch/TradingAgents.git
synced 2026-06-16 21:06:15 +03:00
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:
@@ -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
|
||||
|
||||
105
tests/test_vendor_errors.py
Normal file
105
tests/test_vendor_errors.py
Normal 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()
|
||||
@@ -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,14 +49,14 @@ 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:
|
||||
|
||||
55
tradingagents/dataflows/errors.py
Normal file
55
tradingagents/dataflows/errors.py
Normal 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".
|
||||
"""
|
||||
@@ -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.
|
||||
"""
|
||||
|
||||
|
||||
|
||||
@@ -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."
|
||||
)
|
||||
|
||||
|
||||
@@ -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.
|
||||
|
||||
Reference in New Issue
Block a user