fix(hub): align trend pullback card with instance layout

Match strategy page plan card: 3x3 metrics, DCA table, breakeven row, snapshot footer; PnL percent on plan margin.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
dekun
2026-06-04 10:51:13 +08:00
parent 3fb2023efb
commit 7037dc2334
3 changed files with 314 additions and 142 deletions
+148 -59
View File
@@ -345,6 +345,76 @@
return { text: pnlText, cls: pnlCls(upnl) };
}
/** 与实例策略页一致:浮盈亏 % = 浮盈亏 / 计划保证金 */
function formatTrendPlanFloatingPnl(upnl, planMargin) {
if (upnl == null || !Number.isFinite(Number(upnl))) {
return { text: "—", cls: "" };
}
let pnlText = fmt(upnl, 2) + "U";
const margin = Number(planMargin);
if (Number.isFinite(margin) && margin > 0) {
const pct = (Number(upnl) / margin) * 100;
pnlText += ` (${pct >= 0 ? "+" : ""}${pct.toFixed(2)}%)`;
}
const n = Number(upnl);
let cls = "pnl-neutral";
if (n > 0) cls = "pnl-profit";
else if (n < 0) cls = "pnl-loss";
return { text: pnlText, cls };
}
function renderDirectionBadge(side) {
const s = normSide(side);
const label = sideDirLabel(side);
const cls = s === "long" ? "direction-long" : s === "short" ? "direction-short" : "";
if (!cls) return esc(String(label));
return `<span class="badge ${cls}">${esc(label)}</span>`;
}
function resolveTrendDcaLevels(t) {
if (Array.isArray(t.dca_levels) && t.dca_levels.length) return t.dca_levels;
const plan = t || {};
let grid = [];
let legAmounts = [];
try {
grid = JSON.parse(plan.grid_prices_json || "[]");
if (!Array.isArray(grid)) grid = [];
} catch (_e) {
grid = [];
}
try {
legAmounts = JSON.parse(plan.leg_amounts_json || "[]");
if (!Array.isArray(legAmounts)) legAmounts = [];
} catch (_e2) {
legAmounts = [];
}
const legsDone = Number(plan.legs_done) || 0;
const dcaLegs = Number(plan.dca_legs) || 0;
const firstDone = Number(plan.first_order_done) !== 0;
const out = [
{
label: "首仓",
price: null,
contracts: plan.first_order_amount,
status: firstDone ? "done" : "pending",
status_label: firstDone ? "已开仓" : "待开仓",
},
];
const n = Math.max(grid.length, legAmounts.length, dcaLegs);
for (let idx = 0; idx < n; idx += 1) {
const legI = idx + 1;
const done = legI <= legsDone;
out.push({
label: `补仓${legI}`,
price: idx < grid.length ? grid[idx] : null,
contracts: idx < legAmounts.length ? legAmounts[idx] : null,
status: done ? "done" : "pending",
status_label: done ? "已补仓" : "待补仓",
});
}
return out;
}
function pnlCls(v) {
const n = Number(v);
if (!Number.isFinite(n) || n === 0) return "";
@@ -1333,6 +1403,8 @@
btn.onclick = (ev) => {
ev.preventDefault();
ev.stopPropagation();
const msg = (btn.dataset.confirm || "").trim();
if (msg && !confirm(msg)) return;
openInstance(btn.dataset.exId, btn.dataset.next || "/", {
newTab: ev.ctrlKey || ev.metaKey,
});
@@ -1514,7 +1586,7 @@
}
function renderTrendDcaTable(t, tickMap) {
const levels = Array.isArray(t.dca_levels) ? t.dca_levels : [];
const levels = resolveTrendDcaLevels(t);
if (!levels.length) return "";
const sym = t.exchange_symbol || t.symbol || "";
const rows = levels
@@ -1525,7 +1597,7 @@
: "—";
const amt =
lv.contracts != null && lv.contracts !== "" ? esc(String(lv.contracts)) : "—";
const stCls = lv.status === "done" ? "dca-done" : "dca-pending";
const stCls = lv.status === "done" ? "st-done" : "st-pending";
const label = lv.status_label || (lv.status === "done" ? "已补仓" : "待补仓");
return `<tr>
<td>${esc(lv.label || lv.leg_key || "—")}</td>
@@ -1535,16 +1607,16 @@
</tr>`;
})
.join("");
return `<div class="hub-trend-dca-block">
<div class="hub-trend-dca-title">补仓计划明细</div>
<table class="hub-trend-dca-table">
<thead><tr><th>档位</th><th>触发价</th><th>张数</th><th>状态</th></tr></thead>
<tbody>${rows}</tbody>
return `<div class="plan-dca-block">
<div class="plan-dca-title">补仓计划明细</div>
<table class="plan-dca-table">
<tr><th>档位</th><th>触发价</th><th>张数</th><th>状态</th></tr>
${rows}
</table>
</div>`;
}
function renderTrendPlanCard(t, tickMap, pos) {
function renderTrendPlanCard(t, tickMap, pos, exchangeRow) {
const sym = t.exchange_symbol || t.symbol || "";
const side = (t.direction || "long").toLowerCase();
const sl = t.stop_loss_display || fmtSymbolPrice(t.stop_loss, sym, tickMap);
@@ -1564,66 +1636,79 @@
? esc(legsDone)
: "—";
const upnlTrend = resolveTrendFloatingPnl(pos, t);
const notional =
pos && pos.notional_usdt != null
? pos.notional_usdt
: t.plan_margin_capital != null
? Number(t.plan_margin_capital) * Number(t.leverage || 1)
: null;
const pnlFmt = formatFloatingPnlText(upnlTrend, notional);
const pnlInner =
const pnlFmt = formatTrendPlanFloatingPnl(upnlTrend, t.plan_margin_capital);
const pnlVal =
pnlFmt.text === "—"
? "—"
: `<span class="pos-value ${pnlFmt.cls}">${esc(pnlFmt.text)}</span>`;
const sizing = resolveTrendSizingFooter({}, t, true);
const levTxt =
sizing.leverage != null && sizing.leverage !== "" ? `${esc(sizing.leverage)}x` : "—";
const baseTxt =
sizing.planBase != null && sizing.planBase !== "" ? `${fmt(sizing.planBase, 2)}U` : "—";
const ratioTxt =
sizing.positionRatio != null && sizing.positionRatio !== ""
? `${fmt(sizing.positionRatio, 2)}%`
: "—";
: `<span class="val ${pnlFmt.cls}">${esc(pnlFmt.text)}</span>`;
const riskTxt =
t.risk_percent != null && t.risk_percent !== "" ? `${esc(t.risk_percent)}%` : "—";
const footHtml = `<div class="hub-trend-plan-foot pos-footer">
<span>杠杆: ${levTxt}</span>
<span>计划基数: ${baseTxt}</span>
<span>仓位占比: ${ratioTxt}</span>
</div>`;
const snapTxt =
t.snapshot_available_usdt != null && t.snapshot_available_usdt !== ""
? `${fmt(t.snapshot_available_usdt, 2)}U`
: "—";
const marginTxt =
t.plan_margin_capital != null && t.plan_margin_capital !== ""
? `${fmt(t.plan_margin_capital, 2)}U`
: "—";
const levTxt = t.leverage != null && t.leverage !== "" ? `${esc(t.leverage)}x` : "—";
const bePct =
t.breakeven_offset_pct != null && t.breakeven_offset_pct !== ""
? esc(t.breakeven_offset_pct)
: "0.3";
const exId = exchangeRow && exchangeRow.id != null ? esc(exchangeRow.id) : "";
const canOpen = !!(exchangeRow && (exchangeRow.flask_url_browser || exchangeRow.flask_url));
const endBtn = canOpen
? `<a href="#" class="btn-close-plan btn-open-instance" data-ex-id="${exId}" data-next="/stop_trend_pullback/${esc(t.id)}" data-confirm="结束计划:市价平仓并撤掉该合约全部挂单,确定?">结束计划</a>`
: "";
const beBtn = canOpen
? `<a href="#" class="hub-plan-be-btn btn-open-instance" data-ex-id="${exId}" data-next="/strategy">保本移交下单监控</a>`
: `<span class="hub-plan-be-btn hub-plan-be-btn--static">保本移交下单监控</span>`;
const beApplied =
t.breakeven_applied
? `<span class="hub-plan-be-done">已保本 ${esc(String(t.breakeven_applied_at || "").slice(0, 16))}</span>`
: "";
const dcaHtml = renderTrendDcaTable(t, tickMap);
return `<div class="hub-trend-plan-card hub-pos-card hub-trend-plan-card--horizontal">
<div class="hub-trend-plan-body">
<div class="hub-trend-plan-head">
<span class="hub-trend-plan-title">#${esc(t.id)} ${esc(sym)} ${renderDirectionHtml(t.direction)}</span>
<span class="hub-trend-plan-status">${esc(t.status || "active")}</span>
</div>
<div class="hub-trend-plan-meta">
<span>来源: 趋势回调计划</span>
<span>风险: ${riskTxt}</span>
<span>${esc(trendAddZoneLabel(t.direction))} ${esc(addZone)}</span>
<span>已补仓 <strong>${legsTxt}</strong></span>
</div>
<div class="hub-trend-plan-metrics-row">
<div class="pos-grid hub-trend-plan-grid">
<div class="pos-cell"><span class="pos-label">均价</span><span class="pos-value">${esc(avg)}</span></div>
<div class="pos-cell"><span class="pos-label">止损</span><span class="pos-value">${esc(sl)}</span></div>
<div class="pos-cell"><span class="pos-label">止盈</span><span class="pos-value pos-tp-program">程序监控 · ${esc(tp)}</span></div>
<div class="pos-cell"><span class="pos-label">盈亏比</span><span class="pos-value">${esc(rrTxt)}</span></div>
<div class="pos-cell"><span class="pos-label">标记价</span><span class="pos-value">${esc(mark)}</span></div>
<div class="pos-cell"><span class="pos-label">浮盈亏</span>${pnlInner}</div>
</div>
${footHtml}
return `<div class="plan-position-card hub-trend-plan-card">
<div class="plan-card-head">
<div class="plan-card-title">
<span>#${esc(t.id)} ${esc(sym)}</span>
${renderDirectionBadge(t.direction)}
</div>
${endBtn}
</div>
<div class="plan-card-meta">
来源: 趋势回调计划 | 风险: ${riskTxt}
<span class="accent">${esc(trendAddZoneLabel(t.direction))} ${esc(addZone)}</span>
已补仓 <strong>${legsTxt}</strong>
</div>
<div class="plan-card-grid">
<div class="plan-cell"><span class="lbl">均价</span><span class="val">${esc(avg)}</span></div>
<div class="plan-cell"><span class="lbl">止损</span><span class="val">${esc(sl)}</span></div>
<div class="plan-cell"><span class="lbl">止盈</span><span class="val">${esc(tp)}</span></div>
<div class="plan-cell"><span class="lbl">盈亏比</span><span class="val">${esc(rrTxt)}</span></div>
<div class="plan-cell"><span class="lbl">标记价</span><span class="val">${esc(mark)}</span></div>
<div class="plan-cell"><span class="lbl">浮盈亏</span>${pnlVal}</div>
</div>
${dcaHtml}
<div class="plan-card-meta hub-plan-breakeven-row">
<label class="hub-plan-be-label">
保本移交 偏移%
<input type="number" disabled value="${bePct}" class="hub-plan-be-input" />
</label>
${beBtn}
${beApplied}
</div>
<div class="plan-card-meta hub-plan-account-foot">
快照可用: ${esc(snapTxt)} 计划保证金${esc(marginTxt)} 杠杆: ${levTxt}
</div>
${dcaHtml ? `<div class="hub-trend-plan-side">${dcaHtml}</div>` : ""}
</div>`;
}
function renderTrendSection(trends, tickMap, positions) {
function renderTrendSection(trends, tickMap, positions, exchangeRow) {
if (!trends || !trends.length) return "";
const posList = Array.isArray(positions) ? positions : [];
return `<div class="hub-trend-plan-list">${trends
const cards = trends
.map((t) => {
const sym = t.exchange_symbol || t.symbol || "";
const side = (t.direction || "long").toLowerCase();
@@ -1636,9 +1721,13 @@
break;
}
}
return renderTrendPlanCard(t, tickMap, matched);
return renderTrendPlanCard(t, tickMap, matched, exchangeRow);
})
.join("")}</div>`;
.join("");
return `<div class="hub-trend-running">
<div class="hub-trend-running-title">运行中的计划</div>
<div class="running-plans-stack hub-trend-plan-list">${cards}</div>
</div>`;
}
function renderLivePositionCard(exchangeId, exchangeKey, pos, monitorOrder, trendPlan, tickMap) {
@@ -2052,7 +2141,7 @@
if ((row.capabilities || []).includes("trend")) {
html += renderHubSectionCard(
"趋势回调",
renderTrendSection(trends, tickMap, pos),
renderTrendSection(trends, tickMap, pos, row),
"暂无运行中的趋势回调计划"
);
}