fix(okx): use OCO algo orders for hub TP/SL to avoid instant close
Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
+16
-22
@@ -2610,37 +2610,31 @@ def cancel_okx_swap_open_orders(exchange_symbol):
|
||||
|
||||
def _okx_place_tp_sl_orders(exchange_symbol, direction, amount, stop_loss, take_profit):
|
||||
"""
|
||||
为已有持仓挂条件止盈/止损(算法单)。
|
||||
勿在同一笔 reduce-only 市价单上同时带 stopLoss+takeProfit,OKX/ccxt 可能当成立即全平。
|
||||
为已有持仓挂条件止盈/止损(一笔 OCO 算法单)。
|
||||
勿带 reduceOnly,勿分两笔 reduce-only 市价单,否则 OKX/ccxt 可能当成立即全平。
|
||||
"""
|
||||
ensure_markets_loaded()
|
||||
close_side = "sell" if direction == "long" else "buy"
|
||||
amt = float(exchange.amount_to_precision(exchange_symbol, float(amount)))
|
||||
if amt <= 0:
|
||||
raise RuntimeError("止盈止损:可平数量经精度舍入后为 0")
|
||||
base = build_okx_order_params(direction, reduce_only=True)
|
||||
sl_px = float(stop_loss)
|
||||
tp_px = float(take_profit)
|
||||
base = build_okx_order_params(direction, reduce_only=False)
|
||||
sl_px = _okx_algo_trigger_price_str(exchange_symbol, stop_loss)
|
||||
tp_px = _okx_algo_trigger_price_str(exchange_symbol, take_profit)
|
||||
order_params = {
|
||||
**base,
|
||||
"stopLossPrice": float(sl_px),
|
||||
"takeProfitPrice": float(tp_px),
|
||||
"tpOrdPx": "-1",
|
||||
"slOrdPx": "-1",
|
||||
}
|
||||
if OKX_POS_MODE == "hedge":
|
||||
ps = "long" if direction == "long" else "short"
|
||||
order_params["positionSide"] = ps
|
||||
last_err = None
|
||||
for attempt in range(6):
|
||||
try:
|
||||
exchange.create_order(
|
||||
exchange_symbol,
|
||||
"market",
|
||||
close_side,
|
||||
amt,
|
||||
None,
|
||||
{**base, "stopLossPrice": sl_px},
|
||||
)
|
||||
time.sleep(0.05)
|
||||
exchange.create_order(
|
||||
exchange_symbol,
|
||||
"market",
|
||||
close_side,
|
||||
amt,
|
||||
None,
|
||||
{**base, "takeProfitPrice": tp_px},
|
||||
)
|
||||
exchange.create_order(exchange_symbol, "oco", close_side, amt, None, order_params)
|
||||
return
|
||||
except Exception as e:
|
||||
last_err = e
|
||||
|
||||
@@ -131,7 +131,10 @@ def _make_exchange() -> Any:
|
||||
"secret": secret,
|
||||
"password": password,
|
||||
"enableRateLimit": True,
|
||||
"options": {"defaultType": "swap"},
|
||||
"options": {
|
||||
"defaultType": "swap",
|
||||
"hedged": OKX_POS_MODE == "hedge",
|
||||
},
|
||||
}
|
||||
)
|
||||
_attach_proxies(ex, "OKX")
|
||||
|
||||
@@ -398,6 +398,8 @@ def cancel_order(
|
||||
params = None
|
||||
if kind == "gate" and ch == "algo":
|
||||
params = _gate_trigger_params(ex)
|
||||
elif kind == "okx" and ch == "algo":
|
||||
params = {"stop": True}
|
||||
oid = _okx_algo_order_id(order_id) if kind == "okx" else str(order_id)
|
||||
ex.cancel_order(oid, unified, params)
|
||||
|
||||
@@ -489,11 +491,21 @@ def _binance_place_tp_sl(
|
||||
raise RuntimeError(f"Binance 未接受止盈/止损:{last_err}")
|
||||
|
||||
|
||||
def _okx_order_params(direction: str, *, reduce_only: bool, pos_mode: str, td_mode: str) -> dict:
|
||||
def _okx_order_params(
|
||||
direction: str,
|
||||
*,
|
||||
reduce_only: bool,
|
||||
pos_mode: str,
|
||||
td_mode: str,
|
||||
for_algo_tpsl: bool = False,
|
||||
) -> dict:
|
||||
params: dict[str, Any] = {"tdMode": td_mode or "cross"}
|
||||
if (pos_mode or "hedge").lower() in ("hedge", "long_short_mode", "dual"):
|
||||
params["posSide"] = "long" if direction == "long" else "short"
|
||||
if reduce_only:
|
||||
ps = "long" if direction == "long" else "short"
|
||||
params["posSide"] = ps
|
||||
params["positionSide"] = ps
|
||||
# OKX 条件/OCO 算法单勿带 reduceOnly,否则可能被当市价减仓立即成交
|
||||
if reduce_only and not for_algo_tpsl:
|
||||
params["reduceOnly"] = True
|
||||
return params
|
||||
|
||||
@@ -509,34 +521,32 @@ def _okx_place_tp_sl(
|
||||
pos_mode: str = "hedge",
|
||||
td_mode: str = "cross",
|
||||
) -> None:
|
||||
"""OKX 永续:一笔 OCO 算法单挂止盈+止损(勿 reduceOnly + 分两笔 market)。"""
|
||||
ex.load_markets()
|
||||
close_side = "sell" if direction == "long" else "buy"
|
||||
amt = float(ex.amount_to_precision(symbol, float(amount)))
|
||||
if amt <= 0:
|
||||
raise RuntimeError("止盈止损:可平数量经精度舍入后为 0")
|
||||
base = _okx_order_params(direction, reduce_only=True, pos_mode=pos_mode, td_mode=td_mode)
|
||||
sl_px = float(stop_loss)
|
||||
tp_px = float(take_profit)
|
||||
base = _okx_order_params(
|
||||
direction,
|
||||
reduce_only=False,
|
||||
pos_mode=pos_mode,
|
||||
td_mode=td_mode,
|
||||
for_algo_tpsl=True,
|
||||
)
|
||||
sl_px = ex.price_to_precision(symbol, float(stop_loss))
|
||||
tp_px = ex.price_to_precision(symbol, float(take_profit))
|
||||
order_params = {
|
||||
**base,
|
||||
"stopLossPrice": float(sl_px),
|
||||
"takeProfitPrice": float(tp_px),
|
||||
"tpOrdPx": "-1",
|
||||
"slOrdPx": "-1",
|
||||
}
|
||||
last_err: Exception | None = None
|
||||
for attempt in range(6):
|
||||
try:
|
||||
ex.create_order(
|
||||
symbol,
|
||||
"market",
|
||||
close_side,
|
||||
amt,
|
||||
None,
|
||||
{**base, "stopLossPrice": sl_px},
|
||||
)
|
||||
time.sleep(0.05)
|
||||
ex.create_order(
|
||||
symbol,
|
||||
"market",
|
||||
close_side,
|
||||
amt,
|
||||
None,
|
||||
{**base, "takeProfitPrice": tp_px},
|
||||
)
|
||||
ex.create_order(symbol, "oco", close_side, amt, None, order_params)
|
||||
return
|
||||
except Exception as e:
|
||||
last_err = e
|
||||
|
||||
@@ -0,0 +1,67 @@
|
||||
"""OKX 中控委托:须为 OCO 条件单,不得带 reduceOnly 或分两笔 market。"""
|
||||
from __future__ import annotations
|
||||
|
||||
import sys
|
||||
import unittest
|
||||
from pathlib import Path
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[1]
|
||||
sys.path.insert(0, str(ROOT / "manual_trading_hub"))
|
||||
|
||||
from exchange_orders import _okx_place_tp_sl # noqa: E402
|
||||
|
||||
|
||||
class TestHubOkxPlaceTpsl(unittest.TestCase):
|
||||
def test_okx_place_tpsl_single_oco_without_reduce_only(self):
|
||||
captured: list[dict] = []
|
||||
|
||||
def fake_create_order(symbol, order_type, side, amount, price, params):
|
||||
captured.append(
|
||||
{
|
||||
"symbol": symbol,
|
||||
"type": order_type,
|
||||
"side": side,
|
||||
"amount": amount,
|
||||
"params": dict(params or {}),
|
||||
}
|
||||
)
|
||||
return {"id": "algo-1"}
|
||||
|
||||
ex = MagicMock()
|
||||
ex.create_order = fake_create_order
|
||||
ex.load_markets = MagicMock()
|
||||
ex.amount_to_precision = lambda sym, amt: str(amt)
|
||||
ex.price_to_precision = lambda sym, px: str(px)
|
||||
|
||||
with patch.dict(
|
||||
"os.environ",
|
||||
{"OKX_POS_MODE": "hedge", "OKX_TD_MODE": "cross"},
|
||||
clear=False,
|
||||
):
|
||||
_okx_place_tp_sl(
|
||||
ex,
|
||||
"HYPE/USDT:USDT",
|
||||
"short",
|
||||
6.0,
|
||||
75.5,
|
||||
70.2,
|
||||
)
|
||||
|
||||
self.assertEqual(len(captured), 1, captured)
|
||||
call = captured[0]
|
||||
self.assertEqual(call["type"], "oco")
|
||||
self.assertEqual(call["side"], "buy")
|
||||
params = call["params"]
|
||||
self.assertNotIn("reduceOnly", params)
|
||||
self.assertEqual(params.get("posSide"), "short")
|
||||
self.assertEqual(params.get("positionSide"), "short")
|
||||
self.assertEqual(params.get("stopLossPrice"), 75.5)
|
||||
self.assertEqual(params.get("takeProfitPrice"), 70.2)
|
||||
self.assertEqual(params.get("tpOrdPx"), "-1")
|
||||
self.assertEqual(params.get("slOrdPx"), "-1")
|
||||
self.assertNotIn("stopLoss", params)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
Reference in New Issue
Block a user