fix(yahoo): read a symbol's news from Yahoo search when its quote feed is empty (#1467, #1469)

- an empty quote feed falls back to the search articles tagged with the symbol
- when search has no article tagged with the symbol, or neither source answers, Yahoo news is unavailable, so a configured next vendor serves the call
- articles from search never make an empty window read as no news
This commit is contained in:
Yijia-Xiao
2026-10-03 21:27:43 +00:00
parent 11dab3b0fe
commit 442907897e
3 changed files with 210 additions and 51 deletions
+138 -6
View File
@@ -106,7 +106,11 @@ def test_global_news_empty_after_filter_is_informative(monkeypatch):
assert "unavailable" in out and "not an absence" in out assert "unavailable" in out and "not an absence" in out
def _ticker_with(articles, monkeypatch): def _ticker_with(articles, monkeypatch, search_news=(), search_quotes=("AAPL",)):
"""A quote feed answering ``articles``, and a search answering the rest.
By default the search knows the symbol but has no articles, i.e. Yahoo answered.
"""
class FakeTicker: class FakeTicker:
def __init__(self, *a, **k): def __init__(self, *a, **k):
pass pass
@@ -114,7 +118,17 @@ def _ticker_with(articles, monkeypatch):
def get_news(self, count=20): def get_news(self, count=20):
return articles return articles
searched = []
class FakeSearch:
def __init__(self, query, **k):
searched.append(query)
self.news = list(search_news)
self.quotes = [{"symbol": s} for s in search_quotes]
monkeypatch.setattr(ynews.yf, "Ticker", FakeTicker) monkeypatch.setattr(ynews.yf, "Ticker", FakeTicker)
monkeypatch.setattr(ynews.yf, "Search", FakeSearch)
return searched
@pytest.mark.unit @pytest.mark.unit
@@ -163,17 +177,135 @@ def test_coverage_gap_boundaries(dates, expect_gap):
@pytest.mark.unit @pytest.mark.unit
def test_ticker_news_empty_feed_for_a_past_window_is_unavailable(monkeypatch): def test_ticker_news_empty_feed_for_a_past_window_is_unavailable(monkeypatch):
from tradingagents.dataflows.errors import VendorUnavailableError
_ticker_with([], monkeypatch) _ticker_with([], monkeypatch)
out = ynews.get_news_yfinance("AAPL", "2026-08-07", "2026-08-14") with pytest.raises(VendorUnavailableError):
assert "unavailable" in out and "not an absence" in out ynews.get_news_yfinance("AAPL", "2026-08-07", "2026-08-14")
@pytest.mark.unit @pytest.mark.unit
def test_ticker_news_null_feed_is_handled(monkeypatch): def test_ticker_news_null_feed_is_handled(monkeypatch):
# Yahoo can return None instead of a list; that is unavailability, not an error. # Yahoo can return None instead of a list; that is unavailability, not a crash.
from tradingagents.dataflows.errors import VendorUnavailableError
_ticker_with(None, monkeypatch) _ticker_with(None, monkeypatch)
out = ynews.get_news_yfinance("AAPL", "2026-08-07", "2026-08-14") with pytest.raises(VendorUnavailableError):
assert "unavailable" in out and "Error" not in out ynews.get_news_yfinance("AAPL", "2026-08-07", "2026-08-14")
def _tagged(title, day, *tickers):
return {"title": title, "publisher": "P", "link": "l",
"providerPublishTime": _epoch(day), "relatedTickers": list(tickers)}
@pytest.mark.unit
def test_an_empty_quote_feed_falls_back_to_the_articles_search_tags_with_the_symbol(monkeypatch):
today = datetime.now(UTC).strftime("%Y-%m-%d")
searched = _ticker_with([], monkeypatch, search_news=[
_tagged("ABOUT AAPL", today, "AAPL", "MSFT"),
_tagged("ABOUT GIS", today, "GIS"),
])
out = ynews.get_news_yfinance("AAPL", today, today)
assert searched == ["AAPL"]
assert "ABOUT AAPL" in out and "ABOUT GIS" not in out
@pytest.mark.unit
def test_a_quote_feed_with_articles_is_not_searched(monkeypatch):
today = datetime.now(UTC).strftime("%Y-%m-%d")
searched = _ticker_with([_tagged("FEED", today)], monkeypatch)
out = ynews.get_news_yfinance("AAPL", today, today)
assert "FEED" in out and searched == []
@pytest.mark.unit
def test_news_neither_yahoo_source_answers_is_a_vendor_outage(monkeypatch):
from tradingagents.dataflows.errors import VendorUnavailableError
today = datetime.now(UTC).strftime("%Y-%m-%d")
_ticker_with([], monkeypatch, search_quotes=())
with pytest.raises(VendorUnavailableError):
ynews.get_news_yfinance("AAPL", today, today)
@pytest.mark.unit
def test_a_yahoo_news_outage_moves_to_the_next_configured_vendor(monkeypatch):
from unittest.mock import patch
from tradingagents.dataflows import router
from tradingagents.dataflows.config import get_config, set_config
_ticker_with([], monkeypatch, search_quotes=())
calls = []
def other_vendor(*a, **k):
calls.append(a)
return "OTHER VENDOR NEWS"
saved = dict(get_config()["data_vendors"])
set_config({"data_vendors": {**saved, "news_data": "yfinance,alpha_vantage"}})
try:
with patch.dict(router.VENDOR_METHODS, {"get_news": {
"yfinance": ynews.get_news_yfinance, "alpha_vantage": other_vendor,
}}):
out = router.route_to_vendor("get_news", "AAPL", "2026-10-01", "2026-10-02")
finally:
set_config({"data_vendors": saved})
assert out == "OTHER VENDOR NEWS" and len(calls) == 1
@pytest.mark.unit
def test_search_results_never_count_as_an_absence_of_news(monkeypatch):
# Search returns a sparse, relevance-picked sample: nothing in a window it
# reaches is still no proof that no news was published.
today = datetime.now(UTC).strftime("%Y-%m-%d")
_ticker_with([], monkeypatch, search_news=[
_tagged("NEWER", today, "AAPL"), _tagged("OLDER", "2026-01-05", "AAPL"),
])
out = ynews.get_news_yfinance("AAPL", "2026-03-01", "2026-03-08")
assert "No news found" not in out
assert "unavailable" in out and "not an absence" in out
assert "NEWER" not in out and "OLDER" not in out
@pytest.mark.unit
def test_a_symbol_yahoo_has_no_articles_for_is_left_to_the_next_vendor(monkeypatch):
# With its quote feed down, Yahoo serving no article about a symbol it knows
# (every non-US listing today) says nothing about the company's news.
from tradingagents.dataflows.errors import VendorUnavailableError
today = datetime.now(UTC).strftime("%Y-%m-%d")
_ticker_with([], monkeypatch, search_quotes=("SHEL.L",),
search_news=[_tagged("ABOUT SHEL", today, "SHEL")])
with pytest.raises(VendorUnavailableError, match="SHEL.L"):
ynews.get_news_yfinance("SHEL.L", today, today)
@pytest.mark.unit
def test_search_tags_are_matched_whatever_their_case(monkeypatch):
today = datetime.now(UTC).strftime("%Y-%m-%d")
_ticker_with([], monkeypatch, search_news=[_tagged("ABOUT AAPL", today, "aapl")])
assert "ABOUT AAPL" in ynews.get_news_yfinance("AAPL", today, today)
@pytest.mark.unit
def test_a_search_with_articles_has_answered_even_without_listing_the_symbol(monkeypatch):
today = datetime.now(UTC).strftime("%Y-%m-%d")
_ticker_with([], monkeypatch, search_quotes=(),
search_news=[_tagged("ABOUT AAPL", today, "AAPL")])
assert "ABOUT AAPL" in ynews.get_news_yfinance("AAPL", today, today)
@pytest.mark.unit
def test_a_search_yahoo_answers_with_not_found_is_an_outage(monkeypatch):
from tradingagents.dataflows.errors import VendorUnavailableError
_ticker_with([], monkeypatch)
monkeypatch.setattr(ynews, "yf_retry", lambda fn: [] if "get_news" in repr(fn.__code__.co_names) else None)
with pytest.raises(VendorUnavailableError):
ynews.get_news_yfinance("AAPL", "2026-10-01", "2026-10-02")
@pytest.mark.unit @pytest.mark.unit
+9
View File
@@ -67,11 +67,20 @@ def test_news_lookup_normalizes_symbol(monkeypatch):
def get_news(self, count): def get_news(self, count):
return [] return []
class FakeSearch:
def __init__(self, query, **k):
seen["searched"] = query
self.news = [{"title": "Gold", "publisher": "P", "link": "l",
"providerPublishTime": 1736150400, "relatedTickers": ["GC=F"]}]
self.quotes = [{"symbol": "GC=F"}]
monkeypatch.setattr(ynews.yf, "Ticker", FakeTicker) monkeypatch.setattr(ynews.yf, "Ticker", FakeTicker)
monkeypatch.setattr(ynews.yf, "Search", FakeSearch)
monkeypatch.setattr(ynews, "yf_retry", lambda fn: fn()) monkeypatch.setattr(ynews, "yf_retry", lambda fn: fn())
out = ynews.get_news_yfinance("XAUUSD", "2025-01-01", "2025-01-10") out = ynews.get_news_yfinance("XAUUSD", "2025-01-01", "2025-01-10")
assert seen["symbol"] == "GC=F" # news queried with the canonical symbol assert seen["symbol"] == "GC=F" # news queried with the canonical symbol
assert seen["searched"] == "GC=F"
assert "XAUUSD" in out # the user's ticker stays in the report assert "XAUUSD" in out # the user's ticker stays in the report
assert "GC=F" in out # provenance noted assert "GC=F" in out # provenance noted
+59 -41
View File
@@ -8,6 +8,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.errors import VendorUnavailableError
from tradingagents.dataflows.symbols import normalize_symbol from tradingagents.dataflows.symbols import normalize_symbol
from tradingagents.dataflows.vendors.yahoo.common import yf_retry from tradingagents.dataflows.vendors.yahoo.common import yf_retry
@@ -53,21 +54,48 @@ def _extract_article_data(article: dict) -> dict:
} }
def _search_news(canonical: str, limit: int) -> tuple[list, bool]:
"""Yahoo search's articles tagged with ``canonical``, and whether it knows the symbol.
Search answers when the quote feed is empty, but its articles are a
relevance-picked sample, and some are tagged loosely; only those tagged with
the symbol are about it.
"""
search = yf_retry(lambda: yf.Search(canonical, news_count=limit))
if search is None: # Yahoo answered "not found": its search is down
return [], False
tagged = [
a for a in (search.news or [])
if canonical in (t.upper() for t in a.get("relatedTickers") or [])
]
knows = any((q.get("symbol") or "").upper() == canonical for q in search.quotes or [])
return tagged, knows or bool(search.news)
def _format_articles(articles: list[dict]) -> str:
out = ""
for data in articles:
out += f"### {data['title']} (source: {data['publisher']})\n"
if data["summary"]:
out += f"{data['summary']}\n"
if data["link"]:
out += f"Link: {data['link']}\n"
out += "\n"
return out
def get_news_yfinance( def get_news_yfinance(
ticker: str, ticker: str,
start_date: str, start_date: str,
end_date: str, end_date: str,
) -> str: ) -> str:
""" """News about ``ticker`` published in the window, from Yahoo Finance.
Retrieve news for a specific stock ticker using yfinance.
Args: The symbol's quote feed comes first. When it is empty, Yahoo search serves
ticker: Stock ticker symbol (e.g., "AAPL") the articles tagged with the symbol; when it has none, or neither answers,
start_date: Start date in yyyy-mm-dd format Yahoo news is unavailable, which is not an absence of news, and a configured
end_date: End date in yyyy-mm-dd format next vendor is tried. Search results are a sample, so finding none in the
window never reads as "no news".
Returns:
Formatted string containing news articles
""" """
article_limit = get_config()["news_article_limit"] article_limit = get_config()["news_article_limit"]
# Query Yahoo with the canonical symbol, like every other yfinance path — # Query Yahoo with the canonical symbol, like every other yfinance path —
@@ -75,39 +103,36 @@ def get_news_yfinance(
# returns no news. Keep the user's ticker in the report header. # returns no news. Keep the user's ticker in the report header.
canonical = normalize_symbol(ticker) canonical = normalize_symbol(ticker)
resolved = "" if canonical == ticker else f" (resolved to {canonical})" resolved = "" if canonical == ticker else f" (resolved to {canonical})"
news = yf_retry(lambda: yf.Ticker(canonical).get_news(count=article_limit)) or [] subject = f"news for {ticker}{resolved}"
feed = yf_retry(lambda: yf.Ticker(canonical).get_news(count=article_limit)) or []
articles = [_extract_article_data(a) for a in feed]
if not feed:
found, answered = _search_news(canonical, article_limit)
if not answered:
raise VendorUnavailableError(f"Yahoo Finance returned no news response for {canonical}")
if not found:
# With the quote feed empty, no tagged article says nothing about the
# company's news (Yahoo tags no article with most non-US listings).
raise VendorUnavailableError(f"Yahoo Finance has no news articles tagged {canonical}")
articles = [_extract_article_data(a) for a in found]
start_dt = datetime.strptime(start_date, "%Y-%m-%d") start_dt = datetime.strptime(start_date, "%Y-%m-%d")
end_dt = datetime.strptime(end_date, "%Y-%m-%d") end_dt = datetime.strptime(end_date, "%Y-%m-%d")
news_str = ""
filtered_count = 0
for article in news:
data = _extract_article_data(article)
# Keep only articles within the requested window (look-ahead safe). # Keep only articles within the requested window (look-ahead safe).
if not in_window(data["pub_date"], start_dt, end_dt): in_range = [a for a in articles if in_window(a["pub_date"], start_dt, end_dt)]
continue if in_range:
return f"## {ticker}{resolved} News, from {start_date} to {end_date}:\n\n{_format_articles(in_range)}"
news_str += f"### {data['title']} (source: {data['publisher']})\n" if not feed:
if data["summary"]: return (f"<Yahoo Finance news unavailable for {start_date}..{end_date}: Yahoo search "
news_str += f"{data['summary']}\n" f"returns only a sample of recent articles about {canonical}, so this is not "
if data["link"]: f"an absence of {subject}>")
news_str += f"Link: {data['link']}\n"
news_str += "\n"
filtered_count += 1
if filtered_count == 0:
gap = coverage_gap( gap = coverage_gap(
(_extract_article_data(a)["pub_date"] for a in news), (a["pub_date"] for a in articles),
start_date, end_date, "Yahoo Finance news", f"news for {ticker}{resolved}", start_date, end_date, "Yahoo Finance news", subject,
) )
return gap or f"No news found for {ticker}{resolved} between {start_date} and {end_date}" return gap or f"No news found for {ticker}{resolved} between {start_date} and {end_date}"
return f"## {ticker}{resolved} News, from {start_date} to {end_date}:\n\n{news_str}"
def get_global_news_yfinance( def get_global_news_yfinance(
as_of_date: str, as_of_date: str,
@@ -163,14 +188,7 @@ def get_global_news_yfinance(
if len(in_window_news) >= limit: if len(in_window_news) >= limit:
break break
news_str = "" news_str = _format_articles(in_window_news[:limit])
for data in in_window_news[:limit]:
news_str += f"### {data['title']} (source: {data['publisher']})\n"
if data["summary"]:
news_str += f"{data['summary']}\n"
if data["link"]:
news_str += f"Link: {data['link']}\n"
news_str += "\n"
# Nothing fell inside the window -> say so rather than return an # Nothing fell inside the window -> say so rather than return an
# empty-bodied report (#993). # empty-bodied report (#993).