BrianIsaac commited on
Commit
1c78117
·
1 Parent(s): 60dd254

perf: optimise data formatting and implement comprehensive caching

Browse files

- Add token-efficient formatters for portfolio, market, fundamentals, technical indicators, ML forecasts, and sentiment data
- Implement cached_async decorators across all MCP router methods with tailored TTL values
- Adjust cache TTL strategy: reduce market data to 1min, extend analysis results to 4hrs
- Integrate sentiment data into portfolio analysis workflow

backend/agents/portfolio_analyst.py CHANGED
@@ -161,6 +161,411 @@ def format_optimisation_results_xml(opt_results: Dict[str, Any]) -> str:
161
  return "\n".join(lines)
162
 
163
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
164
  class PortfolioAnalysisOutput(BaseModel):
165
  """Structured output from portfolio analysis."""
166
 
@@ -281,6 +686,7 @@ class PortfolioAnalystAgent(BasePortfolioAgent[PortfolioAnalysisOutput]):
281
  optimization_results: Dict[str, Any],
282
  risk_analysis: Dict[str, Any],
283
  ensemble_forecasts: Optional[Dict[str, Any]] = None,
 
284
  risk_tolerance: str = "moderate",
285
  ) -> "AgentResult[PortfolioAnalysisOutput]":
286
  """Analyze a complete portfolio with all available data.
@@ -294,34 +700,36 @@ class PortfolioAnalystAgent(BasePortfolioAgent[PortfolioAnalysisOutput]):
294
  optimization_results: Portfolio optimization outputs
295
  risk_analysis: VaR, CVaR, and risk metrics
296
  ensemble_forecasts: ML-based price forecasts (Chronos + statistical models)
 
297
  risk_tolerance: Investor's risk tolerance
298
 
299
  Returns:
300
  Structured portfolio analysis
301
  """
302
- # Construct comprehensive analysis prompt
 
 
 
 
303
  economic_summary = format_economic_summary(economic_data)
304
- risk_xml = format_risk_analysis_xml(risk_analysis)
305
  optimisation_xml = format_optimisation_results_xml(optimization_results)
306
-
307
- # Format ensemble forecasts if available
308
- forecasts_section = ""
309
- if ensemble_forecasts:
310
- forecasts_section = f"\n\nML FORECASTS (30-day predictions):\n{ensemble_forecasts}"
311
 
