From 9295dcffe336f2b1c5f9deb98e5c2e78558ac349 Mon Sep 17 00:00:00 2001 From: dekun Date: Wed, 14 Oct 2026 17:29:58 +0800 Subject: [PATCH] Show editable exit on live cards; price perp PnL via close-side book VWAP. Align monitor profit-exit with the same book VWAP so card and auto-exit stay consistent. Co-authored-by: Cursor --- lib/common/static/hedge_plan.js | 166 ++++++++++++++- lib/common/static/instance_theme.css | 40 ++++ lib/hedge_plan/hedge_plan_book_lib.py | 193 ++++++++++++++++++ lib/hedge_plan/hedge_plan_exit_lib.py | 79 +++++++ .../hedge_plan_live_positions_lib.py | 46 ++++- lib/hedge_plan/hedge_plan_monitor_lib.py | 23 ++- lib/hedge_plan/hedge_plan_register.py | 19 ++ .../templates/hedge_plan_panel.html | 37 +++- lib/instance/templates/embed_shell.html | 2 +- lib/instance/templates/index.html | 2 +- 10 files changed, 597 insertions(+), 10 deletions(-) create mode 100644 lib/hedge_plan/hedge_plan_book_lib.py create mode 100644 lib/hedge_plan/hedge_plan_exit_lib.py diff --git a/lib/common/static/hedge_plan.js b/lib/common/static/hedge_plan.js index c3cb4cd..04895a2 100644 --- a/lib/common/static/hedge_plan.js +++ b/lib/common/static/hedge_plan.js @@ -929,19 +929,46 @@ return m + "分"; } + function formatExitLine(ex) { + const e = ex || {}; + const mode = String(e.mode || "").toLowerCase() === "target" ? "target" : "profit"; + if (mode === "profit") { + return ( + "出场 · 盈利金额 " + + (e.profit_target_usdt != null ? fmt(e.profit_target_usdt, 2) + " U" : "—") + + "" + ); + } + return ( + "出场 · 目标位 期权±" + + (e.option_target_points != null ? fmt(e.option_target_points, 0) : "—") + + " / 永续±" + + (e.perp_target_points != null ? fmt(e.perp_target_points, 0) : "—") + + "" + ); + } + function renderLivePosCard(card) { const opt = card.option || {}; const perp = card.perp || {}; const combo = card.combo || {}; + const exit = card.exit || {}; const optType = String(opt.opt_type || "").toUpperCase() === "P" ? "Put" : "Call"; const perpSide = String(perp.side || "").toLowerCase() === "short" ? "空" : "多"; const comboCls = pnlClsNum(combo.net_usdt); const liqWarn = opt.liq_ok === false; + const perpLiqWarn = perp.liq_ok === false; const unflat = !!card.unflat || String(card.status || "") === "closed"; const bidTxt = opt.bid != null ? fmt(opt.bid, 4) + (opt.bid_sz != null ? "/" + fmt(opt.bid_sz, 0) : "") : "—"; + const bookLab = String(perp.book_side || "") === "asks" ? "卖一" : "买一"; + const perpTopTxt = + perp.top_px != null + ? fmt(perp.top_px, 2) + (perp.top_sz != null ? "/" + fmt(perp.top_sz, 0) : "") + : "—"; + const canEditExit = !unflat; return ( '
" + "" + + '
' + + "" + + formatExitLine(exit) + + "" + + (canEditExit + ? ' ' + : "") + + "
" + '
' + '
' + '
期权 · ' + @@ -1025,9 +1070,22 @@ "开仓 " + fmt(perp.avg_open, 2) + "" + - "标记 " + + '标记 ' + fmt(perp.mark_px, 2) + "" + + '' + + bookLab + + "/流动性 " + + perpTopTxt + + (perpLiqWarn ? " · 不足" : "") + + "" + + '估均价 ' + + (perp.vwap != null ? fmt(perp.vwap, 2) : "—") + + "" + "保证金 " + (perp.margin != null ? fmt(perp.margin, 2) + " U" : "—") + "" + @@ -1039,7 +1097,7 @@ "
" + "
" + '
' + - '组合净盈亏(扣开平手续费)' + + '组合净盈亏(扣开平手续费·永续盘口均价)' + '' + @@ -1057,6 +1115,89 @@ ); } + function setExitModalMode(mode) { + const m = mode === "target" ? "target" : "profit"; + document.querySelectorAll(".hp-exit-mode-btn").forEach(function (b) { + const on = b.getAttribute("data-exit") === m; + b.classList.toggle("is-selected", on); + }); + const profitBox = $("hp-exit-fields-profit"); + const targetBox = $("hp-exit-fields-target"); + if (profitBox) { + const show = m === "profit"; + profitBox.classList.toggle("hidden", !show); + if (show) profitBox.removeAttribute("hidden"); + else profitBox.setAttribute("hidden", "hidden"); + } + if (targetBox) { + const show = m === "target"; + targetBox.classList.toggle("hidden", !show); + if (show) targetBox.removeAttribute("hidden"); + else targetBox.setAttribute("hidden", "hidden"); + } + } + + function openExitModal(btn) { + const modal = $("hp-exit-modal"); + if (!modal || !btn) return; + const id = Number(btn.getAttribute("data-id") || 0); + if (!(id > 0)) return; + if ($("hp-exit-plan-id")) $("hp-exit-plan-id").value = String(id); + const mode = btn.getAttribute("data-mode") === "target" ? "target" : "profit"; + setExitModalMode(mode); + if ($("hp-exit-profit")) $("hp-exit-profit").value = btn.getAttribute("data-profit") || ""; + if ($("hp-exit-opt-pts")) $("hp-exit-opt-pts").value = btn.getAttribute("data-opt-pts") || ""; + if ($("hp-exit-perp-pts")) $("hp-exit-perp-pts").value = btn.getAttribute("data-perp-pts") || ""; + if ($("hp-exit-title")) $("hp-exit-title").textContent = "改出场 #" + id; + if ($("hp-exit-err")) { + $("hp-exit-err").textContent = ""; + $("hp-exit-err").setAttribute("hidden", ""); + } + modal.hidden = false; + } + + function closeExitModal() { + const modal = $("hp-exit-modal"); + if (modal) modal.hidden = true; + } + + async function saveExitModal() { + const id = Number(($("hp-exit-plan-id") && $("hp-exit-plan-id").value) || 0); + const err = $("hp-exit-err"); + if (!(id > 0)) return; + const modeBtn = document.querySelector(".hp-exit-mode-btn.is-selected"); + const mode = modeBtn && modeBtn.getAttribute("data-exit") === "target" ? "target" : "profit"; + const body = + mode === "profit" + ? { + exit_mode: "profit", + profit_target_usdt: Number(($("hp-exit-profit") && $("hp-exit-profit").value) || 0), + } + : { + exit_mode: "target", + option_target_points: Number(($("hp-exit-opt-pts") && $("hp-exit-opt-pts").value) || 0), + perp_target_points: Number(($("hp-exit-perp-pts") && $("hp-exit-perp-pts").value) || 0), + }; + try { + const d = await apiJson("/api/hedge-plan/" + id + "/exit", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(body), + }); + closeExitModal(); + alert(d.msg || "出场已更新"); + void loadLivePositions(); + void loadActivePlans(); + } catch (e) { + if (err) { + err.textContent = e.message || String(e); + err.removeAttribute("hidden"); + } else { + alert(e.message || String(e)); + } + } + } + async function loadLivePositions() { const box = $("hp-live-pos-list"); if (!box) return; @@ -1078,6 +1219,11 @@ void endActivePlan(Number(btn.getAttribute("data-id"))); }); }); + box.querySelectorAll(".hp-btn-exit").forEach(function (btn) { + btn.addEventListener("click", function () { + openExitModal(btn); + }); + }); } catch (e) { box.innerHTML = '

' + (e.message || e) + "

"; } @@ -2823,6 +2969,22 @@ if (ev.target === modal) closeModal(); }); } + document.querySelectorAll(".hp-exit-mode-btn").forEach(function (b) { + b.addEventListener("click", function () { + setExitModalMode(b.getAttribute("data-exit") === "target" ? "target" : "profit"); + }); + }); + if ($("hp-exit-close")) $("hp-exit-close").addEventListener("click", closeExitModal); + if ($("hp-exit-cancel")) $("hp-exit-cancel").addEventListener("click", closeExitModal); + if ($("hp-exit-save")) $("hp-exit-save").addEventListener("click", function () { + void saveExitModal(); + }); + const exitModal = $("hp-exit-modal"); + if (exitModal) { + exitModal.addEventListener("click", function (ev) { + if (ev.target === exitModal) closeExitModal(); + }); + } } async function loadHistory() { diff --git a/lib/common/static/instance_theme.css b/lib/common/static/instance_theme.css index 7a135e7..4b0abe8 100644 --- a/lib/common/static/instance_theme.css +++ b/lib/common/static/instance_theme.css @@ -4202,6 +4202,46 @@ html[data-theme="light"] .opt-be-dist-down { color: #e8ecf4; font-weight: 600; } +.hedge-plan-page-wrap .hp-live-exit-row { + display: flex; + flex-wrap: wrap; + align-items: center; + gap: 8px 12px; + margin: 0 0 10px; + padding: 8px 10px; + border-radius: 8px; + background: rgba(110, 140, 255, 0.08); + border: 1px solid rgba(110, 140, 255, 0.18); + font-size: 0.78rem; + color: #c5d0ee; +} +.hedge-plan-page-wrap .hp-live-exit-row strong { + color: #e8ecf4; + font-weight: 650; +} +.hedge-plan-page-wrap .hp-live-exit-row .hp-btn-exit { + padding: 2px 8px; + font-size: 0.72rem; + min-height: 26px; +} +.hedge-plan-page-wrap .hp-exit-modal .hp-exit-mode-seg { + margin-bottom: 12px; +} +.hedge-plan-page-wrap .hp-exit-fields { + display: flex; + flex-direction: column; + gap: 10px; + margin-bottom: 12px; +} +.hedge-plan-page-wrap .hp-exit-fields label { + display: flex; + flex-direction: column; + gap: 4px; + font-size: 0.82rem; +} +.hedge-plan-page-wrap .hp-exit-fields input { + max-width: 220px; +} .hedge-plan-page-wrap .hp-liq-warn, .hedge-plan-page-wrap .hp-liq-warn strong { color: #f5c542 !important; diff --git a/lib/hedge_plan/hedge_plan_book_lib.py b/lib/hedge_plan/hedge_plan_book_lib.py new file mode 100644 index 0000000..9fe09bb --- /dev/null +++ b/lib/hedge_plan/hedge_plan_book_lib.py @@ -0,0 +1,193 @@ +"""永续盘口:按平仓方向扫档估 VWAP(持仓净利/出场判定与真平仓同口径).""" +from __future__ import annotations + +from typing import Any, Optional + + +def _sf(v: Any) -> Optional[float]: + try: + if v is None or v == "": + return None + return float(v) + except (TypeError, ValueError): + return None + + +def _levels_from_book(book: dict[str, Any] | None, side: str) -> list[tuple[float, float]]: + """side=bids|asks → [(px, sz_contracts), ...]. ccxt 多为 [px, amount].""" + if not isinstance(book, dict): + return [] + raw = book.get(side) or [] + out: list[tuple[float, float]] = [] + for row in raw: + px = sz = None + if isinstance(row, (list, tuple)) and len(row) >= 2: + px, sz = _sf(row[0]), _sf(row[1]) + elif isinstance(row, dict): + px = _sf(row.get("px") or row.get("price") or row.get(0)) + sz = _sf(row.get("sz") or row.get("size") or row.get("amount") or row.get(1)) + if px is None or px <= 0 or sz is None or sz <= 0: + continue + out.append((float(px), float(sz))) + return out + + +def walk_book_vwap( + levels: list[tuple[float, float]], + *, + need_contracts: float, +) -> dict[str, Any]: + """从第一档往下扫 need_contracts 张,返回 VWAP 与流动性结果.""" + need = float(need_contracts or 0) + if need <= 0: + return { + "ok": False, + "liq_ok": False, + "msg": "平仓张数无效", + "vwap": None, + "top_px": None, + "top_sz": None, + "filled_contracts": 0.0, + "need_contracts": need, + } + if not levels: + return { + "ok": False, + "liq_ok": False, + "msg": "盘口为空", + "vwap": None, + "top_px": None, + "top_sz": None, + "filled_contracts": 0.0, + "need_contracts": need, + } + top_px, top_sz = levels[0][0], levels[0][1] + remain = need + notional = 0.0 + filled = 0.0 + for px, sz in levels: + take = min(remain, sz) + if take <= 0: + continue + notional += take * px + filled += take + remain -= take + if remain <= 1e-12: + break + if filled <= 1e-12: + return { + "ok": False, + "liq_ok": False, + "msg": "盘口深度为 0", + "vwap": None, + "top_px": top_px, + "top_sz": top_sz, + "filled_contracts": 0.0, + "need_contracts": need, + } + vwap = notional / filled + liq_ok = remain <= 1e-12 + msg = "" if liq_ok else f"盘口深度不足(需 {need:g} 张,可扫 {filled:g})" + return { + "ok": True, + "liq_ok": liq_ok, + "msg": msg, + "vwap": round(float(vwap), 8), + "top_px": float(top_px), + "top_sz": float(top_sz), + "filled_contracts": round(float(filled), 8), + "need_contracts": need, + } + + +def perp_close_book_side(position_side: str) -> str: + """持仓方向 → 平仓吃的盘口侧:平多吃 bids,平空吃 asks.""" + return "bids" if str(position_side or "").lower() == "long" else "asks" + + +def fetch_perp_close_vwap( + exchange: Any, + *, + symbol: str, + position_side: str, + contracts: float, + fallback_px: Optional[float] = None, + limit: int = 20, +) -> dict[str, Any]: + """拉永续盘口并按平仓方向扫档估 VWAP. + + 返回含: vwap, top_px, top_sz, book_side, liq_ok, msg, filled_contracts. + 深度不足时仍返回已扫部分均价(ok=True,liq_ok=False);完全无盘口则回退 fallback_px. + """ + sym = str(symbol or "").strip() + side = str(position_side or "").lower() + book_side = perp_close_book_side(side) + need = float(contracts or 0) + base: dict[str, Any] = { + "ok": False, + "liq_ok": False, + "msg": "", + "vwap": None, + "top_px": None, + "top_sz": None, + "filled_contracts": 0.0, + "need_contracts": need, + "book_side": book_side, + "symbol": sym, + "position_side": side, + } + if not sym or need <= 0: + base["msg"] = "符号或张数无效" + if fallback_px and float(fallback_px) > 0: + base.update({"ok": True, "vwap": float(fallback_px), "msg": "无盘口,回退标记/开仓价"}) + return base + + book = None + if exchange is not None and hasattr(exchange, "fetch_order_book"): + try: + book = exchange.fetch_order_book(sym, limit=int(limit or 20)) + except Exception as e: + base["msg"] = f"拉盘口失败: {e}" + book = None + + levels = _levels_from_book(book, book_side) + walked = walk_book_vwap(levels, need_contracts=need) + base.update(walked) + base["book_side"] = book_side + if base.get("vwap") is not None: + return base + + # 回退:ticker 单档 + try: + if exchange is not None and hasattr(exchange, "fetch_ticker"): + t = exchange.fetch_ticker(sym) or {} + if book_side == "bids": + px = _sf(t.get("bid") or (t.get("info") or {}).get("bidPx")) + else: + px = _sf(t.get("ask") or (t.get("info") or {}).get("askPx")) + if px and px > 0: + base.update( + { + "ok": True, + "liq_ok": False, + "vwap": float(px), + "top_px": float(px), + "top_sz": None, + "msg": "仅单档行情,深度未知", + "filled_contracts": need, + } + ) + return base + except Exception: + pass + + if fallback_px and float(fallback_px) > 0: + base.update( + { + "ok": True, + "liq_ok": False, + "vwap": float(fallback_px), + "msg": base.get("msg") or "无盘口,回退标记/开仓价", + } + ) + return base diff --git a/lib/hedge_plan/hedge_plan_exit_lib.py b/lib/hedge_plan/hedge_plan_exit_lib.py new file mode 100644 index 0000000..53fe445 --- /dev/null +++ b/lib/hedge_plan/hedge_plan_exit_lib.py @@ -0,0 +1,79 @@ +"""对冲计划出场条件:读取/校验/更新.""" +from __future__ import annotations + +from typing import Any, Optional + + +def _sf(v: Any) -> Optional[float]: + try: + if v is None or v == "": + return None + return float(v) + except (TypeError, ValueError): + return None + + +def normalize_exit_mode(raw: Any, *, profit_target: Any = None) -> str: + m = str(raw or "").strip().lower() + if m in ("profit", "target"): + return m + if _sf(profit_target) is not None and float(profit_target) > 0: + return "profit" + return "target" + + +def validate_exit_payload(body: dict[str, Any] | None) -> tuple[bool, str, dict[str, Any]]: + """校验改出场请求 → (ok, msg, fields_for_update).""" + b = body or {} + mode = normalize_exit_mode(b.get("exit_mode") or b.get("mode")) + if mode == "profit": + pt = _sf(b.get("profit_target_usdt")) + if pt is None or pt <= 0: + return False, "盈利金额须大于 0", {} + return True, "", { + "exit_mode": "profit", + "profit_target_usdt": float(pt), + } + opt_pts = _sf(b.get("option_target_points")) + perp_pts = _sf(b.get("perp_target_points")) + if opt_pts is None or opt_pts <= 0: + return False, "期权目标点数须大于 0", {} + if perp_pts is None or perp_pts <= 0: + return False, "永续目标点数须大于 0", {} + return True, "", { + "exit_mode": "target", + "option_target_points": float(opt_pts), + "perp_target_points": float(perp_pts), + } + + +def update_plan_exit(cfg: dict[str, Any], conn: Any, plan_id: int, body: dict[str, Any] | None) -> dict[str, Any]: + """更新进行中计划的出场条件.""" + from lib.hedge_plan.hedge_plan_db import get_plan, get_plan_legs, update_plan + + plan = get_plan(conn, int(plan_id)) + if not plan: + return {"ok": False, "msg": "计划不存在"} + st = str(plan.get("status") or "").lower() + legs = get_plan_legs(conn, int(plan_id)) + has_open = any(str(x.get("status") or "").lower() == "open" for x in legs) + if st not in ("opening", "active", "partial", "watching") and not (st == "closed" and has_open): + return {"ok": False, "msg": f"当前状态 {st or '—'} 不可改出场"} + + ok, msg, fields = validate_exit_payload(body) + if not ok: + return {"ok": False, "msg": msg} + + update_plan(conn, int(plan_id), **fields) + plan2 = get_plan(conn, int(plan_id)) + return { + "ok": True, + "plan_id": int(plan_id), + "msg": "出场条件已更新", + "exit": { + "mode": fields.get("exit_mode"), + "profit_target_usdt": plan2.get("profit_target_usdt") if plan2 else fields.get("profit_target_usdt"), + "option_target_points": plan2.get("option_target_points") if plan2 else fields.get("option_target_points"), + "perp_target_points": plan2.get("perp_target_points") if plan2 else fields.get("perp_target_points"), + }, + } diff --git a/lib/hedge_plan/hedge_plan_live_positions_lib.py b/lib/hedge_plan/hedge_plan_live_positions_lib.py index 6d38fb2..fb28ae9 100644 --- a/lib/hedge_plan/hedge_plan_live_positions_lib.py +++ b/lib/hedge_plan/hedge_plan_live_positions_lib.py @@ -4,6 +4,7 @@ from __future__ import annotations from datetime import datetime, timedelta, timezone from typing import Any, Optional +from lib.hedge_plan.hedge_plan_book_lib import fetch_perp_close_vwap from lib.hedge_plan.hedge_plan_option_primary_lib import ( estimate_combo_net_pnl, fee_rate, @@ -129,6 +130,12 @@ def build_live_hedge_cards(cfg: dict[str, Any], conn: Any) -> list[dict[str, Any get_perp_metrics = cfg.get("get_live_position_exchange_metrics") get_cs = cfg.get("get_contract_size") fetch_idx = cfg.get("fetch_index_price") + ex_perp = None + try: + get_pex = cfg.get("exchange") + ex_perp = get_pex() if callable(get_pex) else get_pex + except Exception: + ex_perp = None fr = fee_rate() fr_opt = option_fee_rate() now_ms = int(datetime.now(tz=_BJ).timestamp() * 1000) @@ -205,6 +212,21 @@ def build_live_hedge_cards(cfg: dict[str, Any], conn: Any) -> list[dict[str, Any if (margin is None or margin <= 0) and contracts and perp_mark and perp_lev and perp_lev > 0: margin = round(float(contracts) * float(cs) * float(perp_mark) / float(perp_lev), 2) + # 永续净利:按平仓方向扫盘口 VWAP(方案 A);标记价仅参考 + book = fetch_perp_close_vwap( + ex_perp, + symbol=sym, + position_side=perp_dir, + contracts=float(contracts or 0), + fallback_px=perp_mark or perp_entry, + ) + perp_vwap = _sf(book.get("vwap")) or perp_mark or perp_entry + perp_liq_ok = bool(book.get("liq_ok")) if book.get("ok") else False + perp_liq_msg = str(book.get("msg") or "") + perp_top = _sf(book.get("top_px")) + perp_top_sz = _sf(book.get("top_sz")) + book_side = str(book.get("book_side") or "") + idx = None if callable(fetch_idx): try: @@ -212,7 +234,7 @@ def build_live_hedge_cards(cfg: dict[str, Any], conn: Any) -> list[dict[str, Any except Exception: idx = None if idx is None or idx <= 0: - idx = perp_mark or strike or 0.0 + idx = perp_vwap or perp_mark or strike or 0.0 net = estimate_combo_net_pnl( view_side=view, @@ -224,7 +246,7 @@ def build_live_hedge_cards(cfg: dict[str, Any], conn: Any) -> list[dict[str, Any ct_mult=float(ct or 0.01), perp_direction=perp_dir, perp_entry=float(perp_entry or 0), - perp_mark=float(perp_mark or 0), + perp_mark=float(perp_vwap or 0), contracts=float(contracts or 0), contract_size=float(cs or 0.01), fee=fr, @@ -245,6 +267,10 @@ def build_live_hedge_cards(cfg: dict[str, Any], conn: Any) -> list[dict[str, Any hold_sec = int((now_ms - opened_ms) / 1000) if opened_ms else None remain_sec = int((exp_ms - now_ms) / 1000) if exp_ms else None + exit_mode = str(plan.get("exit_mode") or "").strip().lower() + if exit_mode not in ("profit", "target"): + exit_mode = "profit" if _sf(plan.get("profit_target_usdt")) else "target" + out.append( { "plan_id": plan.get("id"), @@ -260,6 +286,12 @@ def build_live_hedge_cards(cfg: dict[str, Any], conn: Any) -> list[dict[str, Any "index_px": idx, "fee_rate": fr, "option_fee_rate": fr_opt, + "exit": { + "mode": exit_mode, + "profit_target_usdt": _sf(plan.get("profit_target_usdt")), + "option_target_points": _sf(plan.get("option_target_points")), + "perp_target_points": _sf(plan.get("perp_target_points")), + }, "option": { "inst_id": inst_id or "—", "opt_type": opt_type, @@ -286,17 +318,23 @@ def build_live_hedge_cards(cfg: dict[str, Any], conn: Any) -> list[dict[str, Any "contract_size": cs, "avg_open": perp_entry, "mark_px": perp_mark, + "vwap": perp_vwap, + "book_side": book_side, + "top_px": perp_top, + "top_sz": perp_top_sz, + "liq_ok": perp_liq_ok, + "liq_msg": perp_liq_msg, "leverage": perp_lev or None, "margin": margin, "upl": perp_upl, "net_usdt": perp_net, - "live": perp_upl is not None or (perp_mark is not None and perp_mark > 0), + "live": bool(book.get("ok")) or perp_upl is not None, }, "combo": { "net_usdt": combo_usdt, "opt_net_usdt": opt_net_usdt, "perp_net_usdt": perp_net, - "note": "扣开平双边手续费后估", + "note": "扣开平双边手续费;永续按盘口均价估", }, } ) diff --git a/lib/hedge_plan/hedge_plan_monitor_lib.py b/lib/hedge_plan/hedge_plan_monitor_lib.py index 3b2da7b..3599ae0 100644 --- a/lib/hedge_plan/hedge_plan_monitor_lib.py +++ b/lib/hedge_plan/hedge_plan_monitor_lib.py @@ -732,6 +732,11 @@ def _tick_po_option_primary( symbol = str(perp.get("symbol") or "") mark = None ex = cfg.get("exchange") + if callable(ex): + try: + ex = ex() + except Exception: + ex = None if ex is not None and symbol: try: t = ex.fetch_ticker(symbol) @@ -750,6 +755,11 @@ def _tick_po_option_primary( quote_fn = cfg.get("quote_option_contract") ex_opt = cfg.get("exchange_options") + if callable(ex_opt): + try: + ex_opt = ex_opt() + except Exception: + ex_opt = None bid = None bid_sz = None if callable(quote_fn) and ex_opt is not None: @@ -765,6 +775,17 @@ def _tick_po_option_primary( sheets = float(opt.get("size") or 1) ct = float(opt.get("ct_mult") or 0.01) contracts = float(perp.get("size") or plan.get("perp_size") or 0) + # 与持仓卡同口径:永续按平仓方向扫盘口 VWAP + from lib.hedge_plan.hedge_plan_book_lib import fetch_perp_close_vwap + + book = fetch_perp_close_vwap( + ex, + symbol=symbol, + position_side=perp_dir, + contracts=contracts, + fallback_px=mark or entry, + ) + perp_px = _sf(book.get("vwap")) or mark or entry # 优先期权腿平仓(须买一流动性);盈利金额模式要求扣双边费后净利≥目标 if hit_opt: @@ -781,7 +802,7 @@ def _tick_po_option_primary( ct_mult=ct, perp_direction=perp_dir, perp_entry=float(entry or 0), - perp_mark=float(mark or 0), + perp_mark=float(perp_px or 0), contracts=contracts, contract_size=cs, ) diff --git a/lib/hedge_plan/hedge_plan_register.py b/lib/hedge_plan/hedge_plan_register.py index 08aee35..1f3fdf6 100644 --- a/lib/hedge_plan/hedge_plan_register.py +++ b/lib/hedge_plan/hedge_plan_register.py @@ -957,6 +957,25 @@ def register_hedge_plan_routes(app: Flask, cfg: dict[str, Any]) -> None: conn.close() return jsonify(out) + @app.route("/api/hedge-plan//exit", methods=["POST"]) + @lr + def api_hedge_update_exit(plan_id: int): + """持仓中改出场:可切换盈利金额/目标位并改数字.""" + from lib.hedge_plan.hedge_plan_db import init_hedge_plan_tables + from lib.hedge_plan.hedge_plan_exit_lib import update_plan_exit + + body = request.get_json(silent=True) or {} + conn = cfg["get_db"]() + try: + init_hedge_plan_tables(conn) + out = update_plan_exit(cfg, conn, plan_id, body) + if not out.get("ok"): + return jsonify(out), 400 + conn.commit() + finally: + conn.close() + return jsonify(out) + @app.route("/api/hedge-plan//complete-leg", methods=["POST"]) @lr def api_hedge_complete_leg(plan_id: int): diff --git a/lib/hedge_plan/templates/hedge_plan_panel.html b/lib/hedge_plan/templates/hedge_plan_panel.html index 4cd3683..fd04867 100644 --- a/lib/hedge_plan/templates/hedge_plan_panel.html +++ b/lib/hedge_plan/templates/hedge_plan_panel.html @@ -430,5 +430,40 @@
+ + - + diff --git a/lib/instance/templates/embed_shell.html b/lib/instance/templates/embed_shell.html index cd9a22c..41d5688 100644 --- a/lib/instance/templates/embed_shell.html +++ b/lib/instance/templates/embed_shell.html @@ -8,7 +8,7 @@ - + diff --git a/lib/instance/templates/index.html b/lib/instance/templates/index.html index 4b2475a..f37a513 100644 --- a/lib/instance/templates/index.html +++ b/lib/instance/templates/index.html @@ -19,7 +19,7 @@ {{ pwa_app_name }} - +