fix
This commit is contained in:
parent
53863709dc
commit
df55f2ff37
@ -159,10 +159,12 @@ class Settings(BaseSettings):
|
||||
bitget_api_secret: str = "" # Bitget API Secret
|
||||
bitget_passphrase: str = "" # Bitget API Passphrase
|
||||
bitget_use_testnet: bool = True # 是否使用测试网(测试时设为 true)
|
||||
bitget_use_unified_account: bool = True # 使用统一账户(UTA)接口
|
||||
|
||||
# 实盘风险控制(Bitget 实盘共用)
|
||||
bitget_max_single_position: float = 1000 # 单笔最大持仓金额 (USDT)
|
||||
bitget_max_total_leverage: float = 10 # 总杠杆上限(倍数)
|
||||
bitget_default_leverage: int = 10 # 默认执行杠杆(启动时同步到交易对)
|
||||
|
||||
# 账户级止损(所有平台通用)
|
||||
account_max_drawdown: float = 0.25 # 账户最大回撤(25%),超过则停止交易并平仓
|
||||
|
||||
@ -176,6 +176,12 @@ class CryptoAgent:
|
||||
|
||||
# 配置
|
||||
self.symbols = self.settings.crypto_symbols.split(',')
|
||||
if self.bitget:
|
||||
sync_result = self.bitget.sync_default_leverage(
|
||||
self.symbols,
|
||||
leverage=self.settings.bitget_default_leverage
|
||||
)
|
||||
logger.info(f"Bitget 默认杠杆同步结果: {sync_result}")
|
||||
|
||||
# 运行状态
|
||||
self.running = False
|
||||
|
||||
@ -22,11 +22,12 @@ class BitgetExecutor(BaseExecutor):
|
||||
"""执行开仓"""
|
||||
try:
|
||||
symbol = decision.get('symbol', '').replace('USDT', '')
|
||||
action = decision.get('action') # buy/sell
|
||||
action = decision.get('signal_action', decision.get('action')) # buy/sell
|
||||
margin = decision.get('margin', decision.get('quantity', 0))
|
||||
entry_price = decision.get('entry_price', current_price)
|
||||
stop_loss = decision.get('stop_loss')
|
||||
take_profit = decision.get('take_profit')
|
||||
leverage = min(decision.get('leverage', self.bitget.settings.bitget_default_leverage), 10)
|
||||
|
||||
# 决定订单类型
|
||||
order_type, order_reason = self.decide_order_type(decision, current_price)
|
||||
@ -37,17 +38,19 @@ class BitgetExecutor(BaseExecutor):
|
||||
available = account_state.get('available_balance', 0)
|
||||
adjusted_margin = self.calculate_effective_margin(available, margin)
|
||||
|
||||
# 讣算合约张数
|
||||
contracts = self._calculate_contracts(symbol, adjusted_margin, entry_price)
|
||||
# 计算合约张数,必须与实际执行杠杆保持一致
|
||||
contracts = self._calculate_contracts(symbol, adjusted_margin, entry_price, leverage)
|
||||
|
||||
if contracts < 1:
|
||||
return {
|
||||
'success': False,
|
||||
'error': f'仓位计算结果 {contracts} 张,低于最小下单量'
|
||||
'error': (
|
||||
f'仓位计算结果 {contracts} 张,低于最小下单量 '
|
||||
f'(保证金=${adjusted_margin:.2f}, 杠杆={leverage}x)'
|
||||
)
|
||||
}
|
||||
|
||||
# 设置杠杆
|
||||
leverage = min(decision.get('leverage', 5), 10)
|
||||
self.bitget.update_leverage(symbol, leverage)
|
||||
|
||||
# 下单
|
||||
@ -319,14 +322,14 @@ class BitgetExecutor(BaseExecutor):
|
||||
|
||||
# ==================== 辅助方法 ====================
|
||||
|
||||
def _calculate_contracts(self, symbol: str, margin: float, price: float) -> int:
|
||||
def _calculate_contracts(self, symbol: str, margin: float, price: float, leverage: int) -> int:
|
||||
"""计算合约张数"""
|
||||
try:
|
||||
# 获取合约规格
|
||||
contract_size = self.bitget.get_contract_size(symbol.replace('USDT', ''))
|
||||
|
||||
# 计算持仓价值
|
||||
position_value = margin * 5 # 假设 5x 杠杆
|
||||
position_value = margin * leverage
|
||||
|
||||
# 计算币数量
|
||||
coin_amount = position_value / price
|
||||
@ -334,7 +337,10 @@ class BitgetExecutor(BaseExecutor):
|
||||
# 计算合约张数(向下取整)
|
||||
contracts = int(coin_amount / contract_size)
|
||||
|
||||
logger.info(f" 仓位计算: ${margin:.2f} USD → {coin_amount:.6f} {symbol} → {contracts} 张")
|
||||
logger.info(
|
||||
f" 仓位计算: ${margin:.2f} × {leverage}x = ${position_value:.2f} "
|
||||
f"→ {coin_amount:.6f} {symbol} → {contracts} 张"
|
||||
)
|
||||
|
||||
return contracts
|
||||
|
||||
|
||||
@ -21,7 +21,7 @@ class HyperliquidExecutor(BaseExecutor):
|
||||
"""执行开仓"""
|
||||
try:
|
||||
symbol = decision.get('symbol', '').replace('USDT', '')
|
||||
action = decision.get('action') # buy/sell
|
||||
action = decision.get('signal_action', decision.get('action')) # buy/sell
|
||||
margin = decision.get('margin', decision.get('quantity', 0))
|
||||
entry_price = decision.get('entry_price', current_price)
|
||||
stop_loss = decision.get('stop_loss')
|
||||
@ -37,7 +37,7 @@ class HyperliquidExecutor(BaseExecutor):
|
||||
adjusted_margin = self.calculate_effective_margin(available, margin)
|
||||
|
||||
# 计算仓位大小
|
||||
leverage = min(decision.get('leverage', 5), 10)
|
||||
leverage = min(decision.get('leverage', 10), 10)
|
||||
position_size = self._calculate_position_size(symbol, adjusted_margin, entry_price, leverage)
|
||||
|
||||
if position_size <= 0:
|
||||
|
||||
@ -88,7 +88,8 @@ class BitgetLiveTradingService:
|
||||
usdt = balance.get('USDT', {})
|
||||
available = float(usdt.get('available', 0) or 0)
|
||||
frozen = float(usdt.get('frozen', 0) or 0)
|
||||
account_value = available + frozen
|
||||
equity = float(usdt.get('equity', 0) or 0)
|
||||
account_value = equity if equity > 0 else (available + frozen)
|
||||
|
||||
return {
|
||||
"account_value": account_value,
|
||||
@ -481,12 +482,49 @@ class BitgetLiveTradingService:
|
||||
|
||||
# ==================== 杠杆 ====================
|
||||
|
||||
def update_leverage(self, symbol: str, leverage: int):
|
||||
def update_leverage(self, symbol: str, leverage: int) -> bool:
|
||||
"""设置杠杆倍数"""
|
||||
try:
|
||||
self.trading_api.set_leverage(symbol, leverage)
|
||||
return self.trading_api.set_leverage(symbol, leverage)
|
||||
except Exception as e:
|
||||
logger.warning(f"Bitget 设置杠杆失败: {symbol} {leverage}x: {e}")
|
||||
return False
|
||||
|
||||
def sync_default_leverage(self, symbols: List[str], leverage: Optional[int] = None) -> Dict[str, Any]:
|
||||
"""将默认杠杆同步到一组交易对"""
|
||||
target_leverage = int(leverage or self.settings.bitget_default_leverage or 10)
|
||||
normalized_symbols = []
|
||||
for symbol in symbols:
|
||||
if not symbol:
|
||||
continue
|
||||
normalized = symbol.replace("/", "").strip().upper()
|
||||
if normalized.endswith("USDT"):
|
||||
normalized = normalized[:-4]
|
||||
if normalized:
|
||||
normalized_symbols.append(normalized)
|
||||
|
||||
if not normalized_symbols:
|
||||
logger.info("Bitget 默认杠杆同步跳过:没有可用交易对")
|
||||
return {"success": True, "leverage": target_leverage, "results": {}}
|
||||
|
||||
deduped_symbols = list(dict.fromkeys(normalized_symbols))
|
||||
logger.info(f"开始同步 Bitget 默认杠杆: {target_leverage}x -> {deduped_symbols}")
|
||||
results: Dict[str, bool] = {}
|
||||
for symbol in deduped_symbols:
|
||||
results[symbol] = self.update_leverage(symbol, target_leverage)
|
||||
|
||||
success = all(results.values()) if results else True
|
||||
if success:
|
||||
logger.info(f"Bitget 默认杠杆同步完成: {target_leverage}x")
|
||||
else:
|
||||
failed_symbols = [symbol for symbol, ok in results.items() if not ok]
|
||||
logger.warning(f"Bitget 默认杠杆同步存在失败: {failed_symbols}")
|
||||
|
||||
return {
|
||||
"success": success,
|
||||
"leverage": target_leverage,
|
||||
"results": results,
|
||||
}
|
||||
|
||||
# ==================== 辅助方法 ====================
|
||||
|
||||
|
||||
@ -20,6 +20,9 @@ from app.utils.logger import logger
|
||||
class BitgetTradingAPI:
|
||||
"""Bitget 实盘交易 API (基于 CCXT)"""
|
||||
|
||||
DEFAULT_PRODUCT_TYPE = 'USDT-FUTURES'
|
||||
DEFAULT_MARGIN_COIN = 'USDT'
|
||||
|
||||
def __init__(self, api_key: str, api_secret: str, passphrase: str = "", use_testnet: bool = True):
|
||||
"""
|
||||
初始化 Bitget 交易 API
|
||||
@ -33,6 +36,9 @@ class BitgetTradingAPI:
|
||||
self.api_key = api_key
|
||||
self.api_secret = api_secret
|
||||
self.use_testnet = use_testnet
|
||||
from app.config import get_settings
|
||||
self.settings = get_settings()
|
||||
self.use_unified_account = getattr(self.settings, 'bitget_use_unified_account', True)
|
||||
|
||||
# 创建 CCXT Bitget 实例
|
||||
config = {
|
||||
@ -137,6 +143,7 @@ class BitgetTradingAPI:
|
||||
'marginCoin': 'USDT', # 保证金币种
|
||||
'holdMode': 'oneWay', # 单向持仓模式
|
||||
}
|
||||
params = self._with_account_mode_params(params)
|
||||
|
||||
if client_order_id:
|
||||
params['clientOrderId'] = client_order_id
|
||||
@ -193,9 +200,9 @@ class BitgetTradingAPI:
|
||||
ccxt_symbol = self._standardize_symbol(symbol)
|
||||
|
||||
if order_id:
|
||||
self.exchange.cancel_order(order_id, ccxt_symbol)
|
||||
self.exchange.cancel_order(order_id, ccxt_symbol, self._with_account_mode_params())
|
||||
elif client_order_id:
|
||||
self.exchange.cancel_order_by_client_order_id(client_order_id, ccxt_symbol)
|
||||
self.exchange.cancel_order_by_client_order_id(client_order_id, ccxt_symbol, self._with_account_mode_params())
|
||||
else:
|
||||
logger.error("必须提供 order_id 或 client_order_id")
|
||||
return False
|
||||
@ -222,7 +229,7 @@ class BitgetTradingAPI:
|
||||
"""
|
||||
try:
|
||||
ccxt_symbol = self._standardize_symbol(symbol)
|
||||
self.exchange.cancel_all_orders(ccxt_symbol)
|
||||
self.exchange.cancel_all_orders(ccxt_symbol, self._with_account_mode_params())
|
||||
|
||||
logger.info(f"✅ 撤销所有挂单成功: {symbol}")
|
||||
return True
|
||||
@ -303,6 +310,7 @@ class BitgetTradingAPI:
|
||||
'tdMode': 'cross',
|
||||
'marginCoin': 'USDT',
|
||||
}
|
||||
params = self._with_account_mode_params(params)
|
||||
|
||||
if price:
|
||||
order = self.exchange.create_limit_order(
|
||||
@ -365,6 +373,7 @@ class BitgetTradingAPI:
|
||||
'symbol': ccxt_symbol,
|
||||
'trailingStopCallbackRate': callback_rate,
|
||||
}
|
||||
params = self._with_account_mode_params(params)
|
||||
|
||||
if activation_price:
|
||||
params['trailingStopActivationPrice'] = activation_price
|
||||
@ -469,6 +478,7 @@ class BitgetTradingAPI:
|
||||
'tdMode': 'cross',
|
||||
'marginCoin': 'USDT',
|
||||
'reduceOnly': True, # 只平仓
|
||||
**self._with_account_mode_params(),
|
||||
}
|
||||
)
|
||||
orders_created.append(('止损', sl_order))
|
||||
@ -491,6 +501,7 @@ class BitgetTradingAPI:
|
||||
'tdMode': 'cross',
|
||||
'marginCoin': 'USDT',
|
||||
'reduceOnly': True, # 只平仓
|
||||
**self._with_account_mode_params(),
|
||||
}
|
||||
)
|
||||
orders_created.append(('止盈', tp_order))
|
||||
@ -533,9 +544,9 @@ class BitgetTradingAPI:
|
||||
ccxt_symbol = self._standardize_symbol(symbol)
|
||||
|
||||
if order_id:
|
||||
order = self.exchange.fetch_order(order_id, ccxt_symbol)
|
||||
order = self.exchange.fetch_order(order_id, ccxt_symbol, self._with_account_mode_params())
|
||||
elif client_order_id:
|
||||
order = self.exchange.fetch_order_by_client_order_id(client_order_id, ccxt_symbol)
|
||||
order = self.exchange.fetch_order_by_client_order_id(client_order_id, ccxt_symbol, self._with_account_mode_params())
|
||||
else:
|
||||
logger.error("必须提供 order_id 或 client_order_id")
|
||||
return None
|
||||
@ -561,7 +572,7 @@ class BitgetTradingAPI:
|
||||
"""
|
||||
try:
|
||||
ccxt_symbol = self._standardize_symbol(symbol) if symbol else None
|
||||
orders = self.exchange.fetch_open_orders(ccxt_symbol)
|
||||
orders = self.exchange.fetch_open_orders(ccxt_symbol, None, None, self._with_account_mode_params())
|
||||
|
||||
logger.debug(f"查询到 {len(orders)} 个挂单")
|
||||
return orders
|
||||
@ -586,7 +597,7 @@ class BitgetTradingAPI:
|
||||
"""
|
||||
try:
|
||||
ccxt_symbol = self._standardize_symbol(symbol)
|
||||
orders = self.exchange.fetch_my_trades(ccxt_symbol, limit=limit)
|
||||
orders = self.exchange.fetch_my_trades(ccxt_symbol, limit=limit, params=self._with_account_mode_params())
|
||||
|
||||
logger.debug(f"查询到 {len(orders)} 条历史订单")
|
||||
return orders
|
||||
@ -612,7 +623,7 @@ class BitgetTradingAPI:
|
||||
"""
|
||||
try:
|
||||
# 获取所有持仓
|
||||
positions = self.exchange.fetch_positions()
|
||||
positions = self.exchange.fetch_positions(None, self._with_account_mode_params())
|
||||
|
||||
# 筛选非零持仓
|
||||
active_positions = []
|
||||
@ -691,17 +702,40 @@ class BitgetTradingAPI:
|
||||
余额信息 {USDT: {available: "...", frozen: "...", locked: "..."}}
|
||||
"""
|
||||
try:
|
||||
balance = self.exchange.fetch_balance()
|
||||
|
||||
# 转换为统一格式
|
||||
result = {}
|
||||
for currency, info in balance.get('total', {}).items():
|
||||
if info and info > 0: # 只返回有余额的币种
|
||||
if self.use_unified_account:
|
||||
response = self.exchange.privateUtaGetV3AccountAssets({
|
||||
'coin': self.DEFAULT_MARGIN_COIN,
|
||||
})
|
||||
assets = response.get('data', {}).get('assets', []) or []
|
||||
result = {}
|
||||
for entry in assets:
|
||||
currency = entry.get('coin')
|
||||
if not currency:
|
||||
continue
|
||||
result[currency] = {
|
||||
'available': str(balance.get('free', {}).get(currency, 0)),
|
||||
'frozen': str(balance.get('used', {}).get(currency, 0)),
|
||||
'locked': '0'
|
||||
'available': str(entry.get('available', '0')),
|
||||
'frozen': str(entry.get('locked', '0')),
|
||||
'locked': str(entry.get('locked', '0')),
|
||||
'equity': str(entry.get('equity', entry.get('balance', '0'))),
|
||||
}
|
||||
logger.debug(f"账户余额: {result}")
|
||||
return result
|
||||
|
||||
response = self.exchange.privateMixGetV2MixAccountAccounts({
|
||||
'productType': self.DEFAULT_PRODUCT_TYPE,
|
||||
})
|
||||
|
||||
result = {}
|
||||
for entry in response.get('data', []) or []:
|
||||
currency = entry.get('marginCoin')
|
||||
if not currency:
|
||||
continue
|
||||
result[currency] = {
|
||||
'available': str(entry.get('available', '0')),
|
||||
'frozen': str(entry.get('locked', '0')),
|
||||
'locked': str(entry.get('locked', '0')),
|
||||
'equity': str(entry.get('accountEquity', entry.get('usdtEquity', '0'))),
|
||||
}
|
||||
|
||||
logger.debug(f"账户余额: {result}")
|
||||
return result
|
||||
@ -721,8 +755,13 @@ class BitgetTradingAPI:
|
||||
账户信息
|
||||
"""
|
||||
try:
|
||||
balance = self.exchange.fetch_balance()
|
||||
return balance
|
||||
if self.use_unified_account:
|
||||
return self.exchange.privateUtaGetV3AccountAssets({
|
||||
'coin': self.DEFAULT_MARGIN_COIN,
|
||||
})
|
||||
return self.exchange.privateMixGetV2MixAccountAccounts({
|
||||
'productType': self.DEFAULT_PRODUCT_TYPE,
|
||||
})
|
||||
except ccxt.BaseError as e:
|
||||
logger.error(f"❌ 查询账户信息失败: {e}")
|
||||
return {}
|
||||
@ -743,10 +782,24 @@ class BitgetTradingAPI:
|
||||
是否成功
|
||||
"""
|
||||
try:
|
||||
ccxt_symbol = self._standardize_symbol(symbol)
|
||||
if self.use_unified_account:
|
||||
response = self.exchange.privateUtaPostV3AccountSetLeverage({
|
||||
'symbol': self._to_contract_symbol_id(symbol),
|
||||
'coin': self.DEFAULT_MARGIN_COIN,
|
||||
'category': self.DEFAULT_PRODUCT_TYPE,
|
||||
'leverage': str(leverage),
|
||||
})
|
||||
else:
|
||||
response = self.exchange.privateMixPostV2MixAccountSetLeverage({
|
||||
'symbol': self._to_contract_symbol_id(symbol),
|
||||
'marginCoin': self.DEFAULT_MARGIN_COIN,
|
||||
'productType': self.DEFAULT_PRODUCT_TYPE,
|
||||
'leverage': str(leverage),
|
||||
})
|
||||
|
||||
# CCXT 设置杠杆
|
||||
self.exchange.set_leverage(leverage, ccxt_symbol)
|
||||
if response.get('code') != '00000':
|
||||
logger.error(f"❌ 设置杠杆失败: {response}")
|
||||
return False
|
||||
|
||||
logger.info(f"✅ 设置杠杆成功: {symbol} {leverage}x")
|
||||
return True
|
||||
@ -784,6 +837,22 @@ class BitgetTradingAPI:
|
||||
# 例如:BTC -> BTC/USDT:USDT
|
||||
return f"{symbol}/USDT:USDT"
|
||||
|
||||
def _to_contract_symbol_id(self, symbol: str) -> str:
|
||||
"""转成 Bitget U 本位合约 symbol id,例如 BTCUSDT"""
|
||||
normalized = symbol.strip().upper()
|
||||
if '/' in normalized:
|
||||
base = normalized.split('/')[0]
|
||||
return f"{base}USDT"
|
||||
if ':' in normalized:
|
||||
normalized = normalized.split(':')[0]
|
||||
return normalized if normalized.endswith('USDT') else f"{normalized}USDT"
|
||||
|
||||
def _with_account_mode_params(self, params: Optional[Dict[str, Any]] = None) -> Dict[str, Any]:
|
||||
merged = dict(params or {})
|
||||
if self.use_unified_account:
|
||||
merged['uta'] = True
|
||||
return merged
|
||||
|
||||
def _get_contract_size(self, symbol: str) -> float:
|
||||
"""
|
||||
获取合约面值(每张合约对应的币数量)
|
||||
|
||||
@ -75,6 +75,14 @@ class TestGetAccountState:
|
||||
assert state['available_balance'] == pytest.approx(8000.0)
|
||||
assert state['total_margin_used'] == pytest.approx(2000.0)
|
||||
|
||||
def test_equity_preferred_when_present(self):
|
||||
service, mock_api = make_service()
|
||||
mock_api.get_balance.return_value = {
|
||||
'USDT': {'available': '8000.0', 'frozen': '2000.0', 'locked': '2000.0', 'equity': '10500.0'}
|
||||
}
|
||||
state = service.get_account_state()
|
||||
assert state['account_value'] == pytest.approx(10500.0)
|
||||
|
||||
def test_usdt_not_present(self):
|
||||
service, mock_api = make_service()
|
||||
mock_api.get_balance.return_value = {}
|
||||
@ -105,6 +113,12 @@ class TestGetOpenPositions:
|
||||
def _make_raw_position(self, coin='BTC', contracts=1.0, side='long',
|
||||
entry_price=50000.0, pnl=100.0, leverage=10,
|
||||
liq_price=45000.0):
|
||||
contract_sizes = {
|
||||
'BTC': 0.01,
|
||||
'ETH': 0.1,
|
||||
'SOL': 1.0,
|
||||
}
|
||||
available = contracts * contract_sizes.get(coin, 1.0)
|
||||
return {
|
||||
'symbol': f'{coin}/USDT:USDT',
|
||||
'contracts': contracts,
|
||||
@ -113,6 +127,7 @@ class TestGetOpenPositions:
|
||||
'unrealizedPnl': pnl,
|
||||
'leverage': leverage,
|
||||
'liquidationPrice': liq_price,
|
||||
'info': {'available': str(available)},
|
||||
}
|
||||
|
||||
def test_long_position(self):
|
||||
@ -189,6 +204,7 @@ class TestGetPositionForSymbol:
|
||||
'unrealizedPnl': 0.0,
|
||||
'leverage': 10,
|
||||
'liquidationPrice': 45000.0,
|
||||
'info': {'available': '0.01'},
|
||||
}]
|
||||
pos = service.get_position_for_symbol('BTC')
|
||||
assert pos is not None
|
||||
@ -210,6 +226,7 @@ class TestGetPositionForSymbol:
|
||||
'unrealizedPnl': 0.0,
|
||||
'leverage': 5,
|
||||
'liquidationPrice': 2500.0,
|
||||
'info': {'available': '0.5'},
|
||||
}]
|
||||
pos = service.get_position_for_symbol('ETHUSDT')
|
||||
assert pos is not None
|
||||
@ -529,6 +546,7 @@ class TestCheckRiskLimits:
|
||||
'unrealizedPnl': 0.0,
|
||||
'leverage': 10,
|
||||
'liquidationPrice': 50000.0,
|
||||
'info': {'available': '2'},
|
||||
}]
|
||||
result = service.check_risk_limits()
|
||||
assert result['allowed'] is False
|
||||
@ -718,15 +736,16 @@ class TestMarketCloseAll:
|
||||
'unrealizedPnl': 0.0,
|
||||
'leverage': 10,
|
||||
'liquidationPrice': 45000.0,
|
||||
'info': {'available': '0.02'},
|
||||
}]
|
||||
mock_api.exchange.create_order.return_value = {'id': 'close1', 'status': 'closed'}
|
||||
mock_api.exchange.create_market_order.return_value = {'id': 'close1', 'status': 'closed'}
|
||||
|
||||
result = service.market_close_all()
|
||||
assert result['success'] is True
|
||||
assert len(result['results']) == 1
|
||||
# 多仓平仓用 sell
|
||||
call_args = mock_api.exchange.create_order.call_args
|
||||
assert call_args[1].get('side') or call_args[0][2] == 'sell'
|
||||
kwargs = mock_api.exchange.create_market_order.call_args.kwargs
|
||||
assert kwargs['side'] == 'sell'
|
||||
|
||||
def test_close_single_short(self):
|
||||
"""单个空仓:发出 buy 方向的市价单"""
|
||||
@ -739,24 +758,25 @@ class TestMarketCloseAll:
|
||||
'unrealizedPnl': 0.0,
|
||||
'leverage': 5,
|
||||
'liquidationPrice': 3500.0,
|
||||
'info': {'available': '0.5'},
|
||||
}]
|
||||
mock_api.exchange.create_order.return_value = {'id': 'close2', 'status': 'closed'}
|
||||
mock_api.exchange.create_market_order.return_value = {'id': 'close2', 'status': 'closed'}
|
||||
|
||||
result = service.market_close_all()
|
||||
assert result['success'] is True
|
||||
call_args = mock_api.exchange.create_order.call_args
|
||||
assert call_args[1].get('side') or call_args[0][2] == 'buy'
|
||||
kwargs = mock_api.exchange.create_market_order.call_args.kwargs
|
||||
assert kwargs['side'] == 'buy'
|
||||
|
||||
def test_close_multiple_positions(self):
|
||||
"""多个持仓,全部成功"""
|
||||
service, mock_api = make_service()
|
||||
mock_api.get_position.return_value = [
|
||||
{'symbol': 'BTC/USDT:USDT', 'contracts': 1.0, 'side': 'long',
|
||||
'entryPrice': 50000.0, 'unrealizedPnl': 0.0, 'leverage': 10, 'liquidationPrice': None},
|
||||
'entryPrice': 50000.0, 'unrealizedPnl': 0.0, 'leverage': 10, 'liquidationPrice': None, 'info': {'available': '0.01'}},
|
||||
{'symbol': 'ETH/USDT:USDT', 'contracts': 3.0, 'side': 'short',
|
||||
'entryPrice': 3000.0, 'unrealizedPnl': 0.0, 'leverage': 5, 'liquidationPrice': None},
|
||||
'entryPrice': 3000.0, 'unrealizedPnl': 0.0, 'leverage': 5, 'liquidationPrice': None, 'info': {'available': '0.3'}},
|
||||
]
|
||||
mock_api.exchange.create_order.return_value = {'id': 'x', 'status': 'closed'}
|
||||
mock_api.exchange.create_market_order.return_value = {'id': 'x', 'status': 'closed'}
|
||||
|
||||
result = service.market_close_all()
|
||||
assert result['success'] is True
|
||||
@ -775,35 +795,36 @@ class TestMarketCloseAll:
|
||||
service, mock_api = make_service()
|
||||
mock_api.get_position.return_value = [
|
||||
{'symbol': 'BTC/USDT:USDT', 'contracts': 1.0, 'side': 'long',
|
||||
'entryPrice': 50000.0, 'unrealizedPnl': 0.0, 'leverage': 10, 'liquidationPrice': None},
|
||||
'entryPrice': 50000.0, 'unrealizedPnl': 0.0, 'leverage': 10, 'liquidationPrice': None, 'info': {'available': '0.01'}},
|
||||
{'symbol': 'ETH/USDT:USDT', 'contracts': 3.0, 'side': 'long',
|
||||
'entryPrice': 3000.0, 'unrealizedPnl': 0.0, 'leverage': 5, 'liquidationPrice': None},
|
||||
'entryPrice': 3000.0, 'unrealizedPnl': 0.0, 'leverage': 5, 'liquidationPrice': None, 'info': {'available': '0.3'}},
|
||||
]
|
||||
# 第一次下单成功,第二次失败
|
||||
mock_api.exchange.create_order.side_effect = [
|
||||
mock_api.exchange.create_market_order.side_effect = [
|
||||
{'id': 'ok1', 'status': 'closed'},
|
||||
Exception("rate limit"),
|
||||
]
|
||||
result = service.market_close_all()
|
||||
assert result['success'] is False
|
||||
|
||||
def test_position_too_small_skipped(self):
|
||||
"""持仓量小于 1 张时跳过,不报错"""
|
||||
def test_position_above_min_coin_amount_is_closed(self):
|
||||
"""只要币数量高于最小下单精度,就应允许平仓"""
|
||||
service, mock_api = make_service()
|
||||
# BTC 合约面值 0.01,持仓 0.005 BTC → 0 张 → 跳过
|
||||
mock_api.exchange.create_market_order.return_value = {'id': 'close-small', 'status': 'closed'}
|
||||
mock_api.get_position.return_value = [{
|
||||
'symbol': 'BTC/USDT:USDT',
|
||||
'contracts': 0.5, # 0.5张 × 0.01 = 0.005 BTC → floor(0.005/0.01) = 0张
|
||||
'contracts': 0.5,
|
||||
'side': 'long',
|
||||
'entryPrice': 50000.0,
|
||||
'unrealizedPnl': 0.0,
|
||||
'leverage': 10,
|
||||
'liquidationPrice': None,
|
||||
'info': {'available': '0.005'},
|
||||
}]
|
||||
result = service.market_close_all()
|
||||
assert result['success'] is True
|
||||
assert result['results'] == []
|
||||
mock_api.exchange.create_order.assert_not_called()
|
||||
assert len(result['results']) == 1
|
||||
mock_api.exchange.create_market_order.assert_called_once()
|
||||
|
||||
|
||||
# ==================== TestInitializeAccount ====================
|
||||
|
||||
88
backend/tests/test_bitget_trading_api_sdk_unit.py
Normal file
88
backend/tests/test_bitget_trading_api_sdk_unit.py
Normal file
@ -0,0 +1,88 @@
|
||||
"""
|
||||
BitgetTradingAPI 单元测试
|
||||
|
||||
覆盖重点:
|
||||
- U 本位账户余额固定走 mix account 接口
|
||||
- 杠杆设置固定走 mix set-leverage 接口
|
||||
"""
|
||||
import importlib.util
|
||||
import os
|
||||
import sys
|
||||
import types
|
||||
from pathlib import Path
|
||||
from unittest.mock import MagicMock
|
||||
|
||||
|
||||
sys.path.insert(0, os.path.join(os.path.dirname(__file__), '..'))
|
||||
|
||||
|
||||
def load_bitget_sdk_class():
|
||||
sdk_path = Path(__file__).resolve().parents[1] / 'app' / 'services' / 'bitget_trading_api_sdk.py'
|
||||
|
||||
if 'ccxt' not in sys.modules:
|
||||
ccxt_module = types.ModuleType('ccxt')
|
||||
ccxt_module.BaseError = Exception
|
||||
ccxt_module.bitget = MagicMock()
|
||||
sys.modules['ccxt'] = ccxt_module
|
||||
|
||||
module_name = 'app.services.bitget_trading_api_sdk_test'
|
||||
spec = importlib.util.spec_from_file_location(module_name, sdk_path)
|
||||
module = importlib.util.module_from_spec(spec)
|
||||
sys.modules[module_name] = module
|
||||
spec.loader.exec_module(module)
|
||||
return module.BitgetTradingAPI
|
||||
|
||||
|
||||
def test_get_balance_uses_usdt_futures_account_endpoint():
|
||||
BitgetTradingAPI = load_bitget_sdk_class()
|
||||
api = BitgetTradingAPI.__new__(BitgetTradingAPI)
|
||||
api.exchange = MagicMock()
|
||||
api.use_unified_account = True
|
||||
api.exchange.privateUtaGetV3AccountAssets.return_value = {
|
||||
'code': '00000',
|
||||
'data': {
|
||||
'assets': [
|
||||
{
|
||||
'coin': 'USDT',
|
||||
'available': '123.45',
|
||||
'locked': '6.78',
|
||||
'equity': '130.23',
|
||||
}
|
||||
],
|
||||
},
|
||||
}
|
||||
|
||||
balance = api.get_balance()
|
||||
|
||||
api.exchange.privateUtaGetV3AccountAssets.assert_called_once_with({
|
||||
'coin': 'USDT',
|
||||
})
|
||||
assert balance == {
|
||||
'USDT': {
|
||||
'available': '123.45',
|
||||
'frozen': '6.78',
|
||||
'locked': '6.78',
|
||||
'equity': '130.23',
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
def test_set_leverage_uses_mix_contract_endpoint():
|
||||
BitgetTradingAPI = load_bitget_sdk_class()
|
||||
api = BitgetTradingAPI.__new__(BitgetTradingAPI)
|
||||
api.exchange = MagicMock()
|
||||
api.use_unified_account = True
|
||||
api.exchange.privateUtaPostV3AccountSetLeverage.return_value = {
|
||||
'code': '00000',
|
||||
'msg': 'success',
|
||||
}
|
||||
|
||||
success = api.set_leverage('BTC', 10)
|
||||
|
||||
assert success is True
|
||||
api.exchange.privateUtaPostV3AccountSetLeverage.assert_called_once_with({
|
||||
'symbol': 'BTCUSDT',
|
||||
'coin': 'USDT',
|
||||
'category': 'USDT-FUTURES',
|
||||
'leverage': '10',
|
||||
})
|
||||
@ -127,6 +127,7 @@ def test_bitget_executor_close_uses_symbol_close_not_close_all():
|
||||
|
||||
executor = BitgetExecutor.__new__(BitgetExecutor)
|
||||
executor.bitget = MagicMock()
|
||||
executor.bitget.settings.bitget_default_leverage = 10
|
||||
executor.send_execution_notification = AsyncMock()
|
||||
executor.bitget.market_close_position.return_value = {'success': True, 'coin': 'BTC'}
|
||||
|
||||
@ -156,3 +157,39 @@ def test_bitget_executor_move_stop_loss_uses_set_tp_sl():
|
||||
tp_price=55000.0,
|
||||
sl_price=49000.0,
|
||||
)
|
||||
|
||||
|
||||
def test_bitget_executor_open_uses_actual_leverage_for_contracts():
|
||||
BitgetExecutor = load_bitget_executor_class()
|
||||
|
||||
executor = BitgetExecutor.__new__(BitgetExecutor)
|
||||
executor.bitget = MagicMock()
|
||||
executor.bitget.settings.bitget_default_leverage = 10
|
||||
executor.send_execution_notification = AsyncMock()
|
||||
executor.decide_order_type = MagicMock(return_value=('market', 'test'))
|
||||
executor.calculate_effective_margin = MagicMock(return_value=32.0)
|
||||
executor.bitget.get_account_state.return_value = {'available_balance': 1000.0}
|
||||
executor.bitget.get_contract_size.return_value = 0.1
|
||||
executor.bitget.place_market_order.return_value = {
|
||||
'success': True,
|
||||
'order_id': 'oid-1',
|
||||
'order_status': 'filled',
|
||||
}
|
||||
|
||||
result = asyncio.run(
|
||||
executor.execute_open(
|
||||
{
|
||||
'symbol': 'ETHUSDT',
|
||||
'action': 'buy',
|
||||
'margin': 32.0,
|
||||
'entry_price': 2000.0,
|
||||
'stop_loss': 1980.0,
|
||||
'take_profit': 2040.0,
|
||||
},
|
||||
2000.0,
|
||||
)
|
||||
)
|
||||
|
||||
assert result['success'] is True
|
||||
executor.bitget.update_leverage.assert_called_once_with('ETH', 10)
|
||||
executor.bitget.place_market_order.assert_called_once_with('ETH', is_buy=True, size=1)
|
||||
|
||||
Loading…
Reference in New Issue
Block a user