312
  prompt = f"""Analyze this investment portfolio:
313
 
314
  PORTFOLIO:
315
- {portfolio_data}
316
 
317
- MARKET DATA:
318
- {market_data}
319
 
320
  FUNDAMENTALS:
321
- {fundamentals}
322
 
323
  TECHNICAL INDICATORS:
324
- {technical_indicators}
325
 
326
  ECONOMIC CONTEXT:
327
  {economic_summary}
@@ -330,7 +738,13 @@ OPTIMIZATION ANALYSIS:
330
  {optimisation_xml}
331
 
332
  RISK ANALYSIS:
333
- {risk_xml}{forecasts_section}
 
 
 
 
 
 
334
 
335
  INVESTOR RISK TOLERANCE: {risk_tolerance}
336
 
 
161
  return "\n".join(lines)
162
 
163
 
164
+ def format_fundamentals(fundamentals: Dict[str, Any]) -> str:
165
+ """Format company fundamentals as compact table without lengthy descriptions.
166
+
167
+ Args:
168
+ fundamentals: Dictionary of company profiles keyed by ticker
169
+
170
+ Returns:
171
+ Tab-separated table format for token efficiency
172
+ """
173
+ if not fundamentals:
174
+ return "No fundamental data available"
175
+
176
+ lines = ["Ticker\tSector\tIndustry\tMkt Cap\tP/E\tEmployees\tCountry"]
177
+ for ticker, profile in fundamentals.items():
178
+ # Handle both dict and object attribute access
179
+ if isinstance(profile, dict):
180
+ sector = profile.get('sector', 'N/A')
181
+ industry = profile.get('industry', 'N/A')
182
+ market_cap = profile.get('market_cap', 'N/A')
183
+ pe_ratio = profile.get('pe_ratio', 'N/A')
184
+ employees = profile.get('employees', 'N/A')
185
+ country = profile.get('country', 'N/A')
186
+ else:
187
+ sector = getattr(profile, 'sector', 'N/A')
188
+ industry = getattr(profile, 'industry', 'N/A')
189
+ market_cap = getattr(profile, 'market_cap', 'N/A')
190
+ pe_ratio = getattr(profile, 'pe_ratio', 'N/A')
191
+ employees = getattr(profile, 'employees', 'N/A')
192
+ country = getattr(profile, 'country', 'N/A')
193
+
194
+ # Format market cap and P/E for display
195
+ if market_cap != 'N/A':
196
+ market_cap = safe_float(market_cap, 0)
197
+ if market_cap > 1e9:
198
+ market_cap = f"${market_cap/1e9:.1f}B"
199
+ elif market_cap > 1e6:
200
+ market_cap = f"${market_cap/1e6:.1f}M"
201
+ else:
202
+ market_cap = f"${market_cap:.0f}"
203
+
204
+ lines.append(
205
+ f"{ticker}\t"
206
+ f"{sector}\t"
207
+ f"{industry}\t"
208
+ f"{market_cap}\t"
209
+ f"{safe_float(pe_ratio, 'N/A')}\t"
210
+ f"{employees}\t"
211
+ f"{country}"
212
+ )
213
+ return "\n".join(lines)
214
+
215
+
216
+ def format_market_data(market_data: Dict[str, Any]) -> str:
217
+ """Format real-time market data as compact table.
218
+
219
+ Args:
220
+ market_data: Dictionary of quote data keyed by ticker
221
+
222
+ Returns:
223
+ Tab-separated table with price, change, and key metrics
224
+ """
225
+ if not market_data:
226
+ return "No market data available"
227
+
228
+ lines = ["Ticker\tPrice\tChange%\tVolume\tP/E\tDiv Yield\tMkt Cap"]
229
+ for ticker, quote in market_data.items():
230
+ # Handle both dict and object attribute access
231
+ if isinstance(quote, dict):
232
+ price = quote.get('price', 0)
233
+ prev_close = quote.get('previous_close', price)
234
+ volume = quote.get('volume', 'N/A')
235
+ pe_ratio = quote.get('pe_ratio', 'N/A')
236
+ dividend_yield = quote.get('dividend_yield', 'N/A')
237
+ market_cap = quote.get('market_cap', 'N/A')
238
+ else:
239
+ price = getattr(quote, 'price', 0)
240
+ prev_close = getattr(quote, 'previous_close', price)
241
+ volume = getattr(quote, 'volume', 'N/A')
242
+ pe_ratio = getattr(quote, 'pe_ratio', 'N/A')
243
+ dividend_yield = getattr(quote, 'dividend_yield', 'N/A')
244
+ market_cap = getattr(quote, 'market_cap', 'N/A')
245
+
246
+ # Calculate change percentage
247
+ price_float = safe_float(price, 0)
248
+ prev_close_float = safe_float(prev_close, price_float)
249
+
250
+ if prev_close_float > 0:
251
+ change_pct = ((price_float - prev_close_float) / prev_close_float * 100)
252
+ else:
253
+ change_pct = 0
254
+
255
+ # Format market cap
256
+ if market_cap != 'N/A':
257
+ market_cap_float = safe_float(market_cap, 0)
258
+ if market_cap_float > 1e9:
259
+ market_cap = f"${market_cap_float/1e9:.1f}B"
260
+ elif market_cap_float > 1e6:
261
+ market_cap = f"${market_cap_float/1e6:.1f}M"
262
+ else:
263
+ market_cap = f"${market_cap_float:.0f}"
264
+
265
+ # Format dividend yield
266
+ if dividend_yield != 'N/A':
267
+ dividend_yield = f"{safe_float(dividend_yield, 0):.2f}%"
268
+
269
+ lines.append(
270
+ f"{ticker}\t"
271
+ f"${price_float:.2f}\t"
272
+ f"{change_pct:+.2f}%\t"
273
+ f"{volume}\t"
274
+ f"{safe_float(pe_ratio, 'N/A')}\t"
275
+ f"{dividend_yield}\t"
276
+ f"{market_cap}"
277
+ )
278
+ return "\n".join(lines)
279
+
280
+
281
+ def format_ml_forecasts(forecasts: Dict[str, Any]) -> str:
282
+ """Format ML ensemble forecasts as compact summary table.
283
+
284
+ Args:
285
+ forecasts: Dictionary of forecast results keyed by ticker
286
+
287
+ Returns:
288
+ Tab-separated table with 30-day predictions and confidence
289
+ """
290
+ if not forecasts:
291
+ return "No forecasts available"
292
+
293
+ lines = ["Ticker\t30d Return\tConfidence\tTrend\tModels"]
294
+ for ticker, forecast in forecasts.items():
295
+ # Handle both dict and object attribute access
296
+ if isinstance(forecast, dict):
297
+ predictions = forecast.get('predictions', [])
298
+ lower = forecast.get('lower_bound', [])
299
+ upper = forecast.get('upper_bound', [])
300
+ models = forecast.get('models_used', [])
301
+ else:
302
+ predictions = getattr(forecast, 'predictions', [])
303
+ lower = getattr(forecast, 'lower_bound', [])
304
+ upper = getattr(forecast, 'upper_bound', [])
305
+ models = getattr(forecast, 'models_used', [])
306
+
307
+ if not predictions or len(predictions) == 0:
308
+ continue
309
+
310
+ # Calculate return from first to last prediction
311
+ start_price = safe_float(predictions[0], 0)
312
+ end_price = safe_float(predictions[-1], start_price)
313
+
314
+ if start_price > 0:
315
+ return_pct = ((end_price - start_price) / start_price * 100)
316
+
317
+ # Calculate confidence range width
318
+ if lower and upper and len(lower) > 0 and len(upper) > 0:
319
+ lower_val = safe_float(lower[-1], 0)
320
+ upper_val = safe_float(upper[-1], 0)
321
+ range_width = ((upper_val - lower_val) / start_price * 100)
322
+ else:
323
+ range_width = 0
324
+
325
+ # Determine trend signal
326
+ if return_pct > 2:
327
+ trend = "Bullish"
328
+ elif return_pct < -2:
329
+ trend = "Bearish"
330
+ else:
331
+ trend = "Neutral"
332
+
333
+ # Format models list
334
+ models_str = f"{len(models)} models" if models else "N/A"
335
+
336
+ lines.append(
337
+ f"{ticker}\t"
338
+ f"{return_pct:+.1f}%\t"
339
+ f"±{abs(range_width):.1f}%\t"
340
+ f"{trend}\t"
341
+ f"{models_str}"
342
+ )
343
+
344
+ return "\n".join(lines)
345
+
346
+
347
+ def format_sentiment_data(sentiment_data: Dict[str, Any]) -> str:
348
+ """Format news sentiment analysis as compact summary table.
349
+
350
+ Args:
351
+ sentiment_data: Dictionary of sentiment results keyed by ticker
352
+
353
+ Returns:
354
+ Tab-separated table with sentiment scores and top headlines
355
+ """
356
+ if not sentiment_data:
357
+ return "No sentiment data available"
358
+
359
+ lines = ["Ticker\tSentiment\tConfidence\tArticles\tTop Headlines"]
360
+ for ticker, data in sentiment_data.items():
361
+ # Handle both dict and object attribute access
362
+ if isinstance(data, dict):
363
+ sentiment = data.get('overall_sentiment', 0.0)
364
+ confidence = data.get('confidence', 0.0)
365
+ article_count = data.get('article_count', 0)
366
+ articles = data.get('articles', [])
367
+ error = data.get('error')
368
+ else:
369
+ sentiment = getattr(data, 'overall_sentiment', 0.0)
370
+ confidence = getattr(data, 'confidence', 0.0)
371
+ article_count = getattr(data, 'article_count', 0)
372
+ articles = getattr(data, 'articles', [])
373
+ error = getattr(data, 'error', None)
374
+
375
+ # Skip if there was an error
376
+ if error:
377
+ lines.append(f"{ticker}\tError\t-\t-\t{error[:50]}")
378
+ continue
379
+
380
+ # Determine sentiment label
381
+ sentiment_float = safe_float(sentiment, 0.0)
382
+ if sentiment_float >= 0.05:
383
+ label = f"Positive ({sentiment_float:+.2f})"
384
+ elif sentiment_float <= -0.05:
385
+ label = f"Negative ({sentiment_float:+.2f})"
386
+ else:
387
+ label = f"Neutral ({sentiment_float:+.2f})"
388
+
389
+ # Get top 2 headlines (truncated)
390
+ headlines = []
391
+ for article in articles[:2]:
392
+ if isinstance(article, dict):
393
+ headline = article.get('headline', '')
394
+ else:
395
+ headline = getattr(article, 'headline', '')
396
+
397
+ if headline:
398
+ # Truncate long headlines
399
+ headlines.append(headline[:50] + '...' if len(headline) > 50 else headline)
400
+
401
+ headlines_str = " | ".join(headlines) if headlines else "No recent news"
402
+
403
+ lines.append(
404
+ f"{ticker}\t"
405
+ f"{label}\t"
406
+ f"{safe_float(confidence, 0):.0%}\t"
407
+ f"{article_count}\t"
408
+ f"{headlines_str}"
409
+ )
410
+
411
+ return "\n".join(lines)
412
+
413
+
414
+ def format_technical_indicators(technical_indicators: Dict[str, Any]) -> str:
415
+ """Format technical indicators as compact hybrid table.
416
+
417
+ Combines tabular layout for key metrics with inline summaries.
418
+ Following Anthropic best practices: flat structure with semantic signals.
419
+
420
+ Args:
421
+ technical_indicators: Dict of TechnicalIndicators keyed by ticker
422
+
423
+ Returns:
424
+ Tab-separated table with signals and key metrics (~60-80 tokens per ticker)
425
+ """
426
+ if not technical_indicators:
427
+ return "No technical indicators available"
428
+
429
+ lines = ["Ticker\tSignal\tRSI\tMACD\tBB Pos\tMA Trend\tVolume"]
430
+
431
+ for ticker, indicators in technical_indicators.items():
432
+ # Handle both dict and object attribute access
433
+ if isinstance(indicators, dict):
434
+ overall_signal = indicators.get('overall_signal', 'hold')
435
+ rsi = indicators.get('rsi', {})
436
+ macd = indicators.get('macd', {})
437
+ bb = indicators.get('bollinger_bands', {})
438
+ ma = indicators.get('moving_averages', {})
439
+ volume_trend = indicators.get('volume_trend', 'N/A')
440
+ else:
441
+ overall_signal = getattr(indicators, 'overall_signal', 'hold')
442
+ rsi = getattr(indicators, 'rsi', None)
443
+ macd = getattr(indicators, 'macd', None)
444
+ bb = getattr(indicators, 'bollinger_bands', None)
445
+ ma = getattr(indicators, 'moving_averages', None)
446
+ volume_trend = getattr(indicators, 'volume_trend', 'N/A')
447
+
448
+ # Extract RSI value and signal
449
+ if isinstance(rsi, dict):
450
+ rsi_value = safe_float(rsi.get('value'), 0)
451
+ rsi_signal = rsi.get('signal', 'N/A')
452
+ elif rsi:
453
+ rsi_value = safe_float(getattr(rsi, 'value', 0), 0)
454
+ rsi_signal = getattr(rsi, 'signal', 'N/A')
455
+ else:
456
+ rsi_value = 0
457
+ rsi_signal = 'N/A'
458
+
459
+ # MACD trend
460
+ if isinstance(macd, dict):
461
+ macd_trend = macd.get('trend', 'N/A')
462
+ macd_hist = safe_float(macd.get('histogram'), 0)
463
+ elif macd:
464
+ macd_trend = getattr(macd, 'trend', 'N/A')
465
+ macd_hist = safe_float(getattr(macd, 'histogram', 0), 0)
466
+ else:
467
+ macd_trend = 'N/A'
468
+ macd_hist = 0
469
+
470
+ # Bollinger Band position
471
+ if isinstance(bb, dict):
472
+ bb_position = bb.get('position', 'N/A')
473
+ elif bb:
474
+ bb_position = getattr(bb, 'position', 'N/A')
475
+ else:
476
+ bb_position = 'N/A'
477
+
478
+ # Moving average trend
479
+ if isinstance(ma, dict):
480
+ ma_trend = ma.get('trend', 'N/A')
481
+ elif ma:
482
+ ma_trend = getattr(ma, 'trend', 'N/A')
483
+ else:
484
+ ma_trend = 'N/A'
485
+
486
+ # Format compact RSI with signal indicator
487
+ rsi_compact = f"{rsi_value:.0f}"
488
+ if rsi_signal == 'overbought':
489
+ rsi_compact += "↑"
490
+ elif rsi_signal == 'oversold':
491
+ rsi_compact += "↓"
492
+
493
+ # Format MACD with trend indicator
494
+ macd_compact = f"{macd_hist:+.1f}"
495
+ if macd_trend == 'bullish':
496
+ macd_compact += "↑"
497
+ elif macd_trend == 'bearish':
498
+ macd_compact += "↓"
499
+
500
+ lines.append(
501
+ f"{ticker}\t"
502
+ f"{overall_signal.upper()}\t"
503
+ f"{rsi_compact}\t"
504
+ f"{macd_compact}\t"
505
+ f"{bb_position}\t"
506
+ f"{ma_trend}\t"
507
+ f"{volume_trend}"
508
+ )
509
+
510
+ return "\n".join(lines)
511
+
512
+
513
+ def format_portfolio_data(portfolio_data: Dict[str, Any]) -> str:
514
+ """Format portfolio holdings as compact table.
515
+
516
+ Extracts only essential information and formats numerics for readability.
517
+ Omits redundant zero-value fields and internal IDs.
518
+
519
+ Args:
520
+ portfolio_data: Dict with holdings, portfolio_id, risk_tolerance
521
+
522
+ Returns:
523
+ Formatted portfolio summary with holdings table
524
+ """
525
+ if not portfolio_data:
526
+ return "No portfolio data available"
527
+
528
+ holdings = portfolio_data.get('holdings', [])
529
+ risk_tolerance = portfolio_data.get('risk_tolerance', 'moderate')
530
+
531
+ if not holdings:
532
+ return f"Empty portfolio (Risk Tolerance: {risk_tolerance})"
533
+
534
+ # Calculate total portfolio value
535
+ total_value = sum(h.get('market_value', 0) for h in holdings)
536
+
537
+ lines = [
538
+ f"Portfolio Summary (Risk Tolerance: {risk_tolerance}, Total Value: ${total_value:,.2f})",
539
+ "",
540
+ "Ticker\tShares\tPrice\tValue\tWeight\tCost Basis"
541
+ ]
542
+
543
+ for holding in holdings:
544
+ ticker = holding.get('ticker', 'N/A')
545
+ quantity = safe_float(holding.get('quantity'), 0)
546
+ current_price = safe_float(holding.get('current_price'), 0)
547
+ market_value = safe_float(holding.get('market_value'), 0)
548
+ weight = safe_float(holding.get('weight'), 0)
549
+ cost_basis = safe_float(holding.get('cost_basis'), 0)
550
+
551
+ # Format shares (hide if dollar-based entry)
552
+ shares_str = f"{quantity:.2f}" if quantity > 0 else "-"
553
+
554
+ # Format cost basis (hide if not set)
555
+ cost_basis_str = f"${cost_basis:.2f}" if cost_basis > 0 else "-"
556
+
557
+ lines.append(
558
+ f"{ticker}\t"
559
+ f"{shares_str}\t"
560
+ f"${current_price:.2f}\t"
561
+ f"${market_value:,.2f}\t"
562
+ f"{weight*100:.1f}%\t"
563
+ f"{cost_basis_str}"
564
+ )
565
+
566
+ return "\n".join(lines)
567
+
568
+
569
  class PortfolioAnalysisOutput(BaseModel):
570
  """Structured output from portfolio analysis."""
571
 
 
686
  optimization_results: Dict[str, Any],
687
  risk_analysis: Dict[str, Any],
688
  ensemble_forecasts: Optional[Dict[str, Any]] = None,
689
+ sentiment_data: Optional[Dict[str, Any]] = None,
690
  risk_tolerance: str = "moderate",
691
  ) -> "AgentResult[PortfolioAnalysisOutput]":
692
  """Analyze a complete portfolio with all available data.
 
