feat(sentiment): screen social posts with TypeSafe's Jev (#1376)

- with TYPESAFE_API_KEY set, each StockTwits and Reddit post is asked whether it is about the company and its stance on the stock
- posts clearly about something else are dropped before the per-source cut, and each block opens with a stance count
- any failed request leaves the source's posts unscreened and says so; without a key nothing changes
This commit is contained in:
Yijia-Xiao
2026-09-24 05:00:36 +00:00
parent f281feb483
commit c924f84114
7 changed files with 586 additions and 12 deletions
@@ -7,7 +7,8 @@ prompt, so the model reports on data it was given rather than inventing posts:
2. StockTwits messages: the cashtag stream, with Bullish/Bearish tags
3. Reddit posts: r/wallstreetbets, r/stocks, r/investing
Each source is trimmed to the analysis window. These feeds serve recent items
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
and are not archived, so a historical run's sentiment inputs are not
point-in-time.
@@ -22,6 +23,7 @@ from langchain_core.messages import AIMessage
from langchain_core.prompts import ChatPromptTemplate, MessagesPlaceholder
from tradingagents.agents.context import get_instrument_context_from_state, get_language_instruction
from tradingagents.agents.post_screen import jev_screen
from tradingagents.agents.schemas import SentimentReport, render_sentiment_report
from tradingagents.agents.structured import (
NO_EXTERNAL_TOOLS,
@@ -59,10 +61,11 @@ def create_sentiment_analyst(llm):
news_block = get_news.func(ticker, start_date, end_date)
# Pass the analysis window so a historical run trims social posts to it
# instead of leaking today's chatter into a backtest (#1220).
screen = jev_screen(ticker)
stocktwits_block = fetch_stocktwits_messages(
ticker, limit=30, start_date=start_date, end_date=end_date
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)
reddit_block = fetch_reddit_posts(ticker, start_date=start_date, end_date=end_date, screen=screen)
system_message = _build_system_message(
ticker=ticker,
@@ -152,7 +155,7 @@ Community discussion, without vote or comment counts. Subreddit character matter
## How to analyze this data (best practices)
1. **Read the StockTwits Bullish/Bearish ratio as a leading retail-sentiment signal.** A 70/30 bullish/bearish split is moderately bullish; ≥90/10 may indicate over-extension and contrarian risk; 50/50 is uncertainty. Sample size matters — base rates on the actual message count, not percentages alone.
1. **Read the StockTwits Bullish/Bearish ratio as a leading retail-sentiment signal.** A 70/30 bullish/bearish split is moderately bullish; ≥90/10 may indicate over-extension and contrarian risk; 50/50 is uncertainty. Sample size matters — base rates on the actual message count, not percentages alone. A block headed "Screened by Jev" has had off-topic posts removed; its stance count is a classifier's read of every on-topic post fetched, labelled or not, of which the posts listed are a sample. Read it alongside the user tags.
2. **Look for cross-source divergences.** If news framing is bearish but StockTwits is overwhelmingly bullish, that mismatch is itself a signal — it can mean retail is leaning into a thesis the news flow hasn't caught up to (or vice versa, that retail is chasing while institutions are cautious).
+174
View File
@@ -0,0 +1,174 @@
"""Social-post screening with TypeSafe's Jev, when ``TYPESAFE_API_KEY`` is set.
Jev answers typed questions with calibrated probabilities. Each StockTwits or
Reddit post is asked two: is it about the instrument, and which way does it
lean on the instrument's stock. Code turns the answers into what the Sentiment
Analyst reads: posts that are clearly about something else are dropped, and a
stance count over the rest heads the source's block.
Configured by TypeSafe's own SDK variables, ``TYPESAFE_API_KEY`` and
``TYPESAFE_DEFAULT_MODEL``. Without a key nothing here runs; if any request fails, the source's posts are kept unscreened and the
block says screening was unavailable.
"""
import logging
import os
import random
import time
from concurrent.futures import ThreadPoolExecutor, as_completed
import requests
from tradingagents.agents.context import resolve_instrument_identity
logger = logging.getLogger(__name__)
_URL = "https://api.typesafe.ai/v1/systemone"
_DEFAULT_MODEL = "jev-latest"
_RETRY_STATUSES = (429, 529) # rate limited, overloaded: back off and retry
_TRANSIENT = (requests.ConnectionError, requests.Timeout, requests.exceptions.ChunkedEncodingError)
_ATTEMPTS = 3
_MAX_WAIT = 30.0
_TIMEOUT = 15.0
_WORKERS = 16 # well inside the documented 1,200 requests per minute
# A post is dropped only on a clear "not about it"; the uncertain middle stays.
_OFF_TOPIC_BELOW = 0.3
# A stance counts only when Jev is not genuinely uncertain about it.
_STANCE_CONFIDENCE = 0.5
QUESTIONS = {
"about": {
"type": "noul",
"instructions": "Is `post` about `instrument`: the company, its stock, its products or its outlook?",
"criteria": {
"true": "`post` discusses `instrument` itself.",
"false": "`post` names `instrument` only in passing or in a list of tickers, is spam or "
"promotion, or is about a different company.",
},
},
"stance": {
"type": "choice",
"instructions": "What does the author of `post` expect for the stock price of `instrument`?",
"criteria": {
"bullish": "The author expects `instrument`'s stock to rise, or is buying or holding it long.",
"bearish": "The author expects `instrument`'s stock to fall, or is selling or shorting it.",
"neutral": "The author gives no view of their own on `instrument`'s stock: a question, "
"news without opinion, or someone else's view quoted.",
},
},
}
class TypeSafeError(Exception):
"""A System One request that did not produce answers."""
def system_one(state, questions: dict) -> dict[str, dict]:
"""Ask ``questions`` about ``state``; return the answers keyed by question name.
Rate-limit and overload responses and dropped connections are retried with
backoff, honouring ``Retry-After``; any other failure raises
``TypeSafeError`` at once.
"""
body = {
"state": state,
"model": os.environ.get("TYPESAFE_DEFAULT_MODEL") or _DEFAULT_MODEL,
"questions": questions,
}
headers = {"Authorization": f"Bearer {os.environ.get('TYPESAFE_API_KEY', '')}"}
backoff, retry_after = 1.0, None
for attempt in range(_ATTEMPTS):
if attempt:
time.sleep(retry_after if retry_after is not None else backoff * random.uniform(0.8, 1.2))
backoff *= 2
try:
response = requests.post(_URL, json=body, headers=headers, timeout=_TIMEOUT)
except requests.RequestException as exc:
failure, retry_after = type(exc).__name__, None
if isinstance(exc, _TRANSIENT):
continue
break
if response.status_code == 200:
return _answers(response, questions)
failure, retry_after = f"HTTP {response.status_code}", _retry_after(response)
if response.status_code not in _RETRY_STATUSES:
break
raise TypeSafeError(failure)
def _retry_after(response) -> float | None:
try:
return min(max(0.0, float(response.headers.get("retry-after"))), _MAX_WAIT)
except (TypeError, ValueError):
return None
def _answers(response, questions: dict) -> dict[str, dict]:
try:
payload = response.json()
answers = payload["answers"]
if all(answers[q]["type"] == spec["type"] for q, spec in questions.items()):
logger.debug("TypeSafe answered with %s", payload.get("model"))
return answers
except (ValueError, KeyError, TypeError):
pass
raise TypeSafeError("malformed response")
def _stance(answer: dict) -> str:
choice = answer["choice"]
if choice not in QUESTIONS["stance"]["criteria"] or answer["confidence"] < _STANCE_CONFIDENCE:
return "unclear"
return choice
def jev_screen(ticker: str):
"""A post screen for the social fetchers, or None without a TypeSafe key.
The screen takes the post texts and returns one keep flag per post and a
note line for the top of the source's block.
"""
if not os.environ.get("TYPESAFE_API_KEY"):
return None
name = resolve_instrument_identity(ticker).get("company_name")
instrument = f"{name} ({ticker})" if name else ticker
def screen(posts: list[str]) -> tuple[list[bool], str]:
try:
answers = _ask_each(instrument, posts)
keep = [a["about"]["noul"] >= _OFF_TOPIC_BELOW for a in answers]
stances = [_stance(a["stance"]) for a, kept in zip(answers, keep, strict=True) if kept]
except (KeyError, TypeError):
return _unscreened(instrument, posts, "malformed response")
except TypeSafeError as exc:
return _unscreened(instrument, posts, str(exc))
counts = ", ".join(f"{stances.count(s)} {s}" for s in ("bullish", "bearish", "neutral", "unclear"))
return keep, (
f"Screened by Jev: {len(stances)} of the {len(posts)} posts fetched are about "
f"{instrument}; their stance on its stock: {counts}."
)
return screen
def _unscreened(instrument: str, posts: list[str], failure: str) -> tuple[list[bool], str]:
logger.warning("Jev screening failed for %s: %s", instrument, failure)
return [True] * len(posts), f"<Jev screening unavailable ({failure}); posts are unscreened>"
def _ask_each(instrument: str, posts: list[str]) -> list[dict]:
"""One request per post. The first failure raises at once: requests not yet
sent are cancelled, and those in flight finish in the background unread."""
answers: list = [None] * len(posts)
pool = ThreadPoolExecutor(max_workers=_WORKERS)
try:
futures = {
pool.submit(system_one, {"instrument": instrument, "post": post}, QUESTIONS): i
for i, post in enumerate(posts)
}
for future in as_completed(futures):
answers[futures[future]] = future.result()
finally:
pool.shutdown(wait=False, cancel_futures=True)
return answers
+25 -7
View File
@@ -82,6 +82,7 @@ DEFAULT_SUBREDDITS = ("wallstreetbets", "stocks", "investing")
# subreddits fits well inside one page, which keeps a high-volume subreddit from
# crowding the others out of a combined search.
_FEED_PAGE = 100
_SCREEN_CHARS = 1000 # of a post's title and body sent for screening
_SEARCH_LOOKBACK = timedelta(days=7) # matches t=week below
@@ -238,6 +239,7 @@ def fetch_reddit_posts(
timeout: float = 10.0,
start_date: str | None = None,
end_date: str | None = None,
screen=None,
) -> str:
"""Fetch recent Reddit posts mentioning ``ticker`` across finance
subreddits and return them as a formatted plaintext block.
@@ -250,6 +252,10 @@ def fetch_reddit_posts(
When ``start_date``/``end_date`` (yyyy-mm-dd) are given, posts are trimmed to
that window so a historical run does not leak current discussion into a
backtest (#1220).
``screen``, when given, takes each post's title and body and returns a keep
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.
"""
# 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.
@@ -270,22 +276,34 @@ def fetch_reddit_posts(
period = f"within {start_date}..{end_date}" if window else "in the past 7 days"
return gap or f"<no Reddit posts found mentioning {ticker.upper()} across {label} {period}>"
def sub_of(p):
return p.get("subreddit") or (subreddits[0] if len(subreddits) == 1 else "unknown")
note, screened_out = "", set()
if screen:
keep, note = screen([f"{p.get('title') or ''}\n{p.get('selftext') or ''}"[:_SCREEN_CHARS]
for p in posts])
screened_out = {sub_of(p).lower() for p, kept in zip(posts, keep, strict=True) if not kept}
posts = [p for p, kept in zip(posts, keep, strict=True) if kept]
# Group by the subreddit each entry names, in the requested order. Nothing
# is dropped: an unlabelled post from a one-subreddit request belongs to it,
# and any other name gets its own block.
by_sub = {s.lower(): (s, []) for s in subreddits}
for p in posts:
name = p.get("subreddit") or (subreddits[0] if len(subreddits) == 1 else "unknown")
by_sub.setdefault(name.lower(), (name, []))[1].append(p)
by_sub.setdefault(sub_of(p).lower(), (sub_of(p), []))[1].append(p)
page_full = len(fetched) >= _FEED_PAGE
blocks = []
for sub, sub_posts in by_sub.values():
if not sub_posts:
blocks.append(
f"r/{sub}: <not among the newest {_FEED_PAGE} matches across {label}>"
if page_full else f"r/{sub}: <no posts found mentioning {ticker.upper()}>"
)
if sub.lower() in screened_out:
blocks.append(f"r/{sub}: <no posts about {ticker.upper()} after screening>")
else:
blocks.append(
f"r/{sub}: <not among the newest {_FEED_PAGE} matches across {label}>"
if page_full else f"r/{sub}: <no posts found mentioning {ticker.upper()}>"
)
continue
sub_posts = sub_posts[:limit_per_sub] # the feed is newest-first
lines = [f"r/{sub} — {len(sub_posts)} recent posts mentioning {ticker.upper()}:"]
@@ -301,4 +319,4 @@ def fetch_reddit_posts(
+ (f"\n body excerpt: {selftext}" if selftext else "")
)
blocks.append("\n".join(lines))
return "\n\n".join(blocks)
return "\n\n".join(([note] if note else []) + blocks)
+13 -1
View File
@@ -71,6 +71,7 @@ def fetch_stocktwits_messages(
timeout: float = 10.0,
start_date: str | None = None,
end_date: str | None = None,
screen=None,
) -> str:
"""Fetch recent StockTwits messages for ``ticker`` and return them as a
formatted plaintext block ready for prompt injection.
@@ -80,6 +81,9 @@ def fetch_stocktwits_messages(
public stream only serves recent messages, so a window it cannot reach is
reported as unavailable rather than as silence.
``screen``, when given, takes the message bodies and returns a keep flag per
message and a note line that heads the block.
Returns a placeholder string when the endpoint is unreachable, the
symbol has no messages, or the response shape is unexpected — the
caller never has to special-case None or exceptions.
@@ -109,6 +113,14 @@ def fetch_stocktwits_messages(
)
return f"<no StockTwits messages found for ${ticker.upper()}>"
note = ""
if screen:
keep, note = screen([m.get("body") or "" for m in messages])
screened = len(messages)
messages = [m for m, kept in zip(messages, keep, strict=True) if kept]
if not messages:
return f"{note}\n\n<none of the {screened} StockTwits messages is about ${ticker.upper()}>"
lines = []
bullish = bearish = unlabeled = 0
for m in messages[:limit]:
@@ -141,4 +153,4 @@ def fetch_stocktwits_messages(
f"Unlabeled: {unlabeled} · "
f"Total: {total} most-recent messages"
)
return summary + "\n\n" + "\n".join(lines)
return (f"{note}\n\n" if note else "") + summary + "\n\n" + "\n".join(lines)