fix(dataflows): tell an outage from an unknown symbol on every Yahoo path

- prices, indicators and insider filings now use the same check as the statements
This commit is contained in:
Yijia-Xiao
2026-09-18 04:02:57 +00:00
parent 4acdc16513
commit c039e39056
3 changed files with 47 additions and 25 deletions

View File

@@ -195,17 +195,17 @@ def test_an_unreachable_vendor_is_not_reported_as_a_missing_symbol(monkeypatch):
company has no balance sheet, when the truth is we could not ask.""" company has no balance sheet, when the truth is we could not ask."""
import pandas as pd import pandas as pd
from tradingagents.dataflows import y_finance from tradingagents.dataflows import stockstats_utils, y_finance
from tradingagents.dataflows.errors import NoMarketDataError, VendorRateLimitError from tradingagents.dataflows.errors import NoMarketDataError, VendorRateLimitError
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(y_finance.yf, "Ticker", lambda s: empty) monkeypatch.setattr(y_finance.yf, "Ticker", lambda s: empty)
monkeypatch.setattr(y_finance, "vendor_reachable", lambda url: False) monkeypatch.setattr(stockstats_utils, "vendor_reachable", lambda url: False)
with pytest.raises(VendorRateLimitError, match="unreachable"): with pytest.raises(VendorRateLimitError, match="unreachable"):
y_finance.get_balance_sheet("AAPL", "annual", "2026-09-01") y_finance.get_balance_sheet("AAPL", "annual", "2026-09-01")
monkeypatch.setattr(y_finance, "vendor_reachable", lambda url: True) monkeypatch.setattr(stockstats_utils, "vendor_reachable", lambda url: True)
with pytest.raises(NoMarketDataError): with pytest.raises(NoMarketDataError):
y_finance.get_balance_sheet("AAPL", "annual", "2026-09-01") y_finance.get_balance_sheet("AAPL", "annual", "2026-09-01")
@@ -226,3 +226,23 @@ def test_every_vendor_unavailable_says_so_rather_than_crashing(monkeypatch):
assert "unavailable" in out.lower() and "unreachable" in out.lower() assert "unavailable" in out.lower() and "unreachable" in out.lower()
assert "delisted" not in out.lower() # not a claim about the symbol assert "delisted" not in out.lower() # not a claim about the symbol
@pytest.mark.unit
def test_the_price_path_also_tells_an_outage_from_an_unknown_symbol(monkeypatch):
"""Prices are the most-used path, so an outage there must not read as a
delisted symbol either."""
import pandas as pd
from tradingagents.dataflows import stockstats_utils, y_finance
from tradingagents.dataflows.errors import NoMarketDataError, VendorRateLimitError
monkeypatch.setattr(y_finance.yf, "Ticker", lambda s: mock.Mock(history=lambda **k: pd.DataFrame()))
monkeypatch.setattr(stockstats_utils, "vendor_reachable", lambda url: False)
with pytest.raises(VendorRateLimitError, match="unreachable"):
y_finance.get_YFin_data_online("AAPL", "2026-09-01", "2026-09-10")
monkeypatch.setattr(stockstats_utils, "vendor_reachable", lambda url: True)
with pytest.raises(NoMarketDataError):
y_finance.get_YFin_data_online("AAPL", "2026-09-01", "2026-09-10")

View File

@@ -9,11 +9,14 @@ from stockstats import wrap
from yfinance.exceptions import YFRateLimitError from yfinance.exceptions import YFRateLimitError
from .config import get_config from .config import get_config
from .errors import VendorRateLimitError
from .symbol_utils import NoMarketDataError, normalize_symbol from .symbol_utils import NoMarketDataError, normalize_symbol
from .utils import safe_ticker_component from .utils import safe_ticker_component, vendor_reachable
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).
@@ -26,6 +29,17 @@ 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 VendorRateLimitError(f"Yahoo Finance is unreachable; no {what} was retrieved")
raise NoMarketDataError(symbol, canonical, f"no {what}")
def yf_retry(func, max_retries=3, base_delay=2.0): def yf_retry(func, max_retries=3, base_delay=2.0):
"""Execute a yfinance call with exponential backoff on rate limits. """Execute a yfinance call with exponential backoff on rate limits.
@@ -239,9 +253,7 @@ def load_ohlcv(symbol: str, curr_date: str, fill_gaps: bool = True) -> pd.DataFr
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:
raise NoMarketDataError( raise_for_empty(symbol, canonical, "price rows")
symbol, canonical, "Yahoo Finance returned no rows"
)
downloaded.to_csv(data_file, index=False, encoding="utf-8") downloaded.to_csv(data_file, index=False, encoding="utf-8")
data = downloaded data = downloaded

