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}