sentiment: LLM buy/hold/avoid + full analysis, and search-budget scoping
Deploy / lint (push) Successful in 6s
Deploy / test (push) Successful in 34s
Deploy / deploy (push) Successful in 21s

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>
This commit is contained in:
2026-06-16 16:34:19 +02:00
parent a69557f5d8
commit e5166ed668
16 changed files with 219 additions and 36 deletions
@@ -0,0 +1,29 @@
"""add recommendation to sentiment_scores
Revision ID: 008
Revises: 007
Create Date: 2026-06-16 00:00:00.000000
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision: str = "008"
down_revision: Union[str, None] = "007"
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
op.add_column(
"sentiment_scores",
sa.Column("recommendation", sa.String(length=10), nullable=True),
)
def downgrade() -> None:
op.drop_column("sentiment_scores", "recommendation")
+6
View File
@@ -44,6 +44,12 @@ class Settings(BaseSettings):
# Scheduled Jobs
data_collector_frequency: str = "daily"
sentiment_poll_interval_minutes: int = 30
# Sentiment search-budget controls (Gemini grounding free tier = 5000/month).
# Only fetch sentiment for relevant tickers (watchlist + open trades + top-N by
# composite), skip ones refreshed within fresh_hours, and cap per run.
sentiment_fresh_hours: int = 72
sentiment_max_per_run: int = 25
sentiment_top_composite: int = 30
fundamental_fetch_frequency: str = "daily"
rr_scan_frequency: str = "daily"
alerts_frequency: str = "hourly"
+1
View File
@@ -22,5 +22,6 @@ class SentimentScore(Base):
reasoning: Mapped[str] = mapped_column(Text, nullable=False, default="")
citations_json: Mapped[str] = mapped_column(Text, nullable=False, default="[]")
recommendation: Mapped[str | None] = mapped_column(String(10), nullable=True)
ticker = relationship("Ticker", back_populates="sentiment_scores")
+38 -6
View File
@@ -30,19 +30,48 @@ if _CA_BUNDLE and Path(_CA_BUNDLE).exists():
logger.warning("Could not patch aiohttp SSL context", exc_info=True)
_SENTIMENT_PROMPT = """\
Analyze the current market sentiment for the stock ticker {ticker}.
Search the web for recent news articles, social media mentions, and analyst opinions.
Search the web for the latest news, analyst ratings/opinions, and retail/social \
discussion (e.g. Reddit, StockTwits) about the stock ticker {ticker} from roughly \
the past 1-2 weeks.
Respond ONLY with a JSON object in this exact format (no markdown, no extra text):
{{"classification": "<bullish|bearish|neutral>", "confidence": <0-100>, "reasoning": "<brief explanation>"}}
Assess (1) the current market sentiment and (2) whether BUYING here looks advisable now.
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 paragraph citing specific analyst views, news, and retail sentiment you found, and what drives the recommendation>"}}
Rules:
- classification must be exactly one of: bullish, bearish, neutral
- classification = overall mood/tone (bullish, bearish, neutral)
- recommendation = actionable view on buying now (buy, hold, avoid)
- confidence must be an integer from 0 to 100
- reasoning should be a brief one-sentence explanation
- reasoning should be several sentences citing specific, recent findings
"""
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 _extract_citations(response: object) -> list[dict[str, str]]:
"""Pull source URLs/titles from Gemini's grounding metadata."""
citations: list[dict[str, str]] = []
try:
candidates = getattr(response, "candidates", None) or []
for cand in candidates:
meta = getattr(cand, "grounding_metadata", None)
for chunk in (getattr(meta, "grounding_chunks", None) or []):
web = getattr(chunk, "web", None)
if web is not None:
citations.append({
"url": getattr(web, "uri", "") or "",
"title": getattr(web, "title", "") or "",
})
except Exception:
pass
return citations
class GeminiSentimentProvider:
@@ -90,6 +119,9 @@ class GeminiSentimentProvider:
confidence=confidence,
source="gemini",
timestamp=datetime.now(timezone.utc),
reasoning=reasoning,
citations=_extract_citations(response),
recommendation=_parse_recommendation(parsed.get("recommendation")),
)
except json.JSONDecodeError as exc:
+13 -4
View File
@@ -28,18 +28,26 @@ _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.
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 in this exact format (no markdown, no extra text):
{{"classification": "<bullish|bearish|neutral>", "confidence": <0-100>, "reasoning": "<brief explanation>"}}
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 a brief one-sentence explanation
- 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:
@@ -116,6 +124,7 @@ class OpenAICompatibleSentimentProvider:
source=self._source,
timestamp=datetime.now(timezone.utc),
reasoning=reasoning,
recommendation=_parse_recommendation(parsed.get("recommendation")),
)
except json.JSONDecodeError as exc:
+22 -12
View File
@@ -19,39 +19,48 @@ logger = logging.getLogger(__name__)
_CA_BUNDLE = os.environ.get("SSL_CERT_FILE", "")
_SENTIMENT_PROMPT = """\
Search the web for the LATEST news, analyst opinions, and market developments \
about the stock ticker {ticker} from the past 24-48 hours.
Search the web for the latest news, analyst ratings/opinions, and retail/social \
discussion (e.g. Reddit, StockTwits) about the stock ticker {ticker} from roughly \
the past 1-2 weeks.
Based on your web search findings, analyze the CURRENT market sentiment.
Assess (1) the current market sentiment and (2) whether BUYING here looks advisable now.
Respond ONLY with a JSON object in this exact format (no markdown, no extra text):
{{"classification": "<bullish|bearish|neutral>", "confidence": <0-100>, "reasoning": "<brief explanation citing recent news>"}}
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 paragraph citing specific analyst views, news, and retail sentiment you found, and what drives the recommendation>"}}
Rules:
- classification must be exactly one of: bullish, bearish, neutral
- classification = overall mood/tone of the coverage (bullish, bearish, neutral)
- recommendation = actionable view on buying at the current price (buy, hold, avoid)
- confidence must be an integer from 0 to 100
- reasoning should cite specific recent news or events you found
- reasoning should be several sentences citing specific, recent findings
"""
_SENTIMENT_BATCH_PROMPT = """\
Search the web for the LATEST news, analyst opinions, and market developments \
about each stock ticker from the past 24-48 hours.
Search the web for the latest news, analyst ratings/opinions, and retail/social \
discussion about each stock ticker from roughly the past 1-2 weeks.
Tickers:
{tickers_csv}
Respond ONLY with a JSON array (no markdown, no extra text), one object per ticker:
[{{"ticker":"AAPL","classification":"bullish|bearish|neutral","confidence":0-100,"reasoning":"brief explanation"}}]
[{{"ticker":"AAPL","classification":"bullish|bearish|neutral","confidence":0-100,"recommendation":"buy|hold|avoid","reasoning":"thorough explanation citing findings"}}]
Rules:
- Include every ticker exactly once
- ticker must be uppercase symbol
- Include every ticker exactly once; ticker must be the uppercase symbol
- 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 cite specific recent news or events you found
"""
VALID_CLASSIFICATIONS = {"bullish", "bearish", "neutral"}
VALID_RECOMMENDATIONS = {"buy", "hold", "avoid"}
def parse_recommendation(value: object) -> str | None:
"""Normalise a recommendation to buy/hold/avoid, or None if absent/invalid."""
v = str(value or "").strip().lower()
return v if v in VALID_RECOMMENDATIONS else None
class OpenAISentimentProvider:
@@ -135,6 +144,7 @@ class OpenAISentimentProvider:
timestamp=datetime.now(timezone.utc),
reasoning=reasoning,
citations=citations,
recommendation=parse_recommendation(parsed.get("recommendation")),
)
async def fetch_sentiment(self, ticker: str) -> SentimentData:
+1
View File
@@ -41,6 +41,7 @@ class SentimentData:
timestamp: datetime
reasoning: str = ""
citations: list[dict[str, str]] = field(default_factory=list) # [{"url": ..., "title": ...}]
recommendation: str | None = None # "buy" | "hold" | "avoid" — actionable LLM view
@dataclass(frozen=True, slots=True)
+2 -1
View File
@@ -30,7 +30,7 @@ def _parse_citations(citations_json: str) -> list[CitationItem]:
@router.get("/sentiment/{symbol}", response_model=APIEnvelope)
async def read_sentiment(
symbol: str,
lookback_hours: float = Query(24, gt=0, description="Lookback window in hours"),
lookback_hours: float = Query(168, gt=0, description="Lookback window in hours"),
_user=Depends(require_access),
db: AsyncSession = Depends(get_db),
) -> APIEnvelope:
@@ -51,6 +51,7 @@ async def read_sentiment(
timestamp=s.timestamp,
reasoning=s.reasoning,
citations=_parse_citations(s.citations_json),
recommendation=s.recommendation,
)
for s in scores
],
+37 -7
View File
@@ -16,10 +16,10 @@ from __future__ import annotations
import json
import logging
import asyncio
from datetime import date, datetime, timezone
from datetime import date, datetime, timedelta, timezone
from apscheduler.schedulers.asyncio import AsyncIOScheduler
from sqlalchemy import case, func, select
from sqlalchemy import case, func, or_, select
from sqlalchemy.ext.asyncio import AsyncSession
from app.config import settings
@@ -281,20 +281,49 @@ async def _get_ohlcv_priority_tickers(db: AsyncSession) -> list[str]:
async def _get_sentiment_priority_tickers(db: AsyncSession) -> list[str]:
"""Return symbols prioritized for sentiment collection.
"""Symbols to fetch sentiment for, budgeted to stay in the free search tier.
Priority:
1) Tickers with no sentiment records
2) Tickers with records, oldest latest sentiment timestamp first
3) Alphabetical tiebreaker
Scope: only tickers that matter — watchlist + open paper trades + top-N by
composite score. Skip any refreshed within ``sentiment_fresh_hours``. Cap the
run at ``sentiment_max_per_run``, oldest/missing first. Once the relevant set
is fresh, runs make zero grounded searches until it ages out.
"""
from app.models.paper_trade import PaperTrade
from app.models.score import CompositeScore
from app.models.watchlist import WatchlistEntry
relevant: set[int] = set()
wl = await db.execute(
select(WatchlistEntry.ticker_id)
.where(WatchlistEntry.entry_type != "dismissed")
.distinct()
)
relevant.update(r[0] for r in wl.all())
pt = await db.execute(
select(PaperTrade.ticker_id).where(PaperTrade.status == "open").distinct()
)
relevant.update(r[0] for r in pt.all())
top = await db.execute(
select(CompositeScore.ticker_id)
.order_by(CompositeScore.score.desc())
.limit(settings.sentiment_top_composite)
)
relevant.update(r[0] for r in top.all())
if not relevant:
return []
cutoff = datetime.now(timezone.utc) - timedelta(hours=settings.sentiment_fresh_hours)
latest_ts = func.max(SentimentScore.timestamp)
missing_first = case((latest_ts.is_(None), 0), else_=1)
result = await db.execute(
select(Ticker.symbol)
.outerjoin(SentimentScore, SentimentScore.ticker_id == Ticker.id)
.where(Ticker.id.in_(relevant))
.group_by(Ticker.id, Ticker.symbol)
.having(or_(latest_ts.is_(None), latest_ts < cutoff))
.order_by(missing_first.asc(), latest_ts.asc(), Ticker.symbol.asc())
.limit(settings.sentiment_max_per_run)
)
return list(result.scalars().all())
@@ -531,6 +560,7 @@ async def collect_sentiment() -> None:
timestamp=data.timestamp,
reasoning=data.reasoning,
citations=data.citations,
recommendation=data.recommendation,
)
_last_successful[job_name] = symbol
processed += 1
+1
View File
@@ -25,6 +25,7 @@ class SentimentScoreResult(BaseModel):
timestamp: datetime
reasoning: str = ""
citations: list[CitationItem] = []
recommendation: Literal["buy", "hold", "avoid"] | None = None
class SentimentResponse(BaseModel):
+1 -1
View File
@@ -347,7 +347,7 @@ async def _compute_sentiment_score(
get_sentiment_scores,
)
lookback_hours: float = 24
lookback_hours: float = 168 # 7 days — sentiment is collected sparsely to stay in free tier
decay_rate: float = 0.1
try:
+2
View File
@@ -37,6 +37,7 @@ async def store_sentiment(
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)
@@ -55,6 +56,7 @@ async def store_sentiment(
timestamp=timestamp,
reasoning=reasoning,
citations_json=json.dumps(citations),
recommendation=recommendation,
)
db.add(record)
await db.commit()
@@ -12,6 +12,12 @@ const classificationColors: Record<string, string> = {
neutral: 'text-gray-300',
};
const recommendationStyle: Record<string, string> = {
buy: 'bg-emerald-500/15 text-emerald-300 border-emerald-500/30',
hold: 'bg-amber-500/15 text-amber-300 border-amber-500/30',
avoid: 'bg-red-500/15 text-red-300 border-red-500/30',
};
export function SentimentPanel({ data }: SentimentPanelProps) {
const [expanded, setExpanded] = useState<boolean>(false);
const latest = data.scores[0];
@@ -21,6 +27,14 @@ export function SentimentPanel({ data }: SentimentPanelProps) {
<h3 className="mb-3 text-xs font-medium uppercase tracking-widest text-gray-500">Sentiment</h3>
{latest ? (
<>
{latest.recommendation && (
<div className="mb-3 flex items-center justify-between rounded-lg border bg-white/[0.02] px-3 py-2">
<span className="text-xs uppercase tracking-wider text-gray-500">LLM view</span>
<span className={`rounded-md border px-2.5 py-1 text-sm font-semibold uppercase tracking-wide ${recommendationStyle[latest.recommendation] ?? 'text-gray-300'}`}>
{latest.recommendation}
</span>
</div>
)}
<div className="space-y-2.5 text-sm">
<div className="flex justify-between">
<span className="text-gray-400">Classification</span>
@@ -45,12 +59,12 @@ export function SentimentPanel({ data }: SentimentPanelProps) {
<button
type="button"
onClick={() => setExpanded((prev) => !prev)}
className="mt-3 flex w-full items-center justify-center gap-1 text-xs text-gray-500 hover:text-gray-300 transition-colors"
className="mt-3 flex w-full items-center justify-center gap-1.5 rounded-md border border-white/[0.08] py-1.5 text-xs text-gray-400 hover:bg-white/[0.04] hover:text-gray-200 transition-colors"
aria-expanded={expanded}
aria-label={expanded ? 'Collapse details' : 'Expand details'}
>
{expanded ? 'Hide' : 'Full analysis & sources'}
<svg
className={`h-4 w-4 transition-transform ${expanded ? 'rotate-180' : ''}`}
className={`h-3.5 w-3.5 transition-transform ${expanded ? 'rotate-180' : ''}`}
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
+1
View File
@@ -319,6 +319,7 @@ export interface SentimentScore {
timestamp: string;
reasoning: string;
citations: CitationItem[];
recommendation: 'buy' | 'hold' | 'avoid' | null;
}
export interface SentimentResponse {
+2 -2
View File
@@ -92,7 +92,7 @@ async def test_returns_breakdown_with_sub_scores(db_session):
raw_map = {s["name"]: s["raw_value"] for s in breakdown["sub_scores"]}
assert raw_map["record_count"] == 3
assert raw_map["decay_rate"] == 0.1
assert raw_map["lookback_window"] == 24
assert raw_map["lookback_window"] == 168
assert breakdown["unavailable"] == []
@@ -115,7 +115,7 @@ async def test_formula_contains_decay_info(db_session):
assert "Time-decay" in breakdown["formula"]
assert "decay_rate" in breakdown["formula"]
assert "24" in breakdown["formula"]
assert "168" in breakdown["formula"]
@pytest.mark.asyncio
@@ -0,0 +1,46 @@
"""Tests for the LLM recommendation field on sentiment."""
from __future__ import annotations
import pytest
from app.models.ticker import Ticker
from app.providers.openai_sentiment import parse_recommendation
from app.services import sentiment_service as svc
from tests.conftest import _test_session_factory # type: ignore
@pytest.fixture
async def session():
async with _test_session_factory() as s:
yield s
def test_parse_recommendation_valid_and_invalid():
assert parse_recommendation("buy") == "buy"
assert parse_recommendation("HOLD") == "hold"
assert parse_recommendation(" Avoid ") == "avoid"
assert parse_recommendation("strong buy") is None
assert parse_recommendation(None) is None
assert parse_recommendation("") is None
async def test_store_persists_recommendation(session):
session.add(Ticker(symbol="AAA"))
await session.commit()
rec = await svc.store_sentiment(
session,
symbol="AAA",
classification="bullish",
confidence=70,
source="gemini",
reasoning="Analysts upgraded; strong retail buzz.",
citations=[{"url": "http://x", "title": "X"}],
recommendation="buy",
)
assert rec.recommendation == "buy"
rows = await svc.get_sentiment_scores(session, "AAA", lookback_hours=168)
assert rows[0].recommendation == "buy"
assert rows[0].reasoning.startswith("Analysts")