BrianIsaac commited on
Commit
9cf801a
ยท
1 Parent(s): d038452

feat: add ReAct agent for Build Portfolio workflow

Browse files

- Add PortfolioBuilderAgent with LangGraph and 7 financial tools
- Add goal wizard UI with investment goals, risk tolerance, constraints
- Enable Build Portfolio task in workflow router
- Add langchain-anthropic and langchain-core dependencies
- Optimise historical price data to return summaries

.gitignore CHANGED
@@ -198,4 +198,6 @@ cython_debug/
198
  *.claude/
199
 
200
  # Research Documents
201
- *finance-agentic-ai/.playwright-mcp/
 
 
 
198
  *.claude/
199
 
200
  # Research Documents
201
+ *finance-agentic-ai/
202
+ *.playwright-mcp/
203
+ *thoughts/
app.py CHANGED
@@ -1909,13 +1909,13 @@ def create_interface() -> gr.Blocks:
1909
  size="lg"
1910
  )
1911
 
1912
- # Build Portfolio - coming soon
1913
  with gr.Column(scale=1, min_width=200):
1914
  task_build_btn = gr.Button(
1915
- value="๐Ÿ—๏ธ\n\nBuild Portfolio\n\nAI helps construct a portfolio based on your goals\n\n๐Ÿ”’ Coming Soon",
1916
  elem_classes=["task-card"],
1917
- variant="secondary",
1918
- interactive=False,
1919
  size="lg"
1920
  )
1921
 
@@ -1939,6 +1939,76 @@ def create_interface() -> gr.Blocks:
1939
  size="lg"
1940
  )
1941
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1942
  # Input Page with side-by-side layout (hidden until authenticated)
1943
  with gr.Group(visible=False) as input_page:
1944
  # Side-by-side input and preview (2:3 ratio for wider preview)
@@ -2375,7 +2445,8 @@ def create_interface() -> gr.Blocks:
2375
  task_page: gr.update(visible=False),
2376
  input_page: gr.update(visible=True),
2377
  results_page: gr.update(visible=False),
2378
- history_page: gr.update(visible=False)
 
2379
  }
2380
 
2381
  def show_task_page():
@@ -2383,9 +2454,139 @@ def create_interface() -> gr.Blocks:
2383
  task_page: gr.update(visible=True),
2384
  input_page: gr.update(visible=False),
2385
  results_page: gr.update(visible=False),
2386
- history_page: gr.update(visible=False)
 
2387
  }
2388
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
2389
  async def load_history(session_state):
