diff --git a/crypto_monitor_okx/app.py b/crypto_monitor_okx/app.py index 1473186..65a53b1 100644 --- a/crypto_monitor_okx/app.py +++ b/crypto_monitor_okx/app.py @@ -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 diff --git a/manual_trading_hub/agent.py b/manual_trading_hub/agent.py index b0f68f5..f71f1f0 100644 --- a/manual_trading_hub/agent.py +++ b/manual_trading_hub/agent.py @@ -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") diff --git a/manual_trading_hub/exchange_orders.py b/manual_trading_hub/exchange_orders.py index 55cdbd0..a33afd4 100644 --- a/manual_trading_hub/exchange_orders.py +++ b/manual_trading_hub/exchange_orders.py @@ -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 diff --git a/tests/test_hub_exchange_orders_okx.py b/tests/test_hub_exchange_orders_okx.py new file mode 100644 index 0000000..2f03d62 --- /dev/null +++ b/tests/test_hub_exchange_orders_okx.py @@ -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()