""" BitgetLiveTradingService 单元测试 使用 pytest + unittest.mock,mock BitgetTradingAPI 实例(不调用真实 ccxt/网络)。 测试覆盖: - get_account_state - get_open_positions - get_position_for_symbol - place_market_order - place_limit_order - set_tp_sl - cancel_all_orders - get_open_orders - get_tp_sl_prices - update_leverage - check_risk_limits - get_contract_size / coins_to_contracts """ import math import sys import os import pytest from unittest.mock import MagicMock, patch, PropertyMock # 将 backend 目录加入 path,使 app.* 可以导入 sys.path.insert(0, os.path.join(os.path.dirname(__file__), '..')) # ==================== Fixtures ==================== def make_service(settings_overrides=None): """ 创建 BitgetLiveTradingService 实例,直接用 __new__ 绕过 __init__, 手动注入 mock trading_api 和 settings,无需真实网络/数据库。 """ from app.services.bitget_live_trading_service import BitgetLiveTradingService mock_api = MagicMock() 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.account_max_drawdown = 0.25 if settings_overrides: for k, v in settings_overrides.items(): setattr(mock_settings, k, v) # 用 __new__ 跳过 __init__(避免真实 API/数据库调用),手动设置所有属性 service = BitgetLiveTradingService.__new__(BitgetLiveTradingService) service.account_id = "default" 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.account_max_drawdown service.trading_api = mock_api service.initial_balance = 10000.0 return service, mock_api # ==================== TestGetAccountState ==================== class TestGetAccountState: def test_normal(self): service, mock_api = make_service() mock_api.get_balance.return_value = { 'USDT': {'available': '8000.0', 'frozen': '2000.0', 'locked': '0'} } state = service.get_account_state() assert state['account_value'] == pytest.approx(10000.0) 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 = {} state = service.get_account_state() assert state['account_value'] == pytest.approx(0.0) assert state['available_balance'] == pytest.approx(0.0) assert state['total_margin_used'] == pytest.approx(0.0) def test_none_values_treated_as_zero(self): service, mock_api = make_service() mock_api.get_balance.return_value = { 'USDT': {'available': None, 'frozen': None, 'locked': '0'} } state = service.get_account_state() assert state['account_value'] == pytest.approx(0.0) def test_alternative_fields_and_inferred_available_supported(self): service, mock_api = make_service() mock_api.get_balance.return_value = { 'USDT': { 'availableBalance': None, 'locked': '10.0', 'accountEquity': '210.0', } } state = service.get_account_state() assert state['account_value'] == pytest.approx(210.0) assert state['available_balance'] == pytest.approx(200.0) assert state['total_margin_used'] == pytest.approx(10.0) def test_api_exception_propagates(self): service, mock_api = make_service() mock_api.get_balance.side_effect = Exception("network error") with pytest.raises(Exception, match="network error"): service.get_account_state() # ==================== TestGetOpenPositions ==================== 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, 'side': side, 'entryPrice': entry_price, 'unrealizedPnl': pnl, 'leverage': leverage, 'liquidationPrice': liq_price, 'info': {'available': str(available)}, } def test_long_position(self): service, mock_api = make_service() mock_api.get_position.return_value = [ self._make_raw_position('BTC', 2.0, 'long', 50000.0) ] positions = service.get_open_positions() assert len(positions) == 1 pos = positions[0] assert pos['coin'] == 'BTC' # 2 张 × 0.01 BTC/张 = 0.02 BTC,多仓为正 assert pos['size'] == pytest.approx(0.02) assert pos['entry_price'] == pytest.approx(50000.0) def test_short_position(self): service, mock_api = make_service() mock_api.get_position.return_value = [ self._make_raw_position('ETH', 5.0, 'short', 3000.0) ] positions = service.get_open_positions() assert len(positions) == 1 pos = positions[0] assert pos['coin'] == 'ETH' # 5 张 × 0.1 ETH/张 = 0.5 ETH,空仓为负 assert pos['size'] == pytest.approx(-0.5) def test_zero_contracts_filtered(self): service, mock_api = make_service() mock_api.get_position.return_value = [ self._make_raw_position('BTC', 0.0, 'long', 50000.0) ] positions = service.get_open_positions() assert positions == [] def test_empty_positions(self): service, mock_api = make_service() mock_api.get_position.return_value = [] positions = service.get_open_positions() assert positions == [] def test_multiple_positions(self): service, mock_api = make_service() mock_api.get_position.return_value = [ self._make_raw_position('BTC', 1.0, 'long', 50000.0), self._make_raw_position('ETH', 3.0, 'short', 3000.0), ] positions = service.get_open_positions() assert len(positions) == 2 coins = [p['coin'] for p in positions] assert 'BTC' in coins assert 'ETH' in coins def test_api_exception_returns_empty(self): service, mock_api = make_service() mock_api.get_position.side_effect = Exception("timeout") # get_open_positions 内部调用 trading_api.get_position() 会抛出 # service 应让异常传播(不静默吞掉),由上层处理 with pytest.raises(Exception): service.get_open_positions() # ==================== TestGetPositionForSymbol ==================== class TestGetPositionForSymbol: def test_found(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': 45000.0, 'info': {'available': '0.01'}, }] pos = service.get_position_for_symbol('BTC') assert pos is not None assert pos['coin'] == 'BTC' def test_not_found(self): service, mock_api = make_service() mock_api.get_position.return_value = [] pos = service.get_position_for_symbol('SOL') assert pos is None def test_usdt_suffix_stripped(self): service, mock_api = make_service() mock_api.get_position.return_value = [{ 'symbol': 'ETH/USDT:USDT', 'contracts': 5.0, 'side': 'long', 'entryPrice': 3000.0, 'unrealizedPnl': 0.0, 'leverage': 5, 'liquidationPrice': 2500.0, 'info': {'available': '0.5'}, }] pos = service.get_position_for_symbol('ETHUSDT') assert pos is not None assert pos['coin'] == 'ETH' # ==================== TestPlaceMarketOrder ==================== class TestPlaceMarketOrder: def _mock_order(self, order_id='ord001', status='closed'): return {'id': order_id, 'status': status} def test_buy_success(self): service, mock_api = make_service() mock_api.exchange.create_order.return_value = self._mock_order('o1') result = service.place_market_order('BTC', is_buy=True, size=1) assert result['success'] is True assert result['side'] == 'buy' assert result['size'] == 1 call_kwargs = mock_api.exchange.create_order.call_args assert call_kwargs[1]['type'] == 'market' or call_kwargs[0][1] == 'market' def test_sell_success(self): service, mock_api = make_service() mock_api.exchange.create_order.return_value = self._mock_order('o2') result = service.place_market_order('ETH', is_buy=False, size=5) assert result['success'] is True assert result['side'] == 'sell' def test_reduce_only(self): service, mock_api = make_service() mock_api.exchange.create_order.return_value = self._mock_order('o3') result = service.place_market_order('BTC', is_buy=False, size=1, reduce_only=True) assert result['success'] is True # 验证 reduceOnly 被传入 params call_params = mock_api.exchange.create_order.call_args[1].get('params', {}) assert call_params.get('reduceOnly') is True def test_api_returns_none(self): service, mock_api = make_service() mock_api.exchange.create_order.return_value = None result = service.place_market_order('BTC', is_buy=True, size=1) assert result['success'] is False assert 'error' in result def test_api_exception(self): service, mock_api = make_service() mock_api.exchange.create_order.side_effect = Exception("insufficient margin") result = service.place_market_order('BTC', is_buy=True, size=100) assert result['success'] is False assert 'insufficient margin' in result['error'] def test_contract_size_applied_btc(self): """BTC 1张 = 0.01 BTC,传给 create_order 的 amount 应为 0.01""" service, mock_api = make_service() mock_api.exchange.create_order.return_value = self._mock_order() service.place_market_order('BTC', is_buy=True, size=2) call_args = mock_api.exchange.create_order.call_args amount = call_args[1].get('amount') or call_args[0][3] assert amount == pytest.approx(0.02) # 2张 × 0.01 def test_contract_size_applied_eth(self): """ETH 3张 = 0.3 ETH""" service, mock_api = make_service() mock_api.exchange.create_order.return_value = self._mock_order() service.place_market_order('ETH', is_buy=True, size=3) call_args = mock_api.exchange.create_order.call_args amount = call_args[1].get('amount') or call_args[0][3] assert amount == pytest.approx(0.3) # 3张 × 0.1 # ==================== TestPlaceLimitOrder ==================== class TestPlaceLimitOrder: def test_resting_order(self): """限价单未立即成交 → order_status = resting""" service, mock_api = make_service() mock_api.exchange.create_order.return_value = {'id': 'lim001', 'status': 'open'} result = service.place_limit_order('BTC', is_buy=True, size=1, price=49000.0) assert result['success'] is True assert result['order_status'] == 'resting' assert result['order_id'] == 'lim001' assert result['price'] == pytest.approx(49000.0) def test_filled_order(self): """限价单立即成交(status=closed)→ order_status = filled""" service, mock_api = make_service() mock_api.exchange.create_order.return_value = {'id': 'lim002', 'status': 'closed'} result = service.place_limit_order('ETH', is_buy=False, size=2, price=3100.0) assert result['success'] is True assert result['order_status'] == 'filled' def test_api_returns_none(self): service, mock_api = make_service() mock_api.exchange.create_order.return_value = None result = service.place_limit_order('BTC', is_buy=True, size=1, price=50000.0) assert result['success'] is False assert 'error' in result def test_api_exception(self): service, mock_api = make_service() mock_api.exchange.create_order.side_effect = Exception("price out of range") result = service.place_limit_order('BTC', is_buy=True, size=1, price=1.0) assert result['success'] is False assert 'price out of range' in result['error'] def test_reduce_only_flag(self): service, mock_api = make_service() mock_api.exchange.create_order.return_value = {'id': 'lim003', 'status': 'open'} result = service.place_limit_order('BTC', is_buy=False, size=1, price=55000.0, reduce_only=True) assert result['success'] is True call_params = mock_api.exchange.create_order.call_args[1].get('params', {}) assert call_params.get('reduceOnly') is True # ==================== TestSetTpSl ==================== class TestSetTpSl: def test_tp_and_sl_success(self): service, mock_api = make_service() mock_api.modify_sl_tp.return_value = {"success": True, "tp_set": True, "sl_set": True, "errors": []} result = service.set_tp_sl('BTC', is_long=True, size=1, tp_price=55000.0, sl_price=47000.0) assert result['success'] is True assert result['tp_set'] is True assert result['sl_set'] is True mock_api.modify_sl_tp.assert_called_once_with( symbol='BTC', stop_loss=47000.0, take_profit=55000.0 ) def test_only_sl(self): service, mock_api = make_service() mock_api.modify_sl_tp.return_value = {"success": True, "tp_set": False, "sl_set": True, "errors": []} result = service.set_tp_sl('ETH', is_long=False, size=2, tp_price=None, sl_price=3200.0) assert result['success'] is True assert result['sl_set'] is True def test_modify_sl_tp_returns_failure(self): service, mock_api = make_service() mock_api.modify_sl_tp.return_value = {"success": False, "tp_set": False, "sl_set": False, "errors": ["持仓数量为 0"]} result = service.set_tp_sl('BTC', is_long=True, size=1, tp_price=55000.0, sl_price=47000.0) assert result['success'] is False def test_api_exception(self): service, mock_api = make_service() mock_api.modify_sl_tp.side_effect = Exception("order rejected") result = service.set_tp_sl('BTC', is_long=True, size=1, tp_price=55000.0, sl_price=47000.0) assert result['success'] is False assert 'order rejected' in result['errors'][0] # ==================== TestCancelAllOrders ==================== class TestCancelAllOrders: def test_cancel_success(self): service, mock_api = make_service() mock_api.cancel_all_orders.return_value = True result = service.cancel_all_orders('BTC') assert result['success'] is True assert result['cancelled'] == 1 def test_cancel_returns_false(self): service, mock_api = make_service() mock_api.cancel_all_orders.return_value = False result = service.cancel_all_orders('ETH') assert result['success'] is False assert result['cancelled'] == 0 def test_cancel_no_symbol(self): """不传 symbol 时撤销全部挂单""" service, mock_api = make_service() mock_api.cancel_all_orders.return_value = True result = service.cancel_all_orders() assert result['success'] is True def test_api_exception(self): service, mock_api = make_service() mock_api.cancel_all_orders.side_effect = Exception("connection refused") result = service.cancel_all_orders('BTC') assert result['success'] is False assert 'connection refused' in result['error'] # ==================== TestGetOpenOrders ==================== class TestGetOpenOrders: def _make_ccxt_order(self, order_id, symbol, side, amount, price, reduce_only=False, order_type='limit'): return { 'id': order_id, 'symbol': symbol, 'side': side, 'amount': amount, 'price': price, 'reduceOnly': reduce_only, 'type': order_type, } def test_returns_formatted_orders(self): service, mock_api = make_service() mock_api.get_open_orders.return_value = [ self._make_ccxt_order('o1', 'BTC/USDT:USDT', 'buy', 0.01, 49000.0), self._make_ccxt_order('o2', 'ETH/USDT:USDT', 'sell', 0.1, 3200.0, reduce_only=True), ] orders = service.get_open_orders() assert len(orders) == 2 assert orders[0]['order_id'] == 'o1' assert orders[0]['symbol'] == 'BTC' assert orders[0]['is_reduce_only'] is False assert orders[1]['is_reduce_only'] is True def test_empty_orders(self): service, mock_api = make_service() mock_api.get_open_orders.return_value = [] orders = service.get_open_orders() assert orders == [] def test_with_symbol_filter(self): service, mock_api = make_service() mock_api.get_open_orders.return_value = [ self._make_ccxt_order('o1', 'BTC/USDT:USDT', 'buy', 0.01, 49000.0), ] orders = service.get_open_orders('BTC') mock_api.get_open_orders.assert_called_with('BTC') assert len(orders) == 1 # ==================== TestGetTpSlPrices ==================== class TestGetTpSlPrices: def test_has_tp_and_sl(self): service, mock_api = make_service() mock_api.get_open_orders.return_value = [ {'id': 'tp1', 'symbol': 'BTC/USDT:USDT', 'side': 'sell', 'price': 55000.0, 'amount': 0.01, 'reduceOnly': True, 'type': 'limit'}, {'id': 'sl1', 'symbol': 'BTC/USDT:USDT', 'side': 'sell', 'price': 47000.0, 'amount': 0.01, 'reduceOnly': True, 'type': 'stop'}, ] result = service.get_tp_sl_prices('BTC') assert result['take_profit'] == pytest.approx(55000.0) assert result['stop_loss'] == pytest.approx(47000.0) def test_no_positions(self): service, mock_api = make_service() mock_api.get_open_orders.return_value = [] result = service.get_tp_sl_prices('BTC') assert result['take_profit'] is None assert result['stop_loss'] is None def test_api_exception_returns_none(self): service, mock_api = make_service() mock_api.get_open_orders.side_effect = Exception("timeout") result = service.get_tp_sl_prices('BTC') assert result['take_profit'] is None assert result['stop_loss'] is None # ==================== TestUpdateLeverage ==================== class TestUpdateLeverage: def test_success(self): service, mock_api = make_service() mock_api.set_leverage.return_value = True service.update_leverage('BTC', 10) # 不应抛出异常 mock_api.set_leverage.assert_called_once_with('BTC', 10) def test_failure_logged_not_raised(self): """set_leverage 失败时记录 warning,不抛出异常""" service, mock_api = make_service() mock_api.set_leverage.side_effect = Exception("leverage rejected") service.update_leverage('BTC', 20) # 不应抛出,只 warning # ==================== TestCheckRiskLimits ==================== class TestCheckRiskLimits: def test_normal_allowed(self): service, mock_api = make_service() mock_api.get_balance.return_value = { 'USDT': {'available': '9000.0', 'frozen': '1000.0', 'locked': '0'} } mock_api.get_position.return_value = [] # 无持仓 → 总杠杆 0 result = service.check_risk_limits() assert result['allowed'] is True def test_circuit_breaker_triggered(self): """账户余额从 10000 跌至 7000 → 回撤 30% > 25% → 熔断""" service, mock_api = make_service() service.initial_balance = 10000.0 mock_api.get_balance.return_value = { 'USDT': {'available': '7000.0', 'frozen': '0.0', 'locked': '0'} } mock_api.get_position.return_value = [] result = service.check_risk_limits() assert result['allowed'] is False assert '熔断' in result['reason'] def test_leverage_limit_exceeded(self): """持仓价值 = 110000, 账户 = 10000 → 杠杆 11x > 10x → 拒绝""" service, mock_api = make_service() service.initial_balance = 10000.0 mock_api.get_balance.return_value = { 'USDT': {'available': '10000.0', 'frozen': '0.0', 'locked': '0'} } # 构造一个持仓:BTC 多仓,2张=0.02 BTC,入场价 55000 → 价值 1100 USDT # 为使总杠杆超限,entry_price 设很大 mock_api.get_position.return_value = [{ 'symbol': 'BTC/USDT:USDT', 'contracts': 200.0, # 200张 × 0.01 = 2 BTC 'side': 'long', 'entryPrice': 55000.0, 'unrealizedPnl': 0.0, 'leverage': 10, 'liquidationPrice': 50000.0, 'info': {'available': '2'}, }] result = service.check_risk_limits() assert result['allowed'] is False assert '杠杆' in result['reason'] def test_no_initial_balance_skips_circuit_breaker(self): """initial_balance 为 None 时,跳过熔断检查""" service, mock_api = make_service() service.initial_balance = None mock_api.get_balance.return_value = { 'USDT': {'available': '5000.0', 'frozen': '0.0', 'locked': '0'} } mock_api.get_position.return_value = [] result = service.check_risk_limits() assert result['allowed'] is True # ==================== TestContractSize ==================== class TestContractSize: @pytest.mark.parametrize("coin,expected", [ ('BTC', 0.01), ('ETH', 0.1), ('SOL', 1.0), ('LTC', 0.1), ('XRP', 10.0), ('DOGE', 100.0), ('AVAX', 1.0), ]) def test_known_coins(self, coin, expected): service, _ = make_service() assert service.get_contract_size(coin) == pytest.approx(expected) def test_unknown_coin_from_market(self): """未知币种从 ccxt market info 获取""" service, mock_api = make_service() mock_api.exchange.market.return_value = {'contractSize': 5.0} size = service.get_contract_size('NEWCOIN') assert size == pytest.approx(5.0) def test_unknown_coin_fallback(self): """ccxt market info 也失败时,默认 1.0""" service, mock_api = make_service() mock_api.exchange.market.side_effect = Exception("market not found") size = service.get_contract_size('UNKNOWNCOIN') assert size == pytest.approx(1.0) # ==================== TestCoinsToContracts ==================== class TestCoinsToContracts: def test_btc(self): service, _ = make_service() # 0.05 BTC / 0.01 BTC/张 = 5 张 assert service.coins_to_contracts('BTC', 0.05) == 5 def test_eth(self): service, _ = make_service() # 0.35 ETH / 0.1 ETH/张 = 3 张(向下取整 3.5 → 3) assert service.coins_to_contracts('ETH', 0.35) == 3 def test_sol(self): service, _ = make_service() # 7.9 SOL / 1 SOL/张 = 7 张 assert service.coins_to_contracts('SOL', 7.9) == 7 def test_floor_not_round(self): """必须向下取整,不能四舍五入""" service, _ = make_service() # 0.099 / 0.01 = 9.9 → 应为 9,不是 10 assert service.coins_to_contracts('BTC', 0.099) == 9 def test_zero_amount(self): service, _ = make_service() assert service.coins_to_contracts('BTC', 0.0) == 0 def test_less_than_one_contract(self): service, _ = make_service() # 0.005 BTC < 0.01 BTC/张 → 0 张 assert service.coins_to_contracts('BTC', 0.005) == 0 # ==================== TestGetBitgetLiveService (factory) ==================== class TestGetBitgetLiveServiceFactory: def test_returns_none_when_disabled(self): from app.services.bitget_live_trading_service import get_bitget_live_service, reset_bitget_live_service reset_bitget_live_service() mock_settings = MagicMock() mock_settings.bitget_trading_enabled = False with patch('app.services.bitget_live_trading_service.get_settings', return_value=mock_settings): result = get_bitget_live_service() assert result is None def test_returns_service_when_enabled(self): from app.services.bitget_live_trading_service import get_bitget_live_service, reset_bitget_live_service reset_bitget_live_service() mock_settings = MagicMock() mock_settings.bitget_trading_enabled = True mock_settings.bitget_max_total_leverage = 10.0 mock_settings.bitget_max_single_position = 1000.0 mock_settings.account_max_drawdown = 0.25 mock_settings.get_enabled_bitget_accounts.return_value = [{ "account_id": "default", "enabled": True, }] 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'} } with patch('app.services.bitget_live_trading_service.get_settings', return_value=mock_settings), \ patch('app.services.bitget_live_trading_service.get_bitget_trading_api', return_value=mock_api): result = get_bitget_live_service() assert result is not None reset_bitget_live_service() def test_init_failure_returns_none(self): from app.services.bitget_live_trading_service import get_bitget_live_service, reset_bitget_live_service reset_bitget_live_service() mock_settings = MagicMock() mock_settings.bitget_trading_enabled = True mock_settings.get_enabled_bitget_accounts.return_value = [{ "account_id": "default", "enabled": True, }] with patch('app.services.bitget_live_trading_service.get_settings', return_value=mock_settings), \ patch('app.services.bitget_live_trading_service.get_bitget_trading_api', return_value=None): result = get_bitget_live_service() assert result is None reset_bitget_live_service() # ==================== TestCancelTpSlOrders ==================== class TestCancelTpSlOrders: """cancel_tp_sl_orders 是 cancel_all_orders 的别名,验证调用链正确""" def test_delegates_to_cancel_all_orders(self): service, mock_api = make_service() mock_api.cancel_all_orders.return_value = True result = service.cancel_tp_sl_orders('BTC') assert result['success'] is True mock_api.cancel_all_orders.assert_called_once_with('BTC') def test_cancel_returns_false(self): service, mock_api = make_service() mock_api.cancel_all_orders.return_value = False result = service.cancel_tp_sl_orders('ETH') assert result['success'] is False def test_api_exception(self): service, mock_api = make_service() mock_api.cancel_all_orders.side_effect = Exception("network error") result = service.cancel_tp_sl_orders('SOL') assert result['success'] is False assert 'network error' in result['error'] # ==================== TestMarketCloseAll ==================== class TestMarketCloseAll: def _make_position(self, coin, size, entry_price=50000.0): return { 'coin': coin, 'size': size, 'entry_price': entry_price, 'unrealized_pnl': 0.0, 'leverage': 10, 'liquidation_price': None, 'position': {}, } def test_close_single_long(self): """单个多仓:发出方向相反(sell)的市价单""" 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'} result = service.market_close_all() assert result['success'] is True assert len(result['results']) == 1 # 多仓平仓用 sell kwargs = mock_api.exchange.create_market_order.call_args.kwargs assert kwargs['side'] == 'sell' def test_close_single_short(self): """单个空仓:发出 buy 方向的市价单""" service, mock_api = make_service() mock_api.get_position.return_value = [{ 'symbol': 'ETH/USDT:USDT', 'contracts': 5.0, 'side': 'short', 'entryPrice': 3000.0, 'unrealizedPnl': 0.0, 'leverage': 5, 'liquidationPrice': 3500.0, 'info': {'available': '0.5'}, }] mock_api.exchange.create_market_order.return_value = {'id': 'close2', 'status': 'closed'} result = service.market_close_all() assert result['success'] is True 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, 'info': {'available': '0.01'}}, {'symbol': 'ETH/USDT:USDT', 'contracts': 3.0, 'side': 'short', 'entryPrice': 3000.0, 'unrealizedPnl': 0.0, 'leverage': 5, 'liquidationPrice': None, 'info': {'available': '0.3'}}, ] mock_api.exchange.create_market_order.return_value = {'id': 'x', 'status': 'closed'} result = service.market_close_all() assert result['success'] is True assert len(result['results']) == 2 def test_no_positions_returns_success(self): """无持仓时,成功返回空结果""" service, mock_api = make_service() mock_api.get_position.return_value = [] result = service.market_close_all() assert result['success'] is True assert result['results'] == [] def test_partial_failure(self): """一个平仓失败,success 应为 False""" 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, 'info': {'available': '0.01'}}, {'symbol': 'ETH/USDT:USDT', 'contracts': 3.0, 'side': 'long', 'entryPrice': 3000.0, 'unrealizedPnl': 0.0, 'leverage': 5, 'liquidationPrice': None, 'info': {'available': '0.3'}}, ] # 第一次下单成功,第二次失败 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_above_min_coin_amount_is_closed(self): """只要币数量高于最小下单精度,就应允许平仓""" service, mock_api = make_service() 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, '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 len(result['results']) == 1 mock_api.exchange.create_market_order.assert_called_once() # ==================== TestInitializeAccount ==================== class TestInitializeAccount: def test_sets_initial_balance(self): """成功时 initial_balance 被赋值""" from app.services.bitget_live_trading_service import BitgetLiveTradingService mock_api = MagicMock() mock_api._standardize_symbol = lambda s: f"{s}/USDT:USDT" mock_api.get_balance.return_value = { 'USDT': {'available': '8000.0', 'frozen': '2000.0', 'locked': '0'} } service = BitgetLiveTradingService.__new__(BitgetLiveTradingService) service.trading_api = mock_api service.initial_balance = None service._initialize_account() assert service.initial_balance == pytest.approx(10000.0) def test_api_failure_leaves_none(self): """get_balance 抛出异常时,initial_balance 保持 None,不传播异常""" from app.services.bitget_live_trading_service import BitgetLiveTradingService mock_api = MagicMock() mock_api.get_balance.side_effect = Exception("timeout") service = BitgetLiveTradingService.__new__(BitgetLiveTradingService) service.trading_api = mock_api service.initial_balance = None 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('hedged') is True, f"market_close_position 缺少 hedged=True: {params}" def test_place_market_order_preserves_cross_margin_params(self): """确保 UTA V3 hedge mode 参数正确传递""" 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('hedged') is True assert params.get('marginCoin') == 'USDT' assert params.get('uta') is True # ==================== TestFloorAmount ==================== class TestFloorAmount: """测试动态精度向下取整""" def test_floor_with_market_precision(self): """amount_to_precision 正确截断""" service, mock_api = make_service() mock_api.exchange.amount_to_precision.return_value = '1.23' result = service._floor_amount('BTCUSDT', 1.23456) assert result == pytest.approx(1.23) mock_api.exchange.amount_to_precision.assert_called_once_with('BTC/USDT:USDT', 1.23456) def test_floor_with_4_decimal_precision(self): service, mock_api = make_service() mock_api.exchange.amount_to_precision.return_value = '0.1234' result = service._floor_amount('BTCUSDT', 0.12345) assert result == pytest.approx(0.1234) def test_floor_fallback_when_market_unavailable(self): """amount_to_precision 失败时回退到 4 位小数""" service, mock_api = make_service() mock_api.exchange.amount_to_precision.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.amount_to_precision.return_value = '1.99' 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() service.get_open_orders = MagicMock(return_value=[{'order_id': 'ord123'}]) 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() service.get_open_orders = MagicMock(return_value=[{'order_id': 'ord456'}]) 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() service.get_open_orders = MagicMock(return_value=[{'order_id': 'ord789'}]) 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']