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
row omitted).
"""
from types import SimpleNamespace
import pandas as pd
import pytest
@@ -44,7 +46,7 @@ def test_load_ohlcv_requests_inclusive_end(monkeypatch, tmp_path):
set_config({"data_cache_dir": str(tmp_path)})
captured = {}
def fake_download(symbol, start, end, **kwargs):
def fake_history(start, end, **kwargs):
captured["end"] = end
idx = pd.to_datetime([pd.Timestamp.today().normalize()])
return pd.DataFrame(
@@ -53,7 +55,7 @@ def test_load_ohlcv_requests_inclusive_end(monkeypatch, tmp_path):
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")
ohlcv.load_ohlcv("AAPL", today)
+3 -3
View File
@@ -33,19 +33,19 @@ class TestLoadOhlcvNoPoison(unittest.TestCase):
os.rmdir(self._tmp)
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.
reachable = mock.patch.object(ohlcv, "vendor_reachable", return_value=True)
reachable.start()
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):
ohlcv.load_ohlcv("FAKE", "2026-01-01")
# Nothing should have been written to the cache.
self.assertEqual(os.listdir(self._tmp), [])
# 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):
ohlcv.load_ohlcv("FAKE", "2026-01-01")
self.assertTrue(dl2.called)
+4 -2
View File
@@ -8,6 +8,7 @@ day (#1330).
from __future__ import annotations
import os
from types import SimpleNamespace
import pandas as pd
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):
monkeypatch.setattr(ohlcv, "get_config", lambda: {"data_cache_dir": str(tmp_path)})
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)
@@ -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)})
frame = pd.DataFrame({"Date": pd.to_datetime(["2026-07-16", "2026-07-17"]), "Close": [1.0, 2.0]})
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"):
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
import os
from types import SimpleNamespace
import pandas as pd
import pytest
@@ -105,7 +106,7 @@ def _run_load(monkeypatch, tmp_path, frame, curr_date):
def _fail_download(*a, **k):
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)
@@ -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.write_text(frame.to_csv(index=False))
_stamp(cache, today)
monkeypatch.setattr(ohlcv.yf, "download", lambda *a, **k: (_ for _ in ()).throw(
AssertionError("should read the seeded cache")))
monkeypatch.setattr(ohlcv.yf, "Ticker", lambda symbol: SimpleNamespace(
history=lambda *a, **k: (_ for _ in ()).throw(AssertionError("should read the seeded cache"))))
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)