""" 仓位 sizing 回归测试 覆盖重点: - 中线信号默认仓位降到 light - 总杠杆限制按“名义仓位空间 -> 保证金”正确换算 - 模拟盘回退仓位计算不再按可用保证金倍数放大 """ import importlib.util import sys import types from pathlib import Path from unittest.mock import MagicMock import pytest def load_position_sizing_module(): module_path = Path(__file__).resolve().parents[1] / "app" / "services" / "position_sizing.py" if "app" not in sys.modules: app_pkg = types.ModuleType("app") app_pkg.__path__ = [str(module_path.parents[2] / "app")] sys.modules["app"] = app_pkg if "app.services" not in sys.modules: services_pkg = types.ModuleType("app.services") services_pkg.__path__ = [str(module_path.parent)] sys.modules["app.services"] = services_pkg if "app.utils" not in sys.modules: utils_pkg = types.ModuleType("app.utils") utils_pkg.__path__ = [str(module_path.parents[1] / "utils")] sys.modules["app.utils"] = utils_pkg logger_module = types.ModuleType("app.utils.logger") logger_module.logger = MagicMock() sys.modules["app.utils.logger"] = logger_module module_name = "app.services.position_sizing_test" spec = importlib.util.spec_from_file_location(module_name, module_path) module = importlib.util.module_from_spec(spec) sys.modules[module_name] = module spec.loader.exec_module(module) return module def load_paper_trading_service_class(): service_path = Path(__file__).resolve().parents[1] / "app" / "services" / "paper_trading_service.py" position_sizing_module = load_position_sizing_module() sys.modules["app.services.position_sizing"] = position_sizing_module if "app.models" not in sys.modules: models_pkg = types.ModuleType("app.models") models_pkg.__path__ = [str(service_path.parents[1] / "models")] sys.modules["app.models"] = models_pkg config_module = types.ModuleType("app.config") config_module.get_settings = MagicMock(return_value=MagicMock()) sys.modules["app.config"] = config_module datetime_module = types.ModuleType("app.utils.datetime_utils") datetime_module.get_beijing_time = MagicMock() sys.modules["app.utils.datetime_utils"] = datetime_module db_module = types.ModuleType("app.services.db_service") db_module.db_service = MagicMock() sys.modules["app.services.db_service"] = db_module paper_models_module = types.ModuleType("app.models.paper_trading") paper_models_module.PaperOrder = object paper_models_module.OrderStatus = types.SimpleNamespace( PENDING="pending", OPEN="open", CLOSED_TP="closed_tp", CLOSED_SL="closed_sl", CLOSED_BE="closed_be", CLOSED_TS="closed_ts", CLOSED_MANUAL="closed_manual", CANCELLED="cancelled", ) paper_models_module.OrderSide = types.SimpleNamespace(LONG="long", SHORT="short") paper_models_module.SignalGrade = lambda value: value paper_models_module.EntryType = types.SimpleNamespace(MARKET="market", LIMIT="limit") sys.modules["app.models.paper_trading"] = paper_models_module module_name = "app.services.paper_trading_service_test" spec = importlib.util.spec_from_file_location(module_name, service_path) module = importlib.util.module_from_spec(spec) sys.modules[module_name] = module spec.loader.exec_module(module) return module.PaperTradingService def test_medium_term_defaults_to_light_margin_budget(): module = load_position_sizing_module() target_pct, _, position_size, grade = module.resolve_target_margin_pct( position_size=None, signal_type="medium_term", confidence=75, ) assert position_size == "light" assert grade == "B" assert target_pct == pytest.approx(0.08) def test_total_leverage_cap_is_converted_to_margin_cap(): module = load_position_sizing_module() margin, position_value, _ = module.calculate_margin_and_position_value( balance=20000, available_margin=12000, current_total_leverage=9.5, max_total_leverage=10, order_leverage=10, target_margin_pct=0.18, max_margin_pct=0.25, ) assert margin == pytest.approx(1000.0) assert position_value == pytest.approx(10000.0) def test_paper_dynamic_position_uses_equity_pct_instead_of_margin_multiple(): PaperTradingService = load_paper_trading_service_class() service = PaperTradingService.__new__(PaperTradingService) service.leverage = 10 service.max_total_leverage = 10 service.get_account_status = MagicMock( return_value={ "current_balance": 20000, "used_margin": 0, "current_total_leverage": 0, "max_total_leverage": 10, } ) margin, position_value = service._calculate_dynamic_position( position_size="medium", symbol="ETHUSDT", signal_type="medium_term", confidence=75, grade="B", ) assert margin == pytest.approx(2400.0) assert position_value == pytest.approx(24000.0) def test_setup_profile_can_cap_margin_budget_more_conservatively(): module = load_position_sizing_module() margin, position_value, _ = module.calculate_margin_and_position_value( balance=20000, available_margin=19000, current_total_leverage=0, max_total_leverage=10, order_leverage=10, target_margin_pct=0.12 * 0.55, max_margin_pct=0.08, ) assert margin == pytest.approx(1320.0) assert position_value == pytest.approx(13200.0) def test_min_position_value_floor_can_raise_too_small_contract_position(): module = load_position_sizing_module() margin, position_value, _ = module.calculate_margin_and_position_value( balance=1400, available_margin=1400, current_total_leverage=0, max_total_leverage=10, order_leverage=10, target_margin_pct=0.028, # 原本只会开到约 392U 名义仓位 max_margin_pct=0.25, min_position_value=1400, ) assert margin == pytest.approx(140.0) assert position_value == pytest.approx(1400.0)