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:
+255
-32
@@ -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, """) +
|
||||
"\"><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);
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -151,8 +151,124 @@ def get_plan_legs(conn: sqlite3.Connection, plan_id: int) -> list[dict[str, Any]
|
||||
return [dict(r) for r in rows]
|
||||
|
||||
|
||||
def delete_plan(conn: sqlite3.Connection, plan_id: int) -> dict[str, Any]:
|
||||
"""删除已结束/失败/取消的计划及其腿;活跃计划拒绝删除."""
|
||||
plan = get_plan(conn, int(plan_id))
|
||||
if not plan:
|
||||
return {"ok": False, "msg": "计划不存在"}
|
||||
st = str(plan.get("status") or "")
|
||||
if st in ("opening", "active", "partial"):
|
||||
return {"ok": False, "msg": "进行中的计划不可删除,请先结束"}
|
||||
conn.execute("DELETE FROM hedge_plan_legs WHERE plan_id=?", (int(plan_id),))
|
||||
conn.execute("DELETE FROM hedge_plans WHERE id=?", (int(plan_id),))
|
||||
return {"ok": True, "deleted_id": int(plan_id)}
|
||||
|
||||
|
||||
def legs_contract_summary(legs: list[dict[str, Any]]) -> str:
|
||||
parts: list[str] = []
|
||||
for leg in legs:
|
||||
role = str(leg.get("leg_role") or "")
|
||||
if role == "perp":
|
||||
name = str(leg.get("symbol") or "永续")
|
||||
parts.append(f"永续 {name}")
|
||||
else:
|
||||
inst = str(leg.get("inst_id") or "")
|
||||
ot = str(leg.get("opt_type") or "").upper()
|
||||
strike = leg.get("strike")
|
||||
label = inst or (f"{ot}{strike}" if ot or strike is not None else role)
|
||||
parts.append(label)
|
||||
return " · ".join(parts) if parts else "—"
|
||||
|
||||
|
||||
def attach_legs_to_plans(conn: sqlite3.Connection, plans: list[dict[str, Any]]) -> list[dict[str, Any]]:
|
||||
out: list[dict[str, Any]] = []
|
||||
for p in plans:
|
||||
legs = get_plan_legs(conn, int(p["id"]))
|
||||
row = dict(p)
|
||||
row["legs"] = legs
|
||||
row["contracts_summary"] = legs_contract_summary(legs)
|
||||
out.append(row)
|
||||
return out
|
||||
|
||||
|
||||
def _sf(v: Any) -> Optional[float]:
|
||||
try:
|
||||
if v is None or v == "":
|
||||
return None
|
||||
return float(v)
|
||||
except (TypeError, ValueError):
|
||||
return None
|
||||
|
||||
|
||||
def _metrics_from_pnls(rows: list[dict[str, Any]]) -> dict[str, Any]:
|
||||
"""对一组已结束计划计算胜率/盈亏比/最大盈亏/最大回撤."""
|
||||
pnls: list[float] = []
|
||||
timed: list[tuple[str, float]] = []
|
||||
for r in rows:
|
||||
pnl = _sf(r.get("realized_pnl_total"))
|
||||
if pnl is None:
|
||||
continue
|
||||
pnls.append(pnl)
|
||||
t = str(r.get("closed_at") or r.get("opened_at") or r.get("created_at") or "")
|
||||
timed.append((t, pnl))
|
||||
n = len(pnls)
|
||||
if n == 0:
|
||||
return {
|
||||
"count": 0,
|
||||
"wins": 0,
|
||||
"losses": 0,
|
||||
"win_rate": None,
|
||||
"net_pnl": 0.0,
|
||||
"avg_pnl": None,
|
||||
"avg_premium": None,
|
||||
"profit_factor": None,
|
||||
"max_profit": None,
|
||||
"max_loss": None,
|
||||
"max_drawdown": None,
|
||||
}
|
||||
wins = [x for x in pnls if x > 0]
|
||||
losses = [x for x in pnls if x < 0]
|
||||
gross_win = sum(wins)
|
||||
gross_loss = abs(sum(losses))
|
||||
if gross_loss > 0:
|
||||
profit_factor = round(gross_win / gross_loss, 4)
|
||||
elif gross_win > 0:
|
||||
profit_factor = None # 全胜,标无限
|
||||
else:
|
||||
profit_factor = 0.0
|
||||
|
||||
timed.sort(key=lambda x: x[0] or "")
|
||||
cum = 0.0
|
||||
peak = 0.0
|
||||
mdd = 0.0
|
||||
for _, p in timed:
|
||||
cum += p
|
||||
if cum > peak:
|
||||
peak = cum
|
||||
dd = peak - cum
|
||||
if dd > mdd:
|
||||
mdd = dd
|
||||
|
||||
premiums = [_sf(r.get("premium_total")) for r in rows]
|
||||
premiums_f = [x for x in premiums if x is not None]
|
||||
return {
|
||||
"count": n,
|
||||
"wins": len(wins),
|
||||
"losses": len(losses),
|
||||
"win_rate": round(len(wins) / n, 4),
|
||||
"net_pnl": round(sum(pnls), 4),
|
||||
"avg_pnl": round(sum(pnls) / n, 4),
|
||||
"avg_premium": round(sum(premiums_f) / len(premiums_f), 4) if premiums_f else None,
|
||||
"profit_factor": profit_factor,
|
||||
"profit_factor_infinite": bool(gross_loss <= 0 and gross_win > 0),
|
||||
"max_profit": round(max(pnls), 4),
|
||||
"max_loss": round(min(pnls), 4),
|
||||
"max_drawdown": round(mdd, 4),
|
||||
}
|
||||
|
||||
|
||||
def stats_summary(conn: sqlite3.Connection) -> dict[str, Any]:
|
||||
rows = conn.execute(
|
||||
reason_rows = conn.execute(
|
||||
"""
|
||||
SELECT plan_type, close_reason, COUNT(1) AS n,
|
||||
COALESCE(SUM(realized_pnl_total), 0) AS pnl
|
||||
@@ -161,13 +277,42 @@ def stats_summary(conn: sqlite3.Connection) -> dict[str, Any]:
|
||||
GROUP BY plan_type, close_reason
|
||||
"""
|
||||
).fetchall()
|
||||
closed = conn.execute(
|
||||
"SELECT COUNT(1) AS c, COALESCE(SUM(realized_pnl_total),0) AS pnl FROM hedge_plans WHERE status='closed'"
|
||||
).fetchone()
|
||||
closed_rows = [
|
||||
dict(r)
|
||||
for r in conn.execute(
|
||||
"SELECT * FROM hedge_plans WHERE status='closed' ORDER BY COALESCE(closed_at, opened_at, created_at), id"
|
||||
).fetchall()
|
||||
]
|
||||
active = count_active_plans(conn)
|
||||
overall = _metrics_from_pnls(closed_rows)
|
||||
by_type = {
|
||||
"perp_options": _metrics_from_pnls(
|
||||
[r for r in closed_rows if r.get("plan_type") == "perp_options"]
|
||||
),
|
||||
"options_options": _metrics_from_pnls(
|
||||
[r for r in closed_rows if r.get("plan_type") == "options_options"]
|
||||
),
|
||||
}
|
||||
# 永期止盈/止损分桶
|
||||
po = [r for r in closed_rows if r.get("plan_type") == "perp_options"]
|
||||
by_type["perp_options"]["buckets"] = {
|
||||
"tp": _metrics_from_pnls([r for r in po if r.get("close_reason") == "perp_tp"]),
|
||||
"sl": _metrics_from_pnls([r for r in po if r.get("close_reason") == "perp_sl"]),
|
||||
}
|
||||
oo = [r for r in closed_rows if r.get("plan_type") == "options_options"]
|
||||
by_type["options_options"]["buckets"] = {
|
||||
"expiry_loss": _metrics_from_pnls(
|
||||
[r for r in oo if r.get("close_reason") == "oo_expiry_loss"]
|
||||
),
|
||||
"expiry_win": _metrics_from_pnls(
|
||||
[r for r in oo if r.get("close_reason") == "oo_expiry_win"]
|
||||
),
|
||||
}
|
||||
return {
|
||||
"active": active,
|
||||
"closed_count": int((closed["c"] if closed else 0) or 0),
|
||||
"closed_pnl_total": float((closed["pnl"] if closed else 0) or 0),
|
||||
"by_reason": [dict(r) for r in rows],
|
||||
"closed_count": overall["count"],
|
||||
"closed_pnl_total": overall["net_pnl"],
|
||||
"overall": overall,
|
||||
"by_type": by_type,
|
||||
"by_reason": [dict(r) for r in reason_rows],
|
||||
}
|
||||
|
||||
@@ -490,7 +490,11 @@ def register_hedge_plan_routes(app: Flask, cfg: dict[str, Any]) -> None:
|
||||
@app.route("/api/hedge-plan/history")
|
||||
@lr
|
||||
def api_hedge_history():
|
||||
from lib.hedge_plan.hedge_plan_db import init_hedge_plan_tables, list_plans
|
||||
from lib.hedge_plan.hedge_plan_db import (
|
||||
attach_legs_to_plans,
|
||||
init_hedge_plan_tables,
|
||||
list_plans,
|
||||
)
|
||||
|
||||
conn = cfg["get_db"]()
|
||||
try:
|
||||
@@ -498,10 +502,11 @@ def register_hedge_plan_routes(app: Flask, cfg: dict[str, Any]) -> None:
|
||||
rows = list_plans(conn, status="closed", limit=100)
|
||||
failed = list_plans(conn, status="failed", limit=50)
|
||||
cancelled = list_plans(conn, status="cancelled", limit=50)
|
||||
merged = attach_legs_to_plans(conn, rows + failed + cancelled)
|
||||
conn.commit()
|
||||
finally:
|
||||
conn.close()
|
||||
return jsonify({"ok": True, "plans": rows + failed + cancelled})
|
||||
return jsonify({"ok": True, "plans": merged})
|
||||
|
||||
@app.route("/api/hedge-plan/stats")
|
||||
@lr
|
||||
@@ -520,7 +525,12 @@ def register_hedge_plan_routes(app: Flask, cfg: dict[str, Any]) -> None:
|
||||
@app.route("/api/hedge-plan/<int:plan_id>")
|
||||
@lr
|
||||
def api_hedge_detail(plan_id: int):
|
||||
from lib.hedge_plan.hedge_plan_db import get_plan, get_plan_legs, init_hedge_plan_tables
|
||||
from lib.hedge_plan.hedge_plan_db import (
|
||||
get_plan,
|
||||
get_plan_legs,
|
||||
init_hedge_plan_tables,
|
||||
legs_contract_summary,
|
||||
)
|
||||
|
||||
conn = cfg["get_db"]()
|
||||
try:
|
||||
@@ -532,7 +542,30 @@ def register_hedge_plan_routes(app: Flask, cfg: dict[str, Any]) -> None:
|
||||
conn.commit()
|
||||
finally:
|
||||
conn.close()
|
||||
return jsonify({"ok": True, "plan": plan, "legs": legs})
|
||||
return jsonify(
|
||||
{
|
||||
"ok": True,
|
||||
"plan": plan,
|
||||
"legs": legs,
|
||||
"contracts_summary": legs_contract_summary(legs),
|
||||
}
|
||||
)
|
||||
|
||||
@app.route("/api/hedge-plan/<int:plan_id>", methods=["DELETE"])
|
||||
@lr
|
||||
def api_hedge_delete(plan_id: int):
|
||||
from lib.hedge_plan.hedge_plan_db import delete_plan, init_hedge_plan_tables
|
||||
|
||||
conn = cfg["get_db"]()
|
||||
try:
|
||||
init_hedge_plan_tables(conn)
|
||||
out = delete_plan(conn, plan_id)
|
||||
if not out.get("ok"):
|
||||
return jsonify(out), 400
|
||||
conn.commit()
|
||||
finally:
|
||||
conn.close()
|
||||
return jsonify(out)
|
||||
|
||||
@app.route("/api/hedge-plan/monitor-tick", methods=["POST"])
|
||||
@lr
|
||||
|
||||
@@ -196,16 +196,16 @@
|
||||
<div id="hp-tab-history" class="hp-tab-panel hidden" role="tabpanel" hidden>
|
||||
<div class="card">
|
||||
<h2>历史记录</h2>
|
||||
<p class="muted">独立对冲计划历史(与普通交易记录分离)</p>
|
||||
<p class="muted">独立对冲计划历史(与普通交易记录分离)。点「成交细节」查看合约名与成交字段。</p>
|
||||
<div class="options-strike-table-wrap">
|
||||
<table class="options-strike-table">
|
||||
<table class="options-strike-table hp-history-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>ID</th><th>类型</th><th>标的</th><th>状态</th><th>合计≈U</th><th>原因</th><th>开仓</th><th>结束</th>
|
||||
<th>ID</th><th>类型</th><th>标的</th><th>合约</th><th>状态</th><th>合计≈U</th><th>原因</th><th>开仓</th><th>结束</th><th>操作</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody id="hp-history-tbody">
|
||||
<tr><td colspan="8" class="muted">加载中…</td></tr>
|
||||
<tr><td colspan="10" class="muted">加载中…</td></tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
@@ -215,9 +215,19 @@
|
||||
<div id="hp-tab-stats" class="hp-tab-panel hidden" role="tabpanel" hidden>
|
||||
<div class="card">
|
||||
<h2>统计分析</h2>
|
||||
<p class="muted">止盈=盈利−保费;止损=期权盈亏+永续盈亏;期期到期无盈利记总亏损</p>
|
||||
<p class="muted">按永期 / 期期分别统计:胜率、盈亏比、最大盈利、最大亏损、最大回撤(按结束时间累积)</p>
|
||||
<div id="hp-stats-box" class="muted">加载中…</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div id="hp-detail-modal" class="hp-modal-backdrop" hidden>
|
||||
<div class="hp-modal" role="dialog" aria-modal="true" aria-labelledby="hp-detail-title">
|
||||
<div class="hp-modal-head">
|
||||
<h3 id="hp-detail-title">成交细节</h3>
|
||||
<button type="button" class="btn-secondary" id="hp-detail-close">关闭</button>
|
||||
</div>
|
||||
<div id="hp-detail-body" class="hp-modal-body muted">加载中…</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<script src="/static/hedge_plan.js?v=10"></script>
|
||||
<script src="/static/hedge_plan.js?v=11"></script>
|
||||
|
||||
@@ -7,7 +7,7 @@
|
||||
<link rel="stylesheet" href="/static/instance_theme_early.css?v=4">
|
||||
<link rel="stylesheet" href="/static/account_risk_badge.css?v=4">
|
||||
<link rel="stylesheet" href="/static/instance_page.css?v=6">
|
||||
<link rel="stylesheet" href="/static/instance_theme.css?v=88">
|
||||
<link rel="stylesheet" href="/static/instance_theme.css?v=89">
|
||||
<script src="/static/account_risk_badge.js?v=4"></script>
|
||||
<meta name="theme-color" content="#0b0d14">
|
||||
<title>{{ exchange_display }} · 加密货币 | 交易监控复盘系统</title>
|
||||
|
||||
@@ -17,7 +17,7 @@
|
||||
<link rel="manifest" href="/static/icons/manifest.webmanifest">
|
||||
<title>{{ exchange_display }} · 加密货币 | 交易监控复盘系统</title>
|
||||
<link rel="stylesheet" href="/static/instance_page.css?v=3">
|
||||
<link rel="stylesheet" href="/static/instance_theme.css?v=88">
|
||||
<link rel="stylesheet" href="/static/instance_theme.css?v=89">
|
||||
|
||||
</head>
|
||||
<body
|
||||
|
||||
@@ -0,0 +1,121 @@
|
||||
"""对冲计划历史删除与分类型统计."""
|
||||
import sqlite3
|
||||
import unittest
|
||||
|
||||
from lib.hedge_plan.hedge_plan_db import (
|
||||
_metrics_from_pnls,
|
||||
delete_plan,
|
||||
init_hedge_plan_tables,
|
||||
insert_leg,
|
||||
insert_plan,
|
||||
legs_contract_summary,
|
||||
stats_summary,
|
||||
)
|
||||
|
||||
|
||||
def _mem():
|
||||
conn = sqlite3.connect(":memory:")
|
||||
conn.row_factory = sqlite3.Row
|
||||
init_hedge_plan_tables(conn)
|
||||
return conn
|
||||
|
||||
|
||||
class TestHedgeHistoryStats(unittest.TestCase):
|
||||
def test_metrics_win_rate_pf_dd(self):
|
||||
rows = [
|
||||
{"realized_pnl_total": 10, "closed_at": "2026-01-01", "premium_total": 1},
|
||||
{"realized_pnl_total": -4, "closed_at": "2026-01-02", "premium_total": 1},
|
||||
{"realized_pnl_total": 6, "closed_at": "2026-01-03", "premium_total": 1},
|
||||
{"realized_pnl_total": -12, "closed_at": "2026-01-04", "premium_total": 1},
|
||||
]
|
||||
m = _metrics_from_pnls(rows)
|
||||
self.assertEqual(m["count"], 4)
|
||||
self.assertEqual(m["wins"], 2)
|
||||
self.assertAlmostEqual(m["win_rate"], 0.5)
|
||||
# gross win 16 / gross loss 16 = 1
|
||||
self.assertAlmostEqual(m["profit_factor"], 1.0)
|
||||
self.assertAlmostEqual(m["max_profit"], 10)
|
||||
self.assertAlmostEqual(m["max_loss"], -12)
|
||||
# equity: 10 → 6 → 12 → 0; peak 12, dd to 0 = 12
|
||||
self.assertAlmostEqual(m["max_drawdown"], 12)
|
||||
|
||||
def test_stats_by_type_and_delete(self):
|
||||
conn = _mem()
|
||||
po = insert_plan(
|
||||
conn,
|
||||
{
|
||||
"plan_type": "perp_options",
|
||||
"status": "closed",
|
||||
"underlying": "ETH",
|
||||
"realized_pnl_total": 5,
|
||||
"premium_total": 1,
|
||||
"close_reason": "perp_tp",
|
||||
"closed_at": "2026-07-01 10:00:00",
|
||||
},
|
||||
)
|
||||
insert_leg(
|
||||
conn,
|
||||
{
|
||||
"plan_id": po,
|
||||
"leg_role": "perp",
|
||||
"symbol": "ETH/USDT:USDT",
|
||||
"status": "closed",
|
||||
},
|
||||
)
|
||||
oo = insert_plan(
|
||||
conn,
|
||||
{
|
||||
"plan_type": "options_options",
|
||||
"status": "closed",
|
||||
"underlying": "ETH",
|
||||
"realized_pnl_total": -2,
|
||||
"premium_total": 0.02,
|
||||
"close_reason": "oo_expiry_loss",
|
||||
"closed_at": "2026-07-02 10:00:00",
|
||||
},
|
||||
)
|
||||
insert_leg(
|
||||
conn,
|
||||
{
|
||||
"plan_id": oo,
|
||||
"leg_role": "option_a",
|
||||
"inst_id": "ETH-USD_UM-260715-1900-C",
|
||||
"status": "closed",
|
||||
},
|
||||
)
|
||||
active = insert_plan(
|
||||
conn,
|
||||
{
|
||||
"plan_type": "perp_options",
|
||||
"status": "active",
|
||||
"underlying": "BTC",
|
||||
"realized_pnl_total": None,
|
||||
},
|
||||
)
|
||||
s = stats_summary(conn)
|
||||
self.assertEqual(s["closed_count"], 2)
|
||||
self.assertEqual(s["by_type"]["perp_options"]["count"], 1)
|
||||
self.assertEqual(s["by_type"]["options_options"]["count"], 1)
|
||||
self.assertAlmostEqual(s["by_type"]["perp_options"]["win_rate"], 1.0)
|
||||
self.assertAlmostEqual(s["by_type"]["options_options"]["max_loss"], -2)
|
||||
|
||||
bad = delete_plan(conn, active)
|
||||
self.assertFalse(bad["ok"])
|
||||
ok = delete_plan(conn, oo)
|
||||
self.assertTrue(ok["ok"])
|
||||
s2 = stats_summary(conn)
|
||||
self.assertEqual(s2["closed_count"], 1)
|
||||
|
||||
def test_contract_summary(self):
|
||||
s = legs_contract_summary(
|
||||
[
|
||||
{"leg_role": "perp", "symbol": "ETH/USDT:USDT"},
|
||||
{"leg_role": "option_hedge", "inst_id": "ETH-USD_UM-260715-1790-P"},
|
||||
]
|
||||
)
|
||||
self.assertIn("永续 ETH/USDT:USDT", s)
|
||||
self.assertIn("ETH-USD_UM-260715-1790-P", s)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
Reference in New Issue
Block a user