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:
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user