BrianIsaac commited on
Commit
cea2220
·
1 Parent(s): 8ed36e2

feat: enable native Gradio MCP server with fastmcp 2.9.1

Browse files

- Add unified mcp_tools.py with 22 namespaced tools for Gradio MCP
- Update pyproject.toml to use compatible dependency versions:
- pydantic-ai-slim[anthropic]==1.18.0 (avoids mcp extra conflict)
- fastmcp==2.9.1 (latest compatible with mcp==1.10.1)
- gradio[mcp]==5.49.1 (native MCP support)
- Enable mcp_server=True in app.py for native MCP endpoint
- Register all tools via gr.api() for MCP exposure
- Fix Decimal serialisation in mcp_router.py using dumps_str()
- Update mcp_router.py as compatibility layer delegating to mcp_tools
- Add comprehensive tests for MCP tools module

Native MCP server available at /gradio_api/mcp/

Files changed (6) hide show
  1. app.py +37 -0
  2. backend/mcp_router.py +200 -224
  3. backend/mcp_tools.py +958 -0
  4. pyproject.toml +9 -8
  5. tests/test_mcp_tools.py +216 -0
  6. uv.lock +0 -0
app.py CHANGED
@@ -32,6 +32,7 @@ from backend.monitoring import initialise_sentry
32
  initialise_sentry()
33
 
34
  from backend.mcp_router import mcp_router
 
35
  from backend.agents.workflow import PortfolioAnalysisWorkflow
36
  from backend.models.agent_state import AgentState
37
  from backend.database import db
@@ -4856,6 +4857,41 @@ Please try again with different parameters.
4856
  ]
4857
  )
4858
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
4859
  return demo
4860
 
4861
 
@@ -4865,5 +4901,6 @@ if __name__ == "__main__":
4865
  server_name="0.0.0.0",
4866
  server_port=7860,
4867
  share=False,
 
4868
  allowed_paths=["/tmp"] # Allow serving export files from temp directory
4869
  )
 
32
  initialise_sentry()
33
 
34
  from backend.mcp_router import mcp_router
35
+ from backend import mcp_tools
36
  from backend.agents.workflow import PortfolioAnalysisWorkflow
37
  from backend.models.agent_state import AgentState
38
  from backend.database import db
 
4857
  ]
4858
  )
4859
 
4860
+ # MCP Tool Registrations (API/MCP only - no UI components)
4861
+ # Market Data Tools
4862
+ gr.api(mcp_tools.market_get_quote, api_name="market_get_quote")
4863
+ gr.api(mcp_tools.market_get_historical_data, api_name="market_get_historical_data")
4864
+ gr.api(mcp_tools.market_get_fundamentals, api_name="market_get_fundamentals")
4865
+ gr.api(mcp_tools.market_get_company_profile, api_name="market_get_company_profile")
4866
+ gr.api(mcp_tools.market_get_income_statement, api_name="market_get_income_statement")
4867
+ gr.api(mcp_tools.market_get_balance_sheet, api_name="market_get_balance_sheet")
4868
+ gr.api(mcp_tools.market_get_cash_flow_statement, api_name="market_get_cash_flow_statement")
4869
+ gr.api(mcp_tools.market_get_financial_ratios, api_name="market_get_financial_ratios")
4870
+ gr.api(mcp_tools.market_get_key_metrics, api_name="market_get_key_metrics")
4871
+ gr.api(mcp_tools.market_get_economic_series, api_name="market_get_economic_series")
4872
+
4873
+ # Technical Analysis Tools
4874
+ gr.api(mcp_tools.technical_get_indicators, api_name="technical_get_indicators")
4875
+ gr.api(mcp_tools.technical_extract_features, api_name="technical_extract_features")
4876
+ gr.api(mcp_tools.technical_normalise_features, api_name="technical_normalise_features")
4877
+ gr.api(mcp_tools.technical_select_features, api_name="technical_select_features")
4878
+ gr.api(mcp_tools.technical_compute_feature_vector, api_name="technical_compute_feature_vector")
4879
+
4880
+ # Portfolio Optimisation Tools
4881
+ gr.api(mcp_tools.portfolio_optimize_hrp, api_name="portfolio_optimize_hrp")
4882
+ gr.api(mcp_tools.portfolio_optimize_black_litterman, api_name="portfolio_optimize_black_litterman")
4883
+ gr.api(mcp_tools.portfolio_optimize_mean_variance, api_name="portfolio_optimize_mean_variance")
4884
+
4885
+ # Risk Analysis Tools
4886
+ gr.api(mcp_tools.risk_analyze, api_name="risk_analyze")
4887
+ gr.api(mcp_tools.risk_forecast_volatility_garch, api_name="risk_forecast_volatility_garch")
4888
+
4889
+ # ML Forecasting Tools
4890
+ gr.api(mcp_tools.ml_forecast_ensemble, api_name="ml_forecast_ensemble")
4891
+
4892
+ # Sentiment Analysis Tools
4893
+ gr.api(mcp_tools.sentiment_get_news, api_name="sentiment_get_news")
4894
+
4895
  return demo
4896
 
4897
 
 
4901
  server_name="0.0.0.0",
4902
  server_port=7860,
4903
  share=False,
4904
+ mcp_server=True, # Native MCP server at /gradio_api/mcp/
4905
  allowed_paths=["/tmp"] # Allow serving export files from temp directory
4906
  )
backend/mcp_router.py CHANGED
@@ -1,67 +1,38 @@
1
  """MCP Router for Portfolio Intelligence Platform.
2
 
3
- Orchestrates MCP servers for financial data and quantitative analysis.
 
 
 
 
4
  """
5
 
6
- from typing import Dict, List, Any, Optional
7
  import logging
8
- import sys
9
- import os
10
-
11
- # Add backend directory to path for MCP imports
12
- sys.path.insert(0, os.path.dirname(os.path.dirname(__file__)))
13
-
14
- # Import all MCP servers
15
- from backend.mcp_servers import yahoo_finance_mcp, fmp_mcp, trading_mcp, fred_mcp
16
- from backend.mcp_servers import portfolio_optimizer_mcp, risk_analyzer_mcp, ensemble_predictor_mcp
17
- from backend.mcp_servers import news_sentiment_mcp, feature_extraction_mcp
18
 
19
- # Import caching decorator
20
- from backend.caching.decorators import cached_async
21
- from backend.caching.redis_cache import CacheDataType
22
 
23
  logger = logging.getLogger(__name__)
24
 
25
 
26
  class MCPRouter:
27
- """Router for orchestrating multiple MCP servers.
28
 
29
- Manages connections to:
30
- - P0 (Week 1): Yahoo Finance, FMP, Trading-MCP, FRED, Portfolio Optimizer, Risk Analyzer
31
- - P1 (Week 2): Ensemble Predictor (Chronos + statistical models)
32
  """
33
 
34
  def __init__(self):
35
- """Initialise MCP router with configured servers."""
36
- self.servers: Dict[str, Any] = {}
37
- self._initialise_servers()
38
-
39
- def _initialise_servers(self):
40
- """Initialise connections to MCP servers."""
41
- logger.info("Initialising MCP servers")
42
-
43
- # Map MCP server modules
44
- self.servers = {
45
- "yahoo_finance": yahoo_finance_mcp,
46
- "fmp": fmp_mcp,
47
- "trading_mcp": trading_mcp,
48
- "fred": fred_mcp,
49
- "portfolio_optimizer": portfolio_optimizer_mcp,
50
- "risk_analyzer": risk_analyzer_mcp,
51
- "ensemble_predictor": ensemble_predictor_mcp,
52
- "news_sentiment": news_sentiment_mcp,
53
- "feature_extraction": feature_extraction_mcp,
54
- }
55
-
56
- logger.info(f"Initialised {len(self.servers)} MCP servers")
57
 
58
  # Yahoo Finance MCP methods
59
- @cached_async(
60
- namespace="yahoo_finance",
61
- data_type=CacheDataType.MARKET_DATA, # 60s TTL for real-time quotes
62
- )
63
- async def call_yahoo_finance_mcp(self, tool: str, params: Dict[str, Any]) -> Dict[str, Any]:
64
- """Call Yahoo Finance MCP tool.
65
 
66
  Args:
67
  tool: Tool name (get_quote, get_historical_data, get_fundamentals)
@@ -70,41 +41,28 @@ class MCPRouter:
70
  Returns:
71
  Tool result
72
  """
73
- logger.debug(f"Calling Yahoo Finance MCP: {tool}")
74
 
75
  if tool == "get_quote":
76
- from backend.mcp_servers.yahoo_finance_mcp import get_quote, QuoteRequest
77
- request = QuoteRequest(**params)
78
- result = await get_quote.fn(request)
79
 
80
  elif tool == "get_historical_data":
81
- from backend.mcp_servers.yahoo_finance_mcp import get_historical_data, HistoricalRequest
82
- request = HistoricalRequest(**params)
83
- result = await get_historical_data.fn(request)
 
 
84
 
85
  elif tool == "get_fundamentals":
86
- from backend.mcp_servers.yahoo_finance_mcp import get_fundamentals, FundamentalsRequest
87
- request = FundamentalsRequest(**params)
88
- result = await get_fundamentals.fn(request)
89
 
90
  else:
91
  raise ValueError(f"Unknown Yahoo Finance tool: {tool}")
92
 
93
- # Convert Pydantic models to dicts
94
- if hasattr(result, 'model_dump'):
95
- return result.model_dump()
96
- elif isinstance(result, list):
97
- return [r.model_dump() if hasattr(r, 'model_dump') else r for r in result]
98
- return result
99
-
100
  # FMP MCP methods
101
- @cached_async(
102
- namespace="fmp",
103
- data_type=CacheDataType.HISTORICAL_DATA, # 12 hours TTL
104
- ttl=21600, # Override to 6 hours for company fundamentals
105
- )
106
  async def call_fmp_mcp(self, tool: str, params: Dict[str, Any]) -> Dict[str, Any]:
107
- """Call Financial Modeling Prep MCP tool.
108
 
109
  Args:
110
  tool: Tool name
@@ -113,39 +71,52 @@ class MCPRouter:
113
  Returns:
114
  Tool result
115
  """
116
- logger.debug(f"Calling FMP MCP: {tool}")
117
 
118
  if tool == "get_company_profile":
119
- from backend.mcp_servers.fmp_mcp import get_company_profile, CompanyProfileRequest
120
- request = CompanyProfileRequest(**params)
121
- result = await get_company_profile.fn(request)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
122
 
123
  elif tool == "get_financial_ratios":
124
- from backend.mcp_servers.fmp_mcp import get_financial_ratios, FinancialRatiosRequest
125
- request = FinancialRatiosRequest(**params)
126
- result = await get_financial_ratios.fn(request)
 
127
 
128
  elif tool == "get_key_metrics":
129
- from backend.mcp_servers.fmp_mcp import get_key_metrics, KeyMetricsRequest
130
- request = KeyMetricsRequest(**params)
131
- result = await get_key_metrics.fn(request)
 