2390
  """Load analysis history from database.
2391
 
@@ -3456,13 +3657,62 @@ Please try again with different parameters.
3456
  # Navigation event handlers
3457
  nav_new_analysis.click(
3458
  show_task_page,
3459
- outputs=[task_page, input_page, results_page, history_page]
3460
  )
3461
 
3462
  # Task card event handlers
3463
  task_analyse_btn.click(
3464
  show_input_page,
3465
- outputs=[task_page, input_page, results_page, history_page]
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
3466
  )
3467
 
3468
  nav_view_history.click(
 
1909
  size="lg"
1910
  )
1911
 
1912
+ # Build Portfolio - enabled
1913
  with gr.Column(scale=1, min_width=200):
1914
  task_build_btn = gr.Button(
1915
+ value="๐Ÿ—๏ธ\n\nBuild Portfolio\n\nAI helps construct a portfolio based on your goals",
1916
  elem_classes=["task-card"],
1917
+ variant="primary",
1918
+ interactive=True,
1919
  size="lg"
1920
  )
1921
 
 
1939
  size="lg"
1940
  )
1941
 
1942
+ # Build Portfolio Page (shown when Build Portfolio task selected)
1943
+ with gr.Group(visible=False, elem_classes="build-portfolio-container") as build_page:
1944
+ gr.Markdown("## Build Your Portfolio", elem_classes="section-title")
1945
+ gr.Markdown("Tell us about your investment goals and we'll build a portfolio for you.")
1946
+
1947
+ with gr.Row(equal_height=True):
1948
+ # Left column: Goal settings
1949
+ with gr.Column(scale=2):
1950
+ with gr.Group(elem_classes="input-card"):
1951
+ build_goals = gr.CheckboxGroup(
1952
+ choices=["Growth", "Income", "Capital Preservation", "Speculation"],
1953
+ label="Investment Goals",
1954
+ info="Select one or more investment objectives"
1955
+ )
1956
+
1957
+ build_risk_tolerance = gr.Slider(
1958
+ minimum=1,
1959
+ maximum=10,
1960
+ value=5,
1961
+ step=1,
1962
+ label="Risk Tolerance",
1963
+ info="1 = Very Conservative, 10 = Very Aggressive"
1964
+ )
1965
+
1966
+ build_constraints = gr.Textbox(
1967
+ label="Constraints (Optional)",
1968
+ placeholder="e.g., 'No crypto, ESG focused, minimum 10 stocks, max 5% per position'",
1969
+ lines=3,
1970
+ info="Any specific requirements or restrictions"
1971
+ )
1972
+
1973
+ build_show_reasoning = gr.Checkbox(
1974
+ label="Show AI Reasoning",
1975
+ value=False,
1976
+ info="Display the AI's thought process and tool usage"
1977
+ )
1978
+
1979
+ with gr.Row():
1980
+ build_submit_btn = gr.Button(
1981
+ "Build My Portfolio",
1982
+ variant="primary",
1983
+ size="lg"
1984
+ )
1985
+ build_back_btn = gr.Button(
1986
+ "Back",
1987
+ variant="secondary",
1988
+ size="lg"
1989
+ )
1990
+
1991
+ # Right column: Results
1992
+ with gr.Column(scale=3):
1993
+ with gr.Group(elem_classes="preview-card", visible=False) as build_results_container:
1994
+ build_status = gr.Markdown("", elem_classes="build-status")
1995
+
1996
+ with gr.Group(visible=False) as build_reasoning_container:
1997
+ gr.Markdown("### AI Reasoning")
1998
+ build_reasoning_trace = gr.JSON(label="Reasoning Trace")
1999
+
2000
+ build_portfolio_table = gr.Dataframe(
2001
+ headers=["Ticker", "Allocation %", "Reasoning"],
2002
+ label="Suggested Portfolio",
2003
+ interactive=False
2004
+ )
2005
+
2006
+ build_final_response = gr.Markdown("", label="Summary")
2007
+
2008
+ with gr.Row():
2009
+ build_accept_btn = gr.Button("Accept & Analyse", variant="primary", size="sm")
2010
+ build_regenerate_btn = gr.Button("Regenerate", variant="secondary", size="sm")
2011
+
2012
  # Input Page with side-by-side layout (hidden until authenticated)
2013
  with gr.Group(visible=False) as input_page:
2014
  # Side-by-side input and preview (2:3 ratio for wider preview)
 
2445
  task_page: gr.update(visible=False),
2446
  input_page: gr.update(visible=True),
2447
  results_page: gr.update(visible=False),
2448
+ history_page: gr.update(visible=False),
2449
+ build_page: gr.update(visible=False)
2450
  }
2451
 
2452
  def show_task_page():
 
2454
  task_page: gr.update(visible=True),
2455
  input_page: gr.update(visible=False),
2456
  results_page: gr.update(visible=False),
2457
+ history_page: gr.update(visible=False),
2458
+ build_page: gr.update(visible=False)
2459
  }
2460
 
2461
+ def show_build_page():
2462
+ return {
2463
+ task_page: gr.update(visible=False),
2464
+ input_page: gr.update(visible=False),
2465
+ results_page: gr.update(visible=False),
2466
+ history_page: gr.update(visible=False),
2467
+ build_page: gr.update(visible=True)
2468
+ }
2469
+
2470
+ async def handle_build_portfolio(goals, risk_tolerance, constraints, show_reasoning, session_state):
2471
+ """Handle the Build Portfolio workflow.
2472
+
2473
+ Args:
2474
+ goals: List of selected investment goals
2475
+ risk_tolerance: Risk tolerance score 1-10
2476
+ constraints: User constraints text
2477
+ show_reasoning: Whether to show reasoning trace
2478
+ session_state: User session
2479
+
2480
+ Returns:
2481
+ Tuple of UI updates for build results
2482
+ """
2483
+ try:
2484
+ if not goals:
2485
+ return (
2486
+ gr.update(visible=True), # build_results_container
2487
+ "Please select at least one investment goal.", # build_status
2488
+ gr.update(visible=False), # build_reasoning_container
2489
+ [], # build_reasoning_trace
2490
+ [], # build_portfolio_table
2491
+ "" # build_final_response
2492
+ )
2493
+
2494
+ # Initialise MCP router and workflow router
2495
+ from backend.mcp_router import MCPRouter
2496
+ from backend.agents.workflow_router import WorkflowRouter
2497
+
2498
+ mcp_router = MCPRouter()
2499
+ workflow_router = WorkflowRouter(mcp_router)
2500
+
2501
+ # Run the build workflow
2502
+ result = await workflow_router.route_build(
2503
+ goals=goals,
2504
+ risk_tolerance=int(risk_tolerance),
2505
+ constraints=constraints or ""
2506
+ )
2507
+
2508
+ # Extract portfolio data for table
2509
+ portfolio_data = []
2510
+ for item in result.get("portfolio", []):
2511
+ portfolio_data.append([
2512
+ item.get("ticker", ""),
2513
+ item.get("allocation", 0),
2514
+ item.get("reasoning", "")
2515
+ ])
2516
+
2517
+ # If no portfolio extracted, show the final response
2518
+ if not portfolio_data and result.get("final_response"):
2519
+ return (
2520
+ gr.update(visible=True),
2521
+ "Portfolio built successfully!",
2522
+ gr.update(visible=show_reasoning),
2523
+ result.get("reasoning_trace", []),
2524
+ [],
2525
+ result.get("final_response", "")
2526
+ )
2527
+
2528
+ return (
2529
+ gr.update(visible=True),
2530
+ "Portfolio built successfully!",
2531
+ gr.update(visible=show_reasoning),
2532
+ result.get("reasoning_trace", []),
2533
+ portfolio_data,
2534
+ result.get("final_response", "")
2535
+ )
2536
+
2537
+ except Exception as e:
2538
+ logger.error(f"Build portfolio error: {e}")
2539
+ return (
2540
+ gr.update(visible=True),
2541
+ f"Error building portfolio: {str(e)}",
2542
+ gr.update(visible=False),
2543
+ [],
2544
+ [],
2545
+ ""
2546
+ )
2547
+
2548
+ def sync_handle_build_portfolio(goals, risk_tolerance, constraints, show_reasoning, session_state):
2549
+ """Synchronous wrapper for handle_build_portfolio."""
2550
+ return asyncio.run(handle_build_portfolio(goals, risk_tolerance, constraints, show_reasoning, session_state))
2551
+
2552
+ def handle_build_accept(portfolio_table):
2553
+ """Accept built portfolio and populate input for analysis.
2554
+
2555
+ Args:
2556
+ portfolio_table: DataFrame with ticker and allocation data
2557
+
2558
+ Returns:
2559
+ Tuple of values for each output component
2560
+ """
2561
+ if portfolio_table is None or len(portfolio_table) == 0:
2562
+ return (
2563
+ "", # portfolio_input
2564
+ gr.update(visible=False), # task_page
2565
+ gr.update(visible=True), # input_page
2566
+ gr.update(visible=False), # results_page
2567
+ gr.update(visible=False), # history_page
2568
+ gr.update(visible=False) # build_page
2569
+ )
2570
+
2571
+ # Convert portfolio table to input format
2572
+ lines = []
2573
+ for row in portfolio_table:
2574
+ ticker = row[0] if len(row) > 0 else ""
2575
+ allocation = row[1] if len(row) > 1 else 0
2576
+ if ticker:
2577
+ lines.append(f"{ticker} {allocation}%")
2578
+
2579
+ portfolio_text = "\n".join(lines)
2580
+
2581
+ return (
2582
+ portfolio_text, # portfolio_input
2583
+ gr.update(visible=False), # task_page
2584
+ gr.update(visible=True), # input_page
2585
+ gr.update(visible=False), # results_page
2586
+ gr.update(visible=False), # history_page
2587
+ gr.update(visible=False) # build_page
2588
+ )
2589
+
2590
  async def load_history(session_state):
2591
  """Load analysis history from database.
