@@ -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