700
  optimization_results: Portfolio optimization outputs
701
  risk_analysis: VaR, CVaR, and risk metrics
702
  ensemble_forecasts: ML-based price forecasts (Chronos + statistical models)
703
+ sentiment_data: News sentiment analysis for each ticker
704
  risk_tolerance: Investor's risk tolerance
705
 
706
  Returns:
707
  Structured portfolio analysis
708
  """
709
+ # Format all data sections for token efficiency
710
+ portfolio_table = format_portfolio_data(portfolio_data)
711
+ market_table = format_market_data(market_data)
712
+ fundamentals_table = format_fundamentals(fundamentals)
713
+ technical_table = format_technical_indicators(technical_indicators)
714
  economic_summary = format_economic_summary(economic_data)
 
715
  optimisation_xml = format_optimisation_results_xml(optimization_results)
716
+ risk_xml = format_risk_analysis_xml(risk_analysis)
717
+ forecasts_table = format_ml_forecasts(ensemble_forecasts) if ensemble_forecasts else "No forecasts available"
718
+ sentiment_table = format_sentiment_data(sentiment_data) if sentiment_data else "No sentiment data available"
 
 
719
 
720
  prompt = f"""Analyze this investment portfolio:
721
 
722
  PORTFOLIO:
723
+ {portfolio_table}
724
 
