stock-ai-agent/backend/tests/test_bitget_live_trading_service.py
2026-03-25 22:23:38 +08:00

845 lines
32 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_settings = MagicMock()
mock_settings.bitget_max_total_leverage = 10.0
mock_settings.bitget_max_single_position = 1000.0
mock_settings.hyperliquid_circuit_breaker_drawdown = 0.10
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.hyperliquid_circuit_breaker_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_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_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):
return {
'symbol': f'{coin}/USDT:USDT',
'contracts': contracts,
'side': side,
'entryPrice': entry_price,
'unrealizedPnl': pnl,
'leverage': leverage,
'liquidationPrice': liq_price,
}
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,
}]
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,
}]
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 跌至 8900 → 回撤 11% > 10% → 熔断"""
service, mock_api = make_service()
service.initial_balance = 10000.0
mock_api.get_balance.return_value = {
'USDT': {'available': '8900.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,
}]
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.hyperliquid_circuit_breaker_drawdown = 0.10
mock_api = MagicMock()
mock_api._standardize_symbol = lambda s: f"{s}/USDT:USDT"
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,
}]
mock_api.exchange.create_order.return_value = {'id': 'close1', 'status': 'closed'}
result = service.market_close_all()
assert result['success'] is True
assert len(result['results']) == 1
# 多仓平仓用 sell
call_args = mock_api.exchange.create_order.call_args
assert call_args[1].get('side') or call_args[0][2] == 'sell'
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,
}]
mock_api.exchange.create_order.return_value = {'id': 'close2', 'status': 'closed'}
result = service.market_close_all()
assert result['success'] is True
call_args = mock_api.exchange.create_order.call_args
assert call_args[1].get('side') or call_args[0][2] == 'buy'
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},
{'symbol': 'ETH/USDT:USDT', 'contracts': 3.0, 'side': 'short',
'entryPrice': 3000.0, 'unrealizedPnl': 0.0, 'leverage': 5, 'liquidationPrice': None},
]
mock_api.exchange.create_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},
{'symbol': 'ETH/USDT:USDT', 'contracts': 3.0, 'side': 'long',
'entryPrice': 3000.0, 'unrealizedPnl': 0.0, 'leverage': 5, 'liquidationPrice': None},
]
# 第一次下单成功,第二次失败
mock_api.exchange.create_order.side_effect = [
{'id': 'ok1', 'status': 'closed'},
Exception("rate limit"),
]
result = service.market_close_all()
assert result['success'] is False
def test_position_too_small_skipped(self):
"""持仓量小于 1 张时跳过,不报错"""
service, mock_api = make_service()
# BTC 合约面值 0.01,持仓 0.005 BTC → 0 张 → 跳过
mock_api.get_position.return_value = [{
'symbol': 'BTC/USDT:USDT',
'contracts': 0.5, # 0.5张 × 0.01 = 0.005 BTC → floor(0.005/0.01) = 0张
'side': 'long',
'entryPrice': 50000.0,
'unrealizedPnl': 0.0,
'leverage': 10,
'liquidationPrice': None,
}]
result = service.market_close_all()
assert result['success'] is True
assert result['results'] == []
mock_api.exchange.create_order.assert_not_called()
# ==================== 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