a
This commit is contained in:
parent
c92ce63a3a
commit
d59fa97036
|
|
@ -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 通常平 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]:
|
||||
"""
|
||||
下单
|
||||
|
|
@ -851,20 +956,57 @@ class BinanceClient:
|
|||
'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 平仓订单")
|
||||
|
||||
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:
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Reference in New Issue
Block a user