Park partial hedge plans for manual leg complete instead of auto-close.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
dekun
2026-07-19 09:06:22 +08:00
parent e11c13747a
commit d74d0aeae0
10 changed files with 540 additions and 64 deletions
+3
View File
@@ -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
# =============================================================================
+66 -3
View File
@@ -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 '<span class="hp-plan-partial">半腿待补</span>';
}
if ((p.status || "") === "opening") {
return '<span class="hp-plan-active">开仓中</span>';
}
return '<span class="hp-plan-active">进行中</span>';
}
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 (
'<button type="button" class="primary hp-btn-complete" data-id="' +
p.id +
'" data-role="' +
role +
'">' +
label +
"</button>"
);
}
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 @@
"\"><code>" +
contracts +
"</code></td><td>" +
'<span class="hp-plan-active">进行中</span>' +
activeStatusLabel(p) +
"</td><td>" +
activeTargetLabel(p) +
"</td><td>" +
(p.opened_at || "—") +
'</td><td class="hp-hist-actions"><button type="button" class="btn-secondary hp-btn-detail" data-id="' +
'</td><td class="hp-hist-actions">' +
completeLegButtonHtml(p) +
'<button type="button" class="btn-secondary hp-btn-detail" data-id="' +
p.id +
'">成交细节</button></td>';
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 = '<tr><td colspan="8" class="err">' + (e.message || e) + "</td></tr>";
}
@@ -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));
+10
View File
@@ -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;
}
+1
View File
@@ -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",
+11 -1
View File
@@ -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",
}
+21 -2
View File
@@ -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
+199 -17
View File
@@ -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":
+183 -40
View File
@@ -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/<int:plan_id>/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():
@@ -300,4 +300,4 @@
</div>
</div>
</div>
<script src="/static/hedge_plan.js?v=21"></script>
<script src="/static/hedge_plan.js?v=22"></script>
+45
View File
@@ -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()