2592
 
 
3657
  # Navigation event handlers
3658
  nav_new_analysis.click(
3659
  show_task_page,
3660
+ outputs=[task_page, input_page, results_page, history_page, build_page]
3661
  )
3662
 
3663
  # Task card event handlers
3664
  task_analyse_btn.click(
3665
  show_input_page,
3666
+ outputs=[task_page, input_page, results_page, history_page, build_page]
3667
+ )
3668
+
3669
+ task_build_btn.click(
3670
+ show_build_page,
3671
+ outputs=[task_page, input_page, results_page, history_page, build_page]
3672
+ )
3673
+
3674
+ # Build page event handlers
3675
+ build_back_btn.click(
3676
+ show_task_page,
3677
+ outputs=[task_page, input_page, results_page, history_page, build_page]
3678
+ )
3679
+
3680
+ build_submit_btn.click(
3681
+ sync_handle_build_portfolio,
3682
+ inputs=[build_goals, build_risk_tolerance, build_constraints, build_show_reasoning, session_state],
3683
+ outputs=[
3684
+ build_results_container,
3685
+ build_status,
3686
+ build_reasoning_container,
3687
+ build_reasoning_trace,
3688
+ build_portfolio_table,
3689
+ build_final_response
3690
+ ]
3691
+ )
3692
+
3693
+ build_show_reasoning.change(
3694
+ lambda x: gr.update(visible=x),
3695
+ inputs=[build_show_reasoning],
3696
+ outputs=[build_reasoning_container]
3697
+ )
3698
+
3699
+ build_accept_btn.click(
3700
+ handle_build_accept,
3701
+ inputs=[build_portfolio_table],
3702
+ outputs=[portfolio_input, task_page, input_page, results_page, history_page, build_page]
3703
+ )
3704
+
3705
+ build_regenerate_btn.click(
3706
+ sync_handle_build_portfolio,
3707
+ inputs=[build_goals, build_risk_tolerance, build_constraints, build_show_reasoning, session_state],
3708
+ outputs=[
3709
+ build_results_container,
3710
+ build_status,
3711
+ build_reasoning_container,
3712
+ build_reasoning_trace,
3713
+ build_portfolio_table,
3714
+ build_final_response
3715
+ ]
3716
  )
