Asish Karthikeya Gogineni commited on
Commit ·
06b52d2
1
Parent(s): 318edb7
Feat: Replace limited Alpha Vantage API with free unlimited yfinance data
Browse files- alphavantage_mcp.py +79 -192
- requirements.txt +12 -14
alphavantage_mcp.py
CHANGED
|
@@ -1,52 +1,23 @@
|
|
| 1 |
-
# alphavantage_mcp.py (
|
| 2 |
from fastapi import FastAPI, HTTPException
|
| 3 |
import uvicorn
|
| 4 |
-
import os
|
| 5 |
-
from dotenv import load_dotenv
|
| 6 |
-
from alpha_vantage.timeseries import TimeSeries
|
| 7 |
import logging
|
|
|
|
|
|
|
| 8 |
|
| 9 |
-
# ---
|
| 10 |
-
load_dotenv()
|
| 11 |
-
|
| 12 |
-
# --- Logging Setup (MUST be before we use logger) ---
|
| 13 |
logging.basicConfig(level=logging.INFO, format='%(asctime)s - %(name)s - %(levelname)s - %(message)s')
|
| 14 |
-
logger = logging.getLogger("
|
| 15 |
-
|
| 16 |
-
# --- Get API Key ---
|
| 17 |
-
ALPHA_VANTAGE_API_KEY = os.getenv("ALPHA_VANTAGE_API_KEY")
|
| 18 |
-
|
| 19 |
-
# Fallback: Try to read from Streamlit secrets file (for cloud deployment)
|
| 20 |
-
if not ALPHA_VANTAGE_API_KEY:
|
| 21 |
-
try:
|
| 22 |
-
import toml
|
| 23 |
-
secrets_path = os.path.join(os.path.dirname(__file__), ".streamlit", "secrets.toml")
|
| 24 |
-
if os.path.exists(secrets_path):
|
| 25 |
-
secrets = toml.load(secrets_path)
|
| 26 |
-
ALPHA_VANTAGE_API_KEY = secrets.get("ALPHA_VANTAGE_API_KEY")
|
| 27 |
-
logger.info("Loaded ALPHA_VANTAGE_API_KEY from .streamlit/secrets.toml")
|
| 28 |
-
except Exception as e:
|
| 29 |
-
logger.warning(f"Could not load from secrets.toml: {e}")
|
| 30 |
-
|
| 31 |
-
if not ALPHA_VANTAGE_API_KEY:
|
| 32 |
-
logger.warning("ALPHA_VANTAGE_API_KEY not found in environment. Market data features will fail.")
|
| 33 |
-
else:
|
| 34 |
-
logger.info(f"ALPHA_VANTAGE_API_KEY found: {ALPHA_VANTAGE_API_KEY[:4]}...")
|
| 35 |
|
| 36 |
-
# --- FastAPI App
|
| 37 |
-
app = FastAPI(title="Aegis
|
| 38 |
-
ts = TimeSeries(key=ALPHA_VANTAGE_API_KEY, output_format='json')
|
| 39 |
|
| 40 |
@app.post("/market_data")
|
| 41 |
async def get_market_data(payload: dict):
|
| 42 |
"""
|
| 43 |
-
Fetches market data using
|
| 44 |
-
|
| 45 |
-
|
| 46 |
-
{
|
| 47 |
-
"symbol": "NVDA",
|
| 48 |
-
"time_range": "INTRADAY" | "1D" | "3D" | "1W" | "1M" | "3M" | "1Y"
|
| 49 |
-
}
|
| 50 |
"""
|
| 51 |
symbol = payload.get("symbol")
|
| 52 |
time_range = payload.get("time_range", "INTRADAY")
|
|
@@ -57,170 +28,86 @@ async def get_market_data(payload: dict):
|
|
| 57 |
|
| 58 |
logger.info(f"Received market data request for symbol: {symbol}, time_range: {time_range}")
|
| 59 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 60 |
try:
|
| 61 |
-
|
| 62 |
-
|
| 63 |
-
# Intraday data (last 4-6 hours, 5-min intervals)
|
| 64 |
-
data, meta_data = ts.get_intraday(symbol=symbol, interval="5min", outputsize='compact')
|
| 65 |
-
logger.info(f"Successfully retrieved intraday data for {symbol}")
|
| 66 |
-
# Detect Alpha Vantage rate-limit / info response (200 OK but no real data)
|
| 67 |
-
if isinstance(data, dict) and "Information" in data:
|
| 68 |
-
raise Exception(f"Alpha Vantage rate limit: {data['Information']}")
|
| 69 |
-
if isinstance(data, dict) and "Note" in data:
|
| 70 |
-
raise Exception(f"Alpha Vantage note (rate limit): {data['Note']}")
|
| 71 |
-
meta_data["Source"] = "Real API (Alpha Vantage)"
|
| 72 |
-
else:
|
| 73 |
-
# Daily data for historical ranges
|
| 74 |
-
data, meta_data = ts.get_daily(symbol=symbol, outputsize='full')
|
| 75 |
-
logger.info(f"Successfully retrieved daily data for {symbol}")
|
| 76 |
-
# Detect Alpha Vantage rate-limit / info response
|
| 77 |
-
if isinstance(data, dict) and "Information" in data:
|
| 78 |
-
raise Exception(f"Alpha Vantage rate limit: {data['Information']}")
|
| 79 |
-
if isinstance(data, dict) and "Note" in data:
|
| 80 |
-
raise Exception(f"Alpha Vantage note (rate limit): {data['Note']}")
|
| 81 |
-
# Filter data based on time range
|
| 82 |
-
data = filter_data_by_time_range(data, time_range)
|
| 83 |
-
logger.info(f"Filtered to {len(data)} data points for time_range={time_range}")
|
| 84 |
-
meta_data["Source"] = "Real API (Alpha Vantage)"
|
| 85 |
|
| 86 |
-
|
|
|
|
|
|
|
|
|
|
| 87 |
|
| 88 |
-
|
| 89 |
-
|
| 90 |
-
logger.error(f"Alpha Vantage API error for symbol {symbol}: {e}")
|
| 91 |
-
logger.warning(f"Triggering MOCK DATA fallback for {symbol} due to error.")
|
| 92 |
-
|
| 93 |
-
import random
|
| 94 |
-
import math
|
| 95 |
-
from datetime import datetime, timedelta
|
| 96 |
-
|
| 97 |
-
# Seed randomness with symbol AND date to ensure it changes daily
|
| 98 |
-
# But stays consistent within the same day
|
| 99 |
-
today_str = datetime.now().strftime("%Y-%m-%d %H:%M")
|
| 100 |
-
seed_value = f"{symbol}_{today_str}"
|
| 101 |
-
random.seed(seed_value)
|
| 102 |
-
|
| 103 |
-
mock_data = {}
|
| 104 |
-
current_time = datetime.now()
|
| 105 |
-
|
| 106 |
-
# Generate unique base price
|
| 107 |
-
symbol_hash = sum(ord(c) for c in symbol)
|
| 108 |
-
base_price = float(symbol_hash % 500) + 50
|
| 109 |
-
|
| 110 |
-
# Force distinct start prices for common stocks
|
| 111 |
-
if "AAPL" in symbol: base_price = 150.0
|
| 112 |
-
if "TSLA" in symbol: base_price = 250.0
|
| 113 |
-
if "NVDA" in symbol: base_price = 450.0
|
| 114 |
-
if "MSFT" in symbol: base_price = 350.0
|
| 115 |
-
if "GOOG" in symbol: base_price = 130.0
|
| 116 |
-
if "AMZN" in symbol: base_price = 140.0
|
| 117 |
-
|
| 118 |
-
# Add some daily variation to base price
|
| 119 |
-
daily_noise = (hash(today_str) % 100) / 10.0 # -5 to +5 variation
|
| 120 |
-
base_price += daily_noise
|
| 121 |
|
| 122 |
-
|
| 123 |
-
|
| 124 |
-
|
| 125 |
-
|
| 126 |
-
|
| 127 |
-
|
| 128 |
-
|
| 129 |
-
|
| 130 |
-
|
| 131 |
-
|
| 132 |
-
|
| 133 |
-
|
| 134 |
-
|
| 135 |
-
|
| 136 |
-
|
| 137 |
-
|
| 138 |
-
time_delta = timedelta(hours=1)
|
| 139 |
-
elif time_range == "1W":
|
| 140 |
-
num_points = 168 # 7 days × 24 hourly data points
|
| 141 |
-
time_delta = timedelta(hours=1)
|
| 142 |
-
elif time_range == "1M":
|
| 143 |
-
num_points = 30
|
| 144 |
-
time_delta = timedelta(days=1)
|
| 145 |
-
elif time_range == "3M":
|
| 146 |
-
num_points = 90
|
| 147 |
-
time_delta = timedelta(days=1)
|
| 148 |
-
elif time_range == "1Y":
|
| 149 |
-
num_points = 365
|
| 150 |
-
time_delta = timedelta(days=1)
|
| 151 |
-
else:
|
| 152 |
-
num_points = 100
|
| 153 |
-
time_delta = timedelta(minutes=5)
|
| 154 |
-
|
| 155 |
-
for i in range(num_points):
|
| 156 |
-
noise = random.uniform(-volatility, volatility)
|
| 157 |
-
cycle_1 = (base_price * 0.02) * math.sin(i / 8.0)
|
| 158 |
-
cycle_2 = (base_price * 0.01) * math.sin(i / 3.0)
|
| 159 |
-
change = noise + (trend_direction * trend_strength)
|
| 160 |
-
current_price += change
|
| 161 |
-
final_price = current_price + cycle_1 + cycle_2
|
| 162 |
-
final_price = max(1.0, final_price)
|
| 163 |
-
|
| 164 |
-
t = current_time - (time_delta * (num_points - i - 1))
|
| 165 |
-
|
| 166 |
-
# Format timestamp based on data type
|
| 167 |
-
if time_range in ["INTRADAY", "1D", "3D", "1W"]:
|
| 168 |
-
timestamp_str = t.strftime("%Y-%m-%d %H:%M:%S")
|
| 169 |
else:
|
| 170 |
-
timestamp_str =
|
| 171 |
-
|
| 172 |
-
|
| 173 |
-
"1. open": str(round(
|
| 174 |
-
"2. high": str(round(
|
| 175 |
-
"3. low": str(round(
|
| 176 |
-
"4. close": str(round(
|
| 177 |
-
"5. volume": str(int(
|
| 178 |
-
}
|
| 179 |
-
|
| 180 |
-
return {
|
| 181 |
-
"status": "success",
|
| 182 |
-
"data": mock_data,
|
| 183 |
-
"meta_data": {
|
| 184 |
-
"Information": f"Mock Data ({time_range}) - API Limit/Error",
|
| 185 |
-
"Source": "Simulated (Fallback)"
|
| 186 |
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 187 |
}
|
| 188 |
-
|
| 189 |
-
|
| 190 |
-
def filter_data_by_time_range(data: dict, time_range: str) -> dict:
|
| 191 |
-
"""Filter daily data to the specified time range."""
|
| 192 |
-
from datetime import datetime, timedelta
|
| 193 |
-
|
| 194 |
-
# Map time ranges to days
|
| 195 |
-
range_map = {
|
| 196 |
-
"1D": 1,
|
| 197 |
-
"3D": 3,
|
| 198 |
-
"1W": 7,
|
| 199 |
-
"1M": 30,
|
| 200 |
-
"3M": 90,
|
| 201 |
-
"1Y": 365
|
| 202 |
-
}
|
| 203 |
-
|
| 204 |
-
days = range_map.get(time_range, 30)
|
| 205 |
-
cutoff_date = datetime.now() - timedelta(days=days)
|
| 206 |
-
|
| 207 |
-
# Filter data
|
| 208 |
-
filtered = {}
|
| 209 |
-
for timestamp_str, values in data.items():
|
| 210 |
-
try:
|
| 211 |
-
timestamp = datetime.strptime(timestamp_str, "%Y-%m-%d")
|
| 212 |
-
if timestamp >= cutoff_date:
|
| 213 |
-
filtered[timestamp_str] = values
|
| 214 |
-
except:
|
| 215 |
-
# If parsing fails, include the data point
|
| 216 |
-
filtered[timestamp_str] = values
|
| 217 |
-
|
| 218 |
-
return filtered
|
| 219 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 220 |
|
| 221 |
@app.get("/")
|
| 222 |
def read_root():
|
| 223 |
-
return {"message": "Aegis
|
| 224 |
|
| 225 |
# --- Main Execution ---
|
| 226 |
if __name__ == "__main__":
|
|
|
|
| 1 |
+
# alphavantage_mcp.py (Rewritten to use yfinance for unlimited free data)
|
| 2 |
from fastapi import FastAPI, HTTPException
|
| 3 |
import uvicorn
|
|
|
|
|
|
|
|
|
|
| 4 |
import logging
|
| 5 |
+
import yfinance as yf
|
| 6 |
+
from datetime import datetime, timedelta
|
| 7 |
|
| 8 |
+
# --- Logging Setup ---
|
|
|
|
|
|
|
|
|
|
| 9 |
logging.basicConfig(level=logging.INFO, format='%(asctime)s - %(name)s - %(levelname)s - %(message)s')
|
| 10 |
+
logger = logging.getLogger("MarketData_MCP_Server")
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 11 |
|
| 12 |
+
# --- FastAPI App ---
|
| 13 |
+
app = FastAPI(title="Aegis Market Data MCP Server (yfinance)")
|
|
|
|
| 14 |
|
| 15 |
@app.post("/market_data")
|
| 16 |
async def get_market_data(payload: dict):
|
| 17 |
"""
|
| 18 |
+
Fetches market data using yfinance (free, no rate limits).
|
| 19 |
+
Returns data in the exact same format expected by the orchestrator.
|
| 20 |
+
Supports time_ranges: "INTRADAY", "1D", "3D", "1W", "1M", "3M", "1Y"
|
|
|
|
|
|
|
|
|
|
|
|
|
| 21 |
"""
|
| 22 |
symbol = payload.get("symbol")
|
| 23 |
time_range = payload.get("time_range", "INTRADAY")
|
|
|
|
| 28 |
|
| 29 |
logger.info(f"Received market data request for symbol: {symbol}, time_range: {time_range}")
|
| 30 |
|
| 31 |
+
# Map our time_range to yfinance period/interval
|
| 32 |
+
if time_range == "INTRADAY":
|
| 33 |
+
period = "1d"
|
| 34 |
+
interval = "5m"
|
| 35 |
+
elif time_range == "1D":
|
| 36 |
+
period = "1d"
|
| 37 |
+
interval = "1m"
|
| 38 |
+
elif time_range == "3D":
|
| 39 |
+
period = "5d" # yfinance doesn't have 3d, we'll fetch 5d and filter
|
| 40 |
+
interval = "15m"
|
| 41 |
+
elif time_range == "1W":
|
| 42 |
+
period = "5d" # 5 trading days = 1 week
|
| 43 |
+
interval = "15m"
|
| 44 |
+
elif time_range == "1M":
|
| 45 |
+
period = "1mo"
|
| 46 |
+
interval = "1d"
|
| 47 |
+
elif time_range == "3M":
|
| 48 |
+
period = "3mo"
|
| 49 |
+
interval = "1d"
|
| 50 |
+
elif time_range == "1Y":
|
| 51 |
+
period = "1y"
|
| 52 |
+
interval = "1d"
|
| 53 |
+
else:
|
| 54 |
+
period = "1mo"
|
| 55 |
+
interval = "1d"
|
| 56 |
+
|
| 57 |
try:
|
| 58 |
+
ticker = yf.Ticker(symbol)
|
| 59 |
+
df = ticker.history(period=period, interval=interval)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 60 |
|
| 61 |
+
if df.empty:
|
| 62 |
+
raise Exception(f"No data found for symbol {symbol}")
|
| 63 |
+
|
| 64 |
+
logger.info(f"Successfully retrieved {len(df)} data points from yfinance for {symbol}")
|
| 65 |
|
| 66 |
+
# Format dataframe into the expected nested dictionary format
|
| 67 |
+
formatted_data = {}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 68 |
|
| 69 |
+
# If we fetched 5d for a 3D request, calculate the cutoff
|
| 70 |
+
cutoff_date = None
|
| 71 |
+
if time_range == "3D":
|
| 72 |
+
cutoff_date = getattr(df.index[-1], "tz_localize", lambda x: df.index[-1])(None) - timedelta(days=3)
|
| 73 |
+
|
| 74 |
+
for idx, row in df.iterrows():
|
| 75 |
+
# Filter for 3D request
|
| 76 |
+
if cutoff_date:
|
| 77 |
+
# Remove timezone for comparison to avoid offset-naive/aware errors
|
| 78 |
+
naive_idx = getattr(idx, "tz_localize", lambda x: idx)(None)
|
| 79 |
+
if naive_idx < cutoff_date:
|
| 80 |
+
continue
|
| 81 |
+
|
| 82 |
+
# Format timestamp based on whether it's daily or intraday
|
| 83 |
+
if interval in ["1m", "2m", "5m", "15m", "30m", "60m", "1h"]:
|
| 84 |
+
timestamp_str = idx.strftime("%Y-%m-%d %H:%M:%S")
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 85 |
else:
|
| 86 |
+
timestamp_str = idx.strftime("%Y-%m-%d")
|
| 87 |
+
|
| 88 |
+
formatted_data[timestamp_str] = {
|
| 89 |
+
"1. open": str(round(row["Open"], 2)),
|
| 90 |
+
"2. high": str(round(row["High"], 2)),
|
| 91 |
+
"3. low": str(round(row["Low"], 2)),
|
| 92 |
+
"4. close": str(round(row["Close"], 2)),
|
| 93 |
+
"5. volume": str(int(row["Volume"]))
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 94 |
}
|
| 95 |
+
|
| 96 |
+
meta_data = {
|
| 97 |
+
"Information": f"Market Data ({time_range})",
|
| 98 |
+
"Symbol": symbol,
|
| 99 |
+
"Source": "Real API (yfinance)"
|
| 100 |
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 101 |
|
| 102 |
+
return {"status": "success", "data": formatted_data, "meta_data": meta_data}
|
| 103 |
+
|
| 104 |
+
except Exception as e:
|
| 105 |
+
logger.error(f"yfinance error for symbol {symbol}: {e}")
|
| 106 |
+
raise HTTPException(status_code=500, detail=f"Failed to fetch market data: {str(e)}")
|
| 107 |
|
| 108 |
@app.get("/")
|
| 109 |
def read_root():
|
| 110 |
+
return {"message": "Aegis Market Data MCP Server (yfinance) is operational."}
|
| 111 |
|
| 112 |
# --- Main Execution ---
|
| 113 |
if __name__ == "__main__":
|
requirements.txt
CHANGED
|
@@ -1,15 +1,13 @@
|
|
| 1 |
-
streamlit
|
| 2 |
-
langchain
|
| 3 |
-
langchain-core
|
| 4 |
-
langgraph
|
| 5 |
pydantic<3,>=2
|
| 6 |
-
pandas
|
| 7 |
-
plotly
|
| 8 |
-
python-dotenv
|
| 9 |
-
httpx
|
| 10 |
-
alpha_vantage
|
| 11 |
-
|
| 12 |
-
|
| 13 |
-
|
| 14 |
-
langchain_ollama
|
| 15 |
-
langchain-google-genai
|
|
|
|
| 1 |
+
streamlit>=1.31.0
|
| 2 |
+
langchain>=0.1.0
|
| 3 |
+
langchain-core>=0.1.0
|
| 4 |
+
langgraph>=0.0.40
|
| 5 |
pydantic<3,>=2
|
| 6 |
+
pandas>=2.0.0
|
| 7 |
+
plotly>=5.18.0
|
| 8 |
+
python-dotenv>=1.0.0
|
| 9 |
+
httpx>=0.25.0
|
| 10 |
+
alpha_vantage>=2.3.1
|
| 11 |
+
tavily-python>=0.3.0
|
| 12 |
+
langchain-google-genai>=1.0.0
|
| 13 |
+
yfinance>=0.2.0
|
|
|
|
|
|