diff --git a/crypto_monitor_okx/.env.example b/crypto_monitor_okx/.env.example index 8569c90..a36f8c7 100644 --- a/crypto_monitor_okx/.env.example +++ b/crypto_monitor_okx/.env.example @@ -133,6 +133,8 @@ HEDGE_PLAN_OPEN_ORDER=options_first HEDGE_PLAN_ON_PERP_SL_CLOSE_OPTIONS=true HEDGE_PLAN_ON_PERP_TP_CLOSE_OPTIONS=false HEDGE_PLAN_OO_CLOSE_WINNER_ONLY=true +# 方案C:期期页面显示「平仓模式」(到期平/全平);关则固定到期平.默认开启,页面默认选全平 +HEDGE_PLAN_OO_CLOSE_MODE_ENABLED=true MAX_ACTIVE_HEDGE_PLANS=1 HEDGE_PLAN_MONITOR_POLL_SECONDS=15 HEDGE_PLAN_PARTIAL_AUTO_CLOSE_OPTION=true diff --git a/crypto_monitor_okx/app.py b/crypto_monitor_okx/app.py index ece3d99..d0ffffa 100644 --- a/crypto_monitor_okx/app.py +++ b/crypto_monitor_okx/app.py @@ -6789,6 +6789,8 @@ def render_main_page(page="trade", embed_mode=None): in ("1", "true", "yes", "on"), hedge_plan_show_options_options=os.getenv("HEDGE_PLAN_SHOW_OPTIONS_OPTIONS", "true").lower() in ("1", "true", "yes", "on"), + hedge_plan_oo_close_mode_enabled=os.getenv("HEDGE_PLAN_OO_CLOSE_MODE_ENABLED", "true").lower() + in ("1", "true", "yes", "on"), options_trade_budget=OKX_OPTIONS_TRADE_BUDGET_USDC, options_default_underly=OKX_OPTIONS_DEFAULT_UNDERLY, risk_status=risk_status, diff --git a/docs/对冲计划开发方案.md b/docs/对冲计划开发方案.md index be86c17..2d1012f 100644 --- a/docs/对冲计划开发方案.md +++ b/docs/对冲计划开发方案.md @@ -145,16 +145,17 @@ ### 5.2 期期对冲 -| 事件 | 盈利方 | 亏损方 | 计划是否结束 | -|------|--------|--------|--------------| -| **标的价到达用户目标价 S\*** | **自动平仓** | **不平**,持有至到期 | 平盈利腿后计划可标 `closing`;**全部腿终态后结束**(亏损腿到期后结账) | -| **到期且整体无盈利** | — | 到期结算 | **算结束**;合计记 **总亏损**(通常 ≈ −全部权利金,或到期结算净值 < 0 的合计) | -| 到期时组合合计仍盈利 | — | 到期结算 | **算结束**;按实际结算盈亏入账 | -| 未达 S\* 至到期 | 两腿均到期 | | 同上,按结算合计结束 | +| 事件 | 盈利方 | 另一腿(残腿) | 计划是否结束 | +|------|--------|--------------|--------------| +| **标的价到达目标 + 平仓模式=到期平** | **自动平仓** | **不平**,持有至到期(`hold_expiry`) | 平盈利腿后仍 `active`;残腿到期后结账 | +| **标的价到达目标 + 平仓模式=全平**(默认) | **自动平仓** | **随即买一清残腿**(无 2×门控,失败则每轮重试) | 两腿都平完后 `closed` | +| **到期且整体无盈利** | — | 到期结算 | **算结束**;合计记 **总亏损** | +| **到期时组合合计仍盈利** | — | 到期结算 | **算结束**;按实际结算盈亏入账 | -判定「整体无盈利」:到期(或计划收口)时 `realized_pnl_total ≤ 0`(含双腿权利金全损). - -盈利方判定规则仍按前文(触达 S\* 时按浮盈较大一侧平仓;皆亏则等到期). +- 界面「平仓模式」仅控制**盈利腿已平之后**另一腿的处理;须 `HEDGE_PLAN_OO_CLOSE_MODE_ENABLED=true`(默认开)才显示,页面默认选 **全平**. +- 关闭方案C开关时行为固定为 **到期平**. +- 判定「整体无盈利」:到期(或计划收口)时 `realized_pnl_total ≤ 0`(含双腿权利金全损). +- 盈利方判定:触达上破/下破时按浮盈较大一侧平仓;皆亏则等到期. ### 5.2.1 期权腿实盘平仓执行(与期权页共用) @@ -564,6 +565,7 @@ realized_pnl_total = pnl_option_close - abs(pnl_perp_sl) | `HEDGE_PLAN_ON_PERP_SL_CLOSE_OPTIONS` | 永期止损后强制平期权 | true | bool | 热更 | **保护机制,默认 true** | | `HEDGE_PLAN_ON_PERP_TP_CLOSE_OPTIONS` | 永期止盈后强制平期权 | false | bool | 热更 | **默认 false,保险腿不平** | | `HEDGE_PLAN_OO_CLOSE_WINNER_ONLY` | 期期只平盈利腿 | true | bool | 热更 | 达目标价只平盈利方 | +| `HEDGE_PLAN_OO_CLOSE_MODE_ENABLED` | 期期平仓模式(方案C) | true | bool | 热更 | 开:页面可选到期平/全平;关:固定到期平 | | `MAX_ACTIVE_HEDGE_PLANS` | 最大同时活跃计划数 | 1 | number | 热更 | 建议保持 1 | | `HEDGE_PLAN_MONITOR_POLL_SECONDS` | 对冲监控轮询(秒) | 15 | number | 热更 | 侦测 TP/SL/目标价 | | `HEDGE_PLAN_PARTIAL_AUTO_CLOSE_OPTION` | 半腿失败时自动平期权 | true | bool | 热更 | 期权成、永续败时的补偿 | @@ -590,6 +592,7 @@ HEDGE_PLAN_OPEN_ORDER=options_first HEDGE_PLAN_ON_PERP_SL_CLOSE_OPTIONS=true HEDGE_PLAN_ON_PERP_TP_CLOSE_OPTIONS=false HEDGE_PLAN_OO_CLOSE_WINNER_ONLY=true +HEDGE_PLAN_OO_CLOSE_MODE_ENABLED=true MAX_ACTIVE_HEDGE_PLANS=1 HEDGE_PLAN_MONITOR_POLL_SECONDS=15 HEDGE_PLAN_PARTIAL_AUTO_CLOSE_OPTION=true diff --git a/lib/common/static/hedge_plan.js b/lib/common/static/hedge_plan.js index d0310bb..1c999ea 100644 --- a/lib/common/static/hedge_plan.js +++ b/lib/common/static/hedge_plan.js @@ -29,6 +29,8 @@ legB: null, market: null, ooSheetsMode: "same_sheets", + ooCloseModeEnabled: root.getAttribute("data-oo-close-mode-enabled") !== "0", + ooCloseMode: "close_all", tradingUsdc: null, tradeBudgetUsdc: null, budgetBuffer: 0.95, @@ -295,6 +297,19 @@ }); } + function syncOoCloseModeUI() { + const enabled = !!state.ooCloseModeEnabled; + const row = $("hp-oo-close-mode-row"); + const hint = $("hp-oo-close-mode-hint"); + if (row) row.classList.toggle("hidden", !enabled); + if (hint) hint.classList.toggle("hidden", !enabled); + if (!enabled) state.ooCloseMode = "hold_expiry"; + document.querySelectorAll(".hp-oo-close-mode").forEach(function (b) { + const on = b.getAttribute("data-oo-close") === state.ooCloseMode; + b.classList.toggle("active", on); + }); + } + function updateOoBudgetLine(extra) { const line = $("hp-oo-budget-line"); if (!line) return; @@ -331,6 +346,15 @@ async function loadGates() { try { const d = await apiJson("/api/hedge-plan/gates?plan_type=" + encodeURIComponent(state.mode)); + if (d.oo_close_mode_enabled != null) { + state.ooCloseModeEnabled = !!d.oo_close_mode_enabled; + } + if (!state.ooCloseModeEnabled) { + state.ooCloseMode = "hold_expiry"; + } else if (d.oo_close_mode_default && !state._ooCloseModeTouched) { + state.ooCloseMode = d.oo_close_mode_default === "hold_expiry" ? "hold_expiry" : "close_all"; + } + syncOoCloseModeUI(); setGateLine(d); } catch (e) { setGateLine({ can_preview: false, can_start: false, reasons: [e.message], is_full_margin: false }); @@ -953,6 +977,14 @@ autoFillOoSheets(); }); }); + document.querySelectorAll(".hp-oo-close-mode").forEach(function (b) { + b.addEventListener("click", function () { + if (!state.ooCloseModeEnabled) return; + state.ooCloseMode = b.getAttribute("data-oo-close") || "close_all"; + state._ooCloseModeTouched = true; + syncOoCloseModeUI(); + }); + }); if ($("hp-preview-btn")) $("hp-preview-btn").addEventListener("click", function () { state.mode = "perp_options"; @@ -1346,6 +1378,7 @@ target_price_up: up, target_price_down: down, target_price: up, + oo_close_mode: state.ooCloseModeEnabled ? state.ooCloseMode : "hold_expiry", leg_a: legPayload(state.legA, ooSheets("hp-oo-sheets-a")), leg_b: legPayload(state.legB, ooSheets("hp-oo-sheets-b")), }; @@ -1410,6 +1443,7 @@ syncUnderlyingUI(); syncMoneyUI(); syncOoSizeModeUI(); + syncOoCloseModeUI(); updateOoBudgetLine(null); bind(); void refreshAll(); diff --git a/lib/common/static/instance_theme.css b/lib/common/static/instance_theme.css index 1f9acba..4191811 100644 --- a/lib/common/static/instance_theme.css +++ b/lib/common/static/instance_theme.css @@ -3265,11 +3265,16 @@ html[data-theme="light"] .opt-be-dist-down { gap: 8px; margin: 6px 0 2px; } -.hedge-plan-page-wrap .hp-oo-size-mode.active { +.hedge-plan-page-wrap .hp-oo-size-mode.active, +.hedge-plan-page-wrap .hp-oo-close-mode.active { border-color: var(--accent, #00d4ff); color: var(--text, #fff); background: rgba(0, 212, 255, 0.12); } +.hedge-plan-page-wrap #hp-oo-close-mode-row.hidden, +.hedge-plan-page-wrap #hp-oo-close-mode-hint.hidden { + display: none !important; +} .hedge-plan-page-wrap #hp-oo-budget-line.hp-oo-budget-warn { color: #ff8a8a; } diff --git a/lib/env/env_schema.py b/lib/env/env_schema.py index 6bb14a1..bd7671f 100644 --- a/lib/env/env_schema.py +++ b/lib/env/env_schema.py @@ -90,6 +90,7 @@ HOT_RELOAD_EXACT = frozenset({ "HEDGE_PLAN_ON_PERP_SL_CLOSE_OPTIONS", "HEDGE_PLAN_ON_PERP_TP_CLOSE_OPTIONS", "HEDGE_PLAN_OO_CLOSE_WINNER_ONLY", + "HEDGE_PLAN_OO_CLOSE_MODE_ENABLED", "MAX_ACTIVE_HEDGE_PLANS", "HEDGE_PLAN_MONITOR_POLL_SECONDS", "HEDGE_PLAN_PARTIAL_AUTO_CLOSE_OPTION", diff --git a/lib/env/env_ui_manifest.py b/lib/env/env_ui_manifest.py index ac1983d..b5c2574 100644 --- a/lib/env/env_ui_manifest.py +++ b/lib/env/env_ui_manifest.py @@ -141,6 +141,11 @@ _HEDGE_PLAN_SECTION: dict[str, Any] = { ("HEDGE_PLAN_ON_PERP_SL_CLOSE_OPTIONS", "永期止损后强制平期权", "保护机制,建议保持 true"), ("HEDGE_PLAN_ON_PERP_TP_CLOSE_OPTIONS", "永期止盈后强制平期权", "默认 false,保险腿不平"), ("HEDGE_PLAN_OO_CLOSE_WINNER_ONLY", "期期只平盈利腿", "达目标价只平盈利方"), + ( + "HEDGE_PLAN_OO_CLOSE_MODE_ENABLED", + "期期平仓模式(方案C)", + "默认 true;开启后页面可选「到期平/全平」(盈利腿平后另一腿);关闭则固定到期平", + ), ("MAX_ACTIVE_HEDGE_PLANS", "最大同时活跃计划数", "建议 1"), ("HEDGE_PLAN_MONITOR_POLL_SECONDS", "对冲监控轮询(秒)", "默认 15"), ("HEDGE_PLAN_PARTIAL_AUTO_CLOSE_OPTION", "半腿失败时自动平期权", ""), @@ -157,6 +162,7 @@ _RUNTIME_ENV_DEFAULTS: dict[str, str] = { "RISK_MOOD_ISSUES_DAILY_FREEZE": "true", "HEDGE_PLAN_SHOW_PERP_OPTIONS": "true", "HEDGE_PLAN_SHOW_OPTIONS_OPTIONS": "true", + "HEDGE_PLAN_OO_CLOSE_MODE_ENABLED": "true", } diff --git a/lib/hedge_plan/hedge_plan_db.py b/lib/hedge_plan/hedge_plan_db.py index 5458ada..9fcd978 100644 --- a/lib/hedge_plan/hedge_plan_db.py +++ b/lib/hedge_plan/hedge_plan_db.py @@ -72,6 +72,8 @@ def init_hedge_plan_tables(conn: sqlite3.Connection) -> None: ) _ensure_column(conn, "hedge_plans", "target_price_up", "REAL") _ensure_column(conn, "hedge_plans", "target_price_down", "REAL") + # close_all=盈利腿平后清残腿;hold_expiry=残腿持有至到期(现状) + _ensure_column(conn, "hedge_plans", "oo_close_mode", "TEXT") def _ensure_column(conn: sqlite3.Connection, table: str, col: str, typedef: str) -> None: diff --git a/lib/hedge_plan/hedge_plan_monitor_lib.py b/lib/hedge_plan/hedge_plan_monitor_lib.py index 1048ea8..f6127f0 100644 --- a/lib/hedge_plan/hedge_plan_monitor_lib.py +++ b/lib/hedge_plan/hedge_plan_monitor_lib.py @@ -92,6 +92,55 @@ def _notify_end_reload(cfg: dict[str, Any], conn: Any, plan_id: int) -> None: notify_plan_end(cfg, conn, plan) +def resolve_oo_rest_close_mode(plan: dict[str, Any]) -> str: + """盈利腿平后另一腿:close_all(全平) / hold_expiry(到期平). + + - 方案C关闭 → 强制到期平 + - 计划未写 oo_close_mode(旧单) → 到期平,避免误清残腿 + - 新开仓默认写入 close_all + """ + if not _env_bool("HEDGE_PLAN_OO_CLOSE_MODE_ENABLED", True): + return "hold_expiry" + raw = plan.get("oo_close_mode") + if raw is None or str(raw).strip() == "": + return "hold_expiry" + v = str(raw).strip().lower() + if v in ("hold_expiry", "hold_to_expiry", "expiry", "到期平"): + return "hold_expiry" + return "close_all" + + +def _oo_option_legs(legs: list[dict[str, Any]], *, statuses: tuple[str, ...]) -> list[dict[str, Any]]: + out = [] + for x in legs: + if not str(x.get("leg_role") or "").startswith("option"): + continue + if str(x.get("status") or "") in statuses: + out.append(x) + return out + + +def _finalize_oo_all_closed( + cfg: dict[str, Any], conn: Any, plan: dict[str, Any], legs: list[dict[str, Any]], *, reason: str +) -> dict[str, Any]: + closed_opts = _oo_option_legs(legs, statuses=("closed",)) + total_opts = sum(float(x.get("realized_pnl") or 0) for x in closed_opts) + close_reason = reason or "oo_rest_closed" + bucket = "oo_target" if total_opts > 0 else "oo_expiry_loss" + update_plan( + conn, + int(plan["id"]), + status="closed", + close_reason=close_reason, + realized_pnl_options=round(total_opts, 4), + realized_pnl_total=round(total_opts, 4), + stats_bucket=bucket, + closed_at=_now(), + ) + _notify_end_reload(cfg, conn, int(plan["id"])) + return {"plan_id": plan["id"], "close_reason": close_reason, "total": total_opts} + + def _tick_one(cfg: dict[str, Any], conn: Any, plan: dict[str, Any]) -> Optional[dict[str, Any]]: pt = plan.get("plan_type") legs = get_plan_legs(conn, int(plan["id"])) @@ -101,6 +150,9 @@ def _tick_one(cfg: dict[str, Any], conn: Any, plan: dict[str, Any]) -> Optional[ return r if pt == "options_options": r = _tick_oo_expiry(cfg, conn, plan, legs) + if r: + return r + r = _tick_oo_close_rest(cfg, conn, plan, legs) if r: return r return _tick_oo_target(cfg, conn, plan, legs) @@ -232,10 +284,88 @@ def _tick_po(cfg: dict[str, Any], conn: Any, plan: dict[str, Any], legs: list[di return {"plan_id": plan["id"], "close_reason": reason, "total": total} +def _estimate_leg_close_pnl(leg: dict[str, Any], idx: Optional[float], bid: Optional[float]) -> float: + """残腿平仓盈亏估算:优先买一回收 − 权利金;无买一则用内在价值.""" + premium = float(leg.get("premium") or 0) + sheets = float(leg.get("size") or 1) + ct = float(leg.get("ct_mult") or 0.01) + if bid is not None and float(bid) > 0: + return float(bid) * sheets * ct - premium + if idx is None: + return -premium + strike = _sf(leg.get("strike")) or 0 + o = (leg.get("opt_type") or "").upper() + intrinsic = max(0.0, idx - strike) if o == "C" else max(0.0, strike - idx) + return intrinsic * sheets * ct - premium + + +def _tick_oo_close_rest( + cfg: dict[str, Any], conn: Any, plan: dict[str, Any], legs: list[dict[str, Any]] +) -> Optional[dict[str, Any]]: + """盈利腿已平后:全平模式清残腿(无2×门控,买一失败则下轮重试).""" + if resolve_oo_rest_close_mode(plan) != "close_all": + return None + open_legs = _oo_option_legs(legs, statuses=("open",)) + closed_legs = _oo_option_legs(legs, statuses=("closed",)) + # 至少已平一条,且仍有残腿;避免双腿都还 open 时误清 + if len(closed_legs) < 1 or len(open_legs) < 1: + return None + reason0 = str(plan.get("close_reason") or "") + allowed_reasons = ( + "target_win_leg", + "target_up_win_leg", + "target_down_win_leg", + "oo_rest_closing", + "", + ) + if reason0 not in allowed_reasons and not ( + len(closed_legs) >= 1 and len(open_legs) == 1 + ): + return None + + idx = _index_px(cfg, str(plan.get("underlying") or "ETH")) + acted = False + for leg in list(open_legs): + close_r = _sell_option( + cfg, inst_id=str(leg.get("inst_id") or ""), sheets=float(leg.get("size") or 1) + ) + if not close_r.get("ok"): + notify_hedge( + cfg, + build_hedge_alert_message( + title="期期全平·残腿平仓失败(将重试)", + plan_id=plan.get("id"), + detail=str(close_r.get("msg") or close_r), + ), + ) + update_plan(conn, int(plan["id"]), close_reason="oo_rest_closing") + return {"plan_id": plan["id"], "msg": "残腿平仓失败", "close": close_r, "retry": True} + bid = _sf(close_r.get("bid")) + pnl = _estimate_leg_close_pnl(leg, idx, bid) + conn.execute( + "UPDATE hedge_plan_legs SET status=?, close_reason=?, closed_at=?, realized_pnl=? WHERE id=?", + ("closed", "oo_rest_close", _now(), round(pnl, 4), leg["id"]), + ) + leg["status"] = "closed" + leg["realized_pnl"] = round(pnl, 4) + acted = True + + if not acted: + return None + legs2 = get_plan_legs(conn, int(plan["id"])) + still_open = _oo_option_legs(legs2, statuses=("open", "hold_to_expiry")) + if still_open: + update_plan(conn, int(plan["id"]), close_reason="oo_rest_closing") + return {"plan_id": plan["id"], "msg": "残腿部分已平,继续重试", "remaining": len(still_open)} + return _finalize_oo_all_closed( + cfg, conn, plan, legs2, reason="oo_rest_closed" + ) + + def _tick_oo_target( cfg: dict[str, Any], conn: Any, plan: dict[str, Any], legs: list[dict[str, Any]] ) -> Optional[dict[str, Any]]: - """期期:触及上破或下破目标价时平盈利腿.""" + """期期:触及上破或下破目标价时平盈利腿;按平仓模式处理另一腿.""" idx = _index_px(cfg, str(plan.get("underlying") or "ETH")) if idx is None: return None @@ -261,7 +391,7 @@ def _tick_oo_target( return None if not _env_bool("HEDGE_PLAN_OO_CLOSE_WINNER_ONLY", True): return None - open_legs = [x for x in legs if x.get("status") == "open" and str(x.get("leg_role") or "").startswith("option")] + open_legs = _oo_option_legs(legs, statuses=("open",)) if len(open_legs) < 2: return None winners = [] @@ -292,17 +422,45 @@ def _tick_oo_target( "UPDATE hedge_plan_legs SET status=?, close_reason=?, closed_at=?, realized_pnl=? WHERE id=?", ("closed", reason, _now(), best_pnl, best["id"]), ) + rest_mode = resolve_oo_rest_close_mode(plan) update_plan(conn, int(plan["id"]), close_reason=reason) mid = dict(plan) mid["close_reason"] = reason mid["status"] = "active" + mid["oo_close_mode"] = rest_mode notify_plan_end(cfg, conn, mid) + + # 全平:同轮尝试清残腿;失败则下轮 _tick_oo_close_rest 重试 + if rest_mode == "close_all": + legs2 = get_plan_legs(conn, int(plan["id"])) + rest = _tick_oo_close_rest(cfg, conn, mid, legs2) + out = { + "plan_id": plan["id"], + "close_reason": reason, + "hit_side": hit_side, + "closed_leg": best.get("id"), + "index": idx, + "oo_close_mode": rest_mode, + } + if rest: + out["rest"] = rest + return out + + # 到期平:显式标记残腿 hold_to_expiry + for leg in open_legs: + if int(leg.get("id") or 0) == int(best.get("id") or 0): + continue + conn.execute( + "UPDATE hedge_plan_legs SET status=? WHERE id=?", + ("hold_to_expiry", leg["id"]), + ) return { "plan_id": plan["id"], "close_reason": reason, "hit_side": hit_side, "closed_leg": best.get("id"), "index": idx, + "oo_close_mode": rest_mode, } diff --git a/lib/hedge_plan/hedge_plan_notify_lib.py b/lib/hedge_plan/hedge_plan_notify_lib.py index 76a13a3..2e8ce7d 100644 --- a/lib/hedge_plan/hedge_plan_notify_lib.py +++ b/lib/hedge_plan/hedge_plan_notify_lib.py @@ -81,6 +81,8 @@ def build_hedge_end_message(plan: dict[str, Any]) -> str: "target_win_leg": "期期已平盈利腿(中间态)", "target_up_win_leg": "期期上破·已平盈利腿", "target_down_win_leg": "期期下破·已平盈利腿", + "oo_rest_closing": "期期全平·清残腿中", + "oo_rest_closed": "期期全平·两腿已平", "oo_expiry_loss": "期期到期无盈利·总亏损", "oo_expiry_win": "期期到期仍盈利", "expiry": "到期收口", @@ -150,14 +152,20 @@ def notify_plan_end(cfg: dict[str, Any], conn: Any, plan: dict[str, Any]) -> boo "target_win_leg", "target_up_win_leg", "target_down_win_leg", + "oo_rest_closing", ) and (plan.get("status") or "") != "closed": side = "上破" if "up" in str(plan.get("close_reason")) else ( "下破" if "down" in str(plan.get("close_reason")) else "目标价" ) + mode = (plan.get("oo_close_mode") or "").strip().lower() + if mode in ("close_all", "全平"): + rest_txt = "另一腿将全平(买一清残腿,无2×门控,失败重试)" + else: + rest_txt = "另一腿到期平(持有至到期结算)" notify_hedge( cfg, build_hedge_alert_message( - title=f"期期{side}已平盈利腿,亏损腿继续持有至到期", + title=f"期期{side}已平盈利腿 · {rest_txt}", plan_id=plan.get("id"), detail=( f"上破 {_fmt(plan.get('target_price_up') or plan.get('target_price'))}" diff --git a/lib/hedge_plan/hedge_plan_register.py b/lib/hedge_plan/hedge_plan_register.py index a5ea506..d984fe2 100644 --- a/lib/hedge_plan/hedge_plan_register.py +++ b/lib/hedge_plan/hedge_plan_register.py @@ -116,6 +116,20 @@ def _show_options_options() -> bool: return _env_bool("HEDGE_PLAN_SHOW_OPTIONS_OPTIONS", True) +def _oo_close_mode_enabled() -> bool: + return _env_bool("HEDGE_PLAN_OO_CLOSE_MODE_ENABLED", True) + + +def _normalize_oo_close_mode(raw: Any) -> str: + """方案C关闭时强制 hold_expiry;开启时默认 close_all.""" + if not _oo_close_mode_enabled(): + return "hold_expiry" + v = str(raw or "close_all").strip().lower() + if v in ("hold_expiry", "hold_to_expiry", "expiry", "到期平"): + return "hold_expiry" + return "close_all" + + def _live_order() -> bool: return _env_bool("HEDGE_PLAN_LIVE_ORDER", False) @@ -155,6 +169,13 @@ def _gates_dict(cfg: dict[str, Any], plan_type: str) -> dict[str, Any]: ) +def _gates_public(cfg: dict[str, Any], plan_type: str) -> dict[str, Any]: + g = _gates_dict(cfg, plan_type) + g["oo_close_mode_enabled"] = _oo_close_mode_enabled() + g["oo_close_mode_default"] = "close_all" if _oo_close_mode_enabled() else "hold_expiry" + return g + + def _maybe_start_monitor(cfg: dict[str, Any]) -> None: if not _hedge_enabled(): return @@ -298,6 +319,7 @@ def _persist_oo(cfg: dict[str, Any], result: dict[str, Any], body: dict[str, Any ), "sizing_mode_at_open": load_position_sizing_mode(), "premium_total": premium, + "oo_close_mode": _normalize_oo_close_mode(body.get("oo_close_mode")), "opened_at": result.get("opened_at"), }, ) @@ -347,7 +369,7 @@ def register_hedge_plan_routes(app: Flask, cfg: dict[str, Any]) -> None: @lr def api_hedge_gates(): plan_type = (request.args.get("plan_type") or "perp_options").strip() - return jsonify({"ok": True, **_gates_dict(cfg, plan_type)}) + return jsonify({"ok": True, **_gates_public(cfg, plan_type)}) @app.route("/api/hedge-plan/market") @lr diff --git a/lib/hedge_plan/templates/hedge_plan_panel.html b/lib/hedge_plan/templates/hedge_plan_panel.html index 97b6199..1a441bb 100644 --- a/lib/hedge_plan/templates/hedge_plan_panel.html +++ b/lib/hedge_plan/templates/hedge_plan_panel.html @@ -4,6 +4,7 @@ data-options-enabled="{{ '1' if options_enabled else '0' }}" data-show-perp="{{ '1' if hedge_plan_show_perp_options | default(true) else '0' }}" data-show-oo="{{ '1' if hedge_plan_show_options_options | default(true) else '0' }}" + data-oo-close-mode-enabled="{{ '1' if hedge_plan_oo_close_mode_enabled | default(true) else '0' }}" data-sizing-mode="{{ position_sizing_mode | default('risk') }}" data-is-full-margin="{{ '1' if position_sizing_mode == 'full_margin' else '0' }}"> {% if not hedge_plan_enabled %} @@ -143,6 +144,12 @@ +
盈利腿平后:全平=立刻买一清另一腿(无2×,失败重试);到期平=另一腿持有至到期