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

194 lines
7.2 KiB
Python

"""
CryptoAgent 平台熔断回归测试
覆盖重点:
- 账户级止损只暂停单个平台,不再要求全局停机
- 手动恢复平台会重置该平台初始权益基线
"""
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 load_crypto_agent_class():
agent_path = Path(__file__).resolve().parents[1] / 'app' / 'crypto_agent' / 'crypto_agent.py'
if 'app' not in sys.modules:
app_pkg = types.ModuleType('app')
app_pkg.__path__ = [str(agent_path.parents[2] / 'app')]
sys.modules['app'] = app_pkg
for pkg_name, pkg_path in [
('app.crypto_agent', agent_path.parent),
('app.services', agent_path.parents[1] / 'services'),
('app.utils', agent_path.parents[1] / 'utils'),
]:
if pkg_name not in sys.modules:
pkg = types.ModuleType(pkg_name)
pkg.__path__ = [str(pkg_path)]
sys.modules[pkg_name] = pkg
logger_module = types.ModuleType('app.utils.logger')
logger_module.logger = MagicMock()
sys.modules['app.utils.logger'] = logger_module
config_module = types.ModuleType('app.config')
config_module.get_settings = MagicMock()
sys.modules['app.config'] = config_module
bitget_service_module = types.ModuleType('app.services.bitget_service')
bitget_service_module.bitget_service = MagicMock()
sys.modules['app.services.bitget_service'] = bitget_service_module
feishu_module = types.ModuleType('app.services.feishu_service')
feishu_module.get_feishu_service = MagicMock()
feishu_module.get_feishu_paper_trading_service = MagicMock()
sys.modules['app.services.feishu_service'] = feishu_module
telegram_module = types.ModuleType('app.services.telegram_service')
telegram_module.get_telegram_service = MagicMock()
sys.modules['app.services.telegram_service'] = telegram_module
dingtalk_module = types.ModuleType('app.services.dingtalk_service')
dingtalk_module.get_dingtalk_service = MagicMock()
sys.modules['app.services.dingtalk_service'] = dingtalk_module
paper_module = types.ModuleType('app.services.paper_trading_service')
paper_module.get_paper_trading_service = MagicMock()
sys.modules['app.services.paper_trading_service'] = paper_module
signal_db_module = types.ModuleType('app.services.signal_database_service')
signal_db_module.get_signal_db_service = MagicMock()
sys.modules['app.services.signal_database_service'] = signal_db_module
position_sizing_module = types.ModuleType('app.services.position_sizing')
position_sizing_module.DEFAULT_SIGNAL_POSITION_SIZE_BY_TIMEFRAME = {}
position_sizing_module.DEFAULT_TIMEFRAME_MARGIN_MULTIPLIERS = {}
position_sizing_module.calculate_margin_and_position_value = MagicMock()
position_sizing_module.resolve_target_margin_pct = MagicMock()
sys.modules['app.services.position_sizing'] = position_sizing_module
market_analyzer_module = types.ModuleType('app.crypto_agent.market_signal_analyzer')
market_analyzer_module.MarketSignalAnalyzer = MagicMock()
sys.modules['app.crypto_agent.market_signal_analyzer'] = market_analyzer_module
system_status_module = types.ModuleType('app.utils.system_status')
system_status_module.get_system_monitor = MagicMock()
system_status_module.AgentStatus = types.SimpleNamespace(RUNNING='running', STOPPED='stopped')
sys.modules['app.utils.system_status'] = system_status_module
module_name = 'app.crypto_agent.crypto_agent_test'
spec = importlib.util.spec_from_file_location(module_name, agent_path)
module = importlib.util.module_from_spec(spec)
sys.modules[module_name] = module
spec.loader.exec_module(module)
return module.CryptoAgent
def make_agent():
CryptoAgent = load_crypto_agent_class()
agent = CryptoAgent.__new__(CryptoAgent)
agent.settings = types.SimpleNamespace(account_max_drawdown=0.25, account_drawdown_alert=0.15)
agent.paper_trading = None
agent.hyperliquid = None
agent.bitget = None
agent.symbols = ['BTCUSDT']
agent.executors = {}
agent._platform_halts = {}
from collections import deque
agent._execution_events = deque(maxlen=120)
agent._initial_balances = {}
agent._save_platform_halts = MagicMock()
agent._save_initial_balances = MagicMock()
agent._send_alert_notification = AsyncMock()
agent._emergency_close_all_positions = AsyncMock()
return agent
def test_account_stop_loss_halts_only_triggered_platform():
agent = make_agent()
bitget = MagicMock()
bitget.get_account_state.return_value = {
'account_value': 700.0,
'current_balance': 700.0,
}
agent.bitget = bitget
agent._get_risk_platforms = MagicMock(return_value=[('Bitget', bitget)])
agent._get_initial_balance = MagicMock(return_value=1000.0)
should_stop, reason = asyncio.run(agent._check_account_level_stop_loss())
assert should_stop is True
assert 'Bitget' in reason
assert agent._platform_halts['Bitget']['halted'] is True
agent._emergency_close_all_positions.assert_awaited_once()
def test_resume_platform_resets_initial_balance_and_clears_halt():
agent = make_agent()
bitget = MagicMock()
bitget.get_account_state.return_value = {
'account_value': 888.0,
'current_balance': 888.0,
}
agent.bitget = bitget
agent._platform_halts = {
'Bitget': {
'halted': True,
'reason': 'drawdown',
'drawdown_pct': 25.1,
}
}
result = agent.resume_platform('Bitget')
assert result['halted'] is False
assert agent._initial_balances['Bitget'] == 888.0
assert result['initial_balance'] == 888.0
assert result['current_balance'] == 888.0
def test_execution_events_are_recorded_and_returned_in_reverse_time_order():
agent = make_agent()
agent._record_execution_event('Bitget', 'open_failed', symbol='ETHUSDT', reason='余额不足', status='error')
agent._record_execution_event('Hyperliquid', 'hold', symbol='BTCUSDT', reason='已有盈利反向仓', status='hold')
events = agent.get_recent_execution_events(limit=10)
assert len(events) == 2
assert events[0]['platform'] == 'Hyperliquid'
assert events[0]['event_type'] == 'hold'
assert events[1]['platform'] == 'Bitget'
assert events[1]['reason'] == '余额不足'
def test_get_status_contains_last_execution_preview():
agent = make_agent()
agent.running = True
agent.symbols = ['BTCUSDT']
agent.last_signals = {
'BTCUSDT': {'type': 'medium_term', 'action': 'sell', 'confidence': 78, 'grade': 'B'}
}
agent.last_execution_preview = {
'BTCUSDT': {
'timestamp': '2026-04-22T12:00:00',
'current_price': 65000.0,
'paper': {'decision': 'OPEN', 'reason': '正常开仓'},
'hyperliquid': {'decision': 'HOLD', 'reason': '无适配信号'},
'bitget': {'decision': 'CANCEL_PENDING', 'reason': '替换旧挂单'},
}
}
status = agent.get_status()
assert status['last_execution_preview']['BTCUSDT']['paper']['decision'] == 'OPEN'
assert status['last_execution_preview']['BTCUSDT']['bitget']['reason'] == '替换旧挂单'