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
+288
View File
@@ -0,0 +1,288 @@
"""Jev post screening, against TypeSafe's documented request and response shapes."""
import pytest
import requests
from tradingagents.agents import post_screen as typesafe
QUESTIONS = {"is_urgent": {"type": "noul", "instructions": "Does this convey urgency?"}}
ANSWERS = {"is_urgent": {"type": "noul", "noul": 0.95}}
class _Response:
def __init__(self, status, payload=None, headers=None):
self.status_code = status
self._payload = payload
self.headers = headers or {}
def json(self):
if self._payload is None:
raise ValueError("no JSON")
return self._payload
class _Calls(list):
def __init__(self):
super().__init__()
self.queue = []
self.sleeps = []
@pytest.fixture
def post(monkeypatch):
"""Queue responses on ``.queue``; the list records each call to requests.post."""
calls = _Calls()
queue = calls.queue
def fake_post(url, **kwargs):
calls.append((url, kwargs))
item = queue.pop(0)
if isinstance(item, Exception):
raise item
return item
monkeypatch.setattr(typesafe.requests, "post", fake_post)
monkeypatch.setattr(typesafe.time, "sleep", calls.sleeps.append)
monkeypatch.setenv("TYPESAFE_API_KEY", "ts-test")
monkeypatch.delenv("TYPESAFE_DEFAULT_MODEL", raising=False)
return calls
def _ok():
return _Response(200, {"model": "jev-1.13.0", "answers": ANSWERS,
"usage": {"input_tokens": 296, "output_tokens": 20}})
@pytest.mark.unit
def test_sends_the_documented_request_and_returns_the_answers(post):
post.queue.append(_ok())
assert typesafe.system_one("Help! My payouts have been failing.", QUESTIONS) == ANSWERS
url, kwargs = post[0]
assert url == "https://api.typesafe.ai/v1/systemone"
assert kwargs["headers"]["Authorization"] == "Bearer ts-test"
assert kwargs["json"] == {"state": "Help! My payouts have been failing.",
"model": "jev-latest", "questions": QUESTIONS}
@pytest.mark.unit
def test_the_model_follows_the_sdk_environment(post, monkeypatch):
monkeypatch.setenv("TYPESAFE_DEFAULT_MODEL", "jev-1.13.0")
post.queue.append(_ok())
typesafe.system_one("s", QUESTIONS)
assert post[0][1]["json"]["model"] == "jev-1.13.0"
@pytest.mark.unit
@pytest.mark.parametrize("transient", [
_Response(429), _Response(529), requests.ConnectionError(), requests.Timeout(),
requests.exceptions.ChunkedEncodingError(),
])
def test_rate_limits_overload_and_dropped_connections_are_retried(post, transient):
post.queue.extend([transient, _ok()])
assert typesafe.system_one("s", QUESTIONS) == ANSWERS
assert len(post) == 2
@pytest.mark.unit
def test_a_retry_after_header_sets_the_wait(post):
post.queue.extend([_Response(429, headers={"retry-after": "7"}), _ok()])
typesafe.system_one("s", QUESTIONS)
assert post.sleeps == [7.0]
@pytest.mark.unit
def test_a_long_retry_after_is_capped(post):
post.queue.extend([_Response(529, headers={"retry-after": "600"}), _ok()])
typesafe.system_one("s", QUESTIONS)
assert post.sleeps == [typesafe._MAX_WAIT]
@pytest.mark.unit
def test_retries_are_bounded(post):
post.queue.extend([_Response(529)] * 3)
with pytest.raises(typesafe.TypeSafeError, match="HTTP 529"):
typesafe.system_one("s", QUESTIONS)
assert len(post) == 3
@pytest.mark.unit
@pytest.mark.parametrize("error", [requests.exceptions.InvalidHeader(), requests.exceptions.TooManyRedirects()])
def test_other_request_errors_are_screening_failures_without_retry(post, error):
post.queue.append(error)
with pytest.raises(typesafe.TypeSafeError, match=type(error).__name__):
typesafe.system_one("s", QUESTIONS)
assert len(post) == 1
@pytest.mark.unit
@pytest.mark.parametrize("status", [401, 422, 500])
def test_other_failures_raise_without_retry(post, status):
post.queue.append(_Response(status))
with pytest.raises(typesafe.TypeSafeError, match=f"HTTP {status}"):
typesafe.system_one("s", QUESTIONS)
assert len(post) == 1
@pytest.mark.unit
@pytest.mark.parametrize("payload", [
None, {"model": "jev"}, {"answers": {"other": {}}},
{"answers": {"is_urgent": {"type": "choice", "choice": "yes"}}},
])
def test_a_response_without_every_answer_is_an_error(post, payload):
post.queue.append(_Response(200, payload))
with pytest.raises(typesafe.TypeSafeError, match="malformed"):
typesafe.system_one("s", QUESTIONS)
def _post_answers(about: float, stance: str = "bullish", confidence: float = 0.9):
return _Response(200, {"model": "jev-1.13.0", "answers": {
"about": {"type": "noul", "noul": about},
"stance": {"type": "choice", "choice": stance, "confidence": confidence,
"probabilities": {stance: 1.0}},
}})
@pytest.fixture
def jev(post, monkeypatch):
"""Answer each post by its text: ``post`` maps text -> response."""
answers = {}
def fake_post(url, **kwargs):
post.append((url, kwargs))
return answers[kwargs["json"]["state"]["post"]]
monkeypatch.setattr(typesafe.requests, "post", fake_post)
monkeypatch.setattr(typesafe, "resolve_instrument_identity",
lambda t: {"company_name": "NVIDIA Corporation"})
return answers
@pytest.mark.unit
def test_no_key_no_screen(monkeypatch):
monkeypatch.delenv("TYPESAFE_API_KEY", raising=False)
assert typesafe.jev_screen("NVDA") is None
@pytest.mark.unit
def test_each_post_is_its_own_state_under_the_fixed_questions(jev, post):
jev["NVDA to 200"] = _post_answers(0.9)
typesafe.jev_screen("NVDA")(["NVDA to 200"])
body = post[0][1]["json"]
assert body["state"] == {"instrument": "NVIDIA Corporation (NVDA)", "post": "NVDA to 200"}
assert body["questions"] == typesafe.QUESTIONS
@pytest.mark.unit
def test_screen_drops_clear_off_topic_posts_and_counts_confident_stances(jev):
jev.update({
"long NVDA": _post_answers(0.95, "bullish"),
"NVDA puts": _post_answers(0.9, "bearish"),
"maybe NVDA": _post_answers(0.4, "neutral"), # uncertain relevance: kept
"NVDA?": _post_answers(0.8, "bullish", 0.3), # uncertain stance: unclear
"NVDA!": _post_answers(0.8, "sideways"), # not an option: unclear
"$AAPL $MSFT $NVDA pump": _post_answers(0.1, "bullish"),
})
keep, note = typesafe.jev_screen("NVDA")(list(jev))
assert keep == [True, True, True, True, True, False]
assert note == ("Screened by Jev: 5 of the 6 posts fetched are about NVIDIA Corporation (NVDA); "
"their stance on its stock: 1 bullish, 1 bearish, 1 neutral, 2 unclear.")
@pytest.mark.unit
def test_one_failed_request_leaves_every_post_unscreened(jev):
jev.update({"a": _post_answers(0.1), "b": _Response(401)})
keep, note = typesafe.jev_screen("NVDA")(["a", "b"])
assert keep == [True, True]
assert note == "<Jev screening unavailable (HTTP 401); posts are unscreened>"
@pytest.mark.unit
def test_an_answer_missing_its_fields_reads_as_malformed(jev):
jev["a"] = _Response(200, {"answers": {"about": {"type": "noul"},
"stance": {"type": "choice"}}})
keep, note = typesafe.jev_screen("NVDA")(["a"])
assert keep == [True]
assert note == "<Jev screening unavailable (malformed response); posts are unscreened>"
@pytest.mark.unit
def test_the_first_failure_cancels_the_requests_not_yet_sent(jev, post, monkeypatch):
monkeypatch.setattr(typesafe, "_WORKERS", 1)
jev.update({"a": _Response(401), **{f"p{i}": _post_answers(0.9) for i in range(20)}})
typesafe.jev_screen("NVDA")(list(jev))
assert len(post) < 21
@pytest.mark.unit
def test_the_sentiment_analyst_hands_the_screen_to_both_social_fetchers(monkeypatch):
from langchain_core.messages import AIMessage
from tradingagents.agents.analysts import sentiment_analyst
screen = object()
seen = []
monkeypatch.setattr(sentiment_analyst, "jev_screen", lambda ticker: screen)
monkeypatch.setattr(sentiment_analyst.get_news, "func", lambda *a: "news")
for name in ("fetch_stocktwits_messages", "fetch_reddit_posts"):
monkeypatch.setattr(sentiment_analyst, name, lambda *a, screen=None, **k: seen.append(screen) or "")
class _LLM:
def with_structured_output(self, *a, **k):
raise NotImplementedError
def invoke(self, messages):
return AIMessage(content="report")
node = sentiment_analyst.create_sentiment_analyst(_LLM())
node({"company_of_interest": "NVDA", "trade_date": "2026-01-09", "messages": []})
assert seen == [screen, screen]
@pytest.mark.unit
def test_a_failure_does_not_wait_for_requests_still_in_flight(jev, monkeypatch):
import threading
import time
release = threading.Event()
class _Slow:
status_code = 200
headers = {}
def json(self):
release.wait(5)
return _post_answers(0.9).json()
jev.update({"slow": _Slow(), "bad": _Response(401)})
started = time.monotonic()
keep, note = typesafe.jev_screen("NVDA")(["slow", "bad"])
elapsed = time.monotonic() - started
release.set()
assert keep == [True, True] and "unavailable" in note
assert elapsed < 2