diff --git a/app/schemas/score.py b/app/schemas/score.py index 21920f9..0f3bbda 100644 --- a/app/schemas/score.py +++ b/app/schemas/score.py @@ -33,6 +33,12 @@ class CompositeBreakdownResponse(BaseModel): missing_dimensions: list[str] renormalized_weights: dict[str, float] formula: str + # Sentiment is applied as a signed adjustment on top of the non-sentiment base + # rather than averaged in. + base_score: float | None = None + sentiment_score: float | None = None + sentiment_adjustment: float | None = None + max_sentiment_adjustment: float | None = None class DimensionScoreResponse(BaseModel): diff --git a/app/services/scoring_service.py b/app/services/scoring_service.py index a238fa6..5732a1e 100644 --- a/app/services/scoring_service.py +++ b/app/services/scoring_service.py @@ -28,13 +28,31 @@ DIMENSIONS = ["technical", "sr_quality", "sentiment", "fundamental", "momentum"] DEFAULT_WEIGHTS: dict[str, float] = { "technical": 0.25, "sr_quality": 0.20, - "sentiment": 0.15, + "sentiment": 0.10, "fundamental": 0.20, "momentum": 0.20, } SCORING_WEIGHTS_KEY = "scoring_weights" +# Sentiment enters the composite as a signed adjustment around this neutral point, +# not as an averaged-in level (see _sentiment_adjustment / compute_composite_score). +NEUTRAL_SENTIMENT = 50.0 + + +def _sentiment_adjustment(sentiment_score: float | None, sentiment_weight: float) -> float: + """Signed points sentiment contributes to the base composite. + + +MAX_ADJ at max-confidence bullish (score 100), 0 at neutral (50), -MAX_ADJ at + max-confidence bearish (score 0), where MAX_ADJ = sentiment weight * 100. A + 50%-confidence call maps to score 50 → no effect (a coin flip carries no info), + so going from no sentiment to bullish can only ever help. + """ + if sentiment_score is None: + return 0.0 + max_adj = sentiment_weight * 100.0 + return max_adj * (sentiment_score - NEUTRAL_SENTIMENT) / 50.0 + # --------------------------------------------------------------------------- # Helpers @@ -670,10 +688,15 @@ async def compute_composite_score( symbol: str, weights: dict[str, float] | None = None, ) -> tuple[float | None, list[str]]: - """Compute composite score from available dimension scores. + """Compute the composite score. + + The non-sentiment dimensions form a re-normalized weighted-average *base*. + Sentiment is then applied as a signed adjustment around neutral (50), not + averaged in: neutral leaves the base unchanged, bullish adds and bearish + subtracts (scaled by confidence), so going from no sentiment to bullish can + only help. See _sentiment_adjustment. Returns (composite_score, missing_dimensions). - Missing dimensions are excluded and weights re-normalized. """ ticker = await _get_ticker(db, symbol) @@ -686,29 +709,32 @@ async def compute_composite_score( ) dim_scores = {ds.dimension: ds for ds in result.scalars().all()} - available: list[tuple[str, float, float]] = [] # (dim, weight, score) - missing: list[str] = [] - - for dim in DIMENSIONS: - w = weights.get(dim, 0.0) - if w <= 0: - continue + def _live(dim: str) -> float | None: ds = dim_scores.get(dim) if ds is not None and not ds.is_stale and ds.score is not None: - available.append((dim, w, ds.score)) - else: - missing.append(dim) + return ds.score + return None - if not available: - return None, missing + missing = [dim for dim in DIMENSIONS if _live(dim) is None] - # Re-normalize weights - total_weight = sum(w for _, w, _ in available) - if total_weight == 0: - return None, missing + # Base: re-normalized weighted average of the non-sentiment dimensions. + base_available = [ + (dim, weights.get(dim, 0.0), _live(dim)) + for dim in DIMENSIONS + if dim != "sentiment" and weights.get(dim, 0.0) > 0 and _live(dim) is not None + ] + sentiment_score = _live("sentiment") - composite = sum(w * s for _, w, s in available) / total_weight - composite = max(0.0, min(100.0, composite)) + if base_available: + total_weight = sum(w for _, w, _ in base_available) + base = sum(w * s for _, w, s in base_available) / total_weight + elif sentiment_score is not None: + base = NEUTRAL_SENTIMENT # only sentiment present → neutral baseline + else: + return None, missing # nothing to score + + delta = _sentiment_adjustment(sentiment_score, weights.get("sentiment", 0.0)) + composite = max(0.0, min(100.0, base + delta)) # Persist composite score now = datetime.now(timezone.utc) @@ -822,22 +848,47 @@ async def get_score( "breakdown": breakdowns.get(dim), }) - # Build composite breakdown with re-normalization info - composite_breakdown = None - available_weight_sum = sum(weights.get(d, 0.0) for d in available_dims) + # Build composite breakdown: the non-sentiment base (re-normalized weighted + # average) plus sentiment as a signed adjustment around neutral. + base_dims = [d for d in available_dims if d != "sentiment"] + available_weight_sum = sum(weights.get(d, 0.0) for d in base_dims) if available_weight_sum > 0: renormalized_weights = { - d: weights.get(d, 0.0) / available_weight_sum for d in available_dims + d: weights.get(d, 0.0) / available_weight_sum for d in base_dims } else: renormalized_weights = {} + fresh = { + ds.dimension: ds.score + for ds in dim_scores_list + if not ds.is_stale and ds.score is not None + } + if renormalized_weights: + base_score = sum(renormalized_weights[d] * fresh[d] for d in base_dims) + elif "sentiment" in fresh: + base_score = NEUTRAL_SENTIMENT + else: + base_score = None + + sentiment_val = fresh.get("sentiment") + sentiment_weight = weights.get("sentiment", 0.0) + sentiment_adjustment = _sentiment_adjustment(sentiment_val, sentiment_weight) + composite_breakdown = { "weights": weights, - "available_dimensions": available_dims, + "available_dimensions": base_dims, "missing_dimensions": missing, "renormalized_weights": renormalized_weights, - "formula": "Weighted average of available dimensions with re-normalized weights: sum(weight_i * score_i) / sum(weight_i)", + "base_score": base_score, + "sentiment_score": sentiment_val, + "sentiment_adjustment": sentiment_adjustment, + "max_sentiment_adjustment": sentiment_weight * 100.0, + "formula": ( + "Base = re-normalized weighted average of the non-sentiment dimensions. " + "Composite = base + sentiment adjustment, where adjustment = " + "MAX_ADJ * (sentiment - 50) / 50 and MAX_ADJ = sentiment weight * 100." + ), } return { diff --git a/frontend/src/components/ui/ScoreCard.tsx b/frontend/src/components/ui/ScoreCard.tsx index b3da6d0..f63a31c 100644 --- a/frontend/src/components/ui/ScoreCard.tsx +++ b/frontend/src/components/ui/ScoreCard.tsx @@ -76,8 +76,14 @@ export function ScoreCard({ compositeScore, dimensions, compositeBreakdown, show {compositeScore !== null ? Math.round(compositeScore) : '—'}

