From f61e11adeab84c2eaf44dfec9421795782574a35 Mon Sep 17 00:00:00 2001
From: Dennis Thiessen
Date: Wed, 1 Jul 2026 09:34:37 +0200
Subject: [PATCH] feat: sentiment as a signed adjustment to the composite, not
averaged in
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Going from no sentiment to a bullish read used to be able to *lower* the composite:
sentiment was blended into the weighted average as an absolute level, so a bullish
75 diluted a ticker already scoring 78. That's backwards for a directional signal.
Now the non-sentiment dimensions form a re-normalized weighted-average base, and
sentiment is applied as a signed adjustment around neutral (50):
composite = clamp(base + MAX_ADJ * (sentiment - 50) / 50)
MAX_ADJ = sentiment weight * 100 (default weight 0.10 → ±10)
Neutral leaves the base unchanged, bullish adds and bearish subtracts (scaled by
confidence, since a 50%-confidence call maps to 50 → no effect), and no sentiment
never penalises. Default sentiment weight 0.15 → 0.10; the weight now means "max ±
points." Composite breakdown exposes base_score/sentiment_score/sentiment_adjustment,
and the ScoreCard shows "Base 78 · sentiment +5.0" plus the per-dimension adjustment.
Co-Authored-By: Claude Opus 4.8
---
app/schemas/score.py | 6 +
app/services/scoring_service.py | 105 +++++++++++++-----
frontend/src/components/ui/ScoreCard.tsx | 29 ++++-
frontend/src/lib/types.ts | 4 +
.../unit/test_scoring_sentiment_adjustment.py | 91 +++++++++++++++
5 files changed, 204 insertions(+), 31 deletions(-)
create mode 100644 tests/unit/test_scoring_sentiment_adjustment.py
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