Spaces:
Running
on
Zero
Running
on
Zero
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 +427 -13
- backend/agents/workflow.py +1 -0
- backend/caching/redis_cache.py +4 -4
- backend/mcp_router.py +42 -0
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 |
-
#
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 |
-
|
| 308 |
-
|
| 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 |
-
{
|
| 316 |
|
| 317 |
-
MARKET DATA:
|
| 318 |
-
{
|
| 319 |
|
| 320 |
FUNDAMENTALS:
|
| 321 |
-
{
|
| 322 |
|
| 323 |
TECHNICAL INDICATORS:
|
| 324 |
-
{
|
| 325 |
|
| 326 |
ECONOMIC CONTEXT:
|
| 327 |
{economic_summary}
|
|
@@ -330,7 +738,13 @@ OPTIMIZATION ANALYSIS:
|
|
| 330 |
{optimisation_xml}
|
| 331 |
|
| 332 |
RISK ANALYSIS:
|
| 333 |
-
{risk_xml}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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:
|
| 95 |
CacheDataType.PORTFOLIO_METRICS: 1800, # 30 minutes
|
| 96 |
-
CacheDataType.ANALYSIS_RESULTS:
|
| 97 |
-
CacheDataType.HISTORICAL_DATA:
|
| 98 |
-
CacheDataType.MCP_RESPONSE:
|
| 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 |
|