From c925f6032857f116329fa3e84640469fec1dad6c Mon Sep 17 00:00:00 2001 From: Yijia-Xiao Date: Fri, 2 Oct 2026 22:19:35 +0000 Subject: [PATCH] feat(sentiment): search crypto communities on Reddit for a crypto ticker (#1461) - a crypto pair is searched in r/CryptoCurrency and r/CryptoMarkets, plus r/Bitcoin, r/ethereum or r/solana for those coins, still in one request - the Sentiment Analyst's prompt names the subreddits actually searched --- tests/test_reddit_fallback.py | 22 ++++++++++++++++ tests/test_structured_agent_prompts.py | 20 ++++++++++++++ .../agents/analysts/sentiment_analyst.py | 26 +++++++++++++++---- tradingagents/dataflows/vendors/reddit.py | 21 ++++++++++++--- 4 files changed, 81 insertions(+), 8 deletions(-) diff --git a/tests/test_reddit_fallback.py b/tests/test_reddit_fallback.py index 2bd630fd2..8df9d6ad8 100644 --- a/tests/test_reddit_fallback.py +++ b/tests/test_reddit_fallback.py @@ -311,3 +311,25 @@ def test_an_unavailable_screen_keeps_every_post_and_says_so(): screened = reddit.fetch_reddit_posts("NVDA", subreddits=("a", "b"), screen=unavailable) plain = reddit.fetch_reddit_posts("NVDA", subreddits=("a", "b")) assert screened == "\n\n" + plain + + +@pytest.mark.unit +@pytest.mark.parametrize("ticker, expected", [ + ("NVDA", ("wallstreetbets", "stocks", "investing")), + ("BTC-USD", ("Bitcoin", "CryptoCurrency", "CryptoMarkets")), + ("ETH-USD", ("ethereum", "CryptoCurrency", "CryptoMarkets")), + ("SOL-USD", ("solana", "CryptoCurrency", "CryptoMarkets")), + ("DOGE-USD", ("CryptoCurrency", "CryptoMarkets")), +]) +def test_a_crypto_ticker_is_searched_in_crypto_communities(ticker, expected): + assert reddit.subreddits_for(ticker) == expected + + +@pytest.mark.unit +def test_a_crypto_pair_is_fetched_from_its_communities_in_one_request(): + with patch.object(reddit, "_fetch_subreddit_rss", return_value=[]) as fetch: + out = reddit.fetch_reddit_posts("ETH-USD") + fetch.assert_called_once() + query, subs = fetch.call_args.args[:2] + assert (query, subs) == ("ETH", "ethereum+CryptoCurrency+CryptoMarkets") + assert "r/wallstreetbets" not in out diff --git a/tests/test_structured_agent_prompts.py b/tests/test_structured_agent_prompts.py index 47b8559ad..6b2f6ff62 100644 --- a/tests/test_structured_agent_prompts.py +++ b/tests/test_structured_agent_prompts.py @@ -146,3 +146,23 @@ def test_constraint_text_is_unambiguous(): # No template braces: it is embedded in ChatPromptTemplate strings, where # braces would be parsed as input variables. assert "{" not in NO_EXTERNAL_TOOLS and "}" not in NO_EXTERNAL_TOOLS + + +@pytest.mark.unit +def test_the_sentiment_prompt_names_the_subreddits_searched(monkeypatch): + from tradingagents.agents.schemas import SentimentBand, SentimentReport + + monkeypatch.setattr(sentiment, "fetch_stocktwits_messages", lambda *a, **k: "st") + monkeypatch.setattr(sentiment, "fetch_reddit_posts", lambda *a, **k: "rd") + monkeypatch.setattr(sentiment.get_news, "func", lambda *a, **k: "news", raising=False) + captured = {} + llm = _capturing_llm(captured, SentimentReport( + overall_band=SentimentBand.NEUTRAL, overall_score=5.0, confidence="low", narrative="n", + )) + sentiment.create_sentiment_analyst(llm)({ + "company_of_interest": "BTC-USD", "trade_date": "2026-01-15", + "asset_type": "crypto", "messages": [], + }) + text = _prompt_text(captured["prompt"]) + assert "r/Bitcoin, r/CryptoCurrency, r/CryptoMarkets" in text + assert "wallstreetbets" not in text diff --git a/tradingagents/agents/analysts/sentiment_analyst.py b/tradingagents/agents/analysts/sentiment_analyst.py index ff2cf3623..1cc43097d 100644 --- a/tradingagents/agents/analysts/sentiment_analyst.py +++ b/tradingagents/agents/analysts/sentiment_analyst.py @@ -5,7 +5,7 @@ prompt, so the model reports on data it was given rather than inventing posts: 1. News headlines: Yahoo Finance 2. StockTwits messages: the cashtag stream, with Bullish/Bearish tags - 3. Reddit posts: r/wallstreetbets, r/stocks, r/investing + 3. Reddit posts: r/wallstreetbets, r/stocks, r/investing, or crypto communities for a crypto pair Each source is trimmed to the analysis window. With a TypeSafe key, the social posts are screened by Jev first (see post_screen). These feeds serve recent items @@ -31,7 +31,12 @@ from tradingagents.agents.structured import ( invoke_structured_or_freetext, ) from tradingagents.agents.tools import get_news -from tradingagents.dataflows.vendors.reddit import fetch_reddit_posts +from tradingagents.dataflows.vendors.reddit import ( + CRYPTO_SUBREDDITS, + DEFAULT_SUBREDDITS, + fetch_reddit_posts, + subreddits_for, +) from tradingagents.dataflows.vendors.stocktwits import fetch_stocktwits_messages @@ -65,7 +70,10 @@ def create_sentiment_analyst(llm): stocktwits_block = fetch_stocktwits_messages( ticker, limit=30, start_date=start_date, end_date=end_date, screen=screen ) - reddit_block = fetch_reddit_posts(ticker, start_date=start_date, end_date=end_date, screen=screen) + subreddits = subreddits_for(ticker) + reddit_block = fetch_reddit_posts( + ticker, subreddits, start_date=start_date, end_date=end_date, screen=screen + ) system_message = _build_system_message( ticker=ticker, @@ -74,6 +82,7 @@ def create_sentiment_analyst(llm): news_block=news_block, stocktwits_block=stocktwits_block, reddit_block=reddit_block, + subreddits=subreddits, ) prompt = ChatPromptTemplate.from_messages( @@ -126,8 +135,15 @@ def _build_system_message( news_block: str, stocktwits_block: str, reddit_block: str, + subreddits: tuple[str, ...] = DEFAULT_SUBREDDITS, ) -> str: """Assemble the sentiment-analyst system message with structured data blocks.""" + if subreddits == DEFAULT_SUBREDDITS: + character = "r/wallstreetbets is often contrarian/exuberant; r/stocks more measured; r/investing longer-term" + elif len(subreddits) > len(CRYPTO_SUBREDDITS): + character = f"r/{subreddits[0]} leans toward the coin's holders; r/CryptoCurrency and r/CryptoMarkets are broader" + else: + character = "r/CryptoCurrency and r/CryptoMarkets are broad crypto communities" return f"""You are a financial market sentiment analyst. Your task is to produce a comprehensive sentiment report for {ticker} covering the period from {start_date} to {end_date}, drawing on three complementary data sources that have already been collected for you. ## Data sources (pre-fetched, in this prompt) @@ -146,8 +162,8 @@ Fast-moving signal. Each message carries a user-labeled sentiment tag (Bullish / {stocktwits_block} -### Reddit posts — r/wallstreetbets, r/stocks, r/investing (past 7 days) -Community discussion, without vote or comment counts. Subreddit character matters (r/wallstreetbets is often contrarian/exuberant; r/stocks more measured; r/investing longer-term). +### Reddit posts — {", ".join(f"r/{sub}" for sub in subreddits)} (past 7 days) +Community discussion, without vote or comment counts. Subreddit character matters ({character}). {reddit_block} diff --git a/tradingagents/dataflows/vendors/reddit.py b/tradingagents/dataflows/vendors/reddit.py index d39a052fd..299e99e18 100644 --- a/tradingagents/dataflows/vendors/reddit.py +++ b/tradingagents/dataflows/vendors/reddit.py @@ -78,6 +78,20 @@ _ATOM_NS = {"atom": "http://www.w3.org/2005/Atom"} # investing trend more measured. Caller can override. DEFAULT_SUBREDDITS = ("wallstreetbets", "stocks", "investing") +# Crypto is discussed in crypto communities, where a coin's own subreddit carries +# most of it; the stock subreddits above barely mention it. +CRYPTO_SUBREDDITS = ("CryptoCurrency", "CryptoMarkets") +_COIN_SUBREDDITS = {"BTC": "Bitcoin", "ETH": "ethereum", "SOL": "solana"} + + +def subreddits_for(ticker: str) -> tuple[str, ...]: + """The subreddits searched for ``ticker``: crypto communities for a crypto pair.""" + base = crypto_base(ticker) + if not base: + return DEFAULT_SUBREDDITS + coin = _COIN_SUBREDDITS.get(base) + return ((coin,) if coin else ()) + CRYPTO_SUBREDDITS + # Reddit's maximum page size. A week of posts for a ticker across the default # subreddits fits well inside one page, which keeps a high-volume subreddit from # crowding the others out of a combined search. @@ -233,7 +247,7 @@ def _fetch_subreddit_rss( def fetch_reddit_posts( ticker: str, - subreddits: Iterable[str] = DEFAULT_SUBREDDITS, + subreddits: Iterable[str] | None = None, *, limit_per_sub: int = 5, timeout: float = 10.0, @@ -242,7 +256,8 @@ def fetch_reddit_posts( screen=None, ) -> str: """Fetch recent Reddit posts mentioning ``ticker`` across finance - subreddits and return them as a formatted plaintext block. + subreddits (by default ``subreddits_for(ticker)``) and return them as a + formatted plaintext block. All subreddits are searched in one combined feed (``r/a+b+c``): anonymous RSS allows about one request per minute per IP, so a request per subreddit @@ -257,10 +272,10 @@ def fetch_reddit_posts( flag per post and a note line that heads the block. It runs before the per-subreddit cut, so the posts it keeps fill the slots. """ + subreddits = list(subreddits or subreddits_for(ticker)) # Crypto reaches us as a Yahoo pair (BTC-USD); search Reddit for the base # ("BTC") so the query actually matches discussion instead of near-nothing. ticker = crypto_base(ticker) or ticker - subreddits = list(subreddits) label = ", ".join(f"r/{s}" for s in subreddits) fetched = _fetch_subreddit_rss(ticker, "+".join(subreddits), _FEED_PAGE, timeout) if fetched is None: