diff --git a/lib/common/static/instance_theme.css b/lib/common/static/instance_theme.css index b1b7552..59d0413 100644 --- a/lib/common/static/instance_theme.css +++ b/lib/common/static/instance_theme.css @@ -4424,6 +4424,9 @@ html[data-theme="light"] .opt-pending-item { .opt-size-mode-chip { position: relative; } +.opt-size-mode-chip[hidden] { + display: none !important; +} .opt-size-mode-chip input[type="radio"] { position: absolute; opacity: 0; diff --git a/lib/common/static/options_panel.js b/lib/common/static/options_panel.js index 6f22a04..14f79dc 100644 --- a/lib/common/static/options_panel.js +++ b/lib/common/static/options_panel.js @@ -271,13 +271,32 @@ } } - function currentSizeMode() { - const el = document.querySelector('input[name="opt-size-mode"]:checked'); - return el ? el.value : "sheets"; + function compoundFullEnabled() { + // 缺省按关闭,避免热更关闭后仍误用全仓复利 + return !!(root && String(root.dataset.compoundFullEnabled || "0") === "1"); } - function compoundFullEnabled() { - return !!(root && String(root.dataset.compoundFullEnabled || "1") === "1"); + function currentSizeMode() { + const el = document.querySelector('input[name="opt-size-mode"]:checked:not(:disabled)'); + if (el) return el.value; + const any = document.querySelector('input[name="opt-size-mode"]:checked'); + if (any && any.value === "compound_full" && !compoundFullEnabled()) return "sheets"; + if (any && any.value === "budget_full" && compoundFullEnabled()) return "compound_full"; + return "sheets"; + } + + function applyCompoundModeUi(compoundOn) { + if (root) root.dataset.compoundFullEnabled = compoundOn ? "1" : "0"; + updateSizeInputs(); + } + + function syncCompoundFlagsFromPayload(d) { + if (!d || typeof d !== "object") return; + if (d.compound_full_enabled != null) { + applyCompoundModeUi(!!d.compound_full_enabled); + } else if (d.cfg && d.cfg.compound_full_enabled != null) { + applyCompoundModeUi(!!d.cfg.compound_full_enabled); + } } function updateSizeInputs() { @@ -292,21 +311,38 @@ const compoundCapLine = document.getElementById("opt-compound-cap-line"); const compoundOn = compoundFullEnabled(); if (budgetWrap) { - budgetWrap.hidden = compoundOn; + budgetWrap.hidden = !!compoundOn; + budgetWrap.style.display = compoundOn ? "none" : ""; const radio = budgetWrap.querySelector('input[name="opt-size-mode"]'); - if (radio) radio.disabled = compoundOn; + if (radio) radio.disabled = !!compoundOn; } if (compoundWrap) { compoundWrap.hidden = !compoundOn; + compoundWrap.style.display = compoundOn ? "" : "none"; const radio = compoundWrap.querySelector('input[name="opt-size-mode"]'); if (radio) radio.disabled = !compoundOn; } - if (compoundOn && mode === "budget_full") { + if (compoundOn && (mode === "budget_full" || mode === "compound_full")) { const compoundRadio = document.querySelector('input[name="opt-size-mode"][value="compound_full"]'); - if (compoundRadio) compoundRadio.checked = true; - } else if (!compoundOn && mode === "compound_full") { - const sheetsRadio = document.querySelector('input[name="opt-size-mode"][value="sheets"]'); - if (sheetsRadio) sheetsRadio.checked = true; + if (compoundRadio) { + compoundRadio.disabled = false; + compoundRadio.checked = true; + } + } else if (!compoundOn) { + const compoundRadio = document.querySelector('input[name="opt-size-mode"][value="compound_full"]'); + if (compoundRadio) { + compoundRadio.checked = false; + compoundRadio.disabled = true; + } + // currentSizeMode 会把残留 compound 映射成 sheets,须实际勾选,避免无选中无法开仓 + const checkedOk = document.querySelector('input[name="opt-size-mode"]:checked:not(:disabled)'); + if (!checkedOk) { + const sheetsRadio = document.querySelector('input[name="opt-size-mode"][value="sheets"]'); + if (sheetsRadio) { + sheetsRadio.disabled = false; + sheetsRadio.checked = true; + } + } } const modeNow = currentSizeMode(); if (sheetsEl) sheetsEl.style.display = modeNow === "sheets" ? "" : "none"; @@ -328,8 +364,9 @@ } document.querySelectorAll(".opt-size-mode-chip").forEach(function (chip) { const radio = chip.querySelector('input[name="opt-size-mode"]'); - chip.classList.toggle("is-selected", !!(radio && radio.checked)); - chip.classList.toggle("active", !!(radio && radio.checked)); + const selected = !!(radio && radio.checked && !radio.disabled); + chip.classList.toggle("is-selected", selected); + chip.classList.toggle("active", selected); }); } @@ -365,6 +402,7 @@ } function quoteUrl(instId) { + updateSizeInputs(); const mode = currentSizeMode(); let url = "/api/options/quote?inst_id=" + encodeURIComponent(instId) + "&mode=" + mode; if (mode === "eth_amount") { @@ -1186,6 +1224,7 @@ } function fillOrderPanel(d) { + syncCompoundFlagsFromPayload(d); state.orderQuote = d && d.ok ? d : null; const sz = d.sizing || {}; const canOpen = !!(d && d.ok && d.can_open); @@ -1386,6 +1425,7 @@ const btn = document.getElementById("opt-open-btn"); btn.disabled = true; try { + updateSizeInputs(); const mode = currentSizeMode(); const body = { inst_id: state.selectedInst, @@ -1395,7 +1435,10 @@ if (mode === "eth_amount") { body.eth_amount = parseFloat(document.getElementById("opt-eth-amount").value); } else if (mode === "sheets") { - body.sheets = parseInt(document.getElementById("opt-sheets-amount").value, 10); + body.sheets = parseInt(document.getElementById("opt-sheets-amount").value, 10) || 1; + } else if (mode === "compound_full" && !compoundFullEnabled()) { + body.mode = "sheets"; + body.sheets = parseInt(document.getElementById("opt-sheets-amount").value, 10) || 1; } const tgtRaw = (document.getElementById("opt-target-idx").value || "").trim(); if (tgtRaw !== "") { @@ -2369,6 +2412,15 @@ function bootOptionsPanel() { applyBudgetBuffer(state.budgetBuffer); updateSizeInputs(); + void (async function syncLiveCompoundFlag() { + try { + const d = await apiJson("/api/options/balances"); + syncCompoundFlagsFromPayload(d); + if (d && d.trade_budget != null && root) { + root.dataset.tradeBudget = String(d.trade_budget); + } + } catch (_) {} + })(); syncMoneyFilterButtons(); syncChainViewUI(); updateUnderlyingLabel(); diff --git a/lib/env/env_schema.py b/lib/env/env_schema.py index 3c97723..fbe7058 100644 --- a/lib/env/env_schema.py +++ b/lib/env/env_schema.py @@ -95,6 +95,11 @@ HOT_RELOAD_EXACT = frozenset({ "OKX_OPTIONS_CHAIN_MAX_DTE_DAYS", "OKX_OPTIONS_MAX_DTE_DAYS", "OKX_OPTIONS_MAX_ACTIVE_POSITIONS", + "OKX_OPTIONS_COMPOUND_FULL_ENABLED", + "OKX_OPTIONS_COMPOUND_FULL_CAP_ENABLED", + "OKX_OPTIONS_COMPOUND_FULL_CAP_USDC", + "OKX_OPTIONS_TRADE_BUDGET_USDC", + "OKX_OPTIONS_BUDGET_BUFFER", "OKX_TRADE_MODE", "MAX_ACTIVE_HEDGE_PLANS", "HEDGE_PLAN_LIVE_ORDER", diff --git a/lib/instance/templates/embed_shell.html b/lib/instance/templates/embed_shell.html index 23a5aa3..da4955d 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 c1b8f98..3354750 100644 --- a/lib/instance/templates/index.html +++ b/lib/instance/templates/index.html @@ -19,7 +19,7 @@ {{ pwa_app_name }} - + tuple[str, str | None]: + """全仓复利关闭时强制离开 compound_full,避免前端残留选中导致无法开仓.""" + m = (mode or "sheets").strip() or "sheets" + if m == "compound_full" and not _compound_full_enabled(): + return "sheets", "全仓复利已关闭,已改用指定张数" + if m == "budget_full" and _compound_full_enabled(): + return "compound_full", None + return m, None + + def _compound_full_usdc(cfg: dict[str, Any], ex: Any) -> tuple[float | None, str]: """全仓复利 = 期权交易户可用(可选上限封顶);再由 calc_order_size × budget_buffer.""" if not _compound_full_enabled(): @@ -411,7 +421,16 @@ def register_options_routes(app: Flask, cfg: dict[str, Any]) -> None: return jsonify({"ok": False, "msg": err}) force = (request.args.get("force") or "").strip().lower() in ("1", "true", "yes") bal = cfg["fetch_options_balances"](ex, force=force, scope="main") - return jsonify({"ok": True, **bal, "trade_budget": cfg["trade_budget"]}) + return jsonify( + { + "ok": True, + **bal, + "trade_budget": _env_float("OKX_OPTIONS_TRADE_BUDGET_USDC", float(cfg.get("trade_budget") or 10)), + "compound_full_enabled": _compound_full_enabled(), + "compound_full_cap_enabled": _env_bool("OKX_OPTIONS_COMPOUND_FULL_CAP_ENABLED", False), + "compound_full_cap_usdc": _env_float("OKX_OPTIONS_COMPOUND_FULL_CAP_USDC", 300.0), + } + ) @app.route("/api/options/chain") @lr @@ -475,7 +494,7 @@ def register_options_routes(app: Flask, cfg: dict[str, Any]) -> None: ask = q.get("ask") ct_mult = q.get("ct_mult") or 0.01 min_sz = q.get("min_sz") or 1 - mode = (request.args.get("mode") or "budget_full").strip() + mode = (request.args.get("mode") or "sheets").strip() sheet_count = None try: if request.args.get("sheets"): @@ -486,26 +505,39 @@ def register_options_routes(app: Flask, cfg: dict[str, Any]) -> None: paid = _open_premium_paid(cfg, inst_id) target = sheet_count if sheet_count is not None else 0 return jsonify(_attach_close_preview(cfg, ex, {**q, "pos": target, "premium_paid": paid}, sheets=target, premium_paid=paid)) + mode, mode_note = _normalize_size_mode(mode) budget = cfg["trade_budget"] budget_cap = cfg["trade_budget"] available_usdc = None if mode == "budget_full": blocked = _budget_full_blocked_by_compound_msg() if blocked: - return jsonify({"ok": False, "msg": blocked}) + return jsonify( + { + "ok": False, + "msg": blocked, + "compound_full_enabled": _compound_full_enabled(), + } + ) budget, budget_err = _budget_full_usdc(cfg, ex) if budget is None: - return jsonify({"ok": False, "msg": budget_err}) + return jsonify({"ok": False, "msg": budget_err, "compound_full_enabled": _compound_full_enabled()}) budget_cap = budget from lib.exchange.okx_options_lib import fetch_options_trading_usdc available_usdc = fetch_options_trading_usdc(ex) elif mode == "compound_full": if not _compound_full_enabled(): - return jsonify({"ok": False, "msg": "全仓复利未开启(OKX_OPTIONS_COMPOUND_FULL_ENABLED)"}) + return jsonify( + { + "ok": False, + "msg": "全仓复利未开启(OKX_OPTIONS_COMPOUND_FULL_ENABLED)", + "compound_full_enabled": False, + } + ) budget, budget_err = _compound_full_usdc(cfg, ex) if budget is None: - return jsonify({"ok": False, "msg": budget_err}) + return jsonify({"ok": False, "msg": budget_err, "compound_full_enabled": True}) budget_cap = budget from lib.exchange.okx_options_lib import fetch_options_trading_usdc @@ -721,6 +753,9 @@ def register_options_routes(app: Flask, cfg: dict[str, Any]) -> None: "available_usdc": available_usdc, "budget_full_usdc": budget if mode == "budget_full" else None, "compound_full_usdc": budget if mode == "compound_full" else None, + "mode": mode, + "mode_note": mode_note, + "compound_full_enabled": _compound_full_enabled(), } ) @@ -752,8 +787,12 @@ def register_options_routes(app: Flask, cfg: dict[str, Any]) -> None: return jsonify({"ok": False, "msg": f"互斥校验失败: {e}"}) data = request.get_json(silent=True) or {} inst_id = (data.get("inst_id") or "").strip() - mode = (data.get("mode") or "budget_full").strip() + mode = (data.get("mode") or "sheets").strip() + mode, mode_note = _normalize_size_mode(mode) signal_note = (data.get("signal_note") or "").strip() + if mode_note and mode == "sheets" and (data.get("mode") or "").strip() == "compound_full": + # 前端残留全仓复利选中时,已自动改指定张数;继续开仓 + pass target_index = None raw_target = data.get("target_index") if raw_target is not None and str(raw_target).strip() != "": @@ -819,20 +858,32 @@ def register_options_routes(app: Flask, cfg: dict[str, Any]) -> None: try: sheet_count = int(data.get("sheets")) except (TypeError, ValueError): - return jsonify({"ok": False, "msg": "张数无效"}) + sheet_count = None + if sheet_count is None or int(sheet_count) < 1: + # 全仓复利关闭后前端可能仍带着旧 mode 过来,归一后缺张数则默认 1 + if (data.get("mode") or "").strip() == "compound_full": + sheet_count = 1 + else: + return jsonify({"ok": False, "msg": "张数无效"}) budget = cfg["trade_budget"] budget_cap = cfg["trade_budget"] if mode == "budget_full": blocked = _budget_full_blocked_by_compound_msg() if blocked: - return jsonify({"ok": False, "msg": blocked}) + return jsonify({"ok": False, "msg": blocked, "compound_full_enabled": _compound_full_enabled()}) budget, budget_err = _budget_full_usdc(cfg, ex) if budget is None: return jsonify({"ok": False, "msg": budget_err}) budget_cap = budget elif mode == "compound_full": if not _compound_full_enabled(): - return jsonify({"ok": False, "msg": "全仓复利未开启(OKX_OPTIONS_COMPOUND_FULL_ENABLED)"}) + return jsonify( + { + "ok": False, + "msg": "全仓复利未开启,请改用指定张数或先开启全仓复利", + "compound_full_enabled": False, + } + ) budget, budget_err = _compound_full_usdc(cfg, ex) if budget is None: return jsonify({"ok": False, "msg": budget_err}) diff --git a/lib/options/templates/options_panel.html b/lib/options/templates/options_panel.html index fffcd1d..29b2422 100644 --- a/lib/options/templates/options_panel.html +++ b/lib/options/templates/options_panel.html @@ -350,4 +350,4 @@ - + diff --git a/tests/test_options_budget_full.py b/tests/test_options_budget_full.py index 6f5f32e..21967f0 100644 --- a/tests/test_options_budget_full.py +++ b/tests/test_options_budget_full.py @@ -64,6 +64,35 @@ class TestOptionsBudgetModes(unittest.TestCase): self.assertIsNone(msg) self.assertEqual(count_live_option_positions([]), 0) + def test_normalize_size_mode_when_compound_off(self): + import os + from unittest.mock import patch + + from lib.options import options_register as reg + + with patch.dict(os.environ, {"OKX_OPTIONS_COMPOUND_FULL_ENABLED": "false"}): + mode, note = reg._normalize_size_mode("compound_full") + self.assertEqual(mode, "sheets") + self.assertIsNotNone(note) + mode2, note2 = reg._normalize_size_mode("budget_full") + self.assertEqual(mode2, "budget_full") + self.assertIsNone(note2) + mode3, _ = reg._normalize_size_mode("sheets") + self.assertEqual(mode3, "sheets") + + def test_normalize_size_mode_when_compound_on(self): + import os + from unittest.mock import patch + + from lib.options import options_register as reg + + with patch.dict(os.environ, {"OKX_OPTIONS_COMPOUND_FULL_ENABLED": "true"}): + mode, note = reg._normalize_size_mode("budget_full") + self.assertEqual(mode, "compound_full") + self.assertIsNone(note) + mode2, _ = reg._normalize_size_mode("compound_full") + self.assertEqual(mode2, "compound_full") + if __name__ == "__main__": unittest.main()