diff --git a/docs/期权开平仓与监控说明.md b/docs/期权开平仓与监控说明.md
index d0999f9..47fbecd 100644
--- a/docs/期权开平仓与监控说明.md
+++ b/docs/期权开平仓与监控说明.md
@@ -47,6 +47,13 @@
- 首次通过后,同仓**续批**只再验流动性,不再重跑 2 分钟计时.
- 无有效买一或门控未就绪 → 本轮不挂单,等下一轮;已有未成交卖平单则等成交,不撤了重挂.
+### 2.4 翻倍出场(可选)
+
+- 开仓勾选或持仓卡开启;倍数默认 **1**(盈利金额 = 初始权利金).
+- 触发条件:买一可回收 ≥ 权利金 × (1 + 倍数);达标后走买一限价平,**不再**额外卡「回收≥2×」门控(倍数本身已是出场条件).
+- 可随时关闭;与目标位监控并行,谁先达标谁平.
+- 与「翻倍提醒」独立:提醒只推微信,翻倍出场会真正挂平仓单.
+
---
## 3. 监控逻辑
@@ -58,19 +65,21 @@
| 未成交委托 | 期权下单区右侧「委托」列表展示开/平仓限价单,可手动撤销;页面轮询刷新 |
| 平仓挂单超时 | 卖出平仓限价超 TTL 未成交 → 自动撤单(默认 10 分钟) |
| 目标位 | 独立监控表;触发后买一平;推送企业微信(防重复) |
-| 翻倍提醒 | 未实现口径达权利金 × `OKX_OPTIONS_PROFIT_ALERT_RATIO` 时推送一次 |
+| 翻倍出场 | 开仓/持仓可开关;自选倍数(默认1);1倍=盈利等于权利金(可回收≥2×权利金)达标后买一限价平;可随时关闭;与目标位并行 |
+| 翻倍提醒 | 未实现口径达权利金 × `OKX_OPTIONS_PROFIT_ALERT_RATIO` 时推送一次(仅提醒,不平仓) |
| 到期 | 无系统止损;到期交割/保险腿自灭(对冲计划另有退出规则) |
---
## 4. 平仓校验(门控)
-| 门控 | 手动买一平 | 目标自动平 | 说明 |
-|------|------------|------------|------|
-| 有效流动性 | ✅ 必验 | ✅ 必验 | 残档买一 / 无买一 → 拒平 |
-| 回收 ≥ 2× 权利金 + 持续 hold | ❌ | ✅ 首次 | 通过后同仓续批只验流动性 |
-| 锁定买一价 | ✅ | ✅ | 下单价 = 通过校验时的买一 |
-| 市价兜底 | ❌ | ❌ | 永不市价 |
+| 门控 | 手动买一平 | 目标自动平 | 翻倍出场 | 说明 |
+|------|------------|------------|----------|------|
+| 有效流动性 | ✅ 必验 | ✅ 必验 | ✅ 必验 | 残档买一 / 无买一 → 拒平 |
+| 回收 ≥ 2× 权利金 + 持续 hold | ❌ | ✅ 首次 | ❌(倍数即条件) | 目标平仓专用门控 |
+| 回收 ≥ 权利金×(1+倍数) | ❌ | ❌ | ✅ 触发条件 | 1倍 ⇒ 回收≥2×权利金 |
+| 锁定买一价 | ✅ | ✅ | ✅ | 下单价 = 通过校验时的买一 |
+| 市价兜底 | ❌ | ❌ | ❌ | 永不市价 |
---
diff --git a/lib/common/static/instance_theme.css b/lib/common/static/instance_theme.css
index 49a1fc4..b1b7552 100644
--- a/lib/common/static/instance_theme.css
+++ b/lib/common/static/instance_theme.css
@@ -4447,6 +4447,22 @@ html[data-theme="light"] .opt-pending-item {
min-height: 32px;
box-sizing: border-box;
}
+.options-estimate-row .opt-profit-exit-mult,
+.options-page-wrap .opt-pos-profit-exit-mult {
+ width: 4.5rem;
+ min-width: 0;
+ font-size: 0.8rem;
+ padding: 6px 8px;
+ min-height: 32px;
+ box-sizing: border-box;
+}
+.options-page-wrap .opt-profit-exit-toggle {
+ display: inline-flex;
+ align-items: center;
+ gap: 4px;
+ font-size: 0.78rem;
+ white-space: nowrap;
+}
.options-estimate-row .k {
color: #8892b0;
}
diff --git a/lib/common/static/options_panel.js b/lib/common/static/options_panel.js
index d1314c2..87d9e23 100644
--- a/lib/common/static/options_panel.js
+++ b/lib/common/static/options_panel.js
@@ -1406,6 +1406,17 @@
}
body.target_index = tgt;
}
+ const peEnabled = !!(document.getElementById("opt-profit-exit-enabled") || {}).checked;
+ if (peEnabled) {
+ const multRaw = (document.getElementById("opt-profit-exit-mult") || {}).value;
+ const mult = parseFloat(multRaw);
+ if (!Number.isFinite(mult) || mult <= 0) {
+ alert("翻倍倍数无效");
+ return false;
+ }
+ body.profit_exit_enabled = true;
+ body.profit_exit_mult = mult;
+ }
const d = await apiJson("/api/options/open", {
method: "POST",
headers: { "Content-Type": "application/json" },
@@ -1484,7 +1495,42 @@
const hint = closeGateHint(closePreview);
return hint ? '
' + hint + "
" : "";
})() +
- renderTargetDelegateRow(p)
+ renderTargetDelegateRow(p) +
+ renderProfitExitRow(p)
+ );
+ }
+
+ function renderProfitExitRow(p) {
+ const inst = p.inst_id || "";
+ if (p.hedge_plan_target) {
+ return "";
+ }
+ const enabled = !!p.profit_exit_enabled;
+ const mult = p.profit_exit_mult != null && Number(p.profit_exit_mult) > 0
+ ? Number(p.profit_exit_mult)
+ : 1;
+ const state = String(p.profit_exit_state || (enabled ? "active" : "idle"));
+ const req = p.profit_exit_required_recycle;
+ let statusTxt = enabled
+ ? ("监控中 · " + fmt(mult, 2) + "倍")
+ : "未开启";
+ if (enabled && state === "closing") statusTxt = "平仓挂单中 · " + fmt(mult, 2) + "倍";
+ return (
+ '' +
+ '翻倍' +
+ '" +
+ '" +
+ '' +
+ '' + statusTxt + "" +
+ '' +
+ (enabled
+ ? ("1倍=盈利=权利金" + (req != null ? (" · 需回收≥" + fmtUsdc(req)) : ""))
+ : "开启后自选倍数;达标按买一限价平;可随时关闭") +
+ "" +
+ "
"
);
}
@@ -1686,6 +1732,30 @@
cancelPositionTarget(btn.getAttribute("data-inst"), btn);
});
});
+ container.querySelectorAll(".opt-profit-exit-save-btn").forEach(function (btn) {
+ btn.addEventListener("click", function (e) {
+ e.stopPropagation();
+ savePositionProfitExit(btn.getAttribute("data-inst"), btn);
+ });
+ });
+ container.querySelectorAll(".opt-pos-profit-exit-enabled").forEach(function (cb) {
+ cb.addEventListener("click", function (e) { e.stopPropagation(); });
+ cb.addEventListener("change", function () {
+ const row = cb.closest(".opt-profit-exit-pos-row");
+ const multInp = row && row.querySelector(".opt-pos-profit-exit-mult");
+ if (multInp) multInp.disabled = !cb.checked;
+ });
+ });
+ container.querySelectorAll(".opt-pos-profit-exit-mult").forEach(function (inp) {
+ inp.addEventListener("click", function (e) { e.stopPropagation(); });
+ inp.addEventListener("keydown", function (e) {
+ if (e.key === "Enter") {
+ e.preventDefault();
+ e.stopPropagation();
+ savePositionProfitExit(inp.getAttribute("data-inst"), null);
+ }
+ });
+ });
container.querySelectorAll(".opt-pos-target-input").forEach(function (inp) {
inp.addEventListener("click", function (e) { e.stopPropagation(); });
inp.addEventListener("input", function () {
@@ -1776,6 +1846,39 @@
}
}
+ async function savePositionProfitExit(inst, btn) {
+ if (!inst) return;
+ const card = document.querySelector('.opt-pos-card[data-inst="' + inst + '"]') ||
+ document.querySelector('.opt-pos-accordion-item[data-inst="' + inst + '"]');
+ const row = card ? card.querySelector(".opt-profit-exit-pos-row") : null;
+ const enabledEl = row ? row.querySelector(".opt-pos-profit-exit-enabled") : null;
+ const multEl = row ? row.querySelector(".opt-pos-profit-exit-mult") : null;
+ const enabled = !!(enabledEl && enabledEl.checked);
+ let mult = 1;
+ if (enabled) {
+ mult = parseFloat(multEl ? multEl.value : "1");
+ if (!Number.isFinite(mult) || mult <= 0) {
+ alert("翻倍倍数无效");
+ return;
+ }
+ }
+ if (btn) btn.disabled = true;
+ try {
+ const d = await apiJson("/api/options/profit-exit", {
+ method: "POST",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify({ inst_id: inst, enabled: enabled, mult: mult }),
+ });
+ if (!d.ok) {
+ alert(d.msg || "保存失败");
+ return;
+ }
+ await refreshAllPositions();
+ } finally {
+ if (btn) btn.disabled = false;
+ }
+ }
+
function paintTargetMonitors(list) {
const box = document.getElementById("opt-target-monitors");
const host = document.getElementById("opt-target-monitors-list");
@@ -2349,6 +2452,18 @@
bindOptionsPosTabs();
hardenOrderAutofill();
+ (function bindProfitExitOpenControls() {
+ const peCb = document.getElementById("opt-profit-exit-enabled");
+ const peMult = document.getElementById("opt-profit-exit-mult");
+ if (!peCb || !peMult) return;
+ function sync() {
+ peMult.disabled = !peCb.checked;
+ if (peCb.checked && (!peMult.value || Number(peMult.value) <= 0)) peMult.value = "1";
+ }
+ peCb.addEventListener("change", sync);
+ sync();
+ })();
+
document.querySelectorAll('input[name="opt-size-mode"]').forEach(function (r) {
r.addEventListener("change", function () {
updateSizeInputs();
diff --git a/lib/instance/templates/embed_shell.html b/lib/instance/templates/embed_shell.html
index 21724db..23a5aa3 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 c715cbb..c1b8f98 100644
--- a/lib/instance/templates/index.html
+++ b/lib/instance/templates/index.html
@@ -19,7 +19,7 @@
{{ pwa_app_name }}
-
+
None:
for ddl in (
"ALTER TABLE options_trades ADD COLUMN wechat_open_sent INTEGER DEFAULT 0",
"ALTER TABLE options_trades ADD COLUMN wechat_close_sent INTEGER DEFAULT 0",
+ "ALTER TABLE options_trades ADD COLUMN profit_exit_enabled INTEGER DEFAULT 0",
+ "ALTER TABLE options_trades ADD COLUMN profit_exit_mult REAL DEFAULT 1.0",
+ "ALTER TABLE options_trades ADD COLUMN profit_exit_state TEXT DEFAULT 'idle'",
):
try:
conn.execute(ddl)
diff --git a/lib/options/options_monitor_lib.py b/lib/options/options_monitor_lib.py
index f77450d..e6e5093 100644
--- a/lib/options/options_monitor_lib.py
+++ b/lib/options/options_monitor_lib.py
@@ -428,6 +428,8 @@ def options_monitor_loop(
profit_ratio: float,
sync_trades_fn: Callable[[sqlite3.Connection], int] | None = None,
target_close_fn: Callable[[str], dict[str, Any]] | None = None,
+ profit_exit_close_fn: Callable[[str], dict[str, Any]] | None = None,
+ profit_exit_cfg: dict[str, Any] | None = None,
stale_pending_fn: Callable[[], dict[str, Any]] | None = None,
stop_event: Any = None,
) -> None:
@@ -459,6 +461,21 @@ def options_monitor_loop(
account_label=account_label,
cfg={"send_wechat": send_wechat, "account_label": account_label},
)
+ if profit_exit_close_fn is not None:
+ from lib.options.options_profit_exit_lib import run_options_profit_exits
+
+ pe_cfg = dict(profit_exit_cfg or {})
+ pe_cfg.setdefault("send_wechat", send_wechat)
+ pe_cfg.setdefault("account_label", account_label)
+ run_options_profit_exits(
+ conn,
+ positions,
+ close_fn=profit_exit_close_fn,
+ send_wechat=send_wechat,
+ account_label=account_label,
+ cfg=pe_cfg,
+ ex=pe_cfg.get("exchange_options"),
+ )
if sync_trades_fn is not None:
sync_trades_fn(conn)
conn.commit()
diff --git a/lib/options/options_profit_exit_lib.py b/lib/options/options_profit_exit_lib.py
new file mode 100644
index 0000000..c71c986
--- /dev/null
+++ b/lib/options/options_profit_exit_lib.py
@@ -0,0 +1,377 @@
+"""单独期权翻倍出场:盈利达权利金×倍数后按买一限价平仓.
+
+1 倍 = 盈利金额等于初始权利金 ⇒ 买一可回收 ≥ 权利金 × (1 + 倍数).
+与「目标位」并行;与仅微信提醒的 OKX_OPTIONS_PROFIT_ALERT_RATIO 独立.
+"""
+from __future__ import annotations
+
+import sqlite3
+from typing import Any, Callable
+
+from lib.options.options_db import init_options_tables, sum_open_premium_paid
+
+
+def _safe_float(v: Any) -> float | None:
+ if v is None or v == "":
+ return None
+ try:
+ return float(v)
+ except (TypeError, ValueError):
+ return None
+
+
+def ensure_profit_exit_columns(conn: sqlite3.Connection) -> None:
+ init_options_tables(conn)
+ for ddl in (
+ "ALTER TABLE options_trades ADD COLUMN profit_exit_enabled INTEGER DEFAULT 0",
+ "ALTER TABLE options_trades ADD COLUMN profit_exit_mult REAL DEFAULT 1.0",
+ "ALTER TABLE options_trades ADD COLUMN profit_exit_state TEXT DEFAULT 'idle'",
+ ):
+ try:
+ conn.execute(ddl)
+ except Exception:
+ pass
+
+
+def normalize_profit_exit_mult(raw: Any, *, default: float = 1.0) -> float:
+ try:
+ mult = float(raw)
+ except (TypeError, ValueError):
+ mult = float(default)
+ if mult <= 0:
+ mult = float(default)
+ return round(mult, 4)
+
+
+def profit_exit_hit(
+ *,
+ premium_paid: float,
+ recycle_usdc: float,
+ mult: float,
+) -> bool:
+ """1倍:盈利=权利金 ⇒ recycle ≥ premium×(1+mult)."""
+ prem = float(premium_paid or 0)
+ recv = float(recycle_usdc or 0)
+ m = float(mult or 0)
+ if prem <= 0 or m <= 0 or recv <= 0:
+ return False
+ return recv + 1e-9 >= prem * (1.0 + m)
+
+
+def required_recycle_usdc(premium_paid: float, mult: float) -> float | None:
+ prem = float(premium_paid or 0)
+ m = float(mult or 0)
+ if prem <= 0 or m <= 0:
+ return None
+ return round(prem * (1.0 + m), 4)
+
+
+def set_profit_exit(
+ conn: sqlite3.Connection,
+ *,
+ inst_id: str,
+ enabled: bool,
+ mult: float | None = None,
+) -> dict[str, Any]:
+ ensure_profit_exit_columns(conn)
+ inst = (inst_id or "").strip()
+ if not inst:
+ return {"ok": False, "msg": "缺少 inst_id"}
+ m = normalize_profit_exit_mult(mult if mult is not None else 1.0)
+ rows = conn.execute(
+ """
+ SELECT id FROM options_trades
+ WHERE inst_id = ? AND status = 'open'
+ """,
+ (inst,),
+ ).fetchall()
+ if not rows:
+ return {"ok": False, "msg": "未找到该合约的本地开仓记录"}
+ if enabled:
+ conn.execute(
+ """
+ UPDATE options_trades
+ SET profit_exit_enabled = 1,
+ profit_exit_mult = ?,
+ profit_exit_state = 'active'
+ WHERE inst_id = ? AND status = 'open'
+ """,
+ (m, inst),
+ )
+ else:
+ conn.execute(
+ """
+ UPDATE options_trades
+ SET profit_exit_enabled = 0,
+ profit_exit_state = 'idle'
+ WHERE inst_id = ? AND status = 'open'
+ """,
+ (inst,),
+ )
+ return {
+ "ok": True,
+ "inst_id": inst,
+ "profit_exit_enabled": bool(enabled),
+ "profit_exit_mult": m if enabled else None,
+ "updated": len(rows),
+ }
+
+
+def profit_exit_by_inst(conn: sqlite3.Connection) -> dict[str, dict[str, Any]]:
+ """进行中(active/closing)的翻倍出场,按合约取最新一条规则."""
+ ensure_profit_exit_columns(conn)
+ rows = conn.execute(
+ """
+ SELECT inst_id, profit_exit_enabled, profit_exit_mult, profit_exit_state
+ FROM options_trades
+ WHERE status = 'open'
+ AND (
+ CAST(COALESCE(profit_exit_enabled, 0) AS INTEGER) = 1
+ OR COALESCE(profit_exit_state, 'idle') IN ('active', 'closing')
+ )
+ ORDER BY id DESC
+ """
+ ).fetchall()
+ out: dict[str, dict[str, Any]] = {}
+ for r in rows:
+ inst = str(r["inst_id"] or "").strip()
+ if not inst or inst in out:
+ continue
+ enabled = int(r["profit_exit_enabled"] or 0) == 1
+ state = str(r["profit_exit_state"] or "idle")
+ if not enabled and state not in ("active", "closing"):
+ continue
+ mult = normalize_profit_exit_mult(r["profit_exit_mult"], default=1.0)
+ out[inst] = {
+ "inst_id": inst,
+ "profit_exit_enabled": enabled or state in ("active", "closing"),
+ "profit_exit_mult": mult,
+ "profit_exit_state": state if state in ("active", "closing") else ("active" if enabled else "idle"),
+ "required_recycle": None,
+ }
+ for inst, info in out.items():
+ prem = sum_open_premium_paid(conn, inst)
+ if prem is not None:
+ info["premium_paid"] = prem
+ info["required_recycle"] = required_recycle_usdc(prem, float(info["profit_exit_mult"]))
+ return out
+
+
+def _mark_state(conn: sqlite3.Connection, inst_id: str, state: str) -> None:
+ conn.execute(
+ """
+ UPDATE options_trades
+ SET profit_exit_state = ?
+ WHERE inst_id = ? AND status = 'open'
+ """,
+ (state, inst_id),
+ )
+
+
+def _commit(conn: sqlite3.Connection) -> None:
+ try:
+ conn.commit()
+ except Exception:
+ pass
+
+
+def _result_fully_done(result: dict[str, Any]) -> bool:
+ if result.get("already_flat"):
+ return True
+ if result.get("fully_closed"):
+ return True
+ remaining = result.get("remaining_sheets")
+ if remaining is not None and int(remaining) <= 0 and result.get("ok"):
+ return True
+ return False
+
+
+def close_option_by_bid_profit_exit(
+ cfg: dict[str, Any],
+ ex: Any,
+ inst_id: str,
+ *,
+ sheets: int | None = None,
+) -> dict[str, Any]:
+ from lib.options.options_close_exec_lib import close_option_by_bid1
+
+ return close_option_by_bid1(
+ cfg,
+ ex,
+ inst_id,
+ sheets=sheets,
+ require_recycle_gate=False,
+ signal_note="翻倍出场",
+ )
+
+
+def _estimate_recycle(
+ cfg: dict[str, Any],
+ ex: Any,
+ pos: dict[str, Any],
+ premium_paid: float | None,
+) -> float | None:
+ from lib.options.options_positions_lib import attach_close_preview
+
+ row = dict(pos)
+ attach_close_preview(cfg, ex, row, premium_paid=premium_paid)
+ preview = row.get("close_preview") if isinstance(row.get("close_preview"), dict) else {}
+ if preview.get("bid_invalid"):
+ return None
+ return _safe_float(preview.get("total_received"))
+
+
+def _notify_profit_exit_close(
+ cfg: dict[str, Any] | None,
+ send_wechat: Callable[[str], None] | None,
+ *,
+ account_label: str,
+ inst_id: str,
+ mult: float,
+ premium_paid: float | None,
+ recycle: float | None,
+ result: dict[str, Any],
+ conn: Any = None,
+) -> None:
+ if result.get("fully_closed") or result.get("already_flat"):
+ if cfg is not None:
+ try:
+ from lib.options.options_notify_lib import notify_options_close
+
+ notify_options_close(
+ cfg,
+ conn,
+ inst_id=inst_id,
+ reason=f"翻倍出场({mult:g}倍)",
+ sheets=result.get("submitted_sheets"),
+ premium_received=result.get("premium_received"),
+ close_quote=result.get("locked_bid_px") or result.get("bid"),
+ )
+ return
+ except Exception:
+ pass
+ if not send_wechat:
+ return
+ try:
+ send_wechat(
+ "\n".join(
+ [
+ "【OKX期权·翻倍出场】",
+ f"账户:{account_label}",
+ f"合约:{inst_id}",
+ f"倍数:{mult:g}(1倍=盈利=权利金)",
+ f"权利金:{premium_paid if premium_paid is not None else '—'}",
+ f"可回收:{recycle if recycle is not None else '—'}",
+ f"提交张数:{result.get('submitted_sheets') or '—'}",
+ f"状态:{'已全平' if (result.get('fully_closed') or result.get('already_flat')) else '挂单中/部分'}",
+ ]
+ )
+ )
+ except Exception:
+ pass
+
+
+def run_options_profit_exits(
+ conn: sqlite3.Connection,
+ positions: list[dict[str, Any]],
+ *,
+ close_fn: Callable[[str], dict[str, Any]],
+ recycle_fn: Callable[[dict[str, Any], float | None], float | None] | None = None,
+ send_wechat: Callable[[str], None] | None = None,
+ account_label: str = "OKX期权",
+ cfg: dict[str, Any] | None = None,
+ ex: Any = None,
+) -> int:
+ """扫描开启翻倍出场的 open 仓;买一可回收达标后限价平仓.返回本次新触发条数."""
+ ensure_profit_exit_columns(conn)
+ pos_by_inst = {str(p.get("inst_id") or p.get("instId") or ""): p for p in positions}
+ hedge_managed: set[str] = set()
+ try:
+ from lib.hedge_plan.hedge_plan_db import active_hedge_option_inst_ids, init_hedge_plan_tables
+
+ init_hedge_plan_tables(conn)
+ hedge_managed = active_hedge_option_inst_ids(conn)
+ except Exception:
+ return 0
+
+ rules = profit_exit_by_inst(conn)
+ triggered = 0
+
+ for inst_id, info in list(rules.items()):
+ if not inst_id:
+ continue
+ if inst_id in hedge_managed:
+ _mark_state(conn, inst_id, "idle")
+ conn.execute(
+ """
+ UPDATE options_trades
+ SET profit_exit_enabled = 0, profit_exit_state = 'idle'
+ WHERE inst_id = ? AND status = 'open'
+ """,
+ (inst_id,),
+ )
+ _commit(conn)
+ continue
+ pos = pos_by_inst.get(inst_id)
+ if not pos:
+ # 持仓已平:收尾
+ _mark_state(conn, inst_id, "done")
+ _commit(conn)
+ continue
+
+ state = str(info.get("profit_exit_state") or "active")
+ mult = normalize_profit_exit_mult(info.get("profit_exit_mult"), default=1.0)
+ prem = sum_open_premium_paid(conn, inst_id)
+ if prem is None or prem <= 0:
+ continue
+
+ if state == "closing":
+ result = close_fn(inst_id)
+ if result.get("already_flat") or _result_fully_done(result):
+ _mark_state(conn, inst_id, "done")
+ _commit(conn)
+ else:
+ _mark_state(conn, inst_id, "closing")
+ _commit(conn)
+ continue
+
+ if not info.get("profit_exit_enabled"):
+ continue
+
+ if recycle_fn is not None:
+ recycle = recycle_fn(pos, prem)
+ elif cfg is not None and ex is not None:
+ recycle = _estimate_recycle(cfg, ex, pos, prem)
+ else:
+ continue
+ if recycle is None:
+ continue
+ if not profit_exit_hit(premium_paid=prem, recycle_usdc=recycle, mult=mult):
+ continue
+
+ result = close_fn(inst_id)
+ if result.get("already_flat"):
+ _mark_state(conn, inst_id, "done")
+ _commit(conn)
+ continue
+ if not result.get("ok"):
+ _mark_state(conn, inst_id, "active")
+ _commit(conn)
+ continue
+
+ done = _result_fully_done(result)
+ _mark_state(conn, inst_id, "done" if done else "closing")
+ _commit(conn)
+ triggered += 1
+ _notify_profit_exit_close(
+ cfg,
+ send_wechat,
+ account_label=account_label,
+ inst_id=inst_id,
+ mult=mult,
+ premium_paid=prem,
+ recycle=recycle,
+ result=result,
+ conn=conn,
+ )
+ return triggered
diff --git a/lib/options/options_register.py b/lib/options/options_register.py
index 2dd0d87..0d68238 100644
--- a/lib/options/options_register.py
+++ b/lib/options/options_register.py
@@ -763,6 +763,12 @@ 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": "目标位无效"})
+ profit_exit_enabled = bool(data.get("profit_exit_enabled"))
+ profit_exit_mult = 1.0
+ if profit_exit_enabled:
+ from lib.options.options_profit_exit_lib import normalize_profit_exit_mult
+
+ profit_exit_mult = normalize_profit_exit_mult(data.get("profit_exit_mult"), default=1.0)
if not inst_id:
return jsonify({"ok": False, "msg": "缺少 inst_id"})
q = cfg["quote_option_contract"](ex, inst_id)
@@ -926,6 +932,9 @@ def register_options_routes(app: Flask, cfg: dict[str, Any]) -> None:
open_opt_type = None
try:
init_options_tables(conn)
+ from lib.options.options_profit_exit_lib import ensure_profit_exit_columns
+
+ ensure_profit_exit_columns(conn)
meta = q.get("meta") or {}
u = str(meta.get("uly") or inst_id).split("-")[0]
opt_type = meta.get("optType")
@@ -935,8 +944,9 @@ def register_options_routes(app: Flask, cfg: dict[str, Any]) -> None:
"""
INSERT INTO options_trades
(inst_id, underlying, opt_type, strike, exp_time, sheets, eth_amount,
- open_quote, premium_paid, status, signal_note, exchange_ord_id)
- VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, 'open', ?, ?)
+ open_quote, premium_paid, status, signal_note, exchange_ord_id,
+ profit_exit_enabled, profit_exit_mult, profit_exit_state)
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, 'open', ?, ?, ?, ?, ?)
""",
(
inst_id,
@@ -950,6 +960,9 @@ def register_options_routes(app: Flask, cfg: dict[str, Any]) -> None:
sizing["total_premium"],
signal_note,
ord_id,
+ 1 if profit_exit_enabled else 0,
+ profit_exit_mult if profit_exit_enabled else 1.0,
+ "active" if profit_exit_enabled else "idle",
),
)
trade_id = int(cur.lastrowid)
@@ -965,6 +978,8 @@ def register_options_routes(app: Flask, cfg: dict[str, Any]) -> None:
trade_id=trade_id,
sheets=sheets,
)
+ if profit_exit_enabled:
+ pass # 列已由 init_options_tables / ensure 迁移
conn.commit()
finally:
conn.close()
@@ -1083,9 +1098,11 @@ def register_options_routes(app: Flask, cfg: dict[str, Any]) -> None:
conn = cfg["get_db"]()
try:
from lib.options.options_target_lib import targets_by_inst
+ from lib.options.options_profit_exit_lib import profit_exit_by_inst
from lib.hedge_plan.hedge_plan_db import active_options_targets_by_inst
tgt_map = targets_by_inst(conn)
+ profit_exit_map = profit_exit_by_inst(conn)
hedge_target_map = active_options_targets_by_inst(conn)
rows = []
for p in raw:
@@ -1104,6 +1121,12 @@ def register_options_routes(app: Flask, cfg: dict[str, Any]) -> None:
row["target_index"] = mon.get("target_index")
row["target_monitor_id"] = mon.get("id")
row["target_monitor"] = mon
+ pe = profit_exit_map.get(inst)
+ if pe:
+ row["profit_exit_enabled"] = pe.get("profit_exit_enabled")
+ row["profit_exit_mult"] = pe.get("profit_exit_mult")
+ row["profit_exit_state"] = pe.get("profit_exit_state")
+ row["profit_exit_required_recycle"] = pe.get("required_recycle")
hedge_target = hedge_target_map.get(inst)
if hedge_target:
row["hedge_plan_target"] = hedge_target
@@ -1230,6 +1253,62 @@ def register_options_routes(app: Flask, cfg: dict[str, Any]) -> None:
finally:
conn.close()
+ @app.route("/api/options/profit-exit", methods=["POST"])
+ @lr
+ def api_options_profit_exit_set():
+ ex, err = _require_options_ex(cfg)
+ if ex is None:
+ return jsonify({"ok": False, "msg": err})
+ data = request.get_json(silent=True) or {}
+ inst_id = (data.get("inst_id") or "").strip()
+ if not inst_id:
+ return jsonify({"ok": False, "msg": "缺少 inst_id"})
+ try:
+ from lib.hedge_plan.hedge_plan_db import (
+ active_hedge_option_inst_ids,
+ init_hedge_plan_tables,
+ )
+
+ conn_h = cfg["get_db"]()
+ try:
+ init_hedge_plan_tables(conn_h)
+ if inst_id in active_hedge_option_inst_ids(conn_h):
+ return jsonify(
+ {
+ "ok": False,
+ "msg": "该合约属于进行中的对冲计划,请在对冲计划中管理,禁止在期权页设置翻倍出场",
+ }
+ )
+ finally:
+ conn_h.close()
+ except Exception as e:
+ return jsonify({"ok": False, "msg": f"对冲托管校验失败: {e}"})
+ enabled_raw = data.get("enabled")
+ if enabled_raw is None:
+ enabled_raw = data.get("profit_exit_enabled")
+ enabled = bool(enabled_raw) and str(enabled_raw).strip().lower() not in (
+ "0",
+ "false",
+ "off",
+ "no",
+ )
+ from lib.options.options_profit_exit_lib import normalize_profit_exit_mult, set_profit_exit
+
+ mult = normalize_profit_exit_mult(data.get("mult", data.get("profit_exit_mult")), default=1.0)
+ raw = cfg["fetch_option_positions"](ex)
+ if raw is None:
+ return jsonify({"ok": False, "msg": "获取期权持仓失败"})
+ if not _find_position(raw, inst_id):
+ return jsonify({"ok": False, "msg": "未找到持仓"})
+ conn = cfg["get_db"]()
+ try:
+ out = set_profit_exit(conn, inst_id=inst_id, enabled=enabled, mult=mult)
+ if out.get("ok"):
+ conn.commit()
+ return jsonify(out)
+ finally:
+ conn.close()
+
@app.route("/api/options/close", methods=["POST"])
@lr
def api_options_close():
@@ -1583,6 +1662,24 @@ def _start_monitor_thread(app: Flask, cfg: dict[str, Any]) -> None:
pass
return result
+ def _profit_exit_close(inst_id: str) -> dict[str, Any]:
+ from lib.options.options_profit_exit_lib import close_option_by_bid_profit_exit
+
+ ex = cfg.get("exchange_options")
+ if ex is None:
+ return {"ok": False, "msg": "期权 exchange 未就绪"}
+ result = close_option_by_bid_profit_exit(cfg, ex, inst_id)
+ if result.get("ok"):
+ try:
+ _sync_options_trades(cfg, force=True)
+ except Exception:
+ pass
+ try:
+ _mark_balances_stale(cfg)
+ except Exception:
+ pass
+ return result
+
def _stale_pending() -> dict[str, Any]:
from lib.exchange.okx_options_lib import invalidate_option_positions_cache
from lib.options.options_pending_lib import cancel_stale_close_pending_orders
@@ -1633,6 +1730,8 @@ def _start_monitor_thread(app: Flask, cfg: dict[str, Any]) -> None:
"profit_ratio": cfg["profit_ratio"],
"sync_trades_fn": _sync,
"target_close_fn": _target_close,
+ "profit_exit_close_fn": _profit_exit_close,
+ "profit_exit_cfg": cfg,
"stale_pending_fn": _stale_pending,
},
daemon=True,
diff --git a/lib/options/templates/options_panel.html b/lib/options/templates/options_panel.html
index fdef341..a1014c5 100644
--- a/lib/options/templates/options_panel.html
+++ b/lib/options/templates/options_panel.html
@@ -28,6 +28,7 @@
链展示近 14 日到期;列表与 T 型默认平值 + 实值3档 + 虚值3档,勾选「展开全部」看全部行权价(若当前为实值/虚值筛选会自动切回「全部」)。
「按可用余额打满」可用额度 = min(交易户可用 USDC, 单笔预算 {{ '%.2f'|format(options_trade_budget|default(10)|float) }}),再 × 预算缓冲 {{ '%.2f'|format(options_budget_buffer|default(0.95)|float) }} 算张数(env 可改)。
「全仓复利」用期权交易户全部可用×缓冲开仓(不受单笔预算限制);可选开启全仓上限;该模式下仅允许同时 1 笔持仓。
+ 翻倍出场:开仓时可勾选;1倍=盈利等于权利金,买一可回收达标后限价平;持仓卡可改倍数或关闭。
平仓仅买一限价,详见说明文档。
打开《期权开平仓与监控说明》
@@ -124,6 +125,18 @@
目标位仅参考(按到期实值估);盈亏比=盈利÷权利金;到位后按买一限价平;无止损,到期即止损
+
-
+
diff --git a/tests/test_options_profit_exit.py b/tests/test_options_profit_exit.py
new file mode 100644
index 0000000..944991a
--- /dev/null
+++ b/tests/test_options_profit_exit.py
@@ -0,0 +1,65 @@
+"""单独期权翻倍出场命中条件."""
+from __future__ import annotations
+
+import sqlite3
+import tempfile
+import unittest
+from pathlib import Path
+
+from lib.options.options_db import init_options_tables
+from lib.options.options_profit_exit_lib import (
+ normalize_profit_exit_mult,
+ profit_exit_by_inst,
+ profit_exit_hit,
+ required_recycle_usdc,
+ set_profit_exit,
+)
+
+
+class TestOptionsProfitExit(unittest.TestCase):
+ def test_hit_one_x_means_profit_equals_premium(self):
+ # 1倍:盈利=权利金 ⇒ 回收≥2×权利金
+ self.assertTrue(profit_exit_hit(premium_paid=10.0, recycle_usdc=20.0, mult=1.0))
+ self.assertFalse(profit_exit_hit(premium_paid=10.0, recycle_usdc=19.9, mult=1.0))
+ self.assertEqual(required_recycle_usdc(10.0, 1.0), 20.0)
+
+ def test_hit_two_x(self):
+ self.assertTrue(profit_exit_hit(premium_paid=10.0, recycle_usdc=30.0, mult=2.0))
+ self.assertFalse(profit_exit_hit(premium_paid=10.0, recycle_usdc=29.9, mult=2.0))
+
+ def test_normalize_mult(self):
+ self.assertEqual(normalize_profit_exit_mult(None), 1.0)
+ self.assertEqual(normalize_profit_exit_mult(0), 1.0)
+ self.assertEqual(normalize_profit_exit_mult("1.5"), 1.5)
+
+ def test_set_and_clear(self):
+ with tempfile.TemporaryDirectory() as td:
+ db = Path(td) / "t.db"
+ conn = sqlite3.connect(str(db))
+ conn.row_factory = sqlite3.Row
+ init_options_tables(conn)
+ conn.execute(
+ """
+ INSERT INTO options_trades
+ (inst_id, underlying, opt_type, sheets, eth_amount, premium_paid, status)
+ VALUES ('ETH-X', 'ETH', 'C', 1, 0.01, 10.0, 'open')
+ """
+ )
+ conn.commit()
+ out = set_profit_exit(conn, inst_id="ETH-X", enabled=True, mult=1.5)
+ self.assertTrue(out["ok"])
+ conn.commit()
+ m = profit_exit_by_inst(conn)
+ self.assertTrue(m["ETH-X"]["profit_exit_enabled"])
+ self.assertEqual(m["ETH-X"]["profit_exit_mult"], 1.5)
+ self.assertEqual(m["ETH-X"]["required_recycle"], 25.0)
+ out2 = set_profit_exit(conn, inst_id="ETH-X", enabled=False, mult=1.5)
+ self.assertTrue(out2["ok"])
+ conn.commit()
+ m2 = profit_exit_by_inst(conn)
+ self.assertNotIn("ETH-X", m2)
+ conn.close()
+
+
+if __name__ == "__main__":
+ unittest.main()