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