diff --git a/crypto_monitor_gate_bot/app.py b/crypto_monitor_gate_bot/app.py index 6180fbb..df3f43a 100644 --- a/crypto_monitor_gate_bot/app.py +++ b/crypto_monitor_gate_bot/app.py @@ -237,6 +237,7 @@ ORDER_CHART_LIMIT = int(os.getenv("ORDER_CHART_LIMIT", "100")) ORDER_CHART_DIR = resolve_path(os.getenv("ORDER_CHART_DIR", "static/images/order_charts")) DAILY_OPEN_ALERT_THRESHOLD = int(os.getenv("DAILY_OPEN_ALERT_THRESHOLD", "5")) RISK_PERCENT = float(os.getenv("RISK_PERCENT", "2")) +MANUAL_MIN_PLANNED_RR = float(os.getenv("MANUAL_MIN_PLANNED_RR", "1.4")) BREAKEVEN_RR_TRIGGER = float(os.getenv("BREAKEVEN_RR_TRIGGER", "1.0")) BREAKEVEN_OFFSET_PCT = float(os.getenv("BREAKEVEN_OFFSET_PCT", "0.02")) BREAKEVEN_STEP_R = float(os.getenv("BREAKEVEN_STEP_R", "1.0")) @@ -3344,6 +3345,41 @@ def fetch_exchange_tpsl_slots(exchange_symbol, direction, plan_sl=None, plan_tp= 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 cancel_all_open_orders_for_symbol(exchange_symbol): """策略结束时:尽量撤掉该合约下条件单与普通挂单。""" cancel_gate_swap_trigger_orders(exchange_symbol) @@ -5537,6 +5573,7 @@ def render_main_page(page="trade"): price_refresh_seconds=PRICE_REFRESH_SECONDS, active_count=active_count, max_active_positions=MAX_ACTIVE_POSITIONS, + manual_min_planned_rr=MANUAL_MIN_PLANNED_RR, can_trade=can_trade, trend_plans=trend_plans, preview_snapshots=preview_snapshots, @@ -5642,8 +5679,15 @@ def api_account_snapshot(): current_capital = round(trading_capital, 2) if trading_capital is not None else round(local_current_capital, 2) recommended_capital = round(get_recommended_capital(current_capital), 2) active_count = get_active_position_count(conn) + trend_active = conn.execute( + "SELECT COUNT(*) FROM trend_pullback_plans WHERE status='active'" + ).fetchone()[0] conn.close() - can_trade = trading_day_reset_allows_new_open(now) and active_count < MAX_ACTIVE_POSITIONS + can_trade = ( + trading_day_reset_allows_new_open(now) + and active_count < MAX_ACTIVE_POSITIONS + and int(trend_active or 0) == 0 + ) available_trading_usdt = get_available_trading_usdt() return jsonify({ "funding_usdt": funding_usdt, @@ -5653,6 +5697,7 @@ def api_account_snapshot(): "active_count": active_count, "max_active_positions": MAX_ACTIVE_POSITIONS, "can_trade": can_trade, + "manual_min_planned_rr": MANUAL_MIN_PLANNED_RR, "trading_day": trading_day }) @@ -5821,6 +5866,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(): @@ -6212,12 +6351,11 @@ def add_order(): conn.close() flash("价格参数必须大于0") return redirect("/") - _min_rr = float(os.getenv("MANUAL_MIN_PLANNED_RR", "1.4")) planned_rr_manual = calc_rr_ratio(direction, live_price, stop_loss, take_profit) - if planned_rr_manual is None or planned_rr_manual < _min_rr: + if planned_rr_manual is None or planned_rr_manual < MANUAL_MIN_PLANNED_RR: conn.close() rr_txt = f"{planned_rr_manual:.4f}" if planned_rr_manual is not None else "无法计算" - flash(f"风控拒绝下单:计划盈亏比 {rr_txt}:1 低于最低要求 {_min_rr}:1") + flash(f"风控拒绝下单:计划盈亏比 {rr_txt}:1 低于最低要求 {MANUAL_MIN_PLANNED_RR}:1") return redirect("/") risk_fraction = calc_risk_fraction(direction, live_price, stop_loss) if risk_fraction is None: @@ -7684,7 +7822,7 @@ def _hub_meta_bundle(): "trend_manual_breakeven_offset_pct": TREND_PULLBACK_MANUAL_BREAKEVEN_OFFSET_PCT, "trend_pullback_dca_legs": TREND_PULLBACK_DCA_LEGS, "trend_preview_max_drift_pct": TREND_PREVIEW_MAX_BALANCE_DRIFT_PCT, - "manual_min_planned_rr": float(os.getenv("MANUAL_MIN_PLANNED_RR", "1.4")), + "manual_min_planned_rr": MANUAL_MIN_PLANNED_RR, "max_active_positions": max(1, int(os.getenv("MAX_ACTIVE_POSITIONS", "1"))), } diff --git a/crypto_monitor_gate_bot/templates/index.html b/crypto_monitor_gate_bot/templates/index.html index 521e1b1..1b4802a 100644 --- a/crypto_monitor_gate_bot/templates/index.html +++ b/crypto_monitor_gate_bot/templates/index.html @@ -113,6 +113,8 @@ .table-wrap{overflow-x:auto} .trade-dashboard{grid-column:1/-1;display:flex;flex-direction:column;gap:14px} .trade-panels-row,.dual-panel-grid,.strategy-trading-grid{display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:14px;align-items:stretch} + .dual-panel-grid .card{height:100%;display:flex;flex-direction:column} + .panel-scroll{flex:1;min-height:280px;max-height:420px;overflow:auto} .strategy-trading-grid .card{min-height:320px;display:flex;flex-direction:column} .strategy-trading-grid .panel-scroll{flex:1;overflow:auto;max-height:78vh} .trade-panels-row > .card{min-height:0;height:100%;display:flex;flex-direction:column;box-sizing:border-box} @@ -157,6 +159,7 @@ .review-card.is-fullscreen .panel-item{max-height:none;height:auto;min-height:280px} .review-card.is-fullscreen .ai-result{max-height:min(36vh, 320px)} @media (min-width: 1440px){ + .panel-scroll,.pos-list{max-height:420px} .order-card .order-live-positions{max-height:420px} .records-card .table-wrap{max-height:620px;overflow:auto} } @@ -174,6 +177,7 @@ @media (max-width: 1100px){ .grid{grid-template-columns:1fr} .trade-dashboard,.records-card,.review-card{grid-column:auto} + .dual-panel-grid{grid-template-columns:1fr} .panel-list{grid-template-columns:1fr} } @media (max-width:1200px){ @@ -197,6 +201,52 @@ .key-history h3{font-size:.88rem;color:#b8c4ff;margin-bottom:6px} .key-history .sub{font-size:.72rem;color:#8892b0;margin-bottom:6px} .key-history .list{max-height:200px} + .pos-list{display:flex;flex-direction:column;gap:10px;max-height:280px;overflow:auto} + .dual-panel-grid .pos-list-live{max-height:none;overflow:visible;flex:1 1 auto} + .dual-panel-grid .panel-scroll.pos-list-live{max-height:none;overflow:visible} + .pos-card{background:#141923;border:1px solid #2a3348;border-radius:10px;padding:12px 14px} + .pos-card-head{display:flex;align-items:center;justify-content:space-between;gap:10px;margin-bottom:10px} + .pos-meta{font-size:.74rem;color:#8b95a8;line-height:1.45;margin-bottom:12px;display:flex;flex-wrap:wrap;align-items:center;gap:4px 0} + .pos-meta-item{display:inline-flex;align-items:center} + .pos-meta-item:not(:last-child)::after{content:'|';margin:0 8px;color:#3d4659} + .pos-meta-on{color:#6eb5ff} + .pos-meta-off{color:#7d8799} + .pos-card-symbol{display:flex;align-items:center;gap:8px;flex-wrap:wrap;min-width:0} + .pos-card-symbol strong{font-size:.95rem;color:#fff;font-weight:600} + .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-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} + .pos-value{font-size:.88rem;color:#e8ecf4;font-weight:500;line-height:1.25} + .pos-val-dash{opacity:.75;color:#8b95a8} + .pos-value.price-up{color:#4cd97f} + .pos-value.price-down{color:#ff6666} + .pos-value.price-flat{color:#e8ecf4} + .pos-footer{display:flex;flex-wrap:wrap;gap:14px 18px;font-size:.75rem;color:#6d7689} + .pos-empty{padding:18px;text-align:center;color:#8892b0;font-size:.85rem;background:#141923;border:1px dashed #2a3348;border-radius:10px} + @media (max-width:520px){.pos-grid{grid-template-columns:repeat(2,1fr)}} .stats-card{grid-column:1/-1;margin-top:14px} .stats-card .stats-toggle{background:#1f3a5a;color:#8fc8ff;border:none;border-radius:8px;padding:6px 10px;cursor:pointer} .stats-card.collapsed .stats-content{display:none} @@ -214,7 +264,7 @@ - + {% macro period_metrics_cells(s) %}
开单次数
{{ s.opens_count }}
平仓笔数
{{ s.closed_count }}
@@ -310,9 +360,8 @@
{% if page == 'trade' %} -
-
-
+
+

机器人下单监控(单仓)

{% if focus_order_id %} @@ -322,9 +371,9 @@ {% endif %}
- 规则:最大同时持仓 {{ max_active_positions }}(与 Gate 主站 MAX_ACTIVE_POSITIONS 一致,当前 active {{ active_count }});与「趋势回调」计划互斥;BTC {{ btc_leverage }}x / 山寨 {{ alt_leverage }}x; + 规则:最大同时持仓 {{ max_active_positions }}(当前 active {{ active_count }});与「趋势回调」计划互斥;BTC {{ btc_leverage }}x / 山寨 {{ alt_leverage }}x; {% if can_trade %}可开仓{% else %}不可开仓(持仓达上限、有趋势回调计划,或未到北京时间 {{ reset_hour }}:00){% endif %}; - 按风险比例自动计算仓位 + 人工开仓盈亏比不得低于 {{ manual_min_planned_rr }}:1
计仓模式:{{ position_sizing_mode_label }}(仅 .env POSITION_SIZING_MODE,须无仓后重启) @@ -365,7 +414,9 @@ + {% if position_sizing_mode != 'full_margin' %} + {% endif %} @@ -379,64 +430,120 @@ -
-

实时持仓

-
+
+
+

实时持仓

+
{% for o in order %} - {% set osym = o.exchange_symbol or o.symbol %} -
-
-
- {{ osym }} - {{ '做多' if o.direction == 'long' else '做空' }} +
+
+
+ {{ o.exchange_symbol or o.symbol }} + {{ '做多' if o.direction == 'long' else '做空' }}
- 平仓 -
-
- 来源: 下单监控 | 风格: {{ 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 %} - -
-
-
- 成交价 - {{ price_fmt(osym, o.trigger_price) }} -
-
- 止损 - {{ price_fmt(osym, o.stop_loss) }} -
-
- 止盈 - {{ price_fmt(osym, o.take_profit) }} -
-
- 盈亏比 - {% if o.rr_ratio is not none %}{{ '%.2f'|format(o.rr_ratio) }}:1{% else %}—{% endif %} -
-
- 标记价 - - -
-
- 浮盈亏 - - +
+ + 平仓
-
- 保证金: - - | 计划基数: {{ money_fmt(o.margin_capital) }}U - | 杠杆: {{ o.leverage }}x - | 仓位占比: {{ o.position_ratio }}% +
+ 来源: {{ o.monitor_type|default('下单监控', true) }}{% if o.key_signal_type %} · {{ o.key_signal_type }}{% endif %} + 风格: {{ o.trade_style or 'trend' }} + 风险: {{ o.risk_percent or '-' }}%≈{{ money_fmt(o.risk_amount) if o.risk_amount is not none else '-' }}U + + {% if o.breakeven_enabled %}移动保本:开 {{ o.breakeven_rr_trigger or '-' }}R→{{ price_fmt(o.symbol, o.breakeven_price) }}{% else %}移动保本:关{% endif %} + + +
+
+
+ 成交价 + {{ price_fmt(o.symbol, o.trigger_price) }} +
+
+ 止损 + {% if o.stop_loss %} + {{ price_fmt(o.symbol, o.stop_loss) }} + {% else %} + + {% endif %} +
+
+ 止盈 + {% if o.take_profit %} + {{ price_fmt(o.symbol, o.take_profit) }} + {% else %} + + {% endif %} +
+
+ 盈亏比 + {% if o.rr_ratio is not none %}{{ '%g'|format(o.rr_ratio) }}:1{% else %}-:1{% endif %} +
+
+ 标记价 + - +
+
+ 浮盈亏 + - +
+
+ +
+
交易所止盈止损
+
+ 止损:加载中… + +
+
+ 止盈:加载中… + +
{% else %} -
暂无机器人持仓
+
暂无持仓
{% endfor %} +
+
+ +
+
+

挂止盈止损

+

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

+
+ +
+
+ + +
+
+ + +
+
+ +
-
+
{% elif page in ('strategy', 'strategy_trend', 'strategy_roll') %} {% set can_trade_trend = can_trade %} @@ -1462,20 +1569,6 @@ if(keyForm){ }); }); } -const addOrderForm = document.getElementById("add-order-form"); -if(addOrderForm){ - addOrderForm.addEventListener("submit", function(ev){ - if(addOrderForm.dataset.submitOnce === "1"){ - addOrderForm.dataset.submitOnce = "0"; - return; - } - ev.preventDefault(); - if(window.FormSubmitGuard && FormSubmitGuard.isLocked(addOrderForm)) return; - addOrderForm.dataset.submitOnce = "1"; - if(window.FormSubmitGuard) FormSubmitGuard.nativeSubmitOnce(addOrderForm, "开仓提交中…"); - else addOrderForm.submit(); - }); -} // 复盘/AI列表:初次进入页面后再异步刷新一次,避免浏览器 bfcache/重定向后仍显示旧缓存 setTimeout(() => { if(document.getElementById("journal-list")) loadJournals(); @@ -1484,6 +1577,132 @@ setTimeout(() => { let latestAvailableUsdt = null; const lastPriceMap = {}; +const MANUAL_MIN_PLANNED_RR = {{ manual_min_planned_rr }}; +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; + if(direction === 'short'){ + if(s <= e || t >= e) return null; + return (e - t) / (s - e); + } + if(s >= e || t <= e) return null; + return (t - e) / (e - s); +} +function calcClientRrFromPct(slPct, tpPct){ + const sl = Number(slPct), tp = Number(tpPct); + if(!Number.isFinite(sl) || !Number.isFinite(tp) || sl <= 0 || tp <= 0) return null; + return tp / sl; +} +function rejectManualOrderRr(rr){ + if(rr !== null && rr >= MANUAL_MIN_PLANNED_RR) return false; + 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 = formatPriceForInput(card.getAttribute('data-plan-sl') || ''); + if(tpEl) tpEl.value = formatPriceForInput(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"; + if(window.FormSubmitGuard){ + if(FormSubmitGuard.isLocked(form)){ + FormSubmitGuard.setSubmitLabel(form, "开仓提交中…"); + } else { + FormSubmitGuard.lock(form, "开仓提交中…"); + } + } + form.submit(); +} function formatSigned(v, digits=4){ if(v === null || typeof v === "undefined" || Number.isNaN(Number(v))) return "-"; @@ -1491,6 +1710,13 @@ function formatSigned(v, digits=4){ const sign = n > 0 ? "+" : ""; return `${sign}${n.toFixed(digits)}`; } +function formatRrRatio(rr){ + if(rr === null || typeof rr === "undefined") return "-:1"; + const n = Number(rr); + if(Number.isNaN(n)) return "-:1"; + const body = Number.isInteger(n) ? String(n) : String(parseFloat(n.toFixed(2))); + return `${body}:1`; +} function paintPriceTrend(el, key, value){ if(!el) return; @@ -1512,71 +1738,71 @@ function paintBreakevenBadge(orderId, secured){ wrap.style.display = secured ? "inline-flex" : "none"; } -function refreshPriceSnapshot(){ +function refreshPriceSnapshotConditional(){ + const page = document.body.getAttribute("data-page") || ""; fetch("/api/price_snapshot").then(r=>r.json()).then(data=>{ const updatedEl = document.getElementById("price-last-updated"); - if(data.updated_at && updatedEl){ - updatedEl.innerText = data.updated_at; - } - (data.key_prices || []).forEach(k=>{ - const pEl = document.getElementById(`key-price-${k.id}`); - if(pEl){ - pEl.innerText = Number(k.price).toFixed(6); - paintPriceTrend(pEl, `k-${k.id}`, Number(k.price)); - } - const upEl = document.getElementById(`key-up-diff-${k.id}`); - if(upEl){ - upEl.innerText = `${formatSigned(k.upper_diff, 4)} (${formatSigned(k.upper_pct, 2)}%)`; - } - const lowEl = document.getElementById(`key-low-diff-${k.id}`); - if(lowEl){ - lowEl.innerText = `${formatSigned(k.lower_diff, 4)} (${formatSigned(k.lower_pct, 2)}%)`; - } - const gateEl = document.getElementById(`key-gate-${k.id}`); - if(gateEl){ - gateEl.innerText = k.gate_summary || "-"; - gateEl.style.color = k.gate_ok ? "#4cd97f" : "#ff8f8f"; - } - const gateMetricEl = document.getElementById(`key-gate-metrics-${k.id}`); - if(gateMetricEl){ - gateMetricEl.innerText = k.gate_metrics || ""; - } - }); - (data.order_prices || []).forEach(o=>{ - const pEl = document.getElementById(`order-price-${o.id}`); - if(pEl){ - const hasMark = (()=>{ const x = o.exchange_mark_price; if(x===null||x===undefined||x==="")return false; const n=Number(x); return !Number.isNaN(n); })(); - const px = hasMark ? Number(o.exchange_mark_price) : Number(o.price); - const decimals = hasMark ? 8 : 6; - pEl.innerText = px.toFixed(decimals); - paintPriceTrend(pEl, `o-${o.id}`, px); - } - const exM = document.getElementById(`order-ex-margin-${o.id}`); - if(exM){ - const mv = o.exchange_initial_margin; - const mn = (mv === null || mv === undefined || mv === "") ? NaN : Number(mv); - if(!Number.isNaN(mn)){ - exM.innerText = `${mn.toFixed(2)}U`; - } else { - const prc = (typeof data.positions_raw_count === "number") ? data.positions_raw_count : null; - exM.innerText = (prc === 0) ? "无仓数据" : "-"; + if(data.updated_at && updatedEl) updatedEl.innerText = data.updated_at; + if(page === "key_monitor"){ + (data.key_prices || []).forEach(k=>{ + const pEl = document.getElementById(`key-price-${k.id}`); + if(pEl){ + pEl.innerText = Number(k.price).toFixed(6); + paintPriceTrend(pEl, `k-${k.id}`, Number(k.price)); } - } - const pnlEl = document.getElementById(`order-pnl-${o.id}`); - if(pnlEl){ - pnlEl.innerText = `${formatSigned(o.float_pnl, 2)}U (${formatSigned(o.float_pct, 2)}%)`; - pnlEl.classList.remove("pnl-profit","pnl-loss","pnl-neutral"); - const fp = Number(o.float_pnl); - if(fp > 0) pnlEl.classList.add("pnl-profit"); - else if(fp < 0) pnlEl.classList.add("pnl-loss"); - else pnlEl.classList.add("pnl-neutral"); - } - const rrEl = document.getElementById(`order-rr-${o.id}`); - if(rrEl){ - rrEl.innerText = (typeof o.rr_ratio !== "undefined" && o.rr_ratio !== null) ? `${Number(o.rr_ratio).toFixed(2)}:1` : "-"; - } - paintBreakevenBadge(o.id, o.sl_breakeven_secured); - }); + const upEl = document.getElementById(`key-up-diff-${k.id}`); + if(upEl) upEl.innerText = `${formatSigned(k.upper_diff, 4)} (${formatSigned(k.upper_pct, 2)}%)`; + const lowEl = document.getElementById(`key-low-diff-${k.id}`); + if(lowEl) lowEl.innerText = `${formatSigned(k.lower_diff, 4)} (${formatSigned(k.lower_pct, 2)}%)`; + const gateEl = document.getElementById(`key-gate-${k.id}`); + if(gateEl){ + gateEl.innerText = k.gate_summary || "-"; + gateEl.style.color = k.gate_ok ? "#4cd97f" : "#ff8f8f"; + } + const gateMetricEl = document.getElementById(`key-gate-metrics-${k.id}`); + if(gateMetricEl) gateMetricEl.innerText = k.gate_metrics || ""; + }); + } + if(page === "trade"){ + (data.order_prices || []).forEach(o=>{ + const pEl = document.getElementById(`order-price-${o.id}`); + if(pEl){ + const hasMark = (()=>{ const x = o.exchange_mark_price; if(x===null||x===undefined||x==="")return false; const n=Number(x); return !Number.isNaN(n); })(); + let disp = ""; + if(hasMark && o.exchange_mark_price_display) disp = o.exchange_mark_price_display; + else if(o.price_display) disp = o.price_display; + else { + const px = hasMark ? Number(o.exchange_mark_price) : Number(o.price); + disp = Number.isFinite(px) ? px.toFixed(hasMark ? 8 : 6) : "-"; + } + pEl.innerText = disp; + const pxNum = hasMark ? Number(o.exchange_mark_price) : Number(o.price); + paintPriceTrend(pEl, `o-${o.id}`, Number.isFinite(pxNum) ? pxNum : px); + } + const exM = document.getElementById(`order-ex-margin-${o.id}`); + if(exM){ + const mv = o.exchange_initial_margin; + const mn = (mv === null || mv === undefined || mv === "") ? NaN : Number(mv); + if(!Number.isNaN(mn)) exM.innerText = `${mn.toFixed(2)}U`; + else { + const prc = (typeof data.positions_raw_count === "number") ? data.positions_raw_count : null; + exM.innerText = (prc === 0) ? "无仓数据" : "-"; + } + } + const pnlEl = document.getElementById(`order-pnl-${o.id}`); + if(pnlEl){ + pnlEl.innerText = `${formatSigned(o.float_pnl, 2)}U (${formatSigned(o.float_pct, 2)}%)`; + pnlEl.classList.remove("price-up","price-down","price-flat","pnl-profit","pnl-loss","pnl-neutral"); + if(Number(o.float_pnl) > 0) pnlEl.classList.add("price-up"); + else if(Number(o.float_pnl) < 0) pnlEl.classList.add("price-down"); + else pnlEl.classList.add("price-flat"); + } + const rrEl = document.getElementById(`order-rr-${o.id}`); + if(rrEl) rrEl.innerText = formatRrRatio(o.rr_ratio); + paintBreakevenBadge(o.id, o.sl_breakeven_secured); + paintExchangeTpslRow(o.id, o.exchange_tpsl || {}); + }); + } }).catch(()=>{}); } @@ -1620,11 +1846,12 @@ function refreshAccountSnapshot(){ if (typeof data.available_trading_usdt !== "undefined" && data.available_trading_usdt !== null) { latestAvailableUsdt = Number(data.available_trading_usdt); } - const canTradeText = data.can_trade ? "可开仓" : "不可开仓(有持仓或未到北京时间 {{ reset_hour }}:00)"; + const canTradeText = data.can_trade ? "可开仓" : `不可开仓(持仓 ${data.active_count||0}/${data.max_active_positions||{{ max_active_positions }}}、有趋势回调计划,或未到北京时间 {{ reset_hour }}:00)`; const tip = document.getElementById("order-rule-tip"); const avail = (latestAvailableUsdt !== null && !Number.isNaN(latestAvailableUsdt)) ? `;交易账户可用约${latestAvailableUsdt.toFixed(2)}U` : ""; + const minRr = data.manual_min_planned_rr != null ? data.manual_min_planned_rr : MANUAL_MIN_PLANNED_RR; if(tip){ - tip.innerText = `规则:单仓;BTC {{ btc_leverage }}x / 山寨 {{ alt_leverage }}x;${canTradeText}${avail}`; + tip.innerText = `规则:最大同时持仓 ${data.max_active_positions || {{ max_active_positions }}}(当前 active ${data.active_count||0});与「趋势回调」计划互斥;BTC {{ btc_leverage }}x / 山寨 {{ alt_leverage }}x;${canTradeText}${avail};人工开仓盈亏比不得低于 ${minRr}:1`; } }).catch(()=>{}); } @@ -1676,10 +1903,65 @@ if(_journalFormEl){ if(_jErSel) _jErSel.addEventListener("change", syncJournalEntryReasonOtherUi); syncJournalEntryReasonOtherUi(); } +const addOrderForm = document.getElementById("add-order-form"); +if(addOrderForm){ + addOrderForm.addEventListener("submit", function(ev){ + if(addOrderForm.dataset.rrOk === "1"){ + addOrderForm.dataset.rrOk = "0"; + return; + } + 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 symbol = ((document.getElementById("order-symbol")||{}).value || "").trim(); + if(mode === "pct"){ + if(window.FormSubmitGuard) FormSubmitGuard.lock(addOrderForm, "校验盈亏比…"); + const rr = calcClientRrFromPct( + (document.getElementById("order-sl-pct")||{}).value, + (document.getElementById("order-tp-pct")||{}).value + ); + if(rejectManualOrderRr(rr)){ + if(window.FormSubmitGuard) FormSubmitGuard.unlock(addOrderForm); + return; + } + allowManualOrderSubmit(addOrderForm); + return; + } + const sl = Number((document.getElementById("order-sl")||{}).value); + const tp = Number((document.getElementById("order-tp")||{}).value); + let entry = sl; + if(window.FormSubmitGuard) FormSubmitGuard.lock(addOrderForm, "校验盈亏比…"); + if(!symbol){ + if(rejectManualOrderRr(calcClientRr(direction, entry, sl, tp))){ + if(window.FormSubmitGuard) FormSubmitGuard.unlock(addOrderForm); + return; + } + allowManualOrderSubmit(addOrderForm); + return; + } + fetch(`/api/order_defaults?symbol=${encodeURIComponent(symbol)}&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))){ + if(window.FormSubmitGuard) FormSubmitGuard.unlock(addOrderForm); + return; + } + allowManualOrderSubmit(addOrderForm); + }) + .catch(()=>{ + alert("无法校验盈亏比,请稍后重试"); + if(window.FormSubmitGuard) FormSubmitGuard.unlock(addOrderForm); + }); + }); +} + refreshOrderDefaults(); -refreshPriceSnapshot(); +refreshPriceSnapshotConditional(); setInterval(refreshAccountSnapshot, {{ balance_refresh_seconds * 1000 }}); -setInterval(refreshPriceSnapshot, {{ price_refresh_seconds * 1000 }}); +setInterval(refreshPriceSnapshotConditional, {{ price_refresh_seconds * 1000 }}); \ No newline at end of file