Spaces:
Sleeping
Sleeping
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 |
+
]
|