feat: sentiment as a signed adjustment to the composite, not averaged in
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 <noreply@anthropic.com>
This commit is contained in:
@@ -33,6 +33,12 @@ class CompositeBreakdownResponse(BaseModel):
|
|||||||
missing_dimensions: list[str]
|
missing_dimensions: list[str]
|
||||||
renormalized_weights: dict[str, float]
|
renormalized_weights: dict[str, float]
|
||||||
formula: str
|
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):
|
class DimensionScoreResponse(BaseModel):
|
||||||
|
|||||||
@@ -28,13 +28,31 @@ DIMENSIONS = ["technical", "sr_quality", "sentiment", "fundamental", "momentum"]
|
|||||||
DEFAULT_WEIGHTS: dict[str, float] = {
|
DEFAULT_WEIGHTS: dict[str, float] = {
|
||||||
"technical": 0.25,
|
"technical": 0.25,
|
||||||
"sr_quality": 0.20,
|
"sr_quality": 0.20,
|
||||||
"sentiment": 0.15,
|
"sentiment": 0.10,
|
||||||
"fundamental": 0.20,
|
"fundamental": 0.20,
|
||||||
"momentum": 0.20,
|
"momentum": 0.20,
|
||||||
}
|
}
|
||||||
|
|
||||||
SCORING_WEIGHTS_KEY = "scoring_weights"
|
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
|
# Helpers
|
||||||
@@ -670,10 +688,15 @@ async def compute_composite_score(
|
|||||||
symbol: str,
|
symbol: str,
|
||||||
weights: dict[str, float] | None = None,
|
weights: dict[str, float] | None = None,
|
||||||
) -> tuple[float | None, list[str]]:
|
) -> 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).
|
Returns (composite_score, missing_dimensions).
|
||||||
Missing dimensions are excluded and weights re-normalized.
|
|
||||||
"""
|
"""
|
||||||
ticker = await _get_ticker(db, symbol)
|
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()}
|
dim_scores = {ds.dimension: ds for ds in result.scalars().all()}
|
||||||
|
|
||||||
available: list[tuple[str, float, float]] = [] # (dim, weight, score)
|
def _live(dim: str) -> float | None:
|
||||||
missing: list[str] = []
|
|
||||||
|
|
||||||
for dim in DIMENSIONS:
|
|
||||||
w = weights.get(dim, 0.0)
|
|
||||||
if w <= 0:
|
|
||||||
continue
|
|
||||||
ds = dim_scores.get(dim)
|
ds = dim_scores.get(dim)
|
||||||
if ds is not None and not ds.is_stale and ds.score is not None:
|
if ds is not None and not ds.is_stale and ds.score is not None:
|
||||||
available.append((dim, w, ds.score))
|
return ds.score
|
||||||
else:
|
return None
|
||||||
missing.append(dim)
|
|
||||||
|
|
||||||
if not available:
|
missing = [dim for dim in DIMENSIONS if _live(dim) is None]
|
||||||
return None, missing
|
|
||||||
|
|
||||||
# Re-normalize weights
|
# Base: re-normalized weighted average of the non-sentiment dimensions.
|
||||||
total_weight = sum(w for _, w, _ in available)
|
base_available = [
|
||||||
if total_weight == 0:
|
(dim, weights.get(dim, 0.0), _live(dim))
|
||||||
return None, missing
|
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
|
if base_available:
|
||||||
composite = max(0.0, min(100.0, composite))
|
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
|
# Persist composite score
|
||||||
now = datetime.now(timezone.utc)
|
now = datetime.now(timezone.utc)
|
||||||
@@ -822,22 +848,47 @@ async def get_score(
|
|||||||
"breakdown": breakdowns.get(dim),
|
"breakdown": breakdowns.get(dim),
|
||||||
})
|
})
|
||||||
|
|
||||||
# Build composite breakdown with re-normalization info
|
# Build composite breakdown: the non-sentiment base (re-normalized weighted
|
||||||
composite_breakdown = None
|
# average) plus sentiment as a signed adjustment around neutral.
|
||||||
available_weight_sum = sum(weights.get(d, 0.0) for d in available_dims)
|
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:
|
if available_weight_sum > 0:
|
||||||
renormalized_weights = {
|
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:
|
else:
|
||||||
renormalized_weights = {}
|
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 = {
|
composite_breakdown = {
|
||||||
"weights": weights,
|
"weights": weights,
|
||||||
"available_dimensions": available_dims,
|
"available_dimensions": base_dims,
|
||||||
"missing_dimensions": missing,
|
"missing_dimensions": missing,
|
||||||
"renormalized_weights": renormalized_weights,
|
"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 {
|
return {
|
||||||
|
|||||||
@@ -76,8 +76,14 @@ export function ScoreCard({ compositeScore, dimensions, compositeBreakdown, show
|
|||||||
{compositeScore !== null ? Math.round(compositeScore) : '—'}
|
{compositeScore !== null ? Math.round(compositeScore) : '—'}
|
||||||
</p>
|
</p>
|
||||||
{compositeBreakdown && (
|
{compositeBreakdown && (
|
||||||
<p className="mt-1 text-[10px] text-gray-500 leading-snug max-w-[200px]" data-testid="renorm-explanation">
|
<p className="mt-1 text-[10px] text-gray-500 leading-snug max-w-[220px]" data-testid="renorm-explanation">
|
||||||
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.'}
|
||||||
</p>
|
</p>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
@@ -107,11 +113,26 @@ export function ScoreCard({ compositeScore, dimensions, compositeBreakdown, show
|
|||||||
{d.dimension}
|
{d.dimension}
|
||||||
</span>
|
</span>
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
{weight != null && (
|
{d.dimension === 'sentiment' && compositeBreakdown?.sentiment_adjustment != null ? (
|
||||||
|
<span
|
||||||
|
className={`text-[10px] tabular-nums ${
|
||||||
|
compositeBreakdown.sentiment_adjustment > 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)}
|
||||||
|
</span>
|
||||||
|
) : weight != null ? (
|
||||||
<span className="text-[10px] text-gray-500 tabular-nums" data-testid={`weight-${d.dimension}`}>
|
<span className="text-[10px] text-gray-500 tabular-nums" data-testid={`weight-${d.dimension}`}>
|
||||||
{Math.round(weight * 100)}%
|
{Math.round(weight * 100)}%
|
||||||
</span>
|
</span>
|
||||||
)}
|
) : null}
|
||||||
<div className="h-1.5 w-20 rounded-full bg-white/[0.06] overflow-hidden">
|
<div className="h-1.5 w-20 rounded-full bg-white/[0.06] overflow-hidden">
|
||||||
<div
|
<div
|
||||||
className={`h-1.5 rounded-full bg-gradient-to-r ${barGradient(d.score)} transition-all duration-500`}
|
className={`h-1.5 rounded-full bg-gradient-to-r ${barGradient(d.score)} transition-all duration-500`}
|
||||||
|
|||||||
@@ -82,6 +82,10 @@ export interface CompositeBreakdown {
|
|||||||
missing_dimensions: string[];
|
missing_dimensions: string[];
|
||||||
renormalized_weights: Record<string, number>;
|
renormalized_weights: Record<string, number>;
|
||||||
formula: string;
|
formula: string;
|
||||||
|
base_score?: number | null;
|
||||||
|
sentiment_score?: number | null;
|
||||||
|
sentiment_adjustment?: number | null;
|
||||||
|
max_sentiment_adjustment?: number | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface ScoreResponse {
|
export interface ScoreResponse {
|
||||||
|
|||||||
@@ -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
|
||||||
Reference in New Issue
Block a user