diff --git a/backend/app/crypto_agent/crypto_agent.py b/backend/app/crypto_agent/crypto_agent.py index 7e818d0..c48fa1f 100644 --- a/backend/app/crypto_agent/crypto_agent.py +++ b/backend/app/crypto_agent/crypto_agent.py @@ -11,7 +11,11 @@ import pandas as pd from app.utils.logger import logger from app.config import get_settings from app.services.bitget_service import bitget_service -from app.services.feishu_service import get_feishu_service, get_feishu_paper_trading_service +from app.services.feishu_service import ( + get_feishu_service, + get_feishu_paper_trading_service, + get_feishu_error_service, +) from app.services.telegram_service import get_telegram_service from app.services.dingtalk_service import get_dingtalk_service from app.services.paper_trading_service import get_paper_trading_service @@ -96,6 +100,12 @@ class CryptoAgent: 'long_term': 2.5, } + SIGNAL_MIN_EFFECTIVE_LEVERAGE = { + 'short_term': 4.0, + 'medium_term': 2.0, + 'long_term': 2.0, + } + SIGNAL_EXECUTION_RULES = { 'short_term': { 'min_add_price_gap_pct': 1.0, @@ -123,6 +133,11 @@ class CryptoAgent: }, } + TP_SL_RETRY_ALERT_THRESHOLD = 3 + TP_SL_MAX_RETRY_BEFORE_ERROR = 6 + TP_SL_ALERT_COOLDOWN_MINUTES = 15 + ANALYSIS_HEARTBEAT_INTERVAL_MINUTES = 60 + def __new__(cls, *args, **kwargs): """单例模式 - 确保只有一个实例""" if cls._instance is None: @@ -140,6 +155,7 @@ class CryptoAgent: self.exchange = bitget_service # 交易所服务 self.feishu = get_feishu_service() # 通用飞书服务(crypto等) self.feishu_paper = get_feishu_paper_trading_service() # 模拟交易专用飞书服务 + self.feishu_error = get_feishu_error_service() # 异常/风控专用飞书服务 self.telegram = get_telegram_service() self.dingtalk = get_dingtalk_service() # 添加钉钉服务 @@ -217,6 +233,11 @@ class CryptoAgent: "last_analysis_detail": "", "next_scheduled_run_at": None, } + self._analysis_notification_state: Dict[str, Any] = { + "last_signal_at": None, + "last_signal_symbol": None, + "last_heartbeat_notified_at": None, + } # 挂单 TP/SL 追踪:挂单成交后自动补设止盈止损 # key=order_id, value={symbol, is_long, size/contracts, tp_price, sl_price} @@ -282,6 +303,205 @@ class CryptoAgent: def _touch_analysis_heartbeat(self): self._analysis_monitor["last_heartbeat_at"] = datetime.now().isoformat() + def _parse_iso_datetime(self, value: Optional[str]) -> Optional[datetime]: + if not value: + return None + try: + return datetime.fromisoformat(str(value)) + except (TypeError, ValueError): + return None + + def _classify_execution_block_reason(self, + platform_name: str, + decision: Optional[Dict[str, Any]]) -> tuple[str, str]: + if self._is_platform_halted(platform_name): + halt_info = self._platform_halts.get(platform_name, {}) + halt_reason = halt_info.get("reason") or "平台已触发风控停机" + return "平台停机", halt_reason + + decision = decision or {} + decision_type = str(decision.get("decision", "")).upper() + reason = str(decision.get("reason") or decision.get("reasoning") or "未返回具体原因").strip() + if any(keyword in reason for keyword in ["无适配信号", "未匹配到信号", "未选中信号"]): + return "无适配信号", "该平台本轮没有匹配到可执行信号" + + if any(keyword in reason for keyword in ["账户余额无效", "余额不足", "可用余额不足", "保证金不足", "账户权益不足"]): + return "资金不足", reason + + if any(keyword in reason for keyword in ["有效杠杆", "最小保证金", "最小下单", "合约张数", "名义仓位", "下单数量不足", "仓位价值过小"]): + return "仓位不达标", reason + + if any(keyword in reason for keyword in ["同向持仓", "已有持仓", "无需重复开仓", "已有同向仓位"]): + return "已有同向仓位", reason + + if any(keyword in reason for keyword in ["挂单等待", "挂单中", "待成交", "已有挂单", "等待成交"]): + return "等待挂单成交", reason + + if any(keyword in reason for keyword in ["撤销反向挂单", "先撤销", "取消挂单", "撤单后再"]): + return "挂单切换中", reason + + if any(keyword in reason for keyword in ["风控", "回撤", "熔断", "风险控制", "止损停机"]): + return "风控拦截", reason + + if any(keyword in reason for keyword in ["未启用", "未初始化", "不可用"]): + return "平台不可用", reason + + if decision_type in {"CLOSE", "REDUCE"}: + return "平仓未执行", reason + if decision_type == "CANCEL_PENDING": + return "撤单未执行", reason + if decision_type in {"OPEN", "ADD"}: + return "未满足执行条件", reason + + return "未执行", reason + + async def _maybe_send_analysis_heartbeat(self): + if not self.settings.feishu_enabled: + return + + now = datetime.now() + interval = timedelta(minutes=self.ANALYSIS_HEARTBEAT_INTERVAL_MINUTES) + state = self._analysis_notification_state + last_heartbeat_notified_at = self._parse_iso_datetime(state.get("last_heartbeat_notified_at")) + if last_heartbeat_notified_at and now - last_heartbeat_notified_at < interval: + return + + last_signal_at = self._parse_iso_datetime(state.get("last_signal_at")) + if last_signal_at and now - last_signal_at < interval: + return + + window_start = now - interval + recent_events = [ + event for event in self._analysis_events + if self._parse_iso_datetime(event.get("timestamp")) and self._parse_iso_datetime(event.get("timestamp")) >= window_start + ] + if not recent_events: + return + + cycle_completed = sum(1 for event in recent_events if event.get("event_type") == "cycle_completed") + symbol_completed = sum(1 for event in recent_events if event.get("event_type") == "symbol_analysis_completed") + symbol_skipped = sum(1 for event in recent_events if event.get("event_type") == "symbol_analysis_skipped") + symbol_errors = sum(1 for event in recent_events if event.get("event_type") == "symbol_analysis_error") + valid_signal_total = sum(int(event.get("valid_signals", 0) or 0) for event in recent_events) + + if cycle_completed <= 0 or valid_signal_total > 0: + return + + last_cycle_completed_at = self._parse_iso_datetime(self._analysis_monitor.get("last_cycle_completed_at")) + if not last_cycle_completed_at or now - last_cycle_completed_at > interval: + return + + last_symbol = self._analysis_monitor.get("last_analysis_symbol") or "-" + last_status = self._analysis_monitor.get("last_analysis_status") or "unknown" + last_detail = self._analysis_monitor.get("last_analysis_detail") or "最近一轮分析已完成" + threshold = self.settings.crypto_llm_threshold * 100 + + title = "💓 [分析心跳] 系统运行正常" + content = "\n".join([ + f"最近 {self.ANALYSIS_HEARTBEAT_INTERVAL_MINUTES} 分钟持续完成市场分析,但暂无达到阈值的可执行信号。", + "", + f"**分析轮次**: {cycle_completed}", + f"**完成分析**: {symbol_completed} 个交易对", + f"**跳过分析**: {symbol_skipped} 次", + f"**分析异常**: {symbol_errors} 次", + f"**信号阈值**: {threshold:.0f}%", + f"**最近分析对象**: {last_symbol}", + f"**最近状态**: {last_status}", + f"**最近说明**: {last_detail}", + f"**最近完成时间**: {last_cycle_completed_at.strftime('%Y-%m-%d %H:%M:%S')}", + ]) + await self.feishu.send_card(title, content, "blue") + state["last_heartbeat_notified_at"] = now.isoformat() + self._record_analysis_event( + "heartbeat_notified", + status="info", + detail=f"已发送分析心跳通知,最近 {self.ANALYSIS_HEARTBEAT_INTERVAL_MINUTES} 分钟无有效信号", + extra={ + "window_minutes": self.ANALYSIS_HEARTBEAT_INTERVAL_MINUTES, + "last_signal_at": state.get("last_signal_at"), + "last_signal_symbol": state.get("last_signal_symbol"), + }, + ) + + def _build_pending_tp_sl_task(self, + symbol: str, + is_long: bool, + size: float, + tp_price: Optional[float], + sl_price: Optional[float], + order_status: Optional[str] = None, + has_real_order_id: bool = True, + retry_count: int = 0, + first_seen_at: Optional[str] = None, + last_alert_at: Optional[str] = None) -> Dict[str, Any]: + return { + "symbol": symbol, + "is_long": is_long, + "size": size, + "tp_price": tp_price, + "sl_price": sl_price, + "order_status": order_status, + "has_real_order_id": has_real_order_id, + "retry_count": retry_count, + "first_seen_at": first_seen_at or datetime.now().isoformat(), + "last_alert_at": last_alert_at, + } + + async def _maybe_alert_tp_sl_incomplete(self, + platform: str, + tracking_key: str, + task: Dict[str, Any], + reason: str, + force: bool = False): + now = datetime.now() + last_alert_at_raw = task.get("last_alert_at") + last_alert_at = None + if last_alert_at_raw: + try: + last_alert_at = datetime.fromisoformat(str(last_alert_at_raw)) + except ValueError: + last_alert_at = None + + should_alert = force + if not should_alert and task.get("retry_count", 0) >= self.TP_SL_RETRY_ALERT_THRESHOLD: + should_alert = ( + last_alert_at is None or + now - last_alert_at >= timedelta(minutes=self.TP_SL_ALERT_COOLDOWN_MINUTES) + ) + + severity = "error" if task.get("retry_count", 0) >= self.TP_SL_MAX_RETRY_BEFORE_ERROR else "warning" + self._record_execution_event( + platform, + "tp_sl_incomplete", + symbol=f"{task.get('symbol', '')}USDT", + reason=reason, + status=severity, + extra={ + "tracking_key": tracking_key, + "retry_count": task.get("retry_count", 0), + "missing_take_profit": task.get("tp_price") is not None, + "missing_stop_loss": task.get("sl_price") is not None, + }, + ) + + if should_alert: + missing_parts = [] + if task.get("tp_price") is not None: + missing_parts.append(f"TP={task.get('tp_price')}") + if task.get("sl_price") is not None: + missing_parts.append(f"SL={task.get('sl_price')}") + missing_desc = " / ".join(missing_parts) or "保护单缺失" + await self._send_alert_notification( + f"⚠️ [{platform}] 保护单不完整 - {task.get('symbol', '')}USDT", + "\n".join([ + f"追踪ID: {tracking_key}", + f"缺失项目: {missing_desc}", + f"重试次数: {task.get('retry_count', 0)}", + f"原因: {reason}", + ]) + ) + task["last_alert_at"] = now.isoformat() + def _record_analysis_event(self, event_type: str, symbol: str = "", @@ -620,6 +840,7 @@ class CryptoAgent: # 检查实盘挂单是否已成交,补设止盈止损 if self.hyperliquid: await self._check_and_set_pending_tp_sl_hyperliquid() + await self._check_hyperliquid_missing_tp_sl() if self.bitget: await self._check_and_set_pending_tp_sl_bitget() await self._check_bitget_missing_tp_sl() # 兜底:检查缺少的 TP/SL 并补救 @@ -638,6 +859,7 @@ class CryptoAgent: status="success", detail=f"本轮分析完成,共扫描 {len(self.symbols)} 个交易对", ) + await self._maybe_send_analysis_heartbeat() logger.info("\n" + "─" * 60) logger.info(f"✅ 本轮分析完成,共分析 {len(self.symbols)} 个交易对") logger.info("─" * 60 + "\n") @@ -1375,6 +1597,100 @@ class CryptoAgent: status="warning", ) + await self._notify_execution_summary_if_needed( + market_signal=market_signal, + current_price=current_price, + decisions={ + "PaperTrading": paper_decision, + "Hyperliquid": hyperliquid_decision, + "Bitget": bitget_decision, + }, + ) + + async def _notify_execution_summary_if_needed( + self, + market_signal: Dict[str, Any], + current_price: float, + decisions: Dict[str, Dict[str, Any]], + ): + """当存在可交易信号,但本轮所有平台都未真正执行时,仅发送一条汇总通知。""" + actionable: Dict[str, Dict[str, Any]] = {} + executed = False + + for platform_name, decision in (decisions or {}).items(): + if not decision: + continue + if decision.get('_execution_succeeded'): + executed = True + if decision.get('decision') in {'OPEN', 'ADD', 'CLOSE', 'CANCEL_PENDING'}: + actionable[platform_name] = decision + + if executed or not actionable: + return + + symbol = market_signal.get('symbol', '') + signal = self._get_best_signal_from_market(market_signal) + if not signal: + return + + confidence = signal.get('confidence', 0) + entry_type = signal.get('entry_type', 'market') + entry_price = signal.get('entry_price', current_price) + signal_timeframe = signal.get('timeframe', signal.get('type', 'unknown')) + timeframe_map = {'short_term': '短线', 'medium_term': '趋势', 'long_term': '长线'} + timeframe_text = timeframe_map.get(signal_timeframe, signal_timeframe) + action = signal.get('action', 'wait') + action_text = {'buy': '做多', 'sell': '做空', 'wait': '观望'}.get(action, action) + + title = f"[执行汇总] {symbol} 信号未落单" + content_parts = [ + f"**信号**: {action_text} | {timeframe_text} | 📈 **{confidence}%**", + "", + f"**入场方式**: {entry_type}", + f"**建议入场价**: ${entry_price:,.2f}" if isinstance(entry_price, (int, float)) else f"**建议入场价**: {entry_price}", + f"**当前价格**: ${current_price:,.2f}", + "", + "**平台结果**:", + ] + blocked_platforms: List[Dict[str, Any]] = [] + + for platform_name, decision in actionable.items(): + tag, detail = self._classify_execution_block_reason(platform_name, decision) + content_parts.append(f"- {platform_name}: **{tag}** | {detail}") + blocked_platforms.append({ + "platform": platform_name, + "tag": tag, + "detail": detail, + "decision": decision.get("decision"), + }) + + content = "\n".join(content_parts) + self._record_execution_event( + "SYSTEM", + "execution_blocked_summary", + symbol=symbol, + reason=f"{action_text} {timeframe_text} 信号未落单", + status="warning", + extra={ + "signal_action": action, + "signal_action_text": action_text, + "signal_timeframe": signal_timeframe, + "signal_timeframe_text": timeframe_text, + "confidence": confidence, + "entry_type": entry_type, + "entry_price": entry_price, + "current_price": current_price, + "blocked_platforms": blocked_platforms, + }, + ) + + if self.settings.feishu_enabled: + await self.feishu.send_card(title, content, "orange") + if self.settings.telegram_enabled: + await self.telegram.send_message(f"{title}\n\n{content}") + if self.settings.dingtalk_enabled: + await self.dingtalk.send_action_card(title, content) + async def _execute_paper_decisions(self, decision: Dict[str, Any], market_signal: Dict[str, Any], current_price: float): @@ -1405,12 +1721,12 @@ class CryptoAgent: if result.get('success'): order_id = result.get('order_id', 'unknown') logger.info(f" ✅ 交易成功: 订单ID {order_id}") + decision['_execution_succeeded'] = True self._record_execution_event( "PaperTrading", "open_success", decision=decision, status="success", reason=decision.get('reason', decision.get('reasoning', '')), extra={"order_id": order_id}, ) - await self._send_signal_notification(market_signal, decision, current_price) # TP/SL 警告 if result.get('tp_sl_warning'): @@ -1427,8 +1743,8 @@ class CryptoAgent: if result.get('success'): logger.info(f" ✅ 平仓成功") + decision['_execution_succeeded'] = True self._record_execution_event("PaperTrading", "close_success", decision=decision, status="success") - await self._send_signal_notification(market_signal, decision, current_price) if next_decision: await self._execute_paper_decisions(next_decision, market_signal, current_price) else: @@ -1450,11 +1766,11 @@ class CryptoAgent: if success_count > 0: logger.info(f" ✅ 成功取消 {success_count} 个挂单") + decision['_execution_succeeded'] = True self._record_execution_event( "PaperTrading", "cancel_success", decision=decision, status="success", extra={"cancelled_count": success_count}, ) - await self._send_signal_notification(market_signal, decision, current_price) if next_decision: await self._execute_paper_decisions(next_decision, market_signal, current_price) else: @@ -1908,6 +2224,8 @@ class CryptoAgent: # 根据配置发送通知 - [信号] 发送到 crypto webhook if self.settings.feishu_enabled: await self.feishu.send_card(title, content, color) + self._analysis_notification_state["last_signal_at"] = datetime.now().isoformat() + self._analysis_notification_state["last_signal_symbol"] = symbol if self.settings.telegram_enabled: # Telegram 使用文本格式 message = f"{title}\n\n{content}" @@ -2583,8 +2901,8 @@ class CryptoAgent: f"🕐 **时间**: {datetime.now().strftime('%Y-%m-%d %H:%M:%S')}", ]) logger.error(f"[Hyperliquid] {operation} 失败 | {symbol} | {error}") - if self.settings.feishu_enabled: - await self.feishu.send_card(title, content, "red") + if self.settings.feishu_enabled and self.feishu_error: + await self.feishu_error.send_card(title, content, "red") if self.settings.telegram_enabled: await self.telegram.send_message(f"{title}\n\n{content}") if self.settings.dingtalk_enabled: @@ -2599,8 +2917,8 @@ class CryptoAgent: f"🕐 **时间**: {datetime.now().strftime('%Y-%m-%d %H:%M:%S')}", ]) logger.error(f"[Bitget] {operation} 失败 | {symbol} | {error}") - if self.settings.feishu_enabled: - await self.feishu.send_card(title, content, "red") + if self.settings.feishu_enabled and self.feishu_error: + await self.feishu_error.send_card(title, content, "red") if self.settings.telegram_enabled: await self.telegram.send_message(f"{title}\n\n{content}") if self.settings.dingtalk_enabled: @@ -2735,6 +3053,7 @@ class CryptoAgent: current_leverage = account.get('current_total_leverage', 0) max_leverage = account.get('max_total_leverage', 10) order_leverage = account.get('order_leverage', 10) + min_effective_leverage = self.SIGNAL_MIN_EFFECTIVE_LEVERAGE.get(signal_type, 2.0) target_margin_pct, sizing_reason, _, _ = resolve_target_margin_pct( position_size=position_size, @@ -2773,6 +3092,7 @@ class CryptoAgent: target_margin_pct=target_margin_pct, max_margin_pct=max_margin_pct, min_margin=min_margin, + min_effective_leverage=min_effective_leverage, ) if margin <= 0: @@ -2780,6 +3100,7 @@ class CryptoAgent: return margin, ( f"{sizing_reason} | 平台: {platform_name} | " + f"最小有效杠杆 {min_effective_leverage:.1f}x | " f"限制后保证金 ${margin:.2f} ({budget_reason})" ) @@ -3258,6 +3579,7 @@ class CryptoAgent: order_id = result.get('order_id', 'unknown') order_status = result.get('order_status', 'filled') logger.info(f" ✅ Bitget 交易成功: {order_id} ({order_status})") + decision['_execution_succeeded'] = True self._record_execution_event( "Bitget", "open_success", decision=decision, status="success", reason=decision.get('reason', decision.get('reasoning', '')), @@ -3279,12 +3601,15 @@ class CryptoAgent: if result.get('pending_tp_sl'): order_id = result.get('order_id') if order_id: - self._bg_pending_tp_sl[order_id] = { - 'symbol': symbol, - 'is_long': decision.get('action') == 'buy', - 'contracts': result.get('contracts', 0), - **result['pending_tp_sl'] - } + signal_action = decision.get('signal_action', decision.get('action')) + pending_tp_sl = result.get('pending_tp_sl') or {} + self._bg_pending_tp_sl[order_id] = self._build_pending_tp_sl_task( + symbol=symbol, + is_long=signal_action == 'buy', + size=result.get('contracts', 0), + tp_price=pending_tp_sl.get('tp_price'), + sl_price=pending_tp_sl.get('sl_price'), + ) logger.info(f" 📌 已记录挂单 TP/SL (oid={order_id})") else: error = result.get('error', result.get('message', '未知错误')) @@ -3299,8 +3624,8 @@ class CryptoAgent: if result.get('success'): logger.info(f" ✅ Bitget 平仓成功") + decision['_execution_succeeded'] = True self._record_execution_event("Bitget", "close_success", decision=decision, status="success") - await self._send_signal_notification(market_signal, decision, current_price, prefix="[Bitget]") if next_decision: await self._execute_bitget_decisions(next_decision, market_signal, current_price) else: @@ -3325,6 +3650,7 @@ class CryptoAgent: if success_count > 0: logger.info(f" ✅ Bitget 取消成功: {success_count} 个挂单") + decision['_execution_succeeded'] = True self._record_execution_event( "Bitget", "cancel_success", decision=decision, status="success", extra={"cancelled_count": success_count}, @@ -3613,6 +3939,7 @@ class CryptoAgent: if result.get('success'): order_status = result.get('order_status', 'filled') logger.info(f" ✅ Hyperliquid 交易成功 ({order_status})") + decision['_execution_succeeded'] = True self._record_execution_event( "Hyperliquid", "open_success", decision=decision, status="success", reason=decision.get('reason', decision.get('reasoning', '')), @@ -3623,6 +3950,21 @@ class CryptoAgent: prefix="[Hyperliquid]", hl_order_status=order_status ) + if result.get('pending_tp_sl'): + order_id = str(result.get('order_id') or '') + pending_tp_sl = result.get('pending_tp_sl') or {} + tracking_key = order_id or f"{symbol}:{datetime.now().timestamp()}" + signal_action = decision.get('signal_action', decision.get('action')) + self._hl_pending_tp_sl[tracking_key] = self._build_pending_tp_sl_task( + symbol=symbol.replace('USDT', ''), + is_long=signal_action == 'buy', + size=result.get('position_size') or result.get('size') or 0, + tp_price=pending_tp_sl.get('tp_price'), + sl_price=pending_tp_sl.get('sl_price'), + order_status=order_status, + has_real_order_id=bool(order_id), + ) + logger.info(f" 📌 已记录 Hyperliquid TP/SL 待补设任务 (key={tracking_key})") if result.get('tp_sl_warning'): await self._notify_hyperliquid_error(symbol, "设置止盈止损", result['tp_sl_warning']) else: @@ -3636,8 +3978,8 @@ class CryptoAgent: result = await executor.execute_close(decision, current_price) if result.get('success'): logger.info(f" ✅ Hyperliquid 平仓成功") + decision['_execution_succeeded'] = True self._record_execution_event("Hyperliquid", "close_success", decision=decision, status="success") - await self._send_signal_notification(market_signal, decision, current_price, prefix="[Hyperliquid]") if next_decision: await self._execute_hyperliquid_decisions(next_decision, market_signal, current_price) else: @@ -3657,6 +3999,7 @@ class CryptoAgent: success_count += 1 if success_count > 0: logger.info(f" ✅ Hyperliquid 取消成功: {success_count} 个") + decision['_execution_succeeded'] = True self._record_execution_event( "Hyperliquid", "cancel_success", decision=decision, status="success", extra={"cancelled_count": success_count}, @@ -3835,24 +4178,73 @@ class CryptoAgent: try: for order_id, info in list(self._hl_pending_tp_sl.items()): symbol = info['symbol'] - open_orders = self.hyperliquid.get_open_orders(symbol) - still_open = any(str(o.get('order_id')) == order_id for o in open_orders) + has_real_order_id = info.get('has_real_order_id', True) + if has_real_order_id: + open_orders = self.hyperliquid.get_open_orders(symbol) + still_open = any(str(o.get('order_id')) == order_id for o in open_orders) + else: + still_open = info.get('order_status') == 'resting' if not still_open: # 订单已不在挂单列表 → 已成交,补设 TP/SL tp_price = info.get('tp_price') sl_price = info.get('sl_price') + position = self.hyperliquid.get_position_for_symbol(symbol) + if not position: + logger.info(f"[Hyperliquid] 挂单/持仓 {order_id} ({symbol}) 当前无持仓,跳过 TP/SL 补设") + del self._hl_pending_tp_sl[order_id] + continue + size = info.get('size') or abs(position.get('size', 0)) + if size <= 0: + logger.warning(f"[Hyperliquid] 挂单/持仓 {order_id} ({symbol}) 数量无效,跳过 TP/SL 补设") + del self._hl_pending_tp_sl[order_id] + continue logger.info(f"[Hyperliquid] 挂单 {order_id} ({symbol}) 已成交,补设 TP/SL...") tp_sl_result = self.hyperliquid.set_tp_sl( symbol=symbol, is_long=info['is_long'], - size=info['size'], + size=size, tp_price=tp_price, sl_price=sl_price, ) - if tp_sl_result.get('success'): + info['retry_count'] = int(info.get('retry_count', 0)) + 1 + tp_set = tp_sl_result.get('tp_set', False) + sl_set = tp_sl_result.get('sl_set', False) + if tp_set and sl_set: logger.info(f"[Hyperliquid] ✅ TP/SL 补设成功: {symbol} TP={tp_price} SL={sl_price}") + elif tp_set or sl_set: + missing_tp = tp_price if not tp_set else None + missing_sl = sl_price if not sl_set else None + self._hl_pending_tp_sl[order_id] = self._build_pending_tp_sl_task( + symbol=info['symbol'], + is_long=info['is_long'], + size=size, + tp_price=missing_tp, + sl_price=missing_sl, + order_status=info.get('order_status'), + has_real_order_id=info.get('has_real_order_id', True), + retry_count=info.get('retry_count', 0), + first_seen_at=info.get('first_seen_at'), + last_alert_at=info.get('last_alert_at'), + ) + set_text = "TP" if tp_set else "SL" + fail_text = "TP" if not tp_set else "SL" + logger.warning(f"[Hyperliquid] ⚠️ TP/SL 部分成功: {symbol} {set_text}已设, {fail_text}待下轮补设") + await self._maybe_alert_tp_sl_incomplete( + "Hyperliquid", + order_id, + self._hl_pending_tp_sl[order_id], + f"{set_text}已设,{fail_text}补设失败", + ) + continue else: - logger.warning(f"[Hyperliquid] ⚠️ TP/SL 补设失败: {tp_sl_result.get('error')}") + logger.warning(f"[Hyperliquid] ⚠️ TP/SL 补设失败: {tp_sl_result.get('errors') or tp_sl_result.get('error')}") + await self._maybe_alert_tp_sl_incomplete( + "Hyperliquid", + order_id, + info, + str(tp_sl_result.get('errors') or tp_sl_result.get('error') or 'TP/SL补设失败'), + ) + continue del self._hl_pending_tp_sl[order_id] except Exception as e: logger.error(f"[Hyperliquid] 检查挂单 TP/SL 补设异常: {e}") @@ -3876,10 +4268,11 @@ class CryptoAgent: tp_sl_result = self.bitget.set_tp_sl( symbol=symbol, is_long=info['is_long'], - size=info['contracts'], + size=info['size'], tp_price=tp_price, sl_price=sl_price, ) + info['retry_count'] = int(info.get('retry_count', 0)) + 1 tp_set = tp_sl_result.get('tp_set', False) sl_set = tp_sl_result.get('sl_set', False) @@ -3890,17 +4283,34 @@ class CryptoAgent: missing_tp = tp_price if not tp_set else None missing_sl = sl_price if not sl_set else None if missing_tp or missing_sl: - self._bg_pending_tp_sl[order_id] = { - **info, - 'tp_price': missing_tp, - 'sl_price': missing_sl, - } + self._bg_pending_tp_sl[order_id] = self._build_pending_tp_sl_task( + symbol=info['symbol'], + is_long=info['is_long'], + size=info['size'], + tp_price=missing_tp, + sl_price=missing_sl, + retry_count=info.get('retry_count', 0), + first_seen_at=info.get('first_seen_at'), + last_alert_at=info.get('last_alert_at'), + ) set_text = "TP" if tp_set else "SL" fail_text = "TP" if not tp_set else "SL" logger.warning(f"[Bitget] ⚠️ TP/SL 部分成功: {symbol} {set_text}已设, {fail_text}待下轮补设") + await self._maybe_alert_tp_sl_incomplete( + "Bitget", + order_id, + self._bg_pending_tp_sl[order_id], + f"{set_text}已设,{fail_text}补设失败", + ) continue # 不删除,下轮继续 else: logger.warning(f"[Bitget] ⚠️ TP/SL 补设失败: {tp_sl_result.get('errors')}") + await self._maybe_alert_tp_sl_incomplete( + "Bitget", + order_id, + info, + str(tp_sl_result.get('errors') or 'TP/SL补设失败'), + ) continue # 不删除,下轮继续重试 del self._bg_pending_tp_sl[order_id] @@ -3981,10 +4391,110 @@ class CryptoAgent: logger.info(f"[Bitget] ✅ 补救成功: {symbol} {' & '.join(set_parts)}") else: logger.warning(f"[Bitget] ⚠️ 补救失败: {tp_sl_result.get('errors')}") + await self._maybe_alert_tp_sl_incomplete( + "Bitget", + f"fallback:{symbol}", + self._build_pending_tp_sl_task( + symbol=coin, + is_long=pos.get('size', 0) > 0, + size=size, + tp_price=set_tp, + sl_price=set_sl, + retry_count=self.TP_SL_RETRY_ALERT_THRESHOLD, + ), + str(tp_sl_result.get('errors') or '兜底补设失败'), + force=True, + ) except Exception as e: logger.error(f"[Bitget] 止盈止损兜底检查异常: {e}") + async def _check_hyperliquid_missing_tp_sl(self): + """定时检查 Hyperliquid 持仓是否缺少止盈止损,缺少则从最新信号补救""" + if not self.hyperliquid: + return + try: + positions = self.hyperliquid.get_open_positions() + if not positions: + return + + for pos in positions: + symbol = pos.get('symbol', '') + if not symbol: + continue + + coin = symbol.replace('USDT', '') + tp_sl = self.hyperliquid.get_tp_sl_prices(coin) + has_tp = tp_sl.get('take_profit') is not None + has_sl = tp_sl.get('stop_loss') is not None + + if has_tp and has_sl: + continue + + latest_signal = self.signal_db.get_latest_signal('crypto', symbol) + if not latest_signal: + missing = ('止盈' if not has_tp else '') + ('/' if not has_tp and not has_sl else '') + ('止损' if not has_sl else '') + logger.warning(f"[Hyperliquid] ⚠️ {symbol} 缺少{missing},且无历史信号可补救") + continue + + tp_price = latest_signal.get('take_profit') + sl_price = latest_signal.get('stop_loss') + + if not tp_price and not sl_price: + logger.warning(f"[Hyperliquid] ⚠️ {symbol} 缺少止盈止损,最近信号也无 TP/SL") + continue + + set_tp = tp_price if not has_tp else None + set_sl = sl_price if not has_sl else None + + missing_parts = [] + if not has_tp: + missing_parts.append(f"TP={set_tp}") + if not has_sl: + missing_parts.append(f"SL={set_sl}") + logger.warning(f"[Hyperliquid] 🔧 {symbol} 缺少 {' & '.join(missing_parts)},从信号补救...") + + size = abs(pos.get('size', 0)) + if size <= 0: + continue + + tp_sl_result = self.hyperliquid.set_tp_sl( + symbol=coin, + is_long=pos.get('size', 0) > 0, + size=size, + tp_price=set_tp, + sl_price=set_sl, + ) + + tp_set = tp_sl_result.get('tp_set', False) + sl_set = tp_sl_result.get('sl_set', False) + if tp_set or sl_set: + set_parts = [] + if tp_set: + set_parts.append(f"TP={set_tp}") + if sl_set: + set_parts.append(f"SL={set_sl}") + logger.info(f"[Hyperliquid] ✅ 补救成功: {symbol} {' & '.join(set_parts)}") + else: + logger.warning(f"[Hyperliquid] ⚠️ 补救失败: {tp_sl_result.get('errors') or tp_sl_result.get('error')}") + await self._maybe_alert_tp_sl_incomplete( + "Hyperliquid", + f"fallback:{symbol}", + self._build_pending_tp_sl_task( + symbol=coin, + is_long=pos.get('size', 0) > 0, + size=size, + tp_price=set_tp, + sl_price=set_sl, + retry_count=self.TP_SL_RETRY_ALERT_THRESHOLD, + ), + str(tp_sl_result.get('errors') or tp_sl_result.get('error') or '兜底补设失败'), + force=True, + ) + + except Exception as e: + logger.error(f"[Hyperliquid] 止盈止损兜底检查异常: {e}") + def _calculate_hyperliquid_position_size(self, decision: Dict[str, Any], current_price: float) -> float: """ 计算 Hyperliquid 仓位大小(基于可用保证金和风控限制) @@ -4202,89 +4712,6 @@ class CryptoAgent: if self.settings.dingtalk_enabled: await self.dingtalk.send_action_card(title, content) - async def _notify_signal_not_executed( - self, - market_signal: Dict[str, Any], - decision: Dict[str, Any], - current_price: float, - reason: str = "", - prefix: str = "" - ): - """发送有信号但未执行交易的通知""" - try: - symbol = market_signal.get('symbol') - account_type = "📊" - title_prefix = f"{prefix} " if prefix else "" - - signal = self._get_signal_for_decision(market_signal, decision) - if not signal: - return - - confidence = signal.get('confidence', 0) - entry_type = signal.get('entry_type', 'market') - entry_price = signal.get('entry_price', current_price) - signal_timeframe = signal.get('timeframe', signal.get('type', 'unknown')) - timeframe_map = {'short_term': '短线', 'medium_term': '趋势', 'long_term': '长线'} - timeframe_text = timeframe_map.get(signal_timeframe, signal_timeframe) - - # 决策信息 - decision_type = decision.get('decision', 'HOLD') - decision_reason = decision.get('reason', '') - decision_reasoning = decision.get('reasoning', '') - - # 如果有外部传入的 reason(订单创建失败的具体原因),优先使用 - if reason: - final_reason = reason - elif decision_reason: - final_reason = decision_reason - elif decision_reasoning: - final_reason = decision_reasoning - else: - final_reason = "未知原因" - - # 方向图标 - action = signal.get('action', 'wait') - if action == 'buy': - action_icon = '🟢' - action_text = '做多' - elif action == 'sell': - action_icon = '🔴' - action_text = '做空' - else: - action_icon = '➖' - action_text = '观望' - - # 构建标题 - title = f"{title_prefix}{account_type} {symbol} 信号未执行" - - # 构建内容 - content_parts = [ - f"{action_icon} **信号**: {action_text} | {timeframe_text} | 📈 信心度: **{confidence}%**", - f"", - f"**入场方式**: {entry_type}", - f"**建议入场价**: ${entry_price:,.2f}" if isinstance(entry_price, (int, float)) else f"**建议入场价**: {entry_price}", - f"**当前价格**: ${current_price:,.2f}", - f"", - f"⚠️ **未执行原因**:", - f"{final_reason}", - ] - - content = "\n".join(content_parts) - - # 发送通知 - if self.settings.feishu_enabled: - await self.feishu.send_card(title, content, "orange") - if self.settings.telegram_enabled: - message = f"{title}\n\n{content}" - await self.telegram.send_message(message) - if self.settings.dingtalk_enabled: - await self.dingtalk.send_action_card(title, content) - - logger.info(f" 📤 已发送信号未执行通知: {decision_type} - {final_reason[:50]}") - - except Exception as e: - logger.warning(f"发送信号未执行通知失败: {e}") - async def analyze_once(self, symbol: str) -> Dict[str, Any]: """单次分析并返回市场信号与平台执行预览""" data = self.exchange.get_multi_timeframe_data(symbol) @@ -4365,6 +4792,7 @@ class CryptoAgent: 'mode': 'LLM 驱动', 'platform_halts': self.get_platform_halt_status(), 'analysis_monitor': self._analysis_monitor, + 'analysis_notifications': self._analysis_notification_state, 'last_signals': { symbol: { 'type': sig.get('type'), @@ -4826,6 +5254,19 @@ class CryptoAgent: } self._save_platform_halts() logger.warning(f"🛑 [{platform_name}] 已标记为平台熔断暂停") + if self.settings.feishu_enabled and self.feishu_error: + asyncio.create_task( + self.feishu_error.send_card( + f"🛑 [{platform_name}] 平台已停机", + "\n".join([ + f"**原因**: {reason}", + f"**回撤**: {drawdown_pct:.2f}%", + f"**当前权益**: ${current_balance:,.2f}", + f"**初始权益**: ${initial_balance:,.2f}", + ]), + "red", + ) + ) def get_platform_halt_status(self) -> Dict[str, Any]: result = {} @@ -4889,6 +5330,18 @@ class CryptoAgent: } self._save_platform_halts() logger.info(f"✅ [{platform_name}] 已手动恢复,初始权益重置为 ${current_balance:.2f}") + if self.settings.feishu_enabled and self.feishu_error: + asyncio.create_task( + self.feishu_error.send_card( + f"✅ [{platform_name}] 平台已恢复", + "\n".join([ + f"**当前权益**: ${current_balance:,.2f}", + f"**重置初始权益**: ${current_balance:,.2f}", + f"**恢复时间**: {datetime.now().strftime('%Y-%m-%d %H:%M:%S')}", + ]), + "green", + ) + ) return self._platform_halts[platform_name] # ==================== 初始余额持久化 ==================== @@ -4953,7 +5406,9 @@ class CryptoAgent: """发送告警通知(飞书/钉钉/Telegram)""" try: # 飞书 - if self.feishu: + if self.feishu_error: + await self.feishu_error.send_text(f"{title}\n\n{message}") + elif self.feishu: await self.feishu.send_text(f"{title}\n\n{message}") # 钉钉 diff --git a/backend/app/crypto_agent/executor/base_executor.py b/backend/app/crypto_agent/executor/base_executor.py index 371ee7d..f28af5a 100644 --- a/backend/app/crypto_agent/executor/base_executor.py +++ b/backend/app/crypto_agent/executor/base_executor.py @@ -12,6 +12,12 @@ from app.utils.logger import logger class BaseExecutor(ABC): """交易执行器基类""" + MIN_EFFECTIVE_LEVERAGE_BY_SIGNAL_TYPE = { + 'short_term': 4.0, + 'medium_term': 2.0, + 'long_term': 2.0, + } + def __init__(self, platform_name: str): self.platform_name = platform_name @@ -338,6 +344,30 @@ class BaseExecutor(ABC): return adjusted_margin + def get_min_effective_leverage(self, decision: Dict[str, Any]) -> float: + signal_type = decision.get('timeframe') or decision.get('type') or 'medium_term' + return float(self.MIN_EFFECTIVE_LEVERAGE_BY_SIGNAL_TYPE.get(signal_type, 2.0)) + + def validate_effective_leverage(self, + decision: Dict[str, Any], + margin: float, + actual_position_value: float) -> tuple[bool, str, float]: + if margin <= 0: + return False, "保证金无效", 0.0 + + effective_leverage = actual_position_value / margin if margin > 0 else 0.0 + min_effective_leverage = self.get_min_effective_leverage(decision) + + if effective_leverage + 1e-9 < min_effective_leverage: + signal_type = decision.get('timeframe') or decision.get('type') or 'medium_term' + return ( + False, + f"{signal_type} 实际有效杠杆 {effective_leverage:.2f}x < 最小要求 {min_effective_leverage:.1f}x", + effective_leverage, + ) + + return True, "", effective_leverage + @abstractmethod def get_fee_rate(self) -> float: """ @@ -775,4 +805,3 @@ class BaseExecutor(ABC): color = "green" if success else "red" await self.feishu.send_card(title, content, color) - diff --git a/backend/app/crypto_agent/executor/bitget_executor.py b/backend/app/crypto_agent/executor/bitget_executor.py index fb5df76..f110253 100644 --- a/backend/app/crypto_agent/executor/bitget_executor.py +++ b/backend/app/crypto_agent/executor/bitget_executor.py @@ -40,6 +40,12 @@ class BitgetExecutor(BaseExecutor): # 计算合约张数,必须与实际执行杠杆保持一致 contracts = self._calculate_contracts(symbol, adjusted_margin, entry_price, leverage) + actual_position_value = contracts * self.bitget.get_contract_size(symbol) * entry_price + leverage_ok, leverage_reason, effective_leverage = self.validate_effective_leverage( + decision, + adjusted_margin, + actual_position_value, + ) if contracts < 1: return { @@ -49,6 +55,12 @@ class BitgetExecutor(BaseExecutor): f'(保证金=${adjusted_margin:.2f}, 杠杆={leverage}x)' ) } + if not leverage_ok: + return { + 'success': False, + 'error': leverage_reason, + 'effective_leverage': effective_leverage, + } # 设置杠杆 self.bitget.update_leverage(symbol, leverage) @@ -128,21 +140,7 @@ class BitgetExecutor(BaseExecutor): logger.info(f" ✅ 开仓成功: {symbol} {contracts}张 @ ${order_type}") - # 发送飞书通知 - await self.send_execution_notification( - operation='OPEN', - symbol=symbol, - result=result, - details={ - 'size': contracts, - 'price': entry_price, - 'margin': adjusted_margin, - 'leverage': leverage, - 'stop_loss': stop_loss, - 'take_profit': take_profit, - 'order_type': order_type - } - ) + # 开仓成功通知由 crypto_agent 统一发送,避免与执行摘要重复 return result diff --git a/backend/app/crypto_agent/executor/hyperliquid_executor.py b/backend/app/crypto_agent/executor/hyperliquid_executor.py index 92c9900..c58dc65 100644 --- a/backend/app/crypto_agent/executor/hyperliquid_executor.py +++ b/backend/app/crypto_agent/executor/hyperliquid_executor.py @@ -46,12 +46,24 @@ class HyperliquidExecutor(BaseExecutor): # 计算仓位大小 position_size = self._calculate_position_size(symbol, adjusted_margin, entry_price, leverage) + actual_position_value = position_size * entry_price + leverage_ok, leverage_reason, effective_leverage = self.validate_effective_leverage( + decision, + adjusted_margin, + actual_position_value, + ) if position_size <= 0: return { 'success': False, 'error': f'仓位计算失败: {position_size}' } + if not leverage_ok: + return { + 'success': False, + 'error': leverage_reason, + 'effective_leverage': effective_leverage, + } # 设置杠杆 self.hyperliquid.update_leverage(symbol, leverage) @@ -103,38 +115,44 @@ class HyperliquidExecutor(BaseExecutor): if tp_set and sl_set: logger.info(f" ✅ 止盈止损已设置: TP={take_profit}, SL={stop_loss}") elif tp_set or sl_set: - # 部分成功:记录缺失侧 + # 部分成功:记录缺失侧,交给 agent 后续补设 set_text = "TP" if tp_set else "SL" fail_text = "TP" if not tp_set else "SL" + result['pending_tp_sl'] = { + 'tp_price': take_profit if not tp_set else None, + 'sl_price': stop_loss if not sl_set else None, + } + result['position_size'] = position_size logger.warning(f" ⚠️ 止盈止损部分成功: {set_text}已设, {fail_text}失败") result['tp_sl_warning'] = f"{fail_text}设置失败: {tp_sl_result.get('errors', [])}" else: errors = tp_sl_result.get('errors', []) + result['pending_tp_sl'] = { + 'tp_price': take_profit, + 'sl_price': stop_loss, + } + result['position_size'] = position_size logger.warning(f" ⚠️ 止盈止损设置失败: {errors}") result['tp_sl_warning'] = f"TP/SL设置失败: {'; '.join(errors)}" except Exception as tp_sl_err: logger.error(f" ⚠️ 止盈止损设置异常: {tp_sl_err}") + result['pending_tp_sl'] = { + 'tp_price': take_profit, + 'sl_price': stop_loss, + } + result['position_size'] = position_size result['tp_sl_warning'] = str(tp_sl_err) else: - # 限价单未成交,暂时跳过(等成交后再设) - logger.info(f" 📌 限价单待成交,TP/SL 将在成交后设置: TP={take_profit}, SL={stop_loss}") - result['tp_sl_warning'] = "限价单未成交,TP/SL 待成交后设置" + # 限价单未成交:记录下来,等成交后自动补设 + result['pending_tp_sl'] = { + 'tp_price': take_profit, + 'sl_price': stop_loss, + } + result['position_size'] = position_size + logger.info(f" 📌 限价单待成交,TP/SL 将在成交后自动设置: TP={take_profit}, SL={stop_loss}") + result['tp_sl_warning'] = "限价单未成交,TP/SL 已加入待补设列表" - # 发送飞书通知(在止盈止损之后,通知失败不影响交易结果) - await self.send_execution_notification( - operation='OPEN', - symbol=symbol, - result=result, - details={ - 'size': position_size, - 'price': entry_price, - 'margin': adjusted_margin, - 'leverage': leverage, - 'stop_loss': stop_loss, - 'take_profit': take_profit, - 'order_type': order_type - } - ) + # 开仓成功通知由 crypto_agent 统一发送,避免与执行摘要重复 return result @@ -228,10 +246,15 @@ class HyperliquidExecutor(BaseExecutor): position_size: float) -> Dict[str, Any]: """设置止盈止损""" try: - # Hyperliquid 的 TP/SL 设置方式可能需要查文档 - # 这里假设有类似的方法 + positions = self.hyperliquid.get_open_positions() + pos = next((p for p in positions if p.get('coin') == symbol.replace('USDT', '')), None) + + if not pos: + return {'success': False, 'message': f'找不到 {symbol} 的持仓'} + result = self.hyperliquid.set_tp_sl( symbol=symbol.replace('USDT', ''), + is_long=pos['size'] > 0, size=position_size, tp_price=take_profit, sl_price=stop_loss diff --git a/backend/app/crypto_agent/executor/paper_trading_executor.py b/backend/app/crypto_agent/executor/paper_trading_executor.py index e6dc46b..cefd11e 100644 --- a/backend/app/crypto_agent/executor/paper_trading_executor.py +++ b/backend/app/crypto_agent/executor/paper_trading_executor.py @@ -33,6 +33,18 @@ class PaperTradingExecutor(BaseExecutor): # 调整保证金(模拟盘无手续费) adjusted_margin = margin + actual_position_value = adjusted_margin * self.paper_trading.leverage + leverage_ok, leverage_reason, effective_leverage = self.validate_effective_leverage( + decision, + adjusted_margin, + actual_position_value, + ) + if not leverage_ok: + return { + 'success': False, + 'error': leverage_reason, + 'effective_leverage': effective_leverage, + } # 根据 confidence 推算信号等级 confidence = decision.get('confidence', 0) diff --git a/backend/app/services/feishu_service.py b/backend/app/services/feishu_service.py index efb6970..1485211 100644 --- a/backend/app/services/feishu_service.py +++ b/backend/app/services/feishu_service.py @@ -294,6 +294,7 @@ _feishu_crypto_service: Optional[FeishuService] = None _feishu_stock_service: Optional[FeishuService] = None _feishu_news_service: Optional[FeishuService] = None _feishu_paper_trading_service: Optional[FeishuService] = None +_feishu_error_service: Optional[FeishuService] = None def get_feishu_service() -> FeishuService: @@ -331,3 +332,11 @@ def get_feishu_paper_trading_service() -> FeishuService: if _feishu_paper_trading_service is None: _feishu_paper_trading_service = FeishuService(service_type="paper_trading") return _feishu_paper_trading_service + + +def get_feishu_error_service() -> FeishuService: + """获取系统异常飞书服务实例""" + global _feishu_error_service + if _feishu_error_service is None: + _feishu_error_service = FeishuService(service_type="error") + return _feishu_error_service diff --git a/backend/app/services/position_sizing.py b/backend/app/services/position_sizing.py index b19bcb6..c87ad74 100644 --- a/backend/app/services/position_sizing.py +++ b/backend/app/services/position_sizing.py @@ -115,6 +115,7 @@ def calculate_margin_and_position_value( target_margin_pct: float, max_margin_pct: float, min_margin: float = 0.0, + min_effective_leverage: float = 1.0, reserve_ratio: float = 0.05, ) -> Tuple[float, float, str]: if balance <= 0: @@ -123,6 +124,13 @@ def calculate_margin_and_position_value( return 0.0, 0.0, "可用保证金不足" if order_leverage <= 0: return 0.0, 0.0, "订单杠杆无效" + if min_effective_leverage <= 0: + min_effective_leverage = 1.0 + if order_leverage < min_effective_leverage: + return 0.0, 0.0, ( + f"订单杠杆 {order_leverage:.1f}x 低于最小有效杠杆 " + f"{min_effective_leverage:.1f}x" + ) if target_margin_pct <= 0: return 0.0, 0.0, "目标保证金比例无效" diff --git a/frontend/console.html b/frontend/console.html index c10a6bf..9778054 100644 --- a/frontend/console.html +++ b/frontend/console.html @@ -1093,6 +1093,105 @@ gap: 10px; } + .runtime-summary-grid { + display: grid; + grid-template-columns: minmax(0, 1.1fr) minmax(0, 0.9fr); + gap: 12px; + margin-bottom: 14px; + } + + .runtime-summary-card { + padding: 14px; + border-radius: 14px; + background: rgba(255,255,255,0.03); + border: 1px solid rgba(255,255,255,0.05); + } + + .runtime-summary-title { + color: var(--muted); + font-size: 11px; + margin-bottom: 10px; + font-family: "IBM Plex Mono", monospace; + text-transform: uppercase; + letter-spacing: 0.08em; + } + + .runtime-summary-main { + font-family: "IBM Plex Mono", monospace; + font-size: 16px; + color: var(--text); + margin-bottom: 8px; + } + + .runtime-summary-meta { + display: grid; + gap: 8px; + } + + .runtime-summary-row { + display: flex; + justify-content: space-between; + gap: 10px; + font-size: 12px; + color: var(--muted); + } + + .runtime-summary-row strong { + color: var(--text); + font-weight: 500; + } + + .blocked-list { + display: grid; + gap: 10px; + } + + .blocked-item { + padding: 12px 14px; + border-radius: 14px; + background: rgba(255, 184, 77, 0.08); + border: 1px solid rgba(255, 184, 77, 0.16); + } + + .blocked-item-head { + display: flex; + justify-content: space-between; + gap: 12px; + margin-bottom: 8px; + align-items: center; + } + + .blocked-item-title { + font-size: 13px; + color: var(--text); + font-weight: 600; + } + + .blocked-item-meta { + color: var(--muted); + font-size: 11px; + font-family: "IBM Plex Mono", monospace; + } + + .blocked-platforms { + display: grid; + gap: 6px; + margin-top: 10px; + } + + .blocked-platform { + display: flex; + gap: 8px; + align-items: flex-start; + font-size: 12px; + color: var(--muted); + } + + .blocked-platform strong { + color: var(--text); + min-width: 92px; + } + .analysis-log-item { padding: 12px 14px; border-radius: 14px; @@ -1505,6 +1604,18 @@
当前进度-
下一次运行-
+
+
+
运行摘要
+
正在整理分析状态...
+
+
+
最近阻塞原因
+
+
正在读取未落单汇总...
+
+
+
正在读取分析日志...
@@ -2150,7 +2261,9 @@ function renderAnalysisHeartbeat(analysisMonitor, analysisEvents) { const heartbeat = document.getElementById('analysisHeartbeat'); const logList = document.getElementById('analysisLogList'); + const summaryCard = document.getElementById('runtimeSummaryCard'); const monitor = analysisMonitor || {}; + const notifications = cachedConsoleData?.crypto_agent?.analysis_notifications || {}; const cycleStatus = monitor.last_cycle_status || 'idle'; const progressText = monitor.current_cycle_total ? `${monitor.current_cycle_index || 0}/${monitor.current_cycle_total} ${monitor.current_cycle_symbol || ''}` @@ -2175,6 +2288,19 @@ `; + const heartbeatSentAt = notifications.last_heartbeat_notified_at; + const lastSignalAt = notifications.last_signal_at; + const lastSignalSymbol = notifications.last_signal_symbol || '-'; + summaryCard.innerHTML = ` +
运行摘要
+
${monitor.last_analysis_status || 'idle'} / ${monitor.last_analysis_symbol || '-'}
+
+
最近分析说明${monitor.last_analysis_detail || '-'}
+
最近信号${lastSignalAt ? `${lastSignalSymbol} / ${relativeTime(lastSignalAt)}` : '近 60 分钟无信号'}
+
上次心跳通知${heartbeatSentAt ? `${relativeTime(heartbeatSentAt)} / ${formatTime(heartbeatSentAt)}` : '尚未发送'}
+
+ `; + if (!analysisEvents || analysisEvents.length === 0) { logList.innerHTML = compactEmpty('最近还没有分析日志', '等待下一轮分析或新的运行事件写入。'); return; @@ -2191,6 +2317,39 @@ `).join(''); } + function renderBlockedSummaries(events = cachedExecutionEvents) { + const container = document.getElementById('blockedSummaryList'); + const blockedEvents = (Array.isArray(events) ? events : []) + .filter((event) => event.event_type === 'execution_blocked_summary') + .slice(0, 3); + + if (!blockedEvents.length) { + container.innerHTML = compactEmpty('最近没有未落单阻塞', '出现执行阻塞时,这里会按平台归类展示原因。'); + return; + } + + container.innerHTML = blockedEvents.map((event) => { + const blockedPlatforms = event.blocked_platforms || []; + return ` +
+
+
${event.symbol || '-'} · ${event.signal_action_text || '-'} / ${event.signal_timeframe_text || '-'}
+
${relativeTime(event.timestamp)} / ${formatTime(event.timestamp)}
+
+
建议价 ${formatMoney(event.entry_price)} / 现价 ${formatMoney(event.current_price)} / 信心 ${formatPercent(event.confidence || 0, 1)}
+
+ ${blockedPlatforms.map((item) => ` +
+ ${item.platform} + ${item.tag} | ${item.detail} +
+ `).join('')} +
+
+ `; + }).join(''); + } + function summarizeDecision(decision) { if (!decision) return { label: '-', detail: '无数据', tone: 'hold' }; const decisionType = decision.decision || decision.action || 'HOLD'; @@ -2307,8 +2466,19 @@ ${event.symbol || '-'} ${event.decision ? `${event.decision}` : ''} ${event.action ? `${event.action}` : ''} + ${event.signal_timeframe_text ? `${event.signal_timeframe_text}` : ''} ${event.reason || '无说明'} + ${event.event_type === 'execution_blocked_summary' && Array.isArray(event.blocked_platforms) && event.blocked_platforms.length > 0 ? ` +
+ ${event.blocked_platforms.map((item) => ` +
+ ${item.platform} + ${item.tag} | ${item.detail} +
+ `).join('')} +
+ ` : ''} ${(event.reason || '').length > 90 ? `
查看完整详情 @@ -2495,6 +2665,7 @@ renderDecisionPreview(data.crypto_agent?.last_execution_preview || {}); renderHalts(data.crypto_agent?.platform_halts || {}); renderExecutionEvents(data.execution_events || []); + renderBlockedSummaries(data.execution_events || []); renderAttentionItems(data.management?.attention_items || []); renderUnifiedPositions(data.management?.positions || []); renderUnifiedOrders(data.management?.orders || []);