1100 lines
42 KiB
Python
1100 lines
42 KiB
Python
"""
|
||
LLM Decision Maker - Use Claude/GPT for trading decisions
|
||
"""
|
||
import logging
|
||
import json
|
||
import re
|
||
from typing import Dict, Any, Optional
|
||
from datetime import datetime
|
||
import os
|
||
|
||
from config.settings import settings
|
||
|
||
logger = logging.getLogger(__name__)
|
||
|
||
|
||
class LLMDecisionMaker:
|
||
"""Generate trading decisions using LLM (Claude or OpenAI)"""
|
||
|
||
def __init__(self, provider: str = 'claude', api_key: Optional[str] = None):
|
||
"""
|
||
Initialize LLM decision maker
|
||
|
||
Args:
|
||
provider: 'claude' or 'openai'
|
||
api_key: API key (or use environment variable)
|
||
"""
|
||
self.provider = provider.lower()
|
||
self.api_key = api_key or self._get_api_key()
|
||
|
||
if not self.api_key:
|
||
logger.warning(f"No API key found for {provider}. LLM decisions will be disabled.")
|
||
self.enabled = False
|
||
else:
|
||
self.enabled = True
|
||
self._init_client()
|
||
|
||
def _get_api_key(self) -> Optional[str]:
|
||
"""Get API key from environment"""
|
||
if self.provider == 'claude':
|
||
return os.getenv('ANTHROPIC_API_KEY')
|
||
elif self.provider == 'openai':
|
||
return os.getenv('OPENAI_API_KEY')
|
||
return None
|
||
|
||
def _init_client(self):
|
||
"""Initialize LLM client"""
|
||
try:
|
||
if self.provider == 'claude':
|
||
import anthropic
|
||
self.client = anthropic.Anthropic(api_key=self.api_key)
|
||
self.model = "claude-3-5-sonnet-20241022"
|
||
elif self.provider == 'openai':
|
||
import openai
|
||
|
||
# Support custom base URL (for Deepseek, etc.)
|
||
base_url = os.getenv('OPENAI_BASE_URL')
|
||
|
||
if base_url:
|
||
self.client = openai.OpenAI(
|
||
api_key=self.api_key,
|
||
base_url=base_url
|
||
)
|
||
# Use appropriate model for the endpoint
|
||
if 'deepseek' in base_url.lower():
|
||
self.model = "deepseek-chat"
|
||
logger.info("Using Deepseek API endpoint")
|
||
else:
|
||
self.model = "gpt-4-turbo-preview"
|
||
else:
|
||
self.client = openai.OpenAI(api_key=self.api_key)
|
||
self.model = "gpt-4-turbo-preview"
|
||
|
||
logger.info(f"Initialized {self.provider} client with model {self.model}")
|
||
except ImportError as e:
|
||
logger.error(f"Failed to import {self.provider} library: {e}")
|
||
self.enabled = False
|
||
except Exception as e:
|
||
logger.error(f"Failed to initialize {self.provider} client: {e}")
|
||
self.enabled = False
|
||
|
||
def generate_decision(
|
||
self,
|
||
market_context: Dict[str, Any],
|
||
analysis: Optional[Dict[str, Any]] = None
|
||
) -> Dict[str, Any]:
|
||
"""
|
||
Generate trading decision using LLM
|
||
|
||
Args:
|
||
market_context: LLM context from LLMContextBuilder
|
||
analysis: Optional full analysis for additional details
|
||
|
||
Returns:
|
||
Decision dict with signal, reasoning, and levels
|
||
"""
|
||
if not self.enabled:
|
||
return self._disabled_response()
|
||
|
||
try:
|
||
# Build prompt
|
||
prompt = self._build_prompt(market_context, analysis)
|
||
|
||
# Log the complete prompt being sent to LLM
|
||
logger.info("=" * 80)
|
||
logger.info("📤 完整的 LLM 提示词 (发送给 Deepseek):")
|
||
logger.info("=" * 80)
|
||
logger.info(prompt)
|
||
logger.info("=" * 80)
|
||
|
||
# Call LLM
|
||
response_text = self._call_llm(prompt)
|
||
|
||
# Log the LLM response
|
||
logger.info("=" * 80)
|
||
logger.info("📥 LLM 原始响应:")
|
||
logger.info("=" * 80)
|
||
logger.info(response_text)
|
||
logger.info("=" * 80)
|
||
|
||
# Parse response
|
||
decision = self._parse_response(response_text, market_context)
|
||
|
||
logger.info(
|
||
f"LLM decision: {decision['signal_type']} "
|
||
f"(confidence: {decision.get('confidence', 0):.2f})"
|
||
)
|
||
|
||
return decision
|
||
|
||
except Exception as e:
|
||
logger.error(f"Error generating LLM decision: {e}", exc_info=True)
|
||
logger.debug(f"Market context: {market_context}")
|
||
return self._error_response(str(e))
|
||
|
||
def _build_prompt(
|
||
self,
|
||
market_context: Dict[str, Any],
|
||
analysis: Optional[Dict[str, Any]]
|
||
) -> str:
|
||
"""Build trading decision prompt"""
|
||
|
||
# Check if using new dataset format
|
||
if 'assessment' in market_context and 'trend' in market_context:
|
||
return self._build_prompt_new_format(market_context)
|
||
|
||
# Extract context elements (legacy format)
|
||
market_state = market_context.get('market_state', {})
|
||
momentum = market_context.get('momentum', {})
|
||
signal_consensus = market_context.get('signal_consensus', 0.5)
|
||
current_price = market_context.get('current_price', 0)
|
||
kline_data = market_context.get('kline_data', {})
|
||
|
||
# Build structured prompt - 自动化交易友好的JSON格式
|
||
prompt = f"""你是一个专业的加密货币交易分析师。基于以下多时间周期的K线数据和技术指标,提供精确的交易信号。
|
||
|
||
## 当前价格: ${current_price:,.2f}
|
||
|
||
## 输出要求
|
||
|
||
请严格按照以下JSON Schema输出,所有字段必须存在,价格字段必须是数字(无机会时用0):
|
||
|
||
```json
|
||
{{
|
||
"signal": "BUY|SELL|HOLD",
|
||
"confidence": 0.65,
|
||
"risk_level": "LOW|MEDIUM|HIGH",
|
||
"market_bias": "BULLISH|BEARISH|NEUTRAL",
|
||
|
||
"trades": [
|
||
{{
|
||
"id": "short_001",
|
||
"timeframe": "short",
|
||
"status": "ACTIVE|INACTIVE",
|
||
"direction": "LONG|SHORT|NONE",
|
||
"entry": {{
|
||
"price_1": 90000.00,
|
||
"price_2": 89700.00,
|
||
"price_3": 89400.00,
|
||
"price_4": 89100.00
|
||
}},
|
||
"exit": {{
|
||
"stop_loss": 88500.00,
|
||
"take_profit_1": 91000.00,
|
||
"take_profit_2": 92000.00,
|
||
"take_profit_3": 93000.00
|
||
}},
|
||
"position": {{
|
||
"size_pct_1": 40,
|
||
"size_pct_2": 30,
|
||
"size_pct_3": 20,
|
||
"size_pct_4": 10
|
||
}},
|
||
"risk_reward": 2.5,
|
||
"expected_profit_pct": 1.5,
|
||
"reasoning": "简要说明"
|
||
}},
|
||
{{
|
||
"id": "medium_001",
|
||
"timeframe": "medium",
|
||
"status": "ACTIVE|INACTIVE",
|
||
"direction": "LONG|SHORT|NONE",
|
||
"entry": {{
|
||
"price_1": 89500.00,
|
||
"price_2": 89000.00,
|
||
"price_3": 88500.00,
|
||
"price_4": 88000.00
|
||
}},
|
||
"exit": {{
|
||
"stop_loss": 86000.00,
|
||
"take_profit_1": 93000.00,
|
||
"take_profit_2": 95000.00,
|
||
"take_profit_3": 98000.00
|
||
}},
|
||
"position": {{
|
||
"size_pct_1": 40,
|
||
"size_pct_2": 30,
|
||
"size_pct_3": 20,
|
||
"size_pct_4": 10
|
||
}},
|
||
"risk_reward": 2.0,
|
||
"expected_profit_pct": 3.5,
|
||
"reasoning": "简要说明"
|
||
}},
|
||
{{
|
||
"id": "long_001",
|
||
"timeframe": "long",
|
||
"status": "ACTIVE|INACTIVE",
|
||
"direction": "LONG|SHORT|NONE",
|
||
"entry": {{
|
||
"price_1": 88000.00,
|
||
"price_2": 86000.00,
|
||
"price_3": 84000.00,
|
||
"price_4": 82000.00
|
||
}},
|
||
"exit": {{
|
||
"stop_loss": 78000.00,
|
||
"take_profit_1": 95000.00,
|
||
"take_profit_2": 100000.00,
|
||
"take_profit_3": 110000.00
|
||
}},
|
||
"position": {{
|
||
"size_pct_1": 40,
|
||
"size_pct_2": 30,
|
||
"size_pct_3": 20,
|
||
"size_pct_4": 10
|
||
}},
|
||
"risk_reward": 2.0,
|
||
"expected_profit_pct": 8.0,
|
||
"reasoning": "简要说明"
|
||
}}
|
||
],
|
||
|
||
"key_levels": {{
|
||
"support": [89000.00, 88000.00, 86000.00],
|
||
"resistance": [92000.00, 94000.00, 96000.00]
|
||
}},
|
||
|
||
"analysis": {{
|
||
"trend": "UP|DOWN|SIDEWAYS",
|
||
"momentum": "STRONG|WEAK|NEUTRAL",
|
||
"volume": "HIGH|LOW|NORMAL",
|
||
"summary": "一句话市场总结"
|
||
}},
|
||
|
||
"key_factors": ["因素1", "因素2", "因素3"]
|
||
}}
|
||
```
|
||
|
||
## 字段说明
|
||
|
||
**trades数组** (必须包含3个元素,分别对应short/medium/long):
|
||
- `timeframe`: "short"=5m/15m/1h, "medium"=4h/1d, "long"=1d/1w
|
||
- `status`: "ACTIVE"=有交易机会, "INACTIVE"=暂无机会
|
||
- `direction`: "LONG"=做多, "SHORT"=做空, "NONE"=无方向
|
||
- `entry.price_1~4`: 金字塔4级进场价,做多时price_1最高逐渐降低,做空时price_1最低逐渐升高
|
||
- `exit.stop_loss`: 统一止损价
|
||
- `exit.take_profit_1~3`: 3级止盈目标
|
||
- `position.size_pct_1~4`: 各级仓位百分比,总和=100
|
||
- `risk_reward`: 风险回报比
|
||
- `expected_profit_pct`: 预期盈利百分比
|
||
|
||
**盈利要求** (不满足时status=INACTIVE):
|
||
- short: expected_profit_pct >= 1.0%
|
||
- medium: expected_profit_pct >= 2.0%
|
||
- long: expected_profit_pct >= 5.0%
|
||
|
||
**价格间距建议**:
|
||
- short: 各级入场价间距 0.3-0.5%
|
||
- medium: 各级入场价间距 0.5-1.0%
|
||
- long: 各级入场价间距 1.0-2.0%
|
||
|
||
## 重要规则
|
||
|
||
1. **所有价格字段必须是数字**,无机会时填0,不要用null
|
||
2. **trades数组必须有3个元素**,顺序: short, medium, long
|
||
3. **status=INACTIVE时**,direction设为"NONE",所有价格设为0
|
||
4. **只输出JSON**,不要有其他文字
|
||
|
||
"""
|
||
|
||
# Add multi-timeframe technical indicators
|
||
if 'multi_timeframe' in market_context:
|
||
mtf = market_context['multi_timeframe']
|
||
prompt += "\n## 各周期技术指标\n\n"
|
||
|
||
tf_order = [
|
||
('5m', '5分钟', '日内短线参考'),
|
||
('15m', '15分钟', '日内短线参考'),
|
||
('1h', '1小时', '短期趋势'),
|
||
('4h', '4小时', '中期趋势'),
|
||
('1d', '日线', '大趋势'),
|
||
('1w', '周线', '长期趋势')
|
||
]
|
||
|
||
for tf_key, tf_name, tf_desc in tf_order:
|
||
if tf_key not in mtf:
|
||
continue
|
||
|
||
data = mtf[tf_key]
|
||
quant = data.get('quantitative', {})
|
||
|
||
prompt += f"### {tf_name} ({tf_desc})\n"
|
||
prompt += f"- 量化评分: {quant.get('composite_score', 0):.1f} | 信号: {quant.get('signal_type', 'HOLD')}\n"
|
||
prompt += f"- 趋势: {data.get('trend_direction', '未知')} ({data.get('trend_strength', 'weak')})\n"
|
||
prompt += f"- RSI: {data.get('rsi', 50):.1f} ({data.get('rsi_status', '中性')})\n"
|
||
prompt += f"- MACD: {data.get('macd_signal', '未知')}\n"
|
||
prompt += f"- ATR: ${data.get('atr', 0):.2f} ({data.get('atr_pct', 0):.2f}%)\n"
|
||
vol_ratio = data.get('volume_ratio', 1)
|
||
vol_status = "放量" if vol_ratio > 1.2 else "缩量" if vol_ratio < 0.8 else "正常"
|
||
prompt += f"- 成交量: {vol_status} ({vol_ratio:.2f}x)\n\n"
|
||
|
||
# Add K-line data
|
||
if kline_data:
|
||
prompt += "\n## K线数据 (请从中识别支撑位和压力位)\n\n"
|
||
prompt += "格式: t=时间, o=开盘, h=最高, l=最低, c=收盘, v=成交量\n\n"
|
||
|
||
tf_kline_order = [
|
||
('5m', '5分钟K线 (最近1天)'),
|
||
('15m', '15分钟K线 (最近1天)'),
|
||
('1h', '1小时K线 (最近3天)'),
|
||
('4h', '4小时K线 (最近3天)'),
|
||
('1d', '日线K线 (最近30天)'),
|
||
('1w', '周线K线 (最近60周)')
|
||
]
|
||
|
||
for tf_key, tf_desc in tf_kline_order:
|
||
if tf_key not in kline_data:
|
||
continue
|
||
|
||
klines = kline_data[tf_key]
|
||
if not klines:
|
||
continue
|
||
|
||
prompt += f"### {tf_desc}\n"
|
||
prompt += "```\n"
|
||
|
||
# 对于短周期,显示所有K线;对于长周期,可能只显示最近的一部分
|
||
display_klines = klines
|
||
for k in display_klines:
|
||
prompt += f"{k['t']} | o:{k['o']} h:{k['h']} l:{k['l']} c:{k['c']} v:{k['v']:.0f}\n"
|
||
|
||
prompt += "```\n\n"
|
||
|
||
# Add analysis guidelines
|
||
prompt += """
|
||
## 分析指南
|
||
|
||
### 支撑压力位识别
|
||
1. **短期**: 近1天内明显高低点、整数关口
|
||
2. **中期**: 近几天重要高低点、趋势线
|
||
3. **长期**: 周线/月线级别高低点
|
||
|
||
### 止盈止损设置
|
||
- **short**: 止损0.3-0.5%, 止盈≥1%
|
||
- **medium**: 止损1-2%, 止盈≥2%
|
||
- **long**: 止损2-4%, 止盈≥5%
|
||
|
||
### 最终检查
|
||
1. 确保JSON格式正确,无注释
|
||
2. 确保trades数组有3个元素
|
||
3. 确保所有价格是数字不是null
|
||
4. 确保INACTIVE时所有价格为0
|
||
"""
|
||
|
||
return prompt
|
||
|
||
def _call_llm(self, prompt: str) -> str:
|
||
"""Call LLM API"""
|
||
if self.provider == 'claude':
|
||
return self._call_claude(prompt)
|
||
elif self.provider == 'openai':
|
||
return self._call_openai(prompt)
|
||
else:
|
||
raise ValueError(f"Unsupported provider: {self.provider}")
|
||
|
||
def _call_claude(self, prompt: str) -> str:
|
||
"""Call Claude API"""
|
||
try:
|
||
response = self.client.messages.create(
|
||
model=self.model,
|
||
max_tokens=1500,
|
||
temperature=0.7,
|
||
messages=[
|
||
{"role": "user", "content": prompt}
|
||
]
|
||
)
|
||
return response.content[0].text
|
||
except Exception as e:
|
||
logger.error(f"Claude API error: {e}")
|
||
raise
|
||
|
||
def _call_openai(self, prompt: str) -> str:
|
||
"""Call OpenAI API"""
|
||
try:
|
||
response = self.client.chat.completions.create(
|
||
model=self.model,
|
||
messages=[
|
||
{
|
||
"role": "system",
|
||
"content": "You are a professional cryptocurrency trading analyst. Provide trading advice in JSON format."
|
||
},
|
||
{"role": "user", "content": prompt}
|
||
],
|
||
max_tokens=1500,
|
||
temperature=0.7,
|
||
)
|
||
return response.choices[0].message.content
|
||
except Exception as e:
|
||
logger.error(f"OpenAI API error: {e}")
|
||
raise
|
||
|
||
def _parse_response(
|
||
self,
|
||
response_text: str,
|
||
market_context: Dict[str, Any]
|
||
) -> Dict[str, Any]:
|
||
"""Parse LLM response into structured decision - 支持新版trades数组格式"""
|
||
|
||
# Try to extract JSON from response
|
||
json_match = re.search(r'\{[\s\S]*\}', response_text)
|
||
|
||
if not json_match:
|
||
logger.warning("No JSON found in LLM response, using fallback parsing")
|
||
return self._fallback_parse(response_text, market_context)
|
||
|
||
try:
|
||
llm_decision = json.loads(json_match.group())
|
||
logger.debug(f"Parsed LLM JSON: {llm_decision}")
|
||
except json.JSONDecodeError as e:
|
||
logger.warning(f"Failed to parse JSON: {e}, using fallback")
|
||
logger.debug(f"JSON match was: {json_match.group()[:500]}")
|
||
return self._fallback_parse(response_text, market_context)
|
||
|
||
# Helper function to safely convert to float
|
||
def safe_float(value, default=0.0):
|
||
if value is None:
|
||
return default
|
||
try:
|
||
return float(value)
|
||
except (ValueError, TypeError):
|
||
return default
|
||
|
||
current_price = market_context.get('current_price', 0)
|
||
|
||
# ========== 检测新版trades数组格式 ==========
|
||
trades = llm_decision.get('trades', [])
|
||
|
||
if trades and isinstance(trades, list) and len(trades) >= 3:
|
||
# 新版格式:trades数组
|
||
return self._parse_new_format(llm_decision, market_context, safe_float)
|
||
else:
|
||
# 旧版格式:opportunities对象
|
||
return self._parse_old_format(llm_decision, market_context, safe_float)
|
||
|
||
def _parse_new_format(
|
||
self,
|
||
llm_decision: Dict[str, Any],
|
||
market_context: Dict[str, Any],
|
||
safe_float
|
||
) -> Dict[str, Any]:
|
||
"""解析新版trades数组格式"""
|
||
current_price = market_context.get('current_price', 0)
|
||
trades = llm_decision.get('trades', [])
|
||
|
||
# 构建trades字典 (by timeframe)
|
||
trades_by_tf = {}
|
||
for trade in trades:
|
||
tf = trade.get('timeframe', '')
|
||
if tf in ['short', 'medium', 'long']:
|
||
trades_by_tf[tf] = trade
|
||
|
||
# 转换为统一的opportunities格式 (向后兼容)
|
||
def convert_trade_to_opportunity(trade: Dict, tf_key: str) -> Dict:
|
||
"""将新格式trade转换为旧格式opportunity"""
|
||
if not trade or trade.get('status') != 'ACTIVE':
|
||
return {
|
||
'exists': False,
|
||
'direction': None,
|
||
'entry_levels': [],
|
||
'entry_price': 0,
|
||
'stop_loss': 0,
|
||
'take_profit': 0,
|
||
'reasoning': trade.get('reasoning', '') if trade else ''
|
||
}
|
||
|
||
entry = trade.get('entry', {})
|
||
exit_data = trade.get('exit', {})
|
||
position = trade.get('position', {})
|
||
|
||
# 构建entry_levels
|
||
entry_levels = []
|
||
for i in range(1, 5):
|
||
price = safe_float(entry.get(f'price_{i}'), 0)
|
||
ratio = safe_float(position.get(f'size_pct_{i}'), [40, 30, 20, 10][i-1]) / 100
|
||
if price > 0:
|
||
entry_levels.append({
|
||
'price': price,
|
||
'ratio': ratio,
|
||
'level': i - 1,
|
||
'reasoning': ''
|
||
})
|
||
|
||
return {
|
||
'exists': True,
|
||
'direction': trade.get('direction', 'NONE'),
|
||
'entry_levels': entry_levels,
|
||
'entry_price': safe_float(entry.get('price_1'), 0),
|
||
'stop_loss': safe_float(exit_data.get('stop_loss'), 0),
|
||
'take_profit': safe_float(exit_data.get('take_profit_1'), 0),
|
||
'take_profit_2': safe_float(exit_data.get('take_profit_2'), 0),
|
||
'take_profit_3': safe_float(exit_data.get('take_profit_3'), 0),
|
||
'risk_reward': safe_float(trade.get('risk_reward'), 0),
|
||
'expected_profit_pct': safe_float(trade.get('expected_profit_pct'), 0),
|
||
'reasoning': trade.get('reasoning', '')
|
||
}
|
||
|
||
short_opp = convert_trade_to_opportunity(trades_by_tf.get('short'), 'short')
|
||
medium_opp = convert_trade_to_opportunity(trades_by_tf.get('medium'), 'medium')
|
||
long_opp = convert_trade_to_opportunity(trades_by_tf.get('long'), 'long')
|
||
|
||
# 获取分析数据
|
||
analysis = llm_decision.get('analysis', {})
|
||
key_levels = llm_decision.get('key_levels', {})
|
||
|
||
# 构建最终decision
|
||
decision = {
|
||
'timestamp': datetime.now().isoformat(),
|
||
'signal_type': llm_decision.get('signal', 'HOLD').upper(),
|
||
'confidence': safe_float(llm_decision.get('confidence'), 0.5),
|
||
'trade_type': 'MULTI_TIMEFRAME',
|
||
'market_bias': llm_decision.get('market_bias', 'NEUTRAL'),
|
||
'risk_level': llm_decision.get('risk_level', 'MEDIUM'),
|
||
|
||
# 新版trades数组 (原始格式保留)
|
||
'trades': trades,
|
||
|
||
# 向后兼容的opportunities格式
|
||
'opportunities': {
|
||
'short_term_5m_15m_1h': short_opp,
|
||
'medium_term_4h_1d': medium_opp,
|
||
'long_term_1d_1w': long_opp,
|
||
# 向后兼容
|
||
'intraday': short_opp,
|
||
'swing': medium_opp if medium_opp.get('exists') else long_opp,
|
||
},
|
||
|
||
# 分析数据
|
||
'analysis': {
|
||
'trend': analysis.get('trend', 'SIDEWAYS'),
|
||
'momentum': analysis.get('momentum', 'NEUTRAL'),
|
||
'volume': analysis.get('volume', 'NORMAL'),
|
||
'summary': analysis.get('summary', ''),
|
||
},
|
||
|
||
# 关键价位
|
||
'key_levels': {
|
||
'support': key_levels.get('support', []),
|
||
'resistance': key_levels.get('resistance', []),
|
||
},
|
||
|
||
'key_factors': llm_decision.get('key_factors', []),
|
||
'reasoning': analysis.get('summary', ''),
|
||
|
||
# 价格水平 (向后兼容)
|
||
'levels': {
|
||
'current_price': current_price,
|
||
'entry': short_opp['entry_price'] or medium_opp['entry_price'] or long_opp['entry_price'],
|
||
'stop_loss': short_opp['stop_loss'] or medium_opp['stop_loss'] or long_opp['stop_loss'],
|
||
'take_profit_1': short_opp['take_profit'] or medium_opp['take_profit'] or long_opp['take_profit'],
|
||
'take_profit_2': short_opp.get('take_profit_2', 0) or medium_opp.get('take_profit_2', 0),
|
||
'take_profit_3': short_opp.get('take_profit_3', 0) or medium_opp.get('take_profit_3', 0),
|
||
},
|
||
|
||
'raw_response': '', # Will be set by caller
|
||
}
|
||
|
||
# Calculate risk-reward ratio
|
||
entry = decision['levels']['entry']
|
||
stop_loss = decision['levels']['stop_loss']
|
||
tp1 = decision['levels']['take_profit_1']
|
||
|
||
if entry and stop_loss and tp1 and entry != stop_loss:
|
||
risk = abs(entry - stop_loss)
|
||
reward = abs(tp1 - entry)
|
||
decision['risk_reward_ratio'] = round(reward / risk, 2) if risk > 0 else 0
|
||
else:
|
||
decision['risk_reward_ratio'] = 0
|
||
|
||
return decision
|
||
|
||
def _parse_old_format(
|
||
self,
|
||
llm_decision: Dict[str, Any],
|
||
market_context: Dict[str, Any],
|
||
safe_float
|
||
) -> Dict[str, Any]:
|
||
"""解析旧版opportunities格式 (向后兼容)"""
|
||
current_price = market_context.get('current_price', 0)
|
||
|
||
# Helper functions
|
||
def calc_profit_pct(entry, take_profit, direction):
|
||
if not entry or not take_profit or entry <= 0:
|
||
return 0
|
||
if direction == 'LONG':
|
||
return (take_profit - entry) / entry * 100
|
||
elif direction == 'SHORT':
|
||
return (entry - take_profit) / entry * 100
|
||
return 0
|
||
|
||
def meets_profit_threshold(opp, min_profit_pct=1.0):
|
||
if not opp.get('exists'):
|
||
return False
|
||
entry = safe_float(opp.get('entry_price'), 0)
|
||
tp = safe_float(opp.get('take_profit'), 0)
|
||
direction = opp.get('direction')
|
||
profit_pct = calc_profit_pct(entry, tp, direction)
|
||
return profit_pct >= min_profit_pct
|
||
|
||
def normalize_entry_levels(opp: dict, direction: str) -> list:
|
||
entry_levels = opp.get('entry_levels', [])
|
||
if entry_levels and isinstance(entry_levels, list):
|
||
normalized = []
|
||
for i, level in enumerate(entry_levels[:4]):
|
||
if isinstance(level, dict):
|
||
normalized.append({
|
||
'price': safe_float(level.get('price'), 0),
|
||
'ratio': safe_float(level.get('ratio'), [0.4, 0.3, 0.2, 0.1][i]),
|
||
'level': i,
|
||
})
|
||
return normalized
|
||
|
||
entry_price = safe_float(opp.get('entry_price'), current_price)
|
||
levels = []
|
||
spacings = [0, 0.003, 0.006, 0.010]
|
||
for i, spacing in enumerate(spacings):
|
||
if direction == 'LONG':
|
||
price = round(entry_price * (1 - spacing), 2)
|
||
else:
|
||
price = round(entry_price * (1 + spacing), 2)
|
||
levels.append({'price': price, 'ratio': [0.4, 0.3, 0.2, 0.1][i], 'level': i})
|
||
return levels
|
||
|
||
# Parse opportunities
|
||
opportunities = llm_decision.get('opportunities', {})
|
||
short_term = opportunities.get('short_term_5m_15m_1h', {})
|
||
medium_term = opportunities.get('medium_term_4h_1d', {})
|
||
long_term = opportunities.get('long_term_1d_1w', {})
|
||
|
||
if not short_term and not medium_term and not long_term:
|
||
short_term = opportunities.get('intraday', {})
|
||
medium_term = opportunities.get('swing', {})
|
||
|
||
# Apply profit filters
|
||
MIN_PROFIT_SHORT = settings.MIN_PROFIT_PCT_SHORT
|
||
MIN_PROFIT_MEDIUM = settings.MIN_PROFIT_PCT_MEDIUM
|
||
MIN_PROFIT_LONG = settings.MIN_PROFIT_PCT_LONG
|
||
|
||
if short_term.get('exists') and not meets_profit_threshold(short_term, MIN_PROFIT_SHORT):
|
||
short_term = {'exists': False, 'reasoning': '盈利空间不足'}
|
||
if medium_term.get('exists') and not meets_profit_threshold(medium_term, MIN_PROFIT_MEDIUM):
|
||
medium_term = {'exists': False, 'reasoning': '盈利空间不足'}
|
||
if long_term.get('exists') and not meets_profit_threshold(long_term, MIN_PROFIT_LONG):
|
||
long_term = {'exists': False, 'reasoning': '盈利空间不足'}
|
||
|
||
# Normalize entry levels
|
||
short_levels = normalize_entry_levels(short_term, short_term.get('direction', 'LONG')) if short_term.get('exists') else []
|
||
medium_levels = normalize_entry_levels(medium_term, medium_term.get('direction', 'LONG')) if medium_term.get('exists') else []
|
||
long_levels = normalize_entry_levels(long_term, long_term.get('direction', 'LONG')) if long_term.get('exists') else []
|
||
|
||
def get_first_entry(levels, fallback):
|
||
return levels[0]['price'] if levels else fallback
|
||
|
||
# Build decision
|
||
recommendations = llm_decision.get('recommendations_by_timeframe', {})
|
||
|
||
decision = {
|
||
'timestamp': datetime.now().isoformat(),
|
||
'signal_type': llm_decision.get('signal', 'HOLD').upper(),
|
||
'confidence': safe_float(llm_decision.get('confidence'), 0.5),
|
||
'trade_type': 'MULTI_TIMEFRAME',
|
||
'reasoning': llm_decision.get('reasoning', ''),
|
||
|
||
'opportunities': {
|
||
'short_term_5m_15m_1h': {
|
||
'exists': short_term.get('exists', False),
|
||
'direction': short_term.get('direction'),
|
||
'entry_levels': short_levels,
|
||
'entry_price': get_first_entry(short_levels, safe_float(short_term.get('entry_price'), 0)),
|
||
'stop_loss': safe_float(short_term.get('stop_loss'), 0),
|
||
'take_profit': safe_float(short_term.get('take_profit'), 0),
|
||
'reasoning': short_term.get('reasoning', '')
|
||
},
|
||
'medium_term_4h_1d': {
|
||
'exists': medium_term.get('exists', False),
|
||
'direction': medium_term.get('direction'),
|
||
'entry_levels': medium_levels,
|
||
'entry_price': get_first_entry(medium_levels, safe_float(medium_term.get('entry_price'), 0)),
|
||
'stop_loss': safe_float(medium_term.get('stop_loss'), 0),
|
||
'take_profit': safe_float(medium_term.get('take_profit'), 0),
|
||
'reasoning': medium_term.get('reasoning', '')
|
||
},
|
||
'long_term_1d_1w': {
|
||
'exists': long_term.get('exists', False),
|
||
'direction': long_term.get('direction'),
|
||
'entry_levels': long_levels,
|
||
'entry_price': get_first_entry(long_levels, safe_float(long_term.get('entry_price'), 0)),
|
||
'stop_loss': safe_float(long_term.get('stop_loss'), 0),
|
||
'take_profit': safe_float(long_term.get('take_profit'), 0),
|
||
'reasoning': long_term.get('reasoning', '')
|
||
},
|
||
'intraday': {
|
||
'exists': short_term.get('exists', False),
|
||
'direction': short_term.get('direction'),
|
||
'entry_levels': short_levels,
|
||
'entry_price': get_first_entry(short_levels, safe_float(short_term.get('entry_price'), 0)),
|
||
'stop_loss': safe_float(short_term.get('stop_loss'), 0),
|
||
'take_profit': safe_float(short_term.get('take_profit'), 0),
|
||
'reasoning': short_term.get('reasoning', '')
|
||
},
|
||
'swing': {
|
||
'exists': medium_term.get('exists', False) or long_term.get('exists', False),
|
||
'direction': medium_term.get('direction') or long_term.get('direction'),
|
||
'entry_levels': medium_levels if medium_term.get('exists') else long_levels,
|
||
'entry_price': safe_float(medium_term.get('entry_price') or long_term.get('entry_price'), 0),
|
||
'stop_loss': safe_float(medium_term.get('stop_loss') or long_term.get('stop_loss'), 0),
|
||
'take_profit': safe_float(medium_term.get('take_profit') or long_term.get('take_profit'), 0),
|
||
'reasoning': medium_term.get('reasoning', '') or long_term.get('reasoning', '')
|
||
},
|
||
},
|
||
|
||
'recommendations_by_timeframe': {
|
||
'short_term': recommendations.get('short_term', ''),
|
||
'medium_term': recommendations.get('medium_term', ''),
|
||
'long_term': recommendations.get('long_term', '')
|
||
},
|
||
|
||
# Primary levels (for backward compatibility)
|
||
'levels': {
|
||
'current_price': current_price,
|
||
'entry': get_first_entry(short_levels, 0) or get_first_entry(medium_levels, 0) or get_first_entry(long_levels, 0),
|
||
'stop_loss': safe_float(short_term.get('stop_loss'), 0) or safe_float(medium_term.get('stop_loss'), 0) or safe_float(long_term.get('stop_loss'), 0),
|
||
'take_profit_1': safe_float(short_term.get('take_profit'), 0) or safe_float(medium_term.get('take_profit'), 0) or safe_float(long_term.get('take_profit'), 0),
|
||
'take_profit_2': 0,
|
||
'take_profit_3': 0,
|
||
},
|
||
'risk_level': llm_decision.get('risk_level', 'MEDIUM'),
|
||
'key_factors': llm_decision.get('key_factors', []),
|
||
'raw_response': '',
|
||
}
|
||
|
||
# Calculate risk-reward ratio
|
||
entry = decision['levels']['entry']
|
||
stop_loss = decision['levels']['stop_loss']
|
||
tp1 = decision['levels']['take_profit_1']
|
||
|
||
if entry and stop_loss and tp1 and entry != stop_loss:
|
||
risk = abs(entry - stop_loss)
|
||
reward = abs(tp1 - entry)
|
||
decision['risk_reward_ratio'] = round(reward / risk, 2) if risk > 0 else 0
|
||
else:
|
||
decision['risk_reward_ratio'] = 0
|
||
|
||
return decision
|
||
|
||
def _fallback_parse(
|
||
self,
|
||
response_text: str,
|
||
market_context: Dict[str, Any]
|
||
) -> Dict[str, Any]:
|
||
"""Fallback parsing when JSON extraction fails"""
|
||
|
||
# Simple keyword-based signal extraction
|
||
text_lower = response_text.lower()
|
||
|
||
if 'buy' in text_lower or '买入' in response_text or '做多' in response_text:
|
||
signal_type = 'BUY'
|
||
confidence = 0.6
|
||
elif 'sell' in text_lower or '卖出' in response_text or '做空' in response_text:
|
||
signal_type = 'SELL'
|
||
confidence = 0.6
|
||
else:
|
||
signal_type = 'HOLD'
|
||
confidence = 0.5
|
||
|
||
return {
|
||
'timestamp': datetime.now().isoformat(),
|
||
'signal_type': signal_type,
|
||
'confidence': confidence,
|
||
'reasoning': response_text[:500], # First 500 chars
|
||
'levels': {
|
||
'current_price': market_context.get('current_price', 0),
|
||
'entry': 0,
|
||
'stop_loss': 0,
|
||
'take_profit_1': 0,
|
||
'take_profit_2': 0,
|
||
'take_profit_3': 0,
|
||
},
|
||
'risk_level': 'MEDIUM',
|
||
'time_horizon': 'MEDIUM',
|
||
'key_factors': [],
|
||
'raw_response': response_text,
|
||
'warning': 'Fallback parsing used - levels not available',
|
||
}
|
||
|
||
def _disabled_response(self) -> Dict[str, Any]:
|
||
"""Return response when LLM is disabled"""
|
||
return {
|
||
'timestamp': datetime.now().isoformat(),
|
||
'signal_type': 'HOLD',
|
||
'confidence': 0,
|
||
'reasoning': 'LLM decision maker is disabled (no API key)',
|
||
'enabled': False,
|
||
}
|
||
|
||
def _error_response(self, error_msg: str) -> Dict[str, Any]:
|
||
"""Return error response"""
|
||
return {
|
||
'timestamp': datetime.now().isoformat(),
|
||
'signal_type': 'HOLD',
|
||
'confidence': 0,
|
||
'reasoning': f'Error generating decision: {error_msg}',
|
||
'error': error_msg,
|
||
}
|
||
|
||
def _build_prompt_new_format(self, dataset: Dict[str, Any]) -> str:
|
||
"""
|
||
Build prompt using new LLMDatasetBuilder format
|
||
|
||
This prompt is structured around:
|
||
1. Clear market assessment summary
|
||
2. Multi-timeframe trend analysis
|
||
3. Support/Resistance levels
|
||
4. Order flow / bull-bear battle
|
||
5. Momentum and divergence signals
|
||
6. K-line data for pattern recognition
|
||
"""
|
||
price = dataset.get('price', {})
|
||
current_price = price.get('current', 0)
|
||
assessment = dataset.get('assessment', {})
|
||
trend = dataset.get('trend', {})
|
||
sr = dataset.get('support_resistance', {})
|
||
orderflow = dataset.get('orderflow', {})
|
||
momentum = dataset.get('momentum', {})
|
||
recommendations = dataset.get('recommendations', {})
|
||
klines = dataset.get('klines', {})
|
||
|
||
prompt = f"""你是一个专业的加密货币交易分析师。基于以下详细的市场分析数据,提供精确的交易信号。
|
||
|
||
## 当前价格: ${current_price:,.2f}
|
||
|
||
## 市场总体评估
|
||
|
||
{dataset.get('summary', '无数据')}
|
||
|
||
- 市场偏向: {assessment.get('overall_bias_cn', '未知')}
|
||
- 置信度: {assessment.get('confidence', 0):.0f}%
|
||
"""
|
||
|
||
# Warning if any
|
||
if assessment.get('has_warning'):
|
||
prompt += f"- **警告**: {assessment.get('warning_cn', '')}\n"
|
||
|
||
# Trend Analysis
|
||
prompt += "\n## 趋势分析 (EMA均线排列)\n\n"
|
||
|
||
dominant = trend.get('dominant_trend', {})
|
||
alignment = trend.get('alignment', {})
|
||
prompt += f"**主导趋势**: {dominant.get('direction_cn', '未知')} (置信度 {dominant.get('confidence', 0)*100:.0f}%)\n"
|
||
prompt += f"**多周期一致性**: {alignment.get('status_cn', '未知')} (一致性评分 {alignment.get('alignment_score', 0)*100:.0f}%)\n\n"
|
||
|
||
# Per-timeframe trend
|
||
trend_tfs = trend.get('timeframes', {})
|
||
if trend_tfs:
|
||
prompt += "| 周期 | 趋势 | 强度 | 阶段 | ADX |\n"
|
||
prompt += "|------|------|------|------|-----|\n"
|
||
for tf in ['5m', '15m', '1h', '4h', '1d', '1w']:
|
||
if tf in trend_tfs:
|
||
t = trend_tfs[tf]
|
||
prompt += f"| {tf} | {t.get('direction_cn', '?')} | {t.get('strength_cn', '?')} | {t.get('phase_cn', '?')} | {t.get('adx', 0):.1f} |\n"
|
||
prompt += "\n"
|
||
|
||
# Support/Resistance
|
||
prompt += "\n## 支撑位/压力位 (斐波那契+多周期共振)\n\n"
|
||
|
||
confluence = sr.get('confluence', {})
|
||
supports = confluence.get('supports', [])
|
||
resistances = confluence.get('resistances', [])
|
||
|
||
if supports:
|
||
prompt += "**支撑位** (按强度排序):\n"
|
||
for i, s in enumerate(supports[:3], 1):
|
||
prompt += f" {i}. ${s.get('level', 0):,.0f} ({s.get('strength_cn', '?')}, {s.get('num_timeframes', 0)}个周期确认, 距离 {s.get('distance_pct', 0):.1f}%)\n"
|
||
prompt += "\n"
|
||
|
||
if resistances:
|
||
prompt += "**压力位** (按强度排序):\n"
|
||
for i, r in enumerate(resistances[:3], 1):
|
||
prompt += f" {i}. ${r.get('level', 0):,.0f} ({r.get('strength_cn', '?')}, {r.get('num_timeframes', 0)}个周期确认, 距离 {r.get('distance_pct', 0):.1f}%)\n"
|
||
prompt += "\n"
|
||
|
||
# Order Flow
|
||
prompt += "\n## 订单流分析 (多空博弈)\n\n"
|
||
|
||
of_assessment = orderflow.get('assessment', {})
|
||
of_imbalance = orderflow.get('imbalance', {})
|
||
of_battle = orderflow.get('battle', {})
|
||
|
||
prompt += f"**多空态势**: {of_assessment.get('direction_cn', '未知')}\n"
|
||
prompt += f"**博弈强度**: {of_assessment.get('intensity_cn', '未知')}\n"
|
||
prompt += f"**订单簿失衡**: {of_imbalance.get('status_cn', '未知')} ({of_imbalance.get('ratio_pct', 0):+.1f}%)\n"
|
||
|
||
if of_battle.get('interpretation'):
|
||
prompt += f"**解读**: {of_battle.get('interpretation', '')}\n"
|
||
|
||
# Walls
|
||
walls = orderflow.get('walls', {})
|
||
if walls.get('nearest_support_wall'):
|
||
w = walls['nearest_support_wall']
|
||
prompt += f"**最近支撑墙**: ${w.get('price', 0):,.0f} (距离 {w.get('distance_pct', 0):.2f}%, 强度 {w.get('strength', '?')})\n"
|
||
if walls.get('nearest_resistance_wall'):
|
||
w = walls['nearest_resistance_wall']
|
||
prompt += f"**最近阻力墙**: ${w.get('price', 0):,.0f} (距离 {w.get('distance_pct', 0):.2f}%, 强度 {w.get('strength', '?')})\n"
|
||
|
||
prompt += "\n"
|
||
|
||
# Momentum Analysis
|
||
prompt += "\n## 动能分析 (RSI/MACD/量价背离)\n\n"
|
||
|
||
mom_alignment = momentum.get('alignment', {})
|
||
div_confluence = momentum.get('divergence_confluence', {})
|
||
|
||
prompt += f"**动能一致性**: {mom_alignment.get('status_cn', '未知')}\n"
|
||
|
||
if div_confluence.get('has_confluence'):
|
||
prompt += f"**重要背离**: {div_confluence.get('type_cn', '')} (周期: {', '.join(div_confluence.get('bullish_timeframes', []) + div_confluence.get('bearish_timeframes', []))})\n"
|
||
|
||
# Per-timeframe momentum
|
||
mom_tfs = momentum.get('timeframes', {})
|
||
if mom_tfs:
|
||
prompt += "\n| 周期 | RSI | RSI状态 | MACD | 量价关系 |\n"
|
||
prompt += "|------|-----|---------|------|----------|\n"
|
||
for tf in ['15m', '1h', '4h', '1d']:
|
||
if tf in mom_tfs:
|
||
m = mom_tfs[tf]
|
||
rsi = m.get('rsi', {})
|
||
macd = m.get('macd', {})
|
||
vol = m.get('volume', {})
|
||
prompt += f"| {tf} | {rsi.get('value', 50):.0f} | {rsi.get('zone_cn', '?')} | {macd.get('momentum_cn', '?')} | {vol.get('confirmation_cn', '?')} |\n"
|
||
prompt += "\n"
|
||
|
||
# Recommendations summary
|
||
prompt += "\n## 量化分析建议\n\n"
|
||
|
||
for tf_key, tf_label in [('short_term', '短期'), ('medium_term', '中期'), ('long_term', '长期')]:
|
||
rec = recommendations.get(tf_key, {})
|
||
if rec:
|
||
prompt += f"**{tf_label}**: {rec.get('action_cn', '?')} ({rec.get('confidence_cn', '?')}置信度)\n"
|
||
prompt += f" - 趋势: {rec.get('trend', '?')} ({rec.get('trend_strength', '?')})\n"
|
||
prompt += f" - 动能: {rec.get('momentum', '?')}\n"
|
||
if rec.get('reasoning'):
|
||
prompt += f" - 理由: {rec.get('reasoning', '')}\n"
|
||
prompt += "\n"
|
||
|
||
# K-line data (abbreviated for context)
|
||
if klines:
|
||
prompt += "\n## K线数据 (用于模式识别)\n\n"
|
||
prompt += "格式: t=时间, o=开盘, h=最高, l=最低, c=收盘, v=成交量\n\n"
|
||
|
||
for tf_key, tf_desc in [('1h', '1小时'), ('4h', '4小时'), ('1d', '日线')]:
|
||
if tf_key in klines:
|
||
kline_list = klines[tf_key]
|
||
if kline_list:
|
||
# Show last 24 candles for context
|
||
display_klines = kline_list[-24:]
|
||
prompt += f"### {tf_desc} (最近{len(display_klines)}根)\n```\n"
|
||
for k in display_klines:
|
||
prompt += f"{k.get('t', '')} | o:{k.get('o', 0)} h:{k.get('h', 0)} l:{k.get('l', 0)} c:{k.get('c', 0)} v:{k.get('v', 0):.0f}\n"
|
||
prompt += "```\n\n"
|
||
|
||
# Output requirements
|
||
prompt += """
|
||
## 输出要求
|
||
|
||
请严格按照以下JSON Schema输出,所有字段必须存在:
|
||
|
||
```json
|
||
{
|
||
"signal": "BUY|SELL|HOLD",
|
||
"confidence": 0.65,
|
||
"risk_level": "LOW|MEDIUM|HIGH",
|
||
"market_bias": "BULLISH|BEARISH|NEUTRAL",
|
||
|
||
"trades": [
|
||
{
|
||
"id": "short_001",
|
||
"timeframe": "short",
|
||
"status": "ACTIVE|INACTIVE",
|
||
"direction": "LONG|SHORT|NONE",
|
||
"entry": {
|
||
"price_1": 90000.00,
|
||
"price_2": 89700.00,
|
||
"price_3": 89400.00,
|
||
"price_4": 89100.00
|
||
},
|
||
"exit": {
|
||
"stop_loss": 88500.00,
|
||
"take_profit_1": 91000.00,
|
||
"take_profit_2": 92000.00,
|
||
"take_profit_3": 93000.00
|
||
},
|
||
"position": {
|
||
"size_pct_1": 40,
|
||
"size_pct_2": 30,
|
||
"size_pct_3": 20,
|
||
"size_pct_4": 10
|
||
},
|
||
"risk_reward": 2.5,
|
||
"expected_profit_pct": 1.5,
|
||
"reasoning": "简要说明"
|
||
},
|
||
{
|
||
"id": "medium_001",
|
||
"timeframe": "medium",
|
||
"status": "ACTIVE|INACTIVE",
|
||
"direction": "LONG|SHORT|NONE",
|
||
"entry": { ... },
|
||
"exit": { ... },
|
||
"position": { ... },
|
||
"risk_reward": 2.0,
|
||
"expected_profit_pct": 3.5,
|
||
"reasoning": "简要说明"
|
||
},
|
||
{
|
||
"id": "long_001",
|
||
"timeframe": "long",
|
||
"status": "ACTIVE|INACTIVE",
|
||
"direction": "LONG|SHORT|NONE",
|
||
"entry": { ... },
|
||
"exit": { ... },
|
||
"position": { ... },
|
||
"risk_reward": 2.0,
|
||
"expected_profit_pct": 8.0,
|
||
"reasoning": "简要说明"
|
||
}
|
||
],
|
||
|
||
"key_levels": {
|
||
"support": [89000.00, 88000.00, 86000.00],
|
||
"resistance": [92000.00, 94000.00, 96000.00]
|
||
},
|
||
|
||
"analysis": {
|
||
"trend": "UP|DOWN|SIDEWAYS",
|
||
"momentum": "STRONG|WEAK|NEUTRAL",
|
||
"volume": "HIGH|LOW|NORMAL",
|
||
"summary": "一句话市场总结"
|
||
},
|
||
|
||
"key_factors": ["因素1", "因素2", "因素3"]
|
||
}
|
||
```
|
||
|
||
## 关键规则
|
||
|
||
1. **trades数组必须有3个元素**: short, medium, long
|
||
2. **盈利要求**:
|
||
- short: expected_profit_pct >= 1.0% 才设 ACTIVE
|
||
- medium: expected_profit_pct >= 2.0% 才设 ACTIVE
|
||
- long: expected_profit_pct >= 5.0% 才设 ACTIVE
|
||
3. **INACTIVE时**: direction="NONE", 所有价格=0
|
||
4. **入场价间距建议**:
|
||
- short: 0.3-0.5%
|
||
- medium: 0.5-1.0%
|
||
- long: 1.0-2.0%
|
||
5. **止损止盈设置参考上方支撑压力位**
|
||
6. **只输出JSON**,不要有其他文字
|
||
"""
|
||
|
||
return prompt
|