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."""
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
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, "vendor_reachable", lambda url: False)
monkeypatch.setattr(stockstats_utils, "vendor_reachable", lambda url: False)
with pytest.raises(VendorRateLimitError, match="unreachable"):
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):
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 "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 .config import get_config
from .errors import VendorRateLimitError
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__)
_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).
@@ -26,6 +29,17 @@ 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 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):
"""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())
# Only cache real data — never persist an empty frame.
if downloaded.empty or "Close" not in downloaded.columns:
raise NoMarketDataError(
symbol, canonical, "Yahoo Finance returned no rows"
)
raise_for_empty(symbol, canonical, "price rows")
downloaded.to_csv(data_file, index=False, encoding="utf-8")
data = downloaded

View File

@@ -13,6 +13,7 @@ from .stockstats_utils import (
_assert_ohlcv_not_stale,
filter_financials_by_date,
load_ohlcv,
raise_for_empty,
yf_retry,
)
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
# unambiguous "no data" signal so the agent never fabricates a price.
if data.empty:
raise NoMarketDataError(
symbol, canonical, f"no rows between {start_date} and {end_date}"
)
raise_for_empty(symbol, canonical, f"rows between {start_date} and {end_date}")
# Remove timezone info from index for cleaner output
if data.index.tz is not None:
@@ -304,7 +303,7 @@ def get_fundamentals(
info = yf_retry(lambda: ticker_obj.info)
if not info:
_raise_for_empty(ticker, canonical, "fundamentals")
raise_for_empty(ticker, canonical, "fundamentals")
fields = [
("Name", info.get("longName")),
@@ -375,7 +374,7 @@ def get_balance_sheet(
data = filter_financials_by_date(data, curr_date)
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
csv_string = data.to_csv()
@@ -411,7 +410,7 @@ def get_cashflow(
data = filter_financials_by_date(data, curr_date)
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
csv_string = data.to_csv()
@@ -447,7 +446,7 @@ def get_income_statement(
data = filter_financials_by_date(data, curr_date)
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
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(
ticker: Annotated[str, "ticker symbol of the company"],
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),
# so report it plainly rather than treating the symbol as invalid.
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}'"
if curr_date: