1069 lines
41 KiB
Python
1069 lines
41 KiB
Python
"""
|
||
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.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('reduceOnly') is True
|
||
|
||
def test_place_market_order_preserves_cross_margin_params(self):
|
||
"""确保 UTA 参数不覆盖其他必要参数"""
|
||
service, mock_api = make_service()
|
||
mock_api.exchange.create_order.return_value = {'id': 'o1', 'status': 'closed'}
|
||
service.place_market_order('BTC', is_buy=True, size=1)
|
||
|
||
call_kwargs = mock_api.exchange.create_order.call_args[1]
|
||
params = call_kwargs.get('params', {})
|
||
assert params.get('tdMode') == 'cross'
|
||
assert params.get('marginCoin') == 'USDT'
|
||
assert params.get('holdMode') == 'oneWay'
|
||
assert params.get('uta') is True
|
||
|
||
|
||
# ==================== TestFloorAmount ====================
|
||
|
||
class TestFloorAmount:
|
||
"""测试动态精度向下取整"""
|
||
|
||
def test_floor_with_market_precision(self):
|
||
"""从 market info 获取精度"""
|
||
service, mock_api = make_service()
|
||
mock_api.exchange.market.return_value = {
|
||
'precision': {'amount': 2}
|
||
}
|
||
result = service._floor_amount('BTCUSDT', 1.23456)
|
||
assert result == pytest.approx(1.23)
|
||
|
||
def test_floor_with_4_decimal_precision(self):
|
||
service, mock_api = make_service()
|
||
mock_api.exchange.market.return_value = {
|
||
'precision': {'amount': 4}
|
||
}
|
||
result = service._floor_amount('BTCUSDT', 0.12345)
|
||
assert result == pytest.approx(0.1234)
|
||
|
||
def test_floor_fallback_when_market_unavailable(self):
|
||
"""market info 失败时回退到 4 位小数"""
|
||
service, mock_api = make_service()
|
||
mock_api.exchange.market.side_effect = Exception("not found")
|
||
result = service._floor_amount('UNKNOWN', 1.23456789)
|
||
assert result == pytest.approx(1.2345)
|
||
|
||
def test_floor_truncates_not_rounds(self):
|
||
"""必须向下取整,不能四舍五入"""
|
||
service, mock_api = make_service()
|
||
mock_api.exchange.market.return_value = {
|
||
'precision': {'amount': 2}
|
||
}
|
||
result = service._floor_amount('BTCUSDT', 1.999)
|
||
assert result == pytest.approx(1.99)
|
||
|
||
|
||
# ==================== TestSyncDefaultLeverage ====================
|
||
|
||
class TestSyncDefaultLeverage:
|
||
|
||
def test_sync_multiple_symbols(self):
|
||
service, mock_api = make_service()
|
||
mock_api.set_leverage.return_value = True
|
||
service.settings.bitget_default_leverage = 5
|
||
|
||
result = service.sync_default_leverage(['BTCUSDT', 'ETHUSDT'], leverage=5)
|
||
assert result['success'] is True
|
||
assert result['leverage'] == 5
|
||
assert mock_api.set_leverage.call_count == 2
|
||
|
||
def test_sync_empty_symbols(self):
|
||
service, mock_api = make_service()
|
||
result = service.sync_default_leverage([])
|
||
assert result['success'] is True
|
||
assert result['results'] == {}
|
||
|
||
def test_sync_deduplicates_symbols(self):
|
||
service, mock_api = make_service()
|
||
mock_api.set_leverage.return_value = True
|
||
service.settings.bitget_default_leverage = 10
|
||
|
||
result = service.sync_default_leverage(['BTCUSDT', 'BTC', 'BTCUSDT'])
|
||
# BTC, BTCUSDT, BTCUSDT 去重后只有 BTC
|
||
assert mock_api.set_leverage.call_count == 1
|
||
|
||
def test_sync_partial_failure(self):
|
||
service, mock_api = make_service()
|
||
mock_api.set_leverage.side_effect = [True, False]
|
||
service.settings.bitget_default_leverage = 10
|
||
|
||
result = service.sync_default_leverage(['BTCUSDT', 'ETHUSDT'])
|
||
assert result['success'] is False
|
||
|
||
|
||
# ==================== TestCancelOrder ====================
|
||
|
||
class TestCancelOrder:
|
||
|
||
def test_cancel_success(self):
|
||
service, mock_api = make_service()
|
||
mock_api.cancel_order.return_value = True
|
||
result = service.cancel_order('BTC', 'ord123')
|
||
assert result['success'] is True
|
||
assert result['order_id'] == 'ord123'
|
||
|
||
def test_cancel_failure(self):
|
||
service, mock_api = make_service()
|
||
mock_api.cancel_order.return_value = False
|
||
result = service.cancel_order('BTC', 'ord456')
|
||
assert result['success'] is False
|
||
|
||
def test_cancel_exception(self):
|
||
service, mock_api = make_service()
|
||
mock_api.cancel_order.side_effect = Exception("order not found")
|
||
result = service.cancel_order('BTC', 'ord789')
|
||
assert result['success'] is False
|
||
assert 'order not found' in result['error']
|