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);
+});