From d5ba41bac32319d69b4048192eceb91d02722e95 Mon Sep 17 00:00:00 2001 From: Yijia-Xiao Date: Fri, 18 Sep 2026 01:49:52 +0000 Subject: [PATCH] fix(dataflows): report a vendor failure as a vendor failure - yfinance returned its errors as text, which the router counted as an answer, so the chain stopped and the text reached the analyst - an empty result is checked against the vendor being reachable, so an outage is not reported as a company with no data - a chain where every vendor is unavailable says so instead of ending the run --- tests/test_undated_tools_as_of.py | 77 ++++++++++++++++++++++++ tradingagents/dataflows/interface.py | 17 +++++- tradingagents/dataflows/utils.py | 13 ++++ tradingagents/dataflows/y_finance.py | 45 +++++++++----- tradingagents/dataflows/yfinance_news.py | 5 +- 5 files changed, 138 insertions(+), 19 deletions(-) diff --git a/tests/test_undated_tools_as_of.py b/tests/test_undated_tools_as_of.py index d8be2abd4..b2ca2a493 100644 --- a/tests/test_undated_tools_as_of.py +++ b/tests/test_undated_tools_as_of.py @@ -149,3 +149,80 @@ def test_an_indicator_that_could_not_be_read_is_not_shown_as_a_blank_value(): side_effect=RuntimeError("cache parse failed")), \ pytest.raises(VendorError): y_finance.get_stockstats_indicator("AAPL", "rsi", "2026-05-08") + + +@pytest.mark.unit +@pytest.mark.parametrize("func, args", [ + # A past date withholds the live profile before any request, so the + # fundamentals case is exercised on the date it does fetch. + ("get_fundamentals", ("AAPL", None)), + ("get_balance_sheet", ("AAPL", "annual", "2026-09-01")), + ("get_cashflow", ("AAPL", "annual", "2026-09-01")), + ("get_income_statement", ("AAPL", "annual", "2026-09-01")), + ("get_insider_transactions", ("AAPL", "2026-09-01")), +]) +def test_a_yfinance_failure_is_a_vendor_error_not_a_report(func, args): + """Returning the failure as text makes the router count it as an answer, so + the chain stops and the analyst reads the error message as if it were data. + yfinance serves the default path, so this is the one that matters most.""" + from tradingagents.dataflows import y_finance + from tradingagents.dataflows.errors import VendorError + + with mock.patch.object(y_finance.yf, "Ticker", side_effect=RuntimeError("yahoo hiccup")), \ + pytest.raises(VendorError): + getattr(y_finance, func)(*args) + + +@pytest.mark.unit +@pytest.mark.parametrize("func, args", [ + ("get_news_yfinance", ("AAPL", "2026-08-25", "2026-09-01")), + ("get_global_news_yfinance", ("2026-09-01", 7, 5)), +]) +def test_a_yfinance_news_failure_is_a_vendor_error_not_a_report(func, args): + from tradingagents.dataflows import yfinance_news + from tradingagents.dataflows.errors import VendorError + + target = "Ticker" if "global" not in func else "Search" + with mock.patch.object(yfinance_news.yf, target, side_effect=RuntimeError("yahoo hiccup")), \ + pytest.raises(VendorError): + getattr(yfinance_news, func)(*args) + + +@pytest.mark.unit +def test_an_unreachable_vendor_is_not_reported_as_a_missing_symbol(monkeypatch): + """yfinance returns an empty frame when it cannot reach Yahoo, with no + exception. Reporting that as "no data for AAPL" tells the analyst the + company has no balance sheet, when the truth is we could not ask.""" + import pandas as pd + + from tradingagents.dataflows import y_finance + from tradingagents.dataflows.errors import NoMarketDataError, VendorRateLimitError + + empty = mock.Mock(quarterly_balance_sheet=pd.DataFrame(), balance_sheet=pd.DataFrame()) + monkeypatch.setattr(y_finance.yf, "Ticker", lambda s: empty) + + monkeypatch.setattr(y_finance, "vendor_reachable", lambda url: False) + with pytest.raises(VendorRateLimitError, match="unreachable"): + y_finance.get_balance_sheet("AAPL", "annual", "2026-09-01") + + monkeypatch.setattr(y_finance, "vendor_reachable", lambda url: True) + with pytest.raises(NoMarketDataError): + y_finance.get_balance_sheet("AAPL", "annual", "2026-09-01") + + +@pytest.mark.unit +def test_every_vendor_unavailable_says_so_rather_than_crashing(monkeypatch): + """A throttled or unreachable chain used to raise RuntimeError('No available + vendor'), which ends the run, and never said the vendor was the problem.""" + from tradingagents.dataflows import interface + from tradingagents.dataflows.errors import VendorRateLimitError + + def _down(*a, **k): + raise VendorRateLimitError("Yahoo Finance is unreachable") + + monkeypatch.setitem(interface.VENDOR_METHODS["get_balance_sheet"], "yfinance", _down) + + out = interface.route_to_vendor("get_balance_sheet", "AAPL", "annual", "2026-09-01") + + assert "unavailable" in out.lower() and "unreachable" in out.lower() + assert "delisted" not in out.lower() # not a claim about the symbol diff --git a/tradingagents/dataflows/interface.py b/tradingagents/dataflows/interface.py index 6fc80462d..61fd74e1a 100644 --- a/tradingagents/dataflows/interface.py +++ b/tradingagents/dataflows/interface.py @@ -202,6 +202,7 @@ def route_to_vendor(method: str, *args, **kwargs): vendor_chain = all_available_vendors last_no_data: NoMarketDataError | None = None + last_unavailable: VendorRateLimitError | None = None first_error: Exception | None = None for vendor in vendor_chain: vendor_impl = VENDOR_METHODS[method][vendor] @@ -209,8 +210,11 @@ def route_to_vendor(method: str, *args, **kwargs): try: return impl_func(*args, **kwargs) - except VendorRateLimitError: - logger.warning("Vendor %r rate-limited for %s; trying next vendor.", vendor, method) + except VendorRateLimitError as e: + logger.warning("Vendor %r unavailable for %s: %s; trying next vendor.", vendor, method, e) + # Kept so an all-unavailable chain can say the vendor was the + # problem, rather than reporting nothing about the symbol. + last_unavailable = e continue except VendorNotConfiguredError as e: logger.warning("Vendor %r not configured for %s; trying next vendor.", vendor, method) @@ -259,6 +263,15 @@ def route_to_vendor(method: str, *args, **kwargs): # first real error (e.g. the primary vendor's network failure). Optional # enrichment categories degrade to a sentinel instead, so flavour data can't # abort the run. + # Every vendor was throttled or unreachable: that is a fact about the + # vendors, not about the instrument, and it must not end the run. + if last_unavailable is not None: + return ( + f"DATA_UNAVAILABLE: no configured vendor could serve {method} right now " + f"({last_unavailable}). This says nothing about the instrument; report the " + f"data as unavailable and do not estimate or fabricate values." + ) + if first_error is not None: if category in OPTIONAL_CATEGORIES: logger.warning("Optional %s unavailable for %s: %s", category, method, first_error) diff --git a/tradingagents/dataflows/utils.py b/tradingagents/dataflows/utils.py index 39ede36f0..1bff0273c 100644 --- a/tradingagents/dataflows/utils.py +++ b/tradingagents/dataflows/utils.py @@ -62,3 +62,16 @@ def get_scrubbed(url: str, *, params: dict, timeout: float, secret: str, passthr except requests.RequestException as exc: error = type(exc)(str(exc).replace(secret, "***")) if secret else exc raise error + + +def vendor_reachable(url: str, timeout: float = 5.0) -> bool: + """Whether the vendor answers at all, for telling silence from an outage. + + A client that returns an empty result instead of raising leaves those two + cases indistinguishable. Called only when a result is empty. + """ + try: + requests.head(url, timeout=timeout, allow_redirects=True) + return True + except requests.RequestException: + return False diff --git a/tradingagents/dataflows/y_finance.py b/tradingagents/dataflows/y_finance.py index ad5db396e..5d4f16897 100644 --- a/tradingagents/dataflows/y_finance.py +++ b/tradingagents/dataflows/y_finance.py @@ -7,6 +7,7 @@ import yfinance as yf from dateutil.relativedelta import relativedelta from .date_window import withhold_live_profile +from .errors import VendorError, VendorRateLimitError from .stockstats_utils import ( StockstatsUtils, _assert_ohlcv_not_stale, @@ -15,6 +16,9 @@ from .stockstats_utils import ( yf_retry, ) from .symbol_utils import NoMarketDataError, normalize_symbol +from .utils import vendor_reachable + +_YAHOO_HOST = "https://query2.finance.yahoo.com" logger = logging.getLogger(__name__) @@ -189,7 +193,7 @@ def get_stock_stats_indicators_window( for date_str, value in date_values: ind_string += f"{date_str}: {value}\n" - except NoMarketDataError: + except VendorError: raise # Unknown/delisted symbol — let the router emit the sentinel except Exception as e: logger.warning("Bulk stockstats fetch failed, falling back per-day: %s", e) @@ -264,7 +268,7 @@ def get_stockstats_indicator( indicator, curr_date, ) - except NoMarketDataError: + except VendorError: raise # Unknown/delisted symbol — let the router emit the sentinel except Exception as e: # An empty string renders as "2026-05-08: " in the indicator table, which @@ -300,7 +304,7 @@ def get_fundamentals( info = yf_retry(lambda: ticker_obj.info) if not info: - raise NoMarketDataError(ticker, canonical, "no fundamentals returned") + _raise_for_empty(ticker, canonical, "fundamentals") fields = [ ("Name", info.get("longName")), @@ -347,10 +351,10 @@ def get_fundamentals( return header + "\n".join(lines) - except NoMarketDataError: + except VendorError: raise except Exception as e: - return f"Error retrieving fundamentals for {ticker}: {str(e)}" + raise NoMarketDataError(ticker, canonical, f"fundamentals unavailable: {e}") from e def get_balance_sheet( @@ -371,7 +375,7 @@ def get_balance_sheet( data = filter_financials_by_date(data, curr_date) if data.empty: - raise NoMarketDataError(ticker, canonical, "no balance sheet data") + _raise_for_empty(ticker, canonical, "balance sheet data") # Convert to CSV string for consistency with other functions csv_string = data.to_csv() @@ -383,10 +387,10 @@ def get_balance_sheet( return header + csv_string - except NoMarketDataError: + except VendorError: raise except Exception as e: - return f"Error retrieving balance sheet for {ticker}: {str(e)}" + raise NoMarketDataError(ticker, canonical, f"balance sheet unavailable: {e}") from e def get_cashflow( @@ -407,7 +411,7 @@ def get_cashflow( data = filter_financials_by_date(data, curr_date) if data.empty: - raise NoMarketDataError(ticker, canonical, "no cash flow data") + _raise_for_empty(ticker, canonical, "cash flow data") # Convert to CSV string for consistency with other functions csv_string = data.to_csv() @@ -419,10 +423,10 @@ def get_cashflow( return header + csv_string - except NoMarketDataError: + except VendorError: raise except Exception as e: - return f"Error retrieving cash flow for {ticker}: {str(e)}" + raise NoMarketDataError(ticker, canonical, f"cash flow unavailable: {e}") from e def get_income_statement( @@ -443,7 +447,7 @@ def get_income_statement( data = filter_financials_by_date(data, curr_date) if data.empty: - raise NoMarketDataError(ticker, canonical, "no income statement data") + _raise_for_empty(ticker, canonical, "income statement data") # Convert to CSV string for consistency with other functions csv_string = data.to_csv() @@ -455,10 +459,10 @@ def get_income_statement( return header + csv_string - except NoMarketDataError: + except VendorError: raise except Exception as e: - return f"Error retrieving income statement for {ticker}: {str(e)}" + raise NoMarketDataError(ticker, canonical, f"income statement unavailable: {e}") from e # Rows are dated by the transaction, which is when the insider traded, not when @@ -483,6 +487,17 @@ _PERIOD_END_VINTAGE = ( ) +def _raise_for_empty(ticker: str, canonical: str, what: str) -> None: + """Report an empty result as an absence, or as an outage if Yahoo is down. + + yfinance returns an empty frame for a failed request rather than raising, so + without this an outage reads as "this company reports no {what}". + """ + if not vendor_reachable(_YAHOO_HOST): + raise VendorRateLimitError(f"Yahoo Finance is unreachable; no {what} was retrieved") + raise NoMarketDataError(ticker, canonical, f"no {what}") + + def get_insider_transactions( ticker: Annotated[str, "ticker symbol of the company"], curr_date: Annotated[str | None, "only transactions on or before this date, yyyy-mm-dd"] = None, @@ -519,4 +534,4 @@ def get_insider_transactions( return header + csv_string except Exception as e: - return f"Error retrieving insider transactions for {ticker}: {str(e)}" + raise NoMarketDataError(ticker, canonical, f"insider transactions unavailable: {e}") from e diff --git a/tradingagents/dataflows/yfinance_news.py b/tradingagents/dataflows/yfinance_news.py index 033afcd8c..3b2efce51 100644 --- a/tradingagents/dataflows/yfinance_news.py +++ b/tradingagents/dataflows/yfinance_news.py @@ -8,6 +8,7 @@ from dateutil.relativedelta import relativedelta from .config import get_config from .date_window import coverage_gap, in_window +from .errors import NoMarketDataError from .stockstats_utils import yf_retry from .symbol_utils import normalize_symbol @@ -118,7 +119,7 @@ def get_news_yfinance( return f"## {ticker}{resolved} News, from {start_date} to {end_date}:\n\n{news_str}" except Exception as e: - return f"Error fetching news for {ticker}: {str(e)}" + raise NoMarketDataError(ticker, ticker, f"news unavailable: {e}") from e def get_global_news_yfinance( @@ -196,4 +197,4 @@ def get_global_news_yfinance( return f"## Global Market News, from {start_date} to {curr_date}:\n\n{news_str}" except Exception as e: - return f"Error fetching global news: {str(e)}" + raise NoMarketDataError("global news", "global news", f"unavailable: {e}") from e