fix(hub): sync TP/SL display after trend handoff to order monitor

Use order monitor plan prices on handoff cards and fill exchange TP/SL rows when Gate shows reduce-only orders without algo labels.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
dekun
2026-06-04 19:39:11 +08:00
parent e39fac2c16
commit ed0805538f
4 changed files with 109 additions and 18 deletions
+46 -5
View File
@@ -697,28 +697,59 @@ def _tpsl_slots_to_conditional_orders(exchange_tpsl: dict, symbol: str) -> list[
if not isinstance(slot, dict): if not isinstance(slot, dict):
continue continue
trig = slot.get("trigger_price") trig = slot.get("trigger_price")
oid = slot.get("order_id") if trig is None:
if trig is None or oid is None:
continue continue
try: try:
trig_f = float(trig) trig_f = float(trig)
except (TypeError, ValueError): except (TypeError, ValueError):
continue continue
oid = slot.get("order_id")
out.append( out.append(
{ {
"id": str(oid), "id": str(oid) if oid is not None else "",
"symbol": symbol, "symbol": symbol,
"channel": "algo", "channel": "algo",
"category": "conditional", "category": "conditional",
"label": f"{label} {trig_f:g}", "label": f"{label} {trig_f:g}",
"trigger_price": trig_f, "trigger_price": trig_f,
"amount": None, "amount": slot.get("amount"),
"status": "open", "status": "open",
} }
) )
return out return out
def _exchange_tpsl_from_hub_order(hub_orders: list, symbol: str, side: str) -> dict | None:
"""趋势保本移交后:用下单监控计划价补全 exchange_tpsl(与实例页一致)。"""
side_l = (side or "").lower()
for o in hub_orders:
if not isinstance(o, dict):
continue
o_sym = o.get("exchange_symbol") or o.get("symbol") or ""
if not _symbols_match(symbol, o_sym):
continue
if (o.get("direction") or "").lower() != side_l:
continue
sl = o.get("stop_loss")
tp = o.get("take_profit")
if sl in (None, "") and tp in (None, ""):
continue
slots: dict = {"sl": None, "tp": None}
if sl not in (None, ""):
try:
slots["sl"] = {"trigger_price": float(sl), "order_id": None}
except (TypeError, ValueError):
pass
if tp not in (None, ""):
try:
slots["tp"] = {"trigger_price": float(tp), "order_id": None}
except (TypeError, ValueError):
pass
if slots["sl"] or slots["tp"]:
return slots
return None
def _find_exchange_tpsl_for_position( def _find_exchange_tpsl_for_position(
symbol: str, symbol: str,
side: str, side: str,
@@ -946,12 +977,22 @@ def _merge_flask_exchange_tpsl(agent_row: dict, snap: dict | None, hub_mon: dict
sym = p.get("symbol") or "" sym = p.get("symbol") or ""
side = p.get("side") or "" side = p.get("side") or ""
et = _find_exchange_tpsl_for_position(sym, side, order_prices, hub_orders) et = _find_exchange_tpsl_for_position(sym, side, order_prices, hub_orders)
if not et:
et = _exchange_tpsl_from_hub_order(hub_orders, sym, side)
if not et: if not et:
continue continue
p["exchange_tpsl"] = et p["exchange_tpsl"] = et
cond = p.get("conditional_orders") or [] cond = p.get("conditional_orders") or []
merged = _tpsl_slots_to_conditional_orders(et, sym)
if not cond: if not cond:
p["conditional_orders"] = _tpsl_slots_to_conditional_orders(et, sym) p["conditional_orders"] = merged
elif merged:
labels = {str(c.get("label") or "") for c in cond if isinstance(c, dict)}
for row in merged:
lbl = str(row.get("label") or "")
if lbl and not any(lbl in x or x in lbl for x in labels):
cond.append(row)
p["conditional_orders"] = cond
async def _fetch_exchange_flask_bundle( async def _fetch_exchange_flask_bundle(
+61 -12
View File
@@ -455,6 +455,11 @@
return side || "—"; return side || "—";
} }
function isTrendHandoffOrder(monitorOrder) {
const mo = monitorOrder || {};
return String(mo.trade_style || "").toLowerCase() === "trend_pullback_handoff";
}
function isTrendContext(monitorOrder, trendPlan) { function isTrendContext(monitorOrder, trendPlan) {
const mo = monitorOrder || {}; const mo = monitorOrder || {};
const tp = trendPlan || {}; const tp = trendPlan || {};
@@ -1176,20 +1181,36 @@
return null; return null;
} }
function orderTriggerOrPrice(o) {
if (!o) return null;
if (o.trigger_price != null && o.trigger_price !== "") {
const t = Number(o.trigger_price);
if (Number.isFinite(t) && t > 0) return t;
}
if (o.price != null && o.price !== "") {
const p = Number(o.price);
if (Number.isFinite(p) && p > 0) return p;
}
return null;
}
function inferTpslFromCondOrders(side, cond, entry) { function inferTpslFromCondOrders(side, cond, entry) {
const picked = pickExTpslOrders(cond); const picked = pickExTpslOrders(cond);
let sl = picked.sl && picked.sl.trigger_price != null ? picked.sl.trigger_price : ""; let sl = picked.sl ? orderTriggerOrPrice(picked.sl) : "";
let tp = picked.tp && picked.tp.trigger_price != null ? picked.tp.trigger_price : ""; let tp = picked.tp ? orderTriggerOrPrice(picked.tp) : "";
if (sl !== "" && sl != null) sl = Number(sl);
if (tp !== "" && tp != null) tp = Number(tp);
if (sl !== "" && tp !== "" && Number(sl) !== Number(tp)) { if (sl !== "" && tp !== "" && Number(sl) !== Number(tp)) {
return { sl, tp }; return { sl, tp };
} }
const triggers = (cond || []) const triggers = (cond || [])
.map(function (o) { .map(function (o) {
return { price: Number(o.trigger_price), label: o.label || "" }; const px = orderTriggerOrPrice(o);
return px == null ? null : { price: px, label: o.label || "" };
}) })
.filter(function (o) { .filter(function (o) {
return o.price != null && !Number.isNaN(o.price) && o.price > 0; return o != null;
}); });
if (!triggers.length) return { sl: sl || "", tp: tp || "" }; if (!triggers.length) return { sl: sl || "", tp: tp || "" };
@@ -1270,12 +1291,15 @@
: tp.avg_entry_price; : tp.avg_entry_price;
const entryN = entryRaw != null && entryRaw !== "" ? Number(entryRaw) : null; const entryN = entryRaw != null && entryRaw !== "" ? Number(entryRaw) : null;
const isTrend = isTrendContext(mo, trendPlan); const isTrend = isTrendContext(mo, trendPlan);
const handoff = isTrendHandoffOrder(mo);
let sl = mo.stop_loss != null && mo.stop_loss !== "" ? mo.stop_loss : ""; let sl = mo.stop_loss != null && mo.stop_loss !== "" ? mo.stop_loss : "";
let takeProfit = mo.take_profit != null && mo.take_profit !== "" ? mo.take_profit : ""; let takeProfit = mo.take_profit != null && mo.take_profit !== "" ? mo.take_profit : "";
let tpMonitored = false; let tpMonitored = false;
if (isTrend) { if (handoff) {
tpMonitored = false;
} else if (isTrend) {
tpMonitored = true; tpMonitored = true;
if (trendPlan && trendPlan.stop_loss != null && trendPlan.stop_loss !== "") { if (trendPlan && trendPlan.stop_loss != null && trendPlan.stop_loss !== "") {
sl = trendPlan.stop_loss; sl = trendPlan.stop_loss;
@@ -1301,6 +1325,7 @@
tp: takeProfit, tp: takeProfit,
tp_monitored: tpMonitored, tp_monitored: tpMonitored,
is_trend: isTrend, is_trend: isTrend,
is_handoff: handoff,
}; };
} }
@@ -1568,6 +1593,18 @@
</details>`; </details>`;
} }
function syntheticExTpslOrder(role, price, amount) {
if (price == null || price === "" || !Number.isFinite(Number(price))) return null;
return {
label: role === "sl" ? "止损" : "止盈",
trigger_price: Number(price),
price: Number(price),
amount: amount != null ? amount : null,
id: "",
channel: "plan",
};
}
function pickExTpslOrders(cond) { function pickExTpslOrders(cond) {
let sl = cond.find((o) => /^止损\b/.test(o.label || "")); let sl = cond.find((o) => /^止损\b/.test(o.label || ""));
let tp = cond.find((o) => /^止盈\b/.test(o.label || "") && !(o.label || "").includes("止盈止损")); let tp = cond.find((o) => /^止盈\b/.test(o.label || "") && !(o.label || "").includes("止盈止损"));
@@ -1586,20 +1623,32 @@
return { sl, tp }; return { sl, tp };
} }
function renderExTpslRows(exchangeId, symbol, cond, tickMap) { function renderExTpslRows(exchangeId, symbol, cond, tickMap, resolvedTpsl, contracts) {
const symAttr = esc(symbol || "").replace(/"/g, "&quot;"); const symAttr = esc(symbol || "").replace(/"/g, "&quot;");
const { sl, tp } = pickExTpslOrders(cond); let { sl, tp } = pickExTpslOrders(cond);
const plan = resolvedTpsl || {};
if (!sl && plan.sl != null && plan.sl !== "") {
sl = syntheticExTpslOrder("sl", plan.sl, contracts);
}
if (!tp && plan.tp != null && plan.tp !== "") {
tp = syntheticExTpslOrder("tp", plan.tp, contracts);
}
function row(label, o) { function row(label, o) {
if (!o) { if (!o) {
return `<div class="pos-ex-order-row"><span class="pos-ex-order-main">${label}:—</span></div>`; return `<div class="pos-ex-order-row"><span class="pos-ex-order-main">${label}:—</span></div>`;
} }
const oid = esc(o.id || "").replace(/"/g, "&quot;"); const oid = esc(o.id || "").replace(/"/g, "&quot;");
const ch = esc(o.channel || "regular").replace(/"/g, "&quot;"); const ch = esc(o.channel || "regular").replace(/"/g, "&quot;");
const trig = const px = orderTriggerOrPrice(o);
o.trigger_price != null ? fmtSymbolPrice(o.trigger_price, symbol, tickMap) : "—"; const trig = px != null ? fmtSymbolPrice(px, symbol, tickMap) : "—";
const cancelBtn =
oid && o.channel !== "plan"
? `<button type="button" class="pos-ex-cancel-btn btn-cancel-order" data-ex-id="${esc(exchangeId)}" data-symbol="${symAttr}" data-order-id="${oid}" data-channel="${ch}">撤单</button>`
: "";
const planHint = o.channel === "plan" ? '<span class="pos-ex-plan-hint">(下单监控)</span>' : "";
return `<div class="pos-ex-order-row"> return `<div class="pos-ex-order-row">
<span class="pos-ex-order-main">${label}触发 ${trig} · 数量 ${fmt(o.amount, 4)}</span> <span class="pos-ex-order-main">${label}触发 ${trig} · 数量 ${fmt(o.amount, 4)}${planHint}</span>
<button type="button" class="pos-ex-cancel-btn btn-cancel-order" data-ex-id="${esc(exchangeId)}" data-symbol="${symAttr}" data-order-id="${oid}" data-channel="${ch}">撤单</button> ${cancelBtn}
</div>`; </div>`;
} }
return row("止损", sl) + row("止盈", tp); return row("止损", sl) + row("止盈", tp);
@@ -1884,7 +1933,7 @@
</div> </div>
<div class="pos-ex-orders"> <div class="pos-ex-orders">
<div class="pos-ex-orders-title">交易所止盈止损</div> <div class="pos-ex-orders-title">交易所止盈止损</div>
${renderExTpslRows(exchangeId, symbol, cond, tickMap)} ${renderExTpslRows(exchangeId, symbol, cond, tickMap, tpsl, pos.contracts)}
</div> </div>
${renderOrdersCollapse(exchangeId, symbol, cond, reg, tickMap)} ${renderOrdersCollapse(exchangeId, symbol, cond, reg, tickMap)}
</div>`; </div>`;
+1 -1
View File
@@ -250,6 +250,6 @@
<div id="toast"></div> <div id="toast"></div>
<script src="https://unpkg.com/lightweight-charts@4.2.0/dist/lightweight-charts.standalone.production.js"></script> <script src="https://unpkg.com/lightweight-charts@4.2.0/dist/lightweight-charts.standalone.production.js"></script>
<script src="/assets/chart.js?v=20260604-market-pnl-sl-drag"></script> <script src="/assets/chart.js?v=20260604-market-pnl-sl-drag"></script>
<script src="/assets/app.js?v=20260604-hub-inst-theme"></script> <script src="/assets/app.js?v=20260604-hub-handoff-tpsl"></script>
</body> </body>
</html> </html>
+1
View File
@@ -68,6 +68,7 @@
- **价格轴**:「自动」切换是否跟随最新价缩放。 - **价格轴**:「自动」切换是否跟随最新价缩放。
- **技术指标**(可选勾选):EMA 21/55、MACD、RSI(含 30/70 参考线);副图自上而下为 MACD、RSI。 - **技术指标**(可选勾选):EMA 21/55、MACD、RSI(含 30/70 参考线);副图自上而下为 MACD、RSI。
- **持仓标记**(从监控跳转时):展示入场、止损、止盈、张数、**浮盈亏**(约 5 秒随监控快照刷新)、委托摘要;K 线上绘制对应价格线。趋势回调若止盈为程序监控,止盈栏显示「程序监控」且不与止损同价误显。 - **持仓标记**(从监控跳转时):展示入场、止损、止盈、张数、**浮盈亏**(约 5 秒随监控快照刷新)、委托摘要;K 线上绘制对应价格线。趋势回调若止盈为程序监控,止盈栏显示「程序监控」且不与止损同价误显。
- **趋势保本移交**:移交到下单监控后,持仓卡止盈/止损与「交易所止盈止损」与实例 **下单监控** 计划价一致(不再清空为程序监控占位);交易所仅市价只减仓单时也会按价格推断展示。
- **拖动止损线**:鼠标靠近红色止损线(⟷)可上下拖动;松手确认后调用与监控区相同的 **挂止盈/止损** API(先撤全部条件单再挂新止损+止盈)。须已有有效止盈价(交易所条件单或计划止盈);仅改止损、不改止盈时止盈价沿用当前上下文。 - **拖动止损线**:鼠标靠近红色止损线(⟷)可上下拖动;松手确认后调用与监控区相同的 **挂止盈/止损** API(先撤全部条件单再挂新止损+止盈)。须已有有效止盈价(交易所条件单或计划止盈);仅改止损、不改止盈时止盈价沿用当前上下文。
- **背离**:MACD/RSI 与价格简易背离标注(箭头 + 图例说明)。 - **背离**:MACD/RSI 与价格简易背离标注(箭头 + 图例说明)。