A truncated/incomplete chunked response raises http.client exceptions (IncompleteRead/BadStatusLine) that are not OSErrors, so they bypassed the existing handler and crashed the analysis. Broaden the catch so the fetch degrades to its placeholder string like every other transport failure.
84 lines
3.3 KiB
Python
84 lines
3.3 KiB
Python
"""StockTwits public symbol-stream fetcher.
|
|
|
|
StockTwits exposes a per-symbol message stream at
|
|
``api.stocktwits.com/api/2/streams/symbol/{ticker}.json`` that requires no
|
|
API key, no OAuth, and no registration. Each message includes a
|
|
user-labeled sentiment field (``Bullish``/``Bearish``/null), the message
|
|
body, timestamp, and posting user.
|
|
|
|
The function is deliberately self-contained: short timeout, graceful
|
|
degradation on any HTTP or parse failure, and a string return type so
|
|
the calling agent gets a uniform interface regardless of whether the
|
|
network call succeeded.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import http.client
|
|
import json
|
|
import logging
|
|
from urllib.request import Request, urlopen
|
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
_API = "https://api.stocktwits.com/api/2/streams/symbol/{ticker}.json"
|
|
_UA = "tradingagents/0.2 (+https://github.com/TauricResearch/TradingAgents)"
|
|
|
|
|
|
def fetch_stocktwits_messages(ticker: str, limit: int = 30, timeout: float = 10.0) -> str:
|
|
"""Fetch recent StockTwits messages for ``ticker`` and return them as a
|
|
formatted plaintext block ready for prompt injection.
|
|
|
|
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.
|
|
"""
|
|
url = _API.format(ticker=ticker.upper())
|
|
req = Request(url, headers={"User-Agent": _UA, "Accept": "application/json"})
|
|
try:
|
|
with urlopen(req, timeout=timeout) as resp:
|
|
data = json.loads(resp.read())
|
|
except (OSError, http.client.HTTPException, json.JSONDecodeError) as exc:
|
|
# OSError covers URLError/TimeoutError/connection resets; HTTPException
|
|
# covers chunked-transfer errors (IncompleteRead/BadStatusLine, #1024).
|
|
logger.warning("StockTwits fetch failed for %s: %s", ticker, exc)
|
|
return f"<stocktwits unavailable: {type(exc).__name__}>"
|
|
|
|
messages = data.get("messages", []) if isinstance(data, dict) else []
|
|
if not messages:
|
|
return f"<no StockTwits messages found for ${ticker.upper()}>"
|
|
|
|
lines = []
|
|
bullish = bearish = unlabeled = 0
|
|
for m in messages[:limit]:
|
|
created = m.get("created_at", "")
|
|
user = (m.get("user") or {}).get("username", "?")
|
|
entities = m.get("entities") or {}
|
|
sentiment_obj = entities.get("sentiment") or {}
|
|
sentiment = sentiment_obj.get("basic") if isinstance(sentiment_obj, dict) else None
|
|
body = (m.get("body") or "").replace("\n", " ").strip()
|
|
if len(body) > 280:
|
|
body = body[:280] + "…"
|
|
|
|
if sentiment == "Bullish":
|
|
bullish += 1
|
|
tag = "Bullish"
|
|
elif sentiment == "Bearish":
|
|
bearish += 1
|
|
tag = "Bearish"
|
|
else:
|
|
unlabeled += 1
|
|
tag = "no-label"
|
|
lines.append(f"[{created} · @{user} · {tag}] {body}")
|
|
|
|
total = bullish + bearish + unlabeled
|
|
bull_pct = round(100 * bullish / total) if total else 0
|
|
bear_pct = round(100 * bearish / total) if total else 0
|
|
summary = (
|
|
f"Bullish: {bullish} ({bull_pct}%) · "
|
|
f"Bearish: {bearish} ({bear_pct}%) · "
|
|
f"Unlabeled: {unlabeled} · "
|
|
f"Total: {total} most-recent messages"
|
|
)
|
|
return summary + "\n\n" + "\n".join(lines)
|