186 lines
6.1 KiB
Python
186 lines
6.1 KiB
Python
"""
|
|
仓位 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)
|