diff --git a/crypto_monitor_binance/app.py b/crypto_monitor_binance/app.py index 46f499c..9474a2d 100644 --- a/crypto_monitor_binance/app.py +++ b/crypto_monitor_binance/app.py @@ -72,6 +72,7 @@ from key_sl_tp_lib import ( sl_tp_mode_label, sl_tp_plan_summary_text, ) +from order_manual_breakeven_lib import apply_order_manual_breakeven from key_monitor_lib import ( KEY_DIRECTION_WATCH, KEY_MONITOR_ALERT_ONLY_TYPES, @@ -6119,6 +6120,80 @@ def api_order_cancel_tpsl(order_id): return jsonify({"ok": False, "msg": friendly_exchange_error(e)}), 400 +@app.route("/api/order//manual_breakeven", methods=["POST"]) +@login_required +def api_order_manual_breakeven(order_id): + data = request.get_json(silent=True) or {} + try: + offset_pct = float( + data.get("offset_pct", os.getenv("MANUAL_BREAKEVEN_OFFSET_PCT", "0.2")) + ) + except (TypeError, ValueError): + return jsonify({"ok": False, "msg": "offset_pct 无效"}), 400 + if offset_pct < 0 or offset_pct > 10: + return jsonify({"ok": False, "msg": "偏移%须在 0~10 之间"}), 400 + 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 + ok, err, new_sl = apply_order_manual_breakeven( + row, + offset_pct, + calc_stop_fn=calc_trend_manual_breakeven_stop, + round_price_fn=round_price_to_exchange, + resolve_ex_sym_fn=resolve_monitor_exchange_symbol, + get_position_fn=get_live_position_contracts, + replace_tpsl_fn=replace_active_monitor_tpsl_on_exchange, + ) + if not ok: + conn.close() + return jsonify({"ok": False, "msg": err or "保本失败"}), 400 + conn.execute( + "UPDATE order_monitors SET stop_loss=?, breakeven_armed=1, breakeven_price=? WHERE id=?", + (new_sl, new_sl, order_id), + ) + conn.commit() + sym = row["symbol"] + direction = row["direction"] + ex_sym = resolve_monitor_exchange_symbol(row) + take_profit = float(row["take_profit"] or 0) + slots = fetch_exchange_tpsl_slots( + ex_sym, direction, plan_sl=new_sl, plan_tp=take_profit + ) + conn.close() + try: + entry = float(row["trigger_price"] or 0) + send_wechat_msg( + "\n".join( + [ + f"# ✅ {sym} 一键保本", + f"**账户:{_wechat_account_label()}**", + f"- 方向:{'做多' if direction == 'long' else '做空'}", + f"- 成交价:{format_price_for_symbol(sym, entry)}", + f"- 偏移:{offset_pct}%(相对成交价)", + f"- 新止损:{format_price_for_symbol(sym, new_sl)}", + "- 交易所:已更新止盈止损", + ] + ) + ) + except Exception: + pass + return jsonify( + { + "ok": True, + "msg": "已一键保本", + "stop_loss": new_sl, + "stop_loss_display": format_price_for_symbol(sym, new_sl), + "breakeven_armed": True, + "exchange_tpsl": slots, + } + ) + + @app.route("/api/order//place_tpsl", methods=["POST"]) @login_required def api_order_place_tpsl(order_id): diff --git a/crypto_monitor_binance/templates/index.html b/crypto_monitor_binance/templates/index.html index ae3f5b5..1db2c12 100644 --- a/crypto_monitor_binance/templates/index.html +++ b/crypto_monitor_binance/templates/index.html @@ -194,6 +194,13 @@ .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} + .pos-be-global{font-size:.76rem;font-weight:400;color:#8892b0;margin-left:10px;display:inline-flex;align-items:center;gap:4px} + .pos-be-global input{width:52px;padding:3px 6px;background:#0d1119;border:1px solid #3a4460;border-radius:6px;color:#e8ecf5;font-size:.78rem} + .pos-be-row{display:flex;align-items:center;gap:8px;flex-wrap:wrap;margin-bottom:10px;padding:8px 10px;background:#1a2030;border-radius:8px;border:1px solid #2a3348} + .pos-be-label{display:inline-flex;align-items:center;gap:6px;font-size:.78rem;color:#b8c4e8;cursor:pointer;user-select:none} + .pos-be-label input{width:15px;height:15px;accent-color:#6eb5ff} + .pos-be-offset{width:52px;padding:4px 6px;background:#0d1119;border:1px solid #3a4460;border-radius:6px;color:#e8ecf5;font-size:.78rem} + .pos-be-status{font-size:.74rem;color:#6ab88a} .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} @@ -464,7 +471,11 @@
-

实时持仓

+

实时持仓 + 一键保本默认偏移 + % + +

{% for o in order %}
+
+ + % + {% if o.breakeven_armed %}已保本{% endif %} +
成交价 @@ -1733,6 +1753,96 @@ function submitTpslEntrust(){ post(); }).catch(()=>alert('无法校验盈亏比')); } +const MANUAL_BE_OFFSET_KEY = 'manualBreakevenOffsetPct'; +function getManualBeOffsetPct(){ + const g = document.getElementById('manual-be-offset-global'); + if(g && g.value !== ''){ + const n = parseFloat(g.value); + if(Number.isFinite(n)) return n; + } + const saved = localStorage.getItem(MANUAL_BE_OFFSET_KEY); + const n = saved ? parseFloat(saved) : 0.2; + return Number.isFinite(n) ? n : 0.2; +} +function initManualBreakevenUi(){ + const g = document.getElementById('manual-be-offset-global'); + if(g){ + const saved = localStorage.getItem(MANUAL_BE_OFFSET_KEY); + if(saved) g.value = saved; + g.addEventListener('change', ()=>{ + localStorage.setItem(MANUAL_BE_OFFSET_KEY, g.value); + document.querySelectorAll('.pos-be-offset').forEach(inp=>{ + if(!inp.dataset.touched) inp.value = g.value; + }); + }); + } + document.querySelectorAll('.pos-be-offset').forEach(inp=>{ + if(!inp.dataset.touched){ + inp.value = getManualBeOffsetPct(); + } + inp.addEventListener('input', ()=>{ inp.dataset.touched = '1'; }); + }); + document.querySelectorAll('.pos-be-switch').forEach(sw=>{ + if(sw.dataset.bound) return; + sw.dataset.bound = '1'; + sw.addEventListener('change', ()=>{ + const orderId = sw.getAttribute('data-order-id'); + const card = sw.closest('.pos-card'); + const offInp = card && card.querySelector('.pos-be-offset'); + if(!sw.checked){ + if(sw.dataset.armed === '1') sw.checked = true; + return; + } + applyManualBreakeven(orderId, sw, offInp, card); + }); + }); +} +function updateOrderCardSlDisplay(card, slDisplay){ + if(!card || !slDisplay) return; + card.setAttribute('data-plan-sl', slDisplay); + const cells = card.querySelectorAll('.pos-grid .pos-cell'); + if(cells[1]){ + const val = cells[1].querySelector('.pos-value'); + if(val) val.textContent = slDisplay; + } +} +function applyManualBreakeven(orderId, sw, offInp, card){ + const offset = offInp ? parseFloat(offInp.value) : getManualBeOffsetPct(); + if(!Number.isFinite(offset) || offset < 0 || offset > 10){ + alert('偏移%须在 0~10 之间'); + sw.checked = sw.dataset.armed === '1'; + return; + } + const dirHint = card && card.getAttribute('data-direction') === 'short' ? '下' : '上'; + if(!confirm(`确认一键保本?止损将移至成交价${dirHint}移 ${offset}%(相对成交价)`)){ + sw.checked = sw.dataset.armed === '1'; + return; + } + sw.disabled = true; + fetch(`/api/order/${orderId}/manual_breakeven`, { + method:'POST', + headers:{'Content-Type':'application/json'}, + body: JSON.stringify({ offset_pct: offset }) + }).then(r=>r.json()).then(data=>{ + if(!data.ok){ + alert(data.msg || '保本失败'); + sw.checked = sw.dataset.armed === '1'; + sw.disabled = false; + return; + } + sw.dataset.armed = '1'; + sw.checked = true; + const st = document.getElementById(`pos-be-status-${orderId}`); + if(st) st.textContent = '已保本'; + if(data.stop_loss_display) updateOrderCardSlDisplay(card, data.stop_loss_display); + if(data.exchange_tpsl) paintExchangeTpslRow(orderId, data.exchange_tpsl); + else refreshPriceSnapshotConditional(); + }).catch(()=>{ + alert('保本请求失败'); + sw.checked = sw.dataset.armed === '1'; + sw.disabled = false; + }); +} function cancelExchangeTpsl(orderId, role){ const label = role === 'sl' ? '止损' : '止盈'; if(!confirm(`确认撤销交易所${label}委托?(不会平仓)`)) return; @@ -2072,6 +2182,7 @@ function refreshPriceSnapshotConditional(){ } }).catch(()=>{}); } +initManualBreakevenUi(); setInterval(refreshPriceSnapshotConditional, {{ price_refresh_seconds * 1000 }}); diff --git a/crypto_monitor_gate/app.py b/crypto_monitor_gate/app.py index ec16788..1738034 100644 --- a/crypto_monitor_gate/app.py +++ b/crypto_monitor_gate/app.py @@ -73,6 +73,7 @@ from key_sl_tp_lib import ( sl_tp_mode_label, sl_tp_plan_summary_text, ) +from order_manual_breakeven_lib import apply_order_manual_breakeven from key_monitor_lib import ( KEY_DIRECTION_WATCH, KEY_MONITOR_ALERT_ONLY_TYPES, @@ -6207,6 +6208,80 @@ def api_order_cancel_tpsl(order_id): return jsonify({"ok": False, "msg": friendly_exchange_error(e)}), 400 +@app.route("/api/order//manual_breakeven", methods=["POST"]) +@login_required +def api_order_manual_breakeven(order_id): + data = request.get_json(silent=True) or {} + try: + offset_pct = float( + data.get("offset_pct", os.getenv("MANUAL_BREAKEVEN_OFFSET_PCT", "0.2")) + ) + except (TypeError, ValueError): + return jsonify({"ok": False, "msg": "offset_pct 无效"}), 400 + if offset_pct < 0 or offset_pct > 10: + return jsonify({"ok": False, "msg": "偏移%须在 0~10 之间"}), 400 + 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 + ok, err, new_sl = apply_order_manual_breakeven( + row, + offset_pct, + calc_stop_fn=calc_trend_manual_breakeven_stop, + round_price_fn=round_price_to_exchange, + resolve_ex_sym_fn=resolve_monitor_exchange_symbol, + get_position_fn=get_live_position_contracts, + replace_tpsl_fn=replace_active_monitor_tpsl_on_exchange, + ) + if not ok: + conn.close() + return jsonify({"ok": False, "msg": err or "保本失败"}), 400 + conn.execute( + "UPDATE order_monitors SET stop_loss=?, breakeven_armed=1, breakeven_price=? WHERE id=?", + (new_sl, new_sl, order_id), + ) + conn.commit() + sym = row["symbol"] + direction = row["direction"] + ex_sym = resolve_monitor_exchange_symbol(row) + take_profit = float(row["take_profit"] or 0) + slots = fetch_exchange_tpsl_slots( + ex_sym, direction, plan_sl=new_sl, plan_tp=take_profit + ) + conn.close() + try: + entry = float(row["trigger_price"] or 0) + send_wechat_msg( + "\n".join( + [ + f"# ✅ {sym} 一键保本", + f"**账户:{_wechat_account_label()}**", + f"- 方向:{'做多' if direction == 'long' else '做空'}", + f"- 成交价:{format_price_for_symbol(sym, entry)}", + f"- 偏移:{offset_pct}%(相对成交价)", + f"- 新止损:{format_price_for_symbol(sym, new_sl)}", + "- 交易所:已更新止盈止损", + ] + ) + ) + except Exception: + pass + return jsonify( + { + "ok": True, + "msg": "已一键保本", + "stop_loss": new_sl, + "stop_loss_display": format_price_for_symbol(sym, new_sl), + "breakeven_armed": True, + "exchange_tpsl": slots, + } + ) + + @app.route("/api/order//place_tpsl", methods=["POST"]) @login_required def api_order_place_tpsl(order_id): diff --git a/crypto_monitor_gate/templates/index.html b/crypto_monitor_gate/templates/index.html index ae3f5b5..1db2c12 100644 --- a/crypto_monitor_gate/templates/index.html +++ b/crypto_monitor_gate/templates/index.html @@ -194,6 +194,13 @@ .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} + .pos-be-global{font-size:.76rem;font-weight:400;color:#8892b0;margin-left:10px;display:inline-flex;align-items:center;gap:4px} + .pos-be-global input{width:52px;padding:3px 6px;background:#0d1119;border:1px solid #3a4460;border-radius:6px;color:#e8ecf5;font-size:.78rem} + .pos-be-row{display:flex;align-items:center;gap:8px;flex-wrap:wrap;margin-bottom:10px;padding:8px 10px;background:#1a2030;border-radius:8px;border:1px solid #2a3348} + .pos-be-label{display:inline-flex;align-items:center;gap:6px;font-size:.78rem;color:#b8c4e8;cursor:pointer;user-select:none} + .pos-be-label input{width:15px;height:15px;accent-color:#6eb5ff} + .pos-be-offset{width:52px;padding:4px 6px;background:#0d1119;border:1px solid #3a4460;border-radius:6px;color:#e8ecf5;font-size:.78rem} + .pos-be-status{font-size:.74rem;color:#6ab88a} .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} @@ -464,7 +471,11 @@
-

实时持仓

+

实时持仓 + 一键保本默认偏移 + % + +

{% for o in order %}
+
+ + % + {% if o.breakeven_armed %}已保本{% endif %} +
成交价 @@ -1733,6 +1753,96 @@ function submitTpslEntrust(){ post(); }).catch(()=>alert('无法校验盈亏比')); } +const MANUAL_BE_OFFSET_KEY = 'manualBreakevenOffsetPct'; +function getManualBeOffsetPct(){ + const g = document.getElementById('manual-be-offset-global'); + if(g && g.value !== ''){ + const n = parseFloat(g.value); + if(Number.isFinite(n)) return n; + } + const saved = localStorage.getItem(MANUAL_BE_OFFSET_KEY); + const n = saved ? parseFloat(saved) : 0.2; + return Number.isFinite(n) ? n : 0.2; +} +function initManualBreakevenUi(){ + const g = document.getElementById('manual-be-offset-global'); + if(g){ + const saved = localStorage.getItem(MANUAL_BE_OFFSET_KEY); + if(saved) g.value = saved; + g.addEventListener('change', ()=>{ + localStorage.setItem(MANUAL_BE_OFFSET_KEY, g.value); + document.querySelectorAll('.pos-be-offset').forEach(inp=>{ + if(!inp.dataset.touched) inp.value = g.value; + }); + }); + } + document.querySelectorAll('.pos-be-offset').forEach(inp=>{ + if(!inp.dataset.touched){ + inp.value = getManualBeOffsetPct(); + } + inp.addEventListener('input', ()=>{ inp.dataset.touched = '1'; }); + }); + document.querySelectorAll('.pos-be-switch').forEach(sw=>{ + if(sw.dataset.bound) return; + sw.dataset.bound = '1'; + sw.addEventListener('change', ()=>{ + const orderId = sw.getAttribute('data-order-id'); + const card = sw.closest('.pos-card'); + const offInp = card && card.querySelector('.pos-be-offset'); + if(!sw.checked){ + if(sw.dataset.armed === '1') sw.checked = true; + return; + } + applyManualBreakeven(orderId, sw, offInp, card); + }); + }); +} +function updateOrderCardSlDisplay(card, slDisplay){ + if(!card || !slDisplay) return; + card.setAttribute('data-plan-sl', slDisplay); + const cells = card.querySelectorAll('.pos-grid .pos-cell'); + if(cells[1]){ + const val = cells[1].querySelector('.pos-value'); + if(val) val.textContent = slDisplay; + } +} +function applyManualBreakeven(orderId, sw, offInp, card){ + const offset = offInp ? parseFloat(offInp.value) : getManualBeOffsetPct(); + if(!Number.isFinite(offset) || offset < 0 || offset > 10){ + alert('偏移%须在 0~10 之间'); + sw.checked = sw.dataset.armed === '1'; + return; + } + const dirHint = card && card.getAttribute('data-direction') === 'short' ? '下' : '上'; + if(!confirm(`确认一键保本?止损将移至成交价${dirHint}移 ${offset}%(相对成交价)`)){ + sw.checked = sw.dataset.armed === '1'; + return; + } + sw.disabled = true; + fetch(`/api/order/${orderId}/manual_breakeven`, { + method:'POST', + headers:{'Content-Type':'application/json'}, + body: JSON.stringify({ offset_pct: offset }) + }).then(r=>r.json()).then(data=>{ + if(!data.ok){ + alert(data.msg || '保本失败'); + sw.checked = sw.dataset.armed === '1'; + sw.disabled = false; + return; + } + sw.dataset.armed = '1'; + sw.checked = true; + const st = document.getElementById(`pos-be-status-${orderId}`); + if(st) st.textContent = '已保本'; + if(data.stop_loss_display) updateOrderCardSlDisplay(card, data.stop_loss_display); + if(data.exchange_tpsl) paintExchangeTpslRow(orderId, data.exchange_tpsl); + else refreshPriceSnapshotConditional(); + }).catch(()=>{ + alert('保本请求失败'); + sw.checked = sw.dataset.armed === '1'; + sw.disabled = false; + }); +} function cancelExchangeTpsl(orderId, role){ const label = role === 'sl' ? '止损' : '止盈'; if(!confirm(`确认撤销交易所${label}委托?(不会平仓)`)) return; @@ -2072,6 +2182,7 @@ function refreshPriceSnapshotConditional(){ } }).catch(()=>{}); } +initManualBreakevenUi(); setInterval(refreshPriceSnapshotConditional, {{ price_refresh_seconds * 1000 }}); diff --git a/crypto_monitor_gate_bot/app.py b/crypto_monitor_gate_bot/app.py index b37824a..61df640 100644 --- a/crypto_monitor_gate_bot/app.py +++ b/crypto_monitor_gate_bot/app.py @@ -53,6 +53,7 @@ from journal_chart_lib import ( trade_review_fetch_window, trim_rows_for_trade_review, ) +from order_manual_breakeven_lib import apply_order_manual_breakeven from hub_auth import request_allowed as hub_request_allowed from history_window_lib import ( PRESET_CUSTOM, @@ -5416,6 +5417,74 @@ def api_account_snapshot(): }) +@app.route("/api/order//manual_breakeven", methods=["POST"]) +@login_required +def api_order_manual_breakeven(order_id): + data = request.get_json(silent=True) or {} + try: + offset_pct = float( + data.get("offset_pct", os.getenv("MANUAL_BREAKEVEN_OFFSET_PCT", "0.2")) + ) + except (TypeError, ValueError): + return jsonify({"ok": False, "msg": "offset_pct 无效"}), 400 + if offset_pct < 0 or offset_pct > 10: + return jsonify({"ok": False, "msg": "偏移%须在 0~10 之间"}), 400 + 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 + ok, err, new_sl = apply_order_manual_breakeven( + row, + offset_pct, + calc_stop_fn=calc_trend_manual_breakeven_stop, + round_price_fn=round_price_to_exchange, + resolve_ex_sym_fn=resolve_monitor_exchange_symbol, + get_position_fn=get_live_position_contracts, + replace_tpsl_fn=replace_active_monitor_tpsl_on_exchange, + ) + if not ok: + conn.close() + return jsonify({"ok": False, "msg": err or "保本失败"}), 400 + conn.execute( + "UPDATE order_monitors SET stop_loss=?, breakeven_armed=1, breakeven_price=? WHERE id=?", + (new_sl, new_sl, order_id), + ) + conn.commit() + sym = row["symbol"] + direction = row["direction"] + conn.close() + try: + entry = float(row["trigger_price"] or 0) + send_wechat_msg( + "\n".join( + [ + f"# ✅ {sym} 一键保本", + f"**账户:{_wechat_account_label()}**", + f"- 方向:{'做多' if direction == 'long' else '做空'}", + f"- 成交价:{format_price_for_symbol(sym, entry)}", + f"- 偏移:{offset_pct}%(相对成交价)", + f"- 新止损:{format_price_for_symbol(sym, new_sl)}", + "- 交易所:已更新止盈止损", + ] + ) + ) + except Exception: + pass + return jsonify( + { + "ok": True, + "msg": "已一键保本", + "stop_loss": new_sl, + "stop_loss_display": format_price_for_symbol(sym, new_sl), + "breakeven_armed": True, + } + ) + + @app.route("/api/price_snapshot") @login_required def api_price_snapshot(): diff --git a/crypto_monitor_gate_bot/templates/index.html b/crypto_monitor_gate_bot/templates/index.html index 6bf98ee..49f2f34 100644 --- a/crypto_monitor_gate_bot/templates/index.html +++ b/crypto_monitor_gate_bot/templates/index.html @@ -131,6 +131,13 @@ .plan-cell .val.pnl-neutral,.plan-cell .val .pnl-neutral{color:#cfd3ef} .btn-close-plan{padding:7px 14px;background:#5c1e2a;color:#ffb4b4;border:none;border-radius:8px;cursor:pointer;font-size:.82rem;font-weight:600;text-decoration:none;white-space:nowrap} .btn-close-plan:hover{filter:brightness(1.08)} + .pos-be-global{font-size:.74rem;font-weight:400;color:#8892b0;margin-left:8px;display:inline-flex;align-items:center;gap:4px} + .pos-be-global input{width:52px;padding:3px 6px;background:#0d1119;border:1px solid #3a4460;border-radius:6px;color:#e8ecf5;font-size:.76rem} + .pos-be-row{display:flex;align-items:center;gap:8px;flex-wrap:wrap;margin-bottom:10px;padding:8px 10px;background:#1a2030;border-radius:8px;border:1px solid #2a3348} + .pos-be-label{display:inline-flex;align-items:center;gap:6px;font-size:.76rem;color:#b8c4e8;cursor:pointer} + .pos-be-label input{width:15px;height:15px} + .pos-be-offset{width:52px;padding:4px 6px;background:#0d1119;border:1px solid #3a4460;border-radius:6px;color:#e8ecf5;font-size:.76rem} + .pos-be-status{font-size:.72rem;color:#6ab88a} .records-card{grid-column:1/-1} .review-card{grid-column:1/-1} .review-card-head{display:flex;justify-content:space-between;align-items:center;gap:12px;margin-bottom:10px;flex-wrap:wrap} @@ -348,11 +355,15 @@
-

实时持仓

+

实时持仓 + 一键保本默认偏移 + % + +

{% for o in order %} {% set osym = o.exchange_symbol or o.symbol %} -
+
{{ osym }} @@ -360,6 +371,15 @@
平仓
+
+ + % + {% if o.breakeven_armed %}已保本{% endif %} +
来源: 下单监控 | 风格: {{ o.trade_style or 'trend' }} | 风险: {% if o.risk_percent is not none %}{{ o.risk_percent }}%{% else %}—{% endif %}≈{{ money_fmt(o.risk_amount) }}U | {% if o.breakeven_enabled %}移动保本: 开 {{ o.breakeven_rr_trigger or '-' }}R→{{ price_fmt(osym, o.breakeven_price) }}{% else %}移动保本: 关{% endif %} @@ -1633,6 +1653,92 @@ if(_journalFormEl){ syncJournalEntryReasonOtherUi(); } refreshOrderDefaults(); +const MANUAL_BE_OFFSET_KEY = 'manualBreakevenOffsetPct'; +function getManualBeOffsetPct(){ + const g = document.getElementById('manual-be-offset-global'); + if(g && g.value !== ''){ + const n = parseFloat(g.value); + if(Number.isFinite(n)) return n; + } + const saved = localStorage.getItem(MANUAL_BE_OFFSET_KEY); + const n = saved ? parseFloat(saved) : 0.2; + return Number.isFinite(n) ? n : 0.2; +} +function initManualBreakevenUi(){ + const g = document.getElementById('manual-be-offset-global'); + if(g){ + const saved = localStorage.getItem(MANUAL_BE_OFFSET_KEY); + if(saved) g.value = saved; + g.addEventListener('change', ()=>{ + localStorage.setItem(MANUAL_BE_OFFSET_KEY, g.value); + document.querySelectorAll('.pos-be-offset').forEach(inp=>{ + if(!inp.dataset.touched) inp.value = g.value; + }); + }); + } + document.querySelectorAll('.pos-be-offset').forEach(inp=>{ + if(!inp.dataset.touched) inp.value = getManualBeOffsetPct(); + inp.addEventListener('input', ()=>{ inp.dataset.touched = '1'; }); + }); + document.querySelectorAll('.pos-be-switch').forEach(sw=>{ + if(sw.dataset.bound) return; + sw.dataset.bound = '1'; + sw.addEventListener('change', ()=>{ + const orderId = sw.getAttribute('data-order-id'); + const card = document.getElementById(`order-row-${orderId}`); + const offInp = card && card.querySelector('.pos-be-offset'); + if(!sw.checked){ + if(sw.dataset.armed === '1') sw.checked = true; + return; + } + applyManualBreakeven(orderId, sw, offInp, card); + }); + }); +} +function updateOrderCardSlDisplay(card, slDisplay){ + if(!card || !slDisplay) return; + const cells = card.querySelectorAll('.plan-card-grid .plan-cell'); + if(cells[1]){ + const val = cells[1].querySelector('.val'); + if(val) val.textContent = slDisplay; + } +} +function applyManualBreakeven(orderId, sw, offInp, card){ + const offset = offInp ? parseFloat(offInp.value) : getManualBeOffsetPct(); + if(!Number.isFinite(offset) || offset < 0 || offset > 10){ + alert('偏移%须在 0~10 之间'); + sw.checked = sw.dataset.armed === '1'; + return; + } + const dirHint = card && card.getAttribute('data-direction') === 'short' ? '下' : '上'; + if(!confirm(`确认一键保本?止损将移至成交价${dirHint}移 ${offset}%`)){ + sw.checked = sw.dataset.armed === '1'; + return; + } + sw.disabled = true; + fetch(`/api/order/${orderId}/manual_breakeven`, { + method:'POST', + headers:{'Content-Type':'application/json'}, + body: JSON.stringify({ offset_pct: offset }) + }).then(r=>r.json()).then(data=>{ + if(!data.ok){ + alert(data.msg || '保本失败'); + sw.checked = sw.dataset.armed === '1'; + sw.disabled = false; + return; + } + sw.dataset.armed = '1'; + sw.checked = true; + const st = document.getElementById(`pos-be-status-${orderId}`); + if(st) st.textContent = '已保本'; + if(data.stop_loss_display) updateOrderCardSlDisplay(card, data.stop_loss_display); + }).catch(()=>{ + alert('保本请求失败'); + sw.checked = sw.dataset.armed === '1'; + sw.disabled = false; + }); +} +initManualBreakevenUi(); refreshPriceSnapshot(); setInterval(refreshAccountSnapshot, {{ balance_refresh_seconds * 1000 }}); setInterval(refreshPriceSnapshot, {{ price_refresh_seconds * 1000 }}); diff --git a/crypto_monitor_okx/app.py b/crypto_monitor_okx/app.py index 6807b43..200e092 100644 --- a/crypto_monitor_okx/app.py +++ b/crypto_monitor_okx/app.py @@ -73,6 +73,7 @@ from key_sl_tp_lib import ( sl_tp_mode_label, sl_tp_plan_summary_text, ) +from order_manual_breakeven_lib import apply_order_manual_breakeven from key_monitor_lib import ( KEY_DIRECTION_WATCH, KEY_MONITOR_ALERT_ONLY_TYPES, @@ -5846,6 +5847,80 @@ def api_order_cancel_tpsl(order_id): return jsonify({"ok": False, "msg": friendly_exchange_error(e)}), 400 +@app.route("/api/order//manual_breakeven", methods=["POST"]) +@login_required +def api_order_manual_breakeven(order_id): + data = request.get_json(silent=True) or {} + try: + offset_pct = float( + data.get("offset_pct", os.getenv("MANUAL_BREAKEVEN_OFFSET_PCT", "0.2")) + ) + except (TypeError, ValueError): + return jsonify({"ok": False, "msg": "offset_pct 无效"}), 400 + if offset_pct < 0 or offset_pct > 10: + return jsonify({"ok": False, "msg": "偏移%须在 0~10 之间"}), 400 + 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 + ok, err, new_sl = apply_order_manual_breakeven( + row, + offset_pct, + calc_stop_fn=calc_trend_manual_breakeven_stop, + round_price_fn=round_price_to_exchange, + resolve_ex_sym_fn=resolve_monitor_exchange_symbol, + get_position_fn=get_live_position_contracts, + replace_tpsl_fn=replace_active_monitor_tpsl_on_exchange, + ) + if not ok: + conn.close() + return jsonify({"ok": False, "msg": err or "保本失败"}), 400 + conn.execute( + "UPDATE order_monitors SET stop_loss=?, breakeven_armed=1, breakeven_price=? WHERE id=?", + (new_sl, new_sl, order_id), + ) + conn.commit() + sym = row["symbol"] + direction = row["direction"] + ex_sym = resolve_monitor_exchange_symbol(row) + take_profit = float(row["take_profit"] or 0) + slots = fetch_exchange_tpsl_slots( + ex_sym, direction, plan_sl=new_sl, plan_tp=take_profit + ) + conn.close() + try: + entry = float(row["trigger_price"] or 0) + send_wechat_msg( + "\n".join( + [ + f"# ✅ {sym} 一键保本", + f"**账户:{_wechat_account_label()}**", + f"- 方向:{'做多' if direction == 'long' else '做空'}", + f"- 成交价:{format_price_for_symbol(sym, entry)}", + f"- 偏移:{offset_pct}%(相对成交价)", + f"- 新止损:{format_price_for_symbol(sym, new_sl)}", + "- 交易所:已更新止盈止损", + ] + ) + ) + except Exception: + pass + return jsonify( + { + "ok": True, + "msg": "已一键保本", + "stop_loss": new_sl, + "stop_loss_display": format_price_for_symbol(sym, new_sl), + "breakeven_armed": True, + "exchange_tpsl": slots, + } + ) + + @app.route("/api/order//place_tpsl", methods=["POST"]) @login_required def api_order_place_tpsl(order_id): diff --git a/crypto_monitor_okx/templates/index.html b/crypto_monitor_okx/templates/index.html index 00f7a2c..9a02a14 100644 --- a/crypto_monitor_okx/templates/index.html +++ b/crypto_monitor_okx/templates/index.html @@ -194,6 +194,13 @@ .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} + .pos-be-global{font-size:.76rem;font-weight:400;color:#8892b0;margin-left:10px;display:inline-flex;align-items:center;gap:4px} + .pos-be-global input{width:52px;padding:3px 6px;background:#0d1119;border:1px solid #3a4460;border-radius:6px;color:#e8ecf5;font-size:.78rem} + .pos-be-row{display:flex;align-items:center;gap:8px;flex-wrap:wrap;margin-bottom:10px;padding:8px 10px;background:#1a2030;border-radius:8px;border:1px solid #2a3348} + .pos-be-label{display:inline-flex;align-items:center;gap:6px;font-size:.78rem;color:#b8c4e8;cursor:pointer;user-select:none} + .pos-be-label input{width:15px;height:15px;accent-color:#6eb5ff} + .pos-be-offset{width:52px;padding:4px 6px;background:#0d1119;border:1px solid #3a4460;border-radius:6px;color:#e8ecf5;font-size:.78rem} + .pos-be-status{font-size:.74rem;color:#6ab88a} .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} @@ -473,7 +480,11 @@
-

实时持仓

+

实时持仓 + 一键保本默认偏移 + % + +

{% for o in order %}
+
+ + % + {% if o.breakeven_armed %}已保本{% endif %} +
成交价 @@ -1743,6 +1763,96 @@ function submitTpslEntrust(){ post(); }).catch(()=>alert('无法校验盈亏比')); } +const MANUAL_BE_OFFSET_KEY = 'manualBreakevenOffsetPct'; +function getManualBeOffsetPct(){ + const g = document.getElementById('manual-be-offset-global'); + if(g && g.value !== ''){ + const n = parseFloat(g.value); + if(Number.isFinite(n)) return n; + } + const saved = localStorage.getItem(MANUAL_BE_OFFSET_KEY); + const n = saved ? parseFloat(saved) : 0.2; + return Number.isFinite(n) ? n : 0.2; +} +function initManualBreakevenUi(){ + const g = document.getElementById('manual-be-offset-global'); + if(g){ + const saved = localStorage.getItem(MANUAL_BE_OFFSET_KEY); + if(saved) g.value = saved; + g.addEventListener('change', ()=>{ + localStorage.setItem(MANUAL_BE_OFFSET_KEY, g.value); + document.querySelectorAll('.pos-be-offset').forEach(inp=>{ + if(!inp.dataset.touched) inp.value = g.value; + }); + }); + } + document.querySelectorAll('.pos-be-offset').forEach(inp=>{ + if(!inp.dataset.touched){ + inp.value = getManualBeOffsetPct(); + } + inp.addEventListener('input', ()=>{ inp.dataset.touched = '1'; }); + }); + document.querySelectorAll('.pos-be-switch').forEach(sw=>{ + if(sw.dataset.bound) return; + sw.dataset.bound = '1'; + sw.addEventListener('change', ()=>{ + const orderId = sw.getAttribute('data-order-id'); + const card = sw.closest('.pos-card'); + const offInp = card && card.querySelector('.pos-be-offset'); + if(!sw.checked){ + if(sw.dataset.armed === '1') sw.checked = true; + return; + } + applyManualBreakeven(orderId, sw, offInp, card); + }); + }); +} +function updateOrderCardSlDisplay(card, slDisplay){ + if(!card || !slDisplay) return; + card.setAttribute('data-plan-sl', slDisplay); + const cells = card.querySelectorAll('.pos-grid .pos-cell'); + if(cells[1]){ + const val = cells[1].querySelector('.pos-value'); + if(val) val.textContent = slDisplay; + } +} +function applyManualBreakeven(orderId, sw, offInp, card){ + const offset = offInp ? parseFloat(offInp.value) : getManualBeOffsetPct(); + if(!Number.isFinite(offset) || offset < 0 || offset > 10){ + alert('偏移%须在 0~10 之间'); + sw.checked = sw.dataset.armed === '1'; + return; + } + const dirHint = card && card.getAttribute('data-direction') === 'short' ? '下' : '上'; + if(!confirm(`确认一键保本?止损将移至成交价${dirHint}移 ${offset}%(相对成交价)`)){ + sw.checked = sw.dataset.armed === '1'; + return; + } + sw.disabled = true; + fetch(`/api/order/${orderId}/manual_breakeven`, { + method:'POST', + headers:{'Content-Type':'application/json'}, + body: JSON.stringify({ offset_pct: offset }) + }).then(r=>r.json()).then(data=>{ + if(!data.ok){ + alert(data.msg || '保本失败'); + sw.checked = sw.dataset.armed === '1'; + sw.disabled = false; + return; + } + sw.dataset.armed = '1'; + sw.checked = true; + const st = document.getElementById(`pos-be-status-${orderId}`); + if(st) st.textContent = '已保本'; + if(data.stop_loss_display) updateOrderCardSlDisplay(card, data.stop_loss_display); + if(data.exchange_tpsl) paintExchangeTpslRow(orderId, data.exchange_tpsl); + else refreshPriceSnapshotConditional(); + }).catch(()=>{ + alert('保本请求失败'); + sw.checked = sw.dataset.armed === '1'; + sw.disabled = false; + }); +} function cancelExchangeTpsl(orderId, role){ const label = role === 'sl' ? '止损' : '止盈'; if(!confirm(`确认撤销交易所${label}委托?(不会平仓)`)) return; @@ -2114,6 +2224,7 @@ function refreshPriceSnapshotConditional(){ } }).catch(()=>{}); } +initManualBreakevenUi(); setInterval(refreshPriceSnapshotConditional, {{ price_refresh_seconds * 1000 }}); diff --git a/order_manual_breakeven_lib.py b/order_manual_breakeven_lib.py new file mode 100644 index 0000000..4e8962e --- /dev/null +++ b/order_manual_breakeven_lib.py @@ -0,0 +1,63 @@ +"""中控台持仓:一键保本(成交价 ± 偏移%)。""" +from typing import Any, Callable, Optional, Tuple + + +def calc_manual_breakeven_stop(direction: str, entry_price: float, offset_pct: float) -> Optional[float]: + try: + e = float(entry_price) + pct = float(offset_pct) + except (TypeError, ValueError): + return None + if e <= 0 or pct < 0: + return None + direction = (direction or "long").strip().lower() + if direction == "short": + return e * (1.0 - pct / 100.0) + return e * (1.0 + pct / 100.0) + + +def apply_order_manual_breakeven( + row: Any, + offset_pct: float, + *, + calc_stop_fn: Callable[..., Optional[float]], + round_price_fn: Callable[[str, float], Any], + resolve_ex_sym_fn: Callable[[Any], str], + get_position_fn: Callable[[str, str], Any], + replace_tpsl_fn: Callable[[Any, float, float], None], + entry_price_key: str = "trigger_price", +) -> Tuple[bool, Optional[str], Optional[float]]: + if (row["status"] or "").strip() != "active": + return False, "持仓已结束", None + entry = float(row[entry_price_key] or 0) + if entry <= 0: + return False, "缺少有效成交价", None + direction = (row["direction"] or "long").lower() + take_profit = float(row["take_profit"] or 0) + if take_profit <= 0: + return False, "缺少有效止盈价,无法更新交易所委托", None + ex_sym = resolve_ex_sym_fn(row) + pos = get_position_fn(ex_sym, direction) + if pos is None or float(pos) <= 0: + return False, "交易所当前无该方向持仓", None + new_sl_raw = calc_stop_fn(direction, entry, offset_pct) + if new_sl_raw is None: + new_sl_raw = calc_manual_breakeven_stop(direction, entry, offset_pct) + if new_sl_raw is None: + return False, "保本价计算失败", None + new_sl = round_price_fn(ex_sym, new_sl_raw) + if new_sl is None: + return False, "保本价经交易所精度舍入后无效", None + new_sl = float(new_sl) + cur_sl = float(row["stop_loss"] or 0) + if direction == "long": + if cur_sl > 0 and new_sl <= cur_sl: + return False, f"新止损 {new_sl} 未高于当前止损 {cur_sl}(多仓需上移)", None + else: + if cur_sl > 0 and new_sl >= cur_sl: + return False, f"新止损 {new_sl} 未低于当前止损 {cur_sl}(空仓需下移)", None + try: + replace_tpsl_fn(row, new_sl, take_profit) + except Exception as e: + return False, str(e), None + return True, None, new_sl