feat: sentiment as a signed adjustment to the composite, not averaged in
Deploy / lint (push) Successful in 23s
Deploy / test (push) Successful in 54s
Deploy / deploy (push) Successful in 31s

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:
2026-07-01 09:34:37 +02:00
parent 1566b84379
commit f61e11adea
5 changed files with 204 additions and 31 deletions
+25 -4
View File
@@ -76,8 +76,14 @@ export function ScoreCard({ compositeScore, dimensions, compositeBreakdown, show
{compositeScore !== null ? Math.round(compositeScore) : '—'}
</p>
{compositeBreakdown && (
<p className="mt-1 text-[10px] text-gray-500 leading-snug max-w-[200px]" data-testid="renorm-explanation">
Weighted average of available dimensions with re-normalized weights.
<p className="mt-1 text-[10px] text-gray-500 leading-snug max-w-[220px]" data-testid="renorm-explanation">
{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>
)}
</div>
@@ -107,11 +113,26 @@ export function ScoreCard({ compositeScore, dimensions, compositeBreakdown, show
{d.dimension}
</span>
<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}`}>
{Math.round(weight * 100)}%
</span>
)}
) : null}
<div className="h-1.5 w-20 rounded-full bg-white/[0.06] overflow-hidden">
<div
className={`h-1.5 rounded-full bg-gradient-to-r ${barGradient(d.score)} transition-all duration-500`}
+4
View File
@@ -82,6 +82,10 @@ export interface CompositeBreakdown {
missing_dimensions: string[];
renormalized_weights: Record<string, number>;
formula: string;
base_score?: number | null;
sentiment_score?: number | null;
sentiment_adjustment?: number | null;
max_sentiment_adjustment?: number | null;
}
export interface ScoreResponse {