update
This commit is contained in:
parent
520428895b
commit
8533f7c4b4
@ -55,7 +55,7 @@ class BitgetLiveTradingService:
|
||||
self.settings = get_settings()
|
||||
self.max_total_leverage: float = self.settings.bitget_max_total_leverage
|
||||
self.max_single_position: float = self.settings.bitget_max_single_position
|
||||
self.circuit_breaker_drawdown: float = self.settings.hyperliquid_circuit_breaker_drawdown
|
||||
self.circuit_breaker_drawdown: float = self.settings.account_max_drawdown
|
||||
|
||||
self.trading_api = get_bitget_trading_api()
|
||||
if not self.trading_api:
|
||||
@ -289,12 +289,12 @@ class BitgetLiveTradingService:
|
||||
type='market',
|
||||
side=side,
|
||||
amount=actual_amount,
|
||||
params={
|
||||
params=self.trading_api._with_account_mode_params({
|
||||
'tdMode': 'cross',
|
||||
'marginCoin': 'USDT',
|
||||
'holdMode': 'oneWay',
|
||||
**params
|
||||
}
|
||||
})
|
||||
)
|
||||
|
||||
if not order:
|
||||
@ -355,7 +355,7 @@ class BitgetLiveTradingService:
|
||||
side=side,
|
||||
amount=actual_amount,
|
||||
price=price,
|
||||
params=params
|
||||
params=self.trading_api._with_account_mode_params(params)
|
||||
)
|
||||
|
||||
if not order:
|
||||
@ -625,9 +625,22 @@ class BitgetLiveTradingService:
|
||||
all_ok = all(r.get('success') for r in results)
|
||||
return {"success": all_ok, "results": results}
|
||||
|
||||
def _floor_amount(self, symbol: str, amount: float) -> float:
|
||||
"""根据交易对精度向下取整数量"""
|
||||
try:
|
||||
ccxt_symbol = self.trading_api._standardize_symbol(symbol)
|
||||
market = self.trading_api.exchange.market(ccxt_symbol)
|
||||
precision = market.get('precision', {}).get('amount')
|
||||
if precision and precision > 0:
|
||||
factor = 10 ** precision
|
||||
return math.floor(amount * factor) / factor
|
||||
except Exception:
|
||||
pass
|
||||
# fallback: 4 位小数
|
||||
return math.floor(amount * 10000) / 10000
|
||||
|
||||
def market_close_position(self, symbol: str) -> Dict[str, Any]:
|
||||
"""按交易对市价平仓单个持仓"""
|
||||
import math
|
||||
|
||||
coin = symbol.replace('USDT', '').replace('/', '').upper()
|
||||
position = self.get_position_for_symbol(coin)
|
||||
@ -635,7 +648,7 @@ class BitgetLiveTradingService:
|
||||
return {"success": False, "coin": coin, "error": "未找到持仓"}
|
||||
|
||||
is_long = position['size'] > 0
|
||||
coin_amount = math.floor(abs(position['size']) * 10000) / 10000
|
||||
coin_amount = self._floor_amount(coin + 'USDT', abs(position['size']))
|
||||
if coin_amount < 0.0001:
|
||||
logger.warning(f"{coin} 持仓过小 ({coin_amount}),跳过")
|
||||
return {"success": True, "coin": coin, "size": 0}
|
||||
@ -649,11 +662,11 @@ class BitgetLiveTradingService:
|
||||
symbol=ccxt_symbol,
|
||||
side=side,
|
||||
amount=coin_amount,
|
||||
params={
|
||||
params=self.trading_api._with_account_mode_params({
|
||||
'reduceOnly': True,
|
||||
'tdMode': 'cross',
|
||||
'marginCoin': 'USDT',
|
||||
}
|
||||
})
|
||||
)
|
||||
if order:
|
||||
logger.info(f"✅ Bitget 单币种平仓成功: {coin} {side} {coin_amount}")
|
||||
|
||||
@ -12,6 +12,7 @@ Bitget 实盘交易 API (基于 CCXT SDK)
|
||||
使用 CCXT 统一交易所接口,提供更稳定的 API 交互。
|
||||
"""
|
||||
import ccxt
|
||||
import math
|
||||
from typing import Dict, List, Optional, Any
|
||||
from datetime import datetime
|
||||
from app.utils.logger import logger
|
||||
@ -23,6 +24,14 @@ class BitgetTradingAPI:
|
||||
DEFAULT_PRODUCT_TYPE = 'USDT-FUTURES'
|
||||
DEFAULT_MARGIN_COIN = 'USDT'
|
||||
|
||||
# 合约面值表(张 → 币数量),与 bitget_live_trading_service.CONTRACT_SIZES 保持同步
|
||||
CONTRACT_SIZES: Dict[str, float] = {
|
||||
'BTC': 0.01, 'ETH': 0.1, 'LTC': 0.1, 'BCH': 0.1, 'BNB': 0.1,
|
||||
'SOL': 1.0, 'AVAX': 1.0, 'LINK': 1.0, 'UNI': 1.0, 'ATOM': 1.0,
|
||||
'FIL': 1.0, 'DOT': 1.0, 'XRP': 10.0, 'DOGE': 100.0,
|
||||
'MATIC': 10.0, 'POL': 10.0,
|
||||
}
|
||||
|
||||
@staticmethod
|
||||
def _pick_first_value(entry: Dict[str, Any], *keys: str, default: str = '0') -> str:
|
||||
"""兼容 Bitget UTA 不同字段名,返回第一个非空值。"""
|
||||
@ -99,47 +108,8 @@ class BitgetTradingAPI:
|
||||
# CCXT 标准化交易对格式
|
||||
ccxt_symbol = self._standardize_symbol(symbol)
|
||||
|
||||
# 手动获取合约规格(不依赖 CCXT 的 contractSize)
|
||||
# Bitget 永续合约规格
|
||||
if 'BTC' in symbol:
|
||||
contract_size = 0.01 # BTC 每张 0.01 BTC
|
||||
elif 'ETH' in symbol:
|
||||
contract_size = 0.1 # ETH 每张 0.1 ETH
|
||||
elif 'SOL' in symbol:
|
||||
contract_size = 1 # SOL 每张 1 SOL
|
||||
elif 'BNB' in symbol:
|
||||
contract_size = 0.1 # BNB 每张 0.1 BNB
|
||||
elif 'XRP' in symbol:
|
||||
contract_size = 10 # XRP 每张 10 XRP
|
||||
elif 'DOGE' in symbol:
|
||||
contract_size = 100 # DOGE 每张 100 DOGE
|
||||
elif 'MATIC' in symbol or 'POL' in symbol:
|
||||
contract_size = 10 # MATIC 每张 10 MATIC
|
||||
elif 'AVAX' in symbol:
|
||||
contract_size = 1 # AVAX 每张 1 AVAX
|
||||
elif 'LINK' in symbol:
|
||||
contract_size = 1 # LINK 每张 1 LINK
|
||||
elif 'UNI' in symbol:
|
||||
contract_size = 1 # UNI 每张 1 UNI
|
||||
elif 'ATOM' in symbol:
|
||||
contract_size = 1 # ATOM 每张 1 ATOM
|
||||
elif 'LTC' in symbol:
|
||||
contract_size = 0.1 # LTC 每张 0.1 LTC
|
||||
elif 'BCH' in symbol:
|
||||
contract_size = 0.1 # BCH 每张 0.1 BCH
|
||||
elif 'FIL' in symbol:
|
||||
contract_size = 1 # FIL 每张 1 FIL
|
||||
elif 'DOT' in symbol:
|
||||
contract_size = 1 # DOT 每张 1 DOT
|
||||
else:
|
||||
# 默认尝试从市场信息获取
|
||||
try:
|
||||
market = self.exchange.market(ccxt_symbol)
|
||||
contract_size = market.get('contractSize', 1)
|
||||
logger.info(f"从市场信息获取合约规格: {contract_size}")
|
||||
except Exception:
|
||||
contract_size = 1
|
||||
logger.warning(f"无法确定 {symbol} 的合约规格,使用默认值 1")
|
||||
# 获取合约面值
|
||||
contract_size = self._get_contract_size(symbol)
|
||||
|
||||
# 计算实际下单数量(张数 × 合约规格)
|
||||
# Bitget 的 amount 参数是币的数量,不是张数
|
||||
@ -253,7 +223,7 @@ class BitgetTradingAPI:
|
||||
def close_position(self, symbol: str, side: str = None, size: float = None,
|
||||
price: float = None) -> Optional[Dict]:
|
||||
"""
|
||||
平仓(双向持仓模式)
|
||||
平仓(单向持仓模式)
|
||||
|
||||
Args:
|
||||
symbol: 交易对
|
||||
@ -299,15 +269,15 @@ class BitgetTradingAPI:
|
||||
side = 'sell' if pos_side == 'long' else 'buy'
|
||||
|
||||
# 如果没有指定平仓数量,则全部平仓
|
||||
# 注意:直接使用 BTC 数量,不再通过 place_order 转换
|
||||
# 注意:直接使用币数量,不再通过 place_order 转换
|
||||
close_size_btc = size * self._get_contract_size(symbol) if size else current_size_btc
|
||||
|
||||
# 精度处理:向下取整到 0.0001 BTC(Bitget 最小精度)
|
||||
import math
|
||||
close_size_btc = math.floor(close_size_btc * 10000) / 10000
|
||||
# 精度处理:从市场信息动态获取精度
|
||||
close_size_btc = self._floor_amount(ccxt_symbol, close_size_btc)
|
||||
|
||||
if close_size_btc < 0.0001:
|
||||
logger.warning(f"{symbol} 平仓数量 {close_size_btc} BTC 小于最小交易单位 0.0001 BTC")
|
||||
min_amount = self._get_min_amount(ccxt_symbol)
|
||||
if close_size_btc < min_amount:
|
||||
logger.warning(f"{symbol} 平仓数量 {close_size_btc} 小于最小交易单位 {min_amount}")
|
||||
return None
|
||||
|
||||
logger.info(f"平仓: {symbol} 持仓方向={pos_side}, 平仓方向={side}, 数量={close_size_btc} BTC")
|
||||
@ -350,7 +320,9 @@ class BitgetTradingAPI:
|
||||
def set_trailing_stop(self, symbol: str, callback_rate: float = None,
|
||||
activation_price: float = None) -> bool:
|
||||
"""
|
||||
设置移动止损
|
||||
设置移动止损(UTA V3 兼容)
|
||||
|
||||
通过下一个 trailing_stop 类型的条件单来实现移动止损。
|
||||
|
||||
Args:
|
||||
symbol: 交易对
|
||||
@ -370,26 +342,36 @@ class BitgetTradingAPI:
|
||||
return False
|
||||
|
||||
position = positions[0]
|
||||
current_size = float(position.get('contracts', 0))
|
||||
info = position.get('info', {})
|
||||
available = float(info.get('available', 0))
|
||||
|
||||
if current_size == 0:
|
||||
if available == 0:
|
||||
logger.warning(f"{symbol} 持仓数量为 0")
|
||||
return False
|
||||
|
||||
# 使用 CCXT 的私人 API 设置移动止损
|
||||
# Bitget 需要通过私人API调用
|
||||
params = {
|
||||
'symbol': ccxt_symbol,
|
||||
'trailingStopCallbackRate': callback_rate,
|
||||
}
|
||||
params = self._with_account_mode_params(params)
|
||||
pos_side = position.get('side', 'long')
|
||||
close_side = 'sell' if pos_side == 'long' else 'buy'
|
||||
|
||||
# 使用 CCXT 统一接口下 trailing stop 条件单
|
||||
params = self._with_account_mode_params({
|
||||
'tdMode': 'cross',
|
||||
'marginCoin': 'USDT',
|
||||
'reduceOnly': True,
|
||||
'trailingPercent': callback_rate * 100 if callback_rate else None, # CCXT 需要百分比
|
||||
})
|
||||
|
||||
if activation_price:
|
||||
params['trailingStopActivationPrice'] = activation_price
|
||||
params['activationPrice'] = activation_price
|
||||
|
||||
self.exchange.private_mix_post_modify_contract_trailing_stop(params)
|
||||
order = self.exchange.create_order(
|
||||
symbol=ccxt_symbol,
|
||||
type='trailing_stop_market',
|
||||
side=close_side,
|
||||
amount=available,
|
||||
params=params,
|
||||
)
|
||||
|
||||
logger.info(f"✅ 设置移动止损成功: {symbol} callback={callback_rate}")
|
||||
logger.info(f"✅ 设置移动止损成功: {symbol} callback={callback_rate}, order={order.get('id')}")
|
||||
return True
|
||||
|
||||
except ccxt.BaseError as e:
|
||||
@ -460,13 +442,11 @@ class BitgetTradingAPI:
|
||||
# 使用独立的止损/止盈计划订单
|
||||
# 注意:这种方式需要在平仓时也取消这些计划订单
|
||||
|
||||
# CCXT 的 contracts 字段对于 Bitget 实际上已经是 BTC 数量
|
||||
# 所以我们直接使用,不需要再乘以 contract_size
|
||||
# CCXT 的 contracts 字段对于 Bitget 实际上已经是币数量
|
||||
btc_amount = abs(contracts)
|
||||
|
||||
# 精度处理
|
||||
import math
|
||||
btc_amount = math.floor(btc_amount * 10000) / 10000
|
||||
# 精度处理:使用动态精度
|
||||
btc_amount = self._floor_amount(ccxt_symbol, btc_amount)
|
||||
|
||||
orders_created = []
|
||||
|
||||
@ -899,46 +879,54 @@ class BitgetTradingAPI:
|
||||
"""
|
||||
获取合约面值(每张合约对应的币数量)
|
||||
|
||||
优先从硬编码表获取,不存在则查询 CCXT 市场信息。
|
||||
|
||||
Args:
|
||||
symbol: 交易对
|
||||
|
||||
Returns:
|
||||
合约面值
|
||||
"""
|
||||
# Bitget 永续合约规格
|
||||
if 'BTC' in symbol:
|
||||
return 0.01
|
||||
elif 'ETH' in symbol:
|
||||
return 0.1
|
||||
elif 'SOL' in symbol:
|
||||
return 1.0
|
||||
elif 'BNB' in symbol:
|
||||
return 0.1
|
||||
elif 'XRP' in symbol:
|
||||
return 10.0
|
||||
elif 'DOGE' in symbol:
|
||||
return 100.0
|
||||
elif 'MATIC' in symbol or 'POL' in symbol:
|
||||
return 10.0
|
||||
elif 'AVAX' in symbol:
|
||||
return 1.0
|
||||
elif 'LINK' in symbol:
|
||||
return 1.0
|
||||
elif 'UNI' in symbol:
|
||||
return 1.0
|
||||
elif 'ATOM' in symbol:
|
||||
return 1.0
|
||||
elif 'LTC' in symbol:
|
||||
return 0.1
|
||||
elif 'BCH' in symbol:
|
||||
return 0.1
|
||||
elif 'FIL' in symbol:
|
||||
return 1.0
|
||||
elif 'DOT' in symbol:
|
||||
return 1.0
|
||||
else:
|
||||
# 提取纯币种名
|
||||
coin = symbol.replace('USDT', '').replace('/USDT:USDT', '').replace('/', '').upper()
|
||||
if coin in self.CONTRACT_SIZES:
|
||||
return self.CONTRACT_SIZES[coin]
|
||||
|
||||
# fallback: 从 CCXT market info 获取
|
||||
try:
|
||||
ccxt_symbol = self._standardize_symbol(symbol)
|
||||
market = self.exchange.market(ccxt_symbol)
|
||||
size = float(market.get('contractSize', 1) or 1)
|
||||
logger.info(f"从市场信息获取合约面值: {coin} = {size}")
|
||||
return size
|
||||
except Exception:
|
||||
logger.warning(f"无法获取 {coin} 合约面值,使用默认值 1")
|
||||
return 1.0
|
||||
|
||||
def _floor_amount(self, ccxt_symbol: str, amount: float) -> float:
|
||||
"""根据交易对精度向下取整数量"""
|
||||
try:
|
||||
market = self.exchange.market(ccxt_symbol)
|
||||
precision = market.get('precision', {}).get('amount')
|
||||
if precision and precision > 0:
|
||||
factor = 10 ** precision
|
||||
return math.floor(amount * factor) / factor
|
||||
except Exception:
|
||||
pass
|
||||
return math.floor(amount * 10000) / 10000
|
||||
|
||||
def _get_min_amount(self, ccxt_symbol: str) -> float:
|
||||
"""获取交易对最小下单数量"""
|
||||
try:
|
||||
market = self.exchange.market(ccxt_symbol)
|
||||
limits = market.get('limits', {}).get('amount', {})
|
||||
min_amount = limits.get('min')
|
||||
if min_amount:
|
||||
return float(min_amount)
|
||||
except Exception:
|
||||
pass
|
||||
return 0.0001
|
||||
|
||||
def test_connection(self) -> bool:
|
||||
"""
|
||||
测试 API 连接
|
||||
@ -983,15 +971,8 @@ def get_bitget_trading_api() -> Optional[BitgetTradingAPI]:
|
||||
"""
|
||||
global _trading_api
|
||||
|
||||
# 如果已有实例,检查它是否仍然有效
|
||||
if _trading_api:
|
||||
try:
|
||||
# 尝试获取余额来验证连接是否仍然有效
|
||||
_trading_api.get_balance()
|
||||
return _trading_api
|
||||
except Exception as e:
|
||||
logger.warning(f"Bitget API 实例已失效({e}),将重新创建")
|
||||
_trading_api = None
|
||||
return _trading_api
|
||||
|
||||
from app.config import get_settings
|
||||
|
||||
@ -999,7 +980,7 @@ def get_bitget_trading_api() -> Optional[BitgetTradingAPI]:
|
||||
|
||||
# 检查是否配置了 API Key
|
||||
if not settings.bitget_api_key or not settings.bitget_api_secret:
|
||||
logger.warning("Bitget API Key 未配置,实盘交易功能不可用")
|
||||
logger.warning("Bitget API Key 未配置<EFBFBD><EFBFBD>实盘交易功能不可用")
|
||||
return None
|
||||
|
||||
# 创建实例
|
||||
|
||||
@ -12,7 +12,8 @@ def _mock_settings():
|
||||
s = MagicMock()
|
||||
s.bitget_max_total_leverage = 10.0
|
||||
s.bitget_max_single_position = 1000.0
|
||||
s.hyperliquid_circuit_breaker_drawdown = 0.10
|
||||
s.account_max_drawdown = 0.25
|
||||
s.hyperliquid_circuit_breaker_drawdown = 0.10 # 保留兼容性
|
||||
s.bitget_trading_enabled = False
|
||||
return s
|
||||
|
||||
|
||||
@ -221,7 +221,7 @@ def test_uta_none_fields():
|
||||
|
||||
balance = api.get_balance()
|
||||
|
||||
assert balance['USDT']['available'] == 'None' # str(None) = 'None', float('None') → downstream handles
|
||||
assert balance['USDT']['available'] == '0' # _pick_first_value 对 None 返回默认值 '0'
|
||||
|
||||
|
||||
# ──────────────────────────────────────────────
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@ -40,11 +40,13 @@ def make_service(settings_overrides=None):
|
||||
mock_exchange = MagicMock()
|
||||
mock_api.exchange = mock_exchange
|
||||
mock_api._standardize_symbol = lambda s: f"{s.replace('USDT', '')}/USDT:USDT"
|
||||
mock_api._with_account_mode_params = lambda params=None: {**(params or {}), 'uta': True}
|
||||
mock_api.use_unified_account = True
|
||||
|
||||
mock_settings = MagicMock()
|
||||
mock_settings.bitget_max_total_leverage = 10.0
|
||||
mock_settings.bitget_max_single_position = 1000.0
|
||||
mock_settings.hyperliquid_circuit_breaker_drawdown = 0.10
|
||||
mock_settings.account_max_drawdown = 0.25
|
||||
if settings_overrides:
|
||||
for k, v in settings_overrides.items():
|
||||
setattr(mock_settings, k, v)
|
||||
@ -54,7 +56,7 @@ def make_service(settings_overrides=None):
|
||||
service.settings = mock_settings
|
||||
service.max_total_leverage = mock_settings.bitget_max_total_leverage
|
||||
service.max_single_position = mock_settings.bitget_max_single_position
|
||||
service.circuit_breaker_drawdown = mock_settings.hyperliquid_circuit_breaker_drawdown
|
||||
service.circuit_breaker_drawdown = mock_settings.account_max_drawdown
|
||||
service.trading_api = mock_api
|
||||
service.initial_balance = 10000.0
|
||||
|
||||
@ -532,11 +534,11 @@ class TestCheckRiskLimits:
|
||||
assert result['allowed'] is True
|
||||
|
||||
def test_circuit_breaker_triggered(self):
|
||||
"""账户余额从 10000 跌至 8900 → 回撤 11% > 10% → 熔断"""
|
||||
"""账户余额从 10000 跌至 7000 → 回撤 30% > 25% → 熔断"""
|
||||
service, mock_api = make_service()
|
||||
service.initial_balance = 10000.0
|
||||
mock_api.get_balance.return_value = {
|
||||
'USDT': {'available': '8900.0', 'frozen': '0.0', 'locked': '0'}
|
||||
'USDT': {'available': '7000.0', 'frozen': '0.0', 'locked': '0'}
|
||||
}
|
||||
mock_api.get_position.return_value = []
|
||||
result = service.check_risk_limits()
|
||||
@ -668,10 +670,11 @@ class TestGetBitgetLiveServiceFactory:
|
||||
mock_settings.bitget_trading_enabled = True
|
||||
mock_settings.bitget_max_total_leverage = 10.0
|
||||
mock_settings.bitget_max_single_position = 1000.0
|
||||
mock_settings.hyperliquid_circuit_breaker_drawdown = 0.10
|
||||
mock_settings.account_max_drawdown = 0.25
|
||||
|
||||
mock_api = MagicMock()
|
||||
mock_api._standardize_symbol = lambda s: f"{s}/USDT:USDT"
|
||||
mock_api._with_account_mode_params = lambda params=None: {**(params or {}), 'uta': True}
|
||||
mock_api.get_balance.return_value = {
|
||||
'USDT': {'available': '5000', 'frozen': '0', 'locked': '0'}
|
||||
}
|
||||
@ -877,3 +880,189 @@ class TestInitializeAccount:
|
||||
service._initialize_account() # 不应抛出异常
|
||||
|
||||
assert service.initial_balance is None
|
||||
|
||||
|
||||
# ==================== TestUTAParams ====================
|
||||
|
||||
class TestUTAParams:
|
||||
"""验证所有下单/平仓方法都正确传递 UTA 参数(uta: True)"""
|
||||
|
||||
def test_place_market_order_passes_uta(self):
|
||||
"""place_market_order 必须传递 uta: True"""
|
||||
service, mock_api = make_service()
|
||||
mock_api.exchange.create_order.return_value = {'id': 'o1', 'status': 'closed'}
|
||||
service.place_market_order('BTC', is_buy=True, size=1)
|
||||
|
||||
call_kwargs = mock_api.exchange.create_order.call_args[1]
|
||||
params = call_kwargs.get('params', {})
|
||||
assert params.get('uta') is True, f"place_market_order 缺少 uta 参数: {params}"
|
||||
|
||||
def test_place_market_order_uta_with_reduce_only(self):
|
||||
"""reduce_only 模式下也必须传递 uta: True"""
|
||||
service, mock_api = make_service()
|
||||
mock_api.exchange.create_order.return_value = {'id': 'o2', 'status': 'closed'}
|
||||
service.place_market_order('ETH', is_buy=False, size=3, reduce_only=True)
|
||||
|
||||
call_kwargs = mock_api.exchange.create_order.call_args[1]
|
||||
params = call_kwargs.get('params', {})
|
||||
assert params.get('uta') is True
|
||||
assert params.get('reduceOnly') is True
|
||||
|
||||
def test_place_limit_order_passes_uta(self):
|
||||
"""place_limit_order 必须传递 uta: True"""
|
||||
service, mock_api = make_service()
|
||||
mock_api.exchange.create_order.return_value = {'id': 'lim1', 'status': 'open'}
|
||||
service.place_limit_order('BTC', is_buy=True, size=1, price=50000.0)
|
||||
|
||||
call_kwargs = mock_api.exchange.create_order.call_args[1]
|
||||
params = call_kwargs.get('params', {})
|
||||
assert params.get('uta') is True, f"place_limit_order 缺少 uta 参数: {params}"
|
||||
|
||||
def test_place_limit_order_uta_with_reduce_only(self):
|
||||
"""限价单 reduce_only 模式也必须传递 uta: True"""
|
||||
service, mock_api = make_service()
|
||||
mock_api.exchange.create_order.return_value = {'id': 'lim2', 'status': 'open'}
|
||||
service.place_limit_order('SOL', is_buy=False, size=5, price=150.0, reduce_only=True)
|
||||
|
||||
call_kwargs = mock_api.exchange.create_order.call_args[1]
|
||||
params = call_kwargs.get('params', {})
|
||||
assert params.get('uta') is True
|
||||
assert params.get('reduceOnly') is True
|
||||
|
||||
def test_market_close_position_passes_uta(self):
|
||||
"""market_close_position 必须传递 uta: True"""
|
||||
service, mock_api = make_service()
|
||||
# 构造持仓数据
|
||||
mock_api.get_position.return_value = [{
|
||||
'symbol': 'BTC/USDT:USDT',
|
||||
'contracts': 2.0,
|
||||
'side': 'long',
|
||||
'entryPrice': 50000.0,
|
||||
'unrealizedPnl': 0.0,
|
||||
'leverage': 10,
|
||||
'liquidationPrice': 45000.0,
|
||||
'info': {'available': '0.02'},
|
||||
}]
|
||||
mock_api.exchange.create_market_order.return_value = {'id': 'close1', 'status': 'closed'}
|
||||
|
||||
service.market_close_position('BTC')
|
||||
|
||||
call_kwargs = mock_api.exchange.create_market_order.call_args[1]
|
||||
params = call_kwargs.get('params', {})
|
||||
assert params.get('uta') is True, f"market_close_position 缺少 uta 参数: {params}"
|
||||
assert params.get('reduceOnly') is True
|
||||
|
||||
def test_place_market_order_preserves_cross_margin_params(self):
|
||||
"""确保 UTA 参数不覆盖其他必要参数"""
|
||||
service, mock_api = make_service()
|
||||
mock_api.exchange.create_order.return_value = {'id': 'o1', 'status': 'closed'}
|
||||
service.place_market_order('BTC', is_buy=True, size=1)
|
||||
|
||||
call_kwargs = mock_api.exchange.create_order.call_args[1]
|
||||
params = call_kwargs.get('params', {})
|
||||
assert params.get('tdMode') == 'cross'
|
||||
assert params.get('marginCoin') == 'USDT'
|
||||
assert params.get('holdMode') == 'oneWay'
|
||||
assert params.get('uta') is True
|
||||
|
||||
|
||||
# ==================== TestFloorAmount ====================
|
||||
|
||||
class TestFloorAmount:
|
||||
"""测试动态精度向下取整"""
|
||||
|
||||
def test_floor_with_market_precision(self):
|
||||
"""从 market info 获取精度"""
|
||||
service, mock_api = make_service()
|
||||
mock_api.exchange.market.return_value = {
|
||||
'precision': {'amount': 2}
|
||||
}
|
||||
result = service._floor_amount('BTCUSDT', 1.23456)
|
||||
assert result == pytest.approx(1.23)
|
||||
|
||||
def test_floor_with_4_decimal_precision(self):
|
||||
service, mock_api = make_service()
|
||||
mock_api.exchange.market.return_value = {
|
||||
'precision': {'amount': 4}
|
||||
}
|
||||
result = service._floor_amount('BTCUSDT', 0.12345)
|
||||
assert result == pytest.approx(0.1234)
|
||||
|
||||
def test_floor_fallback_when_market_unavailable(self):
|
||||
"""market info 失败时回退到 4 位小数"""
|
||||
service, mock_api = make_service()
|
||||
mock_api.exchange.market.side_effect = Exception("not found")
|
||||
result = service._floor_amount('UNKNOWN', 1.23456789)
|
||||
assert result == pytest.approx(1.2345)
|
||||
|
||||
def test_floor_truncates_not_rounds(self):
|
||||
"""必须向下取整,不能四舍五入"""
|
||||
service, mock_api = make_service()
|
||||
mock_api.exchange.market.return_value = {
|
||||
'precision': {'amount': 2}
|
||||
}
|
||||
result = service._floor_amount('BTCUSDT', 1.999)
|
||||
assert result == pytest.approx(1.99)
|
||||
|
||||
|
||||
# ==================== TestSyncDefaultLeverage ====================
|
||||
|
||||
class TestSyncDefaultLeverage:
|
||||
|
||||
def test_sync_multiple_symbols(self):
|
||||
service, mock_api = make_service()
|
||||
mock_api.set_leverage.return_value = True
|
||||
service.settings.bitget_default_leverage = 5
|
||||
|
||||
result = service.sync_default_leverage(['BTCUSDT', 'ETHUSDT'], leverage=5)
|
||||
assert result['success'] is True
|
||||
assert result['leverage'] == 5
|
||||
assert mock_api.set_leverage.call_count == 2
|
||||
|
||||
def test_sync_empty_symbols(self):
|
||||
service, mock_api = make_service()
|
||||
result = service.sync_default_leverage([])
|
||||
assert result['success'] is True
|
||||
assert result['results'] == {}
|
||||
|
||||
def test_sync_deduplicates_symbols(self):
|
||||
service, mock_api = make_service()
|
||||
mock_api.set_leverage.return_value = True
|
||||
service.settings.bitget_default_leverage = 10
|
||||
|
||||
result = service.sync_default_leverage(['BTCUSDT', 'BTC', 'BTCUSDT'])
|
||||
# BTC, BTCUSDT, BTCUSDT 去重后只有 BTC
|
||||
assert mock_api.set_leverage.call_count == 1
|
||||
|
||||
def test_sync_partial_failure(self):
|
||||
service, mock_api = make_service()
|
||||
mock_api.set_leverage.side_effect = [True, False]
|
||||
service.settings.bitget_default_leverage = 10
|
||||
|
||||
result = service.sync_default_leverage(['BTCUSDT', 'ETHUSDT'])
|
||||
assert result['success'] is False
|
||||
|
||||
|
||||
# ==================== TestCancelOrder ====================
|
||||
|
||||
class TestCancelOrder:
|
||||
|
||||
def test_cancel_success(self):
|
||||
service, mock_api = make_service()
|
||||
mock_api.cancel_order.return_value = True
|
||||
result = service.cancel_order('BTC', 'ord123')
|
||||
assert result['success'] is True
|
||||
assert result['order_id'] == 'ord123'
|
||||
|
||||
def test_cancel_failure(self):
|
||||
service, mock_api = make_service()
|
||||
mock_api.cancel_order.return_value = False
|
||||
result = service.cancel_order('BTC', 'ord456')
|
||||
assert result['success'] is False
|
||||
|
||||
def test_cancel_exception(self):
|
||||
service, mock_api = make_service()
|
||||
mock_api.cancel_order.side_effect = Exception("order not found")
|
||||
result = service.cancel_order('BTC', 'ord789')
|
||||
assert result['success'] is False
|
||||
assert 'order not found' in result['error']
|
||||
|
||||
Loading…
Reference in New Issue
Block a user