Files
signal-platform/app/providers/openai_compatible_sentiment.py
T
dennisthiessen e5166ed668
Deploy / lint (push) Successful in 6s
Deploy / test (push) Successful in 34s
Deploy / deploy (push) Successful in 21s
sentiment: LLM buy/hold/avoid + full analysis, and search-budget scoping
Richer LLM output (same grounded call, ~no extra cost):
- All providers now also return a recommendation (buy/hold/avoid) and a thorough
  reasoning paragraph; Gemini now actually captures reasoning + grounding
  citations (it was dropping them). Stored on sentiment_scores (migration 008),
  exposed in the API; display-only — NOT fed into the composite/EV.
- Ticker Sentiment panel shows an "LLM view" badge and a "Full analysis & sources"
  expander with the complete reasoning + citations.

Search-budget scoping (Gemini grounding free tier = 5000/mo):
- collect_sentiment now targets only watchlist + open paper trades + top-N by
  composite, skips tickers refreshed within sentiment_fresh_hours (72h), and caps
  per run (sentiment_max_per_run). Once the relevant set is fresh, runs spend 0
  searches until it ages out — bounding monthly usage well under the free tier.
- Widened sentiment lookback to 7d (scoring + display) so sparser collection
  still feeds the dimension score.

Deploy: alembic upgrade (sentiment_scores.recommendation). Switch provider to
Gemini Flash in Admin for the cost win (grounded, cheapest).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-16 16:34:19 +02:00

141 lines
5.3 KiB
Python

"""Sentiment provider for any OpenAI-compatible Chat Completions endpoint.
Covers DeepSeek, OpenRouter, Together, Groq, Mistral, local Ollama, etc. — any
service exposing the OpenAI Chat Completions API at a custom base_url.
NOTE: Unlike the OpenAI Responses provider and Gemini, this path has NO web
search grounding. Sentiment reflects the model's training knowledge, not live
news. Cheap, but not real-time.
"""
from __future__ import annotations
import json
import logging
import os
from datetime import datetime, timezone
from pathlib import Path
import httpx
from openai import AsyncOpenAI
from app.exceptions import ProviderError, RateLimitError
from app.providers.protocol import SentimentData
logger = logging.getLogger(__name__)
_CA_BUNDLE = os.environ.get("SSL_CERT_FILE", "")
_SENTIMENT_PROMPT = """\
Assess the CURRENT market sentiment for the stock ticker {ticker} based on your \
knowledge of the company, its sector, and recent developments you are aware of, \
and whether BUYING here looks advisable.
Respond ONLY with a JSON object (no markdown, no extra text):
{{"classification": "<bullish|bearish|neutral>", "confidence": <0-100>, "recommendation": "<buy|hold|avoid>", "reasoning": "<a thorough explanation of the drivers>"}}
Rules:
- classification must be exactly one of: bullish, bearish, neutral
- recommendation must be exactly one of: buy, hold, avoid
- confidence must be an integer from 0 to 100
- reasoning should be several sentences
"""
VALID_CLASSIFICATIONS = {"bullish", "bearish", "neutral"}
VALID_RECOMMENDATIONS = {"buy", "hold", "avoid"}
def _parse_recommendation(value: object) -> str | None:
v = str(value or "").strip().lower()
return v if v in VALID_RECOMMENDATIONS else None
def _clean_json_text(raw: str) -> str:
clean = raw.strip()
if clean.startswith("```"):
clean = clean.split("\n", 1)[1] if "\n" in clean else clean[3:]
if clean.endswith("```"):
clean = clean[:-3]
return clean.strip()
class OpenAICompatibleSentimentProvider:
"""Sentiment via the OpenAI Chat Completions API at a configurable base_url."""
def __init__(
self,
api_key: str,
model: str,
base_url: str,
source: str = "openai_compatible",
) -> None:
if not api_key:
raise ProviderError("API key is required")
if not base_url:
raise ProviderError("base_url is required for an OpenAI-compatible provider")
if not model:
raise ProviderError("model is required")
http_kwargs: dict = {}
if _CA_BUNDLE and Path(_CA_BUNDLE).exists():
http_kwargs["verify"] = _CA_BUNDLE
http_client = httpx.AsyncClient(**http_kwargs)
self._client = AsyncOpenAI(api_key=api_key, base_url=base_url, http_client=http_client)
self._model = model
self._source = source
async def fetch_sentiment(self, ticker: str) -> SentimentData:
try:
response = await self._client.chat.completions.create(
model=self._model,
messages=[
{
"role": "system",
"content": "You are a financial sentiment analyst. Always respond with valid JSON only, no markdown fences.",
},
{"role": "user", "content": _SENTIMENT_PROMPT.format(ticker=ticker)},
],
temperature=0.3,
)
raw_text = (response.choices[0].message.content or "").strip()
if not raw_text:
raise ProviderError(f"Empty response from {self._source} for {ticker}")
parsed = json.loads(_clean_json_text(raw_text))
classification = str(parsed.get("classification", "")).lower()
if classification not in VALID_CLASSIFICATIONS:
raise ProviderError(
f"Invalid classification '{classification}' from {self._source} for {ticker}"
)
confidence = max(0, min(100, int(parsed.get("confidence", 50))))
reasoning = str(parsed.get("reasoning", ""))
if reasoning:
logger.info(
"%s sentiment for %s: %s (confidence=%d) — %s",
self._source, ticker, classification, confidence, reasoning,
)
return SentimentData(
ticker=ticker,
classification=classification,
confidence=confidence,
source=self._source,
timestamp=datetime.now(timezone.utc),
reasoning=reasoning,
recommendation=_parse_recommendation(parsed.get("recommendation")),
)
except json.JSONDecodeError as exc:
logger.error("Failed to parse %s JSON for %s: %s", self._source, ticker, exc)
raise ProviderError(f"Invalid JSON from {self._source} for {ticker}") from exc
except ProviderError:
raise
except Exception as exc:
msg = str(exc).lower()
if "429" in msg or "rate" in msg or "quota" in msg or "insufficient" in msg:
raise RateLimitError(f"{self._source} rate limit hit for {ticker}") from exc
logger.error("%s provider error for %s: %s", self._source, ticker, exc)
raise ProviderError(f"{self._source} provider error for {ticker}: {exc}") from exc