Skip to main content
Glama
knishioka

IB Analytics MCP Server

by knishioka
test_composite.py17.7 kB
""" Tests for Issue #7: Composite Sentiment Analyzer Acceptance Criteria: - [ ] CompositeSentimentAnalyzer aggregates multiple sentiment sources - [ ] Configurable weights for different sentiment sources - [ ] Combine news sentiment with future sources (social media, analyst ratings) - [ ] Return aggregated SentimentScore with combined confidence - [ ] Handle missing or failed sentiment sources gracefully """ from datetime import datetime from decimal import Decimal from unittest.mock import AsyncMock import pytest # These imports will fail until implementation exists try: from ib_sec_mcp.analyzers.sentiment.base import ( BaseSentimentAnalyzer, SentimentScore, ) from ib_sec_mcp.analyzers.sentiment.composite import CompositeSentimentAnalyzer except ImportError: CompositeSentimentAnalyzer = None BaseSentimentAnalyzer = None SentimentScore = None pytestmark = pytest.mark.skipif( CompositeSentimentAnalyzer is None, reason="CompositeSentimentAnalyzer not implemented yet (TDD red phase)", ) @pytest.fixture def mock_news_analyzer(): """Mock news sentiment analyzer""" analyzer = AsyncMock(spec=BaseSentimentAnalyzer) analyzer.analyze_sentiment = AsyncMock( return_value=SentimentScore( score=Decimal("0.7"), confidence=Decimal("0.8"), timestamp=datetime.now() ) ) return analyzer @pytest.fixture def mock_social_analyzer(): """Mock social media sentiment analyzer (future implementation)""" analyzer = AsyncMock(spec=BaseSentimentAnalyzer) analyzer.analyze_sentiment = AsyncMock( return_value=SentimentScore( score=Decimal("0.5"), confidence=Decimal("0.6"), timestamp=datetime.now() ) ) return analyzer @pytest.fixture def mock_analyst_analyzer(): """Mock analyst rating analyzer (future implementation)""" analyzer = AsyncMock(spec=BaseSentimentAnalyzer) analyzer.analyze_sentiment = AsyncMock( return_value=SentimentScore( score=Decimal("0.8"), confidence=Decimal("0.9"), timestamp=datetime.now() ) ) return analyzer class TestCompositeSentimentAnalyzer: """Test suite for CompositeSentimentAnalyzer implementation""" @pytest.mark.asyncio async def test_composite_analyzer_basic_initialization(self): """ Basic Test: Analyzer can be instantiated Should create composite analyzer with no sources """ analyzer = CompositeSentimentAnalyzer() assert analyzer is not None @pytest.mark.asyncio async def test_composite_analyzer_inherits_base(self): """ Acceptance Criterion: Inherits from BaseSentimentAnalyzer Should be instance of base class """ analyzer = CompositeSentimentAnalyzer() assert isinstance(analyzer, BaseSentimentAnalyzer) @pytest.mark.asyncio async def test_composite_analyzer_single_source(self, mock_news_analyzer): """ Basic Test: Composite with single sentiment source Should return sentiment from single source """ analyzer = CompositeSentimentAnalyzer(sources={"news": mock_news_analyzer}) result = await analyzer.analyze_sentiment("AAPL") assert isinstance(result, SentimentScore) assert result.score == Decimal("0.7") assert result.confidence == Decimal("0.8") @pytest.mark.asyncio async def test_composite_analyzer_multiple_sources( self, mock_news_analyzer, mock_social_analyzer ): """ Acceptance Criterion: Aggregate multiple sentiment sources Should combine sentiments from multiple sources """ analyzer = CompositeSentimentAnalyzer( sources={ "news": mock_news_analyzer, "social": mock_social_analyzer, } ) result = await analyzer.analyze_sentiment("AAPL") assert isinstance(result, SentimentScore) # Should be weighted average: (0.7 + 0.5) / 2 = 0.6 assert Decimal("0.5") <= result.score <= Decimal("0.7") @pytest.mark.asyncio async def test_composite_analyzer_weighted_aggregation( self, mock_news_analyzer, mock_social_analyzer ): """ Acceptance Criterion: Configurable weights for sources Should apply weights when aggregating sentiments """ analyzer = CompositeSentimentAnalyzer( sources={ "news": mock_news_analyzer, "social": mock_social_analyzer, }, weights={ "news": Decimal("0.7"), "social": Decimal("0.3"), }, ) result = await analyzer.analyze_sentiment("AAPL") assert isinstance(result, SentimentScore) # Weighted: 0.7*0.7 + 0.3*0.5 = 0.49 + 0.15 = 0.64 assert Decimal("0.6") <= result.score <= Decimal("0.7") @pytest.mark.asyncio async def test_composite_analyzer_equal_weights_default( self, mock_news_analyzer, mock_social_analyzer ): """ Integration Test: Default equal weights Should use equal weights if not specified """ analyzer = CompositeSentimentAnalyzer( sources={ "news": mock_news_analyzer, "social": mock_social_analyzer, } ) result = await analyzer.analyze_sentiment("AAPL") # Equal weights: simple average expected = (Decimal("0.7") + Decimal("0.5")) / Decimal("2") assert abs(result.score - expected) < Decimal("0.01") @pytest.mark.asyncio async def test_composite_analyzer_confidence_aggregation( self, mock_news_analyzer, mock_social_analyzer ): """ Acceptance Criterion: Combined confidence calculation Should aggregate confidence from multiple sources """ analyzer = CompositeSentimentAnalyzer( sources={ "news": mock_news_analyzer, "social": mock_social_analyzer, } ) result = await analyzer.analyze_sentiment("AAPL") # Confidence should be aggregated (e.g., average or minimum) assert Decimal("0.6") <= result.confidence <= Decimal("0.8") @pytest.mark.asyncio async def test_composite_analyzer_source_failure(self, mock_news_analyzer): """ Edge Case: One source fails Should handle gracefully and use remaining sources """ failing_analyzer = AsyncMock(spec=BaseSentimentAnalyzer) failing_analyzer.analyze_sentiment = AsyncMock(side_effect=Exception("API error")) analyzer = CompositeSentimentAnalyzer( sources={ "news": mock_news_analyzer, "failing": failing_analyzer, } ) result = await analyzer.analyze_sentiment("AAPL") # Should still return valid result from working source assert isinstance(result, SentimentScore) assert result.score == Decimal("0.7") # From news only @pytest.mark.asyncio async def test_composite_analyzer_all_sources_fail(self): """ Edge Case: All sources fail Should return neutral sentiment with zero confidence """ failing_analyzer = AsyncMock(spec=BaseSentimentAnalyzer) failing_analyzer.analyze_sentiment = AsyncMock(side_effect=Exception("API error")) analyzer = CompositeSentimentAnalyzer( sources={ "news": failing_analyzer, "social": failing_analyzer, } ) result = await analyzer.analyze_sentiment("AAPL") assert isinstance(result, SentimentScore) assert result.score == Decimal("0.0") # Neutral fallback assert result.confidence == Decimal("0.0") # No confidence @pytest.mark.asyncio async def test_composite_analyzer_no_sources(self): """ Edge Case: No sources configured Should return neutral sentiment with zero confidence """ analyzer = CompositeSentimentAnalyzer(sources={}) result = await analyzer.analyze_sentiment("AAPL") assert isinstance(result, SentimentScore) assert result.score == Decimal("0.0") assert result.confidence == Decimal("0.0") @pytest.mark.asyncio async def test_composite_analyzer_conflicting_sentiments(self): """ Integration Test: Conflicting sentiment signals Should aggregate positive and negative sentiments correctly """ positive_analyzer = AsyncMock(spec=BaseSentimentAnalyzer) positive_analyzer.analyze_sentiment = AsyncMock( return_value=SentimentScore( score=Decimal("0.8"), confidence=Decimal("0.9"), timestamp=datetime.now() ) ) negative_analyzer = AsyncMock(spec=BaseSentimentAnalyzer) negative_analyzer.analyze_sentiment = AsyncMock( return_value=SentimentScore( score=Decimal("-0.6"), confidence=Decimal("0.7"), timestamp=datetime.now() ) ) analyzer = CompositeSentimentAnalyzer( sources={ "positive": positive_analyzer, "negative": negative_analyzer, } ) result = await analyzer.analyze_sentiment("AAPL") # Should average: (0.8 + -0.6) / 2 = 0.1 assert Decimal("-0.2") <= result.score <= Decimal("0.2") # Confidence should be lower due to disagreement assert result.confidence < Decimal("0.8") @pytest.mark.asyncio async def test_composite_analyzer_decimal_precision( self, mock_news_analyzer, mock_social_analyzer ): """ Financial Code Requirement: Maintain Decimal precision Ensure no float conversion during aggregation """ analyzer = CompositeSentimentAnalyzer( sources={ "news": mock_news_analyzer, "social": mock_social_analyzer, } ) result = await analyzer.analyze_sentiment("AAPL") # Verify Decimal types assert isinstance(result.score, Decimal) assert isinstance(result.confidence, Decimal) # Verify not float assert not isinstance(result.score, float) assert not isinstance(result.confidence, float) @pytest.mark.asyncio async def test_composite_analyzer_weight_normalization( self, mock_news_analyzer, mock_social_analyzer ): """ Integration Test: Weight normalization Should normalize weights if they don't sum to 1.0 """ analyzer = CompositeSentimentAnalyzer( sources={ "news": mock_news_analyzer, "social": mock_social_analyzer, }, weights={ "news": Decimal("2.0"), # Non-normalized "social": Decimal("1.0"), }, ) result = await analyzer.analyze_sentiment("AAPL") # Should normalize to: news=0.67, social=0.33 # Result: 0.67*0.7 + 0.33*0.5 = 0.469 + 0.165 = 0.634 assert isinstance(result, SentimentScore) assert Decimal("0.6") <= result.score <= Decimal("0.7") @pytest.mark.asyncio async def test_composite_analyzer_partial_source_failure( self, mock_news_analyzer, mock_social_analyzer ): """ Integration Test: Partial source failure with weight adjustment Should re-weight remaining sources when one fails """ failing_analyzer = AsyncMock(spec=BaseSentimentAnalyzer) failing_analyzer.analyze_sentiment = AsyncMock(side_effect=Exception("API error")) analyzer = CompositeSentimentAnalyzer( sources={ "news": mock_news_analyzer, "social": mock_social_analyzer, "failing": failing_analyzer, }, weights={ "news": Decimal("0.5"), "social": Decimal("0.3"), "failing": Decimal("0.2"), }, ) result = await analyzer.analyze_sentiment("AAPL") # Should re-normalize: news=0.625, social=0.375 # Result: 0.625*0.7 + 0.375*0.5 = 0.4375 + 0.1875 = 0.625 assert isinstance(result, SentimentScore) assert Decimal("0.5") <= result.score <= Decimal("0.7") @pytest.mark.asyncio async def test_composite_analyzer_timestamp(self, mock_news_analyzer, mock_social_analyzer): """ Integration Test: Timestamp reflects aggregation time Should use current timestamp for aggregated result """ analyzer = CompositeSentimentAnalyzer( sources={ "news": mock_news_analyzer, "social": mock_social_analyzer, } ) before = datetime.now() result = await analyzer.analyze_sentiment("AAPL") after = datetime.now() assert before <= result.timestamp <= after @pytest.mark.parametrize("num_sources", [1, 2, 3, 5]) @pytest.mark.asyncio async def test_composite_analyzer_various_source_counts(self, num_sources): """ Parametrized Test: Various numbers of sources Should handle different numbers of sources correctly """ sources = {} for i in range(num_sources): mock = AsyncMock(spec=BaseSentimentAnalyzer) mock.analyze_sentiment = AsyncMock( return_value=SentimentScore( score=Decimal("0.5"), confidence=Decimal("0.8"), timestamp=datetime.now() ) ) sources[f"source_{i}"] = mock analyzer = CompositeSentimentAnalyzer(sources=sources) result = await analyzer.analyze_sentiment("AAPL") assert isinstance(result, SentimentScore) assert Decimal("0.4") <= result.score <= Decimal("0.6") @pytest.mark.asyncio async def test_composite_analyzer_extreme_sentiments(self): """ Edge Case: Extreme sentiment values Should handle edge values correctly """ max_positive = AsyncMock(spec=BaseSentimentAnalyzer) max_positive.analyze_sentiment = AsyncMock( return_value=SentimentScore( score=Decimal("1.0"), confidence=Decimal("1.0"), timestamp=datetime.now() ) ) max_negative = AsyncMock(spec=BaseSentimentAnalyzer) max_negative.analyze_sentiment = AsyncMock( return_value=SentimentScore( score=Decimal("-1.0"), confidence=Decimal("1.0"), timestamp=datetime.now() ) ) analyzer = CompositeSentimentAnalyzer( sources={ "positive": max_positive, "negative": max_negative, } ) result = await analyzer.analyze_sentiment("AAPL") # Should average to 0.0 assert result.score == Decimal("0.0") @pytest.mark.asyncio async def test_composite_analyzer_confidence_disagreement_penalty(self): """ Integration Test: Confidence penalty for disagreement High disagreement between sources should lower confidence """ high_positive = AsyncMock(spec=BaseSentimentAnalyzer) high_positive.analyze_sentiment = AsyncMock( return_value=SentimentScore( score=Decimal("0.9"), confidence=Decimal("0.9"), timestamp=datetime.now() ) ) high_negative = AsyncMock(spec=BaseSentimentAnalyzer) high_negative.analyze_sentiment = AsyncMock( return_value=SentimentScore( score=Decimal("-0.8"), confidence=Decimal("0.9"), timestamp=datetime.now() ) ) analyzer = CompositeSentimentAnalyzer( sources={ "positive": high_positive, "negative": high_negative, } ) result = await analyzer.analyze_sentiment("AAPL") # Even though individual confidences are high, # disagreement should lower combined confidence assert result.confidence < Decimal("0.6") @pytest.mark.asyncio async def test_composite_analyzer_three_sources( self, mock_news_analyzer, mock_social_analyzer, mock_analyst_analyzer ): """ Integration Test: Three sentiment sources Should aggregate three sources correctly """ analyzer = CompositeSentimentAnalyzer( sources={ "news": mock_news_analyzer, # 0.7 "social": mock_social_analyzer, # 0.5 "analyst": mock_analyst_analyzer, # 0.8 } ) result = await analyzer.analyze_sentiment("AAPL") # Average: (0.7 + 0.5 + 0.8) / 3 = 0.667 assert Decimal("0.6") <= result.score <= Decimal("0.7") @pytest.mark.asyncio async def test_composite_analyzer_invalid_weights( self, mock_news_analyzer, mock_social_analyzer ): """ Edge Case: Invalid weight configuration Should handle negative or zero weights gracefully """ with pytest.raises((ValueError, Exception)): CompositeSentimentAnalyzer( sources={ "news": mock_news_analyzer, "social": mock_social_analyzer, }, weights={ "news": Decimal("-0.5"), # Negative weight "social": Decimal("1.5"), }, )

Latest Blog Posts

MCP directory API

We provide all the information about MCP servers via our MCP API.

curl -X GET 'https://glama.ai/api/mcp/v1/servers/knishioka/ib-sec-mcp'

If you have feedback or need assistance with the MCP directory API, please join our Discord server