351 lines
12 KiB
Python
351 lines
12 KiB
Python
"""
|
|
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"])
|