mirror of
https://github.com/TauricResearch/TradingAgents.git
synced 2026-09-19 11:15:24 +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:
@@ -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()
|
||||
|
||||
|
||||
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."
|
||||
)
|
||||
|
||||
@@ -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}'")
|
||||
raise RuntimeError(f"No available vendor for '{method}'")
|
||||
|
||||
@@ -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