""" Comprehensive tests for Signal Processing components. """ import pytest import numpy as np from unittest.mock import AsyncMock, MagicMock, patch from sentiment_engine.signal.processor import FearGreedProcessor from sentiment_engine.signal.velocity import VelocityCalculator from sentiment_engine.signal.decay import DecayEngine from sentiment_engine.signal.fusion import MultiSourceFusion from sentiment_engine.schemas.processed import ProcessedItem, SentimentScores, EmotionScores class TestFearGreedProcessor: """Tests for FearGreedProcessor""" @pytest.fixture def processor(self): return FearGreedProcessor() def test_compute_fear_greed_basic(self, processor): """Should compute basic fear/greed index""" items = [ ProcessedItem( payload_id="test1", source_id="source1", source_type="news", ingest_ts=1700000000.0, publish_ts=1700000000.0, entities=[], sentiment_per_asset={"BTC": SentimentScores(polarity=0.8, confidence=0.9, positive_prob=0.9, negative_prob=0.05, neutral_prob=0.05)}, emotions_per_asset={"BTC": EmotionScores(joy=0.8, fear=0.1, anger=0.0, greed=0.7, sadness=0.0, intensity=0.8)}, events=[], temporal=None, credibility=None, processed_ts=1700000000.0, processing_latency_ms=100, model_versions={} ) ] result = processor.compute(items) assert 0 <= result <= 100 # High positive sentiment should give high (greed) index assert result > 50 def test_compute_fear_greed_negative(self, processor): """Negative sentiment should give low (fear) index""" items = [ ProcessedItem( payload_id="test1", source_id="source1", source_type="news", ingest_ts=1700000000.0, publish_ts=1700000000.0, entities=[], sentiment_per_asset={"BTC": SentimentScores(polarity=-0.8, confidence=0.9, positive_prob=0.05, negative_prob=0.9, neutral_prob=0.05)}, emotions_per_asset={"BTC": EmotionScores(joy=0.1, fear=0.8, anger=0.3, greed=0.0, sadness=0.4, intensity=0.8)}, events=[], temporal=None, credibility=None, processed_ts=1700000000.0, processing_latency_ms=100, model_versions={} ) ] result = processor.compute(items) assert 0 <= result <= 100 assert result < 50 def test_compute_empty(self, processor): """Empty items should return neutral""" result = processor.compute([]) assert result == 50 def test_compute_multiple_assets(self, processor): """Should aggregate across multiple assets""" items = [ ProcessedItem( payload_id="test1", source_id="source1", source_type="news", ingest_ts=1700000000.0, publish_ts=1700000000.0, entities=[], sentiment_per_asset={ "BTC": SentimentScores(polarity=0.5, confidence=0.8, positive_prob=0.7, negative_prob=0.1, neutral_prob=0.2), "ETH": SentimentScores(polarity=-0.3, confidence=0.7, positive_prob=0.2, negative_prob=0.6, neutral_prob=0.2) }, emotions_per_asset={}, events=[], temporal=None, credibility=None, processed_ts=1700000000.0, processing_latency_ms=100, model_versions={} ) ] result = processor.compute(items) assert 0 <= result <= 100 class TestVelocityCalculator: """Tests for VelocityCalculator""" @pytest.fixture def calculator(self): return VelocityCalculator() def test_compute_velocity_basic(self, calculator): """Should compute velocity from recent items""" now = 1700000000.0 items = [ {"asset_id": "BTC", "publish_ts": now - 3600, "sentiment_polarity": 0.5}, {"asset_id": "BTC", "publish_ts": now - 1800, "sentiment_polarity": 0.7}, ] velocity = calculator.compute_velocity("BTC", items) assert isinstance(velocity, float) def test_velocity_positive_trend(self, calculator): """Positive trend should give positive velocity""" now = 1700000000.0 items = [ {"asset_id": "BTC", "publish_ts": now - 7200, "sentiment_polarity": 0.2}, {"asset_id": "BTC", "publish_ts": now - 3600, "sentiment_polarity": 0.5}, {"asset_id": "BTC", "publish_ts": now - 1800, "sentiment_polarity": 0.8}, ] velocity = calculator.compute_velocity("BTC", items) assert velocity > 0 def test_velocity_negative_trend(self, calculator): """Negative trend should give negative velocity""" now = 1700000000.0 items = [ {"asset_id": "BTC", "publish_ts": now - 7200, "sentiment_polarity": 0.8}, {"asset_id": "BTC", "publish_ts": now - 3600, "sentiment_polarity": 0.5}, {"asset_id": "BTC", "publish_ts": now - 1800, "sentiment_polarity": 0.2}, ] velocity = calculator.compute_velocity("BTC", items) assert velocity < 0 def test_velocity_flat(self, calculator): """Flat sentiment should give near-zero velocity""" now = 1700000000.0 items = [ {"asset_id": "BTC", "publish_ts": now - 7200, "sentiment_polarity": 0.5}, {"asset_id": "BTC", "publish_ts": now - 3600, "sentiment_polarity": 0.5}, {"asset_id": "BTC", "publish_ts": now - 1800, "sentiment_polarity": 0.5}, ] velocity = calculator.compute_velocity("BTC", items) assert abs(velocity) < 0.1 def test_velocity_empty(self, calculator): """Empty items should return 0""" velocity = calculator.compute_velocity("BTC", []) assert velocity == 0 def test_velocity_single_point(self, calculator): """Single point should return 0""" now = 1700000000.0 items = [{"asset_id": "BTC", "publish_ts": now - 3600, "sentiment_polarity": 0.5}] velocity = calculator.compute_velocity("BTC", items) assert velocity == 0 class TestDecayEngine: """Tests for DecayEngine""" @pytest.fixture def engine(self): return DecayEngine() def test_compute_decay_exponential(self, engine): """Exponential decay should decrease over time""" now = 1700000000.0 weight_recent = engine.compute_decay(now - 60, now, halflife_minutes=60) # 1 min ago weight_old = engine.compute_decay(now - 3600, now, halflife_minutes=60) # 1 hour ago assert weight_recent > weight_old def test_compute_decay_half_life(self, engine): """At half-life, weight should be 0.5""" now = 1700000000.0 halflife = 60 # minutes # At exactly one half-life weight = engine.compute_decay(now - halflife * 60, now, halflife_minutes=halflife) assert abs(weight - 0.5) < 0.01 def test_compute_decay_now(self, engine): """Weight at now should be 1""" now = 1700000000.0 weight = engine.compute_decay(now, now, halflife_minutes=60) assert weight == 1.0 def test_compute_decay_future(self, engine): """Future timestamps should return 1""" now = 1700000000.0 weight = engine.compute_decay(now + 3600, now, halflife_minutes=60) assert weight == 1.0 def test_compute_decay_custom_halflife(self, engine): """Custom half-life should work""" now = 1700000000.0 weight_short = engine.compute_decay(now - 3600, now, halflife_minutes=30) # 1 hour ago, 30min halflife weight_long = engine.compute_decay(now - 3600, now, halflife_minutes=120) # 1 hour ago, 2hr halflife # Shorter half-life = more decay assert weight_short < weight_long class TestMultiSourceFusion: """Tests for MultiSourceFusion""" @pytest.fixture def fusion(self): return MultiSourceFusion() def test_fuse_equal_weights(self, fusion): """Equal weights should produce average""" scores = { "source1": 0.8, "source2": 0.2, } result = fusion.fuse(scores, weights={"source1": 0.5, "source2": 0.5}) assert abs(result - 0.5) < 0.01 def test_fuse_weighted(self, fusion): """Weighted fusion should respect weights""" scores = { "source1": 1.0, "source2": 0.0, } result = fusion.fuse(scores, weights={"source1": 0.8, "source2": 0.2}) assert result > 0.7 # Closer to source1 def test_fuse_normalizes_weights(self, fusion): """Should normalize weights that don't sum to 1""" scores = { "source1": 1.0, "source2": 0.0, } result = fusion.fuse(scores, weights={"source1": 2.0, "source2": 1.0}) # Weights normalized to 2/3 and 1/3 assert result > 0.6 def test_fuse_missing_weight(self, fusion): """Missing weights should default to equal""" scores = { "source1": 1.0, "source2": 0.0, "source3": 0.5, } result = fusion.fuse(scores, weights={"source1": 0.5, "source2": 0.5}) # source3 gets default weight assert 0 <= result <= 1 def test_fuse_empty(self, fusion): """Empty scores should return neutral""" result = fusion.fuse({}) assert result == 0.5 class TestSignalProcessingIntegration: """Integration tests for signal processing""" def test_fear_greed_velocity_correlation(self): """High fear/greed with positive velocity should align""" from sentiment_engine.signal.processor import FearGreedProcessor from sentiment_engine.signal.velocity import VelocityCalculator processor = FearGreedProcessor() calculator = VelocityCalculator() now = 1700000000.0 # Create items with positive sentiment trend items = [ ProcessedItem( payload_id="test1", source_id="source1", source_type="news", ingest_ts=now, publish_ts=now - 3600, entities=[], sentiment_per_asset={"BTC": SentimentScores(polarity=0.8, confidence=0.9, positive_prob=0.9, negative_prob=0.05, neutral_prob=0.05)}, emotions_per_asset={"BTC": EmotionScores(joy=0.8, fear=0.1, anger=0.0, greed=0.7, sadness=0.0, intensity=0.8)}, events=[], temporal=None, credibility=None, processed_ts=now, processing_latency_ms=100, model_versions={} ), ProcessedItem( payload_id="test2", source_id="source1", source_type="news", ingest_ts=now, publish_ts=now - 1800, entities=[], sentiment_per_asset={"BTC": SentimentScores(polarity=0.9, confidence=0.95, positive_prob=0.95, negative_prob=0.02, neutral_prob=0.03)}, emotions_per_asset={"BTC": EmotionScores(joy=0.9, fear=0.05, anger=0.0, greed=0.8, sadness=0.0, intensity=0.9)}, events=[], temporal=None, credibility=None, processed_ts=now, processing_latency_ms=100, model_versions={} ) ] fg = processor.compute(items) velocity = calculator.compute_velocity("BTC", [ {"asset_id": "BTC", "publish_ts": 1700000000.0 - 3600, "sentiment_polarity": 0.8}, {"asset_id": "BTC", "publish_ts": 1700000000.0 - 1800, "sentiment_polarity": 0.9}, ]) # Both should be positive assert fg > 50 assert velocity > 0 if __name__ == "__main__": pytest.main([__file__, "-v"])