725
+ MARKET DATA (Real-time Quotes):
726
+ {market_table}
727
 
728
  FUNDAMENTALS:
729
+ {fundamentals_table}
730
 
731
  TECHNICAL INDICATORS:
732
+ {technical_table}
733
 
734
  ECONOMIC CONTEXT:
735
  {economic_summary}
 
738
  {optimisation_xml}
739
 
740
  RISK ANALYSIS:
741
+ {risk_xml}
742
+
743
+ ML FORECASTS (30-day predictions):
744
+ {forecasts_table}
745
+
746
+ NEWS SENTIMENT (7-day analysis):
747
+ {sentiment_table}
748
 
749
  INVESTOR RISK TOLERANCE: {risk_tolerance}
750
 
backend/agents/workflow.py CHANGED
@@ -506,6 +506,7 @@ class PortfolioAnalysisWorkflow:
506
  optimization_results=state.get("optimisation_results", {}),
507
  risk_analysis=state.get("risk_analysis", {}),
508
  ensemble_forecasts=state.get("ensemble_forecasts", {}),
 
509
  risk_tolerance=state["risk_tolerance"],
510
  )
511
 
 
506
  optimization_results=state.get("optimisation_results", {}),
507
  risk_analysis=state.get("risk_analysis", {}),
508
  ensemble_forecasts=state.get("ensemble_forecasts", {}),
