diff --git a/lib/common/static/options_panel.js b/lib/common/static/options_panel.js index 30e00f4..88d8505 100644 --- a/lib/common/static/options_panel.js +++ b/lib/common/static/options_panel.js @@ -321,7 +321,7 @@ [ "opt-sheets-amount", "opt-eth-amount", - "opt-target-idx", + "opt-profit-rr", ].forEach(function (id) { harden(document.getElementById(id)); }); @@ -894,11 +894,10 @@ } function updateOrderEstimates() { - const levEl = document.getElementById("opt-order-leverage"); const valueEl = document.getElementById("opt-est-value"); const profitEl = document.getElementById("opt-est-profit"); - const targetLevEl = document.getElementById("opt-est-leverage"); - const targetEl = document.getElementById("opt-target-idx"); + const levEl = document.getElementById("opt-order-leverage"); + const rrEl = document.getElementById("opt-profit-rr"); const q = state.orderQuote; if (!q || !q.ok || !q.can_open) { if (levEl) levEl.textContent = "—"; @@ -907,7 +906,6 @@ profitEl.textContent = "—"; profitEl.className = "v"; } - if (targetLevEl) targetLevEl.textContent = "—"; return; } const sz = q.sizing || {}; @@ -916,30 +914,19 @@ const lev = calcContractLeverage(q.index_px, ethAmount, premium); if (levEl) levEl.textContent = fmtLeverage(lev); - if (valueEl && profitEl && targetEl) { - const targetRaw = targetEl.value; - if (targetRaw === "" || targetRaw == null) { + if (valueEl && profitEl && rrEl) { + const rrRaw = rrEl.value; + const rr = rrRaw === "" || rrRaw == null ? NaN : Number(rrRaw); + if (!Number.isFinite(rr) || rr <= 0 || !(Number(premium) > 0)) { valueEl.textContent = "—"; profitEl.textContent = "—"; profitEl.className = "v"; - if (targetLevEl) targetLevEl.textContent = "—"; } else { - const value = estimateExpiryValue(q.opt_type, q.strike, Number(targetRaw), ethAmount); - const profit = estimateExpiryProfit(q.opt_type, q.strike, Number(targetRaw), ethAmount, premium); - if (value == null || Number.isNaN(value)) { - valueEl.textContent = "—"; - } else { - valueEl.textContent = fmtUsdc(value) + " USDC"; - } - if (profit == null || Number.isNaN(profit)) { - profitEl.textContent = "—"; - profitEl.className = "v"; - } else { - profitEl.textContent = fmtUsdcSigned(profit); - profitEl.className = "v " + pnlCls(profit); - } - const targetLev = calcContractLeverage(Number(targetRaw), ethAmount, premium); - if (targetLevEl) targetLevEl.textContent = fmtLeverage(targetLev); + const targetProfit = Number(premium) * rr; + const needRecycle = Number(premium) + targetProfit; + valueEl.textContent = fmtUsdc(needRecycle) + " USDC"; + profitEl.textContent = fmtUsdcSigned(targetProfit); + profitEl.className = "v " + pnlCls(targetProfit); } } } @@ -1329,15 +1316,13 @@ } else if (mode === "sheets") { body.sheets = parseInt(document.getElementById("opt-sheets-amount").value, 10); } - const tgtRaw = (document.getElementById("opt-target-idx").value || "").trim(); - if (tgtRaw !== "") { - const tgt = parseFloat(tgtRaw); - if (!Number.isFinite(tgt) || tgt <= 0) { - alert("目标位无效"); - return false; - } - body.target_index = tgt; + const rrRaw = (document.getElementById("opt-profit-rr").value || "").trim(); + const rr = rrRaw === "" ? 2 : parseFloat(rrRaw); + if (!Number.isFinite(rr) || rr <= 0) { + alert("盈亏比无效"); + return false; } + body.profit_rr = rr; const d = await apiJson("/api/options/open", { method: "POST", headers: { "Content-Type": "application/json" }, @@ -1428,15 +1413,17 @@ return null; } - function formatTargetEstimateHtml(optType, strike, targetIdx, ethAmount, premiumPaid) { - const value = estimateExpiryValue(optType, strike, targetIdx, ethAmount); - const profit = estimateExpiryProfit(optType, strike, targetIdx, ethAmount, premiumPaid); - if (value == null && profit == null) return ""; + function formatRrEstimateHtml(rr, premiumPaid) { + const r = Number(rr); + const prem = Number(premiumPaid); + if (!Number.isFinite(r) || r <= 0 || !Number.isFinite(prem) || prem <= 0) return ""; + const profit = Math.round(prem * r * 100) / 100; + const need = Math.round((prem + profit) * 100) / 100; let html = ''; - html += '价值' + - (value == null ? "—" : fmtUsdc(value) + " USDC") + ""; - html += '预估盈利' + - (profit == null ? "—" : fmtUsdcSigned(profit)) + ""; + html += '目标盈利' + + fmtUsdcSigned(profit) + ""; + html += '需回收' + + fmtUsdc(need) + " USDC"; html += ""; return html; } @@ -1444,47 +1431,73 @@ function renderTargetDelegateRow(p) { const inst = p.inst_id || ""; const hedgeTarget = p.hedge_plan_target || null; - if (hedgeTarget && Number(hedgeTarget.target_index) > 0) { - const side = (p.opt_type || hedgeTarget.opt_type || "").toUpperCase() === "P" ? "Put ≤" : "Call ≥"; + if (hedgeTarget && hedgeTarget.managed_by === "hedge_plan") { + const rr = hedgeTarget.oo_profit_rr != null ? Number(hedgeTarget.oo_profit_rr) : null; + const armedTxt = + rr != null && Number.isFinite(rr) && rr > 0 + ? "盈亏比 ×" + fmt(rr, 2) + : hedgeTarget.target_index != null + ? "目标 " + fmt(hedgeTarget.target_index, 1) + : "托管中"; return ( '
' + '对冲计划' + '计划 #' + hedgeTarget.plan_id + " · " + - side + - " " + - fmt(hedgeTarget.target_index, 1) + + armedTxt + "" + - '进行中 · 由对冲计划监控,到位后仅平盈利腿' + + '进行中 · 由对冲计划监控' + "
" ); } - const tgt = p.target_index != null && p.target_index !== "" ? Number(p.target_index) : null; - const armed = tgt != null && Number.isFinite(tgt) && tgt > 0; - const ethAmt = posEthAmount(p); + const rrArmed = + p.profit_rr != null && p.profit_rr !== "" + ? Number(p.profit_rr) + : p.target_monitor && p.target_monitor.profit_rr != null + ? Number(p.target_monitor.profit_rr) + : null; + const armed = rrArmed != null && Number.isFinite(rrArmed) && rrArmed > 0; const prem = p.premium_paid; + const draft = + state.targetDraftByInst[inst] != null + ? String(state.targetDraftByInst[inst]) + : armed + ? String(rrArmed) + : "2"; const estHtml = armed - ? formatTargetEstimateHtml(p.opt_type, p.strike, tgt, ethAmt, prem) + ? formatRrEstimateHtml(rrArmed, prem) : ''; return ( - '
' + + '
' + '委托' + - '' + - '' + - '" + - (armed - ? '目标 ' + fmt(tgt, 1) + "" - : "") + + '' + + '' + + '" + + (armed ? '盈亏比 ×' + fmt(rrArmed, 2) + "" : "") + estHtml + '' + - (armed ? "监控中 · 到位按买一限价平" : "输入后设定 · 到位按买一限价平 · 到期即止损") + + (armed + ? "监控中 · 买一浮盈达盈亏比后全平" + : "默认2 · 买一浮盈达盈亏比×权利金后全平 · 不达标等到期") + "" + "
" ); @@ -1496,20 +1509,14 @@ if (!est) return; const inp = row.querySelector(".opt-pos-target-input"); const typed = inp ? String(inp.value || "").trim() : ""; - const armed = row.getAttribute("data-armed-target") || ""; - const targetRaw = typed !== "" ? typed : armed; - if (targetRaw === "") { + const armed = row.getAttribute("data-armed-rr") || ""; + const rrRaw = typed !== "" ? typed : armed; + if (rrRaw === "") { est.className = "opt-target-est opt-target-est--idle"; est.innerHTML = ""; return; } - const html = formatTargetEstimateHtml( - row.getAttribute("data-opt-type"), - row.getAttribute("data-strike"), - targetRaw, - row.getAttribute("data-eth"), - row.getAttribute("data-prem") - ); + const html = formatRrEstimateHtml(rrRaw, row.getAttribute("data-prem")); if (!html) { est.className = "opt-target-est opt-target-est--idle"; est.innerHTML = ""; @@ -1641,9 +1648,9 @@ const row = card ? card.querySelector(".opt-target-row") : null; const inp = card ? card.querySelector(".opt-pos-target-input") : null; const raw = inp ? String(inp.value || "").trim() : ""; - const tgt = parseFloat(raw); - if (!Number.isFinite(tgt) || tgt <= 0) { - alert("请输入有效目标指数价"); + const rr = raw === "" ? 2 : parseFloat(raw); + if (!Number.isFinite(rr) || rr <= 0) { + alert("请输入有效盈亏比(相对权利金,默认2)"); return; } if (btn) btn.disabled = true; @@ -1651,17 +1658,16 @@ const d = await apiJson("/api/options/target", { method: "POST", headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ inst_id: inst, target_index: tgt }), + body: JSON.stringify({ inst_id: inst, profit_rr: rr }), }); if (!d.ok) { alert(d.msg || "设定失败"); return; } delete state.targetDraftByInst[inst]; - if (inp) inp.value = ""; + if (inp) inp.value = String(rr); if (row) { - row.setAttribute("data-armed-target", String(tgt)); - updatePosTargetEstimate(row); + row.setAttribute("data-armed-rr", String(rr)); } await refreshAllPositions(); } finally { @@ -1701,12 +1707,22 @@ } box.hidden = false; host.innerHTML = rows.map(function (t) { - const side = (t.opt_type || "").toUpperCase() === "P" ? "Put ≤" : "Call ≥"; const managed = t.managed_by === "hedge_plan"; + let rule; + if (t.profit_rr != null && Number(t.profit_rr) > 0) { + rule = "盈亏比 ×" + fmt(t.profit_rr, 2); + } else if (t.oo_profit_rr != null && Number(t.oo_profit_rr) > 0) { + rule = "盈亏比 ×" + fmt(t.oo_profit_rr, 2); + } else if (t.target_index != null && Number(t.target_index) > 0) { + const side = (t.opt_type || "").toUpperCase() === "P" ? "Put ≤" : "Call ≥"; + rule = side + " " + fmt(t.target_index, 1); + } else { + rule = "委托中"; + } return ( '
' + '' + (t.inst_id || "") + "" + - '' + side + " " + fmt(t.target_index, 1) + "" + + '' + rule + "" + (managed ? '对冲计划 #' + (t.plan_id || "") + " · 进行中" : '') + @@ -1887,20 +1903,23 @@ paintPositions(list); const fromPos = list.reduce(function (targets, p) { if (!p) return targets; - if (p.target_index != null) { + if (p.profit_rr != null || p.target_index != null) { targets.push({ id: p.target_monitor_id, inst_id: p.inst_id, opt_type: p.opt_type, target_index: p.target_index, + profit_rr: p.profit_rr, }); } const hedgeTarget = p.hedge_plan_target; - if (hedgeTarget && hedgeTarget.target_index != null) { + if (hedgeTarget) { targets.push({ inst_id: p.inst_id, opt_type: p.opt_type || hedgeTarget.opt_type, target_index: hedgeTarget.target_index, + oo_profit_rr: hedgeTarget.oo_profit_rr, + profit_rr: hedgeTarget.oo_profit_rr, plan_id: hedgeTarget.plan_id, managed_by: hedgeTarget.managed_by, }); @@ -2286,17 +2305,17 @@ } bindOrderDialogChrome(); - ["opt-sheets-amount", "opt-eth-amount", "opt-target-idx"].forEach(function (id) { + ["opt-sheets-amount", "opt-eth-amount", "opt-profit-rr"].forEach(function (id) { const el = document.getElementById(id); if (!el) return; el.addEventListener("change", function () { - if (id === "opt-target-idx") { + if (id === "opt-profit-rr") { updateEstimatedProfit(); return; } if (state.selectedInst) selectContract(state.selectedInst, null, true); }); - if (id === "opt-target-idx") { + if (id === "opt-profit-rr") { el.addEventListener("input", updateEstimatedProfit); } }); diff --git a/lib/common/static/options_position_cards.js b/lib/common/static/options_position_cards.js index d2010d2..3f1e46d 100644 --- a/lib/common/static/options_position_cards.js +++ b/lib/common/static/options_position_cards.js @@ -219,38 +219,42 @@ const hint = closeGateHint(closePreview); return hint ? '
' + hint + "
" : ""; })() + - (p.target_index != null + (p.profit_rr != null || p.target_index != null ? (function () { - const eth = p.eth_amount != null ? Number(p.eth_amount) - : (Number(p.pos) > 0 ? Number(p.pos) * Number(p.ct_mult || 0.01) : null); - const strike = Number(p.strike); - const tgt = Number(p.target_index); + const hedgeTarget = p.hedge_plan_target || null; + const managed = hedgeTarget && hedgeTarget.managed_by === "hedge_plan"; + const rr = + managed && hedgeTarget.oo_profit_rr != null + ? Number(hedgeTarget.oo_profit_rr) + : p.profit_rr != null + ? Number(p.profit_rr) + : null; const prem = Number(p.premium_paid); let profit = null; - let value = null; - if (Number.isFinite(tgt) && Number.isFinite(strike) && eth > 0) { - const o = String(p.opt_type || "").toUpperCase(); - const intrinsic = o === "C" ? Math.max(0, tgt - strike) : o === "P" ? Math.max(0, strike - tgt) : null; - if (intrinsic != null) { - value = Math.round(intrinsic * eth * 100) / 100; - if (!hidePnl && Number.isFinite(prem)) profit = Math.round((value - prem) * 100) / 100; - } + let need = null; + if (rr != null && Number.isFinite(rr) && rr > 0 && Number.isFinite(prem) && prem > 0) { + profit = Math.round(prem * rr * 100) / 100; + need = Math.round((prem + profit) * 100) / 100; } const profitTxt = profit == null ? "—" : ((profit > 0 ? "+" : "") + fmtUsdc(profit) + " USDC"); const profitCls = profit > 0 ? " pnl-pos" : profit < 0 ? " pnl-neg" : ""; - const hedgeTarget = p.hedge_plan_target || null; - const managed = hedgeTarget && hedgeTarget.managed_by === "hedge_plan"; const profitSpan = hidePnl ? "" - : '预估盈利 ' + profitTxt + ""; + : '目标盈利 ' + profitTxt + ""; + const ruleTxt = + rr != null && Number.isFinite(rr) && rr > 0 + ? "盈亏比 ×" + fmt(rr, 2) + : p.target_index != null + ? "目标 " + fmt(p.target_index, 1) + : "委托中"; return ( '
' + '' + (managed ? "对冲计划 #" + hedgeTarget.plan_id : "委托") + "" + - '目标 ' + fmt(p.target_index, 1) + "" + - '价值 ' + (value == null ? "—" : fmtUsdc(value) + " USDC") + "" + + '' + ruleTxt + "" + + (need != null ? '需回收 ' + fmtUsdc(need) + " USDC" : "") + profitSpan + '' + - (managed ? "进行中 · 由对冲计划监控,到位后仅平盈利腿" : "监控中 · 到位按买一限价平") + + (managed ? "进行中 · 由对冲计划监控" : "监控中 · 买一浮盈达盈亏比后全平") + "
" ); })() diff --git a/lib/instance/instance_dashboard_lib.py b/lib/instance/instance_dashboard_lib.py index 39c96c4..25a5c33 100644 --- a/lib/instance/instance_dashboard_lib.py +++ b/lib/instance/instance_dashboard_lib.py @@ -123,21 +123,31 @@ def _resolve_options_source(conn, inst_id: str) -> tuple[str, str, int | None]: def _format_options_target(p: dict[str, Any]) -> str: hedge = p.get("hedge_plan_target") if isinstance(p.get("hedge_plan_target"), dict) else None - opt_type = str(p.get("opt_type") or p.get("optType") or "").upper() if hedge: - ot = str(hedge.get("opt_type") or opt_type).upper() + rr = _safe_float(hedge.get("oo_profit_rr") or hedge.get("profit_rr")) + pid = hedge.get("plan_id") + if rr is not None and rr > 0: + return f"对冲#{pid} 盈亏比×{rr:g}" if pid is not None else f"盈亏比×{rr:g}" + ot = str(hedge.get("opt_type") or p.get("opt_type") or p.get("optType") or "").upper() side = "Put ≤" if ot == "P" else "Call ≥" tgt = _safe_float(hedge.get("target_index")) - pid = hedge.get("plan_id") if tgt is not None: return f"对冲#{pid} {side} {tgt:g}" if pid is not None else f"{side} {tgt:g}" + mon = p.get("target_monitor") if isinstance(p.get("target_monitor"), dict) else None + rr = _safe_float(p.get("profit_rr")) + if rr is None and mon: + rr = _safe_float(mon.get("profit_rr")) + if rr is not None and rr > 0: + return f"盈亏比×{rr:g}" tgt = _safe_float(p.get("target_index")) + if tgt is None and mon: + tgt = _safe_float(mon.get("target_index")) if tgt is not None and tgt > 0: + opt_type = str(p.get("opt_type") or p.get("optType") or "").upper() side = "Put ≤" if opt_type == "P" else "Call ≥" return f"{side} {tgt:g}" return "—" - def _format_options_item(p: dict[str, Any], *, conn=None) -> dict[str, Any]: inst = str(p.get("inst_id") or p.get("instId") or "-").strip() or "-" opt_type = str(p.get("opt_type") or p.get("optType") or "").upper() diff --git a/lib/options/options_dashboard_lib.py b/lib/options/options_dashboard_lib.py index 7c1f5c8..1d8820a 100644 --- a/lib/options/options_dashboard_lib.py +++ b/lib/options/options_dashboard_lib.py @@ -72,11 +72,14 @@ def fetch_light_option_positions_for_dashboard(cfg: dict[str, Any]) -> list[dict mon = tgt_map.get(str(row.get("inst_id") or "")) if mon: row["target_index"] = mon.get("target_index") + row["profit_rr"] = mon.get("profit_rr") row["target_monitor_id"] = mon.get("id") row["target_monitor"] = mon hedge_target = hedge_target_map.get(str(row.get("inst_id") or "")) if hedge_target: row["hedge_plan_target"] = hedge_target + if hedge_target.get("oo_profit_rr") is not None and row.get("profit_rr") is None: + row["profit_rr"] = hedge_target.get("oo_profit_rr") if not mon: row["target_index"] = hedge_target.get("target_index") rows.append(row) diff --git a/lib/options/options_hub_lib.py b/lib/options/options_hub_lib.py index cbe0d9b..604a185 100644 --- a/lib/options/options_hub_lib.py +++ b/lib/options/options_hub_lib.py @@ -38,14 +38,15 @@ def build_options_hub_snapshot(cfg: dict[str, Any]) -> dict[str, Any]: mon = tgt_map.get(str(p.get("inst_id") or "")) if mon: p["target_index"] = mon.get("target_index") + p["profit_rr"] = mon.get("profit_rr") p["target_monitor_id"] = mon.get("id") p["target_monitor"] = mon hedge_target = hedge_target_map.get(str(p.get("inst_id") or "")) if hedge_target: p["hedge_plan_target"] = hedge_target if not mon: - # 中控卡片共用 target_index 只读展示;实际平仓仍由对冲计划监控处理。 p["target_index"] = hedge_target.get("target_index") + p["profit_rr"] = hedge_target.get("oo_profit_rr") try: from lib.instance.instance_dashboard_lib import ( _format_options_target, diff --git a/lib/options/options_monitor_lib.py b/lib/options/options_monitor_lib.py index f77450d..c5157cc 100644 --- a/lib/options/options_monitor_lib.py +++ b/lib/options/options_monitor_lib.py @@ -455,6 +455,7 @@ def options_monitor_loop( conn, positions, close_fn=target_close_fn, + bid_fn=ticker_bid_fn, send_wechat=send_wechat, account_label=account_label, cfg={"send_wechat": send_wechat, "account_label": account_label}, diff --git a/lib/options/options_notify_lib.py b/lib/options/options_notify_lib.py index 7b32721..6f99b92 100644 --- a/lib/options/options_notify_lib.py +++ b/lib/options/options_notify_lib.py @@ -55,6 +55,7 @@ def build_options_open_message( premium_paid: Any = None, open_quote: Any = None, target_index: Any = None, + profit_rr: Any = None, signal_note: str = "", trade_id: Any = None, ) -> str: @@ -73,7 +74,12 @@ def build_options_open_message( f"权利金:{_fmt(premium_paid)} USDC", ] ) - if target_index is not None and str(target_index).strip() != "": + if profit_rr is not None and str(profit_rr).strip() != "": + try: + lines.append(f"盈亏比:×{float(profit_rr):g}(达标全平;不达标等到期)") + except (TypeError, ValueError): + lines.append(f"盈亏比:{profit_rr}") + elif target_index is not None and str(target_index).strip() != "": try: lines.append(f"目标指数:{float(target_index):g}") except (TypeError, ValueError): @@ -96,6 +102,7 @@ def build_options_close_message( realized_pnl: Any = None, close_quote: Any = None, target_index: Any = None, + profit_rr: Any = None, trigger_idx: Any = None, trade_id: Any = None, ) -> str: @@ -116,7 +123,12 @@ def build_options_close_message( f"实现盈亏:{_fmt(realized_pnl, 4)} USDC", ] ) - if target_index is not None and str(target_index).strip() != "": + if profit_rr is not None and str(profit_rr).strip() != "": + try: + lines.append(f"盈亏比:×{float(profit_rr):g}") + except (TypeError, ValueError): + lines.append(f"盈亏比:{profit_rr}") + elif target_index is not None and str(target_index).strip() != "": try: lines.append(f"目标指数:{float(target_index):g}") except (TypeError, ValueError): @@ -141,6 +153,7 @@ def notify_options_open( premium_paid: Any = None, open_quote: Any = None, target_index: Any = None, + profit_rr: Any = None, signal_note: str = "", ) -> bool: ensure_options_notify_columns(conn) if conn is not None else None @@ -160,6 +173,7 @@ def notify_options_open( premium_paid=premium_paid, open_quote=open_quote, target_index=target_index, + profit_rr=profit_rr, signal_note=signal_note, trade_id=trade_id, ) @@ -196,6 +210,7 @@ def notify_options_close( realized_pnl: Any = None, close_quote: Any = None, target_index: Any = None, + profit_rr: Any = None, trigger_idx: Any = None, force: bool = False, ) -> bool: @@ -257,6 +272,7 @@ def notify_options_close( realized_pnl=total_pnl, close_quote=close_quote if close_quote is not None else head.get("close_quote"), target_index=target_index, + profit_rr=profit_rr, trigger_idx=trigger_idx, trade_id=head.get("id") if len(rows) == 1 else None, ) @@ -286,6 +302,7 @@ def notify_options_close( realized_pnl=realized_pnl, close_quote=close_quote, target_index=target_index, + profit_rr=profit_rr, trigger_idx=trigger_idx, trade_id=trade_id, ) diff --git a/lib/options/options_register.py b/lib/options/options_register.py index acf8684..97865e0 100644 --- a/lib/options/options_register.py +++ b/lib/options/options_register.py @@ -646,6 +646,17 @@ def register_options_routes(app: Flask, cfg: dict[str, Any]) -> None: mode = (data.get("mode") or "budget_full").strip() signal_note = (data.get("signal_note") or "").strip() target_index = None + profit_rr = None + raw_rr = data.get("profit_rr") + if raw_rr is None or str(raw_rr).strip() == "": + raw_rr = data.get("oo_profit_rr") + if raw_rr is not None and str(raw_rr).strip() != "": + try: + profit_rr = float(raw_rr) + except (TypeError, ValueError): + return jsonify({"ok": False, "msg": "盈亏比无效"}) + if profit_rr <= 0: + return jsonify({"ok": False, "msg": "盈亏比须大于 0"}) raw_target = data.get("target_index") if raw_target is not None and str(raw_target).strip() != "": try: @@ -654,6 +665,9 @@ def register_options_routes(app: Flask, cfg: dict[str, Any]) -> None: return jsonify({"ok": False, "msg": "目标位无效"}) if target_index <= 0: return jsonify({"ok": False, "msg": "目标位无效"}) + # 未显式传目标时默认盈亏比 2 + if profit_rr is None and target_index is None: + profit_rr = 2.0 if not inst_id: return jsonify({"ok": False, "msg": "缺少 inst_id"}) q = cfg["quote_option_contract"](ex, inst_id) @@ -820,13 +834,14 @@ def register_options_routes(app: Flask, cfg: dict[str, Any]) -> None: ), ) trade_id = int(cur.lastrowid) - if target_index is not None: + if profit_rr is not None or target_index is not None: from lib.options.options_target_lib import upsert_target_monitor target_mon = upsert_target_monitor( conn, inst_id=inst_id, target_index=target_index, + profit_rr=profit_rr, underlying=u, opt_type=str(opt_type) if opt_type else None, trade_id=trade_id, @@ -854,6 +869,7 @@ def register_options_routes(app: Flask, cfg: dict[str, Any]) -> None: premium_paid=sizing.get("total_premium"), open_quote=fill_px, target_index=target_index, + profit_rr=profit_rr, signal_note=signal_note, ) finally: @@ -969,11 +985,14 @@ def register_options_routes(app: Flask, cfg: dict[str, Any]) -> None: mon = tgt_map.get(inst) if mon: row["target_index"] = mon.get("target_index") + row["profit_rr"] = mon.get("profit_rr") row["target_monitor_id"] = mon.get("id") row["target_monitor"] = mon hedge_target = hedge_target_map.get(inst) if hedge_target: row["hedge_plan_target"] = hedge_target + if hedge_target.get("oo_profit_rr") is not None: + row.setdefault("profit_rr", hedge_target.get("oo_profit_rr")) try: from lib.instance.instance_dashboard_lib import _resolve_options_source @@ -1031,12 +1050,28 @@ def register_options_routes(app: Flask, cfg: dict[str, Any]) -> None: conn_h.close() except Exception as e: return jsonify({"ok": False, "msg": f"对冲托管校验失败: {e}"}) - try: - target_index = float(data.get("target_index")) - except (TypeError, ValueError): - return jsonify({"ok": False, "msg": "目标位无效"}) - if target_index <= 0: - return jsonify({"ok": False, "msg": "目标位无效"}) + profit_rr = None + target_index = None + raw_rr = data.get("profit_rr") + if raw_rr is None or str(raw_rr).strip() == "": + raw_rr = data.get("oo_profit_rr") + if raw_rr is not None and str(raw_rr).strip() != "": + try: + profit_rr = float(raw_rr) + except (TypeError, ValueError): + return jsonify({"ok": False, "msg": "盈亏比无效"}) + if profit_rr <= 0: + return jsonify({"ok": False, "msg": "盈亏比须大于 0"}) + raw_tgt = data.get("target_index") + if raw_tgt is not None and str(raw_tgt).strip() != "": + try: + target_index = float(raw_tgt) + except (TypeError, ValueError): + return jsonify({"ok": False, "msg": "目标位无效"}) + if target_index <= 0: + return jsonify({"ok": False, "msg": "目标位无效"}) + if profit_rr is None and target_index is None: + profit_rr = 2.0 raw = cfg["fetch_option_positions"](ex) if raw is None: return jsonify({"ok": False, "msg": "获取期权持仓失败"}) @@ -1065,6 +1100,7 @@ def register_options_routes(app: Flask, cfg: dict[str, Any]) -> None: conn, inst_id=inst_id, target_index=target_index, + profit_rr=profit_rr, underlying=str(underlying) if underlying else None, opt_type=str(opt_type) if opt_type else None, trade_id=trade_id, @@ -1411,7 +1447,24 @@ def _start_monitor_thread(app: Flask, cfg: dict[str, Any]) -> None: raw = cfg["fetch_option_positions"](ex) if raw is None: return [] - return [cfg["format_position_row"](p) for p in raw] + rows = [cfg["format_position_row"](p) for p in raw] + try: + from lib.options.options_db import sum_open_premium_paid + + conn = cfg["get_db"]() + try: + for row in rows: + inst = str(row.get("inst_id") or "") + if not inst: + continue + paid = sum_open_premium_paid(conn, inst) + if paid is not None: + row["premium_paid"] = paid + finally: + conn.close() + except Exception: + pass + return rows def _sync(conn): from lib.exchange.okx_options_lib import fetch_option_position_history diff --git a/lib/options/options_target_lib.py b/lib/options/options_target_lib.py index ecbb8c6..75c8f76 100644 --- a/lib/options/options_target_lib.py +++ b/lib/options/options_target_lib.py @@ -1,11 +1,14 @@ -"""期权目标位委托:指数目标价仅用于监控触发;触发后按买一限价平仓(无止损,到期结算).""" +"""期权目标委托:盈亏比×权利金触发后按买一限价平仓(无止损,到期结算). + +兼容旧「目标指数」委托:无 profit_rr 时仍按指数到位触发. +""" from __future__ import annotations import sqlite3 import time from typing import Any, Callable -from lib.options.options_db import init_options_tables +from lib.options.options_db import init_options_tables, sum_open_premium_paid from lib.options.options_pricing_lib import close_ref_prices, fetch_option_mark_px @@ -18,6 +21,18 @@ def _safe_float(v: Any) -> float | None: return None +def _ensure_column(conn: sqlite3.Connection, table: str, col: str, typedef: str) -> None: + rows = conn.execute(f"PRAGMA table_info({table})").fetchall() + names: set[str] = set() + for r in rows: + try: + names.add(str(r["name"])) + except (TypeError, KeyError, IndexError): + names.add(str(r[1])) + if col not in names: + conn.execute(f"ALTER TABLE {table} ADD COLUMN {col} {typedef}") + + def _pos_close_refs(ex: Any, pos: dict[str, Any], quote: dict[str, Any] | None = None) -> tuple[float | None, float | None]: from lib.exchange.okx_options_lib import option_fields_from_inst_id @@ -63,21 +78,44 @@ def ensure_target_tables(conn: sqlite3.Connection) -> None: ON options_target_monitors(status) """ ) + # 盈亏比=目标盈利/权利金;如 2=盈利 2 倍权利金.有值时优先生效,target_index 可置 0 + _ensure_column(conn, "options_target_monitors", "profit_rr", "REAL") def target_hit(*, opt_type: str | None, index_px: float, target_index: float) -> bool: - """Call:指数涨到/超过目标平仓;Put:指数跌到/低于目标平仓.""" + """旧逻辑:Call 指数≥目标;Put 指数≤目标.""" ot = (opt_type or "").strip().upper() if ot == "P": return index_px <= target_index return index_px >= target_index +def profit_rr_hit( + *, + premium: float, + bid: float | None, + sheets: float, + ct_mult: float, + profit_rr: float, +) -> bool: + """买一回收 − 权利金 ≥ 盈亏比 × 权利金.""" + if premium <= 0 or profit_rr <= 0: + return False + if bid is None or float(bid) <= 0: + return False + if sheets <= 0 or ct_mult <= 0: + return False + recycle = float(bid) * float(sheets) * float(ct_mult) + pnl = recycle - float(premium) + return pnl + 1e-9 >= float(profit_rr) * float(premium) + + def upsert_target_monitor( conn: sqlite3.Connection, *, inst_id: str, - target_index: float, + target_index: float | None = None, + profit_rr: float | None = None, underlying: str | None = None, opt_type: str | None = None, trade_id: int | None = None, @@ -87,9 +125,18 @@ def upsert_target_monitor( inst_id = (inst_id or "").strip() if not inst_id: return {"ok": False, "msg": "缺少 inst_id"} - if target_index is None or float(target_index) <= 0: - return {"ok": False, "msg": "目标位无效"} - target_index = float(target_index) + + rr = _safe_float(profit_rr) + tgt = _safe_float(target_index) + if rr is not None and rr > 0: + tgt_store = float(tgt) if tgt is not None and tgt > 0 else 0.0 + rr_store = float(rr) + elif tgt is not None and tgt > 0: + tgt_store = float(tgt) + rr_store = None + else: + return {"ok": False, "msg": "请填写盈亏比(相对权利金,默认2)"} + row = conn.execute( """ SELECT id FROM options_target_monitors @@ -104,6 +151,7 @@ def upsert_target_monitor( """ UPDATE options_target_monitors SET target_index = ?, + profit_rr = ?, underlying = COALESCE(?, underlying), opt_type = COALESCE(?, opt_type), trade_id = COALESCE(?, trade_id), @@ -115,14 +163,13 @@ def upsert_target_monitor( triggered_at = NULL WHERE id = ? """, - (target_index, underlying, opt_type, trade_id, sheets, int(row["id"])), + (tgt_store, rr_store, underlying, opt_type, trade_id, sheets, int(row["id"])), ) mon_id = int(row["id"]) - # 同一合约其他进行中的委托取消,避免双轨触发重复推送 conn.execute( """ UPDATE options_target_monitors - SET status = 'cancelled', message = '被新目标位覆盖' + SET status = 'cancelled', message = '被新目标委托覆盖' WHERE inst_id = ? AND id != ? AND status IN ('active', 'closing') """, (inst_id, mon_id), @@ -131,13 +178,21 @@ def upsert_target_monitor( cur = conn.execute( """ INSERT INTO options_target_monitors - (inst_id, underlying, opt_type, target_index, trade_id, sheets, status) - VALUES (?, ?, ?, ?, ?, ?, 'active') + (inst_id, underlying, opt_type, target_index, profit_rr, trade_id, sheets, status) + VALUES (?, ?, ?, ?, ?, ?, ?, 'active') """, - (inst_id, underlying, opt_type, target_index, trade_id, sheets), + (inst_id, underlying, opt_type, tgt_store, rr_store, trade_id, sheets), ) mon_id = int(cur.lastrowid) - return {"ok": True, "id": mon_id, "inst_id": inst_id, "target_index": target_index} + out: dict[str, Any] = { + "ok": True, + "id": mon_id, + "inst_id": inst_id, + "target_index": tgt_store if tgt_store > 0 else None, + } + if rr_store is not None: + out["profit_rr"] = rr_store + return out def cancel_target_monitor(conn: sqlite3.Connection, *, inst_id: str | None = None, monitor_id: int | None = None) -> int: @@ -166,12 +221,19 @@ def cancel_target_monitor(conn: sqlite3.Connection, *, inst_id: str | None = Non def _row_to_target(r: sqlite3.Row) -> dict[str, Any]: + tgt = _safe_float(r["target_index"]) + rr = None + try: + rr = _safe_float(r["profit_rr"]) + except (KeyError, IndexError): + rr = None return { "id": int(r["id"]), "inst_id": r["inst_id"], "underlying": r["underlying"], "opt_type": r["opt_type"], - "target_index": _safe_float(r["target_index"]), + "target_index": tgt if tgt is not None and tgt > 0 else None, + "profit_rr": rr if rr is not None and rr > 0 else None, "trade_id": r["trade_id"], "sheets": r["sheets"], "status": r["status"], @@ -180,16 +242,16 @@ def _row_to_target(r: sqlite3.Row) -> dict[str, Any]: } +_TARGET_SELECT = ( + "SELECT id, inst_id, underlying, opt_type, target_index, profit_rr, trade_id, sheets, " + "status, message, created_at FROM options_target_monitors" +) + + def list_active_targets(conn: sqlite3.Connection) -> list[dict[str, Any]]: ensure_target_tables(conn) rows = conn.execute( - """ - SELECT id, inst_id, underlying, opt_type, target_index, trade_id, sheets, - status, message, created_at - FROM options_target_monitors - WHERE status = 'active' - ORDER BY id DESC - """ + f"{_TARGET_SELECT} WHERE status = 'active' ORDER BY id DESC" ).fetchall() return [_row_to_target(r) for r in rows] @@ -198,13 +260,7 @@ def list_closing_targets(conn: sqlite3.Connection) -> list[dict[str, Any]]: """已挂出平仓单、等待成交的目标(不再重复推送微信).""" ensure_target_tables(conn) rows = conn.execute( - """ - SELECT id, inst_id, underlying, opt_type, target_index, trade_id, sheets, - status, message, created_at - FROM options_target_monitors - WHERE status = 'closing' - ORDER BY id DESC - """ + f"{_TARGET_SELECT} WHERE status = 'closing' ORDER BY id DESC" ).fetchall() return [_row_to_target(r) for r in rows] @@ -286,23 +342,23 @@ def close_option_by_bid_depth( inst_id, sheets=sheets, require_recycle_gate=True, - signal_note="目标位平仓", + signal_note="盈亏比平仓", ) - def _notify_target_close( cfg: dict[str, Any] | None, send_wechat: Callable[[str], None] | None, *, account_label: str, inst_id: str, - target: float, - idx: float, + target: float | None, + profit_rr: float | None, + idx: float | None, result: dict[str, Any], conn: Any = None, ) -> None: - """目标位平仓推送:优先走统一平仓必发(幂等);无 cfg 时回退旧文案.""" + """目标平仓推送:优先走统一平仓必发(幂等);无 cfg 时回退旧文案.""" if result.get("fully_closed") or result.get("already_flat"): if cfg is not None: try: @@ -312,12 +368,13 @@ def _notify_target_close( cfg, conn, inst_id=inst_id, - reason="目标位平仓", + reason="盈亏比平仓" if profit_rr else "目标位平仓", sheets=result.get("submitted_sheets"), premium_received=result.get("premium_received"), close_quote=result.get("locked_bid_px") or result.get("bid"), target_index=target, trigger_idx=idx, + profit_rr=profit_rr, ) return except Exception: @@ -325,14 +382,20 @@ def _notify_target_close( if not send_wechat: return try: + if profit_rr is not None and profit_rr > 0: + rule = f"盈亏比×{profit_rr:g}" + elif target is not None: + rule = f"目标指数:{target:g}" + else: + rule = "目标委托" send_wechat( "\n".join( [ - "【OKX期权·目标位平仓】", + "【OKX期权·盈亏比平仓】" if profit_rr else "【OKX期权·目标位平仓】", f"账户:{account_label}", f"合约:{inst_id}", - f"目标指数:{target:g}", - f"触发指数:{idx:g}", + rule, + f"触发指数:{idx:g}" if idx is not None else "触发指数:—", f"提交张数:{result.get('submitted_sheets') or '—'}", f"预估收回:{result.get('premium_received') if result.get('premium_received') is not None else '—'} USDC", f"状态:{'已全平' if (result.get('fully_closed') or result.get('already_flat')) else '挂单中/部分'}", @@ -354,18 +417,77 @@ def _result_fully_done(result: dict[str, Any]) -> bool: return False +def _monitor_should_close( + conn: sqlite3.Connection, + mon: dict[str, Any], + pos: dict[str, Any], + *, + bid_fn: Callable[[str], float | None] | None, + index_fn: Callable[[dict[str, Any]], float | None] | None, +) -> tuple[bool, float | None]: + """返回 (是否触发, 当前指数).""" + inst_id = str(mon.get("inst_id") or "") + rr = _safe_float(mon.get("profit_rr")) + if index_fn is not None: + idx = index_fn(pos) + else: + idx = _safe_float(pos.get("idx_px") or pos.get("idxPx")) + + if rr is not None and rr > 0: + premium = sum_open_premium_paid(conn, inst_id) + if premium is None or premium <= 0: + premium = _safe_float(pos.get("premium_paid")) + sheets = _safe_float(mon.get("sheets")) + if sheets is None or sheets <= 0: + sheets = _safe_float(pos.get("pos") or pos.get("avail_pos") or pos.get("availPos")) + ct = _safe_float(pos.get("ct_mult") or pos.get("ctMult")) or 0.01 + bid = None + if bid_fn is not None: + try: + bid = bid_fn(inst_id) + except Exception: + bid = None + if bid is None: + bid = _safe_float(pos.get("bid_px") or pos.get("bidPx") or pos.get("bid")) + preview = pos.get("close_preview") if isinstance(pos.get("close_preview"), dict) else {} + if bid is None: + bid = _safe_float(preview.get("bid") or preview.get("best_bid")) + if premium is None or sheets is None: + return False, idx + return ( + profit_rr_hit( + premium=float(premium), + bid=bid, + sheets=float(sheets), + ct_mult=float(ct), + profit_rr=float(rr), + ), + idx, + ) + + target = _safe_float(mon.get("target_index")) + if target is None or target <= 0 or idx is None: + return False, idx + opt_type = mon.get("opt_type") or pos.get("opt_type") or pos.get("optType") + return ( + target_hit(opt_type=str(opt_type) if opt_type else None, index_px=idx, target_index=target), + idx, + ) + + def run_options_target_closes( conn: sqlite3.Connection, positions: list[dict[str, Any]], *, close_fn: Callable[[str], dict[str, Any]], index_fn: Callable[[dict[str, Any]], float | None] | None = None, + bid_fn: Callable[[str], float | None] | None = None, send_wechat: Callable[[str], None] | None = None, account_label: str = "OKX期权", cfg: dict[str, Any] | None = None, ) -> int: """ - 扫描 active 目标委托;指数到位后限价平仓. + 扫描 active 目标委托;盈亏比达标(或旧指数到位)后限价平仓. 状态先 commit 再推微信,避免 sync 失败回滚导致同一笔反复推送. 未完全成交进入 closing,仅重试平仓不再推送. 返回本次新触发(并推送)的条数. @@ -412,7 +534,7 @@ def run_options_target_closes( status="triggered", trigger_idx=idx, close_ord_id=result.get("close_ord_id"), - message="目标位限价平仓完成", + message="盈亏比限价平仓完成", ) _commit_monitor(conn) continue @@ -429,8 +551,7 @@ def run_options_target_closes( triggered = 0 for mon in list_active_targets(conn): inst_id = str(mon.get("inst_id") or "") - target = _safe_float(mon.get("target_index")) - if not inst_id or target is None: + if not inst_id: continue if inst_id in hedge_managed: mark_monitor( @@ -444,17 +565,15 @@ def run_options_target_closes( pos = pos_by_inst.get(inst_id) if not pos: continue - if index_fn is not None: - idx = index_fn(pos) - else: - idx = _safe_float(pos.get("idx_px") or pos.get("idxPx")) - if idx is None: - continue - opt_type = mon.get("opt_type") or pos.get("opt_type") or pos.get("optType") - if not target_hit(opt_type=str(opt_type) if opt_type else None, index_px=idx, target_index=target): + should, idx = _monitor_should_close( + conn, mon, pos, bid_fn=bid_fn, index_fn=index_fn + ) + if not should: continue result = close_fn(inst_id) + rr = _safe_float(mon.get("profit_rr")) + target = _safe_float(mon.get("target_index")) if result.get("already_flat"): mark_monitor(conn, int(mon["id"]), status="expired", trigger_idx=idx, message="持仓已平") _commit_monitor(conn) @@ -472,15 +591,19 @@ def run_options_target_closes( done = _result_fully_done(result) status = "triggered" if done else "closing" + hit_msg = ( + "盈亏比达标限价平仓" + if (rr is not None and rr > 0) + else "目标位触发限价平仓" + ) mark_monitor( conn, int(mon["id"]), status=status, trigger_idx=idx, close_ord_id=result.get("close_ord_id"), - message="目标位触发限价平仓" if done else "目标位已挂买一限价,等待成交", + message=hit_msg if done else "已挂买一限价,等待成交", ) - # 关键:先落库,再推送——否则后续 sync 异常回滚会让同一笔反复推微信 _commit_monitor(conn) triggered += 1 _notify_target_close( @@ -489,6 +612,7 @@ def run_options_target_closes( account_label=account_label, inst_id=inst_id, target=target, + profit_rr=rr, idx=idx, result=result, conn=conn, diff --git a/lib/options/templates/options_panel.html b/lib/options/templates/options_panel.html index c9546ca..cb6496a 100644 --- a/lib/options/templates/options_panel.html +++ b/lib/options/templates/options_panel.html @@ -107,17 +107,15 @@
- - 盈亏比 + - 预计价值 - - 盈利 + 目标盈利 - 目标杠杆 - + 需回收 +
- 目标价=监控指数;到位后按买一限价平仓;无止损,到期即止损 + 按买一浮盈达盈亏比×权利金后限价全平;不达标等到期;无止损
@@ -324,4 +322,4 @@
- + diff --git a/manual_trading_hub/static/app.js b/manual_trading_hub/static/app.js index 450dfba..652ca17 100644 --- a/manual_trading_hub/static/app.js +++ b/manual_trading_hub/static/app.js @@ -3930,6 +3930,19 @@ function renderOptionsTargetCell(target) { if (!target) return "—"; + const rr = + target.profit_rr != null + ? Number(target.profit_rr) + : target.oo_profit_rr != null + ? Number(target.oo_profit_rr) + : null; + if (rr != null && Number.isFinite(rr) && rr > 0) { + const txt = `盈亏比×${fmt(rr, 2)}`; + if (target.managed_by === "hedge_plan") { + return `对冲#${esc(target.plan_id)} ${esc(txt)}`; + } + return `${esc(txt)}`; + } const side = String(target.opt_type || "").toUpperCase() === "P" ? "Put≤" : "Call≥"; const px = target.target_index != null ? fmt(target.target_index, 1) : "—"; if (target.managed_by === "hedge_plan") { @@ -3942,7 +3955,7 @@ if (!pos.length) return '
暂无期权持仓
'; const showPnl = showAccountPnlPref(); let html = '
'; - html += ""; + html += ""; if (showPnl) html += ""; html += ""; pos.forEach((p) => { diff --git a/manual_trading_hub/static/index.html b/manual_trading_hub/static/index.html index 2e5808b..f6e9c16 100644 --- a/manual_trading_hub/static/index.html +++ b/manual_trading_hub/static/index.html @@ -115,7 +115,7 @@
合约类型张数到期倒计时目标监控合约类型张数到期倒计时盈亏比净盈亏收益率