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 asyncio
import logging import logging
import time
from typing import Dict, List, Optional, Any from typing import Dict, List, Optional, Any
from binance import AsyncClient, BinanceSocketManager from binance import AsyncClient, BinanceSocketManager
from binance.exceptions import BinanceAPIException from binance.exceptions import BinanceAPIException
@ -66,6 +67,11 @@ class BinanceClient:
self._semaphore = asyncio.Semaphore(10) # 限制并发请求数 self._semaphore = asyncio.Semaphore(10) # 限制并发请求数
self._price_cache: Dict[str, Dict] = {} # WebSocket价格缓存 {symbol: {price, volume, changePercent, timestamp}} self._price_cache: Dict[str, Dict] = {} # WebSocket价格缓存 {symbol: {price, volume, changePercent, timestamp}}
self._price_cache_ttl = 60 # 价格缓存有效期(秒) 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位 # 隐藏敏感信息只显示前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_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密钥权限 # 验证API密钥权限
await self._verify_api_permissions() 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 return
@ -204,6 +218,96 @@ class BinanceClient:
logger.warning("请检查API密钥是否启用了合约交易权限") logger.warning("请检查API密钥是否启用了合约交易权限")
else: else:
logger.warning(f"⚠ API密钥验证时出现错误: {e}") 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): async def disconnect(self):
"""断开连接""" """断开连接"""
@ -682,7 +786,8 @@ class BinanceClient:
quantity: float, quantity: float,
order_type: str = 'MARKET', order_type: str = 'MARKET',
price: Optional[float] = None, price: Optional[float] = None,
reduce_only: bool = False reduce_only: bool = False,
position_side: Optional[str] = None,
) -> Optional[Dict]: ) -> Optional[Dict]:
""" """
下单 下单
@ -850,21 +955,58 @@ class BinanceClient:
'type': order_type, 'type': order_type,
'quantity': adjusted_quantity '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 参数 # 如果是平仓订单,添加 reduceOnly 参数
# 根据币安API文档reduceOnly 应该是字符串 "true" 或 "false" # 根据币安API文档reduceOnly 应该是字符串 "true" 或 "false"
if reduce_only: if reduce_only:
order_params['reduceOnly'] = "true" # 使用字符串格式符合币安API要求 order_params['reduceOnly'] = "true" # 使用字符串格式符合币安API要求
logger.info(f"{symbol} 使用 reduceOnly=true 平仓订单") logger.info(f"{symbol} 使用 reduceOnly=true 平仓订单")
if order_type == 'MARKET': async def _submit(params: Dict[str, Any]) -> Dict[str, Any]:
order = await self.client.futures_create_order(**order_params) if order_type == 'MARKET':
else: return await self.client.futures_create_order(**params)
if price is None: if price is None:
raise ValueError("限价单必须指定价格") raise ValueError("限价单必须指定价格")
order_params['timeInForce'] = 'GTC' params = dict(params)
order_params['price'] = price params['timeInForce'] = 'GTC'
order = await self.client.futures_create_order(**order_params) 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)") logger.info(f"下单成功: {symbol} {side} {adjusted_quantity} @ {order_type} (名义价值: {notional_value:.2f} USDT)")
return order return order
@ -895,7 +1037,10 @@ class BinanceClient:
else: else:
logger.error(f"下单失败 {symbol} {side}: {e}") logger.error(f"下单失败 {symbol} {side}: {e}")
logger.error(f" 错误码: {error_code}") 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 return None
except Exception as e: except Exception as e:

View File

@ -524,6 +524,7 @@ class PositionManager:
position_amt = position['positionAmt'] position_amt = position['positionAmt']
side = 'SELL' if position_amt > 0 else 'BUY' side = 'SELL' if position_amt > 0 else 'BUY'
quantity = abs(position_amt) quantity = abs(position_amt)
position_side = 'LONG' if position_amt > 0 else 'SHORT'
logger.info( logger.info(
f"{symbol} [平仓] 下单信息: {side} {quantity:.4f} @ MARKET " f"{symbol} [平仓] 下单信息: {side} {quantity:.4f} @ MARKET "
@ -538,7 +539,8 @@ class PositionManager:
side=side, side=side,
quantity=quantity, quantity=quantity,
order_type='MARKET', 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}") logger.debug(f"{symbol} [平仓] place_order 返回: {order}")
except Exception as order_error: except Exception as order_error:
@ -948,11 +950,14 @@ class PositionManager:
try: try:
# 部分平仓 # 部分平仓
close_side = 'SELL' if position_info['side'] == 'BUY' else 'BUY' 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( partial_order = await self.client.place_order(
symbol=symbol, symbol=symbol,
side=close_side, side=close_side,
quantity=partial_quantity, quantity=partial_quantity,
order_type='MARKET' order_type='MARKET',
reduce_only=True, # 部分止盈必须 reduceOnly避免反向开仓
position_side=close_position_side, # 兼容对冲模式:指定要减少的持仓方向
) )
if partial_order: if partial_order:
position_info['partialProfitTaken'] = True position_info['partialProfitTaken'] = True