"""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