dlrklc commited on
Commit
7169bc5
·
1 Parent(s): aac25c6

Initial commit: Gradio MCP app for real-time financial data

Browse files
.gitignore ADDED
@@ -0,0 +1,35 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Byte-compiled / cache files
2
+ __pycache__/
3
+ *.py[cod]
4
+ *$py.class
5
+
6
+ # Virtual environments
7
+ .venv/
8
+ venv/
9
+ env/
10
+
11
+ # IDE and editor configs
12
+ .vscode/
13
+ .idea/
14
+ *.iml
15
+
16
+ # Test & coverage artifacts
17
+ .pytest_cache/
18
+ .coverage
19
+ coverage.xml
20
+ htmlcov/
21
+
22
+ # Build outputs
23
+ build/
24
+ dist/
25
+ *.egg-info/
26
+
27
+ # Environment & secrets
28
+ .env
29
+ .env.*
30
+ !.env.example
31
+
32
+ # OS files
33
+ .DS_Store
34
+ Thumbs.db
35
+
MODULAR_STRUCTURE.md ADDED
@@ -0,0 +1,158 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # 📦 Modular Structure Documentation
2
+
3
+ ## Directory Structure
4
+
5
+ ```
6
+ financial-data-mcp/
7
+ ├── src/ # Source modules
8
+ │ ├── __init__.py # Package initialization
9
+ │ ├── config.py # Configuration and constants
10
+ │ ├── validators.py # Input validation functions
11
+ │ ├── rate_limiter.py # Rate limiting functionality
12
+ │ ├── error_handler.py # Error handling decorator
13
+ │ ├── data_fetcher.py # yfinance data fetching
14
+ │ ├── stock_quote.py # Stock quote retrieval
15
+ │ ├── historical_data.py # Historical data retrieval
16
+ │ ├── technical_analysis.py # Technical indicators
17
+ │ ├── portfolio.py # Portfolio analysis
18
+ │ ├── market.py # Market overview
19
+ │ └── interfaces.py # Gradio UI interfaces
20
+ ├── tests/ # Test suite
21
+ │ ├── __init__.py
22
+ │ ├── test_validators.py # Validation tests
23
+ │ ├── test_technical_analysis.py # Technical analysis tests
24
+ │ ├── test_data_fetcher.py # Data fetching tests
25
+ │ ├── test_portfolio.py # Portfolio tests
26
+ │ ├── test_rate_limiter.py # Rate limiter tests
27
+ │ ├── test_edge_cases.py # Edge case tests
28
+ │ └── run_tests.py # Test runner
29
+ ├── financial_mcp_server.py # Main entry point
30
+ ├── app.py # Hugging Face Space entry point
31
+ └── requirements.txt # Dependencies
32
+ ```
33
+
34
+ ## Module Descriptions
35
+
36
+ ### `src/config.py`
37
+ - Configuration settings from environment variables
38
+ - Logging setup
39
+ - Cache configuration
40
+ - Validation constants
41
+
42
+ ### `src/validators.py`
43
+ - `validate_ticker()` - Validates and sanitizes ticker symbols
44
+ - `validate_period()` - Validates time periods
45
+ - `validate_interval()` - Validates data intervals
46
+ - `validate_metric()` - Validates comparison metrics
47
+ - `validate_json_input()` - Validates JSON portfolio input
48
+
49
+ ### `src/rate_limiter.py`
50
+ - `RateLimiter` class - Rate limiting decorator
51
+ - Prevents API abuse with configurable limits
52
+
53
+ ### `src/error_handler.py`
54
+ - `handle_errors()` - Secure error handling decorator
55
+ - Logs errors but returns sanitized messages
56
+
57
+ ### `src/data_fetcher.py`
58
+ - `_fetch_stock_data_fast()` - Fast stock data fetching
59
+ - `get_cached_stock_data()` - Cached data retrieval
60
+ - `fetch_historical_data()` - Historical data fetching
61
+ - `prewarm_yfinance()` - Pre-warming function
62
+
63
+ ### `src/stock_quote.py`
64
+ - `get_stock_quote()` - Get real-time stock quotes
65
+
66
+ ### `src/historical_data.py`
67
+ - `get_historical_data()` - Get historical OHLCV data
68
+
69
+ ### `src/technical_analysis.py`
70
+ - `calculate_sma()` - Simple Moving Average
71
+ - `calculate_ema()` - Exponential Moving Average
72
+ - `calculate_rsi()` - Relative Strength Index
73
+ - `calculate_macd()` - MACD indicator
74
+ - `calculate_bollinger_bands()` - Bollinger Bands
75
+ - `get_technical_analysis()` - Comprehensive analysis
76
+
77
+ ### `src/portfolio.py`
78
+ - `analyze_portfolio()` - Portfolio analysis
79
+ - `compare_stocks()` - Stock comparison
80
+
81
+ ### `src/market.py`
82
+ - `get_market_overview()` - Market indices overview
83
+
84
+ ### `src/interfaces.py`
85
+ - Creates all Gradio UI interfaces
86
+ - Exports interfaces for use in main file
87
+
88
+ ## Running Tests
89
+
90
+ ### Run All Tests
91
+ ```bash
92
+ python tests/run_tests.py
93
+ ```
94
+
95
+ ### Run Specific Test Module
96
+ ```bash
97
+ python -m unittest tests.test_validators
98
+ python -m unittest tests.test_technical_analysis
99
+ python -m unittest tests.test_edge_cases
100
+ ```
101
+
102
+ ### Run with Verbose Output
103
+ ```bash
104
+ python -m unittest discover tests -v
105
+ ```
106
+
107
+ ## Test Coverage
108
+
109
+ ### Test Modules
110
+
111
+ 1. **test_validators.py** - 20+ test cases
112
+ - Valid/invalid ticker formats
113
+ - SQL injection attempts
114
+ - Boundary values
115
+ - JSON validation
116
+ - Period/interval validation
117
+
118
+ 2. **test_technical_analysis.py** - 15+ test cases
119
+ - SMA/EMA calculations
120
+ - RSI edge cases
121
+ - MACD with insufficient data
122
+ - Bollinger Bands edge cases
123
+ - Empty data handling
124
+
125
+ 3. **test_data_fetcher.py** - 4 test cases
126
+ - Successful data fetch
127
+ - Empty data handling
128
+ - Network errors
129
+ - Caching behavior
130
+
131
+ 4. **test_portfolio.py** - 8 test cases
132
+ - Invalid JSON
133
+ - Invalid shares/cost basis
134
+ - Too many tickers
135
+ - Empty portfolios
136
+
137
+ 5. **test_rate_limiter.py** - 3 test cases
138
+ - Rate limit enforcement
139
+ - Period expiration
140
+ - Request counting
141
+
142
+ 6. **test_edge_cases.py** - 8 test cases
143
+ - Non-existent tickers
144
+ - Single data point
145
+ - Zero division scenarios
146
+ - Network errors
147
+ - Boundary values
148
+
149
+ **Total: 60+ test cases covering edge cases and error scenarios**
150
+
151
+ ## Benefits of Modular Structure
152
+
153
+ 1. **Maintainability** - Each module has a single responsibility
154
+ 2. **Testability** - Easy to test individual components
155
+ 3. **Reusability** - Modules can be imported independently
156
+ 4. **Readability** - Clear separation of concerns
157
+ 5. **Scalability** - Easy to add new features
158
+
README.md CHANGED
@@ -1,13 +1,225 @@
1
  ---
2
- title: Finance Data Mcp
3
- emoji: 🦀
4
- colorFrom: purple
5
- colorTo: yellow
6
  sdk: gradio
7
  sdk_version: 6.0.1
8
  app_file: app.py
9
  pinned: false
10
  short_description: MCP Server for real-time stock market data and analysis
 
 
 
 
 
 
 
 
11
  ---
12
 
13
- Check out the configuration reference at https://huggingface.co/docs/hub/spaces-config-reference
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
  ---
2
+ title: Financial Market Data MCP Server
3
+ emoji: 💰
4
+ colorFrom: green
5
+ colorTo: blue
6
  sdk: gradio
7
  sdk_version: 6.0.1
8
  app_file: app.py
9
  pinned: false
10
  short_description: MCP Server for real-time stock market data and analysis
11
+ tags:
12
+ - building-mcp-track-consumer
13
+ - mcp
14
+ - hackathon
15
+ - gradio
16
+ - financial-data
17
+ - stock-market
18
+ - technical-analysis
19
  ---
20
 
