diff --git a/backend/app/config.py b/backend/app/config.py index da12998..49a48ae 100644 --- a/backend/app/config.py +++ b/backend/app/config.py @@ -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%),超过则停止交易并平仓 diff --git a/backend/app/crypto_agent/crypto_agent.py b/backend/app/crypto_agent/crypto_agent.py index bdbb67a..af36756 100644 --- a/backend/app/crypto_agent/crypto_agent.py +++ b/backend/app/crypto_agent/crypto_agent.py @@ -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 diff --git a/backend/app/crypto_agent/executor/bitget_executor.py b/backend/app/crypto_agent/executor/bitget_executor.py index 449c122..0af82e2 100644 --- a/backend/app/crypto_agent/executor/bitget_executor.py +++ b/backend/app/crypto_agent/executor/bitget_executor.py @@ -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 diff --git a/backend/app/crypto_agent/executor/hyperliquid_executor.py b/backend/app/crypto_agent/executor/hyperliquid_executor.py index 71d7f56..f96a033 100644 --- a/backend/app/crypto_agent/executor/hyperliquid_executor.py +++ b/backend/app/crypto_agent/executor/hyperliquid_executor.py @@ -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: diff --git a/backend/app/services/bitget_live_trading_service.py b/backend/app/services/bitget_live_trading_service.py index e63c4e7..ff755fb 100644 --- a/backend/app/services/bitget_live_trading_service.py +++ b/backend/app/services/bitget_live_trading_service.py @@ -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, + } # ==================== 辅助方法 ==================== diff --git a/backend/app/services/bitget_trading_api_sdk.py b/backend/app/services/bitget_trading_api_sdk.py index f4360f7..6fe2f35 100644 --- a/backend/app/services/bitget_trading_api_sdk.py +++ b/backend/app/services/bitget_trading_api_sdk.py @@ -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: """ 获取合约面值(每张合约对应的币数量) diff --git a/backend/tests/test_bitget_live_trading_service.py b/backend/tests/test_bitget_live_trading_service.py index e9b1086..dc875e7 100644 --- a/backend/tests/test_bitget_live_trading_service.py +++ b/backend/tests/test_bitget_live_trading_service.py @@ -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 ==================== diff --git a/backend/tests/test_bitget_trading_api_sdk_unit.py b/backend/tests/test_bitget_trading_api_sdk_unit.py new file mode 100644 index 0000000..dd43f80 --- /dev/null +++ b/backend/tests/test_bitget_trading_api_sdk_unit.py @@ -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', + }) diff --git a/backend/tests/test_execution_safety_fixes.py b/backend/tests/test_execution_safety_fixes.py index 9e66d2e..0ce303e 100644 --- a/backend/tests/test_execution_safety_fixes.py +++ b/backend/tests/test_execution_safety_fixes.py @@ -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)