diff --git a/crypto_monitor_binance/app.py b/crypto_monitor_binance/app.py index 1f7b017..c03af31 100644 --- a/crypto_monitor_binance/app.py +++ b/crypto_monitor_binance/app.py @@ -93,6 +93,10 @@ from key_monitor_lib import ( rs_break_from_direction, run_rs_level_alert_tick, ) +from order_monitor_display_lib import ( + apply_order_price_display_fields, + enrich_order_display_fields, +) from wechat_notify_lib import build_wechat_rs_level_message, send_wechat_webhook from hub_auth import request_allowed as hub_request_allowed from history_window_lib import ( @@ -2544,12 +2548,7 @@ def enrich_order_item(raw_item, current_capital): ratio = round(margin / current_capital * 100, 2) if current_capital else 0 item["notional_value"] = notional item["position_ratio"] = ratio - item["rr_ratio"] = calc_rr_ratio( - item.get("direction") or "long", - item.get("trigger_price"), - item.get("initial_stop_loss") or item.get("stop_loss"), - item.get("take_profit"), - ) + enrich_order_display_fields(item, calc_rr_ratio) try: be = item.get("breakeven_enabled") item["breakeven_enabled"] = 0 if be is not None and int(be) == 0 else 1 @@ -6132,7 +6131,7 @@ def api_price_snapshot(): entry = float(r["trigger_price"] or 0) pnl = calc_pnl(r["direction"], entry, price, margin, leverage) if entry > 0 else 0 pnl_pct = round((pnl / margin * 100), 2) if margin > 0 else 0 - rr_ratio = calc_rr_ratio(r["direction"], entry, r["initial_stop_loss"] or r["stop_loss"], r["take_profit"]) + exchange_tpsl = {"sl": None, "tp": None} ex_sym = resolve_monitor_exchange_symbol(r) prow = _select_live_position_row(all_swap_positions, ex_sym, r["direction"]) lev_row = r["leverage"] if "leverage" in r.keys() else None @@ -6144,7 +6143,6 @@ def api_price_snapshot(): "price_display": format_price_for_symbol(ex_sym, price), "float_pnl": round(pnl, FUNDS_DECIMALS), "float_pct": pnl_pct, - "rr_ratio": rr_ratio, "plan_margin": round(margin, FUNDS_DECIMALS) if margin else None, "exchange_initial_margin": None, "exchange_notional": None, @@ -6170,11 +6168,20 @@ def api_price_snapshot(): ) if exchange_private_api_configured(): try: - payload["exchange_tpsl"] = fetch_exchange_tpsl_slots(ex_sym, r["direction"]) + 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} + exchange_tpsl = {"sl": None, "tp": None} + payload["exchange_tpsl"] = exchange_tpsl + apply_order_price_display_fields( + payload, + direction=r["direction"], + entry_price=entry, + initial_stop_loss=r["initial_stop_loss"], + stop_loss=r["stop_loss"], + take_profit=r["take_profit"], + calc_rr_ratio_fn=calc_rr_ratio, + exchange_tpsl=exchange_tpsl, + ) order_prices.append(payload) return jsonify({ diff --git a/crypto_monitor_binance/templates/index.html b/crypto_monitor_binance/templates/index.html index 7cebe2c..ed1f5a4 100644 --- a/crypto_monitor_binance/templates/index.html +++ b/crypto_monitor_binance/templates/index.html @@ -178,6 +178,7 @@ .pos-meta-item:not(:last-child)::after{content:'|';margin:0 8px;color:#3d4659} .pos-meta-on{color:#6eb5ff} .pos-meta-off{color:#7d8799} + .pos-breakeven-badge{display:inline-flex;align-items:center;padding:2px 8px;border-radius:6px;font-size:.72rem;font-weight:600;background:#1a3d2e;color:#4cd97f} .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} @@ -491,6 +492,7 @@ {% if o.breakeven_enabled %}移动保本:开 {{ o.breakeven_rr_trigger or '-' }}R→{{ price_fmt(o.symbol, o.breakeven_price) }}{% else %}移动保本:关{% endif %} +
@@ -1781,6 +1783,12 @@ function formatRrRatio(rr){ return `${body}:1`; } +function paintBreakevenBadge(orderId, secured){ + const wrap = document.getElementById(`order-be-wrap-${orderId}`); + if(!wrap) return; + wrap.style.display = secured ? "inline-flex" : "none"; +} + function paintPriceTrend(el, key, value){ if(!el) return; const prev = lastPriceMap[key]; @@ -1865,6 +1873,7 @@ function refreshPriceSnapshot(){ if(rrEl){ rrEl.innerText = formatRrRatio(o.rr_ratio); } + paintBreakevenBadge(o.id, o.sl_breakeven_secured); if(o.exchange_tpsl) paintExchangeTpslRow(o.id, o.exchange_tpsl); }); }).catch(()=>{}); @@ -2074,6 +2083,7 @@ function refreshPriceSnapshotConditional(){ } 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 || {}); }); } diff --git a/crypto_monitor_gate/app.py b/crypto_monitor_gate/app.py index 6af3fb4..32895d2 100644 --- a/crypto_monitor_gate/app.py +++ b/crypto_monitor_gate/app.py @@ -94,6 +94,10 @@ from key_monitor_lib import ( rs_break_from_direction, run_rs_level_alert_tick, ) +from order_monitor_display_lib import ( + apply_order_price_display_fields, + enrich_order_display_fields, +) from wechat_notify_lib import build_wechat_rs_level_message, send_wechat_webhook from hub_auth import request_allowed as hub_request_allowed from history_window_lib import ( @@ -2270,12 +2274,7 @@ def enrich_order_item(raw_item, current_capital): ratio = round(margin / current_capital * 100, 2) if current_capital else 0 item["notional_value"] = notional item["position_ratio"] = ratio - item["rr_ratio"] = calc_rr_ratio( - item.get("direction") or "long", - item.get("trigger_price"), - item.get("initial_stop_loss") or item.get("stop_loss"), - item.get("take_profit"), - ) + enrich_order_display_fields(item, calc_rr_ratio) try: be = item.get("breakeven_enabled") item["breakeven_enabled"] = 0 if be is not None and int(be) == 0 else 1 @@ -6253,7 +6252,7 @@ def api_price_snapshot(): entry = float(r["trigger_price"] or 0) pnl = calc_pnl(r["direction"], entry, price, margin, leverage) if entry > 0 else 0 pnl_pct = round((pnl / margin * 100), 4) if margin > 0 else 0 - rr_ratio = calc_rr_ratio(r["direction"], entry, r["initial_stop_loss"] or r["stop_loss"], r["take_profit"]) + exchange_tpsl = {"sl": None, "tp": None} ex_sym = resolve_monitor_exchange_symbol(r) prow = _select_live_position_row(all_swap_positions, ex_sym, r["direction"]) lev_row = r["leverage"] if "leverage" in r.keys() else None @@ -6263,7 +6262,6 @@ def api_price_snapshot(): "symbol": r["symbol"], "float_pnl": round(pnl, 2), "float_pct": pnl_pct, - "rr_ratio": rr_ratio, "plan_margin": round(margin, 2) if margin else None, "exchange_initial_margin": None, "exchange_notional": None, @@ -6298,16 +6296,25 @@ def api_price_snapshot(): payload["price_display"] = px_disp if exchange_private_api_configured(): try: - payload["exchange_tpsl"] = fetch_exchange_tpsl_slots( + 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} + exchange_tpsl = {"sl": None, "tp": None} + payload["exchange_tpsl"] = exchange_tpsl + apply_order_price_display_fields( + payload, + direction=r["direction"], + entry_price=entry, + initial_stop_loss=r["initial_stop_loss"], + stop_loss=r["stop_loss"], + take_profit=r["take_profit"], + calc_rr_ratio_fn=calc_rr_ratio, + exchange_tpsl=exchange_tpsl, + ) order_prices.append(payload) return jsonify({ diff --git a/crypto_monitor_gate/templates/index.html b/crypto_monitor_gate/templates/index.html index 7cebe2c..ed1f5a4 100644 --- a/crypto_monitor_gate/templates/index.html +++ b/crypto_monitor_gate/templates/index.html @@ -178,6 +178,7 @@ .pos-meta-item:not(:last-child)::after{content:'|';margin:0 8px;color:#3d4659} .pos-meta-on{color:#6eb5ff} .pos-meta-off{color:#7d8799} + .pos-breakeven-badge{display:inline-flex;align-items:center;padding:2px 8px;border-radius:6px;font-size:.72rem;font-weight:600;background:#1a3d2e;color:#4cd97f} .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} @@ -491,6 +492,7 @@ {% if o.breakeven_enabled %}移动保本:开 {{ o.breakeven_rr_trigger or '-' }}R→{{ price_fmt(o.symbol, o.breakeven_price) }}{% else %}移动保本:关{% endif %} +
@@ -1781,6 +1783,12 @@ function formatRrRatio(rr){ return `${body}:1`; } +function paintBreakevenBadge(orderId, secured){ + const wrap = document.getElementById(`order-be-wrap-${orderId}`); + if(!wrap) return; + wrap.style.display = secured ? "inline-flex" : "none"; +} + function paintPriceTrend(el, key, value){ if(!el) return; const prev = lastPriceMap[key]; @@ -1865,6 +1873,7 @@ function refreshPriceSnapshot(){ if(rrEl){ rrEl.innerText = formatRrRatio(o.rr_ratio); } + paintBreakevenBadge(o.id, o.sl_breakeven_secured); if(o.exchange_tpsl) paintExchangeTpslRow(o.id, o.exchange_tpsl); }); }).catch(()=>{}); @@ -2074,6 +2083,7 @@ function refreshPriceSnapshotConditional(){ } 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 || {}); }); } diff --git a/crypto_monitor_gate_bot/app.py b/crypto_monitor_gate_bot/app.py index dfa5ec9..7fb1d04 100644 --- a/crypto_monitor_gate_bot/app.py +++ b/crypto_monitor_gate_bot/app.py @@ -37,6 +37,10 @@ if _REPO_ROOT not in sys.path: from ai_client import ai_generate, ai_review, ai_short_advice from ai_review_lib import build_journal_ai_chart_path, collect_images_for_ai_review from form_submit_lib import check_duplicate_submit, submit_scope_add_key, submit_scope_add_order +from order_monitor_display_lib import ( + apply_order_price_display_fields, + enrich_order_display_fields, +) from journal_chart_lib import ( JOURNAL_CHART_DEFAULT_LIMIT, JOURNAL_CHART_DEFAULT_TF1, @@ -2299,12 +2303,7 @@ def enrich_order_item(raw_item, current_capital): ratio = round(margin / current_capital * 100, 2) if current_capital else 0 item["notional_value"] = notional item["position_ratio"] = ratio - item["rr_ratio"] = calc_rr_ratio( - item.get("direction") or "long", - item.get("trigger_price"), - item.get("initial_stop_loss") or item.get("stop_loss"), - item.get("take_profit"), - ) + enrich_order_display_fields(item, calc_rr_ratio) try: be = item.get("breakeven_enabled") item["breakeven_enabled"] = 0 if be is not None and int(be) == 0 else 1 @@ -3218,6 +3217,143 @@ 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_all_open_orders_for_symbol(exchange_symbol): """策略结束时:尽量撤掉该合约下条件单与普通挂单。""" cancel_gate_swap_trigger_orders(exchange_symbol) @@ -5626,7 +5762,7 @@ def api_price_snapshot(): entry = float(r["trigger_price"] or 0) pnl = calc_pnl(r["direction"], entry, price, margin, leverage) if entry > 0 else 0 pnl_pct = round((pnl / margin * 100), 4) if margin > 0 else 0 - rr_ratio = calc_rr_ratio(r["direction"], entry, r["initial_stop_loss"] or r["stop_loss"], r["take_profit"]) + exchange_tpsl = {"sl": None, "tp": None} ex_sym = resolve_monitor_exchange_symbol(r) prow = _select_live_position_row(all_swap_positions, ex_sym, r["direction"]) lev_row = r["leverage"] if "leverage" in r.keys() else None @@ -5637,7 +5773,6 @@ def api_price_snapshot(): "price": round(price, 6), "float_pnl": round(pnl, 6), "float_pct": pnl_pct, - "rr_ratio": rr_ratio, "plan_margin": round(margin, 4) if margin else None, "exchange_initial_margin": None, "exchange_notional": None, @@ -5658,6 +5793,27 @@ def api_price_snapshot(): payload["float_pct"] = ( round((payload["float_pnl"] / float(denom)) * 100, 4) if denom and float(denom) > 0 else pnl_pct ) + if exchange_private_api_configured(): + try: + exchange_tpsl = fetch_exchange_tpsl_slots( + ex_sym, + r["direction"], + plan_sl=r["stop_loss"], + plan_tp=r["take_profit"], + ) + except Exception: + exchange_tpsl = {"sl": None, "tp": None} + payload["exchange_tpsl"] = exchange_tpsl + apply_order_price_display_fields( + payload, + direction=r["direction"], + entry_price=entry, + initial_stop_loss=r["initial_stop_loss"], + stop_loss=r["stop_loss"], + take_profit=r["take_profit"], + calc_rr_ratio_fn=calc_rr_ratio, + exchange_tpsl=exchange_tpsl, + ) order_prices.append(payload) return jsonify({ diff --git a/crypto_monitor_gate_bot/templates/index.html b/crypto_monitor_gate_bot/templates/index.html index 35d62e0..77e3d14 100644 --- a/crypto_monitor_gate_bot/templates/index.html +++ b/crypto_monitor_gate_bot/templates/index.html @@ -120,6 +120,7 @@ .plan-card-title{display:flex;align-items:center;gap:8px;flex-wrap:wrap;font-size:1rem;font-weight:700;color:#f0f2ff} .plan-card-meta{font-size:.76rem;color:#8892b0;line-height:1.55;margin-bottom:10px} .plan-card-meta .accent{color:#6ab8ff} + .pos-breakeven-badge{display:inline-flex;align-items:center;padding:2px 8px;border-radius:6px;font-size:.72rem;font-weight:600;background:#1a3d2e;color:#4cd97f;margin-left:6px} .plan-card-grid{display:grid;grid-template-columns:repeat(3,minmax(0,1fr));gap:10px 14px;margin-bottom:10px} @media (max-width:720px){ .plan-card-grid{grid-template-columns:1fr} @@ -363,6 +364,7 @@
来源: 下单监控 | 风格: {{ 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 %} +
@@ -1471,6 +1473,12 @@ function paintPriceTrend(el, key, value){ lastPriceMap[key] = value; } +function paintBreakevenBadge(orderId, secured){ + const wrap = document.getElementById(`order-be-wrap-${orderId}`); + if(!wrap) return; + wrap.style.display = secured ? "inline-flex" : "none"; +} + function refreshPriceSnapshot(){ fetch("/api/price_snapshot").then(r=>r.json()).then(data=>{ const updatedEl = document.getElementById("price-last-updated"); @@ -1534,6 +1542,7 @@ function refreshPriceSnapshot(){ 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); }); }).catch(()=>{}); } diff --git a/crypto_monitor_okx/app.py b/crypto_monitor_okx/app.py index c642488..3a8737b 100644 --- a/crypto_monitor_okx/app.py +++ b/crypto_monitor_okx/app.py @@ -94,6 +94,10 @@ from key_monitor_lib import ( rs_break_from_direction, run_rs_level_alert_tick, ) +from order_monitor_display_lib import ( + apply_order_price_display_fields, + enrich_order_display_fields, +) from wechat_notify_lib import build_wechat_rs_level_message, send_wechat_webhook from hub_auth import request_allowed as hub_request_allowed from history_window_lib import ( @@ -2126,13 +2130,7 @@ def enrich_order_item(raw_item, current_capital): ratio = round(margin / current_capital * 100, 2) if current_capital else 0 item["notional_value"] = notional item["position_ratio"] = ratio - item["rr_ratio"] = calc_planned_rr_ratio( - item.get("direction") or "long", - item.get("trigger_price"), - item.get("stop_loss"), - item.get("initial_stop_loss"), - item.get("take_profit"), - ) + enrich_order_display_fields(item, calc_rr_ratio) try: be = item.get("breakeven_enabled") item["breakeven_enabled"] = 0 if be is not None and int(be) == 0 else 1 @@ -5934,13 +5932,7 @@ def api_price_snapshot(): entry = float(r["trigger_price"] or 0) pnl = calc_pnl(r["direction"], entry, price, margin, leverage) if entry > 0 else 0 pnl_pct = round((pnl / margin * 100), 4) if margin > 0 else 0 - rr_ratio = calc_planned_rr_ratio( - r["direction"], - entry, - r["stop_loss"], - r["initial_stop_loss"], - r["take_profit"], - ) + exchange_tpsl = {"sl": None, "tp": None} ex_sym = resolve_monitor_exchange_symbol(r) prow = _select_live_position_row(all_swap_positions, ex_sym, r["direction"]) lev_row = r["leverage"] if "leverage" in r.keys() else None @@ -5950,7 +5942,6 @@ def api_price_snapshot(): "symbol": r["symbol"], "float_pnl": round(pnl, 2), "float_pct": pnl_pct, - "rr_ratio": rr_ratio, "plan_margin": round(margin, 2) if margin else None, "exchange_initial_margin": None, "exchange_notional": None, @@ -5985,16 +5976,25 @@ def api_price_snapshot(): payload["price_display"] = px_disp if exchange_private_api_configured(): try: - payload["exchange_tpsl"] = fetch_exchange_tpsl_slots( + 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} + exchange_tpsl = {"sl": None, "tp": None} + payload["exchange_tpsl"] = exchange_tpsl + apply_order_price_display_fields( + payload, + direction=r["direction"], + entry_price=entry, + initial_stop_loss=r["initial_stop_loss"], + stop_loss=r["stop_loss"], + take_profit=r["take_profit"], + calc_rr_ratio_fn=calc_rr_ratio, + exchange_tpsl=exchange_tpsl, + ) order_prices.append(payload) return jsonify({ diff --git a/crypto_monitor_okx/templates/index.html b/crypto_monitor_okx/templates/index.html index 78e78a3..8e6f0c2 100644 --- a/crypto_monitor_okx/templates/index.html +++ b/crypto_monitor_okx/templates/index.html @@ -178,6 +178,7 @@ .pos-meta-item:not(:last-child)::after{content:'|';margin:0 8px;color:#3d4659} .pos-meta-on{color:#6eb5ff} .pos-meta-off{color:#7d8799} + .pos-breakeven-badge{display:inline-flex;align-items:center;padding:2px 8px;border-radius:6px;font-size:.72rem;font-weight:600;background:#1a3d2e;color:#4cd97f} .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} @@ -500,6 +501,7 @@ {% if o.breakeven_enabled %}移动保本:开 {{ o.breakeven_rr_trigger or '-' }}R→{{ price_fmt(o.symbol, o.breakeven_price) }}{% else %}移动保本:关{% endif %} +
@@ -1791,6 +1793,12 @@ function formatRrRatio(rr){ return `${body}:1`; } +function paintBreakevenBadge(orderId, secured){ + const wrap = document.getElementById(`order-be-wrap-${orderId}`); + if(!wrap) return; + wrap.style.display = secured ? "inline-flex" : "none"; +} + function paintPriceTrend(el, key, value){ if(!el) return; const prev = lastPriceMap[key]; @@ -1875,6 +1883,7 @@ function refreshPriceSnapshot(){ if(rrEl){ rrEl.innerText = formatRrRatio(o.rr_ratio); } + paintBreakevenBadge(o.id, o.sl_breakeven_secured); if(o.exchange_tpsl) paintExchangeTpslRow(o.id, o.exchange_tpsl); }); }).catch(()=>{}); @@ -2116,6 +2125,7 @@ function refreshPriceSnapshotConditional(){ } 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 || {}); }); } diff --git a/manual_trading_hub/hub.py b/manual_trading_hub/hub.py index 9af6d0d..1fcd960 100644 --- a/manual_trading_hub/hub.py +++ b/manual_trading_hub/hub.py @@ -601,6 +601,31 @@ def _find_exchange_tpsl_for_position( return None +def _merge_flask_order_price_fields(hub_mon: dict | None, snap: dict | None) -> None: + """将 price_snapshot 中的快照盈亏比、已保本状态合并进 hub_monitor.orders。""" + if not isinstance(hub_mon, dict) or not isinstance(snap, dict): + return + order_prices = snap.get("order_prices") or [] + op_by_id = { + op.get("id"): op + for op in order_prices + if isinstance(op, dict) and op.get("id") is not None + } + orders = hub_mon.get("orders") or [] + if not isinstance(orders, list): + return + for o in orders: + if not isinstance(o, dict): + continue + op = op_by_id.get(o.get("id")) + if not isinstance(op, dict): + continue + if op.get("rr_ratio") is not None: + o["rr_ratio"] = op["rr_ratio"] + if "sl_breakeven_secured" in op: + o["sl_breakeven_secured"] = bool(op["sl_breakeven_secured"]) + + def _merge_flask_exchange_tpsl(agent_row: dict, snap: dict | None, hub_mon: dict | None) -> None: """子代理挂单为空时,用实例 Flask 已算好的 exchange_tpsl 补全展示。""" ag = agent_row.get("agent") @@ -656,6 +681,8 @@ async def _assemble_board_row( client: httpx.AsyncClient, ex: dict, agent_row: dict ) -> dict: hub_mon, meta, key_prices, snap = await _fetch_exchange_flask_bundle(client, ex) + if isinstance(hub_mon, dict): + _merge_flask_order_price_fields(hub_mon, snap) _merge_flask_exchange_tpsl(agent_row, snap, hub_mon if isinstance(hub_mon, dict) else None) flask_ok = isinstance(hub_mon, dict) and hub_mon.get("ok") is not False raw_review = (ex.get("review_url") or "").strip() diff --git a/manual_trading_hub/static/app.css b/manual_trading_hub/static/app.css index 368e7ef..89d8f9c 100644 --- a/manual_trading_hub/static/app.css +++ b/manual_trading_hub/static/app.css @@ -847,6 +847,17 @@ body.market-chart-fs-open { color: var(--muted); } +.hub-pos-card .pos-breakeven-badge { + display: inline-flex; + align-items: center; + padding: 2px 8px; + border-radius: 6px; + font-size: 11px; + font-weight: 600; + background: #1a3d2e; + color: #4cd97f; +} + .hub-pos-card .pos-grid { display: grid; grid-template-columns: repeat(3, 1fr); diff --git a/manual_trading_hub/static/app.js b/manual_trading_hub/static/app.js index dae32ff..e5ad274 100644 --- a/manual_trading_hub/static/app.js +++ b/manual_trading_hub/static/app.js @@ -403,6 +403,28 @@ return reward / risk; } + function resolveSnapshotRr(mo, side, entry, sl, tp, tpMonitored) { + if (tpMonitored) return null; + const snap = mo && mo.rr_ratio; + if (snap != null && snap !== "") { + const n = Number(snap); + if (Number.isFinite(n)) return n; + } + const initSl = mo && (mo.initial_stop_loss != null ? mo.initial_stop_loss : mo.stop_loss); + return calcRrRatio(side, entry, initSl || sl, tp); + } + + function isBreakevenSecured(side, entry, monitorOrder, cond) { + const mo = monitorOrder || {}; + if (mo.sl_breakeven_secured === true || mo.sl_breakeven_secured === 1) return true; + const { sl } = pickExTpslOrders(cond); + const trig = sl && sl.trigger_price != null ? Number(sl.trigger_price) : NaN; + const e = Number(entry); + if (!Number.isFinite(trig) || !Number.isFinite(e)) return false; + if ((side || "long").toLowerCase() === "short") return trig <= e; + return trig >= e; + } + async function loadMonitorBoard() { const box = document.getElementById("monitor-grid"); const showLoading = !lastMonitorRows.length; @@ -932,7 +954,8 @@ const sl = tpsl.sl; const tp = tpsl.tp; const tpMonitored = tpsl.tp_monitored; - const rr = tpMonitored ? null : calcRrRatio(side, entry, sl, tp); + const rr = resolveSnapshotRr(mo, side, entry, sl, tp, tpMonitored); + const beSecured = isBreakevenSecured(side, entry, mo, cond); const upnl = pos.unrealized_pnl; let pnlText = fmt(upnl, 2) + "U"; if (pos.notional_usdt && upnl != null && Math.abs(Number(pos.notional_usdt)) > 1e-8) { @@ -956,6 +979,9 @@ meta.push( `移动保本:${beOn ? "开" : "关"}` ); + if (beSecured) { + meta.push(`已保本`); + } const mktAttrs = marketOpenBtnAttrs(exchangeId, exchangeKey, symbol, pos, monitorOrder, trendPlan); return `
diff --git a/order_monitor_display_lib.py b/order_monitor_display_lib.py new file mode 100644 index 0000000..43b3caf --- /dev/null +++ b/order_monitor_display_lib.py @@ -0,0 +1,111 @@ +"""实时持仓展示:开仓快照盈亏比、交易所止损是否已保本。""" +from __future__ import annotations + +from typing import Any, Callable, Optional + + +def _positive_float(value: Any) -> Optional[float]: + try: + v = float(value) + return v if v > 0 else None + except (TypeError, ValueError): + return None + + +def snapshot_stop_loss(initial_stop_loss: Any, stop_loss: Any) -> Optional[float]: + """展示盈亏比时优先用开仓时止损快照。""" + sl = _positive_float(initial_stop_loss) + if sl is not None: + return sl + return _positive_float(stop_loss) + + +def snapshot_rr( + calc_rr_ratio_fn: Callable[..., Optional[float]], + direction: str, + trigger_price: Any, + initial_stop_loss: Any, + stop_loss: Any, + take_profit: Any, +) -> Optional[float]: + entry = _positive_float(trigger_price) + sl = snapshot_stop_loss(initial_stop_loss, stop_loss) + tp = _positive_float(take_profit) + if entry is None or sl is None or tp is None: + return None + return calc_rr_ratio_fn(direction or "long", entry, sl, tp) + + +def tpsl_slot_trigger_price(slot: Any) -> Optional[float]: + if not isinstance(slot, dict): + return None + for key in ("trigger_price", "trigger_display"): + v = _positive_float(slot.get(key)) + if v is not None: + return v + return None + + +def is_sl_breakeven_secured(direction: str, entry_price: Any, exchange_sl_price: Any) -> bool: + """ + 交易所当前止损相对开仓成交价是否已保本。 + 做多:止损 >= 成交价;做空:止损 <= 成交价。 + """ + entry = _positive_float(entry_price) + sl = _positive_float(exchange_sl_price) + if entry is None or sl is None: + return False + d = (direction or "long").strip().lower() + if d == "short": + return sl <= entry + return sl >= entry + + +def sl_breakeven_from_exchange_tpsl( + direction: str, + entry_price: Any, + exchange_tpsl: Any, +) -> bool: + if not isinstance(exchange_tpsl, dict): + return False + sl_px = tpsl_slot_trigger_price(exchange_tpsl.get("sl")) + if sl_px is None: + return False + return is_sl_breakeven_secured(direction, entry_price, sl_px) + + +def enrich_order_display_fields(item: dict[str, Any], calc_rr_ratio_fn: Callable[..., Optional[float]]) -> dict[str, Any]: + item["rr_ratio"] = snapshot_rr( + calc_rr_ratio_fn, + item.get("direction") or "long", + item.get("trigger_price"), + item.get("initial_stop_loss"), + item.get("stop_loss"), + item.get("take_profit"), + ) + return item + + +def apply_order_price_display_fields( + payload: dict[str, Any], + *, + direction: str, + entry_price: Any, + initial_stop_loss: Any, + stop_loss: Any, + take_profit: Any, + calc_rr_ratio_fn: Callable[..., Optional[float]], + exchange_tpsl: Any = None, +) -> dict[str, Any]: + payload["rr_ratio"] = snapshot_rr( + calc_rr_ratio_fn, + direction, + entry_price, + initial_stop_loss, + stop_loss, + take_profit, + ) + payload["sl_breakeven_secured"] = sl_breakeven_from_exchange_tpsl( + direction, entry_price, exchange_tpsl + ) + return payload diff --git a/tests/test_order_monitor_display_lib.py b/tests/test_order_monitor_display_lib.py new file mode 100644 index 0000000..b6d0c67 --- /dev/null +++ b/tests/test_order_monitor_display_lib.py @@ -0,0 +1,50 @@ +from order_monitor_display_lib import ( + is_sl_breakeven_secured, + sl_breakeven_from_exchange_tpsl, + snapshot_rr, + snapshot_stop_loss, +) + + +def _calc_rr(direction, entry, sl, tp): + if direction == "long": + risk = entry - sl + reward = tp - entry + else: + risk = sl - entry + reward = entry - tp + if risk <= 0 or reward <= 0: + return None + return round(reward / risk, 4) + + +def test_snapshot_stop_loss_prefers_initial(): + assert snapshot_stop_loss(2.45, 2.6) == 2.45 + assert snapshot_stop_loss(None, 2.6) == 2.6 + + +def test_snapshot_rr_ignores_current_stop_after_manual_move(): + rr = snapshot_rr(_calc_rr, "long", 2.726, 2.45, 2.65, 3.3) + assert rr is not None + assert rr > 2.0 + + +def test_breakeven_long(): + assert is_sl_breakeven_secured("long", 2.726, 2.726) is True + assert is_sl_breakeven_secured("long", 2.726, 2.75) is True + assert is_sl_breakeven_secured("long", 2.726, 2.45) is False + + +def test_breakeven_short(): + assert is_sl_breakeven_secured("short", 72.73, 72.73) is True + assert is_sl_breakeven_secured("short", 72.73, 72.0) is True + assert is_sl_breakeven_secured("short", 72.73, 74.0) is False + + +def test_sl_breakeven_from_exchange_tpsl(): + ok = sl_breakeven_from_exchange_tpsl( + "long", + 2.726, + {"sl": {"trigger_price": 2.735}, "tp": {"trigger_price": 3.3}}, + ) + assert ok is True