From c039e3905673337b9893d6fad198bedcbccf1ee7 Mon Sep 17 00:00:00 2001 From: Yijia-Xiao Date: Fri, 18 Sep 2026 04:02:57 +0000 Subject: [PATCH] fix(dataflows): tell an outage from an unknown symbol on every Yahoo path - prices, indicators and insider filings now use the same check as the statements --- tests/test_undated_tools_as_of.py | 26 ++++++++++++++++++--- tradingagents/dataflows/stockstats_utils.py | 20 ++++++++++++---- tradingagents/dataflows/y_finance.py | 26 +++++++-------------- 3 files changed, 47 insertions(+), 25 deletions(-) diff --git a/tests/test_undated_tools_as_of.py b/tests/test_undated_tools_as_of.py index b2ca2a493..84c91e1bb 100644 --- a/tests/test_undated_tools_as_of.py +++ b/tests/test_undated_tools_as_of.py @@ -195,17 +195,17 @@ def test_an_unreachable_vendor_is_not_reported_as_a_missing_symbol(monkeypatch): 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 import stockstats_utils, 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) + monkeypatch.setattr(stockstats_utils, "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) + monkeypatch.setattr(stockstats_utils, "vendor_reachable", lambda url: True) with pytest.raises(NoMarketDataError): y_finance.get_balance_sheet("AAPL", "annual", "2026-09-01") @@ -226,3 +226,23 @@ def test_every_vendor_unavailable_says_so_rather_than_crashing(monkeypatch): assert "unavailable" in out.lower() and "unreachable" in out.lower() assert "delisted" not in out.lower() # not a claim about the symbol + + +@pytest.mark.unit +def test_the_price_path_also_tells_an_outage_from_an_unknown_symbol(monkeypatch): + """Prices are the most-used path, so an outage there must not read as a + delisted symbol either.""" + import pandas as pd + + from tradingagents.dataflows import stockstats_utils, y_finance + from tradingagents.dataflows.errors import NoMarketDataError, VendorRateLimitError + + monkeypatch.setattr(y_finance.yf, "Ticker", lambda s: mock.Mock(history=lambda **k: pd.DataFrame())) + + monkeypatch.setattr(stockstats_utils, "vendor_reachable", lambda url: False) + with pytest.raises(VendorRateLimitError, match="unreachable"): + y_finance.get_YFin_data_online("AAPL", "2026-09-01", "2026-09-10") + + monkeypatch.setattr(stockstats_utils, "vendor_reachable", lambda url: True) + with pytest.raises(NoMarketDataError): + y_finance.get_YFin_data_online("AAPL", "2026-09-01", "2026-09-10") diff --git a/tradingagents/dataflows/stockstats_utils.py b/tradingagents/dataflows/stockstats_utils.py index 54df1d4ad..5625f2e08 100644 --- a/tradingagents/dataflows/stockstats_utils.py +++ b/tradingagents/dataflows/stockstats_utils.py @@ -9,11 +9,14 @@ from stockstats import wrap from yfinance.exceptions import YFRateLimitError from .config import get_config +from .errors import VendorRateLimitError from .symbol_utils import NoMarketDataError, normalize_symbol -from .utils import safe_ticker_component +from .utils import safe_ticker_component, vendor_reachable logger = logging.getLogger(__name__) +_YAHOO_HOST = "https://query2.finance.yahoo.com" + # A vendor's latest OHLCV row this many calendar days before the requested date # is treated as stale. Generous enough to span long holiday weekends, tight # enough to catch the year-old frames yfinance occasionally returns (#1021). @@ -26,6 +29,17 @@ MAX_OHLCV_STALE_DAYS = 10 OHLCV_CACHE_TTL_SECONDS = 900 +def raise_for_empty(symbol: str, canonical: str, what: str) -> None: + """Report an empty Yahoo result as an absence, or as an outage if it is one. + + yfinance returns an empty frame for a failed request rather than raising, so + without this a Yahoo outage reads as "this symbol has no {what}". + """ + if not vendor_reachable(_YAHOO_HOST): + raise VendorRateLimitError(f"Yahoo Finance is unreachable; no {what} was retrieved") + raise NoMarketDataError(symbol, canonical, f"no {what}") + + def yf_retry(func, max_retries=3, base_delay=2.0): """Execute a yfinance call with exponential backoff on rate limits. @@ -239,9 +253,7 @@ def load_ohlcv(symbol: str, curr_date: str, fill_gaps: bool = True) -> pd.DataFr downloaded = _ensure_date_column(downloaded.reset_index()) # Only cache real data — never persist an empty frame. if downloaded.empty or "Close" not in downloaded.columns: - raise NoMarketDataError( - symbol, canonical, "Yahoo Finance returned no rows" - ) + raise_for_empty(symbol, canonical, "price rows") downloaded.to_csv(data_file, index=False, encoding="utf-8") data = downloaded diff --git a/tradingagents/dataflows/y_finance.py b/tradingagents/dataflows/y_finance.py index 5d4f16897..c2f24f35d 100644 --- a/tradingagents/dataflows/y_finance.py +++ b/tradingagents/dataflows/y_finance.py @@ -13,6 +13,7 @@ from .stockstats_utils import ( _assert_ohlcv_not_stale, filter_financials_by_date, load_ohlcv, + raise_for_empty, yf_retry, ) from .symbol_utils import NoMarketDataError, normalize_symbol @@ -46,9 +47,7 @@ def get_YFin_data_online( # instead of returning prose: the routing layer turns it into a single # unambiguous "no data" signal so the agent never fabricates a price. if data.empty: - raise NoMarketDataError( - symbol, canonical, f"no rows between {start_date} and {end_date}" - ) + raise_for_empty(symbol, canonical, f"rows between {start_date} and {end_date}") # Remove timezone info from index for cleaner output if data.index.tz is not None: @@ -304,7 +303,7 @@ def get_fundamentals( info = yf_retry(lambda: ticker_obj.info) if not info: - _raise_for_empty(ticker, canonical, "fundamentals") + raise_for_empty(ticker, canonical, "fundamentals") fields = [ ("Name", info.get("longName")), @@ -375,7 +374,7 @@ def get_balance_sheet( data = filter_financials_by_date(data, curr_date) if data.empty: - _raise_for_empty(ticker, canonical, "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() @@ -411,7 +410,7 @@ def get_cashflow( data = filter_financials_by_date(data, curr_date) if data.empty: - _raise_for_empty(ticker, canonical, "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() @@ -447,7 +446,7 @@ def get_income_statement( data = filter_financials_by_date(data, curr_date) if data.empty: - _raise_for_empty(ticker, canonical, "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() @@ -487,17 +486,6 @@ _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, @@ -511,6 +499,8 @@ def get_insider_transactions( # Empty is normal here (many valid symbols have no insider filings), # so report it plainly rather than treating the symbol as invalid. if data is None or data.empty: + if not vendor_reachable(_YAHOO_HOST): + raise VendorRateLimitError("Yahoo Finance is unreachable; insider filings were not retrieved") return f"No insider transactions reported for symbol '{canonical}'" if curr_date: