The router silently extended every request to all available vendors regardless of config, so an explicit single-vendor choice still fell back to others and returned data from an unexpected source (#988, #289), and serious primary-vendor errors were swallowed without a trace (#989). The configured vendor list is now the exact chain (list several for ordered fallback; "default" uses all), unknown vendors raise, and swallowed vendor errors are logged. Adds an autouse config isolation fixture so vendor config can't leak between tests.
215 lines
8.0 KiB
Python
215 lines
8.0 KiB
Python
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_income_statement as get_alpha_vantage_income_statement,
|
|
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,
|
|
)
|
|
from .alpha_vantage_common import AlphaVantageRateLimitError
|
|
from .symbol_utils import NoMarketDataError
|
|
|
|
# Configuration and routing logic
|
|
from .config import get_config
|
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
# Tools organized by category
|
|
TOOLS_CATEGORIES = {
|
|
"core_stock_apis": {
|
|
"description": "OHLCV stock price data",
|
|
"tools": [
|
|
"get_stock_data"
|
|
]
|
|
},
|
|
"technical_indicators": {
|
|
"description": "Technical analysis indicators",
|
|
"tools": [
|
|
"get_indicators"
|
|
]
|
|
},
|
|
"fundamental_data": {
|
|
"description": "Company fundamentals",
|
|
"tools": [
|
|
"get_fundamentals",
|
|
"get_balance_sheet",
|
|
"get_cashflow",
|
|
"get_income_statement"
|
|
]
|
|
},
|
|
"news_data": {
|
|
"description": "News and insider data",
|
|
"tools": [
|
|
"get_news",
|
|
"get_global_news",
|
|
"get_insider_transactions",
|
|
]
|
|
}
|
|
}
|
|
|
|
VENDOR_LIST = [
|
|
"yfinance",
|
|
"alpha_vantage",
|
|
]
|
|
|
|
# Mapping of methods to their vendor-specific implementations
|
|
VENDOR_METHODS = {
|
|
# core_stock_apis
|
|
"get_stock_data": {
|
|
"alpha_vantage": get_alpha_vantage_stock,
|
|
"yfinance": get_YFin_data_online,
|
|
},
|
|
# technical_indicators
|
|
"get_indicators": {
|
|
"alpha_vantage": get_alpha_vantage_indicator,
|
|
"yfinance": get_stock_stats_indicators_window,
|
|
},
|
|
# fundamental_data
|
|
"get_fundamentals": {
|
|
"alpha_vantage": get_alpha_vantage_fundamentals,
|
|
"yfinance": get_yfinance_fundamentals,
|
|
},
|
|
"get_balance_sheet": {
|
|
"alpha_vantage": get_alpha_vantage_balance_sheet,
|
|
"yfinance": get_yfinance_balance_sheet,
|
|
},
|
|
"get_cashflow": {
|
|
"alpha_vantage": get_alpha_vantage_cashflow,
|
|
"yfinance": get_yfinance_cashflow,
|
|
},
|
|
"get_income_statement": {
|
|
"alpha_vantage": get_alpha_vantage_income_statement,
|
|
"yfinance": get_yfinance_income_statement,
|
|
},
|
|
# news_data
|
|
"get_news": {
|
|
"alpha_vantage": get_alpha_vantage_news,
|
|
"yfinance": get_news_yfinance,
|
|
},
|
|
"get_global_news": {
|
|
"yfinance": get_global_news_yfinance,
|
|
"alpha_vantage": get_alpha_vantage_global_news,
|
|
},
|
|
"get_insider_transactions": {
|
|
"alpha_vantage": get_alpha_vantage_insider_transactions,
|
|
"yfinance": get_yfinance_insider_transactions,
|
|
},
|
|
}
|
|
|
|
def get_category_for_method(method: str) -> str:
|
|
"""Get the category that contains the specified method."""
|
|
for category, info in TOOLS_CATEGORIES.items():
|
|
if method in info["tools"]:
|
|
return category
|
|
raise ValueError(f"Method '{method}' not found in any category")
|
|
|
|
def get_vendor(category: str, method: str = None) -> str:
|
|
"""Get the configured vendor for a data category or specific tool method.
|
|
Tool-level configuration takes precedence over category-level.
|
|
"""
|
|
config = get_config()
|
|
|
|
# Check tool-level configuration first (if method provided)
|
|
if method:
|
|
tool_vendors = config.get("tool_vendors", {})
|
|
if method in tool_vendors:
|
|
return tool_vendors[method]
|
|
|
|
# Fall back to category-level configuration
|
|
return config.get("data_vendors", {}).get(category, "default")
|
|
|
|
def route_to_vendor(method: str, *args, **kwargs):
|
|
"""Route method calls to appropriate vendor implementation with fallback support."""
|
|
category = get_category_for_method(method)
|
|
vendor_config = get_vendor(category, method)
|
|
primary_vendors = [v.strip() for v in vendor_config.split(',')]
|
|
|
|
if method not in VENDOR_METHODS:
|
|
raise ValueError(f"Method '{method}' not supported")
|
|
|
|
all_available_vendors = list(VENDOR_METHODS[method].keys())
|
|
|
|
# The configured vendor list IS the chain: we do NOT silently fall back to
|
|
# vendors the user did not choose (#988/#289) — that returned data from an
|
|
# unexpected source and caused cross-vendor inconsistencies. For multi-vendor
|
|
# fallback, list them in order, e.g. data_vendors="yfinance,alpha_vantage".
|
|
# The "default" sentinel (no explicit config) uses all available vendors.
|
|
explicit = [v for v in primary_vendors if v and v != "default"]
|
|
if explicit:
|
|
vendor_chain = [v for v in explicit if v in VENDOR_METHODS[method]]
|
|
if not vendor_chain:
|
|
raise ValueError(
|
|
f"Configured vendor(s) {explicit} not available for '{method}'. "
|
|
f"Available: {all_available_vendors}."
|
|
)
|
|
else:
|
|
vendor_chain = all_available_vendors
|
|
|
|
last_no_data: NoMarketDataError | None = None
|
|
first_error: Exception | None = None
|
|
for vendor in vendor_chain:
|
|
vendor_impl = VENDOR_METHODS[method][vendor]
|
|
impl_func = vendor_impl[0] if isinstance(vendor_impl, list) else vendor_impl
|
|
|
|
try:
|
|
return impl_func(*args, **kwargs)
|
|
except AlphaVantageRateLimitError:
|
|
logger.warning("Vendor %r rate-limited for %s; trying next vendor.", vendor, method)
|
|
continue
|
|
except NoMarketDataError as e:
|
|
last_no_data = e # No data here; another configured vendor may have it
|
|
continue
|
|
except Exception as e:
|
|
# Don't let one vendor's failure crash the call when another can
|
|
# serve it, but never swallow silently: a broken primary must be
|
|
# visible in the logs (#989), not hidden behind a fallback's verdict.
|
|
logger.warning("Vendor %r failed for %s: %s", vendor, method, e)
|
|
if first_error is None:
|
|
first_error = e
|
|
continue
|
|
|
|
# If any vendor reported "no data", the symbol is genuinely unavailable.
|
|
# Return one explicit, instructive sentinel rather than a vendor-specific
|
|
# empty string, so the agent reports "unavailable" instead of inventing a
|
|
# value. This takes precedence over incidental fallback errors.
|
|
if last_no_data is not None:
|
|
if first_error is not None:
|
|
# A vendor also hit a real error; surface it in logs so the no-data
|
|
# verdict can't hide a broken primary (network/auth/etc.).
|
|
logger.warning(
|
|
"Returning NO_DATA for %s, but a vendor errored earlier: %s",
|
|
method, first_error,
|
|
)
|
|
sym = last_no_data.symbol
|
|
canonical = last_no_data.canonical
|
|
resolved = "" if canonical == sym else f" (resolved to '{canonical}')"
|
|
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"fabricate values — report that data is unavailable for this symbol."
|
|
)
|
|
|
|
# No vendor returned data and none reported clean "no data" — surface the
|
|
# first real error (e.g. the primary vendor's network failure).
|
|
if first_error is not None:
|
|
raise first_error
|
|
|
|
raise RuntimeError(f"No available vendor for '{method}'") |