From 15b7c18ad7c797aed30a0035bb24bbd65739f1c2 Mon Sep 17 00:00:00 2001
From: LiZhenhai-MBP14 <5935568+jackhai9@users.noreply.github.com>
Date: Sun, 20 Sep 2026 13:38:57 +0800
Subject: [PATCH] fix: continue Binance tasks in hidden tabs
---
docs/binance-orderbook-trade-development.md | 10 +
docs/binance-orderbook-trade-ui-automation.md | 10 +
.../helpers/simulated-visibility.js | 49 +++++
.../specs/account-rebalance-behavior.pw.js | 28 +++
.../specs/cancel-current-symbol.pw.js | 30 +++
.../specs/continuous-readiness-behavior.pw.js | 26 +++
.../specs/order-submit-behavior.pw.js | 117 +++++++++++
scripts/binance-orderbook-trade.user.js | 188 +++++++++++-------
src/binance-orderbook-trade/dom/trade-form.js | 171 +++++++++-------
src/binance-orderbook-trade/index.user.js | 43 ++--
test/helpers/dom.js | 1 +
.../trade-form.test.js | 105 ++++++++++
12 files changed, 616 insertions(+), 162 deletions(-)
create mode 100644 e2e/binance-orderbook/helpers/simulated-visibility.js
diff --git a/docs/binance-orderbook-trade-development.md b/docs/binance-orderbook-trade-development.md
index 8ce2e7b..8ab415e 100644
--- a/docs/binance-orderbook-trade-development.md
+++ b/docs/binance-orderbook-trade-development.md
@@ -276,6 +276,8 @@ The rebalance path is pinned to the current Binance page bundle contract instead
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.
+
Ladder replacement must stay scoped and direction-aware. Automatic replacement may cancel only visible basic open-order rows for the current symbol and the same plan direction (`开多`, `开空`, `平多`, or `平空`). It must not use current-symbol cancel-all for ladder replacement, must not touch conditional/protection orders, and must retry the ladder plan only after the replacement path is validated by current DOM rows.
User-facing failures must preserve the observed reason instead of collapsing multiple states into one generic message. In particular, an unread openable quantity, a confirmed zero available balance, and a Binance-calculated openable quantity of zero are separate outcomes. Use the panel term `价格精度` consistently instead of exposing the internal `缩放值` name. Cancellation feedback should describe the user-visible action and result, not implementation details such as row-by-row processing, observer roots, context loss, or temporary chart-state manipulation. Combined `A or B` failures are allowed only when the code cannot distinguish the causes; when each branch is already known, report that branch directly.
@@ -286,6 +288,8 @@ Every observed place-order response keeps a sanitized diagnostic contract: HTTP
Continuous ladder trading is available only for close actions through `Option/Alt + click`; an ordinary click remains one round. A round is the complete existing ladder-close workflow, including any scoped same-direction replacement and cleanup. After a completed round, the runner must observe the same symbol, close mode, current precision, and native close button as ready before starting a full one-second cooldown. It must validate readiness again after the cooldown; losing readiness restarts the wait and a new full cooldown. Every new round must rebuild its plan from the latest panel profile and live trading context. A failed, stopped, or interrupted round ends the continuous session.
+An already clicked ladder or single-order task may continue when Chrome hides the page. Input and action-button stability checks use separate timer tasks while hidden because paint frames can pause; they still re-read live controls, current symbol, mode, precision, price, quantity, and the exact native response before moving to the next order. A single-order draft expires 15 seconds after its trusted price click, so a long background stall cannot later submit its old clicked price and quantity. Continuous close readiness and reduce-only recovery use those same live conditions without treating `document.hidden` as a refusal. Stop aborts an active ladder wait before any later submit. Background timers can be throttled, so neither order timing nor the one-second continuous cooldown has a strict wall-clock guarantee while hidden. A frozen or discarded page cannot run page JavaScript until Chrome resumes or reloads it; this userscript does not resume an unconfirmed order automatically after reload.
+
Continuous-session feedback stays in the shared ladder status row and uses `连续阶梯平多` / `连续阶梯平空` as the stable action name. The action, phase, and counters are separated with ` · ` instead of concatenating `连续` after the ordinary ladder label. `2/3 轮` means two rounds completed out of three started, `本轮 1/3 笔` reports the active or latest partial plan, and `累计 7 笔` reports all confirmed submissions across the session. Confirmed cancellations are appended only when greater than zero. The active round must combine its live progress with the completed-round aggregate; ordinary single-round status text must never overwrite the continuous-session identity. Round outcomes must expose a detached progress snapshot so a terminal continuous summary cannot be overwritten by the latest single-round message.
A session waiting for readiness, stopping, or failing before its first recorded
@@ -334,6 +338,12 @@ Run manual checks when behavior touches trading flow, DOM selectors, account ord
- verify the precision apply button changes Binance orderbook precision only after an explicit user click
- verify precision decrease/increase selects the exact native divide-by-10/multiply-by-10 option, restores the corresponding symbol-mode-precision panel profile, and stops at a missing native decade option
- start ladder order, confirm start buttons are disabled while running
+- after clicking ordinary open and close ladders, hide or minimize Chrome while the first order is pending; verify the task completes the remaining orders with the captured direction, and that Stop prevents a pending hidden control check from submitting
+- hide the page immediately after a trusted single-order price click; verify exactly one current-symbol order is submitted after the input settles
+- hold a hidden single-order draft for longer than 15 seconds, then resume the page; verify the stale clicked price is refused without a native order request
+- hide the page after one continuous-close round is acknowledged; verify the next round can start after live readiness and cooldown, then Stop while its response is pending and verify no additional order starts
+- hide the page after confirming current-symbol cancellation or a reviewed USDT rebalance; verify the original scope, manual confirmation, per-step account checks, final balance, and temporary UI restoration
+- repeat one background scenario in an actual Chrome/Tampermonkey tab and after minimizing Chrome; record the installed userscript version, hidden/frozen/discarded state, elapsed time, request count, exact exchange response, and refreshed authoritative order/account state
- Option/Alt-click a close ladder button, confirm the next round starts only after the prior round is complete, the native close action is ready, and one full second has elapsed; change ratio, levels, row span, and precision during the wait and confirm the next plan uses the updated profile
- during a continuous close cooldown, make the native close action temporarily unavailable and confirm the cooldown restarts only after readiness returns; confirm Stop also aborts the cooldown immediately
- complete multiple continuous close rounds, then stop both during a round and during cooldown; confirm every active-round status starts with `连续阶梯平多` or `连续阶梯平空` and shows completed/started rounds, live-round order ratio, cumulative confirmed submissions, and no zero-cancellation segment
diff --git a/docs/binance-orderbook-trade-ui-automation.md b/docs/binance-orderbook-trade-ui-automation.md
index 621df9b..096f4e2 100644
--- a/docs/binance-orderbook-trade-ui-automation.md
+++ b/docs/binance-orderbook-trade-ui-automation.md
@@ -50,6 +50,16 @@ not a latency or throughput measurement. The ordinary single-round unknown
submission scenario must not be generalized to continuous mode, whose existing
`submit_unconfirmed` policy deliberately permits a later recovery round.
+Background-tab L2 scenarios override `document.hidden`, dispatch
+`visibilitychange`, and pause paint-frame callbacks after the real generated
+userscript is loaded. They verify that already clicked open/close ladders,
+continuous close, single-order submission, confirmed cancellation, and confirmed
+rebalance continue through the fixture's native request and response boundaries.
+This harness does not model Chrome timer throttling, page freeze, discard, or
+Tampermonkey injection. Those require a separate L3/L4 observation in an actual
+background or minimized Chrome tab; record elapsed time and authoritative
+exchange/account state before claiming that the live task completed.
+
`npm run test:coverage` additionally collects V8 execution and maps it to the
complete production source set. The collector's own browser proof uses virtual
code and remains separate from production coverage. See [Source Coverage](test-coverage.md)
diff --git a/e2e/binance-orderbook/helpers/simulated-visibility.js b/e2e/binance-orderbook/helpers/simulated-visibility.js
new file mode 100644
index 0000000..7a322f0
--- /dev/null
+++ b/e2e/binance-orderbook/helpers/simulated-visibility.js
@@ -0,0 +1,49 @@
+/** Simulate a background tab after the real userscript has loaded in the fixture. */
+export async function installSimulatedVisibility(page) {
+ await page.evaluate(() => {
+ const nativeRequestAnimationFrame = window.requestAnimationFrame.bind(window);
+ const nativeCancelAnimationFrame = window.cancelAnimationFrame.bind(window);
+ const pendingHiddenFrames = new Map();
+ const resumedHiddenFrames = new Map();
+ let nextHiddenFrame = 1_000_000;
+ const state = {
+ hidden: false,
+ setHidden(hidden) {
+ if (this.hidden === hidden) return;
+ this.hidden = hidden;
+ document.dispatchEvent(new Event('visibilitychange'));
+ if (!hidden) {
+ for (const [handle, callback] of pendingHiddenFrames) {
+ pendingHiddenFrames.delete(handle);
+ resumedHiddenFrames.set(handle, nativeRequestAnimationFrame(timestamp => {
+ resumedHiddenFrames.delete(handle);
+ callback(timestamp);
+ }));
+ }
+ }
+ },
+ };
+ Object.defineProperty(document, 'hidden', { configurable: true, get: () => state.hidden });
+ window.requestAnimationFrame = callback => {
+ if (!state.hidden) return nativeRequestAnimationFrame(callback);
+ const handle = nextHiddenFrame++;
+ pendingHiddenFrames.set(handle, callback);
+ return handle;
+ };
+ window.cancelAnimationFrame = handle => {
+ if (pendingHiddenFrames.delete(handle)) return;
+ const resumedHandle = resumedHiddenFrames.get(handle);
+ if (resumedHiddenFrames.has(handle)) {
+ resumedHiddenFrames.delete(handle);
+ nativeCancelAnimationFrame(resumedHandle);
+ return;
+ }
+ nativeCancelAnimationFrame(handle);
+ };
+ window.__SIMULATED_VISIBILITY__ = state;
+ });
+}
+
+export async function setSimulatedVisibility(page, hidden) {
+ await page.evaluate(value => window.__SIMULATED_VISIBILITY__.setHidden(value), hidden);
+}
diff --git a/e2e/binance-orderbook/specs/account-rebalance-behavior.pw.js b/e2e/binance-orderbook/specs/account-rebalance-behavior.pw.js
index 4cee469..b4c0afe 100644
--- a/e2e/binance-orderbook/specs/account-rebalance-behavior.pw.js
+++ b/e2e/binance-orderbook/specs/account-rebalance-behavior.pw.js
@@ -3,6 +3,7 @@ import { ACCOUNT_PATHS, createAccountRebalanceApi } from '../fixtures/account-re
import { 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';
async function openRebalance(page, balances, options) {
await installScenarioClock(page);
@@ -53,6 +54,33 @@ test('user completes exactly two USDT transfers only after confirming the comple
expect(errors).toEqual([]);
});
+test('user completes a confirmed account rebalance after the tab becomes hidden', async ({ page }) => {
+ // Given the user has reviewed a two-transfer plan while the trading page is visible.
+ const { api, errors, action, status } = await openRebalance(page, {
+ FUNDING: '100', MAIN: '0', UMFUTURE: '0',
+ });
+ await installSimulatedVisibility(page);
+ await action.evaluate(button => button.click());
+ const dialog = page.getByRole('dialog', { name: '账户再平衡' });
+ await expect(dialog).toBeVisible();
+ await expect(dialog).toContainText('40 USDT');
+ await expect(dialog).toContainText('10 USDT');
+
+ // When confirmation is clicked and the tab hides in the same event turn.
+ await dialog.getByRole('button', { name: '确认再平衡', exact: true }).evaluate(button => {
+ button.click();
+ window.__SIMULATED_VISIBILITY__.setHidden(true);
+ });
+
+ // Then each transfer still requires fresh account checks and reaches the reviewed target.
+ await expect.poll(() => api.snapshot().requests.filter(request => request.pathname === ACCOUNT_PATHS.transfer))
+ .toHaveLength(2);
+ await setSimulatedVisibility(page, false);
+ await expect(status).toHaveText('账户再平衡已完成 · 2/2 笔');
+ expect(api.snapshot().balances).toEqual({ FUNDING: '50', MAIN: '40', UMFUTURE: '10' });
+ expect(errors).toEqual([]);
+});
+
test('user cancels an account preview without sending a transfer', async ({ page }) => {
// Given a flat account has a valid two-transfer rebalance plan.
const { api, errors, action, status } = await openRebalance(page, { FUNDING: '100', MAIN: '0', UMFUTURE: '0' });
diff --git a/e2e/binance-orderbook/specs/cancel-current-symbol.pw.js b/e2e/binance-orderbook/specs/cancel-current-symbol.pw.js
index bffde24..d6589f0 100644
--- a/e2e/binance-orderbook/specs/cancel-current-symbol.pw.js
+++ b/e2e/binance-orderbook/specs/cancel-current-symbol.pw.js
@@ -11,6 +11,7 @@ import {
readFixtureState,
} from '../helpers/userscript-page.js';
import { installScenarioClock, pauseScenarioClock } from '../helpers/scenario-clock.js';
+import { installSimulatedVisibility, setSimulatedVisibility } from '../helpers/simulated-visibility.js';
import {
assertResponsiveInteraction,
assertStableGeometry,
@@ -216,6 +217,35 @@ test('user confirms cancellation for the current symbol while other-symbol order
expect(errors).toEqual([]);
});
+test('user finishes confirmed current-symbol cancellation after the tab becomes hidden', async ({ page }) => {
+ // Given a native confirmation is open for current and other-symbol Basic orders.
+ const scenario = createCancelScenario({
+ positions: POSITION_SETS.both,
+ orders: ORDER_SETS.both,
+ ui: { hideOtherSymbols: false, accountTab: 'positions', showOrders: true },
+ });
+ const { errors } = await openUserscriptScenario(page, scenario);
+ await installSimulatedVisibility(page);
+ await page.getByRole('button', { name: '撤单' }).click();
+ const dialog = page.getByRole('dialog');
+ await expect(dialog).toBeVisible();
+
+ // When the user confirms, then moves the page to a background tab.
+ await dialog.getByRole('button', { name: '确认' }).evaluate(button => {
+ button.click();
+ window.__SIMULATED_VISIBILITY__.setHidden(true);
+ });
+
+ // Then only the captured current-symbol order is cancelled and UI state is restored.
+ await expect.poll(async () => (await readFixtureState(page)).events
+ .filter(({ type }) => type === 'cancel-requested')).toHaveLength(1);
+ await setSimulatedVisibility(page, false);
+ await expect(page.getByText('撤单已完成')).toBeVisible();
+ expect((await readFixtureState(page)).orders).toEqual(otherSymbolOrders(scenario));
+ await expectRestoredState(page, scenario);
+ expect(errors).toEqual([]);
+});
+
test('user keeps an already enabled symbol filter after confirming cancellation', async ({ page }) => {
// Given the initial open-orders view has Hide Other Symbols enabled and chart orders hidden.
const scenario = createCancelScenario({
diff --git a/e2e/binance-orderbook/specs/continuous-readiness-behavior.pw.js b/e2e/binance-orderbook/specs/continuous-readiness-behavior.pw.js
index 812361b..080d7b3 100644
--- a/e2e/binance-orderbook/specs/continuous-readiness-behavior.pw.js
+++ b/e2e/binance-orderbook/specs/continuous-readiness-behavior.pw.js
@@ -2,6 +2,7 @@ import { test, expect } from '../test.js';
import { CURRENT_SYMBOL, 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 PANEL = '#jh-binance-close-qty-multiplier-panel';
const STATUS = '#jh-binance-ladder-status';
@@ -174,6 +175,31 @@ test('user completes two close-short rounds with a full cooldown and exact cumul
expect(host.errors).toEqual([]);
});
+test('user continues a confirmed close round while hidden and can stop the next hidden round', async ({ page }) => {
+ // Given the first round's final native acknowledgement is held after a visible user click.
+ const host = await openPendingFirstRound(page);
+ await installSimulatedVisibility(page);
+
+ // When the tab becomes hidden and the held acknowledgement completes.
+ await setSimulatedVisibility(page, true);
+ await host.releaseSubmitResponse(3);
+ await page.clock.resume();
+
+ // Then the next round begins after its readiness check without a foreground frame.
+ await expect.poll(host.pendingSubmitSequences, { timeout: 10_000 }).toEqual([4]);
+ expect((await readSubmissions(page)).map(({ action }) => action)).toEqual(Array(4).fill('平空'));
+
+ // When Stop is clicked while that fourth native response remains held.
+ await host.panel.locator('[data-ladder-stop]').evaluate(button => button.click());
+ await host.releaseSubmitResponse(4);
+ await setSimulatedVisibility(page, false);
+
+ // Then no later order starts and the acknowledged progress remains exact.
+ await expect(host.status).toContainText('已停止');
+ expect(await readSubmissions(page)).toHaveLength(4);
+ expect(host.errors).toEqual([]);
+});
+
test('user waits for a disabled close button and then receives a complete cooldown', async ({ page }) => {
// Given the current round can finish while the native close button is disabled.
const host = await openPendingFirstRound(page);
diff --git a/e2e/binance-orderbook/specs/order-submit-behavior.pw.js b/e2e/binance-orderbook/specs/order-submit-behavior.pw.js
index 39d5c20..04f0de7 100644
--- a/e2e/binance-orderbook/specs/order-submit-behavior.pw.js
+++ b/e2e/binance-orderbook/specs/order-submit-behavior.pw.js
@@ -2,6 +2,7 @@ import { test, expect } from '../test.js';
import { CURRENT_SYMBOL, 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 PLACE_ORDER = '**/bapi/futures/v1/private/future/order/place-order';
const STATUS = '#jh-binance-ladder-status';
@@ -12,6 +13,122 @@ 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 direction of [
+ { action: 'OPEN_LONG', mode: 'OPEN', label: '开多' },
+ { action: 'CLOSE_SHORT', mode: 'CLOSE', label: '平空' },
+]) {
+ test(`user completes a clicked ${direction.action} ladder after its tab becomes hidden`, async ({ page }) => {
+ // Given the first native order request is held after the visible button click.
+ const host = await openUserscriptScenario(page, createCancelScenario({
+ positions: [{ symbol: CURRENT_SYMBOL, side: 'SHORT', quantity: '100' }],
+ ui: { tradeMode: direction.mode },
+ host: { submitApiResponses: [
+ { outcome: 'success', delivery: 'manual' },
+ ...Array.from({ length: 4 }, () => ({ outcome: 'success', delivery: 'immediate' })),
+ ] },
+ }));
+ await installSimulatedVisibility(page);
+ await page.locator(`[data-ladder-action="${direction.action}"]`).click();
+ await expect.poll(host.pendingSubmitSequences).toEqual([1]);
+
+ // When the tab becomes hidden before the first response arrives.
+ await setSimulatedVisibility(page, true);
+ await host.releaseSubmitResponse(1);
+
+ // Then the original task submits each remaining order in the captured direction.
+ await expect.poll(async () => (await readFixtureState(page)).events
+ .filter(({ type }) => type === 'order-submitted'), { timeout: 10_000 }).toHaveLength(5);
+ await setSimulatedVisibility(page, false);
+ await expect(page.locator(STATUS)).toContainText('已完成');
+ const submitted = (await readFixtureState(page)).events.filter(({ type }) => type === 'order-submitted');
+ expect(submitted.map(({ action }) => action)).toEqual(Array(5).fill(direction.label));
+ expect(host.errors).toEqual([]);
+ });
+}
+
+test('user completes one orderbook click after the tab becomes hidden before input settlement', async ({ page }) => {
+ // Given the native orderbook click starts the actual single-order input workflow.
+ const host = await openUserscriptScenario(page, createCancelScenario({ host: {
+ submitApiResponses: [{ outcome: 'success', delivery: 'manual' }],
+ } }));
+ await installSimulatedVisibility(page);
+
+ // When a trusted click reaches the page and the same event turn hides the tab.
+ await page.evaluate(() => {
+ document.addEventListener('click', event => {
+ if (event.target.closest('#futuresOrderbook .bid-light.emit-price')) {
+ queueMicrotask(() => window.__SIMULATED_VISIBILITY__.setHidden(true));
+ }
+ }, { capture: true, once: true });
+ });
+ await page.locator('#futuresOrderbook .bid-light.emit-price').first().click();
+ expect(await page.evaluate(() => document.hidden)).toBe(true);
+
+ // Then one native request is made and the acknowledgement completes that task.
+ await expect.poll(host.pendingSubmitSequences, { timeout: 10_000 }).toEqual([1]);
+ await host.releaseSubmitResponse(1);
+ await setSimulatedVisibility(page, false);
+ await expect(page.locator(STATUS)).toHaveText('单击开多已提交 · 81.0 × 0.07');
+ expect((await readFixtureState(page)).events.filter(({ type }) => type === 'order-submitted'))
+ .toEqual([expect.objectContaining({ action: '开多', price: '81.0', quantity: '0.07' })]);
+ expect(host.errors).toEqual([]);
+});
+
+test('user does not submit an old orderbook click after a long hidden-page stall', async ({ page }) => {
+ // Given a trusted single-order click starts before Chrome hides the page.
+ await installScenarioClock(page);
+ const host = await openUserscriptScenario(page, createCancelScenario());
+ await installSimulatedVisibility(page);
+ await page.evaluate(() => {
+ document.addEventListener('click', event => {
+ if (event.target.closest('#futuresOrderbook .bid-light.emit-price')) {
+ queueMicrotask(() => window.__SIMULATED_VISIBILITY__.setHidden(true));
+ }
+ }, { capture: true, once: true });
+ });
+ await page.locator('#futuresOrderbook .bid-light.emit-price').first().click();
+ expect(await page.evaluate(() => document.hidden)).toBe(true);
+
+ // When a long frozen-style time jump passes before background checks can settle.
+ await pauseScenarioClock(page);
+ await page.clock.fastForward(30_000);
+ await page.clock.runFor(500);
+ await setSimulatedVisibility(page, false);
+
+ // Then the original clicked price is expired and cannot become a native order.
+ await expect(page.locator(STATUS)).toContainText('点击后等待过久');
+ expect((await readFixtureState(page)).events.filter(({ type }) => type === 'order-submitted'))
+ .toEqual([]);
+ expect(host.errors).toEqual([]);
+});
+
+test('user stops a hidden ladder before its pending control check can submit', async ({ page }) => {
+ // Given a trusted ladder click starts while the foreground form is ready.
+ await installScenarioClock(page);
+ const host = await openUserscriptScenario(page, createCancelScenario());
+ await installSimulatedVisibility(page);
+ await page.evaluate(() => {
+ document.addEventListener('click', event => {
+ if (event.target.closest('[data-ladder-action="OPEN_LONG"]')) {
+ queueMicrotask(() => window.__SIMULATED_VISIBILITY__.setHidden(true));
+ }
+ }, { capture: true, once: true });
+ });
+
+ // When the tab hides in that click turn and Stop cancels the pending wait.
+ await page.locator('[data-ladder-action="OPEN_LONG"]').click();
+ await page.evaluate(() => window.__TM_CLOSE_LONG_DEBUG__.stopLadder());
+ await setSimulatedVisibility(page, false);
+
+ // Then no native submit is made after the cancelled wait receives more time.
+ await pauseScenarioClock(page);
+ await page.clock.runFor(400);
+ await expect(page.locator(STATUS)).toContainText('已停止');
+ expect((await readFixtureState(page)).events.filter(({ type }) => type === 'order-submitted'))
+ .toEqual([]);
+ expect(host.errors).toEqual([]);
+});
+
for (const direction of DIRECTIONS) {
test(`user reprices only the three remaining ${direction.action} orders after a native maker rejection`, async ({ page }) => {
// Given the first two orders succeed and the third meets a changed native book.
diff --git a/scripts/binance-orderbook-trade.user.js b/scripts/binance-orderbook-trade.user.js
index 6924290..2d3db4f 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.211
+// @version 2.7.212
// @author jackhai9
// @description 单击订单簿价格,按当前开仓/平仓 tab 自动填数量并执行下单,内置数量倍率面板
// @match https://www.binance.com/*/futures/*
@@ -2703,97 +2703,116 @@
check();
});
}
- function waitForTradeFormFrameState(observationRoot, readState, timeoutMs, requiredStableFrames = 2) {
- const view = observationRoot?.ownerDocument?.defaultView;
+ function waitForStableTradeControl(observationRoot, readCandidate, isSameCandidate, timeoutMs, requiredStableFrames, abortSignal, missingSchedulerMessage) {
+ const document2 = observationRoot?.ownerDocument || observationRoot;
+ const view = document2?.defaultView;
if (!view || typeof view.requestAnimationFrame !== "function" || typeof view.cancelAnimationFrame !== "function") {
- throw new Error("交易表单帧调度器不可用");
+ throw new Error(missingSchedulerMessage);
}
if (!Number.isInteger(requiredStableFrames) || requiredStableFrames < 1) {
throw new Error("稳定帧数必须为正整数");
}
- return new Promise((resolve) => {
+ return new Promise((resolve, reject) => {
let settled = false;
let frameHandle = 0;
- let timer = 0;
+ let observationTimer = 0;
+ let deadlineTimer = 0;
let stableFrames = 0;
- let stableState = null;
- const finish = (value) => {
+ let stableCandidate = null;
+ let hiddenObservations = 0;
+ const maxHiddenObservations = Math.max(requiredStableFrames, Math.ceil(timeoutMs / 100));
+ const finish = (value, error = null) => {
if (settled) return;
settled = true;
if (frameHandle) view.cancelAnimationFrame(frameHandle);
- view.clearTimeout(timer);
- resolve(value);
+ view.clearTimeout(observationTimer);
+ view.clearTimeout(deadlineTimer);
+ document2.removeEventListener("visibilitychange", onVisibilityChange);
+ abortSignal?.removeEventListener("abort", onAbort);
+ if (error) reject(error);
+ else resolve(value);
+ };
+ const onAbort = () => finish(null, abortSignal.reason);
+ const scheduleObservation = () => {
+ if (document2.hidden) {
+ observationTimer = view.setTimeout(check, 100);
+ } else {
+ frameHandle = view.requestAnimationFrame(check);
+ }
};
const check = () => {
frameHandle = 0;
- const state = readState();
- if (state) {
- stableFrames += 1;
- stableState = state;
+ observationTimer = 0;
+ const candidate = readCandidate();
+ if (candidate) {
+ stableFrames = isSameCandidate(candidate, stableCandidate) ? stableFrames + 1 : 1;
+ stableCandidate = candidate;
if (stableFrames >= requiredStableFrames) {
- finish(stableState);
+ finish(candidate);
return;
}
} else {
stableFrames = 0;
- stableState = null;
+ stableCandidate = null;
+ }
+ if (document2.hidden) {
+ hiddenObservations += 1;
+ if (hiddenObservations >= maxHiddenObservations) {
+ finish(null);
+ return;
+ }
}
- frameHandle = view.requestAnimationFrame(check);
+ scheduleObservation();
+ };
+ const onVisibilityChange = () => {
+ if (frameHandle) view.cancelAnimationFrame(frameHandle);
+ view.clearTimeout(observationTimer);
+ view.clearTimeout(deadlineTimer);
+ frameHandle = 0;
+ observationTimer = 0;
+ if (!document2.hidden) deadlineTimer = view.setTimeout(() => finish(null), timeoutMs);
+ scheduleObservation();
};
- timer = view.setTimeout(() => finish(null), timeoutMs);
- frameHandle = view.requestAnimationFrame(check);
+ document2.addEventListener("visibilitychange", onVisibilityChange);
+ abortSignal?.addEventListener("abort", onAbort, { once: true });
+ if (abortSignal?.aborted) {
+ onAbort();
+ return;
+ }
+ if (!document2.hidden) deadlineTimer = view.setTimeout(() => finish(null), timeoutMs);
+ scheduleObservation();
});
}
- function waitForTradeActionButtonFrameState(observationRoot, findButton, isVisibleElement, timeoutMs, requiredStableFrames = 2) {
- const view = observationRoot?.ownerDocument?.defaultView || observationRoot?.defaultView;
- if (!view || typeof view.requestAnimationFrame !== "function" || typeof view.cancelAnimationFrame !== "function") {
- throw new Error("下单按钮帧调度器不可用");
- }
+ function waitForTradeFormFrameState(observationRoot, readState, timeoutMs, requiredStableFrames = 2, abortSignal = null) {
+ return waitForStableTradeControl(
+ observationRoot,
+ readState,
+ () => true,
+ timeoutMs,
+ requiredStableFrames,
+ abortSignal,
+ "交易表单帧调度器不可用"
+ );
+ }
+ function waitForTradeActionButtonFrameState(observationRoot, findButton, isVisibleElement, timeoutMs, requiredStableFrames = 2, abortSignal = null) {
if (typeof findButton !== "function" || typeof isVisibleElement !== "function") {
throw new Error("下单按钮定位器不可用");
}
- if (!Number.isInteger(requiredStableFrames) || requiredStableFrames < 1) {
- throw new Error("稳定帧数必须为正整数");
- }
- return new Promise((resolve) => {
- let settled = false;
- let frameHandle = 0;
- let timer = 0;
- let stableFrames = 0;
- let stableButton = null;
- const finish = (value) => {
- if (settled) return;
- settled = true;
- if (frameHandle) view.cancelAnimationFrame(frameHandle);
- view.clearTimeout(timer);
- resolve(value);
- };
- const check = () => {
- frameHandle = 0;
+ return waitForStableTradeControl(
+ observationRoot,
+ () => {
const button = findButton();
const actionable = Boolean(
button && button.isConnected && isVisibleElement(button) && !button.disabled && button.getAttribute("aria-disabled") !== "true"
);
- if (actionable) {
- if (button === stableButton) {
- stableFrames += 1;
- } else {
- stableButton = button;
- stableFrames = 1;
- }
- if (stableFrames >= requiredStableFrames) {
- finish(button);
- return;
- }
- } else {
- stableButton = null;
- stableFrames = 0;
- }
- frameHandle = view.requestAnimationFrame(check);
- };
- timer = view.setTimeout(() => finish(null), timeoutMs);
- frameHandle = view.requestAnimationFrame(check);
- });
+ return actionable ? button : null;
+ },
+ (button, previousButton) => button === previousButton,
+ timeoutMs,
+ requiredStableFrames,
+ abortSignal,
+ "下单按钮帧调度器不可用"
+ );
}
function isTradeModeTab(node, { panelId }) {
if (!node?.matches?.('[role="tab"]')) return false;
@@ -5225,6 +5244,7 @@
const USDT_REBALANCE_BALANCE_POLL_MS = 1e3;
const LADDER_OPEN_QTY_READY_TIMEOUT_MS = 1200;
const TRADE_INPUT_SYNC_TIMEOUT_MS = 350;
+ const SINGLE_ORDER_DRAFT_MAX_AGE_MS = 15e3;
const TRADE_INPUT_SYNC_STABLE_FRAMES = 2;
const LADDER_INPUT_SETTLE_TIMEOUT_MS = 1200;
const LADDER_INPUT_SETTLE_STABLE_MS = 180;
@@ -7470,7 +7490,8 @@
observationRoot,
readTradeState,
syncTimeoutMs,
- TRADE_INPUT_SYNC_STABLE_FRAMES
+ TRADE_INPUT_SYNC_STABLE_FRAMES,
+ options?.abortSignal
);
if (synchronized) return synchronized;
assertSubmittedPriceMatchesExpectedPrice(
@@ -7490,7 +7511,7 @@
const cls = String(button.className || "").toLowerCase();
return button.disabled || button.getAttribute("aria-disabled") === "true" || button.getAttribute("aria-busy") === "true" || button.getAttribute("data-loading") === "true" || includesBinancePageText(text, BINANCE_PAGE_TEXT.submitBusy) || cls.includes("loading") || !!button.querySelector('[class*="loading"], [class*="spinner"], [aria-busy="true"]');
}
- async function waitForReadyLadderSubmitButton(plan) {
+ async function waitForReadyLadderSubmitButton(plan, abortSignal) {
const resolveReadyButton = () => {
const candidate = plan.spec.buttonGetter();
return candidate && !isSubmitButtonBusy(candidate) ? candidate : null;
@@ -7499,7 +7520,9 @@
document,
resolveReadyButton,
isVisibleElement,
- TRADE_ACTION_BUTTON_READY_TIMEOUT_MS
+ TRADE_ACTION_BUTTON_READY_TIMEOUT_MS,
+ 2,
+ abortSignal
);
if (button) return button;
const currentButton = plan.spec.buttonGetter();
@@ -7734,7 +7757,7 @@
throwIfAborted(abortSignal);
assertLadderExecutionContext(plan);
assertLadderMakerPrice(plan, order.price);
- await waitForReadyLadderSubmitButton(plan);
+ await waitForReadyLadderSubmitButton(plan, abortSignal);
throwIfAborted(abortSignal);
assertLadderExecutionContext(plan);
assertLadderMakerPrice(plan, order.price);
@@ -7756,13 +7779,14 @@
priceLabel: "计划价",
qtyLabel: "计划量",
settleControlledForm: true,
- previousSubmittedInputs: previousAcknowledgedInputs
+ previousSubmittedInputs: previousAcknowledgedInputs,
+ abortSignal
});
throwIfAborted(abortSignal);
const submittedPrice = synchronizedInputs.submittedPrice;
assertLadderExecutionContext(plan);
assertLadderMakerPrice(plan, submittedPrice);
- const button = await waitForReadyLadderSubmitButton(plan);
+ const button = await waitForReadyLadderSubmitButton(plan, abortSignal);
throwIfAborted(abortSignal);
assertLadderExecutionContext(plan);
assertSubmittedPriceMatchesExpectedPrice(
@@ -8164,7 +8188,7 @@
if (getActiveTradeMode() !== spec.mode) {
return { status: "stopped", reason: "mode_changed" };
}
- if (document.hidden || ladderTask || singleOrderTask || cancelCurrentSymbolOpenOrdersTask || !readCurrentOrderbookPrecisionValue()) {
+ if (ladderTask || singleOrderTask || cancelCurrentSymbolOpenOrdersTask || !readCurrentOrderbookPrecisionValue()) {
return { status: "waiting" };
}
const button = spec.buttonGetter();
@@ -10157,8 +10181,8 @@
readReadiness: async () => {
assertLadderExecutionContext(plan);
const button = plan.spec.buttonGetter();
- const ready = !document.hidden && isCloseSnapshotReady(plan.symbol) && button && button.isConnected && isVisibleElement(button) && !isSubmitButtonBusy(button);
- if (!ready && !document.hidden && Date.now() >= nextPositionCheckAt) {
+ const ready = isCloseSnapshotReady(plan.symbol) && button && button.isConnected && isVisibleElement(button) && !isSubmitButtonBusy(button);
+ if (!ready && Date.now() >= nextPositionCheckAt) {
await throwIfClosePositionCompleted(plan, abortSignal);
nextPositionCheckAt = Date.now() + CONTINUOUS_LADDER_RECOVERY_COOLDOWN_MS;
assertLadderExecutionContext(plan);
@@ -10554,8 +10578,10 @@
futuresPayload
);
}
- async function assertUsdtRebalanceTradingState() {
- if (!isFuturesTradingPage() || document.hidden) throw new Error("当前不在可操作的合约页面");
+ async function assertUsdtRebalanceTradingState({ allowHidden = false } = {}) {
+ if (!isFuturesTradingPage() || !allowHidden && document.hidden) {
+ throw new Error("当前不在可操作的合约页面");
+ }
if (ladderTask || continuousLadderTask || singleOrderTask || cancelCurrentSymbolOpenOrdersTask) {
throw new Error("当前仍有交易任务运行");
}
@@ -10640,7 +10666,7 @@
}
let expectedBalances = initialBalances;
for (const transfer of plan.transfers) {
- await assertUsdtRebalanceTradingState();
+ await assertUsdtRebalanceTradingState({ allowHidden: true });
const currentBalances = await readCurrentUsdtRebalanceBalances();
if (!areUsdtBalancesEqual(currentBalances, expectedBalances)) {
throw new Error("账户余额已变化,已停止账户再平衡");
@@ -12236,6 +12262,7 @@
});
}
const now = Date.now();
+ const clickedAt = performance.now();
if (CFG.COOLDOWN_MS > 0 && now - lastTs < CFG.COOLDOWN_MS) {
if (CFG.DEBUG) warn("跳过:cooldown");
return;
@@ -12331,6 +12358,9 @@
if (!isCurrentObservedSymbol(qtyPlan.symbol)) {
throw new Error("提交前交易对已变化,已停止");
}
+ if (Math.max(Date.now() - now, performance.now() - clickedAt) > SINGLE_ORDER_DRAFT_MAX_AGE_MS) {
+ throw new Error("点击后等待过久,已停止提交");
+ }
const previousFeedback = takeOrderFeedbackSnapshot();
const submitCaptureId = beginLadderSubmitResponseCapture();
try {
@@ -12428,13 +12458,13 @@
ensureDepthProfileObserver();
scheduleDepthProfileSync();
}
- function stopTradingTimers() {
+ function stopTradingTimers({ preserveTradeUiMutationWait = false } = {}) {
stopTradeModeTabObserver();
stopAccountPositionObserver();
stopOrderbookPrecisionObserver();
stopDepthProfileObserver();
stopDepthProfileSession();
- clearTradeUiMutationWait();
+ if (!preserveTradeUiMutationWait) clearTradeUiMutationWait();
}
function syncRouteState() {
if (document.hidden) return;
@@ -12504,7 +12534,11 @@
} catch (error) {
err("页面隐藏前图表保存刷新失败:", error);
}
- stopTradingTimers();
+ stopTradingTimers({
+ preserveTradeUiMutationWait: Boolean(
+ ladderTask || continuousLadderTask || singleOrderTask || cancelCurrentSymbolOpenOrdersTask
+ )
+ });
stopRouteWatcher();
return;
}
diff --git a/src/binance-orderbook-trade/dom/trade-form.js b/src/binance-orderbook-trade/dom/trade-form.js
index df811be..a78fdea 100644
--- a/src/binance-orderbook-trade/dom/trade-form.js
+++ b/src/binance-orderbook-trade/dom/trade-form.js
@@ -554,66 +554,130 @@ export function waitForTradeFormMutationState(observationRoot, readState, timeou
}
/**
- * Confirm property-based controlled-input state across consecutive paint frames.
- * MutationObserver cannot observe React restoring an input's value property, so
- * trade submission must read the current live inputs after React has settled.
+ * Observe live trade controls on paint frames while visible and separate timer
+ * tasks while hidden. Chrome pauses paint frames in background tabs; a wall
+ * timeout there could expire before the second independent observation.
*/
-export function waitForTradeFormFrameState(
+function waitForStableTradeControl(
observationRoot,
- readState,
+ readCandidate,
+ isSameCandidate,
timeoutMs,
- requiredStableFrames = 2,
+ requiredStableFrames,
+ abortSignal,
+ missingSchedulerMessage,
) {
- const view = observationRoot?.ownerDocument?.defaultView;
+ const document = observationRoot?.ownerDocument || observationRoot;
+ const view = document?.defaultView;
if (
!view
|| typeof view.requestAnimationFrame !== 'function'
|| typeof view.cancelAnimationFrame !== 'function'
) {
- throw new Error('交易表单帧调度器不可用');
+ throw new Error(missingSchedulerMessage);
}
if (!Number.isInteger(requiredStableFrames) || requiredStableFrames < 1) {
throw new Error('稳定帧数必须为正整数');
}
- return new Promise((resolve) => {
+ return new Promise((resolve, reject) => {
let settled = false;
let frameHandle = 0;
- let timer = 0;
+ let observationTimer = 0;
+ let deadlineTimer = 0;
let stableFrames = 0;
- let stableState = null;
- const finish = (value) => {
+ let stableCandidate = null;
+ let hiddenObservations = 0;
+ const maxHiddenObservations = Math.max(requiredStableFrames, Math.ceil(timeoutMs / 100));
+ const finish = (value, error = null) => {
if (settled) return;
settled = true;
if (frameHandle) view.cancelAnimationFrame(frameHandle);
- view.clearTimeout(timer);
- resolve(value);
+ view.clearTimeout(observationTimer);
+ view.clearTimeout(deadlineTimer);
+ document.removeEventListener('visibilitychange', onVisibilityChange);
+ abortSignal?.removeEventListener('abort', onAbort);
+ if (error) reject(error);
+ else resolve(value);
+ };
+ const onAbort = () => finish(null, abortSignal.reason);
+ const scheduleObservation = () => {
+ if (document.hidden) {
+ observationTimer = view.setTimeout(check, 100);
+ } else {
+ frameHandle = view.requestAnimationFrame(check);
+ }
};
const check = () => {
frameHandle = 0;
- const state = readState();
- if (state) {
- stableFrames += 1;
- stableState = state;
+ observationTimer = 0;
+ const candidate = readCandidate();
+ if (candidate) {
+ stableFrames = isSameCandidate(candidate, stableCandidate) ? stableFrames + 1 : 1;
+ stableCandidate = candidate;
if (stableFrames >= requiredStableFrames) {
- finish(stableState);
+ finish(candidate);
return;
}
} else {
stableFrames = 0;
- stableState = null;
+ stableCandidate = null;
+ }
+ if (document.hidden) {
+ hiddenObservations += 1;
+ if (hiddenObservations >= maxHiddenObservations) {
+ finish(null);
+ return;
+ }
}
- frameHandle = view.requestAnimationFrame(check);
+ scheduleObservation();
+ };
+ const onVisibilityChange = () => {
+ if (frameHandle) view.cancelAnimationFrame(frameHandle);
+ view.clearTimeout(observationTimer);
+ view.clearTimeout(deadlineTimer);
+ frameHandle = 0;
+ observationTimer = 0;
+ if (!document.hidden) deadlineTimer = view.setTimeout(() => finish(null), timeoutMs);
+ scheduleObservation();
};
- timer = view.setTimeout(() => finish(null), timeoutMs);
- frameHandle = view.requestAnimationFrame(check);
+ document.addEventListener('visibilitychange', onVisibilityChange);
+ abortSignal?.addEventListener('abort', onAbort, { once: true });
+ if (abortSignal?.aborted) {
+ onAbort();
+ return;
+ }
+ if (!document.hidden) deadlineTimer = view.setTimeout(() => finish(null), timeoutMs);
+ scheduleObservation();
});
}
+/**
+ * MutationObserver cannot observe React restoring an input's value property, so
+ * trade submission must read the current live inputs after React has settled.
+ */
+export function waitForTradeFormFrameState(
+ observationRoot,
+ readState,
+ timeoutMs,
+ requiredStableFrames = 2,
+ abortSignal = null,
+) {
+ return waitForStableTradeControl(
+ observationRoot,
+ readState,
+ () => true,
+ timeoutMs,
+ requiredStableFrames,
+ abortSignal,
+ '交易表单帧调度器不可用',
+ );
+}
+
/**
* Binance can mark the requested trade mode active before React replaces the
* native action buttons. Require one live button identity to remain actionable
- * across consecutive paint frames so callers never click the outgoing node.
+ * across independent observations so callers never click the outgoing node.
*/
export function waitForTradeActionButtonFrameState(
observationRoot,
@@ -621,37 +685,14 @@ export function waitForTradeActionButtonFrameState(
isVisibleElement,
timeoutMs,
requiredStableFrames = 2,
+ abortSignal = null,
) {
- const view = observationRoot?.ownerDocument?.defaultView || observationRoot?.defaultView;
- if (
- !view
- || typeof view.requestAnimationFrame !== 'function'
- || typeof view.cancelAnimationFrame !== 'function'
- ) {
- throw new Error('下单按钮帧调度器不可用');
- }
if (typeof findButton !== 'function' || typeof isVisibleElement !== 'function') {
throw new Error('下单按钮定位器不可用');
}
- if (!Number.isInteger(requiredStableFrames) || requiredStableFrames < 1) {
- throw new Error('稳定帧数必须为正整数');
- }
-
- return new Promise((resolve) => {
- let settled = false;
- let frameHandle = 0;
- let timer = 0;
- let stableFrames = 0;
- let stableButton = null;
- const finish = (value) => {
- if (settled) return;
- settled = true;
- if (frameHandle) view.cancelAnimationFrame(frameHandle);
- view.clearTimeout(timer);
- resolve(value);
- };
- const check = () => {
- frameHandle = 0;
+ return waitForStableTradeControl(
+ observationRoot,
+ () => {
const button = findButton();
const actionable = Boolean(
button
@@ -660,26 +701,14 @@ export function waitForTradeActionButtonFrameState(
&& !button.disabled
&& button.getAttribute('aria-disabled') !== 'true'
);
- if (actionable) {
- if (button === stableButton) {
- stableFrames += 1;
- } else {
- stableButton = button;
- stableFrames = 1;
- }
- if (stableFrames >= requiredStableFrames) {
- finish(button);
- return;
- }
- } else {
- stableButton = null;
- stableFrames = 0;
- }
- frameHandle = view.requestAnimationFrame(check);
- };
- timer = view.setTimeout(() => finish(null), timeoutMs);
- frameHandle = view.requestAnimationFrame(check);
- });
+ return actionable ? button : null;
+ },
+ (button, previousButton) => button === previousButton,
+ timeoutMs,
+ requiredStableFrames,
+ abortSignal,
+ '下单按钮帧调度器不可用',
+ );
}
export function isTradeModeTab(node, { panelId }) {
diff --git a/src/binance-orderbook-trade/index.user.js b/src/binance-orderbook-trade/index.user.js
index d8fc020..ba74560 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.211
+// @version 2.7.212
// @author jackhai9
// @description 单击订单簿价格,按当前开仓/平仓 tab 自动填数量并执行下单,内置数量倍率面板
// @match https://www.binance.com/*/futures/*
@@ -378,6 +378,8 @@ import { showUsdtRebalanceDialog } from './dom/usdt-rebalance-dialog.js';
const USDT_REBALANCE_BALANCE_POLL_MS = 1000;
const LADDER_OPEN_QTY_READY_TIMEOUT_MS = 1200;
const TRADE_INPUT_SYNC_TIMEOUT_MS = 350;
+ // A clicked limit price has no ladder-style maker-price recheck after a long background stall.
+ const SINGLE_ORDER_DRAFT_MAX_AGE_MS = 15000;
const TRADE_INPUT_SYNC_STABLE_FRAMES = 2;
const LADDER_INPUT_SETTLE_TIMEOUT_MS = 1200;
const LADDER_INPUT_SETTLE_STABLE_MS = 180;
@@ -3067,6 +3069,7 @@ import { showUsdtRebalanceDialog } from './dom/usdt-rebalance-dialog.js';
readTradeState,
syncTimeoutMs,
TRADE_INPUT_SYNC_STABLE_FRAMES,
+ options?.abortSignal,
);
if (synchronized) return synchronized;
@@ -3097,7 +3100,7 @@ import { showUsdtRebalanceDialog } from './dom/usdt-rebalance-dialog.js';
);
}
- async function waitForReadyLadderSubmitButton(plan) {
+ async function waitForReadyLadderSubmitButton(plan, abortSignal) {
const resolveReadyButton = () => {
const candidate = plan.spec.buttonGetter();
return candidate && !isSubmitButtonBusy(candidate) ? candidate : null;
@@ -3107,6 +3110,8 @@ import { showUsdtRebalanceDialog } from './dom/usdt-rebalance-dialog.js';
resolveReadyButton,
isVisibleElement,
TRADE_ACTION_BUTTON_READY_TIMEOUT_MS,
+ 2,
+ abortSignal,
);
if (button) return button;
@@ -3407,7 +3412,7 @@ import { showUsdtRebalanceDialog } from './dom/usdt-rebalance-dialog.js';
assertLadderExecutionContext(plan);
assertLadderMakerPrice(plan, order.price);
- await waitForReadyLadderSubmitButton(plan);
+ await waitForReadyLadderSubmitButton(plan, abortSignal);
throwIfAborted(abortSignal);
assertLadderExecutionContext(plan);
assertLadderMakerPrice(plan, order.price);
@@ -3432,13 +3437,14 @@ import { showUsdtRebalanceDialog } from './dom/usdt-rebalance-dialog.js';
qtyLabel: '计划量',
settleControlledForm: true,
previousSubmittedInputs: previousAcknowledgedInputs,
+ abortSignal,
});
throwIfAborted(abortSignal);
const submittedPrice = synchronizedInputs.submittedPrice;
assertLadderExecutionContext(plan);
assertLadderMakerPrice(plan, submittedPrice);
- const button = await waitForReadyLadderSubmitButton(plan);
+ const button = await waitForReadyLadderSubmitButton(plan, abortSignal);
throwIfAborted(abortSignal);
assertLadderExecutionContext(plan);
assertSubmittedPriceMatchesExpectedPrice(
@@ -3867,8 +3873,7 @@ import { showUsdtRebalanceDialog } from './dom/usdt-rebalance-dialog.js';
return { status: 'stopped', reason: 'mode_changed' };
}
if (
- document.hidden
- || ladderTask
+ ladderTask
|| singleOrderTask
|| cancelCurrentSymbolOpenOrdersTask
|| !readCurrentOrderbookPrecisionValue()
@@ -6209,11 +6214,11 @@ import { showUsdtRebalanceDialog } from './dom/usdt-rebalance-dialog.js';
readReadiness: async () => {
assertLadderExecutionContext(plan);
const button = plan.spec.buttonGetter();
- const ready = !document.hidden && isCloseSnapshotReady(plan.symbol)
+ const ready = isCloseSnapshotReady(plan.symbol)
&& button && button.isConnected && isVisibleElement(button) && !isSubmitButtonBusy(button);
// A completed close can disable the button permanently; recheck the
// position while waiting rather than waiting for an impossible ready state.
- if (!ready && !document.hidden && Date.now() >= nextPositionCheckAt) {
+ if (!ready && Date.now() >= nextPositionCheckAt) {
await throwIfClosePositionCompleted(plan, abortSignal);
nextPositionCheckAt = Date.now() + CONTINUOUS_LADDER_RECOVERY_COOLDOWN_MS;
assertLadderExecutionContext(plan);
@@ -6675,8 +6680,10 @@ import { showUsdtRebalanceDialog } from './dom/usdt-rebalance-dialog.js';
);
}
- async function assertUsdtRebalanceTradingState() {
- if (!isFuturesTradingPage() || document.hidden) throw new Error('当前不在可操作的合约页面');
+ async function assertUsdtRebalanceTradingState({ allowHidden = false } = {}) {
+ if (!isFuturesTradingPage() || (!allowHidden && document.hidden)) {
+ throw new Error('当前不在可操作的合约页面');
+ }
if (ladderTask || continuousLadderTask || singleOrderTask || cancelCurrentSymbolOpenOrdersTask) {
throw new Error('当前仍有交易任务运行');
}
@@ -6766,7 +6773,7 @@ import { showUsdtRebalanceDialog } from './dom/usdt-rebalance-dialog.js';
let expectedBalances = initialBalances;
for (const transfer of plan.transfers) {
- await assertUsdtRebalanceTradingState();
+ await assertUsdtRebalanceTradingState({ allowHidden: true });
const currentBalances = await readCurrentUsdtRebalanceBalances();
if (!areUsdtBalancesEqual(currentBalances, expectedBalances)) {
throw new Error('账户余额已变化,已停止账户再平衡');
@@ -8593,6 +8600,7 @@ import { showUsdtRebalanceDialog } from './dom/usdt-rebalance-dialog.js';
}
const now = Date.now();
+ const clickedAt = performance.now();
if (CFG.COOLDOWN_MS > 0 && now - lastTs < CFG.COOLDOWN_MS) {
if (CFG.DEBUG) warn('跳过:cooldown');
return;
@@ -8701,6 +8709,9 @@ import { showUsdtRebalanceDialog } from './dom/usdt-rebalance-dialog.js';
if (!isCurrentObservedSymbol(qtyPlan.symbol)) {
throw new Error('提交前交易对已变化,已停止');
}
+ if (Math.max(Date.now() - now, performance.now() - clickedAt) > SINGLE_ORDER_DRAFT_MAX_AGE_MS) {
+ throw new Error('点击后等待过久,已停止提交');
+ }
const previousFeedback = takeOrderFeedbackSnapshot();
const submitCaptureId = beginLadderSubmitResponseCapture();
try {
@@ -8811,13 +8822,13 @@ import { showUsdtRebalanceDialog } from './dom/usdt-rebalance-dialog.js';
scheduleDepthProfileSync();
}
- function stopTradingTimers() {
+ function stopTradingTimers({ preserveTradeUiMutationWait = false } = {}) {
stopTradeModeTabObserver();
stopAccountPositionObserver();
stopOrderbookPrecisionObserver();
stopDepthProfileObserver();
stopDepthProfileSession();
- clearTradeUiMutationWait();
+ if (!preserveTradeUiMutationWait) clearTradeUiMutationWait();
}
function syncRouteState() {
@@ -8895,7 +8906,11 @@ import { showUsdtRebalanceDialog } from './dom/usdt-rebalance-dialog.js';
} catch (error) {
err('页面隐藏前图表保存刷新失败:', error);
}
- stopTradingTimers();
+ stopTradingTimers({
+ preserveTradeUiMutationWait: Boolean(
+ ladderTask || continuousLadderTask || singleOrderTask || cancelCurrentSymbolOpenOrdersTask
+ ),
+ });
stopRouteWatcher();
return;
}
diff --git a/test/helpers/dom.js b/test/helpers/dom.js
index 41cffe8..840943b 100644
--- a/test/helpers/dom.js
+++ b/test/helpers/dom.js
@@ -3,6 +3,7 @@ import { JSDOM } from 'jsdom';
export function loadFixtureDom(html) {
const dom = new JSDOM(html);
const { window } = dom;
+ Object.defineProperty(window.document, 'hidden', { configurable: true, value: false });
window.HTMLElement.prototype.getClientRects = function getClientRects() {
return this.hasAttribute('data-hidden') ? [] : [{ width: 100, height: 24 }];
diff --git a/test/unit/binance-orderbook-trade/trade-form.test.js b/test/unit/binance-orderbook-trade/trade-form.test.js
index ba5c4b0..6252a88 100644
--- a/test/unit/binance-orderbook-trade/trade-form.test.js
+++ b/test/unit/binance-orderbook-trade/trade-form.test.js
@@ -316,6 +316,111 @@ test('user confirms controlled trade inputs only after consecutive stable frames
assert.equal(frames.pendingCount, 0);
});
+test('user confirms stable trade inputs while the tab is hidden and paint frames are paused', async (t) => {
+ // Given native inputs are ready but Chrome has hidden the document and paused paint frames
+ const dom = loadFixtureDom('');
+ const root = dom.window.document.querySelector('section');
+ const frames = createAnimationFrameBoundary(dom.window);
+ Object.defineProperty(dom.window.document, 'hidden', { configurable: true, value: true });
+ t.mock.timers.enable({ apis: ['setTimeout'] });
+ const pending = waitForTradeFormFrameState(root, () => ({
+ price: root.querySelector('#price').value,
+ qty: root.querySelector('#qty').value,
+ }), 1200, 2);
+
+ // When background task opportunities advance without any animation frame
+ t.mock.timers.tick(100);
+ t.mock.timers.tick(100);
+ const observed = await pending;
+
+ // Then the exact stable inputs are accepted without depending on a paint frame
+ assert.deepEqual(observed, { price: '81.9', qty: '0.01' });
+ assert.equal(frames.pendingCount, 0);
+});
+
+test('user confirms one connected action button while the tab is hidden', async (t) => {
+ // Given the native action button is ready but background paint frames are paused
+ const dom = loadFixtureDom('');
+ const { document } = dom.window;
+ const button = document.querySelector('button');
+ const frames = createAnimationFrameBoundary(dom.window);
+ Object.defineProperty(document, 'hidden', { configurable: true, value: true });
+ t.mock.timers.enable({ apis: ['setTimeout'] });
+ const pending = waitForTradeActionButtonFrameState(document, () => button, () => true, 3000, 2);
+
+ // When background task opportunities advance without any animation frame
+ t.mock.timers.tick(100);
+ t.mock.timers.tick(100);
+ const observed = await pending;
+
+ // Then the exact current button is accepted without depending on a paint frame
+ assert.equal(observed, button);
+ assert.equal(frames.pendingCount, 0);
+});
+
+test('user follows a replacement native button when the page hides before its next paint frame', async (t) => {
+ // Given a visible trade form has scheduled its first paint observation.
+ const dom = loadFixtureDom('');
+ const { document } = dom.window;
+ const root = document.querySelector('section');
+ const frames = createAnimationFrameBoundary(dom.window);
+ t.mock.timers.enable({ apis: ['setTimeout'] });
+ const pending = waitForTradeActionButtonFrameState(document, () => root.querySelector('button'), () => true, 3000, 2);
+ assert.equal(frames.pendingCount, 1);
+
+ // When Chrome hides the page and React replaces the native action button.
+ Object.defineProperty(document, 'hidden', { configurable: true, value: true });
+ document.dispatchEvent(new dom.window.Event('visibilitychange'));
+ assert.equal(frames.pendingCount, 0);
+ t.mock.timers.tick(100);
+ root.innerHTML = '';
+ const replacement = root.querySelector('button');
+ t.mock.timers.tick(100);
+ t.mock.timers.tick(100);
+
+ // Then the later connected identity, not the removed node, is accepted.
+ assert.equal(await pending, replacement);
+ assert.equal(frames.pendingCount, 0);
+});
+
+test('user rejects a hidden trade form that never reaches a stable input state', async (t) => {
+ // Given React keeps the native quantity at a wrong value in the hidden tab.
+ const dom = loadFixtureDom('');
+ const root = dom.window.document.querySelector('section');
+ const frames = createAnimationFrameBoundary(dom.window);
+ Object.defineProperty(dom.window.document, 'hidden', { configurable: true, value: true });
+ t.mock.timers.enable({ apis: ['setTimeout'] });
+ const pending = waitForTradeFormFrameState(root, () => null, 1200, 2);
+
+ // When every allowed independent background observation still finds invalid inputs.
+ for (let observation = 0; observation < 12; observation += 1) t.mock.timers.tick(100);
+
+ // Then the task refuses to accept a form state and leaves no paint callback.
+ assert.equal(await pending, null);
+ assert.equal(frames.pendingCount, 0);
+});
+
+test('user stops a hidden trade-input wait before it can accept a later button click', async (t) => {
+ // Given the hidden tab has no stable input state and a stop signal owns the pending wait
+ const dom = loadFixtureDom('');
+ const root = dom.window.document.querySelector('section');
+ const frames = createAnimationFrameBoundary(dom.window);
+ Object.defineProperty(dom.window.document, 'hidden', { configurable: true, value: true });
+ t.mock.timers.enable({ apis: ['setTimeout'] });
+ const stop = new AbortController();
+ const stoppedError = new Error('Stopped');
+ stoppedError.name = 'LadderStoppedError';
+ const pending = waitForTradeFormFrameState(root, () => null, 1200, 2, stop.signal);
+
+ // When the user stops while hidden and browser task time advances
+ stop.abort(stoppedError);
+ t.mock.timers.tick(1200);
+
+ // Then the wait ends with the stop reason and does not retain a paint callback
+ await assert.rejects(pending, { name: 'LadderStoppedError', message: 'Stopped' });
+ assert.equal(frames.pendingCount, 0);
+});
+
test('user rejects trade inputs that keep rolling back before their virtual deadline', async (t) => {
// Given a real native quantity input alternates between the expected and rolled-back values
const dom = loadFixtureDom('');