mirror of
https://github.com/TauricResearch/TradingAgents.git
synced 2026-11-03 16:12:44 +02:00
- 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:
@@ -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
|
||||||
|
|||||||
@@ -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
@@ -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).
|
||||||
|
|||||||
Reference in New Issue
Block a user