This commit is contained in:
aaron 2026-03-30 11:56:28 +08:00
parent 520428895b
commit 8533f7c4b4
6 changed files with 786 additions and 708 deletions

View File

@ -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}")

View File

@ -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 BTCBitget 最小精度)
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
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
# 创建实例

View File

@ -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

View File

@ -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

View File

@ -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']