{compositeBreakdown && ( -

- Weighted average of available dimensions with re-normalized weights. +

+ {compositeBreakdown.sentiment_adjustment != null && + compositeBreakdown.base_score != null && + Math.abs(compositeBreakdown.sentiment_adjustment) >= 0.05 + ? `Base ${Math.round(compositeBreakdown.base_score)} · sentiment ${ + compositeBreakdown.sentiment_adjustment >= 0 ? '+' : '−' + }${Math.abs(compositeBreakdown.sentiment_adjustment).toFixed(1)}` + : 'Weighted base of the other dimensions; sentiment adjusts it up or down.'}

)} @@ -107,11 +113,26 @@ export function ScoreCard({ compositeScore, dimensions, compositeBreakdown, show {d.dimension}
- {weight != null && ( + {d.dimension === 'sentiment' && compositeBreakdown?.sentiment_adjustment != null ? ( + 0.05 + ? 'text-emerald-400/80' + : compositeBreakdown.sentiment_adjustment < -0.05 + ? 'text-red-400/80' + : 'text-gray-500' + }`} + data-testid="weight-sentiment" + title="Points sentiment adds to or subtracts from the base composite" + > + {compositeBreakdown.sentiment_adjustment >= 0 ? '+' : '−'} + {Math.abs(compositeBreakdown.sentiment_adjustment).toFixed(1)} + + ) : weight != null ? ( {Math.round(weight * 100)}% - )} + ) : null}
; formula: string; + base_score?: number | null; + sentiment_score?: number | null; + sentiment_adjustment?: number | null; + max_sentiment_adjustment?: number | null; } export interface ScoreResponse { diff --git a/tests/unit/test_scoring_sentiment_adjustment.py b/tests/unit/test_scoring_sentiment_adjustment.py new file mode 100644 index 0000000..8908c88 --- /dev/null +++ b/tests/unit/test_scoring_sentiment_adjustment.py @@ -0,0 +1,91 @@ +"""Composite scoring: sentiment applied as a signed adjustment around neutral, +not averaged in. Going from no sentiment to bullish must never lower the score.""" + +from __future__ import annotations + +from datetime import datetime, timezone + +import pytest +from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker, create_async_engine + +from app.database import Base +from app.models.score import DimensionScore +from app.models.ticker import Ticker +from app.services import scoring_service as svc + + +@pytest.fixture +async def db(): + engine = create_async_engine("sqlite+aiosqlite://", echo=False) + async with engine.begin() as conn: + await conn.run_sync(Base.metadata.create_all) + factory = async_sessionmaker(engine, class_=AsyncSession, expire_on_commit=False) + async with factory() as session: + yield session + await engine.dispose() + + +# Non-sentiment dims all at 78 → base = 78 at any positive weights. +BASE_DIMS = {"technical": 78.0, "sr_quality": 78.0, "fundamental": 78.0, "momentum": 78.0} + + +async def _seed(session, dims: dict[str, float]) -> None: + ticker = Ticker(symbol="AAA") + session.add(ticker) + await session.flush() + now = datetime.now(timezone.utc) + for dim, score in dims.items(): + session.add(DimensionScore( + ticker_id=ticker.id, dimension=dim, score=score, is_stale=False, computed_at=now + )) + await session.flush() + + +def test_sentiment_adjustment_formula(): + # weight 0.10 → MAX_ADJ = 10 points + assert svc._sentiment_adjustment(None, 0.10) == 0.0 + assert svc._sentiment_adjustment(50.0, 0.10) == 0.0 # neutral / coin-flip + assert svc._sentiment_adjustment(75.0, 0.10) == pytest.approx(5.0) # bullish 75% + assert svc._sentiment_adjustment(100.0, 0.10) == pytest.approx(10.0) + assert svc._sentiment_adjustment(25.0, 0.10) == pytest.approx(-5.0) # bearish 75% + assert svc._sentiment_adjustment(0.0, 0.10) == pytest.approx(-10.0) + + +async def test_no_sentiment_equals_base(db): + await _seed(db, BASE_DIMS) + composite, missing = await svc.compute_composite_score(db, "AAA") + assert composite == pytest.approx(78.0) + assert "sentiment" in missing + + +async def test_bullish_raises_above_base(db): + await _seed(db, {**BASE_DIMS, "sentiment": 75.0}) # bullish, 75% confidence + composite, _ = await svc.compute_composite_score(db, "AAA") + assert composite == pytest.approx(83.0) # 78 + 5 — the whole point + + +async def test_neutral_leaves_base_unchanged(db): + await _seed(db, {**BASE_DIMS, "sentiment": 50.0}) + composite, _ = await svc.compute_composite_score(db, "AAA") + assert composite == pytest.approx(78.0) + + +async def test_bearish_lowers_base(db): + await _seed(db, {**BASE_DIMS, "sentiment": 25.0}) # bearish, 75% confidence + composite, _ = await svc.compute_composite_score(db, "AAA") + assert composite == pytest.approx(73.0) # 78 - 5 + + +async def test_only_sentiment_uses_neutral_base(db): + await _seed(db, {"sentiment": 75.0}) + composite, _ = await svc.compute_composite_score(db, "AAA") + assert composite == pytest.approx(55.0) # base 50 + 5 + + +async def test_no_dimensions_returns_none(db): + ticker = Ticker(symbol="AAA") + db.add(ticker) + await db.flush() + composite, missing = await svc.compute_composite_score(db, "AAA") + assert composite is None + assert len(missing) == 5