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 HEDGE_PLAN_OO_CLOSE_MODE_ENABLED=true
# 对冲与单独期权互斥(默认 true):有对冲计划不可单独开期权;有单独期权不可启动对冲;false=可同时开 # 对冲与单独期权互斥(默认 true):有对冲计划不可单独开期权;有单独期权不可启动对冲;false=可同时开
HEDGE_PLAN_OPTIONS_MUTUAL_EXCLUSIVE=true HEDGE_PLAN_OPTIONS_MUTUAL_EXCLUSIVE=true
# 半腿失败改手动补开(默认 true):不自动平已成腿,计划挂 partial,页面补开;开启时下方自动平强制无效
HEDGE_PLAN_MANUAL_COMPLETE_ON_PARTIAL=true
MAX_ACTIVE_HEDGE_PLANS=1 MAX_ACTIVE_HEDGE_PLANS=1
HEDGE_PLAN_MONITOR_POLL_SECONDS=15 HEDGE_PLAN_MONITOR_POLL_SECONDS=15
# 半腿失败自动平期权;若 MANUAL_COMPLETE_ON_PARTIAL=true 则不会执行自动平
HEDGE_PLAN_PARTIAL_AUTO_CLOSE_OPTION=true HEDGE_PLAN_PARTIAL_AUTO_CLOSE_OPTION=true
# ============================================================================= # =============================================================================
+65 -2
View File
@@ -1198,6 +1198,51 @@
return "上破 " + fmt(p.target_price_up || p.target_price) + " · 下破 " + fmt(p.target_price_down || p.target_price); 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() { async function loadActivePlans() {
const tbody = $("hp-active-tbody"); const tbody = $("hp-active-tbody");
if (!tbody) return; if (!tbody) return;
@@ -1225,12 +1270,14 @@
"\"><code>" + "\"><code>" +
contracts + contracts +
"</code></td><td>" + "</code></td><td>" +
'<span class="hp-plan-active">进行中</span>' + activeStatusLabel(p) +
"</td><td>" + "</td><td>" +
activeTargetLabel(p) + activeTargetLabel(p) +
"</td><td>" + "</td><td>" +
(p.opened_at || "—") + (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 + p.id +
'">成交细节</button></td>'; '">成交细节</button></td>';
tbody.appendChild(tr); tbody.appendChild(tr);
@@ -1240,6 +1287,11 @@
void showPlanDetail(Number(btn.getAttribute("data-id"))); 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) { } catch (e) {
tbody.innerHTML = '<tr><td colspan="8" class="err">' + (e.message || e) + "</td></tr>"; tbody.innerHTML = '<tr><td colspan="8" class="err">' + (e.message || e) + "</td></tr>";
} }
@@ -1532,7 +1584,18 @@
}); });
setGateLine(d.gates); setGateLine(d.gates);
closePreviewModal(); closePreviewModal();
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)" : "")); alert("计划已启动 #" + (d.plan_id || "") + (d.dry_run ? " (dry_run)" : ""));
}
void loadGates(); void loadGates();
} catch (e) { } catch (e) {
alert(e.message || String(e)); alert(e.message || String(e));
+10
View File
@@ -3249,6 +3249,16 @@ html[data-theme="light"] .opt-be-dist-down {
color: #7ee787; color: #7ee787;
font-weight: 700; 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 { .hedge-plan-page-wrap .hp-pnl-neg {
color: #ff8a8a; color: #ff8a8a;
} }
+1
View File
@@ -92,6 +92,7 @@ HOT_RELOAD_EXACT = frozenset({
"HEDGE_PLAN_OO_CLOSE_WINNER_ONLY", "HEDGE_PLAN_OO_CLOSE_WINNER_ONLY",
"HEDGE_PLAN_OO_CLOSE_MODE_ENABLED", "HEDGE_PLAN_OO_CLOSE_MODE_ENABLED",
"HEDGE_PLAN_OPTIONS_MUTUAL_EXCLUSIVE", "HEDGE_PLAN_OPTIONS_MUTUAL_EXCLUSIVE",
"HEDGE_PLAN_MANUAL_COMPLETE_ON_PARTIAL",
"MAX_ACTIVE_HEDGE_PLANS", "MAX_ACTIVE_HEDGE_PLANS",
"HEDGE_PLAN_MONITOR_POLL_SECONDS", "HEDGE_PLAN_MONITOR_POLL_SECONDS",
"HEDGE_PLAN_PARTIAL_AUTO_CLOSE_OPTION", "HEDGE_PLAN_PARTIAL_AUTO_CLOSE_OPTION",
+11 -1
View File
@@ -151,9 +151,18 @@ _HEDGE_PLAN_SECTION: dict[str, Any] = {
"对冲与期权互斥门控", "对冲与期权互斥门控",
"默认 true;开启时:有对冲计划则不可单独开期权,有单独期权则不可启动对冲;关闭后两边可同时开", "默认 true;开启时:有对冲计划则不可单独开期权,有单独期权则不可启动对冲;关闭后两边可同时开",
), ),
(
"HEDGE_PLAN_MANUAL_COMPLETE_ON_PARTIAL",
"半腿失败改手动补开",
"默认 true;开启时半腿失败不自动平,计划挂 partial,页面可补开永续/腿B;并强制关闭下方自动平",
),
("MAX_ACTIVE_HEDGE_PLANS", "最大同时活跃计划数", "建议 1"), ("MAX_ACTIVE_HEDGE_PLANS", "最大同时活跃计划数", "建议 1"),
("HEDGE_PLAN_MONITOR_POLL_SECONDS", "对冲监控轮询(秒)", "默认 15"), ("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_SHOW_OPTIONS_OPTIONS": "true",
"HEDGE_PLAN_OO_CLOSE_MODE_ENABLED": "true", "HEDGE_PLAN_OO_CLOSE_MODE_ENABLED": "true",
"HEDGE_PLAN_OPTIONS_MUTUAL_EXCLUSIVE": "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]) 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( def list_plans(
conn: sqlite3.Connection, conn: sqlite3.Connection,
*, *,
@@ -184,15 +200,17 @@ def legs_contract_summary(legs: list[dict[str, Any]]) -> str:
parts: list[str] = [] parts: list[str] = []
for leg in legs: for leg in legs:
role = str(leg.get("leg_role") or "") role = str(leg.get("leg_role") or "")
st = str(leg.get("status") or "").strip().lower()
suffix = "(待补)" if st == "pending" else ""
if role == "perp": if role == "perp":
name = str(leg.get("symbol") or "永续") name = str(leg.get("symbol") or "永续")
parts.append(f"永续 {name}") parts.append(f"永续 {name}{suffix}")
else: else:
inst = str(leg.get("inst_id") or "") inst = str(leg.get("inst_id") or "")
ot = str(leg.get("opt_type") or "").upper() ot = str(leg.get("opt_type") or "").upper()
strike = leg.get("strike") strike = leg.get("strike")
label = inst or (f"{ot}{strike}" if ot or strike is not None else role) 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 "" 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 = dict(p)
row["legs"] = legs row["legs"] = legs
row["contracts_summary"] = legs_contract_summary(legs) row["contracts_summary"] = legs_contract_summary(legs)
row["missing_leg"] = missing_leg_role(legs)
out.append(row) out.append(row)
return out 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" 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]]: def build_po_path_plan(body: dict[str, Any]) -> list[dict[str, Any]]:
"""永期下单路径清单(不交易).""" """永期下单路径清单(不交易)."""
mode = open_order_mode() mode = open_order_mode()
@@ -257,6 +269,49 @@ def _sell_option(
return order if order.get("ok") else order 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( def execute_perp_options_start(
cfg: dict[str, Any], cfg: dict[str, Any],
body: dict[str, Any], body: dict[str, Any],
@@ -278,6 +333,27 @@ def execute_perp_options_start(
) )
results.append({"step": step["step"], **opt_res}) results.append({"step": step["step"], **opt_res})
if not opt_res.get("ok"): 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} return {"ok": False, "msg": opt_res.get("msg") or "期权开仓失败", "path": path, "results": results}
else: else:
perp_res = _open_perp( perp_res = _open_perp(
@@ -292,8 +368,7 @@ def execute_perp_options_start(
) )
results.append({"step": step["step"], **perp_res}) results.append({"step": step["step"], **perp_res})
if not perp_res.get("ok"): if not perp_res.get("ok"):
# 半腿补偿:期权已成 + 配置允许则平期权 if opt_res and opt_res.get("ok") and not dry_run and partial_auto_close_enabled():
if opt_res and opt_res.get("ok") and not dry_run and _env_bool("HEDGE_PLAN_PARTIAL_AUTO_CLOSE_OPTION", True):
close_r = _sell_option( close_r = _sell_option(
cfg, cfg,
inst_id=str(opt_res.get("inst_id") or body.get("opt_inst_id") or ""), inst_id=str(opt_res.get("inst_id") or body.get("opt_inst_id") or ""),
@@ -301,15 +376,37 @@ def execute_perp_options_start(
) )
results.append({"step": "options_auto_close_on_perp_fail", **close_r}) results.append({"step": "options_auto_close_on_perp_fail", **close_r})
msg = perp_res.get("msg") or "永续开仓失败" msg = perp_res.get("msg") or "永续开仓失败"
if not dry_run: _notify_partial(cfg, "perp_options", msg, results)
try: return {
from lib.hedge_plan.hedge_plan_notify_lib import notify_partial_fail "ok": False,
"msg": msg,
notify_partial_fail( "path": path,
cfg, plan_type="perp_options", msg=msg, results=results "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,
) )
except Exception: msg = perp_res.get("msg") or "永续开仓失败"
pass if not dry_run:
_notify_partial(cfg, "perp_options", msg, results)
return { return {
"ok": False, "ok": False,
"msg": msg, "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) 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}) results.append({"step": "options_buy_limit", "leg": "b", **b_res})
if not b_res.get("ok"): 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)) 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}) results.append({"step": "options_auto_close_leg_a", **close_r})
msg = b_res.get("msg") or "腿B开仓失败" 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: if not dry_run:
try: _notify_partial(cfg, "options_options", msg, results)
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
return { return {
"ok": False, "ok": False,
"msg": msg, "msg": msg,
@@ -384,6 +499,73 @@ def execute_options_options_start(
return out 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]: def validate_start_body(plan_type: str, body: dict[str, Any]) -> Optional[str]:
pt = (plan_type or "").strip().lower() pt = (plan_type or "").strip().lower()
if pt == "perp_options": if pt == "perp_options":
+173 -30
View File
@@ -2,7 +2,7 @@
from __future__ import annotations from __future__ import annotations
import os import os
from typing import Any from typing import Any, Optional
from flask import Flask, jsonify, request from flask import Flask, jsonify, request
from jinja2 import ChoiceLoader, FileSystemLoader from jinja2 import ChoiceLoader, FileSystemLoader
@@ -223,6 +223,18 @@ def _maybe_start_monitor(cfg: dict[str, Any]) -> None:
cfg["hedge_monitor_thread"] = t 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: def _persist_po(cfg: dict[str, Any], result: dict[str, Any], body: dict[str, Any]) -> int:
from lib.hedge_plan.hedge_plan_db import ( from lib.hedge_plan.hedge_plan_db import (
get_plan, 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"]() conn = cfg["get_db"]()
try: try:
init_hedge_plan_tables(conn) 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 {} opt = result.get("option") or {}
perp = result.get("perp") 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( plan_id = insert_plan(
conn, conn,
{ {
"plan_type": "perp_options", "plan_type": "perp_options",
"status": "active", "status": "partial" if is_partial else "active",
"underlying": str(body.get("underlying") or "ETH").upper(), "underlying": str(body.get("underlying") or "ETH").upper(),
"direction": str(body.get("direction") or "long"), "direction": str(body.get("direction") or "long"),
"entry_mark": float(body.get("entry") or 0), "entry_mark": float(body.get("entry") or 0),
"tp": float(body.get("tp") or 0), "tp": float(body.get("tp") or 0),
"sl": float(body.get("sl") or 0), "sl": float(body.get("sl") or 0),
"sizing_mode_at_open": load_position_sizing_mode(), "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"), "margin": body.get("margin"),
"leverage": float(body.get("leverage") or 10), "leverage": float(body.get("leverage") or 10),
"premium_total": premium, "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"), "opened_at": result.get("opened_at"),
"note": (result.get("msg") or "")[:500] if is_partial else None,
}, },
) )
insert_leg( insert_leg(
@@ -264,11 +287,11 @@ def _persist_po(cfg: dict[str, Any], result: dict[str, Any], body: dict[str, Any
"leg_role": "perp", "leg_role": "perp",
"symbol": str(body.get("exchange_symbol") or ""), "symbol": str(body.get("exchange_symbol") or ""),
"side": str(body.get("direction") or "long"), "side": str(body.get("direction") or "long"),
"size": float(perp.get("contracts") or body.get("contracts") or 0), "size": float((perp or {}).get("contracts") or body.get("contracts") or 0),
"avg_open": float(body.get("entry") or 0), "avg_open": float(body.get("entry") or 0) if perp_ok else None,
"status": "open", "status": "open" if perp_ok else "pending",
"exchange_ord_id": str(perp.get("exchange_ord_id") or ""), "exchange_ord_id": str((perp or {}).get("exchange_ord_id") or ""),
"opened_at": result.get("opened_at"), "opened_at": result.get("opened_at") if perp_ok else None,
}, },
) )
insert_leg( insert_leg(
@@ -276,19 +299,20 @@ def _persist_po(cfg: dict[str, Any], result: dict[str, Any], body: dict[str, Any
{ {
"plan_id": plan_id, "plan_id": plan_id,
"leg_role": "option_hedge", "leg_role": "option_hedge",
"inst_id": str(opt.get("inst_id") or body.get("opt_inst_id") or ""), "inst_id": str((opt or {}).get("inst_id") or body.get("opt_inst_id") or ""),
"opt_type": str(opt.get("opt_type") or body.get("opt_type") or ""), "opt_type": str((opt or {}).get("opt_type") or body.get("opt_type") or ""),
"strike": opt.get("strike") or body.get("strike"), "strike": (opt or {}).get("strike") or body.get("strike"),
"side": "buy", "side": "buy",
"size": float(opt.get("sheets") or body.get("sheets") or 1), "size": float((opt or {}).get("sheets") or body.get("sheets") or 1),
"avg_open": float(opt.get("ask") or 0), "avg_open": float((opt or {}).get("ask") or 0) if opt_ok else None,
"premium": premium, "premium": premium if opt_ok else 0,
"status": "open", "status": "open" if opt_ok else "pending",
"exchange_ord_id": str(opt.get("exchange_ord_id") or ""), "exchange_ord_id": str((opt or {}).get("exchange_ord_id") or ""),
"opened_at": result.get("opened_at"), "opened_at": result.get("opened_at") if opt_ok else None,
}, },
) )
conn.commit() conn.commit()
if not is_partial:
plan = get_plan(conn, plan_id) plan = get_plan(conn, plan_id)
legs = get_plan_legs(conn, plan_id) legs = get_plan_legs(conn, plan_id)
if plan: if plan:
@@ -312,14 +336,20 @@ def _persist_oo(cfg: dict[str, Any], result: dict[str, Any], body: dict[str, Any
conn = cfg["get_db"]() conn = cfg["get_db"]()
try: try:
init_hedge_plan_tables(conn) 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 {} a = result.get("leg_a") or {}
b = result.get("leg_b") 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( plan_id = insert_plan(
conn, conn,
{ {
"plan_type": "options_options", "plan_type": "options_options",
"status": "active", "status": "partial" if is_partial else "active",
"underlying": str(body.get("underlying") or "ETH").upper(), "underlying": str(body.get("underlying") or "ETH").upper(),
"target_price": float( "target_price": float(
body.get("target_price_up") body.get("target_price_up")
@@ -339,28 +369,35 @@ def _persist_oo(cfg: dict[str, Any], result: dict[str, Any], body: dict[str, Any
"sizing_mode_at_open": load_position_sizing_mode(), "sizing_mode_at_open": load_position_sizing_mode(),
"premium_total": premium, "premium_total": premium,
"oo_close_mode": _normalize_oo_close_mode(body.get("oo_close_mode")), "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"), "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( insert_leg(
conn, conn,
{ {
"plan_id": plan_id, "plan_id": plan_id,
"leg_role": role, "leg_role": role,
"inst_id": str(res.get("inst_id") or src.get("inst_id") or ""), "inst_id": str((res or {}).get("inst_id") or src.get("inst_id") or ""),
"opt_type": str(res.get("opt_type") or src.get("opt_type") or ""), "opt_type": str((res or {}).get("opt_type") or src.get("opt_type") or ""),
"strike": res.get("strike") or src.get("strike"), "strike": (res or {}).get("strike") or src.get("strike"),
"side": "buy", "side": "buy",
"size": float(res.get("sheets") or src.get("sheets") or 1), "size": float((res or {}).get("sheets") or src.get("sheets") or 1),
"avg_open": float(res.get("ask") or 0), "avg_open": float((res or {}).get("ask") or 0) if ok else None,
"premium": float(res.get("premium") or 0), "premium": float((res or {}).get("premium") or 0) if ok else 0,
"status": "open", "status": "open" if ok else "pending",
"exchange_ord_id": str(res.get("exchange_ord_id") or ""), "exchange_ord_id": str((res or {}).get("exchange_ord_id") or ""),
"opened_at": result.get("opened_at"), "opened_at": result.get("opened_at") if ok else None,
}, },
) )
conn.commit() conn.commit()
if not is_partial:
plan = get_plan(conn, plan_id) plan = get_plan(conn, plan_id)
legs = get_plan_legs(conn, plan_id) legs = get_plan_legs(conn, plan_id)
if plan: if plan:
@@ -537,6 +574,112 @@ def register_hedge_plan_routes(app: Flask, cfg: dict[str, Any]) -> None:
out["gates"] = gates out["gates"] = gates
return jsonify(out), (200 if out.get("ok") else 400) 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") @app.route("/api/hedge-plan/list")
@lr @lr
def api_hedge_list(): def api_hedge_list():
@@ -300,4 +300,4 @@
</div> </div>
</div> </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()