a
This commit is contained in:
parent
c92ce63a3a
commit
d59fa97036
|
|
@ -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
|
||||||
|
|
@ -67,6 +68,11 @@ class BinanceClient:
|
||||||
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
|
||||||
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
|
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密钥权限
|
# 验证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
|
||||||
|
|
||||||
except asyncio.TimeoutError as e:
|
except asyncio.TimeoutError as e:
|
||||||
|
|
@ -205,6 +219,96 @@ class BinanceClient:
|
||||||
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 通常平 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):
|
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]:
|
||||||
"""
|
"""
|
||||||
下单
|
下单
|
||||||
|
|
@ -851,20 +956,57 @@ class BinanceClient:
|
||||||
'quantity': adjusted_quantity
|
'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 参数
|
# 如果是平仓订单,添加 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:
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue
Block a user