Skip to main content
Glama
knishioka

IB Analytics MCP Server

by knishioka
test_news.py16 kB
""" Tests for Issue #7: News Sentiment Analyzer Acceptance Criteria: - [ ] NewsSentimentAnalyzer inherits from BaseSentimentAnalyzer - [ ] Fetch news headlines from Yahoo Finance API - [ ] Analyze sentiment using TextBlob or VADER - [ ] Return SentimentScore with aggregated sentiment - [ ] Handle API failures gracefully (fallback to neutral sentiment) - [ ] Cache results for performance """ from datetime import datetime from decimal import Decimal from unittest.mock import AsyncMock, patch import pytest # These imports will fail until implementation exists try: from ib_sec_mcp.analyzers.sentiment.base import SentimentScore from ib_sec_mcp.analyzers.sentiment.news import NewsSentimentAnalyzer except ImportError: NewsSentimentAnalyzer = None SentimentScore = None pytestmark = pytest.mark.skipif( NewsSentimentAnalyzer is None, reason="NewsSentimentAnalyzer not implemented yet (TDD red phase)", ) @pytest.fixture def mock_positive_news(): """ Sample news data with positive sentiment Expected aggregated sentiment: ~0.7-0.8 """ return [ { "title": "Company reports record earnings, stock surges", "published": datetime.now(), "source": "Reuters", }, { "title": "Analysts upgrade rating to strong buy", "published": datetime.now(), "source": "Bloomberg", }, { "title": "New product launch exceeds expectations", "published": datetime.now(), "source": "CNBC", }, ] @pytest.fixture def mock_negative_news(): """ Sample news data with negative sentiment Expected aggregated sentiment: ~-0.7 to -0.8 """ return [ { "title": "Company faces lawsuit over product defects", "published": datetime.now(), "source": "Reuters", }, { "title": "Stock plummets on disappointing earnings report", "published": datetime.now(), "source": "Bloomberg", }, { "title": "Analysts downgrade to sell amid concerns", "published": datetime.now(), "source": "CNBC", }, ] @pytest.fixture def mock_neutral_news(): """ Sample news data with neutral sentiment Expected aggregated sentiment: ~0.0 to 0.2 """ return [ { "title": "Company announces quarterly results", "published": datetime.now(), "source": "Reuters", }, { "title": "CEO speaks at industry conference", "published": datetime.now(), "source": "Bloomberg", }, {"title": "Stock trading at average volume", "published": datetime.now(), "source": "CNBC"}, ] @pytest.fixture def mock_mixed_news(): """ Sample news data with mixed sentiment Expected aggregated sentiment: ~-0.1 to 0.1 (slightly negative overall) """ return [ { "title": "Company reports strong revenue growth", "published": datetime.now(), "source": "Reuters", }, { "title": "Profit margins decline due to rising costs", "published": datetime.now(), "source": "Bloomberg", }, { "title": "Stock unchanged in after-hours trading", "published": datetime.now(), "source": "CNBC", }, ] class TestNewsSentimentAnalyzer: """Test suite for NewsSentimentAnalyzer implementation""" @pytest.mark.asyncio async def test_news_analyzer_basic_initialization(self): """ Basic Test: Analyzer can be instantiated Should create analyzer without errors """ analyzer = NewsSentimentAnalyzer() assert analyzer is not None @pytest.mark.asyncio async def test_news_analyzer_inherits_base(self): """ Acceptance Criterion: Inherits from BaseSentimentAnalyzer Should be instance of base class """ from ib_sec_mcp.analyzers.sentiment.base import BaseSentimentAnalyzer analyzer = NewsSentimentAnalyzer() assert isinstance(analyzer, BaseSentimentAnalyzer) @pytest.mark.asyncio async def test_news_analyzer_positive_sentiment(self, mock_positive_news): """ Acceptance Criterion: Analyze positive news sentiment Should return positive sentiment score (0.5 to 1.0) """ analyzer = NewsSentimentAnalyzer() with patch.object(analyzer, "_fetch_news", new_callable=AsyncMock) as mock_fetch: mock_fetch.return_value = mock_positive_news result = await analyzer.analyze_sentiment("AAPL") assert isinstance(result, SentimentScore) assert result.score > Decimal("0.3") # Positive sentiment assert Decimal("0.0") <= result.confidence <= Decimal("1.0") @pytest.mark.asyncio async def test_news_analyzer_negative_sentiment(self, mock_negative_news): """ Acceptance Criterion: Analyze negative news sentiment Should return negative sentiment score (-1.0 to -0.5) """ analyzer = NewsSentimentAnalyzer() with patch.object(analyzer, "_fetch_news", new_callable=AsyncMock) as mock_fetch: mock_fetch.return_value = mock_negative_news result = await analyzer.analyze_sentiment("AAPL") assert isinstance(result, SentimentScore) assert result.score < Decimal("-0.3") # Negative sentiment assert Decimal("0.0") <= result.confidence <= Decimal("1.0") @pytest.mark.asyncio async def test_news_analyzer_neutral_sentiment(self, mock_neutral_news): """ Acceptance Criterion: Analyze neutral news sentiment Should return near-zero sentiment score """ analyzer = NewsSentimentAnalyzer() with patch.object(analyzer, "_fetch_news", new_callable=AsyncMock) as mock_fetch: mock_fetch.return_value = mock_neutral_news result = await analyzer.analyze_sentiment("AAPL") assert isinstance(result, SentimentScore) assert Decimal("-0.3") <= result.score <= Decimal("0.3") # Near neutral @pytest.mark.asyncio async def test_news_analyzer_mixed_sentiment(self, mock_mixed_news): """ Integration Test: Analyze mixed sentiment news Should aggregate multiple sentiments correctly """ analyzer = NewsSentimentAnalyzer() with patch.object(analyzer, "_fetch_news", new_callable=AsyncMock) as mock_fetch: mock_fetch.return_value = mock_mixed_news result = await analyzer.analyze_sentiment("AAPL") assert isinstance(result, SentimentScore) # Mixed sentiment should be near neutral assert Decimal("-0.5") <= result.score <= Decimal("0.5") @pytest.mark.asyncio async def test_news_analyzer_no_news_available(self): """ Edge Case: No news articles found Should return neutral sentiment with low confidence """ analyzer = NewsSentimentAnalyzer() with patch.object(analyzer, "_fetch_news", new_callable=AsyncMock) as mock_fetch: mock_fetch.return_value = [] result = await analyzer.analyze_sentiment("OBSCURE") assert isinstance(result, SentimentScore) assert result.score == Decimal("0.0") # Neutral assert result.confidence < Decimal("0.3") # Low confidence @pytest.mark.asyncio async def test_news_analyzer_api_failure(self): """ Edge Case: API request fails Should return neutral sentiment and handle gracefully """ analyzer = NewsSentimentAnalyzer() with patch.object(analyzer, "_fetch_news", new_callable=AsyncMock) as mock_fetch: mock_fetch.side_effect = Exception("API timeout") 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_news_analyzer_api_rate_limit(self): """ Edge Case: API rate limiting Should handle rate limit errors gracefully """ analyzer = NewsSentimentAnalyzer() with patch.object(analyzer, "_fetch_news", new_callable=AsyncMock) as mock_fetch: mock_fetch.side_effect = Exception("Rate limit exceeded") 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_news_analyzer_empty_headlines(self): """ Edge Case: News with empty titles Should filter out empty headlines """ analyzer = NewsSentimentAnalyzer() with patch.object(analyzer, "_fetch_news", new_callable=AsyncMock) as mock_fetch: mock_fetch.return_value = [ {"title": "", "published": datetime.now(), "source": "Reuters"}, {"title": " ", "published": datetime.now(), "source": "Bloomberg"}, ] result = await analyzer.analyze_sentiment("AAPL") # Should treat as no news assert result.score == Decimal("0.0") assert result.confidence < Decimal("0.3") @pytest.mark.asyncio async def test_news_analyzer_decimal_precision(self, mock_positive_news): """ Financial Code Requirement: Maintain Decimal precision Ensure no float conversion during sentiment calculation """ analyzer = NewsSentimentAnalyzer() with patch.object(analyzer, "_fetch_news", new_callable=AsyncMock) as mock_fetch: mock_fetch.return_value = mock_positive_news 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_news_analyzer_confidence_calculation(self, mock_positive_news): """ Acceptance Criterion: Confidence score reflects data quality More news articles should increase confidence """ analyzer = NewsSentimentAnalyzer() # Test with few articles (use different symbol to avoid cache) with patch.object(analyzer, "_fetch_news", new_callable=AsyncMock) as mock_fetch: mock_fetch.return_value = mock_positive_news[:1] result_low = await analyzer.analyze_sentiment("MSFT") # Test with many articles (use different symbol to avoid cache) with patch.object(analyzer, "_fetch_news", new_callable=AsyncMock) as mock_fetch: mock_fetch.return_value = mock_positive_news result_high = await analyzer.analyze_sentiment("TSLA") # More articles → higher confidence assert result_high.confidence > result_low.confidence @pytest.mark.asyncio async def test_news_analyzer_caching(self, mock_positive_news): """ Acceptance Criterion: Cache results for performance Should cache recent sentiment analysis """ analyzer = NewsSentimentAnalyzer() with patch.object(analyzer, "_fetch_news", new_callable=AsyncMock) as mock_fetch: mock_fetch.return_value = mock_positive_news # First call result1 = await analyzer.analyze_sentiment("AAPL") # Second call (should use cache) result2 = await analyzer.analyze_sentiment("AAPL") # Verify API was only called once (cached) # Implementation may vary - this is one approach assert result1.score == result2.score @pytest.mark.asyncio async def test_news_analyzer_timestamp(self, mock_positive_news): """ Integration Test: Timestamp reflects analysis time Should include timestamp in result """ analyzer = NewsSentimentAnalyzer() with patch.object(analyzer, "_fetch_news", new_callable=AsyncMock) as mock_fetch: mock_fetch.return_value = mock_positive_news before = datetime.now() result = await analyzer.analyze_sentiment("AAPL") after = datetime.now() assert before <= result.timestamp <= after @pytest.mark.parametrize( "symbol", [ "AAPL", "GOOGL", "TSLA", "MSFT", "AMZN", ], ) @pytest.mark.asyncio async def test_news_analyzer_various_symbols(self, symbol, mock_positive_news): """ Parametrized Test: Various stock symbols Should work with different symbols """ analyzer = NewsSentimentAnalyzer() with patch.object(analyzer, "_fetch_news", new_callable=AsyncMock) as mock_fetch: mock_fetch.return_value = mock_positive_news result = await analyzer.analyze_sentiment(symbol) assert isinstance(result, SentimentScore) assert Decimal("-1.0") <= result.score <= Decimal("1.0") assert Decimal("0.0") <= result.confidence <= Decimal("1.0") @pytest.mark.asyncio async def test_news_analyzer_invalid_symbol(self): """ Edge Case: Invalid or non-existent symbol Should handle gracefully with neutral sentiment """ analyzer = NewsSentimentAnalyzer() with patch.object(analyzer, "_fetch_news", new_callable=AsyncMock) as mock_fetch: mock_fetch.return_value = [] result = await analyzer.analyze_sentiment("INVALID123") assert result.score == Decimal("0.0") assert result.confidence == Decimal("0.0") @pytest.mark.asyncio async def test_news_analyzer_special_characters_in_headlines(self): """ Edge Case: Headlines with special characters Should handle unicode and special characters correctly """ analyzer = NewsSentimentAnalyzer() special_news = [ { "title": "Stock 📈 surges on strong earnings 💰", "published": datetime.now(), "source": "Reuters", } ] with patch.object(analyzer, "_fetch_news", new_callable=AsyncMock) as mock_fetch: mock_fetch.return_value = special_news result = await analyzer.analyze_sentiment("AAPL") # Should not crash, sentiment should be positive assert isinstance(result, SentimentScore) assert result.score > Decimal("0.0") @pytest.mark.asyncio async def test_news_analyzer_aggregation_strategy(self, mock_mixed_news): """ Integration Test: Sentiment aggregation strategy Should aggregate multiple sentiments using appropriate method (mean, median, or weighted average) """ analyzer = NewsSentimentAnalyzer() with patch.object(analyzer, "_fetch_news", new_callable=AsyncMock) as mock_fetch: mock_fetch.return_value = mock_mixed_news result = await analyzer.analyze_sentiment("AAPL") # Result should be aggregate of individual sentiments assert isinstance(result, SentimentScore) assert Decimal("-1.0") <= result.score <= Decimal("1.0") # Confidence should reflect consistency # (high confidence if sentiments agree, low if mixed) assert Decimal("0.0") <= result.confidence <= Decimal("1.0")

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