132
 
133
  else:
134
  raise ValueError(f"Unknown FMP tool: {tool}")
135
 
136
- if hasattr(result, 'model_dump'):
137
- return result.model_dump()
138
- elif isinstance(result, list):
139
- return [r.model_dump() if hasattr(r, 'model_dump') else r for r in result]
140
- return result
141
-
142
  # Trading MCP methods
143
- @cached_async(
144
- namespace="trading",
145
- data_type=CacheDataType.HISTORICAL_DATA, # 12 hours TTL for technical indicators
146
- )
147
- async def call_trading_mcp(self, tool: str, params: Dict[str, Any]) -> Dict[str, Any]:
148
- """Call Trading MCP tool.
149
 
150
  Args:
151
  tool: Tool name
@@ -154,27 +125,20 @@ class MCPRouter:
154
  Returns:
155
  Tool result
156
  """
157
- logger.debug(f"Calling Trading MCP: {tool}")
158
 
159
  if tool == "get_technical_indicators":
160
- from backend.mcp_servers.trading_mcp import get_technical_indicators, TechnicalIndicatorsRequest
161
- request = TechnicalIndicatorsRequest(**params)
162
- result = await get_technical_indicators.fn(request)
 
 
163
  else:
164
  raise ValueError(f"Unknown Trading MCP tool: {tool}")
165
 
166
- if hasattr(result, 'model_dump'):
167
- return result.model_dump()
168
- return result
169
-
170
  # FRED MCP methods
171
- @cached_async(
172
- namespace="fred",
173
- data_type=CacheDataType.HISTORICAL_DATA, # 12 hours default
174
- ttl=86400, # Override to 24 hours for economic data (changes infrequently)
175
- )
176
  async def call_fred_mcp(self, tool: str, params: Dict[str, Any]) -> Dict[str, Any]:
177
- """Call FRED MCP tool.
178
 
179
  Args:
180
  tool: Tool name
@@ -183,27 +147,23 @@ class MCPRouter:
183
  Returns:
184
  Tool result
185
  """
186
- logger.debug(f"Calling FRED MCP: {tool}")
187
 
188
  if tool == "get_economic_series":
189
- from backend.mcp_servers.fred_mcp import get_economic_series, SeriesRequest
190
- request = SeriesRequest(**params)
191
- result = await get_economic_series.fn(request)
 
 
 
192
  else:
193
  raise ValueError(f"Unknown FRED tool: {tool}")
194
 
195
- if hasattr(result, 'model_dump'):
196
- return result.model_dump()
197
- return result
198
-
199
  # Portfolio Optimizer MCP methods
200
- @cached_async(
201
- namespace="portfolio_optimizer",
202
- data_type=CacheDataType.PORTFOLIO_METRICS, # 30 min default
203
- ttl=14400, # Override to 4 hours for optimization results (computational, deterministic)
204
- )
205
- async def call_portfolio_optimizer_mcp(self, tool: str, params: Dict[str, Any]) -> Dict[str, Any]:
206
- """Call Portfolio Optimizer MCP tool.
207
 
208
  Args:
209
  tool: Tool name
@@ -212,38 +172,38 @@ class MCPRouter:
212
  Returns:
213
  Tool result
214
  """
215
- logger.debug(f"Calling Portfolio Optimizer MCP: {tool}")
216
-
217
- from backend.mcp_servers.portfolio_optimizer_mcp import (
218
- optimize_hrp, optimize_black_litterman, optimize_mean_variance, OptimizationRequest
219
- )
220
 
221
- request = OptimizationRequest(**params)
 
 
222
 
223
  if tool == "optimize_hrp":
224
- result = await optimize_hrp.fn(request)
 
 
 
225
 
226
  elif tool == "optimize_black_litterman":
227
- result = await optimize_black_litterman.fn(request)
 
 
 
228
 
229
  elif tool == "optimize_mean_variance":
230
- result = await optimize_mean_variance.fn(request)
 
 
 
231
 
232
  else:
233
  raise ValueError(f"Unknown Portfolio Optimizer tool: {tool}")
234
 
235
- if hasattr(result, 'model_dump'):
236
- return result.model_dump()
237
- return result
238
-
239
  # Risk Analyzer MCP methods
240
- @cached_async(
241
- namespace="risk_analyzer",
242
- data_type=CacheDataType.PORTFOLIO_METRICS, # 30 min default
243
- ttl=14400, # Override to 4 hours for risk analysis (computational, deterministic)
244
- )
245
- async def call_risk_analyzer_mcp(self, tool: str, params: Dict[str, Any]) -> Dict[str, Any]:
246
- """Call Risk Analyzer MCP tool.
247
 
248
  Args:
249
  tool: Tool name
@@ -252,27 +212,41 @@ class MCPRouter:
252
  Returns:
253
  Tool result
254
  """
255
- logger.debug(f"Calling Risk Analyzer MCP: {tool}")
256
 
257
  if tool == "analyze_risk":
258
- from backend.mcp_servers.risk_analyzer_mcp import analyze_risk, RiskAnalysisRequest
259
- request = RiskAnalysisRequest(**params)
260
- result = await analyze_risk.fn(request)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
261
  else:
262
  raise ValueError(f"Unknown Risk Analyzer tool: {tool}")
263
 
264
- if hasattr(result, 'model_dump'):
265
- return result.model_dump()
266
- return result
267
-
268
  # Ensemble Predictor MCP methods
269
- @cached_async(
270
- namespace="ensemble_predictor",
271
- data_type=CacheDataType.HISTORICAL_DATA, # 12 hours default
272
- ttl=21600, # Override to 6 hours for ML forecasts (expensive computation)
273
- )
274
- async def call_ensemble_predictor_mcp(self, tool: str, params: Dict[str, Any]) -> Dict[str, Any]:
275
- """Call Ensemble Predictor MCP tool.
276
 
277
  Args:
278
  tool: Tool name
@@ -281,103 +255,104 @@ class MCPRouter:
281
  Returns:
282
  Tool result
283
  """
284
- logger.debug(f"Calling Ensemble Predictor MCP: {tool}")
285
 
286
  if tool == "forecast_ensemble":
287
- from backend.mcp_servers.ensemble_predictor_mcp import forecast_ensemble, ForecastRequest
288
- request = ForecastRequest(**params)
289
- result = await forecast_ensemble.fn(request)
 
 
 
 
 
 
 
 
 
 
290
  else:
291
  raise ValueError(f"Unknown Ensemble Predictor tool: {tool}")
292
 
293
- if hasattr(result, 'model_dump'):
294
- return result.model_dump()
295
- return result
296
-
297
- @cached_async(
298
- namespace="news_sentiment",
299
- data_type=CacheDataType.USER_DATA, # 2 hours default
300
- ttl=7200, # 2 hours for news sentiment (balance freshness vs API costs)
301
- )
302
- async def call_news_sentiment_mcp(self, tool: str, params: Dict[str, Any]) -> Dict[str, Any]:
303
- """Call News Sentiment MCP tool.
304
 
305
  Args:
306
- tool: Tool name (get_news_with_sentiment)
307
- params: Tool parameters (ticker, days_back)
308
 
309
  Returns:
310
- TickerNewsWithSentiment with articles and sentiment scores
311
  """
312
- logger.debug(f"Calling News Sentiment MCP: {tool}")
313
 
314
  if tool == "get_news_with_sentiment":
315
- from backend.mcp_servers.news_sentiment_mcp import get_news_with_sentiment
316
- result = await get_news_with_sentiment.fn(
317
- ticker=params.get("ticker"),
318
- days_back=params.get("days_back", 7)
319
  )
 
320
  else:
321
  raise ValueError(f"Unknown News Sentiment tool: {tool}")
322
 
323
- if hasattr(result, 'model_dump'):
324
- return result.model_dump()
325
- return result
326
-
327
  # Feature Extraction MCP methods
328
- @cached_async(
329
- namespace="feature_extraction",
330
- data_type=CacheDataType.PORTFOLIO_METRICS, # 30 min default
331
- ttl=1800, # 30 minutes for computed features
332
- )
333
- async def call_feature_extraction_mcp(self, tool: str, params: Dict[str, Any]) -> Dict[str, Any]:
334
- """Call Feature Extraction MCP tool.
335
 
336
  Args:
337
- tool: Tool name (extract_technical_features, normalise_features,
338
- select_features, compute_feature_vector)
339
  params: Tool parameters
340
 
341
  Returns:
342
- Tool result with extracted/processed features
343
  """
344
- logger.debug(f"Calling Feature Extraction MCP: {tool}")
345
 
346
  if tool == "extract_technical_features":
347
- from backend.mcp_servers.feature_extraction_mcp import (
348
- extract_technical_features, FeatureExtractionRequest
 
 
 
 
 
349
  )
350
- request = FeatureExtractionRequest(**params)
351
- result = await extract_technical_features.fn(request)
352
 
353
  elif tool == "normalise_features":
354
- from backend.mcp_servers.feature_extraction_mcp import (
355
- normalise_features, NormalisationRequest
 
 
 
 
356
  )
357
- request = NormalisationRequest(**params)
358
- result = await normalise_features.fn(request)
359
 
360
  elif tool == "select_features":
361
- from backend.mcp_servers.feature_extraction_mcp import (
362
- select_features, FeatureSelectionRequest
 
 
 
363
  )
364
- request = FeatureSelectionRequest(**params)
365
- result = await select_features.fn(request)
366
 
367
  elif tool == "compute_feature_vector":
368
- from backend.mcp_servers.feature_extraction_mcp import (
369
- compute_feature_vector, FeatureVectorRequest
 
 
 
 
 
370
  )
371
- request = FeatureVectorRequest(**params)
372
- result = await compute_feature_vector.fn(request)
373
 
374
  else:
375
  raise ValueError(f"Unknown Feature Extraction tool: {tool}")
376
 
377
- if hasattr(result, 'model_dump'):
378
- return result.model_dump()
379
- return result
380
-
381
  # High-level helper methods
382
  async def fetch_market_data(self, tickers: List[str]) -> Dict[str, Any]:
383
  """Fetch market data for given tickers.
@@ -386,51 +361,52 @@ class MCPRouter:
386
  tickers: List of stock/asset tickers
387
 
388
  Returns:
389
- Market data from Yahoo Finance and other sources
390
  """
391
  logger.info(f"Fetching market data for {len(tickers)} tickers")
392
- return await self.call_yahoo_finance_mcp("get_quote", {"tickers": tickers})
 
393
 
394
  async def fetch_fundamentals(self, tickers: List[str]) -> Dict[str, Any]:
395
- """Fetch fundamental data from Financial Modeling Prep.
396
 
397
  Args:
398
  tickers: List of stock tickers
399
 
400
  Returns:
401
- Fundamental data (P/E, margins, revenue, etc.)
402
  """
403
  logger.info(f"Fetching fundamentals for {len(tickers)} tickers")
404
  results = {}
405
  for ticker in tickers:
