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:
@@ -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