update
This commit is contained in:
parent
520428895b
commit
8533f7c4b4
@ -55,7 +55,7 @@ class BitgetLiveTradingService:
|
|||||||
self.settings = get_settings()
|
self.settings = get_settings()
|
||||||
self.max_total_leverage: float = self.settings.bitget_max_total_leverage
|
self.max_total_leverage: float = self.settings.bitget_max_total_leverage
|
||||||
self.max_single_position: float = self.settings.bitget_max_single_position
|
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()
|
self.trading_api = get_bitget_trading_api()
|
||||||
if not self.trading_api:
|
if not self.trading_api:
|
||||||
@ -289,12 +289,12 @@ class BitgetLiveTradingService:
|
|||||||
type='market',
|
type='market',
|
||||||
side=side,
|
side=side,
|
||||||
amount=actual_amount,
|
amount=actual_amount,
|
||||||
params={
|
params=self.trading_api._with_account_mode_params({
|
||||||
'tdMode': 'cross',
|
'tdMode': 'cross',
|
||||||
'marginCoin': 'USDT',
|
'marginCoin': 'USDT',
|
||||||
'holdMode': 'oneWay',
|
'holdMode': 'oneWay',
|
||||||
**params
|
**params
|
||||||
}
|
})
|
||||||
)
|
)
|
||||||
|
|
||||||
if not order:
|
if not order:
|
||||||
@ -355,7 +355,7 @@ class BitgetLiveTradingService:
|
|||||||
side=side,
|
side=side,
|
||||||
amount=actual_amount,
|
amount=actual_amount,
|
||||||
price=price,
|
price=price,
|
||||||
params=params
|
params=self.trading_api._with_account_mode_params(params)
|
||||||
)
|
)
|
||||||
|
|
||||||
if not order:
|
if not order:
|
||||||
@ -625,9 +625,22 @@ class BitgetLiveTradingService:
|
|||||||
all_ok = all(r.get('success') for r in results)
|
all_ok = all(r.get('success') for r in results)
|
||||||
return {"success": all_ok, "results": 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]:
|
def market_close_position(self, symbol: str) -> Dict[str, Any]:
|
||||||
"""按交易对市价平仓单个持仓"""
|
"""按交易对市价平仓单个持仓"""
|
||||||
import math
|
|
||||||
|
|
||||||
coin = symbol.replace('USDT', '').replace('/', '').upper()
|
coin = symbol.replace('USDT', '').replace('/', '').upper()
|
||||||
position = self.get_position_for_symbol(coin)
|
position = self.get_position_for_symbol(coin)
|
||||||
@ -635,7 +648,7 @@ class BitgetLiveTradingService:
|
|||||||
return {"success": False, "coin": coin, "error": "未找到持仓"}
|
return {"success": False, "coin": coin, "error": "未找到持仓"}
|
||||||
|
|
||||||
is_long = position['size'] > 0
|
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:
|
if coin_amount < 0.0001:
|
||||||
logger.warning(f"{coin} 持仓过小 ({coin_amount}),跳过")
|
logger.warning(f"{coin} 持仓过小 ({coin_amount}),跳过")
|
||||||
return {"success": True, "coin": coin, "size": 0}
|
return {"success": True, "coin": coin, "size": 0}
|
||||||
@ -649,11 +662,11 @@ class BitgetLiveTradingService:
|
|||||||
symbol=ccxt_symbol,
|
symbol=ccxt_symbol,
|
||||||
side=side,
|
side=side,
|
||||||
amount=coin_amount,
|
amount=coin_amount,
|
||||||
params={
|
params=self.trading_api._with_account_mode_params({
|
||||||
'reduceOnly': True,
|
'reduceOnly': True,
|
||||||
'tdMode': 'cross',
|
'tdMode': 'cross',
|
||||||
'marginCoin': 'USDT',
|
'marginCoin': 'USDT',
|
||||||
}
|
})
|
||||||
)
|
)
|
||||||
if order:
|
if order:
|
||||||
logger.info(f"✅ Bitget 单币种平仓成功: {coin} {side} {coin_amount}")
|
logger.info(f"✅ Bitget 单币种平仓成功: {coin} {side} {coin_amount}")
|
||||||
|
|||||||
@ -12,6 +12,7 @@ Bitget 实盘交易 API (基于 CCXT SDK)
|
|||||||
使用 CCXT 统一交易所接口,提供更稳定的 API 交互。
|
使用 CCXT 统一交易所接口,提供更稳定的 API 交互。
|
||||||
"""
|
"""
|
||||||
import ccxt
|
import ccxt
|
||||||
|
import math
|
||||||
from typing import Dict, List, Optional, Any
|
from typing import Dict, List, Optional, Any
|
||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
from app.utils.logger import logger
|
from app.utils.logger import logger
|
||||||
@ -23,6 +24,14 @@ class BitgetTradingAPI:
|
|||||||
DEFAULT_PRODUCT_TYPE = 'USDT-FUTURES'
|
DEFAULT_PRODUCT_TYPE = 'USDT-FUTURES'
|
||||||
DEFAULT_MARGIN_COIN = 'USDT'
|
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
|
@staticmethod
|
||||||
def _pick_first_value(entry: Dict[str, Any], *keys: str, default: str = '0') -> str:
|
def _pick_first_value(entry: Dict[str, Any], *keys: str, default: str = '0') -> str:
|
||||||
"""兼容 Bitget UTA 不同字段名,返回第一个非空值。"""
|
"""兼容 Bitget UTA 不同字段名,返回第一个非空值。"""
|
||||||
@ -99,47 +108,8 @@ class BitgetTradingAPI:
|
|||||||
# CCXT 标准化交易对格式
|
# CCXT 标准化交易对格式
|
||||||
ccxt_symbol = self._standardize_symbol(symbol)
|
ccxt_symbol = self._standardize_symbol(symbol)
|
||||||
|
|
||||||
# 手动获取合约规格(不依赖 CCXT 的 contractSize)
|
# 获取合约面值
|
||||||
# Bitget 永续合约规格
|
contract_size = self._get_contract_size(symbol)
|
||||||
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")
|
|
||||||
|
|
||||||
# 计算实际下单数量(张数 × 合约规格)
|
# 计算实际下单数量(张数 × 合约规格)
|
||||||
# Bitget 的 amount 参数是币的数量,不是张数
|
# Bitget 的 amount 参数是币的数量,不是张数
|
||||||
@ -253,7 +223,7 @@ class BitgetTradingAPI:
|
|||||||
def close_position(self, symbol: str, side: str = None, size: float = None,
|
def close_position(self, symbol: str, side: str = None, size: float = None,
|
||||||
price: float = None) -> Optional[Dict]:
|
price: float = None) -> Optional[Dict]:
|
||||||
"""
|
"""
|
||||||
平仓(双向持仓模式)
|
平仓(单向持仓模式)
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
symbol: 交易对
|
symbol: 交易对
|
||||||
@ -299,15 +269,15 @@ class BitgetTradingAPI:
|
|||||||
side = 'sell' if pos_side == 'long' else 'buy'
|
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
|
close_size_btc = size * self._get_contract_size(symbol) if size else current_size_btc
|
||||||
|
|
||||||
# 精度处理:向下取整到 0.0001 BTC(Bitget 最小精度)
|
# 精度处理:从市场信息动态获取精度
|
||||||
import math
|
close_size_btc = self._floor_amount(ccxt_symbol, close_size_btc)
|
||||||
close_size_btc = math.floor(close_size_btc * 10000) / 10000
|
|
||||||
|
|
||||||
if close_size_btc < 0.0001:
|
min_amount = self._get_min_amount(ccxt_symbol)
|
||||||
logger.warning(f"{symbol} 平仓数量 {close_size_btc} BTC 小于最小交易单位 0.0001 BTC")
|
if close_size_btc < min_amount:
|
||||||
|
logger.warning(f"{symbol} 平仓数量 {close_size_btc} 小于最小交易单位 {min_amount}")
|
||||||
return None
|
return None
|
||||||
|
|
||||||
logger.info(f"平仓: {symbol} 持仓方向={pos_side}, 平仓方向={side}, 数量={close_size_btc} BTC")
|
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,
|
def set_trailing_stop(self, symbol: str, callback_rate: float = None,
|
||||||
activation_price: float = None) -> bool:
|
activation_price: float = None) -> bool:
|
||||||
"""
|
"""
|
||||||
设置移动止损
|
设置移动止损(UTA V3 兼容)
|
||||||
|
|
||||||
|
通过下一个 trailing_stop 类型的条件单来实现移动止损。
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
symbol: 交易对
|
symbol: 交易对
|
||||||
@ -370,26 +342,36 @@ class BitgetTradingAPI:
|
|||||||
return False
|
return False
|
||||||
|
|
||||||
position = positions[0]
|
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")
|
logger.warning(f"{symbol} 持仓数量为 0")
|
||||||
return False
|
return False
|
||||||
|
|
||||||
# 使用 CCXT 的私人 API 设置移动止损
|
pos_side = position.get('side', 'long')
|
||||||
# Bitget 需要通过私人API调用
|
close_side = 'sell' if pos_side == 'long' else 'buy'
|
||||||
params = {
|
|
||||||
'symbol': ccxt_symbol,
|
# 使用 CCXT 统一接口下 trailing stop 条件单
|
||||||
'trailingStopCallbackRate': callback_rate,
|
params = self._with_account_mode_params({
|
||||||
}
|
'tdMode': 'cross',
|
||||||
params = self._with_account_mode_params(params)
|
'marginCoin': 'USDT',
|
||||||
|
'reduceOnly': True,
|
||||||
|
'trailingPercent': callback_rate * 100 if callback_rate else None, # CCXT 需要百分比
|
||||||
|
})
|
||||||
|
|
||||||
if activation_price:
|
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
|
return True
|
||||||
|
|
||||||
except ccxt.BaseError as e:
|
except ccxt.BaseError as e:
|
||||||
@ -460,13 +442,11 @@ class BitgetTradingAPI:
|
|||||||
# 使用独立的止损/止盈计划订单
|
# 使用独立的止损/止盈计划订单
|
||||||
# 注意:这种方式需要在平仓时也取消这些计划订单
|
# 注意:这种方式需要在平仓时也取消这些计划订单
|
||||||
|
|
||||||
# CCXT 的 contracts 字段对于 Bitget 实际上已经是 BTC 数量
|
# CCXT 的 contracts 字段对于 Bitget 实际上已经是币数量
|
||||||
# 所以我们直接使用,不需要再乘以 contract_size
|
|
||||||
btc_amount = abs(contracts)
|
btc_amount = abs(contracts)
|
||||||
|
|
||||||
# 精度处理
|
# 精度处理:使用动态精度
|
||||||
import math
|
btc_amount = self._floor_amount(ccxt_symbol, btc_amount)
|
||||||
btc_amount = math.floor(btc_amount * 10000) / 10000
|
|
||||||
|
|
||||||
orders_created = []
|
orders_created = []
|
||||||
|
|
||||||
@ -899,46 +879,54 @@ class BitgetTradingAPI:
|
|||||||
"""
|
"""
|
||||||
获取合约面值(每张合约对应的币数量)
|
获取合约面值(每张合约对应的币数量)
|
||||||
|
|
||||||
|
优先从硬编码表获取,不存在则查询 CCXT 市场信息。
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
symbol: 交易对
|
symbol: 交易对
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
合约面值
|
合约面值
|
||||||
"""
|
"""
|
||||||
# Bitget 永续合约规格
|
# 提取纯币种名
|
||||||
if 'BTC' in symbol:
|
coin = symbol.replace('USDT', '').replace('/USDT:USDT', '').replace('/', '').upper()
|
||||||
return 0.01
|
if coin in self.CONTRACT_SIZES:
|
||||||
elif 'ETH' in symbol:
|
return self.CONTRACT_SIZES[coin]
|
||||||
return 0.1
|
|
||||||
elif 'SOL' in symbol:
|
# fallback: 从 CCXT market info 获取
|
||||||
return 1.0
|
try:
|
||||||
elif 'BNB' in symbol:
|
ccxt_symbol = self._standardize_symbol(symbol)
|
||||||
return 0.1
|
market = self.exchange.market(ccxt_symbol)
|
||||||
elif 'XRP' in symbol:
|
size = float(market.get('contractSize', 1) or 1)
|
||||||
return 10.0
|
logger.info(f"从市场信息获取合约面值: {coin} = {size}")
|
||||||
elif 'DOGE' in symbol:
|
return size
|
||||||
return 100.0
|
except Exception:
|
||||||
elif 'MATIC' in symbol or 'POL' in symbol:
|
logger.warning(f"无法获取 {coin} 合约面值,使用默认值 1")
|
||||||
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:
|
|
||||||
return 1.0
|
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:
|
def test_connection(self) -> bool:
|
||||||
"""
|
"""
|
||||||
测试 API 连接
|
测试 API 连接
|
||||||
@ -983,15 +971,8 @@ def get_bitget_trading_api() -> Optional[BitgetTradingAPI]:
|
|||||||
"""
|
"""
|
||||||
global _trading_api
|
global _trading_api
|
||||||
|
|
||||||
# 如果已有实例,检查它是否仍然有效
|
|
||||||
if _trading_api:
|
if _trading_api:
|
||||||
try:
|
return _trading_api
|
||||||
# 尝试获取余额来验证连接是否仍然有效
|
|
||||||
_trading_api.get_balance()
|
|
||||||
return _trading_api
|
|
||||||
except Exception as e:
|
|
||||||
logger.warning(f"Bitget API 实例已失效({e}),将重新创建")
|
|
||||||
_trading_api = None
|
|
||||||
|
|
||||||
from app.config import get_settings
|
from app.config import get_settings
|
||||||
|
|
||||||
@ -999,7 +980,7 @@ def get_bitget_trading_api() -> Optional[BitgetTradingAPI]:
|
|||||||
|
|
||||||
# 检查是否配置了 API Key
|
# 检查是否配置了 API Key
|
||||||
if not settings.bitget_api_key or not settings.bitget_api_secret:
|
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
|
return None
|
||||||
|
|
||||||
# 创建实例
|
# 创建实例
|
||||||
|
|||||||
@ -12,7 +12,8 @@ def _mock_settings():
|
|||||||
s = MagicMock()
|
s = MagicMock()
|
||||||
s.bitget_max_total_leverage = 10.0
|
s.bitget_max_total_leverage = 10.0
|
||||||
s.bitget_max_single_position = 1000.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
|
s.bitget_trading_enabled = False
|
||||||
return s
|
return s
|
||||||
|
|
||||||
|
|||||||
@ -221,7 +221,7 @@ def test_uta_none_fields():
|
|||||||
|
|
||||||
balance = api.get_balance()
|
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_exchange = MagicMock()
|
||||||
mock_api.exchange = mock_exchange
|
mock_api.exchange = mock_exchange
|
||||||
mock_api._standardize_symbol = lambda s: f"{s.replace('USDT', '')}/USDT:USDT"
|
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 = MagicMock()
|
||||||
mock_settings.bitget_max_total_leverage = 10.0
|
mock_settings.bitget_max_total_leverage = 10.0
|
||||||
mock_settings.bitget_max_single_position = 1000.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:
|
if settings_overrides:
|
||||||
for k, v in settings_overrides.items():
|
for k, v in settings_overrides.items():
|
||||||
setattr(mock_settings, k, v)
|
setattr(mock_settings, k, v)
|
||||||
@ -54,7 +56,7 @@ def make_service(settings_overrides=None):
|
|||||||
service.settings = mock_settings
|
service.settings = mock_settings
|
||||||
service.max_total_leverage = mock_settings.bitget_max_total_leverage
|
service.max_total_leverage = mock_settings.bitget_max_total_leverage
|
||||||
service.max_single_position = mock_settings.bitget_max_single_position
|
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.trading_api = mock_api
|
||||||
service.initial_balance = 10000.0
|
service.initial_balance = 10000.0
|
||||||
|
|
||||||
@ -532,11 +534,11 @@ class TestCheckRiskLimits:
|
|||||||
assert result['allowed'] is True
|
assert result['allowed'] is True
|
||||||
|
|
||||||
def test_circuit_breaker_triggered(self):
|
def test_circuit_breaker_triggered(self):
|
||||||
"""账户余额从 10000 跌至 8900 → 回撤 11% > 10% → 熔断"""
|
"""账户余额从 10000 跌至 7000 → 回撤 30% > 25% → 熔断"""
|
||||||
service, mock_api = make_service()
|
service, mock_api = make_service()
|
||||||
service.initial_balance = 10000.0
|
service.initial_balance = 10000.0
|
||||||
mock_api.get_balance.return_value = {
|
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 = []
|
mock_api.get_position.return_value = []
|
||||||
result = service.check_risk_limits()
|
result = service.check_risk_limits()
|
||||||
@ -668,10 +670,11 @@ class TestGetBitgetLiveServiceFactory:
|
|||||||
mock_settings.bitget_trading_enabled = True
|
mock_settings.bitget_trading_enabled = True
|
||||||
mock_settings.bitget_max_total_leverage = 10.0
|
mock_settings.bitget_max_total_leverage = 10.0
|
||||||
mock_settings.bitget_max_single_position = 1000.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 = MagicMock()
|
||||||
mock_api._standardize_symbol = lambda s: f"{s}/USDT:USDT"
|
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 = {
|
mock_api.get_balance.return_value = {
|
||||||
'USDT': {'available': '5000', 'frozen': '0', 'locked': '0'}
|
'USDT': {'available': '5000', 'frozen': '0', 'locked': '0'}
|
||||||
}
|
}
|
||||||
@ -877,3 +880,189 @@ class TestInitializeAccount:
|
|||||||
service._initialize_account() # 不应抛出异常
|
service._initialize_account() # 不应抛出异常
|
||||||
|
|
||||||
assert service.initial_balance is None
|
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