"""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 from .symbol_utils import crypto_base from .stockstats_utils import _fetch_via_ssh 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 _stocktwits_symbol(ticker: str) -> str: """Map a crypto pair to StockTwits' ``.X`` convention. StockTwits lists crypto as ``BTC.X`` (Yahoo's ``BTC-USD`` form 404s), so any crypto symbol resolves to its base plus ``.X``; other symbols pass through upper-cased. """ base = crypto_base(ticker) return f"{base}.X" if base else ticker.strip().upper() 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=_stocktwits_symbol(ticker)) 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 direct fetch failed for %s: %s, trying SSH proxy...", ticker, exc) try: proxy_data = _fetch_via_ssh(url, timeout=timeout) data = json.loads(proxy_data) except Exception as proxy_exc: logger.warning("StockTwits proxy also failed for %s: %s", ticker, proxy_exc) return f"" messages = data.get("messages", []) if isinstance(data, dict) else [] if not messages: return f"" 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)