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
This commit is contained in:
Yijia-Xiao
2026-09-18 01:49:52 +00:00
parent 10cc070fa3
commit d5ba41bac3
5 changed files with 138 additions and 19 deletions

View File

@@ -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

View File

@@ -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)

View File

@@ -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

View File

@@ -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

View File

@@ -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