Spaces:
Sleeping
Sleeping
Initial commit: Gradio MCP app for real-time financial data
Browse files- .gitignore +35 -0
- MODULAR_STRUCTURE.md +158 -0
- README.md +217 -5
- app.py +299 -0
- financial_mcp_server.py +76 -0
- requirements.txt +10 -0
- src/__init__.py +6 -0
- src/config.py +51 -0
- src/data_fetcher.py +108 -0
- src/error_handler.py +35 -0
- src/historical_data.py +70 -0
- src/interfaces.py +101 -0
- src/market.py +75 -0
- src/portfolio.py +256 -0
- src/rate_limiter.py +53 -0
- src/stock_quote.py +90 -0
- src/technical_analysis.py +214 -0
- src/validators.py +165 -0
- tests/__init__.py +4 -0
- tests/run_tests.py +48 -0
- tests/test_data_fetcher.py +78 -0
- tests/test_edge_cases.py +121 -0
- tests/test_portfolio.py +67 -0
- tests/test_rate_limiter.py +70 -0
- tests/test_technical_analysis.py +190 -0
- tests/test_validators.py +204 -0
.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:
|
| 3 |
-
emoji:
|
| 4 |
-
colorFrom:
|
| 5 |
-
colorTo:
|
| 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 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 |
+
|