diff --git a/tests/test_yahoo_rate_limit.py b/tests/test_yahoo_rate_limit.py index ffbab5ec4..3669a4fa2 100644 --- a/tests/test_yahoo_rate_limit.py +++ b/tests/test_yahoo_rate_limit.py @@ -199,3 +199,44 @@ def test_a_snapshot_with_no_rows_is_reported_not_raised(yahoo, frame): out = get_verified_market_snapshot.func("ZZZZ", DAY, trade_date=DAY) assert out.startswith("NO_DATA_AVAILABLE"), out + + +def _yahoo_request_raises(yahoo, exc): + """Every request yfinance makes raises ``exc``, below its own error handling.""" + from yfinance.data import YfData + + def request(self, *args, **kwargs): + raise exc + + yahoo.setattr(YfData, "_make_request", request) + + +@pytest.mark.unit +@pytest.mark.parametrize("call", _yahoo_calls()) +def test_a_request_that_yfinance_would_swallow_is_unavailable(yahoo, call): + """yfinance answers some failed requests with an empty result; an empty + statement from a Yahoo whose host answers a probe read as "no data".""" + _yahoo_request_raises(yahoo, TimeoutError("read timed out")) + + with pytest.raises(VendorUnavailableError): + call() + + +@pytest.mark.unit +@pytest.mark.parametrize("call", [c for c in _yahoo_calls() if c.id in ("fundamentals", "stock_data", "insider")]) +def test_yahoo_answering_not_found_is_no_data(yahoo, call): + """Yahoo answers an unknown symbol with HTTP 404: an answer about the symbol, not an outage.""" + from curl_cffi.requests import Response + from curl_cffi.requests.exceptions import HTTPError + + from tradingagents.dataflows.errors import NoMarketDataError + + not_found = Response() + not_found.status_code = 404 + _yahoo_request_raises(yahoo, HTTPError("HTTP Error 404: ", response=not_found)) + + try: + out = call() + except NoMarketDataError: + return + assert out.startswith("No insider transactions"), out # an empty filing list is not an error diff --git a/tradingagents/dataflows/vendors/yahoo/fundamentals.py b/tradingagents/dataflows/vendors/yahoo/fundamentals.py index 15a6b7b97..3bbcdb890 100644 --- a/tradingagents/dataflows/vendors/yahoo/fundamentals.py +++ b/tradingagents/dataflows/vendors/yahoo/fundamentals.py @@ -100,7 +100,8 @@ def _statement(ticker, freq, as_of_date, title, quarterly_attr, annual_attr) -> canonical = normalize_symbol(ticker) what = title.lower() attr = quarterly_attr if freq.lower() == "quarterly" else annual_attr - data = filter_financials_by_date(yf_retry(lambda: getattr(yf.Ticker(canonical), attr)), as_of_date) + data = yf_retry(lambda: getattr(yf.Ticker(canonical), attr)) + data = pd.DataFrame() if data is None else filter_financials_by_date(data, as_of_date) if data.empty: raise_for_empty(ticker, canonical, f"{what} data") return f"# {title} data for {canonical} ({freq})\n" + _PERIOD_END_VINTAGE + data.to_csv() diff --git a/tradingagents/dataflows/vendors/yahoo/market.py b/tradingagents/dataflows/vendors/yahoo/market.py index cc65ef2d5..7ce6e0735 100644 --- a/tradingagents/dataflows/vendors/yahoo/market.py +++ b/tradingagents/dataflows/vendors/yahoo/market.py @@ -30,18 +30,17 @@ def get_YFin_data_online( # Resolve broker/forex symbols to Yahoo's convention (XAUUSD+ -> GC=F). canonical = normalize_symbol(symbol) - ticker = yf.Ticker(canonical) # yfinance treats ``end`` as EXCLUSIVE, so it would drop the requested # end_date row (and the current day when end_date is today). Request one day # past end_date so the requested range is actually inclusive (#986/#987). end_inclusive = (end_dt + relativedelta(days=1)).strftime("%Y-%m-%d") - data = yf_retry(lambda: ticker.history(start=start_date, end=end_inclusive)) + data = yf_retry(lambda: yf.Ticker(canonical).history(start=start_date, end=end_inclusive)) # Empty result means the symbol is unknown/delisted. Raise a typed error # 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: + if data is None or data.empty: raise_for_empty(symbol, canonical, f"rows between {start_date} and {end_date}") # Remove timezone info from index for cleaner output @@ -274,7 +273,7 @@ def get_closes(symbol: str, start_date: str, end_date: str) -> pd.Series: """Daily closes from ``start_date`` up to, not including, ``end_date``.""" canonical = normalize_symbol(symbol) history = yf_retry(lambda: yf.Ticker(canonical).history(start=start_date, end=end_date)) - return history["Close"] if "Close" in history else pd.Series(dtype=float) + return history["Close"] if history is not None and "Close" in history else pd.Series(dtype=float) def get_stock_stats( diff --git a/tradingagents/dataflows/vendors/yahoo/ohlcv.py b/tradingagents/dataflows/vendors/yahoo/ohlcv.py index b5b886c0a..f7c1cd8d1 100644 --- a/tradingagents/dataflows/vendors/yahoo/ohlcv.py +++ b/tradingagents/dataflows/vendors/yahoo/ohlcv.py @@ -39,15 +39,25 @@ def raise_for_empty(symbol: str, canonical: str, what: str) -> None: raise NoMarketDataError(symbol, canonical, f"no {what}") +# yfinance answers some failed requests with an empty result, which would read as +# "no data" for a symbol nobody checked; raised, the failure is reported as one. +yf.config.debug.hide_exceptions = False + + +def _not_found(exc: Exception) -> bool: + """Whether Yahoo answered that it has no such symbol (HTTP 404).""" + return getattr(getattr(exc, "response", None), "status_code", None) == 404 + + def yf_retry(func, max_retries=3, base_delay=2.0): """Execute a yfinance call with exponential backoff on rate limits. yfinance raises YFRateLimitError on HTTP 429 responses but does not retry them internally, so this wrapper retries them. A rate limit that outlasts the retries, or any other exception, is raised as - VendorUnavailableError: Yahoo answers an unknown symbol with an empty - result, so a request that raised failed in transit and says nothing about - the symbol. ``func`` should build its own Ticker and make the request + VendorUnavailableError: it failed in transit and says nothing about the + symbol. Yahoo answering that it has no such symbol (HTTP 404) returns None, + an empty answer. ``func`` should build its own Ticker and make the request itself: a Ticker keeps a failed ``info`` fetch as done, so asking the same one again reads an empty profile. """ @@ -64,6 +74,8 @@ def yf_retry(func, max_retries=3, base_delay=2.0): f"Yahoo Finance rate limited after {max_retries} retries: {exc}" ) from exc except Exception as exc: + if _not_found(exc): + return None raise VendorUnavailableError(f"Yahoo Finance request failed: {type(exc).__name__}") from exc @@ -258,6 +270,8 @@ def load_ohlcv(symbol: str, as_of_date: str, fill_gaps: bool = True) -> pd.DataF auto_adjust=True, actions=False, )) + if downloaded is None: + raise_for_empty(symbol, canonical, "price rows") 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: