Add hedge history delete/detail modal and typed stats cards.

History shows contract names with clickable fill details; stats split perp vs options plans for win rate, profit factor, max win/loss, and drawdown.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
dekun
2026-07-14 14:04:48 +08:00
parent e6907dfbbe
commit f6ef06f659
8 changed files with 700 additions and 51 deletions
+255 -32
View File
@@ -780,6 +780,13 @@
$("hp-start-btn-oo").addEventListener("click", function () {
void startPlan("options_options");
});
if ($("hp-detail-close")) $("hp-detail-close").addEventListener("click", closeModal);
const modal = $("hp-detail-modal");
if (modal) {
modal.addEventListener("click", function (ev) {
if (ev.target === modal) closeModal();
});
}
}
async function loadHistory() {
@@ -789,67 +796,283 @@
const d = await apiJson("/api/hedge-plan/history");
const rows = d.plans || [];
if (!rows.length) {
tbody.innerHTML = '<tr><td colspan="8" class="muted">暂无已结束计划</td></tr>';
tbody.innerHTML = '<tr><td colspan="10" class="muted">暂无已结束计划</td></tr>';
return;
}
tbody.innerHTML = "";
rows.forEach(function (p) {
const tr = document.createElement("tr");
const typeLabel = p.plan_type === "perp_options" ? "永期" : "期期";
const contracts = p.contracts_summary || "—";
const pnl = p.realized_pnl_total;
const pnlCls =
pnl == null || Number.isNaN(Number(pnl)) ? "" : Number(pnl) >= 0 ? "hp-pnl-pos" : "hp-pnl-neg";
tr.innerHTML =
"<td>" +
"<td>#" +
p.id +
"</td><td>" +
(p.plan_type === "perp_options" ? "永期" : "期期") +
typeLabel +
"</td><td>" +
(p.underlying || "") +
"</td><td>" +
"</td><td class=\"hp-contracts-cell\" title=\"" +
String(contracts).replace(/"/g, "&quot;") +
"\"><code>" +
contracts +
"</code></td><td>" +
(p.status || "") +
'</td><td class="' +
pnlCls +
'">' +
fmt(pnl) +
"</td><td>" +
fmt(p.realized_pnl_total) +
"</td><td>" +
(p.close_reason || "—") +
reasonLabel(p.close_reason) +
"</td><td>" +
(p.opened_at || "—") +
"</td><td>" +
(p.closed_at || "—") +
'</td><td class="hp-hist-actions">' +
'<button type="button" class="btn-secondary hp-btn-detail" data-id="' +
p.id +
'">成交细节</button> ' +
'<button type="button" class="btn-secondary hp-btn-del" data-id="' +
p.id +
'">删除</button>' +
"</td>";
tbody.appendChild(tr);
});
tbody.querySelectorAll(".hp-btn-detail").forEach(function (btn) {
btn.addEventListener("click", function () {
void showPlanDetail(Number(btn.getAttribute("data-id")));
});
});
tbody.querySelectorAll(".hp-btn-del").forEach(function (btn) {
btn.addEventListener("click", function () {
void deletePlan(Number(btn.getAttribute("data-id")));
});
});
} catch (e) {
tbody.innerHTML = '<tr><td colspan="8" class="err">' + (e.message || e) + "</td></tr>";
tbody.innerHTML = '<tr><td colspan="10" class="err">' + (e.message || e) + "</td></tr>";
}
}
function reasonLabel(r) {
const map = {
perp_tp: "永续止盈",
perp_sl: "永续止损",
oo_expiry_loss: "期期到期亏损",
oo_expiry_win: "期期到期盈利",
target_win_leg: "期期平盈利腿",
expiry: "到期",
manual: "人工结束",
partial_fail: "半腿失败",
cancelled: "已取消",
};
return map[r] || r || "—";
}
function roleLabel(role) {
const map = {
perp: "永续腿",
option_hedge: "保险期权",
option_a: "期期腿A",
option_b: "期期腿B",
};
return map[role] || role || "—";
}
function closeModal() {
const m = $("hp-detail-modal");
if (m) m.hidden = true;
}
async function showPlanDetail(planId) {
const modal = $("hp-detail-modal");
const body = $("hp-detail-body");
const title = $("hp-detail-title");
if (!modal || !body) return;
modal.hidden = false;
body.innerHTML = '<p class="muted">加载中…</p>';
if (title) title.textContent = "成交细节 #" + planId;
try {
const d = await apiJson("/api/hedge-plan/" + planId);
const p = d.plan || {};
const legs = d.legs || [];
const typeLabel = p.plan_type === "perp_options" ? "永期对冲" : "期期对冲";
let html = "";
html += '<div class="hp-detail-summary">';
html += "<div><span class=\"muted\">类型</span> " + typeLabel + "</div>";
html += "<div><span class=\"muted\">标的</span> " + (p.underlying || "—");
if (p.direction) html += " · " + (p.direction === "long" ? "做多" : "做空");
html += "</div>";
html += "<div><span class=\"muted\">状态</span> " + (p.status || "—") + " / " + reasonLabel(p.close_reason) + "</div>";
html += "<div><span class=\"muted\">时间</span> " + (p.opened_at || "—") + " → " + (p.closed_at || "—") + "</div>";
html +=
"<div><span class=\"muted\">盈亏</span> 永续 " +
fmt(p.realized_pnl_perp) +
" · 期权 " +
fmt(p.realized_pnl_options) +
" · 合计 <strong class=\"" +
(Number(p.realized_pnl_total) >= 0 ? "hp-pnl-pos" : "hp-pnl-neg") +
'">' +
fmt(p.realized_pnl_total) +
"</strong> ≈U</div>";
if (p.plan_type === "perp_options") {
html +=
"<div><span class=\"muted\">参考价</span> 开 " +
fmt(p.entry_mark) +
" · 止盈 " +
fmt(p.tp) +
" · 止损 " +
fmt(p.sl) +
" · 杠杆 " +
fmt(p.leverage, 0) +
"x · 张数 " +
fmt(p.perp_size, 4) +
"</div>";
} else {
html += "<div><span class=\"muted\">目标价 S*</span> " + fmt(p.target_price) + "</div>";
}
html +=
"<div><span class=\"muted\">权利金合计</span> " +
fmt(p.premium_total, 4) +
" USDC</div>";
html +=
"<div><span class=\"muted\">合约摘要</span> <code>" +
(d.contracts_summary || "—") +
"</code></div>";
html += "</div>";
html += '<table class="options-strike-table hp-detail-legs"><thead><tr>';
html +=
"<th>角色</th><th>合约名称</th><th>方向/类型</th><th>数量</th><th>开仓价</th><th>权利金</th><th>状态</th><th>腿盈亏</th><th>成交号</th><th>平仓原因</th>";
html += "</tr></thead><tbody>";
if (!legs.length) {
html += '<tr><td colspan="10" class="muted">无腿记录</td></tr>';
} else {
legs.forEach(function (leg) {
const contract =
leg.leg_role === "perp"
? leg.symbol || "—"
: leg.inst_id || "—";
const side =
leg.leg_role === "perp"
? leg.side || "—"
: (leg.opt_type || "") + (leg.strike != null ? " K" + fmt(leg.strike, 0) : "");
html += "<tr>";
html += "<td>" + roleLabel(leg.leg_role) + "</td>";
html += "<td><code>" + contract + "</code></td>";
html += "<td>" + side + "</td>";
html += "<td>" + fmt(leg.size, leg.leg_role === "perp" ? 4 : 0) + "</td>";
html += "<td>" + fmt(leg.avg_open, 4) + "</td>";
html += "<td>" + (leg.premium != null ? fmt(leg.premium, 4) : "—") + "</td>";
html += "<td>" + (leg.status || "—") + "</td>";
html += "<td>" + fmt(leg.realized_pnl, 4) + "</td>";
html += "<td><code class=\"hp-ord\">" + (leg.exchange_ord_id || "—") + "</code></td>";
html += "<td>" + reasonLabel(leg.close_reason) + "</td>";
html += "</tr>";
});
}
html += "</tbody></table>";
if (p.note) {
html += '<p class="hp-detail-note"><span class="muted">备注</span> ' + String(p.note) + "</p>";
}
body.innerHTML = html;
} catch (e) {
body.innerHTML = '<p class="err">' + (e.message || e) + "</p>";
}
}
async function deletePlan(planId) {
if (!window.confirm("确认删除历史计划 #" + planId + "?此操作不可恢复。")) return;
try {
await apiJson("/api/hedge-plan/" + planId, { method: "DELETE" });
await loadHistory();
if (state.tab === "stats") await loadStats();
} catch (e) {
window.alert(e.message || String(e));
}
}
function metricCard(title, m) {
if (!m || !m.count) {
return (
'<div class="hp-stats-card"><h3>' +
title +
'</h3><p class="muted">暂无已结束样本</p></div>'
);
}
const wr = m.win_rate == null ? "—" : (Number(m.win_rate) * 100).toFixed(1) + "%";
let pf = "—";
if (m.profit_factor_infinite) pf = "∞";
else if (m.profit_factor != null) pf = fmt(m.profit_factor, 2);
return (
'<div class="hp-stats-card"><h3>' +
title +
"</h3><ul class=\"hp-stats-list\">" +
"<li><span>笔数</span><strong>" +
m.count +
"</strong></li>" +
"<li><span>胜率</span><strong>" +
wr +
"</strong> <span class=\"muted\">(" +
m.wins +
"/" +
m.count +
")</span></li>" +
"<li><span>净盈亏≈U</span><strong class=\"" +
(Number(m.net_pnl) >= 0 ? "hp-pnl-pos" : "hp-pnl-neg") +
'">' +
fmt(m.net_pnl) +
"</strong></li>" +
"<li><span>盈亏比</span><strong>" +
pf +
"</strong> <span class=\"muted\">毛利/|毛亏|</span></li>" +
"<li><span>最大盈利</span><strong class=\"hp-pnl-pos\">" +
fmt(m.max_profit) +
"</strong></li>" +
"<li><span>最大亏损</span><strong class=\"hp-pnl-neg\">" +
fmt(m.max_loss) +
"</strong></li>" +
"<li><span>最大回撤</span><strong>" +
fmt(m.max_drawdown) +
"</strong></li>" +
"<li><span>平均保费</span><strong>" +
fmt(m.avg_premium, 4) +
"</strong></li>" +
"</ul></div>"
);
}
async function loadStats() {
const box = $("hp-stats-box");
if (!box) return;
try {
const d = await apiJson("/api/hedge-plan/stats");
const parts = [
"活跃计划 <strong>" + (d.active || 0) + "</strong>",
"已结笔数 <strong>" + (d.closed_count || 0) + "</strong>",
"已结合计 <strong>" + fmt(d.closed_pnl_total) + "</strong> ≈U",
];
const by = d.by_reason || [];
if (by.length) {
parts.push(
"<br/>按原因: " +
by
.map(function (r) {
return (
(r.plan_type || "") +
"/" +
(r.close_reason || "") +
" ×" +
r.n +
" pnl=" +
fmt(r.pnl)
);
})
.join(" · ")
);
const by = d.by_type || {};
let html = '<div class="hp-stats-grid">';
html +=
'<div class="hp-stats-card hp-stats-card--overview"><h3>总览</h3><p>活跃 <strong>' +
(d.active || 0) +
"</strong> · 已结 <strong>" +
(d.closed_count || 0) +
'</strong> · 合计 <strong class="' +
(Number(d.closed_pnl_total) >= 0 ? "hp-pnl-pos" : "hp-pnl-neg") +
'">' +
fmt(d.closed_pnl_total) +
"</strong> ≈U</p></div>";
html += metricCard("永期对冲", by.perp_options);
html += metricCard("期期对冲", by.options_options);
html += "</div>";
const poB = (by.perp_options && by.perp_options.buckets) || {};
const ooB = (by.options_options && by.options_options.buckets) || {};
if ((poB.tp && poB.tp.count) || (poB.sl && poB.sl.count) || (ooB.expiry_loss && ooB.expiry_loss.count)) {
html += '<div class="hp-stats-grid hp-stats-grid--sub">';
if (poB.tp && poB.tp.count) html += metricCard("永期·止盈桶", poB.tp);
if (poB.sl && poB.sl.count) html += metricCard("永期·止损桶", poB.sl);
if (ooB.expiry_loss && ooB.expiry_loss.count) html += metricCard("期期·到期亏损", ooB.expiry_loss);
if (ooB.expiry_win && ooB.expiry_win.count) html += metricCard("期期·到期盈利", ooB.expiry_win);
html += "</div>";
}
box.innerHTML = parts.join(" · ");
box.innerHTML = html;
} catch (e) {
box.textContent = e.message || String(e);
}
+117
View File
@@ -3403,6 +3403,123 @@ html[data-theme="light"] .opt-be-dist-down {
background: rgba(255, 255, 255, 0.03);
font-size: 0.78rem;
}
.hedge-plan-page-wrap .hp-contracts-cell {
max-width: 220px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.hedge-plan-page-wrap .hp-hist-actions {
white-space: nowrap;
}
.hedge-plan-page-wrap .hp-hist-actions .btn-secondary {
padding: 3px 8px;
font-size: 0.72rem;
min-height: 26px;
}
.hedge-plan-page-wrap .hp-stats-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(220px, 1fr));
gap: 12px;
margin-top: 8px;
}
.hedge-plan-page-wrap .hp-stats-grid--sub {
margin-top: 14px;
}
.hedge-plan-page-wrap .hp-stats-card {
border: 1px solid rgba(255, 255, 255, 0.1);
border-radius: 10px;
padding: 12px 14px;
background: rgba(0, 0, 0, 0.22);
}
.hedge-plan-page-wrap .hp-stats-card h3 {
margin: 0 0 8px;
font-size: 0.92rem;
color: #e8eefc;
}
.hedge-plan-page-wrap .hp-stats-list {
list-style: none;
margin: 0;
padding: 0;
}
.hedge-plan-page-wrap .hp-stats-list li {
display: flex;
flex-wrap: wrap;
align-items: baseline;
gap: 6px;
margin: 5px 0;
font-size: 0.8rem;
}
.hedge-plan-page-wrap .hp-stats-list li > span:first-child {
color: #9aa4b2;
min-width: 4.5em;
}
.hedge-plan-page-wrap .hp-modal-backdrop {
position: fixed;
inset: 0;
z-index: 1300;
background: rgba(0, 0, 0, 0.72);
display: flex;
align-items: center;
justify-content: center;
padding: 16px;
}
.hedge-plan-page-wrap .hp-modal-backdrop[hidden] {
display: none !important;
}
.hedge-plan-page-wrap .hp-modal {
width: min(96vw, 980px);
max-height: 88vh;
overflow: auto;
background: #121726;
border: 1px solid #2a3150;
border-radius: 12px;
padding: 14px 16px;
}
.hedge-plan-page-wrap .hp-modal-head {
display: flex;
justify-content: space-between;
align-items: center;
gap: 10px;
margin-bottom: 10px;
}
.hedge-plan-page-wrap .hp-modal-head h3 {
margin: 0;
font-size: 1rem;
color: #dbe4ff;
}
.hedge-plan-page-wrap .hp-detail-summary {
display: grid;
gap: 6px;
margin-bottom: 12px;
font-size: 0.82rem;
color: #e5e9ff;
}
.hedge-plan-page-wrap .hp-detail-legs {
margin-top: 4px;
}
.hedge-plan-page-wrap .hp-ord {
font-size: 0.62rem;
word-break: break-all;
}
.hedge-plan-page-wrap .hp-detail-note {
margin-top: 10px;
font-size: 0.82rem;
}
html[data-theme="light"] .hedge-plan-page-wrap .hp-modal {
background: #f7f8fc;
border-color: #c9d2e8;
}
html[data-theme="light"] .hedge-plan-page-wrap .hp-modal-head h3 {
color: #1a2438;
}
html[data-theme="light"] .hedge-plan-page-wrap .hp-stats-card {
background: rgba(255, 255, 255, 0.7);
border-color: rgba(0, 0, 0, 0.08);
}
html[data-theme="light"] .hedge-plan-page-wrap .hp-stats-card h3 {
color: #1a2438;
}
html[data-theme="light"] .hedge-plan-page-wrap .hp-tabs {
background: rgba(15, 23, 42, 0.06);
border-color: rgba(15, 23, 42, 0.1);