Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion docs/binance-orderbook-trade-development.md
Original file line number Diff line number Diff line change
Expand Up @@ -270,7 +270,7 @@ 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 task ends, readable zero account counters restart the same guarded eligibility window even if the counts did not change during the task. The action opens a script-owned native `<dialog>` 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 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 `<dialog>` 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.

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.

Expand Down
186 changes: 186 additions & 0 deletions e2e/binance-orderbook/specs/continuous-rebalance-behavior.pw.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,186 @@
import { test, expect } from '../test.js';
import { ACCOUNT_PATHS, createAccountRebalanceApi } from '../fixtures/account-rebalance-api.js';
import { CURRENT_SYMBOL, OTHER_SYMBOL, ORDER_SETS, createCancelScenario } from '../scenarios/cancel-current-symbol.js';
import { openUserscriptScenario, readFixtureState } from '../helpers/userscript-page.js';
import { installScenarioClock, pauseScenarioClock } from '../helpers/scenario-clock.js';
import { installSimulatedVisibility, setSimulatedVisibility } from '../helpers/simulated-visibility.js';

const ACTION = '[data-usdt-rebalance]';
const STATUS = '#jh-binance-ladder-status';
const OTHER_POSITION = { symbol: OTHER_SYMBOL, side: 'LONG', quantity: '1' };
const API_OTHER_POSITION = { symbol: OTHER_SYMBOL, positionSide: 'LONG', positionAmount: '1' };

/** Keep the last acknowledgement pending while native account updates settle. */
async function openPendingClose(page, side) {
await installScenarioClock(page);
const host = await openUserscriptScenario(page, createCancelScenario({
positions: [{ symbol: CURRENT_SYMBOL, side, quantity: '100' }],
ui: { tradeMode: 'CLOSE' },
host: { submitApiResponses: [
{ outcome: 'success', delivery: 'immediate' },
{ outcome: 'success', delivery: 'immediate' },
{ outcome: 'success', delivery: 'manual' },
] },
}));
const panel = page.locator('#jh-binance-close-qty-multiplier-panel');
await panel.locator('[data-ladder-group="levels"][data-ladder-value="3"]').click();
const action = side === 'LONG' ? '平多' : '平空';
await panel.getByRole('button', { name: `阶梯${action}`, exact: true }).click({ modifiers: ['Alt'] });
await expect.poll(host.pendingSubmitSequences).toEqual([3]);
await pauseScenarioClock(page);

const api = createAccountRebalanceApi({ FUNDING: '100', MAIN: '0', UMFUTURE: '0' });
await page.route('https://www.binance.com/bapi/**', async route => {
const request = route.request();
const pathname = new URL(request.url()).pathname;
if (!api.supports(pathname)) return route.fallback();
const body = request.postData() === null ? undefined : request.postDataJSON();
const response = api.handle({ pathname, method: request.method(), body });
await route.fulfill({ status: response.status, contentType: 'application/json', body: JSON.stringify(response.body) });
});
return { ...host, api, action };
}

async function publishAccount(page, positions = [], orders = []) {
await page.evaluate(({ positions, orders }) => {
window.__BINANCE_FIXTURE__.setPositions(positions);
window.__BINANCE_FIXTURE__.setOrders(orders);
}, { positions, orders });
}

async function finishClose(page, host) {
await host.releaseSubmitResponse(3);
await page.clock.runFor(50);
await expect(page.locator(STATUS)).toHaveText(
`连续阶梯${host.action} · 已结束 · 当前方向已无持仓 · 1/1 轮 · 累计 3 笔`,
);
}

async function expectNoAdditionalTrading(page, host) {
const events = (await readFixtureState(page)).events;
expect(events.filter(({ type }) => type === 'order-submitted').map(({ action }) => action))
.toEqual(Array(3).fill(host.action));
expect(events.filter(({ type }) => /cancel-requested/.test(type))).toEqual([]);
expect(host.api.snapshot().requests.filter(({ pathname }) => pathname === ACCOUNT_PATHS.transfer)).toEqual([]);
expect(host.errors).toEqual([]);
}

for (const side of ['LONG', 'SHORT']) {
test(`user regains rebalance after continuous ${side} closing misses its flat check while busy`, async ({ page }) => {
// Given the final close acknowledgement is held while the account becomes flat.
const host = await openPendingClose(page, side);
await publishAccount(page);

// When the full flat window expires before the continuous task has finished.
await page.clock.runFor(2000);

// Then the busy task prevents qualification even though both counters are zero.
await expect(page.locator('[data-account-tab="positions"]')).toHaveText('仓位(0)');
await expect(page.locator('[data-account-tab="openOrders"]')).toHaveText('当前委托(0)');
await expect(page.getByRole('button', { name: `停止${host.action}`, exact: true })).toBeVisible();
await expect(page.locator(ACTION)).toBeHidden();

// When the final acknowledgement arrives without another account-counter change.
await finishClose(page, host);
let qualificationRequested = false;
const release = Promise.withResolvers();
await page.route(`https://www.binance.com${ACCOUNT_PATHS.positions}`, async route => {
qualificationRequested = true;
await release.promise;
await route.fallback();
});
const requestsBefore = host.api.snapshot().requests.length;
await page.clock.runFor(1949);

// Then the renewed full two-second window still prevents an early check.
expect(host.api.snapshot().requests).toHaveLength(requestsBefore);
expect(qualificationRequested).toBe(false);
await expect(page.locator(ACTION)).toBeHidden();

// When the renewed deadline passes but its fresh authoritative response is held.
await page.clock.runFor(51);
await expect.poll(() => qualificationRequested).toBe(true);

// Then prior flat evidence cannot expose the action before the new response.
await expect(page.locator(ACTION)).toBeHidden();

// When the fresh response confirms all account positions are flat.
const response = page.waitForResponse(`https://www.binance.com${ACCOUNT_PATHS.positions}`);
release.resolve();
await (await response).finished();
await page.clock.runFor(32);

// Then the action returns automatically without any extra order or transfer.
await expect(page.locator(ACTION)).toBeVisible();
await expect(page.locator(ACTION)).toBeEnabled();
await expectNoAdditionalTrading(page, host);
});
}

for (const blocker of ['native position', 'native order', 'API position']) {
test(`user cannot rebalance after continuous closing while another ${blocker} remains`, async ({ page }) => {
// Given a pending close still has unrelated account activity.
const host = await openPendingClose(page, 'LONG');
if (blocker !== 'native order') host.api.setPositions([API_OTHER_POSITION]);
await publishAccount(page,
blocker === 'native position' ? [OTHER_POSITION] : [],
blocker === 'native order' ? ORDER_SETS.other : [],
);

// When the current direction finishes and another full qualification window passes.
await page.clock.runFor(2000);
await finishClose(page, host);
await page.clock.runFor(2050);

// Then current-direction completion cannot expose a globally unsafe rebalance action.
await expect(page.locator(ACTION)).toBeHidden();
if (blocker === 'API position') {
expect(host.api.snapshot().requests.filter(({ pathname }) => pathname === ACCOUNT_PATHS.positions).length)
.toBeGreaterThanOrEqual(3);
}
await expectNoAdditionalTrading(page, host);
});
}

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.
const host = await openPendingClose(page, 'LONG');
await installSimulatedVisibility(page);
await publishAccount(page);

// When the page hides before the final acknowledgement arrives.
await setSimulatedVisibility(page, true);
await host.releaseSubmitResponse(3);
await page.clock.runFor(2050);

// Then the next round detects flat before submitting, while the hidden page cannot qualify.
await expect(page.locator(STATUS)).toHaveText(
'连续阶梯平多 · 已结束 · 当前方向已无持仓 · 1/2 轮 · 累计 3 笔',
);
await expect(page.locator(ACTION)).toBeHidden();
const completedRequests = host.api.snapshot().requests.length;

// When another full eligibility interval passes while the completed page stays hidden.
await page.clock.runFor(2000);

// Then completion cannot start a hidden account qualification request.
expect(host.api.snapshot().requests).toHaveLength(completedRequests);
await expect(page.locator(ACTION)).toBeHidden();

// When the page returns and completes a fresh visible flat window.
await setSimulatedVisibility(page, false);
await page.clock.runFor(1999);

// Then the action is still hidden before the full two seconds.
await expect(page.locator(ACTION)).toBeHidden();

// When the remaining millisecond passes and the fresh flat response arrives.
const response = page.waitForResponse(`https://www.binance.com${ACCOUNT_PATHS.positions}`);
await page.clock.runFor(1);
await (await response).finished();
await page.clock.runFor(32);

// Then returning visibility restores the manual action without automatic transfers.
await expect(page.locator(ACTION)).toBeVisible();
await expectNoAdditionalTrading(page, host);
});
3 changes: 2 additions & 1 deletion scripts/binance-orderbook-trade.user.js
Original file line number Diff line number Diff line change
Expand Up @@ -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.214
// @version 2.7.215
// @author jackhai9
// @description 单击订单簿价格,按当前开仓/平仓 tab 自动填数量并执行下单,内置数量倍率面板
// @match https://www.binance.com/*/futures/*
Expand Down Expand Up @@ -8398,6 +8398,7 @@
continuousLadderTask = null;
activeContinuousLadderActionType = null;
activeContinuousLadderProgress = null;
restartUsdtRebalanceEligibilityFromCurrentAccountState();
scheduleRenderPanel();
});
scheduleRenderPanel();
Expand Down
7 changes: 4 additions & 3 deletions src/binance-orderbook-trade/index.user.js
Original file line number Diff line number Diff line change
Expand Up @@ -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.214
// @version 2.7.215
// @author jackhai9
// @description 单击订单簿价格,按当前开仓/平仓 tab 自动填数量并执行下单,内置数量倍率面板
// @match https://www.binance.com/*/futures/*
Expand Down Expand Up @@ -4115,6 +4115,7 @@ import { showUsdtRebalanceDialog } from './dom/usdt-rebalance-dialog.js';
continuousLadderTask = null;
activeContinuousLadderActionType = null;
activeContinuousLadderProgress = null;
restartUsdtRebalanceEligibilityFromCurrentAccountState();
scheduleRenderPanel();
});
scheduleRenderPanel();
Expand Down Expand Up @@ -7032,8 +7033,8 @@ import { showUsdtRebalanceDialog } from './dom/usdt-rebalance-dialog.js';
}

/**
* Cancellation invalidates eligibility before it starts, but a no-orders result leaves
* both observed counters at 0 and therefore produces no observer transition to restart it.
* Account counters can settle at zero while a task still blocks qualification.
* Task completion must restart the window because unchanged counters emit no transition.
*/
function restartUsdtRebalanceEligibilityFromCurrentAccountState() {
if (document.hidden || !isFuturesTradingPage()) return;
Expand Down
Loading