406
- results[ticker] = await self.call_fmp_mcp("get_company_profile", {"ticker": ticker})
407
  return results
408
 
409
  async def fetch_technical_indicators(self, tickers: List[str]) -> Dict[str, Any]:
410
- """Fetch technical indicators from Trading-MCP.
411
 
412
  Args:
413
  tickers: List of stock tickers
414
 
415
  Returns:
416
- Technical indicators (RSI, MACD, Bollinger Bands, etc.)
417
  """
418
  logger.info(f"Fetching technical indicators for {len(tickers)} tickers")
419
  results = {}
420
  for ticker in tickers:
421
- results[ticker] = await self.call_trading_mcp("get_technical_indicators", {"ticker": ticker})
422
  return results
423
 
424
  async def fetch_macro_data(self) -> Dict[str, Any]:
425
  """Fetch macroeconomic data from FRED.
426
 
427
  Returns:
428
- Macroeconomic indicators (GDP, unemployment, inflation, etc.)
429
  """
430
  logger.info("Fetching macroeconomic data")
431
  results = {}
432
  for series_id in ["GDP", "UNRATE", "DFF"]:
433
- results[series_id] = await self.call_fred_mcp("get_economic_series", {"series_id": series_id})
434
  return results
435
 
436
 
 
1
  """MCP Router for Portfolio Intelligence Platform.
2
 
3
+ Compatibility layer that routes to unified mcp_tools module.
4
+ This file maintains backwards compatibility with existing code that uses
5
+ mcp_router.call_*_mcp() methods.
6
+
7
+ All actual implementation is now in backend.mcp_tools module.
8
  """
9
 
10
+ import json
11
  import logging
12
+ from typing import Any, Dict, List
 
 
 
 
 
 
 
 
 
13
 
14
+ from backend import mcp_tools
15
+ from backend.utils.serialisation import dumps_str
 
16
 
17
  logger = logging.getLogger(__name__)
18
 
19
 
20
  class MCPRouter:
21
+ """Router for orchestrating MCP tool calls.
22
 
23
+ This is a compatibility layer - all actual implementation
24
+ is in backend.mcp_tools module with namespaced functions.
 
25
  """
26
 
27
  def __init__(self):
28
+ """Initialise MCP router (compatibility layer)."""
29
+ logger.info("Initialising MCP router (compatibility layer)")
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
30
 
31
  # Yahoo Finance MCP methods
32
+ async def call_yahoo_finance_mcp(
33
+ self, tool: str, params: Dict[str, Any]
34
+ ) -> Dict[str, Any]:
35
+ """Call Yahoo Finance MCP tool (delegates to market_* functions).
 
 
36
 
37
  Args:
38
  tool: Tool name (get_quote, get_historical_data, get_fundamentals)
 
41
  Returns:
42
  Tool result
43
  """
44
+ logger.debug(f"Routing Yahoo Finance MCP call: {tool}")
45
 
46
  if tool == "get_quote":
47
+ tickers = params.get("tickers", [])
48
+ return await mcp_tools.market_get_quote(dumps_str(tickers))
 
49
 
50
  elif tool == "get_historical_data":
51
+ return await mcp_tools.market_get_historical_data(
52
+ ticker=params["ticker"],
53
+ period=params.get("period", "1y"),
54
+ interval=params.get("interval", "1d"),
55
+ )
56
 
57
  elif tool == "get_fundamentals":
58
+ return await mcp_tools.market_get_fundamentals(ticker=params["ticker"])
 
 
59
 
60
  else:
61
  raise ValueError(f"Unknown Yahoo Finance tool: {tool}")
62
 
 
 
 
 
 
 
 
63
  # FMP MCP methods
 
 
 
 
 
64
  async def call_fmp_mcp(self, tool: str, params: Dict[str, Any]) -> Dict[str, Any]:
65
+ """Call Financial Modeling Prep MCP tool (delegates to market_* functions).
66
 
67
  Args:
68
  tool: Tool name
 
71
  Returns:
72
  Tool result
73
  """
74
+ logger.debug(f"Routing FMP MCP call: {tool}")
75
 
76
  if tool == "get_company_profile":
77
+ return await mcp_tools.market_get_company_profile(ticker=params["ticker"])
78
+
79
+ elif tool == "get_income_statement":
80
+ return await mcp_tools.market_get_income_statement(
81
+ ticker=params["ticker"],
82
+ period=params.get("period", "annual"),
83
+ limit=str(params.get("limit", 5)),
84
+ )
85
+
86
+ elif tool == "get_balance_sheet":
87
+ return await mcp_tools.market_get_balance_sheet(
88
+ ticker=params["ticker"],
89
+ period=params.get("period", "annual"),
90
+ limit=str(params.get("limit", 5)),
91
+ )
92
+
93
+ elif tool == "get_cash_flow_statement":
94
+ return await mcp_tools.market_get_cash_flow_statement(
95
+ ticker=params["ticker"],
96
+ period=params.get("period", "annual"),
97
+ limit=str(params.get("limit", 5)),
98
+ )
99
 
100
  elif tool == "get_financial_ratios":
101
+ return await mcp_tools.market_get_financial_ratios(
102
+ ticker=params["ticker"],
103
+ ttm=str(params.get("ttm", True)).lower(),
104
+ )
105
 
106
  elif tool == "get_key_metrics":
107
+ return await mcp_tools.market_get_key_metrics(
108
+ ticker=params["ticker"],
109
+ ttm=str(params.get("ttm", True)).lower(),
110
+ )
111
 
112
  else:
113
  raise ValueError(f"Unknown FMP tool: {tool}")
114
 
 
 
 
 
 
 
115
  # Trading MCP methods
116
+ async def call_trading_mcp(
117
+ self, tool: str, params: Dict[str, Any]
118
+ ) -> Dict[str, Any]:
119
+ """Call Trading MCP tool (delegates to technical_* functions).
 
 
120
 
121
  Args:
122
  tool: Tool name
 
125
  Returns:
126
  Tool result
127
  """
128
+ logger.debug(f"Routing Trading MCP call: {tool}")
129
 
130
  if tool == "get_technical_indicators":
131
+ return await mcp_tools.technical_get_indicators(
132
+ ticker=params["ticker"],
133
+ period=params.get("period", "3mo"),
134
+ )
135
+
136
  else:
137
  raise ValueError(f"Unknown Trading MCP tool: {tool}")
138
 
 
 
 
 
139
  # FRED MCP methods
 
 
 
 
 
140
  async def call_fred_mcp(self, tool: str, params: Dict[str, Any]) -> Dict[str, Any]:
141
+ """Call FRED MCP tool (delegates to market_* functions).
142
 
143
  Args:
144
  tool: Tool name
 
147
  Returns:
148
  Tool result
149
  """
150
+ logger.debug(f"Routing FRED MCP call: {tool}")
151
 
152
  if tool == "get_economic_series":
153
+ return await mcp_tools.market_get_economic_series(
154
+ series_id=params["series_id"],
155
+ observation_start=params.get("observation_start"),
156
+ observation_end=params.get("observation_end"),
157
+ )
158
+
159
  else:
160
  raise ValueError(f"Unknown FRED tool: {tool}")
161
 
 
 
 
 
162
  # Portfolio Optimizer MCP methods
163
+ async def call_portfolio_optimizer_mcp(
164
+ self, tool: str, params: Dict[str, Any]
165
+ ) -> Dict[str, Any]:
166
+ """Call Portfolio Optimizer MCP tool (delegates to portfolio_* functions).
 
 
 
167
 
168
  Args:
169
  tool: Tool name
 
172
  Returns:
173
  Tool result
174
  """
175
+ logger.debug(f"Routing Portfolio Optimizer MCP call: {tool}")
 
 
 
 
176
 
177
+ market_data = params.get("market_data", [])
178
+ market_data_json = dumps_str(market_data)
179
+ risk_tolerance = params.get("risk_tolerance", "moderate")
180
 
181
  if tool == "optimize_hrp":
182
+ return await mcp_tools.portfolio_optimize_hrp(
183
+ market_data_json=market_data_json,
184
+ risk_tolerance=risk_tolerance,
185
+ )
186
 
187
  elif tool == "optimize_black_litterman":
188
+ return await mcp_tools.portfolio_optimize_black_litterman(
189
+ market_data_json=market_data_json,
190
+ risk_tolerance=risk_tolerance,
191
+ )
192
 
193
  elif tool == "optimize_mean_variance":
194
+ return await mcp_tools.portfolio_optimize_mean_variance(
195
+ market_data_json=market_data_json,
196
+ risk_tolerance=risk_tolerance,
197
+ )
198
 
199
  else:
200
  raise ValueError(f"Unknown Portfolio Optimizer tool: {tool}")
201
 
 
 
 
 
202
  # Risk Analyzer MCP methods
203
+ async def call_risk_analyzer_mcp(
204
+ self, tool: str, params: Dict[str, Any]
205
+ ) -> Dict[str, Any]:
206
+ """Call Risk Analyzer MCP tool (delegates to risk_* functions).
 
 
 
207
 
208
  Args:
209
  tool: Tool name
 
212
  Returns:
213
  Tool result
214
  """
215
+ logger.debug(f"Routing Risk Analyzer MCP call: {tool}")
216
 
217
  if tool == "analyze_risk":
218
+ portfolio = params.get("portfolio", [])
219
+ benchmark = params.get("benchmark")
220
+
221
+ return await mcp_tools.risk_analyze(
222
+ portfolio_json=dumps_str(portfolio),
223
+ portfolio_value=str(params.get("portfolio_value", 100000)),
224
+ confidence_level=str(params.get("confidence_level", 0.95)),
225
+ time_horizon=str(params.get("time_horizon", 1)),
226
+ method=params.get("method", "historical"),
227
+ num_simulations=str(params.get("num_simulations", 10000)),
228
+ benchmark_json=dumps_str(benchmark) if benchmark else None,
229
+ )
230
+
231
+ elif tool == "forecast_volatility_garch":
232
+ returns = params.get("returns", [])
233
+
234
+ return await mcp_tools.risk_forecast_volatility_garch(
235
+ ticker=params["ticker"],
236
+ returns_json=dumps_str([float(r) for r in returns]),
237
+ forecast_horizon=str(params.get("forecast_horizon", 30)),
238
+ garch_p=str(params.get("garch_p", 1)),
239
+ garch_q=str(params.get("garch_q", 1)),
240
+ )
241
+
242
  else:
243
  raise ValueError(f"Unknown Risk Analyzer tool: {tool}")
244
 
 
 
 
 
245
  # Ensemble Predictor MCP methods
246
+ async def call_ensemble_predictor_mcp(
247
+ self, tool: str, params: Dict[str, Any]
248
+ ) -> Dict[str, Any]:
249
+ """Call Ensemble Predictor MCP tool (delegates to ml_* functions).
 
 
 
250
 
251
  Args:
252
  tool: Tool name
 
255
  Returns:
256
  Tool result
257
  """
258
+ logger.debug(f"Routing Ensemble Predictor MCP call: {tool}")
259
 
