fix(yahoo): report the failed requests yfinance would answer as empty

- yfinance raises its failures instead of answering them with an empty result, so a statement request that timed out is unavailable, not "no data"
- Yahoo answering that it has no such symbol (HTTP 404) stays an answer: no data
This commit is contained in:
Yijia-Xiao
2026-09-29 05:57:18 +00:00
parent 0f05689334
commit 1bf67e35b6
4 changed files with 63 additions and 8 deletions
+41
View File
@@ -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
+2 -1
View File
@@ -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()
+3 -4
View File
@@ -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(
+17 -3
View File
@@ -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: