""" Bitget 实盘交易服务 封装 Bitget U 本位合约实盘交易能力,供 crypto_agent.py 的执行层使用。 """ import math from datetime import datetime from typing import Dict, List, Optional, Any from app.config import get_settings from app.services.bitget_trading_api_sdk import get_bitget_trading_api from app.utils.logger import logger # 合约面值表(张 → 币数量) CONTRACT_SIZES: Dict[str, float] = { 'BTC': 0.01, 'ETH': 0.1, 'LTC': 0.1, 'BCH': 0.1, 'BNB': 0.1, 'SOL': 1.0, 'AVAX': 1.0, 'LINK': 1.0, 'UNI': 1.0, 'ATOM': 1.0, 'FIL': 1.0, 'DOT': 1.0, 'XRP': 10.0, 'DOGE': 100.0, 'MATIC': 10.0, 'POL': 10.0, } class BitgetLiveTradingService: """ Bitget 实盘交易服务 Bitget 实盘交易服务,统一处理账户、持仓、下单和保护单。 """ @staticmethod def _safe_float(*values: Any) -> float: for value in values: if value in (None, ''): continue try: return float(value) except (TypeError, ValueError): continue return 0.0 def __init__(self, account_id: str = "default", trading_api: Any = None): self.account_id = (account_id or "default").strip() or "default" self.settings = get_settings() self.max_total_leverage: float = self.settings.bitget_max_total_leverage self.max_single_position: float = self.settings.bitget_max_single_position self.circuit_breaker_drawdown: float = self.settings.account_max_drawdown self.trading_api = trading_api or get_bitget_trading_api(self.account_id) if not self.trading_api: raise RuntimeError(f"Bitget 交易 API 初始化失败,请检查账号 {self.account_id} 的 API Key 配置") # 初始余额(用于回撤计算) self.initial_balance: Optional[float] = None self._initialize_account() logger.info( f"✅ BitgetLiveTradingService 初始化完成 " f"(account={self.account_id}, " f"(最大总杠杆: {self.max_total_leverage}x, " f"单笔上限: ${self.max_single_position}, " f"熔断阈值: {self.circuit_breaker_drawdown * 100:.0f}%)" ) def _initialize_account(self): """初始化账户状态,记录初始余额""" try: state = self.get_account_state() self.initial_balance = state["account_value"] logger.info(f"Bitget 初始账户余额: ${self.initial_balance:.2f}") except Exception as e: logger.warning(f"Bitget 初始化账户余额失败: {e}") # ==================== 账户 ==================== def get_account_state(self) -> Dict[str, Any]: """ 获取账户状态 Returns: { "account_value": float, # 账户总价值(USDT) "total_margin_used": float, # 已用保证金 "available_balance": float, # 可用余额 } """ balance = self.trading_api.get_balance() account_tag = f"[Bitget:{getattr(self, 'account_id', 'default')}]" if not balance: logger.warning(f"{account_tag} get_balance() 返回空,API 调用可能失败") else: logger.debug(f"{account_tag} get_balance 原始返回: {balance}") usdt = balance.get('USDT') or balance.get('usdt') or {} if not usdt: for currency, asset in balance.items(): if str(currency).upper() == 'USDT': usdt = asset break available = self._safe_float( usdt.get('available'), usdt.get('availableBalance'), usdt.get('crossedMaxAvailable'), usdt.get('maxTransferOut'), usdt.get('free'), ) frozen = self._safe_float( usdt.get('frozen'), usdt.get('locked'), usdt.get('occupied'), usdt.get('used'), ) equity = self._safe_float( usdt.get('equity'), usdt.get('accountEquity'), usdt.get('usdtEquity'), usdt.get('balance'), usdt.get('accountBalance'), usdt.get('totalEquity'), ) account_value = equity if equity > 0 else (available + frozen) if available <= 0 and account_value > 0: inferred_available = max(account_value - frozen, 0.0) if inferred_available > 0: logger.warning( f"{account_tag} 可用余额字段缺失,使用 account_value - frozen 回退: " f"${account_value:.2f} - ${frozen:.2f} = ${inferred_available:.2f}" ) available = inferred_available logger.info( f"{account_tag} 账户状态: available=${available:.2f}, " f"frozen=${frozen:.2f}, equity=${equity:.2f}, account_value=${account_value:.2f}" ) return { "account_value": account_value, "current_balance": account_value, "total_margin_used": frozen, "available_balance": available, } def check_risk_limits(self) -> Dict[str, Any]: """ 检查风控限制 Returns: {"allowed": bool, "reason": str} """ try: state = self.get_account_state() account_value = state["account_value"] # 1. 熔断检查 if self.initial_balance and self.initial_balance > 0: drawdown = (self.initial_balance - account_value) / self.initial_balance if drawdown >= self.circuit_breaker_drawdown: return { "allowed": False, "reason": f"熔断触发: 回撤 {drawdown * 100:.1f}% >= 阈值 {self.circuit_breaker_drawdown * 100:.0f}%" } # 2. 总杠杆检查 positions = self.get_open_positions() total_position_value = sum( abs(p["size"]) * p["entry_price"] for p in positions ) if account_value > 0: current_leverage = total_position_value / account_value if current_leverage >= self.max_total_leverage: return { "allowed": False, "reason": f"总杠杆超限: {current_leverage:.1f}x >= {self.max_total_leverage}x" } return {"allowed": True, "reason": ""} except Exception as e: logger.error(f"Bitget 风控检查异常: {e}") return {"allowed": False, "reason": f"风控检查异常: {e}"} # ==================== 持仓 ==================== def get_open_positions(self) -> List[Dict[str, Any]]: """ 获取所有持仓 Returns: [ { "coin": "BTC", "size": float, # 正数=多, 负数=空(以币为单位) "entry_price": float, "unrealized_pnl": float, "leverage": int, "liquidation_price": Optional[float], "position": dict, # 原始数据 } ] """ raw_positions = self.trading_api.get_position() result = [] for pos in raw_positions: # 使用 info.available 获取实际持仓量(币数量) # 注意:CCXT 的 'contracts' 字段对于 Bitget 实际上已经是币数量,不是张数 info = pos.get('info', {}) available = float(info.get('available', 0)) if available == 0: continue symbol_raw = pos.get('symbol', '') # e.g. "BTC/USDT:USDT" coin = symbol_raw.split('/')[0] if '/' in symbol_raw else symbol_raw.replace('/USDT:USDT', '') # available 已经是币数量,不需要再乘以 contract_size coin_amount = available 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 def get_position_for_symbol(self, symbol: str) -> Optional[Dict[str, Any]]: """获取指定币种的持仓,无持仓返回 None""" coin = symbol.replace('USDT', '').replace('/', '').upper() for pos in self.get_open_positions(): if pos['coin'] == coin: return pos return None # ==================== 下单 ==================== def place_market_order( self, symbol: str, is_buy: bool, size: int, reduce_only: bool = False ) -> Dict[str, Any]: """ 市价下单 Args: symbol: 币种,如 "BTC" is_buy: True=买入/做多, False=卖出/做空 size: 合约数量(张) reduce_only: 是否只减仓 Returns: {"success": bool, "order_id": str, "symbol": str, "side": str, "size": int, "error"?: str} """ try: side = 'buy' if is_buy else 'sell' params = {} if reduce_only: params['reduceOnly'] = True ccxt_symbol = self.trading_api._standardize_symbol(symbol) contract_size = self.get_contract_size(symbol) actual_amount = size * contract_size order = self.trading_api.exchange.create_order( symbol=ccxt_symbol, type='market', side=side, amount=actual_amount, params=self.trading_api._with_account_mode_params( { 'hedged': True, 'marginCoin': 'USDT', **({'reduceOnly': True} if reduce_only else {}), } if self.trading_api.use_unified_account else { 'tdMode': 'cross', 'marginCoin': 'USDT', 'holdMode': 'oneWay', **params, } ) ) if not order: return {"success": False, "error": "下单返回空", "symbol": symbol, "side": side, "size": size} logger.info(f"✅ Bitget 市价单成功: {symbol} {side} {size}张") return { "success": True, "order_id": str(order.get('id', '')), "symbol": symbol, "side": side, "size": size, } except Exception as e: logger.error(f"❌ Bitget 市价单失败: {symbol} {e}") return {"success": False, "error": str(e), "symbol": symbol, "side": "buy" if is_buy else "sell", "size": size} def place_limit_order( self, symbol: str, is_buy: bool, size: int, price: float, reduce_only: bool = False ) -> Dict[str, Any]: """ 限价下单 Returns: { "success": bool, "order_status": "resting" | "filled", "order_id": str, "symbol": str, "side": str, "size": int, "price": float, "error"?: str } """ try: side = 'buy' if is_buy else 'sell' if self.trading_api.use_unified_account: order_params = { 'hedged': True, 'marginCoin': 'USDT', } if reduce_only: order_params['reduceOnly'] = True else: order_params = { 'tdMode': 'cross', 'marginCoin': 'USDT', 'holdMode': 'oneWay', } if reduce_only: order_params['reduceOnly'] = True ccxt_symbol = self.trading_api._standardize_symbol(symbol) contract_size = self.get_contract_size(symbol) actual_amount = size * contract_size order = self.trading_api.exchange.create_order( symbol=ccxt_symbol, type='limit', side=side, amount=actual_amount, price=price, params=self.trading_api._with_account_mode_params(order_params) ) if not order: return { "success": False, "error": "下单返回空", "symbol": symbol, "side": side, "size": size, "price": price, } # 判断订单状态:open → resting(挂单中),closed → filled(立即成交) raw_status = order.get('status', 'open') order_status = 'filled' if raw_status == 'closed' else 'resting' logger.info(f"✅ Bitget 限价单: {symbol} {side} {size}张 @ {price} [{order_status}]") return { "success": True, "order_status": order_status, "order_id": str(order.get('id', '')), "symbol": symbol, "side": side, "size": size, "price": price, } except Exception as e: logger.error(f"❌ Bitget 限价单失败: {symbol} {e}") return { "success": False, "error": str(e), "symbol": symbol, "side": "buy" if is_buy else "sell", "size": size, "price": price, } # ==================== 止盈止损 ==================== def set_tp_sl( self, symbol: str, is_long: bool, size: int, tp_price: Optional[float] = None, sl_price: Optional[float] = None ) -> Dict[str, Any]: """ 设置止盈止损 Returns: {"success": bool, "tp_set": bool, "sl_set": bool, "errors": [...]} """ try: result = self.trading_api.modify_sl_tp( symbol=symbol, stop_loss=sl_price, take_profit=tp_price, ) if result.get("success"): logger.info(f"✅ Bitget TP/SL 设置成功: {symbol} TP={tp_price} SL={sl_price}") else: errors = result.get("errors", []) tp_set = result.get("tp_set", False) sl_set = result.get("sl_set", False) if tp_set or sl_set: logger.warning(f"⚠️ Bitget TP/SL 部分成功: {symbol} tp_set={tp_set} sl_set={sl_set} errors={errors}") else: logger.error(f"❌ Bitget TP/SL 设置失败: {symbol} errors={errors}") return result except Exception as e: logger.error(f"❌ Bitget 设置 TP/SL 失败: {symbol} {e}") return {"success": False, "tp_set": False, "sl_set": False, "errors": [str(e)]} def get_tp_sl_prices(self, symbol: str) -> Dict[str, Optional[float]]: """ 从挂单中读取止盈止损价格 Returns: {"take_profit": float|None, "stop_loss": float|None} """ result = {"take_profit": None, "stop_loss": None} try: orders = self.trading_api.get_open_orders(symbol) for order in orders: if not order.get('reduceOnly'): continue order_side = order.get('side', '') info = order.get('info') or {} price = self._safe_float( order.get('price'), order.get('stopPrice'), order.get('triggerPrice'), info.get('triggerPrice'), info.get('executePrice'), info.get('stopSurplusTriggerPrice'), info.get('stopLossTriggerPrice'), info.get('takeProfit'), info.get('stopLoss'), ) order_type = order.get('type', '') plan_type = str(info.get('planType') or info.get('strategyType') or '').lower() take_profit_price = self._safe_float( info.get('takeProfit'), info.get('tpTriggerPrice'), info.get('stopSurplusTriggerPrice'), ) stop_loss_price = self._safe_float( info.get('stopLoss'), info.get('slTriggerPrice'), info.get('stopLossTriggerPrice'), ) if take_profit_price: result['take_profit'] = take_profit_price if stop_loss_price: result['stop_loss'] = stop_loss_price # 兼容独立 TP/SL 策略单和经典账户 reduce-only 挂单。 if 'loss' in plan_type or 'stop_loss' in order_type.lower() or 'stop-loss' in order_type.lower(): result['stop_loss'] = stop_loss_price or price elif 'profit' in plan_type or 'take_profit' in order_type.lower() or 'take-profit' in order_type.lower(): result['take_profit'] = take_profit_price or price elif order_type == 'uta_tpsl': continue elif 'stop' in order_type.lower(): result['stop_loss'] = price elif order_type == 'limit' and price: result['take_profit'] = price except Exception as e: logger.warning(f"Bitget 获取 TP/SL 价格失败: {symbol} {e}") return result # ==================== 挂单管理 ==================== def get_open_orders(self, symbol: Optional[str] = None) -> List[Dict[str, Any]]: """ 获取挂单列表 Returns: [{"order_id", "symbol", "side", "size", "price", "is_reduce_only"}] """ raw_orders = self.trading_api.get_open_orders(symbol) result = [] for order in raw_orders: sym_raw = order.get('symbol', '') coin = sym_raw.split('/')[0] if '/' in sym_raw else sym_raw 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 info = order.get('info') or {} display_price = self._safe_float( order.get('price'), order.get('stopPrice'), order.get('triggerPrice'), info.get('takeProfit'), info.get('stopLoss'), ) result.append({ "order_id": str(order.get('id', '')), "symbol": coin, "side": order.get('side', ''), "size": size_in_coins, "price": display_price, "is_reduce_only": bool(order.get('reduceOnly', False)), "order_type": order.get('type', ''), "take_profit": info.get('takeProfit') or info.get('tpTriggerPrice'), "stop_loss": info.get('stopLoss') or info.get('slTriggerPrice'), "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: open_orders = self.get_open_orders(symbol) normalized_order_id = str(order_id) matched_order = next((o for o in open_orders if str(o.get('order_id', '')) == normalized_order_id), None) if not matched_order: logger.info(f"ℹ️ Bitget 挂单已不存在,视为撤单完成: {symbol} #{order_id}") return { "success": True, "order_id": normalized_order_id, "symbol": symbol, "already_closed": True, "message": "订单已不在挂单列表,可能已成交、已撤销或已失效", } 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": normalized_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]: """ 撤销指定币种的所有挂单 Returns: {"success": bool, "cancelled": int, "error"?: str} """ try: success = self.trading_api.cancel_all_orders(symbol or '') if success: logger.info(f"✅ Bitget 撤销挂单成功: {symbol or '全部'}") return {"success": True, "cancelled": 1} else: return {"success": False, "cancelled": 0, "error": "cancel_all_orders 返回 False"} except Exception as e: logger.error(f"❌ Bitget 撤销挂单失败: {symbol} {e}") return {"success": False, "cancelled": 0, "error": str(e)} def cancel_tp_sl_orders(self, symbol: str) -> Dict[str, Any]: """撤销止盈止损单(reduce-only 挂单)""" return self.cancel_all_orders(symbol) # ==================== 杠杆 ==================== def update_leverage(self, symbol: str, leverage: int) -> bool: """设置杠杆倍数""" try: return self.trading_api.set_leverage(symbol, leverage) except Exception as e: logger.warning(f"Bitget 设置杠杆失败: {symbol} {leverage}x: {e}") return False def sync_default_leverage(self, symbols: List[str], leverage: Optional[int] = None) -> Dict[str, Any]: """将默认杠杆同步到一组交易对""" target_leverage = int(leverage or self.settings.bitget_default_leverage or 10) normalized_symbols = [] for symbol in symbols: if not symbol: continue normalized = symbol.replace("/", "").strip().upper() if normalized.endswith("USDT"): normalized = normalized[:-4] if normalized: normalized_symbols.append(normalized) if not normalized_symbols: logger.info("Bitget 默认杠杆同步跳过:没有可用交易对") return {"success": True, "leverage": target_leverage, "results": {}} deduped_symbols = list(dict.fromkeys(normalized_symbols)) logger.info(f"开始同步 Bitget 默认杠杆: {target_leverage}x -> {deduped_symbols}") results: Dict[str, bool] = {} for symbol in deduped_symbols: results[symbol] = self.update_leverage(symbol, target_leverage) success = all(results.values()) if results else True if success: logger.info(f"Bitget 默认杠杆同步完成: {target_leverage}x") else: failed_symbols = [symbol for symbol, ok in results.items() if not ok] logger.warning(f"Bitget 默认杠杆同步存在失败: {failed_symbols}") return { "success": success, "leverage": target_leverage, "results": results, } # ==================== 辅助方法 ==================== def get_contract_size(self, symbol: str) -> float: """ 获取合约面值(1张合约对应的币数量) 优先从硬编码表获取,不存在则查询 ccxt 市场信息。 """ coin = symbol.replace('USDT', '').replace('/', '').upper() if coin in CONTRACT_SIZES: return CONTRACT_SIZES[coin] # fallback: 从 ccxt market info 获取 try: ccxt_symbol = self.trading_api._standardize_symbol(symbol) market = self.trading_api.exchange.market(ccxt_symbol) size = float(market.get('contractSize', 1) or 1) logger.info(f"Bitget 从市场信息获取合约面值: {coin} = {size}") return size except Exception: logger.warning(f"Bitget 无法获取 {coin} 合约面值,使用默认值 1") return 1.0 def coins_to_contracts(self, symbol: str, coin_amount: float, price: float = 1.0) -> int: """ 将币数量转换为合约张数(向下取整) Args: symbol: 币种 coin_amount: 要转换的币数量 price: 当前价格(未使用,保留接口兼容) Returns: 整数合约张数 """ contract_size = self.get_contract_size(symbol) if contract_size <= 0: return 0 return math.floor(coin_amount / contract_size) def market_close_all(self) -> Dict[str, Any]: """市价平仓所有持仓""" results = [] positions = self.get_open_positions() for pos in positions: 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 _floor_amount(self, symbol: str, amount: float) -> float: """根据交易对精度向下取整数量(使用 CCXT 内置精度处理)""" try: ccxt_symbol = self.trading_api._standardize_symbol(symbol) return float(self.trading_api.exchange.amount_to_precision(ccxt_symbol, amount)) except Exception: pass # fallback: 4 位小数截断 return math.floor(amount * 10000) / 10000 def market_close_position(self, symbol: str) -> Dict[str, Any]: """按交易对市价平仓单个持仓""" 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 = self._floor_amount(coin + 'USDT', abs(position['size'])) 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' if self.trading_api.use_unified_account: close_params = { 'hedged': True, 'reduceOnly': True, 'marginCoin': 'USDT', } else: close_params = { 'reduceOnly': True, 'tdMode': 'cross', 'marginCoin': 'USDT', } order = self.trading_api.exchange.create_market_order( symbol=ccxt_symbol, side=side, amount=coin_amount, params=self.trading_api._with_account_mode_params(close_params) ) 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)} # ==================== 单例工厂 ==================== _bitget_live_services: Dict[str, BitgetLiveTradingService] = {} def get_bitget_live_service(account_id: str = "default") -> Optional[BitgetLiveTradingService]: """ 获取 BitgetLiveTradingService 单例(按账号)。 bitget_trading_enabled=False 时返回 None(功能关闭)。 """ global _bitget_live_services settings = get_settings() if not settings.bitget_trading_enabled: return None normalized_account_id = (account_id or "default").strip() or "default" existing = _bitget_live_services.get(normalized_account_id) if existing is not None: return existing try: service = BitgetLiveTradingService(account_id=normalized_account_id) _bitget_live_services[normalized_account_id] = service return service except Exception as e: logger.error(f"❌ BitgetLiveTradingService 初始化失败: account={normalized_account_id} error={e}") return None def get_all_bitget_live_services() -> Dict[str, BitgetLiveTradingService]: """获取所有已启用 Bitget 账号的服务实例。""" settings = get_settings() services: Dict[str, BitgetLiveTradingService] = {} for account in settings.get_enabled_bitget_accounts(): account_id = account.get("account_id") or "default" service = get_bitget_live_service(account_id) if service: services[account_id] = service return services def reset_bitget_live_service(account_id: Optional[str] = None): """重置单例(测试用)""" global _bitget_live_services if account_id is None: _bitget_live_services = {} return normalized_account_id = (account_id or "default").strip() or "default" _bitget_live_services.pop(normalized_account_id, None)