';
+ 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 @@