"
);
@@ -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 @@
-
+
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 `
`;
+ }
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 '