update
This commit is contained in:
parent
60a5410907
commit
0198dd5345
@ -1,5 +1,5 @@
|
||||
"""
|
||||
加密货币交易智能体 - 主控制器
|
||||
加密货币交易智能体 - 主控制器(LLM 驱动版)
|
||||
"""
|
||||
import asyncio
|
||||
from typing import Dict, Any, List, Optional
|
||||
@ -13,12 +13,11 @@ from app.services.feishu_service import get_feishu_service
|
||||
from app.services.telegram_service import get_telegram_service
|
||||
from app.services.paper_trading_service import get_paper_trading_service
|
||||
from app.services.price_monitor_service import get_price_monitor_service
|
||||
from app.crypto_agent.signal_analyzer import SignalAnalyzer
|
||||
from app.crypto_agent.strategy import TrendFollowingStrategy
|
||||
from app.crypto_agent.llm_signal_analyzer import LLMSignalAnalyzer
|
||||
|
||||
|
||||
class CryptoAgent:
|
||||
"""加密货币交易信号智能体"""
|
||||
"""加密货币交易信号智能体(LLM 驱动版)"""
|
||||
|
||||
def __init__(self):
|
||||
"""初始化智能体"""
|
||||
@ -26,35 +25,30 @@ class CryptoAgent:
|
||||
self.binance = binance_service
|
||||
self.feishu = get_feishu_service()
|
||||
self.telegram = get_telegram_service()
|
||||
self.analyzer = SignalAnalyzer()
|
||||
self.strategy = TrendFollowingStrategy()
|
||||
self.llm_analyzer = LLMSignalAnalyzer()
|
||||
|
||||
# 模拟交易服务
|
||||
self.paper_trading_enabled = self.settings.paper_trading_enabled
|
||||
if self.paper_trading_enabled:
|
||||
self.paper_trading = get_paper_trading_service()
|
||||
self.price_monitor = get_price_monitor_service()
|
||||
# 注册价格回调
|
||||
self.price_monitor.add_price_callback(self._on_price_update)
|
||||
else:
|
||||
self.paper_trading = None
|
||||
self.price_monitor = None
|
||||
|
||||
# 状态管理
|
||||
self.last_signals: Dict[str, Dict[str, Any]] = {} # 上次信号
|
||||
self.last_trends: Dict[str, str] = {} # 上次趋势
|
||||
self.signal_cooldown: Dict[str, datetime] = {} # 信号冷却时间
|
||||
self.last_signals: Dict[str, Dict[str, Any]] = {}
|
||||
self.signal_cooldown: Dict[str, datetime] = {}
|
||||
|
||||
# 配置
|
||||
self.symbols = self.settings.crypto_symbols.split(',')
|
||||
self.analysis_interval = self.settings.crypto_analysis_interval
|
||||
self.llm_threshold = self.settings.crypto_llm_threshold
|
||||
|
||||
# 运行状态
|
||||
self.running = False
|
||||
self._event_loop = None # 保存主事件循环引用
|
||||
self._event_loop = None
|
||||
|
||||
logger.info(f"加密货币智能体初始化完成,监控交易对: {self.symbols}")
|
||||
logger.info(f"加密货币智能体初始化完成(LLM 驱动),监控交易对: {self.symbols}")
|
||||
if self.paper_trading_enabled:
|
||||
logger.info(f"模拟交易已启用")
|
||||
|
||||
@ -63,11 +57,9 @@ class CryptoAgent:
|
||||
if not self.paper_trading:
|
||||
return
|
||||
|
||||
# 检查是否有订单触发止盈止损
|
||||
triggered = self.paper_trading.check_price_triggers(symbol, price)
|
||||
|
||||
for result in triggered:
|
||||
# 使用 asyncio.run_coroutine_threadsafe 从 WebSocket 线程安全地调度协程
|
||||
if self._event_loop and self._event_loop.is_running():
|
||||
asyncio.run_coroutine_threadsafe(self._notify_order_closed(result), self._event_loop)
|
||||
else:
|
||||
@ -75,10 +67,9 @@ class CryptoAgent:
|
||||
|
||||
async def _notify_order_closed(self, result: Dict[str, Any]):
|
||||
"""发送订单平仓通知"""
|
||||
is_win = result.get('is_win', False)
|
||||
status = result.get('status', '')
|
||||
is_win = result.get('is_win', False)
|
||||
|
||||
# 确定图标和文本
|
||||
if status == 'closed_tp':
|
||||
emoji = "🎯"
|
||||
status_text = "止盈平仓"
|
||||
@ -92,7 +83,6 @@ class CryptoAgent:
|
||||
win_text = "盈利" if is_win else "亏损"
|
||||
side_text = "做多" if result.get('side') == 'long' else "做空"
|
||||
|
||||
# 构建消息
|
||||
message = f"""{emoji} 订单{status_text}
|
||||
|
||||
交易对: {result.get('symbol')}
|
||||
@ -102,10 +92,8 @@ class CryptoAgent:
|
||||
{win_text}: {result.get('pnl_percent', 0):+.2f}% (${result.get('pnl_amount', 0):+.2f})
|
||||
持仓时间: {result.get('hold_duration', 'N/A')}"""
|
||||
|
||||
# 发送通知
|
||||
await self.feishu.send_text(message)
|
||||
await self.telegram.send_message(message)
|
||||
|
||||
logger.info(f"已发送订单平仓通知: {result.get('order_id')}")
|
||||
|
||||
def _get_seconds_until_next_5min(self) -> int:
|
||||
@ -114,10 +102,8 @@ class CryptoAgent:
|
||||
current_minute = now.minute
|
||||
current_second = now.second
|
||||
|
||||
# 计算下一个5的倍数分钟
|
||||
minutes_past = current_minute % 5
|
||||
if minutes_past == 0 and current_second == 0:
|
||||
# 刚好在整点,立即执行
|
||||
return 0
|
||||
minutes_to_wait = 5 - minutes_past if minutes_past > 0 else 5
|
||||
seconds_to_wait = minutes_to_wait * 60 - current_second
|
||||
@ -125,45 +111,43 @@ class CryptoAgent:
|
||||
return seconds_to_wait
|
||||
|
||||
async def run(self):
|
||||
"""主运行循环 - 在5的倍数分钟执行"""
|
||||
"""主运行循环"""
|
||||
self.running = True
|
||||
self._event_loop = asyncio.get_event_loop() # 保存事件循环引用
|
||||
self._event_loop = asyncio.get_event_loop()
|
||||
|
||||
# 启动横幅
|
||||
logger.info("\n" + "=" * 60)
|
||||
logger.info("🚀 加密货币交易信号智能体")
|
||||
logger.info("🚀 加密货币交易信号智能体(LLM 驱动)")
|
||||
logger.info("=" * 60)
|
||||
logger.info(f" 监控交易对: {', '.join(self.symbols)}")
|
||||
logger.info(f" 运行模式: 每5分钟整点执行 (:00, :05, :10, ...)")
|
||||
logger.info(f" LLM阈值: {self.llm_threshold * 100:.0f}%")
|
||||
logger.info(f" 运行模式: 每5分钟整点执行")
|
||||
logger.info(f" 分析引擎: LLM 自主分析")
|
||||
if self.paper_trading_enabled:
|
||||
logger.info(f" 模拟交易: 已启用")
|
||||
logger.info("=" * 60 + "\n")
|
||||
|
||||
# 启动价格监控(用于模拟交易)
|
||||
# 启动价格监控
|
||||
if self.paper_trading_enabled and self.price_monitor:
|
||||
for symbol in self.symbols:
|
||||
self.price_monitor.subscribe_symbol(symbol)
|
||||
logger.info(f"已启动 WebSocket 价格监控: {', '.join(self.symbols)}")
|
||||
|
||||
# 发送启动通知(飞书 + Telegram)
|
||||
# 发送启动通知
|
||||
await self.feishu.send_text(
|
||||
f"🚀 加密货币智能体已启动\n"
|
||||
f"🚀 加密货币智能体已启动(LLM 驱动)\n"
|
||||
f"监控交易对: {', '.join(self.symbols)}\n"
|
||||
f"运行时间: 每5分钟整点 (:00, :05, :10, ...)"
|
||||
f"运行时间: 每5分钟整点"
|
||||
)
|
||||
await self.telegram.send_startup_notification(self.symbols)
|
||||
|
||||
while self.running:
|
||||
try:
|
||||
# 等待到下一个5分钟整点
|
||||
wait_seconds = self._get_seconds_until_next_5min()
|
||||
if wait_seconds > 0:
|
||||
next_run = datetime.now() + timedelta(seconds=wait_seconds)
|
||||
logger.info(f"⏳ 等待 {wait_seconds} 秒,下次运行: {next_run.strftime('%H:%M:%S')}")
|
||||
await asyncio.sleep(wait_seconds)
|
||||
|
||||
# 执行分析
|
||||
run_time = datetime.now()
|
||||
logger.info("\n" + "=" * 60)
|
||||
logger.info(f"⏰ 定时任务执行 [{run_time.strftime('%Y-%m-%d %H:%M:%S')}]")
|
||||
@ -176,32 +160,29 @@ class CryptoAgent:
|
||||
logger.info(f"✅ 本轮分析完成,共分析 {len(self.symbols)} 个交易对")
|
||||
logger.info("─" * 60 + "\n")
|
||||
|
||||
# 等待几秒确保不会在同一分钟内重复执行
|
||||
await asyncio.sleep(2)
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"❌ 分析循环出错: {e}")
|
||||
await asyncio.sleep(10) # 出错后等待10秒再继续
|
||||
import traceback
|
||||
logger.error(traceback.format_exc())
|
||||
await asyncio.sleep(10)
|
||||
|
||||
def stop(self):
|
||||
"""停止运行"""
|
||||
self.running = False
|
||||
|
||||
# 停止价格监控
|
||||
if self.price_monitor:
|
||||
self.price_monitor.stop()
|
||||
|
||||
logger.info("加密货币智能体已停止")
|
||||
|
||||
async def analyze_symbol(self, symbol: str):
|
||||
"""
|
||||
分析单个交易对
|
||||
分析单个交易对(LLM 驱动)
|
||||
|
||||
Args:
|
||||
symbol: 交易对,如 'BTCUSDT'
|
||||
"""
|
||||
try:
|
||||
# 分隔线
|
||||
logger.info(f"\n{'─' * 50}")
|
||||
logger.info(f"📊 {symbol} 分析开始")
|
||||
logger.info(f"{'─' * 50}")
|
||||
@ -213,159 +194,125 @@ class CryptoAgent:
|
||||
logger.warning(f"⚠️ {symbol} 数据不完整,跳过分析")
|
||||
return
|
||||
|
||||
# 获取当前价格
|
||||
# 当前价格
|
||||
current_price = float(data['5m'].iloc[-1]['close'])
|
||||
price_change_24h = self._calculate_price_change(data['1h'])
|
||||
logger.info(f"💰 当前价格: ${current_price:,.2f} ({price_change_24h})")
|
||||
|
||||
# 2. 分析趋势(1H + 4H)- 返回详细趋势信息
|
||||
logger.info(f"\n📈 【趋势分析】")
|
||||
trend = self.analyzer.analyze_trend(data['1h'], data['4h'])
|
||||
trend_direction = trend.get('direction', 'neutral') if isinstance(trend, dict) else trend
|
||||
trend_strength = trend.get('strength', 'unknown') if isinstance(trend, dict) else 'unknown'
|
||||
trend_phase = trend.get('phase', 'unknown') if isinstance(trend, dict) else 'unknown'
|
||||
# 2. LLM 分析(包含新闻舆情)
|
||||
logger.info(f"\n🤖 【LLM 分析中...】")
|
||||
result = await self.llm_analyzer.analyze(symbol, data, symbols=self.symbols)
|
||||
|
||||
# 趋势方向图标
|
||||
trend_icon = {'bullish': '🟢 看涨', 'bearish': '🔴 看跌', 'neutral': '⚪ 震荡'}.get(trend_direction, '❓')
|
||||
phase_text = {'impulse': '主升/主跌浪', 'correction': '回调/反弹', 'oversold': '极度超卖',
|
||||
'overbought': '极度超买', 'sideways': '横盘'}.get(trend_phase, trend_phase)
|
||||
# 输出分析摘要
|
||||
summary = result.get('analysis_summary', '无')
|
||||
logger.info(f" 市场状态: {summary}")
|
||||
|
||||
logger.info(f" 方向: {trend_icon} | 强度: {trend_strength} | 阶段: {phase_text}")
|
||||
# 输出新闻情绪
|
||||
news_sentiment = result.get('news_sentiment', '')
|
||||
news_impact = result.get('news_impact', '')
|
||||
if news_sentiment:
|
||||
sentiment_icon = {'positive': '📈', 'negative': '📉', 'neutral': '➖'}.get(news_sentiment, '')
|
||||
logger.info(f" 新闻情绪: {sentiment_icon} {news_sentiment}")
|
||||
if news_impact:
|
||||
logger.info(f" 消息影响: {news_impact}")
|
||||
|
||||
# 3. 检查趋势变化(只有之前有记录时才检查,避免启动时误报)
|
||||
if symbol in self.last_trends:
|
||||
last_trend = self.last_trends[symbol]
|
||||
if isinstance(last_trend, dict):
|
||||
last_direction = last_trend.get('direction', 'neutral')
|
||||
else:
|
||||
last_direction = last_trend
|
||||
if last_direction != trend_direction:
|
||||
await self._handle_trend_change(symbol, last_direction, trend_direction, data)
|
||||
# 输出关键价位
|
||||
levels = result.get('key_levels', {})
|
||||
if levels.get('support') or levels.get('resistance'):
|
||||
support_str = ', '.join([f"${s:,.2f}" for s in levels.get('support', [])[:2]])
|
||||
resistance_str = ', '.join([f"${r:,.2f}" for r in levels.get('resistance', [])[:2]])
|
||||
logger.info(f" 支撑位: {support_str or '-'}")
|
||||
logger.info(f" 阻力位: {resistance_str or '-'}")
|
||||
|
||||
self.last_trends[symbol] = trend
|
||||
# 3. 处理信号
|
||||
signals = result.get('signals', [])
|
||||
|
||||
# 4. 分析进场信号(15M 为主,5M 辅助,传入 1H 数据用于支撑阻力位)
|
||||
logger.info(f"\n🎯 【信号分析】")
|
||||
signal = self.analyzer.analyze_entry_signal(data['5m'], data['15m'], trend, data['1h'])
|
||||
signal['symbol'] = symbol
|
||||
signal['trend'] = trend_direction
|
||||
signal['trend_info'] = trend if isinstance(trend, dict) else {'direction': trend}
|
||||
signal['price'] = current_price
|
||||
signal['timestamp'] = datetime.now()
|
||||
if not signals:
|
||||
logger.info(f"\n⏸️ 结论: 无交易信号,继续观望")
|
||||
return
|
||||
|
||||
# 输出信号详情
|
||||
action_icon = {'buy': '🟢 买入', 'sell': '🔴 卖出', 'hold': '⏸️ 观望'}.get(signal['action'], '❓')
|
||||
grade_icon = {'A': '⭐⭐⭐', 'B': '⭐⭐', 'C': '⭐', 'D': ''}.get(signal.get('signal_grade', 'D'), '')
|
||||
signal_type = signal.get('signal_type', 'swing')
|
||||
type_text = '📈短线' if signal_type == 'short_term' else '📊波段'
|
||||
# 输出所有信号
|
||||
logger.info(f"\n🎯 【发现 {len(signals)} 个信号】")
|
||||
|
||||
logger.info(f" 信号: {action_icon} | 类型: {type_text} | 置信度: {signal['confidence']}% | 等级: {signal.get('signal_grade', 'D')} {grade_icon}")
|
||||
for sig in signals:
|
||||
signal_type = sig.get('type', 'unknown')
|
||||
type_map = {'short_term': '短线', 'medium_term': '中线', 'long_term': '长线'}
|
||||
type_text = type_map.get(signal_type, signal_type)
|
||||
|
||||
# 输出触发原因
|
||||
if signal.get('reasons'):
|
||||
logger.info(f" 原因: {', '.join(signal['reasons'][:5])}") # 最多显示5个原因
|
||||
action = sig.get('action', 'wait')
|
||||
action_map = {'buy': '🟢 做多', 'sell': '🔴 做空'}
|
||||
action_text = action_map.get(action, action)
|
||||
|
||||
# 输出权重详情
|
||||
weights = signal.get('signal_weights', {})
|
||||
if weights:
|
||||
logger.info(f" 权重: 买入={weights.get('buy', 0):.1f} | 卖出={weights.get('sell', 0):.1f}")
|
||||
grade = sig.get('grade', 'D')
|
||||
confidence = sig.get('confidence', 0)
|
||||
grade_icon = {'A': '⭐⭐⭐', 'B': '⭐⭐', 'C': '⭐', 'D': ''}.get(grade, '')
|
||||
|
||||
# 输出K线形态
|
||||
patterns = signal.get('patterns', {})
|
||||
if patterns.get('bullish_patterns') or patterns.get('bearish_patterns'):
|
||||
all_patterns = patterns.get('bullish_patterns', []) + patterns.get('bearish_patterns', [])
|
||||
logger.info(f" 形态: {', '.join(all_patterns)}")
|
||||
logger.info(f"\n [{type_text}] {action_text}")
|
||||
logger.info(f" 等级: {grade} {grade_icon} | 置信度: {confidence}%")
|
||||
logger.info(f" 入场: ${sig.get('entry_price', 0):,.2f} | "
|
||||
f"止损: ${sig.get('stop_loss', 0):,.2f} | "
|
||||
f"止盈: ${sig.get('take_profit', 0):,.2f}")
|
||||
logger.info(f" 原因: {sig.get('reason', '无')[:100]}")
|
||||
|
||||
# 输出成交量分析
|
||||
vol = signal.get('volume_analysis', {})
|
||||
if vol:
|
||||
vol_icon = {'high': '📈', 'low': '📉', 'normal': '➖'}.get(vol.get('volume_signal', 'normal'), '')
|
||||
confirm_text = '✓ 确认' if vol.get('volume_confirms') else '✗ 未确认'
|
||||
logger.info(f" 成交量: {vol_icon} {vol.get('volume_signal', 'normal')} | {confirm_text}")
|
||||
if sig.get('risk_warning'):
|
||||
logger.info(f" 风险: {sig.get('risk_warning')}")
|
||||
|
||||
# 输出支撑阻力位
|
||||
levels = signal.get('levels', {})
|
||||
if levels.get('nearest_support') or levels.get('nearest_resistance'):
|
||||
support = f"${levels['nearest_support']:,.2f}" if levels.get('nearest_support') else '-'
|
||||
resistance = f"${levels['nearest_resistance']:,.2f}" if levels.get('nearest_resistance') else '-'
|
||||
logger.info(f" 关键位: 支撑={support} | 阻力={resistance}")
|
||||
# 4. 选择最佳信号发送通知
|
||||
best_signal = self.llm_analyzer.get_best_signal(result)
|
||||
|
||||
# 5. 检查是否需要发送信号
|
||||
if self._should_send_signal(symbol, signal):
|
||||
if best_signal and self._should_send_signal(symbol, best_signal):
|
||||
logger.info(f"\n📤 【发送通知】")
|
||||
|
||||
# 6. 计算止损止盈
|
||||
atr = float(data['15m'].iloc[-1].get('atr', 0))
|
||||
if atr > 0:
|
||||
sl_tp = self.analyzer.calculate_stop_loss_take_profit(
|
||||
signal['price'], signal['action'], atr
|
||||
)
|
||||
signal.update(sl_tp)
|
||||
logger.info(f" 止损: ${signal['stop_loss']:,.2f} | 止盈: ${signal['take_profit']:,.2f}")
|
||||
# 构建通知消息
|
||||
message = self.llm_analyzer.format_signal_message(best_signal, symbol)
|
||||
|
||||
# 7. LLM 深度分析(置信度超过阈值时)
|
||||
if signal['confidence'] >= self.llm_threshold * 100:
|
||||
logger.info(f" 🤖 触发 LLM 深度分析...")
|
||||
llm_result = await self.analyzer.llm_analyze(data, signal, symbol)
|
||||
# 发送通知
|
||||
await self.feishu.send_text(message)
|
||||
await self.telegram.send_message(message)
|
||||
|
||||
# 处理 LLM 分析结果
|
||||
if llm_result.get('parsed'):
|
||||
parsed = llm_result['parsed']
|
||||
# 新格式使用 signal 而不是 recommendation
|
||||
recommendation = parsed.get('signal', parsed.get('recommendation', {}))
|
||||
logger.info(f" ✅ 已发送信号通知")
|
||||
|
||||
# 如果 LLM 建议观望,降低置信度
|
||||
if recommendation.get('action') == 'wait':
|
||||
signal['confidence'] = min(signal['confidence'], 40)
|
||||
signal['llm_analysis'] = llm_result.get('summary', 'LLM 建议观望')
|
||||
logger.info(f" 🤖 LLM 建议: 观望")
|
||||
else:
|
||||
# 使用 LLM 的止损止盈建议
|
||||
if recommendation.get('stop_loss'):
|
||||
signal['stop_loss'] = recommendation['stop_loss']
|
||||
if recommendation.get('targets'):
|
||||
signal['take_profit'] = recommendation['targets'][0]
|
||||
elif recommendation.get('take_profit'):
|
||||
signal['take_profit'] = recommendation['take_profit']
|
||||
signal['llm_analysis'] = llm_result.get('summary', '')
|
||||
logger.info(f" 🤖 LLM 建议: {recommendation.get('action', 'N/A')}")
|
||||
else:
|
||||
signal['llm_analysis'] = llm_result.get('summary', llm_result.get('raw', '')[:200])
|
||||
# 更新状态
|
||||
self.last_signals[symbol] = best_signal
|
||||
self.signal_cooldown[symbol] = datetime.now()
|
||||
|
||||
# 8. 发送通知(飞书 + Telegram,置信度仍然足够高时)
|
||||
if signal['confidence'] >= 50:
|
||||
await self.feishu.send_trading_signal(signal)
|
||||
await self.telegram.send_trading_signal(signal)
|
||||
|
||||
# 9. 更新状态
|
||||
self.last_signals[symbol] = signal
|
||||
self.signal_cooldown[symbol] = datetime.now()
|
||||
|
||||
action_text = '买入' if signal['action'] == 'buy' else '卖出'
|
||||
logger.info(f" ✅ 已发送 {action_text} 信号通知(飞书+Telegram)")
|
||||
|
||||
# 10. 创建模拟订单
|
||||
if self.paper_trading_enabled and self.paper_trading:
|
||||
if signal.get('signal_grade', 'D') != 'D':
|
||||
order = self.paper_trading.create_order_from_signal(signal)
|
||||
if order:
|
||||
logger.info(f" 📝 已创建模拟订单: {order.order_id}")
|
||||
else:
|
||||
logger.info(f" ⏸️ 置信度不足({signal['confidence']}%),不发送通知")
|
||||
# 5. 创建模拟订单
|
||||
if self.paper_trading_enabled and self.paper_trading:
|
||||
grade = best_signal.get('grade', 'D')
|
||||
if grade != 'D':
|
||||
# 转换信号格式以兼容 paper_trading
|
||||
paper_signal = self._convert_to_paper_signal(symbol, best_signal, current_price)
|
||||
order = self.paper_trading.create_order_from_signal(paper_signal)
|
||||
if order:
|
||||
logger.info(f" 📝 已创建模拟订单: {order.order_id}")
|
||||
else:
|
||||
# 输出为什么不发送
|
||||
if signal['action'] == 'hold':
|
||||
logger.info(f"\n⏸️ 结论: 观望,无交易机会")
|
||||
elif signal['confidence'] < 50:
|
||||
logger.info(f"\n⏸️ 结论: 置信度不足({signal['confidence']}%),继续观望")
|
||||
else:
|
||||
logger.info(f"\n⏸️ 结论: 信号冷却中,跳过")
|
||||
if best_signal:
|
||||
logger.info(f"\n⏸️ 信号冷却中或置信度不足,不发送通知")
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"❌ 分析 {symbol} 出错: {e}")
|
||||
import traceback
|
||||
logger.error(traceback.format_exc())
|
||||
|
||||
def _convert_to_paper_signal(self, symbol: str, signal: Dict[str, Any],
|
||||
current_price: float) -> Dict[str, Any]:
|
||||
"""转换 LLM 信号格式为模拟交易格式"""
|
||||
signal_type = signal.get('type', 'medium_term')
|
||||
type_map = {'short_term': 'short_term', 'medium_term': 'swing', 'long_term': 'swing'}
|
||||
|
||||
return {
|
||||
'symbol': symbol,
|
||||
'action': signal.get('action', 'hold'),
|
||||
'price': current_price,
|
||||
'stop_loss': signal.get('stop_loss', 0),
|
||||
'take_profit': signal.get('take_profit', 0),
|
||||
'confidence': signal.get('confidence', 0),
|
||||
'signal_grade': signal.get('grade', 'D'),
|
||||
'signal_type': type_map.get(signal_type, 'swing'),
|
||||
'reasons': [signal.get('reason', '')],
|
||||
'timestamp': datetime.now()
|
||||
}
|
||||
|
||||
def _calculate_price_change(self, h1_data: pd.DataFrame) -> str:
|
||||
"""计算24小时价格变化"""
|
||||
if len(h1_data) < 24:
|
||||
@ -383,96 +330,56 @@ class CryptoAgent:
|
||||
for interval in required_intervals:
|
||||
if interval not in data or data[interval].empty:
|
||||
return False
|
||||
if len(data[interval]) < 20: # 至少需要20条数据
|
||||
if len(data[interval]) < 20:
|
||||
return False
|
||||
return True
|
||||
|
||||
def _should_send_signal(self, symbol: str, signal: Dict[str, Any]) -> bool:
|
||||
"""
|
||||
判断是否应该发送信号
|
||||
|
||||
Args:
|
||||
symbol: 交易对
|
||||
signal: 信号数据
|
||||
|
||||
Returns:
|
||||
是否发送
|
||||
"""
|
||||
# 如果是观望,不发送
|
||||
if signal['action'] == 'hold':
|
||||
"""判断是否应该发送信号"""
|
||||
action = signal.get('action', 'wait')
|
||||
if action == 'wait':
|
||||
return False
|
||||
|
||||
# 置信度太低,不发送
|
||||
if signal['confidence'] < 50:
|
||||
confidence = signal.get('confidence', 0)
|
||||
if confidence < 50:
|
||||
return False
|
||||
|
||||
# 检查冷却时间(同一交易对30分钟内不重复发送相同方向的信号)
|
||||
# 检查冷却时间(30分钟内不重复发送相同方向的信号)
|
||||
if symbol in self.signal_cooldown:
|
||||
cooldown_end = self.signal_cooldown[symbol] + timedelta(minutes=30)
|
||||
if datetime.now() < cooldown_end:
|
||||
# 检查是否是相同方向的信号
|
||||
if symbol in self.last_signals:
|
||||
if self.last_signals[symbol]['action'] == signal['action']:
|
||||
if self.last_signals[symbol].get('action') == action:
|
||||
logger.debug(f"{symbol} 信号冷却中,跳过")
|
||||
return False
|
||||
|
||||
return True
|
||||
|
||||
async def _handle_trend_change(self, symbol: str, old_trend: str, new_trend: str,
|
||||
data: Dict[str, pd.DataFrame]):
|
||||
"""处理趋势变化"""
|
||||
price = float(data['1h'].iloc[-1]['close'])
|
||||
await self.feishu.send_trend_change(symbol, old_trend, new_trend, price)
|
||||
await self.telegram.send_trend_change(symbol, old_trend, new_trend, price)
|
||||
logger.info(f"{symbol} 趋势变化: {old_trend} -> {new_trend}")
|
||||
|
||||
async def analyze_once(self, symbol: str) -> Dict[str, Any]:
|
||||
"""
|
||||
单次分析(用于测试或手动触发)
|
||||
|
||||
Args:
|
||||
symbol: 交易对
|
||||
|
||||
Returns:
|
||||
分析结果
|
||||
"""
|
||||
"""单次分析(用于测试或手动触发)"""
|
||||
data = self.binance.get_multi_timeframe_data(symbol)
|
||||
|
||||
if not self._validate_data(data):
|
||||
return {'error': '数据不完整'}
|
||||
|
||||
trend = self.analyzer.analyze_trend(data['1h'], data['4h'])
|
||||
signal = self.analyzer.analyze_entry_signal(data['5m'], data['15m'], trend)
|
||||
|
||||
signal['symbol'] = symbol
|
||||
signal['trend'] = trend
|
||||
signal['price'] = float(data['5m'].iloc[-1]['close'])
|
||||
|
||||
# 计算止损止盈
|
||||
atr = float(data['15m'].iloc[-1].get('atr', 0))
|
||||
if atr > 0:
|
||||
sl_tp = self.analyzer.calculate_stop_loss_take_profit(
|
||||
signal['price'], signal['action'], atr
|
||||
)
|
||||
signal.update(sl_tp)
|
||||
|
||||
return signal
|
||||
result = await self.llm_analyzer.analyze(symbol, data, symbols=self.symbols)
|
||||
return result
|
||||
|
||||
def get_status(self) -> Dict[str, Any]:
|
||||
"""获取智能体状态"""
|
||||
return {
|
||||
'running': self.running,
|
||||
'symbols': self.symbols,
|
||||
'analysis_interval': self.analysis_interval,
|
||||
'mode': 'LLM 驱动',
|
||||
'last_signals': {
|
||||
symbol: {
|
||||
'type': sig.get('type'),
|
||||
'action': sig.get('action'),
|
||||
'confidence': sig.get('confidence'),
|
||||
'timestamp': sig.get('timestamp').isoformat() if sig.get('timestamp') else None
|
||||
'grade': sig.get('grade')
|
||||
}
|
||||
for symbol, sig in self.last_signals.items()
|
||||
},
|
||||
'last_trends': self.last_trends
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
443
backend/app/crypto_agent/llm_signal_analyzer.py
Normal file
443
backend/app/crypto_agent/llm_signal_analyzer.py
Normal file
@ -0,0 +1,443 @@
|
||||
"""
|
||||
LLM 驱动的信号分析器 - 让 LLM 自主分析市场数据并给出交易信号
|
||||
"""
|
||||
import json
|
||||
import re
|
||||
import pandas as pd
|
||||
from typing import Dict, Any, Optional, List
|
||||
from datetime import datetime
|
||||
from app.utils.logger import logger
|
||||
from app.services.llm_service import llm_service
|
||||
from app.services.news_service import get_news_service
|
||||
|
||||
|
||||
class LLMSignalAnalyzer:
|
||||
"""LLM 驱动的交易信号分析器"""
|
||||
|
||||
# 系统提示词 - 让 LLM 自主分析
|
||||
SYSTEM_PROMPT = """你是一位专业的加密货币技术分析师。你的任务是综合分析市场数据和新闻舆情,判断是否存在交易机会。
|
||||
|
||||
## 你的分析方法
|
||||
你可以自由运用你所知道的任何技术分析方法,包括但不限于:
|
||||
- 趋势分析(均线、趋势线、高低点)
|
||||
- 动量指标(RSI、MACD、KDJ 等)
|
||||
- 波动率分析(布林带、ATR)
|
||||
- 价格形态(K线形态、图表形态)
|
||||
- 支撑阻力位
|
||||
- 成交量分析
|
||||
- 多周期共振
|
||||
|
||||
## 新闻舆情分析
|
||||
你还需要结合最新的市场新闻进行分析:
|
||||
- 重大利好/利空消息
|
||||
- 市场情绪(恐慌/贪婪)
|
||||
- 大户/机构动向
|
||||
- 监管政策变化
|
||||
- 宏观经济影响
|
||||
|
||||
## 信号类型
|
||||
请判断是否存在以下三种类型的交易机会:
|
||||
|
||||
1. **短线信号**(持仓 4小时 - 1天)
|
||||
- 适合快速的超跌反弹或超涨回落
|
||||
- 风险较高,需要快速止盈止损
|
||||
|
||||
2. **中线信号**(持仓 1-7 天)
|
||||
- 波段交易,顺势回调入场
|
||||
- 风险适中,有明确的止损止盈
|
||||
|
||||
3. **长线信号**(持仓 1周以上)
|
||||
- 趋势交易,大级别趋势确认
|
||||
- 风险较低,止损较宽
|
||||
|
||||
## 输出格式
|
||||
请严格按照以下 JSON 格式输出你的分析结果:
|
||||
|
||||
```json
|
||||
{
|
||||
"analysis_summary": "简要描述当前市场状态(50字以内)",
|
||||
"news_sentiment": "positive/negative/neutral",
|
||||
"news_impact": "新闻对市场的影响分析(30字以内)",
|
||||
"signals": [
|
||||
{
|
||||
"type": "short_term/medium_term/long_term",
|
||||
"action": "buy/sell/wait",
|
||||
"confidence": 0-100,
|
||||
"grade": "A/B/C/D",
|
||||
"entry_price": 建议入场价,
|
||||
"stop_loss": 止损价,
|
||||
"take_profit": 止盈价,
|
||||
"reason": "详细的入场理由,说明你看到了什么技术信号和消息面因素",
|
||||
"risk_warning": "风险提示"
|
||||
}
|
||||
],
|
||||
"key_levels": {
|
||||
"support": [支撑位列表],
|
||||
"resistance": [阻力位列表]
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## 信号等级说明
|
||||
- **A级**:技术面+消息面共振,高置信度(80+),强烈建议入场
|
||||
- **B级**:信号较好,置信度中等(60-80),可以入场
|
||||
- **C级**:有机会但需谨慎(40-60),轻仓试探
|
||||
- **D级**:不建议交易(<40),继续观望
|
||||
|
||||
## 重要原则
|
||||
1. 宁可错过,不要做错 - 没有明确信号时输出空的 signals 数组
|
||||
2. 每种类型最多输出一个信号
|
||||
3. 止损必须明确,风险收益比至少 1:1.5
|
||||
4. 如果市场混乱或数据不足,直接建议观望
|
||||
5. reason 字段要具体说明你看到了什么(如"15M RSI 从 25 回升到 35,同时 MACD 金叉,且有大户加仓消息")
|
||||
6. 消息面和技术面冲突时,优先考虑技术面,但要在 risk_warning 中提示"""
|
||||
|
||||
def __init__(self):
|
||||
"""初始化分析器"""
|
||||
self.news_service = get_news_service()
|
||||
logger.info("LLM 信号分析器初始化完成(含新闻舆情)")
|
||||
|
||||
async def analyze(self, symbol: str, data: Dict[str, pd.DataFrame],
|
||||
symbols: List[str] = None) -> Dict[str, Any]:
|
||||
"""
|
||||
使用 LLM 分析市场数据
|
||||
|
||||
Args:
|
||||
symbol: 交易对,如 'BTCUSDT'
|
||||
data: 多周期K线数据 {'5m': df, '15m': df, '1h': df, '4h': df}
|
||||
symbols: 所有监控的交易对(用于过滤相关新闻)
|
||||
|
||||
Returns:
|
||||
分析结果
|
||||
"""
|
||||
try:
|
||||
# 获取新闻数据
|
||||
news_text = await self._get_news_context(symbol, symbols or [symbol])
|
||||
|
||||
# 构建数据提示
|
||||
data_prompt = self._build_data_prompt(symbol, data, news_text)
|
||||
|
||||
# 调用 LLM
|
||||
response = llm_service.chat([
|
||||
{"role": "system", "content": self.SYSTEM_PROMPT},
|
||||
{"role": "user", "content": data_prompt}
|
||||
])
|
||||
|
||||
if not response:
|
||||
logger.warning(f"{symbol} LLM 分析无响应")
|
||||
return self._empty_result(symbol, "LLM 无响应")
|
||||
|
||||
# 解析响应
|
||||
result = self._parse_response(response)
|
||||
result['symbol'] = symbol
|
||||
result['timestamp'] = datetime.now().isoformat()
|
||||
|
||||
# 记录日志
|
||||
signals = result.get('signals', [])
|
||||
if signals:
|
||||
for sig in signals:
|
||||
logger.info(f"{symbol} [{sig['type']}] {sig['action']} "
|
||||
f"置信度:{sig['confidence']}% 等级:{sig['grade']} "
|
||||
f"原因:{sig['reason'][:50]}...")
|
||||
else:
|
||||
logger.info(f"{symbol} 无交易信号 - {result.get('analysis_summary', '观望')}")
|
||||
|
||||
return result
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"{symbol} LLM 分析出错: {e}")
|
||||
import traceback
|
||||
logger.error(traceback.format_exc())
|
||||
return self._empty_result(symbol, str(e))
|
||||
|
||||
async def _get_news_context(self, symbol: str, symbols: List[str]) -> str:
|
||||
"""获取新闻上下文"""
|
||||
try:
|
||||
# 获取最新新闻
|
||||
all_news = await self.news_service.get_latest_news(limit=30)
|
||||
|
||||
# 过滤相关新闻(最近4小时)
|
||||
relevant_news = self.news_service.filter_relevant_news(
|
||||
all_news, symbols=symbols, hours=4
|
||||
)
|
||||
|
||||
if not relevant_news:
|
||||
return "暂无相关新闻"
|
||||
|
||||
# 格式化新闻
|
||||
return self.news_service.format_news_for_llm(relevant_news, max_items=8)
|
||||
|
||||
except Exception as e:
|
||||
logger.warning(f"获取新闻失败: {e}")
|
||||
return "新闻数据暂时不可用"
|
||||
|
||||
def _build_data_prompt(self, symbol: str, data: Dict[str, pd.DataFrame],
|
||||
news_text: str = "") -> str:
|
||||
"""构建数据提示词"""
|
||||
parts = [f"# {symbol} 市场数据分析\n"]
|
||||
parts.append(f"分析时间: {datetime.now().strftime('%Y-%m-%d %H:%M:%S')}\n")
|
||||
|
||||
# 当前价格
|
||||
if '5m' in data and not data['5m'].empty:
|
||||
current_price = float(data['5m'].iloc[-1]['close'])
|
||||
parts.append(f"**当前价格**: ${current_price:,.2f}\n")
|
||||
|
||||
# 各周期数据
|
||||
for interval in ['4h', '1h', '15m', '5m']:
|
||||
df = data.get(interval)
|
||||
if df is None or df.empty:
|
||||
continue
|
||||
|
||||
parts.append(f"\n## {interval.upper()} 周期数据")
|
||||
|
||||
# 最新指标
|
||||
latest = df.iloc[-1]
|
||||
parts.append(self._format_indicators(latest))
|
||||
|
||||
# 最近 K 线数据
|
||||
parts.append(self._format_recent_klines(df, interval))
|
||||
|
||||
# 添加新闻数据
|
||||
if news_text and news_text != "暂无相关新闻":
|
||||
parts.append(f"\n{news_text}")
|
||||
|
||||
parts.append("\n---")
|
||||
parts.append("请综合分析以上技术数据和新闻舆情,判断是否存在短线、中线或长线的交易机会。")
|
||||
parts.append("如果没有明确的交易机会,signals 数组返回空即可。")
|
||||
|
||||
return "\n".join(parts)
|
||||
|
||||
def _format_indicators(self, row: pd.Series) -> str:
|
||||
"""格式化指标数据"""
|
||||
lines = []
|
||||
|
||||
# 价格
|
||||
close = row.get('close', 0)
|
||||
open_price = row.get('open', 0)
|
||||
high = row.get('high', 0)
|
||||
low = row.get('low', 0)
|
||||
change = ((close - open_price) / open_price * 100) if open_price else 0
|
||||
lines.append(f"- K线: O={open_price:.2f} H={high:.2f} L={low:.2f} C={close:.2f} ({change:+.2f}%)")
|
||||
|
||||
# 均线
|
||||
ma5 = row.get('ma5', 0)
|
||||
ma10 = row.get('ma10', 0)
|
||||
ma20 = row.get('ma20', 0)
|
||||
ma50 = row.get('ma50', 0)
|
||||
if pd.notna(ma20):
|
||||
ma_str = f"- 均线: MA5={ma5:.2f}, MA10={ma10:.2f}, MA20={ma20:.2f}"
|
||||
if pd.notna(ma50):
|
||||
ma_str += f", MA50={ma50:.2f}"
|
||||
lines.append(ma_str)
|
||||
|
||||
# RSI
|
||||
rsi = row.get('rsi', 0)
|
||||
if pd.notna(rsi):
|
||||
rsi_status = "超卖" if rsi < 30 else ("超买" if rsi > 70 else "中性")
|
||||
lines.append(f"- RSI: {rsi:.1f} ({rsi_status})")
|
||||
|
||||
# MACD
|
||||
macd = row.get('macd', 0)
|
||||
macd_signal = row.get('macd_signal', 0)
|
||||
macd_hist = row.get('macd_hist', 0)
|
||||
if pd.notna(macd):
|
||||
macd_status = "多头" if macd > macd_signal else "空头"
|
||||
lines.append(f"- MACD: DIF={macd:.4f}, DEA={macd_signal:.4f}, 柱={macd_hist:.4f} ({macd_status})")
|
||||
|
||||
# KDJ
|
||||
k = row.get('k', 0)
|
||||
d = row.get('d', 0)
|
||||
j = row.get('j', 0)
|
||||
if pd.notna(k):
|
||||
lines.append(f"- KDJ: K={k:.1f}, D={d:.1f}, J={j:.1f}")
|
||||
|
||||
# 布林带
|
||||
bb_upper = row.get('bb_upper', 0)
|
||||
bb_middle = row.get('bb_middle', 0)
|
||||
bb_lower = row.get('bb_lower', 0)
|
||||
if pd.notna(bb_upper):
|
||||
lines.append(f"- 布林带: 上={bb_upper:.2f}, 中={bb_middle:.2f}, 下={bb_lower:.2f}")
|
||||
|
||||
# ATR
|
||||
atr = row.get('atr', 0)
|
||||
if pd.notna(atr):
|
||||
lines.append(f"- ATR: {atr:.2f}")
|
||||
|
||||
# 成交量
|
||||
volume = row.get('volume', 0)
|
||||
volume_ratio = row.get('volume_ratio', 0)
|
||||
if pd.notna(volume_ratio):
|
||||
vol_status = "放量" if volume_ratio > 1.5 else ("缩量" if volume_ratio < 0.5 else "正常")
|
||||
lines.append(f"- 成交量: {volume:.2f}, 量比={volume_ratio:.2f} ({vol_status})")
|
||||
|
||||
return "\n".join(lines)
|
||||
|
||||
def _format_recent_klines(self, df: pd.DataFrame, interval: str) -> str:
|
||||
"""格式化最近 K 线"""
|
||||
# 根据周期决定显示数量
|
||||
count = {'4h': 6, '1h': 12, '15m': 8, '5m': 6}.get(interval, 6)
|
||||
|
||||
if len(df) < count:
|
||||
count = len(df)
|
||||
|
||||
lines = [f"\n最近 {count} 根 K 线:"]
|
||||
lines.append("| 时间 | 开盘 | 最高 | 最低 | 收盘 | 涨跌 | RSI |")
|
||||
lines.append("|------|------|------|------|------|------|-----|")
|
||||
|
||||
for i in range(-count, 0):
|
||||
row = df.iloc[i]
|
||||
change = ((row['close'] - row['open']) / row['open'] * 100) if row['open'] else 0
|
||||
change_str = f"{change:+.2f}%"
|
||||
time_str = row['open_time'].strftime('%m-%d %H:%M') if pd.notna(row.get('open_time')) else 'N/A'
|
||||
rsi = row.get('rsi', 0)
|
||||
rsi_str = f"{rsi:.0f}" if pd.notna(rsi) else "-"
|
||||
lines.append(f"| {time_str} | {row['open']:.2f} | {row['high']:.2f} | "
|
||||
f"{row['low']:.2f} | {row['close']:.2f} | {change_str} | {rsi_str} |")
|
||||
|
||||
return "\n".join(lines)
|
||||
|
||||
def _parse_response(self, response: str) -> Dict[str, Any]:
|
||||
"""解析 LLM 响应"""
|
||||
result = {
|
||||
'raw_response': response,
|
||||
'analysis_summary': '',
|
||||
'signals': [],
|
||||
'key_levels': {'support': [], 'resistance': []}
|
||||
}
|
||||
|
||||
try:
|
||||
# 尝试提取 JSON
|
||||
json_match = re.search(r'```json\s*([\s\S]*?)\s*```', response)
|
||||
if json_match:
|
||||
json_str = json_match.group(1)
|
||||
else:
|
||||
# 尝试直接解析
|
||||
json_str = response
|
||||
|
||||
parsed = json.loads(json_str)
|
||||
|
||||
result['analysis_summary'] = parsed.get('analysis_summary', '')
|
||||
result['signals'] = parsed.get('signals', [])
|
||||
result['key_levels'] = parsed.get('key_levels', {'support': [], 'resistance': []})
|
||||
|
||||
# 验证和清理信号
|
||||
valid_signals = []
|
||||
for sig in result['signals']:
|
||||
if self._validate_signal(sig):
|
||||
valid_signals.append(sig)
|
||||
result['signals'] = valid_signals
|
||||
|
||||
except json.JSONDecodeError:
|
||||
logger.warning("LLM 响应不是有效 JSON,尝试提取关键信息")
|
||||
result['analysis_summary'] = self._extract_summary(response)
|
||||
|
||||
return result
|
||||
|
||||
def _validate_signal(self, signal: Dict[str, Any]) -> bool:
|
||||
"""验证信号是否有效"""
|
||||
required_fields = ['type', 'action', 'confidence', 'grade', 'reason']
|
||||
for field in required_fields:
|
||||
if field not in signal:
|
||||
return False
|
||||
|
||||
# 验证类型
|
||||
if signal['type'] not in ['short_term', 'medium_term', 'long_term']:
|
||||
return False
|
||||
|
||||
# 验证动作
|
||||
if signal['action'] not in ['buy', 'sell', 'wait']:
|
||||
return False
|
||||
|
||||
# wait 动作不算有效信号
|
||||
if signal['action'] == 'wait':
|
||||
return False
|
||||
|
||||
# 验证置信度
|
||||
confidence = signal.get('confidence', 0)
|
||||
if not isinstance(confidence, (int, float)) or confidence < 40:
|
||||
return False
|
||||
|
||||
return True
|
||||
|
||||
def _extract_summary(self, text: str) -> str:
|
||||
"""从文本中提取摘要"""
|
||||
text = text.strip()
|
||||
if len(text) > 100:
|
||||
return text[:100] + "..."
|
||||
return text
|
||||
|
||||
def _empty_result(self, symbol: str, reason: str = "") -> Dict[str, Any]:
|
||||
"""返回空结果"""
|
||||
return {
|
||||
'symbol': symbol,
|
||||
'timestamp': datetime.now().isoformat(),
|
||||
'analysis_summary': reason or '无法分析',
|
||||
'signals': [],
|
||||
'key_levels': {'support': [], 'resistance': []},
|
||||
'error': reason
|
||||
}
|
||||
|
||||
def get_best_signal(self, result: Dict[str, Any]) -> Optional[Dict[str, Any]]:
|
||||
"""
|
||||
从分析结果中获取最佳信号
|
||||
|
||||
Args:
|
||||
result: analyze() 的返回结果
|
||||
|
||||
Returns:
|
||||
最佳信号,如果没有则返回 None
|
||||
"""
|
||||
signals = result.get('signals', [])
|
||||
if not signals:
|
||||
return None
|
||||
|
||||
# 按置信度排序
|
||||
sorted_signals = sorted(signals, key=lambda x: x.get('confidence', 0), reverse=True)
|
||||
return sorted_signals[0]
|
||||
|
||||
def format_signal_message(self, signal: Dict[str, Any], symbol: str) -> str:
|
||||
"""
|
||||
格式化信号消息(用于通知)
|
||||
|
||||
Args:
|
||||
signal: 信号数据
|
||||
symbol: 交易对
|
||||
|
||||
Returns:
|
||||
格式化的消息文本
|
||||
"""
|
||||
type_map = {
|
||||
'short_term': '短线',
|
||||
'medium_term': '中线',
|
||||
'long_term': '长线'
|
||||
}
|
||||
action_map = {
|
||||
'buy': '做多',
|
||||
'sell': '做空'
|
||||
}
|
||||
|
||||
signal_type = type_map.get(signal['type'], signal['type'])
|
||||
action = action_map.get(signal['action'], signal['action'])
|
||||
grade = signal.get('grade', 'C')
|
||||
confidence = signal.get('confidence', 0)
|
||||
|
||||
# 等级图标
|
||||
grade_icon = {'A': '⭐⭐⭐', 'B': '⭐⭐', 'C': '⭐', 'D': ''}.get(grade, '')
|
||||
|
||||
message = f"""📊 {symbol} {signal_type}信号
|
||||
|
||||
方向: {action}
|
||||
等级: {grade} {grade_icon}
|
||||
置信度: {confidence}%
|
||||
|
||||
入场价: ${signal.get('entry_price', 0):,.2f}
|
||||
止损价: ${signal.get('stop_loss', 0):,.2f}
|
||||
止盈价: ${signal.get('take_profit', 0):,.2f}
|
||||
|
||||
📝 分析理由:
|
||||
{signal.get('reason', '无')}
|
||||
|
||||
⚠️ 风险提示:
|
||||
{signal.get('risk_warning', '请注意风险控制')}"""
|
||||
|
||||
return message
|
||||
272
backend/app/services/news_service.py
Normal file
272
backend/app/services/news_service.py
Normal file
@ -0,0 +1,272 @@
|
||||
"""
|
||||
新闻舆情服务 - 获取加密货币相关新闻
|
||||
"""
|
||||
import re
|
||||
import html
|
||||
import aiohttp
|
||||
import xml.etree.ElementTree as ET
|
||||
from typing import List, Dict, Any, Optional
|
||||
from datetime import datetime, timedelta
|
||||
from app.utils.logger import logger
|
||||
|
||||
|
||||
class NewsService:
|
||||
"""新闻舆情服务"""
|
||||
|
||||
# 律动快讯 RSS
|
||||
BLOCKBEATS_RSS = "https://api.theblockbeats.news/v2/rss/newsflash"
|
||||
|
||||
def __init__(self):
|
||||
"""初始化新闻服务"""
|
||||
self._cache: List[Dict[str, Any]] = []
|
||||
self._cache_time: Optional[datetime] = None
|
||||
self._cache_duration = timedelta(minutes=5) # 缓存5分钟
|
||||
logger.info("新闻舆情服务初始化完成")
|
||||
|
||||
async def get_latest_news(self, limit: int = 20) -> List[Dict[str, Any]]:
|
||||
"""
|
||||
获取最新新闻
|
||||
|
||||
Args:
|
||||
limit: 获取数量
|
||||
|
||||
Returns:
|
||||
新闻列表
|
||||
"""
|
||||
# 检查缓存
|
||||
if self._cache and self._cache_time:
|
||||
if datetime.now() - self._cache_time < self._cache_duration:
|
||||
return self._cache[:limit]
|
||||
|
||||
try:
|
||||
news = await self._fetch_blockbeats_news()
|
||||
self._cache = news
|
||||
self._cache_time = datetime.now()
|
||||
return news[:limit]
|
||||
except Exception as e:
|
||||
logger.error(f"获取新闻失败: {e}")
|
||||
return self._cache[:limit] if self._cache else []
|
||||
|
||||
async def _fetch_blockbeats_news(self) -> List[Dict[str, Any]]:
|
||||
"""获取律动快讯"""
|
||||
news_list = []
|
||||
|
||||
try:
|
||||
async with aiohttp.ClientSession() as session:
|
||||
async with session.get(self.BLOCKBEATS_RSS, timeout=10) as response:
|
||||
if response.status != 200:
|
||||
logger.error(f"获取律动快讯失败: HTTP {response.status}")
|
||||
return []
|
||||
|
||||
content = await response.text()
|
||||
|
||||
# 解析 XML
|
||||
root = ET.fromstring(content)
|
||||
channel = root.find('channel')
|
||||
|
||||
if channel is None:
|
||||
return []
|
||||
|
||||
for item in channel.findall('item'):
|
||||
title_elem = item.find('title')
|
||||
desc_elem = item.find('description')
|
||||
pub_date_elem = item.find('pubDate')
|
||||
link_elem = item.find('link')
|
||||
|
||||
if title_elem is None:
|
||||
continue
|
||||
|
||||
# 提取标题
|
||||
title = self._clean_cdata(title_elem.text or '')
|
||||
|
||||
# 提取描述(去除 HTML 标签)
|
||||
description = ''
|
||||
if desc_elem is not None and desc_elem.text:
|
||||
description = self._clean_html(self._clean_cdata(desc_elem.text))
|
||||
|
||||
# 解析时间
|
||||
pub_time = None
|
||||
if pub_date_elem is not None and pub_date_elem.text:
|
||||
pub_time = self._parse_rss_date(self._clean_cdata(pub_date_elem.text))
|
||||
|
||||
# 链接
|
||||
link = ''
|
||||
if link_elem is not None and link_elem.text:
|
||||
link = self._clean_cdata(link_elem.text)
|
||||
|
||||
news_list.append({
|
||||
'title': title,
|
||||
'description': description[:500], # 限制长度
|
||||
'time': pub_time,
|
||||
'time_str': pub_time.strftime('%m-%d %H:%M') if pub_time else '',
|
||||
'link': link,
|
||||
'source': '律动BlockBeats'
|
||||
})
|
||||
|
||||
logger.info(f"获取到 {len(news_list)} 条律动快讯")
|
||||
return news_list
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"解析律动快讯失败: {e}")
|
||||
return []
|
||||
|
||||
def _clean_cdata(self, text: str) -> str:
|
||||
"""清理 CDATA 标记"""
|
||||
if not text:
|
||||
return ''
|
||||
# 移除 CDATA 包装
|
||||
text = re.sub(r'<!\[CDATA\[(.*?)\]\]>', r'\1', text, flags=re.DOTALL)
|
||||
return text.strip()
|
||||
|
||||
def _clean_html(self, text: str) -> str:
|
||||
"""清理 HTML 标签"""
|
||||
if not text:
|
||||
return ''
|
||||
# 移除 HTML 标签
|
||||
text = re.sub(r'<[^>]+>', '', text)
|
||||
# 解码 HTML 实体
|
||||
text = html.unescape(text)
|
||||
# 清理多余空白
|
||||
text = re.sub(r'\s+', ' ', text)
|
||||
return text.strip()
|
||||
|
||||
def _parse_rss_date(self, date_str: str) -> Optional[datetime]:
|
||||
"""解析 RSS 日期格式"""
|
||||
if not date_str:
|
||||
return None
|
||||
|
||||
# RSS 日期格式: "Sat, 07 Feb 2026 00:30:33 +0800"
|
||||
formats = [
|
||||
'%a, %d %b %Y %H:%M:%S %z',
|
||||
'%a, %d %b %Y %H:%M:%S',
|
||||
'%Y-%m-%d %H:%M:%S'
|
||||
]
|
||||
|
||||
for fmt in formats:
|
||||
try:
|
||||
return datetime.strptime(date_str, fmt)
|
||||
except ValueError:
|
||||
continue
|
||||
|
||||
return None
|
||||
|
||||
def filter_relevant_news(self, news_list: List[Dict[str, Any]],
|
||||
symbols: List[str] = None,
|
||||
hours: int = 4) -> List[Dict[str, Any]]:
|
||||
"""
|
||||
过滤相关新闻
|
||||
|
||||
Args:
|
||||
news_list: 新闻列表
|
||||
symbols: 关注的交易对(如 ['BTCUSDT', 'ETHUSDT'])
|
||||
hours: 只保留最近几小时的新闻
|
||||
|
||||
Returns:
|
||||
过滤后的新闻
|
||||
"""
|
||||
if not news_list:
|
||||
return []
|
||||
|
||||
# 时间过滤
|
||||
cutoff_time = datetime.now() - timedelta(hours=hours)
|
||||
filtered = []
|
||||
|
||||
# 关键词映射
|
||||
symbol_keywords = {
|
||||
'BTCUSDT': ['比特币', 'BTC', 'Bitcoin'],
|
||||
'ETHUSDT': ['以太坊', 'ETH', 'Ethereum'],
|
||||
'BNBUSDT': ['BNB', 'Binance'],
|
||||
'SOLUSDT': ['SOL', 'Solana'],
|
||||
}
|
||||
|
||||
# 通用关键词(影响整体市场)
|
||||
market_keywords = [
|
||||
'市场', '行情', '反弹', '下跌', '暴跌', '暴涨', '清算',
|
||||
'资金费率', '多单', '空单', '杠杆', '爆仓',
|
||||
'美联储', 'Fed', '利率', '通胀',
|
||||
'监管', 'SEC', 'ETF',
|
||||
'鲸鱼', '巨鲸', '大户',
|
||||
'交易所', 'Binance', 'Coinbase'
|
||||
]
|
||||
|
||||
for news in news_list:
|
||||
# 时间过滤
|
||||
if news.get('time'):
|
||||
# 处理带时区的时间
|
||||
news_time = news['time']
|
||||
if news_time.tzinfo:
|
||||
news_time = news_time.replace(tzinfo=None)
|
||||
if news_time < cutoff_time:
|
||||
continue
|
||||
|
||||
title = news.get('title', '')
|
||||
desc = news.get('description', '')
|
||||
content = title + ' ' + desc
|
||||
|
||||
# 检查是否与关注的交易对相关
|
||||
is_relevant = False
|
||||
|
||||
if symbols:
|
||||
for symbol in symbols:
|
||||
keywords = symbol_keywords.get(symbol, [])
|
||||
for kw in keywords:
|
||||
if kw.lower() in content.lower():
|
||||
is_relevant = True
|
||||
news['related_symbol'] = symbol
|
||||
break
|
||||
if is_relevant:
|
||||
break
|
||||
|
||||
# 检查是否包含市场关键词
|
||||
if not is_relevant:
|
||||
for kw in market_keywords:
|
||||
if kw.lower() in content.lower():
|
||||
is_relevant = True
|
||||
news['related_symbol'] = 'MARKET'
|
||||
break
|
||||
|
||||
if is_relevant:
|
||||
filtered.append(news)
|
||||
|
||||
return filtered
|
||||
|
||||
def format_news_for_llm(self, news_list: List[Dict[str, Any]],
|
||||
max_items: int = 10) -> str:
|
||||
"""
|
||||
格式化新闻供 LLM 分析
|
||||
|
||||
Args:
|
||||
news_list: 新闻列表
|
||||
max_items: 最大条数
|
||||
|
||||
Returns:
|
||||
格式化的新闻文本
|
||||
"""
|
||||
if not news_list:
|
||||
return "暂无相关新闻"
|
||||
|
||||
lines = ["## 最新市场新闻\n"]
|
||||
|
||||
for i, news in enumerate(news_list[:max_items], 1):
|
||||
time_str = news.get('time_str', '')
|
||||
title = news.get('title', '')
|
||||
desc = news.get('description', '')[:200] # 限制描述长度
|
||||
|
||||
lines.append(f"### {i}. [{time_str}] {title}")
|
||||
if desc:
|
||||
lines.append(f"{desc}")
|
||||
lines.append("")
|
||||
|
||||
return "\n".join(lines)
|
||||
|
||||
|
||||
# 全局实例
|
||||
_news_service: Optional[NewsService] = None
|
||||
|
||||
|
||||
def get_news_service() -> NewsService:
|
||||
"""获取新闻服务实例"""
|
||||
global _news_service
|
||||
if _news_service is None:
|
||||
_news_service = NewsService()
|
||||
return _news_service
|
||||
@ -364,6 +364,74 @@
|
||||
border-color: #ff4444;
|
||||
color: #ff4444;
|
||||
}
|
||||
|
||||
/* 持仓汇总 */
|
||||
.position-summary {
|
||||
display: flex;
|
||||
gap: 24px;
|
||||
padding: 16px 20px;
|
||||
background: var(--bg-secondary);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 4px;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.summary-item {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.summary-label {
|
||||
font-size: 12px;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.summary-value {
|
||||
font-size: 18px;
|
||||
font-weight: 500;
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.summary-value.positive {
|
||||
color: #00ff41;
|
||||
}
|
||||
|
||||
.summary-value.negative {
|
||||
color: #ff4444;
|
||||
}
|
||||
|
||||
/* 现价样式 */
|
||||
.current-price {
|
||||
font-family: monospace;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.current-price.price-up {
|
||||
color: #00ff41;
|
||||
}
|
||||
|
||||
.current-price.price-down {
|
||||
color: #ff4444;
|
||||
}
|
||||
|
||||
/* 警告价格(接近止损) */
|
||||
.warning-price {
|
||||
color: #ff4444;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
/* 成功价格(接近止盈) */
|
||||
.success-price {
|
||||
color: #00ff41;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
/* 盈亏小字 */
|
||||
.pnl small {
|
||||
font-size: 11px;
|
||||
opacity: 0.8;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
@ -438,6 +506,25 @@
|
||||
|
||||
<!-- 活跃订单 -->
|
||||
<div v-if="activeTab === 'active'">
|
||||
<!-- 持仓汇总 -->
|
||||
<div v-if="activeOrders.length > 0" class="position-summary">
|
||||
<div class="summary-item">
|
||||
<span class="summary-label">持仓数量</span>
|
||||
<span class="summary-value">{{ activeOrders.length }}</span>
|
||||
</div>
|
||||
<div class="summary-item">
|
||||
<span class="summary-label">总仓位</span>
|
||||
<span class="summary-value">${{ totalPosition.toFixed(2) }}</span>
|
||||
</div>
|
||||
<div class="summary-item">
|
||||
<span class="summary-label">浮动盈亏</span>
|
||||
<span class="summary-value" :class="totalUnrealizedPnl >= 0 ? 'positive' : 'negative'">
|
||||
{{ totalUnrealizedPnl >= 0 ? '+' : '' }}${{ totalUnrealizedPnl.toFixed(2) }}
|
||||
({{ totalUnrealizedPnlPercent >= 0 ? '+' : '' }}{{ totalUnrealizedPnlPercent.toFixed(2) }}%)
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="loading" class="loading">加载中...</div>
|
||||
<div v-else-if="activeOrders.length === 0" class="empty-state">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5">
|
||||
@ -453,6 +540,8 @@
|
||||
<th>方向</th>
|
||||
<th>等级</th>
|
||||
<th>入场价</th>
|
||||
<th>现价</th>
|
||||
<th>浮动盈亏</th>
|
||||
<th>止损</th>
|
||||
<th>止盈</th>
|
||||
<th>仓位</th>
|
||||
@ -467,8 +556,28 @@
|
||||
<td><span class="side-badge" :class="order.side">{{ order.side === 'long' ? '做多' : '做空' }}</span></td>
|
||||
<td><span class="grade-badge" :class="order.signal_grade">{{ order.signal_grade }}</span></td>
|
||||
<td>${{ order.entry_price?.toLocaleString() }}</td>
|
||||
<td>${{ order.stop_loss?.toLocaleString() }}</td>
|
||||
<td>${{ order.take_profit?.toLocaleString() }}</td>
|
||||
<td>
|
||||
<span class="current-price" :class="getPriceChangeClass(order)">
|
||||
${{ getCurrentPrice(order.symbol)?.toLocaleString() || '-' }}
|
||||
</span>
|
||||
</td>
|
||||
<td>
|
||||
<span class="pnl" :class="getUnrealizedPnl(order).pnl >= 0 ? 'positive' : 'negative'">
|
||||
{{ getUnrealizedPnl(order).pnl >= 0 ? '+' : '' }}{{ getUnrealizedPnl(order).percent.toFixed(2) }}%
|
||||
<br>
|
||||
<small>(${{ getUnrealizedPnl(order).pnl >= 0 ? '+' : '' }}{{ getUnrealizedPnl(order).pnl.toFixed(2) }})</small>
|
||||
</span>
|
||||
</td>
|
||||
<td>
|
||||
<span :class="isNearStopLoss(order) ? 'warning-price' : ''">
|
||||
${{ order.stop_loss?.toLocaleString() }}
|
||||
</span>
|
||||
</td>
|
||||
<td>
|
||||
<span :class="isNearTakeProfit(order) ? 'success-price' : ''">
|
||||
${{ order.take_profit?.toLocaleString() }}
|
||||
</span>
|
||||
</td>
|
||||
<td>${{ order.quantity }}</td>
|
||||
<td>{{ formatTime(order.opened_at) }}</td>
|
||||
<td>
|
||||
@ -611,10 +720,10 @@
|
||||
},
|
||||
mounted() {
|
||||
this.refreshData();
|
||||
// 每10秒自动刷新
|
||||
// 每3秒自动刷新(实时价格更新)
|
||||
this.refreshInterval = setInterval(() => {
|
||||
this.refreshData();
|
||||
}, 10000);
|
||||
}, 3000);
|
||||
},
|
||||
beforeUnmount() {
|
||||
if (this.refreshInterval) {
|
||||
@ -719,6 +828,82 @@
|
||||
'closed_manual': '手动平仓'
|
||||
};
|
||||
return map[status] || status;
|
||||
},
|
||||
|
||||
// 获取当前价格
|
||||
getCurrentPrice(symbol) {
|
||||
return this.latestPrices[symbol] || null;
|
||||
},
|
||||
|
||||
// 计算未实现盈亏
|
||||
getUnrealizedPnl(order) {
|
||||
const currentPrice = this.getCurrentPrice(order.symbol);
|
||||
if (!currentPrice || !order.entry_price) {
|
||||
return { pnl: 0, percent: 0 };
|
||||
}
|
||||
|
||||
let pnlPercent;
|
||||
if (order.side === 'long') {
|
||||
pnlPercent = ((currentPrice - order.entry_price) / order.entry_price) * 100;
|
||||
} else {
|
||||
pnlPercent = ((order.entry_price - currentPrice) / order.entry_price) * 100;
|
||||
}
|
||||
|
||||
const pnlAmount = (order.quantity || 0) * pnlPercent / 100;
|
||||
|
||||
return {
|
||||
pnl: pnlAmount,
|
||||
percent: pnlPercent
|
||||
};
|
||||
},
|
||||
|
||||
// 获取价格变化样式类
|
||||
getPriceChangeClass(order) {
|
||||
const currentPrice = this.getCurrentPrice(order.symbol);
|
||||
if (!currentPrice || !order.entry_price) return '';
|
||||
|
||||
if (order.side === 'long') {
|
||||
return currentPrice >= order.entry_price ? 'price-up' : 'price-down';
|
||||
} else {
|
||||
return currentPrice <= order.entry_price ? 'price-up' : 'price-down';
|
||||
}
|
||||
},
|
||||
|
||||
// 检查是否接近止损(距离止损 < 1%)
|
||||
isNearStopLoss(order) {
|
||||
const currentPrice = this.getCurrentPrice(order.symbol);
|
||||
if (!currentPrice || !order.stop_loss) return false;
|
||||
|
||||
const distance = Math.abs(currentPrice - order.stop_loss) / currentPrice;
|
||||
return distance < 0.01;
|
||||
},
|
||||
|
||||
// 检查是否接近止盈(距离止盈 < 1%)
|
||||
isNearTakeProfit(order) {
|
||||
const currentPrice = this.getCurrentPrice(order.symbol);
|
||||
if (!currentPrice || !order.take_profit) return false;
|
||||
|
||||
const distance = Math.abs(currentPrice - order.take_profit) / currentPrice;
|
||||
return distance < 0.01;
|
||||
}
|
||||
},
|
||||
computed: {
|
||||
// 总仓位
|
||||
totalPosition() {
|
||||
return this.activeOrders.reduce((sum, order) => sum + (order.quantity || 0), 0);
|
||||
},
|
||||
|
||||
// 总浮动盈亏
|
||||
totalUnrealizedPnl() {
|
||||
return this.activeOrders.reduce((sum, order) => {
|
||||
return sum + this.getUnrealizedPnl(order).pnl;
|
||||
}, 0);
|
||||
},
|
||||
|
||||
// 总浮动盈亏百分比
|
||||
totalUnrealizedPnlPercent() {
|
||||
if (this.totalPosition === 0) return 0;
|
||||
return (this.totalUnrealizedPnl / this.totalPosition) * 100;
|
||||
}
|
||||
}
|
||||
}).mount('#app');
|
||||
|
||||
Loading…
Reference in New Issue
Block a user