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()