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]
|
[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
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 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:
|
||||||
|
|||||||
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
|
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.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -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."
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|||||||
Reference in New Issue
Block a user