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
This commit is contained in:
Yijia-Xiao
2026-10-03 21:27:43 +00:00
parent df630b9aea
commit c925f60328
4 changed files with 81 additions and 8 deletions
+22
View File
@@ -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 == "<Jev screening unavailable (HTTP 529); posts are unscreened>\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
+20
View File
@@ -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
@@ -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}
<end_of_stocktwits>
### 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}).
<start_of_reddit>
{reddit_block}
+18 -3
View File
@@ -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: