fix(dataflows): report a vendor failure as a vendor failure

- yfinance returned its errors as text, which the router counted as an answer, so the chain stopped and the text reached the analyst
- an empty result is checked against the vendor being reachable, so an outage is not reported as a company with no data
- a chain where every vendor is unavailable says so instead of ending the run
This commit is contained in:
Yijia-Xiao
2026-09-18 01:49:52 +00:00
parent 10cc070fa3
commit d5ba41bac3
5 changed files with 138 additions and 19 deletions
+15 -2
View File
@@ -202,6 +202,7 @@ def route_to_vendor(method: str, *args, **kwargs):
vendor_chain = all_available_vendors
last_no_data: NoMarketDataError | None = None
last_unavailable: VendorRateLimitError | None = None
first_error: Exception | None = None
for vendor in vendor_chain:
vendor_impl = VENDOR_METHODS[method][vendor]
@@ -209,8 +210,11 @@ def route_to_vendor(method: str, *args, **kwargs):
try:
return impl_func(*args, **kwargs)
except VendorRateLimitError:
logger.warning("Vendor %r rate-limited for %s; trying next vendor.", vendor, method)
except VendorRateLimitError as e:
logger.warning("Vendor %r unavailable for %s: %s; trying next vendor.", vendor, method, e)
# Kept so an all-unavailable chain can say the vendor was the
# problem, rather than reporting nothing about the symbol.
last_unavailable = e
continue
except VendorNotConfiguredError as e:
logger.warning("Vendor %r not configured for %s; trying next vendor.", vendor, method)
@@ -259,6 +263,15 @@ def route_to_vendor(method: str, *args, **kwargs):
# first real error (e.g. the primary vendor's network failure). Optional
# enrichment categories degrade to a sentinel instead, so flavour data can't
# abort the run.
# Every vendor was throttled or unreachable: that is a fact about the
# vendors, not about the instrument, and it must not end the run.
if last_unavailable is not None:
return (
f"DATA_UNAVAILABLE: no configured vendor could serve {method} right now "
f"({last_unavailable}). This says nothing about the instrument; report the "
f"data as unavailable and do not estimate or fabricate values."
)
if first_error is not None:
if category in OPTIONAL_CATEGORIES:
logger.warning("Optional %s unavailable for %s: %s", category, method, first_error)
+13
View File
@@ -62,3 +62,16 @@ def get_scrubbed(url: str, *, params: dict, timeout: float, secret: str, passthr
except requests.RequestException as exc:
error = type(exc)(str(exc).replace(secret, "***")) if secret else exc
raise error
def vendor_reachable(url: str, timeout: float = 5.0) -> bool:
"""Whether the vendor answers at all, for telling silence from an outage.
A client that returns an empty result instead of raising leaves those two
cases indistinguishable. Called only when a result is empty.
"""
try:
requests.head(url, timeout=timeout, allow_redirects=True)
return True
except requests.RequestException:
return False
+30 -15
View File
@@ -7,6 +7,7 @@ import yfinance as yf
from dateutil.relativedelta import relativedelta
from .date_window import withhold_live_profile
from .errors import VendorError, VendorRateLimitError
from .stockstats_utils import (
StockstatsUtils,
_assert_ohlcv_not_stale,
@@ -15,6 +16,9 @@ from .stockstats_utils import (
yf_retry,
)
from .symbol_utils import NoMarketDataError, normalize_symbol
from .utils import vendor_reachable
_YAHOO_HOST = "https://query2.finance.yahoo.com"
logger = logging.getLogger(__name__)
@@ -189,7 +193,7 @@ def get_stock_stats_indicators_window(
for date_str, value in date_values:
ind_string += f"{date_str}: {value}\n"
except NoMarketDataError:
except VendorError:
raise # Unknown/delisted symbol — let the router emit the sentinel
except Exception as e:
logger.warning("Bulk stockstats fetch failed, falling back per-day: %s", e)
@@ -264,7 +268,7 @@ def get_stockstats_indicator(
indicator,
curr_date,
)
except NoMarketDataError:
except VendorError:
raise # Unknown/delisted symbol — let the router emit the sentinel
except Exception as e:
# An empty string renders as "2026-05-08: " in the indicator table, which
@@ -300,7 +304,7 @@ def get_fundamentals(
info = yf_retry(lambda: ticker_obj.info)
if not info:
raise NoMarketDataError(ticker, canonical, "no fundamentals returned")
_raise_for_empty(ticker, canonical, "fundamentals")
fields = [
("Name", info.get("longName")),
@@ -347,10 +351,10 @@ def get_fundamentals(
return header + "\n".join(lines)
except NoMarketDataError:
except VendorError:
raise
except Exception as e:
return f"Error retrieving fundamentals for {ticker}: {str(e)}"
raise NoMarketDataError(ticker, canonical, f"fundamentals unavailable: {e}") from e
def get_balance_sheet(
@@ -371,7 +375,7 @@ def get_balance_sheet(
data = filter_financials_by_date(data, curr_date)
if data.empty:
raise NoMarketDataError(ticker, canonical, "no 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()
@@ -383,10 +387,10 @@ def get_balance_sheet(
return header + csv_string
except NoMarketDataError:
except VendorError:
raise
except Exception as e:
return f"Error retrieving balance sheet for {ticker}: {str(e)}"
raise NoMarketDataError(ticker, canonical, f"balance sheet unavailable: {e}") from e
def get_cashflow(
@@ -407,7 +411,7 @@ def get_cashflow(
data = filter_financials_by_date(data, curr_date)
if data.empty:
raise NoMarketDataError(ticker, canonical, "no 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()
@@ -419,10 +423,10 @@ def get_cashflow(
return header + csv_string
except NoMarketDataError:
except VendorError:
raise
except Exception as e:
return f"Error retrieving cash flow for {ticker}: {str(e)}"
raise NoMarketDataError(ticker, canonical, f"cash flow unavailable: {e}") from e
def get_income_statement(
@@ -443,7 +447,7 @@ def get_income_statement(
data = filter_financials_by_date(data, curr_date)
if data.empty:
raise NoMarketDataError(ticker, canonical, "no 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()
@@ -455,10 +459,10 @@ def get_income_statement(
return header + csv_string
except NoMarketDataError:
except VendorError:
raise
except Exception as e:
return f"Error retrieving income statement for {ticker}: {str(e)}"
raise NoMarketDataError(ticker, canonical, f"income statement unavailable: {e}") from e
# Rows are dated by the transaction, which is when the insider traded, not when
@@ -483,6 +487,17 @@ _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,
@@ -519,4 +534,4 @@ def get_insider_transactions(
return header + csv_string
except Exception as e:
return f"Error retrieving insider transactions for {ticker}: {str(e)}"
raise NoMarketDataError(ticker, canonical, f"insider transactions unavailable: {e}") from e
+3 -2
View File
@@ -8,6 +8,7 @@ from dateutil.relativedelta import relativedelta
from .config import get_config
from .date_window import coverage_gap, in_window
from .errors import NoMarketDataError
from .stockstats_utils import yf_retry
from .symbol_utils import normalize_symbol
@@ -118,7 +119,7 @@ def get_news_yfinance(
return f"## {ticker}{resolved} News, from {start_date} to {end_date}:\n\n{news_str}"
except Exception as e:
return f"Error fetching news for {ticker}: {str(e)}"
raise NoMarketDataError(ticker, ticker, f"news unavailable: {e}") from e
def get_global_news_yfinance(
@@ -196,4 +197,4 @@ def get_global_news_yfinance(
return f"## Global Market News, from {start_date} to {curr_date}:\n\n{news_str}"
except Exception as e:
return f"Error fetching global news: {str(e)}"
raise NoMarketDataError("global news", "global news", f"unavailable: {e}") from e