mirror of
https://github.com/TauricResearch/TradingAgents.git
synced 2026-11-03 16:12:44 +02:00
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:
@@ -129,9 +129,9 @@ class TestNoUsableFieldsStillRaises:
|
|||||||
# yfinance returns {"trailingPegRatio": None} for unknown symbols; on a
|
# yfinance returns {"trailingPegRatio": None} for unknown symbols; on a
|
||||||
# live run that must stay a hard "no data", not a bare header.
|
# live run that must stay a hard "no data", not a bare header.
|
||||||
from tradingagents.dataflows.errors import NoMarketDataError
|
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):
|
with pytest.raises(NoMarketDataError):
|
||||||
_yf(_TODAY, info={"trailingPegRatio": None})
|
_yf(_TODAY, info={"trailingPegRatio": None})
|
||||||
|
|||||||
@@ -17,7 +17,7 @@ import pytest
|
|||||||
from tradingagents.dataflows import router
|
from tradingagents.dataflows import router
|
||||||
from tradingagents.dataflows.config import set_config
|
from tradingagents.dataflows.config import set_config
|
||||||
from tradingagents.dataflows.errors import NoMarketDataError
|
from tradingagents.dataflows.errors import NoMarketDataError
|
||||||
from tradingagents.dataflows.vendors.yahoo import ohlcv
|
from tradingagents.dataflows.vendors.yahoo import common, ohlcv
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.unit
|
@pytest.mark.unit
|
||||||
@@ -35,7 +35,7 @@ class TestLoadOhlcvNoPoison(unittest.TestCase):
|
|||||||
def test_empty_download_raises_and_does_not_cache(self):
|
def test_empty_download_raises_and_does_not_cache(self):
|
||||||
empty = mock.Mock(history=mock.Mock(return_value=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(common, "vendor_reachable", return_value=True)
|
||||||
reachable.start()
|
reachable.start()
|
||||||
self.addCleanup(reachable.stop)
|
self.addCleanup(reachable.stop)
|
||||||
with mock.patch.object(ohlcv.yf, "Ticker", return_value=empty), \
|
with mock.patch.object(ohlcv.yf, "Ticker", return_value=empty), \
|
||||||
|
|||||||
@@ -200,16 +200,16 @@ def test_an_unreachable_vendor_is_not_reported_as_a_missing_symbol(monkeypatch):
|
|||||||
import pandas as pd
|
import pandas as pd
|
||||||
|
|
||||||
from tradingagents.dataflows.errors import NoMarketDataError, VendorUnavailableError
|
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())
|
empty = mock.Mock(quarterly_balance_sheet=pd.DataFrame(), balance_sheet=pd.DataFrame())
|
||||||
monkeypatch.setattr(yahoo_market.yf, "Ticker", lambda s: empty)
|
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"):
|
with pytest.raises(VendorUnavailableError, match="unreachable"):
|
||||||
yahoo_fundamentals.get_balance_sheet("AAPL", "annual", get_current_date())
|
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):
|
with pytest.raises(NoMarketDataError):
|
||||||
yahoo_fundamentals.get_balance_sheet("AAPL", "annual", get_current_date())
|
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
|
import pandas as pd
|
||||||
|
|
||||||
from tradingagents.dataflows.errors import NoMarketDataError, VendorUnavailableError
|
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(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"):
|
with pytest.raises(VendorUnavailableError, match="unreachable"):
|
||||||
yahoo_market.get_YFin_data_online("AAPL", "2026-09-01", "2026-09-10")
|
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):
|
with pytest.raises(NoMarketDataError):
|
||||||
yahoo_market.get_YFin_data_online("AAPL", "2026-09-01", "2026-09-10")
|
yahoo_market.get_YFin_data_online("AAPL", "2026-09-01", "2026-09-10")
|
||||||
|
|
||||||
|
|||||||
@@ -22,7 +22,7 @@ from tradingagents.agents.tools import (
|
|||||||
from tradingagents.dataflows import router
|
from tradingagents.dataflows import router
|
||||||
from tradingagents.dataflows.config import set_config
|
from tradingagents.dataflows.config import set_config
|
||||||
from tradingagents.dataflows.errors import VendorUnavailableError
|
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"
|
DAY = "2026-09-18"
|
||||||
|
|
||||||
@@ -34,8 +34,8 @@ def _rate_limited(*args, **kwargs):
|
|||||||
@pytest.fixture
|
@pytest.fixture
|
||||||
def yahoo(monkeypatch, tmp_path):
|
def yahoo(monkeypatch, tmp_path):
|
||||||
set_config({"data_cache_dir": str(tmp_path)})
|
set_config({"data_cache_dir": str(tmp_path)})
|
||||||
monkeypatch.setattr(ohlcv.time, "sleep", lambda seconds: None)
|
monkeypatch.setattr(common.time, "sleep", lambda seconds: None)
|
||||||
for module in (ohlcv, fundamentals):
|
for module in (common, fundamentals):
|
||||||
monkeypatch.setattr(module, "vendor_reachable", lambda url: True)
|
monkeypatch.setattr(module, "vendor_reachable", lambda url: True)
|
||||||
return monkeypatch
|
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)
|
yahoo.setattr(yf.Ticker, "history", refused)
|
||||||
for reachable in (False, True):
|
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"):
|
with pytest.raises(VendorUnavailableError, match="request failed"):
|
||||||
ohlcv.load_ohlcv("AAPL", DAY)
|
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, "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.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):
|
with pytest.raises(VendorUnavailableError):
|
||||||
ohlcv.load_ohlcv("NVDA", "2026-09-23")
|
ohlcv.load_ohlcv("NVDA", "2026-09-23")
|
||||||
|
|||||||
+65
@@ -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
@@ -7,7 +7,7 @@ from tradingagents.dataflows.date_window import withhold_live_profile, withhold_
|
|||||||
from tradingagents.dataflows.errors import VendorUnavailableError
|
from tradingagents.dataflows.errors import VendorUnavailableError
|
||||||
from tradingagents.dataflows.net import vendor_reachable
|
from tradingagents.dataflows.net import vendor_reachable
|
||||||
from tradingagents.dataflows.symbols import normalize_symbol
|
from tradingagents.dataflows.symbols import normalize_symbol
|
||||||
from tradingagents.dataflows.vendors.yahoo.ohlcv import (
|
from tradingagents.dataflows.vendors.yahoo.common import (
|
||||||
YAHOO_HOST,
|
YAHOO_HOST,
|
||||||
raise_for_empty,
|
raise_for_empty,
|
||||||
yf_retry,
|
yf_retry,
|
||||||
|
|||||||
+2
-6
@@ -9,12 +9,8 @@ from stockstats import wrap
|
|||||||
|
|
||||||
from tradingagents.dataflows.errors import NoMarketDataError, VendorError
|
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 (
|
from tradingagents.dataflows.vendors.yahoo.common import raise_for_empty, yf_retry
|
||||||
_assert_ohlcv_not_stale,
|
from tradingagents.dataflows.vendors.yahoo.ohlcv import _assert_ohlcv_not_stale, load_ohlcv
|
||||||
load_ohlcv,
|
|
||||||
raise_for_empty,
|
|
||||||
yf_retry,
|
|
||||||
)
|
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|||||||
+1
-1
@@ -9,7 +9,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.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.common import yf_retry
|
||||||
|
|
||||||
|
|
||||||
def _extract_article_data(article: dict) -> dict:
|
def _extract_article_data(article: dict) -> dict:
|
||||||
|
|||||||
+2
-57
@@ -1,21 +1,17 @@
|
|||||||
import logging
|
import logging
|
||||||
import os
|
import os
|
||||||
import time
|
|
||||||
|
|
||||||
import pandas as pd
|
import pandas as pd
|
||||||
import yfinance as yf
|
import yfinance as yf
|
||||||
from yfinance.exceptions import YFRateLimitError
|
|
||||||
|
|
||||||
from tradingagents.dataflows.config import get_config
|
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.files import replace_file
|
||||||
from tradingagents.dataflows.net import vendor_reachable
|
|
||||||
from tradingagents.dataflows.symbols import normalize_symbol, safe_ticker_component
|
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__)
|
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
|
# 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
|
# is treated as stale. Generous enough to span long holiday weekends, tight
|
||||||
# enough to catch the year-old frames yfinance occasionally returns (#1021).
|
# 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
|
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:
|
def _ensure_date_column(data: pd.DataFrame) -> pd.DataFrame:
|
||||||
"""Normalize the date column to ``Date``.
|
"""Normalize the date column to ``Date``.
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user