509
+ sentiment_data=state.get("sentiment_data", {}),
510
  risk_tolerance=state["risk_tolerance"],
511
  )
512
 
backend/caching/redis_cache.py CHANGED
@@ -91,11 +91,11 @@ class TTLStrategy:
91
 
92
  # Base TTL values in seconds
93
  TTL_CONFIG: Dict[CacheDataType, int] = {
94
- CacheDataType.MARKET_DATA: 300, # 5 minutes
95
  CacheDataType.PORTFOLIO_METRICS: 1800, # 30 minutes
96
- CacheDataType.ANALYSIS_RESULTS: 3600, # 1 hour
97
- CacheDataType.HISTORICAL_DATA: 86400, # 24 hours
98
- CacheDataType.MCP_RESPONSE: 600, # 10 minutes
99
  CacheDataType.USER_DATA: 7200, # 2 hours
100
  }
101
 
 
91
 
92
  # Base TTL values in seconds
93
  TTL_CONFIG: Dict[CacheDataType, int] = {
94
+ CacheDataType.MARKET_DATA: 60, # 1 minute (reduced for real-time data)
95
  CacheDataType.PORTFOLIO_METRICS: 1800, # 30 minutes
96
+ CacheDataType.ANALYSIS_RESULTS: 14400, # 4 hours (extended from 1hr for cost savings)
97
+ CacheDataType.HISTORICAL_DATA: 43200, # 12 hours (extended for older data)
98
+ CacheDataType.MCP_RESPONSE: 300, # 5 minutes (extended from 10min)
99
  CacheDataType.USER_DATA: 7200, # 2 hours
100
  }
