From 88fc21e27890eb2754b22d8ee16a028cd2214168 Mon Sep 17 00:00:00 2001 From: dekun Date: Thu, 4 Jun 2026 16:28:47 +0800 Subject: [PATCH] fix(gate-bot): allow profit-side stop loss on TP/SL entrust Skip min planned RR when stop is on the winning side of entry; validate entrust against open price and fall back to plan take-profit when omitted. --- crypto_monitor_gate_bot/app.py | 48 ++++++++++++++------ crypto_monitor_gate_bot/templates/index.html | 40 +++++++++++++--- order_monitor_display_lib.py | 33 ++++++++++++++ 3 files changed, 102 insertions(+), 19 deletions(-) diff --git a/crypto_monitor_gate_bot/app.py b/crypto_monitor_gate_bot/app.py index c6b8c44..f1a9bd4 100644 --- a/crypto_monitor_gate_bot/app.py +++ b/crypto_monitor_gate_bot/app.py @@ -54,7 +54,9 @@ from form_submit_lib import check_duplicate_submit, submit_scope_add_key, submit from order_monitor_display_lib import ( apply_order_price_display_fields, enrich_order_display_fields, + stop_is_profit_protecting, tpsl_slot_trigger_price, + tpsl_update_passes_rr_gate, ) from journal_chart_lib import ( JOURNAL_CHART_DEFAULT_LIMIT, @@ -3357,7 +3359,9 @@ def cancel_gate_tpsl_slot(exchange_symbol, slot): exchange.cancel_order(str(oid), exchange_symbol, params) -def _resolve_tpsl_prices_for_manual(direction, live_price, sltp_mode, data): +def _resolve_tpsl_prices_for_manual( + direction, live_price, sltp_mode, data, *, fallback_sl=None, fallback_tp=None +): sltp_mode = (sltp_mode or "price").strip().lower() if sltp_mode == "pct": sl_pct = float(data.get("sl_pct") or 0) @@ -3376,8 +3380,14 @@ def _resolve_tpsl_prices_for_manual(direction, live_price, sltp_mode, data): else: stop_loss = float(data.get("sl") or data.get("stop_loss") or 0) take_profit = float(data.get("tp") or data.get("take_profit") or data.get("tgt") or 0) - if stop_loss <= 0 or take_profit <= 0: - raise ValueError("止盈止损价格须大于 0") + if stop_loss <= 0 and fallback_sl is not None: + stop_loss = float(fallback_sl) + if take_profit <= 0 and fallback_tp is not None: + take_profit = float(fallback_tp) + if stop_loss <= 0: + raise ValueError("止损价格须大于 0") + if take_profit <= 0: + raise ValueError("请填写止盈价格,或保留原计划止盈") return stop_loss, take_profit @@ -6044,20 +6054,32 @@ def api_order_place_tpsl(order_id): return jsonify({"ok": False, "msg": "获取交易所实时价格失败"}), 400 try: sltp_mode = (data.get("sltp_mode") or "price").strip().lower() - stop_loss, take_profit = _resolve_tpsl_prices_for_manual(direction, live_price, sltp_mode, data) + stop_loss, take_profit = _resolve_tpsl_prices_for_manual( + direction, + live_price, + sltp_mode, + data, + fallback_sl=row["stop_loss"], + fallback_tp=row["take_profit"], + ) except Exception as e: conn.close() return jsonify({"ok": False, "msg": str(e)}), 400 - planned_rr = calc_rr_ratio(direction, live_price, stop_loss, take_profit) - if planned_rr is None or planned_rr < MANUAL_MIN_PLANNED_RR: + entry_price = float(row["trigger_price"] or live_price or 0) + rr_ok, rr_err = tpsl_update_passes_rr_gate( + direction, + entry_price, + stop_loss, + take_profit, + MANUAL_MIN_PLANNED_RR, + calc_rr_ratio, + ) + if not rr_ok: conn.close() - rr_txt = f"{planned_rr:.4f}" if planned_rr is not None else "无法计算" - return jsonify( - { - "ok": False, - "msg": f"计划盈亏比 {rr_txt}:1 低于最低要求 {MANUAL_MIN_PLANNED_RR}:1", - } - ), 400 + return jsonify({"ok": False, "msg": rr_err}), 400 + planned_rr = calc_rr_ratio(direction, entry_price, stop_loss, take_profit) + if stop_is_profit_protecting(direction, entry_price, stop_loss): + planned_rr = None try: replace_active_monitor_tpsl_on_exchange(row, stop_loss, take_profit) except Exception as e: diff --git a/crypto_monitor_gate_bot/templates/index.html b/crypto_monitor_gate_bot/templates/index.html index 02f6696..99140f4 100644 --- a/crypto_monitor_gate_bot/templates/index.html +++ b/crypto_monitor_gate_bot/templates/index.html @@ -1623,6 +1623,22 @@ function rejectManualOrderRr(rr){ alert(`计划盈亏比 ${rr === null ? '无效' : rr.toFixed(2)}:1 低于最低要求 ${MANUAL_MIN_PLANNED_RR}:1,已阻止人工下单。`); return true; } +function stopIsProfitProtecting(direction, entry, sl){ + const e = Number(entry), s = Number(sl); + if(!Number.isFinite(e) || !Number.isFinite(s)) return false; + return (direction || "long") === "short" ? s < e : s > e; +} +function entryPriceFromOrderCard(card){ + if(!card) return null; + const raw = card.getAttribute("data-entry"); + if(raw === null || raw === "") return null; + const e = Number(raw); + return Number.isFinite(e) ? e : null; +} +function tpslRrCheckPasses(direction, entry, sl, tp){ + if(stopIsProfitProtecting(direction, entry, sl)) return true; + return !rejectManualOrderRr(calcClientRr(direction, entry, sl, tp)); +} let tpslEntrustMonitorId = null; function formatExTpslLine(role, slot){ const label = role === 'sl' ? '止损' : '止盈'; @@ -1720,16 +1736,28 @@ function submitTpslEntrust(){ }).catch(()=>alert('委托请求失败')); }; if(mode === 'pct'){ post(); return; } - const sl = Number(body.sl), tp = Number(body.tp); - let entry = sl; + let sl = Number(body.sl); + let tp = Number(body.tp); + const planTp = card && card.getAttribute('data-plan-tp'); + if((!Number.isFinite(tp) || tp <= 0) && planTp){ + const pt = Number(planTp); + if(Number.isFinite(pt) && pt > 0) tp = pt; + } + if(!Number.isFinite(sl) || sl <= 0){ alert('请填写止损价格'); return; } + if(!Number.isFinite(tp) || tp <= 0){ alert('请填写止盈价格,或保留原计划止盈'); return; } + let entry = entryPriceFromOrderCard(card); const sym = (card && card.getAttribute('data-symbol')) || ''; - if(!sym){ if(rejectManualOrderRr(calcClientRr(direction, entry, sl, tp))) return; post(); return; } + const finishRr = (entryPx)=>{ + const e = entryPx != null ? entryPx : entry; + if(!tpslRrCheckPasses(direction, e, sl, tp)) return; + post(); + }; + if(entry != null){ finishRr(entry); return; } + if(!sym){ finishRr(sl); return; } fetch(`/api/order_defaults?symbol=${encodeURIComponent(sym)}&direction=${encodeURIComponent(direction)}`) .then(r=>r.json()).then(data=>{ const px = data.last_price || data.price; - if(px) entry = Number(px); - if(rejectManualOrderRr(calcClientRr(direction, entry, sl, tp))) return; - post(); + finishRr(px ? Number(px) : null); }).catch(()=>alert('无法校验盈亏比')); } function relinkOrphanPosition(symbol, direction){ diff --git a/order_monitor_display_lib.py b/order_monitor_display_lib.py index 43b3caf..2decaf3 100644 --- a/order_monitor_display_lib.py +++ b/order_monitor_display_lib.py @@ -46,6 +46,39 @@ def tpsl_slot_trigger_price(slot: Any) -> Optional[float]: return None +def stop_is_profit_protecting(direction: str, entry_price: Any, stop_loss: Any) -> bool: + """ + 止损是否已在盈利侧(保本/锁盈),不再适用「开仓盈亏比」风控。 + 做空:止损 < 成交价;做多:止损 > 成交价。 + """ + entry = _positive_float(entry_price) + sl = _positive_float(stop_loss) + if entry is None or sl is None: + return False + d = (direction or "long").strip().lower() + if d == "short": + return sl < entry + return sl > entry + + +def tpsl_update_passes_rr_gate( + direction: str, + entry_price: Any, + stop_loss: Any, + take_profit: Any, + min_rr: float, + calc_rr_ratio_fn: Callable[..., Optional[float]], +) -> tuple[bool, Optional[str]]: + """持仓委托改价:盈利侧止损跳过最低盈亏比;否则按开仓价几何校验。""" + if stop_is_profit_protecting(direction, entry_price, stop_loss): + return True, None + rr = calc_rr_ratio_fn(direction or "long", entry_price, stop_loss, take_profit) + if rr is not None and rr >= float(min_rr): + return True, None + rr_txt = f"{rr:.4f}" if rr is not None else "无法计算" + return False, f"计划盈亏比 {rr_txt}:1 低于最低要求 {min_rr}:1(盈利侧保本止损不受此限)" + + def is_sl_breakeven_secured(direction: str, entry_price: Any, exchange_sl_price: Any) -> bool: """ 交易所当前止损相对开仓成交价是否已保本。