3717
 
3718
  nav_view_history.click(
backend/agents/react_agent.py ADDED
@@ -0,0 +1,411 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """ReAct agent for dynamic portfolio building.
2
+
3
+ Uses LangGraph for tool calling and dynamic routing based on user goals.
4
+ """
5
+
6
+ from typing import Annotated, Literal, Any
7
+ from typing_extensions import TypedDict
8
+ import logging
9
+ import re
10
+
11
+ from langgraph.graph import StateGraph, START, END
12
+ from langgraph.graph.message import add_messages
13
+ from langgraph.prebuilt import ToolNode
14
+ from langchain_core.messages import ToolMessage, HumanMessage
15
+ from langchain_core.tools import tool
16
+ from langchain_anthropic import ChatAnthropic
17
+
18
+ from backend.config import settings
19
+
20
+ logger = logging.getLogger(__name__)
21
+
22
+
23
+ class ReactAgentState(TypedDict):
24
+ """State for ReAct agent."""
25
+ messages: Annotated[list, add_messages]
26
+ user_goals: list[str]
27
+ risk_tolerance: int
28
+ constraints: str
29
+ selected_tools: list[str]
30
+ portfolio_result: dict
31
+
32
+
33
+ class PortfolioBuilderAgent:
34
+ """ReAct agent for building portfolios based on user goals.
35
+
36
+ Uses LangGraph with tool calling for dynamic tool selection based on
37
+ user-specified investment goals, risk tolerance, and constraints.
38
+ """
39
+
40
+ def __init__(self, mcp_router):
41
+ """Initialise the portfolio builder agent.
42
+
43
+ Args:
44
+ mcp_router: MCP router instance for calling MCP servers
45
+ """
46
+ self.mcp_router = mcp_router
47
+ self.model = ChatAnthropic(
48
+ model=settings.anthropic_model,
49
+ api_key=settings.anthropic_api_key,
50
+ max_tokens=4096,
51
+ )
52
+ self.tools = self._register_tools()
53
+ self.tools_by_name = {t.name: t for t in self.tools}
54
+ self.workflow = self._build_workflow()
55
+
56
+ def _register_tools(self) -> list:
57
+ """Register available tools for portfolio building."""
58
+
59
+ mcp_router = self.mcp_router
60
+
61
+ @tool
62
+ async def fetch_market_data(tickers: list[str]) -> dict:
63
+ """Fetch current market data for given tickers.
64
+
65
+ Args:
66
+ tickers: List of stock ticker symbols to fetch data for
67
+
68
+ Returns:
69
+ Dictionary mapping tickers to their market data
70
+ """
71
+ results = {}
72
+ for ticker in tickers:
73
+ try:
74
+ data = await mcp_router.call_yahoo_finance_mcp(
75
+ "get_quote", {"tickers": [ticker]}
76
+ )
77
+ results[ticker] = data
78
+ except Exception as e:
79
+ logger.error(f"Error fetching market data for {ticker}: {e}")
80
+ results[ticker] = {"error": str(e)}
81
+ return results
82
+
83
+ @tool
84
+ async def get_fundamentals(ticker: str) -> dict:
85
+ """Get fundamental analysis for a ticker.
86
+
87
+ Args:
88
+ ticker: Stock ticker symbol
89
+
90
+ Returns:
91
+ Fundamental data including financials, ratios, and metrics
92
+ """
93
+ try:
94
+ return await mcp_router.call_fmp_mcp(
95
+ "get_company_profile", {"ticker": ticker}
96
+ )
97
+ except Exception as e:
98
+ logger.error(f"Error getting fundamentals for {ticker}: {e}")
99
+ return {"error": str(e)}
100
+
101
+ @tool
102
+ async def get_historical_prices(ticker: str, period: str = "3mo") -> dict:
103
+ """Get historical price data summary for a ticker.
104
+
105
+ Args:
106
+ ticker: Stock ticker symbol
107
+ period: Time period (e.g., '1mo', '3mo', '6mo')
108
+
109
+ Returns:
110
+ Summary of historical price data with key statistics
111
+ """
112
+ try:
113
+ data = await mcp_router.call_yahoo_finance_mcp(
114
+ "get_historical_data", {"ticker": ticker, "period": period}
115
+ )
116
+ if not data or "error" in data:
117
+ return data
118
+
119
+ prices = data.get("close_prices", [])
120
+ if not prices:
121
+ return {"error": "No price data available"}
122
+
123
+ return {
124
+ "ticker": ticker,
125
+ "period": period,
126
+ "data_points": len(prices),
127
+ "latest_price": prices[-1] if prices else None,
128
+ "first_price": prices[0] if prices else None,
129
+ "price_change_pct": round(((prices[-1] - prices[0]) / prices[0]) * 100, 2) if prices else None,
130
+ "high": max(prices) if prices else None,
131
+ "low": min(prices) if prices else None,
132
+ "avg": round(sum(prices) / len(prices), 2) if prices else None,
133
+ }
134
+ except Exception as e:
135
+ logger.error(f"Error getting historical prices for {ticker}: {e}")
136
+ return {"error": str(e)}
137
+
138
+ @tool
139
+ async def calculate_technicals(ticker: str, prices: list[float]) -> dict:
140
+ """Calculate technical indicators for a ticker.
141
+
142
+ Args:
143
+ ticker: Stock ticker symbol
144
+ prices: List of historical closing prices
145
+
146
+ Returns:
147
+ Technical indicators including RSI, MACD, Bollinger Bands, etc.
148
+ """
149
+ try:
150
+ return await mcp_router.call_feature_extraction_mcp(
151
+ "extract_technical_features",
152
+ {
153
+ "ticker": ticker,
154
+ "prices": prices,
155
+ "include_momentum": True,
156
+ "include_volatility": True,
157
+ "include_trend": True,
158
+ }
159
+ )
160
+ except Exception as e:
161
+ logger.error(f"Error calculating technicals for {ticker}: {e}")
162
+ return {"error": str(e)}
163
+
164
+ @tool
165
+ async def optimise_allocation(
166
+ tickers: list[str],
167
+ returns_data: dict[str, list[float]]
168
+ ) -> dict:
169
+ """Optimise portfolio allocation using Hierarchical Risk Parity.
170
+
171
+ Args:
172
+ tickers: List of ticker symbols
173
+ returns_data: Dictionary mapping tickers to their return series
174
+
175
+ Returns:
176
+ Optimised weights for each ticker
177
+ """
178
+ try:
179
+ return await mcp_router.call_portfolio_optimizer_mcp(
180
+ "optimize_hrp", {"tickers": tickers, "returns": returns_data}
181
+ )
182
+ except Exception as e:
183
+ logger.error(f"Error optimising allocation: {e}")
184
+ return {"error": str(e)}
185
+
186
+ @tool
187
+ async def assess_risk(
188
+ weights: dict[str, float],
189
+ returns_data: dict[str, list[float]]
190
+ ) -> dict:
191
+ """Assess portfolio risk metrics.
192
+
193
+ Args:
194
+ weights: Dictionary mapping tickers to allocation weights
195
+ returns_data: Dictionary mapping tickers to their return series
196
+
197
+ Returns:
198
+ Risk metrics including VaR, CVaR, volatility, etc.
199
+ """
200
+ try:
201
+ return await mcp_router.call_risk_analyzer_mcp(
202
+ "analyze_risk", {"weights": weights, "returns": returns_data}
203
+ )
204
+ except Exception as e:
205
+ logger.error(f"Error assessing risk: {e}")
206
+ return {"error": str(e)}
207
+
208
+ @tool
209
+ async def get_news_sentiment(ticker: str) -> dict:
210
+ """Get news sentiment analysis for a ticker.
211
+
212
+ Args:
213
+ ticker: Stock ticker symbol
214
+
215
+ Returns:
216
+ Sentiment scores and recent news analysis
217
+ """
218
+ try:
219
+ return await mcp_router.call_news_sentiment_mcp(
220
+ "analyze_sentiment", {"ticker": ticker}
221
+ )
222
+ except Exception as e:
223
+ logger.error(f"Error getting news sentiment for {ticker}: {e}")
224
+ return {"error": str(e)}
225
+
226
+ return [
227
+ fetch_market_data,
228
+ get_fundamentals,
229
+ get_historical_prices,
230
+ calculate_technicals,
231
+ optimise_allocation,
232
+ assess_risk,
233
+ get_news_sentiment,
234
+ ]
235
+
236
+ def _build_workflow(self):
237
+ """Build ReAct workflow with tool calling."""
238
+
239
+ builder = StateGraph(ReactAgentState)
240
+
241
+ async def agent_node(state: ReactAgentState) -> dict:
242
+ """Call model with available tools."""
243
+ system_prompt = self._build_system_prompt(state)
244
+ model_with_tools = self.model.bind_tools(self.tools)
245
+
246
+ messages = [
247
+ {"role": "system", "content": system_prompt}
248
+ ] + state["messages"]
249
+
250
+ response = await model_with_tools.ainvoke(messages)
251
+ return {"messages": [response]}
252
+
253
+ tool_node = ToolNode(tools=self.tools)
254
+
255
+ def should_continue(state: ReactAgentState) -> Literal["tools", "end"]:
256
+ """Determine if agent should continue or finish."""
257
+ last_message = state["messages"][-1]
258
+
259
+ if not hasattr(last_message, 'tool_calls') or not last_message.tool_calls:
260
+ return "end"
261
+
262
+ return "tools"
263
+
264
+ builder.add_node("agent", agent_node)
265
+ builder.add_node("tools", tool_node)
266
+
267
+ builder.add_edge(START, "agent")
268
+ builder.add_conditional_edges(
269
+ "agent",
270
+ should_continue,
271
+ {"tools": "tools", "end": END}
272
+ )
273
+ builder.add_edge("tools", "agent")
274
+
275
+ return builder.compile()
276
+
277
+ def _build_system_prompt(self, state: ReactAgentState) -> str:
278
+ """Build system prompt based on user goals.
279
+
280
+ Args:
281
+ state: Current agent state with user preferences
282
+
283
+ Returns:
284
+ Formatted system prompt
285
+ """
286
+ goals = ", ".join(state["user_goals"]) if state["user_goals"] else "General investing"
287
+ risk = state["risk_tolerance"]
288
+ constraints = state["constraints"] or "None specified"
289
+
290
+ return f"""You are an expert portfolio builder. Build a portfolio based on:
291
+
292
+ Goals: {goals}
293
+ Risk Tolerance: {risk}/10
294
+ Constraints: {constraints}
295
+
296
+ Use the available tools to:
297
+ 1. Research suitable stocks/ETFs based on the goals
298
+ 2. Fetch market data and fundamentals
299
+ 3. Calculate technical indicators
300
+ 4. Optimise allocation using HRP
301
+ 5. Assess overall risk
302
+
303
+ Think step-by-step and explain your reasoning. When you have a final portfolio
304
+ recommendation, provide it in this format:
305
+
306
+ PORTFOLIO RECOMMENDATION:
307
+ - [TICKER]: [ALLOCATION]% - [REASONING]
308
+ - [TICKER]: [ALLOCATION]% - [REASONING]
309
+ ...
310
+
311
+ TOTAL EXPECTED RETURN: [X]%
312
+ ESTIMATED RISK (VOLATILITY): [Y]%
313
+ KEY CONSIDERATIONS: [Summary]"""
314
+
315
+ async def run(
316
+ self,
317
+ goals: list[str],
318
+ risk_tolerance: int,
319
+ constraints: str
320
+ ) -> dict:
321
+ """Run the ReAct agent to build a portfolio.
322
+
323
+ Args:
324
+ goals: List of investment goals (e.g., ['Growth', 'Income'])
325
+ risk_tolerance: Risk tolerance score from 1-10
326
+ constraints: User-specified constraints as text
327
+
328
+ Returns:
329
+ Dictionary containing portfolio, reasoning trace, and messages
330
+ """
331
+ logger.info(f"Building portfolio with goals={goals}, risk={risk_tolerance}")
332
+
333
+ initial_state: ReactAgentState = {
334
+ "messages": [
335
+ HumanMessage(content="Build me a portfolio based on my goals and constraints.")
336
+ ],
337
+ "user_goals": goals,
338
+ "risk_tolerance": risk_tolerance,
339
+ "constraints": constraints,
340
+ "selected_tools": [],
341
+ "portfolio_result": {}
342
+ }
343
+
344
+ result = await self.workflow.ainvoke(initial_state)
345
+
346
+ reasoning_trace = self._extract_reasoning_trace(result["messages"])
347
+ portfolio = self._extract_portfolio(result["messages"])
348
+
349
+ return {
350
+ "portfolio": portfolio,
351
+ "reasoning_trace": reasoning_trace,
352
+ "final_response": result["messages"][-1].content if result["messages"] else ""
353
+ }
354
+
355
+ def _extract_reasoning_trace(self, messages: list) -> list[dict]:
356
+ """Extract reasoning trace from message history.
357
+
358
+ Args:
359
+ messages: List of messages from the workflow
360
+
361
+ Returns:
362
+ List of reasoning steps with thoughts, actions, and observations
363
+ """
364
+ reasoning_trace = []
365
+
366
+ for msg in messages:
367
+ if hasattr(msg, 'tool_calls') and msg.tool_calls:
368
+ for tc in msg.tool_calls:
369
+ reasoning_trace.append({
370
+ "thought": msg.content if msg.content else "Executing tool",
371
+ "action": tc["name"],
372
+ "args": tc["args"]
373
+ })
374
+ elif isinstance(msg, ToolMessage):
375
+ content = msg.content
376
+ if isinstance(content, str) and len(content) > 500:
377
+ content = content[:500] + "..."
378
+ reasoning_trace.append({
379
+ "observation": content
380
+ })
381
+
382
+ return reasoning_trace
383
+
384
+ def _extract_portfolio(self, messages: list) -> list[dict]:
385
+ """Extract portfolio recommendations from final message.
386
+
387
+ Args:
388
+ messages: List of messages from the workflow
389
+
390
+ Returns:
391
+ List of portfolio holdings with ticker, allocation, and reasoning
392
+ """
393
+ if not messages:
394
+ return []
395
+
396
+ final_message = messages[-1]
397
+ content = final_message.content if hasattr(final_message, 'content') else ""
398
+
399
+ portfolio = []
400
+ lines = content.split('\n')
401
+
402
+ for line in lines:
403
+ match = re.match(r'[-โ€ข]\s*([A-Z]{1,5}):\s*(\d+(?:\.\d+)?)\s*%\s*[-โ€“]\s*(.+)', line)
404
+ if match:
405
+ portfolio.append({
406
+ "ticker": match.group(1),
407
+ "allocation": float(match.group(2)),
408
+ "reasoning": match.group(3).strip()
409
+ })
410
+
411
+ return portfolio
backend/agents/workflow_router.py CHANGED
@@ -8,6 +8,7 @@ from enum import Enum
8
  from typing import Optional, Dict, Any, List
9
 
10
  from backend.agents.workflow import PortfolioAnalysisWorkflow
 
11
  from backend.agents.personas import PersonaType
12
  from backend.models.agent_state import AgentState
13
 
@@ -98,7 +99,10 @@ class WorkflowRouter:
98
  return await workflow.run(initial_state)
99
 
100
  elif task == TaskType.BUILD:
101
- raise ValueError("Build Portfolio workflow not yet implemented")
 
 
 
102
 
103
  elif task == TaskType.COMPARE:
104
  raise ValueError("Compare Strategies workflow not yet implemented")
@@ -109,6 +113,25 @@ class WorkflowRouter:
109
  else:
110
  raise ValueError(f"Unknown task type: {task}")
111
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
112
  def get_available_tasks(self) -> list[Dict[str, Any]]:
113
  """Get list of available tasks with their status.
114
 
@@ -129,7 +152,7 @@ class WorkflowRouter:
129
  "title": "Build Portfolio",
130
  "description": "AI helps construct a portfolio based on your goals",
131
  "icon": "hammer",
132
- "enabled": False,
133
  "task_type": TaskType.BUILD,
134
  },
135
  {
 
8
  from typing import Optional, Dict, Any, List
9
 
10
  from backend.agents.workflow import PortfolioAnalysisWorkflow
11
+ from backend.agents.react_agent import PortfolioBuilderAgent
12
  from backend.agents.personas import PersonaType
13
  from backend.models.agent_state import AgentState
14
 
 
99
  return await workflow.run(initial_state)
100
 
101
  elif task == TaskType.BUILD:
102
+ raise ValueError(
103
+ "Build Portfolio requires different parameters. "
104
+ "Use route_build() method instead."
105
+ )
106
 
107
  elif task == TaskType.COMPARE:
108
  raise ValueError("Compare Strategies workflow not yet implemented")
 
113
  else:
114
  raise ValueError(f"Unknown task type: {task}")
115
 
116
+ async def route_build(
117
+ self,
118
+ goals: List[str],
119
+ risk_tolerance: int,
120
+ constraints: str
121
+ ) -> Dict[str, Any]:
122
+ """Route to the Build Portfolio workflow.
123
+
124
+ Args:
125
+ goals: List of investment goals (e.g., ['Growth', 'Income'])
126
+ risk_tolerance: Risk tolerance score from 1-10
127
+ constraints: User-specified constraints as text
128
+
129
+ Returns:
130
+ Build result with portfolio, reasoning trace, and final response
131
+ """
132
+ agent = PortfolioBuilderAgent(self.mcp_router)
133
+ return await agent.run(goals, risk_tolerance, constraints)
134
+
135
  def get_available_tasks(self) -> list[Dict[str, Any]]:
136
  """Get list of available tasks with their status.
137
 
 
152
  "title": "Build Portfolio",
153
  "description": "AI helps construct a portfolio based on your goals",
154
  "icon": "hammer",
155
+ "enabled": True,
156
  "task_type": TaskType.BUILD,
157
  },
