""" 执行监管器 从 CryptoAgent 主循环中拆分执行后监管职责,负责: - 挂单超时清理 - 持仓管理(止盈 / 超时退出 / 移动止损) - Bitget 挂单成交后的 TP/SL 补设 - Bitget 持仓保护单缺失补救 第一版先作为确定性协调器运行,不引入新的 LLM 决策。 """ from __future__ import annotations from datetime import datetime from typing import Any, Dict, List from app.crypto_agent.execution_targets import ExecutionTarget from app.utils.logger import logger class ExecutionGuardian: """执行监管协调器。""" def __init__(self, agent: Any): self.agent = agent self._state: Dict[str, Any] = { "last_run_at": None, "last_status": "idle", "last_error": "", "last_actions": [], } def get_status(self) -> Dict[str, Any]: return { "last_run_at": self._state.get("last_run_at"), "last_status": self._state.get("last_status", "idle"), "last_error": self._state.get("last_error", ""), "targets": [self._serialize_target(target) for target in self._iter_targets()], "last_actions": list(self._state.get("last_actions", []))[:20], } def _serialize_target(self, target: ExecutionTarget) -> Dict[str, Any]: return { "target_key": target.target_key, "platform": target.platform, "account_id": target.account_id, "supports_pending_timeout": target.supports_pending_timeout, "supports_position_management": target.supports_position_management, "supports_tpsl_repair": target.supports_tpsl_repair, } def _iter_targets(self) -> List[ExecutionTarget]: targets = self.agent.get_execution_targets() if not isinstance(targets, list): return [] return targets async def run_cycle(self): """执行一轮监管扫描。""" self._state["last_run_at"] = datetime.now().isoformat() self._state["last_status"] = "running" self._state["last_error"] = "" self._state["last_actions"] = [] try: for target in self._iter_targets(): if self.agent._is_platform_halted(target.target_key): continue if target.supports_pending_timeout: await self._check_pending_order_timeouts(target) if target.supports_position_management: await self._check_position_management(target) if target.supports_tpsl_repair: await self._check_and_set_pending_tp_sl(target) await self._check_missing_tp_sl(target) self._state["last_status"] = "completed" except Exception as e: self._state["last_status"] = "error" self._state["last_error"] = str(e) logger.error(f"ExecutionGuardian 运行异常: {e}") raise def _record_action(self, action_type: str, platform: str, symbol: str = "", detail: str = ""): self._state.setdefault("last_actions", []).insert(0, { "timestamp": datetime.now().isoformat(), "action_type": action_type, "platform": platform, "symbol": symbol, "detail": detail, }) self._state["last_actions"] = self._state["last_actions"][:20] async def _check_pending_order_timeouts(self, target: ExecutionTarget): """检查各平台挂单超时。""" pending_orders = [] if target.platform == 'PaperTrading': pending_orders = target.service.get_open_orders() elif target.platform == 'Bitget': pending_orders = target.service.get_open_orders() if target.service else [] if not pending_orders: return timeout_orders = target.executor.check_pending_order_timeout(pending_orders) for order_info in timeout_orders: order_id = order_info.get('order_id') symbol = order_info.get('symbol', '') reason = order_info.get('reason', '') logger.info(f" ⏰ [{target.target_key}] {symbol} {reason}") result = await target.executor.execute_cancel(order_id, symbol) if result.get('success'): self._record_action("cancel_timeout", target.target_key, symbol, reason) logger.info(f" ✅ 已取消超时挂单: {order_id}") message = ( f"⏰ 挂单超时自动取消\n\n" f"平台: {target.platform}\n" f"账户: {target.account_id}\n" f"交易对: {symbol}\n" f"订单ID: {order_id}\n" f"原因: {reason}" ) await self.agent._send_alert_notification(f"⏰ [{target.target_key}] 挂单超时", message) else: error = result.get('error', '未知错误') logger.error(f" ❌ 取消失败: {error}") async def _check_position_management(self, target: ExecutionTarget): """检查各平台持仓管理(止盈/止损/移动止损)。""" current_prices = {} volatility_data = {} for symbol in self.agent.symbols: try: data = self.agent.exchange.get_multi_timeframe_data(symbol) current_prices[symbol] = float(data['5m'].iloc[-1]['close']) if '1h' in data and 'atr' in data['1h'].columns: atr_value = data['1h']['atr'].iloc[-1] price_1h = data['1h']['close'].iloc[-1] if atr_value and price_1h > 0: volatility_data[symbol] = float(atr_value) / float(price_1h) except Exception: continue if target.platform == 'PaperTrading': positions = target.service.get_open_positions() elif target.platform == 'Bitget': positions = target.service.get_open_positions() if target.service else [] else: positions = [] if not positions: return actions = target.executor.check_position_management(positions, current_prices, volatility_data) for action_info in actions: symbol = action_info.get('symbol') action = action_info.get('action') reason = action_info.get('reason', '') logger.info(f" 📊 [{target.target_key}] {symbol} {reason}") if action in {'TAKE_PROFIT', 'TIME_EXIT'}: normalized_symbol = self.agent._normalize_symbol(symbol) close_order_ids = [ p.get('order_id') for p in positions if self.agent._normalize_symbol(p.get('symbol', '')) == normalized_symbol and p.get('order_id') ] decision = { 'decision': 'CLOSE', 'symbol': normalized_symbol, 'orders_to_close': close_order_ids, 'reason': reason, } result = await target.executor.execute_close(decision, current_prices.get(symbol, 0)) if result.get('success'): self._record_action(action.lower(), target.target_key, normalized_symbol, reason) title = "💰" if action == 'TAKE_PROFIT' else "⏰" text = "自动止盈" if action == 'TAKE_PROFIT' else "持仓超时平仓" await self.agent._send_alert_notification( f"{title} [{target.target_key}] {text}", f"交易对: {symbol}\n原因: {reason}" ) elif action == 'MOVE_SL': new_sl = action_info.get('new_sl') pnl_pct = action_info.get('pnl_pct', 0) if new_sl: move_result = await target.executor.move_stop_loss(symbol=symbol, new_stop_loss=new_sl) if move_result.get('success'): self._record_action("move_sl", target.target_key, symbol, f"new_sl={new_sl}") await self.agent._send_alert_notification( f"🔒 [{target.target_key}] 移动止损", f"交易对: {symbol}\n新止损: ${new_sl:.2f}\n原因: {reason}" ) await target.executor.send_execution_notification( operation='POSITION_MANAGEMENT', symbol=symbol, result={'success': True, 'action': 'MOVE_SL', 'reason': reason}, details={ 'new_sl': new_sl, 'pnl_percent': pnl_pct, 'account_id': target.account_id, 'target_key': target.target_key, } ) async def _check_and_set_pending_tp_sl(self, target: ExecutionTarget): """检查 Bitget 挂单是否已成交,若成交则补设止盈止损。""" if target.platform != 'Bitget': return pending_state = self.agent._get_pending_tp_sl_state(target.pending_tpsl_state_key or target.target_key) if not pending_state: return for order_id, info in list(pending_state.items()): symbol = self.agent._normalize_symbol(info['symbol']) coin = symbol.replace('USDT', '') open_orders = target.service.get_open_orders(symbol) still_open = any(str(o.get('order_id')) == order_id for o in open_orders) if still_open: continue position = target.service.get_position_for_symbol(coin) if not position: logger.info(f"[{target.target_key}] 挂单追踪 {order_id} 已结束:{symbol} 无持仓,移除待补设任务") self._record_action("cleanup_pending_tpsl", target.target_key, symbol, f"order_id={order_id}") del pending_state[order_id] continue tp_price = info.get('tp_price') sl_price = info.get('sl_price') logger.info(f"[{target.target_key}] 挂单 {order_id} ({symbol}) 已成交,补设 TP/SL...") tp_sl_result = target.service.set_tp_sl( symbol=coin, is_long=position.get('size', 0) > 0, size=abs(position.get('size', 0)), 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) if tp_set and sl_set: self._record_action("repair_tpsl", target.target_key, symbol, f"order_id={order_id}") logger.info(f"[{target.target_key}] ✅ TP/SL 补设成功: {symbol} TP={tp_price} SL={sl_price}") del pending_state[order_id] continue if 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 pending_state[order_id] = self.agent._build_pending_tp_sl_task( symbol=symbol, is_long=position.get('size', 0) > 0, size=abs(position.get('size', 0)), 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" await self.agent._maybe_alert_tp_sl_incomplete( target.target_key, order_id, pending_state[order_id], f"{set_text}已设,{fail_text}补设失败", ) continue await self.agent._maybe_alert_tp_sl_incomplete( target.target_key, order_id, info, str(tp_sl_result.get('errors') or 'TP/SL补设失败'), ) async def _check_missing_tp_sl(self, target: ExecutionTarget): """定时检查 Bitget 持仓是否缺少止盈止损,缺少则从信号补救。""" if target.platform != 'Bitget' or not target.service: return positions = target.service.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 = target.service.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.agent.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"[{target.target_key}] ⚠️ {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"[{target.target_key}] ⚠️ {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}") missing_desc = ' & '.join(missing_parts) logger.warning(f"[{target.target_key}] 🔧 {symbol} 缺少 {missing_desc},从信号补救...") size = abs(pos.get('size', 0)) if size <= 0: continue tp_sl_result = target.service.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: self._record_action("fallback_tpsl", target.target_key, symbol, missing_desc) 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"[{target.target_key}] ✅ 补救成功: {symbol} {' & '.join(set_parts)}") else: await self.agent._maybe_alert_tp_sl_incomplete( target.target_key, f"{target.target_key}:fallback:{symbol}", self.agent._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.agent.TP_SL_RETRY_ALERT_THRESHOLD, ), str(tp_sl_result.get('errors') or '兜底补设失败'), force=True, )