diff --git a/lib/common/static/hedge_plan.js b/lib/common/static/hedge_plan.js
index 1864b86..04576a2 100644
--- a/lib/common/static/hedge_plan.js
+++ b/lib/common/static/hedge_plan.js
@@ -1319,7 +1319,7 @@
role +
'">' +
label +
- ""
+ " "
);
}
@@ -1339,6 +1339,42 @@
}
}
+ async function endActivePlan(planId) {
+ if (
+ !window.confirm(
+ "确认结束计划 #" +
+ planId +
+ "?\n不会自动平仓;未成交/待补腿将标为未成交取消。\n已有持仓请自行平掉。"
+ )
+ ) {
+ return;
+ }
+ try {
+ const d = await apiJson("/api/hedge-plan/" + planId + "/end", {
+ method: "POST",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify({}),
+ });
+ alert(d.msg || "计划已结束 #" + (d.plan_id || planId));
+ void loadActivePlans();
+ void loadHistory();
+ void loadGates();
+ } catch (e) {
+ alert(e.message || String(e));
+ }
+ }
+
+ function legStatusLabel(st) {
+ const map = {
+ open: "持仓中",
+ pending: "待补/未成交",
+ cancelled: "未成交取消",
+ canceled: "未成交取消",
+ closed: "已平仓",
+ };
+ return map[st] || st || "—";
+ }
+
async function loadActivePlans() {
const tbody = $("hp-active-tbody");
if (!tbody) return;
@@ -1375,7 +1411,10 @@
completeLegButtonHtml(p) +
'';
+ '">成交细节 ' +
+ '';
tbody.appendChild(tr);
});
tbody.querySelectorAll(".hp-btn-detail").forEach(function (btn) {
@@ -1388,6 +1427,11 @@
void completeMissingLeg(Number(btn.getAttribute("data-id")));
});
});
+ tbody.querySelectorAll(".hp-btn-end").forEach(function (btn) {
+ btn.addEventListener("click", function () {
+ void endActivePlan(Number(btn.getAttribute("data-id")));
+ });
+ });
} catch (e) {
tbody.innerHTML = '
| ' + (e.message || e) + " |
";
}
@@ -1409,8 +1453,10 @@
hold_to_expiry: "持有至到期",
expiry: "到期",
manual: "人工结束",
+ manual_end: "人工结束",
partial_fail: "半腿失败",
cancelled: "已取消",
+ unfilled: "未成交",
};
return map[r] || r || "—";
}
@@ -1514,7 +1560,7 @@
html += "" + fmt(leg.size, leg.leg_role === "perp" ? 4 : 0) + " | ";
html += "" + fmt(leg.avg_open, 4) + " | ";
html += "" + (leg.premium != null ? fmt(leg.premium, 4) : "—") + " | ";
- html += "" + (leg.status || "—") + " | ";
+ html += "" + legStatusLabel(leg.status) + " | ";
html += "" + fmt(leg.realized_pnl, 4) + " | ";
html += "" + (leg.exchange_ord_id || "—") + " | ";
html += "" + reasonLabel(leg.close_reason) + " | ";
diff --git a/lib/hedge_plan/hedge_plan_db.py b/lib/hedge_plan/hedge_plan_db.py
index 4c9bbef..58670f9 100644
--- a/lib/hedge_plan/hedge_plan_db.py
+++ b/lib/hedge_plan/hedge_plan_db.py
@@ -201,7 +201,12 @@ def legs_contract_summary(legs: list[dict[str, Any]]) -> str:
for leg in legs:
role = str(leg.get("leg_role") or "")
st = str(leg.get("status") or "").strip().lower()
- suffix = "(待补)" if st == "pending" else ""
+ if st == "pending":
+ suffix = "(待补)"
+ elif st in ("cancelled", "canceled"):
+ suffix = "(未成交)"
+ else:
+ suffix = ""
if role == "perp":
name = str(leg.get("symbol") or "永续")
parts.append(f"永续 {name}{suffix}")
diff --git a/lib/hedge_plan/hedge_plan_orders_lib.py b/lib/hedge_plan/hedge_plan_orders_lib.py
index 0eefb92..d42ce52 100644
--- a/lib/hedge_plan/hedge_plan_orders_lib.py
+++ b/lib/hedge_plan/hedge_plan_orders_lib.py
@@ -647,3 +647,142 @@ def dump_preview(preview: Any) -> str:
return json.dumps(preview, ensure_ascii=False)[:8000]
except Exception:
return ""
+
+
+def _live_option_pos_sheets(ex: Any, inst_id: str) -> float:
+ from lib.exchange.okx_options_lib import fetch_option_positions
+
+ inst_id = (inst_id or "").strip()
+ if not inst_id or ex is None:
+ return 0.0
+ rows = fetch_option_positions(ex)
+ if rows is None:
+ return -1.0 # API 失败:未知
+ for r in rows:
+ if str(r.get("instId") or "").strip() != inst_id:
+ continue
+ try:
+ return abs(float(r.get("pos") or 0))
+ except (TypeError, ValueError):
+ return 0.0
+ return 0.0
+
+
+def _sync_plan_status_after_leg_fix(conn: Any, plan_id: int) -> None:
+ """腿状态校正后:有 open + pending → partial."""
+ from lib.hedge_plan.hedge_plan_db import get_plan, get_plan_legs, update_plan
+
+ plan = get_plan(conn, int(plan_id))
+ if not plan:
+ return
+ pst = str(plan.get("status") or "")
+ if pst not in ("opening", "active", "partial"):
+ return
+ legs = get_plan_legs(conn, int(plan_id))
+ statuses = [str(l.get("status") or "").lower() for l in legs]
+ n_open = sum(1 for s in statuses if s == "open")
+ n_pending = sum(1 for s in statuses if s == "pending")
+ if n_pending and n_open:
+ update_plan(conn, int(plan_id), status="partial", close_reason="partial_fail")
+
+
+def reconcile_unfilled_option_legs(cfg: dict[str, Any], conn: Any, plan_id: int) -> list[str]:
+ """未成交却标 open 的期权腿 → pending(可补开);不显示成持仓."""
+ from lib.hedge_plan.hedge_plan_db import get_plan_legs, update_leg
+ from lib.exchange.okx_options_lib import fetch_option_order
+
+ ex = cfg.get("exchange_options")
+ notes: list[str] = []
+ legs = get_plan_legs(conn, int(plan_id))
+ for leg in legs:
+ role = str(leg.get("leg_role") or "")
+ if not role.startswith("option"):
+ continue
+ st = str(leg.get("status") or "").lower()
+ if st != "open":
+ continue
+ inst = str(leg.get("inst_id") or "").strip()
+ oid = str(leg.get("exchange_ord_id") or "").strip()
+ leg_id = int(leg["id"])
+ sheets = _live_option_pos_sheets(ex, inst)
+ if sheets < 0:
+ continue # 查仓失败不改
+ if sheets >= 1:
+ continue
+ # 无实仓:再看订单是否已成交(仍挂单只改 pending,不撤单)
+ if ex is not None and inst and oid:
+ od = fetch_option_order(ex, inst_id=inst, ord_id=oid)
+ if od.get("ok"):
+ acc = float(od.get("acc_fill_sz") or 0)
+ ostate = str(od.get("state") or "")
+ if acc >= 1 or ostate == "filled":
+ continue # 有成交但仓位暂未同步,暂不改
+ update_leg(
+ conn,
+ leg_id,
+ status="pending",
+ close_reason=None,
+ closed_at=None,
+ avg_open=None,
+ premium=0,
+ )
+ notes.append(f"{inst} 无成交却标open→pending")
+ if notes:
+ _sync_plan_status_after_leg_fix(conn, int(plan_id))
+ return notes
+
+
+def execute_manual_end_plan(cfg: dict[str, Any], conn: Any, plan_id: int) -> dict[str, Any]:
+ """人工结束进行中计划:不自动平仓;未成交腿标 cancelled."""
+ from lib.hedge_plan.hedge_plan_db import get_plan, get_plan_legs, update_leg, update_plan
+ from lib.hedge_plan.hedge_plan_notify_lib import notify_plan_end
+ from lib.exchange.okx_options_lib import cancel_option_order
+
+ plan = get_plan(conn, int(plan_id))
+ if not plan:
+ return {"ok": False, "msg": "计划不存在"}
+ st = str(plan.get("status") or "")
+ if st not in ("opening", "active", "partial"):
+ return {"ok": False, "msg": f"当前状态 {st or '—'} 不可结束"}
+
+ notes = reconcile_unfilled_option_legs(cfg, conn, int(plan_id))
+ ex = cfg.get("exchange_options")
+ legs = get_plan_legs(conn, int(plan_id))
+ for leg in legs:
+ lst = str(leg.get("status") or "").lower()
+ inst = str(leg.get("inst_id") or "").strip()
+ oid = str(leg.get("exchange_ord_id") or "").strip()
+ if lst == "pending":
+ if ex is not None and inst and oid:
+ cancel_option_order(ex, inst_id=inst, ord_id=oid)
+ update_leg(
+ conn,
+ int(leg["id"]),
+ status="cancelled",
+ close_reason="manual_end",
+ closed_at=_now(),
+ avg_open=None,
+ premium=0,
+ )
+ notes.append(f"{inst or leg.get('leg_role')} 待补→cancelled")
+
+ update_plan(
+ conn,
+ int(plan_id),
+ status="closed",
+ close_reason="manual",
+ closed_at=_now(),
+ note=((plan.get("note") or "") + " · 人工结束(不平仓)").strip(" ·")[:500],
+ )
+ plan2 = get_plan(conn, int(plan_id))
+ if plan2:
+ try:
+ notify_plan_end(cfg, conn, plan2)
+ except Exception:
+ pass
+ return {
+ "ok": True,
+ "plan_id": int(plan_id),
+ "msg": "计划已结束(未自动平仓;有持仓请自行平掉)",
+ "notes": notes,
+ }
diff --git a/lib/hedge_plan/hedge_plan_register.py b/lib/hedge_plan/hedge_plan_register.py
index 51c67e5..2eaa27e 100644
--- a/lib/hedge_plan/hedge_plan_register.py
+++ b/lib/hedge_plan/hedge_plan_register.py
@@ -588,6 +588,24 @@ def register_hedge_plan_routes(app: Flask, cfg: dict[str, Any]) -> None:
out["gates"] = gates
return jsonify(out), (200 if out.get("ok") else 400)
+ @app.route("/api/hedge-plan//end", methods=["POST"])
+ @lr
+ def api_hedge_end_plan(plan_id: int):
+ """人工结束进行中计划:不自动平仓;未成交腿改为 cancelled."""
+ from lib.hedge_plan.hedge_plan_db import init_hedge_plan_tables
+ from lib.hedge_plan.hedge_plan_orders_lib import execute_manual_end_plan
+
+ conn = cfg["get_db"]()
+ try:
+ init_hedge_plan_tables(conn)
+ out = execute_manual_end_plan(cfg, 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//complete-leg", methods=["POST"])
@lr
def api_hedge_complete_leg(plan_id: int):
@@ -742,6 +760,7 @@ def register_hedge_plan_routes(app: Flask, cfg: dict[str, Any]) -> None:
init_hedge_plan_tables,
list_plans,
)
+ from lib.hedge_plan.hedge_plan_orders_lib import reconcile_unfilled_option_legs
conn = cfg["get_db"]()
try:
@@ -750,6 +769,16 @@ def register_hedge_plan_routes(app: Flask, cfg: dict[str, Any]) -> None:
for status in ("opening", "active", "partial"):
rows.extend(list_plans(conn, status=status, limit=80))
rows.sort(key=lambda row: int(row.get("id") or 0), reverse=True)
+ for row in rows:
+ try:
+ reconcile_unfilled_option_legs(cfg, conn, int(row["id"]))
+ except Exception:
+ pass
+ # 校正后可能 status 变化,重新拉一遍
+ rows = []
+ for status in ("opening", "active", "partial"):
+ rows.extend(list_plans(conn, status=status, limit=80))
+ rows.sort(key=lambda row: int(row.get("id") or 0), reverse=True)
plans = attach_legs_to_plans(conn, rows)
conn.commit()
finally:
@@ -779,6 +808,7 @@ def register_hedge_plan_routes(app: Flask, cfg: dict[str, Any]) -> None:
init_hedge_plan_tables,
legs_contract_summary,
)
+ from lib.hedge_plan.hedge_plan_orders_lib import reconcile_unfilled_option_legs
conn = cfg["get_db"]()
try:
@@ -786,6 +816,10 @@ def register_hedge_plan_routes(app: Flask, cfg: dict[str, Any]) -> None:
plan = get_plan(conn, plan_id)
if not plan:
return jsonify({"ok": False, "msg": "计划不存在"}), 404
+ # 打开细节时校正:无成交却标 open → cancelled
+ if str(plan.get("status") or "") in ("opening", "active", "partial"):
+ reconcile_unfilled_option_legs(cfg, conn, plan_id)
+ plan = get_plan(conn, plan_id) or plan
legs = get_plan_legs(conn, plan_id)
conn.commit()
finally:
diff --git a/lib/hedge_plan/templates/hedge_plan_panel.html b/lib/hedge_plan/templates/hedge_plan_panel.html
index 469ec32..84c61e2 100644
--- a/lib/hedge_plan/templates/hedge_plan_panel.html
+++ b/lib/hedge_plan/templates/hedge_plan_panel.html
@@ -301,4 +301,4 @@
-
+
diff --git a/tests/test_hedge_plan_end.py b/tests/test_hedge_plan_end.py
new file mode 100644
index 0000000..b62f747
--- /dev/null
+++ b/tests/test_hedge_plan_end.py
@@ -0,0 +1,140 @@
+"""对冲计划:人工结束 + 未成交不显示 open."""
+from __future__ import annotations
+
+import sqlite3
+import unittest
+from unittest import mock
+
+from lib.hedge_plan.hedge_plan_db import (
+ get_plan,
+ get_plan_legs,
+ init_hedge_plan_tables,
+ insert_leg,
+ insert_plan,
+ legs_contract_summary,
+)
+from lib.hedge_plan.hedge_plan_orders_lib import (
+ execute_manual_end_plan,
+ reconcile_unfilled_option_legs,
+)
+
+
+def _mem():
+ conn = sqlite3.connect(":memory:")
+ conn.row_factory = sqlite3.Row
+ init_hedge_plan_tables(conn)
+ return conn
+
+
+class TestHedgePlanEnd(unittest.TestCase):
+ def test_legs_summary_marks_unfilled(self):
+ s = legs_contract_summary(
+ [
+ {"leg_role": "option_a", "inst_id": "ETH-C", "status": "pending"},
+ {"leg_role": "option_b", "inst_id": "ETH-P", "status": "open"},
+ {"leg_role": "option_a", "inst_id": "X", "status": "cancelled"},
+ ]
+ )
+ self.assertIn("(待补)", s)
+ self.assertIn("(未成交)", s)
+
+ def test_reconcile_ghost_open_to_pending(self):
+ conn = _mem()
+ pid = insert_plan(
+ conn,
+ {
+ "plan_type": "options_options",
+ "status": "active",
+ "underlying": "ETH",
+ },
+ )
+ insert_leg(
+ conn,
+ {
+ "plan_id": pid,
+ "leg_role": "option_a",
+ "inst_id": "ETH-USD_UM-260720-1870-C",
+ "status": "open",
+ "exchange_ord_id": "1",
+ "avg_open": 0.01,
+ },
+ )
+ insert_leg(
+ conn,
+ {
+ "plan_id": pid,
+ "leg_role": "option_b",
+ "inst_id": "ETH-USD_UM-260720-1870-P",
+ "status": "open",
+ "exchange_ord_id": "2",
+ "avg_open": 0.02,
+ },
+ )
+ cfg = {"exchange_options": object()}
+ with mock.patch(
+ "lib.hedge_plan.hedge_plan_orders_lib._live_option_pos_sheets",
+ side_effect=lambda ex, inst: 0.0 if "C" in inst else 50.0,
+ ), mock.patch(
+ "lib.exchange.okx_options_lib.fetch_option_order",
+ return_value={"ok": True, "state": "canceled", "acc_fill_sz": 0},
+ ):
+ notes = reconcile_unfilled_option_legs(cfg, conn, pid)
+ self.assertTrue(notes)
+ legs = {l["leg_role"]: l for l in get_plan_legs(conn, pid)}
+ self.assertEqual(legs["option_a"]["status"], "pending")
+ self.assertEqual(legs["option_b"]["status"], "open")
+ self.assertEqual(get_plan(conn, pid)["status"], "partial")
+
+ def test_manual_end_no_flat(self):
+ conn = _mem()
+ pid = insert_plan(
+ conn,
+ {
+ "plan_type": "options_options",
+ "status": "partial",
+ "underlying": "ETH",
+ },
+ )
+ insert_leg(
+ conn,
+ {
+ "plan_id": pid,
+ "leg_role": "option_a",
+ "inst_id": "ETH-C",
+ "status": "pending",
+ "exchange_ord_id": "9",
+ },
+ )
+ insert_leg(
+ conn,
+ {
+ "plan_id": pid,
+ "leg_role": "option_b",
+ "inst_id": "ETH-P",
+ "status": "open",
+ "exchange_ord_id": "8",
+ },
+ )
+ cfg = {"exchange_options": object()}
+ with mock.patch(
+ "lib.hedge_plan.hedge_plan_orders_lib.reconcile_unfilled_option_legs",
+ return_value=[],
+ ), mock.patch(
+ "lib.exchange.okx_options_lib.cancel_option_order",
+ return_value={"ok": True},
+ ) as cancel, mock.patch(
+ "lib.hedge_plan.hedge_plan_notify_lib.notify_plan_end",
+ return_value=True,
+ ):
+ out = execute_manual_end_plan(cfg, conn, pid)
+ self.assertTrue(out.get("ok"))
+ self.assertEqual(get_plan(conn, pid)["status"], "closed")
+ self.assertEqual(get_plan(conn, pid)["close_reason"], "manual")
+ legs = {l["leg_role"]: l for l in get_plan_legs(conn, pid)}
+ self.assertEqual(legs["option_a"]["status"], "cancelled")
+ self.assertEqual(legs["option_b"]["status"], "open") # 已有持仓不动
+ cancel.assert_called()
+
+
+if __name__ == "__main__":
+ unittest.main()