101
 
backend/mcp_router.py CHANGED
@@ -16,6 +16,10 @@ from backend.mcp_servers import yahoo_finance_mcp, fmp_mcp, trading_mcp, fred_mc
16
  from backend.mcp_servers import portfolio_optimizer_mcp, risk_analyzer_mcp, ensemble_predictor_mcp
17
  from backend.mcp_servers import news_sentiment_mcp
18
 
 
 
 
 
19
  logger = logging.getLogger(__name__)
20
 
21
 
@@ -51,6 +55,10 @@ class MCPRouter:
51
  logger.info(f"Initialised {len(self.servers)} MCP servers")
52
 
53
  # Yahoo Finance MCP methods
 
 
 
 
54
  async def call_yahoo_finance_mcp(self, tool: str, params: Dict[str, Any]) -> Dict[str, Any]:
55
  """Call Yahoo Finance MCP tool.
56
 
@@ -89,6 +97,11 @@ class MCPRouter:
89
  return result
90
 
91
  # FMP MCP methods
 
 
 
 
 
92
  async def call_fmp_mcp(self, tool: str, params: Dict[str, Any]) -> Dict[str, Any]:
93
  """Call Financial Modeling Prep MCP tool.
94
 
@@ -126,6 +139,10 @@ class MCPRouter:
126
  return result
