fudii0921 commited on
Commit
4ef6538
·
verified ·
1 Parent(s): 350361d

Upload 6 files

Browse files
agents/__init__.py ADDED
File without changes
agents/__pycache__/__init__.cpython-312.pyc ADDED
Binary file (171 Bytes). View file
 
agents/__pycache__/financial_agent.cpython-312.pyc ADDED
Binary file (17.5 kB). View file
 
agents/__pycache__/tools.cpython-312.pyc ADDED
Binary file (29.7 kB). View file
 
agents/financial_agent.py ADDED
@@ -0,0 +1,397 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import json
2
+ import operator
3
+ import re
4
+ from typing import Annotated, List, Tuple, TypedDict, Union
5
+
6
+ from langchain.agents import AgentExecutor, create_openai_tools_agent
7
+ from langchain.prompts import ChatPromptTemplate, MessagesPlaceholder
8
+ from langchain.schema import AIMessage, HumanMessage, SystemMessage
9
+ from langchain.tools import Tool
10
+ from langchain_openai import ChatOpenAI
11
+ from langchain_cohere import ChatCohere
12
+
13
+
14
+ class AgentState(TypedDict):
15
+ messages: Annotated[List[Union[HumanMessage, AIMessage]], operator.add]
16
+ context: dict
17
+
18
+
19
+ class FinancialAdvisorAgent:
20
+ def __init__(self, tools: List[Tool], openai_api_key: str):
21
+ #def __init__(self, tools: List[Tool], cohere_api_key: str):
22
+ self.tools = tools
23
+ self.llm = ChatOpenAI(
24
+ api_key=openai_api_key, model="gpt-4.1-mini-2025-04-14", temperature=0.7
25
+ )
26
+ #self.llm = ChatCohere(
27
+ #cohere_api_key=cohere_api_key, model="command-a-03-2025", temperature=0.1
28
+ #)
29
+ self.tools_by_name = {tool.name: tool for tool in tools}
30
+
31
+ # Create agent with tools
32
+ self.system_prompt = """You are a professional financial advisor AI assistant with access to specialized tools.
33
+
34
+ Available tools:
35
+ - budget_planner: Use when users ask about budgeting, income allocation, or expense planning. Input should be JSON with 'income' and 'expenses' keys.
36
+ - investment_analyzer: Use when users ask about specific stocks or investments. Input should be a stock symbol (e.g., AAPL).
37
+ - market_trends: Use when users ask about market trends or financial news. Input should be a search query.
38
+ - portfolio_analyzer: Use when users want to analyze their portfolio. Input should be JSON with 'holdings' array.
39
+
40
+ IMPORTANT: You MUST use these tools when answering financial questions. Do not provide generic advice without using the appropriate tool first.
41
+
42
+ When a user asks a question:
43
+ 1. Identify which tool is most appropriate
44
+ 2. Extract or request the necessary information
45
+ 3. Use the tool to get specific data
46
+ 4. Provide advice based on the tool's output"""
47
+
48
+ self.prompt = ChatPromptTemplate.from_messages(
49
+ [
50
+ ("system", self.system_prompt),
51
+ MessagesPlaceholder(variable_name="messages"),
52
+ ("human", "{input}"),
53
+ MessagesPlaceholder(variable_name="agent_scratchpad"),
54
+ ]
55
+ )
56
+
57
+ self.agent = create_openai_tools_agent(self.llm, self.tools, self.prompt)
58
+ self.agent_executor = AgentExecutor(
59
+ agent=self.agent,
60
+ tools=self.tools,
61
+ verbose=True,
62
+ return_intermediate_steps=True,
63
+ )
64
+
65
+ def _extract_tool_usage(self, intermediate_steps):
66
+ """Extract tool usage from intermediate steps"""
67
+ tools_used = []
68
+ tool_results = []
69
+
70
+ for action, result in intermediate_steps:
71
+ if hasattr(action, "tool"):
72
+ tools_used.append(action.tool)
73
+ tool_results.append(result)
74
+
75
+ # Return the last tool used and its result for backward compatibility
76
+ # But also return all tools and results for multi-tool scenarios
77
+ if tools_used:
78
+ return tools_used[-1], tool_results[-1], tools_used, tool_results
79
+ return None, None, [], []
80
+
81
+ def _prepare_tool_input(self, message: str, tool_name: str) -> str:
82
+ """Prepare input for specific tools based on the message"""
83
+ if tool_name == "investment_analyzer":
84
+ # Use OpenAI to extract stock symbol from natural language
85
+ extraction_prompt = f"""Extract the stock symbol from this message: "{message}"
86
+
87
+ If the user mentions a company name, return the corresponding stock ticker symbol.
88
+ If they mention a ticker symbol directly, return that symbol.
89
+ If no stock or company is mentioned, return "UNKNOWN".
90
+
91
+ Examples:
92
+ - "Tell me about NVIDIA" -> "NVDA"
93
+ - "Analyze AAPL stock" -> "AAPL"
94
+ - "How is Tesla doing?" -> "TSLA"
95
+ - "What about Microsoft stock?" -> "MSFT"
96
+
97
+ Return only the stock symbol, nothing else."""
98
+
99
+ try:
100
+ response = self.llm.invoke([
101
+ SystemMessage(content="You are a stock symbol extraction assistant. Return only the ticker symbol."),
102
+ HumanMessage(content=extraction_prompt)
103
+ ])
104
+
105
+ extracted_symbol = response.content.strip().upper()
106
+ if extracted_symbol and extracted_symbol != "UNKNOWN":
107
+ return extracted_symbol
108
+
109
+ except Exception:
110
+ pass
111
+
112
+ # Fallback to regex if LLM fails
113
+ symbols = re.findall(r"\b[A-Z]{2,5}\b", message)
114
+ return symbols[0] if symbols else ""
115
+
116
+ elif tool_name == "budget_planner":
117
+ # Use OpenAI to extract budget information from natural language
118
+ extraction_prompt = f"""Extract budget information from this message: "{message}"
119
+
120
+ Extract:
121
+ 1. Monthly income (if mentioned)
122
+ 2. Expenses by category (rent, food, utilities, transportation, etc.)
123
+
124
+ Return as JSON format:
125
+ {{"income": 5000, "expenses": {{"rent": 1500, "food": 500, "utilities": 200}}}}
126
+
127
+ If income is not mentioned, use 5000 as default.
128
+ If no expenses are mentioned, return empty expenses object.
129
+
130
+ Examples:
131
+ - "I make $6000 monthly, rent is $1800, food $600" -> {{"income": 6000, "expenses": {{"rent": 1800, "food": 600}}}}
132
+ - "Help with budget, income 4500, utilities 150" -> {{"income": 4500, "expenses": {{"utilities": 150}}}}
133
+
134
+ Return only valid JSON, nothing else. answer in japanese."""
135
+
136
+ try:
137
+ response = self.llm.invoke([
138
+ SystemMessage(content="You are a budget data extraction assistant. Return only valid JSON."),
139
+ HumanMessage(content=extraction_prompt)
140
+ ])
141
+
142
+ # Try to parse the JSON response
143
+ extracted_data = response.content.strip()
144
+ # Remove any markdown formatting
145
+ if extracted_data.startswith("```"):
146
+ extracted_data = extracted_data.split("\n")[1:-1]
147
+ extracted_data = "\n".join(extracted_data)
148
+
149
+ # Validate JSON
150
+ json.loads(extracted_data)
151
+ return extracted_data
152
+
153
+ except Exception:
154
+ pass
155
+
156
+ # Fallback to regex extraction
157
+ income_match = re.search(r"\$?(\d+(?:,\d{3})*(?:\.\d{2})?)\s*(?:monthly\s*)?income", message, re.I)
158
+ income = float(income_match.group(1).replace(",", "")) if income_match else 5000
159
+
160
+ expenses = {}
161
+ expense_patterns = [
162
+ (r"rent:?\s*\$?(\d+(?:,\d{3})*(?:\.\d{2})?)", "rent"),
163
+ (r"food:?\s*\$?(\d+(?:,\d{3})*(?:\.\d{2})?)", "food"),
164
+ (r"utilities:?\s*\$?(\d+(?:,\d{3})*(?:\.\d{2})?)", "utilities"),
165
+ (r"transportation:?\s*\$?(\d+(?:,\d{3})*(?:\.\d{2})?)", "transportation"),
166
+ ]
167
+
168
+ for pattern, category in expense_patterns:
169
+ match = re.search(pattern, message, re.I)
170
+ if match:
171
+ expenses[category] = float(match.group(1).replace(",", ""))
172
+
173
+ return json.dumps({"income": income, "expenses": expenses})
174
+
175
+ elif tool_name == "portfolio_analyzer":
176
+ # Use OpenAI to extract portfolio information from natural language
177
+ extraction_prompt = f"""Extract portfolio holdings and total investment from this message: "{message}"
178
+
179
+ Convert the portfolio information to JSON format with holdings array and total investment amount.
180
+ Each holding should have symbol and either shares or percentage.
181
+
182
+ Return format:
183
+ {{"holdings": [{{"symbol": "AAPL", "shares": 100}}, {{"symbol": "GOOGL", "percentage": 30}}], "total_investment": 100000}}
184
+
185
+ Examples:
186
+ - "My portfolio: AAPL 100 shares, GOOGL 50 shares" -> {{"holdings": [{{"symbol": "AAPL", "shares": 100}}, {{"symbol": "GOOGL", "shares": 50}}], "total_investment": 0}}
187
+ - "I have 40% AAPL, 30% MSFT, 30% TSLA. I have invested total of 100K USD" -> {{"holdings": [{{"symbol": "AAPL", "percentage": 40}}, {{"symbol": "MSFT", "percentage": 30}}, {{"symbol": "TSLA", "percentage": 30}}], "total_investment": 100000}}
188
+ - "Portfolio with Apple 200 shares and Microsoft 25%, total investment $50,000" -> {{"holdings": [{{"symbol": "AAPL", "shares": 200}}, {{"symbol": "MSFT", "percentage": 25}}], "total_investment": 50000}}
189
+
190
+ Important:
191
+ - Extract total investment amount if mentioned (convert K=1000, M=1000000)
192
+ - If total investment not mentioned, set to 0
193
+ - Convert company names to stock symbols (Apple->AAPL, Microsoft->MSFT, Tesla->TSLA, etc.)
194
+
195
+ If no clear portfolio data is found, return: {{"holdings": [], "total_investment": 0}}
196
+
197
+ Return only valid JSON in japanese Yen, nothing else."""
198
+
199
+ try:
200
+ response = self.llm.invoke([
201
+ SystemMessage(content="You are a portfolio data extraction assistant. Return only valid JSON with holdings array and total_investment field."),
202
+ HumanMessage(content=extraction_prompt)
203
+ ])
204
+
205
+ # Try to parse the JSON response
206
+ extracted_data = response.content.strip()
207
+ # Remove any markdown formatting
208
+ if extracted_data.startswith("```"):
209
+ lines = extracted_data.split("\n")
210
+ # Find the start and end of JSON content
211
+ start_idx = 1 if lines[0].startswith("```") else 0
212
+ end_idx = -1 if lines[-1].startswith("```") or lines[-1] == "```" else len(lines)
213
+ extracted_data = "\n".join(lines[start_idx:end_idx])
214
+
215
+ # Validate JSON
216
+ parsed_json = json.loads(extracted_data)
217
+ # Ensure it has the required structure
218
+ if isinstance(parsed_json, dict) and "holdings" in parsed_json:
219
+ return extracted_data
220
+ else:
221
+ # If structure is wrong, fall back to regex
222
+ pass
223
+
224
+ except Exception:
225
+ pass
226
+
227
+ # Fallback to returning the original message
228
+ return message
229
+
230
+ elif tool_name == "market_trends":
231
+ # Use OpenAI to extract and refine market research query
232
+ extraction_prompt = f"""Convert this user message into an optimized market research query: "{message}"
233
+
234
+ Create a focused search query that will get the best market trends and financial news results.
235
+
236
+ Examples:
237
+ - "What's happening in tech stocks?" -> "technology stocks market trends latest news 2025"
238
+ - "Tell me about the market today" -> "stock market trends today financial news latest"
239
+ - "How is the crypto market?" -> "cryptocurrency market trends bitcoin ethereum latest news"
240
+ - "What about NVIDIA trends?" -> "NVIDIA NVDA stock market trends analysis latest news"
241
+
242
+ Return only the optimized search query, nothing else."""
243
+
244
+ try:
245
+ response = self.llm.invoke([
246
+ SystemMessage(content="You are a search query optimization assistant. Return only the optimized search query."),
247
+ HumanMessage(content=extraction_prompt)
248
+ ])
249
+
250
+ optimized_query = response.content.strip()
251
+ return optimized_query if optimized_query else message
252
+
253
+ except Exception:
254
+ pass
255
+
256
+ # Fallback to returning the original message
257
+ return message
258
+
259
+ return message
260
+
261
+ def process_message_with_details(
262
+ self, message: str, history: List[dict] = None
263
+ ) -> Tuple[str, str, str, List[str], List[str]]:
264
+ """Process a message and return response, tool used, tool result, and all tools/results"""
265
+ if history is None:
266
+ history = []
267
+
268
+ # Check if this is a multi-tool query (contains keywords for multiple tools)
269
+ message_lower = message.lower()
270
+ tool_keywords = {
271
+ "budget_planner": ["budget", "income", "expense", "spending", "allocat", "track", "categoriz"],
272
+ "investment_analyzer": ["stock", "invest", "buy", "sell", "analyze"],
273
+ "portfolio_analyzer": ["portfolio", "holdings", "allocation", "diversif"],
274
+ "market_trends": ["market", "trend", "news", "sector", "economic"]
275
+ }
276
+
277
+ detected_tools = []
278
+ for tool_name, keywords in tool_keywords.items():
279
+ if any(word in message_lower for word in keywords):
280
+ # Special check for investment analyzer - needs stock symbols
281
+ if tool_name == "investment_analyzer":
282
+ if re.search(r"\b[A-Z]{2,5}\b", message) or any(word in message_lower for word in ["stock", "invest", "recommend"]):
283
+ detected_tools.append(tool_name)
284
+ else:
285
+ detected_tools.append(tool_name)
286
+
287
+ # If multiple tools detected or complex query, use agent executor
288
+ if len(detected_tools) > 1 or len(message.split()) > 15:
289
+ try:
290
+ result = self.agent_executor.invoke({"input": message, "messages": []})
291
+
292
+ tool_used, tool_result, all_tools, all_results = self._extract_tool_usage(
293
+ result.get("intermediate_steps", [])
294
+ )
295
+ return result["output"], tool_used, tool_result, all_tools, all_results
296
+
297
+ except Exception as e:
298
+ return (
299
+ f"I encountered an error processing your request: {str(e)}",
300
+ None,
301
+ None,
302
+ [],
303
+ []
304
+ )
305
+
306
+ # Single tool execution for simple queries
307
+ elif len(detected_tools) == 1:
308
+ selected_tool = detected_tools[0]
309
+ try:
310
+ tool = self.tools_by_name[selected_tool]
311
+ tool_input = self._prepare_tool_input(message, selected_tool)
312
+
313
+ # Execute the tool
314
+ tool_result = tool.func(tool_input)
315
+
316
+ # Generate response based on tool result - optimized for speed
317
+ response_prompt = f"""Based on this {selected_tool.replace('_', ' ')} analysis, provide a concise financial summary for: {message} Always answer in japanese.
318
+
319
+ Data: {tool_result}
320
+
321
+ Keep response under 200 words with key insights and 2-3 actionable recommendations. Always answer in japanese."""
322
+
323
+ response = self.llm.invoke(
324
+ [
325
+ SystemMessage(content="Financial advisor. Be concise and actionable."),
326
+ HumanMessage(content=response_prompt),
327
+ ]
328
+ )
329
+
330
+ return response.content, selected_tool, tool_result, [selected_tool], [tool_result]
331
+
332
+ except Exception as e:
333
+ return f"Error using {selected_tool}: {str(e)}", selected_tool, None, [], []
334
+
335
+ # Fallback to agent executor for unclear queries
336
+ else:
337
+ try:
338
+ result = self.agent_executor.invoke({"input": message, "messages": []})
339
+
340
+ tool_used, tool_result, all_tools, all_results = self._extract_tool_usage(
341
+ result.get("intermediate_steps", [])
342
+ )
343
+ return result["output"], tool_used, tool_result, all_tools, all_results
344
+
345
+ except Exception as e:
346
+ return (
347
+ f"I encountered an error processing your request: {str(e)}",
348
+ None,
349
+ None,
350
+ [],
351
+ []
352
+ )
353
+
354
+ def process_message(self, message: str, history: List[dict] = None):
355
+ """Process a user message and return response"""
356
+ response, _, _, _, _ = self.process_message_with_details(message, history)
357
+ return response
358
+
359
+ def stream_response(self, message: str, tool_result: str, selected_tool: str, response_type: str = "short"):
360
+ """Stream the LLM response in real-time"""
361
+
362
+ if response_type == "detailed":
363
+ response_prompt = f"""Based on the following comprehensive analysis from the {selected_tool.replace('_', ' ').title()}:
364
+
365
+ {tool_result}
366
+
367
+ Provide detailed financial advice to the user addressing their question: {message}
368
+
369
+ Guidelines:
370
+ - Be thorough and comprehensive
371
+ - Reference specific data points from the analysis
372
+ - Provide clear, actionable recommendations with explanations
373
+ - Include multiple scenarios or considerations where relevant
374
+ - Use a professional but friendly tone
375
+ - Structure your response with clear sections
376
+ - Provide context for your recommendations
377
+ - Always answer in japanese"""
378
+
379
+ system_message = "You are a professional financial advisor. Provide comprehensive, detailed advice based on the analysis results. Be thorough and educational. Always answer in japanese."
380
+ else:
381
+ response_prompt = f"""Based on this {selected_tool.replace('_', ' ')} analysis, provide a concise financial summary for: {message}. Always answer in japanese.
382
+
383
+ Data: {tool_result}
384
+
385
+ Keep response under 200 words with key insights and 2-3 actionable recommendations. Always answer in japanese."""
386
+
387
+ system_message = "Financial advisor. Be concise and actionable."
388
+
389
+ messages = [
390
+ SystemMessage(content=system_message),
391
+ HumanMessage(content=response_prompt),
392
+ ]
393
+
394
+ # Stream the response token by token
395
+ for chunk in self.llm.stream(messages):
396
+ if chunk.content:
397
+ yield chunk.content
agents/tools.py ADDED
@@ -0,0 +1,756 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import json
2
+ from datetime import datetime
3
+ from typing import List
4
+
5
+ import yfinance as yf
6
+ from langchain.tools import Tool
7
+ from langchain_community.tools.tavily_search import TavilySearchResults
8
+
9
+
10
+ class FinancialTools:
11
+ def __init__(self, tavily_api_key: str):
12
+ self.tavily_search = TavilySearchResults(api_key=tavily_api_key)
13
+
14
+ def create_budget_planner(self) -> Tool:
15
+ def budget_planner(input_str: str) -> str:
16
+ """Create a personalized budget plan with advanced features"""
17
+ try:
18
+ # Handle empty or invalid input
19
+ if not input_str or input_str.strip() == "":
20
+ input_str = '{"income": 5000, "expenses": {}}'
21
+
22
+ # Try to parse JSON, if it fails, try to extract values from text
23
+ try:
24
+ data = json.loads(input_str)
25
+ except json.JSONDecodeError:
26
+ # Fallback: extract income and expenses from text
27
+ import re
28
+
29
+ income_match = re.search(r"(\$?[\d,]+(?:\.\d{2})?)", input_str)
30
+ income = (
31
+ float(income_match.group(1).replace("$", "").replace(",", ""))
32
+ if income_match
33
+ else 5000
34
+ )
35
+ data = {"income": income, "expenses": {}}
36
+
37
+ income = data.get("income", 5000)
38
+ expenses = data.get("expenses", {})
39
+ goals = data.get("savings_goals", {})
40
+ debt = data.get("debt", {})
41
+
42
+ # Calculate budget allocations using 50/30/20 rule
43
+ needs = income * 0.5
44
+ wants = income * 0.3
45
+ savings = income * 0.2
46
+
47
+ total_expenses = sum(expenses.values())
48
+ remaining = income - total_expenses
49
+
50
+ # Debt analysis
51
+ total_debt = sum(debt.values()) if debt else 0
52
+ debt_to_income = (total_debt / income * 100) if income > 0 else 0
53
+
54
+ # Emergency fund calculation (3-6 months of expenses)
55
+ emergency_fund_needed = total_expenses * 6
56
+ emergency_fund_goal = goals.get("emergency_fund", 0)
57
+
58
+ # Calculate actual savings potential
59
+ debt_payments = debt.get("monthly_payments", 0)
60
+ available_for_savings = remaining - debt_payments
61
+
62
+ budget_plan = {
63
+ "monthly_income": income,
64
+ "recommended_allocation": {
65
+ "needs": needs,
66
+ "wants": wants,
67
+ "savings": savings,
68
+ },
69
+ "current_expenses": expenses,
70
+ "total_expenses": total_expenses,
71
+ "remaining_budget": remaining,
72
+ "savings_rate": (available_for_savings / income * 100)
73
+ if income > 0
74
+ else 0,
75
+ "debt_analysis": {
76
+ "total_debt": total_debt,
77
+ "debt_to_income_ratio": debt_to_income,
78
+ "monthly_payments": debt_payments,
79
+ },
80
+ "emergency_fund": {
81
+ "recommended": emergency_fund_needed,
82
+ "current": emergency_fund_goal,
83
+ "progress": (emergency_fund_goal / emergency_fund_needed * 100)
84
+ if emergency_fund_needed > 0
85
+ else 0,
86
+ },
87
+ "savings_optimization": {
88
+ "available_monthly": available_for_savings,
89
+ "annual_savings_potential": available_for_savings * 12,
90
+ },
91
+ "recommendations": [],
92
+ }
93
+
94
+ # Enhanced recommendations
95
+ if available_for_savings < savings:
96
+ budget_plan["recommendations"].append(
97
+ f"Increase savings by ${savings - available_for_savings:.2f}/month to reach 20% goal"
98
+ )
99
+
100
+ if debt_to_income > 36:
101
+ budget_plan["recommendations"].append(
102
+ f"High debt-to-income ratio ({debt_to_income:.1f}%). Consider debt consolidation."
103
+ )
104
+
105
+ if emergency_fund_goal < emergency_fund_needed:
106
+ monthly_needed = (emergency_fund_needed - emergency_fund_goal) / 12
107
+ budget_plan["recommendations"].append(
108
+ f"Build emergency fund: save ${monthly_needed:.2f}/month for 12 months"
109
+ )
110
+
111
+ # Expense optimization suggestions
112
+ largest_expense = (
113
+ max(expenses.items(), key=lambda x: x[1]) if expenses else None
114
+ )
115
+ if largest_expense and largest_expense[1] > income * 0.35:
116
+ budget_plan["recommendations"].append(
117
+ f"Your {largest_expense[0]} expense (${largest_expense[1]:.2f}) is high. Consider cost reduction."
118
+ )
119
+
120
+ return json.dumps(budget_plan, indent=2)
121
+ except Exception as e:
122
+ return f"Error creating budget plan: {str(e)}"
123
+
124
+ return Tool(
125
+ name="budget_planner",
126
+ description="Create personalized budget plans with income and expense analysis",
127
+ func=budget_planner,
128
+ )
129
+
130
+ def create_investment_analyzer(self) -> Tool:
131
+ def investment_analyzer(symbol: str) -> str:
132
+ """Analyze stocks with advanced metrics, sector comparison, and risk assessment"""
133
+ try:
134
+ stock = yf.Ticker(symbol.upper())
135
+ info = stock.info
136
+ hist = stock.history(period="1y") # Reduced from 2y to 1y for speed
137
+
138
+ if hist.empty:
139
+ return f"No data available for {symbol}"
140
+
141
+ # Calculate key metrics
142
+ current_price = info.get("currentPrice", hist["Close"].iloc[-1])
143
+ pe_ratio = info.get("trailingPE", "N/A")
144
+ pb_ratio = info.get("priceToBook", "N/A")
145
+ dividend_yield = (
146
+ info.get("dividendYield", 0) * 100
147
+ if info.get("dividendYield")
148
+ else 0
149
+ )
150
+ market_cap = info.get("marketCap", "N/A")
151
+ beta = info.get("beta", "N/A")
152
+ sector = info.get("sector", "Unknown")
153
+ industry = info.get("industry", "Unknown")
154
+
155
+ # Advanced technical indicators
156
+ sma_20 = hist["Close"].rolling(window=20).mean().iloc[-1]
157
+ sma_50 = (
158
+ hist["Close"].rolling(window=50).mean().iloc[-1]
159
+ if len(hist) >= 50
160
+ else None
161
+ )
162
+ sma_200 = (
163
+ hist["Close"].rolling(window=200).mean().iloc[-1]
164
+ if len(hist) >= 200
165
+ else None
166
+ )
167
+
168
+ # RSI calculation
169
+ delta = hist["Close"].diff()
170
+ gain = (delta.where(delta > 0, 0)).rolling(window=14).mean()
171
+ loss = (-delta.where(delta < 0, 0)).rolling(window=14).mean()
172
+ rs = gain / loss
173
+ rsi = 100 - (100 / (1 + rs)).iloc[-1]
174
+
175
+ # Simplified MACD calculation
176
+ ema_12 = hist["Close"].ewm(span=12).mean()
177
+ ema_26 = hist["Close"].ewm(span=26).mean()
178
+ macd = ema_12 - ema_26
179
+ macd_signal = macd.ewm(span=9).mean()
180
+
181
+ # Simplified Bollinger Bands (only what we need)
182
+ bb_middle = hist["Close"].rolling(window=20).mean()
183
+ bb_std_dev = hist["Close"].rolling(window=20).std()
184
+ bb_upper = bb_middle + (bb_std_dev * 2)
185
+ bb_lower = bb_middle - (bb_std_dev * 2)
186
+
187
+ # Simplified volatility analysis
188
+ volatility_30d = (
189
+ hist["Close"].pct_change().rolling(30).std().iloc[-1] * 100
190
+ )
191
+
192
+ # Value at Risk (VaR) - 5% level
193
+ returns = hist["Close"].pct_change().dropna()
194
+ var_5 = returns.quantile(0.05) * 100
195
+
196
+ # Performance metrics
197
+ price_1m = hist["Close"].iloc[-22] if len(hist) >= 22 else None
198
+ price_3m = hist["Close"].iloc[-66] if len(hist) >= 66 else None
199
+ price_6m = hist["Close"].iloc[-132] if len(hist) >= 132 else None
200
+ price_1y = hist["Close"].iloc[-252] if len(hist) >= 252 else None
201
+
202
+ performance = {}
203
+ if price_1m:
204
+ performance["1_month"] = (current_price - price_1m) / price_1m * 100
205
+ if price_3m:
206
+ performance["3_month"] = (current_price - price_3m) / price_3m * 100
207
+ if price_6m:
208
+ performance["6_month"] = (current_price - price_6m) / price_6m * 100
209
+ if price_1y:
210
+ performance["1_year"] = (current_price - price_1y) / price_1y * 100
211
+
212
+ # Sharpe ratio calculation (using risk-free rate of 4%)
213
+ risk_free_rate = 0.04
214
+ mean_return = returns.mean() * 252
215
+ return_std = returns.std() * (252**0.5)
216
+ sharpe_ratio = (
217
+ (mean_return - risk_free_rate) / return_std if return_std > 0 else 0
218
+ )
219
+
220
+ # Risk assessment
221
+ risk_score = 0
222
+ risk_factors = []
223
+
224
+ if volatility_30d > 30:
225
+ risk_score += 2
226
+ risk_factors.append("High volatility (>30%)")
227
+ elif volatility_30d > 20:
228
+ risk_score += 1
229
+ risk_factors.append("Moderate volatility (20-30%)")
230
+
231
+ if isinstance(beta, (int, float)):
232
+ if beta > 1.5:
233
+ risk_score += 2
234
+ risk_factors.append(
235
+ f"High beta ({beta:.2f}) - market sensitive"
236
+ )
237
+ elif beta > 1.2:
238
+ risk_score += 1
239
+ risk_factors.append(f"Above-average beta ({beta:.2f})")
240
+
241
+ if var_5 < -5:
242
+ risk_score += 2
243
+ risk_factors.append(f"High downside risk (VaR: {var_5:.1f}%)")
244
+
245
+ # Enhanced recommendation logic
246
+ recommendation = "HOLD"
247
+ confidence = 50
248
+ reasoning = []
249
+
250
+ # Technical analysis
251
+ if current_price < bb_lower.iloc[-1]:
252
+ recommendation = "BUY"
253
+ confidence += 20
254
+ reasoning.append(
255
+ "Price below Bollinger Band lower bound (oversold)"
256
+ )
257
+ elif current_price > bb_upper.iloc[-1]:
258
+ recommendation = "SELL"
259
+ confidence += 15
260
+ reasoning.append(
261
+ "Price above Bollinger Band upper bound (overbought)"
262
+ )
263
+
264
+ # RSI analysis
265
+ if rsi < 30:
266
+ if recommendation != "SELL":
267
+ recommendation = "BUY"
268
+ confidence += 15
269
+ reasoning.append(f"RSI oversold ({rsi:.1f})")
270
+ elif rsi > 70:
271
+ if recommendation != "BUY":
272
+ recommendation = "SELL"
273
+ confidence += 10
274
+ reasoning.append(f"RSI overbought ({rsi:.1f})")
275
+
276
+ # MACD analysis
277
+ if (
278
+ macd.iloc[-1] > macd_signal.iloc[-1]
279
+ and macd.iloc[-2] <= macd_signal.iloc[-2]
280
+ ):
281
+ if recommendation != "SELL":
282
+ recommendation = "BUY"
283
+ confidence += 10
284
+ reasoning.append("MACD bullish crossover")
285
+
286
+ # Fundamental analysis
287
+ if isinstance(pe_ratio, (int, float)):
288
+ if pe_ratio < 15:
289
+ confidence += 10
290
+ reasoning.append("Low P/E ratio suggests undervaluation")
291
+ elif pe_ratio > 30:
292
+ confidence -= 5
293
+ reasoning.append("High P/E ratio suggests overvaluation")
294
+
295
+ # Risk adjustment
296
+ if risk_score >= 4:
297
+ if recommendation == "BUY":
298
+ recommendation = "HOLD"
299
+ confidence -= 15
300
+ reasoning.append("High risk profile suggests caution")
301
+
302
+ analysis = {
303
+ "symbol": symbol.upper(),
304
+ "company_name": info.get("longName", symbol),
305
+ "sector": sector,
306
+ "industry": industry,
307
+ "current_price": f"${current_price:.2f}",
308
+ "market_cap": f"${market_cap:,.0f}"
309
+ if isinstance(market_cap, (int, float))
310
+ else "N/A",
311
+ "fundamental_metrics": {
312
+ "pe_ratio": pe_ratio,
313
+ "pb_ratio": pb_ratio,
314
+ "dividend_yield": f"{dividend_yield:.2f}%",
315
+ "beta": beta,
316
+ "sharpe_ratio": f"{sharpe_ratio:.2f}",
317
+ },
318
+ "technical_indicators": {
319
+ "sma_20": f"${sma_20:.2f}",
320
+ "sma_50": f"${sma_50:.2f}" if sma_50 else "N/A",
321
+ "sma_200": f"${sma_200:.2f}" if sma_200 else "N/A",
322
+ "rsi": f"{rsi:.1f}",
323
+ "macd": f"{macd.iloc[-1]:.2f}",
324
+ "bollinger_position": "Lower"
325
+ if current_price < bb_lower.iloc[-1]
326
+ else "Upper"
327
+ if current_price > bb_upper.iloc[-1]
328
+ else "Middle",
329
+ },
330
+ "risk_assessment": {
331
+ "volatility_30d": f"{volatility_30d:.1f}%",
332
+ "value_at_risk_5%": f"{var_5:.1f}%",
333
+ "risk_score": f"{risk_score}/6",
334
+ "risk_factors": risk_factors,
335
+ "risk_level": "Low"
336
+ if risk_score <= 1
337
+ else "Medium"
338
+ if risk_score <= 3
339
+ else "High",
340
+ },
341
+ "price_levels": {
342
+ "52_week_high": f"${info.get('fiftyTwoWeekHigh', 'N/A')}",
343
+ "52_week_low": f"${info.get('fiftyTwoWeekLow', 'N/A')}",
344
+ },
345
+ "performance": {k: f"{v:.1f}%" for k, v in performance.items()},
346
+ "recommendation": {
347
+ "action": recommendation,
348
+ "confidence": f"{min(max(confidence, 20), 95)}%",
349
+ "reasoning": reasoning,
350
+ "target_allocation": "5-10%"
351
+ if recommendation == "BUY"
352
+ else "0-5%"
353
+ if recommendation == "SELL"
354
+ else "3-7%",
355
+ },
356
+ "last_updated": datetime.now().strftime("%Y-%m-%d %H:%M:%S"),
357
+ }
358
+
359
+ return json.dumps(analysis, indent=2)
360
+ except Exception as e:
361
+ return f"Error analyzing {symbol}: {str(e)}"
362
+
363
+ return Tool(
364
+ name="investment_analyzer",
365
+ description="Analyze stocks and provide investment recommendations",
366
+ func=investment_analyzer,
367
+ )
368
+
369
+ def create_market_trends_analyzer(self) -> Tool:
370
+ def market_trends(query: str) -> str:
371
+ """Get comprehensive real-time market trends, news, and sector analysis"""
372
+ try:
373
+ # Get current year for search queries
374
+ current_year = datetime.now().year
375
+
376
+ # Status tracking for API calls
377
+ status_updates = []
378
+
379
+ # Optimized single comprehensive search instead of multiple calls
380
+ comprehensive_query = f"stock market {query} trends analysis financial news {current_year} latest"
381
+
382
+ # Get primary market information
383
+ status_updates.append(
384
+ "🔍 Fetching latest market news via Tavily Search API..."
385
+ )
386
+ market_news = self.tavily_search.run(comprehensive_query)
387
+ status_updates.append("✅ Market news retrieved successfully")
388
+
389
+ # Quick market indices check (reduced to just S&P 500 and NASDAQ for speed)
390
+ index_data = {}
391
+ market_sentiment = {"overall": "Unknown", "note": "Limited data"}
392
+
393
+ try:
394
+ status_updates.append(
395
+ "📊 Fetching market indices via Yahoo Finance API..."
396
+ )
397
+ # Fetch only key indices for speed
398
+ key_indices = ["^GSPC", "^IXIC"] # S&P 500, NASDAQ
399
+
400
+ for index in key_indices:
401
+ index_names = {"^GSPC": "S&P 500", "^IXIC": "NASDAQ"}
402
+ status_updates.append(
403
+ f"📈 Getting {index_names[index]} data..."
404
+ )
405
+
406
+ ticker = yf.Ticker(index)
407
+ hist = ticker.history(period="2d") # Reduced period for speed
408
+ if not hist.empty:
409
+ current = hist["Close"].iloc[-1]
410
+ prev = hist["Close"].iloc[-2] if len(hist) > 1 else current
411
+ change = ((current - prev) / prev * 100) if prev != 0 else 0
412
+
413
+ index_data[index_names[index]] = {
414
+ "current": round(current, 2),
415
+ "change_pct": round(change, 2),
416
+ "direction": "📈"
417
+ if change > 0
418
+ else "📉"
419
+ if change < 0
420
+ else "➡️",
421
+ }
422
+
423
+ status_updates.append(
424
+ "✅ 市場指数データが​​正常に取得されました。"
425
+ )
426
+
427
+ # Simple sentiment based on available indices
428
+ if index_data:
429
+ status_updates.append("🧠 市場センチメントの分析...")
430
+ positive_count = sum(
431
+ 1 for data in index_data.values() if data["change_pct"] > 0
432
+ )
433
+ total_count = len(index_data)
434
+
435
+ if positive_count >= total_count * 0.75:
436
+ sentiment = "🟢 Bullish"
437
+ elif positive_count <= total_count * 0.25:
438
+ sentiment = "🔴 Bearish"
439
+ else:
440
+ sentiment = "🟡 Mixed"
441
+
442
+ market_sentiment = {
443
+ "overall": sentiment,
444
+ "summary": f"{positive_count}/{total_count} 指数は正",
445
+ }
446
+ status_updates.append("✅ 市場センチメント分析が完了しました。")
447
+
448
+ except Exception as index_error:
449
+ status_updates.append(
450
+ f"❌ 市場指数の取得中にエラーが発生しました。: {str(index_error)}"
451
+ )
452
+ index_data = {
453
+ "error": f"インデックスデータが利用不可: {str(index_error)}"
454
+ }
455
+
456
+ # Extract key themes from search results
457
+ status_updates.append("🔍 Analyzing key market themes...")
458
+ key_themes = _extract_key_themes(market_news)
459
+ status_updates.append("✅ Theme analysis completed")
460
+
461
+ # Format output for better readability
462
+ def format_search_results(results):
463
+ """Convert search results to readable format"""
464
+ if isinstance(results, list):
465
+ # Extract key information from search results
466
+ formatted = []
467
+ for item in results[:3]: # Limit to top 3 results
468
+ if isinstance(item, dict):
469
+ title = item.get("title", "No title")
470
+ content = item.get(
471
+ "content", item.get("snippet", "No content")
472
+ )
473
+ formatted.append(f"• {title}: {content[:200]}...")
474
+ else:
475
+ formatted.append(f"• {str(item)[:200]}...")
476
+ return "\n".join(formatted)
477
+ elif isinstance(results, str):
478
+ return (
479
+ results[:1000] + "..." if len(results) > 1000 else results
480
+ )
481
+ else:
482
+ return str(results)[:1000]
483
+
484
+ status_updates.append("📋 Compiling final analysis report...")
485
+
486
+ # Compile streamlined analysis
487
+ analysis = {
488
+ "query": query,
489
+ "timestamp": datetime.now().strftime("%Y-%m-%d %H:%M:%S"),
490
+ "api_execution_log": status_updates,
491
+ "market_summary": format_search_results(market_news),
492
+ "key_indices": index_data,
493
+ "market_sentiment": market_sentiment,
494
+ "key_themes": key_themes,
495
+ "note": "Real-time API status tracking enabled",
496
+ }
497
+
498
+ status_updates.append("✅ Analysis report completed successfully")
499
+
500
+ return json.dumps(analysis, indent=2, ensure_ascii=False)
501
+
502
+ except Exception as e:
503
+ return f"Error fetching market analysis: {str(e)}"
504
+
505
+ def _extract_key_themes(news_text) -> list:
506
+ """Extract key themes from market news"""
507
+ themes = []
508
+ keywords = {
509
+ "earnings": ["earnings", "quarterly results", "revenue", "profit"],
510
+ "fed_policy": [
511
+ "federal reserve",
512
+ "interest rates",
513
+ "fed",
514
+ "monetary policy",
515
+ ],
516
+ "inflation": ["inflation", "cpi", "price increases", "cost of living"],
517
+ "geopolitical": ["geopolitical", "war", "trade war", "sanctions"],
518
+ "technology": [
519
+ "ai",
520
+ "artificial intelligence",
521
+ "tech stocks",
522
+ "innovation",
523
+ ],
524
+ "recession": ["recession", "economic downturn", "market crash"],
525
+ }
526
+
527
+ # Handle both string and list inputs
528
+ if isinstance(news_text, list):
529
+ # Convert list to string
530
+ news_text = " ".join(str(item) for item in news_text)
531
+ elif not isinstance(news_text, str):
532
+ # Convert other types to string
533
+ news_text = str(news_text)
534
+
535
+ news_lower = news_text.lower()
536
+ for theme, terms in keywords.items():
537
+ if any(term in news_lower for term in terms):
538
+ themes.append(theme.replace("_", " ").title())
539
+
540
+ return themes[:5] # Return top 5 themes
541
+
542
+ return Tool(
543
+ name="market_trends",
544
+ description="Get real-time market trends and financial news",
545
+ func=market_trends,
546
+ )
547
+
548
+ def create_portfolio_analyzer(self) -> Tool:
549
+ def portfolio_analyzer(input_str: str) -> str:
550
+ """Analyze portfolio performance and diversification"""
551
+ try:
552
+ import re
553
+
554
+ # Try to parse as JSON first (from OpenAI extraction)
555
+ total_investment = 0
556
+ holdings_info = []
557
+
558
+ try:
559
+ # First try to parse as JSON
560
+ data = json.loads(input_str)
561
+ if isinstance(data, dict):
562
+ holdings_info = data.get("holdings", [])
563
+ total_investment = data.get("total_investment", 0)
564
+ except:
565
+ # If JSON parsing fails, extract from natural language
566
+ pass
567
+
568
+ # If no JSON data found, extract from natural language using regex
569
+ if not holdings_info:
570
+ # Extract investment amount using improved patterns
571
+ def extract_investment_amount(text):
572
+ patterns = [
573
+ r"(?:invested|investment|total|have)\s*(?:of)?\s*(?:\$)?(\d+(?:[,\d]*)?(?:\.\d+)?)\s*([KMB]?)\s*(?:USD|dollars?|\$)?",
574
+ r"(\d+(?:[,\d]*)?(?:\.\d+)?)\s*([KMB]?)\s*(?:USD|dollars?)",
575
+ r"\$(\d+(?:[,\d]*)?(?:\.\d+)?)\s*([KMB]?)",
576
+ ]
577
+
578
+ for pattern in patterns:
579
+ match = re.search(pattern, text, re.IGNORECASE)
580
+ if match:
581
+ amount_str = match.group(1).replace(",", "")
582
+ suffix = match.group(2).upper() if len(match.groups()) > 1 else ""
583
+
584
+ multiplier = {"K": 1000, "M": 1000000, "B": 1000000000}.get(suffix, 1)
585
+ return float(amount_str) * multiplier
586
+ return 0
587
+
588
+ if total_investment == 0:
589
+ total_investment = extract_investment_amount(input_str)
590
+
591
+ # Extract holdings using regex
592
+ def extract_holdings(text):
593
+ holdings = []
594
+
595
+ # First try percentage patterns (with % symbol)
596
+ percentage_patterns = [
597
+ r"([A-Z]{2,5})\s*[:\s]*(\d+(?:\.\d+)?)%",
598
+ r"([A-Z]{2,5}):\s*(\d+(?:\.\d+)?)%",
599
+ r"([A-Z]{2,5})\s+(\d+(?:\.\d+)?)%",
600
+ ]
601
+
602
+ for pattern in percentage_patterns:
603
+ matches = re.findall(pattern, text, re.IGNORECASE)
604
+ if matches:
605
+ for symbol, percentage in matches:
606
+ holdings.append({
607
+ "symbol": symbol.upper(),
608
+ "percentage": float(percentage)
609
+ })
610
+ return holdings
611
+
612
+ # If no percentages found, try shares patterns (without % symbol)
613
+ shares_patterns = [
614
+ r"([A-Z]{2,5})\s*[:\s]*(\d+(?:\.\d+)?)\s*(?!%)",
615
+ r"([A-Z]{2,5}):\s*(\d+(?:\.\d+)?)\s*(?!%)",
616
+ r"([A-Z]{2,5})\s+(\d+(?:\.\d+)?)\s*(?!%)",
617
+ ]
618
+
619
+ for pattern in shares_patterns:
620
+ matches = re.findall(pattern, text, re.IGNORECASE)
621
+ if matches:
622
+ for symbol, shares in matches:
623
+ holdings.append({
624
+ "symbol": symbol.upper(),
625
+ "shares": float(shares)
626
+ })
627
+ return holdings
628
+
629
+ return holdings
630
+
631
+ holdings_info = extract_holdings(input_str)
632
+
633
+ # If no valid holdings found, return early to avoid using this tool
634
+ if not holdings_info:
635
+ # Debug: show what we received
636
+ return f"Portfolio analyzer debug - received input: {input_str[:200]}... No holdings found. Please provide portfolio details like 'AAPL 40%, MSFT 30%' or JSON format."
637
+
638
+ portfolio_data = []
639
+ total_calculated_value = 0
640
+
641
+ # Process each holding
642
+ for holding in holdings_info:
643
+ symbol = holding.get("symbol", "")
644
+ percentage = holding.get("percentage", 0)
645
+ shares = holding.get("shares", None)
646
+
647
+ if not symbol:
648
+ continue
649
+
650
+ try:
651
+ # Get current stock price
652
+ stock = yf.Ticker(symbol)
653
+ hist = stock.history(period="1d")
654
+
655
+ if not hist.empty:
656
+ current_price = hist["Close"].iloc[-1]
657
+
658
+ if shares is not None:
659
+ # Shares-based calculation
660
+ value = current_price * shares
661
+ allocation_percentage = percentage
662
+ else:
663
+ # Percentage-based calculation
664
+ value = total_investment * (percentage / 100)
665
+ allocation_percentage = percentage
666
+ shares = value / current_price if current_price > 0 else 0
667
+
668
+ total_calculated_value += value
669
+
670
+ portfolio_data.append(
671
+ {
672
+ "symbol": symbol,
673
+ "shares": round(shares, 2),
674
+ "current_price": f"${current_price:.2f}",
675
+ "value": value,
676
+ "allocation": allocation_percentage,
677
+ }
678
+ )
679
+ except Exception:
680
+ # Skip if can't get data but add placeholder
681
+ if percentage > 0:
682
+ value = total_investment * (percentage / 100)
683
+ portfolio_data.append(
684
+ {
685
+ "symbol": symbol,
686
+ "shares": "N/A",
687
+ "current_price": "N/A",
688
+ "value": value,
689
+ "allocation": percentage,
690
+ }
691
+ )
692
+
693
+ # For percentage-based portfolios, use the original total investment
694
+ # For share-based portfolios, use calculated value
695
+ final_total_value = (
696
+ total_investment
697
+ if total_investment > 0 and any(h.get("percentage", 0) > 0 for h in holdings_info)
698
+ else total_calculated_value
699
+ )
700
+
701
+ # Analysis and recommendations
702
+ analysis = {
703
+ "total_portfolio_value": f"${final_total_value:.2f}",
704
+ "number_of_holdings": len(portfolio_data),
705
+ "holdings": portfolio_data,
706
+ "recommendations": [],
707
+ }
708
+
709
+ # Diversification recommendations
710
+ if len(portfolio_data) < 5:
711
+ analysis["recommendations"].append(
712
+ "Consider diversifying with more holdings"
713
+ )
714
+
715
+ if portfolio_data:
716
+ max_allocation = max(item["allocation"] for item in portfolio_data)
717
+ if max_allocation > 40:
718
+ analysis["recommendations"].append(
719
+ f"High concentration risk: largest holding is {max_allocation:.1f}%"
720
+ )
721
+ elif max_allocation > 30:
722
+ analysis["recommendations"].append(
723
+ f"Moderate concentration risk: largest holding is {max_allocation:.1f}%"
724
+ )
725
+
726
+ # Check if allocations add up to 100%
727
+ total_allocation = sum(item["allocation"] for item in portfolio_data)
728
+ if abs(total_allocation - 100) > 5:
729
+ analysis["recommendations"].append(
730
+ f"Portfolio allocations total {total_allocation:.1f}% - consider rebalancing to 100%"
731
+ )
732
+
733
+ # Sector diversification recommendation
734
+ if len(portfolio_data) == 3:
735
+ analysis["recommendations"].append(
736
+ "Consider adding holdings from different sectors (healthcare, utilities, financials)"
737
+ )
738
+
739
+ return json.dumps(analysis, indent=2)
740
+
741
+ except Exception as e:
742
+ return f"Error analyzing portfolio: {str(e)}"
743
+
744
+ return Tool(
745
+ name="portfolio_analyzer",
746
+ description="Analyze portfolio performance and diversification. Input should include holdings like: [{'symbol': 'AAPL', 'shares': 100}]",
747
+ func=portfolio_analyzer,
748
+ )
749
+
750
+ def get_all_tools(self) -> List[Tool]:
751
+ return [
752
+ self.create_budget_planner(),
753
+ self.create_investment_analyzer(),
754
+ self.create_market_trends_analyzer(),
755
+ self.create_portfolio_analyzer(),
756
+ ]