""" 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() feishu_module.get_feishu_error_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, feishu_enabled=False, crypto_intraday_llm_cooldown_minutes=15, crypto_trend_llm_cooldown_minutes=60, crypto_force_llm_surge_threshold=2.0, crypto_force_llm_trade_zone_pct=0.4, crypto_event_analysis_enabled=True, crypto_event_analysis_window_minutes=5, crypto_event_analysis_price_change_percent=1.0, crypto_event_analysis_cooldown_minutes=10, paper_trading_enabled=True, bitget_trading_enabled=True, ) agent.paper_trading = None agent.bitget = None agent.bitget_services = {} agent.bitget_executors = {} agent.symbols = ['BTCUSDT'] agent.executors = {} agent._platform_halts = {} from collections import deque agent._execution_events = deque(maxlen=120) agent._analysis_events = deque(maxlen=240) agent._analysis_monitor = {} agent._analysis_notification_state = {} agent._lane_analysis_state = {} agent._event_analysis_state = {} agent.execution_guardian = MagicMock() agent.execution_guardian.get_status.return_value = {"last_status": "idle", "targets": [], "last_actions": []} agent._initial_balances = {} agent._target_execution_controls = {} agent._save_platform_halts = MagicMock() agent._save_initial_balances = MagicMock() agent._save_target_execution_controls = 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:default', bitget)]) agent._get_initial_balance = MagicMock(return_value=1000.0) agent._get_bitget_target_key = MagicMock(return_value='Bitget:default') should_stop, reason = asyncio.run(agent._check_account_level_stop_loss()) assert should_stop is True assert 'Bitget:default' in reason assert agent._platform_halts['Bitget:default']['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.bitget_services = {'default': bitget} agent._get_bitget_target_key = MagicMock(return_value='Bitget:default') agent._platform_halts = { 'Bitget:default': { 'halted': True, 'reason': 'drawdown', 'drawdown_pct': 25.1, } } result = agent.resume_platform('Bitget') assert result['halted'] is False assert agent._initial_balances['Bitget:default'] == 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', decision={ 'setup_type': 'breakout_confirmation', 'setup_basis': 'setup=breakout_confirmation | breakout_quality=acceptance_breakout_up', 'entry_basis': 'breakout_acceptance_follow_through', }, ) agent._record_execution_event('PaperTrading', 'hold', symbol='BTCUSDT', reason='已有盈利反向仓', status='hold') events = agent.get_recent_execution_events(limit=10) assert len(events) == 2 assert events[0]['platform'] == 'PaperTrading' assert events[0]['event_type'] == 'hold' assert events[1]['platform'] == 'Bitget' assert events[1]['reason'] == '余额不足' assert events[1]['setup_type'] == 'breakout_confirmation' 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': '正常开仓'}, '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'] == '替换旧挂单' def test_target_execution_status_uses_settings_defaults_until_overridden(): agent = make_agent() agent.bitget_services = {'default': MagicMock()} agent._get_bitget_target_key = MagicMock(return_value='Bitget:default') agent._iter_bitget_accounts = MagicMock(return_value=['default']) status = agent.get_target_execution_status() assert status['PaperTrading']['enabled'] is True assert status['PaperTrading']['source'] == 'default' assert status['Bitget:default']['enabled'] is True assert status['Bitget:default']['source'] == 'default' def test_set_target_execution_enabled_persists_manual_override(): agent = make_agent() agent.bitget_services = {'default': MagicMock()} agent._iter_bitget_accounts = MagicMock(return_value=['default']) agent._get_bitget_target_key = MagicMock(return_value='Bitget:default') result = agent.set_target_execution_enabled('Bitget', False, 'manual off') assert result['enabled'] is False assert result['source'] == 'manual' assert result['reason'] == 'manual off' assert agent._target_execution_controls['Bitget:default']['enabled'] is False agent._save_target_execution_controls.assert_called_once()