From a5c6e0c5b6bd5a6f812b57ce84c2a6de4641c31d Mon Sep 17 00:00:00 2001 From: dekun Date: Mon, 8 Jun 2026 20:57:29 +0800 Subject: [PATCH] feat: add fixed RR stop-loss mode for manual live orders on all instances Co-authored-by: Cursor --- crypto_monitor_binance/app.py | 65 ++------- crypto_monitor_binance/templates/index.html | 103 ++++++++++---- crypto_monitor_gate/app.py | 65 ++------- crypto_monitor_gate/templates/index.html | 103 ++++++++++---- crypto_monitor_gate_bot/app.py | 78 +++-------- crypto_monitor_gate_bot/templates/index.html | 115 +++++++++++----- crypto_monitor_okx/app.py | 65 ++------- crypto_monitor_okx/templates/index.html | 103 ++++++++++---- manual_sltp_lib.py | 136 +++++++++++++++++++ tests/test_manual_sltp_lib.py | 32 +++++ 10 files changed, 555 insertions(+), 310 deletions(-) create mode 100644 manual_sltp_lib.py create mode 100644 tests/test_manual_sltp_lib.py diff --git a/crypto_monitor_binance/app.py b/crypto_monitor_binance/app.py index f2a77c0..249e597 100644 --- a/crypto_monitor_binance/app.py +++ b/crypto_monitor_binance/app.py @@ -85,6 +85,11 @@ from key_sl_tp_lib import ( sl_tp_mode_label, sl_tp_plan_summary_text, ) +from manual_sltp_lib import ( + normalize_open_sltp_mode, + resolve_entrust_sltp_prices, + resolve_open_sltp_prices, +) from position_sizing_lib import ( OPEN_SOURCE_KEY_AUTO, OPEN_SOURCE_MANUAL, @@ -3539,27 +3544,7 @@ def cancel_binance_tpsl_slot(exchange_symbol, slot): def _resolve_tpsl_prices_for_manual(direction, live_price, sltp_mode, data): - sltp_mode = (sltp_mode or "price").strip().lower() - if sltp_mode == "pct": - sl_pct = float(data.get("sl_pct") or 0) - tp_pct = float(data.get("tp_pct") or 0) - if sl_pct <= 0 or tp_pct <= 0: - raise ValueError("百分比止盈止损须为正数") - sl_ratio = sl_pct / 100.0 - tp_ratio = tp_pct / 100.0 - entry = float(live_price) - if direction == "short": - stop_loss = entry * (1 + sl_ratio) - take_profit = entry * (1 - tp_ratio) - else: - stop_loss = entry * (1 - sl_ratio) - take_profit = entry * (1 + tp_ratio) - 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") - return stop_loss, take_profit + return resolve_entrust_sltp_prices(direction, live_price, sltp_mode, data) def replace_active_monitor_tpsl_on_exchange(order_row, stop_loss, take_profit): @@ -6927,35 +6912,15 @@ def add_order(): conn.close() flash("获取交易所实时价格失败,请稍后重试") return redirect("/") - sltp_mode = (d.get("sltp_mode") or "price").strip().lower() - if sltp_mode not in ("price", "pct"): - sltp_mode = "price" - if sltp_mode == "pct": - try: - sl_pct = float(d.get("sl_pct") or 0) - tp_pct = float(d.get("tp_pct") or 0) - if sl_pct <= 0 or tp_pct <= 0: - raise ValueError("pct") - sl_ratio = sl_pct / 100.0 - tp_ratio = tp_pct / 100.0 - if direction == "short": - stop_loss = float(live_price) * (1 + sl_ratio) - take_profit = float(live_price) * (1 - tp_ratio) - else: - stop_loss = float(live_price) * (1 - sl_ratio) - take_profit = float(live_price) * (1 + tp_ratio) - except Exception: - conn.close() - flash("百分比止盈止损参数错误,请填写正数百分比") - return redirect("/") - else: - try: - stop_loss = float(d["sl"]) - take_profit = float(d["tgt"]) - except Exception: - conn.close() - flash("价格参数格式错误") - return redirect("/") + sltp_mode = normalize_open_sltp_mode(d.get("sltp_mode")) + try: + stop_loss, take_profit = resolve_open_sltp_prices( + direction, live_price, sltp_mode, d + ) + except ValueError as e: + conn.close() + flash(str(e) or "止盈止损参数错误") + return redirect("/") if stop_loss <= 0 or take_profit <= 0: conn.close() flash("价格参数必须大于0") diff --git a/crypto_monitor_binance/templates/index.html b/crypto_monitor_binance/templates/index.html index 2ce51a2..102ee98 100644 --- a/crypto_monitor_binance/templates/index.html +++ b/crypto_monitor_binance/templates/index.html @@ -474,6 +474,7 @@ @@ -492,7 +493,9 @@ 成交价自动取交易所实时+成交回报 - + + + @@ -1701,6 +1704,49 @@ setTimeout(() => { const MANUAL_MIN_PLANNED_RR = {{ manual_min_planned_rr }}; +const MANUAL_FIXED_RR_DEFAULT = 1.5; +const FIXED_RR_LS_KEY = "manualFixedRr"; +function loadFixedRrPref(){ + try{ + const raw = localStorage.getItem(FIXED_RR_LS_KEY); + const el = document.getElementById("order-fixed-rr"); + if(!el || raw == null || raw === "") return; + const v = Number(raw); + if(Number.isFinite(v) && v > 0) el.value = raw; + }catch(_){} +} +function saveFixedRrPref(){ + try{ + const el = document.getElementById("order-fixed-rr"); + if(el && el.value) localStorage.setItem(FIXED_RR_LS_KEY, el.value); + }catch(_){} +} +function calcTpFromFixedRr(direction, entry, sl, rr){ + const e = Number(entry), s = Number(sl), r = Number(rr); + if(!Number.isFinite(e) || !Number.isFinite(s) || !Number.isFinite(r) || r <= 0) return null; + if(direction === "short"){ + if(s <= e) return null; + return e - (s - e) * r; + } + if(s >= e) return null; + return e + (e - s) * r; +} +function refreshOrderTpPreview(entryPx){ + const mode = (document.getElementById("sltp-mode")||{}).value || "fixed_rr"; + const preview = document.getElementById("order-tp-preview"); + if(!preview) return; + if(mode !== "fixed_rr"){ + preview.style.display = "none"; + return; + } + preview.style.display = ""; + const direction = (document.getElementById("order-direction")||{}).value || "long"; + const sl = Number((document.getElementById("order-sl")||{}).value); + const rr = Number((document.getElementById("order-fixed-rr")||{}).value) || MANUAL_FIXED_RR_DEFAULT; + const entry = entryPx != null && Number.isFinite(Number(entryPx)) ? Number(entryPx) : sl; + const tp = calcTpFromFixedRr(direction, entry, sl, rr); + preview.textContent = tp == null ? "预估止盈:—" : ("预估止盈:" + formatPriceForInput(tp)); +} function calcClientRr(direction, entry, sl, tp){ const e = Number(entry), s = Number(sl), t = Number(tp); if(!Number.isFinite(e) || !Number.isFinite(s) || !Number.isFinite(t)) return null; @@ -1776,15 +1822,11 @@ function submitTpslEntrust(){ if(mode === 'pct'){ body.sl_pct = Number((document.getElementById('tpsl-modal-sl-pct')||{}).value); body.tp_pct = Number((document.getElementById('tpsl-modal-tp-pct')||{}).value); - if(rejectManualOrderRr(calcClientRrFromPct(body.sl_pct, body.tp_pct))) return; }else{ body.sl = (document.getElementById('tpsl-modal-sl')||{}).value; body.tp = (document.getElementById('tpsl-modal-tp')||{}).value; } - const card = document.getElementById(`order-row-${orderId}`); - const direction = (card && card.getAttribute('data-direction')) || 'long'; - const post = ()=>{ - fetch(`/api/order/${orderId}/place_tpsl`, { method:'POST', headers:{'Content-Type':'application/json'}, body: JSON.stringify(body) }) + fetch(`/api/order/${orderId}/place_tpsl`, { method:'POST', headers:{'Content-Type':'application/json'}, body: JSON.stringify(body) }) .then(r=>r.json()).then(data=>{ if(!data.ok){ alert(data.msg || '委托失败'); return; } alert(data.msg || '已提交'); @@ -1792,19 +1834,6 @@ function submitTpslEntrust(){ if(data.exchange_tpsl) paintExchangeTpslRow(orderId, data.exchange_tpsl); refreshPriceSnapshotConditional(); }).catch(()=>alert('委托请求失败')); - }; - if(mode === 'pct'){ post(); return; } - const sl = Number(body.sl), tp = Number(body.tp); - let entry = sl; - const sym = (card && card.getAttribute('data-symbol')) || ''; - if(!sym){ if(rejectManualOrderRr(calcClientRr(direction, entry, sl, tp))) return; post(); 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(); - }).catch(()=>alert('无法校验盈亏比')); } function cancelExchangeTpsl(orderId, role){ const label = role === 'sl' ? '止损' : '止盈'; @@ -1967,6 +1996,8 @@ function refreshOrderDefaults(){ marginEl.value = m; } } + const px = data.last_price || data.price; + if(px) refreshOrderTpPreview(px); }).catch(()=>{}); } @@ -2008,26 +2039,37 @@ if(fullMarginEl){ const sltpModeEl = document.getElementById("sltp-mode"); function toggleSltpMode(){ - const mode = sltpModeEl ? sltpModeEl.value : "price"; + const mode = sltpModeEl ? sltpModeEl.value : "fixed_rr"; const slEl = document.getElementById("order-sl"); const tpEl = document.getElementById("order-tp"); + const fixedRrEl = document.getElementById("order-fixed-rr"); const slPctEl = document.getElementById("order-sl-pct"); const tpPctEl = document.getElementById("order-tp-pct"); if(!slEl || !tpEl || !slPctEl || !tpPctEl){ return; } const pct = mode === "pct"; + const fixed = mode === "fixed_rr"; slEl.style.display = pct ? "none" : ""; - tpEl.style.display = pct ? "none" : ""; + tpEl.style.display = (pct || fixed) ? "none" : ""; + if(fixedRrEl) fixedRrEl.style.display = fixed ? "" : "none"; slEl.required = !pct; - tpEl.required = !pct; + tpEl.required = !pct && !fixed; + if(fixedRrEl) fixedRrEl.required = fixed; slPctEl.style.display = pct ? "" : "none"; tpPctEl.style.display = pct ? "" : "none"; slPctEl.required = pct; tpPctEl.required = pct; + refreshOrderTpPreview(); } if(sltpModeEl){ sltpModeEl.addEventListener("change", toggleSltpMode); + loadFixedRrPref(); toggleSltpMode(); } +["order-sl","order-fixed-rr","order-direction"].forEach(function(id){ + const el = document.getElementById(id); + if(el) el.addEventListener("input", function(){ refreshOrderTpPreview(); }); + if(el) el.addEventListener("change", function(){ refreshOrderTpPreview(); }); +}); refreshAccountSnapshot(); const _journalFormEl = document.getElementById("journal-form"); @@ -2050,8 +2092,23 @@ if(addOrderForm){ ev.preventDefault(); if(window.FormSubmitGuard && FormSubmitGuard.isLocked(addOrderForm)) return; const direction = (document.getElementById("order-direction")||{}).value || "long"; - const mode = (document.getElementById("sltp-mode")||{}).value || "price"; + const mode = (document.getElementById("sltp-mode")||{}).value || "fixed_rr"; const symbol = ((document.getElementById("order-symbol")||{}).value || "").trim(); + if(mode === "fixed_rr"){ + saveFixedRrPref(); + const rr = Number((document.getElementById("order-fixed-rr")||{}).value); + if(!Number.isFinite(rr) || rr <= 0){ + alert("请填写正数盈亏比"); + return; + } + if(window.FormSubmitGuard) FormSubmitGuard.lock(addOrderForm, "校验盈亏比…"); + if(rejectManualOrderRr(rr)){ + if(window.FormSubmitGuard) FormSubmitGuard.unlock(addOrderForm); + return; + } + allowManualOrderSubmit(addOrderForm); + return; + } if(mode === "pct"){ if(window.FormSubmitGuard) FormSubmitGuard.lock(addOrderForm, "校验盈亏比…"); const rr = calcClientRrFromPct( diff --git a/crypto_monitor_gate/app.py b/crypto_monitor_gate/app.py index 874feea..810b674 100644 --- a/crypto_monitor_gate/app.py +++ b/crypto_monitor_gate/app.py @@ -86,6 +86,11 @@ from key_sl_tp_lib import ( sl_tp_mode_label, sl_tp_plan_summary_text, ) +from manual_sltp_lib import ( + normalize_open_sltp_mode, + resolve_entrust_sltp_prices, + resolve_open_sltp_prices, +) from position_sizing_lib import ( OPEN_SOURCE_KEY_AUTO, OPEN_SOURCE_MANUAL, @@ -3293,27 +3298,7 @@ def cancel_gate_tpsl_slot(exchange_symbol, slot): def _resolve_tpsl_prices_for_manual(direction, live_price, sltp_mode, data): - sltp_mode = (sltp_mode or "price").strip().lower() - if sltp_mode == "pct": - sl_pct = float(data.get("sl_pct") or 0) - tp_pct = float(data.get("tp_pct") or 0) - if sl_pct <= 0 or tp_pct <= 0: - raise ValueError("百分比止盈止损须为正数") - sl_ratio = sl_pct / 100.0 - tp_ratio = tp_pct / 100.0 - entry = float(live_price) - if direction == "short": - stop_loss = entry * (1 + sl_ratio) - take_profit = entry * (1 - tp_ratio) - else: - stop_loss = entry * (1 - sl_ratio) - take_profit = entry * (1 + tp_ratio) - 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") - return stop_loss, take_profit + return resolve_entrust_sltp_prices(direction, live_price, sltp_mode, data) def replace_active_monitor_tpsl_on_exchange(order_row, stop_loss, take_profit): @@ -6984,35 +6969,15 @@ def add_order(): lp_r = round_price_to_exchange(exchange_symbol, live_price) if lp_r is not None: live_price = lp_r - sltp_mode = (d.get("sltp_mode") or "price").strip().lower() - if sltp_mode not in ("price", "pct"): - sltp_mode = "price" - if sltp_mode == "pct": - try: - sl_pct = float(d.get("sl_pct") or 0) - tp_pct = float(d.get("tp_pct") or 0) - if sl_pct <= 0 or tp_pct <= 0: - raise ValueError("pct") - sl_ratio = sl_pct / 100.0 - tp_ratio = tp_pct / 100.0 - if direction == "short": - stop_loss = float(live_price) * (1 + sl_ratio) - take_profit = float(live_price) * (1 - tp_ratio) - else: - stop_loss = float(live_price) * (1 - sl_ratio) - take_profit = float(live_price) * (1 + tp_ratio) - except Exception: - conn.close() - flash("百分比止盈止损参数错误,请填写正数百分比") - return redirect("/") - else: - try: - stop_loss = float(d["sl"]) - take_profit = float(d["tgt"]) - except Exception: - conn.close() - flash("价格参数格式错误") - return redirect("/") + sltp_mode = normalize_open_sltp_mode(d.get("sltp_mode")) + try: + stop_loss, take_profit = resolve_open_sltp_prices( + direction, live_price, sltp_mode, d + ) + except ValueError as e: + conn.close() + flash(str(e) or "止盈止损参数错误") + return redirect("/") if stop_loss <= 0 or take_profit <= 0: conn.close() flash("价格参数必须大于0") diff --git a/crypto_monitor_gate/templates/index.html b/crypto_monitor_gate/templates/index.html index 293adae..d05aab1 100644 --- a/crypto_monitor_gate/templates/index.html +++ b/crypto_monitor_gate/templates/index.html @@ -458,6 +458,7 @@ @@ -476,7 +477,9 @@ 成交价自动取交易所实时+成交回报 - + + + @@ -1685,6 +1688,49 @@ setTimeout(() => { const MANUAL_MIN_PLANNED_RR = {{ manual_min_planned_rr }}; +const MANUAL_FIXED_RR_DEFAULT = 1.5; +const FIXED_RR_LS_KEY = "manualFixedRr"; +function loadFixedRrPref(){ + try{ + const raw = localStorage.getItem(FIXED_RR_LS_KEY); + const el = document.getElementById("order-fixed-rr"); + if(!el || raw == null || raw === "") return; + const v = Number(raw); + if(Number.isFinite(v) && v > 0) el.value = raw; + }catch(_){} +} +function saveFixedRrPref(){ + try{ + const el = document.getElementById("order-fixed-rr"); + if(el && el.value) localStorage.setItem(FIXED_RR_LS_KEY, el.value); + }catch(_){} +} +function calcTpFromFixedRr(direction, entry, sl, rr){ + const e = Number(entry), s = Number(sl), r = Number(rr); + if(!Number.isFinite(e) || !Number.isFinite(s) || !Number.isFinite(r) || r <= 0) return null; + if(direction === "short"){ + if(s <= e) return null; + return e - (s - e) * r; + } + if(s >= e) return null; + return e + (e - s) * r; +} +function refreshOrderTpPreview(entryPx){ + const mode = (document.getElementById("sltp-mode")||{}).value || "fixed_rr"; + const preview = document.getElementById("order-tp-preview"); + if(!preview) return; + if(mode !== "fixed_rr"){ + preview.style.display = "none"; + return; + } + preview.style.display = ""; + const direction = (document.getElementById("order-direction")||{}).value || "long"; + const sl = Number((document.getElementById("order-sl")||{}).value); + const rr = Number((document.getElementById("order-fixed-rr")||{}).value) || MANUAL_FIXED_RR_DEFAULT; + const entry = entryPx != null && Number.isFinite(Number(entryPx)) ? Number(entryPx) : sl; + const tp = calcTpFromFixedRr(direction, entry, sl, rr); + preview.textContent = tp == null ? "预估止盈:—" : ("预估止盈:" + formatPriceForInput(tp)); +} function calcClientRr(direction, entry, sl, tp){ const e = Number(entry), s = Number(sl), t = Number(tp); if(!Number.isFinite(e) || !Number.isFinite(s) || !Number.isFinite(t)) return null; @@ -1760,15 +1806,11 @@ function submitTpslEntrust(){ if(mode === 'pct'){ body.sl_pct = Number((document.getElementById('tpsl-modal-sl-pct')||{}).value); body.tp_pct = Number((document.getElementById('tpsl-modal-tp-pct')||{}).value); - if(rejectManualOrderRr(calcClientRrFromPct(body.sl_pct, body.tp_pct))) return; }else{ body.sl = (document.getElementById('tpsl-modal-sl')||{}).value; body.tp = (document.getElementById('tpsl-modal-tp')||{}).value; } - const card = document.getElementById(`order-row-${orderId}`); - const direction = (card && card.getAttribute('data-direction')) || 'long'; - const post = ()=>{ - fetch(`/api/order/${orderId}/place_tpsl`, { method:'POST', headers:{'Content-Type':'application/json'}, body: JSON.stringify(body) }) + fetch(`/api/order/${orderId}/place_tpsl`, { method:'POST', headers:{'Content-Type':'application/json'}, body: JSON.stringify(body) }) .then(r=>r.json()).then(data=>{ if(!data.ok){ alert(data.msg || '委托失败'); return; } alert(data.msg || '已提交'); @@ -1776,19 +1818,6 @@ function submitTpslEntrust(){ if(data.exchange_tpsl) paintExchangeTpslRow(orderId, data.exchange_tpsl); refreshPriceSnapshotConditional(); }).catch(()=>alert('委托请求失败')); - }; - if(mode === 'pct'){ post(); return; } - const sl = Number(body.sl), tp = Number(body.tp); - let entry = sl; - const sym = (card && card.getAttribute('data-symbol')) || ''; - if(!sym){ if(rejectManualOrderRr(calcClientRr(direction, entry, sl, tp))) return; post(); 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(); - }).catch(()=>alert('无法校验盈亏比')); } function cancelExchangeTpsl(orderId, role){ const label = role === 'sl' ? '止损' : '止盈'; @@ -1951,6 +1980,8 @@ function refreshOrderDefaults(){ marginEl.value = m; } } + const px = data.last_price || data.price; + if(px) refreshOrderTpPreview(px); }).catch(()=>{}); } @@ -1992,26 +2023,37 @@ if(fullMarginEl){ const sltpModeEl = document.getElementById("sltp-mode"); function toggleSltpMode(){ - const mode = sltpModeEl ? sltpModeEl.value : "price"; + const mode = sltpModeEl ? sltpModeEl.value : "fixed_rr"; const slEl = document.getElementById("order-sl"); const tpEl = document.getElementById("order-tp"); + const fixedRrEl = document.getElementById("order-fixed-rr"); const slPctEl = document.getElementById("order-sl-pct"); const tpPctEl = document.getElementById("order-tp-pct"); if(!slEl || !tpEl || !slPctEl || !tpPctEl){ return; } const pct = mode === "pct"; + const fixed = mode === "fixed_rr"; slEl.style.display = pct ? "none" : ""; - tpEl.style.display = pct ? "none" : ""; + tpEl.style.display = (pct || fixed) ? "none" : ""; + if(fixedRrEl) fixedRrEl.style.display = fixed ? "" : "none"; slEl.required = !pct; - tpEl.required = !pct; + tpEl.required = !pct && !fixed; + if(fixedRrEl) fixedRrEl.required = fixed; slPctEl.style.display = pct ? "" : "none"; tpPctEl.style.display = pct ? "" : "none"; slPctEl.required = pct; tpPctEl.required = pct; + refreshOrderTpPreview(); } if(sltpModeEl){ sltpModeEl.addEventListener("change", toggleSltpMode); + loadFixedRrPref(); toggleSltpMode(); } +["order-sl","order-fixed-rr","order-direction"].forEach(function(id){ + const el = document.getElementById(id); + if(el) el.addEventListener("input", function(){ refreshOrderTpPreview(); }); + if(el) el.addEventListener("change", function(){ refreshOrderTpPreview(); }); +}); refreshAccountSnapshot(); const _journalFormEl = document.getElementById("journal-form"); @@ -2034,8 +2076,23 @@ if(addOrderForm){ ev.preventDefault(); if(window.FormSubmitGuard && FormSubmitGuard.isLocked(addOrderForm)) return; const direction = (document.getElementById("order-direction")||{}).value || "long"; - const mode = (document.getElementById("sltp-mode")||{}).value || "price"; + const mode = (document.getElementById("sltp-mode")||{}).value || "fixed_rr"; const symbol = ((document.getElementById("order-symbol")||{}).value || "").trim(); + if(mode === "fixed_rr"){ + saveFixedRrPref(); + const rr = Number((document.getElementById("order-fixed-rr")||{}).value); + if(!Number.isFinite(rr) || rr <= 0){ + alert("请填写正数盈亏比"); + return; + } + if(window.FormSubmitGuard) FormSubmitGuard.lock(addOrderForm, "校验盈亏比…"); + if(rejectManualOrderRr(rr)){ + if(window.FormSubmitGuard) FormSubmitGuard.unlock(addOrderForm); + return; + } + allowManualOrderSubmit(addOrderForm); + return; + } if(mode === "pct"){ if(window.FormSubmitGuard) FormSubmitGuard.lock(addOrderForm, "校验盈亏比…"); const rr = calcClientRrFromPct( diff --git a/crypto_monitor_gate_bot/app.py b/crypto_monitor_gate_bot/app.py index 5580a27..2f51e42 100644 --- a/crypto_monitor_gate_bot/app.py +++ b/crypto_monitor_gate_bot/app.py @@ -40,6 +40,11 @@ from ai_review_lib import ( collect_images_for_ai_review, journal_row_lines_for_ai, ) +from manual_sltp_lib import ( + normalize_open_sltp_mode, + resolve_entrust_sltp_prices, + resolve_open_sltp_prices, +) from position_sizing_lib import ( assert_open_source_allowed, compute_full_margin_sizing, @@ -3351,33 +3356,14 @@ def cancel_gate_tpsl_slot(exchange_symbol, slot): 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) - tp_pct = float(data.get("tp_pct") or 0) - if sl_pct <= 0 or tp_pct <= 0: - raise ValueError("百分比止盈止损须为正数") - sl_ratio = sl_pct / 100.0 - tp_ratio = tp_pct / 100.0 - entry = float(live_price) - if direction == "short": - stop_loss = entry * (1 + sl_ratio) - take_profit = entry * (1 - tp_ratio) - else: - stop_loss = entry * (1 - sl_ratio) - take_profit = entry * (1 + tp_ratio) - 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 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 + return resolve_entrust_sltp_prices( + direction, + live_price, + sltp_mode, + data, + fallback_sl=fallback_sl, + fallback_tp=fallback_tp, + ) def cancel_all_open_orders_for_symbol(exchange_symbol): @@ -6345,35 +6331,15 @@ def add_order(): conn.close() flash("获取交易所实时价格失败,请稍后重试") return redirect("/") - sltp_mode = (d.get("sltp_mode") or "price").strip().lower() - if sltp_mode not in ("price", "pct"): - sltp_mode = "price" - if sltp_mode == "pct": - try: - sl_pct = float(d.get("sl_pct") or 0) - tp_pct = float(d.get("tp_pct") or 0) - if sl_pct <= 0 or tp_pct <= 0: - raise ValueError("pct") - sl_ratio = sl_pct / 100.0 - tp_ratio = tp_pct / 100.0 - if direction == "short": - stop_loss = float(live_price) * (1 + sl_ratio) - take_profit = float(live_price) * (1 - tp_ratio) - else: - stop_loss = float(live_price) * (1 - sl_ratio) - take_profit = float(live_price) * (1 + tp_ratio) - except Exception: - conn.close() - flash("百分比止盈止损参数错误,请填写正数百分比") - return redirect("/") - else: - try: - stop_loss = float(d["sl"]) - take_profit = float(d["tgt"]) - except Exception: - conn.close() - flash("价格参数格式错误") - return redirect("/") + sltp_mode = normalize_open_sltp_mode(d.get("sltp_mode")) + try: + stop_loss, take_profit = resolve_open_sltp_prices( + direction, live_price, sltp_mode, d + ) + except ValueError as e: + conn.close() + flash(str(e) or "止盈止损参数错误") + return redirect("/") if stop_loss <= 0 or take_profit <= 0: conn.close() flash("价格参数必须大于0") diff --git a/crypto_monitor_gate_bot/templates/index.html b/crypto_monitor_gate_bot/templates/index.html index 4f1a9b3..c8bc76f 100644 --- a/crypto_monitor_gate_bot/templates/index.html +++ b/crypto_monitor_gate_bot/templates/index.html @@ -399,6 +399,7 @@ @@ -417,7 +418,9 @@ 成交价自动取交易所实时+成交回报 - + + + @@ -1616,6 +1619,49 @@ setTimeout(() => { let latestAvailableUsdt = null; const lastPriceMap = {}; const MANUAL_MIN_PLANNED_RR = {{ manual_min_planned_rr }}; +const MANUAL_FIXED_RR_DEFAULT = 1.5; +const FIXED_RR_LS_KEY = "manualFixedRr"; +function loadFixedRrPref(){ + try{ + const raw = localStorage.getItem(FIXED_RR_LS_KEY); + const el = document.getElementById("order-fixed-rr"); + if(!el || raw == null || raw === "") return; + const v = Number(raw); + if(Number.isFinite(v) && v > 0) el.value = raw; + }catch(_){} +} +function saveFixedRrPref(){ + try{ + const el = document.getElementById("order-fixed-rr"); + if(el && el.value) localStorage.setItem(FIXED_RR_LS_KEY, el.value); + }catch(_){} +} +function calcTpFromFixedRr(direction, entry, sl, rr){ + const e = Number(entry), s = Number(sl), r = Number(rr); + if(!Number.isFinite(e) || !Number.isFinite(s) || !Number.isFinite(r) || r <= 0) return null; + if(direction === "short"){ + if(s <= e) return null; + return e - (s - e) * r; + } + if(s >= e) return null; + return e + (e - s) * r; +} +function refreshOrderTpPreview(entryPx){ + const mode = (document.getElementById("sltp-mode")||{}).value || "fixed_rr"; + const preview = document.getElementById("order-tp-preview"); + if(!preview) return; + if(mode !== "fixed_rr"){ + preview.style.display = "none"; + return; + } + preview.style.display = ""; + const direction = (document.getElementById("order-direction")||{}).value || "long"; + const sl = Number((document.getElementById("order-sl")||{}).value); + const rr = Number((document.getElementById("order-fixed-rr")||{}).value) || MANUAL_FIXED_RR_DEFAULT; + const entry = entryPx != null && Number.isFinite(Number(entryPx)) ? Number(entryPx) : sl; + const tp = calcTpFromFixedRr(direction, entry, sl, rr); + preview.textContent = tp == null ? "预估止盈:—" : ("预估止盈:" + formatPriceForInput(tp)); +} function calcClientRr(direction, entry, sl, tp){ const e = Number(entry), s = Number(sl), t = Number(tp); if(!Number.isFinite(e) || !Number.isFinite(s) || !Number.isFinite(t)) return null; @@ -1724,15 +1770,11 @@ function submitTpslEntrust(){ if(mode === 'pct'){ body.sl_pct = Number((document.getElementById('tpsl-modal-sl-pct')||{}).value); body.tp_pct = Number((document.getElementById('tpsl-modal-tp-pct')||{}).value); - if(rejectManualOrderRr(calcClientRrFromPct(body.sl_pct, body.tp_pct))) return; }else{ body.sl = (document.getElementById('tpsl-modal-sl')||{}).value; body.tp = (document.getElementById('tpsl-modal-tp')||{}).value; } - const card = document.getElementById(`order-row-${orderId}`); - const direction = (card && card.getAttribute('data-direction')) || 'long'; - const post = ()=>{ - fetch(`/api/order/${orderId}/place_tpsl`, { method:'POST', headers:{'Content-Type':'application/json'}, body: JSON.stringify(body) }) + fetch(`/api/order/${orderId}/place_tpsl`, { method:'POST', headers:{'Content-Type':'application/json'}, body: JSON.stringify(body) }) .then(r=>r.json()).then(data=>{ if(!data.ok){ alert(data.msg || '委托失败'); return; } alert(data.msg || '已提交'); @@ -1747,31 +1789,6 @@ function submitTpslEntrust(){ }); refreshPriceSnapshotConditional(); }).catch(()=>alert('委托请求失败')); - }; - if(mode === 'pct'){ post(); return; } - 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')) || ''; - 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; - finishRr(px ? Number(px) : null); - }).catch(()=>alert('无法校验盈亏比')); } function relinkOrphanPosition(symbol, direction){ if(!confirm(`恢复 ${symbol} ${direction} 的本地监控?(接回最近一条已停止记录)`)) return; @@ -1937,6 +1954,8 @@ function refreshOrderDefaults(){ marginEl.value = m; } } + const px = data.last_price || data.price; + if(px) refreshOrderTpPreview(px); }).catch(()=>{}); } @@ -1979,26 +1998,37 @@ if(fullMarginEl){ const sltpModeEl = document.getElementById("sltp-mode"); function toggleSltpMode(){ - const mode = sltpModeEl ? sltpModeEl.value : "price"; + const mode = sltpModeEl ? sltpModeEl.value : "fixed_rr"; const slEl = document.getElementById("order-sl"); const tpEl = document.getElementById("order-tp"); + const fixedRrEl = document.getElementById("order-fixed-rr"); const slPctEl = document.getElementById("order-sl-pct"); const tpPctEl = document.getElementById("order-tp-pct"); if(!slEl || !tpEl || !slPctEl || !tpPctEl){ return; } const pct = mode === "pct"; + const fixed = mode === "fixed_rr"; slEl.style.display = pct ? "none" : ""; - tpEl.style.display = pct ? "none" : ""; + tpEl.style.display = (pct || fixed) ? "none" : ""; + if(fixedRrEl) fixedRrEl.style.display = fixed ? "" : "none"; slEl.required = !pct; - tpEl.required = !pct; + tpEl.required = !pct && !fixed; + if(fixedRrEl) fixedRrEl.required = fixed; slPctEl.style.display = pct ? "" : "none"; tpPctEl.style.display = pct ? "" : "none"; slPctEl.required = pct; tpPctEl.required = pct; + refreshOrderTpPreview(); } if(sltpModeEl){ sltpModeEl.addEventListener("change", toggleSltpMode); + loadFixedRrPref(); toggleSltpMode(); } +["order-sl","order-fixed-rr","order-direction"].forEach(function(id){ + const el = document.getElementById(id); + if(el) el.addEventListener("input", function(){ refreshOrderTpPreview(); }); + if(el) el.addEventListener("change", function(){ refreshOrderTpPreview(); }); +}); refreshAccountSnapshot(); const _journalFormEl = document.getElementById("journal-form"); @@ -2020,8 +2050,23 @@ if(addOrderForm){ ev.preventDefault(); if(window.FormSubmitGuard && FormSubmitGuard.isLocked(addOrderForm)) return; const direction = (document.getElementById("order-direction")||{}).value || "long"; - const mode = (document.getElementById("sltp-mode")||{}).value || "price"; + const mode = (document.getElementById("sltp-mode")||{}).value || "fixed_rr"; const symbol = ((document.getElementById("order-symbol")||{}).value || "").trim(); + if(mode === "fixed_rr"){ + saveFixedRrPref(); + const rr = Number((document.getElementById("order-fixed-rr")||{}).value); + if(!Number.isFinite(rr) || rr <= 0){ + alert("请填写正数盈亏比"); + return; + } + if(window.FormSubmitGuard) FormSubmitGuard.lock(addOrderForm, "校验盈亏比…"); + if(rejectManualOrderRr(rr)){ + if(window.FormSubmitGuard) FormSubmitGuard.unlock(addOrderForm); + return; + } + allowManualOrderSubmit(addOrderForm); + return; + } if(mode === "pct"){ if(window.FormSubmitGuard) FormSubmitGuard.lock(addOrderForm, "校验盈亏比…"); const rr = calcClientRrFromPct( diff --git a/crypto_monitor_okx/app.py b/crypto_monitor_okx/app.py index e792e4e..29519c3 100644 --- a/crypto_monitor_okx/app.py +++ b/crypto_monitor_okx/app.py @@ -86,6 +86,11 @@ from key_sl_tp_lib import ( sl_tp_mode_label, sl_tp_plan_summary_text, ) +from manual_sltp_lib import ( + normalize_open_sltp_mode, + resolve_entrust_sltp_prices, + resolve_open_sltp_prices, +) from position_sizing_lib import ( OPEN_SOURCE_KEY_AUTO, OPEN_SOURCE_MANUAL, @@ -2803,27 +2808,7 @@ def parse_ccxt_position_metrics(position, order_leverage=None): def _resolve_tpsl_prices_for_manual(direction, live_price, sltp_mode, data): - sltp_mode = (sltp_mode or "price").strip().lower() - if sltp_mode == "pct": - sl_pct = float(data.get("sl_pct") or 0) - tp_pct = float(data.get("tp_pct") or 0) - if sl_pct <= 0 or tp_pct <= 0: - raise ValueError("百分比止盈止损须为正数") - sl_ratio = sl_pct / 100.0 - tp_ratio = tp_pct / 100.0 - entry = float(live_price) - if direction == "short": - stop_loss = entry * (1 + sl_ratio) - take_profit = entry * (1 - tp_ratio) - else: - stop_loss = entry * (1 - sl_ratio) - take_profit = entry * (1 + tp_ratio) - 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") - return stop_loss, take_profit + return resolve_entrust_sltp_prices(direction, live_price, sltp_mode, data) def _okx_tpsl_slot_build(exchange_symbol, order_id, trigger_price, order_type=""): @@ -6648,35 +6633,15 @@ def add_order(): conn.close() flash("获取交易所实时价格失败,请稍后重试") return redirect("/trade") - sltp_mode = (d.get("sltp_mode") or "price").strip().lower() - if sltp_mode not in ("price", "pct"): - sltp_mode = "price" - if sltp_mode == "pct": - try: - sl_pct = float(d.get("sl_pct") or 0) - tp_pct = float(d.get("tp_pct") or 0) - if sl_pct <= 0 or tp_pct <= 0: - raise ValueError("pct") - sl_ratio = sl_pct / 100.0 - tp_ratio = tp_pct / 100.0 - if direction == "short": - stop_loss = float(live_price) * (1 + sl_ratio) - take_profit = float(live_price) * (1 - tp_ratio) - else: - stop_loss = float(live_price) * (1 - sl_ratio) - take_profit = float(live_price) * (1 + tp_ratio) - except Exception: - conn.close() - flash("百分比止盈止损参数错误,请填写正数百分比") - return redirect("/trade") - else: - try: - stop_loss = float(d["sl"]) - take_profit = float(d["tgt"]) - except Exception: - conn.close() - flash("价格参数格式错误") - return redirect("/trade") + sltp_mode = normalize_open_sltp_mode(d.get("sltp_mode")) + try: + stop_loss, take_profit = resolve_open_sltp_prices( + direction, live_price, sltp_mode, d + ) + except ValueError as e: + conn.close() + flash(str(e) or "止盈止损参数错误") + return redirect("/trade") if stop_loss <= 0 or take_profit <= 0: conn.close() flash("价格参数必须大于0") diff --git a/crypto_monitor_okx/templates/index.html b/crypto_monitor_okx/templates/index.html index b57dc6f..039802b 100644 --- a/crypto_monitor_okx/templates/index.html +++ b/crypto_monitor_okx/templates/index.html @@ -483,6 +483,7 @@ @@ -501,7 +502,9 @@ 成交价自动取交易所实时+成交回报 - + + + @@ -1711,6 +1714,49 @@ setTimeout(() => { const MANUAL_MIN_PLANNED_RR = {{ manual_min_planned_rr }}; +const MANUAL_FIXED_RR_DEFAULT = 1.5; +const FIXED_RR_LS_KEY = "manualFixedRr"; +function loadFixedRrPref(){ + try{ + const raw = localStorage.getItem(FIXED_RR_LS_KEY); + const el = document.getElementById("order-fixed-rr"); + if(!el || raw == null || raw === "") return; + const v = Number(raw); + if(Number.isFinite(v) && v > 0) el.value = raw; + }catch(_){} +} +function saveFixedRrPref(){ + try{ + const el = document.getElementById("order-fixed-rr"); + if(el && el.value) localStorage.setItem(FIXED_RR_LS_KEY, el.value); + }catch(_){} +} +function calcTpFromFixedRr(direction, entry, sl, rr){ + const e = Number(entry), s = Number(sl), r = Number(rr); + if(!Number.isFinite(e) || !Number.isFinite(s) || !Number.isFinite(r) || r <= 0) return null; + if(direction === "short"){ + if(s <= e) return null; + return e - (s - e) * r; + } + if(s >= e) return null; + return e + (e - s) * r; +} +function refreshOrderTpPreview(entryPx){ + const mode = (document.getElementById("sltp-mode")||{}).value || "fixed_rr"; + const preview = document.getElementById("order-tp-preview"); + if(!preview) return; + if(mode !== "fixed_rr"){ + preview.style.display = "none"; + return; + } + preview.style.display = ""; + const direction = (document.getElementById("order-direction")||{}).value || "long"; + const sl = Number((document.getElementById("order-sl")||{}).value); + const rr = Number((document.getElementById("order-fixed-rr")||{}).value) || MANUAL_FIXED_RR_DEFAULT; + const entry = entryPx != null && Number.isFinite(Number(entryPx)) ? Number(entryPx) : sl; + const tp = calcTpFromFixedRr(direction, entry, sl, rr); + preview.textContent = tp == null ? "预估止盈:—" : ("预估止盈:" + formatPriceForInput(tp)); +} function calcClientRr(direction, entry, sl, tp){ const e = Number(entry), s = Number(sl), t = Number(tp); if(!Number.isFinite(e) || !Number.isFinite(s) || !Number.isFinite(t)) return null; @@ -1786,15 +1832,11 @@ function submitTpslEntrust(){ if(mode === 'pct'){ body.sl_pct = Number((document.getElementById('tpsl-modal-sl-pct')||{}).value); body.tp_pct = Number((document.getElementById('tpsl-modal-tp-pct')||{}).value); - if(rejectManualOrderRr(calcClientRrFromPct(body.sl_pct, body.tp_pct))) return; }else{ body.sl = (document.getElementById('tpsl-modal-sl')||{}).value; body.tp = (document.getElementById('tpsl-modal-tp')||{}).value; } - const card = document.getElementById(`order-row-${orderId}`); - const direction = (card && card.getAttribute('data-direction')) || 'long'; - const post = ()=>{ - fetch(`/api/order/${orderId}/place_tpsl`, { method:'POST', headers:{'Content-Type':'application/json'}, body: JSON.stringify(body) }) + fetch(`/api/order/${orderId}/place_tpsl`, { method:'POST', headers:{'Content-Type':'application/json'}, body: JSON.stringify(body) }) .then(r=>r.json()).then(data=>{ if(!data.ok){ alert(data.msg || '委托失败'); return; } alert(data.msg || '已提交'); @@ -1802,19 +1844,6 @@ function submitTpslEntrust(){ if(data.exchange_tpsl) paintExchangeTpslRow(orderId, data.exchange_tpsl); refreshPriceSnapshotConditional(); }).catch(()=>alert('委托请求失败')); - }; - if(mode === 'pct'){ post(); return; } - const sl = Number(body.sl), tp = Number(body.tp); - let entry = sl; - const sym = (card && card.getAttribute('data-symbol')) || ''; - if(!sym){ if(rejectManualOrderRr(calcClientRr(direction, entry, sl, tp))) return; post(); 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(); - }).catch(()=>alert('无法校验盈亏比')); } function cancelExchangeTpsl(orderId, role){ const label = role === 'sl' ? '止损' : '止盈'; @@ -1977,6 +2006,8 @@ function refreshOrderDefaults(){ marginEl.value = m; } } + const px = data.last_price || data.price; + if(px) refreshOrderTpPreview(px); }).catch(()=>{}); } @@ -2050,26 +2081,37 @@ if(fullMarginEl){ const sltpModeEl = document.getElementById("sltp-mode"); function toggleSltpMode(){ - const mode = sltpModeEl ? sltpModeEl.value : "price"; + const mode = sltpModeEl ? sltpModeEl.value : "fixed_rr"; const slEl = document.getElementById("order-sl"); const tpEl = document.getElementById("order-tp"); + const fixedRrEl = document.getElementById("order-fixed-rr"); const slPctEl = document.getElementById("order-sl-pct"); const tpPctEl = document.getElementById("order-tp-pct"); if(!slEl || !tpEl || !slPctEl || !tpPctEl){ return; } const pct = mode === "pct"; + const fixed = mode === "fixed_rr"; slEl.style.display = pct ? "none" : ""; - tpEl.style.display = pct ? "none" : ""; + tpEl.style.display = (pct || fixed) ? "none" : ""; + if(fixedRrEl) fixedRrEl.style.display = fixed ? "" : "none"; slEl.required = !pct; - tpEl.required = !pct; + tpEl.required = !pct && !fixed; + if(fixedRrEl) fixedRrEl.required = fixed; slPctEl.style.display = pct ? "" : "none"; tpPctEl.style.display = pct ? "" : "none"; slPctEl.required = pct; tpPctEl.required = pct; + refreshOrderTpPreview(); } if(sltpModeEl){ sltpModeEl.addEventListener("change", toggleSltpMode); + loadFixedRrPref(); toggleSltpMode(); } +["order-sl","order-fixed-rr","order-direction"].forEach(function(id){ + const el = document.getElementById(id); + if(el) el.addEventListener("input", function(){ refreshOrderTpPreview(); }); + if(el) el.addEventListener("change", function(){ refreshOrderTpPreview(); }); +}); refreshAccountSnapshot(); const _journalFormEl = document.getElementById("journal-form"); @@ -2092,8 +2134,23 @@ if(addOrderForm){ ev.preventDefault(); if(window.FormSubmitGuard && FormSubmitGuard.isLocked(addOrderForm)) return; const direction = (document.getElementById("order-direction")||{}).value || "long"; - const mode = (document.getElementById("sltp-mode")||{}).value || "price"; + const mode = (document.getElementById("sltp-mode")||{}).value || "fixed_rr"; const symbol = ((document.getElementById("order-symbol")||{}).value || "").trim(); + if(mode === "fixed_rr"){ + saveFixedRrPref(); + const rr = Number((document.getElementById("order-fixed-rr")||{}).value); + if(!Number.isFinite(rr) || rr <= 0){ + alert("请填写正数盈亏比"); + return; + } + if(window.FormSubmitGuard) FormSubmitGuard.lock(addOrderForm, "校验盈亏比…"); + if(rejectManualOrderRr(rr)){ + if(window.FormSubmitGuard) FormSubmitGuard.unlock(addOrderForm); + return; + } + allowManualOrderSubmit(addOrderForm); + return; + } if(mode === "pct"){ if(window.FormSubmitGuard) FormSubmitGuard.lock(addOrderForm, "校验盈亏比…"); const rr = calcClientRrFromPct( diff --git a/manual_sltp_lib.py b/manual_sltp_lib.py new file mode 100644 index 0000000..5ca5185 --- /dev/null +++ b/manual_sltp_lib.py @@ -0,0 +1,136 @@ +"""实盘人工下单:止盈止损模式(价格 / 百分比 / 固定盈亏比)。""" +from __future__ import annotations + +from typing import Any, Optional, Tuple + +MANUAL_FIXED_RR_DEFAULT = 1.5 + +SLTP_MODE_PRICE = "price" +SLTP_MODE_PCT = "pct" +SLTP_MODE_FIXED_RR = "fixed_rr" + +OPEN_SLTP_MODES = frozenset({SLTP_MODE_PRICE, SLTP_MODE_PCT, SLTP_MODE_FIXED_RR}) +ENTRUST_SLTP_MODES = frozenset({SLTP_MODE_PRICE, SLTP_MODE_PCT}) + + +def normalize_open_sltp_mode(raw: Optional[str]) -> str: + mode = (raw or SLTP_MODE_FIXED_RR).strip().lower() + if mode in OPEN_SLTP_MODES: + return mode + return SLTP_MODE_PRICE + + +def normalize_entrust_sltp_mode(raw: Optional[str]) -> str: + mode = (raw or SLTP_MODE_PRICE).strip().lower() + if mode in ENTRUST_SLTP_MODES: + return mode + return SLTP_MODE_PRICE + + +def parse_fixed_rr(raw: Any, *, default: float = MANUAL_FIXED_RR_DEFAULT) -> float: + try: + v = float(raw) + if v > 0: + return v + except (TypeError, ValueError): + pass + return float(default) + + +def calc_tp_from_fixed_rr( + direction: str, + entry_price: float, + stop_loss: float, + rr_ratio: float, +) -> float: + entry = float(entry_price) + sl = float(stop_loss) + rr = float(rr_ratio) + if entry <= 0 or sl <= 0 or rr <= 0: + raise ValueError("固定盈亏比参数无效") + side = (direction or "long").strip().lower() + if side == "short": + risk = sl - entry + if risk <= 0: + raise ValueError("止损方向不合法:做空时止损须高于入场价") + return entry - risk * rr + risk = entry - sl + if risk <= 0: + raise ValueError("止损方向不合法:做多时止损须低于入场价") + return entry + risk * rr + + +def _resolve_pct_sltp(direction: str, live_price: float, data: dict[str, Any]) -> Tuple[float, float]: + sl_pct = float(data.get("sl_pct") or 0) + tp_pct = float(data.get("tp_pct") or 0) + if sl_pct <= 0 or tp_pct <= 0: + raise ValueError("百分比止盈止损须为正数") + sl_ratio = sl_pct / 100.0 + tp_ratio = tp_pct / 100.0 + entry = float(live_price) + if (direction or "long").strip().lower() == "short": + stop_loss = entry * (1 + sl_ratio) + take_profit = entry * (1 - tp_ratio) + else: + stop_loss = entry * (1 - sl_ratio) + take_profit = entry * (1 + tp_ratio) + return stop_loss, take_profit + + +def _resolve_price_sltp( + data: dict[str, Any], + *, + fallback_sl: Optional[float] = None, + fallback_tp: Optional[float] = None, + require_tp: bool = True, +) -> Tuple[float, float]: + 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 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 require_tp else "请填写止损价格") + if require_tp and take_profit <= 0: + raise ValueError("止盈止损价格须大于 0" if fallback_tp is None else "请填写止盈价格,或保留原计划止盈") + return stop_loss, take_profit + + +def resolve_open_sltp_prices( + direction: str, + live_price: float, + sltp_mode: Optional[str], + data: dict[str, Any], +) -> Tuple[float, float]: + """新开仓 /add_order:支持 price、pct、fixed_rr。""" + mode = normalize_open_sltp_mode(sltp_mode) + if mode == SLTP_MODE_PCT: + return _resolve_pct_sltp(direction, live_price, data) + if mode == SLTP_MODE_FIXED_RR: + stop_loss, _ = _resolve_price_sltp(data, require_tp=False) + rr = parse_fixed_rr(data.get("fixed_rr")) + take_profit = calc_tp_from_fixed_rr(direction, live_price, stop_loss, rr) + return stop_loss, take_profit + return _resolve_price_sltp(data, require_tp=True) + + +def resolve_entrust_sltp_prices( + direction: str, + live_price: float, + sltp_mode: Optional[str], + data: dict[str, Any], + *, + fallback_sl: Optional[float] = None, + fallback_tp: Optional[float] = None, +) -> Tuple[float, float]: + """持仓委托弹窗:仅 price / pct,不校验盈亏比。""" + mode = normalize_entrust_sltp_mode(sltp_mode) + if mode == SLTP_MODE_PCT: + return _resolve_pct_sltp(direction, live_price, data) + return _resolve_price_sltp( + data, + fallback_sl=fallback_sl, + fallback_tp=fallback_tp, + require_tp=True, + ) diff --git a/tests/test_manual_sltp_lib.py b/tests/test_manual_sltp_lib.py new file mode 100644 index 0000000..e94a757 --- /dev/null +++ b/tests/test_manual_sltp_lib.py @@ -0,0 +1,32 @@ +from manual_sltp_lib import ( + MANUAL_FIXED_RR_DEFAULT, + calc_tp_from_fixed_rr, + parse_fixed_rr, + resolve_open_sltp_prices, +) + + +def test_calc_tp_from_fixed_rr_long(): + tp = calc_tp_from_fixed_rr("long", 100.0, 95.0, 1.5) + assert tp == 107.5 + + +def test_calc_tp_from_fixed_rr_short(): + tp = calc_tp_from_fixed_rr("short", 100.0, 105.0, 1.5) + assert tp == 92.5 + + +def test_resolve_open_fixed_rr_mode(): + sl, tp = resolve_open_sltp_prices( + "long", + 100.0, + "fixed_rr", + {"sl": "95", "fixed_rr": "1.5"}, + ) + assert sl == 95.0 + assert tp == 107.5 + + +def test_parse_fixed_rr_default(): + assert parse_fixed_rr(None) == MANUAL_FIXED_RR_DEFAULT + assert parse_fixed_rr("2") == 2.0