stock-ai-agent/backend/tests/test_execution_safety_fixes.py
2026-04-22 10:38:25 +08:00

276 lines
10 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.

"""
执行链安全修复回归测试
覆盖重点:
- Bitget 单币种平仓,不再误触发全仓平仓
- Bitget 单笔撤单接口可用
- Bitget 移动止损走 set_tp_sl而不是不存在的方法
"""
import asyncio
import importlib.util
import os
import sys
import types
from pathlib import Path
from unittest.mock import AsyncMock, MagicMock
sys.path.insert(0, os.path.join(os.path.dirname(__file__), '..'))
def make_bitget_service():
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
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
def load_bitget_executor_class():
"""按文件加载执行器,避免触发 app.crypto_agent.__init__ 的重依赖"""
executor_dir = Path(__file__).resolve().parents[1] / 'app' / 'crypto_agent' / 'executor'
if 'app.crypto_agent' not in sys.modules:
crypto_pkg = types.ModuleType('app.crypto_agent')
crypto_pkg.__path__ = [str(executor_dir.parent)]
sys.modules['app.crypto_agent'] = crypto_pkg
if 'app.crypto_agent.executor' not in sys.modules:
executor_pkg = types.ModuleType('app.crypto_agent.executor')
executor_pkg.__path__ = [str(executor_dir)]
sys.modules['app.crypto_agent.executor'] = executor_pkg
if 'app.crypto_agent.executor.base_executor' not in sys.modules:
base_spec = importlib.util.spec_from_file_location(
'app.crypto_agent.executor.base_executor',
executor_dir / 'base_executor.py',
)
base_module = importlib.util.module_from_spec(base_spec)
sys.modules[base_spec.name] = base_module
base_spec.loader.exec_module(base_module)
if 'app.crypto_agent.executor.bitget_executor' not in sys.modules:
executor_spec = importlib.util.spec_from_file_location(
'app.crypto_agent.executor.bitget_executor',
executor_dir / 'bitget_executor.py',
)
executor_module = importlib.util.module_from_spec(executor_spec)
sys.modules[executor_spec.name] = executor_module
executor_spec.loader.exec_module(executor_module)
return sys.modules['app.crypto_agent.executor.bitget_executor'].BitgetExecutor
def load_hyperliquid_executor_class():
"""按文件加载执行器,避免触发 app.crypto_agent.__init__ 的重依赖"""
executor_dir = Path(__file__).resolve().parents[1] / 'app' / 'crypto_agent' / 'executor'
if 'app.crypto_agent' not in sys.modules:
crypto_pkg = types.ModuleType('app.crypto_agent')
crypto_pkg.__path__ = [str(executor_dir.parent)]
sys.modules['app.crypto_agent'] = crypto_pkg
if 'app.crypto_agent.executor' not in sys.modules:
executor_pkg = types.ModuleType('app.crypto_agent.executor')
executor_pkg.__path__ = [str(executor_dir)]
sys.modules['app.crypto_agent.executor'] = executor_pkg
if 'app.crypto_agent.executor.base_executor' not in sys.modules:
base_spec = importlib.util.spec_from_file_location(
'app.crypto_agent.executor.base_executor',
executor_dir / 'base_executor.py',
)
base_module = importlib.util.module_from_spec(base_spec)
sys.modules[base_spec.name] = base_module
base_spec.loader.exec_module(base_module)
if 'app.crypto_agent.executor.hyperliquid_executor' not in sys.modules:
executor_spec = importlib.util.spec_from_file_location(
'app.crypto_agent.executor.hyperliquid_executor',
executor_dir / 'hyperliquid_executor.py',
)
executor_module = importlib.util.module_from_spec(executor_spec)
sys.modules[executor_spec.name] = executor_module
executor_spec.loader.exec_module(executor_module)
return sys.modules['app.crypto_agent.executor.hyperliquid_executor'].HyperliquidExecutor
def test_bitget_market_close_position_only_closes_requested_symbol():
service, mock_api = make_bitget_service()
mock_api.get_position.return_value = [
{
'symbol': 'BTC/USDT:USDT',
'side': 'long',
'entryPrice': 50000.0,
'unrealizedPnl': 100.0,
'leverage': 5,
'liquidationPrice': 45000.0,
'info': {'available': '0.02'},
},
{
'symbol': 'ETH/USDT:USDT',
'side': 'long',
'entryPrice': 3000.0,
'unrealizedPnl': 50.0,
'leverage': 5,
'liquidationPrice': 2500.0,
'info': {'available': '0.5'},
},
]
mock_api.get_open_orders.return_value = []
mock_api.cancel_all_orders.return_value = True
mock_api.exchange.create_market_order.return_value = {'id': 'close-btc'}
result = service.market_close_position('BTCUSDT')
assert result['success'] is True
assert result['coin'] == 'BTC'
kwargs = mock_api.exchange.create_market_order.call_args.kwargs
assert kwargs['symbol'] == 'BTC/USDT:USDT'
assert kwargs['side'] == 'sell'
def test_bitget_cancel_order_delegates_to_sdk():
service, mock_api = make_bitget_service()
mock_api.cancel_order.return_value = True
result = service.cancel_order('BTC', 'oid-1')
assert result['success'] is True
mock_api.cancel_order.assert_called_once_with(symbol='BTC', order_id='oid-1')
def test_bitget_executor_close_uses_symbol_close_not_close_all():
BitgetExecutor = load_bitget_executor_class()
executor = BitgetExecutor.__new__(BitgetExecutor)
executor.bitget = MagicMock()
executor.bitget.settings.bitget_default_leverage = 10
executor.send_execution_notification = AsyncMock()
executor.bitget.market_close_position.return_value = {'success': True, 'coin': 'BTC'}
result = asyncio.run(executor.execute_close({'symbol': 'BTCUSDT'}, 50000.0))
assert result['success'] is True
executor.bitget.market_close_position.assert_called_once_with('BTC')
executor.bitget.market_close_all.assert_not_called()
def test_bitget_executor_move_stop_loss_uses_set_tp_sl():
BitgetExecutor = load_bitget_executor_class()
executor = BitgetExecutor.__new__(BitgetExecutor)
executor.bitget = MagicMock()
executor.bitget.get_position_for_symbol.return_value = {'size': 0.02}
executor.bitget.get_tp_sl_prices.return_value = {'take_profit': 55000.0}
executor.bitget.set_tp_sl.return_value = {'success': True}
result = asyncio.run(executor.move_stop_loss('BTCUSDT', 49000.0))
assert result['success'] is True
executor.bitget.set_tp_sl.assert_called_once_with(
symbol='BTC',
is_long=True,
size=0.02,
tp_price=55000.0,
sl_price=49000.0,
)
def test_bitget_executor_open_uses_actual_leverage_for_contracts():
BitgetExecutor = load_bitget_executor_class()
executor = BitgetExecutor.__new__(BitgetExecutor)
executor.bitget = MagicMock()
executor.bitget.settings.bitget_default_leverage = 10
executor.send_execution_notification = AsyncMock()
executor.decide_order_type = MagicMock(return_value=('market', 'test'))
executor.calculate_effective_margin = MagicMock(return_value=32.0)
executor.bitget.get_account_state.return_value = {'available_balance': 1000.0}
executor.bitget.get_contract_size.return_value = 0.1
executor.bitget.place_market_order.return_value = {
'success': True,
'order_id': 'oid-1',
'order_status': 'filled',
}
result = asyncio.run(
executor.execute_open(
{
'symbol': 'ETHUSDT',
'action': 'buy',
'margin': 32.0,
'entry_price': 2000.0,
'stop_loss': 1980.0,
'take_profit': 2040.0,
},
2000.0,
)
)
assert result['success'] is True
executor.bitget.update_leverage.assert_called_once_with('ETH', 10)
executor.bitget.place_market_order.assert_called_once_with('ETH', is_buy=True, size=1)
def test_hyperliquid_executor_open_uses_decision_margin_not_account_value():
HyperliquidExecutor = load_hyperliquid_executor_class()
executor = HyperliquidExecutor.__new__(HyperliquidExecutor)
executor.hyperliquid = MagicMock()
executor.send_execution_notification = AsyncMock()
executor.decide_order_type = MagicMock(return_value=('market', 'test'))
executor.calculate_effective_margin = MagicMock(return_value=120.0)
executor.hyperliquid.get_account_state.return_value = {
'available_balance': 5000.0,
'account_value': 50000.0,
}
executor.hyperliquid.get_sz_decimals.return_value = 3
executor.hyperliquid.place_market_order.return_value = {
'success': True,
'order_id': 'oid-hl-1',
'order_status': 'filled',
}
executor.hyperliquid.set_tp_sl.return_value = {'tp_set': True, 'sl_set': True}
result = asyncio.run(
executor.execute_open(
{
'symbol': 'ETHUSDT',
'action': 'buy',
'margin': 120.0,
'entry_price': 2000.0,
'stop_loss': 1980.0,
'take_profit': 2060.0,
'leverage': 10,
},
2000.0,
)
)
assert result['success'] is True
executor.hyperliquid.update_leverage.assert_called_once_with('ETH', 10)
executor.hyperliquid.place_market_order.assert_called_once_with(
symbol='ETH',
is_buy=True,
size=0.6,
reduce_only=False,
)