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 @@ 目标位仅参考(按到期实值估);盈亏比=盈利÷权利金;到位后按买一限价平;无止损,到期即止损 +
    +
    + + + +
    + 1倍=盈利等于权利金(可回收≥2×权利金);可开可关,与目标位并行 +
    - + 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()