diff --git a/crypto_monitor_binance/app.py b/crypto_monitor_binance/app.py index 084c4a1..46a9800 100644 --- a/crypto_monitor_binance/app.py +++ b/crypto_monitor_binance/app.py @@ -2692,6 +2692,189 @@ def cancel_binance_futures_open_orders(exchange_symbol): pass +def _binance_list_raw_open_orders(exchange_symbol): + """普通挂单 + Algo 条件单(止盈/止损)。""" + ensure_markets_loaded() + market = exchange.market(exchange_symbol) + contract_id = market.get("id") + out = [] + try: + for o in exchange.fetch_open_orders(exchange_symbol) or []: + item = dict(o) + item["_channel"] = "regular" + out.append(item) + except Exception: + pass + try: + if contract_id and hasattr(exchange, "fapiPrivateGetOpenAlgoOrders"): + raw = exchange.fapiPrivateGetOpenAlgoOrders({"symbol": contract_id}) + items = raw if isinstance(raw, list) else (raw.get("orders") or raw.get("data") or []) + for info in items or []: + if not isinstance(info, dict): + continue + out.append( + { + "id": info.get("algoId") or info.get("orderId"), + "info": info, + "_channel": "algo", + "type": info.get("orderType") or info.get("type"), + "positionSide": info.get("positionSide"), + "stopPrice": info.get("triggerPrice") or info.get("stopPrice"), + "amount": info.get("quantity") or info.get("origQty"), + } + ) + except Exception: + pass + return out + + +def _binance_order_type_str(order): + info = order.get("info") or {} + if isinstance(info, dict): + for key in ("orderType", "type", "origType", "algoType"): + val = info.get(key) + if val: + return str(val).upper() + return str(order.get("type") or "").upper() + + +def _binance_order_matches_direction(order, direction): + if BINANCE_POSITION_MODE != "hedge": + return True + info = order.get("info") or {} + ps = str(order.get("positionSide") or info.get("positionSide") or "").upper() + want = "LONG" if direction == "long" else "SHORT" + if ps and ps not in ("", "BOTH") and ps != want: + return False + return True + + +def _binance_order_trigger_price(order): + for key in ("stopPrice", "triggerPrice", "activatePrice"): + try: + v = float(order.get(key) or 0) + if v > 0: + return v + except Exception: + pass + info = order.get("info") or {} + if isinstance(info, dict): + for key in ("triggerPrice", "stopPrice", "activatePrice"): + try: + v = float(info.get(key) or 0) + if v > 0: + return v + except Exception: + pass + return None + + +def _binance_tpsl_role_from_order(order): + typ = _binance_order_type_str(order) + if "TAKE_PROFIT" in typ: + return "tp" + if "STOP" in typ: + return "sl" + return None + + +def _binance_tpsl_slot_from_order(order, exchange_symbol): + trig = _binance_order_trigger_price(order) + try: + amt = float(order.get("amount") or order.get("remaining") or 0) + except Exception: + amt = None + if amt is not None and amt <= 0: + amt = None + channel = order.get("_channel") or "regular" + oid = order.get("id") + if oid is None and isinstance(order.get("info"), dict): + oid = order["info"].get("algoId") or order["info"].get("orderId") + disp = format_price_for_symbol(exchange_symbol, trig) if trig else "-" + return { + "order_id": str(oid) if oid is not None else "", + "channel": channel, + "trigger_price": trig, + "trigger_display": disp, + "amount": amt, + "type": _binance_order_type_str(order), + } + + +def fetch_exchange_tpsl_slots(exchange_symbol, direction): + """返回 { sl: slot|None, tp: slot|None },供页面展示与单笔撤单。""" + slots = {"sl": None, "tp": None} + if not exchange_symbol: + return slots + ok, _ = ensure_exchange_live_ready() + if not ok: + return slots + try: + for order in _binance_list_raw_open_orders(exchange_symbol): + if not _binance_order_matches_direction(order, direction): + continue + role = _binance_tpsl_role_from_order(order) + if role not in ("sl", "tp") or slots[role] is not None: + continue + slots[role] = _binance_tpsl_slot_from_order(order, exchange_symbol) + except Exception: + pass + return slots + + +def cancel_binance_tpsl_slot(exchange_symbol, slot): + if not slot or not exchange_symbol: + return + ensure_markets_loaded() + market = exchange.market(exchange_symbol) + contract_id = market.get("id") + oid = slot.get("order_id") + if not oid: + return + if slot.get("channel") == "algo" and contract_id and hasattr(exchange, "fapiPrivateDeleteAlgoOrder"): + exchange.fapiPrivateDeleteAlgoOrder({"symbol": contract_id, "algoId": oid}) + return + exchange.cancel_order(str(oid), exchange_symbol) + + +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 + + +def replace_active_monitor_tpsl_on_exchange(order_row, stop_loss, take_profit): + """先撤该合约全部 TP/SL,再按新价重挂(与交易所 App 一致)。""" + ok, reason = ensure_exchange_live_ready() + if not ok: + raise RuntimeError(reason or "实盘未就绪") + ex_sym = resolve_monitor_exchange_symbol(order_row) + direction = order_row["direction"] + cancel_binance_futures_open_orders(ex_sym) + pos_amt = get_live_position_contracts(ex_sym, direction) + if pos_amt is None or float(pos_amt) <= 0: + raise ValueError("交易所当前无该方向持仓,无法挂止盈止损") + _binance_place_tp_sl_orders(ex_sym, direction, float(pos_amt), float(stop_loss), float(take_profit)) + + def extract_trade_price_from_order(order): if not order: return None @@ -4514,6 +4697,13 @@ def api_price_snapshot(): payload["float_pct"] = ( round((payload["float_pnl"] / float(denom)) * 100, 2) if denom and float(denom) > 0 else pnl_pct ) + if exchange_private_api_configured(): + try: + payload["exchange_tpsl"] = fetch_exchange_tpsl_slots(ex_sym, r["direction"]) + except Exception: + payload["exchange_tpsl"] = {"sl": None, "tp": None} + else: + payload["exchange_tpsl"] = {"sl": None, "tp": None} order_prices.append(payload) return jsonify({ @@ -4524,6 +4714,95 @@ def api_price_snapshot(): }) +@app.route("/api/order//cancel_tpsl", methods=["POST"]) +@login_required +def api_order_cancel_tpsl(order_id): + data = request.get_json(silent=True) or {} + role = (data.get("role") or "").strip().lower() + if role not in ("sl", "tp"): + return jsonify({"ok": False, "msg": "role 须为 sl 或 tp"}), 400 + conn = get_db() + row = conn.execute( + "SELECT * FROM order_monitors WHERE id=? AND status='active'", + (order_id,), + ).fetchone() + conn.close() + if not row: + return jsonify({"ok": False, "msg": "持仓不存在或已结束"}), 404 + ok, reason = ensure_exchange_live_ready() + if not ok: + return jsonify({"ok": False, "msg": reason}), 400 + ex_sym = resolve_monitor_exchange_symbol(row) + slots = fetch_exchange_tpsl_slots(ex_sym, row["direction"]) + slot = slots.get(role) + if not slot: + return jsonify({"ok": False, "msg": f"交易所未找到{'止损' if role == 'sl' else '止盈'}委托"}), 404 + try: + cancel_binance_tpsl_slot(ex_sym, slot) + return jsonify({"ok": True, "msg": "已撤单", "exchange_tpsl": fetch_exchange_tpsl_slots(ex_sym, row["direction"])}) + except Exception as e: + return jsonify({"ok": False, "msg": friendly_exchange_error(e)}), 400 + + +@app.route("/api/order//place_tpsl", methods=["POST"]) +@login_required +def api_order_place_tpsl(order_id): + data = request.get_json(silent=True) or {} + conn = get_db() + row = conn.execute( + "SELECT * FROM order_monitors WHERE id=? AND status='active'", + (order_id,), + ).fetchone() + if not row: + conn.close() + return jsonify({"ok": False, "msg": "持仓不存在或已结束"}), 404 + symbol = row["symbol"] + direction = row["direction"] + live_price = get_price(symbol) + if live_price is None: + conn.close() + 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) + 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: + 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 + try: + replace_active_monitor_tpsl_on_exchange(row, stop_loss, take_profit) + except Exception as e: + conn.close() + return jsonify({"ok": False, "msg": friendly_exchange_error(e)}), 400 + conn.execute( + "UPDATE order_monitors SET stop_loss=?, take_profit=? WHERE id=?", + (stop_loss, take_profit, order_id), + ) + conn.commit() + ex_sym = resolve_monitor_exchange_symbol(row) + slots = fetch_exchange_tpsl_slots(ex_sym, direction) + conn.close() + return jsonify( + { + "ok": True, + "msg": "已先撤后挂止盈止损", + "stop_loss": stop_loss, + "take_profit": take_profit, + "planned_rr": planned_rr, + "exchange_tpsl": slots, + } + ) + + @app.route("/api/symbol_liquidity_rank") @login_required def api_symbol_liquidity_rank(): diff --git a/crypto_monitor_binance/templates/index.html b/crypto_monitor_binance/templates/index.html index 891ba65..2f54b34 100644 --- a/crypto_monitor_binance/templates/index.html +++ b/crypto_monitor_binance/templates/index.html @@ -129,8 +129,26 @@ .pos-side-badge{padding:3px 8px;border-radius:6px;font-size:.72rem;font-weight:500;line-height:1.2} .pos-side-long{background:#253a6e;color:#6eb5ff} .pos-side-short{background:#4a2230;color:#ff8a8a} - .pos-close-btn{padding:6px 14px;background:#c45454;color:#fff;border-radius:8px;text-decoration:none;font-size:.82rem;font-weight:500;flex-shrink:0;white-space:nowrap} + .pos-head-actions{display:flex;align-items:center;gap:6px;flex-shrink:0} + .pos-entrust-btn{padding:6px 12px;background:#2a4a7a;color:#8fc8ff;border:none;border-radius:8px;font-size:.82rem;font-weight:500;cursor:pointer;white-space:nowrap} + .pos-entrust-btn:hover{background:#355d96} + .pos-close-btn{padding:6px 14px;background:#c45454;color:#fff;border-radius:8px;text-decoration:none;font-size:.82rem;font-weight:500;flex-shrink:0;white-space:nowrap;border:none;cursor:pointer;display:inline-block} .pos-close-btn:hover{background:#d66565;color:#fff} + .pos-ex-orders{margin-top:10px;padding-top:10px;border-top:1px dashed #2a3348} + .pos-ex-orders-title{font-size:.74rem;color:#7d8799;margin-bottom:6px} + .pos-ex-order-row{display:flex;align-items:center;justify-content:space-between;gap:8px;font-size:.78rem;color:#c5cce0;margin-top:5px} + .pos-ex-order-main{flex:1;min-width:0;line-height:1.35} + .pos-ex-cancel-btn{padding:3px 10px;background:#3a3048;color:#d4b8ff;border:none;border-radius:6px;font-size:.74rem;cursor:pointer;flex-shrink:0} + .pos-ex-cancel-btn:disabled{opacity:.4;cursor:not-allowed} + .tpsl-modal-backdrop{display:none;position:fixed;inset:0;background:rgba(0,0,0,.55);z-index:9000;align-items:center;justify-content:center;padding:16px} + .tpsl-modal-backdrop.open{display:flex} + .tpsl-modal{background:#1a2030;border:1px solid #3a4a66;border-radius:12px;padding:16px 18px;width:min(440px,100%);max-height:90vh;overflow:auto} + .tpsl-modal h3{margin:0 0 12px;font-size:1rem;color:#fff} + .tpsl-modal .form-row{margin-bottom:10px} + .tpsl-modal-actions{display:flex;gap:8px;justify-content:flex-end;margin-top:14px} + .tpsl-modal-actions button{padding:8px 16px;border-radius:8px;border:none;cursor:pointer;font-size:.85rem} + .tpsl-modal-submit{background:#2d6a4f;color:#fff} + .tpsl-modal-cancel{background:#3a3f52;color:#ddd} .pos-grid{display:grid;grid-template-columns:repeat(3,1fr);gap:12px 14px;margin-bottom:12px} .pos-cell{display:flex;flex-direction:column;gap:4px;min-width:0} .pos-label{font-size:.72rem;color:#7d8799} @@ -357,13 +375,22 @@

实时持仓

{% for o in order %} -
+
{{ o.exchange_symbol or o.symbol }} {{ '做多' if o.direction == 'long' else '做空' }}
- 平仓 +
+ + 平仓 +
来源: {{ o.monitor_type|default('下单监控', true) }} @@ -413,12 +440,49 @@ 杠杆: {{ o.leverage or '-' }}x 仓位占比: {{ o.position_ratio if o.position_ratio is not none else '-' }}%
+
+
交易所止盈止损
+
+ 止损:加载中… + +
+
+ 止盈:加载中… + +
+
{% else %}
暂无持仓
{% endfor %}
+ +
+
+

挂止盈止损

+

将先撤销该合约已有 TP/SL,再按下列价格重挂。

+
+ +
+
+ + +
+
+ + +
+
+ + +
+
+
+ {% endif %} @@ -607,7 +671,10 @@ + + {% endif %} + {% endif %} {% if page == 'stats' %} @@ -1218,6 +1285,102 @@ function rejectManualOrderRr(rr){ alert(`计划盈亏比 ${rr === null ? '无效' : rr.toFixed(2)}:1 低于最低要求 ${MANUAL_MIN_PLANNED_RR}:1,已阻止人工下单。`); return true; } + +let tpslEntrustMonitorId = null; +function formatExTpslLine(role, slot){ + const label = role === 'sl' ? '止损' : '止盈'; + if(!slot || !slot.order_id) return label + ':未挂单'; + const px = slot.trigger_display || slot.trigger_price || '-'; + const amt = slot.amount != null && !Number.isNaN(Number(slot.amount)) ? ` 数量 ${Number(slot.amount)}` : ''; + return `${label}:触发 ${px}${amt}`; +} +function paintExchangeTpslRow(orderId, tpsl){ + const data = tpsl || {}; + const slText = document.getElementById(`ex-sl-text-${orderId}`); + const tpText = document.getElementById(`ex-tp-text-${orderId}`); + const slBtn = document.getElementById(`ex-sl-cancel-${orderId}`); + const tpBtn = document.getElementById(`ex-tp-cancel-${orderId}`); + if(slText) slText.innerText = formatExTpslLine('sl', data.sl); + if(tpText) tpText.innerText = formatExTpslLine('tp', data.tp); + if(slBtn) slBtn.disabled = !(data.sl && data.sl.order_id); + if(tpBtn) tpBtn.disabled = !(data.tp && data.tp.order_id); +} +function toggleTpslModalMode(){ + const mode = (document.getElementById('tpsl-modal-mode')||{}).value || 'price'; + const pct = mode === 'pct'; + ['tpsl-modal-sl','tpsl-modal-tp'].forEach(id=>{ const el=document.getElementById(id); if(el) el.style.display=pct?'none':''; }); + ['tpsl-modal-sl-pct','tpsl-modal-tp-pct'].forEach(id=>{ const el=document.getElementById(id); if(el) el.style.display=pct?'':'none'; }); +} +function openTpslEntrustModal(orderId){ + const card = document.getElementById(`order-row-${orderId}`); + if(!card) return; + tpslEntrustMonitorId = orderId; + const slEl = document.getElementById('tpsl-modal-sl'); + const tpEl = document.getElementById('tpsl-modal-tp'); + if(slEl) slEl.value = card.getAttribute('data-plan-sl') || ''; + if(tpEl) tpEl.value = card.getAttribute('data-plan-tp') || ''; + const modeEl = document.getElementById('tpsl-modal-mode'); + if(modeEl) modeEl.value = 'price'; + toggleTpslModalMode(); + const title = document.getElementById('tpsl-modal-title'); + if(title) title.innerText = `挂止盈止损 · ${card.getAttribute('data-symbol')||''}`; + const modal = document.getElementById('tpsl-modal'); + if(modal) modal.classList.add('open'); +} +function closeTpslEntrustModal(){ + tpslEntrustMonitorId = null; + const modal = document.getElementById('tpsl-modal'); + if(modal) modal.classList.remove('open'); +} +function submitTpslEntrust(){ + const orderId = tpslEntrustMonitorId; + if(!orderId) return; + const mode = (document.getElementById('tpsl-modal-mode')||{}).value || 'price'; + const body = { sltp_mode: mode }; + 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) }) + .then(r=>r.json()).then(data=>{ + if(!data.ok){ alert(data.msg || '委托失败'); return; } + alert(data.msg || '已提交'); + closeTpslEntrustModal(); + 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' ? '止损' : '止盈'; + if(!confirm(`确认撤销交易所${label}委托?(不会平仓)`)) return; + fetch(`/api/order/${orderId}/cancel_tpsl`, { method:'POST', headers:{'Content-Type':'application/json'}, body: JSON.stringify({ role }) }) + .then(r=>r.json()).then(data=>{ + if(!data.ok){ alert(data.msg || '撤单失败'); return; } + if(data.exchange_tpsl) paintExchangeTpslRow(orderId, data.exchange_tpsl); + else refreshPriceSnapshotConditional(); + }).catch(()=>alert('撤单请求失败')); +} + function allowManualOrderSubmit(form){ form.dataset.rrOk = "1"; form.submit(); @@ -1325,6 +1488,7 @@ function refreshPriceSnapshot(){ if(rrEl){ rrEl.innerText = formatRrRatio(o.rr_ratio); } + if(o.exchange_tpsl) paintExchangeTpslRow(o.id, o.exchange_tpsl); }); }).catch(()=>{}); } diff --git a/crypto_monitor_gate/app.py b/crypto_monitor_gate/app.py index 4832600..aaf1e3a 100644 --- a/crypto_monitor_gate/app.py +++ b/crypto_monitor_gate/app.py @@ -2750,6 +2750,199 @@ def cancel_gate_swap_trigger_orders(exchange_symbol): pass +def _gate_list_trigger_open_orders(exchange_symbol): + params = _gate_swap_trigger_order_params() + try: + return exchange.fetch_open_orders(exchange_symbol, params=params) or [] + except Exception: + return [] + + +def _gate_order_trigger_price(order): + for key in ("stopPrice", "triggerPrice", "price"): + try: + v = float(order.get(key) or 0) + if v > 0: + return v + except Exception: + pass + info = order.get("info") or {} + if isinstance(info, dict): + trig = info.get("trigger") + if isinstance(trig, dict): + try: + v = float(trig.get("price") or 0) + if v > 0: + return v + except Exception: + pass + for key in ("trigger_price", "triggerPrice", "stopPrice", "price"): + try: + v = float(info.get(key) or 0) + if v > 0: + return v + except Exception: + pass + return None + + +def _gate_tpsl_role_from_order(order, direction): + info = order.get("info") or {} + if not isinstance(info, dict): + info = {} + ot = str(info.get("order_type") or info.get("orderType") or order.get("type") or "").lower() + if "take" in ot and "profit" in ot: + return "tp" + if "stop" in ot and "loss" in ot: + return "sl" + trig = info.get("trigger") + rule = None + if isinstance(trig, dict) and trig.get("rule") is not None: + try: + rule = int(trig["rule"]) + except Exception: + rule = None + if rule is None: + try: + rule = int(info.get("rule")) + except Exception: + rule = None + if rule is not None: + if direction == "long": + return "sl" if rule == 2 else ("tp" if rule == 1 else None) + return "sl" if rule == 1 else ("tp" if rule == 2 else None) + if order.get("stopLossPrice"): + return "sl" + if order.get("takeProfitPrice"): + return "tp" + typ = str(order.get("type") or "").upper() + if "TAKE" in typ: + return "tp" + if "STOP" in typ: + return "sl" + return None + + +def _gate_tpsl_slot_from_order(order, exchange_symbol): + trig = _gate_order_trigger_price(order) + try: + amt = float(order.get("amount") or order.get("remaining") or 0) + except Exception: + amt = None + if amt is not None and amt <= 0: + amt = None + oid = order.get("id") + if oid is None and isinstance(order.get("info"), dict): + oid = order["info"].get("id") or order["info"].get("order_id") + disp = format_price_for_symbol(exchange_symbol, trig) if trig else "-" + return { + "order_id": str(oid) if oid is not None else "", + "channel": "gate_trigger", + "trigger_price": trig, + "trigger_display": disp, + "amount": amt, + "type": str(order.get("type") or ""), + } + + +def fetch_exchange_tpsl_slots(exchange_symbol, direction, plan_sl=None, plan_tp=None): + slots = {"sl": None, "tp": None} + if not exchange_symbol: + return slots + ok, _ = ensure_exchange_live_ready() + if not ok: + return slots + try: + ensure_markets_loaded() + ambiguous = [] + for order in _gate_list_trigger_open_orders(exchange_symbol): + role = _gate_tpsl_role_from_order(order, direction) + slot = _gate_tpsl_slot_from_order(order, exchange_symbol) + if role in ("sl", "tp"): + if slots[role] is None: + slots[role] = slot + continue + ambiguous.append(slot) + for slot in ambiguous: + trig = slot.get("trigger_price") + if trig is None: + continue + try: + plan_sl_f = float(plan_sl) if plan_sl is not None else None + plan_tp_f = float(plan_tp) if plan_tp is not None else None + except Exception: + plan_sl_f = plan_tp_f = None + if plan_sl_f is not None and plan_tp_f is not None: + role = "sl" if abs(trig - plan_sl_f) <= abs(trig - plan_tp_f) else "tp" + elif plan_sl_f is not None: + role = "sl" + elif plan_tp_f is not None: + role = "tp" + else: + continue + if slots[role] is None: + slots[role] = slot + except Exception: + pass + return slots + + +def cancel_gate_tpsl_slot(exchange_symbol, slot): + if not slot or not exchange_symbol: + return + ensure_markets_loaded() + oid = slot.get("order_id") + if not oid: + return + params = _gate_swap_trigger_order_params() + exchange.cancel_order(str(oid), exchange_symbol, params) + + +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 + + +def replace_active_monitor_tpsl_on_exchange(order_row, stop_loss, take_profit): + ok, reason = ensure_exchange_live_ready() + if not ok: + raise RuntimeError(reason or "实盘未就绪") + ex_sym = resolve_monitor_exchange_symbol(order_row) + direction = order_row["direction"] + cancel_gate_swap_trigger_orders(ex_sym) + contracts = get_live_position_contracts(ex_sym, direction) + if contracts is None or float(contracts) <= 0: + raise ValueError("交易所当前无该方向持仓,无法挂止盈止损") + amt = float(contracts) + if amt <= 0: + try: + amt = float(order_row["order_amount"] or 0) + except Exception: + amt = 0 + if amt <= 0: + raise ValueError("无法确定平仓数量") + _gate_place_tp_sl_orders(ex_sym, direction, amt, float(stop_loss), float(take_profit)) + + def extract_trade_price_from_order(order): if not order: return None @@ -4671,6 +4864,18 @@ def api_price_snapshot(): except Exception: payload["price"] = px_for_fmt payload["price_display"] = px_disp + if exchange_private_api_configured(): + try: + payload["exchange_tpsl"] = fetch_exchange_tpsl_slots( + ex_sym, + r["direction"], + plan_sl=r["stop_loss"], + plan_tp=r["take_profit"], + ) + except Exception: + payload["exchange_tpsl"] = {"sl": None, "tp": None} + else: + payload["exchange_tpsl"] = {"sl": None, "tp": None} order_prices.append(payload) return jsonify({ @@ -4681,6 +4886,100 @@ def api_price_snapshot(): }) +@app.route("/api/order//cancel_tpsl", methods=["POST"]) +@login_required +def api_order_cancel_tpsl(order_id): + data = request.get_json(silent=True) or {} + role = (data.get("role") or "").strip().lower() + if role not in ("sl", "tp"): + return jsonify({"ok": False, "msg": "role 须为 sl 或 tp"}), 400 + conn = get_db() + row = conn.execute( + "SELECT * FROM order_monitors WHERE id=? AND status='active'", + (order_id,), + ).fetchone() + conn.close() + if not row: + return jsonify({"ok": False, "msg": "持仓不存在或已结束"}), 404 + ok, reason = ensure_exchange_live_ready() + if not ok: + return jsonify({"ok": False, "msg": reason}), 400 + ex_sym = resolve_monitor_exchange_symbol(row) + slots = fetch_exchange_tpsl_slots( + ex_sym, row["direction"], plan_sl=row["stop_loss"], plan_tp=row["take_profit"] + ) + slot = slots.get(role) + if not slot: + return jsonify({"ok": False, "msg": f"交易所未找到{'止损' if role == 'sl' else '止盈'}委托"}), 404 + try: + cancel_gate_tpsl_slot(ex_sym, slot) + slots = fetch_exchange_tpsl_slots( + ex_sym, row["direction"], plan_sl=row["stop_loss"], plan_tp=row["take_profit"] + ) + return jsonify({"ok": True, "msg": "已撤单", "exchange_tpsl": slots}) + except Exception as e: + return jsonify({"ok": False, "msg": friendly_exchange_error(e)}), 400 + + +@app.route("/api/order//place_tpsl", methods=["POST"]) +@login_required +def api_order_place_tpsl(order_id): + data = request.get_json(silent=True) or {} + conn = get_db() + row = conn.execute( + "SELECT * FROM order_monitors WHERE id=? AND status='active'", + (order_id,), + ).fetchone() + if not row: + conn.close() + return jsonify({"ok": False, "msg": "持仓不存在或已结束"}), 404 + symbol = row["symbol"] + direction = row["direction"] + live_price = get_price(symbol) + if live_price is None: + conn.close() + 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) + 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: + 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 + try: + replace_active_monitor_tpsl_on_exchange(row, stop_loss, take_profit) + except Exception as e: + conn.close() + return jsonify({"ok": False, "msg": friendly_exchange_error(e)}), 400 + conn.execute( + "UPDATE order_monitors SET stop_loss=?, take_profit=? WHERE id=?", + (stop_loss, take_profit, order_id), + ) + conn.commit() + ex_sym = resolve_monitor_exchange_symbol(row) + slots = fetch_exchange_tpsl_slots(ex_sym, direction, plan_sl=stop_loss, plan_tp=take_profit) + conn.close() + return jsonify( + { + "ok": True, + "msg": "已先撤后挂止盈止损", + "stop_loss": stop_loss, + "take_profit": take_profit, + "planned_rr": planned_rr, + "exchange_tpsl": slots, + } + ) + + @app.route("/api/symbol_liquidity_rank") @login_required def api_symbol_liquidity_rank(): diff --git a/crypto_monitor_gate/templates/index.html b/crypto_monitor_gate/templates/index.html index 9efccbd..99f2fb5 100644 --- a/crypto_monitor_gate/templates/index.html +++ b/crypto_monitor_gate/templates/index.html @@ -131,6 +131,24 @@ .pos-side-short{background:#4a2230;color:#ff8a8a} .pos-close-btn{padding:6px 14px;background:#c45454;color:#fff;border-radius:8px;text-decoration:none;font-size:.82rem;font-weight:500;flex-shrink:0;white-space:nowrap} .pos-close-btn:hover{background:#d66565;color:#fff} + .pos-head-actions{display:flex;align-items:center;gap:6px;flex-shrink:0} + .pos-entrust-btn{padding:6px 12px;background:#2a4a7a;color:#8fc8ff;border:none;border-radius:8px;font-size:.82rem;font-weight:500;cursor:pointer;white-space:nowrap} + .pos-entrust-btn:hover{background:#355d96} + .pos-ex-orders{margin-top:10px;padding-top:10px;border-top:1px dashed #2a3348} + .pos-ex-orders-title{font-size:.74rem;color:#7d8799;margin-bottom:6px} + .pos-ex-order-row{display:flex;align-items:center;justify-content:space-between;gap:8px;font-size:.78rem;color:#c5cce0;margin-top:5px} + .pos-ex-order-main{flex:1;min-width:0;line-height:1.35} + .pos-ex-cancel-btn{padding:3px 10px;background:#3a3048;color:#d4b8ff;border:none;border-radius:6px;font-size:.74rem;cursor:pointer;flex-shrink:0} + .pos-ex-cancel-btn:disabled{opacity:.4;cursor:not-allowed} + .tpsl-modal-backdrop{display:none;position:fixed;inset:0;background:rgba(0,0,0,.55);z-index:9000;align-items:center;justify-content:center;padding:16px} + .tpsl-modal-backdrop.open{display:flex} + .tpsl-modal{background:#1a2030;border:1px solid #3a4a66;border-radius:12px;padding:16px 18px;width:min(440px,100%);max-height:90vh;overflow:auto} + .tpsl-modal h3{margin:0 0 12px;font-size:1rem;color:#fff} + .tpsl-modal .form-row{margin-bottom:10px} + .tpsl-modal-actions{display:flex;gap:8px;justify-content:flex-end;margin-top:14px} + .tpsl-modal-actions button{padding:8px 16px;border-radius:8px;border:none;cursor:pointer;font-size:.85rem} + .tpsl-modal-submit{background:#2d6a4f;color:#fff} + .tpsl-modal-cancel{background:#3a3f52;color:#ddd} .pos-grid{display:grid;grid-template-columns:repeat(3,1fr);gap:12px 14px;margin-bottom:12px} .pos-cell{display:flex;flex-direction:column;gap:4px;min-width:0} .pos-label{font-size:.72rem;color:#7d8799} @@ -357,13 +375,22 @@

实时持仓

{% for o in order %} -
+
{{ o.exchange_symbol or o.symbol }} {{ '做多' if o.direction == 'long' else '做空' }}
- 平仓 +
+ + 平仓 +
来源: {{ o.monitor_type|default('下单监控', true) }} @@ -413,12 +440,49 @@ 杠杆: {{ o.leverage or '-' }}x 仓位占比: {{ o.position_ratio if o.position_ratio is not none else '-' }}%
+
+
交易所止盈止损
+
+ 止损:加载中… + +
+
+ 止盈:加载中… + +
+
{% else %}
暂无持仓
{% endfor %}
+ +
+
+

挂止盈止损

+

将先撤销该合约已有 TP/SL,再按下列价格重挂。

+
+ +
+
+ + +
+
+ + +
+
+ + +
+
+
+ {% endif %} @@ -607,7 +671,10 @@ + + {% endif %} + {% endif %} {% if page == 'stats' %} @@ -1218,6 +1285,102 @@ function rejectManualOrderRr(rr){ alert(`计划盈亏比 ${rr === null ? '无效' : rr.toFixed(2)}:1 低于最低要求 ${MANUAL_MIN_PLANNED_RR}:1,已阻止人工下单。`); return true; } + +let tpslEntrustMonitorId = null; +function formatExTpslLine(role, slot){ + const label = role === 'sl' ? '止损' : '止盈'; + if(!slot || !slot.order_id) return label + ':未挂单'; + const px = slot.trigger_display || slot.trigger_price || '-'; + const amt = slot.amount != null && !Number.isNaN(Number(slot.amount)) ? ` 数量 ${Number(slot.amount)}` : ''; + return `${label}:触发 ${px}${amt}`; +} +function paintExchangeTpslRow(orderId, tpsl){ + const data = tpsl || {}; + const slText = document.getElementById(`ex-sl-text-${orderId}`); + const tpText = document.getElementById(`ex-tp-text-${orderId}`); + const slBtn = document.getElementById(`ex-sl-cancel-${orderId}`); + const tpBtn = document.getElementById(`ex-tp-cancel-${orderId}`); + if(slText) slText.innerText = formatExTpslLine('sl', data.sl); + if(tpText) tpText.innerText = formatExTpslLine('tp', data.tp); + if(slBtn) slBtn.disabled = !(data.sl && data.sl.order_id); + if(tpBtn) tpBtn.disabled = !(data.tp && data.tp.order_id); +} +function toggleTpslModalMode(){ + const mode = (document.getElementById('tpsl-modal-mode')||{}).value || 'price'; + const pct = mode === 'pct'; + ['tpsl-modal-sl','tpsl-modal-tp'].forEach(id=>{ const el=document.getElementById(id); if(el) el.style.display=pct?'none':''; }); + ['tpsl-modal-sl-pct','tpsl-modal-tp-pct'].forEach(id=>{ const el=document.getElementById(id); if(el) el.style.display=pct?'':'none'; }); +} +function openTpslEntrustModal(orderId){ + const card = document.getElementById(`order-row-${orderId}`); + if(!card) return; + tpslEntrustMonitorId = orderId; + const slEl = document.getElementById('tpsl-modal-sl'); + const tpEl = document.getElementById('tpsl-modal-tp'); + if(slEl) slEl.value = card.getAttribute('data-plan-sl') || ''; + if(tpEl) tpEl.value = card.getAttribute('data-plan-tp') || ''; + const modeEl = document.getElementById('tpsl-modal-mode'); + if(modeEl) modeEl.value = 'price'; + toggleTpslModalMode(); + const title = document.getElementById('tpsl-modal-title'); + if(title) title.innerText = `挂止盈止损 · ${card.getAttribute('data-symbol')||''}`; + const modal = document.getElementById('tpsl-modal'); + if(modal) modal.classList.add('open'); +} +function closeTpslEntrustModal(){ + tpslEntrustMonitorId = null; + const modal = document.getElementById('tpsl-modal'); + if(modal) modal.classList.remove('open'); +} +function submitTpslEntrust(){ + const orderId = tpslEntrustMonitorId; + if(!orderId) return; + const mode = (document.getElementById('tpsl-modal-mode')||{}).value || 'price'; + const body = { sltp_mode: mode }; + 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) }) + .then(r=>r.json()).then(data=>{ + if(!data.ok){ alert(data.msg || '委托失败'); return; } + alert(data.msg || '已提交'); + closeTpslEntrustModal(); + 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' ? '止损' : '止盈'; + if(!confirm(`确认撤销交易所${label}委托?(不会平仓)`)) return; + fetch(`/api/order/${orderId}/cancel_tpsl`, { method:'POST', headers:{'Content-Type':'application/json'}, body: JSON.stringify({ role }) }) + .then(r=>r.json()).then(data=>{ + if(!data.ok){ alert(data.msg || '撤单失败'); return; } + if(data.exchange_tpsl) paintExchangeTpslRow(orderId, data.exchange_tpsl); + else refreshPriceSnapshotConditional(); + }).catch(()=>alert('撤单请求失败')); +} + function allowManualOrderSubmit(form){ form.dataset.rrOk = "1"; form.submit(); @@ -1342,6 +1505,7 @@ function refreshPriceSnapshot(){ if(rrEl){ rrEl.innerText = formatRrRatio(o.rr_ratio); } + if(o.exchange_tpsl) paintExchangeTpslRow(o.id, o.exchange_tpsl); }); }).catch(()=>{}); }