260
  if tool == "forecast_ensemble":
261
+ prices = params.get("prices", [])
262
+ dates = params.get("dates")
263
+
264
+ return await mcp_tools.ml_forecast_ensemble(
265
+ ticker=params["ticker"],
266
+ prices_json=dumps_str([float(p) for p in prices]),
267
+ dates_json=dumps_str(dates) if dates else None,
268
+ forecast_horizon=str(params.get("forecast_horizon", 30)),
269
+ confidence_level=str(params.get("confidence_level", 0.95)),
270
+ use_returns=str(params.get("use_returns", True)).lower(),
271
+ ensemble_method=params.get("ensemble_method", "mean"),
272
+ )
273
+
274
  else:
275
  raise ValueError(f"Unknown Ensemble Predictor tool: {tool}")
276
 
277
+ # News Sentiment MCP methods
278
+ async def call_news_sentiment_mcp(
279
+ self, tool: str, params: Dict[str, Any]
280
+ ) -> Dict[str, Any]:
281
+ """Call News Sentiment MCP tool (delegates to sentiment_* functions).
 
 
 
 
 
 
282
 
283
  Args:
284
+ tool: Tool name
285
+ params: Tool parameters
286
 
287
  Returns:
288
+ Tool result
289
  """
290
+ logger.debug(f"Routing News Sentiment MCP call: {tool}")
291
 
292
  if tool == "get_news_with_sentiment":
293
+ return await mcp_tools.sentiment_get_news(
294
+ ticker=params["ticker"],
295
+ days_back=str(params.get("days_back", 7)),
 
296
  )
297
+
298
  else:
299
  raise ValueError(f"Unknown News Sentiment tool: {tool}")
300
 
 
 
 
 
301
  # Feature Extraction MCP methods
302
+ async def call_feature_extraction_mcp(
303
+ self, tool: str, params: Dict[str, Any]
304
+ ) -> Dict[str, Any]:
305
+ """Call Feature Extraction MCP tool (delegates to technical_* functions).
 
 
 
306
 
307
  Args:
308
+ tool: Tool name
 
309
  params: Tool parameters
310
 
311
  Returns:
312
+ Tool result
313
  """
314
+ logger.debug(f"Routing Feature Extraction MCP call: {tool}")
315
 
316
  if tool == "extract_technical_features":
317
+ return await mcp_tools.technical_extract_features(
318
+ ticker=params["ticker"],
319
+ prices=dumps_str(params.get("prices", [])),
320
+ volumes=dumps_str(params.get("volumes", [])),
321
+ include_momentum=str(params.get("include_momentum", True)).lower(),
322
+ include_volatility=str(params.get("include_volatility", True)).lower(),
323
+ include_trend=str(params.get("include_trend", True)).lower(),
324
  )
 
 
325
 
326
  elif tool == "normalise_features":
327
+ return await mcp_tools.technical_normalise_features(
328
+ ticker=params["ticker"],
329
+ features=dumps_str(params.get("features", {})),
330
+ historical_features=dumps_str(params.get("historical_features", [])),
331
+ window_size=str(params.get("window_size", 100)),
332
+ method=params.get("method", "ewm"),
333
  )
 
 
334
 
335
  elif tool == "select_features":
336
+ return await mcp_tools.technical_select_features(
337
+ ticker=params["ticker"],
338
+ feature_vector=dumps_str(params.get("feature_vector", {})),
339
+ max_features=str(params.get("max_features", 15)),
340
+ variance_threshold=str(params.get("variance_threshold", 0.95)),
341
  )
 
 
342
 
343
  elif tool == "compute_feature_vector":
344
+ return await mcp_tools.technical_compute_feature_vector(
345
+ ticker=params["ticker"],
346
+ technical_features=dumps_str(params.get("technical_features", {})),
347
+ fundamental_features=dumps_str(params.get("fundamental_features", {})),
348
+ sentiment_features=dumps_str(params.get("sentiment_features", {})),
349
+ max_features=str(params.get("max_features", 30)),
350
+ selection_method=params.get("selection_method", "pca"),
351
  )
 
 
352
 
353
  else:
354
  raise ValueError(f"Unknown Feature Extraction tool: {tool}")
355
 
 
 
 
 
356
  # High-level helper methods
357
  async def fetch_market_data(self, tickers: List[str]) -> Dict[str, Any]:
358
  """Fetch market data for given tickers.
 
361
  tickers: List of stock/asset tickers
362
 
363
  Returns:
364
+ Market data from Yahoo Finance
365
  """
366
  logger.info(f"Fetching market data for {len(tickers)} tickers")
367
+ results = await mcp_tools.market_get_quote(dumps_str(tickers))
368
+ return {r.get("ticker", r.get("symbol")): r for r in results}
369
 
370
  async def fetch_fundamentals(self, tickers: List[str]) -> Dict[str, Any]:
371
+ """Fetch fundamental data for tickers.
372
 
373
  Args:
374
  tickers: List of stock tickers
375
 
376
  Returns:
377
+ Fundamental data per ticker
378
  """
379
  logger.info(f"Fetching fundamentals for {len(tickers)} tickers")
380
  results = {}
381
  for ticker in tickers:
382
+ results[ticker] = await mcp_tools.market_get_company_profile(ticker)
383
  return results
384
 
385
  async def fetch_technical_indicators(self, tickers: List[str]) -> Dict[str, Any]:
386
+ """Fetch technical indicators for tickers.
387
 
388
  Args:
389
  tickers: List of stock tickers
390
 
391
  Returns:
392
+ Technical indicators per ticker
393
  """
394
  logger.info(f"Fetching technical indicators for {len(tickers)} tickers")
395
  results = {}
396
  for ticker in tickers:
397
+ results[ticker] = await mcp_tools.technical_get_indicators(ticker)
398
  return results
399
 
400
  async def fetch_macro_data(self) -> Dict[str, Any]:
401
  """Fetch macroeconomic data from FRED.
402
 
403
  Returns:
404
+ Macroeconomic indicators
405
  """
406
  logger.info("Fetching macroeconomic data")
407
  results = {}
408
  for series_id in ["GDP", "UNRATE", "DFF"]:
409
+ results[series_id] = await mcp_tools.market_get_economic_series(series_id)
410
  return results
411
 
412
 
