diff --git a/backend/app/crypto_agent/crypto_agent.py b/backend/app/crypto_agent/crypto_agent.py index c73ae3e..6124101 100644 --- a/backend/app/crypto_agent/crypto_agent.py +++ b/backend/app/crypto_agent/crypto_agent.py @@ -732,6 +732,9 @@ class CryptoAgent: paper_decision = self.execute_signal_with_rules( trading_signal, 'PaperTrading', paper_account, paper_positions, paper_pending ) + paper_decision = self._normalize_execution_decision( + paper_decision, paper_positions, paper_pending + ) # 不发送决策通知(因为是基于硬编码规则的执行,不是 LLM 决策) # await self._send_trading_decision_notification( # paper_decision, market_signal, current_price, prefix="[模拟盘]" @@ -747,6 +750,9 @@ class CryptoAgent: hl_decision = self.execute_signal_with_rules( trading_signal, 'Hyperliquid', hl_account, hl_positions, hl_pending ) + hl_decision = self._normalize_execution_decision( + hl_decision, hl_positions, hl_pending + ) # 不发送决策通知(因为是基于硬编码规则的执行,不是 LLM 决策) # await self._send_trading_decision_notification( # hl_decision, market_signal, current_price, prefix="[Hyperliquid]" @@ -762,6 +768,9 @@ class CryptoAgent: bg_decision = self.execute_signal_with_rules( trading_signal, 'Bitget', bg_account, bg_positions, bg_pending ) + bg_decision = self._normalize_execution_decision( + bg_decision, bg_positions, bg_pending + ) # 不发送决策通知(因为是基于硬编码规则的执行,不是 LLM 决策) # await self._send_trading_decision_notification( # bg_decision, market_signal, current_price, prefix="[Bitget]" @@ -904,23 +913,28 @@ class CryptoAgent: if order.get('status') == 'open' and order.get('filled_price'): # 已成交的订单作为持仓 position_list.append({ + 'order_id': order.get('order_id'), 'symbol': order.get('symbol'), - 'side': order.get('side'), + 'side': 'buy' if order.get('side') == 'long' else 'sell', 'holding': order.get('quantity', 0), 'entry_price': order.get('filled_price') or order.get('entry_price'), + 'unrealized_pnl_pct': order.get('pnl_percent', 0), 'stop_loss': order.get('stop_loss'), - 'take_profit': order.get('take_profit') + 'take_profit': order.get('take_profit'), + 'opened_at': order.get('opened_at'), + 'created_at': order.get('created_at'), }) elif order.get('status') == 'pending': # 未成交的订单作为挂单 pending_orders.append({ 'order_id': order.get('order_id'), 'symbol': order.get('symbol'), - 'side': order.get('side'), + 'side': 'buy' if order.get('side') == 'long' else 'sell', 'entry_price': order.get('entry_price'), 'quantity': order.get('quantity', 0), 'entry_type': order.get('entry_type', 'market'), - 'confidence': order.get('confidence', 0) + 'confidence': order.get('confidence', 0), + 'created_at': order.get('created_at'), }) return position_list, account, pending_orders @@ -986,11 +1000,12 @@ class CryptoAgent: pending_orders.append({ 'order_id': order.get('order_id'), 'symbol': f"{order['symbol']}USDT", # 转换格式 - 'side': 'buy' if order.get('side') == 'B' else 'sell', + 'side': order.get('side'), 'entry_price': order.get('price'), 'quantity': order.get('size'), 'entry_type': 'limit', - 'is_reduce_only': order.get('is_reduce_only', False) + 'is_reduce_only': order.get('is_reduce_only', False), + 'created_at': order.get('created_at'), }) return position_list, account, pending_orders @@ -999,6 +1014,105 @@ class CryptoAgent: logger.error(f"获取 Hyperliquid 状态失败: {e}") return [], {}, [] + def _normalize_symbol(self, symbol: str) -> str: + """统一交易对格式为 BTCUSDT""" + if not symbol: + return symbol + return symbol if symbol.endswith('USDT') else f"{symbol}USDT" + + def _build_follow_up_open_decision(self, decision: Dict[str, Any]) -> Dict[str, Any]: + """为复合动作构建二段式开仓决策""" + follow_up = dict(decision) + follow_up.pop('next_decision', None) + follow_up['decision'] = 'OPEN' + follow_up['action'] = decision.get('signal_action', decision.get('action')) + follow_up['symbol'] = self._normalize_symbol(decision.get('symbol', '')) + return follow_up + + def _normalize_execution_decision(self, + decision: Dict[str, Any], + positions: List[Dict[str, Any]], + pending_orders: List[Dict[str, Any]]) -> Dict[str, Any]: + """将复合动作归一化为执行器可落地的动作""" + if not decision: + return decision + + decision_type = decision.get('decision', 'HOLD') + if decision_type in {'OPEN', 'ADD', 'CLOSE', 'CANCEL_PENDING', 'HOLD'}: + return decision + + symbol = self._normalize_symbol(decision.get('symbol', '')) + signal_action = decision.get('signal_action', decision.get('action')) + opposite_side = 'sell' if signal_action == 'buy' else 'buy' + + same_positions = [ + pos for pos in positions + if self._normalize_symbol(pos.get('symbol', '')) == symbol and pos.get('side') == signal_action + ] + opposite_positions = [ + pos for pos in positions + if self._normalize_symbol(pos.get('symbol', '')) == symbol and pos.get('side') == opposite_side + ] + opposite_pending = [ + order for order in pending_orders + if self._normalize_symbol(order.get('symbol', '')) == symbol and order.get('side') == opposite_side + ] + + def build_close(target_positions: List[Dict[str, Any]], reason: str) -> Dict[str, Any]: + close_decision = dict(decision) + close_decision['decision'] = 'CLOSE' + close_decision['action'] = 'CLOSE' + close_decision['symbol'] = symbol + close_decision['reason'] = reason + close_decision['reasoning'] = reason + order_ids = [pos.get('order_id') for pos in target_positions if pos.get('order_id')] + if order_ids: + close_decision['orders_to_close'] = order_ids + return close_decision + + def build_cancel(target_orders: List[Dict[str, Any]], reason: str) -> Dict[str, Any]: + cancel_decision = dict(decision) + cancel_decision['decision'] = 'CANCEL_PENDING' + cancel_decision['action'] = 'CANCEL_PENDING' + cancel_decision['symbol'] = symbol + cancel_decision['reason'] = reason + cancel_decision['reasoning'] = reason + cancel_decision['orders_to_cancel'] = [ + order.get('order_id') for order in target_orders if order.get('order_id') + ] + return cancel_decision + + if decision_type in {'FLIP', 'CLOSE_OPPOSITE'}: + if opposite_positions: + normalized = build_close(opposite_positions, decision.get('reasoning', decision.get('reason', '反向仓位先平仓'))) + normalized['next_decision'] = self._build_follow_up_open_decision(decision) + return normalized + if opposite_pending: + normalized = build_cancel(opposite_pending, decision.get('reasoning', decision.get('reason', '先撤销反向挂单'))) + normalized['next_decision'] = self._build_follow_up_open_decision(decision) + return normalized + return self._build_follow_up_open_decision(decision) + + if decision_type == 'CANCEL_AND_OPEN': + if opposite_pending: + normalized = build_cancel(opposite_pending, decision.get('reasoning', decision.get('reason', '先撤销反向挂单'))) + normalized['next_decision'] = self._build_follow_up_open_decision(decision) + return normalized + return self._build_follow_up_open_decision(decision) + + if decision_type == 'ROLL': + if same_positions: + normalized = build_close(same_positions, decision.get('reasoning', decision.get('reason', '滚仓先平旧仓'))) + normalized['next_decision'] = self._build_follow_up_open_decision(decision) + return normalized + return self._build_follow_up_open_decision(decision) + + logger.warning(f"未知复合决策类型,降级为 HOLD: {decision_type}") + fallback = dict(decision) + fallback['decision'] = 'HOLD' + fallback['reasoning'] = fallback.get('reasoning', fallback.get('reason', f'未支持的决策类型: {decision_type}')) + return fallback + async def _execute_decisions(self, paper_decision: Dict[str, Any], hyperliquid_decision: Dict[str, Any], bitget_decision: Dict[str, Any], @@ -1039,6 +1153,7 @@ class CryptoAgent: """执行模拟盘决策(使用执行器)""" try: decision_type = decision.get('decision', 'HOLD') + next_decision = decision.get('next_decision') if decision_type == 'HOLD': reasoning = decision.get('reasoning', decision.get('reason', '观望')) @@ -1077,6 +1192,8 @@ class CryptoAgent: if result.get('success'): logger.info(f" ✅ 平仓成功") 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: error = result.get('error', '平仓失败') logger.error(f" ❌ 平仓失败: {error}") @@ -1096,8 +1213,12 @@ class CryptoAgent: if success_count > 0: logger.info(f" ✅ 成功取消 {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: logger.warning(f" ⚠️ 没有成功取消任何挂单") + else: + logger.warning(f" ⚠️ 模拟盘暂不支持的执行动作: {decision_type}") except Exception as e: logger.error(f" ❌ 模拟盘执行异常: {e}") @@ -2128,6 +2249,7 @@ class CryptoAgent: 'quantity': order.get('size'), 'entry_type': 'limit', 'is_reduce_only': order.get('is_reduce_only', False), + 'created_at': order.get('created_at'), }) return position_list, account, pending_orders @@ -2474,7 +2596,14 @@ class CryptoAgent: "decision": final_action, "action": final_action, "reason": final_reason, - "reasoning": final_reason + "reasoning": final_reason, + "signal_action": signal.get('action'), + "symbol": signal.get('symbol'), + "entry_price": signal.get('entry_price'), + "stop_loss": signal.get('stop_loss'), + "take_profit": signal.get('take_profit'), + "confidence": signal.get('confidence', 0), + "grade": signal.get('grade', 'C'), } async def _execute_bitget_decisions(self, decision: Dict[str, Any], @@ -2484,6 +2613,7 @@ class CryptoAgent: try: decision_type = decision.get('decision', 'HOLD') symbol = decision.get('symbol', 'UNKNOWN') + next_decision = decision.get('next_decision') if decision_type == 'HOLD': reasoning = decision.get('reasoning', decision.get('reason', '观望')) @@ -2541,6 +2671,8 @@ class CryptoAgent: if result.get('success'): logger.info(f" ✅ Bitget 平仓成功") 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: error = result.get('error', '未知错误') logger.error(f" ❌ Bitget 平仓失败: {error}") @@ -2562,10 +2694,14 @@ class CryptoAgent: if success_count > 0: logger.info(f" ✅ Bitget 取消成功: {success_count} 个挂单") + if next_decision: + await self._execute_bitget_decisions(next_decision, market_signal, current_price) else: error = "没有成功取消任何挂单" logger.error(f" ❌ Bitget 取消失败: {error}") await self._notify_bitget_error(symbol, "取消挂单", error) + else: + logger.warning(f" ⚠️ Bitget 暂不支持的执行动作: {decision_type}") except Exception as e: logger.error(f" ❌ Bitget 执行异常: {e}") @@ -2816,6 +2952,7 @@ class CryptoAgent: try: decision_type = decision.get('decision', 'HOLD') symbol = decision.get('symbol', 'UNKNOWN') + next_decision = decision.get('next_decision') if decision_type == 'HOLD': reasoning = decision.get('reasoning', decision.get('reason', '观望')) @@ -2854,6 +2991,8 @@ class CryptoAgent: if result.get('success'): logger.info(f" ✅ Hyperliquid 平仓成功") 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: error = result.get('error', '未知错误') logger.error(f" ❌ Hyperliquid 平仓失败: {error}") @@ -2868,12 +3007,16 @@ class CryptoAgent: result = await executor.execute_cancel(order_id, symbol) if result.get('success'): success_count += 1 - if success_count > 1: + if success_count > 0: logger.info(f" ✅ Hyperliquid 取消成功: {success_count} 个") + if next_decision: + await self._execute_hyperliquid_decisions(next_decision, market_signal, current_price) else: error = "没有成功取消任何挂单" logger.error(f" ❌ Hyperliquid 取消失败: {error}") await self._notify_hyperliquid_error(symbol, "取消挂单", error) + else: + logger.warning(f" ⚠️ Hyperliquid 暂不支持的执行动作: {decision_type}") except Exception as e: logger.error(f" ❌ Hyperliquid 执行异常: {e}") await self._notify_hyperliquid_error(symbol, decision_type, str(e)) @@ -3558,7 +3701,7 @@ class CryptoAgent: platforms_to_check.append(('模拟盘', self.paper_trading)) # 添加 Bitget 实盘 - if self.bitget and self.settings.bitget_use_testnet: + if self.bitget: platforms_to_check.append(('Bitget', self.bitget)) # 添加 Hyperliquid 实盘 @@ -3570,6 +3713,8 @@ class CryptoAgent: # 获取账户状态 if hasattr(platform_service, 'get_account_state'): account_state = platform_service.get_account_state() + elif hasattr(platform_service, 'get_account_status'): + account_state = platform_service.get_account_status() elif hasattr(platform_service, 'get_balance'): account_state = platform_service.get_balance() else: @@ -3579,8 +3724,9 @@ class CryptoAgent: # 获取当前余额(统一字段名) current_balance = ( account_state.get('current_balance') or + account_state.get('account_value') or account_state.get('balance') or - account_state.get('available_balance', 0) + 0 ) if current_balance <= 0: @@ -3665,6 +3811,21 @@ class CryptoAgent: logger.info(f"[{platform_name}] 需要平仓 {len(positions)} 个持仓") + if hasattr(platform_service, 'market_close_all'): + result = platform_service.market_close_all() + success_items = result.get('results') or result.get('result') or [] + closed_count = sum(1 for item in success_items if item.get('success', True)) + + await self._send_alert_notification( + f"🚨 [{platform_name}] 紧急平仓完成", + f"触发原因: 账户回撤超过 {self.settings.account_max_drawdown*100:.0f}%\n" + f"平仓数量: {closed_count}/{len(positions)}\n\n" + f"⚠️ 交易系统已停止,请人工检查账户!" + ) + + logger.info(f"🚨 [{platform_name}] 紧急平仓完成: {closed_count}/{len(positions)}") + return + # 逐个平仓 closed_count = 0 for pos in positions: @@ -3753,7 +3914,7 @@ class CryptoAgent: # 达到目标盈利,平仓 decision = { 'decision': 'CLOSE', - 'symbol': symbol + 'USDT', + 'symbol': self._normalize_symbol(symbol), 'reason': reason } result = await executor.execute_close(decision, current_prices.get(symbol, 0)) @@ -3768,7 +3929,7 @@ class CryptoAgent: # 持仓超时,平仓 decision = { 'decision': 'CLOSE', - 'symbol': symbol + 'USDT', + 'symbol': self._normalize_symbol(symbol), 'reason': reason } result = await executor.execute_close(decision, current_prices.get(symbol, 0)) @@ -3874,7 +4035,7 @@ class CryptoAgent: try: # 飞书 if self.feishu: - await self.feishu.send_message(f"{title}\n\n{message}") + await self.feishu.send_text(f"{title}\n\n{message}") # 钉钉 if self.dingtalk: @@ -3895,4 +4056,3 @@ def get_crypto_agent() -> 'CryptoAgent': """获取加密货币智能体单例""" # 直接使用类单例,不使用全局变量(避免 reload 时重置) return CryptoAgent() - diff --git a/backend/app/crypto_agent/executor/bitget_executor.py b/backend/app/crypto_agent/executor/bitget_executor.py index dcf5236..449c122 100644 --- a/backend/app/crypto_agent/executor/bitget_executor.py +++ b/backend/app/crypto_agent/executor/bitget_executor.py @@ -120,37 +120,24 @@ class BitgetExecutor(BaseExecutor): symbol = decision.get('symbol', '').replace('USDT', '') orders_to_close = decision.get('orders_to_close', []) - if not orders_to_close: - # 平掉所有持仓 - result = self.bitget.market_close_all() - logger.info(f" ✅ 平仓所有持仓") + # Bitget 持仓是按 symbol 聚合管理,不能按 order_id 精确平仓。 + result = self.bitget.market_close_position(symbol) + if result.get('success'): + logger.info(f" ✅ 平仓成功: {symbol}") + else: + logger.warning(f" ⚠️ 平仓失败: {symbol} - {result.get('error', '未知错误')}") - # 发送飞书通知 - await self.send_execution_notification( - operation='CLOSE', - symbol=symbol, - result=result - ) - - return result - - # 平掉指定订单 - results = [] - for order_id in orders_to_close: - # 这里需要根据 order_id 找到对应的持仓 - # Bitget 的持仓管理需要优化 - results.append({'order_id': order_id, 'success': True}) - - success_result = {'success': True, 'results': results} + if orders_to_close: + result['requested_order_ids'] = orders_to_close # 发送飞书通知 await self.send_execution_notification( operation='CLOSE', symbol=symbol, - result=success_result + result=result ) - return success_result + return result except Exception as e: logger.error(f"Bitget 平仓失败: {e}") @@ -169,7 +156,8 @@ class BitgetExecutor(BaseExecutor): """执行撤单""" try: result = self.bitget.cancel_order(symbol.replace('USDT', ''), order_id) - logger.info(f" ✅ 撤单成功: {order_id}") + if result.get('success'): + logger.info(f" ✅ 撤单成功: {order_id}") # 发送飞书通知 await self.send_execution_notification( @@ -306,17 +294,24 @@ class BitgetExecutor(BaseExecutor): {'success': bool, 'message': str} """ try: - # Bitget 使用 modify_sl_tp 方法 - success = self.bitget.modify_sl_tp( + position = self.bitget.get_position_for_symbol(symbol) + if not position: + return {'success': False, 'message': f'找不到 {symbol} 的持仓'} + + tp_sl_prices = self.bitget.get_tp_sl_prices(symbol.replace('USDT', '')) + result = self.bitget.set_tp_sl( symbol=symbol.replace('USDT', ''), - stop_loss=new_stop_loss + is_long=position['size'] > 0, + size=abs(position['size']), + tp_price=tp_sl_prices.get('take_profit'), + sl_price=new_stop_loss ) - if success: + if result.get('success'): logger.info(f" ✅ 移动止损成功: {symbol} → ${new_stop_loss:.2f}") return {'success': True, 'message': f'移动止损成功: {new_stop_loss:.2f}'} else: - return {'success': False, 'message': '移动止损失败'} + return {'success': False, 'message': result.get('error', result.get('message', '移动止损失败'))} except Exception as e: logger.error(f"Bitget 移动止损失败: {e}") diff --git a/backend/app/crypto_agent/executor/hyperliquid_executor.py b/backend/app/crypto_agent/executor/hyperliquid_executor.py index b70d6ce..71d7f56 100644 --- a/backend/app/crypto_agent/executor/hyperliquid_executor.py +++ b/backend/app/crypto_agent/executor/hyperliquid_executor.py @@ -129,39 +129,23 @@ class HyperliquidExecutor(BaseExecutor): symbol = decision.get('symbol', '').replace('USDT', '') orders_to_close = decision.get('orders_to_close', []) - if not orders_to_close: - # 平掉所有持仓 - result = self.hyperliquid.market_close_all() - logger.info(f" ✅ 平仓所有持仓") + result = self.hyperliquid.market_close_position(symbol) + if result.get('success'): + logger.info(f" ✅ 平仓成功: {symbol}") + else: + logger.warning(f" ⚠️ 平仓失败: {symbol} - {result.get('error', '未知错误')}") - # 发送飞书通知 - await self.send_execution_notification( - operation='CLOSE', - symbol=symbol, - result=result - ) - - return result - - # 平掉指定订单 - results = [] - for order_id in orders_to_close: - close_result = self.hyperliquid.close_position(symbol, order_id) - results.append({ - 'order_id': order_id, - 'success': close_result.get('success', False) - }) - - success_result = {'success': True, 'results': results} + if orders_to_close: + result['requested_order_ids'] = orders_to_close # 发送飞书通知 await self.send_execution_notification( operation='CLOSE', symbol=symbol, - result=success_result + result=result ) - return success_result + return result except Exception as e: logger.error(f"Hyperliquid 平仓失败: {e}") @@ -320,9 +304,16 @@ class HyperliquidExecutor(BaseExecutor): {'success': bool, 'message': str} """ try: - # Hyperliquid 使用 set_tp_sl 方法(只传 sl_price) + position = self.hyperliquid.get_position_for_symbol(symbol) + if not position: + return {'success': False, 'message': f'找不到 {symbol} 的持仓'} + + tp_sl_prices = self.hyperliquid.get_tp_sl_prices(symbol.replace('USDT', '')) result = self.hyperliquid.set_tp_sl( symbol=symbol.replace('USDT', ''), + is_long=position['size'] > 0, + size=abs(position['size']), + tp_price=tp_sl_prices.get('take_profit'), sl_price=new_stop_loss ) @@ -330,7 +321,7 @@ class HyperliquidExecutor(BaseExecutor): logger.info(f" ✅ 移动止损成功: {symbol} → ${new_stop_loss:.2f}") return {'success': True, 'message': f'移动止损成功: {new_stop_loss:.2f}'} else: - return {'success': False, 'message': result.get('message', '移动止损失败')} + return {'success': False, 'message': result.get('error', result.get('message', '移动止损失败'))} except Exception as e: logger.error(f"Hyperliquid 移动止损失败: {e}") diff --git a/backend/app/services/bitget_live_trading_service.py b/backend/app/services/bitget_live_trading_service.py index 9c5a234..e63c4e7 100644 --- a/backend/app/services/bitget_live_trading_service.py +++ b/backend/app/services/bitget_live_trading_service.py @@ -5,6 +5,7 @@ Bitget 实盘交易服务 供 crypto_agent.py 的决策执行层使用。 """ import math +from datetime import datetime from typing import Dict, List, Optional, Any from app.config import get_settings @@ -91,6 +92,7 @@ class BitgetLiveTradingService: return { "account_value": account_value, + "current_balance": account_value, "total_margin_used": frozen, "available_balance": available, } @@ -170,14 +172,25 @@ class BitgetLiveTradingService: side = pos.get('side', 'long') size = coin_amount if side == 'long' else -coin_amount + tp_sl_prices = self.get_tp_sl_prices(coin) + opened_at_raw = info.get('cTime') or pos.get('timestamp') + if isinstance(opened_at_raw, (int, float)): + opened_at = datetime.fromtimestamp(opened_at_raw / 1000).isoformat() + else: + opened_at = pos.get('datetime') or opened_at_raw result.append({ "coin": coin, + "symbol": f"{coin}USDT", + "side": "buy" if size > 0 else "sell", "size": size, "entry_price": float(pos.get('entryPrice', 0) or 0), "unrealized_pnl": float(pos.get('unrealizedPnl', 0) or 0), "leverage": int(float(pos.get('leverage', 1) or 1)), "liquidation_price": float(pos.get('liquidationPrice', 0) or 0) or None, + "stop_loss": tp_sl_prices.get('stop_loss'), + "take_profit": tp_sl_prices.get('take_profit'), + "opened_at": opened_at, "position": pos, }) return result @@ -409,6 +422,11 @@ class BitgetLiveTradingService: contracts = float(order.get('amount', 0) or 0) contract_size = self.get_contract_size(coin) size_in_coins = contracts # ccxt amount 已是币数量 + created_at_raw = order.get('timestamp') + if isinstance(created_at_raw, (int, float)): + created_at = datetime.fromtimestamp(created_at_raw / 1000).isoformat() + else: + created_at = order.get('datetime') or created_at_raw result.append({ "order_id": str(order.get('id', '')), @@ -418,9 +436,27 @@ class BitgetLiveTradingService: "price": float(order.get('price', 0) or 0), "is_reduce_only": bool(order.get('reduceOnly', False)), "order_type": order.get('type', ''), + "created_at": created_at, }) return result + def cancel_order(self, symbol: str, order_id: str) -> Dict[str, Any]: + """ + 撤销单个挂单 + + Returns: + {"success": bool, "order_id": str, "error"?: str} + """ + try: + success = self.trading_api.cancel_order(symbol=symbol, order_id=order_id) + if success: + logger.info(f"✅ Bitget 单笔撤单成功: {symbol} #{order_id}") + return {"success": True, "order_id": str(order_id), "symbol": symbol} + return {"success": False, "order_id": str(order_id), "error": "cancel_order 返回 False"} + except Exception as e: + logger.error(f"❌ Bitget 单笔撤单失败: {symbol} #{order_id} {e}") + return {"success": False, "order_id": str(order_id), "error": str(e)} + def cancel_all_orders(self, symbol: Optional[str] = None) -> Dict[str, Any]: """ 撤销指定币种的所有挂单 @@ -493,47 +529,57 @@ class BitgetLiveTradingService: def market_close_all(self) -> Dict[str, Any]: """市价平仓所有持仓""" - import math results = [] positions = self.get_open_positions() for pos in positions: - coin = pos['coin'] - is_long = pos['size'] > 0 - coin_amount = abs(pos['size']) - - # 精度处理:向下取整到 0.0001(Bitget 最小精度) - coin_amount = math.floor(coin_amount * 10000) / 10000 - - if coin_amount < 0.0001: - logger.warning(f"{coin} 持仓过小 ({coin_amount}),跳过") - continue - - # 直接使用币数量平仓,不经过合约转换 - try: - ccxt_symbol = self.trading_api._standardize_symbol(coin + 'USDT') - side = 'sell' if is_long else 'buy' - order = self.trading_api.exchange.create_market_order( - symbol=ccxt_symbol, - side=side, - amount=coin_amount, - params={ - 'reduceOnly': True, - 'tdMode': 'cross', - 'marginCoin': 'USDT', - } - ) - if order: - logger.info(f"✅ Bitget 平仓成功: {coin} {side} {coin_amount}") - results.append({"success": True, "coin": coin, "size": coin_amount}) - else: - results.append({"success": False, "coin": coin, "error": "返回空"}) - except Exception as e: - logger.error(f"❌ Bitget 平仓失败: {coin} {e}") - results.append({"success": False, "coin": coin, "error": str(e)}) + results.append(self.market_close_position(pos['coin'])) all_ok = all(r.get('success') for r in results) return {"success": all_ok, "results": results} + def market_close_position(self, symbol: str) -> Dict[str, Any]: + """按交易对市价平仓单个持仓""" + import math + + coin = symbol.replace('USDT', '').replace('/', '').upper() + position = self.get_position_for_symbol(coin) + if not position: + return {"success": False, "coin": coin, "error": "未找到持仓"} + + is_long = position['size'] > 0 + coin_amount = math.floor(abs(position['size']) * 10000) / 10000 + if coin_amount < 0.0001: + logger.warning(f"{coin} 持仓过小 ({coin_amount}),跳过") + return {"success": True, "coin": coin, "size": 0} + + self.cancel_tp_sl_orders(coin) + + try: + ccxt_symbol = self.trading_api._standardize_symbol(coin + 'USDT') + side = 'sell' if is_long else 'buy' + order = self.trading_api.exchange.create_market_order( + symbol=ccxt_symbol, + side=side, + amount=coin_amount, + params={ + 'reduceOnly': True, + 'tdMode': 'cross', + 'marginCoin': 'USDT', + } + ) + if order: + logger.info(f"✅ Bitget 单币种平仓成功: {coin} {side} {coin_amount}") + return { + "success": True, + "coin": coin, + "size": coin_amount, + "order_id": str(order.get('id', '')), + } + return {"success": False, "coin": coin, "error": "返回空"} + except Exception as e: + logger.error(f"❌ Bitget 单币种平仓失败: {coin} {e}") + return {"success": False, "coin": coin, "error": str(e)} + # ==================== 单例工厂 ==================== diff --git a/backend/app/services/hyperliquid_trading_service.py b/backend/app/services/hyperliquid_trading_service.py index ed874bb..bd0a40b 100644 --- a/backend/app/services/hyperliquid_trading_service.py +++ b/backend/app/services/hyperliquid_trading_service.py @@ -79,6 +79,7 @@ class HyperliquidTradingService: return { "account_value": account_value, + "current_balance": account_value, "total_margin_used": total_margin_used, "available_balance": account_value - total_margin_used, "positions": state.get("assetPositions", []), @@ -324,7 +325,9 @@ class HyperliquidTradingService: "order_type": order.get("orderType", {}), "timestamp": order.get("timestamp"), "original_size": float(order.get("origSz", 0)), - "raw_side": side + "raw_side": side, + "created_at": datetime.fromtimestamp(order.get("timestamp", 0) / 1000).isoformat() + if order.get("timestamp") else None, }) return orders @@ -476,6 +479,8 @@ class HyperliquidTradingService: def cancel_order(self, symbol: str, order_id: int) -> Dict[str, Any]: """取消订单""" try: + if isinstance(order_id, str): + order_id = int(order_id) result = self.exchange.cancel(symbol, order_id) logger.info(f"取消订单: {symbol} #{order_id}") return {"success": True, "result": result} @@ -515,29 +520,46 @@ class HyperliquidTradingService: for pos in positions: position_data = pos.get("position", {}) coin = position_data.get("coin") - size = float(position_data.get("szi", 0)) - - if size == 0: + if not coin: continue + results.append(self.market_close_position(coin)) - # 取消该币种的所有挂单(包括止盈止损) - self.cancel_all_orders(coin) - - is_long = size > 0 - result = self.place_market_order( - symbol=coin, - is_buy=not is_long, # 平多头=卖出,平空头=买入 - size=abs(size), - reduce_only=True - ) - results.append(result) - + all_ok = all(result.get("success") for result in results) logger.info(f"🚨 紧急平仓完成,共平仓 {len(results)} 个持仓") - return {"success": True, "closed_positions": len(results), "results": results} + return {"success": all_ok, "closed_positions": len(results), "results": results} except Exception as e: logger.error(f"紧急平仓失败: {e}") return {"success": False, "error": str(e)} + def market_close_position(self, symbol: str) -> Dict[str, Any]: + """按交易对市价平仓单个持仓""" + position = self.get_position_for_symbol(symbol) + if not position: + return {"success": False, "symbol": symbol, "error": "未找到持仓"} + + coin = position.get("coin", symbol.replace('USDT', '').replace('/', '').upper()) + size = abs(float(position.get("size", 0))) + if size <= 0: + return {"success": True, "symbol": coin, "size": 0} + + self.cancel_all_orders(coin) + + is_long = position.get("size", 0) > 0 + result = self.place_market_order( + symbol=coin, + is_buy=not is_long, + size=size, + reduce_only=True + ) + + if result.get("success"): + result["symbol"] = coin + return result + + def close_position(self, symbol: str, order_id: Optional[int] = None) -> Dict[str, Any]: + """兼容旧调用:按交易对平仓,忽略 order_id""" + return self.market_close_position(symbol) + def get_open_positions(self) -> List[Dict[str, Any]]: """获取所有持仓""" try: @@ -552,13 +574,21 @@ class HyperliquidTradingService: if size == 0: continue + tp_sl_prices = self.get_tp_sl_prices(coin) + positions.append({ "coin": coin, + "symbol": f"{coin}USDT", + "side": "buy" if size > 0 else "sell", "size": size, # 正数=多头,负数=空头 "entry_price": float(position_data.get("entryPx", 0)), "unrealized_pnl": float(position_data.get("unrealizedPnl", 0)), "leverage": position_data.get("leverage", {}).get("value"), "liquidation_price": position_data.get("liquidationPx"), + "stop_loss": tp_sl_prices.get("stop_loss"), + "take_profit": tp_sl_prices.get("take_profit"), + "opened_at": datetime.fromtimestamp(position_data.get("timestamp", 0) / 1000).isoformat() + if position_data.get("timestamp") else None, "position": position_data # 保留原始数据 }) @@ -569,9 +599,10 @@ class HyperliquidTradingService: def get_position_for_symbol(self, symbol: str) -> Optional[Dict[str, Any]]: """获取指定币种的持仓""" + normalized_symbol = symbol.replace('USDT', '').replace('/', '').upper() positions = self.get_open_positions() for pos in positions: - if pos["coin"] == symbol: + if pos["coin"] == normalized_symbol: return pos return None diff --git a/backend/app/services/paper_trading_service.py b/backend/app/services/paper_trading_service.py index fbc663a..0faee03 100644 --- a/backend/app/services/paper_trading_service.py +++ b/backend/app/services/paper_trading_service.py @@ -1604,6 +1604,38 @@ class PaperTradingService: finally: db.close() + def get_open_orders(self, symbol: Optional[str] = None) -> List[Dict[str, Any]]: + """兼容执行层接口:返回未成交挂单列表""" + orders = self.get_active_orders(symbol) + return [order for order in orders if order.get('status') == OrderStatus.PENDING.value] + + def get_open_positions(self, symbol: Optional[str] = None) -> List[Dict[str, Any]]: + """兼容执行层接口:返回已成交持仓列表""" + orders = self.get_active_orders(symbol) + positions = [] + + for order in orders: + if order.get('status') != OrderStatus.OPEN.value: + continue + + side = order.get('side') + positions.append({ + 'order_id': order.get('order_id'), + 'symbol': order.get('symbol'), + 'side': 'buy' if side == 'long' else 'sell', + 'entry_price': order.get('filled_price') or order.get('entry_price') or 0, + 'filled_price': order.get('filled_price'), + 'quantity': order.get('quantity', 0), + 'margin': order.get('margin', 0), + 'stop_loss': order.get('stop_loss'), + 'take_profit': order.get('take_profit'), + 'unrealized_pnl_pct': order.get('pnl_percent', 0), + 'opened_at': order.get('opened_at'), + 'created_at': order.get('created_at'), + }) + + return positions + def get_order_by_id(self, order_id: str) -> Optional[Dict[str, Any]]: """根据ID获取订单""" # 先从缓存查找 @@ -1849,6 +1881,10 @@ class PaperTradingService: 'total_pnl_percent': round((realized_pnl / self.initial_balance * 100), 2) if self.initial_balance > 0 else 0 } + def get_account_state(self) -> Dict[str, Any]: + """兼容执行层接口:账户状态别名""" + return self.get_account_status() + def _calculate_grade_statistics(self, orders: List[PaperOrder]) -> Dict[str, Any]: """按信号等级统计""" result = {} diff --git a/backend/tests/test_execution_safety_fixes.py b/backend/tests/test_execution_safety_fixes.py new file mode 100644 index 0000000..9e66d2e --- /dev/null +++ b/backend/tests/test_execution_safety_fixes.py @@ -0,0 +1,158 @@ +""" +执行链安全修复回归测试 + +覆盖重点: + - Bitget 单币种平仓,不再误触发全仓平仓 + - Bitget 单笔撤单接口可用 + - Bitget 移动止损走 set_tp_sl,而不是不存在的方法 +""" +import asyncio +import importlib.util +import os +import sys +import types +from pathlib import Path +from unittest.mock import AsyncMock, MagicMock + + +sys.path.insert(0, os.path.join(os.path.dirname(__file__), '..')) + + +def make_bitget_service(): + from app.services.bitget_live_trading_service import BitgetLiveTradingService + + mock_api = MagicMock() + mock_exchange = MagicMock() + mock_api.exchange = mock_exchange + mock_api._standardize_symbol = lambda s: f"{s.replace('USDT', '')}/USDT:USDT" + + mock_settings = MagicMock() + mock_settings.bitget_max_total_leverage = 10.0 + mock_settings.bitget_max_single_position = 1000.0 + mock_settings.hyperliquid_circuit_breaker_drawdown = 0.10 + + service = BitgetLiveTradingService.__new__(BitgetLiveTradingService) + service.settings = mock_settings + service.max_total_leverage = mock_settings.bitget_max_total_leverage + service.max_single_position = mock_settings.bitget_max_single_position + service.circuit_breaker_drawdown = mock_settings.hyperliquid_circuit_breaker_drawdown + service.trading_api = mock_api + service.initial_balance = 10000.0 + + return service, mock_api + + +def load_bitget_executor_class(): + """按文件加载执行器,避免触发 app.crypto_agent.__init__ 的重依赖""" + executor_dir = Path(__file__).resolve().parents[1] / 'app' / 'crypto_agent' / 'executor' + + if 'app.crypto_agent' not in sys.modules: + crypto_pkg = types.ModuleType('app.crypto_agent') + crypto_pkg.__path__ = [str(executor_dir.parent)] + sys.modules['app.crypto_agent'] = crypto_pkg + + if 'app.crypto_agent.executor' not in sys.modules: + executor_pkg = types.ModuleType('app.crypto_agent.executor') + executor_pkg.__path__ = [str(executor_dir)] + sys.modules['app.crypto_agent.executor'] = executor_pkg + + if 'app.crypto_agent.executor.base_executor' not in sys.modules: + base_spec = importlib.util.spec_from_file_location( + 'app.crypto_agent.executor.base_executor', + executor_dir / 'base_executor.py', + ) + base_module = importlib.util.module_from_spec(base_spec) + sys.modules[base_spec.name] = base_module + base_spec.loader.exec_module(base_module) + + if 'app.crypto_agent.executor.bitget_executor' not in sys.modules: + executor_spec = importlib.util.spec_from_file_location( + 'app.crypto_agent.executor.bitget_executor', + executor_dir / 'bitget_executor.py', + ) + executor_module = importlib.util.module_from_spec(executor_spec) + sys.modules[executor_spec.name] = executor_module + executor_spec.loader.exec_module(executor_module) + + return sys.modules['app.crypto_agent.executor.bitget_executor'].BitgetExecutor + + +def test_bitget_market_close_position_only_closes_requested_symbol(): + service, mock_api = make_bitget_service() + mock_api.get_position.return_value = [ + { + 'symbol': 'BTC/USDT:USDT', + 'side': 'long', + 'entryPrice': 50000.0, + 'unrealizedPnl': 100.0, + 'leverage': 5, + 'liquidationPrice': 45000.0, + 'info': {'available': '0.02'}, + }, + { + 'symbol': 'ETH/USDT:USDT', + 'side': 'long', + 'entryPrice': 3000.0, + 'unrealizedPnl': 50.0, + 'leverage': 5, + 'liquidationPrice': 2500.0, + 'info': {'available': '0.5'}, + }, + ] + mock_api.get_open_orders.return_value = [] + mock_api.cancel_all_orders.return_value = True + mock_api.exchange.create_market_order.return_value = {'id': 'close-btc'} + + result = service.market_close_position('BTCUSDT') + + assert result['success'] is True + assert result['coin'] == 'BTC' + kwargs = mock_api.exchange.create_market_order.call_args.kwargs + assert kwargs['symbol'] == 'BTC/USDT:USDT' + assert kwargs['side'] == 'sell' + + +def test_bitget_cancel_order_delegates_to_sdk(): + service, mock_api = make_bitget_service() + mock_api.cancel_order.return_value = True + + result = service.cancel_order('BTC', 'oid-1') + + assert result['success'] is True + mock_api.cancel_order.assert_called_once_with(symbol='BTC', order_id='oid-1') + + +def test_bitget_executor_close_uses_symbol_close_not_close_all(): + BitgetExecutor = load_bitget_executor_class() + + executor = BitgetExecutor.__new__(BitgetExecutor) + executor.bitget = MagicMock() + executor.send_execution_notification = AsyncMock() + executor.bitget.market_close_position.return_value = {'success': True, 'coin': 'BTC'} + + result = asyncio.run(executor.execute_close({'symbol': 'BTCUSDT'}, 50000.0)) + + assert result['success'] is True + executor.bitget.market_close_position.assert_called_once_with('BTC') + executor.bitget.market_close_all.assert_not_called() + + +def test_bitget_executor_move_stop_loss_uses_set_tp_sl(): + BitgetExecutor = load_bitget_executor_class() + + executor = BitgetExecutor.__new__(BitgetExecutor) + executor.bitget = MagicMock() + executor.bitget.get_position_for_symbol.return_value = {'size': 0.02} + executor.bitget.get_tp_sl_prices.return_value = {'take_profit': 55000.0} + executor.bitget.set_tp_sl.return_value = {'success': True} + + result = asyncio.run(executor.move_stop_loss('BTCUSDT', 49000.0)) + + assert result['success'] is True + executor.bitget.set_tp_sl.assert_called_once_with( + symbol='BTC', + is_long=True, + size=0.02, + tp_price=55000.0, + sl_price=49000.0, + )