stock-ai-agent/backend/app/crypto_agent/execution_guardian.py
2026-04-25 13:20:28 +08:00

362 lines
16 KiB
Python

"""
执行监管器
从 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,
)