backend/mcp_tools.py ADDED
@@ -0,0 +1,958 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Unified MCP tools for Portfolio Intelligence Platform.
2
+
3
+ This module contains all MCP-compatible tool functions that can be:
4
+ 1. Called directly by agents and workflows
5
+ 2. Exposed as MCP tools via Gradio's mcp_server=True
6
+ 3. Cached via @cached_async decorators
7
+
8
+ All tools use namespaced function names for clear organisation:
9
+ - market_*: Market data, fundamentals, and economic data
10
+ - technical_*: Technical analysis and feature extraction
11
+ - portfolio_*: Portfolio optimisation
12
+ - risk_*: Risk analysis and volatility forecasting
13
+ - ml_*: Machine learning predictions
14
+ - sentiment_*: News sentiment analysis
15
+ """
16
+
17
+ import json
18
+ import logging
19
+ from decimal import Decimal
20
+ from typing import Any, Dict, List, Literal, Optional, cast
21
+
22
+ from backend.caching.decorators import cached_async
23
+ from backend.caching.redis_cache import CacheDataType
24
+
25
+ logger = logging.getLogger(__name__)
26
+
27
+
28
+ def _convert_decimals_to_floats(obj: Any) -> Any:
29
+ """Recursively convert Decimal values to floats in a dict/list structure.
30
+
31
+ Pydantic v2 serializes Decimals to strings by default. This function
32
+ converts them back to floats for backward compatibility.
33
+
34
+ Args:
35
+ obj: Object to convert (dict, list, or value)
36
+
37
+ Returns:
38
+ Object with Decimals converted to floats
39
+ """
40
+ if isinstance(obj, dict):
41
+ return {k: _convert_decimals_to_floats(v) for k, v in obj.items()}
42
+ elif isinstance(obj, list):
43
+ return [_convert_decimals_to_floats(item) for item in obj]
44
+ elif isinstance(obj, Decimal):
45
+ return float(obj)
46
+ elif isinstance(obj, str):
47
+ try:
48
+ return float(obj)
49
+ except ValueError:
50
+ return obj
51
+ else:
52
+ return obj
53
+
54
+
55
+ # =============================================================================
56
+ # MARKET DATA TOOLS (Yahoo Finance) - 3 tools
57
+ # =============================================================================
58
+
59
+
60
+ @cached_async(
61
+ namespace="yahoo_finance",
62
+ data_type=CacheDataType.MARKET_DATA,
63
+ )
64
+ async def market_get_quote(tickers: str) -> List[Dict[str, Any]]:
65
+ """Get real-time quotes for multiple tickers.
66
+
67
+ Args:
68
+ tickers: JSON array of stock ticker symbols (e.g., '["AAPL", "NVDA"]')
69
+
70
+ Returns:
71
+ List of quote dictionaries with price, volume, market cap, etc.
72
+ """
73
+ from backend.mcp_servers.yahoo_finance_mcp import get_quote, QuoteRequest
74
+
75
+ tickers_list = json.loads(tickers) if isinstance(tickers, str) else tickers
76
+ request = QuoteRequest(tickers=tickers_list)
77
+ result = await get_quote.fn(request)
78
+
79
+ return [r.model_dump() if hasattr(r, "model_dump") else r for r in result]
80
+
81
+
82
+ @cached_async(
83
+ namespace="yahoo_finance",
84
+ data_type=CacheDataType.MARKET_DATA,
85
+ )
86
+ async def market_get_historical_data(
87
+ ticker: str, period: str = "1y", interval: str = "1d"
88
+ ) -> Dict[str, Any]:
89
+ """Get historical OHLCV price data for a ticker.
90
+
91
+ Args:
92
+ ticker: Stock ticker symbol (e.g., 'AAPL')
93
+ period: Time period (1d, 5d, 1mo, 3mo, 6mo, 1y, 2y, 5y, 10y, ytd, max)
94
+ interval: Data interval (1m, 2m, 5m, 15m, 30m, 60m, 90m, 1h, 1d, 5d, 1wk, 1mo, 3mo)
95
+
96
+ Returns:
97
+ Dictionary with dates, OHLCV arrays, and calculated returns.
98
+ """
99
+ from backend.mcp_servers.yahoo_finance_mcp import (
100
+ get_historical_data,
101
+ HistoricalRequest,
102
+ )
103
+
104
+ request = HistoricalRequest(ticker=ticker, period=period, interval=interval)
105
+ result = await get_historical_data.fn(request)
106
+
107
+ return result.model_dump() if hasattr(result, "model_dump") else result
108
+
109
+
110
+ @cached_async(
111
+ namespace="yahoo_finance",
112
+ data_type=CacheDataType.HISTORICAL_DATA,
113
+ )
114
+ async def market_get_fundamentals(ticker: str) -> Dict[str, Any]:
115
+ """Get company fundamentals and key financial metrics.
116
+
117
+ Args:
118
+ ticker: Stock ticker symbol (e.g., 'AAPL')
119
+
120
+ Returns:
121
+ Dictionary with company name, sector, industry, P/E, market cap, etc.
122
+ """
123
+ from backend.mcp_servers.yahoo_finance_mcp import (
124
+ get_fundamentals,
125
+ FundamentalsRequest,
126
+ )
127
+
128
+ request = FundamentalsRequest(ticker=ticker)
129
+ result = await get_fundamentals.fn(request)
130
+
131
+ return result.model_dump() if hasattr(result, "model_dump") else result
132
+
133
+
134
+ # =============================================================================
135
+ # FUNDAMENTALS TOOLS (FMP) - 6 tools
136
+ # =============================================================================
137
+
138
+
139
+ @cached_async(
140
+ namespace="fmp",
141
+ data_type=CacheDataType.HISTORICAL_DATA,
142
+ ttl=21600,
143
+ )
144
+ async def market_get_company_profile(ticker: str) -> Dict[str, Any]:
145
+ """Get company profile with business description and metadata.
146
+
147
+ Args:
148
+ ticker: Stock ticker symbol (e.g., 'AAPL')
149
+
150
+ Returns:
151
+ Dictionary with company name, sector, industry, description, CEO, etc.
152
+ """
153
+ from backend.mcp_servers.fmp_mcp import get_company_profile, CompanyProfileRequest
154
+
155
+ request = CompanyProfileRequest(ticker=ticker)
156
+ result = await get_company_profile.fn(request)
157
+
158
+ return result.model_dump() if hasattr(result, "model_dump") else result
159
+
160
+
161
+ @cached_async(
162
+ namespace="fmp",
163
+ data_type=CacheDataType.HISTORICAL_DATA,
164
+ ttl=21600,
165
+ )
166
+ async def market_get_income_statement(
167
+ ticker: str, period: str = "annual", limit: str = "5"
168
+ ) -> List[Dict[str, Any]]:
169
+ """Get historical income statement data.
170
+
171
+ Args:
172
+ ticker: Stock ticker symbol (e.g., 'AAPL')
173
+ period: Report period ('annual' or 'quarter')
174
+ limit: Number of periods to retrieve as string (default: '5')
175
+
176
+ Returns:
177
+ List of income statement dictionaries with revenue, net income, EPS, etc.
178
+ """
179
+ from backend.mcp_servers.fmp_mcp import (
180
+ get_income_statement,
181
+ FinancialStatementsRequest,
182
+ )
183
+
184
+ request = FinancialStatementsRequest(
185
+ ticker=ticker, period=period, limit=int(limit)
186
+ )
187
+ result = await get_income_statement.fn(request)
188
+
189
+ return [r.model_dump() if hasattr(r, "model_dump") else r for r in result]
190
+
191
+
192
+ @cached_async(
193
+ namespace="fmp",
194
+ data_type=CacheDataType.HISTORICAL_DATA,
195
+ ttl=21600,
196
+ )
197
+ async def market_get_balance_sheet(
198
+ ticker: str, period: str = "annual", limit: str = "5"
199
+ ) -> List[Dict[str, Any]]:
200
+ """Get historical balance sheet data.
201
+
202
+ Args:
203
+ ticker: Stock ticker symbol (e.g., 'AAPL')
204
+ period: Report period ('annual' or 'quarter')
205
+ limit: Number of periods to retrieve as string (default: '5')
206
+
207
+ Returns:
208
+ List of balance sheet dictionaries with assets, liabilities, equity, etc.
209
+ """
210
+ from backend.mcp_servers.fmp_mcp import get_balance_sheet, FinancialStatementsRequest
211
+
212
+ request = FinancialStatementsRequest(
213
+ ticker=ticker, period=period, limit=int(limit)
214
+ )
215
+ result = await get_balance_sheet.fn(request)
216
+
217
+ return [r.model_dump() if hasattr(r, "model_dump") else r for r in result]
218
+
219
+
220
+ @cached_async(
221
+ namespace="fmp",
222
+ data_type=CacheDataType.HISTORICAL_DATA,
223
+ ttl=21600,
224
+ )
225
+ async def market_get_cash_flow_statement(
226
+ ticker: str, period: str = "annual", limit: str = "5"
227
+ ) -> List[Dict[str, Any]]:
228
+ """Get historical cash flow statement data.
229
+
230
+ Args:
231
+ ticker: Stock ticker symbol (e.g., 'AAPL')
232
+ period: Report period ('annual' or 'quarter')
233
+ limit: Number of periods to retrieve as string (default: '5')
234
+
235
+ Returns:
236
+ List of cash flow statements with operating, investing, financing flows.
237
+ """
238
+ from backend.mcp_servers.fmp_mcp import (
239
+ get_cash_flow_statement,
240
+ FinancialStatementsRequest,
241
+ )
242
+
243
+ request = FinancialStatementsRequest(
244
+ ticker=ticker, period=period, limit=int(limit)
245
+ )
246
+ result = await get_cash_flow_statement.fn(request)
247
+
248
+ return [r.model_dump() if hasattr(r, "model_dump") else r for r in result]
249
+
250
+
251
+ @cached_async(
252
+ namespace="fmp",
253
+ data_type=CacheDataType.HISTORICAL_DATA,
254
+ ttl=21600,
255
+ )
256
+ async def market_get_financial_ratios(
257
+ ticker: str, ttm: str = "true"
258
+ ) -> Dict[str, Any]:
259
+ """Get key financial ratios.
260
+
261
+ Args:
262
+ ticker: Stock ticker symbol (e.g., 'AAPL')
263
+ ttm: Use trailing twelve months as string ('true' or 'false')
264
+
265
+ Returns:
266
+ Dictionary with profitability, liquidity, efficiency, and leverage ratios.
267
+ """
268
+ from backend.mcp_servers.fmp_mcp import get_financial_ratios, FinancialRatiosRequest
269
+
270
+ request = FinancialRatiosRequest(ticker=ticker, ttm=ttm.lower() == "true")
271
+ result = await get_financial_ratios.fn(request)
272
+
273
+ return result.model_dump() if hasattr(result, "model_dump") else result
274
+
275
+
276
+ @cached_async(
277
+ namespace="fmp",
278
+ data_type=CacheDataType.HISTORICAL_DATA,
279
+ ttl=21600,
280
+ )
281
+ async def market_get_key_metrics(ticker: str, ttm: str = "true") -> Dict[str, Any]:
282
+ """Get key company metrics.
283
+
284
+ Args:
285
+ ticker: Stock ticker symbol (e.g., 'AAPL')
286
+ ttm: Use trailing twelve months as string ('true' or 'false')
287
+
288
+ Returns:
289
+ Dictionary with market cap, P/E, P/B, EV/EBITDA, per-share metrics.
290
+ """
291
+ from backend.mcp_servers.fmp_mcp import get_key_metrics, KeyMetricsRequest
292
+
293
+ request = KeyMetricsRequest(ticker=ticker, ttm=ttm.lower() == "true")
294
+ result = await get_key_metrics.fn(request)
295
+
296
+ return result.model_dump() if hasattr(result, "model_dump") else result
297
+
298
+
299
+ # =============================================================================
300
+ # ECONOMIC DATA TOOLS (FRED) - 1 tool
301
+ # =============================================================================
302
+
303
+
304
+ @cached_async(
305
+ namespace="fred",
306
+ data_type=CacheDataType.HISTORICAL_DATA,
307
+ ttl=86400,
308
+ )
309
+ async def market_get_economic_series(
310
+ series_id: str,
311
+ observation_start: Optional[str] = None,
312
+ observation_end: Optional[str] = None,
313
+ ) -> Dict[str, Any]:
314
+ """Get economic data series from FRED.
315
+
316
+ Args:
317
+ series_id: FRED series ID (e.g., 'GDP', 'UNRATE', 'DFF', 'CPIAUCSL')
318
+ observation_start: Start date in YYYY-MM-DD format (optional)
319
+ observation_end: End date in YYYY-MM-DD format (optional)
320
+
321
+ Returns:
322
+ Dictionary with series_id, title, units, frequency, and observations.
323
+ """
324
+ from backend.mcp_servers.fred_mcp import get_economic_series, SeriesRequest
325
+
326
+ request = SeriesRequest(
327
+ series_id=series_id,
328
+ observation_start=observation_start,
329
+ observation_end=observation_end,
330
+ )
331
+ result = await get_economic_series.fn(request)
332
+
333
+ return result.model_dump() if hasattr(result, "model_dump") else result
334
+
335
+
336
+ # =============================================================================
337
+ # TECHNICAL ANALYSIS TOOLS - 5 tools
338
+ # =============================================================================
339
+
340
+
341
+ @cached_async(
342
+ namespace="trading",
343
+ data_type=CacheDataType.HISTORICAL_DATA,
344
+ )
345
+ async def technical_get_indicators(
346
+ ticker: str, period: str = "3mo"
347
+ ) -> Dict[str, Any]:
348
+ """Get technical indicators for a ticker.
349
+
350
+ Calculates RSI, MACD, Bollinger Bands, moving averages, and overall signal.
351
+
352
+ Args:
353
+ ticker: Stock ticker symbol (e.g., 'AAPL')
354
+ period: Data period (1mo, 3mo, 6mo, 1y)
355
+
356
+ Returns:
357
+ Dictionary with RSI, MACD, Bollinger Bands, moving averages, volume trend,
358
+ and overall signal (buy, sell, or hold).
359
+ """
360
+ from backend.mcp_servers.trading_mcp import (
361
+ get_technical_indicators,
362
+ TechnicalIndicatorsRequest,
363
+ )
364
+
365
+ request = TechnicalIndicatorsRequest(ticker=ticker, period=period)
366
+ result = await get_technical_indicators.fn(request)
367
+
368
+ return result.model_dump() if hasattr(result, "model_dump") else result
369
+
370
+
371
+ @cached_async(
372
+ namespace="feature_extraction",
373
+ data_type=CacheDataType.PORTFOLIO_METRICS,
374
+ ttl=1800,
375
+ )
376
+ async def technical_extract_features(
377
+ ticker: str,
378
+ prices: str,
379
+ volumes: str = "[]",
380
+ include_momentum: str = "true",
381
+ include_volatility: str = "true",
382
+ include_trend: str = "true",
383
+ ) -> Dict[str, Any]:
384
+ """Extract technical features with look-ahead bias prevention.
385
+
386
+ All features are calculated using SHIFTED data to prevent future data leakage.
387
+
388
+ Args:
389
+ ticker: Stock ticker symbol
390
+ prices: JSON array of historical closing prices
391
+ volumes: JSON array of historical volumes (optional)
392
+ include_momentum: Include momentum indicators ('true' or 'false')
393
+ include_volatility: Include volatility indicators ('true' or 'false')
394
+ include_trend: Include trend indicators ('true' or 'false')
395
+
396
+ Returns:
397
+ Dictionary with extracted features and feature count.
398
+ """
399
+ from backend.mcp_servers.feature_extraction_mcp import (
400
+ extract_technical_features,
401
+ FeatureExtractionRequest,
402
+ )
403
+
404
+ prices_list = json.loads(prices) if isinstance(prices, str) else prices
405
+ volumes_list = json.loads(volumes) if isinstance(volumes, str) else volumes
406
+
407
+ request = FeatureExtractionRequest(
408
+ ticker=ticker,
409
+ prices=prices_list,
410
+ volumes=volumes_list,
411
+ include_momentum=include_momentum.lower() == "true",
412
+ include_volatility=include_volatility.lower() == "true",
413
+ include_trend=include_trend.lower() == "true",
414
+ )
415
+ result = await extract_technical_features.fn(request)
416
+
417
+ return result
418
+
419
+
420
+ @cached_async(
421
+ namespace="feature_extraction",
422
+ data_type=CacheDataType.PORTFOLIO_METRICS,
423
+ ttl=1800,
424
+ )
425
+ async def technical_normalise_features(
426
+ ticker: str,
427
+ features: str,
428
+ historical_features: str = "[]",
429
+ window_size: str = "100",
430
+ method: str = "ewm",
431
+ ) -> Dict[str, Any]:
432
+ """Normalise features using adaptive rolling window statistics.
433
+
434
+ Uses exponentially weighted mean/variance for robust time-varying normalisation.
435
+
436
+ Args:
437
+ ticker: Stock ticker symbol
438
+ features: JSON object of current feature values
439
+ historical_features: JSON array of historical feature observations
440
+ window_size: Rolling window size as string (default: '100')
441
+ method: Normalisation method ('ewm' or 'z_score')
442
+
443
+ Returns:
444
+ Dictionary with normalised features.
445
+ """
446
+ from backend.mcp_servers.feature_extraction_mcp import (
447
+ normalise_features,
448
+ NormalisationRequest,
449
+ )
450
+
451
+ features_dict = json.loads(features) if isinstance(features, str) else features
452
+ hist_list = (
453
+ json.loads(historical_features)
454
+ if isinstance(historical_features, str)
455
+ else historical_features
456
+ )
457
+
458
+ request = NormalisationRequest(
459
+ ticker=ticker,
460
+ features=features_dict,
461
+ historical_features=hist_list,
462
+ window_size=int(window_size),
463
+ method=method,
464
+ )
465
+ result = await normalise_features.fn(request)
466
+
467
+ return result
468
+
469
+
470
+ @cached_async(
471
+ namespace="feature_extraction",
472
+ data_type=CacheDataType.PORTFOLIO_METRICS,
473
+ ttl=1800,
474
+ )
475
+ async def technical_select_features(
476
+ ticker: str,
477
+ feature_vector: str,
478
+ max_features: str = "15",
479
+ variance_threshold: str = "0.95",
480
+ ) -> Dict[str, Any]:
481
+ """Select optimal features using PCA for dimensionality reduction.
482
+
483
+ Target: 6-15 features to balance predictive power with overfitting prevention.
484
+
485
+ Args:
486
+ ticker: Stock ticker symbol
487
+ feature_vector: JSON object of full feature vector
488
+ max_features: Maximum features to select as string (default: '15')
489
+ variance_threshold: Variance threshold for PCA as string (default: '0.95')
490
+
491
+ Returns:
492
+ Dictionary with selected features and metadata.
493
+ """
494
+ from backend.mcp_servers.feature_extraction_mcp import (
495
+ select_features,
496
+ FeatureSelectionRequest,
497
+ )
498
+
499
+ vector_dict = (
500
+ json.loads(feature_vector) if isinstance(feature_vector, str) else feature_vector
501
+ )
502
+
503
+ request = FeatureSelectionRequest(
504
+ ticker=ticker,
505
+ feature_vector=vector_dict,
506
+ max_features=int(max_features),
507
+ variance_threshold=float(variance_threshold),
508
+ )
509
+ result = await select_features.fn(request)
510
+
511
+ return result
512
+
513
+
514
+ @cached_async(
515
+ namespace="feature_extraction",
516
+ data_type=CacheDataType.PORTFOLIO_METRICS,
517
+ ttl=1800,
518
+ )
519
+ async def technical_compute_feature_vector(
520
+ ticker: str,
521
+ technical_features: str = "{}",
522
+ fundamental_features: str = "{}",
523
+ sentiment_features: str = "{}",
524
+ max_features: str = "30",
525
+ selection_method: str = "pca",
526
+ ) -> Dict[str, Any]:
527
+ """Compute combined feature vector from multiple sources.
528
+
529
+ Combines technical, fundamental, and sentiment features into a single vector
530
+ suitable for ML model input.
531
+
532
+ Args:
533
+ ticker: Stock ticker symbol
534
+ technical_features: JSON object of technical features
535
+ fundamental_features: JSON object of fundamental features
536
+ sentiment_features: JSON object of sentiment features
537
+ max_features: Maximum features in vector as string (default: '30')
538
+ selection_method: Selection method ('pca' or 'variance')
539
+
540
+ Returns:
541
+ Dictionary with combined feature vector and metadata.
542
+ """
543
+ from backend.mcp_servers.feature_extraction_mcp import (
544
+ compute_feature_vector,
545
+ FeatureVectorRequest,
546
+ )
547
+
548
+ tech_dict = (
549
+ json.loads(technical_features)
550
+ if isinstance(technical_features, str)
551
+ else technical_features
552
+ )
553
+ fund_dict = (
554
+ json.loads(fundamental_features)
555
+ if isinstance(fundamental_features, str)
556
+ else fundamental_features
557
+ )
558
+ sent_dict = (
559
+ json.loads(sentiment_features)
560
+ if isinstance(sentiment_features, str)
561
+ else sentiment_features
562
+ )
563
+
564
+ request = FeatureVectorRequest(
565
+ ticker=ticker,
566
+ technical_features=tech_dict,
567
+ fundamental_features=fund_dict,
568
+ sentiment_features=sent_dict,
569
+ max_features=int(max_features),
570
+ selection_method=selection_method,
571
+ )
572
+ result = await compute_feature_vector.fn(request)
573
+
574
+ return result
575
+
576
+
577
+ # =============================================================================
578
+ # PORTFOLIO OPTIMISATION TOOLS - 3 tools
579
+ # =============================================================================
580
+
581
+
582
+ @cached_async(
583
+ namespace="portfolio_optimizer",
584
+ data_type=CacheDataType.PORTFOLIO_METRICS,
585
+ ttl=14400,
586
+ )
587
+ async def portfolio_optimize_hrp(
588
+ market_data_json: str, risk_tolerance: str = "moderate"
589
+ ) -> Dict[str, Any]:
590
+ """Optimise portfolio using Hierarchical Risk Parity.
591
+
592
+ HRP uses hierarchical clustering to construct a diversified portfolio
593
+ that balances risk across clusters of correlated assets.
594
+
595
+ Args:
596
+ market_data_json: JSON array of market data objects with ticker, prices, dates
597
+ e.g., '[{"ticker": "AAPL", "prices": [150.0, 151.5, ...], "dates": ["2024-01-01", ...]}]'
598
+ risk_tolerance: Risk level ('conservative', 'moderate', 'aggressive')
599
+
600
+ Returns:
601
+ Dictionary with optimal weights, expected return, volatility, Sharpe ratio.
602
+ """
603
+ from backend.mcp_servers.portfolio_optimizer_mcp import (
604
+ optimize_hrp,
605
+ OptimizationRequest,
606
+ MarketDataInput,
607
+ )
608
+
609
+ market_data_list = json.loads(market_data_json)
610
+ market_data = [
611
+ MarketDataInput(
612
+ ticker=item["ticker"],
613
+ prices=[Decimal(str(p)) for p in item["prices"]],
614
+ dates=item["dates"],
615
+ )
616
+ for item in market_data_list
617
+ ]
618
+
619
+ request = OptimizationRequest(
620
+ market_data=market_data, method="hrp", risk_tolerance=risk_tolerance
621
+ )
622
+ result = await optimize_hrp.fn(request)
623
+
624
+ data = result.model_dump() if hasattr(result, "model_dump") else result
625
+ return _convert_decimals_to_floats(data)
626
+
627
+
628
+ @cached_async(
629
+ namespace="portfolio_optimizer",
630
+ data_type=CacheDataType.PORTFOLIO_METRICS,
631
+ ttl=14400,
632
+ )
633
+ async def portfolio_optimize_black_litterman(
634
+ market_data_json: str, risk_tolerance: str = "moderate"
635
+ ) -> Dict[str, Any]:
636
+ """Optimise portfolio using Black-Litterman model with market equilibrium.
637
+
638
+ Black-Litterman uses market-implied equilibrium returns as the prior distribution
639
+ when no explicit investor views are provided.
640
+
641
+ Args:
642
+ market_data_json: JSON array of market data objects with ticker, prices, dates
643
+ risk_tolerance: Risk level ('conservative', 'moderate', 'aggressive')
644
+
645
+ Returns:
646
+ Dictionary with optimal weights, expected return, volatility, Sharpe ratio.
647
+ """
648
+ from backend.mcp_servers.portfolio_optimizer_mcp import (
649
+ optimize_black_litterman,
650
+ OptimizationRequest,
651
+ MarketDataInput,
652
+ )
653
+
654
+ market_data_list = json.loads(market_data_json)
655
+ market_data = [
656
+ MarketDataInput(
657
+ ticker=item["ticker"],
658
+ prices=[Decimal(str(p)) for p in item["prices"]],
659
+ dates=item["dates"],
660
+ )
661
+ for item in market_data_list
662
+ ]
663
+
664
+ request = OptimizationRequest(
665
+ market_data=market_data, method="black_litterman", risk_tolerance=risk_tolerance
666
+ )
667
+ result = await optimize_black_litterman.fn(request)
668
+
669
+ data = result.model_dump() if hasattr(result, "model_dump") else result
670
+ return _convert_decimals_to_floats(data)
671
+
672
+
673
+ @cached_async(
674
+ namespace="portfolio_optimizer",
675
+ data_type=CacheDataType.PORTFOLIO_METRICS,
676
+ ttl=14400,
677
+ )
678
+ async def portfolio_optimize_mean_variance(
679
+ market_data_json: str, risk_tolerance: str = "moderate"
680
+ ) -> Dict[str, Any]:
681
+ """Optimise portfolio using Mean-Variance Optimisation (Markowitz).
682
+
683
+ Mean-Variance finds the portfolio with maximum Sharpe ratio or
684
+ minimum volatility for a given return target.
685
+
686
+ Args:
687
+ market_data_json: JSON array of market data objects with ticker, prices, dates
688
+ risk_tolerance: Risk level ('conservative', 'moderate', 'aggressive')
689
+
690
+ Returns:
691
+ Dictionary with optimal weights, expected return, volatility, Sharpe ratio.
692
+ """
693
+ from backend.mcp_servers.portfolio_optimizer_mcp import (
694
+ optimize_mean_variance,
695
+ OptimizationRequest,
696
+ MarketDataInput,
697
+ )
698
+
699
+ market_data_list = json.loads(market_data_json)
700
+ market_data = [
701
+ MarketDataInput(
702
+ ticker=item["ticker"],
703
+ prices=[Decimal(str(p)) for p in item["prices"]],
704
+ dates=item["dates"],
705
+ )
706
+ for item in market_data_list
707
+ ]
708
+
709
+ request = OptimizationRequest(
710
+ market_data=market_data, method="mean_variance", risk_tolerance=risk_tolerance
711
+ )
712
+ result = await optimize_mean_variance.fn(request)
713
+
714
+ data = result.model_dump() if hasattr(result, "model_dump") else result
715
+ return _convert_decimals_to_floats(data)
716
+
717
+
718
+ # =============================================================================
719
+ # RISK ANALYSIS TOOLS - 2 tools
720
+ # =============================================================================
721
+
722
+
723
+ @cached_async(
724
+ namespace="risk_analyzer",
725
+ data_type=CacheDataType.PORTFOLIO_METRICS,
726
+ ttl=14400,
727
+ )
728
+ async def risk_analyze(
729
+ portfolio_json: str,
730
+ portfolio_value: str,
731
+ confidence_level: str = "0.95",
732
+ time_horizon: str = "1",
733
+ method: str = "historical",
734
+ num_simulations: str = "10000",
735
+ benchmark_json: Optional[str] = None,
736
+ ) -> Dict[str, Any]:
737
+ """Perform comprehensive risk analysis on a portfolio.
738
+
739
+ Calculates VaR (95%, 99%), CVaR, Sharpe ratio, Sortino ratio,
740
+ maximum drawdown, Information Ratio, Calmar Ratio, and Ulcer Index.
741
+
742
+ Args:
743
+ portfolio_json: JSON array of portfolio holdings with ticker, weight, prices
744
+ e.g., '[{"ticker": "AAPL", "weight": 0.6, "prices": [150.0, ...]}]'
745
+ portfolio_value: Total portfolio value in dollars as string
746
+ confidence_level: VaR confidence level as string (default: '0.95')
747
+ time_horizon: VaR time horizon in days as string (default: '1')
748
+ method: VaR calculation method ('historical', 'parametric', 'monte_carlo')
749
+ num_simulations: Monte Carlo simulations as string if method='monte_carlo'
750
+ benchmark_json: Optional JSON with benchmark data for Information Ratio
751
+
752
+ Returns:
753
+ Dictionary with VaR, CVaR, risk metrics, and simulation percentiles.
754
+ """
755
+ from backend.mcp_servers.risk_analyzer_mcp import (
756
+ analyze_risk,
757
+ RiskAnalysisRequest,
758
+ PortfolioInput,
759
+ BenchmarkInput,
760
+ )
761
+
762
+ portfolio_list = json.loads(portfolio_json)
763
+ portfolio = [
764
+ PortfolioInput(
765
+ ticker=item["ticker"],
766
+ weight=Decimal(str(item["weight"])),
767
+ prices=[Decimal(str(p)) for p in item["prices"]],
768
+ )
769
+ for item in portfolio_list
770
+ ]
771
+
772
+ benchmark = None
773
+ if benchmark_json:
774
+ benchmark_data = json.loads(benchmark_json)
775
+ benchmark = BenchmarkInput(
776
+ ticker=benchmark_data["ticker"],
777
+ prices=[Decimal(str(p)) for p in benchmark_data["prices"]],
778
+ )
779
+
780
+ request = RiskAnalysisRequest(
781
+ portfolio=portfolio,
782
+ portfolio_value=Decimal(portfolio_value),
783
+ confidence_level=Decimal(confidence_level),
784
+ time_horizon=int(time_horizon),
785
+ method=method,
786
+ num_simulations=int(num_simulations),
787
+ benchmark=benchmark,
788
+ )
789
+ result = await analyze_risk.fn(request)
790
+
791
+ data = result.model_dump() if hasattr(result, "model_dump") else result
792
+ return _convert_decimals_to_floats(data)
793
+
794
+
795
+ @cached_async(
796
+ namespace="risk_analyzer",
797
+ data_type=CacheDataType.PORTFOLIO_METRICS,
798
+ ttl=14400,
799
+ )
800
+ async def risk_forecast_volatility_garch(
801
+ ticker: str,
802
+ returns_json: str,
803
+ forecast_horizon: str = "30",
804
+ garch_p: str = "1",
805
+ garch_q: str = "1",
806
+ ) -> Dict[str, Any]:
807
+ """Forecast volatility using GARCH model.
808
+
809
+ GARCH (Generalised Autoregressive Conditional Heteroskedasticity) models
810
+ are the industry standard for financial volatility forecasting.
811
+
812
+ Args:
813
+ ticker: Stock ticker symbol
814
+ returns_json: JSON array of historical returns (as percentages)
815
+ forecast_horizon: Days to forecast as string (default: '30')
816
+ garch_p: GARCH lag order as string (default: '1')
817
+ garch_q: ARCH lag order as string (default: '1')
818
+
819
+ Returns:
820
+ Dictionary with volatility forecasts, annualised volatility, and diagnostics.
821
+ """
822
+ from backend.mcp_servers.risk_analyzer_mcp import (
823
+ forecast_volatility_garch,
824
+ GARCHForecastRequest,
825
+ )
826
+
827
+ returns_list = json.loads(returns_json)
828
+
829
+ request = GARCHForecastRequest(
830
+ ticker=ticker,
831
+ returns=[Decimal(str(r)) for r in returns_list],
832
+ forecast_horizon=int(forecast_horizon),
833
+ garch_p=int(garch_p),
834
+ garch_q=int(garch_q),
835
+ )
836
+ result = await forecast_volatility_garch.fn(request)
837
+
838
+ data = result.model_dump() if hasattr(result, "model_dump") else result
839
+ return _convert_decimals_to_floats(data)
840
+
841
+
842
+ # =============================================================================
843
+ # MACHINE LEARNING TOOLS - 1 tool
844
+ # =============================================================================
845
+
846
+
847
+ async def ml_forecast_ensemble(
848
+ ticker: str,
849
+ prices_json: str,
850
+ dates_json: Optional[str] = None,
851
+ forecast_horizon: str = "30",
852
+ confidence_level: str = "0.95",
853
+ use_returns: str = "true",
854
+ ensemble_method: str = "mean",
855
+ ) -> Dict[str, Any]:
856
+ """Forecast stock prices using ensemble ML models.
857
+
858
+ Combines multiple forecasting models (Chronos-Bolt, TTM, N-HiTS)
859
+ to produce robust predictions with uncertainty quantification.
860
+
861
+ Args:
862
+ ticker: Stock ticker symbol (e.g., 'AAPL')
863
+ prices_json: JSON array of historical prices (minimum 10 values)
864
+ dates_json: Optional JSON array of corresponding dates
865
+ forecast_horizon: Number of days to forecast as string (default: '30')
866
+ confidence_level: Confidence level for intervals as string (default: '0.95')
867
+ use_returns: Forecast returns instead of raw prices ('true' or 'false')
868
+ ensemble_method: Combination method ('mean', 'median', 'weighted')
869
+
870
+ Returns:
871
+ Dictionary with forecasts, confidence intervals, and model metadata.
872
+ """
873
+ from backend.mcp_servers.ensemble_predictor_mcp import (
874
+ forecast_ensemble,
875
+ ForecastRequest,
876
+ )
877
+
878
+ prices_list = json.loads(prices_json)
879
+ dates_list = json.loads(dates_json) if dates_json else None
880
+
881
+ ensemble_method_literal = cast(
882
+ Literal["mean", "median", "weighted"], ensemble_method
883
+ )
884
+ request = ForecastRequest(
885
+ ticker=ticker,
886
+ prices=[Decimal(str(p)) for p in prices_list],
887
+ dates=dates_list,
888
+ forecast_horizon=int(forecast_horizon),
889
+ confidence_level=float(confidence_level),
890
+ use_returns=use_returns.lower() == "true",
891
+ ensemble_method=ensemble_method_literal,
892
+ )
893
+ result = await forecast_ensemble.fn(request)
894
+
895
+ data = result.model_dump() if hasattr(result, "model_dump") else result
896
+ return _convert_decimals_to_floats(data)
897
+
898
+
899
+ # =============================================================================
900
+ # SENTIMENT ANALYSIS TOOLS - 1 tool
901
+ # =============================================================================
902
+
903
+
904
+ @cached_async(
905
+ namespace="news_sentiment",
906
+ data_type=CacheDataType.USER_DATA,
907
+ ttl=7200,
908
+ )
909
+ async def sentiment_get_news(ticker: str, days_back: str = "7") -> Dict[str, Any]:
910
+ """Fetch recent news for a ticker and analyse sentiment.
911
+
912
+ Uses Finnhub API for news retrieval and VADER for sentiment analysis.
913
+
914
+ Args:
915
+ ticker: Stock ticker symbol (e.g., 'AAPL')
916
+ days_back: Number of days of historical news as string (default: '7')
917
+
918
+ Returns:
919
+ Dictionary with overall sentiment, confidence, article count, and articles.
920
+ """
921
+ from backend.mcp_servers.news_sentiment_mcp import get_news_with_sentiment
922
+
923
+ result = await get_news_with_sentiment.fn(ticker=ticker, days_back=int(days_back))
924
+
925
+ return result.model_dump() if hasattr(result, "model_dump") else result
926
+
927
+
928
+ # =============================================================================
929
+ # CONVENIENCE FUNCTIONS (for internal use by agents)
930
+ # =============================================================================
931
+
932
+
933
+ async def get_quote_list(tickers: List[str]) -> List[Dict[str, Any]]:
934
+ """Internal convenience function - accepts Python list instead of JSON string.
935
+
936
+ Args:
937
+ tickers: List of stock ticker symbols
938
+
939
+ Returns:
940
+ List of quote dictionaries
941
+ """
942
+ return await market_get_quote(json.dumps(tickers))
943
+
944
+
945
+ async def get_historical_prices(
946
+ ticker: str, period: str = "1y", interval: str = "1d"
947
+ ) -> Dict[str, Any]:
948
+ """Internal convenience function - alias for market_get_historical_data.
949
+
950
+ Args:
951
+ ticker: Stock ticker symbol
952
+ period: Time period
953
+ interval: Data interval
954
+
955
+ Returns:
956
+ Historical price data dictionary
957
+ """
958
+ return await market_get_historical_data(ticker, period, interval)
pyproject.toml CHANGED
@@ -5,19 +5,19 @@ description = "AI-powered portfolio analysis platform with MCP orchestration"
5
  readme = "README.md"
6
  requires-python = ">=3.12"
7
  dependencies = [
8
- # AI Framework
9
- "pydantic-ai==1.18.0",
10
  "anthropic>=0.39.0",
11
  "instructor>=1.6.4",
12
  # Agent Orchestration
13
  "langgraph>=0.1.0",
14
  "langchain-anthropic>=0.1.0",
15
  "langchain-core>=0.2.0",
16
- # MCP Framework
17
- "fastmcp>=2.12.5",
18
- "mcp>=1.15.0",
19
- # Frontend
20
- "gradio==5.49.1",
21
  # Backend
22
  "fastapi>=0.104.0",
23
  "uvicorn[standard]>=0.24.0",
@@ -66,7 +66,7 @@ dependencies = [
66
  # Text-to-Speech
67
  "elevenlabs>=1.0.0",
68
  # Monitoring & Observability
69
- "sentry-sdk[fastapi]>=2.0.0",
70
  "fmp-data>=1.0.2",
71
  ]
72
 
@@ -86,3 +86,4 @@ packages = ["backend"]
86
  [build-system]
87
  requires = ["hatchling"]
88
  build-backend = "hatchling.build"
 
 
5
  readme = "README.md"
6
  requires-python = ">=3.12"
7
  dependencies = [
8
+ # AI Framework - use slim without mcp extra to avoid conflict with gradio[mcp]
9
+ "pydantic-ai-slim[anthropic]==1.18.0",
10
  "anthropic>=0.39.0",
11
  "instructor>=1.6.4",
12
  # Agent Orchestration
13
  "langgraph>=0.1.0",
14
  "langchain-anthropic>=0.1.0",
15
  "langchain-core>=0.2.0",
16
+ # MCP Framework - fastmcp 2.9.1 is latest compatible with mcp==1.10.1 (gradio[mcp] requirement)
17
+ # Note: fastmcp 2.9.2+ requires mcp<1.10.0 which conflicts with gradio[mcp]
18
+ "fastmcp==2.9.1",
19
+ # Frontend - gradio with native MCP support
20
+ "gradio[mcp]==5.49.1",
21
  # Backend
22
  "fastapi>=0.104.0",
23
  "uvicorn[standard]>=0.24.0",
 
66
  # Text-to-Speech
67
  "elevenlabs>=1.0.0",
68
  # Monitoring & Observability
69
+ "sentry-sdk[fastapi]>=2.0.0,<3.0.0", # Pin to stable, avoid 3.0 alpha
70
  "fmp-data>=1.0.2",
71
  ]
72
 
 
86
  [build-system]
87
  requires = ["hatchling"]
88
  build-backend = "hatchling.build"
89
+
tests/test_mcp_tools.py ADDED
@@ -0,0 +1,216 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Unit tests for unified MCP tools module.
2
+
3
+ Tests the new namespaced tool functions in backend/mcp_tools.py.
4
+ """
5
+
6
+ import json
7
+ import pytest
8
+ import asyncio
9
+ from backend import mcp_tools
10
+
11
+
12
+ class TestMarketDataTools:
13
+ """Tests for market data tools (Yahoo Finance)."""
14
+
15
+ @pytest.mark.asyncio
16
+ async def test_market_get_quote(self):
17
+ """Test fetching real-time quote for single ticker."""
18
+ result = await mcp_tools.market_get_quote(json.dumps(["AAPL"]))
19
+
20
+ assert isinstance(result, list)
21
+ assert len(result) > 0
22
+ assert "ticker" in result[0] or "symbol" in result[0]
23
+
24
+ @pytest.mark.asyncio
25
+ async def test_market_get_quote_multiple_tickers(self):
26
+ """Test fetching quotes for multiple tickers."""
27
+ result = await mcp_tools.market_get_quote(json.dumps(["AAPL", "MSFT", "GOOGL"]))
28
+
29
+ assert isinstance(result, list)
30
+ assert len(result) == 3
31
+
32
+ @pytest.mark.asyncio
33
+ async def test_market_get_historical_data(self):
34
+ """Test fetching historical price data."""
35
+ result = await mcp_tools.market_get_historical_data(
36
+ ticker="AAPL", period="1mo", interval="1d"
37
+ )
38
+
39
+ assert isinstance(result, dict)
40
+ assert "close_prices" in result
41
+ assert "dates" in result
42
+ assert len(result["close_prices"]) > 0
43
+
44
+
45
+ class TestFundamentalsTools:
46
+ """Tests for fundamentals tools (FMP)."""
47
+
48
+ @pytest.mark.asyncio
49
+ async def test_market_get_company_profile(self):
50
+ """Test fetching company profile."""
51
+ result = await mcp_tools.market_get_company_profile(ticker="AAPL")
52
+
53
+ assert isinstance(result, dict)
54
+ assert result # Not empty
55
+
56
+
57
+ class TestTechnicalAnalysisTools:
58
+ """Tests for technical analysis tools."""
59
+
60
+ @pytest.mark.asyncio
61
+ async def test_technical_get_indicators(self):
62
+ """Test calculating technical indicators."""
63
+ result = await mcp_tools.technical_get_indicators(ticker="AAPL", period="3mo")
64
+
65
+ assert isinstance(result, dict)
66
+ expected_keys = {"rsi", "macd", "bollinger_bands", "moving_averages"}
67
+ assert any(key in result for key in expected_keys)
68
+
69
+
70
+ class TestEconomicDataTools:
71
+ """Tests for economic data tools (FRED)."""
72
+
73
+ @pytest.mark.asyncio
74
+ async def test_market_get_economic_series(self):
75
+ """Test fetching economic time series."""
76
+ result = await mcp_tools.market_get_economic_series(series_id="GDP")
77
+
78
+ assert isinstance(result, dict)
79
+ assert "observations" in result or "series_id" in result
80
+
81
+
82
+ class TestPortfolioOptimisationTools:
83
+ """Tests for portfolio optimisation tools."""
84
+
85
+ @pytest.fixture
86
+ def sample_market_data_json(self):
87
+ """Create sample market data JSON for testing."""
88
+ return json.dumps([
89
+ {
90
+ "ticker": "AAPL",
91
+ "prices": [150.0, 152.0, 151.0, 153.0, 154.0],
92
+ "dates": ["2024-01-01", "2024-01-02", "2024-01-03", "2024-01-04", "2024-01-05"]
93
+ },
94
+ {
95
+ "ticker": "MSFT",
96
+ "prices": [370.0, 372.0, 371.0, 373.0, 374.0],
97
+ "dates": ["2024-01-01", "2024-01-02", "2024-01-03", "2024-01-04", "2024-01-05"]
98
+ }
99
+ ])
100
+
101
+ @pytest.mark.asyncio
102
+ async def test_portfolio_optimize_hrp(self, sample_market_data_json):
103
+ """Test Hierarchical Risk Parity optimisation."""
104
+ result = await mcp_tools.portfolio_optimize_hrp(
105
+ market_data_json=sample_market_data_json,
106
+ risk_tolerance="moderate"
107
+ )
108
+
109
+ assert isinstance(result, dict)
110
+ assert "weights" in result
111
+ assert isinstance(result["weights"], dict)
112
+
113
+ weights_sum = sum(float(w) for w in result["weights"].values())
114
+ assert 0.99 <= weights_sum <= 1.01
115
+
116
+ @pytest.mark.asyncio
117
+ async def test_portfolio_optimize_black_litterman(self, sample_market_data_json):
118
+ """Test Black-Litterman optimisation."""
119
+ result = await mcp_tools.portfolio_optimize_black_litterman(
120
+ market_data_json=sample_market_data_json,
121
+ risk_tolerance="moderate"
122
+ )
123
+
124
+ assert isinstance(result, dict)
125
+ assert "weights" in result
126
+
127
+ @pytest.mark.asyncio
128
+ async def test_portfolio_optimize_mean_variance(self, sample_market_data_json):
129
+ """Test Mean-Variance (Markowitz) optimisation."""
130
+ result = await mcp_tools.portfolio_optimize_mean_variance(
131
+ market_data_json=sample_market_data_json,
132
+ risk_tolerance="moderate"
133
+ )
134
+
135
+ assert isinstance(result, dict)
136
+ assert "weights" in result
137
+
138
+
139
+ class TestRiskAnalysisTools:
140
+ """Tests for risk analysis tools."""
141
+
142
+ @pytest.fixture
143
+ def sample_portfolio_json(self):
144
+ """Create sample portfolio JSON for testing."""
145
+ return json.dumps([
146
+ {
147
+ "ticker": "AAPL",
148
+ "weight": 0.6,
149
+ "prices": [150.0, 152.0, 151.0, 153.0, 154.0, 155.0, 153.0, 156.0]
150
+ },
151
+ {
152
+ "ticker": "MSFT",
153
+ "weight": 0.4,
154
+ "prices": [370.0, 372.0, 371.0, 373.0, 374.0, 375.0, 373.0, 376.0]
155
+ }
156
+ ])
157
+
158
+ @pytest.mark.asyncio
159
+ async def test_risk_analyze(self, sample_portfolio_json):
160
+ """Test comprehensive risk analysis."""
161
+ result = await mcp_tools.risk_analyze(
162
+ portfolio_json=sample_portfolio_json,
163
+ portfolio_value="100000",
164
+ confidence_level="0.95",
165
+ method="monte_carlo",
166
+ num_simulations="1000"
167
+ )
168
+
169
+ assert isinstance(result, dict)
170
+ assert "var_95" in result or "var_99" in result
171
+ assert "cvar_95" in result or "cvar_99" in result
172
+ assert "risk_metrics" in result
173
+
174
+
175
+ class TestCacheBehaviour:
176
+ """Tests for cache behaviour."""
177
+
178
+ @pytest.mark.asyncio
179
+ async def test_cache_hit(self):
180
+ """Test that caching works correctly."""
181
+ result1 = await mcp_tools.market_get_quote(json.dumps(["AAPL"]))
182
+ result2 = await mcp_tools.market_get_quote(json.dumps(["AAPL"]))
183
+
184
+ assert result1 == result2
185
+
186
+
187
+ class TestConvenienceFunctions:
188
+ """Tests for internal convenience functions."""
189
+
190
+ @pytest.mark.asyncio
191
+ async def test_get_quote_list(self):
192
+ """Test convenience function with Python list."""
193
+ result = await mcp_tools.get_quote_list(["AAPL"])
194
+
195
+ assert isinstance(result, list)
196
+ assert len(result) > 0
197
+
198
+ @pytest.mark.asyncio
199
+ async def test_get_historical_prices(self):
200
+ """Test convenience alias function."""
201
+ result = await mcp_tools.get_historical_prices("AAPL", "1mo", "1d")
202
+
203
+ assert isinstance(result, dict)
204
+ assert "close_prices" in result
205
+
206
+
207
+ @pytest.fixture(scope="session")
208
+ def event_loop():
209
+ """Create event loop for async tests."""
210
+ loop = asyncio.get_event_loop_policy().new_event_loop()
211
+ yield loop
212
+ loop.close()
213
+
214
+
215
+ if __name__ == "__main__":
216
+ pytest.main([__file__, "-v", "-s"])
uv.lock CHANGED
The diff for this file is too large to render. See raw diff