158
  {
pyproject.toml CHANGED
@@ -11,6 +11,8 @@ dependencies = [
11
  "instructor>=1.6.4",
12
  # Agent Orchestration
13
  "langgraph>=0.1.0",
 
 
14
  # MCP Framework
15
  "fastmcp>=2.12.5",
16
  "mcp>=1.15.0",
 
11
  "instructor>=1.6.4",
12
  # Agent Orchestration
13
  "langgraph>=0.1.0",
14
+ "langchain-anthropic>=0.1.0",
15
+ "langchain-core>=0.2.0",
16
  # MCP Framework
17
  "fastmcp>=2.12.5",
18
  "mcp>=1.15.0",
uv.lock CHANGED
@@ -2064,6 +2064,20 @@ wheels = [
2064
  { url = "https://files.pythonhosted.org/packages/80/be/3578e8afd18c88cdf9cb4cffde75a96d2be38c5a903f1ed0ceec061bd09e/kiwisolver-1.4.9-cp314-cp314t-win_arm64.whl", hash = "sha256:4a48a2ce79d65d363597ef7b567ce3d14d68783d2b2263d98db3d9477805ba32", size = 70260, upload-time = "2025-08-10T21:27:36.606Z" },
2065
  ]
2066
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
2067
  [[package]]
2068
  name = "langchain-core"
2069
  version = "1.0.5"
@@ -3476,6 +3490,8 @@ dependencies = [
3476
  { name = "granite-tsfm" },
3477
  { name = "httpx" },
3478
  { name = "instructor" },
 
 
3479
  { name = "langgraph" },
3480
  { name = "matplotlib" },
3481
  { name = "mcp" },
@@ -3531,6 +3547,8 @@ requires-dist = [
3531
  { name = "granite-tsfm", specifier = ">=0.2.22" },
3532
  { name = "httpx", specifier = ">=0.25.0" },
3533
  { name = "instructor", specifier = ">=1.6.4" },
 
 
3534
  { name = "langgraph", specifier = ">=0.1.0" },
3535
  { name = "matplotlib", specifier = ">=3.8.0" },
3536
  { name = "mcp", specifier = ">=1.15.0" },
 
2064
  { url = "https://files.pythonhosted.org/packages/80/be/3578e8afd18c88cdf9cb4cffde75a96d2be38c5a903f1ed0ceec061bd09e/kiwisolver-1.4.9-cp314-cp314t-win_arm64.whl", hash = "sha256:4a48a2ce79d65d363597ef7b567ce3d14d68783d2b2263d98db3d9477805ba32", size = 70260, upload-time = "2025-08-10T21:27:36.606Z" },
2065
  ]
2066
 
2067
+ [[package]]
2068
+ name = "langchain-anthropic"
2069
+ version = "1.1.0"
2070
+ source = { registry = "https://pypi.org/simple" }
2071
+ dependencies = [
2072
+ { name = "anthropic" },
2073
+ { name = "langchain-core" },
2074
+ { name = "pydantic" },
2075
+ ]
2076
+ sdist = { url = "https://files.pythonhosted.org/packages/d2/d3/8ac7d664e87aa9bdf5f6a4d55324316933aec8beb1690932dbe7b63416e2/langchain_anthropic-1.1.0.tar.gz", hash = "sha256:427e102b76a417fb5713e81e853225e7459d71fc7abdf4d86722f0e01ad43845", size = 684087, upload-time = "2025-11-17T21:31:31.146Z" }
2077
+ wheels = [
2078
+ { url = "https://files.pythonhosted.org/packages/aa/95/f340acdd8d9f392606f026a1326a33cfb1f9b172c7607c70ab0c2b43d74d/langchain_anthropic-1.1.0-py3-none-any.whl", hash = "sha256:2593f10b984448e31a9fd486ab2f7ebc8a0f7f82ba1ca477339e4f5ddd7f0e8d", size = 47793, upload-time = "2025-11-17T21:31:29.979Z" },
2079
+ ]
2080
+
2081
  [[package]]
2082
  name = "langchain-core"
2083
  version = "1.0.5"
 
3490
  { name = "granite-tsfm" },
3491
  { name = "httpx" },
3492
  { name = "instructor" },
3493
+ { name = "langchain-anthropic" },
3494
+ { name = "langchain-core" },
3495
  { name = "langgraph" },
3496
  { name = "matplotlib" },
3497
  { name = "mcp" },
 
3547
  { name = "granite-tsfm", specifier = ">=0.2.22" },
3548
  { name = "httpx", specifier = ">=0.25.0" },
3549
  { name = "instructor", specifier = ">=1.6.4" },
3550
+ { name = "langchain-anthropic", specifier = ">=0.1.0" },
3551
+ { name = "langchain-core", specifier = ">=0.2.0" },
3552
  { name = "langgraph", specifier = ">=0.1.0" },
3553
  { name = "matplotlib", specifier = ">=3.8.0" },
3554
  { name = "mcp", specifier = ">=1.15.0" },