From d74d0aeae0533dbc05c2a2b463379963525442db Mon Sep 17 00:00:00 2001 From: dekun Date: Sun, 19 Jul 2026 09:06:22 +0800 Subject: [PATCH] Park partial hedge plans for manual leg complete instead of auto-close. Co-authored-by: Cursor --- crypto_monitor_okx/.env.example | 3 + lib/common/static/hedge_plan.js | 69 +++++- lib/common/static/instance_theme.css | 10 + lib/env/env_schema.py | 1 + lib/env/env_ui_manifest.py | 12 +- lib/hedge_plan/hedge_plan_db.py | 23 +- lib/hedge_plan/hedge_plan_orders_lib.py | 216 +++++++++++++++-- lib/hedge_plan/hedge_plan_register.py | 223 ++++++++++++++---- .../templates/hedge_plan_panel.html | 2 +- tests/test_hedge_partial_manual.py | 45 ++++ 10 files changed, 540 insertions(+), 64 deletions(-) create mode 100644 tests/test_hedge_partial_manual.py diff --git a/crypto_monitor_okx/.env.example b/crypto_monitor_okx/.env.example index fe4c0c6..9434c31 100644 --- a/crypto_monitor_okx/.env.example +++ b/crypto_monitor_okx/.env.example @@ -137,8 +137,11 @@ HEDGE_PLAN_OO_CLOSE_WINNER_ONLY=true HEDGE_PLAN_OO_CLOSE_MODE_ENABLED=true # 对冲与单独期权互斥(默认 true):有对冲计划不可单独开期权;有单独期权不可启动对冲;false=可同时开 HEDGE_PLAN_OPTIONS_MUTUAL_EXCLUSIVE=true +# 半腿失败改手动补开(默认 true):不自动平已成腿,计划挂 partial,页面补开;开启时下方自动平强制无效 +HEDGE_PLAN_MANUAL_COMPLETE_ON_PARTIAL=true MAX_ACTIVE_HEDGE_PLANS=1 HEDGE_PLAN_MONITOR_POLL_SECONDS=15 +# 半腿失败自动平期权;若 MANUAL_COMPLETE_ON_PARTIAL=true 则不会执行自动平 HEDGE_PLAN_PARTIAL_AUTO_CLOSE_OPTION=true # ============================================================================= diff --git a/lib/common/static/hedge_plan.js b/lib/common/static/hedge_plan.js index a4f7555..930cabc 100644 --- a/lib/common/static/hedge_plan.js +++ b/lib/common/static/hedge_plan.js @@ -1198,6 +1198,51 @@ return "上破 " + fmt(p.target_price_up || p.target_price) + " · 下破 " + fmt(p.target_price_down || p.target_price); } + function activeStatusLabel(p) { + if ((p.status || "") === "partial") { + return '半腿待补'; + } + if ((p.status || "") === "opening") { + return '开仓中'; + } + return '进行中'; + } + + function completeLegButtonHtml(p) { + if ((p.status || "") !== "partial") return ""; + const role = p.missing_leg || ""; + let label = ""; + if (role === "perp") label = "补开永续"; + else if (role === "option_b") label = "补开腿B"; + else if (role === "option_hedge" || role === "option_a") label = "补开期权"; + else return ""; + return ( + '" + ); + } + + async function completeMissingLeg(planId) { + if (!window.confirm("确认补开缺失腿并真实下单?")) return; + try { + const d = await apiJson("/api/hedge-plan/" + planId + "/complete-leg", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({}), + }); + alert("补开成功 #" + (d.plan_id || planId) + " · 已进入进行中"); + void loadActivePlans(); + void loadGates(); + } catch (e) { + alert(e.message || String(e)); + } + } + async function loadActivePlans() { const tbody = $("hp-active-tbody"); if (!tbody) return; @@ -1225,12 +1270,14 @@ "\">" + contracts + "" + - '进行中' + + activeStatusLabel(p) + "" + activeTargetLabel(p) + "" + (p.opened_at || "—") + - ''; tbody.appendChild(tr); @@ -1240,6 +1287,11 @@ void showPlanDetail(Number(btn.getAttribute("data-id"))); }); }); + tbody.querySelectorAll(".hp-btn-complete").forEach(function (btn) { + btn.addEventListener("click", function () { + void completeMissingLeg(Number(btn.getAttribute("data-id"))); + }); + }); } catch (e) { tbody.innerHTML = '' + (e.message || e) + ""; } @@ -1532,7 +1584,18 @@ }); setGateLine(d.gates); closePreviewModal(); - alert("计划已启动 #" + (d.plan_id || "") + (d.dry_run ? " (dry_run)" : "")); + if (d.partial) { + alert( + (d.msg || "半腿失败,已挂待补") + + (d.plan_id ? "\n计划 #" + d.plan_id : "") + + "\n请到「进行中的计划」补开缺失腿" + ); + state.tab = "active"; + syncTabUI(); + void loadActivePlans(); + } else { + alert("计划已启动 #" + (d.plan_id || "") + (d.dry_run ? " (dry_run)" : "")); + } void loadGates(); } catch (e) { alert(e.message || String(e)); diff --git a/lib/common/static/instance_theme.css b/lib/common/static/instance_theme.css index 18c6dc3..79124bb 100644 --- a/lib/common/static/instance_theme.css +++ b/lib/common/static/instance_theme.css @@ -3249,6 +3249,16 @@ html[data-theme="light"] .opt-be-dist-down { color: #7ee787; font-weight: 700; } +.hedge-plan-page-wrap .hp-plan-partial { + color: #ffb454; + font-weight: 700; +} +.hedge-plan-page-wrap .hp-hist-actions .hp-btn-complete { + margin-right: 6px; + padding: 3px 8px; + font-size: 0.72rem; + min-height: 26px; +} .hedge-plan-page-wrap .hp-pnl-neg { color: #ff8a8a; } diff --git a/lib/env/env_schema.py b/lib/env/env_schema.py index 88d784d..bbc3ff2 100644 --- a/lib/env/env_schema.py +++ b/lib/env/env_schema.py @@ -92,6 +92,7 @@ HOT_RELOAD_EXACT = frozenset({ "HEDGE_PLAN_OO_CLOSE_WINNER_ONLY", "HEDGE_PLAN_OO_CLOSE_MODE_ENABLED", "HEDGE_PLAN_OPTIONS_MUTUAL_EXCLUSIVE", + "HEDGE_PLAN_MANUAL_COMPLETE_ON_PARTIAL", "MAX_ACTIVE_HEDGE_PLANS", "HEDGE_PLAN_MONITOR_POLL_SECONDS", "HEDGE_PLAN_PARTIAL_AUTO_CLOSE_OPTION", diff --git a/lib/env/env_ui_manifest.py b/lib/env/env_ui_manifest.py index 6058061..89ebc55 100644 --- a/lib/env/env_ui_manifest.py +++ b/lib/env/env_ui_manifest.py @@ -151,9 +151,18 @@ _HEDGE_PLAN_SECTION: dict[str, Any] = { "对冲与期权互斥门控", "默认 true;开启时:有对冲计划则不可单独开期权,有单独期权则不可启动对冲;关闭后两边可同时开", ), + ( + "HEDGE_PLAN_MANUAL_COMPLETE_ON_PARTIAL", + "半腿失败改手动补开", + "默认 true;开启时半腿失败不自动平,计划挂 partial,页面可补开永续/腿B;并强制关闭下方自动平", + ), ("MAX_ACTIVE_HEDGE_PLANS", "最大同时活跃计划数", "建议 1"), ("HEDGE_PLAN_MONITOR_POLL_SECONDS", "对冲监控轮询(秒)", "默认 15"), - ("HEDGE_PLAN_PARTIAL_AUTO_CLOSE_OPTION", "半腿失败时自动平期权", ""), + ( + "HEDGE_PLAN_PARTIAL_AUTO_CLOSE_OPTION", + "半腿失败时自动平期权", + "默认 true;若上方「半腿失败改手动补开」开启则本项强制无效(不会自动平)", + ), ], } @@ -169,6 +178,7 @@ _RUNTIME_ENV_DEFAULTS: dict[str, str] = { "HEDGE_PLAN_SHOW_OPTIONS_OPTIONS": "true", "HEDGE_PLAN_OO_CLOSE_MODE_ENABLED": "true", "HEDGE_PLAN_OPTIONS_MUTUAL_EXCLUSIVE": "true", + "HEDGE_PLAN_MANUAL_COMPLETE_ON_PARTIAL": "true", } diff --git a/lib/hedge_plan/hedge_plan_db.py b/lib/hedge_plan/hedge_plan_db.py index 9fcd978..4c9bbef 100644 --- a/lib/hedge_plan/hedge_plan_db.py +++ b/lib/hedge_plan/hedge_plan_db.py @@ -128,6 +128,22 @@ def update_plan(conn: sqlite3.Connection, plan_id: int, **fields: Any) -> None: conn.execute(f"UPDATE hedge_plans SET {sets} WHERE id=?", [*fields.values(), plan_id]) +def update_leg(conn: sqlite3.Connection, leg_id: int, **fields: Any) -> None: + if not fields: + return + sets = ", ".join(f"{k}=?" for k in fields) + conn.execute(f"UPDATE hedge_plan_legs SET {sets} WHERE id=?", [*fields.values(), int(leg_id)]) + + +def missing_leg_role(legs: list[dict[str, Any]]) -> Optional[str]: + for leg in legs or []: + if str(leg.get("status") or "").strip().lower() == "pending": + role = str(leg.get("leg_role") or "").strip() + if role: + return role + return None + + def list_plans( conn: sqlite3.Connection, *, @@ -184,15 +200,17 @@ def legs_contract_summary(legs: list[dict[str, Any]]) -> str: parts: list[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 role == "perp": name = str(leg.get("symbol") or "永续") - parts.append(f"永续 {name}") + parts.append(f"永续 {name}{suffix}") 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) + parts.append(f"{label}{suffix}") return " · ".join(parts) if parts else "—" @@ -203,6 +221,7 @@ def attach_legs_to_plans(conn: sqlite3.Connection, plans: list[dict[str, Any]]) row = dict(p) row["legs"] = legs row["contracts_summary"] = legs_contract_summary(legs) + row["missing_leg"] = missing_leg_role(legs) out.append(row) return out diff --git a/lib/hedge_plan/hedge_plan_orders_lib.py b/lib/hedge_plan/hedge_plan_orders_lib.py index ba9f5c5..81fc2fa 100644 --- a/lib/hedge_plan/hedge_plan_orders_lib.py +++ b/lib/hedge_plan/hedge_plan_orders_lib.py @@ -23,6 +23,18 @@ def open_order_mode() -> str: return v if v in ("options_first", "perp_first") else "options_first" +def manual_complete_on_partial() -> bool: + """半腿失败后挂 partial 并手动补开(默认 true).""" + return _env_bool("HEDGE_PLAN_MANUAL_COMPLETE_ON_PARTIAL", True) + + +def partial_auto_close_enabled() -> bool: + """手动补开开启时强制关闭自动平,避免吃买卖价差.""" + if manual_complete_on_partial(): + return False + return _env_bool("HEDGE_PLAN_PARTIAL_AUTO_CLOSE_OPTION", True) + + def build_po_path_plan(body: dict[str, Any]) -> list[dict[str, Any]]: """永期下单路径清单(不交易).""" mode = open_order_mode() @@ -257,6 +269,49 @@ def _sell_option( return order if order.get("ok") else order +def _notify_partial(cfg: dict[str, Any], plan_type: str, msg: str, results: list[dict[str, Any]]) -> None: + try: + from lib.hedge_plan.hedge_plan_notify_lib import notify_partial_fail + + notify_partial_fail(cfg, plan_type=plan_type, msg=msg, results=results) + except Exception: + pass + + +def _park_partial( + cfg: dict[str, Any], + *, + plan_type: str, + body: dict[str, Any], + missing_leg: str, + msg: str, + path: list[dict[str, Any]], + results: list[dict[str, Any]], + persist: Optional[Callable[..., Any]], + dry_run: bool, + **filled: Any, +) -> dict[str, Any]: + """半腿失败:保留已成腿,挂 partial 供手动补开.""" + if not dry_run: + _notify_partial(cfg, plan_type, msg, results) + out: dict[str, Any] = { + "ok": True, + "partial": True, + "status": "partial", + "dry_run": dry_run, + "plan_type": plan_type, + "missing_leg": missing_leg, + "msg": msg, + "path": path, + "results": results, + "opened_at": _now(), + **filled, + } + if persist and not dry_run: + out["plan_id"] = persist(out, body) + return out + + def execute_perp_options_start( cfg: dict[str, Any], body: dict[str, Any], @@ -278,6 +333,27 @@ def execute_perp_options_start( ) results.append({"step": step["step"], **opt_res}) if not opt_res.get("ok"): + # 永续已成、期权失败 → 可挂 partial 等补开期权 + if ( + perp_res + and perp_res.get("ok") + and not dry_run + and manual_complete_on_partial() + and persist + ): + return _park_partial( + cfg, + plan_type="perp_options", + body=body, + missing_leg="option_hedge", + msg="永续已开、期权失败。计划已挂半腿待补,请在「进行中」补开期权", + path=path, + results=results, + persist=persist, + dry_run=dry_run, + option=None, + perp=perp_res, + ) return {"ok": False, "msg": opt_res.get("msg") or "期权开仓失败", "path": path, "results": results} else: perp_res = _open_perp( @@ -292,24 +368,45 @@ def execute_perp_options_start( ) results.append({"step": step["step"], **perp_res}) if not perp_res.get("ok"): - # 半腿补偿:期权已成 + 配置允许则平期权 - if opt_res and opt_res.get("ok") and not dry_run and _env_bool("HEDGE_PLAN_PARTIAL_AUTO_CLOSE_OPTION", True): + if opt_res and opt_res.get("ok") and not dry_run and partial_auto_close_enabled(): close_r = _sell_option( cfg, inst_id=str(opt_res.get("inst_id") or body.get("opt_inst_id") or ""), sheets=float(opt_res.get("sheets") or body.get("sheets") or 1), ) results.append({"step": "options_auto_close_on_perp_fail", **close_r}) + msg = perp_res.get("msg") or "永续开仓失败" + _notify_partial(cfg, "perp_options", msg, results) + return { + "ok": False, + "msg": msg, + "path": path, + "results": results, + "partial": True, + } + if ( + opt_res + and opt_res.get("ok") + and not dry_run + and manual_complete_on_partial() + and persist + ): + return _park_partial( + cfg, + plan_type="perp_options", + body=body, + missing_leg="perp", + msg="期权已开、永续失败。计划已挂半腿待补,请在「进行中」补开永续", + path=path, + results=results, + persist=persist, + dry_run=dry_run, + option=opt_res, + perp=None, + ) msg = perp_res.get("msg") or "永续开仓失败" if not dry_run: - try: - from lib.hedge_plan.hedge_plan_notify_lib import notify_partial_fail - - notify_partial_fail( - cfg, plan_type="perp_options", msg=msg, results=results - ) - except Exception: - pass + _notify_partial(cfg, "perp_options", msg, results) return { "ok": False, "msg": msg, @@ -351,17 +448,35 @@ def execute_options_options_start( b_res = _buy_option(cfg, inst_id=str(leg_b.get("inst_id") or ""), sheets=float(leg_b.get("sheets") or 1), dry_run=dry_run) results.append({"step": "options_buy_limit", "leg": "b", **b_res}) if not b_res.get("ok"): - if not dry_run and _env_bool("HEDGE_PLAN_PARTIAL_AUTO_CLOSE_OPTION", True): + if not dry_run and partial_auto_close_enabled(): close_r = _sell_option(cfg, inst_id=str(a_res.get("inst_id") or ""), sheets=float(a_res.get("sheets") or 1)) results.append({"step": "options_auto_close_leg_a", **close_r}) + msg = b_res.get("msg") or "腿B开仓失败" + _notify_partial(cfg, "options_options", msg, results) + return { + "ok": False, + "msg": msg, + "path": path, + "results": results, + "partial": True, + } + if not dry_run and manual_complete_on_partial() and persist: + return _park_partial( + cfg, + plan_type="options_options", + body=body, + missing_leg="option_b", + msg="腿A已开、腿B失败。计划已挂半腿待补,请在「进行中」补开腿B", + path=path, + results=results, + persist=persist, + dry_run=dry_run, + leg_a=a_res, + leg_b=None, + ) msg = b_res.get("msg") or "腿B开仓失败" if not dry_run: - try: - from lib.hedge_plan.hedge_plan_notify_lib import notify_partial_fail - - notify_partial_fail(cfg, plan_type="options_options", msg=msg, results=results) - except Exception: - pass + _notify_partial(cfg, "options_options", msg, results) return { "ok": False, "msg": msg, @@ -384,6 +499,73 @@ def execute_options_options_start( return out +def execute_complete_missing_leg( + cfg: dict[str, Any], + plan: dict[str, Any], + legs: list[dict[str, Any]], + start_body: dict[str, Any], + *, + dry_run: bool = False, +) -> dict[str, Any]: + """对 partial 计划补开缺失腿;成功后由调用方把计划升为 active.""" + missing = None + for leg in legs: + if str(leg.get("status") or "").lower() == "pending": + missing = leg + break + if not missing: + return {"ok": False, "msg": "没有待补开的腿"} + role = str(missing.get("leg_role") or "") + results: list[dict[str, Any]] = [] + if role == "perp": + res = _open_perp( + cfg, + symbol=str(start_body.get("exchange_symbol") or missing.get("symbol") or ""), + direction=str(start_body.get("direction") or "long"), + contracts=float(start_body.get("contracts") or missing.get("size") or 0), + leverage=int(start_body.get("leverage") or 10), + tp=float(start_body["tp"]), + sl=float(start_body["sl"]), + dry_run=dry_run, + ) + results.append({"step": "perp_market_open", "complete": True, **res}) + if not res.get("ok"): + return {"ok": False, "msg": res.get("msg") or "补开永续失败", "results": results, "leg_role": role} + return { + "ok": True, + "leg_role": role, + "leg_id": missing.get("id"), + "results": results, + "fill": res, + "opened_at": _now(), + } + if role in ("option_hedge", "option_b", "option_a"): + if role == "option_b": + src = start_body.get("leg_b") or {} + inst = str(src.get("inst_id") or missing.get("inst_id") or "") + sheets = float(src.get("sheets") or missing.get("size") or 1) + elif role == "option_a": + src = start_body.get("leg_a") or {} + inst = str(src.get("inst_id") or missing.get("inst_id") or "") + sheets = float(src.get("sheets") or missing.get("size") or 1) + else: + inst = str(start_body.get("opt_inst_id") or missing.get("inst_id") or "") + sheets = float(start_body.get("sheets") or missing.get("size") or 1) + res = _buy_option(cfg, inst_id=inst, sheets=sheets, dry_run=dry_run) + results.append({"step": "options_buy_limit", "complete": True, "leg_role": role, **res}) + if not res.get("ok"): + return {"ok": False, "msg": res.get("msg") or "补开期权失败", "results": results, "leg_role": role} + return { + "ok": True, + "leg_role": role, + "leg_id": missing.get("id"), + "results": results, + "fill": res, + "opened_at": _now(), + } + return {"ok": False, "msg": f"未知待补腿: {role}"} + + def validate_start_body(plan_type: str, body: dict[str, Any]) -> Optional[str]: pt = (plan_type or "").strip().lower() if pt == "perp_options": diff --git a/lib/hedge_plan/hedge_plan_register.py b/lib/hedge_plan/hedge_plan_register.py index ea76b52..4b37de8 100644 --- a/lib/hedge_plan/hedge_plan_register.py +++ b/lib/hedge_plan/hedge_plan_register.py @@ -2,7 +2,7 @@ from __future__ import annotations import os -from typing import Any +from typing import Any, Optional from flask import Flask, jsonify, request from jinja2 import ChoiceLoader, FileSystemLoader @@ -223,6 +223,18 @@ def _maybe_start_monitor(cfg: dict[str, Any]) -> None: cfg["hedge_monitor_thread"] = t +def _start_body_json(body: dict[str, Any], missing_leg: Optional[str] = None) -> str: + import json + + try: + return json.dumps( + {"start_body": body, "missing_leg": missing_leg}, + ensure_ascii=False, + )[:8000] + except Exception: + return "" + + def _persist_po(cfg: dict[str, Any], result: dict[str, Any], body: dict[str, Any]) -> int: from lib.hedge_plan.hedge_plan_db import ( get_plan, @@ -236,25 +248,36 @@ def _persist_po(cfg: dict[str, Any], result: dict[str, Any], body: dict[str, Any conn = cfg["get_db"]() try: init_hedge_plan_tables(conn) + is_partial = bool(result.get("partial")) + missing = str(result.get("missing_leg") or "") if is_partial else "" opt = result.get("option") or {} perp = result.get("perp") or {} - premium = float(opt.get("premium") or 0) + if is_partial: + opt_ok = missing != "option_hedge" and bool(result.get("option")) + perp_ok = missing != "perp" and bool(result.get("perp")) + else: + opt_ok = True + perp_ok = True + premium = float((opt or {}).get("premium") or 0) if opt_ok else 0.0 plan_id = insert_plan( conn, { "plan_type": "perp_options", - "status": "active", + "status": "partial" if is_partial else "active", "underlying": str(body.get("underlying") or "ETH").upper(), "direction": str(body.get("direction") or "long"), "entry_mark": float(body.get("entry") or 0), "tp": float(body.get("tp") or 0), "sl": float(body.get("sl") or 0), "sizing_mode_at_open": load_position_sizing_mode(), - "perp_size": float(perp.get("contracts") or body.get("contracts") or 0), + "perp_size": float((perp or {}).get("contracts") or body.get("contracts") or 0), "margin": body.get("margin"), "leverage": float(body.get("leverage") or 10), "premium_total": premium, + "preview_json": _start_body_json(body, missing or None), + "close_reason": "partial_fail" if is_partial else None, "opened_at": result.get("opened_at"), + "note": (result.get("msg") or "")[:500] if is_partial else None, }, ) insert_leg( @@ -264,11 +287,11 @@ def _persist_po(cfg: dict[str, Any], result: dict[str, Any], body: dict[str, Any "leg_role": "perp", "symbol": str(body.get("exchange_symbol") or ""), "side": str(body.get("direction") or "long"), - "size": float(perp.get("contracts") or body.get("contracts") or 0), - "avg_open": float(body.get("entry") or 0), - "status": "open", - "exchange_ord_id": str(perp.get("exchange_ord_id") or ""), - "opened_at": result.get("opened_at"), + "size": float((perp or {}).get("contracts") or body.get("contracts") or 0), + "avg_open": float(body.get("entry") or 0) if perp_ok else None, + "status": "open" if perp_ok else "pending", + "exchange_ord_id": str((perp or {}).get("exchange_ord_id") or ""), + "opened_at": result.get("opened_at") if perp_ok else None, }, ) insert_leg( @@ -276,24 +299,25 @@ def _persist_po(cfg: dict[str, Any], result: dict[str, Any], body: dict[str, Any { "plan_id": plan_id, "leg_role": "option_hedge", - "inst_id": str(opt.get("inst_id") or body.get("opt_inst_id") or ""), - "opt_type": str(opt.get("opt_type") or body.get("opt_type") or ""), - "strike": opt.get("strike") or body.get("strike"), + "inst_id": str((opt or {}).get("inst_id") or body.get("opt_inst_id") or ""), + "opt_type": str((opt or {}).get("opt_type") or body.get("opt_type") or ""), + "strike": (opt or {}).get("strike") or body.get("strike"), "side": "buy", - "size": float(opt.get("sheets") or body.get("sheets") or 1), - "avg_open": float(opt.get("ask") or 0), - "premium": premium, - "status": "open", - "exchange_ord_id": str(opt.get("exchange_ord_id") or ""), - "opened_at": result.get("opened_at"), + "size": float((opt or {}).get("sheets") or body.get("sheets") or 1), + "avg_open": float((opt or {}).get("ask") or 0) if opt_ok else None, + "premium": premium if opt_ok else 0, + "status": "open" if opt_ok else "pending", + "exchange_ord_id": str((opt or {}).get("exchange_ord_id") or ""), + "opened_at": result.get("opened_at") if opt_ok else None, }, ) conn.commit() - plan = get_plan(conn, plan_id) - legs = get_plan_legs(conn, plan_id) - if plan: - notify_plan_start(cfg, conn, plan, legs) - conn.commit() + if not is_partial: + plan = get_plan(conn, plan_id) + legs = get_plan_legs(conn, plan_id) + if plan: + notify_plan_start(cfg, conn, plan, legs) + conn.commit() return plan_id finally: conn.close() @@ -312,14 +336,20 @@ def _persist_oo(cfg: dict[str, Any], result: dict[str, Any], body: dict[str, Any conn = cfg["get_db"]() try: init_hedge_plan_tables(conn) + is_partial = bool(result.get("partial")) + missing = str(result.get("missing_leg") or "") if is_partial else "" a = result.get("leg_a") or {} b = result.get("leg_b") or {} - premium = float(a.get("premium") or 0) + float(b.get("premium") or 0) + a_ok = True if not is_partial else bool(result.get("leg_a")) + b_ok = True if not is_partial else (missing != "option_b" and bool(result.get("leg_b"))) + premium = (float(a.get("premium") or 0) if a_ok else 0.0) + ( + float(b.get("premium") or 0) if b_ok else 0.0 + ) plan_id = insert_plan( conn, { "plan_type": "options_options", - "status": "active", + "status": "partial" if is_partial else "active", "underlying": str(body.get("underlying") or "ETH").upper(), "target_price": float( body.get("target_price_up") @@ -339,33 +369,40 @@ def _persist_oo(cfg: dict[str, Any], result: dict[str, Any], body: dict[str, Any "sizing_mode_at_open": load_position_sizing_mode(), "premium_total": premium, "oo_close_mode": _normalize_oo_close_mode(body.get("oo_close_mode")), + "preview_json": _start_body_json(body, missing or None), + "close_reason": "partial_fail" if is_partial else None, "opened_at": result.get("opened_at"), + "note": (result.get("msg") or "")[:500] if is_partial else None, }, ) - for role, res, src in (("option_a", a, body.get("leg_a") or {}), ("option_b", b, body.get("leg_b") or {})): + for role, res, src, ok in ( + ("option_a", a, body.get("leg_a") or {}, a_ok), + ("option_b", b, body.get("leg_b") or {}, b_ok), + ): insert_leg( conn, { "plan_id": plan_id, "leg_role": role, - "inst_id": str(res.get("inst_id") or src.get("inst_id") or ""), - "opt_type": str(res.get("opt_type") or src.get("opt_type") or ""), - "strike": res.get("strike") or src.get("strike"), + "inst_id": str((res or {}).get("inst_id") or src.get("inst_id") or ""), + "opt_type": str((res or {}).get("opt_type") or src.get("opt_type") or ""), + "strike": (res or {}).get("strike") or src.get("strike"), "side": "buy", - "size": float(res.get("sheets") or src.get("sheets") or 1), - "avg_open": float(res.get("ask") or 0), - "premium": float(res.get("premium") or 0), - "status": "open", - "exchange_ord_id": str(res.get("exchange_ord_id") or ""), - "opened_at": result.get("opened_at"), + "size": float((res or {}).get("sheets") or src.get("sheets") or 1), + "avg_open": float((res or {}).get("ask") or 0) if ok else None, + "premium": float((res or {}).get("premium") or 0) if ok else 0, + "status": "open" if ok else "pending", + "exchange_ord_id": str((res or {}).get("exchange_ord_id") or ""), + "opened_at": result.get("opened_at") if ok else None, }, ) conn.commit() - plan = get_plan(conn, plan_id) - legs = get_plan_legs(conn, plan_id) - if plan: - notify_plan_start(cfg, conn, plan, legs) - conn.commit() + if not is_partial: + plan = get_plan(conn, plan_id) + legs = get_plan_legs(conn, plan_id) + if plan: + notify_plan_start(cfg, conn, plan, legs) + conn.commit() return plan_id finally: conn.close() @@ -537,6 +574,112 @@ 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//complete-leg", methods=["POST"]) + @lr + def api_hedge_complete_leg(plan_id: int): + """半腿待补:手动补开缺失腿,成功后升为 active.""" + import json + + from lib.hedge_plan.hedge_plan_db import ( + get_plan, + get_plan_legs, + init_hedge_plan_tables, + update_leg, + update_plan, + ) + from lib.hedge_plan.hedge_plan_notify_lib import notify_plan_start + from lib.hedge_plan.hedge_plan_orders_lib import execute_complete_missing_leg + + body = request.get_json(silent=True) or {} + dry_run = bool(body.get("dry_run")) or _env_bool("HEDGE_PLAN_DRY_RUN", False) + conn = cfg["get_db"]() + try: + init_hedge_plan_tables(conn) + plan = get_plan(conn, plan_id) + if not plan: + return jsonify({"ok": False, "msg": "计划不存在"}), 404 + if str(plan.get("status") or "") != "partial": + return jsonify({"ok": False, "msg": "仅半腿待补(partial)计划可补开"}), 400 + legs = get_plan_legs(conn, plan_id) + start_body: dict[str, Any] = {} + try: + meta = json.loads(plan.get("preview_json") or "{}") + if isinstance(meta, dict): + start_body = dict(meta.get("start_body") or {}) + except Exception: + start_body = {} + if not start_body: + return jsonify({"ok": False, "msg": "缺少开仓参数,无法补开"}), 400 + # 允许请求体覆盖少量字段 + for k in ("contracts", "leverage", "sheets", "tp", "sl"): + if body.get(k) not in (None, ""): + start_body[k] = body.get(k) + out = execute_complete_missing_leg( + cfg, plan, legs, start_body, dry_run=dry_run + ) + if not out.get("ok"): + return jsonify(out), 400 + if dry_run: + return jsonify(out) + fill = out.get("fill") or {} + leg_id = out.get("leg_id") + role = str(out.get("leg_role") or "") + opened_at = out.get("opened_at") + if leg_id: + if role == "perp": + update_leg( + conn, + int(leg_id), + status="open", + size=float(fill.get("contracts") or start_body.get("contracts") or 0), + avg_open=float(start_body.get("entry") or plan.get("entry_mark") or 0), + exchange_ord_id=str(fill.get("exchange_ord_id") or ""), + opened_at=opened_at, + ) + update_plan( + conn, + plan_id, + status="active", + close_reason=None, + note=None, + perp_size=float(fill.get("contracts") or start_body.get("contracts") or 0), + ) + else: + prem = float(fill.get("premium") or 0) + update_leg( + conn, + int(leg_id), + status="open", + size=float(fill.get("sheets") or start_body.get("sheets") or 1), + avg_open=float(fill.get("ask") or 0), + premium=prem, + exchange_ord_id=str(fill.get("exchange_ord_id") or ""), + opened_at=opened_at, + inst_id=str(fill.get("inst_id") or ""), + ) + old_prem = float(plan.get("premium_total") or 0) + update_plan( + conn, + plan_id, + status="active", + close_reason=None, + note=None, + premium_total=old_prem + prem, + ) + conn.commit() + plan2 = get_plan(conn, plan_id) + legs2 = get_plan_legs(conn, plan_id) + if plan2: + notify_plan_start(cfg, conn, plan2, legs2) + conn.commit() + out["plan_id"] = plan_id + out["status"] = "active" + out["plan"] = plan2 + out["legs"] = legs2 + return jsonify(out) + finally: + conn.close() + @app.route("/api/hedge-plan/list") @lr def api_hedge_list(): diff --git a/lib/hedge_plan/templates/hedge_plan_panel.html b/lib/hedge_plan/templates/hedge_plan_panel.html index f09d316..459f4b8 100644 --- a/lib/hedge_plan/templates/hedge_plan_panel.html +++ b/lib/hedge_plan/templates/hedge_plan_panel.html @@ -300,4 +300,4 @@ - + diff --git a/tests/test_hedge_partial_manual.py b/tests/test_hedge_partial_manual.py new file mode 100644 index 0000000..4f82d7d --- /dev/null +++ b/tests/test_hedge_partial_manual.py @@ -0,0 +1,45 @@ +"""半腿失败:手动补开 vs 自动平.""" +from __future__ import annotations + +import os +import unittest +from unittest import mock + +from lib.hedge_plan.hedge_plan_orders_lib import ( + manual_complete_on_partial, + partial_auto_close_enabled, +) + + +class PartialManualTests(unittest.TestCase): + def test_manual_default_forces_auto_close_off(self): + with mock.patch.dict(os.environ, {}, clear=False): + os.environ.pop("HEDGE_PLAN_MANUAL_COMPLETE_ON_PARTIAL", None) + os.environ["HEDGE_PLAN_PARTIAL_AUTO_CLOSE_OPTION"] = "true" + self.assertTrue(manual_complete_on_partial()) + self.assertFalse(partial_auto_close_enabled()) + + def test_manual_off_allows_auto_close(self): + with mock.patch.dict( + os.environ, + { + "HEDGE_PLAN_MANUAL_COMPLETE_ON_PARTIAL": "false", + "HEDGE_PLAN_PARTIAL_AUTO_CLOSE_OPTION": "true", + }, + ): + self.assertFalse(manual_complete_on_partial()) + self.assertTrue(partial_auto_close_enabled()) + + def test_both_off(self): + with mock.patch.dict( + os.environ, + { + "HEDGE_PLAN_MANUAL_COMPLETE_ON_PARTIAL": "false", + "HEDGE_PLAN_PARTIAL_AUTO_CLOSE_OPTION": "false", + }, + ): + self.assertFalse(partial_auto_close_enabled()) + + +if __name__ == "__main__": + unittest.main()