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>
This commit is contained in:
@@ -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")
|
||||
Reference in New Issue
Block a user