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()