453 lines
16 KiB
Python
453 lines
16 KiB
Python
#!/usr/bin/env python3
|
||||
|
|
# -*- coding: utf-8 -*-
|
|||
|
|
"""
|
|||
|
|
LLM 服务模块
|
|||
|
|
处理 LLM 分析相关的业务逻辑
|
|||
|
|
"""
|
|||
|
|
|
|||
|
|
import os
|
|||
|
|
import json
|
|||
|
|
import requests
|
|||
|
|
from datetime import datetime
|
|||
|
|
from typing import Dict, List, Optional
|
|||
|
|
|
|||
|
|
from ..models import LLMConfig, LLMAnalysisResult
|
|||
|
|
from ..store import LLMStore
|
|||
|
|
from .kline_service import KlineService
|
|||
|
|
|
|||
|
|
|
|||
|
|
class LLMService:
|
|||
|
|
"""LLM 服务(处理业务逻辑)"""
|
|||
|
|
|
|||
|
|
# 分析间隔(秒)
|
|||
|
|
ANALYZE_INTERVAL = 300 # 5分钟
|
|||
|
|
|
|||
|
|
# 各周期K线数量限制
|
|||
|
|
KLINE_LIMITS = {
|
|||
|
|
'H4': 20,
|
|||
|
|
'H1': 24,
|
|||
|
|
'M15': 32,
|
|||
|
|
'M5': 48,
|
|||
|
|
'M1': 60
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
# 数据过期阈值(秒)
|
|||
|
|
STALE_THRESHOLD = 180 # 3分钟
|
|||
|
|
|
|||
|
|
def __init__(self, llm_store: LLMStore, kline_service: KlineService):
|
|||
|
|
self.llm_store = llm_store
|
|||
|
|
self.kline_service = kline_service
|
|||
|
|
|
|||
|
|
# 从环境变量补充配置
|
|||
|
|
self._load_env_config()
|
|||
|
|
|
|||
|
|
print("[LLMService] LLM服务已初始化")
|
|||
|
|
|
|||
|
|
def _load_env_config(self):
|
|||
|
|
"""从环境变量加载配置"""
|
|||
|
|
config = self.llm_store.get_config()
|
|||
|
|
|
|||
|
|
if not config.api_key and os.environ.get("LLM_API_KEY"):
|
|||
|
|
self.llm_store.update_config(api_key=os.environ.get("LLM_API_KEY"))
|
|||
|
|
|
|||
|
|
if os.environ.get("LLM_API_BASE"):
|
|||
|
|
self.llm_store.update_config(api_base=os.environ.get("LLM_API_BASE"))
|
|||
|
|
|
|||
|
|
if os.environ.get("LLM_MODEL"):
|
|||
|
|
self.llm_store.update_config(model=os.environ.get("LLM_MODEL"))
|
|||
|
|
|
|||
|
|
# ==================== 配置管理 ====================
|
|||
|
|
|
|||
|
|
def get_config(self) -> Dict:
|
|||
|
|
"""获取配置"""
|
|||
|
|
return self.llm_store.get_config().to_dict()
|
|||
|
|
|
|||
|
|
def configure(self, api_key: str = None, api_base: str = None, model: str = None) -> Dict:
|
|||
|
|
"""配置 LLM 参数"""
|
|||
|
|
config = self.llm_store.update_config(api_key, api_base, model)
|
|||
|
|
return {
|
|||
|
|
"status": "ok",
|
|||
|
|
"enabled": config.enabled,
|
|||
|
|
"model": config.model,
|
|||
|
|
"api_base": config.api_base
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
def is_enabled(self) -> bool:
|
|||
|
|
"""是否启用"""
|
|||
|
|
return self.llm_store.get_config().enabled
|
|||
|
|
|
|||
|
|
# ==================== 数据收集 ====================
|
|||
|
|
|
|||
|
|
def collect_klines_for_analysis(self, symbols: List[str]) -> Dict[str, Dict]:
|
|||
|
|
"""
|
|||
|
|
收集指定品种的K线数据用于分析
|
|||
|
|
|
|||
|
|
Returns:
|
|||
|
|
{symbol: {period: [klines]}}
|
|||
|
|
"""
|
|||
|
|
all_klines = {}
|
|||
|
|
|
|||
|
|
for symbol in symbols:
|
|||
|
|
klines_data = {}
|
|||
|
|
for period in ['H4', 'H1', 'M15', 'M5', 'M1']:
|
|||
|
|
limit = self.KLINE_LIMITS.get(period, 30)
|
|||
|
|
klines = self.kline_service.get_klines(symbol, period, limit)
|
|||
|
|
if klines:
|
|||
|
|
klines_data[period] = klines
|
|||
|
|
|
|||
|
|
if klines_data:
|
|||
|
|
all_klines[symbol] = klines_data
|
|||
|
|
|
|||
|
|
return all_klines
|
|||
|
|
|
|||
|
|
# ==================== Prompt 构建 ====================
|
|||
|
|
|
|||
|
|
def build_analysis_prompt(self, all_klines: Dict[str, Dict]) -> str:
|
|||
|
|
"""构建分析提示词"""
|
|||
|
|
prompt = """你是一位专业的金融分析师。请分析以下多个交易品种的K线数据,给出每个品种的趋势判断和交易建议。
|
|||
|
|
|
|||
|
|
## 分析要求
|
|||
|
|
|
|||
|
|
对于每个品种,请分析:
|
|||
|
|
1. 各周期(H4、H1、M15、M5、M1)的趋势判断,包含趋势类型、置信度(0-100)和判断理由
|
|||
|
|
2. 整体趋势方向、强度(0-100)和总结
|
|||
|
|
3. 关键支撑位和压力位(请根据K线数据自行判断,各列出3个)
|
|||
|
|
4. 交易建议:必须包含M1、M5、M15三个周期的具体交易建议
|
|||
|
|
|
|||
|
|
趋势类型可选值:单边上涨、单边下跌、区间震荡、震荡上升、震荡下跌、震荡收窄、震荡扩大
|
|||
|
|
|
|||
|
|
请按以下JSON格式输出(必须是有效的JSON格式,包含所有品种):
|
|||
|
|
|
|||
|
|
```json
|
|||
|
|
{
|
|||
|
|
"品种1": {
|
|||
|
|
"trend_analysis": {
|
|||
|
|
"H4": {"trend": "趋势类型", "confidence": 置信度, "reason": "判断理由"},
|
|||
|
|
"H1": {"trend": "趋势类型", "confidence": 置信度, "reason": "判断理由"},
|
|||
|
|
"M15": {"trend": "趋势类型", "confidence": 置信度, "reason": "判断理由"},
|
|||
|
|
"M5": {"trend": "趋势类型", "confidence": 置信度, "reason": "判断理由"},
|
|||
|
|
"M1": {"trend": "趋势类型", "confidence": 置信度, "reason": "判断理由"}
|
|||
|
|
},
|
|||
|
|
"overall_trend": {
|
|||
|
|
"direction": "整体趋势方向",
|
|||
|
|
"strength": 强度,
|
|||
|
|
"summary": "整体趋势总结"
|
|||
|
|
},
|
|||
|
|
"key_levels": {
|
|||
|
|
"resistance": [压力位1, 压力位2, 压力位3],
|
|||
|
|
"support": [支撑位1, 支撑位2, 支撑位3]
|
|||
|
|
},
|
|||
|
|
"trade_suggestions": [
|
|||
|
|
{
|
|||
|
|
"period": "M15",
|
|||
|
|
"direction": "buy或sell",
|
|||
|
|
"entry_price": 入场价格,
|
|||
|
|
"stop_loss": 止损价格,
|
|||
|
|
"take_profit": 止盈价格,
|
|||
|
|
"reason": "交易理由"
|
|||
|
|
}
|
|||
|
|
]
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
## K线数据
|
|||
|
|
"""
|
|||
|
|
# 添加各品种的K线数据
|
|||
|
|
for symbol, klines_data in all_klines.items():
|
|||
|
|
prompt += f"\n### {symbol}\n"
|
|||
|
|
for period, klines in klines_data.items():
|
|||
|
|
prompt += f"\n#### {period} 周期({len(klines)}根K线)\n"
|
|||
|
|
prompt += "| 时间 | 开盘 | 最高 | 最低 | 收盘 |\n"
|
|||
|
|
prompt += "|------|------|------|------|------|\n"
|
|||
|
|
for k in klines:
|
|||
|
|
prompt += f"| {k['timestamp']} | {k['open']:.2f} | {k['high']:.2f} | {k['low']:.2f} | {k['close']:.2f} |\n"
|
|||
|
|
|
|||
|
|
prompt += """
|
|||
|
|
|
|||
|
|
请确保输出是纯JSON格式,不要有其他文字说明。每个品种的分析结果都要完整,trade_suggestions必须包含M1、M5、M15三个周期的建议。
|
|||
|
|
"""
|
|||
|
|
return prompt
|
|||
|
|
|
|||
|
|
# ==================== LLM API 调用 ====================
|
|||
|
|
|
|||
|
|
def call_llm(self, prompt: str) -> Optional[Dict]:
|
|||
|
|
"""调用 LLM API(非流式)"""
|
|||
|
|
config = self.llm_store.get_config()
|
|||
|
|
if not config.api_key:
|
|||
|
|
return None
|
|||
|
|
|
|||
|
|
try:
|
|||
|
|
headers = {
|
|||
|
|
"Authorization": f"Bearer {config.api_key}",
|
|||
|
|
"Content-Type": "application/json"
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
data = {
|
|||
|
|
"model": config.model,
|
|||
|
|
"messages": [
|
|||
|
|
{"role": "system", "content": "你是一位专业的金融分析师,擅长技术分析和趋势判断。请用JSON格式输出分析结果,不要有任何额外的文字说明。"},
|
|||
|
|
{"role": "user", "content": prompt}
|
|||
|
|
],
|
|||
|
|
"temperature": 0.3,
|
|||
|
|
"max_tokens": 4000
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
response = requests.post(
|
|||
|
|
f"{config.api_base}/chat/completions",
|
|||
|
|
headers=headers,
|
|||
|
|
json=data,
|
|||
|
|
timeout=120
|
|||
|
|
)
|
|||
|
|
|
|||
|
|
if response.status_code == 200:
|
|||
|
|
result = response.json()
|
|||
|
|
content = result["choices"][0]["message"]["content"]
|
|||
|
|
return self._parse_llm_response(content)
|
|||
|
|
else:
|
|||
|
|
print(f"[LLMService] API调用失败: {response.status_code} - {response.text}")
|
|||
|
|
return None
|
|||
|
|
|
|||
|
|
except Exception as e:
|
|||
|
|
print(f"[LLMService] 调用异常: {e}")
|
|||
|
|
return None
|
|||
|
|
|
|||
|
|
def call_llm_stream(self, prompt: str, on_chunk: callable = None) -> Optional[Dict]:
|
|||
|
|
"""
|
|||
|
|
调用 LLM API(流式)
|
|||
|
|
|
|||
|
|
Args:
|
|||
|
|
prompt: 提示词
|
|||
|
|
on_chunk: 回调函数,参数为 (chunk_count, full_content)
|
|||
|
|
"""
|
|||
|
|
config = self.llm_store.get_config()
|
|||
|
|
if not config.api_key:
|
|||
|
|
return None
|
|||
|
|
|
|||
|
|
try:
|
|||
|
|
headers = {
|
|||
|
|
"Authorization": f"Bearer {config.api_key}",
|
|||
|
|
"Content-Type": "application/json"
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
data = {
|
|||
|
|
"model": config.model,
|
|||
|
|
"messages": [
|
|||
|
|
{"role": "system", "content": "你是一位专业的金融分析师,擅长技术分析和趋势判断。请用JSON格式输出分析结果,不要有任何额外的文字说明。"},
|
|||
|
|
{"role": "user", "content": prompt}
|
|||
|
|
],
|
|||
|
|
"temperature": 0.3,
|
|||
|
|
"max_tokens": 4000,
|
|||
|
|
"stream": True
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
response = requests.post(
|
|||
|
|
f"{config.api_base}/chat/completions",
|
|||
|
|
headers=headers,
|
|||
|
|
json=data,
|
|||
|
|
timeout=120,
|
|||
|
|
stream=True
|
|||
|
|
)
|
|||
|
|
|
|||
|
|
if response.status_code != 200:
|
|||
|
|
print(f"[LLMService] API调用失败: {response.status_code} - {response.text}")
|
|||
|
|
return None
|
|||
|
|
|
|||
|
|
# 收集完整响应
|
|||
|
|
full_content = ""
|
|||
|
|
chunk_count = 0
|
|||
|
|
|
|||
|
|
for line in response.iter_lines():
|
|||
|
|
if not line:
|
|||
|
|
continue
|
|||
|
|
|
|||
|
|
line = line.decode('utf-8')
|
|||
|
|
if line.startswith('data: '):
|
|||
|
|
data_str = line[6:]
|
|||
|
|
if data_str == '[DONE]':
|
|||
|
|
break
|
|||
|
|
|
|||
|
|
try:
|
|||
|
|
chunk_data = json.loads(data_str)
|
|||
|
|
if 'choices' in chunk_data and len(chunk_data['choices']) > 0:
|
|||
|
|
delta = chunk_data['choices'][0].get('delta', {})
|
|||
|
|
content_piece = delta.get('content', '')
|
|||
|
|
if content_piece:
|
|||
|
|
full_content += content_piece
|
|||
|
|
chunk_count += 1
|
|||
|
|
|
|||
|
|
if on_chunk:
|
|||
|
|
on_chunk(chunk_count, full_content)
|
|||
|
|
except json.JSONDecodeError:
|
|||
|
|
continue
|
|||
|
|
|
|||
|
|
print(f"[LLMService] 流式接收完成,共 {chunk_count} 个chunk,{len(full_content)} 字符")
|
|||
|
|
return self._parse_llm_response(full_content)
|
|||
|
|
|
|||
|
|
except Exception as e:
|
|||
|
|
print(f"[LLMService] 流式调用异常: {e}")
|
|||
|
|
return None
|
|||
|
|
|
|||
|
|
def _parse_llm_response(self, content: str) -> Optional[Dict]:
|
|||
|
|
"""解析 LLM 响应"""
|
|||
|
|
try:
|
|||
|
|
# 提取JSON部分
|
|||
|
|
if "```json" in content:
|
|||
|
|
content = content.split("```json")[1].split("```")[0]
|
|||
|
|
elif "```" in content:
|
|||
|
|
content = content.split("```")[1].split("```")[0]
|
|||
|
|
|
|||
|
|
return json.loads(content.strip())
|
|||
|
|
except json.JSONDecodeError as e:
|
|||
|
|
print(f"[LLMService] JSON解析失败: {e}")
|
|||
|
|
return None
|
|||
|
|
|
|||
|
|
# ==================== 入场价检测 ====================
|
|||
|
|
|
|||
|
|
def check_entry_price_nearby(self, symbol: str, current_price: float,
|
|||
|
|
threshold: float = 0.0001) -> List[Dict]:
|
|||
|
|
"""
|
|||
|
|
检查当前价格是否接近 AI 建议的入场价
|
|||
|
|
|
|||
|
|
Args:
|
|||
|
|
symbol: 交易品种
|
|||
|
|
current_price: 当前价格
|
|||
|
|
threshold: 价格接近阈值,默认万分之一
|
|||
|
|
|
|||
|
|
Returns:
|
|||
|
|
匹配的交易建议列表
|
|||
|
|
"""
|
|||
|
|
matched = []
|
|||
|
|
|
|||
|
|
result = self.llm_store.get_analysis_result(symbol)
|
|||
|
|
if not result or not result.trade_suggestions:
|
|||
|
|
return matched
|
|||
|
|
|
|||
|
|
for suggestion in result.trade_suggestions:
|
|||
|
|
entry_price = suggestion.get('entry_price')
|
|||
|
|
period = suggestion.get('period')
|
|||
|
|
direction = suggestion.get('direction')
|
|||
|
|
stop_loss = suggestion.get('stop_loss')
|
|||
|
|
take_profit = suggestion.get('take_profit')
|
|||
|
|
|
|||
|
|
if not entry_price or entry_price <= 0:
|
|||
|
|
continue
|
|||
|
|
|
|||
|
|
# 验证止损止盈
|
|||
|
|
if not stop_loss or not take_profit or stop_loss <= 0 or take_profit <= 0:
|
|||
|
|
print(f"[LLMService] 跳过无效建议: {period} sl={stop_loss}, tp={take_profit}")
|
|||
|
|
continue
|
|||
|
|
|
|||
|
|
price_diff_pct = abs(current_price - entry_price) / entry_price
|
|||
|
|
|
|||
|
|
if price_diff_pct <= threshold:
|
|||
|
|
# 检查冷却
|
|||
|
|
can_alert = self.llm_store.check_entry_alert_cooldown(
|
|||
|
|
symbol, period, direction, entry_price
|
|||
|
|
)
|
|||
|
|
|
|||
|
|
if can_alert:
|
|||
|
|
matched.append({
|
|||
|
|
"symbol": symbol,
|
|||
|
|
"period": period,
|
|||
|
|
"direction": direction,
|
|||
|
|
"entry_price": entry_price,
|
|||
|
|
"current_price": current_price,
|
|||
|
|
"price_diff_pct": round(price_diff_pct * 100, 4),
|
|||
|
|
"stop_loss": stop_loss,
|
|||
|
|
"take_profit": take_profit,
|
|||
|
|
"reason": suggestion.get('reason'),
|
|||
|
|
"analyzed_at": result.analyzed_at
|
|||
|
|
})
|
|||
|
|
print(f"[LLMService] 价格接近AI入场价: {symbol} {period} "
|
|||
|
|
f"入场价 {entry_price:.2f}, 当前价 {current_price:.2f}")
|
|||
|
|
|
|||
|
|
# 清理过期记录
|
|||
|
|
self.llm_store.cleanup_entry_alerts()
|
|||
|
|
|
|||
|
|
return matched
|
|||
|
|
|
|||
|
|
# ==================== 分析执行 ====================
|
|||
|
|
|
|||
|
|
def run_analysis(self, on_status: callable = None, on_complete: callable = None) -> Dict:
|
|||
|
|
"""
|
|||
|
|
执行分析
|
|||
|
|
|
|||
|
|
Args:
|
|||
|
|
on_status: 状态回调
|
|||
|
|
on_complete: 完成回调
|
|||
|
|
|
|||
|
|
Returns:
|
|||
|
|
分析结果
|
|||
|
|
"""
|
|||
|
|
if not self.is_enabled():
|
|||
|
|
return {"status": "error", "message": "LLM 未启用"}
|
|||
|
|
|
|||
|
|
# 获取品种列表
|
|||
|
|
symbols = self.kline_service.get_symbols()
|
|||
|
|
if not symbols:
|
|||
|
|
if on_status:
|
|||
|
|
on_status("error", "没有品种数据")
|
|||
|
|
return {"status": "error", "message": "没有品种数据"}
|
|||
|
|
|
|||
|
|
if on_status:
|
|||
|
|
on_status("analyzing", f"正在检查 {len(symbols)} 个品种...")
|
|||
|
|
|
|||
|
|
# 检查数据状态
|
|||
|
|
status = self.kline_service.check_symbols_status(symbols, self.STALE_THRESHOLD)
|
|||
|
|
active_symbols = status["active"]
|
|||
|
|
|
|||
|
|
# 更新过期和休市品种状态
|
|||
|
|
for symbol in status["stale"]:
|
|||
|
|
self.llm_store.update_market_status(symbol, "stale", data_stale=True)
|
|||
|
|
for symbol in status["closed"]:
|
|||
|
|
self.llm_store.update_market_status(symbol, "closed", data_stale=True)
|
|||
|
|
|
|||
|
|
if not active_symbols:
|
|||
|
|
if on_status:
|
|||
|
|
on_status("stale", "所有品种数据均未更新")
|
|||
|
|
return {"status": "ok", "message": "所有品种数据均未更新"}
|
|||
|
|
|
|||
|
|
if on_status:
|
|||
|
|
on_status("analyzing", f"正在分析 {len(active_symbols)} 个品种...")
|
|||
|
|
|
|||
|
|
# 收集K线数据
|
|||
|
|
all_klines = self.collect_klines_for_analysis(active_symbols)
|
|||
|
|
if not all_klines:
|
|||
|
|
if on_status:
|
|||
|
|
on_status("error", "无K线数据可分析")
|
|||
|
|
return {"status": "error", "message": "无K线数据可分析"}
|
|||
|
|
|
|||
|
|
# 构建提示词
|
|||
|
|
prompt = self.build_analysis_prompt(all_klines)
|
|||
|
|
|
|||
|
|
# 调用 LLM
|
|||
|
|
def on_chunk(count, content):
|
|||
|
|
if on_status and count % 50 == 0:
|
|||
|
|
on_status("streaming", f"正在接收分析结果... ({len(content)} 字符)")
|
|||
|
|
|
|||
|
|
response = self.call_llm_stream(prompt, on_chunk)
|
|||
|
|
|
|||
|
|
# 保存结果
|
|||
|
|
if response:
|
|||
|
|
for symbol, analysis in response.items():
|
|||
|
|
if isinstance(analysis, dict):
|
|||
|
|
self.llm_store.save_analysis_dict(symbol, analysis)
|
|||
|
|
|
|||
|
|
if on_complete:
|
|||
|
|
on_complete(response)
|
|||
|
|
|
|||
|
|
return {
|
|||
|
|
"status": "ok",
|
|||
|
|
"analyzed_symbols": list(response.keys()) if response else []
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
# ==================== 查询 ====================
|
|||
|
|
|
|||
|
|
def get_analysis(self, symbol: str = None) -> Dict:
|
|||
|
|
"""获取分析结果"""
|
|||
|
|
return self.llm_store.get_analysis(symbol)
|
|||
|
|
|
|||
|
|
def get_status(self) -> Dict:
|
|||
|
|
"""获取状态"""
|
|||
|
|
return self.llm_store.get_status()
|