127
 
128
  # Trading MCP methods
 
 
 
 
129
  async def call_trading_mcp(self, tool: str, params: Dict[str, Any]) -> Dict[str, Any]:
130
  """Call Trading MCP tool.
131
 
@@ -150,6 +167,11 @@ class MCPRouter:
150
  return result
151
 
152
  # FRED MCP methods
 
 
 
 
 
153
  async def call_fred_mcp(self, tool: str, params: Dict[str, Any]) -> Dict[str, Any]:
154
  """Call FRED MCP tool.
155
 
@@ -174,6 +196,11 @@ class MCPRouter:
174
  return result
175
 
176
  # Portfolio Optimizer MCP methods
 
 
 
 
 
177
  async def call_portfolio_optimizer_mcp(self, tool: str, params: Dict[str, Any]) -> Dict[str, Any]:
178
  """Call Portfolio Optimizer MCP tool.
179
 
@@ -209,6 +236,11 @@ class MCPRouter:
209
  return result
210
 
211
  # Risk Analyzer MCP methods
 
 
 
 
 
212
  async def call_risk_analyzer_mcp(self, tool: str, params: Dict[str, Any]) -> Dict[str, Any]:
213
  """Call Risk Analyzer MCP tool.
214
 
@@ -233,6 +265,11 @@ class MCPRouter:
233
  return result
234
 
235
  # Ensemble Predictor MCP methods
 
 
 
 
 
236
  async def call_ensemble_predictor_mcp(self, tool: str, params: Dict[str, Any]) -> Dict[str, Any]:
237
  """Call Ensemble Predictor MCP tool.
238
 
@@ -256,6 +293,11 @@ class MCPRouter:
256
  return result.model_dump()
257
  return result
258
 
 
 
 
 
 
259
  async def call_news_sentiment_mcp(self, tool: str, params: Dict[str, Any]) -> Dict[str, Any]:
260
  """Call News Sentiment MCP tool.
261
 
 
16
  from backend.mcp_servers import portfolio_optimizer_mcp, risk_analyzer_mcp, ensemble_predictor_mcp
