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
@@ -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 {