Spaces:
Running
on
Zero
feat: implement 7 production enhancements for portfolio analysis platform
Browse files- Add prompt caching with AnthropicModelSettings for 90% cost reduction
- Enforce rate limiting in analysis handlers (10/hour free tier)
- Implement News Sentiment MCP as 8th server with Finnhub API and VADER
- Add historical analysis storage with database persistence and history UI
- Create tax calculator UI with filing status, income, and cost basis inputs
- Add retry logic with exponential backoff to all 18 MCP tool functions
- Optimise accordion rendering with progressive disclosure
Technical changes:
- New MCP server: news_sentiment_mcp.py with sentiment analysis integration
- Database: sentiment_data field added to schema, auto-save after workflow
- UI: History page, Tax Impact Analysis section, View History navigation
- Config: Add finnhub_api_key to Settings model
- Dependencies: Add vaderSentiment>=3.3.2 for sentiment analysis
- Retry: tenacity decorators on all MCP servers (3 attempts, 2-10s backoff)
All 8 MCP servers now have resilient retry logic for production readiness.
- app.py +196 -3
- backend/agents/base_agent.py +9 -1
- backend/agents/workflow.py +25 -0
- backend/config.py +4 -0
- backend/database.py +1 -0
- backend/mcp_router.py +27 -0
- backend/mcp_servers/ensemble_predictor_mcp.py +11 -0
- backend/mcp_servers/fmp_mcp.py +36 -0
- backend/mcp_servers/fred_mcp.py +11 -0
- backend/mcp_servers/news_sentiment_mcp.py +230 -0
- backend/mcp_servers/portfolio_optimizer_mcp.py +21 -0
- backend/mcp_servers/risk_analyzer_mcp.py +16 -0
- backend/mcp_servers/trading_mcp.py +11 -0
- backend/mcp_servers/yahoo_finance_mcp.py +21 -0
- backend/models/agent_state.py +1 -0
- database/schema.sql +2 -0
- pyproject.toml +2 -0
- uv.lock +14 -0
|
@@ -31,6 +31,7 @@ initialise_sentry()
|
|
| 31 |
from backend.mcp_router import mcp_router
|
| 32 |
from backend.agents.workflow import PortfolioAnalysisWorkflow
|
| 33 |
from backend.models.agent_state import AgentState
|
|
|
|
| 34 |
from backend.theme import get_financial_theme, FINANCIAL_CSS
|
| 35 |
from backend.visualizations import (
|
| 36 |
create_portfolio_allocation_chart,
|
|
@@ -48,6 +49,7 @@ from backend.stress_testing import (
|
|
| 48 |
create_stress_test_dashboard,
|
| 49 |
)
|
| 50 |
from backend.agents.personas import get_available_personas
|
|
|
|
| 51 |
from backend.config import settings
|
| 52 |
from backend.rate_limiting import (
|
| 53 |
TieredRateLimiter,
|
|
@@ -774,6 +776,14 @@ async def run_analysis_with_ui_update(
|
|
| 774 |
final_state = await analysis_workflow.run(initial_state)
|
| 775 |
LAST_ANALYSIS_STATE = final_state
|
| 776 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 777 |
progress(0.7, desc=random.choice(LOADING_MESSAGES))
|
| 778 |
await asyncio.sleep(0.3)
|
| 779 |
|
|
@@ -1668,6 +1678,7 @@ def create_interface() -> gr.Blocks:
|
|
| 1668 |
|
| 1669 |
with gr.Column(scale=1):
|
| 1670 |
new_analysis_btn = gr.Button("New Analysis", variant="secondary")
|
|
|
|
| 1671 |
|
| 1672 |
gr.Markdown("---")
|
| 1673 |
gr.Markdown("### Interactive Dashboard")
|
|
@@ -1690,6 +1701,48 @@ def create_interface() -> gr.Blocks:
|
|
| 1690 |
with gr.Column(scale=1, min_width=400):
|
| 1691 |
optimization_plot = gr.Plot(label="Optimisation Methods Comparison", container=True)
|
| 1692 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1693 |
# Stress Testing Section
|
| 1694 |
gr.Markdown("---")
|
| 1695 |
gr.Markdown("### Portfolio Stress Testing")
|
|
@@ -1754,14 +1807,111 @@ def create_interface() -> gr.Blocks:
|
|
| 1754 |
with gr.Tab("Drawdown Analysis"):
|
| 1755 |
stress_drawdown_plot = gr.Plot(label="Maximum Drawdown Distribution")
|
| 1756 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1757 |
# Event handlers
|
| 1758 |
def show_input_page():
|
| 1759 |
return {
|
| 1760 |
input_page: gr.update(visible=True),
|
| 1761 |
-
results_page: gr.update(visible=False)
|
|
|
|
| 1762 |
}
|
| 1763 |
|
| 1764 |
-
async def
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1765 |
# Check authentication
|
| 1766 |
if not check_authentication(session_state):
|
| 1767 |
yield {
|
|
@@ -1779,6 +1929,27 @@ def create_interface() -> gr.Blocks:
|
|
| 1779 |
}
|
| 1780 |
return
|
| 1781 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1782 |
# Show loading page immediately
|
| 1783 |
yield {
|
| 1784 |
input_page: gr.update(visible=False),
|
|
@@ -1864,7 +2035,29 @@ def create_interface() -> gr.Blocks:
|
|
| 1864 |
|
| 1865 |
new_analysis_btn.click(
|
| 1866 |
show_input_page,
|
| 1867 |
-
outputs=[input_page, results_page]
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1868 |
)
|
| 1869 |
|
| 1870 |
# Wire up stress test button
|
|
|
|
| 31 |
from backend.mcp_router import mcp_router
|
| 32 |
from backend.agents.workflow import PortfolioAnalysisWorkflow
|
| 33 |
from backend.models.agent_state import AgentState
|
| 34 |
+
from backend.database import db
|
| 35 |
from backend.theme import get_financial_theme, FINANCIAL_CSS
|
| 36 |
from backend.visualizations import (
|
| 37 |
create_portfolio_allocation_chart,
|
|
|
|
| 49 |
create_stress_test_dashboard,
|
| 50 |
)
|
| 51 |
from backend.agents.personas import get_available_personas
|
| 52 |
+
from backend.tax.interface import create_tax_analysis, format_tax_analysis_output
|
| 53 |
from backend.config import settings
|
| 54 |
from backend.rate_limiting import (
|
| 55 |
TieredRateLimiter,
|
|
|
|
| 776 |
final_state = await analysis_workflow.run(initial_state)
|
| 777 |
LAST_ANALYSIS_STATE = final_state
|
| 778 |
|
| 779 |
+
# Save analysis to database (Enhancement #4 - Historical Analysis Storage)
|
| 780 |
+
try:
|
| 781 |
+
session = UserSession.from_dict(session_state)
|
| 782 |
+
await db.save_analysis(session.user_id, final_state)
|
| 783 |
+
logger.info(f"Saved analysis for user {session.user_id}")
|
| 784 |
+
except Exception as e:
|
| 785 |
+
logger.warning(f"Failed to save analysis: {e}")
|
| 786 |
+
|
| 787 |
progress(0.7, desc=random.choice(LOADING_MESSAGES))
|
| 788 |
await asyncio.sleep(0.3)
|
| 789 |
|
|
|
|
| 1678 |
|
| 1679 |
with gr.Column(scale=1):
|
| 1680 |
new_analysis_btn = gr.Button("New Analysis", variant="secondary")
|
| 1681 |
+
view_history_btn_results = gr.Button("View History", variant="secondary")
|
| 1682 |
|
| 1683 |
gr.Markdown("---")
|
| 1684 |
gr.Markdown("### Interactive Dashboard")
|
|
|
|
| 1701 |
with gr.Column(scale=1, min_width=400):
|
| 1702 |
optimization_plot = gr.Plot(label="Optimisation Methods Comparison", container=True)
|
| 1703 |
|
| 1704 |
+
# Tax Impact Analysis Section (Enhancement #5)
|
| 1705 |
+
gr.Markdown("---")
|
| 1706 |
+
gr.Markdown("### Tax Impact Analysis")
|
| 1707 |
+
gr.Markdown("Analyse tax implications and identify tax-loss harvesting opportunities.")
|
| 1708 |
+
|
| 1709 |
+
with gr.Row():
|
| 1710 |
+
with gr.Column(scale=1):
|
| 1711 |
+
tax_filing_status = gr.Dropdown(
|
| 1712 |
+
choices=[
|
| 1713 |
+
("Single", "single"),
|
| 1714 |
+
("Married Filing Jointly", "married_joint"),
|
| 1715 |
+
("Married Filing Separately", "married_separate"),
|
| 1716 |
+
("Head of Household", "head_of_household"),
|
| 1717 |
+
],
|
| 1718 |
+
value="single",
|
| 1719 |
+
label="Filing Status",
|
| 1720 |
+
info="Your tax filing status"
|
| 1721 |
+
)
|
| 1722 |
+
tax_annual_income = gr.Slider(
|
| 1723 |
+
minimum=0,
|
| 1724 |
+
maximum=1000000,
|
| 1725 |
+
value=75000,
|
| 1726 |
+
step=5000,
|
| 1727 |
+
label="Annual Income ($)",
|
| 1728 |
+
info="Total taxable income"
|
| 1729 |
+
)
|
| 1730 |
+
tax_cost_basis_method = gr.Dropdown(
|
| 1731 |
+
choices=[
|
| 1732 |
+
("First In, First Out (FIFO)", "fifo"),
|
| 1733 |
+
("Last In, First Out (LIFO)", "lifo"),
|
| 1734 |
+
("Highest In, First Out (HIFO)", "hifo"),
|
| 1735 |
+
("Average Cost", "average"),
|
| 1736 |
+
],
|
| 1737 |
+
value="fifo",
|
| 1738 |
+
label="Cost Basis Method",
|
| 1739 |
+
info="Method for calculating gains/losses"
|
| 1740 |
+
)
|
| 1741 |
+
tax_calculate_btn = gr.Button("Calculate Tax Impact", variant="primary")
|
| 1742 |
+
|
| 1743 |
+
with gr.Column(scale=2):
|
| 1744 |
+
tax_analysis_output = gr.Markdown("")
|
| 1745 |
+
|
| 1746 |
# Stress Testing Section
|
| 1747 |
gr.Markdown("---")
|
| 1748 |
gr.Markdown("### Portfolio Stress Testing")
|
|
|
|
| 1807 |
with gr.Tab("Drawdown Analysis"):
|
| 1808 |
stress_drawdown_plot = gr.Plot(label="Maximum Drawdown Distribution")
|
| 1809 |
|
| 1810 |
+
# History Page (Enhancement #4 - Historical Analysis Storage)
|
| 1811 |
+
with gr.Group(visible=False) as history_page:
|
| 1812 |
+
gr.Markdown("### Analysis History")
|
| 1813 |
+
gr.Markdown("View your previous portfolio analyses")
|
| 1814 |
+
|
| 1815 |
+
with gr.Row():
|
| 1816 |
+
with gr.Column(scale=4):
|
| 1817 |
+
pass
|
| 1818 |
+
with gr.Column(scale=1):
|
| 1819 |
+
back_to_input_btn = gr.Button("New Analysis", variant="secondary")
|
| 1820 |
+
|
| 1821 |
+
history_table = gr.Dataframe(
|
| 1822 |
+
headers=["Date", "Holdings", "Risk Tolerance", "AI Synthesis Preview"],
|
| 1823 |
+
datatype=["str", "str", "str", "str"],
|
| 1824 |
+
interactive=False,
|
| 1825 |
+
wrap=True,
|
| 1826 |
+
elem_id="history-table"
|
| 1827 |
+
)
|
| 1828 |
+
|
| 1829 |
+
with gr.Accordion("Selected Analysis Details", open=False) as history_details:
|
| 1830 |
+
history_details_output = gr.Markdown("")
|
| 1831 |
+
|
| 1832 |
+
view_history_btn = gr.Button("View Analysis History", variant="secondary", visible=False)
|
| 1833 |
+
|
| 1834 |
# Event handlers
|
| 1835 |
def show_input_page():
|
| 1836 |
return {
|
| 1837 |
input_page: gr.update(visible=True),
|
| 1838 |
+
results_page: gr.update(visible=False),
|
| 1839 |
+
history_page: gr.update(visible=False)
|
| 1840 |
}
|
| 1841 |
|
| 1842 |
+
async def load_history(session_state):
|
| 1843 |
+
"""Load analysis history from database."""
|
| 1844 |
+
try:
|
| 1845 |
+
session = UserSession.from_dict(session_state)
|
| 1846 |
+
history = await db.get_analysis_history(session.user_id, limit=20)
|
| 1847 |
+
|
| 1848 |
+
if not history:
|
| 1849 |
+
return [], "No previous analyses found"
|
| 1850 |
+
|
| 1851 |
+
# Format history for dataframe
|
| 1852 |
+
rows = []
|
| 1853 |
+
for record in history:
|
| 1854 |
+
# Format holdings as comma-separated tickers
|
| 1855 |
+
holdings_str = ", ".join([h.get("ticker", "?") for h in record.get("holdings", [])])
|
| 1856 |
+
|
| 1857 |
+
# Truncate AI synthesis for preview
|
| 1858 |
+
synthesis_preview = record.get("ai_synthesis", "")[:100] + "..."
|
| 1859 |
+
|
| 1860 |
+
rows.append([
|
| 1861 |
+
record.get("created_at", "").split("T")[0], # Date only
|
| 1862 |
+
holdings_str,
|
| 1863 |
+
record.get("risk_tolerance", "moderate"),
|
| 1864 |
+
synthesis_preview
|
| 1865 |
+
])
|
| 1866 |
+
|
| 1867 |
+
return rows, f"Loaded {len(history)} previous analyses"
|
| 1868 |
+
except Exception as e:
|
| 1869 |
+
logger.error(f"Failed to load history: {e}")
|
| 1870 |
+
return [], f"Error loading history: {str(e)}"
|
| 1871 |
+
|
| 1872 |
+
def sync_load_history(session_state):
|
| 1873 |
+
"""Synchronous wrapper for load_history."""
|
| 1874 |
+
return asyncio.run(load_history(session_state))
|
| 1875 |
+
|
| 1876 |
+
def show_history_page():
|
| 1877 |
+
"""Navigate to history page."""
|
| 1878 |
+
return {
|
| 1879 |
+
input_page: gr.update(visible=False),
|
| 1880 |
+
results_page: gr.update(visible=False),
|
| 1881 |
+
history_page: gr.update(visible=True)
|
| 1882 |
+
}
|
| 1883 |
+
|
| 1884 |
+
def calculate_tax_impact(filing_status: str, annual_income: float, cost_basis_method: str):
|
| 1885 |
+
"""Calculate tax impact for current portfolio holdings."""
|
| 1886 |
+
global LAST_ANALYSIS_STATE
|
| 1887 |
+
|
| 1888 |
+
if not LAST_ANALYSIS_STATE or "holdings" not in LAST_ANALYSIS_STATE:
|
| 1889 |
+
return """# Tax Impact Analysis
|
| 1890 |
+
|
| 1891 |
+
**Error**: Please run a portfolio analysis first before calculating tax impact.
|
| 1892 |
+
|
| 1893 |
+
Use the "Analyse Portfolio" button to analyse your portfolio, then return here to view tax implications.
|
| 1894 |
+
"""
|
| 1895 |
+
|
| 1896 |
+
try:
|
| 1897 |
+
holdings = LAST_ANALYSIS_STATE.get("holdings", [])
|
| 1898 |
+
report = create_tax_analysis(
|
| 1899 |
+
holdings=holdings,
|
| 1900 |
+
filing_status=filing_status,
|
| 1901 |
+
annual_income=annual_income,
|
| 1902 |
+
cost_basis_method=cost_basis_method,
|
| 1903 |
+
)
|
| 1904 |
+
return format_tax_analysis_output(report)
|
| 1905 |
+
except Exception as e:
|
| 1906 |
+
logger.error(f"Tax calculation error: {e}", exc_info=True)
|
| 1907 |
+
return f"""# Tax Impact Analysis
|
| 1908 |
+
|
| 1909 |
+
**Error**: {str(e)}
|
| 1910 |
+
|
| 1911 |
+
Please try again with different parameters.
|
| 1912 |
+
"""
|
| 1913 |
+
|
| 1914 |
+
async def handle_analysis(session_state, portfolio_text, roast_mode, persona, request: gr.Request, progress=gr.Progress()):
|
| 1915 |
# Check authentication
|
| 1916 |
if not check_authentication(session_state):
|
| 1917 |
yield {
|
|
|
|
| 1929 |
}
|
| 1930 |
return
|
| 1931 |
|
| 1932 |
+
# Enforce rate limiting
|
| 1933 |
+
if rate_limit_middleware:
|
| 1934 |
+
try:
|
| 1935 |
+
rate_limit_middleware.enforce(request)
|
| 1936 |
+
except Exception as e:
|
| 1937 |
+
logger.warning(f"Rate limit exceeded: {e}")
|
| 1938 |
+
yield {
|
| 1939 |
+
input_page: gr.update(visible=False),
|
| 1940 |
+
loading_page: gr.update(visible=False),
|
| 1941 |
+
results_page: gr.update(visible=False),
|
| 1942 |
+
loading_message: f"❌ {str(e)}",
|
| 1943 |
+
analysis_output: "",
|
| 1944 |
+
performance_metrics_output: "",
|
| 1945 |
+
allocation_plot: None,
|
| 1946 |
+
risk_plot: None,
|
| 1947 |
+
performance_plot: None,
|
| 1948 |
+
correlation_plot: None,
|
| 1949 |
+
optimization_plot: None
|
| 1950 |
+
}
|
| 1951 |
+
return
|
| 1952 |
+
|
| 1953 |
# Show loading page immediately
|
| 1954 |
yield {
|
| 1955 |
input_page: gr.update(visible=False),
|
|
|
|
| 2035 |
|
| 2036 |
new_analysis_btn.click(
|
| 2037 |
show_input_page,
|
| 2038 |
+
outputs=[input_page, results_page, history_page]
|
| 2039 |
+
)
|
| 2040 |
+
|
| 2041 |
+
# History page navigation
|
| 2042 |
+
view_history_btn_results.click(
|
| 2043 |
+
show_history_page,
|
| 2044 |
+
outputs=[input_page, results_page, history_page]
|
| 2045 |
+
).then(
|
| 2046 |
+
sync_load_history,
|
| 2047 |
+
inputs=[session_state],
|
| 2048 |
+
outputs=[history_table, history_details_output]
|
| 2049 |
+
)
|
| 2050 |
+
|
| 2051 |
+
back_to_input_btn.click(
|
| 2052 |
+
show_input_page,
|
| 2053 |
+
outputs=[input_page, results_page, history_page]
|
| 2054 |
+
)
|
| 2055 |
+
|
| 2056 |
+
# Tax calculator button (Enhancement #5)
|
| 2057 |
+
tax_calculate_btn.click(
|
| 2058 |
+
calculate_tax_impact,
|
| 2059 |
+
inputs=[tax_filing_status, tax_annual_income, tax_cost_basis_method],
|
| 2060 |
+
outputs=[tax_analysis_output]
|
| 2061 |
)
|
| 2062 |
|
| 2063 |
# Wire up stress test button
|
|
@@ -4,6 +4,7 @@ from typing import TypeVar, Generic, Dict, Any, NamedTuple
|
|
| 4 |
import logging
|
| 5 |
|
| 6 |
from pydantic_ai import Agent
|
|
|
|
| 7 |
from pydantic import BaseModel
|
| 8 |
|
| 9 |
from backend.config import settings
|
|
@@ -56,11 +57,18 @@ class BasePortfolioAgent(Generic[T]):
|
|
| 56 |
self.output_type = output_type
|
| 57 |
self.system_prompt = system_prompt
|
| 58 |
|
| 59 |
-
#
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 60 |
self.agent = Agent(
|
| 61 |
self.model,
|
| 62 |
output_type=output_type,
|
| 63 |
system_prompt=system_prompt,
|
|
|
|
| 64 |
retries=3,
|
| 65 |
output_retries=5,
|
| 66 |
)
|
|
|
|
| 4 |
import logging
|
| 5 |
|
| 6 |
from pydantic_ai import Agent
|
| 7 |
+
from pydantic_ai.models.anthropic import AnthropicModelSettings
|
| 8 |
from pydantic import BaseModel
|
| 9 |
|
| 10 |
from backend.config import settings
|
|
|
|
| 57 |
self.output_type = output_type
|
| 58 |
self.system_prompt = system_prompt
|
| 59 |
|
| 60 |
+
# Configure native prompt caching (90% cost reduction)
|
| 61 |
+
model_settings = AnthropicModelSettings(
|
| 62 |
+
anthropic_cache_instructions=True,
|
| 63 |
+
anthropic_cache_tool_definitions=True,
|
| 64 |
+
)
|
| 65 |
+
|
| 66 |
+
# Initialise Pydantic AI agent with native prompt caching
|
| 67 |
self.agent = Agent(
|
| 68 |
self.model,
|
| 69 |
output_type=output_type,
|
| 70 |
system_prompt=system_prompt,
|
| 71 |
+
model_settings=model_settings,
|
| 72 |
retries=3,
|
| 73 |
output_retries=5,
|
| 74 |
)
|
|
@@ -169,6 +169,29 @@ class PortfolioAnalysisWorkflow:
|
|
| 169 |
econ = await self.mcp_router.call_fred_mcp("get_economic_series", {"series_id": series_id})
|
| 170 |
economic_data[series_id] = summarize_fred_data(econ, series_id)
|
| 171 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 172 |
# Enrich holdings with market values based on realtime data
|
| 173 |
enriched_holdings = []
|
| 174 |
for holding in state["holdings"]:
|
|
@@ -221,6 +244,7 @@ class PortfolioAnalysisWorkflow:
|
|
| 221 |
state["realtime_data"] = market_data
|
| 222 |
state["technical_indicators"] = technical_indicators
|
| 223 |
state["economic_data"] = economic_data
|
|
|
|
| 224 |
state["current_step"] = "phase_1_complete"
|
| 225 |
|
| 226 |
# Log MCP calls
|
|
@@ -230,6 +254,7 @@ class PortfolioAnalysisWorkflow:
|
|
| 230 |
MCPCall.model_validate({"mcp": "fmp", "tool": "get_company_profile"}).model_dump(),
|
| 231 |
MCPCall.model_validate({"mcp": "trading_mcp", "tool": "get_technical_indicators"}).model_dump(),
|
| 232 |
MCPCall.model_validate({"mcp": "fred", "tool": "get_economic_series"}).model_dump(),
|
|
|
|
| 233 |
])
|
| 234 |
|
| 235 |
# Track phase duration
|
|
|
|
| 169 |
econ = await self.mcp_router.call_fred_mcp("get_economic_series", {"series_id": series_id})
|
| 170 |
economic_data[series_id] = summarize_fred_data(econ, series_id)
|
| 171 |
|
| 172 |
+
# Fetch news sentiment (Enhancement #3 - News Sentiment MCP)
|
| 173 |
+
logger.debug("Fetching news sentiment for all holdings")
|
| 174 |
+
sentiment_data = {}
|
| 175 |
+
for ticker in tickers:
|
| 176 |
+
try:
|
| 177 |
+
sentiment = await self.mcp_router.call_news_sentiment_mcp(
|
| 178 |
+
"get_news_with_sentiment",
|
| 179 |
+
{"ticker": ticker, "days_back": 7}
|
| 180 |
+
)
|
| 181 |
+
sentiment_data[ticker] = sentiment
|
| 182 |
+
logger.debug(f"{ticker} sentiment: {sentiment.get('overall_sentiment', 0):.2f}")
|
| 183 |
+
except Exception as e:
|
| 184 |
+
logger.warning(f"Failed to fetch sentiment for {ticker}: {e}")
|
| 185 |
+
# Continue with empty sentiment on error
|
| 186 |
+
sentiment_data[ticker] = {
|
| 187 |
+
"ticker": ticker,
|
| 188 |
+
"overall_sentiment": 0.0,
|
| 189 |
+
"confidence": 0.0,
|
| 190 |
+
"article_count": 0,
|
| 191 |
+
"articles": [],
|
| 192 |
+
"error": str(e)
|
| 193 |
+
}
|
| 194 |
+
|
| 195 |
# Enrich holdings with market values based on realtime data
|
| 196 |
enriched_holdings = []
|
| 197 |
for holding in state["holdings"]:
|
|
|
|
| 244 |
state["realtime_data"] = market_data
|
| 245 |
state["technical_indicators"] = technical_indicators
|
| 246 |
state["economic_data"] = economic_data
|
| 247 |
+
state["sentiment_data"] = sentiment_data # Enhancement #3
|
| 248 |
state["current_step"] = "phase_1_complete"
|
| 249 |
|
| 250 |
# Log MCP calls
|
|
|
|
| 254 |
MCPCall.model_validate({"mcp": "fmp", "tool": "get_company_profile"}).model_dump(),
|
| 255 |
MCPCall.model_validate({"mcp": "trading_mcp", "tool": "get_technical_indicators"}).model_dump(),
|
| 256 |
MCPCall.model_validate({"mcp": "fred", "tool": "get_economic_series"}).model_dump(),
|
| 257 |
+
MCPCall.model_validate({"mcp": "news_sentiment", "tool": "get_news_with_sentiment"}).model_dump(),
|
| 258 |
])
|
| 259 |
|
| 260 |
# Track phase duration
|
|
@@ -56,6 +56,10 @@ class Settings(BaseSettings):
|
|
| 56 |
default=None,
|
| 57 |
validation_alias="FRED_API_KEY"
|
| 58 |
)
|
|
|
|
|
|
|
|
|
|
|
|
|
| 59 |
alpaca_api_key: Optional[str] = Field(
|
| 60 |
default=None,
|
| 61 |
validation_alias="ALPACA_API_KEY"
|
|
|
|
| 56 |
default=None,
|
| 57 |
validation_alias="FRED_API_KEY"
|
| 58 |
)
|
| 59 |
+
finnhub_api_key: Optional[str] = Field(
|
| 60 |
+
default=None,
|
| 61 |
+
validation_alias="FINNHUB_API_KEY"
|
| 62 |
+
)
|
| 63 |
alpaca_api_key: Optional[str] = Field(
|
| 64 |
default=None,
|
| 65 |
validation_alias="ALPACA_API_KEY"
|
|
@@ -73,6 +73,7 @@ class Database:
|
|
| 73 |
'recommendations': analysis_results.get('recommendations', []),
|
| 74 |
'reasoning_steps': analysis_results.get('reasoning_steps', []),
|
| 75 |
'mcp_calls': analysis_results.get('mcp_calls', []),
|
|
|
|
| 76 |
'execution_time_ms': analysis_results.get('execution_time_ms'),
|
| 77 |
'model_version': analysis_results.get('model_version', 'claude-sonnet-4-5'),
|
| 78 |
}
|
|
|
|
| 73 |
'recommendations': analysis_results.get('recommendations', []),
|
| 74 |
'reasoning_steps': analysis_results.get('reasoning_steps', []),
|
| 75 |
'mcp_calls': analysis_results.get('mcp_calls', []),
|
| 76 |
+
'sentiment_data': analysis_results.get('sentiment_data', {}), # Enhancement #3
|
| 77 |
'execution_time_ms': analysis_results.get('execution_time_ms'),
|
| 78 |
'model_version': analysis_results.get('model_version', 'claude-sonnet-4-5'),
|
| 79 |
}
|
|
@@ -14,6 +14,7 @@ sys.path.insert(0, os.path.dirname(os.path.dirname(__file__)))
|
|
| 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 |
|
| 18 |
logger = logging.getLogger(__name__)
|
| 19 |
|
|
@@ -44,6 +45,7 @@ class MCPRouter:
|
|
| 44 |
"portfolio_optimizer": portfolio_optimizer_mcp,
|
| 45 |
"risk_analyzer": risk_analyzer_mcp,
|
| 46 |
"ensemble_predictor": ensemble_predictor_mcp,
|
|
|
|
| 47 |
}
|
| 48 |
|
| 49 |
logger.info(f"Initialised {len(self.servers)} MCP servers")
|
|
@@ -254,6 +256,31 @@ class MCPRouter:
|
|
| 254 |
return result.model_dump()
|
| 255 |
return result
|
| 256 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 257 |
# High-level helper methods
|
| 258 |
async def fetch_market_data(self, tickers: List[str]) -> Dict[str, Any]:
|
| 259 |
"""Fetch market data for given tickers.
|
|
|
|
| 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
|
| 18 |
|
| 19 |
logger = logging.getLogger(__name__)
|
| 20 |
|
|
|
|
| 45 |
"portfolio_optimizer": portfolio_optimizer_mcp,
|
| 46 |
"risk_analyzer": risk_analyzer_mcp,
|
| 47 |
"ensemble_predictor": ensemble_predictor_mcp,
|
| 48 |
+
"news_sentiment": news_sentiment_mcp, # 8th MCP - Enhancement #3
|
| 49 |
}
|
| 50 |
|
| 51 |
logger.info(f"Initialised {len(self.servers)} MCP servers")
|
|
|
|
| 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 |
+
|
| 262 |
+
Args:
|
| 263 |
+
tool: Tool name (get_news_with_sentiment)
|
| 264 |
+
params: Tool parameters (ticker, days_back)
|
| 265 |
+
|
| 266 |
+
Returns:
|
| 267 |
+
TickerNewsWithSentiment with articles and sentiment scores
|
| 268 |
+
"""
|
| 269 |
+
logger.debug(f"Calling News Sentiment MCP: {tool}")
|
| 270 |
+
|
| 271 |
+
if tool == "get_news_with_sentiment":
|
| 272 |
+
from backend.mcp_servers.news_sentiment_mcp import get_news_with_sentiment
|
| 273 |
+
result = await get_news_with_sentiment.fn(
|
| 274 |
+
ticker=params.get("ticker"),
|
| 275 |
+
days_back=params.get("days_back", 7)
|
| 276 |
+
)
|
| 277 |
+
else:
|
| 278 |
+
raise ValueError(f"Unknown News Sentiment tool: {tool}")
|
| 279 |
+
|
| 280 |
+
if hasattr(result, 'model_dump'):
|
| 281 |
+
return result.model_dump()
|
| 282 |
+
return result
|
| 283 |
+
|
| 284 |
# High-level helper methods
|
| 285 |
async def fetch_market_data(self, tickers: List[str]) -> Dict[str, Any]:
|
| 286 |
"""Fetch market data for given tickers.
|
|
@@ -16,6 +16,12 @@ import numpy as np
|
|
| 16 |
import pandas as pd
|
| 17 |
from fastmcp import FastMCP
|
| 18 |
from pydantic import BaseModel, Field
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 19 |
|
| 20 |
logger = logging.getLogger(__name__)
|
| 21 |
|
|
@@ -315,6 +321,11 @@ def _combine_forecasts(
|
|
| 315 |
raise ValueError(f"Unknown combination method: {method}")
|
| 316 |
|
| 317 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 318 |
@mcp.tool()
|
| 319 |
async def forecast_ensemble(request: ForecastRequest) -> ForecastResult:
|
| 320 |
"""Generate ensemble forecast for time series.
|
|
|
|
| 16 |
import pandas as pd
|
| 17 |
from fastmcp import FastMCP
|
| 18 |
from pydantic import BaseModel, Field
|
| 19 |
+
from tenacity import (
|
| 20 |
+
retry,
|
| 21 |
+
stop_after_attempt,
|
| 22 |
+
wait_exponential,
|
| 23 |
+
retry_if_exception_type,
|
| 24 |
+
)
|
| 25 |
|
| 26 |
logger = logging.getLogger(__name__)
|
| 27 |
|
|
|
|
| 321 |
raise ValueError(f"Unknown combination method: {method}")
|
| 322 |
|
| 323 |
|
| 324 |
+
@retry(
|
| 325 |
+
stop=stop_after_attempt(3),
|
| 326 |
+
wait=wait_exponential(multiplier=1, min=2, max=10),
|
| 327 |
+
retry=retry_if_exception_type((TimeoutError, ConnectionError, Exception)),
|
| 328 |
+
)
|
| 329 |
@mcp.tool()
|
| 330 |
async def forecast_ensemble(request: ForecastRequest) -> ForecastResult:
|
| 331 |
"""Generate ensemble forecast for time series.
|
|
@@ -15,6 +15,12 @@ import httpx
|
|
| 15 |
import yfinance as yf
|
| 16 |
from fastmcp import FastMCP
|
| 17 |
from pydantic import BaseModel, Field
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 18 |
|
| 19 |
from backend.config import settings
|
| 20 |
|
|
@@ -187,6 +193,11 @@ async def _make_request(endpoint: str, params: Optional[Dict[str, Any]] = None)
|
|
| 187 |
return response.json()
|
| 188 |
|
| 189 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 190 |
@mcp.tool()
|
| 191 |
async def get_company_profile(request: CompanyProfileRequest) -> CompanyProfile:
|
| 192 |
"""Get company profile using yfinance (free, no API key required).
|
|
@@ -237,6 +248,11 @@ async def get_company_profile(request: CompanyProfileRequest) -> CompanyProfile:
|
|
| 237 |
return CompanyProfile(ticker=request.ticker)
|
| 238 |
|
| 239 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 240 |
@mcp.tool()
|
| 241 |
async def get_income_statement(request: FinancialStatementsRequest) -> List[IncomeStatement]:
|
| 242 |
"""Get income statement data.
|
|
@@ -282,6 +298,11 @@ async def get_income_statement(request: FinancialStatementsRequest) -> List[Inco
|
|
| 282 |
return []
|
| 283 |
|
| 284 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 285 |
@mcp.tool()
|
| 286 |
async def get_balance_sheet(request: FinancialStatementsRequest) -> List[BalanceSheet]:
|
| 287 |
"""Get balance sheet data.
|
|
@@ -325,6 +346,11 @@ async def get_balance_sheet(request: FinancialStatementsRequest) -> List[Balance
|
|
| 325 |
return []
|
| 326 |
|
| 327 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 328 |
@mcp.tool()
|
| 329 |
async def get_cash_flow_statement(request: FinancialStatementsRequest) -> List[CashFlowStatement]:
|
| 330 |
"""Get cash flow statement data.
|
|
@@ -367,6 +393,11 @@ async def get_cash_flow_statement(request: FinancialStatementsRequest) -> List[C
|
|
| 367 |
return []
|
| 368 |
|
| 369 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 370 |
@mcp.tool()
|
| 371 |
async def get_financial_ratios(request: FinancialRatiosRequest) -> FinancialRatios:
|
| 372 |
"""Get key financial ratios.
|
|
@@ -417,6 +448,11 @@ async def get_financial_ratios(request: FinancialRatiosRequest) -> FinancialRati
|
|
| 417 |
return FinancialRatios(ticker=request.ticker)
|
| 418 |
|
| 419 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 420 |
@mcp.tool()
|
| 421 |
async def get_key_metrics(request: KeyMetricsRequest) -> KeyMetrics:
|
| 422 |
"""Get key company metrics.
|
|
|
|
| 15 |
import yfinance as yf
|
| 16 |
from fastmcp import FastMCP
|
| 17 |
from pydantic import BaseModel, Field
|
| 18 |
+
from tenacity import (
|
| 19 |
+
retry,
|
| 20 |
+
stop_after_attempt,
|
| 21 |
+
wait_exponential,
|
| 22 |
+
retry_if_exception_type,
|
| 23 |
+
)
|
| 24 |
|
| 25 |
from backend.config import settings
|
| 26 |
|
|
|
|
| 193 |
return response.json()
|
| 194 |
|
| 195 |
|
| 196 |
+
@retry(
|
| 197 |
+
stop=stop_after_attempt(3),
|
| 198 |
+
wait=wait_exponential(multiplier=1, min=2, max=10),
|
| 199 |
+
retry=retry_if_exception_type((TimeoutError, ConnectionError, httpx.HTTPStatusError)),
|
| 200 |
+
)
|
| 201 |
@mcp.tool()
|
| 202 |
async def get_company_profile(request: CompanyProfileRequest) -> CompanyProfile:
|
| 203 |
"""Get company profile using yfinance (free, no API key required).
|
|
|
|
| 248 |
return CompanyProfile(ticker=request.ticker)
|
| 249 |
|
| 250 |
|
| 251 |
+
@retry(
|
| 252 |
+
stop=stop_after_attempt(3),
|
| 253 |
+
wait=wait_exponential(multiplier=1, min=2, max=10),
|
| 254 |
+
retry=retry_if_exception_type((TimeoutError, ConnectionError, httpx.HTTPStatusError)),
|
| 255 |
+
)
|
| 256 |
@mcp.tool()
|
| 257 |
async def get_income_statement(request: FinancialStatementsRequest) -> List[IncomeStatement]:
|
| 258 |
"""Get income statement data.
|
|
|
|
| 298 |
return []
|
| 299 |
|
| 300 |
|
| 301 |
+
@retry(
|
| 302 |
+
stop=stop_after_attempt(3),
|
| 303 |
+
wait=wait_exponential(multiplier=1, min=2, max=10),
|
| 304 |
+
retry=retry_if_exception_type((TimeoutError, ConnectionError, httpx.HTTPStatusError)),
|
| 305 |
+
)
|
| 306 |
@mcp.tool()
|
| 307 |
async def get_balance_sheet(request: FinancialStatementsRequest) -> List[BalanceSheet]:
|
| 308 |
"""Get balance sheet data.
|
|
|
|
| 346 |
return []
|
| 347 |
|
| 348 |
|
| 349 |
+
@retry(
|
| 350 |
+
stop=stop_after_attempt(3),
|
| 351 |
+
wait=wait_exponential(multiplier=1, min=2, max=10),
|
| 352 |
+
retry=retry_if_exception_type((TimeoutError, ConnectionError, httpx.HTTPStatusError)),
|
| 353 |
+
)
|
| 354 |
@mcp.tool()
|
| 355 |
async def get_cash_flow_statement(request: FinancialStatementsRequest) -> List[CashFlowStatement]:
|
| 356 |
"""Get cash flow statement data.
|
|
|
|
| 393 |
return []
|
| 394 |
|
| 395 |
|
| 396 |
+
@retry(
|
| 397 |
+
stop=stop_after_attempt(3),
|
| 398 |
+
wait=wait_exponential(multiplier=1, min=2, max=10),
|
| 399 |
+
retry=retry_if_exception_type((TimeoutError, ConnectionError, httpx.HTTPStatusError)),
|
| 400 |
+
)
|
| 401 |
@mcp.tool()
|
| 402 |
async def get_financial_ratios(request: FinancialRatiosRequest) -> FinancialRatios:
|
| 403 |
"""Get key financial ratios.
|
|
|
|
| 448 |
return FinancialRatios(ticker=request.ticker)
|
| 449 |
|
| 450 |
|
| 451 |
+
@retry(
|
| 452 |
+
stop=stop_after_attempt(3),
|
| 453 |
+
wait=wait_exponential(multiplier=1, min=2, max=10),
|
| 454 |
+
retry=retry_if_exception_type((TimeoutError, ConnectionError, httpx.HTTPStatusError)),
|
| 455 |
+
)
|
| 456 |
@mcp.tool()
|
| 457 |
async def get_key_metrics(request: KeyMetricsRequest) -> KeyMetrics:
|
| 458 |
"""Get key company metrics.
|
|
@@ -12,6 +12,12 @@ from decimal import Decimal
|
|
| 12 |
import httpx
|
| 13 |
from fastmcp import FastMCP
|
| 14 |
from pydantic import BaseModel, Field
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 15 |
|
| 16 |
from backend.config import settings
|
| 17 |
|
|
@@ -63,6 +69,11 @@ async def _make_request(endpoint: str, params: Dict[str, str]) -> Dict:
|
|
| 63 |
return response.json()
|
| 64 |
|
| 65 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 66 |
@mcp.tool()
|
| 67 |
async def get_economic_series(request: SeriesRequest) -> EconomicSeries:
|
| 68 |
"""Get economic data series from FRED.
|
|
|
|
| 12 |
import httpx
|
| 13 |
from fastmcp import FastMCP
|
| 14 |
from pydantic import BaseModel, Field
|
| 15 |
+
from tenacity import (
|
| 16 |
+
retry,
|
| 17 |
+
stop_after_attempt,
|
| 18 |
+
wait_exponential,
|
| 19 |
+
retry_if_exception_type,
|
| 20 |
+
)
|
| 21 |
|
| 22 |
from backend.config import settings
|
| 23 |
|
|
|
|
| 69 |
return response.json()
|
| 70 |
|
| 71 |
|
| 72 |
+
@retry(
|
| 73 |
+
stop=stop_after_attempt(3),
|
| 74 |
+
wait=wait_exponential(multiplier=1, min=2, max=10),
|
| 75 |
+
retry=retry_if_exception_type((TimeoutError, ConnectionError, Exception)),
|
| 76 |
+
)
|
| 77 |
@mcp.tool()
|
| 78 |
async def get_economic_series(request: SeriesRequest) -> EconomicSeries:
|
| 79 |
"""Get economic data series from FRED.
|
|
@@ -0,0 +1,230 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""News Sentiment MCP Server.
|
| 2 |
+
|
| 3 |
+
Fetches company news from Finnhub API and analyses sentiment using VADER.
|
| 4 |
+
Provides fast, real-time sentiment analysis for portfolio holdings.
|
| 5 |
+
"""
|
| 6 |
+
|
| 7 |
+
from fastmcp import FastMCP
|
| 8 |
+
from pydantic import BaseModel, Field
|
| 9 |
+
from tenacity import (
|
| 10 |
+
retry,
|
| 11 |
+
stop_after_attempt,
|
| 12 |
+
wait_exponential,
|
| 13 |
+
retry_if_exception_type,
|
| 14 |
+
)
|
| 15 |
+
from typing import List, Optional
|
| 16 |
+
from datetime import datetime, timedelta
|
| 17 |
+
import httpx
|
| 18 |
+
import logging
|
| 19 |
+
|
| 20 |
+
logger = logging.getLogger(__name__)
|
| 21 |
+
|
| 22 |
+
mcp = FastMCP("news-sentiment")
|
| 23 |
+
|
| 24 |
+
|
| 25 |
+
class NewsArticle(BaseModel):
|
| 26 |
+
"""Individual news article with sentiment analysis.
|
| 27 |
+
|
| 28 |
+
Attributes:
|
| 29 |
+
headline: Article headline
|
| 30 |
+
source: News source/publisher
|
| 31 |
+
url: Article URL
|
| 32 |
+
published_at: Publication timestamp
|
| 33 |
+
sentiment_score: Compound sentiment score from VADER (-1 to +1)
|
| 34 |
+
sentiment_label: Human-readable sentiment (positive/negative/neutral)
|
| 35 |
+
summary: Article summary/snippet
|
| 36 |
+
"""
|
| 37 |
+
headline: str
|
| 38 |
+
source: str
|
| 39 |
+
url: str
|
| 40 |
+
published_at: datetime
|
| 41 |
+
sentiment_score: float = Field(ge=-1.0, le=1.0, description="VADER compound score")
|
| 42 |
+
sentiment_label: str # "positive" | "negative" | "neutral"
|
| 43 |
+
summary: str
|
| 44 |
+
|
| 45 |
+
|
| 46 |
+
class TickerNewsWithSentiment(BaseModel):
|
| 47 |
+
"""Complete news + sentiment analysis for a ticker.
|
| 48 |
+
|
| 49 |
+
Attributes:
|
| 50 |
+
ticker: Stock ticker symbol
|
| 51 |
+
overall_sentiment: Weighted average sentiment across all articles
|
| 52 |
+
confidence: Confidence score based on agreement between articles
|
| 53 |
+
article_count: Number of articles analysed
|
| 54 |
+
articles: List of individual articles with sentiment
|
| 55 |
+
error: Error message if fetching/analysis failed
|
| 56 |
+
"""
|
| 57 |
+
ticker: str
|
| 58 |
+
overall_sentiment: float = Field(ge=-1.0, le=1.0)
|
| 59 |
+
confidence: float = Field(ge=0.0, le=1.0)
|
| 60 |
+
article_count: int
|
| 61 |
+
articles: List[NewsArticle]
|
| 62 |
+
error: Optional[str] = None
|
| 63 |
+
|
| 64 |
+
|
| 65 |
+
@retry(
|
| 66 |
+
stop=stop_after_attempt(3),
|
| 67 |
+
wait=wait_exponential(multiplier=1, min=2, max=10),
|
| 68 |
+
retry=retry_if_exception_type((TimeoutError, ConnectionError, Exception)),
|
| 69 |
+
)
|
| 70 |
+
@mcp.tool()
|
| 71 |
+
async def get_news_with_sentiment(
|
| 72 |
+
ticker: str,
|
| 73 |
+
days_back: int = 7
|
| 74 |
+
) -> TickerNewsWithSentiment:
|
| 75 |
+
"""Fetch recent news for a ticker and analyse sentiment.
|
| 76 |
+
|
| 77 |
+
Uses Finnhub API for news retrieval (60 calls/min free tier) and
|
| 78 |
+
VADER sentiment analysis (339x faster than FinBERT).
|
| 79 |
+
|
| 80 |
+
Args:
|
| 81 |
+
ticker: Stock ticker symbol (e.g., "AAPL")
|
| 82 |
+
days_back: Number of days of historical news to fetch (default: 7)
|
| 83 |
+
|
| 84 |
+
Returns:
|
| 85 |
+
TickerNewsWithSentiment with articles and aggregated sentiment scores
|
| 86 |
+
"""
|
| 87 |
+
try:
|
| 88 |
+
from vaderSentiment.vaderSentiment import SentimentIntensityAnalyzer
|
| 89 |
+
except ImportError:
|
| 90 |
+
logger.error("vaderSentiment not installed. Install with: uv add vaderSentiment")
|
| 91 |
+
return TickerNewsWithSentiment(
|
| 92 |
+
ticker=ticker,
|
| 93 |
+
overall_sentiment=0.0,
|
| 94 |
+
confidence=0.0,
|
| 95 |
+
article_count=0,
|
| 96 |
+
articles=[],
|
| 97 |
+
error="Sentiment analysis library not installed"
|
| 98 |
+
)
|
| 99 |
+
|
| 100 |
+
try:
|
| 101 |
+
# Get Finnhub API key from environment
|
| 102 |
+
import os
|
| 103 |
+
finnhub_api_key = os.getenv("FINNHUB_API_KEY")
|
| 104 |
+
|
| 105 |
+
if not finnhub_api_key:
|
| 106 |
+
logger.warning("FINNHUB_API_KEY not set, returning empty sentiment")
|
| 107 |
+
return TickerNewsWithSentiment(
|
| 108 |
+
ticker=ticker,
|
| 109 |
+
overall_sentiment=0.0,
|
| 110 |
+
confidence=0.0,
|
| 111 |
+
article_count=0,
|
| 112 |
+
articles=[],
|
| 113 |
+
error="Finnhub API key not configured"
|
| 114 |
+
)
|
| 115 |
+
|
| 116 |
+
# Calculate date range
|
| 117 |
+
end_date = datetime.now()
|
| 118 |
+
start_date = end_date - timedelta(days=days_back)
|
| 119 |
+
|
| 120 |
+
# Fetch news from Finnhub
|
| 121 |
+
async with httpx.AsyncClient() as client:
|
| 122 |
+
response = await client.get(
|
| 123 |
+
"https://finnhub.io/api/v1/company-news",
|
| 124 |
+
params={
|
| 125 |
+
"symbol": ticker,
|
| 126 |
+
"from": start_date.strftime("%Y-%m-%d"),
|
| 127 |
+
"to": end_date.strftime("%Y-%m-%d"),
|
| 128 |
+
"token": finnhub_api_key
|
| 129 |
+
},
|
| 130 |
+
timeout=10.0
|
| 131 |
+
)
|
| 132 |
+
response.raise_for_status()
|
| 133 |
+
news_data = response.json()
|
| 134 |
+
|
| 135 |
+
if not news_data or len(news_data) == 0:
|
| 136 |
+
logger.info(f"No recent news found for {ticker}")
|
| 137 |
+
return TickerNewsWithSentiment(
|
| 138 |
+
ticker=ticker,
|
| 139 |
+
overall_sentiment=0.0,
|
| 140 |
+
confidence=0.0,
|
| 141 |
+
article_count=0,
|
| 142 |
+
articles=[],
|
| 143 |
+
error=f"No recent news found in last {days_back} days"
|
| 144 |
+
)
|
| 145 |
+
|
| 146 |
+
# Initialise VADER sentiment analyser
|
| 147 |
+
analyzer = SentimentIntensityAnalyzer()
|
| 148 |
+
|
| 149 |
+
articles = []
|
| 150 |
+
sentiment_scores = []
|
| 151 |
+
|
| 152 |
+
# Process up to 20 most recent articles
|
| 153 |
+
for item in news_data[:20]:
|
| 154 |
+
# Combine headline and summary for sentiment analysis
|
| 155 |
+
text = f"{item.get('headline', '')} {item.get('summary', '')}"
|
| 156 |
+
|
| 157 |
+
# Run VADER sentiment analysis
|
| 158 |
+
vader_result = analyzer.polarity_scores(text)
|
| 159 |
+
compound = vader_result['compound']
|
| 160 |
+
|
| 161 |
+
# Map compound score to label
|
| 162 |
+
if compound >= 0.05:
|
| 163 |
+
label = 'positive'
|
| 164 |
+
elif compound <= -0.05:
|
| 165 |
+
label = 'negative'
|
| 166 |
+
else:
|
| 167 |
+
label = 'neutral'
|
| 168 |
+
|
| 169 |
+
# Create article object
|
| 170 |
+
articles.append(NewsArticle(
|
| 171 |
+
headline=item.get('headline', 'No headline'),
|
| 172 |
+
source=item.get('source', 'Unknown'),
|
| 173 |
+
url=item.get('url', ''),
|
| 174 |
+
published_at=datetime.fromtimestamp(item.get('datetime', datetime.now().timestamp())),
|
| 175 |
+
sentiment_score=compound,
|
| 176 |
+
sentiment_label=label,
|
| 177 |
+
summary=item.get('summary', '')[:200] # Limit summary length
|
| 178 |
+
))
|
| 179 |
+
|
| 180 |
+
sentiment_scores.append(compound)
|
| 181 |
+
|
| 182 |
+
# Calculate overall sentiment (mean of compound scores)
|
| 183 |
+
overall = sum(sentiment_scores) / len(sentiment_scores) if sentiment_scores else 0.0
|
| 184 |
+
|
| 185 |
+
# Calculate confidence (inverse of standard deviation)
|
| 186 |
+
# More agreement between articles = higher confidence
|
| 187 |
+
if len(sentiment_scores) > 1:
|
| 188 |
+
import statistics
|
| 189 |
+
std_dev = statistics.stdev(sentiment_scores)
|
| 190 |
+
confidence = max(0.0, 1.0 - min(std_dev, 1.0))
|
| 191 |
+
else:
|
| 192 |
+
confidence = 0.5 # Moderate confidence for single article
|
| 193 |
+
|
| 194 |
+
logger.info(
|
| 195 |
+
f"Fetched {len(articles)} articles for {ticker}: "
|
| 196 |
+
f"sentiment={overall:.2f}, confidence={confidence:.2f}"
|
| 197 |
+
)
|
| 198 |
+
|
| 199 |
+
return TickerNewsWithSentiment(
|
| 200 |
+
ticker=ticker,
|
| 201 |
+
overall_sentiment=overall,
|
| 202 |
+
confidence=confidence,
|
| 203 |
+
article_count=len(articles),
|
| 204 |
+
articles=articles
|
| 205 |
+
)
|
| 206 |
+
|
| 207 |
+
except httpx.HTTPStatusError as e:
|
| 208 |
+
logger.error(f"Finnhub API error for {ticker}: {e.response.status_code}")
|
| 209 |
+
return TickerNewsWithSentiment(
|
| 210 |
+
ticker=ticker,
|
| 211 |
+
overall_sentiment=0.0,
|
| 212 |
+
confidence=0.0,
|
| 213 |
+
article_count=0,
|
| 214 |
+
articles=[],
|
| 215 |
+
error=f"API error: {e.response.status_code}"
|
| 216 |
+
)
|
| 217 |
+
except Exception as e:
|
| 218 |
+
logger.error(f"Failed to fetch sentiment for {ticker}: {e}")
|
| 219 |
+
return TickerNewsWithSentiment(
|
| 220 |
+
ticker=ticker,
|
| 221 |
+
overall_sentiment=0.0,
|
| 222 |
+
confidence=0.0,
|
| 223 |
+
article_count=0,
|
| 224 |
+
articles=[],
|
| 225 |
+
error=f"Unexpected error: {str(e)}"
|
| 226 |
+
)
|
| 227 |
+
|
| 228 |
+
|
| 229 |
+
if __name__ == "__main__":
|
| 230 |
+
mcp.run()
|
|
@@ -18,6 +18,12 @@ from pypfopt import HRPOpt, BlackLittermanModel, EfficientFrontier, risk_models,
|
|
| 18 |
from pypfopt import black_litterman
|
| 19 |
from fastmcp import FastMCP
|
| 20 |
from pydantic import BaseModel, Field
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 21 |
|
| 22 |
logger = logging.getLogger(__name__)
|
| 23 |
|
|
@@ -66,6 +72,11 @@ def _prepare_price_data(market_data: List[MarketDataInput]) -> pd.DataFrame:
|
|
| 66 |
return df
|
| 67 |
|
| 68 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 69 |
@mcp.tool()
|
| 70 |
async def optimize_hrp(request: OptimizationRequest) -> OptimizationResult:
|
| 71 |
"""Optimize portfolio using Hierarchical Risk Parity.
|
|
@@ -126,6 +137,11 @@ async def optimize_hrp(request: OptimizationRequest) -> OptimizationResult:
|
|
| 126 |
raise
|
| 127 |
|
| 128 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 129 |
@mcp.tool()
|
| 130 |
async def optimize_black_litterman(request: OptimizationRequest) -> OptimizationResult:
|
| 131 |
"""Optimize portfolio using Black-Litterman model with market equilibrium.
|
|
@@ -249,6 +265,11 @@ async def optimize_black_litterman(request: OptimizationRequest) -> Optimization
|
|
| 249 |
return await optimize_hrp(request)
|
| 250 |
|
| 251 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 252 |
@mcp.tool()
|
| 253 |
async def optimize_mean_variance(request: OptimizationRequest) -> OptimizationResult:
|
| 254 |
"""Optimize portfolio using Mean-Variance Optimization (Markowitz).
|
|
|
|
| 18 |
from pypfopt import black_litterman
|
| 19 |
from fastmcp import FastMCP
|
| 20 |
from pydantic import BaseModel, Field
|
| 21 |
+
from tenacity import (
|
| 22 |
+
retry,
|
| 23 |
+
stop_after_attempt,
|
| 24 |
+
wait_exponential,
|
| 25 |
+
retry_if_exception_type,
|
| 26 |
+
)
|
| 27 |
|
| 28 |
logger = logging.getLogger(__name__)
|
| 29 |
|
|
|
|
| 72 |
return df
|
| 73 |
|
| 74 |
|
| 75 |
+
@retry(
|
| 76 |
+
stop=stop_after_attempt(3),
|
| 77 |
+
wait=wait_exponential(multiplier=1, min=2, max=10),
|
| 78 |
+
retry=retry_if_exception_type((TimeoutError, ConnectionError, Exception)),
|
| 79 |
+
)
|
| 80 |
@mcp.tool()
|
| 81 |
async def optimize_hrp(request: OptimizationRequest) -> OptimizationResult:
|
| 82 |
"""Optimize portfolio using Hierarchical Risk Parity.
|
|
|
|
| 137 |
raise
|
| 138 |
|
| 139 |
|
| 140 |
+
@retry(
|
| 141 |
+
stop=stop_after_attempt(3),
|
| 142 |
+
wait=wait_exponential(multiplier=1, min=2, max=10),
|
| 143 |
+
retry=retry_if_exception_type((TimeoutError, ConnectionError, Exception)),
|
| 144 |
+
)
|
| 145 |
@mcp.tool()
|
| 146 |
async def optimize_black_litterman(request: OptimizationRequest) -> OptimizationResult:
|
| 147 |
"""Optimize portfolio using Black-Litterman model with market equilibrium.
|
|
|
|
| 265 |
return await optimize_hrp(request)
|
| 266 |
|
| 267 |
|
| 268 |
+
@retry(
|
| 269 |
+
stop=stop_after_attempt(3),
|
| 270 |
+
wait=wait_exponential(multiplier=1, min=2, max=10),
|
| 271 |
+
retry=retry_if_exception_type((TimeoutError, ConnectionError, Exception)),
|
| 272 |
+
)
|
| 273 |
@mcp.tool()
|
| 274 |
async def optimize_mean_variance(request: OptimizationRequest) -> OptimizationResult:
|
| 275 |
"""Optimize portfolio using Mean-Variance Optimization (Markowitz).
|
|
@@ -20,6 +20,12 @@ import pandas as pd
|
|
| 20 |
from scipy import stats
|
| 21 |
from fastmcp import FastMCP
|
| 22 |
from pydantic import BaseModel, Field
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 23 |
|
| 24 |
logger = logging.getLogger(__name__)
|
| 25 |
|
|
@@ -118,6 +124,11 @@ def _calculate_portfolio_returns(returns: pd.DataFrame, weights: np.ndarray) ->
|
|
| 118 |
return (returns * weights).sum(axis=1)
|
| 119 |
|
| 120 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 121 |
@mcp.tool()
|
| 122 |
async def analyze_risk(request: RiskAnalysisRequest) -> RiskAnalysisResult:
|
| 123 |
"""Perform comprehensive risk analysis on a portfolio.
|
|
@@ -551,6 +562,11 @@ class GARCHForecastResult(BaseModel):
|
|
| 551 |
model_diagnostics: Dict[str, Decimal]
|
| 552 |
|
| 553 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 554 |
@mcp.tool()
|
| 555 |
async def forecast_volatility_garch(request: GARCHForecastRequest) -> GARCHForecastResult:
|
| 556 |
"""Forecast volatility using GARCH model.
|
|
|
|
| 20 |
from scipy import stats
|
| 21 |
from fastmcp import FastMCP
|
| 22 |
from pydantic import BaseModel, Field
|
| 23 |
+
from tenacity import (
|
| 24 |
+
retry,
|
| 25 |
+
stop_after_attempt,
|
| 26 |
+
wait_exponential,
|
| 27 |
+
retry_if_exception_type,
|
| 28 |
+
)
|
| 29 |
|
| 30 |
logger = logging.getLogger(__name__)
|
| 31 |
|
|
|
|
| 124 |
return (returns * weights).sum(axis=1)
|
| 125 |
|
| 126 |
|
| 127 |
+
@retry(
|
| 128 |
+
stop=stop_after_attempt(3),
|
| 129 |
+
wait=wait_exponential(multiplier=1, min=2, max=10),
|
| 130 |
+
retry=retry_if_exception_type((TimeoutError, ConnectionError, Exception)),
|
| 131 |
+
)
|
| 132 |
@mcp.tool()
|
| 133 |
async def analyze_risk(request: RiskAnalysisRequest) -> RiskAnalysisResult:
|
| 134 |
"""Perform comprehensive risk analysis on a portfolio.
|
|
|
|
| 562 |
model_diagnostics: Dict[str, Decimal]
|
| 563 |
|
| 564 |
|
| 565 |
+
@retry(
|
| 566 |
+
stop=stop_after_attempt(3),
|
| 567 |
+
wait=wait_exponential(multiplier=1, min=2, max=10),
|
| 568 |
+
retry=retry_if_exception_type((TimeoutError, ConnectionError, Exception)),
|
| 569 |
+
)
|
| 570 |
@mcp.tool()
|
| 571 |
async def forecast_volatility_garch(request: GARCHForecastRequest) -> GARCHForecastResult:
|
| 572 |
"""Forecast volatility using GARCH model.
|
|
@@ -12,6 +12,12 @@ import pandas as pd
|
|
| 12 |
import yfinance as yf
|
| 13 |
from fastmcp import FastMCP
|
| 14 |
from pydantic import BaseModel, Field
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 15 |
|
| 16 |
logger = logging.getLogger(__name__)
|
| 17 |
|
|
@@ -117,6 +123,11 @@ def calculate_bollinger_bands(prices: pd.Series, period: int = 20) -> Dict[str,
|
|
| 117 |
}
|
| 118 |
|
| 119 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 120 |
@mcp.tool()
|
| 121 |
async def get_technical_indicators(request: TechnicalIndicatorsRequest) -> TechnicalIndicators:
|
| 122 |
"""Get technical indicators for a ticker.
|
|
|
|
| 12 |
import yfinance as yf
|
| 13 |
from fastmcp import FastMCP
|
| 14 |
from pydantic import BaseModel, Field
|
| 15 |
+
from tenacity import (
|
| 16 |
+
retry,
|
| 17 |
+
stop_after_attempt,
|
| 18 |
+
wait_exponential,
|
| 19 |
+
retry_if_exception_type,
|
| 20 |
+
)
|
| 21 |
|
| 22 |
logger = logging.getLogger(__name__)
|
| 23 |
|
|
|
|
| 123 |
}
|
| 124 |
|
| 125 |
|
| 126 |
+
@retry(
|
| 127 |
+
stop=stop_after_attempt(3),
|
| 128 |
+
wait=wait_exponential(multiplier=1, min=2, max=10),
|
| 129 |
+
retry=retry_if_exception_type((TimeoutError, ConnectionError, Exception)),
|
| 130 |
+
)
|
| 131 |
@mcp.tool()
|
| 132 |
async def get_technical_indicators(request: TechnicalIndicatorsRequest) -> TechnicalIndicators:
|
| 133 |
"""Get technical indicators for a ticker.
|
|
@@ -19,6 +19,12 @@ from decimal import Decimal
|
|
| 19 |
|
| 20 |
from fastmcp import FastMCP
|
| 21 |
from pydantic import BaseModel, Field
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 22 |
|
| 23 |
from backend.data_providers import get_provider, ProviderType
|
| 24 |
from backend.data_providers.base import MarketDataProvider
|
|
@@ -117,6 +123,11 @@ class FundamentalsResponse(BaseModel):
|
|
| 117 |
fifty_two_week_low: Optional[Decimal] = None
|
| 118 |
|
| 119 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 120 |
@mcp.tool()
|
| 121 |
async def get_quote(request: QuoteRequest) -> List[QuoteResponse]:
|
| 122 |
"""Get real-time quotes for multiple tickers.
|
|
@@ -171,6 +182,11 @@ async def get_quote(request: QuoteRequest) -> List[QuoteResponse]:
|
|
| 171 |
return quotes
|
| 172 |
|
| 173 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 174 |
@mcp.tool()
|
| 175 |
async def get_historical_data(request: HistoricalRequest) -> HistoricalResponse:
|
| 176 |
"""Get historical price data for a ticker.
|
|
@@ -261,6 +277,11 @@ async def get_historical_data(request: HistoricalRequest) -> HistoricalResponse:
|
|
| 261 |
)
|
| 262 |
|
| 263 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 264 |
@mcp.tool()
|
| 265 |
async def get_fundamentals(request: FundamentalsRequest) -> FundamentalsResponse:
|
| 266 |
"""Get company fundamentals and key metrics.
|
|
|
|
| 19 |
|
| 20 |
from fastmcp import FastMCP
|
| 21 |
from pydantic import BaseModel, Field
|
| 22 |
+
from tenacity import (
|
| 23 |
+
retry,
|
| 24 |
+
stop_after_attempt,
|
| 25 |
+
wait_exponential,
|
| 26 |
+
retry_if_exception_type,
|
| 27 |
+
)
|
| 28 |
|
| 29 |
from backend.data_providers import get_provider, ProviderType
|
| 30 |
from backend.data_providers.base import MarketDataProvider
|
|
|
|
| 123 |
fifty_two_week_low: Optional[Decimal] = None
|
| 124 |
|
| 125 |
|
| 126 |
+
@retry(
|
| 127 |
+
stop=stop_after_attempt(3),
|
| 128 |
+
wait=wait_exponential(multiplier=1, min=2, max=10),
|
| 129 |
+
retry=retry_if_exception_type((TimeoutError, ConnectionError, Exception)),
|
| 130 |
+
)
|
| 131 |
@mcp.tool()
|
| 132 |
async def get_quote(request: QuoteRequest) -> List[QuoteResponse]:
|
| 133 |
"""Get real-time quotes for multiple tickers.
|
|
|
|
| 182 |
return quotes
|
| 183 |
|
| 184 |
|
| 185 |
+
@retry(
|
| 186 |
+
stop=stop_after_attempt(3),
|
| 187 |
+
wait=wait_exponential(multiplier=1, min=2, max=10),
|
| 188 |
+
retry=retry_if_exception_type((TimeoutError, ConnectionError, Exception)),
|
| 189 |
+
)
|
| 190 |
@mcp.tool()
|
| 191 |
async def get_historical_data(request: HistoricalRequest) -> HistoricalResponse:
|
| 192 |
"""Get historical price data for a ticker.
|
|
|
|
| 277 |
)
|
| 278 |
|
| 279 |
|
| 280 |
+
@retry(
|
| 281 |
+
stop=stop_after_attempt(3),
|
| 282 |
+
wait=wait_exponential(multiplier=1, min=2, max=10),
|
| 283 |
+
retry=retry_if_exception_type((TimeoutError, ConnectionError, Exception)),
|
| 284 |
+
)
|
| 285 |
@mcp.tool()
|
| 286 |
async def get_fundamentals(request: FundamentalsRequest) -> FundamentalsResponse:
|
| 287 |
"""Get company fundamentals and key metrics.
|
|
@@ -46,6 +46,7 @@ class AgentState(TypedDict):
|
|
| 46 |
economic_data: Annotated[Dict[str, Any], merge_dicts]
|
| 47 |
realtime_data: Annotated[Dict[str, Any], merge_dicts]
|
| 48 |
technical_indicators: Annotated[Dict[str, Any], merge_dicts]
|
|
|
|
| 49 |
|
| 50 |
# Phase 2: Computation Layer Results
|
| 51 |
optimisation_results: Annotated[Dict[str, Any], merge_dicts]
|
|
|
|
| 46 |
economic_data: Annotated[Dict[str, Any], merge_dicts]
|
| 47 |
realtime_data: Annotated[Dict[str, Any], merge_dicts]
|
| 48 |
technical_indicators: Annotated[Dict[str, Any], merge_dicts]
|
| 49 |
+
sentiment_data: Annotated[Dict[str, Any], merge_dicts] # Enhancement #3: News Sentiment MCP
|
| 50 |
|
| 51 |
# Phase 2: Computation Layer Results
|
| 52 |
optimisation_results: Annotated[Dict[str, Any], merge_dicts]
|
|
@@ -63,6 +63,7 @@ CREATE TABLE IF NOT EXISTS portfolio_analyses (
|
|
| 63 |
-- Metadata
|
| 64 |
reasoning_steps JSONB,
|
| 65 |
mcp_calls JSONB,
|
|
|
|
| 66 |
execution_time_ms INTEGER,
|
| 67 |
model_version VARCHAR(50),
|
| 68 |
|
|
@@ -77,6 +78,7 @@ CREATE TABLE IF NOT EXISTS portfolio_analyses (
|
|
| 77 |
CREATE INDEX idx_analyses_portfolio_id ON portfolio_analyses(portfolio_id);
|
| 78 |
CREATE INDEX idx_analyses_date ON portfolio_analyses(analysis_date DESC);
|
| 79 |
CREATE INDEX idx_analyses_health_score ON portfolio_analyses(health_score);
|
|
|
|
| 80 |
|
| 81 |
-- Function to automatically create user profile on signup (CRITICAL FOR AUTH)
|
| 82 |
-- Uses SECURITY DEFINER with empty search_path to bypass RLS on INSERT
|
|
|
|
| 63 |
-- Metadata
|
| 64 |
reasoning_steps JSONB,
|
| 65 |
mcp_calls JSONB,
|
| 66 |
+
sentiment_data JSONB,
|
| 67 |
execution_time_ms INTEGER,
|
| 68 |
model_version VARCHAR(50),
|
| 69 |
|
|
|
|
| 78 |
CREATE INDEX idx_analyses_portfolio_id ON portfolio_analyses(portfolio_id);
|
| 79 |
CREATE INDEX idx_analyses_date ON portfolio_analyses(analysis_date DESC);
|
| 80 |
CREATE INDEX idx_analyses_health_score ON portfolio_analyses(health_score);
|
| 81 |
+
CREATE INDEX IF NOT EXISTS idx_analyses_sentiment_data ON portfolio_analyses USING GIN (sentiment_data);
|
| 82 |
|
| 83 |
-- Function to automatically create user profile on signup (CRITICAL FOR AUTH)
|
| 84 |
-- Uses SECURITY DEFINER with empty search_path to bypass RLS on INSERT
|
|
@@ -45,6 +45,8 @@ dependencies = [
|
|
| 45 |
"python-dotenv>=1.0.0",
|
| 46 |
"httpx>=0.25.0",
|
| 47 |
"tenacity>=8.2.0",
|
|
|
|
|
|
|
| 48 |
# Monitoring & Observability
|
| 49 |
"sentry-sdk[fastapi]>=2.0.0",
|
| 50 |
"fmp-data>=1.0.2",
|
|
|
|
| 45 |
"python-dotenv>=1.0.0",
|
| 46 |
"httpx>=0.25.0",
|
| 47 |
"tenacity>=8.2.0",
|
| 48 |
+
# Sentiment Analysis
|
| 49 |
+
"vaderSentiment>=3.3.2",
|
| 50 |
# Monitoring & Observability
|
| 51 |
"sentry-sdk[fastapi]>=2.0.0",
|
| 52 |
"fmp-data>=1.0.2",
|
|
@@ -3183,6 +3183,7 @@ dependencies = [
|
|
| 3183 |
{ name = "torch" },
|
| 3184 |
{ name = "upstash-redis" },
|
| 3185 |
{ name = "uvicorn", extra = ["standard"] },
|
|
|
|
| 3186 |
{ name = "xgboost" },
|
| 3187 |
{ name = "yfinance" },
|
| 3188 |
]
|
|
@@ -3227,6 +3228,7 @@ requires-dist = [
|
|
| 3227 |
{ name = "torch", specifier = ">=2.0.0" },
|
| 3228 |
{ name = "upstash-redis", specifier = ">=0.15.0" },
|
| 3229 |
{ name = "uvicorn", extras = ["standard"], specifier = ">=0.24.0" },
|
|
|
|
| 3230 |
{ name = "xgboost", specifier = ">=3.1.0" },
|
| 3231 |
{ name = "yfinance", specifier = ">=0.2.40" },
|
| 3232 |
]
|
|
@@ -5015,6 +5017,18 @@ wheels = [
|
|
| 5015 |
{ url = "https://files.pythonhosted.org/packages/e4/16/c1fd27e9549f3c4baf1dc9c20c456cd2f822dbf8de9f463824b0c0357e06/uvloop-0.22.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:6cde23eeda1a25c75b2e07d39970f3374105d5eafbaab2a4482be82f272d5a5e", size = 4296730, upload-time = "2025-10-16T22:17:00.744Z" },
|
| 5016 |
]
|
| 5017 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 5018 |
[[package]]
|
| 5019 |
name = "virtualenv"
|
| 5020 |
version = "20.35.4"
|
|
|
|
| 3183 |
{ name = "torch" },
|
| 3184 |
{ name = "upstash-redis" },
|
| 3185 |
{ name = "uvicorn", extra = ["standard"] },
|
| 3186 |
+
{ name = "vadersentiment" },
|
| 3187 |
{ name = "xgboost" },
|
| 3188 |
{ name = "yfinance" },
|
| 3189 |
]
|
|
|
|
| 3228 |
{ name = "torch", specifier = ">=2.0.0" },
|
| 3229 |
{ name = "upstash-redis", specifier = ">=0.15.0" },
|
| 3230 |
{ name = "uvicorn", extras = ["standard"], specifier = ">=0.24.0" },
|
| 3231 |
+
{ name = "vadersentiment", specifier = ">=3.3.2" },
|
| 3232 |
{ name = "xgboost", specifier = ">=3.1.0" },
|
| 3233 |
{ name = "yfinance", specifier = ">=0.2.40" },
|
| 3234 |
]
|
|
|
|
| 5017 |
{ url = "https://files.pythonhosted.org/packages/e4/16/c1fd27e9549f3c4baf1dc9c20c456cd2f822dbf8de9f463824b0c0357e06/uvloop-0.22.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:6cde23eeda1a25c75b2e07d39970f3374105d5eafbaab2a4482be82f272d5a5e", size = 4296730, upload-time = "2025-10-16T22:17:00.744Z" },
|
| 5018 |
]
|
| 5019 |
|
| 5020 |
+
[[package]]
|
| 5021 |
+
name = "vadersentiment"
|
| 5022 |
+
version = "3.3.2"
|
| 5023 |
+
source = { registry = "https://pypi.org/simple" }
|
| 5024 |
+
dependencies = [
|
| 5025 |
+
{ name = "requests" },
|
| 5026 |
+
]
|
| 5027 |
+
sdist = { url = "https://files.pythonhosted.org/packages/77/8c/4a48c10a50f750ae565e341e697d74a38075a3e43ff0df6f1ab72e186902/vaderSentiment-3.3.2.tar.gz", hash = "sha256:5d7c06e027fc8b99238edb0d53d970cf97066ef97654009890b83703849632f9", size = 2466783, upload-time = "2020-05-22T15:06:32.81Z" }
|
| 5028 |
+
wheels = [
|
| 5029 |
+
{ url = "https://files.pythonhosted.org/packages/76/fc/310e16254683c1ed35eeb97386986d6c00bc29df17ce280aed64d55537e9/vaderSentiment-3.3.2-py2.py3-none-any.whl", hash = "sha256:3bf1d243b98b1afad575b9f22bc2cb1e212b94ff89ca74f8a23a588d024ea311", size = 125950, upload-time = "2020-05-22T15:07:00.052Z" },
|
| 5030 |
+
]
|
| 5031 |
+
|
| 5032 |
[[package]]
|
| 5033 |
name = "virtualenv"
|
| 5034 |
version = "20.35.4"
|