This commit is contained in:
薇薇安 2026-01-18 18:12:46 +08:00
parent c92ce63a3a
commit d59fa97036
2 changed files with 161 additions and 11 deletions

View File

@ -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
@ -67,6 +68,11 @@ class BinanceClient:
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
api_secret_display = f"{self.api_secret[:4]}...{self.api_secret[-4:]}" if self.api_secret and len(self.api_secret) > 8 else self.api_secret
@ -157,6 +163,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
except asyncio.TimeoutError as e:
@ -205,6 +219,96 @@ class BinanceClient:
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 通常平 LONGBUY 通常平 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]:
"""
下单
@ -851,20 +956,57 @@ class BinanceClient:
'quantity': adjusted_quantity
}
# 处理持仓模式(解决 -4061position 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 平仓订单")
async def _submit(params: Dict[str, Any]) -> Dict[str, Any]:
if order_type == 'MARKET':
order = await self.client.futures_create_order(**order_params)
else:
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:

View File

@ -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