fix(dataflows): report a Yahoo rate limit as a rate limit (#1387)

This commit is contained in:
Chaoqi
2026-09-24 11:45:39 -07:00
committed by GitHub
parent ecd3404213
commit 6ac7c6f017
10 changed files with 193 additions and 31 deletions
+4 -2
View File
@@ -4,6 +4,8 @@ requested end_date (and the current day) is actually included.
Regressions for #986 (current-day OHLCV excluded) and #987 (requested end_date Regressions for #986 (current-day OHLCV excluded) and #987 (requested end_date
row omitted). row omitted).
""" """
from types import SimpleNamespace
import pandas as pd import pandas as pd
import pytest import pytest
@@ -44,7 +46,7 @@ def test_load_ohlcv_requests_inclusive_end(monkeypatch, tmp_path):
set_config({"data_cache_dir": str(tmp_path)}) set_config({"data_cache_dir": str(tmp_path)})
captured = {} captured = {}
def fake_download(symbol, start, end, **kwargs): def fake_history(start, end, **kwargs):
captured["end"] = end captured["end"] = end
idx = pd.to_datetime([pd.Timestamp.today().normalize()]) idx = pd.to_datetime([pd.Timestamp.today().normalize()])
return pd.DataFrame( return pd.DataFrame(
@@ -53,7 +55,7 @@ def test_load_ohlcv_requests_inclusive_end(monkeypatch, tmp_path):
index=idx, index=idx,
) )
monkeypatch.setattr(ohlcv.yf, "download", fake_download) monkeypatch.setattr(ohlcv.yf, "Ticker", lambda symbol: SimpleNamespace(history=fake_history))
today = pd.Timestamp.today().strftime("%Y-%m-%d") today = pd.Timestamp.today().strftime("%Y-%m-%d")
ohlcv.load_ohlcv("AAPL", today) ohlcv.load_ohlcv("AAPL", today)
+3 -3
View File
@@ -33,19 +33,19 @@ class TestLoadOhlcvNoPoison(unittest.TestCase):
os.rmdir(self._tmp) os.rmdir(self._tmp)
def test_empty_download_raises_and_does_not_cache(self): def test_empty_download_raises_and_does_not_cache(self):
empty = pd.DataFrame() empty = mock.Mock(history=mock.Mock(return_value=pd.DataFrame()))
# Yahoo answers, so an empty download means the symbol has no data. # Yahoo answers, so an empty download means the symbol has no data.
reachable = mock.patch.object(ohlcv, "vendor_reachable", return_value=True) reachable = mock.patch.object(ohlcv, "vendor_reachable", return_value=True)
reachable.start() reachable.start()
self.addCleanup(reachable.stop) self.addCleanup(reachable.stop)
with mock.patch.object(ohlcv.yf, "download", return_value=empty), \ with mock.patch.object(ohlcv.yf, "Ticker", return_value=empty), \
self.assertRaises(NoMarketDataError): self.assertRaises(NoMarketDataError):
ohlcv.load_ohlcv("FAKE", "2026-01-01") ohlcv.load_ohlcv("FAKE", "2026-01-01")
# Nothing should have been written to the cache. # Nothing should have been written to the cache.
self.assertEqual(os.listdir(self._tmp), []) self.assertEqual(os.listdir(self._tmp), [])
# A second call must re-attempt the fetch (no poisoned cache served). # A second call must re-attempt the fetch (no poisoned cache served).
with mock.patch.object(ohlcv.yf, "download", return_value=empty) as dl2: with mock.patch.object(ohlcv.yf, "Ticker", return_value=empty) as dl2:
with self.assertRaises(NoMarketDataError): with self.assertRaises(NoMarketDataError):
ohlcv.load_ohlcv("FAKE", "2026-01-01") ohlcv.load_ohlcv("FAKE", "2026-01-01")
self.assertTrue(dl2.called) self.assertTrue(dl2.called)
+4 -2
View File
@@ -8,6 +8,7 @@ day (#1330).
from __future__ import annotations from __future__ import annotations
import os import os
from types import SimpleNamespace
import pandas as pd import pandas as pd
import pytest import pytest
@@ -35,7 +36,7 @@ def _write(tmp_path, name="AAPL-YFin-data.csv", age_seconds=0.0, last_date="2026
def _load(tmp_path, monkeypatch, curr_date, download): def _load(tmp_path, monkeypatch, curr_date, download):
monkeypatch.setattr(ohlcv, "get_config", lambda: {"data_cache_dir": str(tmp_path)}) monkeypatch.setattr(ohlcv, "get_config", lambda: {"data_cache_dir": str(tmp_path)})
monkeypatch.setattr(ohlcv.pd.Timestamp, "today", staticmethod(lambda: NOW)) monkeypatch.setattr(ohlcv.pd.Timestamp, "today", staticmethod(lambda: NOW))
monkeypatch.setattr(ohlcv.yf, "download", download) monkeypatch.setattr(ohlcv.yf, "Ticker", lambda symbol: SimpleNamespace(history=download))
return ohlcv.load_ohlcv("AAPL", curr_date) return ohlcv.load_ohlcv("AAPL", curr_date)
@@ -98,7 +99,8 @@ def test_one_cache_file_per_symbol_across_days(tmp_path, monkeypatch):
monkeypatch.setattr(ohlcv, "get_config", lambda: {"data_cache_dir": str(tmp_path)}) monkeypatch.setattr(ohlcv, "get_config", lambda: {"data_cache_dir": str(tmp_path)})
frame = pd.DataFrame({"Date": pd.to_datetime(["2026-07-16", "2026-07-17"]), "Close": [1.0, 2.0]}) frame = pd.DataFrame({"Date": pd.to_datetime(["2026-07-16", "2026-07-17"]), "Close": [1.0, 2.0]})
downloads = [] downloads = []
monkeypatch.setattr(ohlcv.yf, "download", lambda *a, **k: downloads.append(1) or frame.set_index("Date")) monkeypatch.setattr(ohlcv.yf, "Ticker", lambda symbol: SimpleNamespace(
history=lambda *a, **k: downloads.append(1) or frame.set_index("Date")))
for day in ("2026-07-18 10:00", "2026-07-19 10:00", "2026-07-20 10:00"): for day in ("2026-07-18 10:00", "2026-07-19 10:00", "2026-07-20 10:00"):
now = pd.Timestamp(day) now = pd.Timestamp(day)
+4 -3
View File
@@ -14,6 +14,7 @@ anywhere counts as no data and the staleness check judges the rest.
from __future__ import annotations from __future__ import annotations
import os import os
from types import SimpleNamespace
import pandas as pd import pandas as pd
import pytest import pytest
@@ -105,7 +106,7 @@ def _run_load(monkeypatch, tmp_path, frame, curr_date):
def _fail_download(*a, **k): def _fail_download(*a, **k):
raise AssertionError("should use the seeded cache, not download") raise AssertionError("should use the seeded cache, not download")
monkeypatch.setattr(ohlcv.yf, "download", _fail_download) monkeypatch.setattr(ohlcv.yf, "Ticker", lambda symbol: SimpleNamespace(history=_fail_download))
return ohlcv.load_ohlcv("AAPL", curr_date) return ohlcv.load_ohlcv("AAPL", curr_date)
@@ -198,8 +199,8 @@ def test_the_snapshot_does_not_present_a_filled_price_as_reported(monkeypatch, t
cache = tmp_path / "AAPL-YFin-data.csv" cache = tmp_path / "AAPL-YFin-data.csv"
cache.write_text(frame.to_csv(index=False)) cache.write_text(frame.to_csv(index=False))
_stamp(cache, today) _stamp(cache, today)
monkeypatch.setattr(ohlcv.yf, "download", lambda *a, **k: (_ for _ in ()).throw( monkeypatch.setattr(ohlcv.yf, "Ticker", lambda symbol: SimpleNamespace(
AssertionError("should read the seeded cache"))) history=lambda *a, **k: (_ for _ in ()).throw(AssertionError("should read the seeded cache"))))
out = snapshot.build_verified_market_snapshot("AAPL", "2026-05-08", 3) out = snapshot.build_verified_market_snapshot("AAPL", "2026-05-08", 3)
+130
View File
@@ -0,0 +1,130 @@
"""A Yahoo rate limit is retried, then reported as a rate limit.
When the limit outlasts the retries, the agent must hear that the vendor is
throttled. "The symbol may be invalid or delisted" is a claim about the company
that nobody checked, and an exception out of a tool ends the run.
"""
import pandas as pd
import pytest
import yfinance as yf
from yfinance.data import YfData
from yfinance.exceptions import YFRateLimitError
from tradingagents.agents.tools import (
get_global_news,
get_indicators,
get_insider_transactions,
get_news,
get_stock_data,
get_verified_market_snapshot,
)
from tradingagents.dataflows import router
from tradingagents.dataflows.config import set_config
from tradingagents.dataflows.errors import NoMarketDataError, VendorRateLimitError
from tradingagents.dataflows.vendors.yahoo import fundamentals, ohlcv
DAY = "2026-09-18"
def _rate_limited(*args, **kwargs):
raise YFRateLimitError()
@pytest.fixture
def yahoo(monkeypatch, tmp_path):
set_config({"data_cache_dir": str(tmp_path)})
monkeypatch.setattr(ohlcv.time, "sleep", lambda seconds: None)
for module in (ohlcv, fundamentals):
monkeypatch.setattr(module, "vendor_reachable", lambda url: True)
return monkeypatch
@pytest.mark.unit
@pytest.mark.parametrize("tool, args", [
pytest.param(get_stock_data, ("AAPL", "2026-09-10", DAY), id="stock_data"),
pytest.param(get_indicators, ("AAPL", "rsi", DAY, 5), id="indicators"),
pytest.param(get_verified_market_snapshot, ("AAPL", DAY), id="snapshot"),
pytest.param(get_news, ("AAPL", "2026-09-10", DAY), id="news"),
pytest.param(get_global_news, (DAY, 7, 5), id="global_news"),
pytest.param(get_insider_transactions, ("AAPL",), id="insider"),
])
def test_a_rate_limit_that_outlasts_the_retries_is_reported_as_one(yahoo, tool, args):
for name in ("history", "get_news"):
yahoo.setattr(yf.Ticker, name, _rate_limited)
yahoo.setattr(yf.Ticker, "insider_transactions", property(_rate_limited))
yahoo.setattr(yf, "Search", _rate_limited)
out = tool.func(*args, trade_date=DAY)
assert out.startswith("DATA_UNAVAILABLE"), out
assert "delisted" not in out
@pytest.mark.unit
def test_the_indicator_path_retries_a_rate_limit(yahoo):
"""The prices behind every indicator were fetched with ``yf.download``, which
returns an empty frame for a 429, so they were never retried."""
bar = pd.DataFrame({"Open": [1.0], "High": [1.0], "Low": [1.0], "Close": [1.0],
"Volume": [100]}, index=pd.DatetimeIndex([DAY], name="Date"))
answers = [YFRateLimitError(), bar]
def history(self, **kwargs):
answer = answers.pop(0)
if isinstance(answer, Exception):
raise answer
return answer
yahoo.setattr(yf.Ticker, "history", history)
assert ohlcv.load_ohlcv("AAPL", DAY)["Close"].tolist() == [1.0]
@pytest.mark.unit
def test_yfinance_raises_the_rate_limit_from_history(yahoo):
yahoo.setattr(YfData, "_make_request", _rate_limited)
with pytest.raises(VendorRateLimitError, match="rate limited"):
ohlcv.load_ohlcv("AAPL", DAY)
@pytest.mark.unit
def test_fundamentals_ask_a_new_ticker_after_a_rate_limit(yahoo):
"""A Ticker keeps a failed ``info`` fetch as done, so asking the same one
again reads an empty profile, which looks like a symbol with no data."""
class Ticker:
def __init__(self, symbol):
self.fetched = False
@property
def info(self):
if self.fetched:
return {}
self.fetched = True
raise YFRateLimitError()
yahoo.setattr(yf, "Ticker", Ticker)
out = router.route_to_vendor("get_fundamentals", "AAPL", None)
assert out.startswith("DATA_UNAVAILABLE"), out
@pytest.mark.unit
def test_another_price_fetch_error_still_tells_an_outage_from_an_unknown_symbol(yahoo):
"""``Ticker.history`` lets some errors through that ``yf.download`` turned
into an empty frame. Whether Yahoo answers still decides which one it is."""
def refused(self, **kwargs):
raise ConnectionError("curl: (7) Failed to connect to query2.finance.yahoo.com")
yahoo.setattr(yf.Ticker, "history", refused)
yahoo.setattr(ohlcv, "vendor_reachable", lambda url: False)
with pytest.raises(VendorRateLimitError, match="unreachable"):
ohlcv.load_ohlcv("AAPL", DAY)
yahoo.setattr(ohlcv, "vendor_reachable", lambda url: True)
with pytest.raises(NoMarketDataError):
ohlcv.load_ohlcv("AAPL", DAY)
+7 -2
View File
@@ -10,7 +10,8 @@ from langchain_core.tools import tool
from langgraph.prebuilt import InjectedState from langgraph.prebuilt import InjectedState
from tradingagents.dataflows.date_window import as_of, as_of_window from tradingagents.dataflows.date_window import as_of, as_of_window
from tradingagents.dataflows.router import route_to_vendor from tradingagents.dataflows.errors import VendorRateLimitError
from tradingagents.dataflows.router import route_to_vendor, vendor_unavailable
from tradingagents.dataflows.vendors.yahoo.snapshot import build_verified_market_snapshot from tradingagents.dataflows.vendors.yahoo.snapshot import build_verified_market_snapshot
@@ -83,7 +84,11 @@ def get_verified_market_snapshot(
price levels, Bollinger bands, RSI, MACD, moving averages, support / price levels, Bollinger bands, RSI, MACD, moving averages, support /
resistance, or historical comparisons, and treat it as the source of truth. resistance, or historical comparisons, and treat it as the source of truth.
""" """
return build_verified_market_snapshot(symbol, as_of(curr_date, trade_date), look_back_days) # An exception out of a tool would end the run.
try:
return build_verified_market_snapshot(symbol, as_of(curr_date, trade_date), look_back_days)
except VendorRateLimitError as exc:
return vendor_unavailable("get_verified_market_snapshot", exc)
@tool @tool
+10 -5
View File
@@ -181,6 +181,15 @@ def get_vendor(category: str, method: str = None) -> str:
return config.get("data_vendors", {}).get(category, "default") return config.get("data_vendors", {}).get(category, "default")
def vendor_unavailable(method: str, error: VendorRateLimitError) -> str:
"""What a call returns when every vendor was throttled or unreachable."""
return (
f"DATA_UNAVAILABLE: no configured vendor could serve {method} right now "
f"({error}). This says nothing about the instrument; report the "
f"data as unavailable and do not estimate or fabricate values."
)
def route_to_vendor(method: str, *args, **kwargs): def route_to_vendor(method: str, *args, **kwargs):
"""Route method calls to appropriate vendor implementation with fallback support.""" """Route method calls to appropriate vendor implementation with fallback support."""
category = get_category_for_method(method) category = get_category_for_method(method)
@@ -273,11 +282,7 @@ def route_to_vendor(method: str, *args, **kwargs):
# Every vendor was throttled or unreachable: that is a fact about the # Every vendor was throttled or unreachable: that is a fact about the
# vendors, not about the instrument, and it must not end the run. # vendors, not about the instrument, and it must not end the run.
if last_unavailable is not None: if last_unavailable is not None:
return ( return vendor_unavailable(method, last_unavailable)
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:
+1 -2
View File
@@ -33,8 +33,7 @@ def get_fundamentals(
return withheld return withheld
try: try:
ticker_obj = yf.Ticker(canonical) info = yf_retry(lambda: yf.Ticker(canonical).info)
info = yf_retry(lambda: ticker_obj.info)
if not info: if not info:
raise_for_empty(ticker, canonical, "fundamentals") raise_for_empty(ticker, canonical, "fundamentals")
+5 -1
View File
@@ -8,7 +8,7 @@ from dateutil.relativedelta import relativedelta
from tradingagents.dataflows.config import get_config from tradingagents.dataflows.config import get_config
from tradingagents.dataflows.date_window import coverage_gap, in_window from tradingagents.dataflows.date_window import coverage_gap, in_window
from tradingagents.dataflows.errors import NoMarketDataError from tradingagents.dataflows.errors import NoMarketDataError, VendorError
from tradingagents.dataflows.symbols import normalize_symbol from tradingagents.dataflows.symbols import normalize_symbol
from tradingagents.dataflows.vendors.yahoo.ohlcv import yf_retry from tradingagents.dataflows.vendors.yahoo.ohlcv import yf_retry
@@ -114,6 +114,8 @@ 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 VendorError:
raise
except Exception as e: except Exception as e:
raise NoMarketDataError(ticker, ticker, f"news unavailable: {e}") from e raise NoMarketDataError(ticker, ticker, f"news unavailable: {e}") from e
@@ -192,5 +194,7 @@ 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 VendorError:
raise
except Exception as e: except Exception as e:
raise NoMarketDataError("global news", "global news", f"unavailable: {e}") from e raise NoMarketDataError("global news", "global news", f"unavailable: {e}") from e
+25 -11
View File
@@ -43,18 +43,24 @@ def yf_retry(func, max_retries=3, base_delay=2.0):
yfinance raises YFRateLimitError on HTTP 429 responses but does not yfinance raises YFRateLimitError on HTTP 429 responses but does not
retry them internally. This wrapper adds retry logic specifically retry them internally. This wrapper adds retry logic specifically
for rate limits. Other exceptions propagate immediately. for rate limits. Other exceptions propagate immediately. A rate limit
that outlasts the retries is raised as VendorRateLimitError, so the
router reports a throttled vendor rather than a symbol with no data.
``func`` should build its own Ticker: a Ticker keeps a failed ``info``
fetch as done, so asking the same one again reads an empty profile.
""" """
for attempt in range(max_retries + 1): for attempt in range(max_retries + 1):
try: try:
return func() return func()
except YFRateLimitError: except YFRateLimitError as exc:
if attempt < max_retries: if attempt < max_retries:
delay = base_delay * (2 ** attempt) delay = base_delay * (2 ** attempt)
logger.warning(f"Yahoo Finance rate limited, retrying in {delay:.0f}s (attempt {attempt + 1}/{max_retries})") logger.warning(f"Yahoo Finance rate limited, retrying in {delay:.0f}s (attempt {attempt + 1}/{max_retries})")
time.sleep(delay) time.sleep(delay)
else: else:
raise raise VendorRateLimitError(
f"Yahoo Finance rate limited after {max_retries} retries: {exc}"
) from exc
def _ensure_date_column(data: pd.DataFrame) -> pd.DataFrame: def _ensure_date_column(data: pd.DataFrame) -> pd.DataFrame:
@@ -240,14 +246,22 @@ def load_ohlcv(symbol: str, curr_date: str, fill_gaps: bool = True) -> pd.DataFr
data = cached data = cached
if data is None: if data is None:
downloaded = yf_retry(lambda: yf.download( # yf.download catches every error, a rate limit included, and returns
canonical, # an empty frame. Ticker.history raises the rate limit, so it is retried.
start=start_str, try:
end=end_str, downloaded = yf_retry(lambda: yf.Ticker(canonical).history(
multi_level_index=False, start=start_str,
progress=False, end=end_str,
auto_adjust=True, auto_adjust=True,
)) actions=False,
))
except VendorRateLimitError:
raise
except Exception as exc:
# Any other failure is an outage or an unknown symbol, which
# raise_for_empty tells apart by whether Yahoo answers at all.
logger.warning("Yahoo Finance price request for %s failed: %s", canonical, exc)
raise_for_empty(symbol, canonical, "price rows")
downloaded = _ensure_date_column(downloaded.reset_index()) downloaded = _ensure_date_column(downloaded.reset_index())
# Only cache real data — never persist an empty frame. # Only cache real data — never persist an empty frame.
if downloaded.empty or "Close" not in downloaded.columns: if downloaded.empty or "Close" not in downloaded.columns: