refactor(yahoo): keep what every Yahoo request shares in yahoo.common

- yf_retry, raise_for_empty and YAHOO_HOST move from yahoo.ohlcv, which the news, fundamentals and market modules imported them from
This commit is contained in:
Yijia-Xiao
2026-09-29 05:57:18 +00:00
parent d7ac9461da
commit ad39133569
9 changed files with 86 additions and 80 deletions
+2 -2
View File
@@ -129,9 +129,9 @@ class TestNoUsableFieldsStillRaises:
# yfinance returns {"trailingPegRatio": None} for unknown symbols; on a
# live run that must stay a hard "no data", not a bare header.
from tradingagents.dataflows.errors import NoMarketDataError
from tradingagents.dataflows.vendors.yahoo import ohlcv
from tradingagents.dataflows.vendors.yahoo import common
monkeypatch.setattr(ohlcv, "vendor_reachable", lambda url: True)
monkeypatch.setattr(common, "vendor_reachable", lambda url: True)
with pytest.raises(NoMarketDataError):
_yf(_TODAY, info={"trailingPegRatio": None})
+2 -2
View File
@@ -17,7 +17,7 @@ import pytest
from tradingagents.dataflows import router
from tradingagents.dataflows.config import set_config
from tradingagents.dataflows.errors import NoMarketDataError
from tradingagents.dataflows.vendors.yahoo import ohlcv
from tradingagents.dataflows.vendors.yahoo import common, ohlcv
@pytest.mark.unit
@@ -35,7 +35,7 @@ class TestLoadOhlcvNoPoison(unittest.TestCase):
def test_empty_download_raises_and_does_not_cache(self):
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 = mock.patch.object(common, "vendor_reachable", return_value=True)
reachable.start()
self.addCleanup(reachable.stop)
with mock.patch.object(ohlcv.yf, "Ticker", return_value=empty), \
+6 -6
View File
@@ -200,16 +200,16 @@ def test_an_unreachable_vendor_is_not_reported_as_a_missing_symbol(monkeypatch):
import pandas as pd
from tradingagents.dataflows.errors import NoMarketDataError, VendorUnavailableError
from tradingagents.dataflows.vendors.yahoo import ohlcv
from tradingagents.dataflows.vendors.yahoo import common
empty = mock.Mock(quarterly_balance_sheet=pd.DataFrame(), balance_sheet=pd.DataFrame())
monkeypatch.setattr(yahoo_market.yf, "Ticker", lambda s: empty)
monkeypatch.setattr(ohlcv, "vendor_reachable", lambda url: False)
monkeypatch.setattr(common, "vendor_reachable", lambda url: False)
with pytest.raises(VendorUnavailableError, match="unreachable"):
yahoo_fundamentals.get_balance_sheet("AAPL", "annual", get_current_date())
monkeypatch.setattr(ohlcv, "vendor_reachable", lambda url: True)
monkeypatch.setattr(common, "vendor_reachable", lambda url: True)
with pytest.raises(NoMarketDataError):
yahoo_fundamentals.get_balance_sheet("AAPL", "annual", get_current_date())
@@ -241,15 +241,15 @@ def test_the_price_path_also_tells_an_outage_from_an_unknown_symbol(monkeypatch)
import pandas as pd
from tradingagents.dataflows.errors import NoMarketDataError, VendorUnavailableError
from tradingagents.dataflows.vendors.yahoo import ohlcv
from tradingagents.dataflows.vendors.yahoo import common
monkeypatch.setattr(yahoo_market.yf, "Ticker", lambda s: mock.Mock(history=lambda **k: pd.DataFrame()))
monkeypatch.setattr(ohlcv, "vendor_reachable", lambda url: False)
monkeypatch.setattr(common, "vendor_reachable", lambda url: False)
with pytest.raises(VendorUnavailableError, match="unreachable"):
yahoo_market.get_YFin_data_online("AAPL", "2026-09-01", "2026-09-10")
monkeypatch.setattr(ohlcv, "vendor_reachable", lambda url: True)
monkeypatch.setattr(common, "vendor_reachable", lambda url: True)
with pytest.raises(NoMarketDataError):
yahoo_market.get_YFin_data_online("AAPL", "2026-09-01", "2026-09-10")
+5 -5
View File
@@ -22,7 +22,7 @@ from tradingagents.agents.tools import (
from tradingagents.dataflows import router
from tradingagents.dataflows.config import set_config
from tradingagents.dataflows.errors import VendorUnavailableError
from tradingagents.dataflows.vendors.yahoo import fundamentals, ohlcv
from tradingagents.dataflows.vendors.yahoo import common, fundamentals, ohlcv
DAY = "2026-09-18"
@@ -34,8 +34,8 @@ def _rate_limited(*args, **kwargs):
@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(common.time, "sleep", lambda seconds: None)
for module in (common, fundamentals):
monkeypatch.setattr(module, "vendor_reachable", lambda url: True)
return monkeypatch
@@ -121,7 +121,7 @@ def test_a_price_request_that_raises_is_unavailable_whether_or_not_yahoo_answers
yahoo.setattr(yf.Ticker, "history", refused)
for reachable in (False, True):
yahoo.setattr(ohlcv, "vendor_reachable", lambda url, _r=reachable: _r)
yahoo.setattr(common, "vendor_reachable", lambda url, _r=reachable: _r)
with pytest.raises(VendorUnavailableError, match="request failed"):
ohlcv.load_ohlcv("AAPL", DAY)
@@ -134,7 +134,7 @@ def test_a_price_request_that_fails_is_unavailable_even_when_yahoo_answers(monke
monkeypatch.setattr(ohlcv, "get_config", lambda: {"data_cache_dir": str(tmp_path)})
monkeypatch.setattr(ohlcv.yf, "Ticker", lambda s: type("T", (), {"history": lambda self, **k: timed_out(**k)})())
monkeypatch.setattr(ohlcv, "vendor_reachable", lambda url: True)
monkeypatch.setattr(common, "vendor_reachable", lambda url: True)
with pytest.raises(VendorUnavailableError):
ohlcv.load_ohlcv("NVDA", "2026-09-23")
+65
View File
@@ -0,0 +1,65 @@
"""What every Yahoo request shares: its retry, and how a failure or an empty answer is reported."""
import logging
import time
import yfinance as yf
from yfinance.exceptions import YFRateLimitError
from tradingagents.dataflows.errors import NoMarketDataError, VendorUnavailableError
from tradingagents.dataflows.net import vendor_reachable
logger = logging.getLogger(__name__)
YAHOO_HOST = "https://query2.finance.yahoo.com"
def raise_for_empty(symbol: str, canonical: str, what: str) -> None:
"""Report an empty Yahoo answer as an absence, or as an outage if it is one.
An empty answer from a Yahoo that cannot be reached is not an answer about
the symbol, so the host is probed before "this symbol has no {what}" is said.
"""
if not vendor_reachable(YAHOO_HOST):
raise VendorUnavailableError(f"Yahoo Finance is unreachable; no {what} was retrieved")
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: 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.
"""
for attempt in range(max_retries + 1):
try:
return func()
except YFRateLimitError as exc:
if attempt < max_retries:
delay = base_delay * (2 ** attempt)
logger.warning(f"Yahoo Finance rate limited, retrying in {delay:.0f}s (attempt {attempt + 1}/{max_retries})")
time.sleep(delay)
else:
raise VendorUnavailableError(
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
+1 -1
View File
@@ -7,7 +7,7 @@ from tradingagents.dataflows.date_window import withhold_live_profile, withhold_
from tradingagents.dataflows.errors import VendorUnavailableError
from tradingagents.dataflows.net import vendor_reachable
from tradingagents.dataflows.symbols import normalize_symbol
from tradingagents.dataflows.vendors.yahoo.ohlcv import (
from tradingagents.dataflows.vendors.yahoo.common import (
YAHOO_HOST,
raise_for_empty,
yf_retry,
+2 -6
View File
@@ -9,12 +9,8 @@ from stockstats import wrap
from tradingagents.dataflows.errors import NoMarketDataError, VendorError
from tradingagents.dataflows.symbols import normalize_symbol
from tradingagents.dataflows.vendors.yahoo.ohlcv import (
_assert_ohlcv_not_stale,
load_ohlcv,
raise_for_empty,
yf_retry,
)
from tradingagents.dataflows.vendors.yahoo.common import raise_for_empty, yf_retry
from tradingagents.dataflows.vendors.yahoo.ohlcv import _assert_ohlcv_not_stale, load_ohlcv
logger = logging.getLogger(__name__)
+1 -1
View File
@@ -9,7 +9,7 @@ from dateutil.relativedelta import relativedelta
from tradingagents.dataflows.config import get_config
from tradingagents.dataflows.date_window import coverage_gap, in_window
from tradingagents.dataflows.symbols import normalize_symbol
from tradingagents.dataflows.vendors.yahoo.ohlcv import yf_retry
from tradingagents.dataflows.vendors.yahoo.common import yf_retry
def _extract_article_data(article: dict) -> dict:
+2 -57
View File
@@ -1,21 +1,17 @@
import logging
import os
import time
import pandas as pd
import yfinance as yf
from yfinance.exceptions import YFRateLimitError
from tradingagents.dataflows.config import get_config
from tradingagents.dataflows.errors import NoMarketDataError, VendorUnavailableError
from tradingagents.dataflows.errors import NoMarketDataError
from tradingagents.dataflows.files import replace_file
from tradingagents.dataflows.net import vendor_reachable
from tradingagents.dataflows.symbols import normalize_symbol, safe_ticker_component
from tradingagents.dataflows.vendors.yahoo.common import raise_for_empty, yf_retry
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).
@@ -28,57 +24,6 @@ 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 VendorUnavailableError(f"Yahoo Finance is unreachable; no {what} was retrieved")
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: 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.
"""
for attempt in range(max_retries + 1):
try:
return func()
except YFRateLimitError as exc:
if attempt < max_retries:
delay = base_delay * (2 ** attempt)
logger.warning(f"Yahoo Finance rate limited, retrying in {delay:.0f}s (attempt {attempt + 1}/{max_retries})")
time.sleep(delay)
else:
raise VendorUnavailableError(
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
def _ensure_date_column(data: pd.DataFrame) -> pd.DataFrame:
"""Normalize the date column to ``Date``.