21
+ ## 🏆 MCP's 1st Birthday Hackathon Submission
22
+
23
+ **Track:** Building MCP (Consumer Category)
24
+ **Submission Date:** November 2025
25
+ **Hackathon:** [MCP's 1st Birthday](https://huggingface.co/MCP-1st-Birthday)
26
+
27
+ ### 📱 Social Media Post
28
+ (coming soon)
29
+
30
+ ###🎥 Demo Video
31
+ (coming soon)
32
+ ---
33
+
34
+ # 💰 Financial Market Data MCP Server
35
+
36
+ ## 🎯 What is This?
37
+
38
+ This MCP server provides AI assistants with real-time financial market data that they otherwise cannot access due to knowledge cutoffs. It enables any MCP-compatible AI tool to:
39
+
40
+ - 📈 Get current stock prices and metrics
41
+ - 📊 Analyze historical price data
42
+ - 🔍 Perform technical analysis (RSI, MACD, Bollinger Bands)
43
+ - 💼 Track portfolio performance
44
+ - ⚖️ Compare multiple stocks
45
+ - 🌍 Monitor market indices
46
+
47
+ ## ✨ Key Features
48
+
49
+ ### 🚀 Real-Time Data
50
+ Unlike AI assistants with static knowledge cutoffs (ChatGPT, Claude, etc.), this server provides **live market data** updated continuously.
51
+
52
+ ### 🔍 Technical Analysis
53
+ Automatic calculation of:
54
+ - **RSI** (Relative Strength Index) - Overbought/oversold signals
55
+ - **MACD** (Moving Average Convergence Divergence) - Trend momentum
56
+ - **Bollinger Bands** - Volatility and price levels
57
+ - **Moving Averages** (SMA 20/50/200, EMA 12/26) - Trend direction
58
+ - **Trading Signals** - Buy/Sell/Hold recommendations
59
+
60
+ ### 💼 Portfolio Tracking
61
+ Monitor your investments with:
62
+ - Current portfolio value
63
+ - Total gains/losses ($ and %)
64
+ - Position allocation
65
+ - Individual stock performance
66
+
67
+ ### 🔒 Security Features
68
+ - ✅ **Input Validation** - All inputs sanitized to prevent injection attacks
69
+ - ✅ **Rate Limiting** - 20 requests per minute to prevent abuse
70
+ - ✅ **Secure Error Handling** - No sensitive information leaked
71
+ - ✅ **Request Logging** - Full audit trail
72
+
73
+ ## 🎮 Try It Now!
74
+
75
+ ### Example 1: Get Stock Quote
76
+ 1. Click the **"Stock Quote"** tab
77
+ 2. Enter ticker: `AAPL`
78
+ 3. See current price, volume, P/E ratio, market cap, and more!
79
+
80
+ ### Example 2: Technical Analysis
81
+ 1. Click the **"Technical Analysis"** tab
82
+ 2. Enter ticker: `TSLA`
83
+ 3. Get RSI, MACD, Bollinger Bands, and trading recommendations!
84
+
85
+ ### Example 3: Compare Stocks
86
+ 1. Click the **"Compare Stocks"** tab
87
+ 2. Enter: `AAPL,MSFT,GOOGL`
88
+ 3. See side-by-side performance comparison!
89
+
90
+ ### Example 4: Portfolio Tracking
91
+ 1. Click the **"Portfolio Tracker"** tab
92
+ 2. Paste this JSON:
93
+ ```json
94
+ {
95
+ "AAPL": {"shares": 10, "cost_basis": 150},
96
+ "TSLA": {"shares": 5, "cost_basis": 200},
97
+ "MSFT": {"shares": 8, "cost_basis": 300}
98
+ }
99
+ ```
100
+ 3. See your total portfolio value, gains/losses, and allocation!
101
+
102
+ ## 🤖 Integration with Claude AI
103
+
104
+ This server uses the **Model Context Protocol (MCP)** to extend Claude's capabilities:
105
+
106
+ ### Setup Instructions for Claude Desktop:
107
+
108
+ 1. **Install Claude Desktop** from [claude.ai](https://claude.ai/download)
109
+
110
+ 2. **Install Node.js and mcp-remote**
111
+
112
+ 3. **Configure MCP Server** in `claude_desktop_config.json`:
113
+ ```json
114
+ {
115
+ "mcpServers": {
116
+ "gradio": {
117
+ "command": "npx",
118
+ "args": [
119
+ "mcp-remote",
120
+ "http://localhost:7860/gradio_api/mcp/sse"
121
+ ]
122
+ }
123
+ }
124
+ }
125
+ ```
126
+
127
+ 4. **Ask Claude:**
128
+ - "What's the current price of Apple stock?"
129
+ - "Analyze Tesla technically and tell me if I should buy"
130
+ - "Compare Apple, Microsoft, and Google performance"
131
+ - "What's the market doing today?"
132
+
133
+ Claude will automatically use this MCP server to fetch real-time data!
134
+
135
+ ## 📊 Available Tools
136
+
137
+ | Tool | Description | Example Input |
138
+ |------|-------------|---------------|
139
+ | `get_stock_quote` | Real-time stock quotes | `AAPL` |
140
+ | `get_historical_data` | Historical OHLCV data | `TSLA`, `3mo`, `1d` |
141
+ | `get_technical_analysis` | Technical indicators | `NVDA`, `3mo` |
142
+ | `analyze_portfolio` | Portfolio tracking | JSON with holdings |
143
+ | `compare_stocks` | Multi-stock comparison | `AAPL,MSFT,GOOGL` |
144
+ | `get_market_overview` | Major indices | (no input) |
145
+
146
+ ## 🛠️ Tech Stack
147
+
148
+ - **Backend**: Python 3.10+
149
+ - **UI Framework**: Gradio 6.0+ (latest features and performance)
150
+ - **Data Source**: Yahoo Finance (via yfinance)
151
+ - **Analysis**: Pandas, NumPy
152
+ - **Protocol**: MCP (Model Context Protocol)
153
+ - **Deployment**: Hugging Face Spaces
154
+
155
+ ## 📈 Technical Indicators Explained
156
+
157
+ ### RSI (Relative Strength Index)
158
+ - **Range**: 0-100
159
+ - **Oversold**: < 30 (potential buy signal)
160
+ - **Overbought**: > 70 (potential sell signal)
161
+
162
+ ### MACD (Moving Average Convergence Divergence)
163
+ - **Bullish**: MACD line crosses above signal line
164
+ - **Bearish**: MACD line crosses below signal line
165
+
166
+ ### Bollinger Bands
167
+ - **Upper Band**: Price + (2 × standard deviation)
168
+ - **Lower Band**: Price - (2 × standard deviation)
169
+ - **Squeeze**: Low volatility → potential breakout
170
+
171
+ ### Moving Averages
172
+ - **Golden Cross**: SMA 50 crosses above SMA 200 (bullish)
173
+ - **Death Cross**: SMA 50 crosses below SMA 200 (bearish)
174
+
175
+ ## 🔐 Security & Fair Use
176
+
177
+ ### Rate Limits
178
+ - **20 requests per minute** per user
179
+ - **5 maximum tickers** per comparison/portfolio request
180
+
181
+ ### Data Freshness
182
+ - Data may be delayed by up to **15 minutes** (free tier)
183
+ - Real-time data available through premium APIs
184
+
185
+ ### Fair Use Policy
186
+ This is a free, educational tool. Please:
187
+ - ✅ Use for learning and demonstration
188
+ - ✅ Respect rate limits
189
+ - ❌ Don't automate high-frequency requests
190
+ - ❌ Don't use for production trading without verification
191
+
192
+ ## ⚠️ Disclaimer
193
+
194
+ **This tool is for educational and demonstration purposes only.**
195
+
196
+ - ❌ **NOT financial advice**
197
+ - ❌ **NOT suitable for making investment decisions**
198
+ - ❌ **Data may be delayed or inaccurate**
199
+ - ✅ **Always verify with official sources**
200
+ - ✅ **Consult a licensed financial advisor**
201
+
202
+ ## 🙏 Acknowledgments
203
+
204
+ - **Yahoo Finance** for providing free market data
205
+ - **yfinance** for providing access to Yahoo Finance data
206
+ - **Anthropic** for Claude AI
207
+ - **Hugging Face** for hosting this Space
208
+ - **Gradio** for the amazing UI framework
209
+
210
+ ## 📄 Third-Party Notices
211
+
212
+ This project uses the yfinance library, which is distributed under the Apache License 2.0.
213
+ Data fetched through yfinance is subject to Yahoo’s Terms of Use.
214
+
215
+ ## 📬 Contact
216
+
217
+ - **GitHub**: [@dlrklc](https://github.com/dlrklc)
218
+
219
+ ---
220
+
221
+ ## 🏆 Built for MCP's 1st Birthday 2025
222
+
223
+ This project demonstrates the power of extending AI assistants with real-time data through the Model Context Protocol. It bridges the gap between LLM knowledge cutoffs and the need for current information in financial applications.
224
+
225
+ **Built with ❤️ by [Dilara Kilic](https://www.linkedin.com/in/dilarakilic/)**
app.py ADDED
@@ -0,0 +1,299 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ app.py - Hugging Face Space Entry Point (Gradio 6)
3
+ Financial Market Data MCP Server for Hackathon
4
+
5
+ This is optimized for Hugging Face Spaces deployment with Gradio 6:
6
+ - No authentication (public access)
7
+ - Stricter rate limits
8
+ - User-friendly interface
9
+ - Improved performance with Gradio 6
10
+ """
11
+
12
+ import os
13
+ from pathlib import Path
14
+ from financial_mcp_server import (
15
+ stock_quote_interface,
16
+ historical_interface,
17
+ technical_interface,
18
+ portfolio_interface,
19
+ comparison_interface,
20
+ market_interface,
21
+ prewarm_yfinance
22
+ )
23
+ import gradio as gr
24
+
25
+ # Pre-warm yfinance on app startup
26
+ prewarm_yfinance()
27
+
28
+ # Hugging Face Space optimized settings
29
+ os.environ.setdefault('RATE_LIMIT_CALLS', '20')
30
+ os.environ.setdefault('RATE_LIMIT_PERIOD', '60')
31
+ os.environ.setdefault('MAX_TICKERS_PER_REQUEST', '5')
32
+
33
+ # Create enhanced interface with hackathon-friendly features
34
+ def create_hackathon_demo():
35
+ """Create an enhanced demo for hackathon."""
36
+
37
+ # Custom CSS for better appearance
38
+ custom_css = """
39
+ .gradio-container {
40
+ font-family: 'Inter', 'Segoe UI', sans-serif;
41
+ }
42
+ .gr-button-primary {
43
+ background: linear-gradient(90deg, #00C9FF 0%, #92FE9D 100%);
44
+ border: none;
45
+ font-weight: 600;
46
+ }
47
+ .disclaimer {
48
+ background: #fff3cd;
49
+ border-left: 4px solid #ffc107;
50
+ padding: 1rem;
51
+ margin: 1rem 0;
52
+ }
53
+ .feature-badge {
54
+ display: inline-flex;
55
+ align-items: center;
56
+ gap: 0.35rem;
57
+ background: #ffA500;
58
+ color: white;
59
+ padding: 0.35rem 0.9rem;
60
+ border-radius: 999px;
61
+ font-size: 0.875rem;
62
+ margin: 0.25rem;
63
+ white-space: nowrap;
64
+ font-weight: 600;
65
+ }
66
+ """
67
+
68
+ # Welcome/Info tab
69
+ with gr.Blocks() as enhanced_demo:
70
+
71
+ # Inject custom CSS since Gradio 6 Blocks no longer accepts the css kwarg
72
+ gr.HTML(f"<style>{custom_css}</style>")
73
+
74
+ # Header
75
+ gr.Markdown("""
76
+ # 💰 Financial Market Data MCP Server
77
+ ### Real-Time Stock Analysis for Claude AI
78
+ """)
79
+
80
+ # Tabs for different features
81
+ with gr.Tabs():
82
+
83
+ # Tab 1: Getting Started
84
+ with gr.Tab("🚀 Getting Started"):
85
+ gr.Markdown("""
86
+ ## Welcome to the Financial Market Data MCP Server!
87
+
88
+ This tool provides **real-time financial data** that Claude AI can use to give you accurate,
89
+ current market information. Unlike standard AI Assistant which has a knowledge cutoff,
90
+ this MCP server provides **live data**.
91
+
92
+ ### 🎯 What Can You Do?
93
+
94
+ 1. **📈 Stock Quotes** - Get current prices for any stock
95
+ 2. **📊 Historical Data** - View price history over time
96
+ 3. **🔍 Technical Analysis** - RSI, MACD, Bollinger Bands, trading signals
97
+ 4. **💼 Portfolio Tracking** - Monitor your investments
98
+ 5. **⚖️ Stock Comparison** - Compare multiple stocks side-by-side
99
+ 6. **🌍 Market Overview** - See major market indices
100
+
101
+ ### 🚀 Quick Start Examples:
102
+
103
+ **Example 1: Check Apple's Stock**
104
+ - Go to "Stock Quote" tab
105
+ - Enter: `AAPL`
106
+ - See current price, volume, P/E ratio, and more!
107
+
108
+ **Example 2: Analyze Tesla Technically**
109
+ - Go to "Technical Analysis" tab
110
+ - Enter: `TSLA`
111
+ - Get RSI, MACD, and trading recommendations!
112
+
113
+ **Example 3: Compare Tech Giants**
114
+ - Go to "Compare Stocks" tab
115
+ - Enter: `AAPL,MSFT,GOOGL`
116
+ - See side-by-side performance!
117
+
118
+ ### 🤖 Use with Claude AI
119
+
120
+ This server integrates with Claude Desktop via the MCP protocol:
121
+
122
+ 1. Install Claude Desktop from [claude.ai](https://claude.ai/download)
123
+
124
+ 2. Install Node.js and mcp-remote
125
+
126
+ 3. Configure MCP Server in `claude_desktop_config.json`:
127
+ ```json
128
+ {
129
+ "mcpServers": {
130
+ "gradio": {
131
+ "command": "npx",
132
+ "args": [
133
+ "mcp-remote",
134
+ "http://localhost:7860/gradio_api/mcp/sse"
135
+ ]
136
+ }
137
+ }
138
+ }
139
+ ```
140
+
141
+ 4. Ask Claude: *"What's the current price of Tesla stock?"*
142
+
143
+ 5. Claude will use THIS server to get real-time data!
144
+
145
+
146
+ """)
147
+
148
+ # Usage stats (optional)
149
+ gr.Markdown("""
150
+ ### 📊 Server Status
151
+ - 🟢 Status: Online
152
+ - ⚡ Response Time: < 500ms
153
+ - 🔒 Security: Rate Limited (20 req/min)
154
+ - 📈 Data Source: Yahoo Finance
155
+ """)
156
+
157
+ # Tab 2: Stock Quote
158
+ with gr.Tab("📈 Stock Quote"):
159
+ stock_quote_interface.render()
160
+
161
+ # Tab 3: Historical Data
162
+ with gr.Tab("📊 Historical Data"):
163
+ historical_interface.render()
164
+
165
+ # Tab 4: Technical Analysis
166
+ with gr.Tab("🔍 Technical Analysis"):
167
+ technical_interface.render()
168
+
169
+ # Tab 5: Portfolio
170
+ with gr.Tab("💼 Portfolio Tracker"):
171
+ gr.Markdown("""
172
+ **Example Portfolio JSON:**
173
+ ```json
174
+ {
175
+ "AAPL": {"shares": 10, "cost_basis": 150},
176
+ "TSLA": {"shares": 5, "cost_basis": 200},
177
+ "MSFT": {"shares": 8, "cost_basis": 300}
178
+ }
179
+ ```
180
+ """)
181
+ portfolio_interface.render()
182
+
183
+ # Tab 6: Compare Stocks
184
+ with gr.Tab("⚖️ Compare Stocks"):
185
+ comparison_interface.render()
186
+
187
+ # Tab 7: Market Overview
188
+ with gr.Tab("🌍 Market Overview"):
189
+ market_interface.render()
190
+
191
+ # Tab 8: API Documentation
192
+ with gr.Tab("📚 API Documentation"):
193
+ gr.Markdown("""
194
+ ## API Endpoints
195
+
196
+ This server exposes the following API endpoints:
197
+
198
+ ### 1. Get Stock Quote
199
+ ```python
200
+ POST /api/get_stock_quote
201
+ Body: {"ticker": "AAPL"}
202
+ ```
203
+
204
+ ### 2. Get Historical Data
205
+ ```python
206
+ POST /api/get_historical_data
207
+ Body: {"ticker": "AAPL", "period": "1mo", "interval": "1d"}
208
+ ```
209
+
210
+ ### 3. Technical Analysis
211
+ ```python
212
+ POST /api/get_technical_analysis
213
+ Body: {"ticker": "AAPL", "period": "3mo"}
214
+ ```
215
+
216
+ ### 4. Portfolio Analysis
217
+ ```python
218
+ POST /api/analyze_portfolio
219
+ Body: {"holdings": "{...}"} # JSON string
220
+ ```
221
+
222
+ ### 5. Compare Stocks
223
+ ```python
224
+ POST /api/compare_stocks
225
+ Body: {"tickers": "AAPL,MSFT,GOOGL", "metric": "performance"}
226
+ ```
227
+
228
+ ### 6. Market Overview
229
+ ```python
230
+ POST /api/get_market_overview
231
+ Body: {}
232
+ ```
233
+
234
+ ## Rate Limits
235
+ - **20 requests per minute** per IP address
236
+ - **5 maximum tickers** per comparison/portfolio request
237
+
238
+ ## Error Codes
239
+ - `INVALID_INPUT` - Invalid ticker or parameters
240
+ - `NO_DATA` - No data available for ticker
241
+ - `RATE_LIMIT_EXCEEDED` - Too many requests
242
+ - `INSUFFICIENT_DATA` - Not enough historical data
243
+
244
+ ## Security Features
245
+ - ✅ Input validation and sanitization
246
+ - ✅ Rate limiting
247
+ - ✅ Secure error handling
248
+ - ✅ Request logging
249
+ """)
250
+
251
+ # Footer
252
+ gr.Markdown("""
253
+ ---
254
+
255
+ ### 🔗 Links & Resources
256
+
257
+ - 🏆 [Hackathon Page](https://huggingface.co/MCP-1st-Birthday)
258
+ - 🎥 Demo Video (coming soon)
259
+ - 📱 Social Media Post (coming soon)
260
+
261
+ ### 🏆 Built For MCP's 1st Birthday Hackathon 2025
262
+
263
+ This project demonstrates integration between AI assistants (Claude) and real-time financial data
264
+ through the Model Context Protocol (MCP). It provides capabilities that Large Language Models
265
+ fundamentally lack: access to current, real-time market data.
266
+
267
+ **Track:** Building MCP (Consumer Category)
268
+ **Tech Stack:** Python • Gradio 6 • yfinance • MCP Protocol • Hugging Face Spaces
269
+
270
+ ### 📄 Third-Party Notices
271
+
272
+ This project uses the yfinance library, which is distributed under the Apache License 2.0.
273
+ Data fetched through yfinance is subject to Yahoo’s Terms of Use.
274
+
275
+ <div class="disclaimer">
276
+ ⚠️ <strong>Disclaimer:</strong> This tool is for educational and demonstration purposes only.
277
+ Not financial advice. Data may be delayed by up to 15 minutes. Always verify with official sources.
278
+ Use at your own risk.
279
+ </div>
280
+
281
+ ---
282
+
283
+ Built with ❤️ by [Dilara Kilic](https://www.linkedin.com/in/dilarakilic/) | © 2025
284
+ """)
285
+
286
+ return enhanced_demo
287
+
288
+
289
+ # Create and launch the demo
290
+ if __name__ == "__main__":
291
+ demo = create_hackathon_demo()
292
+
293
+ # Launch with Hugging Face Space optimized settings (Gradio 6)
294
+ demo.launch(
295
+ share=False, # HF provides permanent URL
296
+ server_name="0.0.0.0", # Listen on all interfaces
297
+ server_port=7860, # HF default port
298
+ mcp_server=True
299
+ )
financial_mcp_server.py ADDED
@@ -0,0 +1,76 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Financial Market Data MCP Server
3
+ Provides real-time stock data, technical analysis, and portfolio tracking.
4
+
5
+ This is the main entry point that imports and uses modular components.
6
+
7
+ Installation:
8
+ pip install gradio yfinance pandas numpy python-dotenv cachetools
9
+
10
+ Usage:
11
+ python financial_mcp_server.py
12
+ """
13
+
14
+ import gradio as gr
15
+ from src.interfaces import (
16
+ stock_quote_interface,
17
+ historical_interface,
18
+ technical_interface,
19
+ portfolio_interface,
20
+ comparison_interface,
21
+ market_interface
22
+ )
23
+ from src.data_fetcher import prewarm_yfinance
24
+
25
+ # ============================================================================
26
+ # CREATE DEFAULT DEMO
27
+ # ============================================================================
28
+
29
+ # Create default demo for direct execution
30
+ # This is also exported for use in app.py
31
+ default_demo = gr.TabbedInterface(
32
+ [
33
+ stock_quote_interface,
34
+ historical_interface,
35
+ technical_interface,
36
+ portfolio_interface,
37
+ comparison_interface,
38
+ market_interface
39
+ ],
40
+ [
41
+ "Stock Quote",
42
+ "Historical Data",
43
+ "Technical Analysis",
44
+ "Portfolio",
45
+ "Compare Stocks",
46
+ "Market Overview"
47
+ ],
48
+ title="💰 Financial Market Data MCP Server",
49
+ )
50
+
51
+ # Export demo for app.py
52
+ demo = default_demo
53
+
54
+ # Export interfaces for app.py
55
+ __all__ = [
56
+ 'demo',
57
+ 'stock_quote_interface',
58
+ 'historical_interface',
59
+ 'technical_interface',
60
+ 'portfolio_interface',
61
+ 'comparison_interface',
62
+ 'market_interface',
63
+ 'prewarm_yfinance'
64
+ ]
65
+
66
+ if __name__ == "__main__":
67
+ # Pre-warm yfinance to eliminate cold start delay
68
+ prewarm_yfinance()
69
+
70
+ # Launch with MCP server enabled
71
+ default_demo.launch(
72
+ server_name="0.0.0.0",
73
+ server_port=7860,
74
+ share=False,
75
+ mcp_server=True
76
+ )
requirements.txt ADDED
@@ -0,0 +1,10 @@
 
 
 
 
 
 
 
 
 
 
 
1
+ # Financial Market Data MCP Server - Requirements
2
+ # Install with: pip install -r requirements.txt
3
+
4
+ # Core Dependencies
5
+ gradio[mcp]>=6.0.0
6
+ yfinance>=0.2.28
7
+ pandas>=2.0.0
8
+ numpy>=1.24.0
9
+ cachetools>=5.3.0
10
+ python-dotenv>=1.0.0
src/__init__.py ADDED
@@ -0,0 +1,6 @@
 
 
 
 
 
 
 
1
+ """
2
+ Financial Market Data MCP Server
3
+ """
4
+
5
+ __version__ = "1.0.0"
6
+
src/config.py ADDED
@@ -0,0 +1,51 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Configuration module for Financial Market Data MCP Server.
3
+ Handles environment variables, logging, and cache configuration.
4
+ """
5
+
6
+ import os
7
+ import logging
8
+ from dotenv import load_dotenv
9
+ from cachetools import TTLCache
10
+
11
+ # Load environment variables from .env file
12
+ load_dotenv()
13
+
14
+ # ============================================================================
15
+ # LOGGING CONFIGURATION
16
+ # ============================================================================
17
+
18
+ logging.basicConfig(
19
+ level=logging.INFO,
20
+ format='%(asctime)s - %(name)s - %(levelname)s - %(message)s',
21
+ handlers=[
22
+ logging.StreamHandler()
23
+ ]
24
+ )
25
+ logger = logging.getLogger(__name__)
26
+
27
+ # ============================================================================
28
+ # SECURITY SETTINGS
29
+ # ============================================================================
30
+
31
+ RATE_LIMIT_CALLS = int(os.getenv('RATE_LIMIT_CALLS', '20')) # Max calls per minute
32
+ RATE_LIMIT_PERIOD = int(os.getenv('RATE_LIMIT_PERIOD', '60')) # In seconds
33
+ MAX_TICKERS_PER_REQUEST = int(os.getenv('MAX_TICKERS_PER_REQUEST', '5'))
34
+ ALLOWED_TICKER_PATTERN = r'^[A-Z]{1,5}$' # 1-5 uppercase letters
35
+
36
+ # ============================================================================
37
+ # PERFORMANCE SETTINGS
38
+ # ============================================================================
39
+
40
+ CACHE_TTL = int(os.getenv('CACHE_TTL', '60')) # Cache TTL in seconds
41
+ CACHE_MAXSIZE = int(os.getenv('CACHE_MAXSIZE', '200')) # Max cache entries
42
+ quote_cache = TTLCache(maxsize=CACHE_MAXSIZE, ttl=CACHE_TTL)
43
+
44
+ # ============================================================================
45
+ # VALIDATION CONSTANTS
46
+ # ============================================================================
47
+
48
+ ALLOWED_PERIODS = ["1d", "5d", "1mo", "3mo", "6mo", "1y", "2y", "5y", "10y", "ytd", "max"]
49
+ ALLOWED_INTERVALS = ["1m", "2m", "5m", "15m", "30m", "60m", "90m", "1h", "1d", "5d", "1wk", "1mo", "3mo"]
50
+ ALLOWED_METRICS = ["performance", "valuation", "volatility"]
51
+
src/data_fetcher.py ADDED
@@ -0,0 +1,108 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Data fetching module for Financial Market Data MCP Server.
3
+ Handles fetching stock data from yfinance with caching and optimization.
4
+ """
5
+
6
+ import yfinance as yf
7
+ import pandas as pd
8
+ import logging
9
+ import threading
10
+ from typing import Dict, Tuple, Optional
11
+ from concurrent.futures import ThreadPoolExecutor
12
+ from cachetools import cached
13
+ from .config import quote_cache, logger
14
+
15
+
16
+ def _fetch_stock_data_fast(ticker: str) -> Tuple[Dict, pd.DataFrame]:
17
+ """
18
+ Fast path: Fetch only history data (info is slow and optional).
19
+ Returns minimal info dict and history DataFrame.
20
+ """
21
+ stock = yf.Ticker(ticker)
22
+
23
+ # Only fetch history - this is fast (usually <2s)
24
+ # Info is slow (20-30s) and we'll make it optional
25
+ hist = stock.history(period="2d")
26
+
27
+ # Return minimal info - we'll enhance it optionally
28
+ info = {
29
+ "longName": ticker, # Default, will be enhanced if info is fetched
30
+ "volume": int(hist['Volume'].iloc[-1]) if not hist.empty else 0
31
+ }
32
+
33
+ return info, hist
34
+
35
+
36
+ def _fetch_stock_info_with_timeout(ticker: str, timeout: int = 5) -> Optional[Dict]:
37
+ """
38
+ Fetch stock info with timeout to prevent hanging.
39
+ Returns None if timeout or error occurs.
40
+ """
41
+ try:
42
+ stock = yf.Ticker(ticker)
43
+ # Use ThreadPoolExecutor with timeout
44
+ with ThreadPoolExecutor(max_workers=1) as executor:
45
+ future = executor.submit(lambda: stock.info)
46
+ try:
47
+ info = future.result(timeout=timeout)
48
+ return info
49
+ except Exception as e:
50
+ logger.warning(f"Info fetch timeout/error for {ticker}: {e}")
51
+ return None
52
+ except Exception as e:
53
+ logger.warning(f"Error fetching info for {ticker}: {e}")
54
+ return None
55
+
56
+
57
+ @cached(quote_cache)
58
+ def get_cached_stock_data(ticker: str) -> Tuple[Dict, pd.DataFrame]:
59
+ """
60
+ Cached wrapper for fast stock data fetching.
61
+ Cache key is the ticker symbol, TTL is configured via CACHE_TTL.
62
+ Uses fast path (history only) for sub-2s response times.
63
+ """
64
+ return _fetch_stock_data_fast(ticker)
65
+
66
+
67
+ def fetch_historical_data(ticker: str, period: str, interval: str) -> pd.DataFrame:
68
+ """
69
+ Fetch historical data for a ticker.
70
+
71
+ Args:
72
+ ticker: Stock ticker symbol
73
+ period: Data period (e.g., "1mo", "1y")
74
+ interval: Data interval (e.g., "1d", "1h")
75
+
76
+ Returns:
77
+ DataFrame with historical OHLCV data
78
+ """
79
+ stock = yf.Ticker(ticker)
80
+ return stock.history(period=period, interval=interval)
81
+
82
+
83
+ def prewarm_yfinance():
84
+ """
85
+ Pre-warm yfinance library on server startup to eliminate cold start delay.
86
+ This makes a test request in the background so the first real request is fast.
87
+ """
88
+ def warmup():
89
+ try:
90
+ logger.info("Pre-warming yfinance library...")
91
+ import time
92
+ start = time.time()
93
+ # Make a quick test request to warm up yfinance and network connections
94
+ test_ticker = yf.Ticker("AAPL")
95
+ test_hist = test_ticker.history(period="1d")
96
+ elapsed = time.time() - start
97
+ logger.info(f"yfinance pre-warmed successfully (took {elapsed:.2f}s)")
98
+ # Pre-populate cache with a common ticker
99
+ if not test_hist.empty:
100
+ get_cached_stock_data("AAPL")
101
+ except Exception as e:
102
+ logger.warning(f"Pre-warm failed (non-critical): {e}")
103
+
104
+ # Run pre-warm in background thread to not block server startup
105
+ thread = threading.Thread(target=warmup, daemon=True)
106
+ thread.start()
107
+ logger.info("Pre-warm thread started (running in background)")
108
+
src/error_handler.py ADDED
@@ -0,0 +1,35 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Error handling module for Financial Market Data MCP Server.
3
+ Provides secure error handling decorator.
4
+ """
5
+
6
+ import logging
7
+ from functools import wraps
8
+ from datetime import datetime
9
+ from typing import Callable, Any
10
+
11
+ logger = logging.getLogger(__name__)
12
+
13
+
14
+ def handle_errors(func: Callable) -> Callable:
15
+ """
16
+ Decorator for secure error handling.
17
+ Logs detailed errors but returns sanitized messages to users.
18
+ """
19
+ @wraps(func)
20
+ def wrapper(*args, **kwargs) -> Any:
21
+ try:
22
+ return func(*args, **kwargs)
23
+ except Exception as e:
24
+ # Log full error details for debugging
25
+ logger.error(f"Error in {func.__name__}: {str(e)}", exc_info=True)
26
+
27
+ # Return sanitized error message to user
28
+ return {
29
+ "error": "An error occurred while processing your request. Please try again later.",
30
+ "error_code": "INTERNAL_ERROR",
31
+ "timestamp": datetime.now().isoformat()
32
+ }
33
+
34
+ return wrapper
35
+
src/historical_data.py ADDED
@@ -0,0 +1,70 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Historical data module for Financial Market Data MCP Server.
3
+ Handles historical price data retrieval.
4
+ """
5
+
6
+ from datetime import datetime
7
+ from typing import Dict
8
+ from .data_fetcher import fetch_historical_data
9
+ from .validators import validate_ticker, validate_period, validate_interval
10
+ from .config import logger
11
+
12
+
13
+ def get_historical_data(ticker: str, period: str = "1mo", interval: str = "1d") -> Dict:
14
+ """
15
+ Get historical price data.
16
+
17
+ Args:
18
+ ticker: Stock ticker symbol
19
+ period: Data period
20
+ interval: Data interval
21
+
22
+ Returns:
23
+ Dictionary with historical OHLCV data
24
+ """
25
+ # Validate all inputs
26
+ is_valid_ticker, sanitized_ticker, error = validate_ticker(ticker)
27
+ if not is_valid_ticker:
28
+ return {"error": error, "error_code": "INVALID_TICKER"}
29
+
30
+ is_valid_period, sanitized_period, error = validate_period(period)
31
+ if not is_valid_period:
32
+ return {"error": error, "error_code": "INVALID_PERIOD"}
33
+
34
+ is_valid_interval, sanitized_interval, error = validate_interval(interval)
35
+ if not is_valid_interval:
36
+ return {"error": error, "error_code": "INVALID_INTERVAL"}
37
+
38
+ logger.info(f"Fetching historical data: {sanitized_ticker}, {sanitized_period}, {sanitized_interval}")
39
+
40
+ try:
41
+ hist = fetch_historical_data(sanitized_ticker, sanitized_period, sanitized_interval)
42
+
43
+ if hist.empty:
44
+ return {
45
+ "error": f"No historical data found for {sanitized_ticker}",
46
+ "error_code": "NO_DATA"
47
+ }
48
+
49
+ return {
50
+ "ticker": sanitized_ticker,
51
+ "period": sanitized_period,
52
+ "interval": sanitized_interval,
53
+ "data": hist.to_dict('records'),
54
+ "summary": {
55
+ "start_date": str(hist.index[0]),
56
+ "end_date": str(hist.index[-1]),
57
+ "num_points": len(hist),
58
+ "high": round(hist['High'].max(), 2),
59
+ "low": round(hist['Low'].min(), 2),
60
+ "avg_volume": int(hist['Volume'].mean())
61
+ },
62
+ "timestamp": datetime.now().isoformat()
63
+ }
64
+ except Exception as e:
65
+ logger.error(f"Error fetching historical data for {sanitized_ticker}: {e}")
66
+ return {
67
+ "error": "Unable to fetch historical data",
68
+ "error_code": "FETCH_ERROR"
69
+ }
70
+
src/interfaces.py ADDED
@@ -0,0 +1,101 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Gradio interfaces module for Financial Market Data MCP Server.
3
+ Creates all Gradio UI interfaces.
4
+ """
5
+
6
+ import gradio as gr
7
+ from .stock_quote import get_stock_quote
8
+ from .historical_data import get_historical_data
9
+ from .technical_analysis import get_technical_analysis
10
+ from .portfolio import analyze_portfolio, compare_stocks
11
+ from .market import get_market_overview
12
+ from .rate_limiter import rate_limiter
13
+ from .error_handler import handle_errors
14
+
15
+
16
+ # Stock Quote Interface
17
+ stock_quote_interface = gr.Interface(
18
+ fn=get_stock_quote,
19
+ inputs=gr.Textbox(label="Ticker Symbol", placeholder="AAPL"),
20
+ outputs=gr.JSON(label="Stock Quote"),
21
+ title="📈 Real-Time Stock Quote",
22
+ description="Get current price, volume, and key metrics for any stock",
23
+ examples=[["AAPL"], ["TSLA"], ["MSFT"], ["GOOGL"], ["NVDA"]]
24
+ )
25
+
26
+ # Historical Data Interface
27
+ historical_interface = gr.Interface(
28
+ fn=get_historical_data,
29
+ inputs=[
30
+ gr.Textbox(label="Ticker Symbol", placeholder="AAPL"),
31
+ gr.Dropdown(
32
+ choices=["1d", "5d", "1mo", "3mo", "6mo", "1y", "2y", "5y"],
33
+ value="1mo",
34
+ label="Period"
35
+ ),
36
+ gr.Dropdown(
37
+ choices=["1m", "5m", "15m", "1h", "1d"],
38
+ value="1d",
39
+ label="Interval"
40
+ )
41
+ ],
42
+ outputs=gr.JSON(label="Historical Data"),
43
+ title="📊 Historical Price Data",
44
+ description="Get historical OHLCV data for any period and interval"
45
+ )
46
+
47
+ # Technical Analysis Interface
48
+ technical_interface = gr.Interface(
49
+ fn=get_technical_analysis,
50
+ inputs=[
51
+ gr.Textbox(label="Ticker Symbol", placeholder="AAPL"),
52
+ gr.Dropdown(
53
+ choices=["1mo", "3mo", "6mo", "1y"],
54
+ value="3mo",
55
+ label="Analysis Period"
56
+ )
57
+ ],
58
+ outputs=gr.JSON(label="Technical Analysis"),
59
+ title="🔍 Technical Analysis",
60
+ description="Comprehensive technical analysis with RSI, MACD, Bollinger Bands, and trading signals",
61
+ examples=[["AAPL", "3mo"], ["TSLA", "6mo"]]
62
+ )
63
+
64
+ # Portfolio Analysis Interface
65
+ portfolio_interface = gr.Interface(
66
+ fn=analyze_portfolio,
67
+ inputs=gr.Textbox(
68
+ label="Portfolio Holdings (JSON)",
69
+ placeholder='{"AAPL": {"shares": 10, "cost_basis": 150}, "TSLA": {"shares": 5, "cost_basis": 200}}',
70
+ lines=5
71
+ ),
72
+ outputs=gr.JSON(label="Portfolio Analysis"),
73
+ title="💼 Portfolio Analyzer",
74
+ description="Analyze your portfolio with current values, gains/losses, and allocation"
75
+ )
76
+
77
+ # Stock Comparison Interface
78
+ comparison_interface = gr.Interface(
79
+ fn=compare_stocks,
80
+ inputs=[
81
+ gr.Textbox(label="Ticker Symbols (comma-separated)", placeholder="AAPL,MSFT,GOOGL"),
82
+ gr.Dropdown(
83
+ choices=["performance", "valuation", "volatility"],
84
+ value="performance",
85
+ label="Comparison Metric"
86
+ )
87
+ ],
88
+ outputs=gr.JSON(label="Stock Comparison"),
89
+ title="⚖️ Stock Comparison",
90
+ description="Compare multiple stocks across performance, valuation, or volatility"
91
+ )
92
+
93
+ # Market Overview Interface
94
+ market_interface = gr.Interface(
95
+ fn=get_market_overview,
96
+ inputs=[],
97
+ outputs=gr.JSON(label="Market Overview"),
98
+ title="🌍 Market Overview",
99
+ description="Current status of major market indices and overall sentiment"
100
+ )
101
+
src/market.py ADDED
@@ -0,0 +1,75 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Market overview module for Financial Market Data MCP Server.
3
+ Provides overview of major market indices.
4
+ """
5
+
6
+ import yfinance as yf
7
+ from datetime import datetime
8
+ from typing import Dict
9
+ from .config import logger
10
+
11
+
12
+ def get_market_overview() -> Dict:
13
+ """
14
+ Get overview of major market indices and sectors.
15
+
16
+ Returns:
17
+ Current status of major indices
18
+ """
19
+ logger.info("Fetching market overview")
20
+
21
+ try:
22
+ indices = {
23
+ "S&P 500": "^GSPC",
24
+ "Dow Jones": "^DJI",
25
+ "NASDAQ": "^IXIC",
26
+ "Russell 2000": "^RUT",
27
+ "VIX": "^VIX"
28
+ }
29
+
30
+ results = {}
31
+
32
+ for name, symbol in indices.items():
33
+ try:
34
+ stock = yf.Ticker(symbol)
35
+ hist = stock.history(period="5d")
36
+
37
+ if not hist.empty:
38
+ current_price = hist['Close'].iloc[-1]
39
+ prev_close = hist['Close'].iloc[-2] if len(hist) > 1 else current_price
40
+ change = current_price - prev_close
41
+ change_percent = (change / prev_close * 100) if prev_close else 0
42
+
43
+ results[name] = {
44
+ "price": round(current_price, 2),
45
+ "change": round(change, 2),
46
+ "change_percent": round(change_percent, 2)
47
+ }
48
+ else:
49
+ logger.warning(f"No data for {name} ({symbol})")
50
+
51
+ except Exception as e:
52
+ logger.error(f"Error fetching {name}: {e}")
53
+ continue
54
+
55
+ # Determine market sentiment
56
+ sp500_change = results.get("S&P 500", {}).get('change_percent', 0)
57
+ if sp500_change > 1:
58
+ sentiment = "🟢 Bullish"
59
+ elif sp500_change < -1:
60
+ sentiment = "🔴 Bearish"
61
+ else:
62
+ sentiment = "🟡 Neutral"
63
+
64
+ return {
65
+ "indices": results,
66
+ "market_sentiment": sentiment,
67
+ "timestamp": datetime.now().isoformat()
68
+ }
69
+ except Exception as e:
70
+ logger.error(f"Error fetching market overview: {e}")
71
+ return {
72
+ "error": "Unable to fetch market overview",
73
+ "error_code": "FETCH_ERROR"
74
+ }
75
+
src/portfolio.py ADDED
@@ -0,0 +1,256 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Portfolio analysis module for Financial Market Data MCP Server.
3
+ Handles portfolio tracking and stock comparison.
4
+ """
5
+
6
+ import time
7
+ import numpy as np
8
+ import yfinance as yf
9
+ from datetime import datetime
10
+ from typing import Dict
11
+ from concurrent.futures import ThreadPoolExecutor
12
+ from .data_fetcher import get_cached_stock_data, _fetch_stock_data_fast
13
+ from .validators import validate_json_input, validate_ticker, validate_metric
14
+ from .config import MAX_TICKERS_PER_REQUEST, logger
15
+
16
+
17
+ def analyze_portfolio(holdings: str) -> Dict:
18
+ """
19
+ Analyze a portfolio of stocks.
20
+
21
+ Args:
22
+ holdings: JSON string with portfolio data
23
+
24
+ Returns:
25
+ Portfolio analysis with current value, gains/losses, and allocation
26
+ """
27
+ # Validate JSON input
28
+ is_valid, portfolio, error = validate_json_input(holdings)
29
+ if not is_valid:
30
+ return {"error": error, "error_code": "INVALID_INPUT"}
31
+
32
+ logger.info(f"Analyzing portfolio with {len(portfolio)} positions")
33
+ start_time = time.time()
34
+
35
+ try:
36
+ total_value = 0
37
+ total_cost = 0
38
+ results = []
39
+
40
+ # Prepare positions for parallel fetching
41
+ positions_data = []
42
+ for ticker, position in portfolio.items():
43
+ is_valid_ticker, _, ticker_error = validate_ticker(
44
+ ticker,
45
+ check_exists=True,
46
+ fetcher=get_cached_stock_data
47
+ )
48
+ if not is_valid_ticker:
49
+ return {"error": ticker_error or f"Invalid ticker '{ticker}'", "error_code": "INVALID_TICKER"}
50
+
51
+ shares = position.get('shares', 0)
52
+ cost_basis = position.get('cost_basis', 0)
53
+
54
+ # Validate numeric inputs
55
+ if not isinstance(shares, (int, float)) or shares <= 0:
56
+ return {"error": f"Invalid shares for {ticker}", "error_code": "INVALID_DATA"}
57
+
58
+ if not isinstance(cost_basis, (int, float)) or cost_basis < 0:
59
+ return {"error": f"Invalid cost basis for {ticker}", "error_code": "INVALID_DATA"}
60
+
61
+ positions_data.append((ticker, shares, cost_basis))
62
+
63
+ if not positions_data:
64
+ logger.info("Portfolio analysis requested with zero positions")
65
+ return {
66
+ "portfolio_value": 0.0,
67
+ "total_cost": 0.0,
68
+ "total_gain_loss": 0.0,
69
+ "total_gain_loss_percent": 0.0,
70
+ "positions": [],
71
+ "num_positions": 0,
72
+ "timestamp": datetime.now().isoformat()
73
+ }
74
+
75
+ # Fetch all positions in parallel
76
+ def fetch_position_data(ticker, shares, cost_basis):
77
+ """Fetch data for a single position."""
78
+ try:
79
+ # Use cached fast fetch if available, otherwise fetch directly
80
+ try:
81
+ info, hist = get_cached_stock_data(ticker)
82
+ except KeyError:
83
+ # Not in cache, fetch directly
84
+ info, hist = _fetch_stock_data_fast(ticker)
85
+
86
+ if not hist.empty:
87
+ current_price = hist['Close'].iloc[-1]
88
+ position_value = shares * current_price
89
+ position_cost = shares * cost_basis
90
+ gain_loss = position_value - position_cost
91
+ gain_loss_percent = (gain_loss / position_cost * 100) if position_cost else 0
92
+
93
+ return {
94
+ "ticker": ticker,
95
+ "name": info.get("longName", ticker),
96
+ "shares": shares,
97
+ "cost_basis": cost_basis,
98
+ "current_price": round(current_price, 2),
99
+ "position_value": round(position_value, 2),
100
+ "gain_loss": round(gain_loss, 2),
101
+ "gain_loss_percent": round(gain_loss_percent, 2),
102
+ "allocation_percent": 0
103
+ }
104
+ except Exception as e:
105
+ logger.error(f"Error fetching data for {ticker}: {e}")
106
+ return None
107
+
108
+ # Fetch all positions in parallel
109
+ with ThreadPoolExecutor(max_workers=min(len(positions_data), 5)) as executor:
110
+ position_results = list(executor.map(
111
+ lambda x: fetch_position_data(x[0], x[1], x[2]),
112
+ positions_data
113
+ ))
114
+
115
+ # Process results and calculate totals
116
+ for result in position_results:
117
+ if result is not None:
118
+ total_value += result['position_value']
119
+ total_cost += result['shares'] * result['cost_basis']
120
+ results.append(result)
121
+
122
+ # Calculate allocation percentages
123
+ for result in results:
124
+ result['allocation_percent'] = round(
125
+ (result['position_value'] / total_value * 100) if total_value else 0, 2
126
+ )
127
+
128
+ total_gain_loss = total_value - total_cost
129
+ total_gain_loss_percent = (total_gain_loss / total_cost * 100) if total_cost else 0
130
+
131
+ elapsed = time.time() - start_time
132
+ logger.info(f"Portfolio analysis completed in {elapsed:.2f}s")
133
+
134
+ return {
135
+ "portfolio_value": round(total_value, 2),
136
+ "total_cost": round(total_cost, 2),
137
+ "total_gain_loss": round(total_gain_loss, 2),
138
+ "total_gain_loss_percent": round(total_gain_loss_percent, 2),
139
+ "positions": results,
140
+ "num_positions": len(results),
141
+ "timestamp": datetime.now().isoformat()
142
+ }
143
+ except Exception as e:
144
+ logger.error(f"Error analyzing portfolio: {e}")
145
+ return {
146
+ "error": "Unable to analyze portfolio",
147
+ "error_code": "ANALYSIS_ERROR"
148
+ }
149
+
150
+
151
+ def compare_stocks(tickers: str, metric: str = "performance") -> Dict:
152
+ """
153
+ Compare multiple stocks across various metrics.
154
+
155
+ Args:
156
+ tickers: Comma-separated ticker symbols
157
+ metric: Comparison metric
158
+
159
+ Returns:
160
+ Comparison data for all stocks
161
+ """
162
+ # Validate metric
163
+ is_valid_metric, sanitized_metric, error = validate_metric(metric)
164
+ if not is_valid_metric:
165
+ return {"error": error, "error_code": "INVALID_METRIC"}
166
+
167
+ # Parse and validate tickers
168
+ ticker_list = [t.strip().upper() for t in tickers.split(',')]
169
+
170
+ if len(ticker_list) > MAX_TICKERS_PER_REQUEST:
171
+ return {
172
+ "error": f"Too many tickers (max {MAX_TICKERS_PER_REQUEST})",
173
+ "error_code": "TOO_MANY_TICKERS"
174
+ }
175
+
176
+ # Validate each ticker
177
+ for ticker in ticker_list:
178
+ is_valid, _, error = validate_ticker(
179
+ ticker,
180
+ check_exists=True,
181
+ fetcher=get_cached_stock_data
182
+ )
183
+ if not is_valid:
184
+ return {"error": f"Invalid ticker '{ticker}': {error}", "error_code": "INVALID_TICKER"}
185
+
186
+ logger.info(f"Comparing stocks: {ticker_list}, metric: {sanitized_metric}")
187
+ start_time = time.time()
188
+
189
+ try:
190
+ results = []
191
+
192
+ # Fetch all stocks in parallel for significant speedup
193
+ def fetch_stock_data(ticker):
194
+ """Fetch data for a single stock in parallel."""
195
+ try:
196
+ stock = yf.Ticker(ticker)
197
+ with ThreadPoolExecutor(max_workers=2) as executor:
198
+ future_info = executor.submit(lambda: stock.info)
199
+ future_hist = executor.submit(stock.history, period="1y")
200
+ info = future_info.result()
201
+ hist = future_hist.result()
202
+ return ticker, info, hist
203
+ except Exception as e:
204
+ logger.error(f"Error fetching {ticker}: {e}")
205
+ return ticker, None, None
206
+
207
+ # Fetch all tickers in parallel
208
+ with ThreadPoolExecutor(max_workers=min(len(ticker_list), 5)) as executor:
209
+ stock_data_list = list(executor.map(fetch_stock_data, ticker_list))
210
+
211
+ # Process results
212
+ for ticker, info, hist in stock_data_list:
213
+ if info is None or hist is None or hist.empty:
214
+ continue
215
+
216
+ year_start = hist['Close'].iloc[0]
217
+ year_end = hist['Close'].iloc[-1]
218
+ year_return = ((year_end - year_start) / year_start * 100) if year_start else 0
219
+ volatility = hist['Close'].pct_change().std() * np.sqrt(252) * 100
220
+
221
+ results.append({
222
+ "ticker": ticker,
223
+ "name": info.get("longName", ticker),
224
+ "current_price": round(hist['Close'].iloc[-1], 2),
225
+ "ytd_return": round(year_return, 2),
226
+ "volatility": round(volatility, 2),
227
+ "pe_ratio": info.get("trailingPE"),
228
+ "market_cap": info.get("marketCap"),
229
+ "volume": info.get("volume"),
230
+ "dividend_yield": info.get("dividendYield")
231
+ })
232
+
233
+ # Sort by selected metric
234
+ if sanitized_metric == "performance":
235
+ results.sort(key=lambda x: x['ytd_return'], reverse=True)
236
+ elif sanitized_metric == "valuation":
237
+ results.sort(key=lambda x: x['pe_ratio'] or float('inf'))
238
+ elif sanitized_metric == "volatility":
239
+ results.sort(key=lambda x: x['volatility'])
240
+
241
+ elapsed = time.time() - start_time
242
+ logger.info(f"Stock comparison completed in {elapsed:.2f}s")
243
+
244
+ return {
245
+ "comparison": results,
246
+ "metric": sanitized_metric,
247
+ "best": results[0]['ticker'] if results else None,
248
+ "timestamp": datetime.now().isoformat()
249
+ }
250
+ except Exception as e:
251
+ logger.error(f"Error comparing stocks: {e}")
252
+ return {
253
+ "error": "Unable to compare stocks",
254
+ "error_code": "COMPARISON_ERROR"
255
+ }
256
+
src/rate_limiter.py ADDED
@@ -0,0 +1,53 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Rate limiting module for Financial Market Data MCP Server.
3
+ Prevents API abuse through request rate limiting.
4
+ """
5
+
6
+ import time
7
+ import logging
8
+ from functools import wraps
9
+ from typing import Callable, Any
10
+ from .config import RATE_LIMIT_CALLS, RATE_LIMIT_PERIOD, logger
11
+
12
+
13
+ class RateLimiter:
14
+ """Simple rate limiter to prevent API abuse."""
15
+
16
+ def __init__(self, max_calls: int = None, period: int = None):
17
+ self.max_calls = max_calls or RATE_LIMIT_CALLS
18
+ self.period = period or RATE_LIMIT_PERIOD
19
+ self.calls = []
20
+
21
+ def __call__(self, func: Callable) -> Callable:
22
+ @wraps(func)
23
+ def wrapper(*args, **kwargs) -> Any:
24
+ now = time.time()
25
+
26
+ # Remove old calls outside the time window
27
+ self.calls = [call_time for call_time in self.calls
28
+ if now - call_time < self.period]
29
+
30
+ # Check if rate limit exceeded
31
+ if len(self.calls) >= self.max_calls:
32
+ wait_time = self.period - (now - self.calls[0])
33
+ logger.warning(f"Rate limit exceeded. Wait {wait_time:.1f}s")
34
+ return {
35
+ "error": f"Rate limit exceeded. Please wait {wait_time:.1f} seconds.",
36
+ "rate_limit": {
37
+ "max_calls": self.max_calls,
38
+ "period": self.period,
39
+ "retry_after": wait_time
40
+ }
41
+ }
42
+
43
+ # Record this call
44
+ self.calls.append(now)
45
+
46
+ return func(*args, **kwargs)
47
+
48
+ return wrapper
49
+
50
+
51
+ # Initialize default rate limiter
52
+ rate_limiter = RateLimiter()
53
+
src/stock_quote.py ADDED
@@ -0,0 +1,90 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Stock quote module for Financial Market Data MCP Server.
3
+ Handles real-time stock quote retrieval.
4
+ """
5
+
6
+ import time
7
+ from datetime import datetime
8
+ from typing import Dict
9
+ from .data_fetcher import get_cached_stock_data
10
+ from .validators import validate_ticker
11
+ from .config import logger
12
+
13
+
14
+ def get_stock_quote(ticker: str) -> Dict:
15
+ """
16
+ Get real-time stock quote with key metrics (OPTIMIZED).
17
+
18
+ Args:
19
+ ticker: Stock ticker symbol (e.g., 'AAPL', 'TSLA')
20
+
21
+ Returns:
22
+ Dictionary with current price, change, volume, and key metrics
23
+ """
24
+ raw_ticker = (ticker or "").strip().upper()
25
+
26
+ # Validate input
27
+ is_valid, sanitized_ticker, error = validate_ticker(
28
+ ticker,
29
+ check_exists=True,
30
+ fetcher=get_cached_stock_data
31
+ )
32
+ if not is_valid:
33
+ if "too long" in error.lower():
34
+ logger.warning(f"Ticker '{ticker}' exceeds max length; continuing with sanitized value.")
35
+ sanitized_ticker = raw_ticker
36
+ else:
37
+ logger.warning(f"Invalid ticker rejected: {ticker}")
38
+ return {"error": error, "error_code": "INVALID_INPUT"}
39
+
40
+ logger.info(f"Fetching quote for: {sanitized_ticker}")
41
+ start_time = time.time()
42
+
43
+ try:
44
+ # Fast path: Get history data (usually <2s)
45
+ info, hist = get_cached_stock_data(sanitized_ticker)
46
+
47
+ if hist.empty:
48
+ logger.info(f"No data found for ticker: {sanitized_ticker}")
49
+ return {
50
+ "error": f"No data found for ticker: {sanitized_ticker}",
51
+ "error_code": "NO_DATA"
52
+ }
53
+
54
+ current_price = hist['Close'].iloc[-1]
55
+ prev_close = hist['Close'].iloc[-2] if len(hist) > 1 else current_price
56
+ change = current_price - prev_close
57
+ change_percent = (change / prev_close * 100) if prev_close else 0
58
+
59
+ # Get volume from history (fast)
60
+ volume = int(hist['Volume'].iloc[-1]) if not hist.empty else 0
61
+
62
+ # Build fast response immediately (core data from history)
63
+ result = {
64
+ "ticker": sanitized_ticker,
65
+ "name": sanitized_ticker,
66
+ "price": round(current_price, 2),
67
+ "change": round(change, 2),
68
+ "change_percent": round(change_percent, 2),
69
+ "volume": volume,
70
+ "market_cap": None,
71
+ "pe_ratio": None,
72
+ "52_week_high": None,
73
+ "52_week_low": None,
74
+ "dividend_yield": None,
75
+ "sector": None,
76
+ "industry": None,
77
+ "timestamp": datetime.now().isoformat()
78
+ }
79
+
80
+ elapsed = time.time() - start_time
81
+ logger.info(f"Successfully fetched quote for {sanitized_ticker}: ${result['price']} (took {elapsed:.2f}s)")
82
+ return result
83
+
84
+ except Exception as e:
85
+ logger.error(f"Error fetching quote for {sanitized_ticker}: {e}")
86
+ return {
87
+ "error": "Unable to fetch stock data. Please verify the ticker symbol.",
88
+ "error_code": "FETCH_ERROR"
89
+ }
90
+
src/technical_analysis.py ADDED
@@ -0,0 +1,214 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Technical analysis module for Financial Market Data MCP Server.
3
+ Calculates technical indicators: RSI, MACD, Bollinger Bands, Moving Averages.
4
+ """
5
+
6
+ import pandas as pd
7
+ import numpy as np
8
+ from typing import Dict
9
+ from .data_fetcher import fetch_historical_data
10
+ from .validators import validate_ticker, validate_period
11
+ from .config import logger
12
+
13
+
14
+ def calculate_sma(prices: pd.Series, window: int) -> pd.Series:
15
+ """Calculate Simple Moving Average."""
16
+ return prices.rolling(window=window).mean()
17
+
18
+
19
+ def calculate_ema(prices: pd.Series, window: int) -> pd.Series:
20
+ """Calculate Exponential Moving Average."""
21
+ return prices.ewm(span=window, adjust=False).mean()
22
+
23
+
24
+ def calculate_rsi(prices: pd.Series, period: int = 14) -> float:
25
+ """Calculate Relative Strength Index."""
26
+ if len(prices) < period + 1:
27
+ return 50.0 # Neutral RSI if insufficient data
28
+
29
+ delta = prices.diff()
30
+ gains = delta.where(delta > 0, 0)
31
+ losses = -delta.where(delta < 0, 0)
32
+ avg_gain = gains.rolling(window=period).mean()
33
+ avg_loss = losses.rolling(window=period).mean()
34
+
35
+ with np.errstate(divide='ignore', invalid='ignore'):
36
+ rs = avg_gain / avg_loss
37
+ rsi = 100 - (100 / (1 + rs))
38
+
39
+ if avg_loss.iloc[-1] == 0 and avg_gain.iloc[-1] > 0:
40
+ return 100.0 # All gains, no losses
41
+ if avg_gain.iloc[-1] == 0 and avg_loss.iloc[-1] > 0:
42
+ return 0.0 # All losses, no gains
43
+
44
+ last_value = rsi.iloc[-1]
45
+ if pd.isna(last_value) or np.isinf(last_value):
46
+ return 50.0
47
+
48
+ return round(float(last_value), 2)
49
+
50
+
51
+ def calculate_macd(prices: pd.Series, fast: int = 12, slow: int = 26, signal: int = 9) -> Dict:
52
+ """Calculate MACD (Moving Average Convergence Divergence)."""
53
+ if len(prices) < slow + signal:
54
+ return {
55
+ "macd": 0.0,
56
+ "signal": 0.0,
57
+ "histogram": 0.0,
58
+ "trend": "neutral"
59
+ }
60
+
61
+ ema_fast = prices.ewm(span=fast, adjust=False).mean()
62
+ ema_slow = prices.ewm(span=slow, adjust=False).mean()
63
+
64
+ macd_line = ema_fast - ema_slow
65
+ signal_line = macd_line.ewm(span=signal, adjust=False).mean()
66
+ histogram = macd_line - signal_line
67
+
68
+ return {
69
+ "macd": round(macd_line.iloc[-1], 2),
70
+ "signal": round(signal_line.iloc[-1], 2),
71
+ "histogram": round(histogram.iloc[-1], 2),
72
+ "trend": "bullish" if histogram.iloc[-1] > 0 else "bearish"
73
+ }
74
+
75
+
76
+ def calculate_bollinger_bands(prices: pd.Series, window: int = 20, num_std: int = 2) -> Dict:
77
+ """Calculate Bollinger Bands."""
78
+ if len(prices) < window:
79
+ current_price = prices.iloc[-1] if not prices.empty else 0
80
+ return {
81
+ "upper": round(current_price, 2),
82
+ "middle": round(current_price, 2),
83
+ "lower": round(current_price, 2),
84
+ "current_price": round(current_price, 2),
85
+ "position": "neutral"
86
+ }
87
+
88
+ sma = prices.rolling(window=window).mean()
89
+ std = prices.rolling(window=window).std()
90
+
91
+ upper_band = sma + (std * num_std)
92
+ lower_band = sma - (std * num_std)
93
+
94
+ current_price = prices.iloc[-1]
95
+
96
+ return {
97
+ "upper": round(upper_band.iloc[-1], 2),
98
+ "middle": round(sma.iloc[-1], 2),
99
+ "lower": round(lower_band.iloc[-1], 2),
100
+ "current_price": round(current_price, 2),
101
+ "position": "overbought" if current_price > upper_band.iloc[-1]
102
+ else "oversold" if current_price < lower_band.iloc[-1]
103
+ else "neutral"
104
+ }
105
+
106
+
107
+ def get_technical_analysis(ticker: str, period: str = "3mo") -> Dict:
108
+ """
109
+ Perform comprehensive technical analysis.
110
+
111
+ Args:
112
+ ticker: Stock ticker symbol
113
+ period: Historical data period for analysis
114
+
115
+ Returns:
116
+ Dictionary with multiple technical indicators and signals
117
+ """
118
+ from datetime import datetime
119
+
120
+ # Validate inputs
121
+ is_valid_ticker, sanitized_ticker, error = validate_ticker(ticker)
122
+ if not is_valid_ticker:
123
+ return {"error": error, "error_code": "INVALID_TICKER"}
124
+
125
+ is_valid_period, sanitized_period, error = validate_period(period)
126
+ if not is_valid_period:
127
+ return {"error": error, "error_code": "INVALID_PERIOD"}
128
+
129
+ logger.info(f"Performing technical analysis: {sanitized_ticker}, {sanitized_period}")
130
+
131
+ try:
132
+ hist = fetch_historical_data(sanitized_ticker, sanitized_period, "1d")
133
+
134
+ if hist.empty or len(hist) < 50:
135
+ return {
136
+ "error": f"Insufficient data for technical analysis of {sanitized_ticker}",
137
+ "error_code": "INSUFFICIENT_DATA"
138
+ }
139
+
140
+ prices = hist['Close']
141
+
142
+ # Calculate indicators
143
+ sma_20 = calculate_sma(prices, 20)
144
+ sma_50 = calculate_sma(prices, 50)
145
+ sma_200 = calculate_sma(prices, 200) if len(hist) >= 200 else None
146
+ ema_12 = calculate_ema(prices, 12)
147
+ ema_26 = calculate_ema(prices, 26)
148
+ rsi = calculate_rsi(prices)
149
+ macd = calculate_macd(prices)
150
+ bollinger = calculate_bollinger_bands(prices)
151
+
152
+ current_price = prices.iloc[-1]
153
+
154
+ # Generate trading signals
155
+ signals = []
156
+
157
+ if rsi < 30:
158
+ signals.append("🟢 RSI oversold - potential BUY signal")
159
+ elif rsi > 70:
160
+ signals.append("🔴 RSI overbought - potential SELL signal")
161
+ else:
162
+ signals.append("🟡 RSI neutral")
163
+
164
+ if macd['trend'] == 'bullish' and macd['histogram'] > 0:
165
+ signals.append("🟢 MACD bullish crossover")
166
+ elif macd['trend'] == 'bearish' and macd['histogram'] < 0:
167
+ signals.append("🔴 MACD bearish crossover")
168
+
169
+ if sma_200 is not None and current_price > sma_50.iloc[-1] > sma_200.iloc[-1]:
170
+ signals.append("🟢 Golden Cross formation (bullish)")
171
+ elif sma_200 is not None and current_price < sma_50.iloc[-1] < sma_200.iloc[-1]:
172
+ signals.append("🔴 Death Cross formation (bearish)")
173
+
174
+ if bollinger['position'] == 'oversold':
175
+ signals.append("🟢 Price at lower Bollinger Band (potential bounce)")
176
+ elif bollinger['position'] == 'overbought':
177
+ signals.append("🔴 Price at upper Bollinger Band (potential pullback)")
178
+
179
+ # Overall recommendation
180
+ bullish_count = sum(1 for s in signals if '🟢' in s)
181
+ bearish_count = sum(1 for s in signals if '🔴' in s)
182
+
183
+ if bullish_count > bearish_count:
184
+ overall = "BUY"
185
+ elif bearish_count > bullish_count:
186
+ overall = "SELL"
187
+ else:
188
+ overall = "HOLD"
189
+
190
+ return {
191
+ "ticker": sanitized_ticker,
192
+ "current_price": round(current_price, 2),
193
+ "indicators": {
194
+ "sma_20": round(sma_20.iloc[-1], 2),
195
+ "sma_50": round(sma_50.iloc[-1], 2),
196
+ "sma_200": round(sma_200.iloc[-1], 2) if sma_200 is not None else None,
197
+ "ema_12": round(ema_12.iloc[-1], 2),
198
+ "ema_26": round(ema_26.iloc[-1], 2),
199
+ "rsi": rsi,
200
+ "macd": macd,
201
+ "bollinger_bands": bollinger
202
+ },
203
+ "signals": signals,
204
+ "recommendation": overall,
205
+ "confidence": f"{max(bullish_count, bearish_count)}/{len(signals)}",
206
+ "timestamp": datetime.now().isoformat()
207
+ }
208
+ except Exception as e:
209
+ logger.error(f"Error in technical analysis for {sanitized_ticker}: {e}")
210
+ return {
211
+ "error": "Unable to perform technical analysis",
212
+ "error_code": "ANALYSIS_ERROR"
213
+ }
214
+
src/validators.py ADDED
@@ -0,0 +1,165 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Input validation module for Financial Market Data MCP Server.
3
+ Handles validation and sanitization of user inputs.
4
+ """
5
+
6
+ import json
7
+ import re
8
+ import logging
9
+ from typing import Tuple, Dict, Callable
10
+ from .config import (
11
+ ALLOWED_TICKER_PATTERN,
12
+ MAX_TICKERS_PER_REQUEST,
13
+ ALLOWED_PERIODS,
14
+ ALLOWED_INTERVALS,
15
+ ALLOWED_METRICS
16
+ )
17
+ from .data_fetcher import get_cached_stock_data
18
+
19
+ logger = logging.getLogger(__name__)
20
+
21
+
22
+ def _ticker_has_market_data(ticker: str, fetcher: Callable) -> bool:
23
+ """Return True if the fetcher can return market data for ticker."""
24
+ try:
25
+ _, hist = fetcher(ticker)
26
+ return not hist.empty
27
+ except Exception as exc:
28
+ logger.warning(f"Ticker existence check failed for {ticker}: {exc}")
29
+ return False
30
+
31
+
32
+ def validate_ticker(
33
+ ticker: str,
34
+ *,
35
+ check_exists: bool = False,
36
+ fetcher: Callable = None
37
+ ) -> Tuple[bool, str, str]:
38
+ """
39
+ Validate and sanitize ticker symbol.
40
+
41
+ Args:
42
+ ticker: Raw ticker input
43
+
44
+ Returns:
45
+ Tuple of (is_valid, sanitized_ticker, error_message)
46
+
47
+ check_exists: When True, ensure the ticker returns real market data
48
+ fetcher: Optional callable returning (info, history). Defaults to get_cached_stock_data
49
+
50
+ Returns:
51
+ Tuple of (is_valid, sanitized_ticker, error_message)
52
+ """
53
+ if not ticker:
54
+ return False, "", "Ticker symbol is required"
55
+
56
+ # Remove whitespace and convert to uppercase
57
+ sanitized = ticker.strip().upper()
58
+
59
+ # Check length
60
+ if len(sanitized) > 5:
61
+ return False, "", "Ticker symbol too long (max 5 characters)"
62
+
63
+ # Check format (only uppercase letters)
64
+ if not re.match(ALLOWED_TICKER_PATTERN, sanitized):
65
+ logger.warning(f"Invalid ticker format attempted: {ticker}")
66
+ return False, "", "Invalid ticker format. Use 1-5 uppercase letters only."
67
+
68
+ # Check for common injection patterns
69
+ dangerous_patterns = [';', '--', '/*', '*/', 'DROP', 'DELETE', 'INSERT']
70
+ if any(pattern in sanitized for pattern in dangerous_patterns):
71
+ logger.error(f"Potential SQL injection attempt: {ticker}")
72
+ return False, "", "Invalid characters in ticker symbol"
73
+
74
+ if check_exists:
75
+ fetch_fn = fetcher or get_cached_stock_data
76
+ if not _ticker_has_market_data(sanitized, fetch_fn):
77
+ return False, "", f"Ticker '{sanitized}' not found or has no market data"
78
+
79
+ return True, sanitized, ""
80
+
81
+
82
+ def validate_period(period: str) -> Tuple[bool, str, str]:
83
+ """
84
+ Validate period parameter.
85
+
86
+ Args:
87
+ period: Period string
88
+
89
+ Returns:
90
+ Tuple of (is_valid, sanitized_period, error_message)
91
+ """
92
+ if period not in ALLOWED_PERIODS:
93
+ return False, "", f"Invalid period. Allowed: {', '.join(ALLOWED_PERIODS)}"
94
+
95
+ return True, period, ""
96
+
97
+
98
+ def validate_interval(interval: str) -> Tuple[bool, str, str]:
99
+ """
100
+ Validate interval parameter.
101
+
102
+ Args:
103
+ interval: Interval string
104
+
105
+ Returns:
106
+ Tuple of (is_valid, sanitized_interval, error_message)
107
+ """
108
+ if interval not in ALLOWED_INTERVALS:
109
+ return False, "", f"Invalid interval. Allowed: {', '.join(ALLOWED_INTERVALS)}"
110
+
111
+ return True, interval, ""
112
+
113
+
114
+ def validate_metric(metric: str) -> Tuple[bool, str, str]:
115
+ """
116
+ Validate comparison metric parameter.
117
+
118
+ Args:
119
+ metric: Metric string
120
+
121
+ Returns:
122
+ Tuple of (is_valid, sanitized_metric, error_message)
123
+ """
124
+ if metric not in ALLOWED_METRICS:
125
+ return False, "", f"Invalid metric. Allowed: {', '.join(ALLOWED_METRICS)}"
126
+
127
+ return True, metric, ""
128
+
129
+
130
+ def validate_json_input(json_str: str) -> Tuple[bool, Dict, str]:
131
+ """
132
+ Validate and sanitize JSON input.
133
+
134
+ Args:
135
+ json_str: JSON string
136
+
137
+ Returns:
138
+ Tuple of (is_valid, parsed_json, error_message)
139
+ """
140
+ try:
141
+ data = json.loads(json_str)
142
+
143
+ # Check if it's a dictionary
144
+ if not isinstance(data, dict):
145
+ return False, {}, "JSON must be an object/dictionary"
146
+
147
+ # Limit number of items
148
+ if len(data) > MAX_TICKERS_PER_REQUEST:
149
+ return False, {}, f"Too many tickers (max {MAX_TICKERS_PER_REQUEST})"
150
+
151
+ # Validate each ticker in the portfolio
152
+ for ticker in data.keys():
153
+ is_valid, _, error = validate_ticker(ticker)
154
+ if not is_valid:
155
+ return False, {}, f"Invalid ticker '{ticker}': {error}"
156
+
157
+ return True, data, ""
158
+
159
+ except json.JSONDecodeError as e:
160
+ logger.warning(f"Invalid JSON input: {e}")
161
+ return False, {}, "Invalid JSON format"
162
+ except Exception as e:
163
+ logger.error(f"Unexpected error validating JSON: {e}")
164
+ return False, {}, "Invalid JSON format"
165
+
tests/__init__.py ADDED
@@ -0,0 +1,4 @@
 
 
 
 
 
1
+ """
2
+ Test suite for Financial Market Data MCP Server
3
+ """
4
+
tests/run_tests.py ADDED
@@ -0,0 +1,48 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Test runner for all test suites.
3
+ Run with: python tests/run_tests.py
4
+ """
5
+
6
+ import unittest
7
+ import sys
8
+ import os
9
+
10
+ # Add parent directory to path
11
+ sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
12
+
13
+ # Import all test modules
14
+ from tests.test_validators import TestValidators
15
+ from tests.test_technical_analysis import TestTechnicalAnalysis
16
+ from tests.test_data_fetcher import TestDataFetcher
17
+ from tests.test_portfolio import TestPortfolio
18
+ from tests.test_rate_limiter import TestRateLimiter
19
+ from tests.test_edge_cases import TestEdgeCases
20
+
21
+
22
+ def create_test_suite():
23
+ """Create and return test suite."""
24
+ loader = unittest.TestLoader()
25
+ suite = unittest.TestSuite()
26
+
27
+ # Add all test classes
28
+ suite.addTests(loader.loadTestsFromTestCase(TestValidators))
29
+ suite.addTests(loader.loadTestsFromTestCase(TestTechnicalAnalysis))
30
+ suite.addTests(loader.loadTestsFromTestCase(TestDataFetcher))
31
+ suite.addTests(loader.loadTestsFromTestCase(TestPortfolio))
32
+ suite.addTests(loader.loadTestsFromTestCase(TestRateLimiter))
33
+ suite.addTests(loader.loadTestsFromTestCase(TestEdgeCases))
34
+
35
+ return suite
36
+
37
+
38
+ if __name__ == '__main__':
39
+ # Create test suite
40
+ suite = create_test_suite()
41
+
42
+ # Run tests with verbose output
43
+ runner = unittest.TextTestRunner(verbosity=2)
44
+ result = runner.run(suite)
45
+
46
+ # Exit with appropriate code
47
+ sys.exit(0 if result.wasSuccessful() else 1)
48
+
tests/test_data_fetcher.py ADDED
@@ -0,0 +1,78 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Test cases for data fetching module.
3
+ Tests edge cases for yfinance data retrieval.
4
+ """
5
+
6
+ import unittest
7
+ from unittest.mock import patch, MagicMock
8
+ import pandas as pd
9
+ from src.data_fetcher import (
10
+ _fetch_stock_data_fast,
11
+ get_cached_stock_data,
12
+ fetch_historical_data
13
+ )
14
+
15
+
16
+ class TestDataFetcher(unittest.TestCase):
17
+ """Test cases for data fetching functions."""
18
+
19
+ @patch('src.data_fetcher.yf.Ticker')
20
+ def test_fetch_stock_data_fast_success(self, mock_ticker):
21
+ """Test successful data fetch."""
22
+ # Mock yfinance response
23
+ mock_stock = MagicMock()
24
+ mock_hist = pd.DataFrame({
25
+ 'Close': [150, 151],
26
+ 'Volume': [1000000, 1100000]
27
+ })
28
+ mock_stock.history.return_value = mock_hist
29
+ mock_ticker.return_value = mock_stock
30
+
31
+ info, hist = _fetch_stock_data_fast("AAPL")
32
+
33
+ self.assertIsInstance(info, dict)
34
+ self.assertIn("longName", info)
35
+ self.assertIn("volume", info)
36
+ self.assertFalse(hist.empty)
37
+
38
+ @patch('src.data_fetcher.yf.Ticker')
39
+ def test_fetch_stock_data_fast_empty(self, mock_ticker):
40
+ """Test data fetch with empty history."""
41
+ mock_stock = MagicMock()
42
+ mock_stock.history.return_value = pd.DataFrame()
43
+ mock_ticker.return_value = mock_stock
44
+
45
+ info, hist = _fetch_stock_data_fast("INVALID")
46
+
47
+ self.assertTrue(hist.empty)
48
+ self.assertEqual(info["volume"], 0)
49
+
50
+ @patch('src.data_fetcher.yf.Ticker')
51
+ def test_fetch_stock_data_fast_exception(self, mock_ticker):
52
+ """Test data fetch with exception."""
53
+ mock_ticker.side_effect = Exception("Network error")
54
+
55
+ with self.assertRaises(Exception):
56
+ _fetch_stock_data_fast("AAPL")
57
+
58
+ @patch('src.data_fetcher._fetch_stock_data_fast')
59
+ def test_get_cached_stock_data(self, mock_fetch):
60
+ """Test cached data retrieval."""
61
+ # Mock return value
62
+ mock_info = {"longName": "Apple Inc.", "volume": 1000000}
63
+ mock_hist = pd.DataFrame({'Close': [150], 'Volume': [1000000]})
64
+ mock_fetch.return_value = (mock_info, mock_hist)
65
+
66
+ # First call should fetch
67
+ info1, hist1 = get_cached_stock_data("AAPL")
68
+ self.assertEqual(mock_fetch.call_count, 1)
69
+
70
+ # Second call should use cache (if within TTL)
71
+ info2, hist2 = get_cached_stock_data("AAPL")
72
+ # Cache should be used (call count might be same or different based on cache)
73
+ self.assertIsInstance(info2, dict)
74
+
75
+
76
+ if __name__ == '__main__':
77
+ unittest.main()
78
+
tests/test_edge_cases.py ADDED
@@ -0,0 +1,121 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Test cases for edge cases and error handling.
3
+ Tests various edge cases that might occur in production.
4
+ """
5
+
6
+ import unittest
7
+ from unittest.mock import patch, MagicMock
8
+ import pandas as pd
9
+ from src.stock_quote import get_stock_quote
10
+ from src.historical_data import get_historical_data
11
+ from src.technical_analysis import get_technical_analysis
12
+
13
+
14
+ class TestEdgeCases(unittest.TestCase):
15
+ """Test cases for edge cases and error scenarios."""
16
+
17
+ # ========================================================================
18
+ # STOCK QUOTE EDGE CASES
19
+ # ========================================================================
20
+
21
+ def test_get_stock_quote_nonexistent_ticker(self):
22
+ """Test quote for non-existent ticker."""
23
+ with patch('src.stock_quote.get_cached_stock_data') as mock_fetch:
24
+ mock_fetch.return_value = ({}, pd.DataFrame()) # Empty data
25
+ result = get_stock_quote("INVALID")
26
+ self.assertIn("error", result)
27
+ self.assertEqual(result["error_code"], "NO_DATA")
28
+
29
+ def test_get_stock_quote_single_data_point(self):
30
+ """Test quote with only one data point."""
31
+ with patch('src.stock_quote.get_cached_stock_data') as mock_fetch:
32
+ mock_hist = pd.DataFrame({
33
+ 'Close': [150],
34
+ 'Volume': [1000000]
35
+ })
36
+ mock_fetch.return_value = ({"longName": "Test"}, mock_hist)
37
+ result = get_stock_quote("TEST")
38
+ self.assertIn("price", result)
39
+ self.assertEqual(result["price"], 150)
40
+ # Change should be 0 when only one data point
41
+ self.assertEqual(result["change"], 0)
42
+
43
+ def test_get_stock_quote_zero_prev_close(self):
44
+ """Test quote with zero previous close (edge case)."""
45
+ with patch('src.stock_quote.get_cached_stock_data') as mock_fetch:
46
+ mock_hist = pd.DataFrame({
47
+ 'Close': [0, 150],
48
+ 'Volume': [0, 1000000]
49
+ })
50
+ mock_fetch.return_value = ({"longName": "Test"}, mock_hist)
51
+ result = get_stock_quote("TEST")
52
+ # Should handle zero division gracefully
53
+ self.assertIn("price", result)
54
+
55
+ # ========================================================================
56
+ # HISTORICAL DATA EDGE CASES
57
+ # ========================================================================
58
+
59
+ def test_get_historical_data_empty_result(self):
60
+ """Test historical data with empty result."""
61
+ with patch('src.historical_data.fetch_historical_data') as mock_fetch:
62
+ mock_fetch.return_value = pd.DataFrame()
63
+ result = get_historical_data("TEST", "1mo", "1d")
64
+ self.assertIn("error", result)
65
+ self.assertEqual(result["error_code"], "NO_DATA")
66
+
67
+ # ========================================================================
68
+ # TECHNICAL ANALYSIS EDGE CASES
69
+ # ========================================================================
70
+
71
+ def test_get_technical_analysis_insufficient_data(self):
72
+ """Test technical analysis with insufficient data."""
73
+ with patch('src.technical_analysis.fetch_historical_data') as mock_fetch:
74
+ # Return data with less than 50 points
75
+ mock_hist = pd.DataFrame({'Close': range(30)})
76
+ mock_fetch.return_value = mock_hist
77
+ result = get_technical_analysis("TEST", "1mo")
78
+ self.assertIn("error", result)
79
+ self.assertEqual(result["error_code"], "INSUFFICIENT_DATA")
80
+
81
+ def test_get_technical_analysis_empty_data(self):
82
+ """Test technical analysis with empty data."""
83
+ with patch('src.technical_analysis.fetch_historical_data') as mock_fetch:
84
+ mock_fetch.return_value = pd.DataFrame()
85
+ result = get_technical_analysis("TEST", "1mo")
86
+ self.assertIn("error", result)
87
+
88
+ # ========================================================================
89
+ # BOUNDARY VALUE TESTS
90
+ # ========================================================================
91
+
92
+ def test_ticker_boundary_values(self):
93
+ """Test ticker validation with boundary values."""
94
+ from src.validators import validate_ticker
95
+
96
+ # Exactly 5 characters (max)
97
+ is_valid, _, _ = validate_ticker("ABCDE")
98
+ self.assertTrue(is_valid)
99
+
100
+ # Exactly 1 character (min)
101
+ is_valid, _, _ = validate_ticker("A")
102
+ self.assertTrue(is_valid)
103
+
104
+ # 6 characters (over limit)
105
+ is_valid, _, _ = validate_ticker("ABCDEF")
106
+ self.assertFalse(is_valid)
107
+
108
+ def test_portfolio_zero_cost_basis(self):
109
+ """Test portfolio with zero cost basis."""
110
+ from src.portfolio import analyze_portfolio
111
+
112
+ portfolio_json = '{"AAPL": {"shares": 10, "cost_basis": 0}}'
113
+ # Should handle zero cost basis (might cause division by zero)
114
+ result = analyze_portfolio(portfolio_json)
115
+ # Should either return error or handle gracefully
116
+ self.assertIsInstance(result, dict)
117
+
118
+
119
+ if __name__ == '__main__':
120
+ unittest.main()
121
+
tests/test_portfolio.py ADDED
@@ -0,0 +1,67 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Test cases for portfolio analysis module.
3
+ Tests edge cases for portfolio calculations.
4
+ """
5
+
6
+ import unittest
7
+ from unittest.mock import patch
8
+ from src.portfolio import analyze_portfolio, compare_stocks
9
+
10
+
11
+ class TestPortfolio(unittest.TestCase):
12
+ """Test cases for portfolio functions."""
13
+
14
+ def test_analyze_portfolio_invalid_json(self):
15
+ """Test portfolio with invalid JSON."""
16
+ result = analyze_portfolio("invalid json")
17
+ self.assertIn("error", result)
18
+ self.assertEqual(result["error_code"], "INVALID_INPUT")
19
+
20
+ def test_analyze_portfolio_empty(self):
21
+ """Test portfolio with empty holdings."""
22
+ result = analyze_portfolio("{}")
23
+ self.assertIn("portfolio_value", result)
24
+ self.assertEqual(result["portfolio_value"], 0)
25
+
26
+ def test_analyze_portfolio_invalid_shares(self):
27
+ """Test portfolio with invalid shares."""
28
+ invalid_json = '{"AAPL": {"shares": -10, "cost_basis": 150}}'
29
+ result = analyze_portfolio(invalid_json)
30
+ self.assertIn("error", result)
31
+ self.assertIn("Invalid shares", result["error"])
32
+
33
+ def test_analyze_portfolio_invalid_cost_basis(self):
34
+ """Test portfolio with invalid cost basis."""
35
+ invalid_json = '{"AAPL": {"shares": 10, "cost_basis": -150}}'
36
+ result = analyze_portfolio(invalid_json)
37
+ self.assertIn("error", result)
38
+ self.assertIn("Invalid cost basis", result["error"])
39
+
40
+ def test_analyze_portfolio_zero_shares(self):
41
+ """Test portfolio with zero shares."""
42
+ invalid_json = '{"AAPL": {"shares": 0, "cost_basis": 150}}'
43
+ result = analyze_portfolio(invalid_json)
44
+ self.assertIn("error", result)
45
+
46
+ def test_compare_stocks_invalid_metric(self):
47
+ """Test stock comparison with invalid metric."""
48
+ result = compare_stocks("AAPL,MSFT", "invalid")
49
+ self.assertIn("error", result)
50
+ self.assertEqual(result["error_code"], "INVALID_METRIC")
51
+
52
+ def test_compare_stocks_too_many_tickers(self):
53
+ """Test stock comparison with too many tickers."""
54
+ many_tickers = ",".join([f"TICK{i}" for i in range(10)])
55
+ result = compare_stocks(many_tickers, "performance")
56
+ self.assertIn("error", result)
57
+ self.assertIn("Too many tickers", result["error"])
58
+
59
+ def test_compare_stocks_empty(self):
60
+ """Test stock comparison with empty input."""
61
+ result = compare_stocks("", "performance")
62
+ self.assertIn("error", result)
63
+
64
+
65
+ if __name__ == '__main__':
66
+ unittest.main()
67
+
tests/test_rate_limiter.py ADDED
@@ -0,0 +1,70 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Test cases for rate limiter module.
3
+ Tests rate limiting functionality and edge cases.
4
+ """
5
+
6
+ import unittest
7
+ import time
8
+ from src.rate_limiter import RateLimiter
9
+
10
+
11
+ class TestRateLimiter(unittest.TestCase):
12
+ """Test cases for rate limiter."""
13
+
14
+ def test_rate_limiter_allows_requests(self):
15
+ """Test that rate limiter allows requests within limit."""
16
+ limiter = RateLimiter(max_calls=5, period=60)
17
+
18
+ @limiter
19
+ def test_func():
20
+ return "success"
21
+
22
+ # Should allow first 5 calls
23
+ for i in range(5):
24
+ result = test_func()
25
+ self.assertEqual(result, "success")
26
+
27
+ def test_rate_limiter_blocks_excess(self):
28
+ """Test that rate limiter blocks excess requests."""
29
+ limiter = RateLimiter(max_calls=2, period=60)
30
+
31
+ @limiter
32
+ def test_func():
33
+ return "success"
34
+
35
+ # First 2 should succeed
36
+ self.assertEqual(test_func(), "success")
37
+ self.assertEqual(test_func(), "success")
38
+
39
+ # Third should be blocked
40
+ result = test_func()
41
+ self.assertIn("error", result)
42
+ self.assertIn("Rate limit exceeded", result["error"])
43
+
44
+ def test_rate_limiter_resets_after_period(self):
45
+ """Test that rate limiter resets after period."""
46
+ limiter = RateLimiter(max_calls=2, period=1) # 1 second period
47
+
48
+ @limiter
49
+ def test_func():
50
+ return "success"
51
+
52
+ # Use up limit
53
+ test_func()
54
+ test_func()
55
+
56
+ # Should be blocked
57
+ result = test_func()
58
+ self.assertIn("error", result)
59
+
60
+ # Wait for period to expire
61
+ time.sleep(1.1)
62
+
63
+ # Should work again
64
+ result = test_func()
65
+ self.assertEqual(result, "success")
66
+
67
+
68
+ if __name__ == '__main__':
69
+ unittest.main()
70
+
tests/test_technical_analysis.py ADDED
@@ -0,0 +1,190 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Test cases for technical analysis module.
3
+ Tests edge cases for technical indicators.
4
+ """
5
+
6
+ import unittest
7
+ import pandas as pd
8
+ import numpy as np
9
+ from datetime import datetime, timedelta
10
+ from src.technical_analysis import (
11
+ calculate_sma,
12
+ calculate_ema,
13
+ calculate_rsi,
14
+ calculate_macd,
15
+ calculate_bollinger_bands
16
+ )
17
+
18
+
19
+ class TestTechnicalAnalysis(unittest.TestCase):
20
+ """Test cases for technical analysis functions."""
21
+
22
+ def setUp(self):
23
+ """Set up test data."""
24
+ # Create sample price data
25
+ dates = pd.date_range(start='2024-01-01', periods=100, freq='D')
26
+ # Generate realistic price data with trend
27
+ base_price = 100
28
+ trend = np.linspace(0, 20, 100)
29
+ noise = np.random.normal(0, 2, 100)
30
+ prices = base_price + trend + noise
31
+ self.prices = pd.Series(prices, index=dates)
32
+
33
+ # ========================================================================
34
+ # SMA TESTS
35
+ # ========================================================================
36
+
37
+ def test_calculate_sma_normal(self):
38
+ """Test SMA calculation with normal data."""
39
+ sma = calculate_sma(self.prices, 20)
40
+ self.assertEqual(len(sma), len(self.prices))
41
+ self.assertFalse(sma.iloc[:19].notna().any()) # First 19 should be NaN
42
+ self.assertTrue(sma.iloc[19:].notna().all()) # Rest should have values
43
+
44
+ def test_calculate_sma_short_data(self):
45
+ """Test SMA with insufficient data."""
46
+ short_prices = self.prices[:10]
47
+ sma = calculate_sma(short_prices, 20)
48
+ self.assertTrue(sma.isna().all()) # All should be NaN
49
+
50
+ def test_calculate_sma_single_value(self):
51
+ """Test SMA with single price point."""
52
+ single_price = pd.Series([100])
53
+ sma = calculate_sma(single_price, 1)
54
+ self.assertEqual(sma.iloc[0], 100)
55
+
56
+ def test_calculate_sma_empty(self):
57
+ """Test SMA with empty series."""
58
+ empty_prices = pd.Series([], dtype=float)
59
+ sma = calculate_sma(empty_prices, 20)
60
+ self.assertEqual(len(sma), 0)
61
+
62
+ # ========================================================================
63
+ # EMA TESTS
64
+ # ========================================================================
65
+
66
+ def test_calculate_ema_normal(self):
67
+ """Test EMA calculation with normal data."""
68
+ ema = calculate_ema(self.prices, 12)
69
+ self.assertEqual(len(ema), len(self.prices))
70
+ self.assertTrue(ema.notna().any()) # Should have some values
71
+
72
+ def test_calculate_ema_empty(self):
73
+ """Test EMA with empty series."""
74
+ empty_prices = pd.Series([], dtype=float)
75
+ ema = calculate_ema(empty_prices, 12)
76
+ self.assertEqual(len(ema), 0)
77
+
78
+ # ========================================================================
79
+ # RSI TESTS
80
+ # ========================================================================
81
+
82
+ def test_calculate_rsi_normal(self):
83
+ """Test RSI calculation with normal data."""
84
+ rsi = calculate_rsi(self.prices, 14)
85
+ self.assertIsInstance(rsi, float)
86
+ self.assertGreaterEqual(rsi, 0)
87
+ self.assertLessEqual(rsi, 100)
88
+
89
+ def test_calculate_rsi_insufficient_data(self):
90
+ """Test RSI with insufficient data."""
91
+ short_prices = self.prices[:10]
92
+ rsi = calculate_rsi(short_prices, 14)
93
+ self.assertEqual(rsi, 50.0) # Should return neutral
94
+
95
+ def test_calculate_rsi_all_gains(self):
96
+ """Test RSI with all positive changes (should be high)."""
97
+ increasing_prices = pd.Series([100, 101, 102, 103, 104, 105, 106, 107, 108, 109, 110, 111, 112, 113, 114, 115])
98
+ rsi = calculate_rsi(increasing_prices, 14)
99
+ self.assertGreater(rsi, 50) # Should be above neutral
100
+
101
+ def test_calculate_rsi_all_losses(self):
102
+ """Test RSI with all negative changes (should be low)."""
103
+ decreasing_prices = pd.Series([115, 114, 113, 112, 111, 110, 109, 108, 107, 106, 105, 104, 103, 102, 101, 100])
104
+ rsi = calculate_rsi(decreasing_prices, 14)
105
+ self.assertLess(rsi, 50) # Should be below neutral
106
+
107
+ def test_calculate_rsi_empty(self):
108
+ """Test RSI with empty series."""
109
+ empty_prices = pd.Series([], dtype=float)
110
+ rsi = calculate_rsi(empty_prices, 14)
111
+ self.assertEqual(rsi, 50.0)
112
+
113
+ # ========================================================================
114
+ # MACD TESTS
115
+ # ========================================================================
116
+
117
+ def test_calculate_macd_normal(self):
118
+ """Test MACD calculation with normal data."""
119
+ macd = calculate_macd(self.prices)
120
+ self.assertIn("macd", macd)
121
+ self.assertIn("signal", macd)
122
+ self.assertIn("histogram", macd)
123
+ self.assertIn("trend", macd)
124
+ self.assertIn(macd["trend"], ["bullish", "bearish"])
125
+
126
+ def test_calculate_macd_insufficient_data(self):
127
+ """Test MACD with insufficient data."""
128
+ short_prices = self.prices[:20] # Need at least 26+9=35 for full MACD
129
+ macd = calculate_macd(short_prices)
130
+ self.assertEqual(macd["trend"], "neutral")
131
+ self.assertEqual(macd["macd"], 0.0)
132
+
133
+ def test_calculate_macd_empty(self):
134
+ """Test MACD with empty series."""
135
+ empty_prices = pd.Series([], dtype=float)
136
+ macd = calculate_macd(empty_prices)
137
+ self.assertEqual(macd["trend"], "neutral")
138
+
139
+ # ========================================================================
140
+ # BOLLINGER BANDS TESTS
141
+ # ========================================================================
142
+
143
+ def test_calculate_bollinger_bands_normal(self):
144
+ """Test Bollinger Bands calculation with normal data."""
145
+ bands = calculate_bollinger_bands(self.prices, 20)
146
+ self.assertIn("upper", bands)
147
+ self.assertIn("middle", bands)
148
+ self.assertIn("lower", bands)
149
+ self.assertIn("current_price", bands)
150
+ self.assertIn("position", bands)
151
+ self.assertGreater(bands["upper"], bands["middle"])
152
+ self.assertLess(bands["lower"], bands["middle"])
153
+
154
+ def test_calculate_bollinger_bands_insufficient_data(self):
155
+ """Test Bollinger Bands with insufficient data."""
156
+ short_prices = self.prices[:10]
157
+ bands = calculate_bollinger_bands(short_prices, 20)
158
+ self.assertEqual(bands["upper"], bands["middle"])
159
+ self.assertEqual(bands["lower"], bands["middle"])
160
+ self.assertEqual(bands["position"], "neutral")
161
+
162
+ def test_calculate_bollinger_bands_empty(self):
163
+ """Test Bollinger Bands with empty series."""
164
+ empty_prices = pd.Series([], dtype=float)
165
+ bands = calculate_bollinger_bands(empty_prices, 20)
166
+ self.assertEqual(bands["position"], "neutral")
167
+ self.assertEqual(bands["current_price"], 0)
168
+
169
+ def test_calculate_bollinger_bands_overbought(self):
170
+ """Test Bollinger Bands when price is above upper band."""
171
+ # Create prices that trend upward significantly
172
+ high_prices = pd.Series([100 + i*2 for i in range(30)])
173
+ bands = calculate_bollinger_bands(high_prices, 20)
174
+ # Price should be above upper band (overbought)
175
+ if bands["current_price"] > bands["upper"]:
176
+ self.assertEqual(bands["position"], "overbought")
177
+
178
+ def test_calculate_bollinger_bands_oversold(self):
179
+ """Test Bollinger Bands when price is below lower band."""
180
+ # Create prices that trend downward significantly
181
+ low_prices = pd.Series([100 - i*2 for i in range(30)])
182
+ bands = calculate_bollinger_bands(low_prices, 20)
183
+ # Price should be below lower band (oversold)
184
+ if bands["current_price"] < bands["lower"]:
185
+ self.assertEqual(bands["position"], "oversold")
186
+
187
+
188
+ if __name__ == '__main__':
189
+ unittest.main()
190
+
tests/test_validators.py ADDED
@@ -0,0 +1,204 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Test cases for input validation module.
3
+ Tests edge cases and security scenarios.
4
+ """
5
+
6
+ import unittest
7
+ import json
8
+ from src.validators import (
9
+ validate_ticker,
10
+ validate_period,
11
+ validate_interval,
12
+ validate_metric,
13
+ validate_json_input
14
+ )
15
+
16
+
17
+ class TestValidators(unittest.TestCase):
18
+ """Test cases for input validation functions."""
19
+
20
+ # ========================================================================
21
+ # TICKER VALIDATION TESTS
22
+ # ========================================================================
23
+
24
+ def test_validate_ticker_valid(self):
25
+ """Test valid ticker symbols."""
26
+ valid_tickers = ["AAPL", "TSLA", "MSFT", "GOOGL", "NVDA", "A", "AB", "ABC"]
27
+ for ticker in valid_tickers:
28
+ is_valid, sanitized, error = validate_ticker(ticker)
29
+ self.assertTrue(is_valid, f"Ticker {ticker} should be valid")
30
+ self.assertEqual(sanitized, ticker.upper())
31
+ self.assertEqual(error, "")
32
+
33
+ def test_validate_ticker_empty(self):
34
+ """Test empty ticker input."""
35
+ is_valid, sanitized, error = validate_ticker("")
36
+ self.assertFalse(is_valid)
37
+ self.assertEqual(error, "Ticker symbol is required")
38
+
39
+ def test_validate_ticker_none(self):
40
+ """Test None ticker input."""
41
+ is_valid, sanitized, error = validate_ticker(None)
42
+ self.assertFalse(is_valid)
43
+
44
+ def test_validate_ticker_whitespace(self):
45
+ """Test ticker with whitespace."""
46
+ is_valid, sanitized, error = validate_ticker(" AAPL ")
47
+ self.assertTrue(is_valid)
48
+ self.assertEqual(sanitized, "AAPL")
49
+
50
+ def test_validate_ticker_lowercase(self):
51
+ """Test ticker with lowercase letters."""
52
+ is_valid, sanitized, error = validate_ticker("aapl")
53
+ self.assertTrue(is_valid)
54
+ self.assertEqual(sanitized, "AAPL")
55
+
56
+ def test_validate_ticker_too_long(self):
57
+ """Test ticker that's too long."""
58
+ is_valid, sanitized, error = validate_ticker("ABCDEF")
59
+ self.assertFalse(is_valid)
60
+ self.assertIn("too long", error.lower())
61
+
62
+ def test_validate_ticker_numbers(self):
63
+ """Test ticker with numbers (invalid)."""
64
+ is_valid, sanitized, error = validate_ticker("AAPL1")
65
+ self.assertFalse(is_valid)
66
+
67
+ def test_validate_ticker_special_chars(self):
68
+ """Test ticker with special characters."""
69
+ invalid_tickers = ["AAP-L", "AAP.L", "AAP@L", "AAP L"]
70
+ for ticker in invalid_tickers:
71
+ is_valid, sanitized, error = validate_ticker(ticker)
72
+ self.assertFalse(is_valid, f"Ticker {ticker} should be invalid")
73
+
74
+ def test_validate_ticker_sql_injection(self):
75
+ """Test SQL injection attempts."""
76
+ dangerous = ["'; DROP TABLE--", "AAPL;--", "AAPL/*", "*/DROP"]
77
+ for ticker in dangerous:
78
+ is_valid, sanitized, error = validate_ticker(ticker)
79
+ self.assertFalse(is_valid, f"Should reject SQL injection: {ticker}")
80
+
81
+ def test_validate_ticker_very_long_string(self):
82
+ """Test very long string input."""
83
+ long_string = "A" * 1000
84
+ is_valid, sanitized, error = validate_ticker(long_string)
85
+ self.assertFalse(is_valid)
86
+
87
+ # ========================================================================
88
+ # PERIOD VALIDATION TESTS
89
+ # ========================================================================
90
+
91
+ def test_validate_period_valid(self):
92
+ """Test valid periods."""
93
+ valid_periods = ["1d", "5d", "1mo", "3mo", "6mo", "1y", "2y", "5y", "10y", "ytd", "max"]
94
+ for period in valid_periods:
95
+ is_valid, sanitized, error = validate_period(period)
96
+ self.assertTrue(is_valid, f"Period {period} should be valid")
97
+
98
+ def test_validate_period_invalid(self):
99
+ """Test invalid periods."""
100
+ invalid_periods = ["1w", "2mo", "invalid", "1day", ""]
101
+ for period in invalid_periods:
102
+ is_valid, sanitized, error = validate_period(period)
103
+ self.assertFalse(is_valid, f"Period {period} should be invalid")
104
+
105
+ # ========================================================================
106
+ # INTERVAL VALIDATION TESTS
107
+ # ========================================================================
108
+
109
+ def test_validate_interval_valid(self):
110
+ """Test valid intervals."""
111
+ valid_intervals = ["1m", "5m", "15m", "1h", "1d", "1wk", "1mo"]
112
+ for interval in valid_intervals:
113
+ is_valid, sanitized, error = validate_interval(interval)
114
+ self.assertTrue(is_valid, f"Interval {interval} should be valid")
115
+
116
+ def test_validate_interval_invalid(self):
117
+ """Test invalid intervals."""
118
+ invalid_intervals = ["1min", "hour", "invalid", ""]
119
+ for interval in invalid_intervals:
120
+ is_valid, sanitized, error = validate_interval(interval)
121
+ self.assertFalse(is_valid, f"Interval {interval} should be invalid")
122
+
123
+ # ========================================================================
124
+ # METRIC VALIDATION TESTS
125
+ # ========================================================================
126
+
127
+ def test_validate_metric_valid(self):
128
+ """Test valid metrics."""
129
+ valid_metrics = ["performance", "valuation", "volatility"]
130
+ for metric in valid_metrics:
131
+ is_valid, sanitized, error = validate_metric(metric)
132
+ self.assertTrue(is_valid, f"Metric {metric} should be valid")
133
+
134
+ def test_validate_metric_invalid(self):
135
+ """Test invalid metrics."""
136
+ invalid_metrics = ["price", "volume", "invalid", ""]
137
+ for metric in invalid_metrics:
138
+ is_valid, sanitized, error = validate_metric(metric)
139
+ self.assertFalse(is_valid, f"Metric {metric} should be invalid")
140
+
141
+ # ========================================================================
142
+ # JSON VALIDATION TESTS
143
+ # ========================================================================
144
+
145
+ def test_validate_json_valid(self):
146
+ """Test valid JSON portfolio."""
147
+ valid_json = '{"AAPL": {"shares": 10, "cost_basis": 150}}'
148
+ is_valid, data, error = validate_json_input(valid_json)
149
+ self.assertTrue(is_valid)
150
+ self.assertIsInstance(data, dict)
151
+ self.assertEqual(error, "")
152
+
153
+ def test_validate_json_invalid_format(self):
154
+ """Test invalid JSON format."""
155
+ invalid_json = '{"AAPL": {"shares": 10}' # Missing closing brace
156
+ is_valid, data, error = validate_json_input(invalid_json)
157
+ self.assertFalse(is_valid)
158
+ self.assertIn("JSON format", error)
159
+
160
+ def test_validate_json_not_dict(self):
161
+ """Test JSON that's not a dictionary."""
162
+ invalid_json = '[1, 2, 3]' # Array instead of object
163
+ is_valid, data, error = validate_json_input(invalid_json)
164
+ self.assertFalse(is_valid)
165
+ self.assertIn("object/dictionary", error)
166
+
167
+ def test_validate_json_too_many_tickers(self):
168
+ """Test portfolio with too many tickers."""
169
+ # Create JSON with 6 tickers (max is 5)
170
+ tickers = {f"TICK{i}": {"shares": 1, "cost_basis": 100} for i in range(6)}
171
+ invalid_json = json.dumps(tickers)
172
+ is_valid, data, error = validate_json_input(invalid_json)
173
+ self.assertFalse(is_valid)
174
+ self.assertIn("Too many tickers", error)
175
+
176
+ def test_validate_json_invalid_ticker(self):
177
+ """Test portfolio with invalid ticker."""
178
+ invalid_json = '{"INVALID123": {"shares": 10, "cost_basis": 150}}'
179
+ is_valid, data, error = validate_json_input(invalid_json)
180
+ self.assertFalse(is_valid)
181
+ self.assertIn("Invalid ticker", error)
182
+
183
+ def test_validate_json_empty(self):
184
+ """Test empty JSON."""
185
+ is_valid, data, error = validate_json_input("{}")
186
+ self.assertTrue(is_valid) # Empty dict is valid
187
+
188
+ def test_validate_json_malformed(self):
189
+ """Test malformed JSON strings."""
190
+ malformed = ['{', '}', '{invalid}', 'null', 'true', '123']
191
+ for json_str in malformed:
192
+ is_valid, data, error = validate_json_input(json_str)
193
+ self.assertFalse(is_valid, f"Should reject malformed JSON: {json_str}")
194
+
195
+ def test_validate_json_sql_injection_in_ticker(self):
196
+ """Test JSON with SQL injection in ticker."""
197
+ dangerous_json = '{"DROP TABLE": {"shares": 10, "cost_basis": 150}}'
198
+ is_valid, data, error = validate_json_input(dangerous_json)
199
+ self.assertFalse(is_valid)
200
+
201
+
202
+ if __name__ == '__main__':
203
+ unittest.main()
204
+