diff --git a/lib/common/static/instance_theme.css b/lib/common/static/instance_theme.css index 26ba58c..c10a42e 100644 --- a/lib/common/static/instance_theme.css +++ b/lib/common/static/instance_theme.css @@ -3330,6 +3330,9 @@ html[data-theme="light"] .options-estimate-row { .opt-error { color: #ff6b6b; } +.opt-success { + color: #3ecf8e; +} .opt-row-actions { white-space: nowrap; } diff --git a/lib/common/static/options_settings.js b/lib/common/static/options_settings.js index 5464b88..2ba297f 100644 --- a/lib/common/static/options_settings.js +++ b/lib/common/static/options_settings.js @@ -4,6 +4,10 @@ const root = document.getElementById("options-settings-root"); if (!root) return; + const SWAP_BTNS = ["opt-set-swap-btn", "opt-set-swap-all-btn"]; + const INT_BTNS = ["opt-set-int-btn", "opt-set-int-all-btn"]; + const CROSS_BTNS = ["opt-set-cross-btn", "opt-set-cross-all-btn"]; + async function apiJson(url, opts) { const r = await fetch(url, Object.assign({ credentials: "same-origin" }, opts || {})); return r.json(); @@ -22,16 +26,66 @@ if (!el) return; el.textContent = text || ""; el.classList.toggle("opt-error", !!isErr); + el.classList.toggle("opt-success", !!text && !isErr); } - function floorAmount(v) { + function fmtAmt(amount, ccy) { + return `${Number(amount).toFixed(2)} ${ccy}`; + } + + function accountLabel(acct) { + return acct === "trading" ? "交易账户" : "资金账户"; + } + + function swapDirLabel(dir) { + return dir === "usdc_to_usdt" ? "USDC → USDT" : "USDT → USDC"; + } + + function confirmOk(message) { + return window.confirm(message); + } + + function setButtonsBusy(btnIds, busy, busyText) { + btnIds.forEach(function (id) { + const btn = document.getElementById(id); + if (!btn) return; + if (busy) { + if (!btn.dataset.origText) btn.dataset.origText = btn.textContent; + btn.disabled = true; + if (busyText) btn.textContent = busyText; + } else { + btn.disabled = false; + if (btn.dataset.origText) { + btn.textContent = btn.dataset.origText; + delete btn.dataset.origText; + } + } + }); + const amountIds = { + "opt-set-swap-btn": "opt-set-swap-amount", + "opt-set-swap-all-btn": "opt-set-swap-amount", + "opt-set-int-btn": "opt-set-int-amount", + "opt-set-int-all-btn": "opt-set-int-amount", + "opt-set-cross-btn": "opt-set-cross-amount", + "opt-set-cross-all-btn": "opt-set-cross-amount", + }; + btnIds.forEach(function (id) { + const input = document.getElementById(amountIds[id]); + if (input) input.disabled = busy; + }); + } + + function roundAvail(v) { const n = Number(v); if (!Number.isFinite(n) || n <= 0) return null; - return Math.floor(n * 100) / 100; + return Math.round(n * 100) / 100; } - async function loadBalances(force) { - const q = force ? "?force=1" : ""; + async function loadBalances(force, scope) { + const parts = []; + if (force) parts.push("force=1"); + if (scope && scope !== "main") parts.push("scope=" + encodeURIComponent(scope)); + const q = parts.length ? "?" + parts.join("&") : ""; const d = await apiJson("/api/options/balances" + q); if (!d.ok) throw new Error(d.msg || "余额拉取失败"); return d; @@ -39,26 +93,42 @@ function pickBalance(bal, account, ccy) { const acct = account === "trading" ? "trading" : "funding"; - const key = acct + "_" + String(ccy || "").toLowerCase(); - return floorAmount(bal[key]); + const c = String(ccy || "").toLowerCase(); + const availKey = acct + "_" + c + "_avail"; + const totalKey = acct + "_" + c; + return roundAvail(bal[availKey] != null ? bal[availKey] : bal[totalKey]); } - async function resolveMaxAmount(account, ccy) { - const bal = await loadBalances(true); + async function resolveMaxAmount(account, ccy, scope) { + const bal = await loadBalances(true, scope || "main"); return pickBalance(bal, account, ccy); } async function submitSwap(amount) { - const d = await apiJson("/api/options/spot/swap", { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ - direction: document.getElementById("opt-set-swap-dir").value, - amount: amount, - }), - }); - setMsg("opt-set-swap-msg", d.ok ? "兑换单已提交" : (d.msg || "失败"), !d.ok); - if (d.ok && typeof refreshAccountSnapshot === "function") refreshFundsAfterMutation(); + setButtonsBusy(SWAP_BTNS, true, "兑换中…"); + setMsg("opt-set-swap-msg", "兑换中,市价成交可能有延时…", false); + try { + const d = await apiJson("/api/options/spot/swap", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + direction: document.getElementById("opt-set-swap-dir").value, + amount: amount, + }), + }); + if (d.ok) { + setMsg("opt-set-swap-msg", "兑换成功", false); + refreshFundsAfterMutation(); + } else { + setMsg("opt-set-swap-msg", "兑换失败:" + (d.msg || "未知错误"), true); + } + return d; + } catch (e) { + setMsg("opt-set-swap-msg", "兑换失败:" + (e.message || "网络错误"), true); + return { ok: false }; + } finally { + setButtonsBusy(SWAP_BTNS, false); + } } const swapBtn = document.getElementById("opt-set-swap-btn"); @@ -79,32 +149,52 @@ try { const dir = document.getElementById("opt-set-swap-dir").value; const ccy = dir === "usdc_to_usdt" ? "USDC" : "USDT"; - const amount = await resolveMaxAmount("funding", ccy); + const amount = await resolveMaxAmount("funding", ccy, "main"); if (!amount) { setMsg("opt-set-swap-msg", "资金账户可用余额不足", true); return; } + const msg = + "确认全部兑换?\n\n" + + "方向:" + swapDirLabel(dir) + "\n" + + "金额:" + fmtAmt(amount, ccy) + "\n\n" + + "将兑换资金账户全部可用余额。市价成交可能有延时,请确认。"; + if (!confirmOk(msg)) return; document.getElementById("opt-set-swap-amount").value = String(amount); await submitSwap(amount); } catch (e) { - setMsg("opt-set-swap-msg", e.message || "余额拉取失败", true); + setMsg("opt-set-swap-msg", "兑换失败:" + (e.message || "余额拉取失败"), true); } }); } async function submitInternalTransfer(amount) { - const d = await apiJson("/api/options/transfer", { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ - ccy: document.getElementById("opt-set-int-ccy").value, - from: document.getElementById("opt-set-int-from").value, - to: document.getElementById("opt-set-int-to").value, - amount: amount, - }), - }); - setMsg("opt-set-int-msg", d.ok ? "划转成功" : (d.msg || "失败"), !d.ok); - if (d.ok && typeof refreshAccountSnapshot === "function") refreshFundsAfterMutation(); + setButtonsBusy(INT_BTNS, true, "划转中…"); + setMsg("opt-set-int-msg", "划转中…", false); + try { + const d = await apiJson("/api/options/transfer", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + ccy: document.getElementById("opt-set-int-ccy").value, + from: document.getElementById("opt-set-int-from").value, + to: document.getElementById("opt-set-int-to").value, + amount: amount, + }), + }); + if (d.ok) { + setMsg("opt-set-int-msg", "划转成功", false); + refreshFundsAfterMutation(); + } else { + setMsg("opt-set-int-msg", "划转失败:" + (d.msg || "未知错误"), true); + } + return d; + } catch (e) { + setMsg("opt-set-int-msg", "划转失败:" + (e.message || "网络错误"), true); + return { ok: false }; + } finally { + setButtonsBusy(INT_BTNS, false); + } } const intBtn = document.getElementById("opt-set-int-btn"); @@ -125,33 +215,56 @@ try { const ccy = document.getElementById("opt-set-int-ccy").value; const from = document.getElementById("opt-set-int-from").value; - const amount = await resolveMaxAmount(from, ccy); + const to = document.getElementById("opt-set-int-to").value; + const amount = await resolveMaxAmount(from, ccy, "main"); if (!amount) { setMsg("opt-set-int-msg", "划出账户可用余额不足", true); return; } + const msg = + "确认全部划转?\n\n" + + "币种:" + ccy + "\n" + + "划出:" + accountLabel(from) + "\n" + + "划入:" + accountLabel(to) + "\n" + + "金额:" + fmtAmt(amount, ccy) + "\n\n" + + "将划转该账户全部可用余额。"; + if (!confirmOk(msg)) return; document.getElementById("opt-set-int-amount").value = String(amount); await submitInternalTransfer(amount); } catch (e) { - setMsg("opt-set-int-msg", e.message || "余额拉取失败", true); + setMsg("opt-set-int-msg", "划转失败:" + (e.message || "余额拉取失败"), true); } }); } async function submitCrossTransfer(amount) { - const d = await apiJson("/api/options/cross-transfer", { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ - ccy: document.getElementById("opt-set-cross-ccy").value, - amount: amount, - from_account: document.getElementById("opt-set-cross-from").value, - to_account: document.getElementById("opt-set-cross-to").value, - direction: document.getElementById("opt-set-cross-dir").value, - }), - }); - setMsg("opt-set-cross-msg", d.ok ? "划转成功" : (d.msg || "失败"), !d.ok); - if (d.ok && typeof refreshAccountSnapshot === "function") refreshFundsAfterMutation(); + setButtonsBusy(CROSS_BTNS, true, "划转中…"); + setMsg("opt-set-cross-msg", "划转中…", false); + try { + const d = await apiJson("/api/options/cross-transfer", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + ccy: document.getElementById("opt-set-cross-ccy").value, + amount: amount, + from_account: document.getElementById("opt-set-cross-from").value, + to_account: document.getElementById("opt-set-cross-to").value, + direction: document.getElementById("opt-set-cross-dir").value, + }), + }); + if (d.ok) { + setMsg("opt-set-cross-msg", "划转成功", false); + refreshFundsAfterMutation(); + } else { + setMsg("opt-set-cross-msg", "划转失败:" + (d.msg || "未知错误"), true); + } + return d; + } catch (e) { + setMsg("opt-set-cross-msg", "划转失败:" + (e.message || "网络错误"), true); + return { ok: false }; + } finally { + setButtonsBusy(CROSS_BTNS, false); + } } const crossBtn = document.getElementById("opt-set-cross-btn"); @@ -172,15 +285,28 @@ try { const ccy = document.getElementById("opt-set-cross-ccy").value; const from = document.getElementById("opt-set-cross-from").value; - const amount = await resolveMaxAmount(from, ccy); + const to = document.getElementById("opt-set-cross-to").value; + const direction = document.getElementById("opt-set-cross-dir").value; + const scope = direction === "sub_to_main" ? "sub" : "main"; + const sideLabel = direction === "sub_to_main" ? "子账户" : "主账户"; + const amount = await resolveMaxAmount(from, ccy, scope); if (!amount) { - setMsg("opt-set-cross-msg", "划出账户可用余额不足", true); + setMsg("opt-set-cross-msg", sideLabel + "划出账户可用余额不足", true); return; } + const msg = + "确认全部划转?\n\n" + + "方向:" + (direction === "main_to_sub" ? "主 → 子" : "子 → 主") + "\n" + + "币种:" + ccy + "\n" + + "划出:" + sideLabel + " · " + accountLabel(from) + "\n" + + "划入:" + (direction === "main_to_sub" ? "子账户" : "主账户") + " · " + accountLabel(to) + "\n" + + "金额:" + fmtAmt(amount, ccy) + "\n\n" + + "将划转该账户全部可用余额。"; + if (!confirmOk(msg)) return; document.getElementById("opt-set-cross-amount").value = String(amount); await submitCrossTransfer(amount); } catch (e) { - setMsg("opt-set-cross-msg", e.message || "余额拉取失败", true); + setMsg("opt-set-cross-msg", "划转失败:" + (e.message || "余额拉取失败"), true); } }); } diff --git a/lib/exchange/okx_options_lib.py b/lib/exchange/okx_options_lib.py index 9de50d7..ecc9fae 100644 --- a/lib/exchange/okx_options_lib.py +++ b/lib/exchange/okx_options_lib.py @@ -384,6 +384,21 @@ def fetch_option_instrument_meta(ex: ccxt.okx, inst_id: str) -> dict[str, Any] | return None +def _extract_ccy_free(balance: dict[str, Any], ccy: str) -> float | None: + ccy = (ccy or "").upper() + if not isinstance(balance, dict): + return None + info = balance.get(ccy) + if isinstance(info, dict): + v = _safe_float(info.get("free")) + if v is not None: + return v + free_map = balance.get("free") or {} + if isinstance(free_map, dict): + return _safe_float(free_map.get(ccy)) + return None + + def _extract_ccy_balance(balance: dict[str, Any], ccy: str) -> float | None: ccy = (ccy or "").upper() if not isinstance(balance, dict): @@ -407,40 +422,91 @@ def _extract_ccy_balance(balance: dict[str, Any], ccy: str) -> float | None: return None -def fetch_account_balances_by_type(ex: ccxt.okx, account_type: str) -> dict[str, float | None]: +def fetch_account_balances_by_type( + ex: ccxt.okx, + account_type: str, +) -> tuple[dict[str, float | None], dict[str, float | None]]: out: dict[str, float | None] = {"USDT": None, "USDC": None, "USDG": None} + avail: dict[str, float | None] = {"USDT": None, "USDC": None, "USDG": None} try: bal = ex.fetch_balance(params={"type": account_type}) for c in out: out[c] = _extract_ccy_balance(bal, c) + avail[c] = _extract_ccy_free(bal, c) + except Exception: + pass + return out, avail + + +def fetch_subaccount_asset_balances(ex: ccxt.okx, sub_acct: str) -> dict[str, float | None]: + """子账户各币种可用余额(主/子划转「全部」用).""" + sub = (sub_acct or "").strip() + out: dict[str, float | None] = {"USDT": None, "USDC": None} + if not sub: + return out + try: + resp = ex.private_get_asset_subaccount_balances({"subAcct": sub}) + for row in (resp or {}).get("data") or []: + if not isinstance(row, dict): + continue + ccy = str(row.get("ccy") or "").upper() + if ccy not in out: + continue + out[ccy] = _safe_float(row.get("availBal")) or _safe_float(row.get("bal")) except Exception: pass return out -def fetch_options_balances(ex: ccxt.okx, *, force: bool = False) -> dict[str, Any]: +def fetch_options_balances( + ex: ccxt.okx, + *, + force: bool = False, + scope: str = "main", + sub_acct: str = "", +) -> dict[str, Any]: import os + if (scope or "").strip().lower() == "sub": + sub_bal = fetch_subaccount_asset_balances(ex, sub_acct) + return { + "scope": "sub", + "funding_usdt": sub_bal.get("USDT"), + "funding_usdc": sub_bal.get("USDC"), + "funding_usdt_avail": sub_bal.get("USDT"), + "funding_usdc_avail": sub_bal.get("USDC"), + "trading_usdt": sub_bal.get("USDT"), + "trading_usdc": sub_bal.get("USDC"), + "trading_usdt_avail": sub_bal.get("USDT"), + "trading_usdc_avail": sub_bal.get("USDC"), + } + ttl = float(os.getenv("OKX_OPTIONS_BALANCE_REFRESH_SEC", "30")) now = time.time() cached = _OPTIONS_BALANCE_CACHE.get("data") if not force and cached is not None and now - float(_OPTIONS_BALANCE_CACHE.get("updated_at") or 0) < ttl: return dict(cached) - funding = fetch_account_balances_by_type(ex, "funding") - trading = fetch_account_balances_by_type(ex, "trading") - # 统一账户部分 USDC 可能在 swap 类型 + funding, funding_avail = fetch_account_balances_by_type(ex, "funding") + trading, trading_avail = fetch_account_balances_by_type(ex, "trading") if trading.get("USDC") is None: - swap_bal = fetch_account_balances_by_type(ex, "swap") + swap_bal, swap_avail = fetch_account_balances_by_type(ex, "swap") if swap_bal.get("USDC") is not None: trading["USDC"] = swap_bal["USDC"] + if trading_avail.get("USDC") is None and swap_avail.get("USDC") is not None: + trading_avail["USDC"] = swap_avail["USDC"] result = { + "scope": "main", "funding_usdt": funding.get("USDT"), "funding_usdc": funding.get("USDC"), "funding_usdg": funding.get("USDG"), + "funding_usdt_avail": funding_avail.get("USDT"), + "funding_usdc_avail": funding_avail.get("USDC"), "trading_usdt": trading.get("USDT"), "trading_usdc": trading.get("USDC"), "trading_usdg": trading.get("USDG"), + "trading_usdt_avail": trading_avail.get("USDT"), + "trading_usdc_avail": trading_avail.get("USDC"), } _OPTIONS_BALANCE_CACHE["updated_at"] = now _OPTIONS_BALANCE_CACHE["data"] = result diff --git a/lib/instance/templates/embed_shell.html b/lib/instance/templates/embed_shell.html index c88b071..d83cc75 100644 --- a/lib/instance/templates/embed_shell.html +++ b/lib/instance/templates/embed_shell.html @@ -7,7 +7,7 @@ - +