diff --git a/docs/binance-orderbook-trade-development.md b/docs/binance-orderbook-trade-development.md index cbe3d34..1ee8528 100644 --- a/docs/binance-orderbook-trade-development.md +++ b/docs/binance-orderbook-trade-development.md @@ -153,6 +153,12 @@ Every Binance UI automation change must report the evidence used: live DOM or st Do not auto-confirm destructive Binance dialogs. The script may open Binance's native cancel confirmation, but final confirmation remains manual. +USDT account rebalancing is a manual action, not an automatic post-close side effect. The inline `再平衡` action becomes available only after the account-order widget has continuously reported zero positions and zero open orders for three seconds, and a fresh all-symbol futures-position response also confirms that every position is flat. Starting any script trade or cancel task invalidates that eligibility immediately. + +The rebalance path is pinned to the current Binance page bundle contract instead of automating the native transfer dialog. It reads Spot and Funding available USDT from `/bapi/asset/v2/private/asset-service/wallet/balance`, reads the U-M Futures transferable amount from `/bapi/futures/v1/private/future/user-data/getMaxWithdrawAmount`, and submits at most two sequential transfers through `/bapi/asset/v1/private/asset-service/wallet/transfer` with `{ asset, amount, kindType }`. The private transfer BAPI uses the page bundle's `MAIN`, `CARD`, and `FUTURE` account codes; do not substitute the public SAPI names `FUNDING` or `UMFUTURE`. It does not save an API key and does not fall back to DOM clicking if this private BAPI contract changes. + +The user must approve one native confirmation that shows the current balances, 5:4:1 targets, and exact transfer list. After confirmation, the script rechecks all positions, all open orders, and the exact three-account balance snapshot before every transfer. Each successful response must then be reflected by a fresh balance read before the next transfer starts. An intervening position, order, or balance change stops the task; a partial completion is reported explicitly and is never retried or rolled back automatically. + Ladder replacement must stay scoped and direction-aware. Automatic replacement may cancel only visible basic open-order rows for the current symbol and the same plan direction (`开多`, `开空`, `平多`, or `平空`). It must not use current-symbol cancel-all for ladder replacement, must not touch conditional/protection orders, and must retry the ladder plan only after the replacement path is validated by current DOM rows. User-facing failures must preserve the observed reason instead of collapsing multiple states into one generic message. In particular, an unread openable quantity, a confirmed zero available balance, and a Binance-calculated openable quantity of zero are separate outcomes. Use the panel term `价格精度` consistently instead of exposing the internal `缩放值` name. Cancellation feedback should describe the user-visible action and result, not implementation details such as row-by-row processing, observer roots, context loss, or temporary chart-state manipulation. Combined `A or B` failures are allowed only when the code cannot distinguish the causes; when each branch is already known, report that branch directly. diff --git a/scripts/binance-orderbook-trade.user.js b/scripts/binance-orderbook-trade.user.js index 289de4c..418fbb7 100644 --- a/scripts/binance-orderbook-trade.user.js +++ b/scripts/binance-orderbook-trade.user.js @@ -3,7 +3,7 @@ // @namespace binance.orderbook.trade // @icon data:image/svg+xml,%3Csvg%20xmlns%3D%22http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg%22%20viewBox%3D%220%200%2064%2064%22%3E%3Crect%20width%3D%2264%22%20height%3D%2264%22%20rx%3D%2214%22%20fill%3D%22%23f0b90b%22%2F%3E%3Ctext%20x%3D%2232%22%20y%3D%2249%22%20text-anchor%3D%22middle%22%20font-family%3D%22Arial%2C%20sans-serif%22%20font-size%3D%2242%22%20font-weight%3D%22800%22%20fill%3D%22%23111827%22%3EJ%3C%2Ftext%3E%3C%2Fsvg%3E // @icon64 data:image/svg+xml,%3Csvg%20xmlns%3D%22http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg%22%20viewBox%3D%220%200%2064%2064%22%3E%3Crect%20width%3D%2264%22%20height%3D%2264%22%20rx%3D%2214%22%20fill%3D%22%23f0b90b%22%2F%3E%3Ctext%20x%3D%2232%22%20y%3D%2249%22%20text-anchor%3D%22middle%22%20font-family%3D%22Arial%2C%20sans-serif%22%20font-size%3D%2242%22%20font-weight%3D%22800%22%20fill%3D%22%23111827%22%3EJ%3C%2Ftext%3E%3C%2Fsvg%3E -// @version 2.7.164 +// @version 2.7.165 // @author jackhai9 // @description 单击订单簿价格,按当前开仓/平仓 tab 自动填数量并执行下单,内置数量倍率面板 // @match https://www.binance.com/*/futures/* @@ -560,6 +560,193 @@ return !!parsed && parsed.digits > 0n; } + // src/binance-orderbook-trade/core/usdt-rebalance.js + var USDT_SCALE = 8; + var ACCOUNT_ORDER = ["FUNDING", "MAIN", "UMFUTURE"]; + var USDT_REBALANCE_ACCOUNTS = Object.freeze({ + FUNDING: Object.freeze({ + walletName: "Funding", + bapiCode: "CARD", + label: "资金", + ratio: 50 + }), + MAIN: Object.freeze({ + walletName: "Spot", + bapiCode: "MAIN", + label: "现货", + ratio: 40 + }), + UMFUTURE: Object.freeze({ + walletName: "USDⓈ-M Futures", + bapiCode: "FUTURE", + label: "U本位合约", + ratio: 10 + }) + }); + function parsePositionAmount2(value) { + if (typeof value === "number" && Number.isFinite(value)) return value; + if (typeof value === "string" && /^-?(?:\d+\.?\d*|\.\d+)$/.test(value.trim())) { + return Number(value); + } + throw new Error(`持仓数量无效:${String(value)}`); + } + function requireDecimal(value, message) { + const normalized = normalizeDecimalString(value); + if (normalized == null) throw new Error(message); + return normalized; + } + function decimalToUnits(value) { + const normalized = requireDecimal(value, `USDT 余额无效:${String(value)}`); + const parsed = parseDecimalString(normalized); + if (parsed.scale > USDT_SCALE) throw new Error("USDT 余额精度超过 8 位"); + return parsed.digits * 10n ** BigInt(USDT_SCALE - parsed.scale); + } + function unitsToDecimal(units) { + return formatDecimalParts(units, USDT_SCALE); + } + function isZeroDecimal(value) { + return decimalToUnits(value) === 0n; + } + function resolveAllFuturesPositionStatus(payload) { + if (payload?.success !== true) throw new Error(payload?.message || "持仓接口返回失败"); + if (!Array.isArray(payload.data)) throw new Error("持仓接口数据格式异常"); + let positionCount = 0; + for (const position of payload.data) { + if (!position || typeof position.symbol !== "string" || !position.symbol) { + throw new Error("持仓接口缺少交易对"); + } + if (parsePositionAmount2(position.positionAmount) !== 0) positionCount += 1; + } + return { + status: positionCount === 0 ? "flat" : "has_position", + positionCount + }; + } + function readWalletUsdtBalance(wallet, account) { + if (wallet.activate !== true) throw new Error(`${account.label}账户未启用`); + if (!Array.isArray(wallet.assetBalances)) { + throw new Error(`${account.label}账户余额格式异常`); + } + const matches = wallet.assetBalances.filter((asset) => asset?.asset === "USDT"); + if (matches.length > 1) throw new Error(`${account.label}账户存在重复的 USDT 余额`); + if (matches.length === 0) return "0"; + const balance = matches[0]; + const free = requireDecimal(balance.free, `${account.label}账户 USDT 可用余额无效`); + const locked = requireDecimal(balance.locked, `${account.label}账户 USDT 锁定余额无效`); + const freeze = requireDecimal(balance.freeze, `${account.label}账户 USDT 冻结余额无效`); + const withdrawing = requireDecimal( + balance.withdrawing, + `${account.label}账户 USDT 划出中余额无效` + ); + if (![locked, freeze, withdrawing].every(isZeroDecimal)) { + throw new Error(`${account.label}账户仍有不可划转 USDT`); + } + return free; + } + function parseUsdtWalletBalances(payload) { + if (payload?.success !== true) throw new Error(payload?.message || "钱包余额接口返回失败"); + if (!Array.isArray(payload.data)) throw new Error("钱包余额接口数据格式异常"); + const balances = {}; + for (const accountCode of ACCOUNT_ORDER) { + const account = USDT_REBALANCE_ACCOUNTS[accountCode]; + const matches = payload.data.filter((wallet) => wallet?.walletName === account.walletName); + if (matches.length === 0) throw new Error(`钱包余额缺少 ${account.label}账户`); + if (matches.length > 1) throw new Error(`钱包余额存在重复的${account.label}账户`); + const free = readWalletUsdtBalance(matches[0], account); + balances[accountCode] = accountCode === "UMFUTURE" ? null : free; + } + return balances; + } + function withFuturesTransferableBalance(balances, payload) { + if (payload?.success !== true) { + throw new Error(`U本位可划转余额读取失败:${payload?.message || "未知错误"}`); + } + const transferable = requireDecimal(payload.data, "U本位可划转余额无效"); + return { + FUNDING: balances.FUNDING, + MAIN: balances.MAIN, + UMFUTURE: transferable + }; + } + function buildUsdtRebalancePlan(rawBalances) { + const beforeUnits = Object.fromEntries( + ACCOUNT_ORDER.map((accountCode) => [accountCode, decimalToUnits(rawBalances[accountCode])]) + ); + const totalUnits = ACCOUNT_ORDER.reduce((sum, accountCode) => sum + beforeUnits[accountCode], 0n); + const targetUnits = { + FUNDING: totalUnits * 50n / 100n, + MAIN: totalUnits * 40n / 100n, + UMFUTURE: 0n + }; + targetUnits.UMFUTURE = totalUnits - targetUnits.FUNDING - targetUnits.MAIN; + const donors = ACCOUNT_ORDER.filter((accountCode) => beforeUnits[accountCode] > targetUnits[accountCode]).map((accountCode) => ({ + accountCode, + remaining: beforeUnits[accountCode] - targetUnits[accountCode] + })); + const recipients = ACCOUNT_ORDER.filter((accountCode) => beforeUnits[accountCode] < targetUnits[accountCode]).map((accountCode) => ({ + accountCode, + remaining: targetUnits[accountCode] - beforeUnits[accountCode] + })); + const transfers = []; + let donorIndex = 0; + let recipientIndex = 0; + while (donorIndex < donors.length && recipientIndex < recipients.length) { + const donor = donors[donorIndex]; + const recipient = recipients[recipientIndex]; + const amount = donor.remaining < recipient.remaining ? donor.remaining : recipient.remaining; + if (amount <= 0n) throw new Error("USDT 再平衡计划出现非正划转金额"); + transfers.push({ + from: donor.accountCode, + to: recipient.accountCode, + kindType: [ + USDT_REBALANCE_ACCOUNTS[donor.accountCode].bapiCode, + USDT_REBALANCE_ACCOUNTS[recipient.accountCode].bapiCode + ].join("_"), + amount: unitsToDecimal(amount) + }); + donor.remaining -= amount; + recipient.remaining -= amount; + if (donor.remaining === 0n) donorIndex += 1; + if (recipient.remaining === 0n) recipientIndex += 1; + } + if (donors.some((donor) => donor.remaining !== 0n) || recipients.some((recipient) => recipient.remaining !== 0n)) { + throw new Error("USDT 再平衡计划未闭合"); + } + if (transfers.length > 2) throw new Error("USDT 再平衡计划超过两笔划转"); + return { + total: unitsToDecimal(totalUnits), + before: Object.fromEntries( + ACCOUNT_ORDER.map((accountCode) => [accountCode, unitsToDecimal(beforeUnits[accountCode])]) + ), + targets: Object.fromEntries( + ACCOUNT_ORDER.map((accountCode) => [accountCode, unitsToDecimal(targetUnits[accountCode])]) + ), + transfers + }; + } + function applyUsdtTransferToBalances(rawBalances, transfer) { + if (!ACCOUNT_ORDER.includes(transfer?.from) || !ACCOUNT_ORDER.includes(transfer?.to)) { + throw new Error("USDT 划转账户无效"); + } + if (transfer.from === transfer.to) throw new Error("USDT 划转账户不能相同"); + const balances = Object.fromEntries( + ACCOUNT_ORDER.map((accountCode) => [accountCode, decimalToUnits(rawBalances[accountCode])]) + ); + const amount = decimalToUnits(transfer.amount); + if (amount <= 0n) throw new Error("USDT 划转金额必须大于 0"); + if (balances[transfer.from] < amount) throw new Error("USDT 划出账户余额不足"); + balances[transfer.from] -= amount; + balances[transfer.to] += amount; + return Object.fromEntries( + ACCOUNT_ORDER.map((accountCode) => [accountCode, unitsToDecimal(balances[accountCode])]) + ); + } + function areUsdtBalancesEqual(left, right) { + return ACCOUNT_ORDER.every( + (accountCode) => decimalToUnits(left?.[accountCode]) === decimalToUnits(right?.[accountCode]) + ); + } + // src/binance-orderbook-trade/core/precision.js function getOrderbookPrecisionShortcutOptions(options, limit = 4) { const normalizedLimit = Number(limit); @@ -2709,6 +2896,8 @@ const SIDE_SHORT_ID = "jh-binance-close-side-short"; const LADDER_BODY_ID = "jh-binance-ladder-body"; const LADDER_STATUS_ID = "jh-binance-ladder-status"; + const LADDER_STATUS_ROW_ID = "jh-binance-ladder-status-row"; + const USDT_REBALANCE_ACTION_ID = "jh-binance-usdt-rebalance-action"; const MULTIPLIER_PRESS_FEEDBACK_ATTR = "data-jh-press-feedback"; const ORDERBOOK_PRECISION_RECOMMENDATION_ID = "jh-binance-orderbook-precision-recommendation"; const DEFAULT_MULTIPLIER = "1"; @@ -2766,6 +2955,12 @@ const LADDER_REPRICE_DELAY_MS = 180; const BINANCE_PLACE_ORDER_BAPI_PATH = "/bapi/futures/v1/private/future/order/place-order"; const BINANCE_USER_POSITION_BAPI_PATH = "/bapi/futures/v6/private/future/user-data/user-position"; + const BINANCE_WALLET_BALANCE_BAPI_PATH = "/bapi/asset/v2/private/asset-service/wallet/balance?needBalanceDetail=true"eAsset=USDT"; + const BINANCE_FUTURES_MAX_WITHDRAW_BAPI_PATH = "/bapi/futures/v1/private/future/user-data/getMaxWithdrawAmount"; + const BINANCE_WALLET_TRANSFER_BAPI_PATH = "/bapi/asset/v1/private/asset-service/wallet/transfer"; + const USDT_REBALANCE_FLAT_STABLE_MS = 3e3; + const USDT_REBALANCE_REQUEST_TIMEOUT_MS = 5e3; + const USDT_REBALANCE_BALANCE_POLL_MS = 1e3; const LADDER_OPEN_QTY_READY_TIMEOUT_MS = 1200; const TRADE_INPUT_SYNC_TIMEOUT_MS = 350; const TRADE_INPUT_SYNC_STABLE_FRAMES = 2; @@ -2835,6 +3030,7 @@ let accountPositionObserver = null; let accountPositionObserverRoot = null; let lastObservedAccountPositionCount = null; + let lastObservedAccountOpenOrdersCount = null; let lastObservedAccountPositionState = null; let lastConfirmedCloseState = null; let lastDisplayCloseState = null; @@ -2866,6 +3062,11 @@ let ladderStopRequested = false; let ladderStatusText = "空闲"; let ladderStatusTitle = "空闲"; + let usdtRebalanceEligibilityTimer = 0; + let usdtRebalanceEligibilityEpoch = 0; + let usdtRebalanceEligibilityTask = null; + let usdtRebalanceEligible = false; + let usdtRebalanceTask = null; let ladderPanelBodySignature = ""; let panelPositionInvalidated = true; let panelObservedSize = ""; @@ -4978,6 +5179,7 @@ } setLadderStatus(singleStatus); }; + invalidateUsdtRebalanceEligibility(); ladderAbortController = abortController; activeLadderActionType = actionType; if (continuousSession) activeContinuousLadderRoundProgress = progress; @@ -6578,6 +6780,7 @@ } clearCancelNoOrdersFeedback(); cancelCurrentSymbolOpenOrdersBlocksLadderActions = false; + invalidateUsdtRebalanceEligibility(); const task = runCancelCurrentSymbolOpenOrders(options); cancelCurrentSymbolOpenOrdersTask = task; scheduleRenderPanel(); @@ -7105,6 +7308,156 @@ window.clearTimeout(timer); } } + async function fetchUsdtRebalanceBapi(path, options = {}) { + if (!cachedBncHeaders) throw new Error("币安请求头尚未就绪"); + const method = options.method || "GET"; + const controller = new AbortController(); + const timer = window.setTimeout( + () => controller.abort(), + USDT_REBALANCE_REQUEST_TIMEOUT_MS + ); + try { + const request = { + method, + headers: getBncHeaders(), + credentials: "include", + signal: controller.signal + }; + if (Object.hasOwn(options, "body")) request.body = JSON.stringify(options.body); + const response = await fetch(`${window.location.origin}${path}`, request); + if (response.status === 401) throw new Error("Binance 登录态已失效"); + if (!response.ok) throw new Error(`${options.label || "Binance 接口"}异常:HTTP ${response.status}`); + return await response.json(); + } finally { + window.clearTimeout(timer); + } + } + async function readCurrentUsdtRebalanceBalances() { + const [walletPayload, futuresPayload] = await Promise.all([ + fetchUsdtRebalanceBapi(BINANCE_WALLET_BALANCE_BAPI_PATH, { + label: "钱包余额接口" + }), + fetchUsdtRebalanceBapi(BINANCE_FUTURES_MAX_WITHDRAW_BAPI_PATH, { + method: "POST", + body: { assetName: "USDT" }, + label: "U本位可划转余额接口" + }) + ]); + return withFuturesTransferableBalance( + parseUsdtWalletBalances(walletPayload), + futuresPayload + ); + } + async function assertUsdtRebalanceTradingState() { + if (!isFuturesTradingPage() || document.hidden) throw new Error("当前不在可操作的合约页面"); + if (ladderTask || continuousLadderTask || singleOrderTask || cancelCurrentSymbolOpenOrdersTask) { + throw new Error("当前仍有交易任务运行"); + } + const positionCount = readAccountPositionCount(); + if (positionCount == null) throw new Error("未读取到全账户持仓数量"); + if (positionCount !== 0) throw new Error("全账户仍有持仓"); + const openOrdersCount = getOpenOrdersTabCount(); + if (openOrdersCount == null) throw new Error("未读取到全账户当前委托数量"); + if (openOrdersCount !== 0) throw new Error("全账户仍有当前委托"); + const positionState = resolveAllFuturesPositionStatus(await fetchCurrentPositionsPayload()); + if (positionState.status !== "flat") throw new Error("全账户仍有持仓"); + } + function formatUsdtRebalanceConfirmation(plan) { + const accountText = (balances) => [ + `资金 ${balances.FUNDING}`, + `现货 ${balances.MAIN}`, + `U本位 ${balances.UMFUTURE}` + ].join(" / "); + const transferText = plan.transfers.map((transfer, index) => { + const from = USDT_REBALANCE_ACCOUNTS[transfer.from].label; + const to = USDT_REBALANCE_ACCOUNTS[transfer.to].label; + return `${index + 1}. ${from} → ${to}:${transfer.amount} USDT`; + }).join("\n"); + return [ + "将全部可划转 USDT 调整为 资金 50% / 现货 40% / U本位 10%", + `当前:${accountText(plan.before)}`, + `目标:${accountText(plan.targets)}`, + transferText, + `确认执行 ${plan.transfers.length} 笔划转?` + ].filter(Boolean).join("\n\n"); + } + async function submitUsdtRebalanceTransfer(transfer) { + const payload = await fetchUsdtRebalanceBapi(BINANCE_WALLET_TRANSFER_BAPI_PATH, { + method: "POST", + body: { + asset: "USDT", + amount: transfer.amount, + kindType: transfer.kindType + }, + label: "USDT 划转接口" + }); + if (payload?.success !== true) throw new Error(payload?.message || "USDT 划转失败"); + return payload; + } + async function waitForUsdtRebalanceBalances(expectedBalances) { + const deadline = Date.now() + USDT_REBALANCE_REQUEST_TIMEOUT_MS; + while (true) { + const actualBalances = await readCurrentUsdtRebalanceBalances(); + if (areUsdtBalancesEqual(actualBalances, expectedBalances)) return actualBalances; + if (Date.now() >= deadline) throw new Error("划转后账户余额未及时更新"); + await delay(USDT_REBALANCE_BALANCE_POLL_MS); + } + } + async function runUsdtRebalance() { + let plan = null; + let completed = 0; + setLadderStatus("正在读取再平衡计划"); + try { + await assertUsdtRebalanceTradingState(); + const initialBalances = await readCurrentUsdtRebalanceBalances(); + plan = buildUsdtRebalancePlan(initialBalances); + if (plan.transfers.length === 0) { + usdtRebalanceEligible = false; + setLadderStatus("USDT 已按 5:4:1 分配"); + return { status: "already_balanced", plan }; + } + if (!window.confirm(formatUsdtRebalanceConfirmation(plan))) { + setLadderStatus("再平衡已取消"); + return { status: "cancelled", plan }; + } + let expectedBalances = initialBalances; + for (const transfer of plan.transfers) { + await assertUsdtRebalanceTradingState(); + const currentBalances = await readCurrentUsdtRebalanceBalances(); + if (!areUsdtBalancesEqual(currentBalances, expectedBalances)) { + throw new Error("账户余额已变化,已停止再平衡"); + } + setLadderStatus(`再平衡中 · ${completed + 1}/${plan.transfers.length} 笔`); + await submitUsdtRebalanceTransfer(transfer); + completed += 1; + expectedBalances = applyUsdtTransferToBalances(expectedBalances, transfer); + await waitForUsdtRebalanceBalances(expectedBalances); + } + usdtRebalanceEligible = false; + setLadderStatus(`再平衡已完成 · ${completed}/${plan.transfers.length} 笔`); + return { status: "completed", plan, completed }; + } catch (error) { + const message = error?.name === "AbortError" ? "Binance 请求超时" : error?.message || String(error); + const prefix = completed > 0 && plan ? `再平衡部分完成 · ${completed}/${plan.transfers.length} 笔` : "再平衡失败"; + setLadderStatus(`${prefix} · ${message}`, message); + throw error; + } + } + function startUsdtRebalance() { + if (usdtRebalanceTask || !usdtRebalanceEligible) return usdtRebalanceTask; + let task = null; + task = runUsdtRebalance().catch((error) => { + err("USDT 再平衡失败:", error); + return { status: "failed", error }; + }).finally(() => { + if (usdtRebalanceTask !== task) return; + usdtRebalanceTask = null; + scheduleRenderPanel(); + }); + usdtRebalanceTask = task; + scheduleRenderPanel(); + return task; + } async function fetchCurrentSymbolPositionState(symbol) { const payload = await fetchCurrentPositionsPayload(); return { @@ -7232,11 +7585,69 @@ }); autoOpenLeveragePositionCheckTask = task; } + function invalidateUsdtRebalanceEligibility() { + const hadPendingTimer = usdtRebalanceEligibilityTimer !== 0; + const wasEligible = usdtRebalanceEligible; + window.clearTimeout(usdtRebalanceEligibilityTimer); + usdtRebalanceEligibilityTimer = 0; + usdtRebalanceEligibilityEpoch += 1; + if (!usdtRebalanceTask) usdtRebalanceEligible = false; + const resetFlatStatus = !usdtRebalanceTask && ladderStatusText === "已全部平仓"; + if (resetFlatStatus) { + setLadderStatus("空闲"); + } + if (hadPendingTimer || wasEligible || resetFlatStatus) scheduleRenderPanel(); + } + async function confirmUsdtRebalanceEligibility(epoch) { + if (epoch !== usdtRebalanceEligibilityEpoch || document.hidden) return false; + if (readAccountPositionCount() !== 0 || getOpenOrdersTabCount() !== 0) return false; + if (ladderTask || continuousLadderTask || singleOrderTask || cancelCurrentSymbolOpenOrdersTask) { + return false; + } + const symbol = getCurrentSymbol(); + if (!symbol || !await waitForBncHeaders(symbol)) return false; + const positionState = resolveAllFuturesPositionStatus(await fetchCurrentPositionsPayload()); + if (epoch !== usdtRebalanceEligibilityEpoch) return false; + if (positionState.status !== "flat") return false; + usdtRebalanceEligible = true; + setLadderStatus("已全部平仓"); + scheduleRenderPanel(); + return true; + } + function scheduleUsdtRebalanceEligibility() { + invalidateUsdtRebalanceEligibility(); + const epoch = usdtRebalanceEligibilityEpoch; + usdtRebalanceEligibilityTimer = window.setTimeout(() => { + usdtRebalanceEligibilityTimer = 0; + let task = null; + task = confirmUsdtRebalanceEligibility(epoch).catch((error) => { + log("USDT 再平衡资格检查未通过", error?.message || error); + return false; + }).finally(() => { + if (usdtRebalanceEligibilityTask === task) usdtRebalanceEligibilityTask = null; + }); + usdtRebalanceEligibilityTask = task; + }, USDT_REBALANCE_FLAT_STABLE_MS); + } + function updateUsdtRebalanceEligibilityFromAccountCounts(positionCount, openOrdersCount) { + if (positionCount === 0 && openOrdersCount === 0) { + scheduleUsdtRebalanceEligibility(); + return; + } + invalidateUsdtRebalanceEligibility(); + } function handleAccountPositionObservation(triggerSource) { const positionCount = readAccountPositionCount(); - if (positionCount == null || positionCount === lastObservedAccountPositionCount) return; + const openOrdersCount = getOpenOrdersTabCount(); + const positionChanged = positionCount !== lastObservedAccountPositionCount; + const openOrdersChanged = openOrdersCount !== lastObservedAccountOpenOrdersCount; + if (!positionChanged && !openOrdersChanged) return; lastObservedAccountPositionCount = positionCount; - queueAutoOpenLeveragePositionCheck(triggerSource); + lastObservedAccountOpenOrdersCount = openOrdersCount; + if (positionChanged && positionCount != null) { + queueAutoOpenLeveragePositionCheck(triggerSource); + } + updateUsdtRebalanceEligibilityFromAccountCounts(positionCount, openOrdersCount); } function getAccountPositionObserverRoot() { const positionTab = findAccountPositionTab2(); @@ -7249,6 +7660,8 @@ } accountPositionObserverRoot = null; lastObservedAccountPositionCount = null; + lastObservedAccountOpenOrdersCount = null; + invalidateUsdtRebalanceEligibility(); } function ensureAccountPositionObserver() { if (document.hidden || !isFuturesTradingPage()) return; @@ -7617,6 +8030,8 @@ function refreshLadderPanel(panel, tradeMode, closeContext) { const body = panel.querySelector(`#${LADDER_BODY_ID}`); const status = panel.querySelector(`#${LADDER_STATUS_ID}`); + const statusRow = panel.querySelector(`#${LADDER_STATUS_ROW_ID}`); + const rebalanceButton = panel.querySelector(`#${USDT_REBALANCE_ACTION_ID}`); const mode = activeLadderPanelContext?.mode || (["OPEN", "CLOSE"].includes(tradeMode) ? tradeMode : null); const symbol = activeLadderPanelContext?.symbol || getCurrentSymbol(); const precision = activeLadderPanelContext?.precision || readCurrentOrderbookPrecisionValue(); @@ -7659,10 +8074,21 @@ } } } + if (statusRow) { + if (statusRow.style.visibility !== "visible") statusRow.style.visibility = "visible"; + } if (status) { if (status.textContent !== ladderStatusText) status.textContent = ladderStatusText; if (status.title !== ladderStatusTitle) status.title = ladderStatusTitle; - if (status.style.visibility !== "visible") status.style.visibility = "visible"; + } + if (rebalanceButton) { + const shouldShow = usdtRebalanceEligible || Boolean(usdtRebalanceTask); + const hidden = !shouldShow; + if (rebalanceButton.hidden !== hidden) rebalanceButton.hidden = hidden; + const disabled = Boolean(usdtRebalanceTask); + if (rebalanceButton.disabled !== disabled) rebalanceButton.disabled = disabled; + if (disabled) rebalanceButton.setAttribute("aria-disabled", "true"); + else rebalanceButton.removeAttribute("aria-disabled"); } } function refreshComputedInfo(panel, multiplier, qtyRuleContext) { @@ -7957,7 +8383,10 @@ `
${PANEL_COPY.section.ladderMaker}
`, `
`, "", - `
空闲
` + `
`, + `空闲`, + ``, + "
" ].join(""); panelPositionInvalidated = true; document.body.appendChild(panel); @@ -8121,6 +8550,12 @@ if (cancelSymbolBtn) { if (cancelSymbolBtn.disabled || cancelSymbolBtn.getAttribute("aria-disabled") === "true") return; cancelCurrentSymbolOpenOrders(); + return; + } + const rebalanceBtn = target.closest("[data-usdt-rebalance]"); + if (rebalanceBtn) { + if (rebalanceBtn.disabled || rebalanceBtn.getAttribute("aria-disabled") === "true") return; + startUsdtRebalance(); } }); return panel; @@ -8420,6 +8855,7 @@ return; } lastTs = now; + invalidateUsdtRebalanceEligibility(); setLadderStatus(`单击${action.side}准备中`); singleOrderTask = (async () => { await syncTradeInputs(clickedPrice, qtyPlan.qty, { diff --git a/src/binance-orderbook-trade/core/usdt-rebalance.js b/src/binance-orderbook-trade/core/usdt-rebalance.js new file mode 100644 index 0000000..97fc4c1 --- /dev/null +++ b/src/binance-orderbook-trade/core/usdt-rebalance.js @@ -0,0 +1,218 @@ +import { + formatDecimalParts, + normalizeDecimalString, + parseDecimalString, +} from './decimal.js'; + +const USDT_SCALE = 8; +const ACCOUNT_ORDER = ['FUNDING', 'MAIN', 'UMFUTURE']; + +/** + * The private transfer BAPI uses the current page bundle's account codes, which + * intentionally differ from the public universal-transfer API enum names. + */ +export const USDT_REBALANCE_ACCOUNTS = Object.freeze({ + FUNDING: Object.freeze({ + walletName: 'Funding', + bapiCode: 'CARD', + label: '资金', + ratio: 50, + }), + MAIN: Object.freeze({ + walletName: 'Spot', + bapiCode: 'MAIN', + label: '现货', + ratio: 40, + }), + UMFUTURE: Object.freeze({ + walletName: 'USDⓈ-M Futures', + bapiCode: 'FUTURE', + label: 'U本位合约', + ratio: 10, + }), +}); + +function parsePositionAmount(value) { + if (typeof value === 'number' && Number.isFinite(value)) return value; + if (typeof value === 'string' && /^-?(?:\d+\.?\d*|\.\d+)$/.test(value.trim())) { + return Number(value); + } + throw new Error(`持仓数量无效:${String(value)}`); +} + +function requireDecimal(value, message) { + const normalized = normalizeDecimalString(value); + if (normalized == null) throw new Error(message); + return normalized; +} + +function decimalToUnits(value) { + const normalized = requireDecimal(value, `USDT 余额无效:${String(value)}`); + const parsed = parseDecimalString(normalized); + if (parsed.scale > USDT_SCALE) throw new Error('USDT 余额精度超过 8 位'); + return parsed.digits * (10n ** BigInt(USDT_SCALE - parsed.scale)); +} + +function unitsToDecimal(units) { + return formatDecimalParts(units, USDT_SCALE); +} + +function isZeroDecimal(value) { + return decimalToUnits(value) === 0n; +} + +export function resolveAllFuturesPositionStatus(payload) { + if (payload?.success !== true) throw new Error(payload?.message || '持仓接口返回失败'); + if (!Array.isArray(payload.data)) throw new Error('持仓接口数据格式异常'); + let positionCount = 0; + for (const position of payload.data) { + if (!position || typeof position.symbol !== 'string' || !position.symbol) { + throw new Error('持仓接口缺少交易对'); + } + if (parsePositionAmount(position.positionAmount) !== 0) positionCount += 1; + } + return { + status: positionCount === 0 ? 'flat' : 'has_position', + positionCount, + }; +} + +function readWalletUsdtBalance(wallet, account) { + if (wallet.activate !== true) throw new Error(`${account.label}账户未启用`); + if (!Array.isArray(wallet.assetBalances)) { + throw new Error(`${account.label}账户余额格式异常`); + } + const matches = wallet.assetBalances.filter((asset) => asset?.asset === 'USDT'); + if (matches.length > 1) throw new Error(`${account.label}账户存在重复的 USDT 余额`); + if (matches.length === 0) return '0'; + const balance = matches[0]; + const free = requireDecimal(balance.free, `${account.label}账户 USDT 可用余额无效`); + const locked = requireDecimal(balance.locked, `${account.label}账户 USDT 锁定余额无效`); + const freeze = requireDecimal(balance.freeze, `${account.label}账户 USDT 冻结余额无效`); + const withdrawing = requireDecimal( + balance.withdrawing, + `${account.label}账户 USDT 划出中余额无效`, + ); + if (![locked, freeze, withdrawing].every(isZeroDecimal)) { + throw new Error(`${account.label}账户仍有不可划转 USDT`); + } + return free; +} + +export function parseUsdtWalletBalances(payload) { + if (payload?.success !== true) throw new Error(payload?.message || '钱包余额接口返回失败'); + if (!Array.isArray(payload.data)) throw new Error('钱包余额接口数据格式异常'); + const balances = {}; + for (const accountCode of ACCOUNT_ORDER) { + const account = USDT_REBALANCE_ACCOUNTS[accountCode]; + const matches = payload.data.filter((wallet) => wallet?.walletName === account.walletName); + if (matches.length === 0) throw new Error(`钱包余额缺少 ${account.label}账户`); + if (matches.length > 1) throw new Error(`钱包余额存在重复的${account.label}账户`); + const free = readWalletUsdtBalance(matches[0], account); + balances[accountCode] = accountCode === 'UMFUTURE' ? null : free; + } + return balances; +} + +export function withFuturesTransferableBalance(balances, payload) { + if (payload?.success !== true) { + throw new Error(`U本位可划转余额读取失败:${payload?.message || '未知错误'}`); + } + const transferable = requireDecimal(payload.data, 'U本位可划转余额无效'); + return { + FUNDING: balances.FUNDING, + MAIN: balances.MAIN, + UMFUTURE: transferable, + }; +} + +export function buildUsdtRebalancePlan(rawBalances) { + const beforeUnits = Object.fromEntries( + ACCOUNT_ORDER.map((accountCode) => [accountCode, decimalToUnits(rawBalances[accountCode])]), + ); + const totalUnits = ACCOUNT_ORDER.reduce((sum, accountCode) => sum + beforeUnits[accountCode], 0n); + const targetUnits = { + FUNDING: totalUnits * 50n / 100n, + MAIN: totalUnits * 40n / 100n, + UMFUTURE: 0n, + }; + targetUnits.UMFUTURE = totalUnits - targetUnits.FUNDING - targetUnits.MAIN; + + const donors = ACCOUNT_ORDER + .filter((accountCode) => beforeUnits[accountCode] > targetUnits[accountCode]) + .map((accountCode) => ({ + accountCode, + remaining: beforeUnits[accountCode] - targetUnits[accountCode], + })); + const recipients = ACCOUNT_ORDER + .filter((accountCode) => beforeUnits[accountCode] < targetUnits[accountCode]) + .map((accountCode) => ({ + accountCode, + remaining: targetUnits[accountCode] - beforeUnits[accountCode], + })); + + const transfers = []; + let donorIndex = 0; + let recipientIndex = 0; + while (donorIndex < donors.length && recipientIndex < recipients.length) { + const donor = donors[donorIndex]; + const recipient = recipients[recipientIndex]; + const amount = donor.remaining < recipient.remaining ? donor.remaining : recipient.remaining; + if (amount <= 0n) throw new Error('USDT 再平衡计划出现非正划转金额'); + transfers.push({ + from: donor.accountCode, + to: recipient.accountCode, + kindType: [ + USDT_REBALANCE_ACCOUNTS[donor.accountCode].bapiCode, + USDT_REBALANCE_ACCOUNTS[recipient.accountCode].bapiCode, + ].join('_'), + amount: unitsToDecimal(amount), + }); + donor.remaining -= amount; + recipient.remaining -= amount; + if (donor.remaining === 0n) donorIndex += 1; + if (recipient.remaining === 0n) recipientIndex += 1; + } + if ( + donors.some((donor) => donor.remaining !== 0n) + || recipients.some((recipient) => recipient.remaining !== 0n) + ) { + throw new Error('USDT 再平衡计划未闭合'); + } + if (transfers.length > 2) throw new Error('USDT 再平衡计划超过两笔划转'); + + return { + total: unitsToDecimal(totalUnits), + before: Object.fromEntries( + ACCOUNT_ORDER.map((accountCode) => [accountCode, unitsToDecimal(beforeUnits[accountCode])]), + ), + targets: Object.fromEntries( + ACCOUNT_ORDER.map((accountCode) => [accountCode, unitsToDecimal(targetUnits[accountCode])]), + ), + transfers, + }; +} + +export function applyUsdtTransferToBalances(rawBalances, transfer) { + if (!ACCOUNT_ORDER.includes(transfer?.from) || !ACCOUNT_ORDER.includes(transfer?.to)) { + throw new Error('USDT 划转账户无效'); + } + if (transfer.from === transfer.to) throw new Error('USDT 划转账户不能相同'); + const balances = Object.fromEntries( + ACCOUNT_ORDER.map((accountCode) => [accountCode, decimalToUnits(rawBalances[accountCode])]), + ); + const amount = decimalToUnits(transfer.amount); + if (amount <= 0n) throw new Error('USDT 划转金额必须大于 0'); + if (balances[transfer.from] < amount) throw new Error('USDT 划出账户余额不足'); + balances[transfer.from] -= amount; + balances[transfer.to] += amount; + return Object.fromEntries( + ACCOUNT_ORDER.map((accountCode) => [accountCode, unitsToDecimal(balances[accountCode])]), + ); +} + +export function areUsdtBalancesEqual(left, right) { + return ACCOUNT_ORDER.every( + (accountCode) => decimalToUnits(left?.[accountCode]) === decimalToUnits(right?.[accountCode]), + ); +} diff --git a/src/binance-orderbook-trade/index.user.js b/src/binance-orderbook-trade/index.user.js index 7d49241..1e975e5 100644 --- a/src/binance-orderbook-trade/index.user.js +++ b/src/binance-orderbook-trade/index.user.js @@ -3,7 +3,7 @@ // @namespace binance.orderbook.trade // @icon data:image/svg+xml,%3Csvg%20xmlns%3D%22http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg%22%20viewBox%3D%220%200%2064%2064%22%3E%3Crect%20width%3D%2264%22%20height%3D%2264%22%20rx%3D%2214%22%20fill%3D%22%23f0b90b%22%2F%3E%3Ctext%20x%3D%2232%22%20y%3D%2249%22%20text-anchor%3D%22middle%22%20font-family%3D%22Arial%2C%20sans-serif%22%20font-size%3D%2242%22%20font-weight%3D%22800%22%20fill%3D%22%23111827%22%3EJ%3C%2Ftext%3E%3C%2Fsvg%3E // @icon64 data:image/svg+xml,%3Csvg%20xmlns%3D%22http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg%22%20viewBox%3D%220%200%2064%2064%22%3E%3Crect%20width%3D%2264%22%20height%3D%2264%22%20rx%3D%2214%22%20fill%3D%22%23f0b90b%22%2F%3E%3Ctext%20x%3D%2232%22%20y%3D%2249%22%20text-anchor%3D%22middle%22%20font-family%3D%22Arial%2C%20sans-serif%22%20font-size%3D%2242%22%20font-weight%3D%22800%22%20fill%3D%22%23111827%22%3EJ%3C%2Ftext%3E%3C%2Fsvg%3E -// @version 2.7.164 +// @version 2.7.165 // @author jackhai9 // @description 单击订单簿价格,按当前开仓/平仓 tab 自动填数量并执行下单,内置数量倍率面板 // @match https://www.binance.com/*/futures/* @@ -53,6 +53,15 @@ import { resolveSymbolPositionSideStatus, resolveSymbolPositionStatus, } from './core/auto-open-leverage.js'; +import { + applyUsdtTransferToBalances, + areUsdtBalancesEqual, + buildUsdtRebalancePlan, + parseUsdtWalletBalances, + resolveAllFuturesPositionStatus, + USDT_REBALANCE_ACCOUNTS, + withFuturesTransferableBalance, +} from './core/usdt-rebalance.js'; import { addDecimalStrings, ceilQtyByNotional, @@ -244,6 +253,8 @@ import { const SIDE_SHORT_ID = 'jh-binance-close-side-short'; const LADDER_BODY_ID = 'jh-binance-ladder-body'; const LADDER_STATUS_ID = 'jh-binance-ladder-status'; + const LADDER_STATUS_ROW_ID = 'jh-binance-ladder-status-row'; + const USDT_REBALANCE_ACTION_ID = 'jh-binance-usdt-rebalance-action'; const MULTIPLIER_PRESS_FEEDBACK_ATTR = 'data-jh-press-feedback'; const ORDERBOOK_PRECISION_RECOMMENDATION_ID = 'jh-binance-orderbook-precision-recommendation'; const DEFAULT_MULTIPLIER = '1'; @@ -301,6 +312,12 @@ import { const LADDER_REPRICE_DELAY_MS = 180; const BINANCE_PLACE_ORDER_BAPI_PATH = '/bapi/futures/v1/private/future/order/place-order'; const BINANCE_USER_POSITION_BAPI_PATH = '/bapi/futures/v6/private/future/user-data/user-position'; + const BINANCE_WALLET_BALANCE_BAPI_PATH = '/bapi/asset/v2/private/asset-service/wallet/balance?needBalanceDetail=true"eAsset=USDT'; + const BINANCE_FUTURES_MAX_WITHDRAW_BAPI_PATH = '/bapi/futures/v1/private/future/user-data/getMaxWithdrawAmount'; + const BINANCE_WALLET_TRANSFER_BAPI_PATH = '/bapi/asset/v1/private/asset-service/wallet/transfer'; + const USDT_REBALANCE_FLAT_STABLE_MS = 3000; + const USDT_REBALANCE_REQUEST_TIMEOUT_MS = 5000; + const USDT_REBALANCE_BALANCE_POLL_MS = 1000; const LADDER_OPEN_QTY_READY_TIMEOUT_MS = 1200; const TRADE_INPUT_SYNC_TIMEOUT_MS = 350; const TRADE_INPUT_SYNC_STABLE_FRAMES = 2; @@ -371,6 +388,7 @@ import { let accountPositionObserver = null; let accountPositionObserverRoot = null; let lastObservedAccountPositionCount = null; + let lastObservedAccountOpenOrdersCount = null; let lastObservedAccountPositionState = null; let lastConfirmedCloseState = null; // 第三层:已确认的稳定缓存(仅 CLOSE 模式下写入) let lastDisplayCloseState = null; // 第二层:仅供脚本面板展示的缓存态 @@ -403,6 +421,11 @@ import { let ladderStopRequested = false; let ladderStatusText = '空闲'; let ladderStatusTitle = '空闲'; + let usdtRebalanceEligibilityTimer = 0; + let usdtRebalanceEligibilityEpoch = 0; + let usdtRebalanceEligibilityTask = null; + let usdtRebalanceEligible = false; + let usdtRebalanceTask = null; let ladderPanelBodySignature = ''; let panelPositionInvalidated = true; let panelObservedSize = ''; @@ -2961,6 +2984,7 @@ import { } setLadderStatus(singleStatus); }; + invalidateUsdtRebalanceEligibility(); ladderAbortController = abortController; activeLadderActionType = actionType; if (continuousSession) activeContinuousLadderRoundProgress = progress; @@ -4816,6 +4840,7 @@ import { } clearCancelNoOrdersFeedback(); cancelCurrentSymbolOpenOrdersBlocksLadderActions = false; + invalidateUsdtRebalanceEligibility(); const task = runCancelCurrentSymbolOpenOrders(options); cancelCurrentSymbolOpenOrdersTask = task; scheduleRenderPanel(); @@ -5445,6 +5470,171 @@ import { } } + async function fetchUsdtRebalanceBapi(path, options = {}) { + if (!cachedBncHeaders) throw new Error('币安请求头尚未就绪'); + const method = options.method || 'GET'; + const controller = new AbortController(); + const timer = window.setTimeout( + () => controller.abort(), + USDT_REBALANCE_REQUEST_TIMEOUT_MS, + ); + try { + const request = { + method, + headers: getBncHeaders(), + credentials: 'include', + signal: controller.signal, + }; + if (Object.hasOwn(options, 'body')) request.body = JSON.stringify(options.body); + const response = await fetch(`${window.location.origin}${path}`, request); + if (response.status === 401) throw new Error('Binance 登录态已失效'); + if (!response.ok) throw new Error(`${options.label || 'Binance 接口'}异常:HTTP ${response.status}`); + return await response.json(); + } finally { + window.clearTimeout(timer); + } + } + + async function readCurrentUsdtRebalanceBalances() { + const [walletPayload, futuresPayload] = await Promise.all([ + fetchUsdtRebalanceBapi(BINANCE_WALLET_BALANCE_BAPI_PATH, { + label: '钱包余额接口', + }), + fetchUsdtRebalanceBapi(BINANCE_FUTURES_MAX_WITHDRAW_BAPI_PATH, { + method: 'POST', + body: { assetName: 'USDT' }, + label: 'U本位可划转余额接口', + }), + ]); + return withFuturesTransferableBalance( + parseUsdtWalletBalances(walletPayload), + futuresPayload, + ); + } + + async function assertUsdtRebalanceTradingState() { + if (!isFuturesTradingPage() || document.hidden) throw new Error('当前不在可操作的合约页面'); + if (ladderTask || continuousLadderTask || singleOrderTask || cancelCurrentSymbolOpenOrdersTask) { + throw new Error('当前仍有交易任务运行'); + } + const positionCount = readAccountPositionCount(); + if (positionCount == null) throw new Error('未读取到全账户持仓数量'); + if (positionCount !== 0) throw new Error('全账户仍有持仓'); + const openOrdersCount = getOpenOrdersTabCount(); + if (openOrdersCount == null) throw new Error('未读取到全账户当前委托数量'); + if (openOrdersCount !== 0) throw new Error('全账户仍有当前委托'); + const positionState = resolveAllFuturesPositionStatus(await fetchCurrentPositionsPayload()); + if (positionState.status !== 'flat') throw new Error('全账户仍有持仓'); + } + + function formatUsdtRebalanceConfirmation(plan) { + const accountText = (balances) => [ + `资金 ${balances.FUNDING}`, + `现货 ${balances.MAIN}`, + `U本位 ${balances.UMFUTURE}`, + ].join(' / '); + const transferText = plan.transfers.map((transfer, index) => { + const from = USDT_REBALANCE_ACCOUNTS[transfer.from].label; + const to = USDT_REBALANCE_ACCOUNTS[transfer.to].label; + return `${index + 1}. ${from} → ${to}:${transfer.amount} USDT`; + }).join('\n'); + return [ + '将全部可划转 USDT 调整为 资金 50% / 现货 40% / U本位 10%', + `当前:${accountText(plan.before)}`, + `目标:${accountText(plan.targets)}`, + transferText, + `确认执行 ${plan.transfers.length} 笔划转?`, + ].filter(Boolean).join('\n\n'); + } + + async function submitUsdtRebalanceTransfer(transfer) { + const payload = await fetchUsdtRebalanceBapi(BINANCE_WALLET_TRANSFER_BAPI_PATH, { + method: 'POST', + body: { + asset: 'USDT', + amount: transfer.amount, + kindType: transfer.kindType, + }, + label: 'USDT 划转接口', + }); + if (payload?.success !== true) throw new Error(payload?.message || 'USDT 划转失败'); + return payload; + } + + async function waitForUsdtRebalanceBalances(expectedBalances) { + const deadline = Date.now() + USDT_REBALANCE_REQUEST_TIMEOUT_MS; + while (true) { + const actualBalances = await readCurrentUsdtRebalanceBalances(); + if (areUsdtBalancesEqual(actualBalances, expectedBalances)) return actualBalances; + if (Date.now() >= deadline) throw new Error('划转后账户余额未及时更新'); + await delay(USDT_REBALANCE_BALANCE_POLL_MS); + } + } + + async function runUsdtRebalance() { + let plan = null; + let completed = 0; + setLadderStatus('正在读取再平衡计划'); + try { + await assertUsdtRebalanceTradingState(); + const initialBalances = await readCurrentUsdtRebalanceBalances(); + plan = buildUsdtRebalancePlan(initialBalances); + if (plan.transfers.length === 0) { + usdtRebalanceEligible = false; + setLadderStatus('USDT 已按 5:4:1 分配'); + return { status: 'already_balanced', plan }; + } + if (!window.confirm(formatUsdtRebalanceConfirmation(plan))) { + setLadderStatus('再平衡已取消'); + return { status: 'cancelled', plan }; + } + + let expectedBalances = initialBalances; + for (const transfer of plan.transfers) { + await assertUsdtRebalanceTradingState(); + const currentBalances = await readCurrentUsdtRebalanceBalances(); + if (!areUsdtBalancesEqual(currentBalances, expectedBalances)) { + throw new Error('账户余额已变化,已停止再平衡'); + } + setLadderStatus(`再平衡中 · ${completed + 1}/${plan.transfers.length} 笔`); + await submitUsdtRebalanceTransfer(transfer); + completed += 1; + expectedBalances = applyUsdtTransferToBalances(expectedBalances, transfer); + await waitForUsdtRebalanceBalances(expectedBalances); + } + usdtRebalanceEligible = false; + setLadderStatus(`再平衡已完成 · ${completed}/${plan.transfers.length} 笔`); + return { status: 'completed', plan, completed }; + } catch (error) { + const message = error?.name === 'AbortError' + ? 'Binance 请求超时' + : (error?.message || String(error)); + const prefix = completed > 0 && plan + ? `再平衡部分完成 · ${completed}/${plan.transfers.length} 笔` + : '再平衡失败'; + setLadderStatus(`${prefix} · ${message}`, message); + throw error; + } + } + + function startUsdtRebalance() { + if (usdtRebalanceTask || !usdtRebalanceEligible) return usdtRebalanceTask; + let task = null; + task = runUsdtRebalance() + .catch((error) => { + err('USDT 再平衡失败:', error); + return { status: 'failed', error }; + }) + .finally(() => { + if (usdtRebalanceTask !== task) return; + usdtRebalanceTask = null; + scheduleRenderPanel(); + }); + usdtRebalanceTask = task; + scheduleRenderPanel(); + return task; + } + async function fetchCurrentSymbolPositionState(symbol) { const payload = await fetchCurrentPositionsPayload(); return { @@ -5601,11 +5791,75 @@ import { autoOpenLeveragePositionCheckTask = task; } + function invalidateUsdtRebalanceEligibility() { + const hadPendingTimer = usdtRebalanceEligibilityTimer !== 0; + const wasEligible = usdtRebalanceEligible; + window.clearTimeout(usdtRebalanceEligibilityTimer); + usdtRebalanceEligibilityTimer = 0; + usdtRebalanceEligibilityEpoch += 1; + if (!usdtRebalanceTask) usdtRebalanceEligible = false; + const resetFlatStatus = !usdtRebalanceTask && ladderStatusText === '已全部平仓'; + if (resetFlatStatus) { + setLadderStatus('空闲'); + } + if (hadPendingTimer || wasEligible || resetFlatStatus) scheduleRenderPanel(); + } + + async function confirmUsdtRebalanceEligibility(epoch) { + if (epoch !== usdtRebalanceEligibilityEpoch || document.hidden) return false; + if (readAccountPositionCount() !== 0 || getOpenOrdersTabCount() !== 0) return false; + if (ladderTask || continuousLadderTask || singleOrderTask || cancelCurrentSymbolOpenOrdersTask) { + return false; + } + const symbol = getCurrentSymbol(); + if (!symbol || !await waitForBncHeaders(symbol)) return false; + const positionState = resolveAllFuturesPositionStatus(await fetchCurrentPositionsPayload()); + if (epoch !== usdtRebalanceEligibilityEpoch) return false; + if (positionState.status !== 'flat') return false; + usdtRebalanceEligible = true; + setLadderStatus('已全部平仓'); + scheduleRenderPanel(); + return true; + } + + function scheduleUsdtRebalanceEligibility() { + invalidateUsdtRebalanceEligibility(); + const epoch = usdtRebalanceEligibilityEpoch; + usdtRebalanceEligibilityTimer = window.setTimeout(() => { + usdtRebalanceEligibilityTimer = 0; + let task = null; + task = confirmUsdtRebalanceEligibility(epoch) + .catch((error) => { + log('USDT 再平衡资格检查未通过', error?.message || error); + return false; + }) + .finally(() => { + if (usdtRebalanceEligibilityTask === task) usdtRebalanceEligibilityTask = null; + }); + usdtRebalanceEligibilityTask = task; + }, USDT_REBALANCE_FLAT_STABLE_MS); + } + + function updateUsdtRebalanceEligibilityFromAccountCounts(positionCount, openOrdersCount) { + if (positionCount === 0 && openOrdersCount === 0) { + scheduleUsdtRebalanceEligibility(); + return; + } + invalidateUsdtRebalanceEligibility(); + } + function handleAccountPositionObservation(triggerSource) { const positionCount = readAccountPositionCount(); - if (positionCount == null || positionCount === lastObservedAccountPositionCount) return; + const openOrdersCount = getOpenOrdersTabCount(); + const positionChanged = positionCount !== lastObservedAccountPositionCount; + const openOrdersChanged = openOrdersCount !== lastObservedAccountOpenOrdersCount; + if (!positionChanged && !openOrdersChanged) return; lastObservedAccountPositionCount = positionCount; - queueAutoOpenLeveragePositionCheck(triggerSource); + lastObservedAccountOpenOrdersCount = openOrdersCount; + if (positionChanged && positionCount != null) { + queueAutoOpenLeveragePositionCheck(triggerSource); + } + updateUsdtRebalanceEligibilityFromAccountCounts(positionCount, openOrdersCount); } function getAccountPositionObserverRoot() { @@ -5620,6 +5874,8 @@ import { } accountPositionObserverRoot = null; lastObservedAccountPositionCount = null; + lastObservedAccountOpenOrdersCount = null; + invalidateUsdtRebalanceEligibility(); } function ensureAccountPositionObserver() { @@ -6050,6 +6306,8 @@ import { function refreshLadderPanel(panel, tradeMode, closeContext) { const body = panel.querySelector(`#${LADDER_BODY_ID}`); const status = panel.querySelector(`#${LADDER_STATUS_ID}`); + const statusRow = panel.querySelector(`#${LADDER_STATUS_ROW_ID}`); + const rebalanceButton = panel.querySelector(`#${USDT_REBALANCE_ACTION_ID}`); const mode = activeLadderPanelContext?.mode || (['OPEN', 'CLOSE'].includes(tradeMode) ? tradeMode : null); const symbol = activeLadderPanelContext?.symbol || getCurrentSymbol(); @@ -6093,10 +6351,21 @@ import { } } } + if (statusRow) { + if (statusRow.style.visibility !== 'visible') statusRow.style.visibility = 'visible'; + } if (status) { if (status.textContent !== ladderStatusText) status.textContent = ladderStatusText; if (status.title !== ladderStatusTitle) status.title = ladderStatusTitle; - if (status.style.visibility !== 'visible') status.style.visibility = 'visible'; + } + if (rebalanceButton) { + const shouldShow = usdtRebalanceEligible || Boolean(usdtRebalanceTask); + const hidden = !shouldShow; + if (rebalanceButton.hidden !== hidden) rebalanceButton.hidden = hidden; + const disabled = Boolean(usdtRebalanceTask); + if (rebalanceButton.disabled !== disabled) rebalanceButton.disabled = disabled; + if (disabled) rebalanceButton.setAttribute('aria-disabled', 'true'); + else rebalanceButton.removeAttribute('aria-disabled'); } } @@ -6439,7 +6708,10 @@ import { `
${PANEL_COPY.section.ladderMaker}
`, `
`, '', - `
空闲
`, + `
`, + `空闲`, + ``, + '
', ].join(''); panelPositionInvalidated = true; document.body.appendChild(panel); @@ -6610,6 +6882,12 @@ import { if (cancelSymbolBtn) { if (cancelSymbolBtn.disabled || cancelSymbolBtn.getAttribute('aria-disabled') === 'true') return; cancelCurrentSymbolOpenOrders(); + return; + } + const rebalanceBtn = target.closest('[data-usdt-rebalance]'); + if (rebalanceBtn) { + if (rebalanceBtn.disabled || rebalanceBtn.getAttribute('aria-disabled') === 'true') return; + startUsdtRebalance(); } }); @@ -6958,6 +7236,7 @@ import { return; } lastTs = now; + invalidateUsdtRebalanceEligibility(); setLadderStatus(`单击${action.side}准备中`); singleOrderTask = (async () => { await syncTradeInputs(clickedPrice, qtyPlan.qty, { diff --git a/test/unit/binance-orderbook-trade/source-regressions.test.js b/test/unit/binance-orderbook-trade/source-regressions.test.js index 1569944..479323d 100644 --- a/test/unit/binance-orderbook-trade/source-regressions.test.js +++ b/test/unit/binance-orderbook-trade/source-regressions.test.js @@ -420,9 +420,11 @@ test('dynamic panel text keeps fixed single-line slots', () => { assert.doesNotMatch(source, /data-orderbook-precision-status/); const ladderBody = readFunctionBody('refreshLadderPanel'); - assert.match(ladderBody, /status\.style\.visibility !== 'visible'/); - assert.doesNotMatch(ladderBody, /status\.style\.display/); - assert.match(source, new RegExp(`id="\\$\\{LADDER_STATUS_ID\\}"[^>]*height:18px;[^>]*visibility:visible;[^>]*white-space:nowrap;overflow:hidden;text-overflow:ellipsis`)); + assert.match(ladderBody, /statusRow\.style\.visibility !== 'visible'/); + assert.doesNotMatch(ladderBody, /statusRow\.style\.display/); + assert.match(source, new RegExp(`id="\\$\\{LADDER_STATUS_ROW_ID\\}"[^>]*display:flex;[^>]*height:18px;[^>]*visibility:visible;[^>]*white-space:nowrap;overflow:hidden`)); + assert.match(source, new RegExp(`id="\\$\\{LADDER_STATUS_ID\\}"[^>]*flex:1 1 auto;min-width:0;overflow:hidden;text-overflow:ellipsis`)); + assert.match(source, new RegExp(`id="\\$\\{USDT_REBALANCE_ACTION_ID\\}"[^>]*data-usdt-rebalance="true" hidden`)); }); test('panel keeps controls in cohesive ordered semantic groups', () => { @@ -507,7 +509,8 @@ test('direction selector is a compact mutually exclusive radio group', () => { assert.match(ensurePanelBody, /data-side-selector role="radiogroup" aria-labelledby="\$\{MODE_HINT_ID\}"/); assert.equal((ensurePanelBody.match(/role="radio" aria-checked="false"/g) || []).length, 2); - assert.equal((ensurePanelBody.match(/border:0;/g) || []).length, 2); + assert.match(ensurePanelBody, /id="\$\{SIDE_LONG_ID\}"[^>]*border:0;/); + assert.match(ensurePanelBody, /id="\$\{SIDE_SHORT_ID\}"[^>]*border:0;/); assert.match(ensurePanelBody, /border-left:1px solid var\(--color-InputLine\)/); assert.match(refreshBody, /let hintText = PANEL_COPY\.field\.clickOrderbook/); assert.doesNotMatch(refreshBody, /hintText = '仓位确认中'/); @@ -1232,6 +1235,7 @@ test('stable panel refreshes avoid writing unchanged text and state attributes', const panelBody = readFunctionBody('refreshLadderPanel'); assert.doesNotMatch(panelBody, /toggle|expanded/); assert.match(panelBody, /status\.textContent !== ladderStatusText/); + assert.match(panelBody, /rebalanceButton\.hidden/); const computedBody = readFunctionBody('refreshComputedInfo'); assert.match(computedBody, /formulaPrefixEl\.textContent !== formulaPrefixText/); @@ -1730,11 +1734,44 @@ test('auto leverage reset is authorized by a fresh current-symbol position respo test('account position count changes schedule symbol-specific API checks', () => { const observationBody = readFunctionBody('handleAccountPositionObservation'); - assert.match(observationBody, /positionCount === lastObservedAccountPositionCount/); + assert.match(observationBody, /positionChanged = positionCount !== lastObservedAccountPositionCount/); + assert.match(observationBody, /openOrdersChanged = openOrdersCount !== lastObservedAccountOpenOrdersCount/); assert.match(observationBody, /queueAutoOpenLeveragePositionCheck\(triggerSource\)/); + assert.match(observationBody, /updateUsdtRebalanceEligibilityFromAccountCounts\(positionCount, openOrdersCount\)/); const checkBody = readFunctionBody('runAutoOpenLeveragePositionCheck'); assert.match(checkBody, /await fetchCurrentSymbolPositionState\(symbol\)/); assert.match(checkBody, /observeAutoOpenLeveragePositionState/); assert.match(checkBody, /observation\.shouldReset \|\| resetIfFlat/); }); + +test('USDT rebalance waits for global flat stability and requires zero open orders', () => { + assert.match(source, /const USDT_REBALANCE_FLAT_STABLE_MS = 3000;/); + const eligibilityBody = readFunctionBody('confirmUsdtRebalanceEligibility'); + assert.match(eligibilityBody, /readAccountPositionCount\(\) !== 0/); + assert.match(eligibilityBody, /getOpenOrdersTabCount\(\) !== 0/); + assert.match(eligibilityBody, /resolveAllFuturesPositionStatus\(await fetchCurrentPositionsPayload\(\)\)/); + assert.match(eligibilityBody, /positionState\.status !== 'flat'/); + assert.match(eligibilityBody, /usdtRebalanceEligible = true/); +}); + +test('USDT rebalance uses direct Binance BAPI only after one explicit plan confirmation', () => { + assert.match(source, /BINANCE_WALLET_BALANCE_BAPI_PATH = '\/bapi\/asset\/v2\/private\/asset-service\/wallet\/balance\?needBalanceDetail=true"eAsset=USDT'/); + assert.match(source, /BINANCE_FUTURES_MAX_WITHDRAW_BAPI_PATH = '\/bapi\/futures\/v1\/private\/future\/user-data\/getMaxWithdrawAmount'/); + assert.match(source, /BINANCE_WALLET_TRANSFER_BAPI_PATH = '\/bapi\/asset\/v1\/private\/asset-service\/wallet\/transfer'/); + const readBalancesBody = readFunctionBody('readCurrentUsdtRebalanceBalances'); + assert.match(readBalancesBody, /body: \{ assetName: 'USDT' \}/); + assert.match(readBalancesBody, /withFuturesTransferableBalance/); + const runBody = readFunctionBody('runUsdtRebalance'); + assert.match(runBody, /window\.confirm\(formatUsdtRebalanceConfirmation\(plan\)\)/); + assert.match(runBody, /await assertUsdtRebalanceTradingState\(\)/); + assert.match(runBody, /areUsdtBalancesEqual\(currentBalances, expectedBalances\)/); + assert.match(runBody, /await submitUsdtRebalanceTransfer\(transfer\)/); + assert.match(runBody, /applyUsdtTransferToBalances\(expectedBalances, transfer\)/); + const submitBody = readFunctionBody('submitUsdtRebalanceTransfer'); + assert.match(submitBody, /asset: 'USDT'/); + assert.match(submitBody, /amount: transfer\.amount/); + assert.match(submitBody, /kindType: transfer\.kindType/); + assert.match(submitBody, /payload\?\.success !== true/); + assert.doesNotMatch(source, /orderform-transfer-button/); +}); diff --git a/test/unit/binance-orderbook-trade/usdt-rebalance.test.js b/test/unit/binance-orderbook-trade/usdt-rebalance.test.js new file mode 100644 index 0000000..100ae35 --- /dev/null +++ b/test/unit/binance-orderbook-trade/usdt-rebalance.test.js @@ -0,0 +1,231 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; + +import { + applyUsdtTransferToBalances, + areUsdtBalancesEqual, + buildUsdtRebalancePlan, + parseUsdtWalletBalances, + resolveAllFuturesPositionStatus, + USDT_REBALANCE_ACCOUNTS, + withFuturesTransferableBalance, +} from '../../../src/binance-orderbook-trade/core/usdt-rebalance.js'; + +test('private transfer BAPI account codes match the current Binance page bundle', () => { + assert.deepEqual({ + funding: USDT_REBALANCE_ACCOUNTS.FUNDING.bapiCode, + spot: USDT_REBALANCE_ACCOUNTS.MAIN.bapiCode, + futures: USDT_REBALANCE_ACCOUNTS.UMFUTURE.bapiCode, + }, { + funding: 'CARD', + spot: 'MAIN', + futures: 'FUTURE', + }); +}); + +test('all futures positions must be zero before rebalance is eligible', () => { + assert.deepEqual(resolveAllFuturesPositionStatus({ + success: true, + data: [ + { symbol: 'BTCUSDT', positionAmount: '0' }, + { symbol: 'ETHUSDT', positionAmount: '-0.25' }, + ], + }), { + status: 'has_position', + positionCount: 1, + }); + + assert.deepEqual(resolveAllFuturesPositionStatus({ + success: true, + data: [ + { symbol: 'BTCUSDT', positionAmount: '0.000' }, + { symbol: 'ETHUSDT', positionAmount: 0 }, + ], + }), { + status: 'flat', + positionCount: 0, + }); +}); + +test('all-position resolver rejects failed and malformed responses', () => { + assert.throws( + () => resolveAllFuturesPositionStatus({ success: false, data: [] }), + /持仓接口返回失败/, + ); + assert.throws( + () => resolveAllFuturesPositionStatus({ + success: true, + data: [{ symbol: 'BTCUSDT', positionAmount: 'unknown' }], + }), + /持仓数量无效/, + ); +}); + +test('wallet response reads exact USDT free balances for spot and funding', () => { + assert.deepEqual(parseUsdtWalletBalances({ + success: true, + data: [ + { + activate: true, + walletName: 'Spot', + assetBalances: [{ + asset: 'USDT', free: '40.5', locked: '0', freeze: '0', withdrawing: '0', + }], + }, + { + activate: true, + walletName: 'Funding', + assetBalances: [{ + asset: 'USDT', free: '50', locked: '0', freeze: '0', withdrawing: '0', + }], + }, + { + activate: true, + walletName: 'USDⓈ-M Futures', + assetBalances: [{ + asset: 'USDT', free: '12', locked: '0', freeze: '0', withdrawing: '0', + }], + }, + ], + }), { + MAIN: '40.5', + FUNDING: '50', + UMFUTURE: null, + }); +}); + +test('wallet response rejects locked, frozen, withdrawing, missing, and duplicate balances', () => { + const wallet = (walletName, overrides = {}) => ({ + activate: true, + walletName, + assetBalances: [{ + asset: 'USDT', free: '1', locked: '0', freeze: '0', withdrawing: '0', ...overrides, + }], + }); + + assert.throws( + () => parseUsdtWalletBalances({ + success: true, + data: [wallet('Spot', { locked: '0.1' }), wallet('Funding'), wallet('USDⓈ-M Futures')], + }), + /现货账户仍有不可划转 USDT/, + ); + assert.throws( + () => parseUsdtWalletBalances({ + success: true, + data: [wallet('Spot'), wallet('Funding', { freeze: '0.1' }), wallet('USDⓈ-M Futures')], + }), + /资金账户仍有不可划转 USDT/, + ); + assert.throws( + () => parseUsdtWalletBalances({ + success: true, + data: [wallet('Spot'), wallet('Funding'), wallet('USDⓈ-M Futures', { withdrawing: '0.1' })], + }), + /U本位合约账户仍有不可划转 USDT/, + ); + assert.throws( + () => parseUsdtWalletBalances({ + success: true, + data: [wallet('Spot'), wallet('Funding')], + }), + /缺少 U本位合约账户/, + ); + assert.throws( + () => parseUsdtWalletBalances({ + success: true, + data: [wallet('Spot'), wallet('Spot'), wallet('Funding'), wallet('USDⓈ-M Futures')], + }), + /重复的现货账户/, + ); +}); + +test('futures transferable amount replaces wallet free amount', () => { + assert.deepEqual(withFuturesTransferableBalance({ + MAIN: '40', + FUNDING: '50', + UMFUTURE: null, + }, { + success: true, + data: '10.25', + }), { + MAIN: '40', + FUNDING: '50', + UMFUTURE: '10.25', + }); + assert.throws( + () => withFuturesTransferableBalance({ MAIN: '40', FUNDING: '50', UMFUTURE: null }, { + success: false, + message: 'failed', + }), + /U本位可划转余额读取失败:failed/, + ); +}); + +test('rebalance plan allocates total USDT to funding, spot, and futures as 5:4:1', () => { + assert.deepEqual(buildUsdtRebalancePlan({ + FUNDING: '0', + MAIN: '100', + UMFUTURE: '0', + }), { + total: '100', + before: { FUNDING: '0', MAIN: '100', UMFUTURE: '0' }, + targets: { FUNDING: '50', MAIN: '40', UMFUTURE: '10' }, + transfers: [ + { from: 'MAIN', to: 'FUNDING', kindType: 'MAIN_CARD', amount: '50' }, + { from: 'MAIN', to: 'UMFUTURE', kindType: 'MAIN_FUTURE', amount: '10' }, + ], + }); +}); + +test('rebalance plan uses at most two transfers and assigns decimal dust to futures', () => { + const plan = buildUsdtRebalancePlan({ + FUNDING: '70.00000001', + MAIN: '10', + UMFUTURE: '20', + }); + assert.deepEqual(plan.targets, { + FUNDING: '50', + MAIN: '40', + UMFUTURE: '10.00000001', + }); + assert.deepEqual(plan.transfers, [ + { from: 'FUNDING', to: 'MAIN', kindType: 'CARD_MAIN', amount: '20.00000001' }, + { from: 'UMFUTURE', to: 'MAIN', kindType: 'FUTURE_MAIN', amount: '9.99999999' }, + ]); + assert.ok(plan.transfers.length <= 2); +}); + +test('rebalance plan is empty when balances already match 5:4:1', () => { + assert.deepEqual(buildUsdtRebalancePlan({ + FUNDING: '5', + MAIN: '4', + UMFUTURE: '1', + }).transfers, []); +}); + +test('rebalance plan rejects unsupported USDT precision instead of rounding', () => { + assert.throws( + () => buildUsdtRebalancePlan({ + FUNDING: '1.000000001', + MAIN: '0', + UMFUTURE: '0', + }), + /USDT 余额精度超过 8 位/, + ); +}); + +test('confirmed transfers advance the expected balance snapshot exactly', () => { + const next = applyUsdtTransferToBalances({ + FUNDING: '0', MAIN: '100', UMFUTURE: '0', + }, { + from: 'MAIN', to: 'FUNDING', amount: '50', + }); + assert.deepEqual(next, { FUNDING: '50', MAIN: '50', UMFUTURE: '0' }); + assert.equal(areUsdtBalancesEqual(next, { + FUNDING: '50.00000000', MAIN: '50', UMFUTURE: '0.0', + }), true); + assert.equal(areUsdtBalancesEqual(next, { + FUNDING: '50', MAIN: '49.99999999', UMFUTURE: '0', + }), false); +});