View File

@@ -13,6 +13,7 @@ from .stockstats_utils import (
_assert_ohlcv_not_stale, _assert_ohlcv_not_stale,
filter_financials_by_date, filter_financials_by_date,
load_ohlcv, load_ohlcv,
raise_for_empty,
yf_retry, yf_retry,
) )
from .symbol_utils import NoMarketDataError, normalize_symbol from .symbol_utils import NoMarketDataError, normalize_symbol
@@ -46,9 +47,7 @@ def get_YFin_data_online(
# instead of returning prose: the routing layer turns it into a single # instead of returning prose: the routing layer turns it into a single
# unambiguous "no data" signal so the agent never fabricates a price. # unambiguous "no data" signal so the agent never fabricates a price.
if data.empty: if data.empty:
raise NoMarketDataError( raise_for_empty(symbol, canonical, f"rows between {start_date} and {end_date}")
symbol, canonical, f"no rows between {start_date} and {end_date}"
)
# Remove timezone info from index for cleaner output # Remove timezone info from index for cleaner output
if data.index.tz is not None: if data.index.tz is not None:
@@ -304,7 +303,7 @@ def get_fundamentals(
info = yf_retry(lambda: ticker_obj.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")
fields = [ fields = [
("Name", info.get("longName")), ("Name", info.get("longName")),
@@ -375,7 +374,7 @@ def get_balance_sheet(
data = filter_financials_by_date(data, curr_date) data = filter_financials_by_date(data, curr_date)
if data.empty: if data.empty:
_raise_for_empty(ticker, canonical, "balance sheet data") raise_for_empty(ticker, canonical, "balance sheet data")
# Convert to CSV string for consistency with other functions # Convert to CSV string for consistency with other functions
csv_string = data.to_csv() csv_string = data.to_csv()
@@ -411,7 +410,7 @@ def get_cashflow(
data = filter_financials_by_date(data, curr_date) data = filter_financials_by_date(data, curr_date)
if data.empty: if data.empty:
_raise_for_empty(ticker, canonical, "cash flow data") raise_for_empty(ticker, canonical, "cash flow data")
# Convert to CSV string for consistency with other functions # Convert to CSV string for consistency with other functions
csv_string = data.to_csv() csv_string = data.to_csv()
@@ -447,7 +446,7 @@ def get_income_statement(
data = filter_financials_by_date(data, curr_date) data = filter_financials_by_date(data, curr_date)
if data.empty: if data.empty:
_raise_for_empty(ticker, canonical, "income statement data") raise_for_empty(ticker, canonical, "income statement data")
# Convert to CSV string for consistency with other functions # Convert to CSV string for consistency with other functions
csv_string = data.to_csv() csv_string = data.to_csv()
@@ -487,17 +486,6 @@ _PERIOD_END_VINTAGE = (
) )
def _raise_for_empty(ticker: str, canonical: str, what: str) -> None:
"""Report an empty result as an absence, or as an outage if Yahoo is down.
yfinance returns an empty frame for a failed request rather than raising, so
without this an outage reads as "this company reports no {what}".
"""
if not vendor_reachable(_YAHOO_HOST):
raise VendorRateLimitError(f"Yahoo Finance is unreachable; no {what} was retrieved")
raise NoMarketDataError(ticker, canonical, f"no {what}")
def get_insider_transactions( def get_insider_transactions(
ticker: Annotated[str, "ticker symbol of the company"], ticker: Annotated[str, "ticker symbol of the company"],
curr_date: Annotated[str | None, "only transactions on or before this date, yyyy-mm-dd"] = None, curr_date: Annotated[str | None, "only transactions on or before this date, yyyy-mm-dd"] = None,
@@ -511,6 +499,8 @@ def get_insider_transactions(
# Empty is normal here (many valid symbols have no insider filings), # Empty is normal here (many valid symbols have no insider filings),
# so report it plainly rather than treating the symbol as invalid. # so report it plainly rather than treating the symbol as invalid.
if data is None or data.empty: if data is None or data.empty:
if not vendor_reachable(_YAHOO_HOST):
raise VendorRateLimitError("Yahoo Finance is unreachable; insider filings were not retrieved")
return f"No insider transactions reported for symbol '{canonical}'" return f"No insider transactions reported for symbol '{canonical}'"
if curr_date: if curr_date: