diff --git a/docs/binance-orderbook-trade-development.md b/docs/binance-orderbook-trade-development.md index a0e6ad39..94cbb5f8 100644 --- a/docs/binance-orderbook-trade-development.md +++ b/docs/binance-orderbook-trade-development.md @@ -322,13 +322,64 @@ and the evidence report contract are owned by 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 two 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. When a cancellation or continuous-close task ends, readable zero account counters restart the same guarded eligibility window even if the counts did not change during the task or the earlier check was blocked by the running task. A continuous session restarts this window only after the whole session ends, not between rounds. Hidden pages defer qualification until the existing visibility lifecycle observes the account again. The action opens a script-owned native `` styled with Binance color variables; it shows current and target balances plus the exact transfer plan, focuses Cancel by default, and proceeds only after an explicit confirmation. Escape and Cancel close it without transferring funds. +USDT account rebalancing has separate automatic excess withdrawal and manual full-allocation paths. The inline `账户再平衡` action becomes available only after the account-order widget has continuously reported zero positions and zero open orders for two 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. When a cancellation or continuous-close task ends, readable zero account counters restart the same guarded eligibility window even if the counts did not change during the task or the earlier check was blocked by the running task. A continuous session restarts this window only after the whole session ends, not between rounds. Hidden pages defer qualification until the existing visibility lifecycle observes the account again. The action opens a script-owned native `` styled with Binance color variables; it shows current and target balances plus the exact transfer plan, focuses Cancel by default, and proceeds only after an explicit confirmation. Escape and Cancel close it without transferring funds. + +Automatic withdrawal runs once per verified globally flat episode after the same +two-second zero-counter window. Its target keeps 10% of total available USDT in +Futures, sends only Futures excess toward Funding's 50% target, and sends the +remainder to Spot. It never debits Funding or Spot, and never refills Futures. +Targets display the balances actually reachable by these outward-only transfers. +The manual action keeps its explicit confirmation and full 5:4:1 allocation. +Automatic qualification, waiting, progress, completion, and failure messages use +the separate `jh-binance-auto-rebalance-status` row. It starts hidden, uses safe +text/title updates, and retains its status across locale-driven panel rebuilds. +Automatic work never replaces a retained manual trading or cancellation result; +the general flat-account message is shown only when the trading row is idle. + +All transfers hold the origin-wide exclusive Web Lock +`userscripts:usdt-account-operation:v1`. Script single orders, cancellations, +ladders, and whole continuous sessions hold that lock in shared mode. A busy +lock refuses a new manual or trading action immediately instead of queuing a stale click; automatic qualification alone waits for the exclusive lock with an +epoch-bound AbortSignal, then rechecks every precondition after acquisition. +Invalidating eligibility cancels a pending lock request; +continuous rounds execute inside their session lock. Ladder and continuous-task +controllers are published synchronously before requesting the lock. Each acquired +lock checks Stop before beginning chart coalescing or trade-form work, so a Stop +in the click turn cannot be lost while the browser schedules its lock callback. +Native Binance actions, +other browsers, and devices are outside this script lock's scope. + +Inside the lock, the script reads `/bapi/accounts/v1/private/account/user/base-detail` +with POST `{}` and requires code `000000`, a nonempty string user ID, an existing +Futures account, and an explicit false portfolio-margin flag. It hashes the ID +with SHA-256 and stores only a versioned episode status under +`userscripts:automatic-usdt-rebalance:v1:`. Missing state initially +qualifies; `consumed` becomes `active` only after a new authoritative nonflat +snapshot. That activity observation uses the same lock in shared mode so it can +observe an ongoing script trade while remaining excluded by transfers. The +`in_flight` marker is persisted before each transfer POST. It and `blocked` remain +terminal across reloads and later activity; neither a timer nor another tab +clears an uncertain outcome. There is no automatic retry or rollback. Missing +Web Locks, digest support, storage, or identity contracts surface an error. + +Every automatic attempt and every transfer step verifies all-symbol positions, +POST `/bapi/futures/v1/private/future/order/open-orders` with `{}`, and POST +`/bapi/futures/v1/private/future/order/open-algo-order` with +`{algoType: "CONDITIONAL"}`. Both pinned order endpoints must return successful +complete arrays, including other symbols. Each step rereads account identity and +balances, then checks the route, eligibility epoch, counters, and script tasks +again synchronously immediately before persisting and submitting. Ordinary +USD-M symbol pages with no query parameters alone qualify. Any query, including +the native copy-trading `cl=public` / `cl=private` modes, prevents automatic +qualification before identity is requested. +The manual dialog opens before exclusive locking, and confirmed execution +revalidates its original account identity and exact previewed balances. 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 }`. Wallet rows are identified by the stable `accountType` values `MAIN`, `CARD`, and `FUTURE`; `walletName` is localized and must not be used as an identifier. The private transfer BAPI uses those same 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. +For manual full rebalancing, 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. -Hiding the tab after the user confirms a rebalance does not cancel its remaining transfers. The initial eligibility and preview still require a visible trading page; each confirmed transfer continues to require the same fresh account checks, matching balances, and current trading route while hidden. +Automatic withdrawal requires visibility again before each transfer. Hiding the tab after the user confirms a manual rebalance does not cancel its remaining transfers. The initial eligibility and preview still require a visible trading page; each confirmed transfer continues to require the same fresh account checks, matching balances, and current trading route while hidden. 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. diff --git a/e2e/binance-orderbook/fixtures/account-rebalance-api.js b/e2e/binance-orderbook/fixtures/account-rebalance-api.js index 172097a5..914befe9 100644 --- a/e2e/binance-orderbook/fixtures/account-rebalance-api.js +++ b/e2e/binance-orderbook/fixtures/account-rebalance-api.js @@ -1,4 +1,7 @@ export const ACCOUNT_PATHS = Object.freeze({ + identity: '/bapi/accounts/v1/private/account/user/base-detail', + basicOrders: '/bapi/futures/v1/private/future/order/open-orders', + conditionalOrders: '/bapi/futures/v1/private/future/order/open-algo-order', positions: '/bapi/futures/v6/private/future/user-data/user-position', wallets: '/bapi/asset/v2/private/asset-service/wallet/balance', withdrawable: '/bapi/futures/v1/private/future/user-data/getMaxWithdrawAmount', @@ -25,6 +28,8 @@ function amount(value) { export function createAccountRebalanceApi(initialBalances, { commitTransfers = true } = {}) { let balances = Object.fromEntries(['FUNDING', 'MAIN', 'UMFUTURE'].map(key => [key, units(initialBalances[key])])); let positions = []; + let identity = { userId: 'fixture-account', isExistFutureAccount: true, isPortfolioMarginRetailUser: false }; + let orders = { basic: [], conditional: [] }; const requests = []; const failures = new Map(); const pendingTransfers = []; @@ -38,6 +43,8 @@ export function createAccountRebalanceApi(initialBalances, { commitTransfers = t return { supports: pathname => Object.values(ACCOUNT_PATHS).includes(pathname), setPositions(value) { positions = structuredClone(value); }, + setIdentity(value) { identity = structuredClone(value); }, + setOrders(value) { orders = structuredClone(value); }, setBalances(value) { balances = Object.fromEntries(['FUNDING', 'MAIN', 'UMFUTURE'].map(key => [key, units(value[key])])); }, @@ -62,10 +69,23 @@ export function createAccountRebalanceApi(initialBalances, { commitTransfers = t if (!Object.values(ACCOUNT_PATHS).includes(pathname)) throw new Error('Unknown account fixture endpoint'); const expectedMethod = pathname === ACCOUNT_PATHS.wallets ? 'GET' : 'POST'; if (method !== expectedMethod) throw new Error('Account fixture received the wrong HTTP method'); + if (pathname === ACCOUNT_PATHS.identity || pathname === ACCOUNT_PATHS.basicOrders) { + if (!body || typeof body !== 'object' || Array.isArray(body) || Object.keys(body).length !== 0) { + throw new Error('Unexpected account identity or basic orders request'); + } + } + if (pathname === ACCOUNT_PATHS.conditionalOrders) { + if (body?.algoType !== 'CONDITIONAL' || Object.keys(body).join(',') !== 'algoType') { + throw new Error('Unexpected conditional orders request'); + } + } requests.push({ pathname, method, body: structuredClone(body) }); const failed = failures.get(pathname)?.shift(); if (failed) return failed; let payload; + if (pathname === ACCOUNT_PATHS.identity) payload = { success: true, code: '000000', data: structuredClone(identity) }; + if (pathname === ACCOUNT_PATHS.basicOrders) payload = { success: true, code: '000000', data: structuredClone(orders.basic) }; + if (pathname === ACCOUNT_PATHS.conditionalOrders) payload = { success: true, code: '000000', data: structuredClone(orders.conditional) }; if (pathname === ACCOUNT_PATHS.positions) payload = { success: true, data: structuredClone(positions) }; if (pathname === ACCOUNT_PATHS.wallets) { payload = { diff --git a/e2e/binance-orderbook/specs/account-lifecycle-behavior.pw.js b/e2e/binance-orderbook/specs/account-lifecycle-behavior.pw.js index a214d30e..96b3dede 100644 --- a/e2e/binance-orderbook/specs/account-lifecycle-behavior.pw.js +++ b/e2e/binance-orderbook/specs/account-lifecycle-behavior.pw.js @@ -22,7 +22,11 @@ async function openSettledAccount(page, options) { } async function expectEligible(page) { - await page.clock.runFor(32); + // Automatic account checks finish asynchronously before their next scheduled render. + await expect.poll(async () => { + await page.clock.runFor(32); + return page.locator(ACTION).isEnabled(); + }).toBe(true); await expect(page.locator(ACTION)).toBeVisible(); await expect(page.locator(ACTION)).toBeEnabled(); } @@ -369,20 +373,22 @@ test('user previews balances published during qualification instead of an earlie await host.waitForPositionResponses(3); await expectEligible(page); - // Then qualification has not cached balances or sent an account transfer. - expect(host.api.snapshot().requests.filter(request => request.pathname === ACCOUNT_PATHS.wallets)).toEqual([]); + // Then automatic qualification reads current wallets without transferring a futures deficit. + const automaticWalletReads = host.api.snapshot().requests.filter(request => request.pathname === ACCOUNT_PATHS.wallets).length; + expect(automaticWalletReads).toBe(1); await host.expectNoTradingActions(); // When the user opens the now-eligible account preview. + host.api.setBalances({ FUNDING: '70', MAIN: '30', UMFUTURE: '0' }); await page.locator(ACTION).evaluate(button => button.click()); const dialog = page.getByRole('dialog', { name: '账户再平衡' }); // Then the reviewed plan uses the newly published balances and remains unexecuted. await expect(dialog).toBeVisible(); - await expect(dialog).toContainText('20 USDT'); - await expect(dialog).toContainText('10 USDT'); - await expect(dialog).not.toContainText('40 USDT'); - expect(host.api.snapshot().balances).toEqual({ FUNDING: '80', MAIN: '20', UMFUTURE: '0' }); + await expect(dialog.locator('.jh-rebalance-dialog-transfer-amount')).toHaveText(['10 USDT', '10 USDT']); + expect(host.api.snapshot().requests.filter(request => request.pathname === ACCOUNT_PATHS.wallets).length) + .toBe(automaticWalletReads + 1); + expect(host.api.snapshot().balances).toEqual({ FUNDING: '70', MAIN: '30', UMFUTURE: '0' }); await dialog.getByRole('button', { name: '取消', exact: true }).evaluate(button => button.click()); await host.expectNoTradingActions(); }); diff --git a/e2e/binance-orderbook/specs/account-rebalance-behavior.pw.js b/e2e/binance-orderbook/specs/account-rebalance-behavior.pw.js index 2e6c212e..3814e752 100644 --- a/e2e/binance-orderbook/specs/account-rebalance-behavior.pw.js +++ b/e2e/binance-orderbook/specs/account-rebalance-behavior.pw.js @@ -193,10 +193,11 @@ test('user sees a partial account result when an acknowledged transfer never app const dialog = page.getByRole('dialog', { name: '账户再平衡' }); await expect(dialog).toBeVisible(); await pauseScenarioClock(page); + const walletReadsBeforeConfirmation = api.snapshot().requests.filter(request => request.pathname === ACCOUNT_PATHS.wallets).length; // When the user confirms and the first balance observation still has the old values. await dialog.getByRole('button', { name: '确认再平衡', exact: true }).evaluate(button => button.click()); - await expect.poll(() => api.snapshot().requests.filter(request => request.pathname === ACCOUNT_PATHS.wallets).length).toBe(3); + await expect.poll(() => api.snapshot().requests.filter(request => request.pathname === ACCOUNT_PATHS.wallets).length).toBe(walletReadsBeforeConfirmation + 2); await page.clock.runFor(1); // Then the second transfer remains blocked while the first one awaits balance confirmation. @@ -222,8 +223,9 @@ test('user sees one completed account transfer when the next transfer fails afte const dialog = page.getByRole('dialog', { name: '账户再平衡' }); await expect(dialog).toBeVisible(); await pauseScenarioClock(page); + const walletReadsBeforeConfirmation = api.snapshot().requests.filter(request => request.pathname === ACCOUNT_PATHS.wallets).length; await dialog.getByRole('button', { name: '确认再平衡', exact: true }).evaluate(button => button.click()); - await expect.poll(() => api.snapshot().requests.filter(request => request.pathname === ACCOUNT_PATHS.wallets).length).toBe(3); + await expect.poll(() => api.snapshot().requests.filter(request => request.pathname === ACCOUNT_PATHS.wallets).length).toBe(walletReadsBeforeConfirmation + 2); await page.clock.runFor(1); // When the account publishes the first transfer and explicitly rejects the next request. diff --git a/e2e/binance-orderbook/specs/automatic-account-rebalance.pw.js b/e2e/binance-orderbook/specs/automatic-account-rebalance.pw.js new file mode 100644 index 00000000..ed697af9 --- /dev/null +++ b/e2e/binance-orderbook/specs/automatic-account-rebalance.pw.js @@ -0,0 +1,256 @@ +import { test, expect, reloadPageWithCoverage } from '../test.js'; +import { ACCOUNT_PATHS, createAccountRebalanceApi } from '../fixtures/account-rebalance-api.js'; +import { createCancelScenario, ORDER_SETS } from '../scenarios/cancel-current-symbol.js'; +import { openUserscriptScenario, readFixtureState } from '../helpers/userscript-page.js'; +import { installScenarioClock, pauseScenarioClock } from '../helpers/scenario-clock.js'; + +const PREFIX = 'userscripts:automatic-usdt-rebalance:v1:'; +const EXCESS = { FUNDING: '0', MAIN: '0', UMFUTURE: '100' }; +const TARGET = { FUNDING: '50', MAIN: '40', UMFUTURE: '10' }; +const transfers = api => api.snapshot().requests.filter(r => r.pathname === ACCOUNT_PATHS.transfer); + +async function episodes(page) { + return page.evaluate(prefix => Object.keys(localStorage).filter(k => k.startsWith(prefix)) + .map(k => JSON.parse(localStorage.getItem(k)).status), PREFIX); +} + +async function openAutomatic(page, api) { + await installScenarioClock(page); + const loaded = await openUserscriptScenario(page, createCancelScenario({ orders: ORDER_SETS.current })); + await pauseScenarioClock(page); + await page.route('https://www.binance.com/bapi/**', async route => { + const request = route.request(); + const url = new URL(request.url()); + if (!api.supports(url.pathname)) return route.fallback(); + expect(url.search).toBe(url.pathname === ACCOUNT_PATHS.wallets + ? '?needBalanceDetail=true"eAsset=USDT' : ''); + const response = api.handle({ pathname: url.pathname, method: request.method(), + body: request.postData() === null ? undefined : request.postDataJSON() }); + await route.fulfill({ status: response.status, contentType: 'application/json', body: JSON.stringify(response.body) }); + }); + return loaded; +} + +async function becomeFlat(page) { + await page.evaluate(() => window.__BINANCE_FIXTURE__.setOrders([])); + await page.clock.runFor(32); + await page.clock.runFor(2000); +} + +test('user automatically withdraws excess once after a stable empty account without a confirmation dialog', async ({ page }) => { + // Given all funds are in Futures while a native order blocks qualification. + const api = createAccountRebalanceApi(EXCESS); + const { errors } = await openAutomatic(page, api); + + // When the account becomes flat and stays empty for the qualification window. + await becomeFlat(page); + + // Then only Futures withdrawals occur and the episode is durably consumed. + await expect.poll(() => api.snapshot().balances).toEqual(TARGET); + await expect.poll(() => episodes(page)).toEqual(['consumed']); + expect(transfers(api).map(r => r.body)).toEqual([ + { asset: 'USDT', amount: '50', kindType: 'FUTURE_CARD' }, + { asset: 'USDT', amount: '40', kindType: 'FUTURE_MAIN' }, + ]); + await expect(page.getByRole('dialog')).toHaveCount(0); + expect((await readFixtureState(page)).events.filter(e => /order-submitted|cancel-requested/.test(e.type))).toEqual([]); + expect(errors).toEqual([]); +}); + +test('user keeps reserves after a loss and does not repeat an empty episode after reload', async ({ page }) => { + // Given Futures is below its allocation and reserves remain untouched. + const below = { FUNDING: '50', MAIN: '40', UMFUTURE: '0' }; + const api = createAccountRebalanceApi(below); + await openAutomatic(page, api); + + // When automatic qualification completes without any excess to transfer. + await becomeFlat(page); + + // Then no reserve funds are moved and the zero-transfer episode is consumed. + await expect.poll(() => episodes(page)).toEqual(['consumed']); + expect(transfers(api)).toEqual([]); + expect(api.snapshot().balances).toEqual(below); + + // When a manual deposit changes balances and the user reloads the same empty episode. + api.setBalances(EXCESS); + await reloadPageWithCoverage(page); + await becomeFlat(page); + await expect(page.locator('[data-usdt-rebalance]')).toBeEnabled(); + + // Then reloading never creates a second automatic execution entitlement. + expect(await episodes(page)).toEqual(['consumed']); + expect(transfers(api)).toEqual([]); +}); + +for (const kind of ['basic', 'conditional']) { + test(`user keeps funds when an authoritative other-symbol ${kind} order is missing from native counters`, async ({ page }) => { + // Given an API order exists even though the visible counters will show zero. + const api = createAccountRebalanceApi(EXCESS); + api.setOrders({ basic: [], conditional: [], [kind]: [{ symbol: 'BTCUSDT' }] }); + await openAutomatic(page, api); + + // When native counters qualify but fresh all-symbol order reads disagree. + await becomeFlat(page); + + // Then the server-side order prevents every transfer. + await expect.poll(() => api.snapshot().requests.filter(r => r.pathname === ACCOUNT_PATHS.conditionalOrders).length).toBeGreaterThan(0); + await expect(page.locator('[data-usdt-rebalance]')).toBeEnabled(); + expect(transfers(api)).toEqual([]); + expect(api.snapshot().balances).toEqual(EXCESS); + }); +} + +test('user receives one automatic allocation across two simultaneous currency tabs', async ({ page, context }) => { + // Given two pages share one browser storage partition and one account. + const api = createAccountRebalanceApi(EXCESS); + await openAutomatic(page, api); + await page.clock.resume(); + const second = await context.newPage(); + await openAutomatic(second, api); + + // When both pages qualify for the same empty account at the same time. + await Promise.all([page, second].map(tab => tab.evaluate(() => window.__BINANCE_FIXTURE__.setOrders([])))); + await page.clock.runFor(2032); + + // Then shared locking and durable state permit only the two planned transfers. + await expect.poll(() => api.snapshot().balances).toEqual(TARGET); + await expect.poll(() => episodes(page)).toEqual(['consumed']); + expect(transfers(api)).toHaveLength(2); + expect(await episodes(second)).toEqual(['consumed']); +}); + +test('user qualifies again only after independently verified new account activity', async ({ page }) => { + // Given the first automatic episode has completed. + const api = createAccountRebalanceApi(EXCESS); + await openAutomatic(page, api); + await becomeFlat(page); + await expect.poll(() => episodes(page)).toEqual(['consumed']); + + // When a new server-side order is observed before its later removal. + api.setOrders({ basic: [{ symbol: 'BTCUSDT' }], conditional: [] }); + await page.evaluate(orders => window.__BINANCE_FIXTURE__.setOrders(orders), ORDER_SETS.other); + await page.clock.runFor(32); + await expect.poll(() => episodes(page)).toEqual(['active']); + api.setOrders({ basic: [], conditional: [] }); + api.setBalances(EXCESS); + await becomeFlat(page); + + // Then this distinct empty episode performs its own single allocation. + await expect.poll(() => transfers(api).length).toBe(4); + await expect.poll(() => episodes(page)).toEqual(['consumed']); + expect(api.snapshot().balances).toEqual(TARGET); +}); + +test('user never repeats a transfer with an unknown response after reloading', async ({ page }) => { + // Given the server commits a transfer but its response is lost. + const api = createAccountRebalanceApi(EXCESS); + await openAutomatic(page, api); + let interrupted = false; + await page.route('https://www.binance.com' + ACCOUNT_PATHS.transfer, async route => { + if (interrupted) return route.fallback(); + interrupted = true; + api.handle({ pathname: ACCOUNT_PATHS.transfer, method: 'POST', body: route.request().postDataJSON() }); + await route.abort('failed'); + }); + + // When the first transfer response fails after the server has moved funds. + await becomeFlat(page); + + // Then the pending record survives and no second transfer is attempted. + await expect.poll(() => episodes(page)).toEqual(['in_flight']); + await expect(page.locator('[data-usdt-rebalance]')).toBeEnabled(); + expect(transfers(api)).toHaveLength(1); + expect(api.snapshot().balances).toEqual({ FUNDING: '50', MAIN: '0', UMFUTURE: '50' }); + + // When the user reloads the page with the unresolved operation. + await reloadPageWithCoverage(page); + await becomeFlat(page); + await expect(page.locator('#jh-binance-auto-rebalance-status')).toContainText('previous outcome'); + + // Then no automatic retry can duplicate the unknown transaction. + expect(transfers(api)).toHaveLength(1); + expect(await episodes(page)).toEqual(['in_flight']); +}); + +test('user keeps funds when the account identity indicates portfolio margin', async ({ page }) => { + // Given this account does not satisfy the ordinary Futures mode contract. + const api = createAccountRebalanceApi(EXCESS); + api.setIdentity({ userId: 'portfolio-fixture', isExistFutureAccount: true, isPortfolioMarginRetailUser: true }); + await openAutomatic(page, api); + + // When the visible account becomes flat. + await becomeFlat(page); + + // Then the unverified account mode is refused before any wallet transfer. + await expect(page.locator('#jh-binance-auto-rebalance-status')).toContainText('identity'); + expect(transfers(api)).toEqual([]); + expect(await episodes(page)).toEqual([]); +}); + +test('user completes automatic allocation after another account observer releases its shared lock', async ({ page }) => { + // Given an independent observer owns the same real browser shared lock. + const api = createAccountRebalanceApi(EXCESS); + await openAutomatic(page, api); + await page.evaluate(() => new Promise(resolve => { + navigator.locks.request('userscripts:usdt-account-operation:v1', { mode: 'shared' }, () => new Promise(release => { + window.__RELEASE_ACCOUNT_OBSERVER__ = release; + resolve(); + })); + })); + + // When the flat qualification window expires while that observer is still running. + await becomeFlat(page); + await expect.poll(() => page.evaluate(async () => (await navigator.locks.query()).pending + .filter(lock => lock.name === 'userscripts:usdt-account-operation:v1').length)).toBe(1); + + // Then no transfer starts before exclusive ownership is available. + expect(transfers(api)).toEqual([]); + + // When the observer releases its lock without another counter change. + await page.evaluate(() => window.__RELEASE_ACCOUNT_OBSERVER__()); + + // Then the waiting operation reacquires fresh evidence and completes exactly once. + await expect.poll(() => api.snapshot().balances).toEqual(TARGET); + await expect.poll(() => episodes(page)).toEqual(['consumed']); + expect(transfers(api)).toHaveLength(2); +}); + +test('user cannot auto-transfer from a copy-trading query route', async ({ page }) => { + // Given a regular account page switches to the native copy-trading query mode. + const api = createAccountRebalanceApi(EXCESS); + await openAutomatic(page, api); + await page.evaluate(() => history.replaceState(null, '', location.pathname + '?cl=public')); + + // When its native account counters become empty. + await becomeFlat(page); + + // Then the unverified route is refused before any transfer. + await expect(page.locator('#jh-binance-auto-rebalance-status')).toContainText('ordinary'); + expect(transfers(api)).toEqual([]); + expect(api.snapshot().balances).toEqual(EXCESS); +}); + +test('user sees a paused automatic check when new orders invalidate a pending transfer lock', async ({ page }) => { + // Given another operation owns the lock while this flat account waits for access. + const api = createAccountRebalanceApi(EXCESS); + await openAutomatic(page, api); + await page.evaluate(() => new Promise(resolve => { + navigator.locks.request('userscripts:usdt-account-operation:v1', () => new Promise(release => { + window.__RELEASE_TRANSFER_LOCK__ = release; + resolve(); + })); + })); + await becomeFlat(page); + await expect(page.locator('#jh-binance-auto-rebalance-status')).toContainText('waiting for account access'); + + // When a new native order invalidates the pending flat qualification. + await page.evaluate(orders => window.__BINANCE_FIXTURE__.setOrders(orders), ORDER_SETS.current); + await page.clock.runFor(32); + await page.evaluate(() => window.__RELEASE_TRANSFER_LOCK__()); + + // Then the obsolete request is cancelled and the visible waiting state ends. + await expect(page.locator('#jh-binance-auto-rebalance-status')).toContainText('paused'); + await expect.poll(() => page.evaluate(async () => (await navigator.locks.query()).pending.length)).toBe(0); + expect(transfers(api)).toEqual([]); + expect(api.snapshot().balances).toEqual(EXCESS); +}); diff --git a/e2e/binance-orderbook/specs/cancel-boundaries-behavior.pw.js b/e2e/binance-orderbook/specs/cancel-boundaries-behavior.pw.js index d241fa01..1f48bd34 100644 --- a/e2e/binance-orderbook/specs/cancel-boundaries-behavior.pw.js +++ b/e2e/binance-orderbook/specs/cancel-boundaries-behavior.pw.js @@ -51,6 +51,19 @@ function expectOriginalUi(state, scenario) { }); } +/** Start the shared-lock task before measuring its native UI acknowledgement deadline. */ +async function advancePendingCancellation(page, elapsed) { + const requestedAt = await page.evaluate(() => Date.now()); + await page.locator(CANCEL).click(); + await expect.poll(async () => { + await page.clock.runFor(16); + return page.locator(CANCEL).isDisabled(); + }).toBe(true); + const elapsedSinceRequest = await page.evaluate(start => Date.now() - start, requestedAt); + expect(elapsedSinceRequest).toBeLessThan(elapsed); + await page.clock.runFor(elapsed - elapsedSinceRequest); +} + test('user keeps all orders when the current-orders tab is missing', async ({ page }) => { // Given both symbols have Basic and conditional orders but the current-orders tab is absent. const scenario = createCancelScenario({ positions: POSITION_SETS.both, orders: ALL_ORDERS }); @@ -86,8 +99,7 @@ test('user sees a bounded refusal when the current-orders tab never becomes sele }); // When the user requests cancellation before the tab-selection deadline. - await page.locator(CANCEL).click(); - await page.clock.runFor(2100); + await advancePendingCancellation(page, 2100); // Then selection remains pending and no confirmation or cancellation has occurred. await expect(page.locator(CANCEL)).toBeDisabled(); @@ -126,8 +138,7 @@ for (const scopeState of ['missing', 'duplicated']) { }, scopeState); // When the user requests cancellation before the unique-panel discovery deadline. - await page.locator(CANCEL).click(); - await page.clock.runFor(2100); + await advancePendingCancellation(page, 2100); // Then the workflow remains pending without selecting an unverified cancellation scope. await expect(page.locator(CANCEL)).toBeDisabled(); @@ -191,8 +202,7 @@ test('user cannot cancel conditional orders when Basic selection never commits', }); // When the user requests cancellation and Basic selection is still within its deadline. - await page.locator(CANCEL).click(); - await page.clock.runFor(2100); + await advancePendingCancellation(page, 2100); // Then the action remains pending with conditional orders preserved and no confirmation. await expect(page.locator(CANCEL)).toBeDisabled(); @@ -257,8 +267,7 @@ test('user cannot cancel while the symbol checkbox ignores its requested change' await page.locator(FILTER).evaluate((filter) => filter.replaceWith(filter.cloneNode(true))); // When cancellation requests the filter but its one-second acknowledgement is still pending. - await page.locator(CANCEL).click(); - await page.clock.runFor(900); + await advancePendingCancellation(page, 900); // Then no cancellation occurs while the checkbox still reports the original unchecked state. await expect(page.locator(CANCEL)).toBeDisabled(); @@ -301,8 +310,7 @@ test('user cannot cancel when a checked symbol filter still exposes another symb }, ORDER_SETS.both[1]); // When the user requests cancellation while the filtered-row settling deadline is still pending. - await page.locator(CANCEL).click(); - await page.clock.runFor(1500); + await advancePendingCancellation(page, 1500); // Then the script waits for row evidence instead of trusting the checked box alone. await expect(page.locator(CANCEL)).toBeDisabled(); diff --git a/e2e/binance-orderbook/specs/continuous-rebalance-behavior.pw.js b/e2e/binance-orderbook/specs/continuous-rebalance-behavior.pw.js index 8c6362dc..c9c2628e 100644 --- a/e2e/binance-orderbook/specs/continuous-rebalance-behavior.pw.js +++ b/e2e/binance-orderbook/specs/continuous-rebalance-behavior.pw.js @@ -111,6 +111,10 @@ for (const side of ['LONG', 'SHORT']) { await page.clock.runFor(32); // Then the action returns automatically without any extra order or transfer. + await expect.poll(async () => { + await page.clock.runFor(32); + return page.locator(ACTION).isEnabled(); + }).toBe(true); await expect(page.locator(ACTION)).toBeVisible(); await expect(page.locator(ACTION)).toBeEnabled(); await expectNoAdditionalTrading(page, host); @@ -143,13 +147,14 @@ for (const blocker of ['native position', 'native order', 'API position']) { } test('user qualifies after returning to a page where continuous closing finished while hidden', async ({ page }) => { - // Given a continuous close is pending while the account becomes flat. + // Given a continuous close is pending with its visible position snapshot settled. const host = await openPendingClose(page, 'LONG'); await installSimulatedVisibility(page); - await publishAccount(page); + await page.clock.runFor(32); - // When the page hides before the final acknowledgement arrives. + // When the page hides before the flat account update and final acknowledgement arrive. await setSimulatedVisibility(page, true); + await publishAccount(page); await host.releaseSubmitResponse(3); await page.clock.runFor(2050); diff --git a/e2e/binance-orderbook/specs/control-flows.pw.js b/e2e/binance-orderbook/specs/control-flows.pw.js index eeff6484..4f113eb8 100644 --- a/e2e/binance-orderbook/specs/control-flows.pw.js +++ b/e2e/binance-orderbook/specs/control-flows.pw.js @@ -555,6 +555,9 @@ test('user stops an active ladder and a late response cannot submit another leve }, }); const { errors, pendingSubmitSequences, releaseSubmitResponse } = await openUserscriptScenario(page, scenario); + await page.route('https://www.binance.com/bapi/accounts/v1/private/account/user/base-detail', route => route.fulfill({ + status: 503, contentType: 'application/json', body: JSON.stringify({ success: false }), + })); const panel = page.locator(PANEL_SELECTOR); await panel.getByRole('button', { name: '阶梯开多', exact: true }).click(); await expect.poll(pendingSubmitSequences).toEqual([1]); @@ -579,6 +582,7 @@ test('user stops an active ladder and a late response cannot submit another leve // Then native readiness and late success cannot restart the stopped ladder. await expect(status).toContainText('阶梯开多已停止'); await expect(panel.getByRole('button', { name: '阶梯开多', exact: true })).toBeEnabled(); + await expect(panel.locator('#jh-binance-auto-rebalance-status')).toContainText('HTTP 503'); const state = await readFixtureState(page); expect(state.events.filter(({ type }) => type === 'submit-button-ready')).toHaveLength(1); expect(state.events.filter(({ type }) => type === 'order-submitted')).toHaveLength(1); diff --git a/e2e/binance-orderbook/specs/draft-preparation-boundaries.pw.js b/e2e/binance-orderbook/specs/draft-preparation-boundaries.pw.js index d468ca0f..b4e50839 100644 --- a/e2e/binance-orderbook/specs/draft-preparation-boundaries.pw.js +++ b/e2e/binance-orderbook/specs/draft-preparation-boundaries.pw.js @@ -143,7 +143,10 @@ test('user ignores programmatic and unreadable price clicks before accepting one await page.clock.runFor(100); // Then exactly the real valid click is acknowledged with its concrete fields. - await expect(page.locator('#jh-binance-ladder-status')).toHaveText('单击开多已提交 · 81.0 × 0.07'); + await expect.poll(async () => { + await page.clock.runFor(16); + return page.locator('#jh-binance-ladder-status').textContent(); + }).toBe('单击开多已提交 · 81.0 × 0.07'); expect((await readFixtureState(page)).events.filter(({ type }) => type === 'order-submitted') .map(({ action, price, quantity }) => ({ action, price, quantity }))) .toEqual([{ action: '开多', price: '81.0', quantity: '0.07' }]); diff --git a/e2e/binance-orderbook/specs/native-submit-boundaries.pw.js b/e2e/binance-orderbook/specs/native-submit-boundaries.pw.js index 3c6ba86e..43332c13 100644 --- a/e2e/binance-orderbook/specs/native-submit-boundaries.pw.js +++ b/e2e/binance-orderbook/specs/native-submit-boundaries.pw.js @@ -10,6 +10,14 @@ async function submissions(page) { return (await readFixtureState(page)).events.filter(({ type }) => type === 'order-submitted'); } +/** Web Lock delivery is a browser event; advance business time only until native validation starts. */ +async function waitForNativeAttempt(page, native) { + await expect.poll(async () => { + await page.clock.runFor(16); + return native.evaluate(boundary => boundary.snapshot().attempts.length); + }).toBe(1); +} + for (const feedback of [ { label: 'a new client rejection', initial: '', text: '订单提交失败', markup: false }, { label: 'a reused toast with updated text', initial: '订单已提交成功', text: '订单提交失败', markup: false }, @@ -24,8 +32,7 @@ for (const feedback of [ // When a trusted price click reaches native validation and a new rejection is published. await page.locator('#futuresOrderbook .bid-light').first().click(); - await page.clock.runFor(100); - await expect.poll(() => native.evaluate(boundary => boundary.snapshot().attempts.length)).toBe(1); + await waitForNativeAttempt(page, native); await native.evaluate((boundary, feedback) => boundary.publish(feedback.text, { replaceMarkup: feedback.markup }), feedback); // Then the rejection is associated with this single attempt without inventing an API error or sending a request. @@ -54,8 +61,7 @@ for (const boundary of [ // When a real single-order click receives only the declared native UI evidence. await page.locator('#futuresOrderbook .bid-light').first().click(); - await page.clock.runFor(100); - await expect.poll(() => native.evaluate(host => host.snapshot().attempts.length)).toBe(1); + await waitForNativeAttempt(page, native); if (boundary.text !== null) await native.evaluate((host, text) => host.publish(text), boundary.text); await page.clock.runFor(3000); @@ -91,8 +97,7 @@ test('user receives a close-maker rejection from native validation without autom // When a trusted close price reaches validation and receives a Post Only maker rejection. await page.locator('#futuresOrderbook .bid-light').first().click(); - await page.clock.runFor(100); - await expect.poll(() => native.evaluate(boundary => boundary.snapshot().attempts.length)).toBe(1); + await waitForNativeAttempt(page, native); await native.evaluate(boundary => boundary.publish('Post Only order rejected: could not be executed as a maker')); await page.clock.runFor(1500); @@ -131,6 +136,10 @@ for (const native of [ // When the ordinary ladder exhausts the native button's three-second readiness window. await page.locator('[data-ladder-action="OPEN_LONG"]').evaluate(button => button.click()); + await expect.poll(async () => { + await page.clock.runFor(16); + return page.locator(STATUS).textContent(); + }).toBe('阶梯开多计划:3.5% / 5档 / 幅5'); await page.clock.runFor(3500); // Then the matching readiness reason stops the ladder with zero confirmed or sent orders. @@ -155,6 +164,10 @@ for (const field of [ // When a real book click exercises the rejected field and the synchronization deadline expires. await page.locator('#futuresOrderbook .bid-light').nth(field.name === 'price' ? 1 : 0).click(); + await expect.poll(async () => { + await page.clock.runFor(16); + return native.evaluate(boundary => boundary.snapshot().proposed.length); + }).toBeGreaterThan(0); await page.clock.runFor(600); // Then the mismatch is explicit, writes remain bounded, and no partially synchronized order is submitted. diff --git a/e2e/binance-orderbook/specs/order-submit-behavior.pw.js b/e2e/binance-orderbook/specs/order-submit-behavior.pw.js index 04f0de72..437e1e0a 100644 --- a/e2e/binance-orderbook/specs/order-submit-behavior.pw.js +++ b/e2e/binance-orderbook/specs/order-submit-behavior.pw.js @@ -13,6 +13,67 @@ const DIRECTIONS = [ { action: 'CLOSE_SHORT', mode: 'CLOSE', side: 'SHORT', label: '平空', qty: '0.06', prices: ['80.9', '80.4', '79.9', '81.9', '81.4', '80.9'] }, ]; +for (const continuous of [false, true]) { + test(`user stops a ${continuous ? 'continuous' : 'single-round'} ladder before its account lock callback starts`, async ({ page }) => { + // Given a ready close action can start either a round or a continuous session. + await installScenarioClock(page); + const host = await openUserscriptScenario(page, createCancelScenario({ + positions: [{ symbol: CURRENT_SYMBOL, side: 'LONG', quantity: '1' }], + ui: { tradeMode: 'CLOSE' }, + })); + const button = page.locator('[data-ladder-action="CLOSE_LONG"]'); + await expect(button).toBeEnabled(); + await pauseScenarioClock(page); + + // When Start and Stop occur in one event turn before Web Locks can call back. + await button.evaluate((element, continuous) => { + element.dispatchEvent(new MouseEvent('click', { bubbles: true, altKey: continuous })); + window.__TM_CLOSE_LONG_DEBUG__.stopLadder(); + }, continuous); + await page.clock.runFor(400); + await expect.poll(async () => { + await page.clock.runFor(16); + return page.locator(STATUS).textContent(); + }).toContain('已停止'); + + // Then the deferred callback cannot start native trading or chart-save preparation. + await page.clock.runFor(400); + const state = await readFixtureState(page); + expect(state.events.filter(event => /order-submitted|cancel-requested|chart-orders-checked/.test(event.type))).toEqual([]); + await expect(button).toBeEnabled(); + expect(host.errors).toEqual([]); + }); +} + +test('user cannot start a ladder while another page owns the transfer lock', async ({ page }) => { + // Given another account operation has exclusive ownership of the origin lock. + await installScenarioClock(page); + const host = await openUserscriptScenario(page, createCancelScenario()); + await pauseScenarioClock(page); + await page.evaluate(() => new Promise(resolve => { + navigator.locks.request('userscripts:usdt-account-operation:v1', () => new Promise(release => { + window.__RELEASE_TRANSFER_LOCK__ = release; + resolve(); + })); + })); + + // When the user clicks an ordinary ladder while the transfer remains active. + await page.locator('[data-ladder-action="OPEN_LONG"]').evaluate(button => button.click()); + await expect.poll(async () => { + await page.clock.runFor(16); + return page.locator(STATUS).textContent(); + }).toContain('another tab'); + await page.clock.runFor(400); + + // Then the request is refused and no delayed order is queued behind the transfer. + expect((await readFixtureState(page)).events.filter(event => event.type === 'order-submitted')).toEqual([]); + await expect(page.locator('[data-ladder-action="OPEN_LONG"]')).toBeEnabled(); + await page.evaluate(() => window.__RELEASE_TRANSFER_LOCK__()); + await page.clock.runFor(400); + expect((await readFixtureState(page)).events.filter(event => event.type === 'order-submitted')).toEqual([]); + expect(host.errors).toEqual([]); +}); + for (const direction of [ { action: 'OPEN_LONG', mode: 'OPEN', label: '开多' }, { action: 'CLOSE_SHORT', mode: 'CLOSE', label: '平空' }, diff --git a/e2e/binance-orderbook/specs/panel-visual-contract.pw.js b/e2e/binance-orderbook/specs/panel-visual-contract.pw.js index dcfc1252..035f9bd6 100644 --- a/e2e/binance-orderbook/specs/panel-visual-contract.pw.js +++ b/e2e/binance-orderbook/specs/panel-visual-contract.pw.js @@ -49,6 +49,7 @@ test('user sees the fixed panel layout in open mode', async ({ page }) => { await openUserscriptScenario(page, scenario); // Then the precision controls, full-width divider, and layout match the checked-in visual contract. await expectPrecisionReady(page); + await expect(page.locator('#jh-binance-auto-rebalance-status')).toBeVisible(); await expectNativeDivider(page); await expectVisualContract(page, 'open-fixed.visual.json'); }); diff --git a/e2e/binance-orderbook/specs/panel-visual-contract.pw.js-snapshots/open-fixed-visual.json b/e2e/binance-orderbook/specs/panel-visual-contract.pw.js-snapshots/open-fixed-visual.json index b4f5b7a9..ce5d47c1 100644 --- a/e2e/binance-orderbook/specs/panel-visual-contract.pw.js-snapshots/open-fixed-visual.json +++ b/e2e/binance-orderbook/specs/panel-visual-contract.pw.js-snapshots/open-fixed-visual.json @@ -1,7 +1,7 @@ { "panel": { "width": 314, - "height": 407, + "height": 431, "padding": "8px 10px", "borderRadius": "10px" }, diff --git a/scripts/binance-orderbook-trade.user.js b/scripts/binance-orderbook-trade.user.js index dbca8dda..5f7dafe7 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.220 +// @version 2.7.221 // @author jackhai9 // @description 单击订单簿价格,按当前开仓/平仓 tab 自动填数量并执行下单,内置数量倍率面板 // @match https://www.binance.com/*/futures/* @@ -682,6 +682,26 @@ }; } + // src/binance-orderbook-trade/core/automatic-usdt-rebalance.js + function readAutomaticRebalanceEpisode(serialized) { + if (serialized === null) return { version: 1, status: "active" }; + const record = JSON.parse(serialized); + if (record?.version !== 1 || !["active", "consumed", "in_flight", "blocked"].includes(record.status)) { + throw new Error("Invalid automatic rebalance episode"); + } + return record; + } + function observeAutomaticRebalanceActivity(record, flat) { + if (!flat && record.status === "consumed") return { version: 1, status: "active" }; + return record; + } + function assertCompleteOpenOrders(payload) { + if (payload?.success !== true || !Array.isArray(payload.data)) { + throw new Error("Invalid complete open-order response"); + } + return payload.data.length; + } + // src/binance-orderbook-trade/core/usdt-rebalance.js var USDT_SCALE = 8; var ACCOUNT_ORDER = ["FUNDING", "MAIN", "UMFUTURE"]; @@ -846,6 +866,47 @@ transfers }; } + function buildAutomaticUsdtRebalancePlan(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 fundingTarget = totalUnits * 50n / 100n; + const spotTarget = totalUnits * 40n / 100n; + const futuresTarget = totalUnits - fundingTarget - spotTarget; + const targetUnits = { ...beforeUnits }; + const transfers = []; + if (beforeUnits.UMFUTURE > futuresTarget) { + const excess = beforeUnits.UMFUTURE - futuresTarget; + const fundingDeficit = fundingTarget > beforeUnits.FUNDING ? fundingTarget - beforeUnits.FUNDING : 0n; + const toFunding = excess < fundingDeficit ? excess : fundingDeficit; + const distributions = [ + { accountCode: "FUNDING", amount: toFunding }, + { accountCode: "MAIN", amount: excess - toFunding } + ]; + for (const { accountCode, amount } of distributions) { + if (amount === 0n) continue; + transfers.push({ + from: "UMFUTURE", + to: accountCode, + kindType: `${USDT_REBALANCE_ACCOUNTS.UMFUTURE.bapiCode}_${USDT_REBALANCE_ACCOUNTS[accountCode].bapiCode}`, + amount: unitsToDecimal(amount) + }); + targetUnits[accountCode] += amount; + targetUnits.UMFUTURE -= amount; + } + } + 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 划转账户无效"); @@ -7449,6 +7510,7 @@ 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 AUTOMATIC_USDT_REBALANCE_STATUS_ID = "jh-binance-auto-rebalance-status"; 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"; @@ -7625,11 +7687,16 @@ let activeUiLocale = resolveUiLocaleFromPathname(location.pathname); let ladderStatusText = PANEL_COPY.state.idle; let ladderStatusTitle = PANEL_COPY.state.idle; + let automaticUsdtRebalanceStatusText = ""; + let automaticUsdtRebalanceStatusTitle = ""; let usdtRebalanceEligibilityTimer = 0; let usdtRebalanceEligibilityEpoch = 0; let usdtRebalanceEligibilityTask = null; let usdtRebalanceEligible = false; let usdtRebalanceTask = null; + let automaticUsdtRebalanceObservationTask = null; + let automaticUsdtRebalanceLockController = null; + let automaticUsdtRebalanceStatusPending = false; let ladderPanelBodySignature = ""; let panelPositionInvalidated = true; let panelObservedSize = ""; @@ -8141,6 +8208,20 @@ if (statusEl.title !== renderedTitle) statusEl.title = renderedTitle; } } + function renderAutomaticUsdtRebalanceStatus(element) { + if (!element) return; + const text = ui(automaticUsdtRebalanceStatusText); + const title = ui(automaticUsdtRebalanceStatusTitle); + if (element.textContent !== text) element.textContent = text; + if (element.title !== title) element.title = title; + if (element.hidden !== (text === "")) element.hidden = text === ""; + } + function setAutomaticUsdtRebalanceStatus(text, title = text, pending = false) { + automaticUsdtRebalanceStatusPending = pending; + automaticUsdtRebalanceStatusText = localizeKnownUiStatus(text); + automaticUsdtRebalanceStatusTitle = localizeKnownUiStatus(title); + renderAutomaticUsdtRebalanceStatus(document.getElementById(AUTOMATIC_USDT_REBALANCE_STATUS_ID)); + } function isValidMultiplier(value) { return /^\d+$/.test(String(value || "").trim()) && Number(value) > 0; } @@ -8490,6 +8571,9 @@ if (isFuturesTradingPage() && getActiveTradeMode() === "OPEN") { queueAutoOpenLeveragePositionCheck("headers_ready"); } + if (isFuturesTradingPage() && (readAccountPositionCount() > 0 || getOpenOrdersTabCount() > 0)) { + queueAutomaticUsdtRebalanceActivityObservation(); + } }); } async function waitForLadderSubmitResponseObservations(captureId, timeoutMs, abortSignal = null) { @@ -10202,6 +10286,19 @@ } return { done, repriceAttempts, lastRepriceApiErrorCode }; } + async function withAccountOperationLock(mode, operation, queuedSignal = null, reportUnavailable = true) { + if (!navigator.locks) throw new Error("Account operations require Web Locks"); + return navigator.locks.request("userscripts:usdt-account-operation:v1", { + mode, + ...queuedSignal ? { signal: queuedSignal } : { ifAvailable: true } + }, async (lock) => { + if (!lock) { + if (reportUnavailable) setLadderStatus("Account operation blocked: another tab is transferring funds"); + return { status: "not_started" }; + } + return operation(); + }); + } async function startLadder(actionType, continuousProgress = null, chartSaveController = null) { const spec = getLadderActionSpec2(actionType); if (!spec) { @@ -10269,7 +10366,7 @@ } setLadderStatus(singleStatus); }; - const chartSaveRound = chartSaveController?.beginRound() || null; + let chartSaveRound = null; invalidateUsdtRebalanceEligibility(); ladderAbortController = abortController; activeLadderActionType = actionType; @@ -10285,6 +10382,8 @@ localizedText("准备中", "Preparing") ); const executeRound = async () => { + throwIfAborted(abortController.signal); + chartSaveRound = chartSaveController?.beginRound() || null; const { plan, done, @@ -10313,7 +10412,7 @@ const executionTask = (async () => { let result; try { - result = await executeRound(); + result = await (continuousSession ? executeRound() : withAccountOperationLock("shared", executeRound)); } catch (executionError) { if (chartSaveRound) { try { @@ -10334,13 +10433,12 @@ minimumMs: LADDER_ACTION_FEEDBACK_MIN_MS, now: () => performance.now(), delay - }).then(({ - plan, - done, - repriceAttempts, - lastRepriceApiErrorCode, - wasStopped - }) => { + }).then((outcome) => { + if (outcome.status === "not_started") { + throwIfAborted(abortController.signal); + return outcome; + } + const { plan, done, repriceAttempts, lastRepriceApiErrorCode, wasStopped } = outcome; if (!isCurrentObservedSymbol(actionSymbol)) { if (!continuousSession) { setLadderStatus(formatInterruptedLadderProgress( @@ -10451,6 +10549,7 @@ activeLadderActionType = null; activeLadderPanelContext = null; ladderStopRequested = false; + if (!continuousSession) restartUsdtRebalanceEligibilityFromCurrentAccountState(); scheduleRenderPanel(); }); scheduleRenderPanel(); @@ -10560,8 +10659,8 @@ continuousChartSaveController = chartSaveCoalescer; activeContinuousLadderActionType = actionType; activeContinuousLadderProgress = continuousProgress; - const executionTask = (async () => { - await Promise.resolve(); + const executionTask = withAccountOperationLock("shared", async () => { + throwIfAborted(abortController.signal); chartSaveCoalescer = await startContinuousChartSaveCoalescing(abortController.signal, actionSymbol); continuousChartSaveController = chartSaveCoalescer; throwIfAborted(abortController.signal); @@ -10649,8 +10748,11 @@ } continue; } - })(); - continuousLadderTask = executionTask.catch((e) => { + }); + continuousLadderTask = executionTask.then((outcome) => { + if (outcome.status === "not_started") throwIfAborted(abortController.signal); + return outcome; + }).catch((e) => { if (isLadderStoppedError(e)) { setContinuousLadderProgressStatus(spec.statusLabel, "stopped", continuousProgress); return { status: "stopped" }; @@ -12114,6 +12216,9 @@ } } async function cancelCurrentSymbolOpenOrders(options = null) { + return withAccountOperationLock("shared", () => runCancelWithAccountLock(options)); + } + async function runCancelWithAccountLock(options = null) { if (cancelCurrentSymbolOpenOrdersTask) return cancelCurrentSymbolOpenOrdersTask; if (singleOrderTask) { return { ok: false, status: "single_order_running", message: "单击下单处理中" }; @@ -12859,21 +12964,178 @@ futuresPayload ); } - async function assertUsdtRebalanceTradingState({ allowHidden = false } = {}) { + function assertUsdtRebalanceLocalState({ allowHidden = false, epoch = null, automatic = false } = {}) { if (!isFuturesTradingPage() || !allowHidden && document.hidden) { - throw new Error("当前不在可操作的合约页面"); + throw new Error("Current futures page is not eligible for account transfers"); + } + const route = new URL(window.location.href); + if (automatic && route.search !== "") { + throw new Error("Automatic transfers require an ordinary USD-M Futures account"); + } + if (epoch !== null && epoch !== usdtRebalanceEligibilityEpoch) { + throw new Error("Account eligibility changed before transfer"); } if (ladderTask || continuousLadderTask || singleOrderTask || cancelCurrentSymbolOpenOrdersTask) { - throw new Error("当前仍有交易任务运行"); + throw new Error("A trading task is still running"); } - 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("全账户仍有持仓"); + if (readAccountPositionCount() !== 0 || getOpenOrdersTabCount() !== 0) { + throw new Error("全账户持仓和当前委托必须为零"); + } + } + async function readUsdtRebalanceAccountKey() { + const payload = await fetchUsdtRebalanceBapi("/bapi/accounts/v1/private/account/user/base-detail", { + method: "POST", + body: {}, + label: "Account identity" + }); + if (payload?.code !== "000000" || typeof payload.data?.userId !== "string" || payload.data.userId.length === 0 || payload.data.isPortfolioMarginRetailUser !== false || payload.data.isExistFutureAccount !== true) { + throw new Error("Unsupported or unverified account identity"); + } + const digest = await crypto.subtle.digest("SHA-256", new TextEncoder().encode(payload.data.userId)); + return "userscripts:automatic-usdt-rebalance:v1:" + Array.from(new Uint8Array(digest), (byte) => byte.toString(16).padStart(2, "0")).join(""); + } + function readUsdtRebalanceEpisode(key) { + return readAutomaticRebalanceEpisode(localStorage.getItem(key)); + } + function writeUsdtRebalanceEpisode(key, status) { + const serialized = JSON.stringify({ version: 1, status }); + localStorage.setItem(key, serialized); + if (localStorage.getItem(key) !== serialized) throw new Error("Automatic transfer state could not be persisted"); + } + async function readAuthoritativeUsdtRebalanceFlatState() { + const [positions, basic, conditional] = await Promise.all([ + fetchCurrentPositionsPayload(), + fetchUsdtRebalanceBapi("/bapi/futures/v1/private/future/order/open-orders", { + method: "POST", + body: {}, + label: "All basic orders" + }), + fetchUsdtRebalanceBapi("/bapi/futures/v1/private/future/order/open-algo-order", { + method: "POST", + body: { algoType: "CONDITIONAL" }, + label: "All conditional orders" + }) + ]); + const basicCount = assertCompleteOpenOrders(basic); + const conditionalCount = assertCompleteOpenOrders(conditional); + return resolveAllFuturesPositionStatus(positions).status === "flat" && basicCount === 0 && conditionalCount === 0; + } + async function assertUsdtRebalanceTradingState(options = {}) { + assertUsdtRebalanceLocalState(options); + if (!await readAuthoritativeUsdtRebalanceFlatState()) { + throw new Error("全账户仍有持仓或当前委托"); + } + assertUsdtRebalanceLocalState(options); + } + async function observeAutomaticUsdtRebalanceActivity() { + return withAccountOperationLock("shared", async () => { + const epoch = usdtRebalanceEligibilityEpoch; + const key = await readUsdtRebalanceAccountKey(); + const previous = readUsdtRebalanceEpisode(key); + if (previous.status !== "consumed") return; + const flat = await readAuthoritativeUsdtRebalanceFlatState(); + if (await readUsdtRebalanceAccountKey() !== key) throw new Error("Account identity changed"); + if (epoch !== usdtRebalanceEligibilityEpoch || !isFuturesTradingPage()) return; + const next = observeAutomaticRebalanceActivity(previous, flat); + if (next.status !== previous.status) writeUsdtRebalanceEpisode(key, next.status); + }, null, false); + } + function queueAutomaticUsdtRebalanceActivityObservation() { + if (automaticUsdtRebalanceObservationTask || !cachedBncHeaders) return; + automaticUsdtRebalanceObservationTask = observeAutomaticUsdtRebalanceActivity().catch((error) => { + log("Automatic USDT activity observation failed", error.name, error.message.slice(0, 240)); + }).finally(() => { + automaticUsdtRebalanceObservationTask = null; + if (!usdtRebalanceTask && !ladderTask && !continuousLadderTask && !singleOrderTask && !cancelCurrentSymbolOpenOrdersTask && readAccountPositionCount() === 0 && getOpenOrdersTabCount() === 0) { + restartUsdtRebalanceEligibilityFromCurrentAccountState(); + } + }); + } + async function executeUsdtRebalancePlan(plan, accountKey, options) { + let expectedBalances = plan.before; + let completed = 0; + try { + for (const transfer of plan.transfers) { + if (await readUsdtRebalanceAccountKey() !== accountKey) throw new Error("Account identity changed"); + await assertUsdtRebalanceTradingState(options); + const currentBalances = await readCurrentUsdtRebalanceBalances(); + if (!areUsdtBalancesEqual(currentBalances, expectedBalances)) { + throw new Error("账户余额已变化,已停止账户再平衡"); + } + if (await readUsdtRebalanceAccountKey() !== accountKey) throw new Error("Account identity changed"); + assertUsdtRebalanceLocalState(options); + if (options.automatic && (transfer.from !== "UMFUTURE" || !["FUNDING", "MAIN"].includes(transfer.to))) { + throw new Error("Automatic transfers may only withdraw Futures excess"); + } + writeUsdtRebalanceEpisode(accountKey, "in_flight"); + if (options.automatic) { + setAutomaticUsdtRebalanceStatus(`Automatic USDT transfer ${completed + 1}/${plan.transfers.length}`); + } else { + setLadderStatus(`账户再平衡中 · ${completed + 1}/${plan.transfers.length} 笔`); + } + await submitUsdtRebalanceTransfer(transfer); + completed += 1; + expectedBalances = applyUsdtTransferToBalances(expectedBalances, transfer); + await waitForUsdtRebalanceBalances(expectedBalances); + writeUsdtRebalanceEpisode(accountKey, "consumed"); + } + } catch (error) { + error.rebalanceCompleted = completed; + throw error; + } + writeUsdtRebalanceEpisode(accountKey, "consumed"); + usdtRebalanceEligible = false; + if (options.automatic) { + setAutomaticUsdtRebalanceStatus(`Automatic USDT transfers completed: ${completed}/${plan.transfers.length}`); + } else { + setLadderStatus(`账户再平衡已完成 · ${completed}/${plan.transfers.length} 笔`); + } + return { status: "completed", plan, completed }; + } + async function runAutomaticUsdtRebalance(epoch, signal) { + return withAccountOperationLock("exclusive", async () => { + const options = { epoch, automatic: true }; + setAutomaticUsdtRebalanceStatus("Automatic USDT transfer: checking account", void 0, true); + assertUsdtRebalanceLocalState(options); + const accountKey = await readUsdtRebalanceAccountKey(); + const record = readUsdtRebalanceEpisode(accountKey); + if (record.status !== "active") { + if (record.status === "in_flight" || record.status === "blocked") { + setAutomaticUsdtRebalanceStatus("Automatic USDT transfer blocked: previous outcome requires account review"); + } else { + setAutomaticUsdtRebalanceStatus("Automatic USDT transfer already checked for this flat episode"); + } + return { status: record.status }; + } + await assertUsdtRebalanceTradingState(options); + const balances = await readCurrentUsdtRebalanceBalances(); + assertUsdtRebalanceLocalState(options); + const plan = buildAutomaticUsdtRebalancePlan(balances); + writeUsdtRebalanceEpisode(accountKey, "consumed"); + if (plan.transfers.length === 0) { + setAutomaticUsdtRebalanceStatus("Automatic USDT transfer: no Futures excess"); + return { status: "no_excess", plan }; + } + return executeUsdtRebalancePlan(plan, accountKey, options); + }, signal); + } + function startAutomaticUsdtRebalance(epoch) { + if (usdtRebalanceTask) return usdtRebalanceTask; + const controller = new AbortController(); + automaticUsdtRebalanceLockController = controller; + setAutomaticUsdtRebalanceStatus("Automatic USDT transfer: waiting for account access", void 0, true); + const task = runAutomaticUsdtRebalance(epoch, controller.signal).catch((error) => { + if (controller.signal.aborted && error.name === "AbortError") return { status: "invalidated" }; + setAutomaticUsdtRebalanceStatus(`Automatic USDT transfer stopped: ${error.message}`, error.message); + return { status: "failed" }; + }).finally(() => { + if (usdtRebalanceTask === task) usdtRebalanceTask = null; + if (automaticUsdtRebalanceLockController === controller) automaticUsdtRebalanceLockController = null; + scheduleRenderPanel(); + }); + usdtRebalanceTask = task; + scheduleRenderPanel(); + return task; } function buildUsdtRebalanceDialogModel(plan) { const accountLabels = { @@ -12930,9 +13192,9 @@ } async function runUsdtRebalance() { let plan = null; - let completed = 0; setLadderStatus("正在读取账户再平衡计划"); try { + const previewAccountKey = await readUsdtRebalanceAccountKey(); await assertUsdtRebalanceTradingState(); const initialBalances = await readCurrentUsdtRebalanceBalances(); plan = buildUsdtRebalancePlan(initialBalances); @@ -12945,24 +13207,18 @@ setLadderStatus("账户再平衡已取消"); return { status: "cancelled", plan }; } - let expectedBalances = initialBalances; - for (const transfer of plan.transfers) { - await assertUsdtRebalanceTradingState({ allowHidden: true }); - const currentBalances = await readCurrentUsdtRebalanceBalances(); - if (!areUsdtBalancesEqual(currentBalances, expectedBalances)) { - throw new Error("账户余额已变化,已停止账户再平衡"); + return await withAccountOperationLock("exclusive", async () => { + const accountKey = await readUsdtRebalanceAccountKey(); + if (accountKey !== previewAccountKey) throw new Error("Account identity changed after confirmation"); + const record = readUsdtRebalanceEpisode(accountKey); + if (record.status === "in_flight" || record.status === "blocked") { + throw new Error("Previous transfer outcome requires account review"); } - 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 }; + return executeUsdtRebalancePlan(plan, accountKey, { allowHidden: true }); + }); } catch (error) { const message = error?.name === "AbortError" ? "Binance 请求超时" : error?.message || String(error); + const completed = error.rebalanceCompleted || 0; const prefix = completed > 0 && plan ? `账户再平衡部分完成 · ${completed}/${plan.transfers.length} 笔` : "账户再平衡失败"; setLadderStatus(`${prefix} · ${message}`, message); throw error; @@ -13111,11 +13367,15 @@ autoOpenLeveragePositionCheckTask = task; } function invalidateUsdtRebalanceEligibility() { + if (automaticUsdtRebalanceStatusPending) { + setAutomaticUsdtRebalanceStatus("Automatic USDT transfer paused: eligibility changed"); + } const hadPendingTimer = usdtRebalanceEligibilityTimer !== 0; const wasEligible = usdtRebalanceEligible; window.clearTimeout(usdtRebalanceEligibilityTimer); usdtRebalanceEligibilityTimer = 0; usdtRebalanceEligibilityEpoch += 1; + automaticUsdtRebalanceLockController?.abort(); if (!usdtRebalanceTask) usdtRebalanceEligible = false; const resetFlatStatus = !usdtRebalanceTask && ladderStatusText === PANEL_COPY.state.allPositionsClosed; if (resetFlatStatus) { @@ -13134,19 +13394,22 @@ const positionState = resolveAllFuturesPositionStatus(await fetchCurrentPositionsPayload()); if (epoch !== usdtRebalanceEligibilityEpoch) return false; if (positionState.status !== "flat") return false; + assertUsdtRebalanceLocalState({ epoch }); usdtRebalanceEligible = true; - setLadderStatus(PANEL_COPY.state.allPositionsClosed); + if (ladderStatusText === PANEL_COPY.state.idle) setLadderStatus(PANEL_COPY.state.allPositionsClosed); scheduleRenderPanel(); + await startAutomaticUsdtRebalance(epoch); return true; } function scheduleUsdtRebalanceEligibility() { invalidateUsdtRebalanceEligibility(); + setAutomaticUsdtRebalanceStatus("Automatic USDT transfer: waiting for stable flat account", void 0, true); const epoch = usdtRebalanceEligibilityEpoch; usdtRebalanceEligibilityTimer = window.setTimeout(() => { usdtRebalanceEligibilityTimer = 0; let task = null; task = confirmUsdtRebalanceEligibility(epoch).catch((error) => { - log("USDT 再平衡资格检查未通过", error?.message || error); + setAutomaticUsdtRebalanceStatus(`Automatic USDT eligibility check failed: ${error.message}`, error.message); return false; }).finally(() => { if (usdtRebalanceEligibilityTask === task) usdtRebalanceEligibilityTask = null; @@ -13160,6 +13423,7 @@ return; } invalidateUsdtRebalanceEligibility(); + if (positionCount > 0 || openOrdersCount > 0) queueAutomaticUsdtRebalanceActivityObservation(); } function restartUsdtRebalanceEligibilityFromCurrentAccountState() { if (document.hidden || !isFuturesTradingPage()) return; @@ -13618,6 +13882,7 @@ if (status.textContent !== renderedText) status.textContent = renderedText; if (status.title !== renderedTitle) status.title = renderedTitle; } + renderAutomaticUsdtRebalanceStatus(panel.querySelector(`#${AUTOMATIC_USDT_REBALANCE_STATUS_ID}`)); if (rebalanceButton) { const shouldShow = usdtRebalanceEligible || Boolean(usdtRebalanceTask); const hidden = !shouldShow; @@ -13948,7 +14213,8 @@ `
`, `${initialStatus}`, ``, - "
" + "", + `` ].join(""); panelPositionInvalidated = true; document.body.appendChild(panel); @@ -14591,7 +14857,7 @@ lastTs = now; invalidateUsdtRebalanceEligibility(); setLadderStatus(`单击${action.side}准备中`); - singleOrderTask = (async () => { + singleOrderTask = withAccountOperationLock("shared", async () => { await syncTradeInputs(clickedPrice, qtyPlan.qty, { priceLabel: "点击价", qtyLabel: "目标量" @@ -14667,7 +14933,7 @@ } setLadderStatus(`单击${action.side}已提交 · ${clickedPrice} × ${qtyPlan.qty}`); log(`单击${action.side}已确认提交`); - })(); + }); scheduleRenderPanel(); try { await singleOrderTask; @@ -14678,6 +14944,7 @@ warn(message); } finally { singleOrderTask = null; + restartUsdtRebalanceEligibilityFromCurrentAccountState(); scheduleRenderPanel(); } } catch (e2) { diff --git a/src/binance-orderbook-trade/core/automatic-usdt-rebalance.js b/src/binance-orderbook-trade/core/automatic-usdt-rebalance.js new file mode 100644 index 00000000..239e849c --- /dev/null +++ b/src/binance-orderbook-trade/core/automatic-usdt-rebalance.js @@ -0,0 +1,22 @@ +/** Unknown financial outcomes remain terminal even after subsequent account activity. */ +export function readAutomaticRebalanceEpisode(serialized) { + if (serialized === null) return { version: 1, status: 'active' }; + const record = JSON.parse(serialized); + if (record?.version !== 1 || !['active', 'consumed', 'in_flight', 'blocked'].includes(record.status)) { + throw new Error('Invalid automatic rebalance episode'); + } + return record; +} + +export function observeAutomaticRebalanceActivity(record, flat) { + if (!flat && record.status === 'consumed') return { version: 1, status: 'active' }; + return record; +} + +/** Both pinned all-symbol BAPI endpoints return the entire order array. */ +export function assertCompleteOpenOrders(payload) { + if (payload?.success !== true || !Array.isArray(payload.data)) { + throw new Error('Invalid complete open-order response'); + } + return payload.data.length; +} diff --git a/src/binance-orderbook-trade/core/usdt-rebalance.js b/src/binance-orderbook-trade/core/usdt-rebalance.js index 0ed0b713..8a55b8c0 100644 --- a/src/binance-orderbook-trade/core/usdt-rebalance.js +++ b/src/binance-orderbook-trade/core/usdt-rebalance.js @@ -194,6 +194,57 @@ export function buildUsdtRebalancePlan(rawBalances) { }; } +/** + * Automatic distribution can only withdraw futures excess. Reserve wallets + * never donate, so `targets` describes reachable final balances rather than a + * mandatory 5:4:1 allocation. Both ratio floors leave decimal dust in futures, + * preserving the manual planner's reserve of at least ten percent. + */ +export function buildAutomaticUsdtRebalancePlan(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 fundingTarget = totalUnits * 50n / 100n; + const spotTarget = totalUnits * 40n / 100n; + const futuresTarget = totalUnits - fundingTarget - spotTarget; + const targetUnits = { ...beforeUnits }; + const transfers = []; + + if (beforeUnits.UMFUTURE > futuresTarget) { + const excess = beforeUnits.UMFUTURE - futuresTarget; + const fundingDeficit = fundingTarget > beforeUnits.FUNDING + ? fundingTarget - beforeUnits.FUNDING : 0n; + const toFunding = excess < fundingDeficit ? excess : fundingDeficit; + const distributions = [ + { accountCode: 'FUNDING', amount: toFunding }, + { accountCode: 'MAIN', amount: excess - toFunding }, + ]; + for (const { accountCode, amount } of distributions) { + if (amount === 0n) continue; + transfers.push({ + from: 'UMFUTURE', + to: accountCode, + kindType: `${USDT_REBALANCE_ACCOUNTS.UMFUTURE.bapiCode}_${USDT_REBALANCE_ACCOUNTS[accountCode].bapiCode}`, + amount: unitsToDecimal(amount), + }); + targetUnits[accountCode] += amount; + targetUnits.UMFUTURE -= amount; + } + } + + 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 划转账户无效'); diff --git a/src/binance-orderbook-trade/index.user.js b/src/binance-orderbook-trade/index.user.js index 8e52e628..222b4de7 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.220 +// @version 2.7.221 // @author jackhai9 // @description 单击订单簿价格,按当前开仓/平仓 tab 自动填数量并执行下单,内置数量倍率面板 // @match https://www.binance.com/*/futures/* @@ -61,10 +61,12 @@ import { resolveSymbolPositionSideStatus, resolveSymbolPositionStatus, } from './core/auto-open-leverage.js'; +import { readAutomaticRebalanceEpisode, observeAutomaticRebalanceActivity, assertCompleteOpenOrders } from './core/automatic-usdt-rebalance.js'; import { applyUsdtTransferToBalances, areUsdtBalancesEqual, buildUsdtRebalancePlan, + buildAutomaticUsdtRebalancePlan, parseUsdtWalletBalances, resolveAllFuturesPositionStatus, withFuturesTransferableBalance, @@ -324,6 +326,7 @@ installChartStorageOptimizer(); 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 AUTOMATIC_USDT_REBALANCE_STATUS_ID = 'jh-binance-auto-rebalance-status'; 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'; @@ -506,11 +509,16 @@ installChartStorageOptimizer(); let activeUiLocale = resolveUiLocaleFromPathname(location.pathname); let ladderStatusText = PANEL_COPY.state.idle; let ladderStatusTitle = PANEL_COPY.state.idle; + let automaticUsdtRebalanceStatusText = ''; + let automaticUsdtRebalanceStatusTitle = ''; let usdtRebalanceEligibilityTimer = 0; let usdtRebalanceEligibilityEpoch = 0; let usdtRebalanceEligibilityTask = null; let usdtRebalanceEligible = false; let usdtRebalanceTask = null; + let automaticUsdtRebalanceObservationTask = null; + let automaticUsdtRebalanceLockController = null; + let automaticUsdtRebalanceStatusPending = false; let ladderPanelBodySignature = ''; let panelPositionInvalidated = true; let panelObservedSize = ''; @@ -1122,6 +1130,23 @@ installChartStorageOptimizer(); } } + function renderAutomaticUsdtRebalanceStatus(element) { + if (!element) return; + const text = ui(automaticUsdtRebalanceStatusText); + const title = ui(automaticUsdtRebalanceStatusTitle); + if (element.textContent !== text) element.textContent = text; + if (element.title !== title) element.title = title; + if (element.hidden !== (text === '')) element.hidden = text === ''; + } + + /** Automatic background outcomes must never replace a retained trading result. */ + function setAutomaticUsdtRebalanceStatus(text, title = text, pending = false) { + automaticUsdtRebalanceStatusPending = pending; + automaticUsdtRebalanceStatusText = localizeKnownUiStatus(text); + automaticUsdtRebalanceStatusTitle = localizeKnownUiStatus(title); + renderAutomaticUsdtRebalanceStatus(document.getElementById(AUTOMATIC_USDT_REBALANCE_STATUS_ID)); + } + function isValidMultiplier(value) { return /^\d+$/.test(String(value || '').trim()) && Number(value) > 0; } @@ -1557,6 +1582,9 @@ installChartStorageOptimizer(); if (isFuturesTradingPage() && getActiveTradeMode() === 'OPEN') { queueAutoOpenLeveragePositionCheck('headers_ready'); } + if (isFuturesTradingPage() && (readAccountPositionCount() > 0 || getOpenOrdersTabCount() > 0)) { + queueAutomaticUsdtRebalanceActivityObservation(); + } }); } @@ -3608,6 +3636,19 @@ installChartStorageOptimizer(); return { done, repriceAttempts, lastRepriceApiErrorCode }; } + async function withAccountOperationLock(mode, operation, queuedSignal = null, reportUnavailable = true) { + if (!navigator.locks) throw new Error('Account operations require Web Locks'); + return navigator.locks.request('userscripts:usdt-account-operation:v1', { + mode, ...(queuedSignal ? { signal: queuedSignal } : { ifAvailable: true }), + }, async (lock) => { + if (!lock) { + if (reportUnavailable) setLadderStatus('Account operation blocked: another tab is transferring funds'); + return { status: 'not_started' }; + } + return operation(); + }); + } + async function startLadder( actionType, continuousProgress = null, @@ -3681,7 +3722,7 @@ installChartStorageOptimizer(); } setLadderStatus(singleStatus); }; - const chartSaveRound = chartSaveController?.beginRound() || null; + let chartSaveRound = null; invalidateUsdtRebalanceEligibility(); ladderAbortController = abortController; activeLadderActionType = actionType; @@ -3697,6 +3738,8 @@ installChartStorageOptimizer(); localizedText('准备中', 'Preparing'), ); const executeRound = async () => { + throwIfAborted(abortController.signal); + chartSaveRound = chartSaveController?.beginRound() || null; const { plan, done, @@ -3725,7 +3768,9 @@ installChartStorageOptimizer(); const executionTask = (async () => { let result; try { - result = await executeRound(); + result = await (continuousSession + ? executeRound() + : withAccountOperationLock('shared', executeRound)); } catch (executionError) { if (chartSaveRound) { try { @@ -3747,13 +3792,12 @@ installChartStorageOptimizer(); now: () => performance.now(), delay, }) - .then(({ - plan, - done, - repriceAttempts, - lastRepriceApiErrorCode, - wasStopped, - }) => { + .then((outcome) => { + if (outcome.status === 'not_started') { + throwIfAborted(abortController.signal); + return outcome; + } + const { plan, done, repriceAttempts, lastRepriceApiErrorCode, wasStopped } = outcome; if (!isCurrentObservedSymbol(actionSymbol)) { if (!continuousSession) { setLadderStatus(formatInterruptedLadderProgress( @@ -3874,6 +3918,7 @@ installChartStorageOptimizer(); activeLadderActionType = null; activeLadderPanelContext = null; ladderStopRequested = false; + if (!continuousSession) restartUsdtRebalanceEligibilityFromCurrentAccountState(); scheduleRenderPanel(); }); scheduleRenderPanel(); @@ -4017,9 +4062,9 @@ installChartStorageOptimizer(); activeContinuousLadderActionType = actionType; activeContinuousLadderProgress = continuousProgress; - const executionTask = (async () => { - // Publish the single-flight task before waiting, blocking new marker work. - await Promise.resolve(); + const executionTask = withAccountOperationLock('shared', async () => { + // Stop can arrive while the lock callback is pending, before any page mutation. + throwIfAborted(abortController.signal); chartSaveCoalescer = await startContinuousChartSaveCoalescing(abortController.signal, actionSymbol); continuousChartSaveController = chartSaveCoalescer; throwIfAborted(abortController.signal); @@ -4112,9 +4157,13 @@ installChartStorageOptimizer(); } continue; } - })(); + }); continuousLadderTask = executionTask + .then((outcome) => { + if (outcome.status === 'not_started') throwIfAborted(abortController.signal); + return outcome; + }) .catch((e) => { if (isLadderStoppedError(e)) { setContinuousLadderProgressStatus(spec.statusLabel, 'stopped', continuousProgress); @@ -5835,6 +5884,10 @@ installChartStorageOptimizer(); } async function cancelCurrentSymbolOpenOrders(options = null) { + return withAccountOperationLock('shared', () => runCancelWithAccountLock(options)); + } + + async function runCancelWithAccountLock(options = null) { if (cancelCurrentSymbolOpenOrdersTask) return cancelCurrentSymbolOpenOrdersTask; if (singleOrderTask) { return { ok: false, status: 'single_order_running', message: '单击下单处理中' }; @@ -6705,21 +6758,196 @@ installChartStorageOptimizer(); ); } - async function assertUsdtRebalanceTradingState({ allowHidden = false } = {}) { + function assertUsdtRebalanceLocalState({ allowHidden = false, epoch = null, automatic = false } = {}) { if (!isFuturesTradingPage() || (!allowHidden && document.hidden)) { - throw new Error('当前不在可操作的合约页面'); + throw new Error('Current futures page is not eligible for account transfers'); + } + const route = new URL(window.location.href); + if (automatic && route.search !== '') { + throw new Error('Automatic transfers require an ordinary USD-M Futures account'); + } + if (epoch !== null && epoch !== usdtRebalanceEligibilityEpoch) { + throw new Error('Account eligibility changed before transfer'); } if (ladderTask || continuousLadderTask || singleOrderTask || cancelCurrentSymbolOpenOrdersTask) { - throw new Error('当前仍有交易任务运行'); + throw new Error('A trading task is still running'); } - 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('全账户仍有持仓'); + if (readAccountPositionCount() !== 0 || getOpenOrdersTabCount() !== 0) { + throw new Error('全账户持仓和当前委托必须为零'); + } + } + + async function readUsdtRebalanceAccountKey() { + const payload = await fetchUsdtRebalanceBapi('/bapi/accounts/v1/private/account/user/base-detail', { + method: 'POST', body: {}, label: 'Account identity', + }); + if (payload?.code !== '000000' || typeof payload.data?.userId !== 'string' + || payload.data.userId.length === 0 + || payload.data.isPortfolioMarginRetailUser !== false + || payload.data.isExistFutureAccount !== true) { + throw new Error('Unsupported or unverified account identity'); + } + const digest = await crypto.subtle.digest('SHA-256', new TextEncoder().encode(payload.data.userId)); + return 'userscripts:automatic-usdt-rebalance:v1:' + + Array.from(new Uint8Array(digest), (byte) => byte.toString(16).padStart(2, '0')).join(''); + } + + function readUsdtRebalanceEpisode(key) { + return readAutomaticRebalanceEpisode(localStorage.getItem(key)); + } + + function writeUsdtRebalanceEpisode(key, status) { + const serialized = JSON.stringify({ version: 1, status }); + localStorage.setItem(key, serialized); + if (localStorage.getItem(key) !== serialized) throw new Error('Automatic transfer state could not be persisted'); + } + + async function readAuthoritativeUsdtRebalanceFlatState() { + const [positions, basic, conditional] = await Promise.all([ + fetchCurrentPositionsPayload(), + fetchUsdtRebalanceBapi('/bapi/futures/v1/private/future/order/open-orders', { + method: 'POST', body: {}, label: 'All basic orders', + }), + fetchUsdtRebalanceBapi('/bapi/futures/v1/private/future/order/open-algo-order', { + method: 'POST', body: { algoType: 'CONDITIONAL' }, label: 'All conditional orders', + }), + ]); + const basicCount = assertCompleteOpenOrders(basic); + const conditionalCount = assertCompleteOpenOrders(conditional); + return resolveAllFuturesPositionStatus(positions).status === 'flat' + && basicCount === 0 && conditionalCount === 0; + } + + async function assertUsdtRebalanceTradingState(options = {}) { + assertUsdtRebalanceLocalState(options); + if (!await readAuthoritativeUsdtRebalanceFlatState()) { + throw new Error('全账户仍有持仓或当前委托'); + } + assertUsdtRebalanceLocalState(options); + } + + async function observeAutomaticUsdtRebalanceActivity() { + return withAccountOperationLock('shared', async () => { + const epoch = usdtRebalanceEligibilityEpoch; + const key = await readUsdtRebalanceAccountKey(); + const previous = readUsdtRebalanceEpisode(key); + if (previous.status !== 'consumed') return; + const flat = await readAuthoritativeUsdtRebalanceFlatState(); + if (await readUsdtRebalanceAccountKey() !== key) throw new Error('Account identity changed'); + if (epoch !== usdtRebalanceEligibilityEpoch || !isFuturesTradingPage()) return; + const next = observeAutomaticRebalanceActivity(previous, flat); + if (next.status !== previous.status) writeUsdtRebalanceEpisode(key, next.status); + }, null, false); + } + + function queueAutomaticUsdtRebalanceActivityObservation() { + if (automaticUsdtRebalanceObservationTask || !cachedBncHeaders) return; + automaticUsdtRebalanceObservationTask = observeAutomaticUsdtRebalanceActivity() + .catch((error) => { + // Background rearming must not replace the outcome of the user's trading task. + log('Automatic USDT activity observation failed', error.name, error.message.slice(0, 240)); + }) + .finally(() => { + automaticUsdtRebalanceObservationTask = null; + if (!usdtRebalanceTask && !ladderTask && !continuousLadderTask + && !singleOrderTask && !cancelCurrentSymbolOpenOrdersTask + && readAccountPositionCount() === 0 && getOpenOrdersTabCount() === 0) { + restartUsdtRebalanceEligibilityFromCurrentAccountState(); + } + }); + } + + async function executeUsdtRebalancePlan(plan, accountKey, options) { + let expectedBalances = plan.before; + let completed = 0; + try { + for (const transfer of plan.transfers) { + if (await readUsdtRebalanceAccountKey() !== accountKey) throw new Error('Account identity changed'); + await assertUsdtRebalanceTradingState(options); + const currentBalances = await readCurrentUsdtRebalanceBalances(); + if (!areUsdtBalancesEqual(currentBalances, expectedBalances)) { + throw new Error('账户余额已变化,已停止账户再平衡'); + } + if (await readUsdtRebalanceAccountKey() !== accountKey) throw new Error('Account identity changed'); + assertUsdtRebalanceLocalState(options); + if (options.automatic && (transfer.from !== 'UMFUTURE' + || !['FUNDING', 'MAIN'].includes(transfer.to))) { + throw new Error('Automatic transfers may only withdraw Futures excess'); + } + // Persist before sending: an aborted response does not establish whether funds moved. + writeUsdtRebalanceEpisode(accountKey, 'in_flight'); + if (options.automatic) { + setAutomaticUsdtRebalanceStatus(`Automatic USDT transfer ${completed + 1}/${plan.transfers.length}`); + } else { + setLadderStatus(`账户再平衡中 · ${completed + 1}/${plan.transfers.length} 笔`); + } + await submitUsdtRebalanceTransfer(transfer); + completed += 1; + expectedBalances = applyUsdtTransferToBalances(expectedBalances, transfer); + await waitForUsdtRebalanceBalances(expectedBalances); + writeUsdtRebalanceEpisode(accountKey, 'consumed'); + } + } catch (error) { + error.rebalanceCompleted = completed; + throw error; + } + writeUsdtRebalanceEpisode(accountKey, 'consumed'); + usdtRebalanceEligible = false; + if (options.automatic) { + setAutomaticUsdtRebalanceStatus(`Automatic USDT transfers completed: ${completed}/${plan.transfers.length}`); + } else { + setLadderStatus(`账户再平衡已完成 · ${completed}/${plan.transfers.length} 笔`); + } + return { status: 'completed', plan, completed }; + } + + async function runAutomaticUsdtRebalance(epoch, signal) { + return withAccountOperationLock('exclusive', async () => { + const options = { epoch, automatic: true }; + setAutomaticUsdtRebalanceStatus('Automatic USDT transfer: checking account', undefined, true); + assertUsdtRebalanceLocalState(options); + const accountKey = await readUsdtRebalanceAccountKey(); + const record = readUsdtRebalanceEpisode(accountKey); + if (record.status !== 'active') { + if (record.status === 'in_flight' || record.status === 'blocked') { + setAutomaticUsdtRebalanceStatus('Automatic USDT transfer blocked: previous outcome requires account review'); + } else { + setAutomaticUsdtRebalanceStatus('Automatic USDT transfer already checked for this flat episode'); + } + return { status: record.status }; + } + await assertUsdtRebalanceTradingState(options); + const balances = await readCurrentUsdtRebalanceBalances(); + assertUsdtRebalanceLocalState(options); + const plan = buildAutomaticUsdtRebalancePlan(balances); + writeUsdtRebalanceEpisode(accountKey, 'consumed'); + if (plan.transfers.length === 0) { + setAutomaticUsdtRebalanceStatus('Automatic USDT transfer: no Futures excess'); + return { status: 'no_excess', plan }; + } + return executeUsdtRebalancePlan(plan, accountKey, options); + }, signal); + } + + function startAutomaticUsdtRebalance(epoch) { + if (usdtRebalanceTask) return usdtRebalanceTask; + const controller = new AbortController(); + automaticUsdtRebalanceLockController = controller; + setAutomaticUsdtRebalanceStatus('Automatic USDT transfer: waiting for account access', undefined, true); + const task = runAutomaticUsdtRebalance(epoch, controller.signal) + .catch((error) => { + if (controller.signal.aborted && error.name === 'AbortError') return { status: 'invalidated' }; + setAutomaticUsdtRebalanceStatus(`Automatic USDT transfer stopped: ${error.message}`, error.message); + return { status: 'failed' }; + }) + .finally(() => { + if (usdtRebalanceTask === task) usdtRebalanceTask = null; + if (automaticUsdtRebalanceLockController === controller) automaticUsdtRebalanceLockController = null; + scheduleRenderPanel(); + }); + usdtRebalanceTask = task; + scheduleRenderPanel(); + return task; } function buildUsdtRebalanceDialogModel(plan) { @@ -6780,9 +7008,9 @@ installChartStorageOptimizer(); async function runUsdtRebalance() { let plan = null; - let completed = 0; setLadderStatus('正在读取账户再平衡计划'); try { + const previewAccountKey = await readUsdtRebalanceAccountKey(); await assertUsdtRebalanceTradingState(); const initialBalances = await readCurrentUsdtRebalanceBalances(); plan = buildUsdtRebalancePlan(initialBalances); @@ -6796,26 +7024,20 @@ installChartStorageOptimizer(); return { status: 'cancelled', plan }; } - let expectedBalances = initialBalances; - for (const transfer of plan.transfers) { - await assertUsdtRebalanceTradingState({ allowHidden: true }); - const currentBalances = await readCurrentUsdtRebalanceBalances(); - if (!areUsdtBalancesEqual(currentBalances, expectedBalances)) { - throw new Error('账户余额已变化,已停止账户再平衡'); + return await withAccountOperationLock('exclusive', async () => { + const accountKey = await readUsdtRebalanceAccountKey(); + if (accountKey !== previewAccountKey) throw new Error('Account identity changed after confirmation'); + const record = readUsdtRebalanceEpisode(accountKey); + if (record.status === 'in_flight' || record.status === 'blocked') { + throw new Error('Previous transfer outcome requires account review'); } - 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 }; + return executeUsdtRebalancePlan(plan, accountKey, { allowHidden: true }); + }); } catch (error) { const message = error?.name === 'AbortError' ? 'Binance 请求超时' : (error?.message || String(error)); + const completed = error.rebalanceCompleted || 0; const prefix = completed > 0 && plan ? `账户再平衡部分完成 · ${completed}/${plan.transfers.length} 笔` : '账户再平衡失败'; @@ -6999,11 +7221,15 @@ installChartStorageOptimizer(); } function invalidateUsdtRebalanceEligibility() { + if (automaticUsdtRebalanceStatusPending) { + setAutomaticUsdtRebalanceStatus('Automatic USDT transfer paused: eligibility changed'); + } const hadPendingTimer = usdtRebalanceEligibilityTimer !== 0; const wasEligible = usdtRebalanceEligible; window.clearTimeout(usdtRebalanceEligibilityTimer); usdtRebalanceEligibilityTimer = 0; usdtRebalanceEligibilityEpoch += 1; + automaticUsdtRebalanceLockController?.abort(); if (!usdtRebalanceTask) usdtRebalanceEligible = false; const resetFlatStatus = !usdtRebalanceTask && ladderStatusText === PANEL_COPY.state.allPositionsClosed; if (resetFlatStatus) { @@ -7023,21 +7249,24 @@ installChartStorageOptimizer(); const positionState = resolveAllFuturesPositionStatus(await fetchCurrentPositionsPayload()); if (epoch !== usdtRebalanceEligibilityEpoch) return false; if (positionState.status !== 'flat') return false; + assertUsdtRebalanceLocalState({ epoch }); usdtRebalanceEligible = true; - setLadderStatus(PANEL_COPY.state.allPositionsClosed); + if (ladderStatusText === PANEL_COPY.state.idle) setLadderStatus(PANEL_COPY.state.allPositionsClosed); scheduleRenderPanel(); + await startAutomaticUsdtRebalance(epoch); return true; } function scheduleUsdtRebalanceEligibility() { invalidateUsdtRebalanceEligibility(); + setAutomaticUsdtRebalanceStatus('Automatic USDT transfer: waiting for stable flat account', undefined, true); const epoch = usdtRebalanceEligibilityEpoch; usdtRebalanceEligibilityTimer = window.setTimeout(() => { usdtRebalanceEligibilityTimer = 0; let task = null; task = confirmUsdtRebalanceEligibility(epoch) .catch((error) => { - log('USDT 再平衡资格检查未通过', error?.message || error); + setAutomaticUsdtRebalanceStatus(`Automatic USDT eligibility check failed: ${error.message}`, error.message); return false; }) .finally(() => { @@ -7053,6 +7282,7 @@ installChartStorageOptimizer(); return; } invalidateUsdtRebalanceEligibility(); + if (positionCount > 0 || openOrdersCount > 0) queueAutomaticUsdtRebalanceActivityObservation(); } /** @@ -7585,6 +7815,7 @@ installChartStorageOptimizer(); if (status.textContent !== renderedText) status.textContent = renderedText; if (status.title !== renderedTitle) status.title = renderedTitle; } + renderAutomaticUsdtRebalanceStatus(panel.querySelector(`#${AUTOMATIC_USDT_REBALANCE_STATUS_ID}`)); if (rebalanceButton) { const shouldShow = usdtRebalanceEligible || Boolean(usdtRebalanceTask); const hidden = !shouldShow; @@ -7964,6 +8195,7 @@ installChartStorageOptimizer(); `${initialStatus}`, ``, '', + ``, ].join(''); panelPositionInvalidated = true; document.body.appendChild(panel); @@ -8681,7 +8913,7 @@ installChartStorageOptimizer(); lastTs = now; invalidateUsdtRebalanceEligibility(); setLadderStatus(`单击${action.side}准备中`); - singleOrderTask = (async () => { + singleOrderTask = withAccountOperationLock('shared', async () => { await syncTradeInputs(clickedPrice, qtyPlan.qty, { priceLabel: '点击价', qtyLabel: '目标量', @@ -8767,7 +8999,7 @@ installChartStorageOptimizer(); } setLadderStatus(`单击${action.side}已提交 · ${clickedPrice} × ${qtyPlan.qty}`); log(`单击${action.side}已确认提交`); - })(); + }); scheduleRenderPanel(); try { await singleOrderTask; @@ -8778,6 +9010,7 @@ installChartStorageOptimizer(); warn(message); } finally { singleOrderTask = null; + restartUsdtRebalanceEligibilityFromCurrentAccountState(); scheduleRenderPanel(); } } catch (e2) { diff --git a/test/unit/account-rebalance-api-contract.test.js b/test/unit/account-rebalance-api-contract.test.js index 60abe754..dda04324 100644 --- a/test/unit/account-rebalance-api-contract.test.js +++ b/test/unit/account-rebalance-api-contract.test.js @@ -2,6 +2,84 @@ import assert from 'node:assert/strict'; import test from 'node:test'; import { ACCOUNT_PATHS, createAccountRebalanceApi } from '../../e2e/binance-orderbook/fixtures/account-rebalance-api.js'; +test('user reads an explicit standard futures identity independently of positions', () => { + // Given an account fixture with no positions and a stable account identity + const api = createAccountRebalanceApi({ FUNDING: '0', MAIN: '0', UMFUTURE: '100' }); + + // When the caller requests the identity with the native empty POST body + const initial = api.handle({ pathname: ACCOUNT_PATHS.identity, method: 'POST', body: {} }); + api.setIdentity({ userId: 'fixture-second-account', isExistFutureAccount: false, isPortfolioMarginRetailUser: true }); + const updated = api.handle({ pathname: ACCOUNT_PATHS.identity, method: 'POST', body: {} }); + const positions = api.handle({ pathname: ACCOUNT_PATHS.positions, method: 'POST', body: {} }); + + // Then identity changes are explicit and never inferred from flat position state + assert.deepEqual(initial, { status: 200, body: { success: true, code: '000000', data: { + userId: 'fixture-account', isExistFutureAccount: true, isPortfolioMarginRetailUser: false, + } } }); + assert.deepEqual(updated.body.data, { + userId: 'fixture-second-account', isExistFutureAccount: false, isPortfolioMarginRetailUser: true, + }); + assert.deepEqual(positions.body.data, []); +}); + +test('user reads every basic and conditional order across symbols independently of positions', () => { + // Given basic and conditional orders exist on different symbols without positions + const api = createAccountRebalanceApi({ FUNDING: '0', MAIN: '0', UMFUTURE: '100' }); + const orders = { + basic: [{ symbol: 'BTCUSDT', orderId: 'basic-one' }, { symbol: 'ETHUSDT', orderId: 'basic-two' }], + conditional: [{ symbol: 'SOLUSDT', algoId: 'conditional-one' }], + }; + api.setOrders(orders); + + // When both all-symbol order endpoints and positions are read + const basic = api.handle({ pathname: ACCOUNT_PATHS.basicOrders, method: 'POST', body: {} }); + const conditional = api.handle({ pathname: ACCOUNT_PATHS.conditionalOrders, method: 'POST', body: { algoType: 'CONDITIONAL' } }); + const positions = api.handle({ pathname: ACCOUNT_PATHS.positions, method: 'POST', body: {} }); + + // Then the complete arrays preserve other-symbol orders and positions remain independent + assert.deepEqual(basic, { status: 200, body: { success: true, code: '000000', data: orders.basic } }); + assert.deepEqual(conditional, { status: 200, body: { success: true, code: '000000', data: orders.conditional } }); + assert.equal(basic.body.data.length + conditional.body.data.length, 3); + assert.deepEqual(positions.body.data, []); +}); + +for (const { key, method, body } of [ + { key: 'identity', method: 'GET', body: {} }, + { key: 'identity', method: 'POST', body: { userId: 'scoped' } }, + { key: 'basicOrders', method: 'POST', body: { symbol: 'BTCUSDT' } }, + { key: 'basicOrders', method: 'POST', body: { page: 1 } }, + { key: 'basicOrders', method: 'POST', body: undefined }, + { key: 'conditionalOrders', method: 'POST', body: {} }, + { key: 'conditionalOrders', method: 'POST', body: { algoType: 'OTHER' } }, + { key: 'conditionalOrders', method: 'POST', body: { algoType: 'CONDITIONAL', symbol: 'BTCUSDT' } }, +]) { + test(`user rejects an invalid ${key} request with ${method} ${JSON.stringify(body)}`, () => { + // Given the independent fixture requires the exact public endpoint contract + const api = createAccountRebalanceApi({ FUNDING: '0', MAIN: '0', UMFUTURE: '100' }); + + // When the caller sends a scoped, missing, or otherwise incorrect request + const request = () => api.handle({ pathname: ACCOUNT_PATHS[key], method, body }); + + // Then the fixture exposes the request mismatch instead of inventing an empty response + assert.throws(request, /wrong HTTP method|Unexpected .* request/); + }); +} + +test('user receives an account order read failure without changing balances or transferring funds', () => { + // Given the next all-symbol basic order read fails before any transfer + const balances = { FUNDING: '0', MAIN: '0', UMFUTURE: '100' }; + const api = createAccountRebalanceApi(balances); + api.failNext(ACCOUNT_PATHS.basicOrders, { status: 503, body: { success: false, code: 'FIXTURE_FAILURE' } }); + + // When the caller reads the account basic orders + const response = api.handle({ pathname: ACCOUNT_PATHS.basicOrders, method: 'POST', body: {} }); + + // Then the declared failure is returned and all funds stay in their original wallet + assert.deepEqual(response, { status: 503, body: { success: false, code: 'FIXTURE_FAILURE' } }); + assert.deepEqual(api.snapshot().balances, balances); + assert.deepEqual(api.snapshot().requests.map(request => request.pathname), [ACCOUNT_PATHS.basicOrders]); +}); + test('user can detect a wrong transfer direction because the native account fake applies the actual request', () => { // Given balances are held by an independently modeled external account boundary. const api = createAccountRebalanceApi({ FUNDING: '50.12345678', MAIN: '40', UMFUTURE: '10' }); diff --git a/test/unit/binance-orderbook-trade/automatic-usdt-rebalance.test.js b/test/unit/binance-orderbook-trade/automatic-usdt-rebalance.test.js new file mode 100644 index 00000000..54584d2a --- /dev/null +++ b/test/unit/binance-orderbook-trade/automatic-usdt-rebalance.test.js @@ -0,0 +1,56 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { readAutomaticRebalanceEpisode, observeAutomaticRebalanceActivity, assertCompleteOpenOrders } from '../../../src/binance-orderbook-trade/core/automatic-usdt-rebalance.js'; + +test('user can qualify an initial or freshly active account episode', () => { + // Given a completed flat episode + const completed = { version: 1, status: 'consumed' }; + // When authoritative activity is observed + const next = observeAutomaticRebalanceActivity(completed, false); + // Then the next flat episode can qualify + assert.deepEqual(next, { version: 1, status: 'active' }); + assert.deepEqual(readAutomaticRebalanceEpisode(null), { version: 1, status: 'active' }); +}); + +for (const status of ['in_flight', 'blocked', 'consumed']) { + test(`user retains ${status} protection across repeated flat observations`, () => { + // Given a persisted protected episode + const record = { version: 1, status }; + // When a reload reads it and observes flat state + const next = observeAutomaticRebalanceActivity(readAutomaticRebalanceEpisode(JSON.stringify(record)), true); + // Then the protection remains durable + assert.deepEqual(next, record); + }); +} + +for (const status of ['in_flight', 'blocked']) { + test(`user cannot clear ${status} transfer uncertainty by opening another position`, () => { + // Given an uncertain financial operation + const record = { version: 1, status }; + // When authoritative nonflat activity arrives + const next = observeAutomaticRebalanceActivity(record, false); + // Then no new automatic attempt is armed + assert.deepEqual(next, record); + }); +} + +test('user stops when persisted episode state is invalid', () => { + // Given an incompatible stored record + const value = JSON.stringify({ version: 0, status: 'active' }); + // When qualification reads it + const read = () => readAutomaticRebalanceEpisode(value); + // Then invalid state is surfaced without replacement + assert.throws(read, /Invalid automatic rebalance episode/); +}); + +test('user qualifies only from complete successful open order arrays', () => { + // Given successful complete and incompatible paginated replies + const empty = { success: true, data: [] }; + const paginated = { success: true, data: { rows: [], total: 0 } }; + // When the authoritative order contract is checked + const count = assertCompleteOpenOrders(empty); + // Then complete empty evidence is accepted and pagination is rejected + assert.equal(count, 0); + assert.throws(() => assertCompleteOpenOrders(paginated), /complete open-order/); + assert.throws(() => assertCompleteOpenOrders({ success: false, data: [] }), /complete open-order/); +}); diff --git a/test/unit/binance-orderbook-trade/usdt-rebalance.test.js b/test/unit/binance-orderbook-trade/usdt-rebalance.test.js index 36d73f36..7a7ccdb8 100644 --- a/test/unit/binance-orderbook-trade/usdt-rebalance.test.js +++ b/test/unit/binance-orderbook-trade/usdt-rebalance.test.js @@ -5,6 +5,7 @@ import assert from 'node:assert/strict'; import { applyUsdtTransferToBalances, areUsdtBalancesEqual, + buildAutomaticUsdtRebalancePlan, buildUsdtRebalancePlan, parseUsdtWalletBalances, resolveAllFuturesPositionStatus, @@ -12,6 +13,102 @@ import { withFuturesTransferableBalance, } from '../../../src/binance-orderbook-trade/core/usdt-rebalance.js'; +for (const { label, before, targets, transfers } of [ + { + label: 'all futures funds into the 5:4:1 allocation', + before: { FUNDING: '0', MAIN: '0', UMFUTURE: '100' }, + targets: { FUNDING: '50', MAIN: '40', UMFUTURE: '10' }, + transfers: [ + { from: 'UMFUTURE', to: 'FUNDING', kindType: 'FUTURE_CARD', amount: '50' }, + { from: 'UMFUTURE', to: 'MAIN', kindType: 'FUTURE_MAIN', amount: '40' }, + ], + }, + { + label: 'excess to spot without withdrawing overfunded funding reserves', + before: { FUNDING: '70', MAIN: '10', UMFUTURE: '20' }, + targets: { FUNDING: '70', MAIN: '20', UMFUTURE: '10' }, + transfers: [{ from: 'UMFUTURE', to: 'MAIN', kindType: 'FUTURE_MAIN', amount: '10' }], + }, + { + label: 'excess to funding without withdrawing overfunded spot reserves', + before: { FUNDING: '0', MAIN: '80', UMFUTURE: '20' }, + targets: { FUNDING: '10', MAIN: '80', UMFUTURE: '10' }, + transfers: [{ from: 'UMFUTURE', to: 'FUNDING', kindType: 'FUTURE_CARD', amount: '10' }], + }, + { + label: 'only the funding deficit before directing the remaining excess to spot', + before: { FUNDING: '45', MAIN: '25', UMFUTURE: '30' }, + targets: { FUNDING: '50', MAIN: '40', UMFUTURE: '10' }, + transfers: [ + { from: 'UMFUTURE', to: 'FUNDING', kindType: 'FUTURE_CARD', amount: '5' }, + { from: 'UMFUTURE', to: 'MAIN', kindType: 'FUTURE_MAIN', amount: '15' }, + ], + }, + { + label: 'eight-decimal excess while preserving rounding dust in futures', + before: { FUNDING: '70.00000001', MAIN: '10', UMFUTURE: '20' }, + targets: { FUNDING: '70.00000001', MAIN: '19.99999999', UMFUTURE: '10.00000001' }, + transfers: [{ from: 'UMFUTURE', to: 'MAIN', kindType: 'FUTURE_MAIN', amount: '9.99999999' }], + }, + { + label: 'a single transferable unit without rounding futures below its reserve', + before: { FUNDING: '0', MAIN: '0', UMFUTURE: '0.00000002' }, + targets: { FUNDING: '0.00000001', MAIN: '0', UMFUTURE: '0.00000001' }, + transfers: [{ from: 'UMFUTURE', to: 'FUNDING', kindType: 'FUTURE_CARD', amount: '0.00000001' }], + }, +]) { + test(`user automatically distributes ${label}`, () => { + // Given confirmed available balances with futures above its rounded reserve + const balances = { ...before }; + + // When automatic withdrawal-only distribution is planned and applied + const plan = buildAutomaticUsdtRebalancePlan(balances); + const finalBalances = plan.transfers.reduce(applyUsdtTransferToBalances, balances); + + // Then only futures donates and targets describe the actual conserved final balances + assert.deepEqual(plan.before, before); + assert.deepEqual(plan.targets, targets); + assert.deepEqual(plan.transfers, transfers); + assert.deepEqual(finalBalances, targets); + assert.equal(buildUsdtRebalancePlan(finalBalances).total, plan.total); + assert.deepEqual(buildAutomaticUsdtRebalancePlan(finalBalances).transfers, []); + assert.deepEqual(balances, before); + }); +} + +for (const balances of [ + { FUNDING: '0', MAIN: '100', UMFUTURE: '0' }, + { FUNDING: '95', MAIN: '0', UMFUTURE: '5' }, + { FUNDING: '0', MAIN: '90', UMFUTURE: '10' }, + { FUNDING: '50', MAIN: '40', UMFUTURE: '10' }, + { FUNDING: '0', MAIN: '0', UMFUTURE: '0' }, + { FUNDING: '0', MAIN: '0', UMFUTURE: '0.00000001' }, +]) { + test(`user never automatically refills futures or redistributes reserves at ${JSON.stringify(balances)}`, () => { + // Given futures has no excess over its rounded ten-percent reserve + const before = { ...balances }; + + // When automatic rebalancing evaluates the account + const plan = buildAutomaticUsdtRebalancePlan(before); + + // Then the account remains unchanged even if reserve wallets miss their nominal ratio + assert.deepEqual(plan.transfers, []); + assert.deepEqual(plan.before, balances); + assert.deepEqual(plan.targets, balances); + }); +} + +test('user rejects unsupported precision before planning automatic withdrawal', () => { + // Given futures has a balance finer than the supported USDT unit + const balances = { FUNDING: '0', MAIN: '0', UMFUTURE: '1.000000001' }; + + // When automatic rebalancing evaluates the balance + const failure = captureThrownError(() => buildAutomaticUsdtRebalancePlan(balances)); + + // Then precision is rejected rather than rounded into a transfer + assert.equal(failure.message, 'USDT 余额精度超过 8 位'); +}); + test("user sees that wallet response and private transfer use stable account codes from the current page bundle", () => { // Given the account identities shared by balance responses and transfer requests const accounts = USDT_REBALANCE_ACCOUNTS;