Files
signal-platform/app/routers/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

63 lines
2.1 KiB
Python

"""Sentiment router — sentiment data endpoints."""
import json
from fastapi import APIRouter, Depends, Query
from sqlalchemy.ext.asyncio import AsyncSession
from app.dependencies import get_db, require_access
from app.schemas.common import APIEnvelope
from app.schemas.sentiment import CitationItem, SentimentResponse, SentimentScoreResult
from app.services.sentiment_service import (
compute_sentiment_dimension_score,
get_sentiment_scores,
)
router = APIRouter(tags=["sentiment"])
def _parse_citations(citations_json: str) -> list[CitationItem]:
"""Deserialize citations_json, defaulting to [] on invalid JSON."""
try:
raw = json.loads(citations_json)
except (json.JSONDecodeError, TypeError):
return []
if not isinstance(raw, list):
return []
return [CitationItem(**item) for item in raw if isinstance(item, dict)]
@router.get("/sentiment/{symbol}", response_model=APIEnvelope)
async def read_sentiment(
symbol: str,
lookback_hours: float = Query(168, gt=0, description="Lookback window in hours"),
_user=Depends(require_access),
db: AsyncSession = Depends(get_db),
) -> APIEnvelope:
"""Get recent sentiment scores and computed dimension score for a symbol."""
scores = await get_sentiment_scores(db, symbol, lookback_hours)
dimension_score = await compute_sentiment_dimension_score(
db, symbol, lookback_hours
)
data = SentimentResponse(
symbol=symbol.strip().upper(),
scores=[
SentimentScoreResult(
id=s.id,
classification=s.classification,
confidence=s.confidence,
source=s.source,
timestamp=s.timestamp,
reasoning=s.reasoning,
citations=_parse_citations(s.citations_json),
recommendation=s.recommendation,
)
for s in scores
],
count=len(scores),
dimension_score=round(dimension_score, 2) if dimension_score is not None else None,
lookback_hours=lookback_hours,
)
return APIEnvelope(status="success", data=data.model_dump())