diff --git a/backend/app/crypto_agent/crypto_agent.py b/backend/app/crypto_agent/crypto_agent.py index 2751aa8..7e818d0 100644 --- a/backend/app/crypto_agent/crypto_agent.py +++ b/backend/app/crypto_agent/crypto_agent.py @@ -200,6 +200,23 @@ class CryptoAgent: self._platform_halts: Dict[str, Dict[str, Any]] = {} self._load_platform_halts() self._execution_events: deque[Dict[str, Any]] = deque(maxlen=120) + self._analysis_events: deque[Dict[str, Any]] = deque(maxlen=240) + self._analysis_monitor: Dict[str, Any] = { + "last_heartbeat_at": None, + "last_cycle_started_at": None, + "last_cycle_completed_at": None, + "last_cycle_status": "idle", + "last_cycle_error": "", + "current_cycle_symbol": None, + "current_cycle_index": 0, + "current_cycle_total": 0, + "last_analysis_started_at": None, + "last_analysis_completed_at": None, + "last_analysis_symbol": None, + "last_analysis_status": "idle", + "last_analysis_detail": "", + "next_scheduled_run_at": None, + } # 挂单 TP/SL 追踪:挂单成交后自动补设止盈止损 # key=order_id, value={symbol, is_long, size/contracts, tp_price, sl_price} @@ -262,6 +279,30 @@ class CryptoAgent: def get_recent_execution_events(self, limit: int = 30) -> List[Dict[str, Any]]: return list(self._execution_events)[:limit] + def _touch_analysis_heartbeat(self): + self._analysis_monitor["last_heartbeat_at"] = datetime.now().isoformat() + + def _record_analysis_event(self, + event_type: str, + symbol: str = "", + status: str = "info", + detail: str = "", + extra: Optional[Dict[str, Any]] = None): + event = { + "timestamp": datetime.now().isoformat(), + "event_type": event_type, + "status": status, + "symbol": symbol, + "detail": detail, + } + if extra: + event.update(extra) + self._analysis_events.appendleft(event) + self._touch_analysis_heartbeat() + + def get_recent_analysis_events(self, limit: int = 40) -> List[Dict[str, Any]]: + return list(self._analysis_events)[:limit] + def _on_price_update(self, symbol: str, price: float): """处理实时价格更新(用于模拟交易)""" if not self.paper_trading: @@ -532,10 +573,26 @@ class CryptoAgent: wait_seconds = self._get_seconds_until_next_5min() if wait_seconds > 0: next_run = datetime.now() + timedelta(seconds=wait_seconds) + self._analysis_monitor["next_scheduled_run_at"] = next_run.isoformat() + self._analysis_monitor["last_cycle_status"] = "waiting" + self._touch_analysis_heartbeat() logger.info(f"⏳ 等待 {wait_seconds} 秒,下次运行: {next_run.strftime('%H:%M:%S')}") await asyncio.sleep(wait_seconds) run_time = datetime.now() + self._analysis_monitor["last_cycle_started_at"] = run_time.isoformat() + self._analysis_monitor["last_cycle_status"] = "running" + self._analysis_monitor["last_cycle_error"] = "" + self._analysis_monitor["current_cycle_symbol"] = None + self._analysis_monitor["current_cycle_index"] = 0 + self._analysis_monitor["current_cycle_total"] = len(self.symbols) + self._analysis_monitor["next_scheduled_run_at"] = None + self._record_analysis_event( + "cycle_started", + status="info", + detail=f"新一轮分析开始,计划扫描 {len(self.symbols)} 个交易对", + extra={"symbols": list(self.symbols)}, + ) logger.info("\n" + "=" * 60) logger.info(f"⏰ 定时任务执行 [{run_time.strftime('%Y-%m-%d %H:%M:%S')}]") logger.info("=" * 60) @@ -567,9 +624,20 @@ class CryptoAgent: await self._check_and_set_pending_tp_sl_bitget() await self._check_bitget_missing_tp_sl() # 兜底:检查缺少的 TP/SL 并补救 - for symbol in self.symbols: + for index, symbol in enumerate(self.symbols, start=1): + self._analysis_monitor["current_cycle_symbol"] = symbol + self._analysis_monitor["current_cycle_index"] = index + self._touch_analysis_heartbeat() await self.analyze_symbol(symbol) + self._analysis_monitor["last_cycle_completed_at"] = datetime.now().isoformat() + self._analysis_monitor["last_cycle_status"] = "completed" + self._analysis_monitor["current_cycle_symbol"] = None + self._record_analysis_event( + "cycle_completed", + status="success", + detail=f"本轮分析完成,共扫描 {len(self.symbols)} 个交易对", + ) logger.info("\n" + "─" * 60) logger.info(f"✅ 本轮分析完成,共分析 {len(self.symbols)} 个交易对") logger.info("─" * 60 + "\n") @@ -577,6 +645,14 @@ class CryptoAgent: await asyncio.sleep(2) except Exception as e: + self._analysis_monitor["last_cycle_completed_at"] = datetime.now().isoformat() + self._analysis_monitor["last_cycle_status"] = "error" + self._analysis_monitor["last_cycle_error"] = str(e) + self._record_analysis_event( + "cycle_error", + status="error", + detail=f"分析主循环异常: {str(e)}", + ) logger.error(f"❌ 分析循环出错: {e}") import traceback logger.error(traceback.format_exc()) @@ -695,6 +771,16 @@ class CryptoAgent: # 更新活动时间 monitor = get_system_monitor() monitor.update_activity("crypto_agent") + self._analysis_monitor["last_analysis_started_at"] = datetime.now().isoformat() + self._analysis_monitor["last_analysis_symbol"] = symbol + self._analysis_monitor["last_analysis_status"] = "running" + self._analysis_monitor["last_analysis_detail"] = "开始获取数据" + self._record_analysis_event( + "symbol_analysis_started", + symbol=symbol, + status="info", + detail="开始分析", + ) logger.info(f"\n{'─' * 50}") logger.info(f"📊 {symbol} 分析开始") @@ -704,6 +790,15 @@ class CryptoAgent: data = self.exchange.get_multi_timeframe_data(symbol) if not self._validate_data(data): + self._analysis_monitor["last_analysis_completed_at"] = datetime.now().isoformat() + self._analysis_monitor["last_analysis_status"] = "warning" + self._analysis_monitor["last_analysis_detail"] = "数据不完整,跳过分析" + self._record_analysis_event( + "symbol_analysis_skipped", + symbol=symbol, + status="warning", + detail="数据不完整,跳过分析", + ) logger.warning(f"⚠️ {symbol} 数据不完整,跳过分析") return @@ -715,6 +810,16 @@ class CryptoAgent: # 1.5. 波动率检查(节省 LLM 调用) should_analyze, volatility_reason, volatility = self._check_volatility(symbol, data) if not should_analyze: + self._analysis_monitor["last_analysis_completed_at"] = datetime.now().isoformat() + self._analysis_monitor["last_analysis_status"] = "skipped" + self._analysis_monitor["last_analysis_detail"] = volatility_reason + self._record_analysis_event( + "symbol_analysis_skipped", + symbol=symbol, + status="hold", + detail=volatility_reason, + extra={"volatility_percent": volatility}, + ) logger.info(f"⏸️ {volatility_reason},跳过本次 LLM 分析") return @@ -746,6 +851,16 @@ class CryptoAgent: trade_signals = [s for s in signals if s.get('action') in ['buy', 'sell']] if not trade_signals: + self._analysis_monitor["last_analysis_completed_at"] = datetime.now().isoformat() + self._analysis_monitor["last_analysis_status"] = "completed" + self._analysis_monitor["last_analysis_detail"] = "完成分析,无交易信号" + self._record_analysis_event( + "symbol_analysis_completed", + symbol=symbol, + status="success", + detail="完成分析,无交易信号", + extra={"trade_signals": 0, "valid_signals": 0}, + ) logger.info(f"\n⏸️ 结论: 无交易信号(仅有观望建议),继续观望") return @@ -754,6 +869,16 @@ class CryptoAgent: valid_signals = [s for s in trade_signals if s.get('confidence', 0) >= threshold] if not valid_signals: + self._analysis_monitor["last_analysis_completed_at"] = datetime.now().isoformat() + self._analysis_monitor["last_analysis_status"] = "completed" + self._analysis_monitor["last_analysis_detail"] = f"完成分析,但无信号达到阈值 {threshold}%" + self._record_analysis_event( + "symbol_analysis_completed", + symbol=symbol, + status="success", + detail=f"完成分析,但无信号达到阈值 {threshold}%", + extra={"trade_signals": len(trade_signals), "valid_signals": 0}, + ) logger.info(f"\n⏸️ 结论: 无交易信号达到置信度阈值 ({threshold}%),继续观望") return @@ -858,8 +983,27 @@ class CryptoAgent: # 第三阶段:执行交易动作(各平台独立) # ============================================================ await self._execute_decisions(paper_decision, hl_decision, bg_decision, market_signal, current_price) + self._analysis_monitor["last_analysis_completed_at"] = datetime.now().isoformat() + self._analysis_monitor["last_analysis_status"] = "completed" + self._analysis_monitor["last_analysis_detail"] = f"完成分析,产生 {len(valid_signals)} 个有效信号" + self._record_analysis_event( + "symbol_analysis_completed", + symbol=symbol, + status="success", + detail=f"完成分析,产生 {len(valid_signals)} 个有效信号", + extra={"trade_signals": len(trade_signals), "valid_signals": len(valid_signals)}, + ) except Exception as e: + self._analysis_monitor["last_analysis_completed_at"] = datetime.now().isoformat() + self._analysis_monitor["last_analysis_status"] = "error" + self._analysis_monitor["last_analysis_detail"] = str(e) + self._record_analysis_event( + "symbol_analysis_error", + symbol=symbol, + status="error", + detail=str(e), + ) logger.error(f"❌ 分析 {symbol} 出错: {e}") import traceback logger.error(traceback.format_exc()) @@ -4220,6 +4364,7 @@ class CryptoAgent: 'symbols': self.symbols, 'mode': 'LLM 驱动', 'platform_halts': self.get_platform_halt_status(), + 'analysis_monitor': self._analysis_monitor, 'last_signals': { symbol: { 'type': sig.get('type'), @@ -4230,6 +4375,7 @@ class CryptoAgent: for symbol, sig in self.last_signals.items() }, 'last_execution_preview': self.last_execution_preview, + 'recent_analysis_events': self.get_recent_analysis_events(limit=30), } async def _notify_expired_orders_cancelled(self, cancelled_orders: List): diff --git a/frontend/console.html b/frontend/console.html index 7f5f3e2..b9b44db 100644 --- a/frontend/console.html +++ b/frontend/console.html @@ -723,6 +723,85 @@ font-family: "IBM Plex Mono", monospace; } + .heartbeat-grid { + display: grid; + grid-template-columns: repeat(4, minmax(0, 1fr)); + gap: 10px; + margin-bottom: 14px; + } + + .heartbeat-card { + padding: 12px; + border-radius: 12px; + background: rgba(255,255,255,0.03); + border: 1px solid rgba(255,255,255,0.05); + } + + .heartbeat-card .label { + display: block; + color: var(--muted); + font-size: 11px; + margin-bottom: 6px; + } + + .heartbeat-card .value { + font-size: 13px; + font-family: "IBM Plex Mono", monospace; + color: var(--text); + } + + .analysis-log-list { + display: grid; + gap: 10px; + } + + .analysis-log-item { + padding: 12px 14px; + border-radius: 14px; + background: rgba(255,255,255,0.03); + border: 1px solid rgba(255,255,255,0.05); + } + + .analysis-log-item.error { + border-color: rgba(255, 111, 97, 0.2); + background: rgba(255, 111, 97, 0.08); + } + + .analysis-log-item.warning, + .analysis-log-item.hold { + border-color: rgba(255, 184, 77, 0.2); + background: rgba(255, 184, 77, 0.08); + } + + .analysis-log-item.success { + border-color: rgba(48, 209, 88, 0.18); + background: rgba(48, 209, 88, 0.08); + } + + .analysis-log-head { + display: flex; + justify-content: space-between; + gap: 12px; + align-items: center; + margin-bottom: 6px; + } + + .analysis-log-head strong { + font-size: 13px; + } + + .analysis-log-meta { + color: var(--muted); + font-size: 11px; + font-family: "IBM Plex Mono", monospace; + } + + .analysis-log-detail { + color: var(--muted); + font-size: 12px; + line-height: 1.6; + } + .ops-grid { display: grid; gap: 18px; @@ -920,7 +999,8 @@ .hero-metrics, .platform-stats, - .signal-stats { + .signal-stats, + .heartbeat-grid { grid-template-columns: 1fr; } @@ -1023,6 +1103,24 @@ +
+
+
+

分析心跳

+
没有信号时,用它判断系统是否仍在正常扫盘
+
+
+
+
最近心跳-
+
最近轮次-
+
当前进度-
+
下一次运行-
+
+
+
正在读取分析日志...
+
+
+
@@ -1359,6 +1457,50 @@ container.innerHTML = cards.join(''); } + function renderAnalysisHeartbeat(analysisMonitor, analysisEvents) { + const heartbeat = document.getElementById('analysisHeartbeat'); + const logList = document.getElementById('analysisLogList'); + const monitor = analysisMonitor || {}; + 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 || ''}` + : '-'; + + heartbeat.innerHTML = ` +
+ 最近心跳 + ${monitor.last_heartbeat_at ? `${relativeTime(monitor.last_heartbeat_at)} / ${formatTime(monitor.last_heartbeat_at)}` : '-'} +
+
+ 最近轮次 + ${cycleStatus}${monitor.last_cycle_completed_at ? ` / ${relativeTime(monitor.last_cycle_completed_at)}` : ''} +
+
+ 当前进度 + ${progressText} +
+
+ 下一次运行 + ${monitor.next_scheduled_run_at ? formatTime(monitor.next_scheduled_run_at) : '-'} +
+ `; + + if (!analysisEvents || analysisEvents.length === 0) { + logList.innerHTML = '
最近还没有分析日志
'; + return; + } + + logList.innerHTML = analysisEvents.map((event) => ` +
+
+ ${event.symbol || 'SYSTEM'} · ${event.event_type || '-'} + ${relativeTime(event.timestamp)} / ${formatTime(event.timestamp)} +
+
${event.detail || '-'}
+
+ `).join(''); + } + function summarizeDecision(decision) { if (!decision) return { label: '-', detail: '无数据', tone: 'hold' }; const decisionType = decision.decision || decision.action || 'HOLD'; @@ -1651,6 +1793,10 @@ renderPlatforms(data.platforms, data.crypto_agent?.platform_halts); renderSignalStream(data.signals?.latest || []); renderAgentSignals(data.crypto_agent, data.signals); + renderAnalysisHeartbeat( + data.crypto_agent?.analysis_monitor || {}, + data.crypto_agent?.recent_analysis_events || [] + ); renderDecisionPreview(data.crypto_agent?.last_execution_preview || {}); renderHalts(data.crypto_agent?.platform_halts || {}); renderExecutionEvents(data.execution_events || []);