From 0198dd5345b0b8c7e27f99a61aa88f269dc40091 Mon Sep 17 00:00:00 2001 From: aaron <> Date: Sat, 7 Feb 2026 00:51:58 +0800 Subject: [PATCH] update --- backend/app/crypto_agent/crypto_agent.py | 345 +++++--------- .../app/crypto_agent/llm_signal_analyzer.py | 443 ++++++++++++++++++ backend/app/services/news_service.py | 272 +++++++++++ frontend/paper-trading.html | 193 +++++++- 4 files changed, 1030 insertions(+), 223 deletions(-) create mode 100644 backend/app/crypto_agent/llm_signal_analyzer.py create mode 100644 backend/app/services/news_service.py diff --git a/backend/app/crypto_agent/crypto_agent.py b/backend/app/crypto_agent/crypto_agent.py index b2ea31a..18c5c70 100644 --- a/backend/app/crypto_agent/crypto_agent.py +++ b/backend/app/crypto_agent/crypto_agent.py @@ -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 + } } diff --git a/backend/app/crypto_agent/llm_signal_analyzer.py b/backend/app/crypto_agent/llm_signal_analyzer.py new file mode 100644 index 0000000..f65cf1d --- /dev/null +++ b/backend/app/crypto_agent/llm_signal_analyzer.py @@ -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 diff --git a/backend/app/services/news_service.py b/backend/app/services/news_service.py new file mode 100644 index 0000000..99769b1 --- /dev/null +++ b/backend/app/services/news_service.py @@ -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'', 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 diff --git a/frontend/paper-trading.html b/frontend/paper-trading.html index 1a51394..79f85dc 100644 --- a/frontend/paper-trading.html +++ b/frontend/paper-trading.html @@ -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; + } @@ -438,6 +506,25 @@
+ +
+
+ 持仓数量 + {{ activeOrders.length }} +
+
+ 总仓位 + ${{ totalPosition.toFixed(2) }} +
+
+ 浮动盈亏 + + {{ totalUnrealizedPnl >= 0 ? '+' : '' }}${{ totalUnrealizedPnl.toFixed(2) }} + ({{ totalUnrealizedPnlPercent >= 0 ? '+' : '' }}{{ totalUnrealizedPnlPercent.toFixed(2) }}%) + +
+
+
加载中...
@@ -453,6 +540,8 @@ 方向 等级 入场价 + 现价 + 浮动盈亏 止损 止盈 仓位 @@ -467,8 +556,28 @@ {{ order.side === 'long' ? '做多' : '做空' }} {{ order.signal_grade }} ${{ order.entry_price?.toLocaleString() }} - ${{ order.stop_loss?.toLocaleString() }} - ${{ order.take_profit?.toLocaleString() }} + + + ${{ getCurrentPrice(order.symbol)?.toLocaleString() || '-' }} + + + + + {{ getUnrealizedPnl(order).pnl >= 0 ? '+' : '' }}{{ getUnrealizedPnl(order).percent.toFixed(2) }}% +
+ (${{ getUnrealizedPnl(order).pnl >= 0 ? '+' : '' }}{{ getUnrealizedPnl(order).pnl.toFixed(2) }}) +
+ + + + ${{ order.stop_loss?.toLocaleString() }} + + + + + ${{ order.take_profit?.toLocaleString() }} + + ${{ order.quantity }} {{ formatTime(order.opened_at) }} @@ -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');