diff --git a/trading_system/binance_client.py b/trading_system/binance_client.py index e61cf81..cf22fe2 100644 --- a/trading_system/binance_client.py +++ b/trading_system/binance_client.py @@ -3,6 +3,7 @@ """ import asyncio import logging +import time from typing import Dict, List, Optional, Any from binance import AsyncClient, BinanceSocketManager from binance.exceptions import BinanceAPIException @@ -66,6 +67,11 @@ class BinanceClient: self._semaphore = asyncio.Semaphore(10) # 限制并发请求数 self._price_cache: Dict[str, Dict] = {} # WebSocket价格缓存 {symbol: {price, volume, changePercent, timestamp}} self._price_cache_ttl = 60 # 价格缓存有效期(秒) + + # 持仓模式缓存(币安 U本位合约):dualSidePosition=True => 对冲模式;False => 单向模式 + self._dual_side_position: Optional[bool] = None + self._position_mode_checked_at: float = 0.0 + self._position_mode_ttl: float = 60.0 # 秒,避免频繁调用接口 # 隐藏敏感信息,只显示前4位和后4位 api_key_display = f"{self.api_key[:4]}...{self.api_key[-4:]}" if self.api_key and len(self.api_key) > 8 else self.api_key @@ -156,6 +162,14 @@ class BinanceClient: # 验证API密钥权限 await self._verify_api_permissions() + + # 预热读取持仓模式(避免首次下单时才触发 -4061) + try: + dual = await self._get_dual_side_position(force=True) + mode = "HEDGE(对冲)" if dual else "ONE-WAY(单向)" + logger.info(f"✓ 当前合约持仓模式: {mode}") + except Exception as e: + logger.debug(f"读取持仓模式失败(可忽略,后续下单会再尝试): {e}") return @@ -204,6 +218,96 @@ class BinanceClient: logger.warning("请检查API密钥是否启用了合约交易权限") else: logger.warning(f"⚠ API密钥验证时出现错误: {e}") + + @staticmethod + def _to_bool(value: Any) -> Optional[bool]: + if value is None: + return None + if isinstance(value, bool): + return value + if isinstance(value, (int, float)): + return bool(value) + s = str(value).strip().lower() + if s in {"true", "1", "yes", "y"}: + return True + if s in {"false", "0", "no", "n"}: + return False + return None + + async def _get_dual_side_position(self, force: bool = False) -> Optional[bool]: + """ + 获取币安合约持仓模式: + - True: 对冲模式(Hedge Mode,需要下单时传 positionSide=LONG/SHORT) + - False: 单向模式(One-way Mode,不应传 positionSide=LONG/SHORT) + """ + if not self.client: + return None + + now = time.time() + if not force and self._dual_side_position is not None and (now - self._position_mode_checked_at) < self._position_mode_ttl: + return self._dual_side_position + + # python-binance: futures_get_position_mode -> {"dualSidePosition": true/false} + res = await self.client.futures_get_position_mode() + dual = self._to_bool(res.get("dualSidePosition") if isinstance(res, dict) else None) + self._dual_side_position = dual + self._position_mode_checked_at = now + return dual + + async def _resolve_position_side_for_order( + self, + symbol: str, + side: str, + reduce_only: bool, + provided: Optional[str], + ) -> Optional[str]: + """ + 在对冲模式下,必须传 positionSide=LONG/SHORT。 + - 开仓:BUY=>LONG, SELL=>SHORT + - 平仓(reduceOnly):需要知道要减少的是 LONG 还是 SHORT + """ + if provided: + ps = provided.strip().upper() + if ps in {"LONG", "SHORT"}: + return ps + if ps == "BOTH": + # 对冲模式下 BOTH 无效,这里按开仓方向兜底 + return "LONG" if side.upper() == "BUY" else "SHORT" + + side_u = side.upper() + if not reduce_only: + return "LONG" if side_u == "BUY" else "SHORT" + + # reduceOnly 平仓:尝试从当前持仓推断(避免调用方漏传) + try: + positions = await self.client.futures_position_information(symbol=symbol) + nonzero = [] + for p in positions or []: + try: + amt = float(p.get("positionAmt", 0)) + except Exception: + continue + if abs(amt) > 0: + nonzero.append((amt, p)) + + if len(nonzero) == 1: + amt, p = nonzero[0] + ps = (p.get("positionSide") or "").upper() + if ps in {"LONG", "SHORT"}: + return ps + return "LONG" if amt > 0 else "SHORT" + + # 多持仓:按平仓方向推断(SELL 通常平 LONG;BUY 通常平 SHORT) + if side_u == "SELL": + cand = next((p for amt, p in nonzero if amt > 0), None) + return (cand.get("positionSide") or "LONG").upper() if cand else "LONG" + if side_u == "BUY": + cand = next((p for amt, p in nonzero if amt < 0), None) + return (cand.get("positionSide") or "SHORT").upper() if cand else "SHORT" + except Exception as e: + logger.debug(f"{symbol} 推断 positionSide 失败: {e}") + + return None async def disconnect(self): """断开连接""" @@ -682,7 +786,8 @@ class BinanceClient: quantity: float, order_type: str = 'MARKET', price: Optional[float] = None, - reduce_only: bool = False + reduce_only: bool = False, + position_side: Optional[str] = None, ) -> Optional[Dict]: """ 下单 @@ -850,21 +955,58 @@ class BinanceClient: 'type': order_type, 'quantity': adjusted_quantity } + + # 处理持仓模式(解决 -4061:position side 与账户设置不匹配) + # - 对冲模式:必须传 positionSide=LONG/SHORT + # - 单向模式:不要传 positionSide=LONG/SHORT(否则会 -4061) + dual = None + try: + dual = await self._get_dual_side_position() + except Exception as e: + logger.debug(f"{symbol} 获取持仓模式失败(稍后用重试兜底): {e}") + + if dual is True: + ps = await self._resolve_position_side_for_order(symbol, side, reduce_only, position_side) + if not ps: + logger.error(f"{symbol} 对冲模式下无法确定 positionSide,拒绝下单以避免 -4061") + return None + order_params["positionSide"] = ps + elif dual is False: + if position_side: + logger.info(f"{symbol} 单向模式下忽略 positionSide={position_side}(避免 -4061)") # 如果是平仓订单,添加 reduceOnly 参数 # 根据币安API文档,reduceOnly 应该是字符串 "true" 或 "false" if reduce_only: order_params['reduceOnly'] = "true" # 使用字符串格式,符合币安API要求 logger.info(f"{symbol} 使用 reduceOnly=true 平仓订单") - - if order_type == 'MARKET': - order = await self.client.futures_create_order(**order_params) - else: + + async def _submit(params: Dict[str, Any]) -> Dict[str, Any]: + if order_type == 'MARKET': + return await self.client.futures_create_order(**params) if price is None: raise ValueError("限价单必须指定价格") - order_params['timeInForce'] = 'GTC' - order_params['price'] = price - order = await self.client.futures_create_order(**order_params) + params = dict(params) + params['timeInForce'] = 'GTC' + params['price'] = price + return await self.client.futures_create_order(**params) + + # 提交订单;若遇到 -4061,则在“带/不带 positionSide”之间做一次兜底重试 + try: + order = await _submit(order_params) + except BinanceAPIException as e: + if getattr(e, "code", None) == -4061: + logger.error(f"{symbol} 触发 -4061(持仓模式不匹配),尝试自动兜底重试一次") + retry_params = dict(order_params) + if "positionSide" in retry_params: + retry_params.pop("positionSide", None) + else: + ps = await self._resolve_position_side_for_order(symbol, side, reduce_only, position_side) + if ps: + retry_params["positionSide"] = ps + order = await _submit(retry_params) + else: + raise logger.info(f"下单成功: {symbol} {side} {adjusted_quantity} @ {order_type} (名义价值: {notional_value:.2f} USDT)") return order @@ -895,7 +1037,10 @@ class BinanceClient: else: logger.error(f"下单失败 {symbol} {side}: {e}") logger.error(f" 错误码: {error_code}") - logger.error(f" 下单参数: symbol={symbol}, side={side}, quantity={adjusted_quantity}, type={order_type}, reduceOnly={reduce_only}") + logger.error( + f" 下单参数: symbol={symbol}, side={side}, quantity={adjusted_quantity}, type={order_type}, " + f"reduceOnly={reduce_only}, positionSide={order_params.get('positionSide') if 'order_params' in locals() else None}" + ) return None except Exception as e: diff --git a/trading_system/position_manager.py b/trading_system/position_manager.py index 8b9b4bd..bb3b34d 100644 --- a/trading_system/position_manager.py +++ b/trading_system/position_manager.py @@ -524,6 +524,7 @@ class PositionManager: position_amt = position['positionAmt'] side = 'SELL' if position_amt > 0 else 'BUY' quantity = abs(position_amt) + position_side = 'LONG' if position_amt > 0 else 'SHORT' logger.info( f"{symbol} [平仓] 下单信息: {side} {quantity:.4f} @ MARKET " @@ -538,7 +539,8 @@ class PositionManager: side=side, quantity=quantity, order_type='MARKET', - reduce_only=True # 平仓时使用 reduceOnly=True + reduce_only=True, # 平仓时使用 reduceOnly=True + position_side=position_side, # 兼容对冲模式(Hedge):必须指定 LONG/SHORT ) logger.debug(f"{symbol} [平仓] place_order 返回: {order}") except Exception as order_error: @@ -948,11 +950,14 @@ class PositionManager: try: # 部分平仓 close_side = 'SELL' if position_info['side'] == 'BUY' else 'BUY' + close_position_side = 'LONG' if position_info['side'] == 'BUY' else 'SHORT' partial_order = await self.client.place_order( symbol=symbol, side=close_side, quantity=partial_quantity, - order_type='MARKET' + order_type='MARKET', + reduce_only=True, # 部分止盈必须 reduceOnly,避免反向开仓 + position_side=close_position_side, # 兼容对冲模式:指定要减少的持仓方向 ) if partial_order: position_info['partialProfitTaken'] = True