diff --git a/lib/common/static/hedge_plan.js b/lib/common/static/hedge_plan.js index 7661335..7918c65 100644 --- a/lib/common/static/hedge_plan.js +++ b/lib/common/static/hedge_plan.js @@ -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 = '暂无已结束计划'; + tbody.innerHTML = '暂无已结束计划'; 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 = - "" + + "#" + p.id + "" + - (p.plan_type === "perp_options" ? "永期" : "期期") + + typeLabel + "" + (p.underlying || "") + - "" + + "" + + contracts + + "" + (p.status || "") + + '' + + fmt(pnl) + "" + - fmt(p.realized_pnl_total) + - "" + - (p.close_reason || "—") + + reasonLabel(p.close_reason) + "" + (p.opened_at || "—") + "" + (p.closed_at || "—") + + '' + + ' ' + + '' + ""; 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 = '' + (e.message || e) + ""; + tbody.innerHTML = '' + (e.message || e) + ""; } } + 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 = '

加载中…

'; + 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 += '
'; + html += "
类型 " + typeLabel + "
"; + html += "
标的 " + (p.underlying || "—"); + if (p.direction) html += " · " + (p.direction === "long" ? "做多" : "做空"); + html += "
"; + html += "
状态 " + (p.status || "—") + " / " + reasonLabel(p.close_reason) + "
"; + html += "
时间 " + (p.opened_at || "—") + " → " + (p.closed_at || "—") + "
"; + html += + "
盈亏 永续 " + + fmt(p.realized_pnl_perp) + + " · 期权 " + + fmt(p.realized_pnl_options) + + " · 合计 = 0 ? "hp-pnl-pos" : "hp-pnl-neg") + + '">' + + fmt(p.realized_pnl_total) + + " ≈U
"; + if (p.plan_type === "perp_options") { + html += + "
参考价 开 " + + fmt(p.entry_mark) + + " · 止盈 " + + fmt(p.tp) + + " · 止损 " + + fmt(p.sl) + + " · 杠杆 " + + fmt(p.leverage, 0) + + "x · 张数 " + + fmt(p.perp_size, 4) + + "
"; + } else { + html += "
目标价 S* " + fmt(p.target_price) + "
"; + } + html += + "
权利金合计 " + + fmt(p.premium_total, 4) + + " USDC
"; + html += + "
合约摘要 " + + (d.contracts_summary || "—") + + "
"; + html += "
"; + html += ''; + html += + ""; + html += ""; + if (!legs.length) { + html += ''; + } 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 += ""; + html += ""; + html += ""; + html += ""; + html += ""; + html += ""; + html += ""; + html += ""; + html += ""; + html += ""; + html += ""; + html += ""; + }); + } + html += "
角色合约名称方向/类型数量开仓价权利金状态腿盈亏成交号平仓原因
无腿记录
" + roleLabel(leg.leg_role) + "" + contract + "" + side + "" + fmt(leg.size, leg.leg_role === "perp" ? 4 : 0) + "" + fmt(leg.avg_open, 4) + "" + (leg.premium != null ? fmt(leg.premium, 4) : "—") + "" + (leg.status || "—") + "" + fmt(leg.realized_pnl, 4) + "" + (leg.exchange_ord_id || "—") + "" + reasonLabel(leg.close_reason) + "
"; + if (p.note) { + html += '

备注 ' + String(p.note) + "

"; + } + body.innerHTML = html; + } catch (e) { + body.innerHTML = '

' + (e.message || e) + "

"; + } + } + + 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 ( + '

' + + title + + '

暂无已结束样本

' + ); + } + 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 ( + '

' + + title + + "

" + ); + } + async function loadStats() { const box = $("hp-stats-box"); if (!box) return; try { const d = await apiJson("/api/hedge-plan/stats"); - const parts = [ - "活跃计划 " + (d.active || 0) + "", - "已结笔数 " + (d.closed_count || 0) + "", - "已结合计 " + fmt(d.closed_pnl_total) + " ≈U", - ]; - const by = d.by_reason || []; - if (by.length) { - parts.push( - "
按原因: " + - 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 = '
'; + html += + '

总览

活跃 ' + + (d.active || 0) + + " · 已结 " + + (d.closed_count || 0) + + ' · 合计 ' + + fmt(d.closed_pnl_total) + + " ≈U

"; + html += metricCard("永期对冲", by.perp_options); + html += metricCard("期期对冲", by.options_options); + html += "
"; + 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 += '
'; + 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 += "
"; } - box.innerHTML = parts.join(" · "); + box.innerHTML = html; } catch (e) { box.textContent = e.message || String(e); } diff --git a/lib/common/static/instance_theme.css b/lib/common/static/instance_theme.css index 7e303e5..8678072 100644 --- a/lib/common/static/instance_theme.css +++ b/lib/common/static/instance_theme.css @@ -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); diff --git a/lib/hedge_plan/hedge_plan_db.py b/lib/hedge_plan/hedge_plan_db.py index 414c574..c8b1052 100644 --- a/lib/hedge_plan/hedge_plan_db.py +++ b/lib/hedge_plan/hedge_plan_db.py @@ -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], } diff --git a/lib/hedge_plan/hedge_plan_register.py b/lib/hedge_plan/hedge_plan_register.py index aab3c67..617604d 100644 --- a/lib/hedge_plan/hedge_plan_register.py +++ b/lib/hedge_plan/hedge_plan_register.py @@ -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/") @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/", 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 diff --git a/lib/hedge_plan/templates/hedge_plan_panel.html b/lib/hedge_plan/templates/hedge_plan_panel.html index 6b75a50..a0393d8 100644 --- a/lib/hedge_plan/templates/hedge_plan_panel.html +++ b/lib/hedge_plan/templates/hedge_plan_panel.html @@ -196,16 +196,16 @@