@@ -0,0 +1,148 @@
|
||||
# 多智能体分析系统
|
||||
|
||||
基于 TradingAgents 架构优化的多智能体股票分析系统。
|
||||
|
||||
## 架构特点
|
||||
|
||||
### 1. 多智能体协作
|
||||
- **分析师团队**:市场分析师、基本面分析师、新闻分析师、情绪分析师、风险分析师
|
||||
- **研究团队**:看涨研究员、看跌研究员
|
||||
- **交易团队**:交易员、风险分析师(激进/中性/保守)
|
||||
|
||||
### 2. 工作流程
|
||||
```
|
||||
分析师团队分析 → 研究辩论 → 交易员决策 → 风险辩论 → 最终决策
|
||||
```
|
||||
|
||||
### 3. 记忆系统
|
||||
- 使用 SQLite 存储历史决策
|
||||
- 基于文本相似度检索历史经验
|
||||
- 支持从交易结果中学习
|
||||
|
||||
### 4. 工具调用
|
||||
- 智能体可以主动获取数据
|
||||
- 支持多数据源(yfinance, finnhub, ccxt, 腾讯接口)
|
||||
|
||||
## 使用方法
|
||||
|
||||
### 基本使用
|
||||
|
||||
```python
|
||||
from app.services.analysis import AnalysisService
|
||||
|
||||
# 使用多智能体架构(默认)
|
||||
service = AnalysisService(use_multi_agent=True)
|
||||
result = service.analyze("USStock", "AAPL", "zh-CN")
|
||||
|
||||
# 使用传统架构(向后兼容)
|
||||
service = AnalysisService(use_multi_agent=False)
|
||||
result = service.analyze("USStock", "AAPL", "zh-CN")
|
||||
```
|
||||
|
||||
### 环境变量配置
|
||||
|
||||
在 `.env` 文件中设置:
|
||||
|
||||
```bash
|
||||
# 是否启用多智能体架构(默认:True)
|
||||
USE_MULTI_AGENT=True
|
||||
|
||||
# 最大辩论轮数(默认:2)
|
||||
MAX_DEBATE_ROUNDS=2
|
||||
```
|
||||
|
||||
### 反思学习
|
||||
|
||||
```python
|
||||
from app.services.analysis import reflect_analysis
|
||||
|
||||
# 从交易结果中学习
|
||||
reflect_analysis(
|
||||
market="USStock",
|
||||
symbol="AAPL",
|
||||
decision="BUY",
|
||||
returns=1000.0, # 收益
|
||||
result="交易成功,收益 10%"
|
||||
)
|
||||
```
|
||||
|
||||
## 智能体说明
|
||||
|
||||
### 分析师智能体
|
||||
|
||||
- **MarketAnalyst**: 技术分析,计算技术指标
|
||||
- **FundamentalAnalyst**: 基本面分析,财务数据
|
||||
- **NewsAnalyst**: 新闻事件分析
|
||||
- **SentimentAnalyst**: 市场情绪分析
|
||||
- **RiskAnalyst**: 风险评估
|
||||
|
||||
### 研究员智能体
|
||||
|
||||
- **BullResearcher**: 构建看涨论据
|
||||
- **BearResearcher**: 构建看跌论据
|
||||
|
||||
### 交易员智能体
|
||||
|
||||
- **TraderAgent**: 综合所有分析,做出交易决策
|
||||
|
||||
### 风险分析师智能体
|
||||
|
||||
- **RiskyAnalyst**: 激进风险分析
|
||||
- **NeutralAnalyst**: 中性风险分析
|
||||
- **SafeAnalyst**: 保守风险分析
|
||||
|
||||
## 返回结果格式
|
||||
|
||||
多智能体模式返回的完整结果包括:
|
||||
|
||||
```json
|
||||
{
|
||||
"overview": {
|
||||
"overallScore": 75,
|
||||
"recommendation": "BUY",
|
||||
"confidence": 82,
|
||||
"dimensionScores": {...},
|
||||
"report": "..."
|
||||
},
|
||||
"fundamental": {...},
|
||||
"technical": {...},
|
||||
"news": {...},
|
||||
"sentiment": {...},
|
||||
"risk": {...},
|
||||
"debate": {
|
||||
"bull": {...},
|
||||
"bear": {...},
|
||||
"research_decision": "..."
|
||||
},
|
||||
"trader_decision": {
|
||||
"decision": "BUY",
|
||||
"confidence": 85,
|
||||
"trading_plan": {...}
|
||||
},
|
||||
"risk_debate": {
|
||||
"risky": {...},
|
||||
"neutral": {...},
|
||||
"safe": {...}
|
||||
},
|
||||
"final_decision": {
|
||||
"decision": "BUY",
|
||||
"confidence": 85,
|
||||
"reasoning": "..."
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## 优势
|
||||
|
||||
1. **多角度分析**:多个智能体从不同角度分析,减少盲点
|
||||
2. **辩论机制**:看涨/看跌辩论,发现潜在问题
|
||||
3. **风险控制**:多维度风险分析,提高决策质量
|
||||
4. **持续学习**:记忆系统支持从历史经验中学习
|
||||
5. **向后兼容**:可以切换到传统模式
|
||||
|
||||
## 注意事项
|
||||
|
||||
1. 多智能体模式会产生更多的 API 调用,成本较高
|
||||
2. 分析时间会比传统模式长
|
||||
3. 记忆系统需要 SQLite 数据库支持
|
||||
4. 建议在生产环境中根据需求选择合适的模式
|
||||
@@ -0,0 +1,32 @@
|
||||
"""
|
||||
多智能体分析系统
|
||||
基于 TradingAgents 架构优化
|
||||
"""
|
||||
from .base_agent import BaseAgent
|
||||
from .analyst_agents import (
|
||||
MarketAnalyst,
|
||||
FundamentalAnalyst,
|
||||
NewsAnalyst,
|
||||
SentimentAnalyst,
|
||||
RiskAnalyst
|
||||
)
|
||||
from .researcher_agents import BullResearcher, BearResearcher
|
||||
from .trader_agent import TraderAgent
|
||||
from .risk_agents import RiskyAnalyst, NeutralAnalyst, SafeAnalyst
|
||||
from .memory import AgentMemory
|
||||
|
||||
__all__ = [
|
||||
'BaseAgent',
|
||||
'MarketAnalyst',
|
||||
'FundamentalAnalyst',
|
||||
'NewsAnalyst',
|
||||
'SentimentAnalyst',
|
||||
'RiskAnalyst',
|
||||
'BullResearcher',
|
||||
'BearResearcher',
|
||||
'TraderAgent',
|
||||
'RiskyAnalyst',
|
||||
'NeutralAnalyst',
|
||||
'SafeAnalyst',
|
||||
'AgentMemory',
|
||||
]
|
||||
@@ -0,0 +1,434 @@
|
||||
"""
|
||||
Analyst agents.
|
||||
Includes: market/technical, fundamental, news, sentiment, risk analysts.
|
||||
"""
|
||||
import json
|
||||
from typing import Dict, Any
|
||||
from .base_agent import BaseAgent
|
||||
from .tools import AgentTools
|
||||
from app.services.llm import LLMService
|
||||
|
||||
logger = __import__('app.utils.logger', fromlist=['get_logger']).get_logger(__name__)
|
||||
|
||||
|
||||
class MarketAnalyst(BaseAgent):
|
||||
"""Market / technical analyst."""
|
||||
|
||||
def __init__(self, memory=None):
|
||||
super().__init__("MarketAnalyst", memory)
|
||||
self.tools = AgentTools()
|
||||
self.llm_service = LLMService()
|
||||
|
||||
def analyze(self, context: Dict[str, Any]) -> Dict[str, Any]:
|
||||
"""Run technical analysis."""
|
||||
market = context.get('market')
|
||||
symbol = context.get('symbol')
|
||||
language = context.get('language', 'zh-CN')
|
||||
model = context.get('model')
|
||||
base_data = context.get('base_data', {})
|
||||
|
||||
# Kline + current price
|
||||
kline_data = base_data.get('kline_data') or self.tools.get_stock_data(market, symbol, days=30)
|
||||
current_price = base_data.get('current_price') or self.tools.get_current_price(market, symbol)
|
||||
|
||||
# Technical indicators
|
||||
indicators = {}
|
||||
if kline_data:
|
||||
indicators = self.tools.calculate_technical_indicators(kline_data)
|
||||
|
||||
# Prompts
|
||||
lang_instruction = self._get_language_instruction(language)
|
||||
system_prompt = f"""You are a professional technical analyst. Please analyze the technical trends of the stock or cryptocurrency, including:
|
||||
{lang_instruction}
|
||||
1. Technical Indicator Signals (MACD signals, RSI range, trend strength, important MA directions)
|
||||
2. Technical Score (0-100). Be objective. Do not default to 75.
|
||||
- 0-40: Bearish/Weak
|
||||
- 41-60: Neutral
|
||||
- 61-100: Bullish/Strong
|
||||
3. Technical Analysis Report (about 300 words)
|
||||
|
||||
Please strictly return the result in JSON format as follows:
|
||||
{{
|
||||
"score": 75,
|
||||
"indicators": {{
|
||||
"MACD": "Golden Cross/Death Cross or Flat",
|
||||
"RSI(14)": "75 (Overbought)",
|
||||
"MA20": "Upward/Downward/Flat",
|
||||
"Support/Resistance": "Support: 150.00, Resistance: 165.50"
|
||||
}},
|
||||
"report": "Technical analysis report content..."
|
||||
}}"""
|
||||
|
||||
user_prompt = f"""Please perform technical analysis for {symbol} in {market} market.
|
||||
|
||||
**Current Price:**
|
||||
{json.dumps(current_price, ensure_ascii=False, indent=2) if current_price else 'No Data'}
|
||||
|
||||
**Kline Data (Last 30 days):**
|
||||
{json.dumps(kline_data[-10:] if kline_data else [], ensure_ascii=False, indent=2) if kline_data else 'No Data'}
|
||||
|
||||
**Calculated Technical Indicators:**
|
||||
{json.dumps(indicators, ensure_ascii=False, indent=2) if indicators else 'No Data'}
|
||||
|
||||
Please analyze the short-term and medium-term trends based on the above Kline data and price movements."""
|
||||
|
||||
# LLM call
|
||||
result = self.llm_service.safe_call_llm(
|
||||
system_prompt,
|
||||
user_prompt,
|
||||
{"score": 50, "indicators": {}, "report": "Failed to parse technical analysis"},
|
||||
model=model
|
||||
)
|
||||
|
||||
return {
|
||||
"type": "technical",
|
||||
"data": result,
|
||||
"indicators": indicators
|
||||
}
|
||||
|
||||
def _get_language_instruction(self, language: str) -> str:
|
||||
"""Return an English language instruction string for the LLM prompt."""
|
||||
language_map = {
|
||||
'zh-CN': 'Answer in Simplified Chinese.',
|
||||
'zh-TW': 'Answer in Traditional Chinese.',
|
||||
'en-US': 'Answer in English.',
|
||||
'ja-JP': 'Answer in Japanese.',
|
||||
'ko-KR': 'Answer in Korean.',
|
||||
'vi-VN': 'Answer in Vietnamese.',
|
||||
'th-TH': 'Answer in Thai.',
|
||||
'ar-SA': 'Answer in Arabic.',
|
||||
'fr-FR': 'Answer in French.',
|
||||
'de-DE': 'Answer in German.'
|
||||
}
|
||||
return language_map.get(language, 'Answer in English.')
|
||||
|
||||
|
||||
class FundamentalAnalyst(BaseAgent):
|
||||
"""Fundamental analyst."""
|
||||
|
||||
def __init__(self, memory=None):
|
||||
super().__init__("FundamentalAnalyst", memory)
|
||||
self.tools = AgentTools()
|
||||
self.llm_service = LLMService()
|
||||
|
||||
def analyze(self, context: Dict[str, Any]) -> Dict[str, Any]:
|
||||
"""Run fundamental analysis."""
|
||||
market = context.get('market')
|
||||
symbol = context.get('symbol')
|
||||
language = context.get('language', 'zh-CN')
|
||||
model = context.get('model')
|
||||
base_data = context.get('base_data', {})
|
||||
|
||||
# Fundamental data
|
||||
fundamental_data = base_data.get('fundamental_data') or self.tools.get_fundamental_data(market, symbol)
|
||||
company_data = base_data.get('company_data') or self.tools.get_company_data(market, symbol, language=language)
|
||||
|
||||
# Memory
|
||||
situation = f"{market}:{symbol} fundamental analysis"
|
||||
memories = self.get_memories(situation, n_matches=2)
|
||||
memory_prompt = self.format_memories_for_prompt(memories)
|
||||
|
||||
lang_instruction = self._get_language_instruction(language)
|
||||
system_prompt = f"""You are a fundamental analyst. Please analyze the financial status and industry position of the stock, including:
|
||||
{lang_instruction}
|
||||
1. Financial Indicators (Select key P/E, P/B, ROE, Revenue Growth)
|
||||
2. Fundamental Score (0-100). Be objective. Do not default to 80.
|
||||
- 0-40: Poor/Overvalued
|
||||
- 41-60: Fair/Neutral
|
||||
- 61-100: Good/Undervalued
|
||||
3. Fundamental Analysis Report (about 300 words)
|
||||
|
||||
{memory_prompt}
|
||||
|
||||
Please strictly return the result in JSON format as follows:
|
||||
{{
|
||||
"score": 80,
|
||||
"financials": {{
|
||||
"P/E": "25.3",
|
||||
"P/B": "4.2",
|
||||
"ROE": "18.5%",
|
||||
"Market Cap": "1200.5 B"
|
||||
}},
|
||||
"report": "Fundamental analysis report content..."
|
||||
}}"""
|
||||
|
||||
user_prompt = f"""Please perform fundamental analysis for {symbol} in {market} market.
|
||||
|
||||
**Basic Company Info:**
|
||||
{json.dumps(company_data, ensure_ascii=False, indent=2) if company_data else 'No Data'}
|
||||
|
||||
**Raw Fundamental Indicators:**
|
||||
{json.dumps(fundamental_data, ensure_ascii=False, indent=2) if fundamental_data else 'No Data'}
|
||||
|
||||
Please analyze based on the above data."""
|
||||
|
||||
result = self.llm_service.safe_call_llm(
|
||||
system_prompt,
|
||||
user_prompt,
|
||||
{"score": 50, "financials": {}, "report": "Failed to parse fundamental analysis"},
|
||||
model=model
|
||||
)
|
||||
|
||||
return {
|
||||
"type": "fundamental",
|
||||
"data": result
|
||||
}
|
||||
|
||||
def _get_language_instruction(self, language: str) -> str:
|
||||
language_map = {
|
||||
'zh-CN': 'Answer in Simplified Chinese.',
|
||||
'zh-TW': 'Answer in Traditional Chinese.',
|
||||
'en-US': 'Answer in English.',
|
||||
'ja-JP': 'Answer in Japanese.',
|
||||
'ko-KR': 'Answer in Korean.',
|
||||
'vi-VN': 'Answer in Vietnamese.',
|
||||
'th-TH': 'Answer in Thai.',
|
||||
'ar-SA': 'Answer in Arabic.',
|
||||
'fr-FR': 'Answer in French.',
|
||||
'de-DE': 'Answer in German.'
|
||||
}
|
||||
return language_map.get(language, 'Answer in English.')
|
||||
|
||||
|
||||
class NewsAnalyst(BaseAgent):
|
||||
"""News analyst."""
|
||||
|
||||
def __init__(self, memory=None):
|
||||
super().__init__("NewsAnalyst", memory)
|
||||
self.llm_service = LLMService()
|
||||
|
||||
def analyze(self, context: Dict[str, Any]) -> Dict[str, Any]:
|
||||
"""Run news analysis."""
|
||||
market = context.get('market')
|
||||
symbol = context.get('symbol')
|
||||
language = context.get('language', 'zh-CN')
|
||||
model = context.get('model')
|
||||
base_data = context.get('base_data', {})
|
||||
|
||||
company_data = base_data.get('company_data', {})
|
||||
news_data = base_data.get('news_data', [])
|
||||
|
||||
lang_instruction = self._get_language_instruction(language)
|
||||
system_prompt = f"""You are a professional news intelligence analyst. Your task is to extract key intelligence that has a major impact on stock prices from massive market information.
|
||||
|
||||
Analysis Requirements:
|
||||
{lang_instruction}
|
||||
1. **Filter Noise**: News data may contain internet results from search engines. Please carefully discriminate and ignore ads, duplicate content, or irrelevant noise. Prioritize authoritative financial media and official announcements.
|
||||
2. **Timeliness**: Focus on breaking news within the last 48 hours. For old news, lower its weight unless there are new developments.
|
||||
3. **Deep Interpretation**: Do not just repeat news titles. Analyze the logic behind the event and its specific impact on company fundamentals or market sentiment (e.g., Earnings Beat -> Profit Improvement -> Valuation Increase).
|
||||
4. **Scoring**: News Score (0-100).
|
||||
- 0-40: Major Negative (e.g., financial fraud, regulatory crackdown, core business damage)
|
||||
- 41-59: Neutral or minor impact
|
||||
- 60-100: Positive (e.g., strong earnings, major partnership, policy support)
|
||||
- Higher scores indicate more positive news.
|
||||
|
||||
Please strictly return the result in JSON format as follows:
|
||||
{{
|
||||
"score": 70,
|
||||
"events": [
|
||||
{{
|
||||
"title": "Event Title",
|
||||
"impact": "Positive/Negative/Neutral",
|
||||
"summary": "Event summary and deep impact analysis...",
|
||||
"date": "2023-10-27"
|
||||
}}
|
||||
],
|
||||
"report": "Comprehensive news analysis report, including overall judgment on recent market public opinion..."
|
||||
}}"""
|
||||
|
||||
user_prompt = f"""Please perform in-depth news intelligence analysis for {symbol} in {market} market.
|
||||
|
||||
**Company Background:**
|
||||
{json.dumps(company_data, ensure_ascii=False, indent=2) if company_data else 'No Data'}
|
||||
|
||||
**Latest Intelligence Sources (Contains professional financial news and web search results, please discriminate):**
|
||||
{json.dumps(news_data, ensure_ascii=False, indent=2) if news_data else 'No directly related news'}
|
||||
|
||||
Please analyze based on the above intelligence. If the provided "Latest Intelligence Sources" contain no substantive content or only irrelevant noise, please explicitly state "No valid news available" and deduce logically based on the industry trends and macro market environment of the stock."""
|
||||
|
||||
result = self.llm_service.safe_call_llm(
|
||||
system_prompt,
|
||||
user_prompt,
|
||||
{"score": 50, "events": [], "report": "Failed to parse news analysis"},
|
||||
model=model
|
||||
)
|
||||
|
||||
return {
|
||||
"type": "news",
|
||||
"data": result
|
||||
}
|
||||
|
||||
def _get_language_instruction(self, language: str) -> str:
|
||||
language_map = {
|
||||
'zh-CN': 'Answer in Simplified Chinese.',
|
||||
'zh-TW': 'Answer in Traditional Chinese.',
|
||||
'en-US': 'Answer in English.',
|
||||
'ja-JP': 'Answer in Japanese.',
|
||||
'ko-KR': 'Answer in Korean.',
|
||||
'vi-VN': 'Answer in Vietnamese.',
|
||||
'th-TH': 'Answer in Thai.',
|
||||
'ar-SA': 'Answer in Arabic.',
|
||||
'fr-FR': 'Answer in French.',
|
||||
'de-DE': 'Answer in German.'
|
||||
}
|
||||
return language_map.get(language, 'Answer in English.')
|
||||
|
||||
|
||||
class SentimentAnalyst(BaseAgent):
|
||||
"""Sentiment analyst."""
|
||||
|
||||
def __init__(self, memory=None):
|
||||
super().__init__("SentimentAnalyst", memory)
|
||||
self.llm_service = LLMService()
|
||||
|
||||
def analyze(self, context: Dict[str, Any]) -> Dict[str, Any]:
|
||||
"""Run sentiment analysis."""
|
||||
market = context.get('market')
|
||||
symbol = context.get('symbol')
|
||||
language = context.get('language', 'zh-CN')
|
||||
model = context.get('model')
|
||||
base_data = context.get('base_data', {})
|
||||
|
||||
current_price = base_data.get('current_price', {})
|
||||
kline_data = base_data.get('kline_data', [])
|
||||
|
||||
lang_instruction = self._get_language_instruction(language)
|
||||
system_prompt = f"""You are a market sentiment analyst. Please analyze the market sentiment and popularity of the stock, including:
|
||||
{lang_instruction}
|
||||
1. Sentiment Heat Indicators (e.g., analyst ratings, social media discussion volume, put/call ratio)
|
||||
2. Sentiment Score (0-100, higher means more optimistic). Be objective. Do not default to 65.
|
||||
- 0-40: Bearish/Fearful
|
||||
- 41-60: Neutral
|
||||
- 61-100: Bullish/Greedy
|
||||
3. Sentiment Analysis Report (about 300 words)
|
||||
|
||||
Please strictly return the result in JSON format as follows:
|
||||
{{
|
||||
"score": 65,
|
||||
"scores": {{
|
||||
"Analyst Rating": 90,
|
||||
"Social Media Heat": 85,
|
||||
"Market Sentiment Index": 70
|
||||
}},
|
||||
"report": "Sentiment analysis report content..."
|
||||
}}
|
||||
Note: All values in the 'scores' dictionary must be integers between 0-100 (pure numbers), representing optimism or heat, without any text or percentage signs."""
|
||||
|
||||
user_prompt = f"""Please perform sentiment analysis for {symbol} in {market} market.
|
||||
Based on the current Kline trends and price fluctuations, combined with your existing knowledge, evaluate whether the market sentiment for this stock is bullish, bearish, or neutral.
|
||||
|
||||
**Current Price:**
|
||||
{json.dumps(current_price, ensure_ascii=False, indent=2) if current_price else 'No Data'}
|
||||
|
||||
**Kline Data (Recent Trends):**
|
||||
{json.dumps(kline_data[-5:] if kline_data else [], ensure_ascii=False, indent=2) if kline_data else 'No Data'}
|
||||
|
||||
Please evaluate market sentiment."""
|
||||
|
||||
result = self.llm_service.safe_call_llm(
|
||||
system_prompt,
|
||||
user_prompt,
|
||||
{"score": 50, "scores": {"Analyst Rating": 50, "Social Media Heat": 50, "Market Sentiment Index": 50}, "report": "Failed to parse sentiment analysis"},
|
||||
model=model
|
||||
)
|
||||
|
||||
return {
|
||||
"type": "sentiment",
|
||||
"data": result
|
||||
}
|
||||
|
||||
def _get_language_instruction(self, language: str) -> str:
|
||||
language_map = {
|
||||
'zh-CN': 'Answer in Simplified Chinese.',
|
||||
'zh-TW': 'Answer in Traditional Chinese.',
|
||||
'en-US': 'Answer in English.',
|
||||
'ja-JP': 'Answer in Japanese.',
|
||||
'ko-KR': 'Answer in Korean.',
|
||||
'vi-VN': 'Answer in Vietnamese.',
|
||||
'th-TH': 'Answer in Thai.',
|
||||
'ar-SA': 'Answer in Arabic.',
|
||||
'fr-FR': 'Answer in French.',
|
||||
'de-DE': 'Answer in German.'
|
||||
}
|
||||
return language_map.get(language, 'Answer in English.')
|
||||
|
||||
|
||||
class RiskAnalyst(BaseAgent):
|
||||
"""Risk analyst."""
|
||||
|
||||
def __init__(self, memory=None):
|
||||
super().__init__("RiskAnalyst", memory)
|
||||
self.llm_service = LLMService()
|
||||
|
||||
def analyze(self, context: Dict[str, Any]) -> Dict[str, Any]:
|
||||
"""Run risk assessment."""
|
||||
market = context.get('market')
|
||||
symbol = context.get('symbol')
|
||||
language = context.get('language', 'zh-CN')
|
||||
model = context.get('model')
|
||||
base_data = context.get('base_data', {})
|
||||
|
||||
current_price = base_data.get('current_price', {})
|
||||
kline_data = base_data.get('kline_data', [])
|
||||
fundamental_data = base_data.get('fundamental_data', {})
|
||||
|
||||
lang_instruction = self._get_language_instruction(language)
|
||||
system_prompt = f"""You are a risk management analyst. Please evaluate the investment risks of the stock, including:
|
||||
{lang_instruction}
|
||||
1. Risk Indicators (Volatility, Liquidity, Concentration Risk, Systemic Risk Exposure)
|
||||
2. Risk Score (0-100, higher score means lower risk/safer). Be objective. Do not default to 60.
|
||||
- 0-40: High Risk (Dangerous)
|
||||
- 41-60: Moderate Risk
|
||||
- 61-100: Low Risk (Safe)
|
||||
3. Risk Assessment Report (about 300 words)
|
||||
|
||||
Please strictly return the result in JSON format as follows:
|
||||
{{
|
||||
"score": 60,
|
||||
"metrics": {{
|
||||
"Volatility (Beta)": "1.2 (Higher than market)",
|
||||
"Liquidity": "Good",
|
||||
"Concentration Risk": "Low (Diversified business)"
|
||||
}},
|
||||
"report": "Risk assessment report content..."
|
||||
}}"""
|
||||
|
||||
user_prompt = f"""Please perform risk assessment for {symbol} in {market} market.
|
||||
|
||||
**Current Price:**
|
||||
{json.dumps(current_price, ensure_ascii=False, indent=2) if current_price else 'No Data'}
|
||||
|
||||
**Kline Data (Volatility Analysis):**
|
||||
{json.dumps(kline_data, ensure_ascii=False, indent=2) if kline_data else 'No Data'}
|
||||
|
||||
**Fundamental Data (Debt Risk):**
|
||||
{json.dumps(fundamental_data, ensure_ascii=False, indent=2) if fundamental_data else 'No Data'}
|
||||
|
||||
Please evaluate investment risks based on price volatility, fundamental data, etc."""
|
||||
|
||||
result = self.llm_service.safe_call_llm(
|
||||
system_prompt,
|
||||
user_prompt,
|
||||
{"score": 50, "metrics": {}, "report": "Failed to parse risk assessment"},
|
||||
model=model
|
||||
)
|
||||
|
||||
return {
|
||||
"type": "risk",
|
||||
"data": result
|
||||
}
|
||||
|
||||
def _get_language_instruction(self, language: str) -> str:
|
||||
language_map = {
|
||||
'zh-CN': 'Answer in Simplified Chinese.',
|
||||
'zh-TW': 'Answer in Traditional Chinese.',
|
||||
'en-US': 'Answer in English.',
|
||||
'ja-JP': 'Answer in Japanese.',
|
||||
'ko-KR': 'Answer in Korean.',
|
||||
'vi-VN': 'Answer in Vietnamese.',
|
||||
'th-TH': 'Answer in Thai.',
|
||||
'ar-SA': 'Answer in Arabic.',
|
||||
'fr-FR': 'Answer in French.',
|
||||
'de-DE': 'Answer in German.'
|
||||
}
|
||||
return language_map.get(language, 'Answer in English.')
|
||||
@@ -0,0 +1,71 @@
|
||||
"""
|
||||
智能体基类
|
||||
"""
|
||||
from abc import ABC, abstractmethod
|
||||
from typing import Dict, Any, Optional, List
|
||||
from app.utils.logger import get_logger
|
||||
|
||||
logger = get_logger(__name__)
|
||||
|
||||
|
||||
class BaseAgent(ABC):
|
||||
"""智能体基类,所有分析智能体都继承此类"""
|
||||
|
||||
def __init__(self, name: str, memory: Optional[Any] = None):
|
||||
"""
|
||||
初始化智能体
|
||||
|
||||
Args:
|
||||
name: 智能体名称
|
||||
memory: 记忆系统实例(可选)
|
||||
"""
|
||||
self.name = name
|
||||
self.memory = memory
|
||||
self.logger = get_logger(f"{__name__}.{name}")
|
||||
|
||||
@abstractmethod
|
||||
def analyze(self, context: Dict[str, Any]) -> Dict[str, Any]:
|
||||
"""
|
||||
执行分析任务
|
||||
|
||||
Args:
|
||||
context: 分析上下文,包含市场、代码、基础数据等
|
||||
|
||||
Returns:
|
||||
分析结果字典
|
||||
"""
|
||||
pass
|
||||
|
||||
def get_memories(self, situation: str, n_matches: int = 2) -> List[Dict[str, Any]]:
|
||||
"""
|
||||
从记忆中检索相似情况
|
||||
|
||||
Args:
|
||||
situation: 当前情况描述
|
||||
n_matches: 返回的匹配数量
|
||||
|
||||
Returns:
|
||||
匹配的历史记录列表
|
||||
"""
|
||||
if self.memory:
|
||||
return self.memory.get_memories(situation, n_matches=n_matches)
|
||||
return []
|
||||
|
||||
def format_memories_for_prompt(self, memories: List[Dict[str, Any]]) -> str:
|
||||
"""
|
||||
格式化记忆为提示词
|
||||
|
||||
Args:
|
||||
memories: 记忆列表
|
||||
|
||||
Returns:
|
||||
格式化的字符串
|
||||
"""
|
||||
if not memories:
|
||||
return "无历史经验可参考。"
|
||||
|
||||
formatted = "历史经验参考:\n"
|
||||
for i, mem in enumerate(memories, 1):
|
||||
formatted += f"{i}. {mem.get('recommendation', 'N/A')}\n"
|
||||
|
||||
return formatted
|
||||
@@ -0,0 +1,500 @@
|
||||
"""
|
||||
Multi-agent coordinator.
|
||||
Orchestrates agents and the overall analysis workflow.
|
||||
"""
|
||||
from typing import Dict, Any, List, Optional
|
||||
from concurrent.futures import ThreadPoolExecutor, as_completed
|
||||
from .analyst_agents import (
|
||||
MarketAnalyst, FundamentalAnalyst, NewsAnalyst,
|
||||
SentimentAnalyst, RiskAnalyst
|
||||
)
|
||||
from .researcher_agents import BullResearcher, BearResearcher
|
||||
from .trader_agent import TraderAgent
|
||||
from .risk_agents import RiskyAnalyst, NeutralAnalyst, SafeAnalyst
|
||||
from .memory import AgentMemory
|
||||
from .reflection import ReflectionService
|
||||
from app.utils.logger import get_logger
|
||||
|
||||
logger = get_logger(__name__)
|
||||
|
||||
|
||||
class AgentCoordinator:
|
||||
"""Multi-agent coordinator."""
|
||||
|
||||
@staticmethod
|
||||
def _is_zh(language: str) -> bool:
|
||||
return str(language or "").lower().startswith("zh")
|
||||
|
||||
@classmethod
|
||||
def _t(cls, language: str, en: str, zh: str) -> str:
|
||||
"""Pick a localized string for user-facing fields (not logs)."""
|
||||
return zh if cls._is_zh(language) else en
|
||||
|
||||
def __init__(self, enable_memory: bool = True, max_debate_rounds: int = 2):
|
||||
"""
|
||||
Args:
|
||||
enable_memory: Enable memory/reflection
|
||||
max_debate_rounds: Max debate rounds
|
||||
"""
|
||||
self.enable_memory = enable_memory
|
||||
self.max_debate_rounds = max_debate_rounds
|
||||
|
||||
# Reflection service
|
||||
self.reflection_service = ReflectionService() if enable_memory else None
|
||||
|
||||
# Memory stores
|
||||
if enable_memory:
|
||||
self.memories = {
|
||||
'market': AgentMemory('market_analyst'),
|
||||
'fundamental': AgentMemory('fundamental_analyst'),
|
||||
'news': AgentMemory('news_analyst'),
|
||||
'sentiment': AgentMemory('sentiment_analyst'),
|
||||
'risk': AgentMemory('risk_analyst'),
|
||||
'bull': AgentMemory('bull_researcher'),
|
||||
'bear': AgentMemory('bear_researcher'),
|
||||
'trader': AgentMemory('trader_agent'),
|
||||
}
|
||||
else:
|
||||
self.memories = {}
|
||||
|
||||
# Initialize agents
|
||||
self._init_agents()
|
||||
|
||||
def _init_agents(self):
|
||||
"""Initialize all agents."""
|
||||
# Analyst agents
|
||||
self.market_analyst = MarketAnalyst(
|
||||
memory=self.memories.get('market')
|
||||
)
|
||||
self.fundamental_analyst = FundamentalAnalyst(
|
||||
memory=self.memories.get('fundamental')
|
||||
)
|
||||
self.news_analyst = NewsAnalyst(
|
||||
memory=self.memories.get('news')
|
||||
)
|
||||
self.sentiment_analyst = SentimentAnalyst(
|
||||
memory=self.memories.get('sentiment')
|
||||
)
|
||||
self.risk_analyst = RiskAnalyst(
|
||||
memory=self.memories.get('risk')
|
||||
)
|
||||
|
||||
# Researcher agents
|
||||
self.bull_researcher = BullResearcher(
|
||||
memory=self.memories.get('bull')
|
||||
)
|
||||
self.bear_researcher = BearResearcher(
|
||||
memory=self.memories.get('bear')
|
||||
)
|
||||
|
||||
# Trader agent
|
||||
self.trader_agent = TraderAgent(
|
||||
memory=self.memories.get('trader')
|
||||
)
|
||||
|
||||
# Risk debate agents
|
||||
self.risky_analyst = RiskyAnalyst()
|
||||
self.neutral_analyst = NeutralAnalyst()
|
||||
self.safe_analyst = SafeAnalyst()
|
||||
|
||||
def run_analysis(self, market: str, symbol: str, language: str = 'zh-CN', model: str = None) -> Dict[str, Any]:
|
||||
"""
|
||||
Run the full multi-agent analysis workflow.
|
||||
"""
|
||||
logger.info(f"Multi-agent analysis start: {market}:{symbol}, model={model}, language={language}")
|
||||
|
||||
# Build base context
|
||||
from .tools import AgentTools
|
||||
tools = AgentTools()
|
||||
|
||||
# 1) Base data
|
||||
current_price = tools.get_current_price(market, symbol)
|
||||
company_data = tools.get_company_data(market, symbol, language=language)
|
||||
|
||||
# 2) Kline + fundamentals
|
||||
kline_data = tools.get_stock_data(market, symbol, days=30)
|
||||
fundamental_data = tools.get_fundamental_data(market, symbol)
|
||||
|
||||
# 3) News (Finnhub + web search)
|
||||
company_name = company_data.get('name', symbol) if company_data else symbol
|
||||
news_data = tools.get_news(market, symbol, days=7, company_name=company_name)
|
||||
|
||||
base_data = {
|
||||
"market": market,
|
||||
"symbol": symbol,
|
||||
"current_price": current_price,
|
||||
"kline_data": kline_data,
|
||||
"fundamental_data": fundamental_data,
|
||||
"company_data": company_data,
|
||||
"news_data": news_data,
|
||||
}
|
||||
|
||||
context = {
|
||||
"market": market,
|
||||
"symbol": symbol,
|
||||
"language": language,
|
||||
"model": model,
|
||||
"base_data": base_data
|
||||
}
|
||||
|
||||
# Phase 1: Analysts (parallel)
|
||||
logger.info("Phase 1: Analyst team (parallel)")
|
||||
with ThreadPoolExecutor(max_workers=5) as executor:
|
||||
future_market = executor.submit(self.market_analyst.analyze, context)
|
||||
future_fundamental = executor.submit(self.fundamental_analyst.analyze, context)
|
||||
future_news = executor.submit(self.news_analyst.analyze, context)
|
||||
future_sentiment = executor.submit(self.sentiment_analyst.analyze, context)
|
||||
future_risk = executor.submit(self.risk_analyst.analyze, context)
|
||||
|
||||
market_report = future_market.result()
|
||||
fundamental_report = future_fundamental.result()
|
||||
news_report = future_news.result()
|
||||
sentiment_report = future_sentiment.result()
|
||||
risk_report = future_risk.result()
|
||||
|
||||
# Update context with analyst outputs
|
||||
context.update({
|
||||
"market_report": market_report,
|
||||
"fundamental_report": fundamental_report,
|
||||
"news_report": news_report,
|
||||
"sentiment_report": sentiment_report,
|
||||
"risk_report": risk_report,
|
||||
})
|
||||
|
||||
# Phase 2: Bull/Bear debate (parallel)
|
||||
logger.info("Phase 2: Research debate (parallel)")
|
||||
with ThreadPoolExecutor(max_workers=2) as executor:
|
||||
future_bull = executor.submit(self.bull_researcher.analyze, context)
|
||||
future_bear = executor.submit(self.bear_researcher.analyze, context)
|
||||
|
||||
bull_argument = future_bull.result()
|
||||
bear_argument = future_bear.result()
|
||||
|
||||
context["bull_argument"] = bull_argument
|
||||
context["bear_argument"] = bear_argument
|
||||
|
||||
# Research manager decision (lightweight, based on debate)
|
||||
research_decision = self._make_research_decision(
|
||||
bull_argument, bear_argument, context
|
||||
)
|
||||
context["research_decision"] = research_decision
|
||||
|
||||
# Phase 3: Trader decision
|
||||
logger.info("Phase 3: Trader decision")
|
||||
trader_result = self.trader_agent.analyze(context)
|
||||
trader_plan = trader_result.get('data', {}).get('trading_plan', {})
|
||||
context["trader_plan"] = trader_plan
|
||||
|
||||
# Phase 4: Risk debate (parallel)
|
||||
logger.info("Phase 4: Risk debate (parallel)")
|
||||
with ThreadPoolExecutor(max_workers=3) as executor:
|
||||
future_risky = executor.submit(self.risky_analyst.analyze, context)
|
||||
future_neutral = executor.submit(self.neutral_analyst.analyze, context)
|
||||
future_safe = executor.submit(self.safe_analyst.analyze, context)
|
||||
|
||||
risky_result = future_risky.result()
|
||||
neutral_result = future_neutral.result()
|
||||
safe_result = future_safe.result()
|
||||
|
||||
# Risk manager final decision
|
||||
final_decision = self._make_risk_decision(
|
||||
risky_result, neutral_result, safe_result,
|
||||
trader_result, context
|
||||
)
|
||||
|
||||
# Record analysis result for later reflection/validation
|
||||
if self.reflection_service and final_decision.get('decision') in ['BUY', 'SELL', 'HOLD']:
|
||||
try:
|
||||
self.reflection_service.record_analysis(
|
||||
market=market,
|
||||
symbol=symbol,
|
||||
price=base_data.get('current_price', {}).get('price'),
|
||||
decision=final_decision.get('decision'),
|
||||
confidence=final_decision.get('confidence', 50),
|
||||
reasoning=final_decision.get('reasoning', ''),
|
||||
check_days=7 # Validate after 7 days by default
|
||||
)
|
||||
except Exception as e:
|
||||
logger.warning(f"Record reflection failed: {e}")
|
||||
|
||||
# Build final result (defensive defaults to keep frontend stable)
|
||||
debate_data = {
|
||||
"bull": bull_argument.get('data', {}) if bull_argument.get('data') else {},
|
||||
"bear": bear_argument.get('data', {}) if bear_argument.get('data') else {},
|
||||
"research_decision": research_decision if research_decision else "Analyzing..."
|
||||
}
|
||||
|
||||
trader_decision_data = trader_result.get('data', {}) if trader_result.get('data') else {
|
||||
"decision": "HOLD",
|
||||
"confidence": 50,
|
||||
"reasoning": "Analyzing...",
|
||||
"trading_plan": {},
|
||||
"report": "Analyzing..."
|
||||
}
|
||||
|
||||
risk_debate_data = {
|
||||
"risky": risky_result.get('data', {}) if risky_result.get('data') else {},
|
||||
"neutral": neutral_result.get('data', {}) if neutral_result.get('data') else {},
|
||||
"safe": safe_result.get('data', {}) if safe_result.get('data') else {}
|
||||
}
|
||||
|
||||
# Ensure final_decision is present
|
||||
if not final_decision or (isinstance(final_decision, dict) and len(final_decision) == 0):
|
||||
final_decision = {
|
||||
"decision": "HOLD",
|
||||
"confidence": 50,
|
||||
"reasoning": "Analyzing...",
|
||||
"risk_summary": {},
|
||||
"recommendation": "Analyzing..."
|
||||
}
|
||||
|
||||
result = {
|
||||
"overview": self._generate_overview(context, final_decision),
|
||||
"fundamental": fundamental_report.get('data', {}),
|
||||
"technical": market_report.get('data', {}),
|
||||
"news": news_report.get('data', {}),
|
||||
"sentiment": sentiment_report.get('data', {}),
|
||||
"risk": risk_report.get('data', {}),
|
||||
"debate": debate_data,
|
||||
"trader_decision": trader_decision_data,
|
||||
"risk_debate": risk_debate_data,
|
||||
"final_decision": final_decision,
|
||||
"error": None
|
||||
}
|
||||
|
||||
logger.info(f"Multi-agent analysis completed: {market}:{symbol}")
|
||||
logger.info(
|
||||
"Result fields - debate=%s, trader_decision=%s, risk_debate=%s, final_decision=%s",
|
||||
bool(result.get('debate')),
|
||||
bool(result.get('trader_decision')),
|
||||
bool(result.get('risk_debate')),
|
||||
bool(result.get('final_decision')),
|
||||
)
|
||||
return result
|
||||
|
||||
def _make_research_decision(self, bull: Dict, bear: Dict, context: Dict) -> str:
|
||||
"""Research manager decision (rule-based, lightweight)."""
|
||||
try:
|
||||
language = context.get("language", "en-US")
|
||||
bull_confidence = bull.get('data', {}).get('confidence', 50)
|
||||
bear_confidence = bear.get('data', {}).get('confidence', 50)
|
||||
|
||||
# Tie-breaker when scores are close (<= 10)
|
||||
score_diff = bull_confidence - bear_confidence
|
||||
|
||||
if abs(score_diff) <= 10:
|
||||
# Use technical + sentiment as a bias signal
|
||||
market_score = context.get('market_report', {}).get('data', {}).get('score', 50)
|
||||
sentiment_score = context.get('sentiment_report', {}).get('data', {}).get('score', 50)
|
||||
|
||||
market_bias = (market_score + sentiment_score) / 2
|
||||
|
||||
if market_bias > 60:
|
||||
return self._t(
|
||||
language,
|
||||
en=(
|
||||
f"Research decision: bull and bear cases are close (bull {bull_confidence}% vs bear {bear_confidence}%), "
|
||||
f"but technical/sentiment are optimistic (avg {market_bias:.1f}), slightly leaning bullish."
|
||||
),
|
||||
zh=(
|
||||
f"研究经理决策:多空论据势均力敌(看涨 {bull_confidence}% vs 看跌 {bear_confidence}%),"
|
||||
f"但鉴于技术面和市场情绪偏乐观(平均分 {market_bias:.1f}),稍微倾向于看涨。"
|
||||
),
|
||||
)
|
||||
elif market_bias < 40:
|
||||
return self._t(
|
||||
language,
|
||||
en=(
|
||||
f"Research decision: bull and bear cases are close (bull {bull_confidence}% vs bear {bear_confidence}%), "
|
||||
f"but technical/sentiment are pessimistic (avg {market_bias:.1f}), slightly leaning bearish."
|
||||
),
|
||||
zh=(
|
||||
f"研究经理决策:多空论据势均力敌(看涨 {bull_confidence}% vs 看跌 {bear_confidence}%),"
|
||||
f"但鉴于技术面和市场情绪偏悲观(平均分 {market_bias:.1f}),稍微倾向于看跌。"
|
||||
),
|
||||
)
|
||||
else:
|
||||
return self._t(
|
||||
language,
|
||||
en=(
|
||||
f"Research decision: bull and bear cases are close (bull {bull_confidence}% vs bear {bear_confidence}%), "
|
||||
"and market bias is unclear. Prefer neutral / wait-and-see."
|
||||
),
|
||||
zh=(
|
||||
f"研究经理决策:多空论据势均力敌(看涨 {bull_confidence}% vs 看跌 {bear_confidence}%),"
|
||||
"且市场情绪不明朗,建议保持中立/观望。"
|
||||
),
|
||||
)
|
||||
|
||||
elif score_diff > 10:
|
||||
return self._t(
|
||||
language,
|
||||
en=(
|
||||
f"Research decision: bullish case (confidence {bull_confidence}%) is clearly stronger than bearish "
|
||||
f"(confidence {bear_confidence}%). Lean bullish."
|
||||
),
|
||||
zh=(
|
||||
f"研究经理决策:基于看涨论据(置信度 {bull_confidence}%)明显强于看跌论据(置信度 {bear_confidence}%),明确倾向于看涨。"
|
||||
),
|
||||
)
|
||||
else: # score_diff < -10
|
||||
return self._t(
|
||||
language,
|
||||
en=(
|
||||
f"Research decision: bearish case (confidence {bear_confidence}%) is clearly stronger than bullish "
|
||||
f"(confidence {bull_confidence}%). Lean bearish."
|
||||
),
|
||||
zh=(
|
||||
f"研究经理决策:基于看跌论据(置信度 {bear_confidence}%)明显强于看涨论据(置信度 {bull_confidence}%),明确倾向于看跌。"
|
||||
),
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Research decision failed: {e}")
|
||||
language = context.get("language", "en-US") if isinstance(context, dict) else "en-US"
|
||||
return self._t(language, en="Research decision: unable to reach a clear conclusion.", zh="研究经理决策:无法做出明确判断。")
|
||||
|
||||
def _make_risk_decision(self, risky: Dict, neutral: Dict, safe: Dict,
|
||||
trader: Dict, context: Dict) -> Dict[str, Any]:
|
||||
"""Risk manager final decision (lightweight)."""
|
||||
try:
|
||||
language = context.get("language", "en-US")
|
||||
trader_decision = trader.get('data', {}).get('decision', 'HOLD')
|
||||
trader_confidence = trader.get('data', {}).get('confidence', 50)
|
||||
|
||||
# Risk debate summary
|
||||
risk_summary = {
|
||||
"risky_view": risky.get('data', {}).get('recommendation', ''),
|
||||
"neutral_view": neutral.get('data', {}).get('recommendation', ''),
|
||||
"safe_view": safe.get('data', {}).get('recommendation', ''),
|
||||
}
|
||||
|
||||
# Final decision (use trader decision + risk debate context)
|
||||
final_decision = {
|
||||
"decision": trader_decision,
|
||||
"confidence": trader_confidence,
|
||||
"reasoning": self._t(
|
||||
language,
|
||||
en=f"Final decision is based on trader analysis ({trader_decision}, confidence {trader_confidence}%) and the risk debate.",
|
||||
zh=f"基于交易员分析({trader_decision},置信度 {trader_confidence}%)和风险辩论,做出最终决策。",
|
||||
),
|
||||
"risk_summary": risk_summary,
|
||||
"recommendation": trader.get('data', {}).get('report', '')
|
||||
}
|
||||
|
||||
return final_decision
|
||||
except Exception as e:
|
||||
logger.error(f"Risk decision failed: {e}")
|
||||
language = context.get("language", "en-US") if isinstance(context, dict) else "en-US"
|
||||
return {
|
||||
"decision": "HOLD",
|
||||
"confidence": 50,
|
||||
"reasoning": self._t(language, en="Risk decision failed", zh="风险决策失败"),
|
||||
"risk_summary": {},
|
||||
"recommendation": ""
|
||||
}
|
||||
|
||||
def _generate_overview(self, context: Dict, final_decision: Dict) -> Dict[str, Any]:
|
||||
"""Generate overview section (lightweight, deterministic)."""
|
||||
try:
|
||||
language = context.get("language", "en-US")
|
||||
# Extract dimension scores
|
||||
technical_data = context.get('market_report', {}).get('data', {})
|
||||
fundamental_data = context.get('fundamental_report', {}).get('data', {})
|
||||
news_data = context.get('news_report', {}).get('data', {})
|
||||
sentiment_data = context.get('sentiment_report', {}).get('data', {})
|
||||
risk_data = context.get('risk_report', {}).get('data', {})
|
||||
|
||||
technical_score = technical_data.get('score', 50)
|
||||
fundamental_score = fundamental_data.get('score', 50)
|
||||
news_score = news_data.get('score', 50)
|
||||
sentiment_score = sentiment_data.get('score', 50)
|
||||
risk_score = risk_data.get('score', 50)
|
||||
|
||||
# Generate an overall score using weighted dimensions + decision/confidence adjustment
|
||||
decision = final_decision.get('decision', 'HOLD')
|
||||
confidence = final_decision.get('confidence', 50)
|
||||
|
||||
# 1) Base score: weighted average (tech 30%, fundamental 25%, news 15%, sentiment 15%, risk 15%)
|
||||
weighted_score = (
|
||||
technical_score * 0.3 +
|
||||
fundamental_score * 0.25 +
|
||||
news_score * 0.15 +
|
||||
sentiment_score * 0.15 +
|
||||
risk_score * 0.15
|
||||
)
|
||||
|
||||
# 2) Decision adjustment: BUY pushes toward 60-100, SELL toward 0-40, HOLD toward 50
|
||||
if decision == 'BUY':
|
||||
target_score = 60 + (confidence / 100 * 40) # Map to 60-100
|
||||
overall_score = (weighted_score * 0.4) + (target_score * 0.6)
|
||||
elif decision == 'SELL':
|
||||
target_score = 40 - (confidence / 100 * 40) # Map to 0-40
|
||||
overall_score = (weighted_score * 0.4) + (target_score * 0.6)
|
||||
else:
|
||||
overall_score = (weighted_score * 0.6) + (50 * 0.4)
|
||||
|
||||
# Clamp to 0..100
|
||||
overall_score = max(0, min(100, int(overall_score)))
|
||||
|
||||
return {
|
||||
"overallScore": overall_score,
|
||||
"recommendation": decision,
|
||||
"confidence": confidence,
|
||||
"dimensionScores": {
|
||||
"fundamental": fundamental_score,
|
||||
"technical": technical_score,
|
||||
"news": news_score,
|
||||
"sentiment": sentiment_score,
|
||||
"risk": risk_score
|
||||
},
|
||||
"report": final_decision.get(
|
||||
'reasoning',
|
||||
self._t(language, en="Overview generated.", zh="综合分析完成"),
|
||||
)
|
||||
}
|
||||
except Exception as e:
|
||||
logger.error(f"Generate overview failed: {e}")
|
||||
language = context.get("language", "en-US") if isinstance(context, dict) else "en-US"
|
||||
return {
|
||||
"overallScore": 50,
|
||||
"recommendation": "HOLD",
|
||||
"confidence": 50,
|
||||
"dimensionScores": {
|
||||
"fundamental": 50,
|
||||
"technical": 50,
|
||||
"news": 50,
|
||||
"sentiment": 50,
|
||||
"risk": 50
|
||||
},
|
||||
"report": self._t(language, en="Failed to generate overview.", zh="综合分析生成失败")
|
||||
}
|
||||
|
||||
def reflect_and_learn(self, market: str, symbol: str, decision: str,
|
||||
returns: Optional[float] = None, result: Optional[str] = None):
|
||||
"""
|
||||
Reflection hook: store post-trade outcomes into memory (local-only).
|
||||
|
||||
Args:
|
||||
market: Market
|
||||
symbol: Symbol
|
||||
decision: BUY/SELL/HOLD
|
||||
returns: Return percentage
|
||||
result: Free-text outcome
|
||||
"""
|
||||
if not self.enable_memory:
|
||||
return
|
||||
|
||||
try:
|
||||
situation = f"{market}:{symbol} trading decision"
|
||||
recommendation = f"Decision: {decision}, returns: {returns if returns is not None else 'N/A'}"
|
||||
|
||||
# Update trader memory
|
||||
if 'trader' in self.memories:
|
||||
self.memories['trader'].add_memory(
|
||||
situation, recommendation, result, returns
|
||||
)
|
||||
|
||||
logger.info(f"Reflection completed: {market}:{symbol}, decision={decision}, returns={returns}")
|
||||
except Exception as e:
|
||||
logger.error(f"Reflection failed: {e}")
|
||||
@@ -0,0 +1,203 @@
|
||||
"""
|
||||
智能体记忆系统
|
||||
使用 SQLite + 简单的文本相似度匹配
|
||||
"""
|
||||
import sqlite3
|
||||
import json
|
||||
import os
|
||||
from typing import List, Dict, Any, Optional
|
||||
from datetime import datetime
|
||||
import difflib
|
||||
|
||||
from app.utils.logger import get_logger
|
||||
|
||||
logger = get_logger(__name__)
|
||||
|
||||
|
||||
class AgentMemory:
|
||||
"""智能体记忆系统"""
|
||||
|
||||
def __init__(self, agent_name: str, db_path: Optional[str] = None):
|
||||
"""
|
||||
初始化记忆系统
|
||||
|
||||
Args:
|
||||
agent_name: 智能体名称
|
||||
db_path: 数据库路径(可选)
|
||||
"""
|
||||
self.agent_name = agent_name
|
||||
|
||||
if db_path is None:
|
||||
# 默认数据库路径
|
||||
db_dir = os.path.join(os.path.dirname(__file__), '..', '..', '..', 'data', 'memory')
|
||||
os.makedirs(db_dir, exist_ok=True)
|
||||
db_path = os.path.join(db_dir, f'{agent_name}_memory.db')
|
||||
|
||||
self.db_path = db_path
|
||||
self._init_database()
|
||||
|
||||
def _init_database(self):
|
||||
"""初始化数据库表"""
|
||||
try:
|
||||
conn = sqlite3.connect(self.db_path)
|
||||
cursor = conn.cursor()
|
||||
|
||||
cursor.execute('''
|
||||
CREATE TABLE IF NOT EXISTS memories (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
situation TEXT NOT NULL,
|
||||
recommendation TEXT NOT NULL,
|
||||
result TEXT,
|
||||
returns REAL,
|
||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
|
||||
)
|
||||
''')
|
||||
|
||||
# 创建索引
|
||||
cursor.execute('''
|
||||
CREATE INDEX IF NOT EXISTS idx_created_at ON memories(created_at)
|
||||
''')
|
||||
|
||||
conn.commit()
|
||||
conn.close()
|
||||
except Exception as e:
|
||||
logger.error(f"初始化记忆数据库失败: {e}")
|
||||
|
||||
def add_memory(self, situation: str, recommendation: str, result: Optional[str] = None, returns: Optional[float] = None):
|
||||
"""
|
||||
添加记忆
|
||||
|
||||
Args:
|
||||
situation: 情况描述
|
||||
recommendation: 建议/决策
|
||||
result: 结果描述(可选)
|
||||
returns: 收益(可选)
|
||||
"""
|
||||
try:
|
||||
conn = sqlite3.connect(self.db_path)
|
||||
cursor = conn.cursor()
|
||||
|
||||
cursor.execute('''
|
||||
INSERT INTO memories (situation, recommendation, result, returns)
|
||||
VALUES (?, ?, ?, ?)
|
||||
''', (situation, recommendation, result, returns))
|
||||
|
||||
conn.commit()
|
||||
conn.close()
|
||||
logger.info(f"{self.agent_name} 添加新记忆")
|
||||
except Exception as e:
|
||||
logger.error(f"添加记忆失败: {e}")
|
||||
|
||||
def get_memories(self, current_situation: str, n_matches: int = 2) -> List[Dict[str, Any]]:
|
||||
"""
|
||||
检索相似记忆
|
||||
|
||||
Args:
|
||||
current_situation: 当前情况描述
|
||||
n_matches: 返回的匹配数量
|
||||
|
||||
Returns:
|
||||
匹配的记忆列表
|
||||
"""
|
||||
try:
|
||||
conn = sqlite3.connect(self.db_path)
|
||||
cursor = conn.cursor()
|
||||
|
||||
# 获取所有记忆
|
||||
cursor.execute('''
|
||||
SELECT id, situation, recommendation, result, returns, created_at
|
||||
FROM memories
|
||||
ORDER BY created_at DESC
|
||||
LIMIT 100
|
||||
''')
|
||||
|
||||
all_memories = cursor.fetchall()
|
||||
conn.close()
|
||||
|
||||
if not all_memories:
|
||||
return []
|
||||
|
||||
# 计算相似度
|
||||
scored_memories = []
|
||||
for mem in all_memories:
|
||||
mem_id, situation, recommendation, result, returns, created_at = mem
|
||||
|
||||
# 使用简单的文本相似度
|
||||
similarity = difflib.SequenceMatcher(
|
||||
None,
|
||||
current_situation.lower(),
|
||||
situation.lower()
|
||||
).ratio()
|
||||
|
||||
scored_memories.append({
|
||||
'id': mem_id,
|
||||
'matched_situation': situation,
|
||||
'recommendation': recommendation,
|
||||
'result': result,
|
||||
'returns': returns,
|
||||
'similarity_score': similarity,
|
||||
'created_at': created_at
|
||||
})
|
||||
|
||||
# 按相似度排序
|
||||
scored_memories.sort(key=lambda x: x['similarity_score'], reverse=True)
|
||||
|
||||
# 返回前 n_matches 个
|
||||
return scored_memories[:n_matches]
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"检索记忆失败: {e}")
|
||||
return []
|
||||
|
||||
def update_memory_result(self, memory_id: int, result: str, returns: Optional[float] = None):
|
||||
"""
|
||||
更新记忆的结果
|
||||
|
||||
Args:
|
||||
memory_id: 记忆ID
|
||||
result: 结果描述
|
||||
returns: 收益
|
||||
"""
|
||||
try:
|
||||
conn = sqlite3.connect(self.db_path)
|
||||
cursor = conn.cursor()
|
||||
|
||||
cursor.execute('''
|
||||
UPDATE memories
|
||||
SET result = ?, returns = ?, updated_at = CURRENT_TIMESTAMP
|
||||
WHERE id = ?
|
||||
''', (result, returns, memory_id))
|
||||
|
||||
conn.commit()
|
||||
conn.close()
|
||||
logger.info(f"{self.agent_name} 更新记忆 {memory_id}")
|
||||
except Exception as e:
|
||||
logger.error(f"更新记忆失败: {e}")
|
||||
|
||||
def get_statistics(self) -> Dict[str, Any]:
|
||||
"""获取记忆统计信息"""
|
||||
try:
|
||||
conn = sqlite3.connect(self.db_path)
|
||||
cursor = conn.cursor()
|
||||
|
||||
cursor.execute('SELECT COUNT(*) FROM memories')
|
||||
total = cursor.fetchone()[0]
|
||||
|
||||
cursor.execute('SELECT AVG(returns) FROM memories WHERE returns IS NOT NULL')
|
||||
avg_returns = cursor.fetchone()[0] or 0
|
||||
|
||||
cursor.execute('SELECT COUNT(*) FROM memories WHERE returns > 0')
|
||||
positive = cursor.fetchone()[0]
|
||||
|
||||
conn.close()
|
||||
|
||||
return {
|
||||
'total_memories': total,
|
||||
'average_returns': round(avg_returns, 2),
|
||||
'positive_decisions': positive,
|
||||
'success_rate': round(positive / total * 100, 2) if total > 0 else 0
|
||||
}
|
||||
except Exception as e:
|
||||
logger.error(f"获取统计信息失败: {e}")
|
||||
return {}
|
||||
@@ -0,0 +1,205 @@
|
||||
"""
|
||||
自动反思与验证服务
|
||||
用于记录分析预测,并在未来自动验证结果,实现闭环学习
|
||||
"""
|
||||
import sqlite3
|
||||
import os
|
||||
import json
|
||||
from datetime import datetime, timedelta
|
||||
from typing import List, Dict, Any, Optional
|
||||
from app.utils.logger import get_logger
|
||||
from .memory import AgentMemory
|
||||
from .tools import AgentTools
|
||||
|
||||
logger = get_logger(__name__)
|
||||
|
||||
class ReflectionService:
|
||||
"""反思服务:管理分析记录的存储和验证"""
|
||||
|
||||
def __init__(self, db_path: Optional[str] = None):
|
||||
if db_path is None:
|
||||
# 默认数据库路径
|
||||
db_dir = os.path.join(os.path.dirname(__file__), '..', '..', '..', 'data', 'memory')
|
||||
os.makedirs(db_dir, exist_ok=True)
|
||||
db_path = os.path.join(db_dir, 'reflection_records.db')
|
||||
|
||||
self.db_path = db_path
|
||||
self.tools = AgentTools()
|
||||
self._init_database()
|
||||
|
||||
def _init_database(self):
|
||||
"""初始化数据库表"""
|
||||
try:
|
||||
conn = sqlite3.connect(self.db_path)
|
||||
cursor = conn.cursor()
|
||||
|
||||
# 创建分析记录表
|
||||
cursor.execute('''
|
||||
CREATE TABLE IF NOT EXISTS analysis_records (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
market TEXT NOT NULL,
|
||||
symbol TEXT NOT NULL,
|
||||
initial_price REAL,
|
||||
decision TEXT,
|
||||
confidence INTEGER,
|
||||
reasoning TEXT,
|
||||
analysis_date TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||
target_check_date TIMESTAMP,
|
||||
status TEXT DEFAULT 'PENDING', -- PENDING, COMPLETED, FAILED
|
||||
final_price REAL,
|
||||
actual_return REAL,
|
||||
check_result TEXT
|
||||
)
|
||||
''')
|
||||
|
||||
# 创建索引
|
||||
cursor.execute('''
|
||||
CREATE INDEX IF NOT EXISTS idx_status_date ON analysis_records(status, target_check_date)
|
||||
''')
|
||||
|
||||
conn.commit()
|
||||
conn.close()
|
||||
except Exception as e:
|
||||
logger.error(f"初始化反思数据库失败: {e}")
|
||||
|
||||
def record_analysis(self, market: str, symbol: str, price: float,
|
||||
decision: str, confidence: int, reasoning: str,
|
||||
check_days: int = 7):
|
||||
"""
|
||||
记录一次分析,以便未来验证
|
||||
|
||||
Args:
|
||||
market: 市场
|
||||
symbol: 代码
|
||||
price: 当前价格
|
||||
decision: 决策 (BUY/SELL/HOLD)
|
||||
confidence: 置信度
|
||||
reasoning: 理由
|
||||
check_days: 几天后验证 (默认7天)
|
||||
"""
|
||||
try:
|
||||
conn = sqlite3.connect(self.db_path)
|
||||
cursor = conn.cursor()
|
||||
|
||||
target_date = datetime.now() + timedelta(days=check_days)
|
||||
|
||||
cursor.execute('''
|
||||
INSERT INTO analysis_records
|
||||
(market, symbol, initial_price, decision, confidence, reasoning, target_check_date)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?)
|
||||
''', (market, symbol, price, decision, confidence, reasoning, target_date))
|
||||
|
||||
conn.commit()
|
||||
conn.close()
|
||||
logger.info(f"已记录分析用于反思: {market}:{symbol}, 将在 {check_days} 天后验证")
|
||||
except Exception as e:
|
||||
logger.error(f"记录分析失败: {e}")
|
||||
|
||||
def run_verification_cycle(self):
|
||||
"""
|
||||
执行验证周期:检查到期的记录,验证结果,并写入记忆
|
||||
"""
|
||||
logger.info("开始执行自动反思验证周期...")
|
||||
|
||||
try:
|
||||
conn = sqlite3.connect(self.db_path)
|
||||
cursor = conn.cursor()
|
||||
|
||||
# 1. 查找所有已到期且未处理的记录
|
||||
cursor.execute('''
|
||||
SELECT id, market, symbol, initial_price, decision, confidence, reasoning, analysis_date
|
||||
FROM analysis_records
|
||||
WHERE status = 'PENDING' AND target_check_date <= CURRENT_TIMESTAMP
|
||||
''')
|
||||
|
||||
records = cursor.fetchall()
|
||||
|
||||
if not records:
|
||||
logger.info("没有需要验证的记录")
|
||||
conn.close()
|
||||
return
|
||||
|
||||
logger.info(f"发现 {len(records)} 条待验证记录")
|
||||
|
||||
# 初始化记忆系统(用于写入验证结果)
|
||||
trader_memory = AgentMemory('trader_agent')
|
||||
|
||||
for record in records:
|
||||
record_id, market, symbol, initial_price, decision, confidence, reasoning, analysis_date = record
|
||||
|
||||
try:
|
||||
# 2. 获取当前最新价格
|
||||
current_price_data = self.tools.get_current_price(market, symbol)
|
||||
current_price = current_price_data.get('price')
|
||||
|
||||
if not current_price:
|
||||
logger.warning(f"无法获取 {market}:{symbol} 的当前价格,跳过")
|
||||
continue
|
||||
|
||||
# 3. 计算收益和结果
|
||||
if not initial_price or initial_price == 0:
|
||||
actual_return = 0.0
|
||||
else:
|
||||
actual_return = (current_price - initial_price) / initial_price * 100
|
||||
|
||||
# 评估结果
|
||||
result_desc = ""
|
||||
is_good_prediction = False
|
||||
|
||||
if decision == "BUY":
|
||||
if actual_return > 2.0:
|
||||
result_desc = "准确:买入后价格上涨"
|
||||
is_good_prediction = True
|
||||
elif actual_return < -2.0:
|
||||
result_desc = "错误:买入后价格下跌"
|
||||
else:
|
||||
result_desc = "中性:价格波动不大"
|
||||
elif decision == "SELL":
|
||||
if actual_return < -2.0:
|
||||
result_desc = "准确:卖出后价格下跌"
|
||||
is_good_prediction = True
|
||||
elif actual_return > 2.0:
|
||||
result_desc = "错误:卖出后价格上涨"
|
||||
else:
|
||||
result_desc = "中性:价格波动不大"
|
||||
else: # HOLD
|
||||
if -2.0 <= actual_return <= 2.0:
|
||||
result_desc = "准确:持有期间波动不大"
|
||||
is_good_prediction = True
|
||||
else:
|
||||
result_desc = f"偏差:持有期间出现了较大波动 ({actual_return:.2f}%)"
|
||||
|
||||
# 4. 写入记忆系统 (Let the agent learn)
|
||||
memory_situation = f"{market}:{symbol} 自动验证 (预测日期: {analysis_date})"
|
||||
memory_recommendation = f"当时决策: {decision} (置信度 {confidence}), 理由: {reasoning[:50]}..."
|
||||
memory_result = f"验证结果: {result_desc}, 实际收益: {actual_return:.2f}% (初始 {initial_price} -> 最新 {current_price})"
|
||||
|
||||
trader_memory.add_memory(
|
||||
memory_situation,
|
||||
memory_recommendation,
|
||||
memory_result,
|
||||
actual_return
|
||||
)
|
||||
|
||||
# 5. 更新记录状态
|
||||
cursor.execute('''
|
||||
UPDATE analysis_records
|
||||
SET status = 'COMPLETED', final_price = ?, actual_return = ?, check_result = ?
|
||||
WHERE id = ?
|
||||
''', (current_price, actual_return, result_desc, record_id))
|
||||
|
||||
conn.commit()
|
||||
logger.info(f"验证完成 {market}:{symbol}: {result_desc}")
|
||||
|
||||
except Exception as inner_e:
|
||||
logger.error(f"处理记录 {record_id} 失败: {inner_e}")
|
||||
# 标记为失败,避免重复处理
|
||||
# cursor.execute("UPDATE analysis_records SET status = 'FAILED' WHERE id = ?", (record_id,))
|
||||
# conn.commit()
|
||||
|
||||
conn.close()
|
||||
logger.info("反思验证周期结束")
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"执行验证周期失败: {e}")
|
||||
|
||||
@@ -0,0 +1,192 @@
|
||||
"""
|
||||
Researcher agents.
|
||||
Includes: bull researcher and bear researcher.
|
||||
"""
|
||||
import json
|
||||
from typing import Dict, Any
|
||||
from .base_agent import BaseAgent
|
||||
from app.services.llm import LLMService
|
||||
|
||||
logger = __import__('app.utils.logger', fromlist=['get_logger']).get_logger(__name__)
|
||||
|
||||
|
||||
class BullResearcher(BaseAgent):
|
||||
"""Bullish researcher."""
|
||||
|
||||
def __init__(self, memory=None):
|
||||
super().__init__("BullResearcher", memory)
|
||||
self.llm_service = LLMService()
|
||||
|
||||
def analyze(self, context: Dict[str, Any]) -> Dict[str, Any]:
|
||||
"""Construct the bull case."""
|
||||
market = context.get('market')
|
||||
symbol = context.get('symbol')
|
||||
language = context.get('language', 'zh-CN')
|
||||
model = context.get('model')
|
||||
|
||||
# Inputs
|
||||
market_report = context.get('market_report', {})
|
||||
fundamental_report = context.get('fundamental_report', {})
|
||||
news_report = context.get('news_report', {})
|
||||
sentiment_report = context.get('sentiment_report', {})
|
||||
|
||||
# Memory
|
||||
situation = f"{market}:{symbol} bull case"
|
||||
memories = self.get_memories(situation, n_matches=2)
|
||||
memory_prompt = self.format_memories_for_prompt(memories)
|
||||
|
||||
lang_instruction = self._get_language_instruction(language)
|
||||
system_prompt = f"""You are a Bullish Analyst, constructing a bullish argument for an investment decision. Your tasks are:
|
||||
{lang_instruction}
|
||||
1. Highlight growth potential, competitive advantages, and positive market indicators.
|
||||
2. Use the provided research and data to build a strong argument.
|
||||
3. Effectively address/counter bearish viewpoints.
|
||||
4. Learn from historical experience: {memory_prompt}
|
||||
5. **Confidence Score**: Evaluate your confidence in the bullish case (0-100). Be realistic. If the data is mixed or weak, lower your confidence. Do NOT default to 75.
|
||||
|
||||
Please return in JSON format as follows:
|
||||
{{
|
||||
"argument": "Detailed bullish argument...",
|
||||
"key_points": ["Point 1", "Point 2", "Point 3"],
|
||||
"confidence": 75
|
||||
}}"""
|
||||
|
||||
user_prompt = f"""Based on the following analysis reports, construct a bullish argument for {symbol} in {market} market:
|
||||
|
||||
**Market Technical Analysis:**
|
||||
{json.dumps(market_report.get('data', {}), ensure_ascii=False, indent=2) if market_report else 'No Data'}
|
||||
|
||||
**Fundamental Analysis:**
|
||||
{json.dumps(fundamental_report.get('data', {}), ensure_ascii=False, indent=2) if fundamental_report else 'No Data'}
|
||||
|
||||
**News Analysis:**
|
||||
{json.dumps(news_report.get('data', {}), ensure_ascii=False, indent=2) if news_report else 'No Data'}
|
||||
|
||||
**Sentiment Analysis:**
|
||||
{json.dumps(sentiment_report.get('data', {}), ensure_ascii=False, indent=2) if sentiment_report else 'No Data'}
|
||||
|
||||
Please construct a strong bullish argument, emphasizing growth potential, competitive advantages, and positive indicators."""
|
||||
|
||||
result = self.llm_service.safe_call_llm(
|
||||
system_prompt,
|
||||
user_prompt,
|
||||
{"argument": "", "key_points": [], "confidence": 50},
|
||||
model=model
|
||||
)
|
||||
|
||||
return {
|
||||
"type": "bull",
|
||||
"data": result
|
||||
}
|
||||
|
||||
def _get_language_instruction(self, language: str) -> str:
|
||||
language_map = {
|
||||
'zh-CN': 'Answer in Simplified Chinese.',
|
||||
'zh-TW': 'Answer in Traditional Chinese.',
|
||||
'en-US': 'Answer in English.',
|
||||
'ja-JP': 'Answer in Japanese.',
|
||||
'ko-KR': 'Answer in Korean.',
|
||||
'vi-VN': 'Answer in Vietnamese.',
|
||||
'th-TH': 'Answer in Thai.',
|
||||
'ar-SA': 'Answer in Arabic.',
|
||||
'fr-FR': 'Answer in French.',
|
||||
'de-DE': 'Answer in German.'
|
||||
}
|
||||
return language_map.get(language, 'Answer in English.')
|
||||
|
||||
|
||||
class BearResearcher(BaseAgent):
|
||||
"""Bearish researcher."""
|
||||
|
||||
def __init__(self, memory=None):
|
||||
super().__init__("BearResearcher", memory)
|
||||
self.llm_service = LLMService()
|
||||
|
||||
def analyze(self, context: Dict[str, Any]) -> Dict[str, Any]:
|
||||
"""Construct the bear case."""
|
||||
market = context.get('market')
|
||||
symbol = context.get('symbol')
|
||||
language = context.get('language', 'zh-CN')
|
||||
model = context.get('model')
|
||||
|
||||
# Inputs
|
||||
market_report = context.get('market_report', {})
|
||||
fundamental_report = context.get('fundamental_report', {})
|
||||
news_report = context.get('news_report', {})
|
||||
sentiment_report = context.get('sentiment_report', {})
|
||||
risk_report = context.get('risk_report', {})
|
||||
|
||||
# Bull argument (if present)
|
||||
bull_argument = context.get('bull_argument', '')
|
||||
|
||||
# Memory
|
||||
situation = f"{market}:{symbol} bear case"
|
||||
memories = self.get_memories(situation, n_matches=2)
|
||||
memory_prompt = self.format_memories_for_prompt(memories)
|
||||
|
||||
lang_instruction = self._get_language_instruction(language)
|
||||
system_prompt = f"""You are a Bearish Analyst, constructing a bearish argument for an investment decision. Your tasks are:
|
||||
{lang_instruction}
|
||||
1. Identify risks, challenges, and negative indicators.
|
||||
2. Use the provided research and data to build a strong argument.
|
||||
3. Effectively address/counter bullish viewpoints.
|
||||
4. Learn from historical experience: {memory_prompt}
|
||||
5. **Confidence Score**: Evaluate your confidence in the bearish case (0-100). Be realistic. If the data is mixed or weak, lower your confidence. Do NOT default to 75.
|
||||
|
||||
Please return in JSON format as follows:
|
||||
{{
|
||||
"argument": "Detailed bearish argument...",
|
||||
"key_points": ["Point 1", "Point 2", "Point 3"],
|
||||
"confidence": 75
|
||||
}}"""
|
||||
|
||||
# Bull argument section (avoid backslashes in f-string expression)
|
||||
bull_argument_section = f"**Bullish Argument (Needs Rebuttal):**\n{bull_argument}" if bull_argument else ""
|
||||
|
||||
user_prompt = f"""Based on the following analysis reports, construct a bearish argument for {symbol} in {market} market:
|
||||
|
||||
**Market Technical Analysis:**
|
||||
{json.dumps(market_report.get('data', {}), ensure_ascii=False, indent=2) if market_report else 'No Data'}
|
||||
|
||||
**Fundamental Analysis:**
|
||||
{json.dumps(fundamental_report.get('data', {}), ensure_ascii=False, indent=2) if fundamental_report else 'No Data'}
|
||||
|
||||
**News Analysis:**
|
||||
{json.dumps(news_report.get('data', {}), ensure_ascii=False, indent=2) if news_report else 'No Data'}
|
||||
|
||||
**Sentiment Analysis:**
|
||||
{json.dumps(sentiment_report.get('data', {}), ensure_ascii=False, indent=2) if sentiment_report else 'No Data'}
|
||||
|
||||
**Risk Analysis:**
|
||||
{json.dumps(risk_report.get('data', {}), ensure_ascii=False, indent=2) if risk_report else 'No Data'}
|
||||
|
||||
{bull_argument_section}
|
||||
|
||||
Please construct a strong bearish argument, emphasizing risks, challenges, and negative indicators."""
|
||||
|
||||
result = self.llm_service.safe_call_llm(
|
||||
system_prompt,
|
||||
user_prompt,
|
||||
{"argument": "", "key_points": [], "confidence": 50},
|
||||
model=model
|
||||
)
|
||||
|
||||
return {
|
||||
"type": "bear",
|
||||
"data": result
|
||||
}
|
||||
|
||||
def _get_language_instruction(self, language: str) -> str:
|
||||
language_map = {
|
||||
'zh-CN': 'Answer in Simplified Chinese.',
|
||||
'zh-TW': 'Answer in Traditional Chinese.',
|
||||
'en-US': 'Answer in English.',
|
||||
'ja-JP': 'Answer in Japanese.',
|
||||
'ko-KR': 'Answer in Korean.',
|
||||
'vi-VN': 'Answer in Vietnamese.',
|
||||
'th-TH': 'Answer in Thai.',
|
||||
'ar-SA': 'Answer in Arabic.',
|
||||
'fr-FR': 'Answer in French.',
|
||||
'de-DE': 'Answer in German.'
|
||||
}
|
||||
return language_map.get(language, 'Answer in English.')
|
||||
@@ -0,0 +1,206 @@
|
||||
"""
|
||||
Risk debate agents.
|
||||
Includes: aggressive / neutral / conservative risk analysts.
|
||||
"""
|
||||
import json
|
||||
from typing import Dict, Any
|
||||
from .base_agent import BaseAgent
|
||||
from app.services.llm import LLMService
|
||||
|
||||
logger = __import__('app.utils.logger', fromlist=['get_logger']).get_logger(__name__)
|
||||
|
||||
|
||||
class RiskyAnalyst(BaseAgent):
|
||||
"""Aggressive risk analyst."""
|
||||
|
||||
def __init__(self, memory=None):
|
||||
super().__init__("RiskyAnalyst", memory)
|
||||
self.llm_service = LLMService()
|
||||
|
||||
def analyze(self, context: Dict[str, Any]) -> Dict[str, Any]:
|
||||
"""Analyze risk from an aggressive perspective."""
|
||||
market = context.get('market')
|
||||
symbol = context.get('symbol')
|
||||
language = context.get('language', 'zh-CN')
|
||||
model = context.get('model')
|
||||
trader_plan = context.get('trader_plan', {})
|
||||
|
||||
lang_instruction = self._get_language_instruction(language)
|
||||
system_prompt = f"""You are an Aggressive Risk Analyst. You tend to:
|
||||
{lang_instruction}
|
||||
1. Emphasize high return potential, even with higher risks.
|
||||
2. Believe current risks are controllable and worth taking.
|
||||
3. Support aggressive trading strategies.
|
||||
|
||||
Please return in JSON format as follows:
|
||||
{{
|
||||
"argument": "Aggressive risk analysis argument...",
|
||||
"risk_assessment": "Risk controllable, high return potential",
|
||||
"recommendation": "Support trading plan"
|
||||
}}"""
|
||||
|
||||
user_prompt = f"""Perform aggressive risk analysis for {symbol} in {market} market.
|
||||
|
||||
**Trading Plan:**
|
||||
{json.dumps(trader_plan, ensure_ascii=False, indent=2) if trader_plan else 'No Data'}
|
||||
|
||||
Please analyze risk from an aggressive perspective, emphasizing return potential."""
|
||||
|
||||
result = self.llm_service.safe_call_llm(
|
||||
system_prompt,
|
||||
user_prompt,
|
||||
{"argument": "", "risk_assessment": "", "recommendation": ""},
|
||||
model=model
|
||||
)
|
||||
|
||||
return {
|
||||
"type": "risky",
|
||||
"data": result
|
||||
}
|
||||
|
||||
def _get_language_instruction(self, language: str) -> str:
|
||||
language_map = {
|
||||
'zh-CN': 'Answer in Simplified Chinese.',
|
||||
'zh-TW': 'Answer in Traditional Chinese.',
|
||||
'en-US': 'Answer in English.',
|
||||
'ja-JP': 'Answer in Japanese.',
|
||||
'ko-KR': 'Answer in Korean.',
|
||||
'vi-VN': 'Answer in Vietnamese.',
|
||||
'th-TH': 'Answer in Thai.',
|
||||
'ar-SA': 'Answer in Arabic.',
|
||||
'fr-FR': 'Answer in French.',
|
||||
'de-DE': 'Answer in German.'
|
||||
}
|
||||
return language_map.get(language, 'Answer in English.')
|
||||
|
||||
|
||||
class NeutralAnalyst(BaseAgent):
|
||||
"""Neutral risk analyst."""
|
||||
|
||||
def __init__(self, memory=None):
|
||||
super().__init__("NeutralAnalyst", memory)
|
||||
self.llm_service = LLMService()
|
||||
|
||||
def analyze(self, context: Dict[str, Any]) -> Dict[str, Any]:
|
||||
"""Analyze risk from a neutral perspective."""
|
||||
market = context.get('market')
|
||||
symbol = context.get('symbol')
|
||||
language = context.get('language', 'zh-CN')
|
||||
model = context.get('model')
|
||||
trader_plan = context.get('trader_plan', {})
|
||||
|
||||
lang_instruction = self._get_language_instruction(language)
|
||||
system_prompt = f"""You are a Neutral Risk Analyst. You tend to:
|
||||
{lang_instruction}
|
||||
1. Balance risk and return.
|
||||
2. Objectively evaluate various possibilities.
|
||||
3. Provide neutral risk advice.
|
||||
|
||||
Please return in JSON format as follows:
|
||||
{{
|
||||
"argument": "Neutral risk analysis argument...",
|
||||
"risk_assessment": "Balance between risk and return",
|
||||
"recommendation": "Cautiously execute trading plan"
|
||||
}}"""
|
||||
|
||||
user_prompt = f"""Perform neutral risk analysis for {symbol} in {market} market.
|
||||
|
||||
**Trading Plan:**
|
||||
{json.dumps(trader_plan, ensure_ascii=False, indent=2) if trader_plan else 'No Data'}
|
||||
|
||||
Please analyze risk from a neutral perspective, balancing risk and return."""
|
||||
|
||||
result = self.llm_service.safe_call_llm(
|
||||
system_prompt,
|
||||
user_prompt,
|
||||
{"argument": "", "risk_assessment": "", "recommendation": ""},
|
||||
model=model
|
||||
)
|
||||
|
||||
return {
|
||||
"type": "neutral",
|
||||
"data": result
|
||||
}
|
||||
|
||||
def _get_language_instruction(self, language: str) -> str:
|
||||
language_map = {
|
||||
'zh-CN': 'Answer in Simplified Chinese.',
|
||||
'zh-TW': 'Answer in Traditional Chinese.',
|
||||
'en-US': 'Answer in English.',
|
||||
'ja-JP': 'Answer in Japanese.',
|
||||
'ko-KR': 'Answer in Korean.',
|
||||
'vi-VN': 'Answer in Vietnamese.',
|
||||
'th-TH': 'Answer in Thai.',
|
||||
'ar-SA': 'Answer in Arabic.',
|
||||
'fr-FR': 'Answer in French.',
|
||||
'de-DE': 'Answer in German.'
|
||||
}
|
||||
return language_map.get(language, 'Answer in English.')
|
||||
|
||||
|
||||
class SafeAnalyst(BaseAgent):
|
||||
"""Conservative risk analyst."""
|
||||
|
||||
def __init__(self, memory=None):
|
||||
super().__init__("SafeAnalyst", memory)
|
||||
self.llm_service = LLMService()
|
||||
|
||||
def analyze(self, context: Dict[str, Any]) -> Dict[str, Any]:
|
||||
"""Analyze risk from a conservative perspective."""
|
||||
market = context.get('market')
|
||||
symbol = context.get('symbol')
|
||||
language = context.get('language', 'zh-CN')
|
||||
model = context.get('model')
|
||||
trader_plan = context.get('trader_plan', {})
|
||||
risk_report = context.get('risk_report', {})
|
||||
|
||||
lang_instruction = self._get_language_instruction(language)
|
||||
system_prompt = f"""You are a Conservative Risk Analyst. You tend to:
|
||||
{lang_instruction}
|
||||
1. Emphasize risk control, prioritizing capital protection.
|
||||
2. Identify potential risk points.
|
||||
3. Suggest cautious or conservative trading strategies.
|
||||
|
||||
Please return in JSON format as follows:
|
||||
{{
|
||||
"argument": "Conservative risk analysis argument...",
|
||||
"risk_assessment": "High risk exists, suggest caution",
|
||||
"recommendation": "Suggest reducing position or suspending trading"
|
||||
}}"""
|
||||
|
||||
user_prompt = f"""Perform conservative risk analysis for {symbol} in {market} market.
|
||||
|
||||
**Trading Plan:**
|
||||
{json.dumps(trader_plan, ensure_ascii=False, indent=2) if trader_plan else 'No Data'}
|
||||
|
||||
**Risk Analysis Report:**
|
||||
{json.dumps(risk_report.get('data', {}), ensure_ascii=False, indent=2) if risk_report else 'No Data'}
|
||||
|
||||
Please analyze risk from a conservative perspective, emphasizing risk control."""
|
||||
|
||||
result = self.llm_service.safe_call_llm(
|
||||
system_prompt,
|
||||
user_prompt,
|
||||
{"argument": "", "risk_assessment": "", "recommendation": ""},
|
||||
model=model
|
||||
)
|
||||
|
||||
return {
|
||||
"type": "safe",
|
||||
"data": result
|
||||
}
|
||||
|
||||
def _get_language_instruction(self, language: str) -> str:
|
||||
language_map = {
|
||||
'zh-CN': 'Answer in Simplified Chinese.',
|
||||
'zh-TW': 'Answer in Traditional Chinese.',
|
||||
'en-US': 'Answer in English.',
|
||||
'ja-JP': 'Answer in Japanese.',
|
||||
'ko-KR': 'Answer in Korean.',
|
||||
'vi-VN': 'Answer in Vietnamese.',
|
||||
'th-TH': 'Answer in Thai.',
|
||||
'ar-SA': 'Answer in Arabic.',
|
||||
'fr-FR': 'Answer in French.',
|
||||
'de-DE': 'Answer in German.'
|
||||
}
|
||||
return language_map.get(language, 'Answer in English.')
|
||||
@@ -0,0 +1,603 @@
|
||||
"""
|
||||
Agent tools.
|
||||
|
||||
Provides data fetching helpers for the multi-agent analysis pipeline.
|
||||
All docstrings/log messages in this module are English. Output language of AI reports
|
||||
is controlled by the `language` value passed through the analysis context.
|
||||
"""
|
||||
from typing import Dict, Any, Optional, List
|
||||
from datetime import datetime, timedelta
|
||||
import os
|
||||
import time
|
||||
import pandas as pd
|
||||
import yfinance as yf
|
||||
import finnhub
|
||||
import ccxt
|
||||
import requests
|
||||
|
||||
from app.utils.logger import get_logger
|
||||
from app.config import APIKeys
|
||||
from app.services.search import SearchService
|
||||
|
||||
logger = get_logger(__name__)
|
||||
|
||||
|
||||
class AgentTools:
|
||||
"""A thin wrapper around various public data sources used by agents."""
|
||||
|
||||
def __init__(self):
|
||||
self.search_service = SearchService()
|
||||
self.finnhub_client = None
|
||||
if APIKeys.is_configured('FINNHUB_API_KEY'):
|
||||
try:
|
||||
self.finnhub_client = finnhub.Client(api_key=APIKeys.FINNHUB_API_KEY)
|
||||
except Exception as e:
|
||||
# Safe logging to avoid cascading errors during exception handling
|
||||
try:
|
||||
logger.warning(f"Finnhub init failed: {e}")
|
||||
except Exception:
|
||||
# Fallback to print if logging fails
|
||||
print(f"Warning: Finnhub init failed: {e}")
|
||||
|
||||
# Optional dependency: akshare (A-share fundamentals/company info)
|
||||
try:
|
||||
import akshare as ak # type: ignore
|
||||
self._ak = ak
|
||||
self._has_akshare = True
|
||||
except Exception:
|
||||
self._ak = None
|
||||
self._has_akshare = False
|
||||
|
||||
# AShare spot cache (avoid fetching the full market list repeatedly)
|
||||
self._ashare_spot_cache = None
|
||||
self._ashare_spot_cache_ts = 0
|
||||
self._ashare_spot_cache_ttl = 300 # seconds
|
||||
|
||||
def _get_ashare_spot_df(self):
|
||||
"""Cached AShare spot dataframe via akshare (may be heavy on first load)."""
|
||||
if not self._akshare_required():
|
||||
return None
|
||||
now = int(time.time())
|
||||
if self._ashare_spot_cache is not None and (now - int(self._ashare_spot_cache_ts)) < int(self._ashare_spot_cache_ttl):
|
||||
return self._ashare_spot_cache
|
||||
ak = self._ak
|
||||
if ak is None or not hasattr(ak, "stock_zh_a_spot_em"):
|
||||
return None
|
||||
df = ak.stock_zh_a_spot_em()
|
||||
self._ashare_spot_cache = df
|
||||
self._ashare_spot_cache_ts = now
|
||||
return df
|
||||
|
||||
def _ccxt_exchange(self):
|
||||
"""Create a CCXT exchange client (Binance) with optional proxy support."""
|
||||
cfg: Dict[str, Any] = {'timeout': 5000, 'enableRateLimit': True}
|
||||
# Keep proxy behavior consistent with data sources (.env PROXY_* is supported)
|
||||
from app.config import CCXTConfig
|
||||
proxy = (CCXTConfig.PROXY or '').strip()
|
||||
if proxy:
|
||||
cfg['proxies'] = {'http': proxy, 'https': proxy}
|
||||
return ccxt.binance(cfg)
|
||||
|
||||
def _akshare_required(self) -> bool:
|
||||
"""Whether akshare is available at runtime."""
|
||||
return bool(self._has_akshare and self._ak is not None)
|
||||
|
||||
def get_stock_data(self, market: str, symbol: str, days: int = 30) -> Optional[List[Dict[str, Any]]]:
|
||||
"""
|
||||
Get daily Kline data for recent days (best-effort).
|
||||
|
||||
Args:
|
||||
market: Market
|
||||
symbol: Symbol
|
||||
days: Days
|
||||
|
||||
Returns:
|
||||
List of OHLCV dicts or None
|
||||
"""
|
||||
try:
|
||||
klines = []
|
||||
|
||||
if market == 'USStock':
|
||||
end_date = datetime.now().strftime('%Y-%m-%d')
|
||||
start_date = (datetime.now() - timedelta(days=days + 5)).strftime('%Y-%m-%d')
|
||||
|
||||
ticker = yf.Ticker(symbol)
|
||||
df = ticker.history(start=start_date, end=end_date, interval="1d")
|
||||
|
||||
if not df.empty:
|
||||
df = df.tail(days).reset_index()
|
||||
for _, row in df.iterrows():
|
||||
klines.append({
|
||||
"time": row['Date'].strftime('%Y-%m-%d'),
|
||||
"open": round(row['Open'], 4),
|
||||
"high": round(row['High'], 4),
|
||||
"low": round(row['Low'], 4),
|
||||
"close": round(row['Close'], 4),
|
||||
"volume": int(row['Volume'])
|
||||
})
|
||||
return klines
|
||||
|
||||
elif market == 'Crypto':
|
||||
exchange = self._ccxt_exchange()
|
||||
symbol_pair = f'{symbol}/USDT'
|
||||
start_time = int((datetime.now() - timedelta(days=days)).timestamp())
|
||||
ohlcv = exchange.fetch_ohlcv(symbol_pair, '1d', since=start_time * 1000, limit=days)
|
||||
if ohlcv:
|
||||
for candle in ohlcv:
|
||||
klines.append({
|
||||
"time": datetime.fromtimestamp(candle[0] / 1000).strftime('%Y-%m-%d'),
|
||||
"open": candle[1],
|
||||
"high": candle[2],
|
||||
"low": candle[3],
|
||||
"close": candle[4],
|
||||
"volume": candle[5]
|
||||
})
|
||||
return klines
|
||||
|
||||
# CN/HK stocks
|
||||
if market in ('AShare', 'HShare'):
|
||||
# Prefer akshare for AShare (requested), fall back to yfinance.
|
||||
if market == 'AShare' and self._akshare_required():
|
||||
try:
|
||||
ak = self._ak
|
||||
start_date = (datetime.now() - timedelta(days=days + 10)).strftime('%Y%m%d')
|
||||
end_date = datetime.now().strftime('%Y%m%d')
|
||||
# akshare returns a dataframe with Chinese column names.
|
||||
df = ak.stock_zh_a_hist(symbol=symbol, period="daily", start_date=start_date, end_date=end_date, adjust="qfq")
|
||||
if df is not None and not df.empty:
|
||||
df = df.tail(days)
|
||||
for _, row in df.iterrows():
|
||||
dt = row.get('日期')
|
||||
# dt can be datetime/date/str
|
||||
if hasattr(dt, "strftime"):
|
||||
t = dt.strftime('%Y-%m-%d')
|
||||
else:
|
||||
t = str(dt)[:10]
|
||||
klines.append({
|
||||
"time": t,
|
||||
"open": float(row.get('开盘', 0) or 0),
|
||||
"high": float(row.get('最高', 0) or 0),
|
||||
"low": float(row.get('最低', 0) or 0),
|
||||
"close": float(row.get('收盘', 0) or 0),
|
||||
"volume": float(row.get('成交量', 0) or 0),
|
||||
})
|
||||
return klines
|
||||
except Exception as e:
|
||||
logger.warning(f"akshare AShare kline failed ({symbol}): {e}")
|
||||
|
||||
# yfinance fallback (daily)
|
||||
if market == 'AShare':
|
||||
yf_symbol = f"{symbol}.SS" if symbol.startswith('6') else f"{symbol}.SZ"
|
||||
else:
|
||||
yf_symbol = f"{symbol.zfill(4)}.HK"
|
||||
|
||||
end_date = datetime.now().strftime('%Y-%m-%d')
|
||||
start_date = (datetime.now() - timedelta(days=days + 5)).strftime('%Y-%m-%d')
|
||||
|
||||
ticker = yf.Ticker(yf_symbol)
|
||||
df = ticker.history(start=start_date, end=end_date, interval="1d")
|
||||
|
||||
if not df.empty:
|
||||
df = df.tail(days).reset_index()
|
||||
for _, row in df.iterrows():
|
||||
klines.append({
|
||||
"time": row['Date'].strftime('%Y-%m-%d'),
|
||||
"open": round(row['Open'], 4),
|
||||
"high": round(row['High'], 4),
|
||||
"low": round(row['Low'], 4),
|
||||
"close": round(row['Close'], 4),
|
||||
"volume": int(row['Volume'])
|
||||
})
|
||||
return klines
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to fetch kline data {market}:{symbol}: {e}")
|
||||
|
||||
return None
|
||||
|
||||
def get_current_price(self, market: str, symbol: str) -> Optional[Dict[str, Any]]:
|
||||
"""Get current price (best-effort)."""
|
||||
try:
|
||||
if market == 'USStock' and self.finnhub_client:
|
||||
quote = self.finnhub_client.quote(symbol)
|
||||
if quote and quote.get('c'):
|
||||
return {
|
||||
"price": quote.get('c', 0),
|
||||
"change": quote.get('d', 0),
|
||||
"changePercent": quote.get('dp', 0),
|
||||
"high": quote.get('h', 0),
|
||||
"low": quote.get('l', 0),
|
||||
"open": quote.get('o', 0),
|
||||
"previousClose": quote.get('pc', 0)
|
||||
}
|
||||
elif market == 'Crypto':
|
||||
exchange = self._ccxt_exchange()
|
||||
symbol_pair = f'{symbol}/USDT'
|
||||
ticker = exchange.fetch_ticker(symbol_pair)
|
||||
if ticker:
|
||||
return {
|
||||
"price": ticker.get('last', 0),
|
||||
"change": ticker.get('change', 0),
|
||||
"changePercent": ticker.get('percentage', 0),
|
||||
"high": ticker.get('high', 0),
|
||||
"low": ticker.get('low', 0),
|
||||
"open": ticker.get('open', 0),
|
||||
"volume": ticker.get('quoteVolume', 0)
|
||||
}
|
||||
|
||||
# CN/HK stocks: prefer akshare for AShare (requested)
|
||||
if market in ('AShare', 'HShare'):
|
||||
if market == 'AShare' and self._akshare_required():
|
||||
try:
|
||||
ak = self._ak
|
||||
df = self._get_ashare_spot_df()
|
||||
if df is not None and not df.empty:
|
||||
row = df[df['代码'] == symbol].iloc[0]
|
||||
price = float(row.get('最新价', 0) or 0)
|
||||
change = float(row.get('涨跌额', 0) or 0)
|
||||
change_pct = float(row.get('涨跌幅', 0) or 0)
|
||||
high = float(row.get('最高', 0) or 0)
|
||||
low = float(row.get('最低', 0) or 0)
|
||||
open_p = float(row.get('今开', 0) or 0)
|
||||
prev_close = float(row.get('昨收', 0) or 0)
|
||||
return {
|
||||
"price": price,
|
||||
"change": change,
|
||||
"changePercent": change_pct,
|
||||
"high": high,
|
||||
"low": low,
|
||||
"open": open_p,
|
||||
"previousClose": prev_close
|
||||
}
|
||||
except Exception as e:
|
||||
logger.warning(f"akshare AShare spot failed ({symbol}): {e}")
|
||||
|
||||
# Do not use Tencent for AShare by default (requested). If akshare is not available,
|
||||
# return None and let the LLM report degrade gracefully.
|
||||
if market == 'AShare':
|
||||
if not self._akshare_required():
|
||||
logger.warning("akshare is not installed; AShare spot price is unavailable.")
|
||||
return None
|
||||
|
||||
# HShare fallback: Tencent quote
|
||||
symbol_code = f'hk{symbol}'
|
||||
|
||||
url = f"http://qt.gtimg.cn/q={symbol_code}"
|
||||
resp = requests.get(url, timeout=10)
|
||||
content = resp.content.decode('gbk', errors='ignore')
|
||||
if '="' in content:
|
||||
data_str = content.split('="')[1].strip('";\n')
|
||||
if data_str:
|
||||
parts = data_str.split('~')
|
||||
if len(parts) > 32:
|
||||
return {
|
||||
"price": float(parts[3]) if parts[3] else 0,
|
||||
"change": float(parts[31]) if parts[31] else 0,
|
||||
"changePercent": float(parts[32]) if parts[32] else 0,
|
||||
"high": float(parts[33]) if len(parts) > 33 and parts[33] else 0,
|
||||
"low": float(parts[34]) if len(parts) > 34 and parts[34] else 0,
|
||||
"open": float(parts[5]) if len(parts) > 5 and parts[5] else 0,
|
||||
"previousClose": float(parts[4]) if parts[4] else 0
|
||||
}
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to fetch current price {market}:{symbol}: {e}")
|
||||
|
||||
return None
|
||||
|
||||
def get_fundamental_data(self, market: str, symbol: str) -> Optional[Dict[str, Any]]:
|
||||
"""Get fundamental data (best-effort)."""
|
||||
try:
|
||||
if market == 'USStock' and self.finnhub_client:
|
||||
metrics = self.finnhub_client.company_basic_financials(symbol, 'all')
|
||||
profile = self.finnhub_client.company_profile2(symbol=symbol)
|
||||
|
||||
return {
|
||||
"metrics": metrics.get('metric', {}),
|
||||
"profile_metrics": {
|
||||
"marketCapitalization": profile.get('marketCapitalization', 0),
|
||||
"currency": profile.get('currency', 'USD'),
|
||||
"finnhubIndustry": profile.get('finnhubIndustry', ''),
|
||||
}
|
||||
}
|
||||
|
||||
# AShare fundamentals via akshare (requested)
|
||||
if market == 'AShare' and self._akshare_required():
|
||||
ak = self._ak
|
||||
out: Dict[str, Any] = {"metrics": {}, "profile_metrics": {}}
|
||||
|
||||
# 1) Use spot list (fast) for valuation/market cap
|
||||
try:
|
||||
df = self._get_ashare_spot_df()
|
||||
if df is not None and not df.empty:
|
||||
row = df[df['代码'] == symbol].iloc[0]
|
||||
out["metrics"].update({
|
||||
"pe_ttm": row.get('市盈率-动态'),
|
||||
"pb": row.get('市净率'),
|
||||
"turnoverRate": row.get('换手率'),
|
||||
})
|
||||
out["profile_metrics"].update({
|
||||
"marketCapitalization": row.get('总市值'),
|
||||
"floatMarketCap": row.get('流通市值'),
|
||||
"currency": "CNY",
|
||||
})
|
||||
except Exception as e:
|
||||
logger.debug(f"akshare spot metrics unavailable ({symbol}): {e}")
|
||||
|
||||
# 2) Try akshare indicator endpoints (optional, may be slower / may change)
|
||||
try:
|
||||
if hasattr(ak, "stock_a_lg_indicator"):
|
||||
ind_df = ak.stock_a_lg_indicator(symbol=symbol)
|
||||
if ind_df is not None and not ind_df.empty:
|
||||
last = ind_df.iloc[-1].to_dict()
|
||||
out["metrics"].update(last)
|
||||
except Exception as e:
|
||||
logger.debug(f"akshare indicator fetch failed ({symbol}): {e}")
|
||||
|
||||
return out
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to fetch fundamental data {market}:{symbol}: {e}")
|
||||
|
||||
return None
|
||||
|
||||
def get_company_data(self, market: str, symbol: str, language: str = "en-US") -> Optional[Dict[str, Any]]:
|
||||
"""Get basic company/project info (best-effort)."""
|
||||
try:
|
||||
# 1) Finnhub (mainly for US stocks)
|
||||
if market == 'USStock' and self.finnhub_client:
|
||||
profile = self.finnhub_client.company_profile2(symbol=symbol)
|
||||
if profile:
|
||||
return {
|
||||
"name": profile.get('name', symbol),
|
||||
"ticker": profile.get('ticker', symbol),
|
||||
"exchange": profile.get('exchange', ''),
|
||||
"industry": profile.get('finnhubIndustry', ''),
|
||||
"website": profile.get('weburl', ''),
|
||||
"marketCapitalization": profile.get('marketCapitalization', 0),
|
||||
"description": f"Sector: {profile.get('finnhubIndustry', '')}, Country: {profile.get('country', '')}"
|
||||
}
|
||||
|
||||
# 2) Basic info for AShare / HShare / Crypto
|
||||
elif market in ('AShare', 'HShare', 'Crypto'):
|
||||
name = symbol
|
||||
if market == 'AShare':
|
||||
# Prefer akshare for AShare (requested)
|
||||
if self._akshare_required():
|
||||
try:
|
||||
ak = self._ak
|
||||
# 1) Individual info (more structured)
|
||||
if hasattr(ak, "stock_individual_info_em"):
|
||||
df = ak.stock_individual_info_em(symbol=symbol)
|
||||
if df is not None and not df.empty and 'item' in df.columns and 'value' in df.columns:
|
||||
info = {str(r['item']).strip(): r['value'] for _, r in df.iterrows()}
|
||||
# common keys: 股票简称, 所属行业, 上市时间, 总市值 ...
|
||||
name = str(info.get('股票简称') or info.get('证券简称') or symbol).strip()
|
||||
industry = str(info.get('所属行业') or '').strip()
|
||||
website = str(info.get('公司网址') or '').strip()
|
||||
market_cap = info.get('总市值') or info.get('总市值(元)') or 0
|
||||
return {
|
||||
"name": name or symbol,
|
||||
"ticker": symbol,
|
||||
"market": market,
|
||||
"industry": industry,
|
||||
"website": website,
|
||||
"marketCapitalization": market_cap,
|
||||
"description": f"Industry: {industry}" if industry else ""
|
||||
}
|
||||
# 2) Spot list for name
|
||||
df2 = ak.stock_zh_a_spot_em()
|
||||
if df2 is not None and not df2.empty:
|
||||
row = df2[df2['代码'] == symbol].iloc[0]
|
||||
name = str(row.get('名称') or symbol).strip()
|
||||
except Exception as e:
|
||||
logger.debug(f"akshare company info failed ({symbol}): {e}")
|
||||
|
||||
# Do not use Tencent for AShare by default (requested).
|
||||
if not self._akshare_required():
|
||||
logger.warning("akshare is not installed; AShare company info is limited.")
|
||||
elif market == 'Crypto':
|
||||
name = f"{symbol} Cryptocurrency"
|
||||
|
||||
# Enrich description via web search (best-effort)
|
||||
# Query language should follow UI language when possible.
|
||||
if str(language).lower().startswith('zh'):
|
||||
search_query = f"{name} {symbol} 公司 简介" if market != 'Crypto' else f"{symbol} 加密 项目 介绍"
|
||||
else:
|
||||
search_query = f"{name} {symbol} company profile" if market != 'Crypto' else f"{symbol} crypto project info"
|
||||
search_results = self.search_service.search(search_query, num_results=1)
|
||||
description = ""
|
||||
if search_results:
|
||||
description = search_results[0].get('snippet', '')
|
||||
|
||||
return {
|
||||
"name": name,
|
||||
"ticker": symbol,
|
||||
"market": market,
|
||||
"description": description
|
||||
}
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to fetch company data {market}:{symbol}: {e}")
|
||||
|
||||
return None
|
||||
|
||||
def _fetch_page_content(self, url: str) -> str:
|
||||
"""
|
||||
Fetch readable page content via Jina Reader.
|
||||
|
||||
Args:
|
||||
url: Target URL
|
||||
|
||||
Returns:
|
||||
Extracted content (markdown-ish), truncated
|
||||
"""
|
||||
try:
|
||||
jina_url = f"https://r.jina.ai/{url}"
|
||||
# Use a slightly longer timeout for content extraction
|
||||
response = requests.get(jina_url, timeout=15)
|
||||
if response.status_code == 200:
|
||||
content = response.text
|
||||
# Truncate to avoid huge prompts
|
||||
if len(content) > 3000:
|
||||
content = content[:3000] + "..."
|
||||
return content
|
||||
return ""
|
||||
except Exception as e:
|
||||
logger.warning(f"Jina Reader content fetch failed {url}: {e}")
|
||||
return ""
|
||||
|
||||
def get_news(self, market: str, symbol: str, days: int = 7, company_name: str = None) -> List[Dict[str, Any]]:
|
||||
"""
|
||||
Get news items (Finnhub + search engine) and optionally enrich via Jina Reader.
|
||||
|
||||
Args:
|
||||
market: Market
|
||||
symbol: Symbol/pair
|
||||
days: Lookback days
|
||||
company_name: Optional company/project name to improve search
|
||||
|
||||
Returns:
|
||||
List of news items
|
||||
"""
|
||||
news_list = []
|
||||
|
||||
# 1) Finnhub news (if available)
|
||||
try:
|
||||
if self.finnhub_client:
|
||||
end_date = datetime.now().strftime('%Y-%m-%d')
|
||||
start_date = (datetime.now() - timedelta(days=days)).strftime('%Y-%m-%d')
|
||||
|
||||
raw_news = []
|
||||
|
||||
if market == 'USStock':
|
||||
raw_news = self.finnhub_client.company_news(symbol, _from=start_date, to=end_date)
|
||||
elif market == 'Crypto':
|
||||
crypto_symbol = symbol.split('/')[0] if '/' in symbol else symbol
|
||||
raw_news = self.finnhub_client.crypto_news(crypto_symbol)
|
||||
else:
|
||||
raw_news = self.finnhub_client.general_news('general', min_id=0)
|
||||
|
||||
if raw_news:
|
||||
for item in raw_news:
|
||||
if not item.get('headline') or not item.get('summary'):
|
||||
continue
|
||||
news_list.append({
|
||||
"id": str(item.get('id', '')),
|
||||
"datetime": datetime.fromtimestamp(item.get('datetime', 0)).strftime('%Y-%m-%d %H:%M'),
|
||||
"headline": item.get('headline', ''),
|
||||
"summary": item.get('summary', ''),
|
||||
"source": f"Finnhub ({item.get('source', '')})",
|
||||
"url": item.get('url', '')
|
||||
})
|
||||
except Exception as e:
|
||||
logger.warning(f"Finnhub news fetch failed: {e}")
|
||||
|
||||
# 2) Supplement with search engine results (useful for non-US markets or specific events)
|
||||
try:
|
||||
# Build search query (use company name to improve relevance)
|
||||
search_query = ""
|
||||
search_name = company_name if company_name else symbol
|
||||
|
||||
# Time restriction for Google CSE
|
||||
date_restrict = f"d{days}"
|
||||
|
||||
if market == 'AShare':
|
||||
# AShare CN keywords
|
||||
search_query = f'"{search_name}" {symbol} (利好 OR 利空 OR 财报 OR 公告 OR 业绩) after:{datetime.now().year-1}'
|
||||
elif market == 'HShare':
|
||||
search_query = f'"{search_name}" {symbol} (港股 OR 股价 OR 业绩) after:{datetime.now().year-1}'
|
||||
elif market == 'Crypto':
|
||||
search_query = f'"{search_name}" {symbol} crypto news analysis'
|
||||
else:
|
||||
search_query = f'"{search_name}" {symbol} stock news'
|
||||
|
||||
logger.info(f"Running news search: {search_query}")
|
||||
# Google CSE uses `dateRestrict` as a separate param; SearchService supports it.
|
||||
search_results = self.search_service.search(search_query, num_results=10, date_restrict=date_restrict)
|
||||
|
||||
for i, item in enumerate(search_results):
|
||||
# Default: use snippet as summary
|
||||
summary = f"{item.get('snippet', '')} (Source: {item.get('source', '')})"
|
||||
|
||||
# Jina Reader: deep-read only first 2 items to avoid slowdowns
|
||||
if i < 2 and item.get('link'):
|
||||
logger.info(f"Deep reading: {item.get('title')}")
|
||||
full_content = self._fetch_page_content(item.get('link'))
|
||||
if full_content:
|
||||
summary = f"Deep content:\n{full_content}\n(Source: {item.get('source', '')})"
|
||||
|
||||
news_list.append({
|
||||
"id": item.get('link', ''), # Use link as a stable id
|
||||
"datetime": item.get('published', datetime.now().strftime('%Y-%m-%d')), # Fallback to today if missing
|
||||
"headline": item.get('title', ''),
|
||||
"summary": summary,
|
||||
"source": f"Search ({item.get('source', '')})",
|
||||
"url": item.get('link', '')
|
||||
})
|
||||
|
||||
except Exception as e:
|
||||
logger.warning(f"Search news failed: {e}")
|
||||
|
||||
# Sort by time desc and keep latest items (best-effort; time formats may vary)
|
||||
news_list.sort(key=lambda x: x.get('datetime', ''), reverse=True)
|
||||
return news_list[:20]
|
||||
|
||||
def calculate_technical_indicators(self, kline_data: List[Dict[str, Any]]) -> Dict[str, Any]:
|
||||
"""
|
||||
Calculate basic technical indicators from kline data.
|
||||
|
||||
Args:
|
||||
kline_data: List of OHLCV dicts
|
||||
|
||||
Returns:
|
||||
Indicators dict
|
||||
"""
|
||||
if not kline_data or len(kline_data) < 20:
|
||||
return {}
|
||||
|
||||
try:
|
||||
df = pd.DataFrame(kline_data)
|
||||
df['close'] = pd.to_numeric(df['close'], errors='coerce')
|
||||
df['high'] = pd.to_numeric(df['high'], errors='coerce')
|
||||
df['low'] = pd.to_numeric(df['low'], errors='coerce')
|
||||
df['volume'] = pd.to_numeric(df['volume'], errors='coerce')
|
||||
|
||||
indicators = {}
|
||||
|
||||
# Moving averages
|
||||
if len(df) >= 20:
|
||||
indicators['MA20'] = round(df['close'].tail(20).mean(), 4)
|
||||
if len(df) >= 50:
|
||||
indicators['MA50'] = round(df['close'].tail(50).mean(), 4)
|
||||
|
||||
# RSI
|
||||
if len(df) >= 14:
|
||||
delta = df['close'].diff()
|
||||
gain = (delta.where(delta > 0, 0)).rolling(window=14).mean()
|
||||
loss = (-delta.where(delta < 0, 0)).rolling(window=14).mean()
|
||||
rs = gain / loss
|
||||
rsi = 100 - (100 / (1 + rs))
|
||||
indicators['RSI'] = round(rsi.iloc[-1], 2) if not rsi.empty else None
|
||||
|
||||
# MACD
|
||||
if len(df) >= 26:
|
||||
exp1 = df['close'].ewm(span=12, adjust=False).mean()
|
||||
exp2 = df['close'].ewm(span=26, adjust=False).mean()
|
||||
macd = exp1 - exp2
|
||||
signal = macd.ewm(span=9, adjust=False).mean()
|
||||
indicators['MACD'] = round(macd.iloc[-1], 4) if not macd.empty else None
|
||||
indicators['MACD_Signal'] = round(signal.iloc[-1], 4) if not signal.empty else None
|
||||
indicators['MACD_Histogram'] = round((macd - signal).iloc[-1], 4) if not (macd - signal).empty else None
|
||||
|
||||
# Bollinger bands
|
||||
if len(df) >= 20:
|
||||
sma = df['close'].rolling(window=20).mean()
|
||||
std = df['close'].rolling(window=20).std()
|
||||
indicators['BB_Upper'] = round((sma + 2 * std).iloc[-1], 4) if not sma.empty else None
|
||||
indicators['BB_Middle'] = round(sma.iloc[-1], 4) if not sma.empty else None
|
||||
indicators['BB_Lower'] = round((sma - 2 * std).iloc[-1], 4) if not sma.empty else None
|
||||
|
||||
return indicators
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to calculate technical indicators: {e}")
|
||||
return {}
|
||||
@@ -0,0 +1,129 @@
|
||||
"""
|
||||
Trader agent.
|
||||
Synthesizes all analysis outputs and produces a final trading decision.
|
||||
"""
|
||||
import json
|
||||
from typing import Dict, Any
|
||||
from .base_agent import BaseAgent
|
||||
from app.services.llm import LLMService
|
||||
|
||||
logger = __import__('app.utils.logger', fromlist=['get_logger']).get_logger(__name__)
|
||||
|
||||
|
||||
class TraderAgent(BaseAgent):
|
||||
"""Trader agent."""
|
||||
|
||||
def __init__(self, memory=None):
|
||||
super().__init__("TraderAgent", memory)
|
||||
self.llm_service = LLMService()
|
||||
|
||||
def analyze(self, context: Dict[str, Any]) -> Dict[str, Any]:
|
||||
"""Make a final trading decision."""
|
||||
market = context.get('market')
|
||||
symbol = context.get('symbol')
|
||||
language = context.get('language', 'zh-CN')
|
||||
model = context.get('model')
|
||||
|
||||
# Inputs
|
||||
market_report = context.get('market_report', {})
|
||||
fundamental_report = context.get('fundamental_report', {})
|
||||
news_report = context.get('news_report', {})
|
||||
sentiment_report = context.get('sentiment_report', {})
|
||||
risk_report = context.get('risk_report', {})
|
||||
|
||||
# Debate outputs
|
||||
bull_argument = context.get('bull_argument', {})
|
||||
bear_argument = context.get('bear_argument', {})
|
||||
research_decision = context.get('research_decision', '')
|
||||
|
||||
# Memory
|
||||
situation = f"{market}:{symbol} trading decision"
|
||||
memories = self.get_memories(situation, n_matches=2)
|
||||
memory_prompt = self.format_memories_for_prompt(memories)
|
||||
|
||||
lang_instruction = self._get_language_instruction(language)
|
||||
system_prompt = f"""You are a Trader, needing to make a final trading decision based on all analysis results.
|
||||
{lang_instruction}
|
||||
|
||||
Your tasks:
|
||||
1. Synthesize analysis results from all dimensions.
|
||||
2. Consider both bullish and bearish arguments.
|
||||
3. Make a clear trading decision: BUY, SELL, or HOLD.
|
||||
4. Provide a detailed trading plan.
|
||||
5. Learn from historical experience: {memory_prompt}
|
||||
6. **Confidence Score**: Evaluate your confidence in the decision (0-100). Be realistic. If the signals are mixed, confidence should be lower (e.g., 40-60). Only use high confidence (>80) for very clear strong signals. Do NOT default to 85.
|
||||
|
||||
Please return in JSON format as follows:
|
||||
{{
|
||||
"decision": "BUY/SELL/HOLD",
|
||||
"confidence": 85,
|
||||
"reasoning": "Reason for decision...",
|
||||
"trading_plan": {{
|
||||
"entry_price": "Suggested entry price",
|
||||
"stop_loss": "Stop loss price",
|
||||
"take_profit": "Take profit price",
|
||||
"position_size": "Suggested position size"
|
||||
}},
|
||||
"report": "Detailed trading plan report..."
|
||||
}}"""
|
||||
|
||||
user_prompt = f"""Based on all the following analyses, make a trading decision for {symbol} in {market} market:
|
||||
|
||||
**Market Technical Analysis:**
|
||||
{json.dumps(market_report.get('data', {}), ensure_ascii=False, indent=2) if market_report else 'No Data'}
|
||||
|
||||
**Fundamental Analysis:**
|
||||
{json.dumps(fundamental_report.get('data', {}), ensure_ascii=False, indent=2) if fundamental_report else 'No Data'}
|
||||
|
||||
**News Analysis:**
|
||||
{json.dumps(news_report.get('data', {}), ensure_ascii=False, indent=2) if news_report else 'No Data'}
|
||||
|
||||
**Sentiment Analysis:**
|
||||
{json.dumps(sentiment_report.get('data', {}), ensure_ascii=False, indent=2) if sentiment_report else 'No Data'}
|
||||
|
||||
**Risk Analysis:**
|
||||
{json.dumps(risk_report.get('data', {}), ensure_ascii=False, indent=2) if risk_report else 'No Data'}
|
||||
|
||||
**Bullish Argument:**
|
||||
{json.dumps(bull_argument.get('data', {}), ensure_ascii=False, indent=2) if bull_argument else 'No Data'}
|
||||
|
||||
**Bearish Argument:**
|
||||
{json.dumps(bear_argument.get('data', {}), ensure_ascii=False, indent=2) if bear_argument else 'No Data'}
|
||||
|
||||
**Research Manager Decision:**
|
||||
{research_decision if research_decision else 'No Data'}
|
||||
|
||||
Please make a clear trading decision (BUY/SELL/HOLD) and provide a detailed trading plan."""
|
||||
|
||||
result = self.llm_service.safe_call_llm(
|
||||
system_prompt,
|
||||
user_prompt,
|
||||
{
|
||||
"decision": "HOLD",
|
||||
"confidence": 50,
|
||||
"reasoning": "",
|
||||
"trading_plan": {},
|
||||
"report": "Failed to parse trader decision"
|
||||
},
|
||||
model=model
|
||||
)
|
||||
|
||||
return {
|
||||
"type": "trader",
|
||||
"data": result
|
||||
}
|
||||
|
||||
def _get_language_instruction(self, language: str) -> str:
|
||||
language_map = {
|
||||
'zh-CN': 'Answer in Simplified Chinese.',
|
||||
'zh-TW': 'Answer in Traditional Chinese.',
|
||||
'en-US': 'Answer in English.',
|
||||
'ja-JP': 'Answer in Japanese.',
|
||||
'ko-KR': 'Answer in Korean.',
|
||||
'vi-VN': 'Answer in Vietnamese.',
|
||||
'th-TH': 'Answer in Thai.',
|
||||
'ar-SA': 'Answer in Arabic.',
|
||||
'fr-FR': 'Answer in French.',
|
||||
'de-DE': 'Answer in German.'
|
||||
}
|
||||
return language_map.get(language, 'Answer in English.')
|
||||
Reference in New Issue
Block a user