From 5cf4b9eea639ed16a44e5a47c2b333813aee258c Mon Sep 17 00:00:00 2001 From: dekun Date: Sat, 30 May 2026 10:10:34 +0800 Subject: [PATCH] =?UTF-8?q?=E4=BF=AE=E5=A4=8Dokx=20=E8=B6=8B=E5=8A=BF?= =?UTF-8?q?=E5=9B=9E=E8=B0=83?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- crypto_monitor_okx/app.py | 33 ++++++++++++++---- scripts/verify_okx_trend_sl.py | 62 ++++++++++++++++++++++++++++++++++ 2 files changed, 88 insertions(+), 7 deletions(-) create mode 100644 scripts/verify_okx_trend_sl.py diff --git a/crypto_monitor_okx/app.py b/crypto_monitor_okx/app.py index ac446a5..8f3dc2d 100644 --- a/crypto_monitor_okx/app.py +++ b/crypto_monitor_okx/app.py @@ -2971,7 +2971,11 @@ def replace_active_monitor_tpsl_on_exchange(order_row, stop_loss, take_profit): def _okx_place_stop_loss_only(exchange_symbol, direction, stop_loss): - """OKX 永续:仅挂止损(趋势回调),止盈由程序监控。""" + """OKX 永续:仅挂止损(趋势回调),止盈由程序监控。 + + 须用 stopLossPrice 挂条件单;勿用 reduce-only 市价单 + params['stopLoss'], + 后者会当成立即市价平仓(开仓后约 1 秒内全平)。 + """ ensure_markets_loaded() pos_amt = get_live_position_contracts(exchange_symbol, direction) if pos_amt is None or float(pos_amt) <= 0: @@ -2979,12 +2983,27 @@ def _okx_place_stop_loss_only(exchange_symbol, direction, stop_loss): cancel_okx_swap_open_orders(exchange_symbol) close_side = "sell" if direction == "long" else "buy" amt = float(exchange.amount_to_precision(exchange_symbol, float(pos_amt))) - params = build_okx_order_params(direction, reduce_only=True) - params["stopLoss"] = { - "triggerPrice": _okx_algo_trigger_price_str(exchange_symbol, stop_loss), - "type": "market", - } - exchange.create_order(exchange_symbol, "market", close_side, amt, None, params) + if amt <= 0: + raise RuntimeError("止损:可平数量经精度舍入后为 0") + base = build_okx_order_params(direction, reduce_only=True) + sl_px = float(stop_loss) + last_err = None + for attempt in range(6): + try: + exchange.create_order( + exchange_symbol, + "market", + close_side, + amt, + None, + {**base, "stopLossPrice": sl_px}, + ) + return + except Exception as e: + last_err = e + cancel_okx_swap_open_orders(exchange_symbol) + time.sleep(0.2 * (attempt + 1)) + raise RuntimeError(f"OKX 未接受止损条件单:{last_err}") def calc_trend_manual_breakeven_stop(direction, entry_price, offset_pct=None): diff --git a/scripts/verify_okx_trend_sl.py b/scripts/verify_okx_trend_sl.py new file mode 100644 index 0000000..e48e0f1 --- /dev/null +++ b/scripts/verify_okx_trend_sl.py @@ -0,0 +1,62 @@ +"""验证 OKX 趋势回调止损挂单:须为 stopLossPrice 条件单,不得为立即市价平仓。""" +from __future__ import annotations + +import sys +from pathlib import Path +from unittest.mock import MagicMock, patch + +ROOT = Path(__file__).resolve().parents[1] +sys.path.insert(0, str(ROOT / "crypto_monitor_okx")) + + +def main() -> int: + 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": "test-order", "average": 1.358} + + mock_exchange = MagicMock() + mock_exchange.create_order = fake_create_order + mock_exchange.amount_to_precision = lambda sym, amt: amt + mock_exchange.market = lambda sym: {"contractSize": 1, "limits": {"amount": {"min": 0.01}}} + mock_exchange.load_markets = MagicMock() + mock_exchange.price_to_precision = lambda sym, px: str(px) + + with patch.dict( + "os.environ", + {"LIVE_TRADING_ENABLED": "true", "OKX_API_KEY": "k", "OKX_API_SECRET": "s", "OKX_API_PASSPHRASE": "p"}, + clear=False, + ): + import app as okx_app + + okx_app.exchange = mock_exchange + okx_app.MARKETS_LOADED = True + + with patch.object(okx_app, "ensure_okx_live_ready", return_value=(True, "")), patch.object( + okx_app, "get_live_position_contracts", return_value=12.0 + ), patch.object(okx_app, "cancel_okx_swap_open_orders"): + okx_app._okx_place_stop_loss_only("XRP/USDT:USDT", "long", 1.1) + + assert len(captured) == 1, f"expected 1 create_order call, got {len(captured)}" + call = captured[0] + params = call["params"] + assert call["side"] == "sell", call + assert params.get("reduceOnly") is True, params + assert "stopLossPrice" in params, f"missing stopLossPrice: {params}" + assert params["stopLossPrice"] == 1.1, params + assert "stopLoss" not in params, f"nested stopLoss causes immediate close: {params}" + print("OK: _okx_place_stop_loss_only uses stopLossPrice conditional attach, not immediate close") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main())