17
  from backend.mcp_servers import news_sentiment_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
 
 
55
  logger.info(f"Initialised {len(self.servers)} MCP servers")
56
 
57
  # Yahoo Finance MCP methods
58
+ @cached_async(
59
+ namespace="yahoo_finance",
60
+ data_type=CacheDataType.MARKET_DATA, # 60s TTL for real-time quotes
61
+ )
62
  async def call_yahoo_finance_mcp(self, tool: str, params: Dict[str, Any]) -> Dict[str, Any]:
63
  """Call Yahoo Finance MCP tool.
64
 
 
97
  return result
98
 
99
  # FMP MCP methods
100
+ @cached_async(
101
+ namespace="fmp",
102
+ data_type=CacheDataType.HISTORICAL_DATA, # 12 hours TTL
103
+ ttl=21600, # Override to 6 hours for company fundamentals
104
+ )
105
  async def call_fmp_mcp(self, tool: str, params: Dict[str, Any]) -> Dict[str, Any]:
106
  """Call Financial Modeling Prep MCP tool.
107
 
 
139
  return result
140
 
141
  # Trading MCP methods
142
+ @cached_async(
143
+ namespace="trading",
144
+ data_type=CacheDataType.HISTORICAL_DATA, # 12 hours TTL for technical indicators
145
+ )
146
  async def call_trading_mcp(self, tool: str, params: Dict[str, Any]) -> Dict[str, Any]:
147
  """Call Trading MCP tool.
148
 
 
167
  return result
168
 
169
  # FRED MCP methods
170
+ @cached_async(
171
+ namespace="fred",
172
+ data_type=CacheDataType.HISTORICAL_DATA, # 12 hours default
173
+ ttl=86400, # Override to 24 hours for economic data (changes infrequently)
174
+ )
175
  async def call_fred_mcp(self, tool: str, params: Dict[str, Any]) -> Dict[str, Any]:
176
  """Call FRED MCP tool.
177
 
 
196
  return result
197
 
198
  # Portfolio Optimizer MCP methods
199
+ @cached_async(
200
+ namespace="portfolio_optimizer",
201
+ data_type=CacheDataType.PORTFOLIO_METRICS, # 30 min default
202
+ ttl=14400, # Override to 4 hours for optimization results (computational, deterministic)
203
+ )
204
  async def call_portfolio_optimizer_mcp(self, tool: str, params: Dict[str, Any]) -> Dict[str, Any]:
205
  """Call Portfolio Optimizer MCP tool.
206
 
 
236
  return result
237
 
238
  # Risk Analyzer MCP methods
239
+ @cached_async(
240
+ namespace="risk_analyzer",
241
+ data_type=CacheDataType.PORTFOLIO_METRICS, # 30 min default
242
+ ttl=14400, # Override to 4 hours for risk analysis (computational, deterministic)
243
+ )
244
  async def call_risk_analyzer_mcp(self, tool: str, params: Dict[str, Any]) -> Dict[str, Any]:
245
  """Call Risk Analyzer MCP tool.
246
 
 
265
  return result
266
 
267
  # Ensemble Predictor MCP methods
268
+ @cached_async(
269
+ namespace="ensemble_predictor",
270
+ data_type=CacheDataType.HISTORICAL_DATA, # 12 hours default
271
+ ttl=21600, # Override to 6 hours for ML forecasts (expensive computation)
272
+ )
273
  async def call_ensemble_predictor_mcp(self, tool: str, params: Dict[str, Any]) -> Dict[str, Any]:
274
  """Call Ensemble Predictor MCP tool.
275
 
 
293
  return result.model_dump()
294
  return result
295
 
296
+ @cached_async(
297
+ namespace="news_sentiment",
298
+ data_type=CacheDataType.USER_DATA, # 2 hours default
299
+ ttl=7200, # 2 hours for news sentiment (balance freshness vs API costs)
300
+ )
301
  async def call_news_sentiment_mcp(self, tool: str, params: Dict[str, Any]) -> Dict[str, Any]:
302
  """Call News Sentiment MCP tool.
303