From 6084edbda1c0e4751e735802de6f96083e3f2b7e Mon Sep 17 00:00:00 2001 From: Jett Wang Date: Tue, 28 Oct 2025 15:27:19 +0800 Subject: [PATCH] =?UTF-8?q?fix:=20=E4=BF=AE=E5=A4=8D=E6=AD=A2=E7=9B=88?= =?UTF-8?q?=E6=AD=A2=E6=8D=9F=E8=AE=A2=E5=8D=95=E7=9A=84422=E5=8F=8D?= =?UTF-8?q?=E5=BA=8F=E5=88=97=E5=8C=96=E9=94=99=E8=AF=AF?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 问题: 设置现有仓位的止盈止损时API返回422错误,原因是订单结构中isMarket=true与limit_px使用激进价格冲突 修复: 止盈使用限价单(isMarket:false),止损使用市价单(isMarket:true),limit_px直接使用触发价格 测试: 新增8个单元测试,更新2个集成测试,所有34个测试通过 --- services/hyperliquid_services.py | 21 +- tests/integration/test_oco_grouping.py | 26 +- tests/unit/test_tpsl_orders.py | 320 +++++++++++++++++++++++++ 3 files changed, 348 insertions(+), 19 deletions(-) create mode 100644 tests/unit/test_tpsl_orders.py diff --git a/services/hyperliquid_services.py b/services/hyperliquid_services.py index 48b6e43..95ce426 100644 --- a/services/hyperliquid_services.py +++ b/services/hyperliquid_services.py @@ -788,20 +788,17 @@ async def set_position_tpsl( # Add take profit order if specified if tp_px is not None: - # For TP orders, use tick-aligned aggressive price - # If closing long (sell), use very low price; if closing short (buy), use very high price - slippage = 0.5 # 50% slippage for very aggressive pricing - aggressive_px = self._slippage_price(coin, not is_long, slippage) - + # For TP orders, use limit order at trigger price + # This ensures the order fills at or better than the TP price tp_order = { "coin": coin, "is_buy": not is_long, "sz": float(position_size), - "limit_px": aggressive_px, # Tick-aligned aggressive price for market execution + "limit_px": float(tp_px), # Limit price = trigger price for TP "order_type": { "trigger": { "triggerPx": float(tp_px), - "isMarket": True, + "isMarket": False, # Use limit order for TP "tpsl": "tp", } }, @@ -812,19 +809,17 @@ async def set_position_tpsl( # Add stop loss order if specified if sl_px is not None: - # For SL orders, use tick-aligned aggressive price - slippage = 0.5 # 50% slippage for very aggressive pricing - aggressive_px = self._slippage_price(coin, not is_long, slippage) - + # For SL orders, use market order for fast execution + # No limit_px needed when isMarket=True sl_order = { "coin": coin, "is_buy": not is_long, "sz": float(position_size), - "limit_px": aggressive_px, # Tick-aligned aggressive price for market execution + "limit_px": float(sl_px), # Use trigger price as limit_px "order_type": { "trigger": { "triggerPx": float(sl_px), - "isMarket": True, + "isMarket": True, # Use market order for SL "tpsl": "sl", } }, diff --git a/tests/integration/test_oco_grouping.py b/tests/integration/test_oco_grouping.py index f5489a7..5e5dcc1 100644 --- a/tests/integration/test_oco_grouping.py +++ b/tests/integration/test_oco_grouping.py @@ -57,10 +57,12 @@ def mock_bulk_orders(order_requests, grouping="na"): async def test_set_position_tpsl_uses_correct_grouping(mock_service, monkeypatch): """测试 set_position_tpsl 使用 positionTpSl 分组""" captured_grouping = None + captured_orders = None def mock_bulk_orders(order_requests, grouping="na"): - nonlocal captured_grouping + nonlocal captured_grouping, captured_orders captured_grouping = grouping + captured_orders = order_requests return {"status": "ok", "response": {"type": "default"}} # Mock info.user_state 返回一个仓位 @@ -74,13 +76,25 @@ def mock_user_state(address): monkeypatch.setattr(mock_service, "_bulk_orders_with_grouping", mock_bulk_orders) monkeypatch.setattr(mock_service.info, "user_state", mock_user_state) - # Mock _slippage_price - monkeypatch.setattr( - mock_service, "_slippage_price", lambda coin, is_buy, slippage: 45000.0 - ) - result = await mock_service.set_position_tpsl(coin="BTC", tp_px=47000, sl_px=43000) assert captured_grouping == OCO_GROUP_EXISTING_POSITION assert result["success"] assert result["position_details"]["grouping"] == OCO_GROUP_EXISTING_POSITION + + # 验证订单结构正确性 + assert len(captured_orders) == 2 + tp_order = next( + o for o in captured_orders if o["order_type"]["trigger"]["tpsl"] == "tp" + ) + sl_order = next( + o for o in captured_orders if o["order_type"]["trigger"]["tpsl"] == "sl" + ) + + # 验证止盈使用限价单 + assert tp_order["order_type"]["trigger"]["isMarket"] is False + assert tp_order["limit_px"] == 47000.0 + + # 验证止损使用市价单 + assert sl_order["order_type"]["trigger"]["isMarket"] is True + assert sl_order["limit_px"] == 43000.0 diff --git a/tests/unit/test_tpsl_orders.py b/tests/unit/test_tpsl_orders.py new file mode 100644 index 0000000..38b3ede --- /dev/null +++ b/tests/unit/test_tpsl_orders.py @@ -0,0 +1,320 @@ +"""止盈止损订单结构测试 + +测试止盈止损订单的正确构建,确保: +1. 止盈订单使用限价单 (isMarket: False) +2. 止损订单使用市价单 (isMarket: True) +3. limit_px 设置正确 +4. OCO 分组正确 +""" + +from unittest.mock import MagicMock, patch + +import pytest + +from services.constants import OCO_GROUP_EXISTING_POSITION +from services.hyperliquid_services import HyperliquidServices + + +@pytest.fixture +def mock_service(): + """创建 mock 服务实例""" + with ( + patch("services.hyperliquid_services.Info"), + patch("services.hyperliquid_services.Exchange"), + patch("eth_account.Account") as mock_account_class, + ): + # Mock wallet + mock_wallet = MagicMock() + mock_wallet.address = "0xTEST_WALLET_ADDRESS" + mock_account_class.from_key.return_value = mock_wallet + + service = HyperliquidServices( + private_key="0x" + "1" * 64, testnet=True, account_address="0xTEST" + ) + return service + + +@pytest.mark.asyncio +async def test_take_profit_order_structure(mock_service, monkeypatch): + """测试止盈订单结构: 应该使用限价单""" + captured_orders = None + + def mock_bulk_orders(order_requests, grouping="na"): + nonlocal captured_orders + captured_orders = order_requests + return {"status": "ok", "response": {"type": "default"}} + + # Mock info.user_state 返回多头仓位 + def mock_user_state(address): + return { + "assetPositions": [ + {"position": {"coin": "BTC", "szi": "0.1"}} # 多头仓位 + ] + } + + monkeypatch.setattr(mock_service, "_bulk_orders_with_grouping", mock_bulk_orders) + monkeypatch.setattr(mock_service.info, "user_state", mock_user_state) + + # 只设置止盈 + await mock_service.set_position_tpsl(coin="BTC", tp_px=50000, sl_px=None) + + assert captured_orders is not None + assert len(captured_orders) == 1 + + tp_order = captured_orders[0] + + # 验证止盈订单结构 + assert tp_order["coin"] == "BTC" + assert tp_order["is_buy"] is False # 平多头,应该是卖单 + assert tp_order["sz"] == 0.1 + assert tp_order["limit_px"] == 50000.0 # 限价应该等于触发价 + assert tp_order["reduce_only"] is True + assert tp_order["order_type"]["trigger"]["triggerPx"] == 50000.0 + assert tp_order["order_type"]["trigger"]["isMarket"] is False # 止盈使用限价单 + assert tp_order["order_type"]["trigger"]["tpsl"] == "tp" + + +@pytest.mark.asyncio +async def test_stop_loss_order_structure(mock_service, monkeypatch): + """测试止损订单结构: 应该使用市价单""" + captured_orders = None + + def mock_bulk_orders(order_requests, grouping="na"): + nonlocal captured_orders + captured_orders = order_requests + return {"status": "ok", "response": {"type": "default"}} + + # Mock info.user_state 返回多头仓位 + def mock_user_state(address): + return { + "assetPositions": [ + {"position": {"coin": "BTC", "szi": "0.1"}} # 多头仓位 + ] + } + + monkeypatch.setattr(mock_service, "_bulk_orders_with_grouping", mock_bulk_orders) + monkeypatch.setattr(mock_service.info, "user_state", mock_user_state) + + # 只设置止损 + await mock_service.set_position_tpsl(coin="BTC", tp_px=None, sl_px=40000) + + assert captured_orders is not None + assert len(captured_orders) == 1 + + sl_order = captured_orders[0] + + # 验证止损订单结构 + assert sl_order["coin"] == "BTC" + assert sl_order["is_buy"] is False # 平多头,应该是卖单 + assert sl_order["sz"] == 0.1 + assert sl_order["limit_px"] == 40000.0 # 止损也用触发价作为限价 + assert sl_order["reduce_only"] is True + assert sl_order["order_type"]["trigger"]["triggerPx"] == 40000.0 + assert sl_order["order_type"]["trigger"]["isMarket"] is True # 止损使用市价单 + assert sl_order["order_type"]["trigger"]["tpsl"] == "sl" + + +@pytest.mark.asyncio +async def test_combined_tpsl_order_structure(mock_service, monkeypatch): + """测试同时设置止盈止损: 应该创建两个订单""" + captured_orders = None + captured_grouping = None + + def mock_bulk_orders(order_requests, grouping="na"): + nonlocal captured_orders, captured_grouping + captured_orders = order_requests + captured_grouping = grouping + return {"status": "ok", "response": {"type": "default"}} + + # Mock info.user_state 返回多头仓位 + def mock_user_state(address): + return { + "assetPositions": [ + {"position": {"coin": "BTC", "szi": "0.1"}} # 多头仓位 + ] + } + + monkeypatch.setattr(mock_service, "_bulk_orders_with_grouping", mock_bulk_orders) + monkeypatch.setattr(mock_service.info, "user_state", mock_user_state) + + # 同时设置止盈止损 + result = await mock_service.set_position_tpsl( + coin="BTC", tp_px=50000, sl_px=40000, position_size=0.1 + ) + + # 验证返回结果 + assert result["success"] is True + assert result["position_details"]["take_profit_price"] == 50000 + assert result["position_details"]["stop_loss_price"] == 40000 + + # 验证订单数量 + assert captured_orders is not None + assert len(captured_orders) == 2 + + # 验证 OCO 分组 + assert captured_grouping == OCO_GROUP_EXISTING_POSITION + + # 找到止盈和止损订单 + tp_order = next( + o for o in captured_orders if o["order_type"]["trigger"]["tpsl"] == "tp" + ) + sl_order = next( + o for o in captured_orders if o["order_type"]["trigger"]["tpsl"] == "sl" + ) + + # 验证止盈订单 + assert tp_order["limit_px"] == 50000.0 + assert tp_order["order_type"]["trigger"]["isMarket"] is False + + # 验证止损订单 + assert sl_order["limit_px"] == 40000.0 + assert sl_order["order_type"]["trigger"]["isMarket"] is True + + +@pytest.mark.asyncio +async def test_short_position_tpsl_direction(mock_service, monkeypatch): + """测试空头仓位的止盈止损方向: 应该使用买单平仓""" + captured_orders = None + + def mock_bulk_orders(order_requests, grouping="na"): + nonlocal captured_orders + captured_orders = order_requests + return {"status": "ok", "response": {"type": "default"}} + + # Mock info.user_state 返回空头仓位 + def mock_user_state(address): + return { + "assetPositions": [ + {"position": {"coin": "ETH", "szi": "-1.5"}} # 负数表示空头 + ] + } + + monkeypatch.setattr(mock_service, "_bulk_orders_with_grouping", mock_bulk_orders) + monkeypatch.setattr(mock_service.info, "user_state", mock_user_state) + + # 设置止盈止损 + result = await mock_service.set_position_tpsl(coin="ETH", tp_px=2000, sl_px=2200) + + assert result["success"] is True + assert result["position_details"]["is_long"] is False + + # 验证订单方向 + tp_order = next( + o for o in captured_orders if o["order_type"]["trigger"]["tpsl"] == "tp" + ) + sl_order = next( + o for o in captured_orders if o["order_type"]["trigger"]["tpsl"] == "sl" + ) + + # 空头平仓应该是买单 + assert tp_order["is_buy"] is True + assert sl_order["is_buy"] is True + + # 仓位大小应该是绝对值 + assert tp_order["sz"] == 1.5 + assert sl_order["sz"] == 1.5 + + +@pytest.mark.asyncio +async def test_tpsl_without_position_fails(mock_service, monkeypatch): + """测试没有仓位时设置止盈止损应该失败""" + + # Mock info.user_state 返回空仓位 + def mock_user_state(address): + return { + "assetPositions": [ + {"position": {"coin": "BTC", "szi": "0"}} # 空仓 + ] + } + + monkeypatch.setattr(mock_service.info, "user_state", mock_user_state) + + result = await mock_service.set_position_tpsl(coin="BTC", tp_px=50000, sl_px=40000) + + assert result["success"] is False + assert "No position found" in result["error"] + + +@pytest.mark.asyncio +async def test_tpsl_price_validation(mock_service, monkeypatch): + """测试止盈止损价格验证""" + + # Mock info.user_state 返回多头仓位 + def mock_user_state(address): + return {"assetPositions": [{"position": {"coin": "BTC", "szi": "0.1"}}]} + + monkeypatch.setattr(mock_service.info, "user_state", mock_user_state) + + # 测试至少需要设置一个价格 + result = await mock_service.set_position_tpsl(coin="BTC", tp_px=None, sl_px=None) + + assert result["success"] is False + assert "At least one of tp_px or sl_px must be specified" in result["error"] + + +@pytest.mark.asyncio +async def test_auto_detect_position_size(mock_service, monkeypatch): + """测试自动检测仓位大小""" + captured_orders = None + + def mock_bulk_orders(order_requests, grouping="na"): + nonlocal captured_orders + captured_orders = order_requests + return {"status": "ok", "response": {"type": "default"}} + + # Mock info.user_state 返回仓位 + def mock_user_state(address): + return { + "assetPositions": [ + {"position": {"coin": "SOL", "szi": "5.25"}} # 5.25 SOL 多头 + ] + } + + monkeypatch.setattr(mock_service, "_bulk_orders_with_grouping", mock_bulk_orders) + monkeypatch.setattr(mock_service.info, "user_state", mock_user_state) + + # 不提供 position_size,应该自动检测 + result = await mock_service.set_position_tpsl(coin="SOL", tp_px=200, sl_px=180) + + assert result["success"] is True + assert result["position_details"]["position_size"] == 5.25 + + # 验证订单大小 + for order in captured_orders: + assert order["sz"] == 5.25 + + +@pytest.mark.asyncio +async def test_limit_px_equals_trigger_px(mock_service, monkeypatch): + """测试 limit_px 应该等于 trigger_px(而不是使用激进价格)""" + captured_orders = None + + def mock_bulk_orders(order_requests, grouping="na"): + nonlocal captured_orders + captured_orders = order_requests + return {"status": "ok", "response": {"type": "default"}} + + def mock_user_state(address): + return {"assetPositions": [{"position": {"coin": "BTC", "szi": "0.1"}}]} + + monkeypatch.setattr(mock_service, "_bulk_orders_with_grouping", mock_bulk_orders) + monkeypatch.setattr(mock_service.info, "user_state", mock_user_state) + + tp_price = 50000.0 + sl_price = 40000.0 + + await mock_service.set_position_tpsl(coin="BTC", tp_px=tp_price, sl_px=sl_price) + + tp_order = next( + o for o in captured_orders if o["order_type"]["trigger"]["tpsl"] == "tp" + ) + sl_order = next( + o for o in captured_orders if o["order_type"]["trigger"]["tpsl"] == "sl" + ) + + # 验证 limit_px 等于用户指定的价格,不是激进价格 + assert tp_order["limit_px"] == tp_price + assert tp_order["order_type"]["trigger"]["triggerPx"] == tp_price + + assert sl_order["limit_px"] == sl_price + assert sl_order["order_type"]["trigger"]["triggerPx"] == sl_price