e5166ed668
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>
142 lines
4.1 KiB
Python
142 lines
4.1 KiB
Python
"""Sentiment service.
|
|
|
|
Stores sentiment records and computes the sentiment dimension score
|
|
using a time-decay weighted average over a configurable lookback window.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import json
|
|
import math
|
|
from datetime import datetime, timedelta, timezone
|
|
|
|
from sqlalchemy import select
|
|
from sqlalchemy.ext.asyncio import AsyncSession
|
|
|
|
from app.exceptions import NotFoundError
|
|
from app.models.sentiment import SentimentScore
|
|
from app.models.ticker import Ticker
|
|
|
|
|
|
async def _get_ticker(db: AsyncSession, symbol: str) -> Ticker:
|
|
"""Look up a ticker by symbol."""
|
|
normalised = symbol.strip().upper()
|
|
result = await db.execute(select(Ticker).where(Ticker.symbol == normalised))
|
|
ticker = result.scalar_one_or_none()
|
|
if ticker is None:
|
|
raise NotFoundError(f"Ticker not found: {normalised}")
|
|
return ticker
|
|
|
|
|
|
async def store_sentiment(
|
|
db: AsyncSession,
|
|
symbol: str,
|
|
classification: str,
|
|
confidence: int,
|
|
source: str,
|
|
timestamp: datetime | None = None,
|
|
reasoning: str = "",
|
|
citations: list[dict] | None = None,
|
|
recommendation: str | None = None,
|
|
) -> SentimentScore:
|
|
"""Store a new sentiment record for a ticker."""
|
|
ticker = await _get_ticker(db, symbol)
|
|
|
|
if timestamp is None:
|
|
timestamp = datetime.now(timezone.utc)
|
|
|
|
if citations is None:
|
|
citations = []
|
|
|
|
record = SentimentScore(
|
|
ticker_id=ticker.id,
|
|
classification=classification,
|
|
confidence=confidence,
|
|
source=source,
|
|
timestamp=timestamp,
|
|
reasoning=reasoning,
|
|
citations_json=json.dumps(citations),
|
|
recommendation=recommendation,
|
|
)
|
|
db.add(record)
|
|
await db.commit()
|
|
await db.refresh(record)
|
|
return record
|
|
|
|
|
|
async def get_sentiment_scores(
|
|
db: AsyncSession,
|
|
symbol: str,
|
|
lookback_hours: float = 24,
|
|
) -> list[SentimentScore]:
|
|
"""Get recent sentiment records within the lookback window."""
|
|
ticker = await _get_ticker(db, symbol)
|
|
cutoff = datetime.now(timezone.utc) - timedelta(hours=lookback_hours)
|
|
|
|
result = await db.execute(
|
|
select(SentimentScore)
|
|
.where(
|
|
SentimentScore.ticker_id == ticker.id,
|
|
SentimentScore.timestamp >= cutoff,
|
|
)
|
|
.order_by(SentimentScore.timestamp.desc())
|
|
)
|
|
return list(result.scalars().all())
|
|
|
|
|
|
def _classification_to_base_score(classification: str, confidence: int) -> float:
|
|
"""Map classification + confidence to a base score (0-100).
|
|
|
|
bullish → confidence (high confidence = high score)
|
|
bearish → 100 - confidence (high confidence bearish = low score)
|
|
neutral → 50
|
|
"""
|
|
cl = classification.lower()
|
|
if cl == "bullish":
|
|
return float(confidence)
|
|
elif cl == "bearish":
|
|
return float(100 - confidence)
|
|
else:
|
|
return 50.0
|
|
|
|
|
|
async def compute_sentiment_dimension_score(
|
|
db: AsyncSession,
|
|
symbol: str,
|
|
lookback_hours: float = 24,
|
|
decay_rate: float = 0.1,
|
|
) -> float | None:
|
|
"""Compute the sentiment dimension score using time-decay weighted average.
|
|
|
|
Returns a score in [0, 100] or None if no scores exist in the window.
|
|
|
|
Algorithm:
|
|
1. For each score in the lookback window, compute base_score from
|
|
classification + confidence.
|
|
2. Apply time decay: weight = exp(-decay_rate * hours_since_score).
|
|
3. Weighted average: sum(base_score * weight) / sum(weight).
|
|
"""
|
|
scores = await get_sentiment_scores(db, symbol, lookback_hours)
|
|
if not scores:
|
|
return None
|
|
|
|
now = datetime.now(timezone.utc)
|
|
weighted_sum = 0.0
|
|
weight_total = 0.0
|
|
|
|
for score in scores:
|
|
ts = score.timestamp
|
|
if ts.tzinfo is None:
|
|
ts = ts.replace(tzinfo=timezone.utc)
|
|
hours_since = (now - ts).total_seconds() / 3600.0
|
|
weight = math.exp(-decay_rate * hours_since)
|
|
base = _classification_to_base_score(score.classification, score.confidence)
|
|
weighted_sum += base * weight
|
|
weight_total += weight
|
|
|
|
if weight_total == 0:
|
|
return None
|
|
|
|
result = weighted_sum / weight_total
|
|
return max(0.0, min(100.0, result))
|