249 lines
9.3 KiB
Python
249 lines
9.3 KiB
Python
"""Tests for aggregation scoring — recency decay, source credibility weighting,
|
|
and market context integration."""
|
|
from datetime import datetime, timedelta, timezone
|
|
|
|
from services.aggregation.scoring import (
|
|
DEFAULT_CONFIG,
|
|
ScoringConfig,
|
|
WeightedSignal,
|
|
compute_signal_weight,
|
|
credibility_weight,
|
|
market_context_multiplier,
|
|
recency_weight,
|
|
sentiment_to_numeric,
|
|
weighted_sentiment_average,
|
|
)
|
|
from services.shared.schemas import MarketContext
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# recency_weight
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
def test_recency_weight_at_zero_age():
|
|
"""A document published exactly at reference time gets weight 1.0."""
|
|
now = datetime(2026, 4, 11, 12, 0, 0, tzinfo=timezone.utc)
|
|
assert recency_weight(now, now, "7d") == 1.0
|
|
|
|
|
|
def test_recency_weight_future_document():
|
|
"""A document published after reference time is clamped to 1.0."""
|
|
now = datetime(2026, 4, 11, 12, 0, 0, tzinfo=timezone.utc)
|
|
future = now + timedelta(hours=1)
|
|
assert recency_weight(future, now, "7d") == 1.0
|
|
|
|
|
|
def test_recency_weight_at_one_half_life():
|
|
"""After exactly one half-life the weight should be ~0.5."""
|
|
now = datetime(2026, 4, 11, 12, 0, 0, tzinfo=timezone.utc)
|
|
half_life_7d = DEFAULT_CONFIG.half_life_hours["7d"] # 72 hours
|
|
published = now - timedelta(hours=half_life_7d)
|
|
w = recency_weight(published, now, "7d")
|
|
assert abs(w - 0.5) < 1e-9
|
|
|
|
|
|
def test_recency_weight_very_old_clamps_to_min():
|
|
"""A very old document should not go below min_recency_weight."""
|
|
now = datetime(2026, 4, 11, 12, 0, 0, tzinfo=timezone.utc)
|
|
ancient = now - timedelta(days=365)
|
|
w = recency_weight(ancient, now, "7d")
|
|
assert w == DEFAULT_CONFIG.min_recency_weight
|
|
|
|
|
|
def test_recency_weight_different_windows():
|
|
"""Shorter windows decay faster than longer ones."""
|
|
now = datetime(2026, 4, 11, 12, 0, 0, tzinfo=timezone.utc)
|
|
published = now - timedelta(hours=24)
|
|
w_intraday = recency_weight(published, now, "intraday")
|
|
w_90d = recency_weight(published, now, "90d")
|
|
assert w_intraday < w_90d
|
|
|
|
|
|
def test_recency_weight_naive_datetimes():
|
|
"""Naive datetimes are treated as UTC."""
|
|
now = datetime(2026, 4, 11, 12, 0, 0)
|
|
published = now - timedelta(hours=72)
|
|
w = recency_weight(published, now, "7d")
|
|
assert abs(w - 0.5) < 1e-9
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# credibility_weight
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
def test_credibility_weight_high():
|
|
"""High credibility source gets weight close to 1.0."""
|
|
assert abs(credibility_weight(1.0) - 1.0) < 1e-9
|
|
|
|
|
|
def test_credibility_weight_low_clamped():
|
|
"""Credibility below floor is clamped to floor."""
|
|
w = credibility_weight(0.0)
|
|
assert abs(w - DEFAULT_CONFIG.credibility_floor) < 1e-9
|
|
|
|
|
|
def test_credibility_weight_mid():
|
|
"""Mid-range credibility passes through with exponent=1."""
|
|
assert abs(credibility_weight(0.5) - 0.5) < 1e-9
|
|
|
|
|
|
def test_credibility_weight_custom_exponent():
|
|
"""Custom exponent penalises low credibility more."""
|
|
cfg = ScoringConfig(credibility_exponent=2.0)
|
|
w = credibility_weight(0.5, config=cfg)
|
|
assert abs(w - 0.25) < 1e-9
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# compute_signal_weight
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
def test_signal_weight_gates_low_confidence():
|
|
"""Documents below confidence floor get zero combined weight."""
|
|
now = datetime(2026, 4, 11, 12, 0, 0, tzinfo=timezone.utc)
|
|
sw = compute_signal_weight(
|
|
published_at=now,
|
|
reference_time=now,
|
|
window="7d",
|
|
source_credibility=0.8,
|
|
extraction_confidence=0.1, # below default 0.2 floor
|
|
)
|
|
assert sw.combined == 0.0
|
|
assert sw.confidence_gate == 0.0
|
|
|
|
|
|
def test_signal_weight_fresh_high_credibility():
|
|
"""Fresh doc with high credibility and default novelty gets a strong weight."""
|
|
now = datetime(2026, 4, 11, 12, 0, 0, tzinfo=timezone.utc)
|
|
sw = compute_signal_weight(
|
|
published_at=now,
|
|
reference_time=now,
|
|
window="7d",
|
|
source_credibility=1.0,
|
|
novelty_score=0.5,
|
|
extraction_confidence=0.8,
|
|
)
|
|
# recency=1.0, credibility=1.0, bonus=0.125, gate=1.0
|
|
expected = 1.0 * 1.0 * (1.0 + 0.125)
|
|
assert abs(sw.combined - expected) < 1e-9
|
|
|
|
|
|
def test_signal_weight_novelty_bonus():
|
|
"""Higher novelty gives a proportionally higher combined weight."""
|
|
now = datetime(2026, 4, 11, 12, 0, 0, tzinfo=timezone.utc)
|
|
sw_low = compute_signal_weight(now, now, "7d", 0.8, novelty_score=0.0, extraction_confidence=0.8)
|
|
sw_high = compute_signal_weight(now, now, "7d", 0.8, novelty_score=1.0, extraction_confidence=0.8)
|
|
assert sw_high.combined > sw_low.combined
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# sentiment helpers
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
def test_sentiment_to_numeric():
|
|
assert sentiment_to_numeric("positive") == 1.0
|
|
assert sentiment_to_numeric("negative") == -1.0
|
|
assert sentiment_to_numeric("neutral") == 0.0
|
|
assert sentiment_to_numeric("mixed") == 0.0
|
|
assert sentiment_to_numeric("unknown") == 0.0
|
|
|
|
|
|
def test_weighted_sentiment_average_empty():
|
|
assert weighted_sentiment_average([]) == 0.0
|
|
|
|
|
|
def test_weighted_sentiment_average_single():
|
|
now = datetime(2026, 4, 11, 12, 0, 0, tzinfo=timezone.utc)
|
|
sw = compute_signal_weight(now, now, "7d", 0.8, extraction_confidence=0.8)
|
|
signals = [WeightedSignal("doc1", sw, sentiment_value=1.0, impact_score=0.7)]
|
|
avg = weighted_sentiment_average(signals)
|
|
assert abs(avg - 1.0) < 1e-9 # single positive signal → 1.0
|
|
|
|
|
|
def test_weighted_sentiment_average_opposing():
|
|
"""Equal-weight opposing signals should cancel to ~0."""
|
|
now = datetime(2026, 4, 11, 12, 0, 0, tzinfo=timezone.utc)
|
|
sw = compute_signal_weight(now, now, "7d", 0.8, extraction_confidence=0.8)
|
|
signals = [
|
|
WeightedSignal("doc1", sw, sentiment_value=1.0, impact_score=0.5),
|
|
WeightedSignal("doc2", sw, sentiment_value=-1.0, impact_score=0.5),
|
|
]
|
|
avg = weighted_sentiment_average(signals)
|
|
assert abs(avg) < 1e-9
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# market_context_multiplier
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
def test_market_context_multiplier_none():
|
|
"""No market context returns 1.0 (no adjustment)."""
|
|
assert market_context_multiplier(None) == 1.0
|
|
|
|
|
|
def test_market_context_multiplier_no_data():
|
|
"""MarketContext with no bars returns 1.0."""
|
|
ctx = MarketContext(ticker="AAPL", bars_available=0)
|
|
assert market_context_multiplier(ctx) == 1.0
|
|
|
|
|
|
def test_market_context_multiplier_low_volatility():
|
|
"""Below-threshold volatility produces no boost."""
|
|
ctx = MarketContext(ticker="AAPL", volatility=0.5, volume_change_pct=10.0, bars_available=5)
|
|
assert market_context_multiplier(ctx) == 1.0
|
|
|
|
|
|
def test_market_context_multiplier_high_volatility():
|
|
"""Above-threshold volatility produces a boost > 1.0."""
|
|
ctx = MarketContext(ticker="AAPL", volatility=3.0, volume_change_pct=10.0, bars_available=5)
|
|
m = market_context_multiplier(ctx)
|
|
assert m > 1.0
|
|
assert m <= 1.0 + DEFAULT_CONFIG.volatility_recency_boost_max + DEFAULT_CONFIG.volume_surge_boost
|
|
|
|
|
|
def test_market_context_multiplier_volume_surge():
|
|
"""Volume surge above threshold adds a boost."""
|
|
ctx = MarketContext(ticker="AAPL", volatility=0.5, volume_change_pct=80.0, bars_available=5)
|
|
m = market_context_multiplier(ctx)
|
|
assert abs(m - (1.0 + DEFAULT_CONFIG.volume_surge_boost)) < 1e-9
|
|
|
|
|
|
def test_market_context_multiplier_both_triggers():
|
|
"""Both volatility and volume surge stack."""
|
|
ctx = MarketContext(ticker="AAPL", volatility=3.0, volume_change_pct=80.0, bars_available=5)
|
|
m = market_context_multiplier(ctx)
|
|
# Should be > 1.0 + volume_surge_boost alone
|
|
assert m > 1.0 + DEFAULT_CONFIG.volume_surge_boost
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# compute_signal_weight with market context
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
def test_signal_weight_with_market_context_boost():
|
|
"""Market context with high volatility should increase combined weight."""
|
|
now = datetime(2026, 4, 11, 12, 0, 0, tzinfo=timezone.utc)
|
|
ctx = MarketContext(ticker="AAPL", volatility=3.0, volume_change_pct=80.0, bars_available=10)
|
|
|
|
sw_no_ctx = compute_signal_weight(now, now, "7d", 0.8, extraction_confidence=0.8)
|
|
sw_with_ctx = compute_signal_weight(now, now, "7d", 0.8, extraction_confidence=0.8, market_ctx=ctx)
|
|
|
|
assert sw_with_ctx.combined > sw_no_ctx.combined
|
|
assert sw_with_ctx.market_ctx_multiplier > 1.0
|
|
assert sw_no_ctx.market_ctx_multiplier == 1.0
|
|
|
|
|
|
def test_signal_weight_market_context_gated_still_zero():
|
|
"""Low confidence docs stay at zero even with market context boost."""
|
|
now = datetime(2026, 4, 11, 12, 0, 0, tzinfo=timezone.utc)
|
|
ctx = MarketContext(ticker="AAPL", volatility=5.0, volume_change_pct=100.0, bars_available=10)
|
|
|
|
sw = compute_signal_weight(now, now, "7d", 0.8, extraction_confidence=0.1, market_ctx=ctx)
|
|
assert sw.combined == 0.0
|