mirror of
https://github.com/TauricResearch/TradingAgents.git
synced 2026-09-19 19:25:24 +03:00
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:
@@ -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")), \
|
side_effect=RuntimeError("cache parse failed")), \
|
||||||
pytest.raises(VendorError):
|
pytest.raises(VendorError):
|
||||||
y_finance.get_stockstats_indicator("AAPL", "rsi", "2026-05-08")
|
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
|
||||||
|
|||||||
@@ -202,6 +202,7 @@ def route_to_vendor(method: str, *args, **kwargs):
|
|||||||
vendor_chain = all_available_vendors
|
vendor_chain = all_available_vendors
|
||||||
|
|
||||||
last_no_data: NoMarketDataError | None = None
|
last_no_data: NoMarketDataError | None = None
|
||||||
|
last_unavailable: VendorRateLimitError | None = None
|
||||||
first_error: Exception | None = None
|
first_error: Exception | None = None
|
||||||
for vendor in vendor_chain:
|
for vendor in vendor_chain:
|
||||||
vendor_impl = VENDOR_METHODS[method][vendor]
|
vendor_impl = VENDOR_METHODS[method][vendor]
|
||||||
@@ -209,8 +210,11 @@ def route_to_vendor(method: str, *args, **kwargs):
|
|||||||
|
|
||||||
try:
|
try:
|
||||||
return impl_func(*args, **kwargs)
|
return impl_func(*args, **kwargs)
|
||||||
except VendorRateLimitError:
|
except VendorRateLimitError as e:
|
||||||
logger.warning("Vendor %r rate-limited for %s; trying next vendor.", vendor, method)
|
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
|
continue
|
||||||
except VendorNotConfiguredError as e:
|
except VendorNotConfiguredError as e:
|
||||||
logger.warning("Vendor %r not configured for %s; trying next vendor.", vendor, method)
|
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
|
# first real error (e.g. the primary vendor's network failure). Optional
|
||||||
# enrichment categories degrade to a sentinel instead, so flavour data can't
|
# enrichment categories degrade to a sentinel instead, so flavour data can't
|
||||||
# abort the run.
|
# 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 first_error is not None:
|
||||||
if category in OPTIONAL_CATEGORIES:
|
if category in OPTIONAL_CATEGORIES:
|
||||||
logger.warning("Optional %s unavailable for %s: %s", category, method, first_error)
|
logger.warning("Optional %s unavailable for %s: %s", category, method, first_error)
|
||||||
|
|||||||
@@ -62,3 +62,16 @@ def get_scrubbed(url: str, *, params: dict, timeout: float, secret: str, passthr
|
|||||||
except requests.RequestException as exc:
|
except requests.RequestException as exc:
|
||||||
error = type(exc)(str(exc).replace(secret, "***")) if secret else exc
|
error = type(exc)(str(exc).replace(secret, "***")) if secret else exc
|
||||||
raise error
|
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
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import yfinance as yf
|
|||||||
from dateutil.relativedelta import relativedelta
|
from dateutil.relativedelta import relativedelta
|
||||||
|
|
||||||
from .date_window import withhold_live_profile
|
from .date_window import withhold_live_profile
|
||||||
|
from .errors import VendorError, VendorRateLimitError
|
||||||
from .stockstats_utils import (
|
from .stockstats_utils import (
|
||||||
StockstatsUtils,
|
StockstatsUtils,
|
||||||
_assert_ohlcv_not_stale,
|
_assert_ohlcv_not_stale,
|
||||||
@@ -15,6 +16,9 @@ from .stockstats_utils import (
|
|||||||
yf_retry,
|
yf_retry,
|
||||||
)
|
)
|
||||||
from .symbol_utils import NoMarketDataError, normalize_symbol
|
from .symbol_utils import NoMarketDataError, normalize_symbol
|
||||||
|
from .utils import vendor_reachable
|
||||||
|
|
||||||
|
_YAHOO_HOST = "https://query2.finance.yahoo.com"
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
@@ -189,7 +193,7 @@ def get_stock_stats_indicators_window(
|
|||||||
for date_str, value in date_values:
|
for date_str, value in date_values:
|
||||||
ind_string += f"{date_str}: {value}\n"
|
ind_string += f"{date_str}: {value}\n"
|
||||||
|
|
||||||
except NoMarketDataError:
|
except VendorError:
|
||||||
raise # Unknown/delisted symbol — let the router emit the sentinel
|
raise # Unknown/delisted symbol — let the router emit the sentinel
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.warning("Bulk stockstats fetch failed, falling back per-day: %s", e)
|
logger.warning("Bulk stockstats fetch failed, falling back per-day: %s", e)
|
||||||
@@ -264,7 +268,7 @@ def get_stockstats_indicator(
|
|||||||
indicator,
|
indicator,
|
||||||
curr_date,
|
curr_date,
|
||||||
)
|
)
|
||||||
except NoMarketDataError:
|
except VendorError:
|
||||||
raise # Unknown/delisted symbol — let the router emit the sentinel
|
raise # Unknown/delisted symbol — let the router emit the sentinel
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
# An empty string renders as "2026-05-08: " in the indicator table, which
|
# 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)
|
info = yf_retry(lambda: ticker_obj.info)
|
||||||
|
|
||||||
if not info:
|
if not info:
|
||||||
raise NoMarketDataError(ticker, canonical, "no fundamentals returned")
|
_raise_for_empty(ticker, canonical, "fundamentals")
|
||||||
|
|
||||||
fields = [
|
fields = [
|
||||||
("Name", info.get("longName")),
|
("Name", info.get("longName")),
|
||||||
@@ -347,10 +351,10 @@ def get_fundamentals(
|
|||||||
|
|
||||||
return header + "\n".join(lines)
|
return header + "\n".join(lines)
|
||||||
|
|
||||||
except NoMarketDataError:
|
except VendorError:
|
||||||
raise
|
raise
|
||||||
except Exception as e:
|
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(
|
def get_balance_sheet(
|
||||||
@@ -371,7 +375,7 @@ def get_balance_sheet(
|
|||||||
data = filter_financials_by_date(data, curr_date)
|
data = filter_financials_by_date(data, curr_date)
|
||||||
|
|
||||||
if data.empty:
|
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
|
# Convert to CSV string for consistency with other functions
|
||||||
csv_string = data.to_csv()
|
csv_string = data.to_csv()
|
||||||
@@ -383,10 +387,10 @@ def get_balance_sheet(
|
|||||||
|
|
||||||
return header + csv_string
|
return header + csv_string
|
||||||
|
|
||||||
except NoMarketDataError:
|
except VendorError:
|
||||||
raise
|
raise
|
||||||
except Exception as e:
|
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(
|
def get_cashflow(
|
||||||
@@ -407,7 +411,7 @@ def get_cashflow(
|
|||||||
data = filter_financials_by_date(data, curr_date)
|
data = filter_financials_by_date(data, curr_date)
|
||||||
|
|
||||||
if data.empty:
|
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
|
# Convert to CSV string for consistency with other functions
|
||||||
csv_string = data.to_csv()
|
csv_string = data.to_csv()
|
||||||
@@ -419,10 +423,10 @@ def get_cashflow(
|
|||||||
|
|
||||||
return header + csv_string
|
return header + csv_string
|
||||||
|
|
||||||
except NoMarketDataError:
|
except VendorError:
|
||||||
raise
|
raise
|
||||||
except Exception as e:
|
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(
|
def get_income_statement(
|
||||||
@@ -443,7 +447,7 @@ def get_income_statement(
|
|||||||
data = filter_financials_by_date(data, curr_date)
|
data = filter_financials_by_date(data, curr_date)
|
||||||
|
|
||||||
if data.empty:
|
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
|
# Convert to CSV string for consistency with other functions
|
||||||
csv_string = data.to_csv()
|
csv_string = data.to_csv()
|
||||||
@@ -455,10 +459,10 @@ def get_income_statement(
|
|||||||
|
|
||||||
return header + csv_string
|
return header + csv_string
|
||||||
|
|
||||||
except NoMarketDataError:
|
except VendorError:
|
||||||
raise
|
raise
|
||||||
except Exception as e:
|
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
|
# 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(
|
def get_insider_transactions(
|
||||||
ticker: Annotated[str, "ticker symbol of the company"],
|
ticker: Annotated[str, "ticker symbol of the company"],
|
||||||
curr_date: Annotated[str | None, "only transactions on or before this date, yyyy-mm-dd"] = None,
|
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
|
return header + csv_string
|
||||||
|
|
||||||
except Exception as e:
|
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
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ from dateutil.relativedelta import relativedelta
|
|||||||
|
|
||||||
from .config import get_config
|
from .config import get_config
|
||||||
from .date_window import coverage_gap, in_window
|
from .date_window import coverage_gap, in_window
|
||||||
|
from .errors import NoMarketDataError
|
||||||
from .stockstats_utils import yf_retry
|
from .stockstats_utils import yf_retry
|
||||||
from .symbol_utils import normalize_symbol
|
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}"
|
return f"## {ticker}{resolved} News, from {start_date} to {end_date}:\n\n{news_str}"
|
||||||
|
|
||||||
except Exception as e:
|
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(
|
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}"
|
return f"## Global Market News, from {start_date} to {curr_date}:\n\n{news_str}"
|
||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
return f"Error fetching global news: {str(e)}"
|
raise NoMarketDataError("global news", "global news", f"unavailable: {e}") from e
|
||||||
|
|||||||
Reference in New Issue
Block a user