stock-ai-agent/backend/tests/test_bitget_live_trading_service.py
2026-03-30 13:47:58 +08:00

1063 lines
41 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"""
BitgetLiveTradingService 单元测试
使用 pytest + unittest.mockmock 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.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 = True
result = service.set_tp_sl('BTC', is_long=True, size=1, tp_price=55000.0, sl_price=47000.0)
assert result['success'] 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 = True
result = service.set_tp_sl('ETH', is_long=False, size=2, tp_price=None, sl_price=3200.0)
assert result['success'] is True
def test_modify_sl_tp_returns_false(self):
service, mock_api = make_service()
mock_api.modify_sl_tp.return_value = False
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 'error' in result
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['error']
# ==================== 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_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
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()
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']