From 82e892548b71f82ebf8ff5e2016af5936442f7ac Mon Sep 17 00:00:00 2001
From: LiZhenhai-MBP14 <5935568+jackhai9@users.noreply.github.com>
Date: Thu, 27 Aug 2026 11:25:51 +0800
Subject: [PATCH 1/2] perf: accelerate ladder order replacement
---
scripts/binance-orderbook-trade.user.js | 149 +++++++++++++---
src/binance-orderbook-trade/index.user.js | 162 +++++++++++++++---
.../ladder-options.test.js | 4 +-
.../source-regressions.test.js | 42 ++++-
4 files changed, 299 insertions(+), 58 deletions(-)
diff --git a/scripts/binance-orderbook-trade.user.js b/scripts/binance-orderbook-trade.user.js
index bf3a150..a3af00e 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.128
+// @version 2.7.129
// @author jackhai9
// @description 单击订单簿价格,按当前开仓/平仓 tab 自动填数量并执行下单,内置数量倍率面板
// @match https://www.binance.com/*/futures/*
@@ -1918,6 +1918,8 @@
let tradeButtonCache = { mode: null, expiresAt: 0, buttons: [] };
let tradeScopeCache = { activeTab: null, expiresAt: 0, scopes: [] };
let ladderTask = null;
+ let activeLadderActionType = null;
+ let activeLadderPanelContext = null;
let cancelCurrentSymbolOpenOrdersTask = null;
let cancelCurrentSymbolOpenOrdersBlocksLadderActions = false;
let cancelNoOrdersFeedbackActive = false;
@@ -1964,7 +1966,7 @@
style.id = styleId;
style.textContent = `
button[${NATIVE_ACTION_DISABLED_ATTR}="true"],
- #${PANEL_ID} button:disabled {
+ #${PANEL_ID} button:disabled:not([data-ladder-preserve-tone="true"]) {
background: ${DISABLED_CONTROL_BG} !important;
color: ${DISABLED_CONTROL_TEXT} !important;
border-color: ${DISABLED_CONTROL_BORDER} !important;
@@ -3641,11 +3643,21 @@
return;
}
const spec = getLadderActionSpec2(actionType);
+ if (!spec) {
+ setLadderStatus("未知阶梯动作");
+ return;
+ }
if (spec?.mode === "CLOSE" && !isCloseSnapshotReady(actionSymbol)) {
setLadderStatus("仓位确认中");
return;
}
ladderStopRequested = false;
+ activeLadderActionType = actionType;
+ activeLadderPanelContext = {
+ mode: spec.mode,
+ symbol: actionSymbol,
+ precision: readCurrentOrderbookPrecisionValue()
+ };
const feedbackStartedAt = performance.now();
setLadderStatus(`${spec?.label || "阶梯"} 准备中`);
const executionTask = (async () => {
@@ -3692,6 +3704,8 @@
setLadderStatus(e?.message || "执行失败", e?.statusTitle);
}).finally(() => {
ladderTask = null;
+ activeLadderActionType = null;
+ activeLadderPanelContext = null;
ladderStopRequested = false;
scheduleRenderPanel();
});
@@ -4122,6 +4136,77 @@
function countOpenOrderRowsByKey(root, symbol, key) {
return readCurrentSymbolOpenOrderRows(root, symbol).filter((row) => row.key === key).length;
}
+ function readOpenOrderRowCancellationOutcome(symbol, key, previousCount, dialogsBefore) {
+ if (!isCurrentObservedSymbol(symbol)) return { status: "symbol_changed" };
+ const dialog = findNewVisibleDialog(dialogsBefore);
+ if (dialog) return { status: "dialog_open", dialog };
+ const activeRoot = getActiveOpenOrdersScope2();
+ if (activeRoot && countOpenOrderRowsByKey(activeRoot, symbol, key) < previousCount) {
+ return { status: "row_removed" };
+ }
+ return null;
+ }
+ async function waitForOpenOrderRowCancellationOutcome(symbol, key, previousCount, dialogsBefore) {
+ const deadline = Date.now() + LADDER_REPLACE_OPEN_ORDERS_CLEAR_TIMEOUT_MS;
+ while (true) {
+ const currentOutcome = readOpenOrderRowCancellationOutcome(
+ symbol,
+ key,
+ previousCount,
+ dialogsBefore
+ );
+ if (currentOutcome) return currentOutcome;
+ const remainingMs = deadline - Date.now();
+ if (remainingMs <= 0) return { status: "timeout" };
+ const observationRoot = getAccountOrdersObservationRoot() || document.body;
+ const accountSignal = createAccountOrdersMutationSignal(observationRoot);
+ const dialogSignal = createDialogMutationSignal(document);
+ if (!accountSignal || !dialogSignal) {
+ accountSignal?.dispose();
+ dialogSignal?.dispose();
+ throw new Error("Row-cancellation observer root is unavailable");
+ }
+ try {
+ const observedAccountVersion = accountSignal.version;
+ const observedDialogVersion = dialogSignal.version;
+ const observedOutcome = readOpenOrderRowCancellationOutcome(
+ symbol,
+ key,
+ previousCount,
+ dialogsBefore
+ );
+ if (observedOutcome) return observedOutcome;
+ await Promise.race([
+ accountSignal.waitForChange(observedAccountVersion, remainingMs),
+ dialogSignal.waitForChange(observedDialogVersion, remainingMs)
+ ]);
+ } finally {
+ accountSignal.dispose();
+ dialogSignal.dispose();
+ }
+ }
+ }
+ async function confirmOpenOrderRowKeyCountBelow(symbol, key, previousCount) {
+ const deadline = Date.now() + LADDER_REPLACE_ROW_SETTLE_MS;
+ const observationRoot = getAccountOrdersObservationRoot() || document.body;
+ const mutationSignal = createAccountOrdersMutationSignal(observationRoot);
+ if (!mutationSignal) throw new Error("Account-orders observer root is unavailable");
+ try {
+ while (true) {
+ const observedVersion = mutationSignal.version;
+ if (!isCurrentObservedSymbol(symbol)) return false;
+ const activeRoot = getActiveOpenOrdersScope2();
+ if (!activeRoot || countOpenOrderRowsByKey(activeRoot, symbol, key) >= previousCount) {
+ return false;
+ }
+ const remainingMs = deadline - Date.now();
+ if (remainingMs <= 0) return true;
+ await mutationSignal.waitForChange(observedVersion, remainingMs);
+ }
+ } finally {
+ mutationSignal.dispose();
+ }
+ }
async function waitForOpenOrderRowKeyCountBelow(symbol, key, previousCount) {
const deadline = Date.now() + LADDER_REPLACE_OPEN_ORDERS_CLEAR_TIMEOUT_MS;
const observationRoot = getAccountOrdersObservationRoot() || document.body;
@@ -4173,8 +4258,20 @@
throw new Error("当前币挂单撤单入口点击失败,已停止重挂");
}
waitForTradeUiMutation({ timeoutMs: 800 });
- const dialog = await waitForNewVisibleDialog(dialogsBefore);
- if (dialog) {
+ const outcome = await waitForOpenOrderRowCancellationOutcome(
+ plan.symbol,
+ row.key,
+ previousKeyCount,
+ dialogsBefore
+ );
+ if (outcome.status === "symbol_changed") {
+ throw new Error("逐行撤单时交易对已变化");
+ }
+ if (outcome.status === "timeout") {
+ throw new Error(`${plan.symbol} 当前币挂单仍存在,已停止重挂`);
+ }
+ if (outcome.status === "dialog_open") {
+ const { dialog } = outcome;
setLadderStatus(`${plan.symbol} 单行撤单确认弹窗已打开`);
const dialogClosed = await waitForDialogToClose(dialog);
if (!dialogClosed) {
@@ -4182,9 +4279,12 @@
error.name = "DialogNotClosedError";
throw error;
}
+ if (!await waitForOpenOrderRowKeyCountBelow(plan.symbol, row.key, previousKeyCount)) {
+ throw new Error(`${plan.symbol} 当前币挂单仍存在,已停止重挂`);
+ }
}
- if (!await waitForOpenOrderRowKeyCountBelow(plan.symbol, row.key, previousKeyCount)) {
- throw new Error(`${plan.symbol} 当前币挂单仍存在,已停止重挂`);
+ if (!await confirmOpenOrderRowKeyCountBelow(plan.symbol, row.key, previousKeyCount)) {
+ throw new Error(`${plan.symbol} 当前币挂单状态不稳定,已停止重挂`);
}
cancelQty = addDecimalStrings(cancelQty, row.qty);
}
@@ -4259,13 +4359,6 @@
}
return null;
}
- async function waitForNewVisibleDialog(dialogsBefore) {
- return waitForDialogMutationState(
- document,
- () => findNewVisibleDialog(dialogsBefore),
- 1800
- );
- }
async function waitForDialogToClose(dialog, timeoutMs = ROW_CANCEL_DIALOG_CLOSE_TIMEOUT_MS) {
return Boolean(await waitForDialogMutationState(
document,
@@ -5554,12 +5647,19 @@
""
].join("");
}
- function ladderActionButton(actionType, label, tone, disabled = false) {
+ function ladderActionButton(actionType, label, tone, disabled = false, preserveTone = false) {
const isBuyTone = tone === "BUY";
const borderColor = isBuyTone ? "var(--color-Buy)" : "var(--color-Sell)";
const background = isBuyTone ? "var(--color-GreenAlpha01)" : "var(--color-RedAlpha01)";
- const disabledAttrs = disabled ? ' disabled aria-disabled="true"' : "";
- return ``;
+ const disabledAttrs = disabled ? ` disabled aria-disabled="true"${preserveTone ? ' data-ladder-preserve-tone="true"' : ""}` : "";
+ const cursor = disabled ? "not-allowed" : "pointer";
+ return ``;
+ }
+ function ladderExecutionButton(actionType, label, tone, disabled = false) {
+ if (activeLadderActionType !== actionType) {
+ return ladderActionButton(actionType, label, tone, disabled, Boolean(activeLadderActionType));
+ }
+ return ``;
}
function getLadderControlSections(tradeMode, closeContext, symbol, precision) {
const ladderRunning = !!ladderTask;
@@ -5584,8 +5684,8 @@
ladderOptionRow(PANEL_COPY.field.interval, PANEL_COPY.tooltip.interval, LADDER_STEP_OPTIONS, getLadderStep(tradeMode, symbol, precision), "step", "")
],
actionButtons: [
- ladderActionButton("OPEN_LONG", PANEL_COPY.action.openLong, "BUY", actionDisabled),
- ladderActionButton("OPEN_SHORT", PANEL_COPY.action.openShort, "SELL", actionDisabled)
+ ladderExecutionButton("OPEN_LONG", PANEL_COPY.action.openLong, "BUY", actionDisabled),
+ ladderExecutionButton("OPEN_SHORT", PANEL_COPY.action.openShort, "SELL", actionDisabled)
]
};
}
@@ -5606,17 +5706,17 @@
ladderOptionRow(PANEL_COPY.field.interval, PANEL_COPY.tooltip.interval, LADDER_STEP_OPTIONS, getLadderStep(tradeMode, symbol, precision), "step", "")
],
actionButtons: [
- ladderActionButton("CLOSE_LONG", PANEL_COPY.action.closeLong, "SELL", closeLongDisabled),
- ladderActionButton("CLOSE_SHORT", PANEL_COPY.action.closeShort, "BUY", closeShortDisabled)
+ ladderExecutionButton("CLOSE_LONG", PANEL_COPY.action.closeLong, "SELL", closeLongDisabled),
+ ladderExecutionButton("CLOSE_SHORT", PANEL_COPY.action.closeShort, "BUY", closeShortDisabled)
]
};
}
function refreshLadderPanel(panel, tradeMode, closeContext) {
const body = panel.querySelector(`#${LADDER_BODY_ID}`);
const status = panel.querySelector(`#${LADDER_STATUS_ID}`);
- const mode = ["OPEN", "CLOSE"].includes(tradeMode) ? tradeMode : null;
- const symbol = getCurrentSymbol();
- const precision = readCurrentOrderbookPrecisionValue();
+ const mode = activeLadderPanelContext?.mode || (["OPEN", "CLOSE"].includes(tradeMode) ? tradeMode : null);
+ const symbol = activeLadderPanelContext?.symbol || getCurrentSymbol();
+ const precision = activeLadderPanelContext?.precision || readCurrentOrderbookPrecisionValue();
if (body) {
const ladderRunning = !!ladderTask;
const cancelRunning = !!cancelCurrentSymbolOpenOrdersTask;
@@ -5626,12 +5726,11 @@
noOrdersFeedback: cancelNoOrdersFeedbackActive
});
const controlSections = getLadderControlSections(mode, closeContext, symbol, precision);
- const primaryActions = ladderRunning ? [``] : controlSections.actionButtons;
const bodyHtml = [
...controlSections.optionRows,
`
`,
'',
- ...primaryActions,
+ ...controlSections.actionButtons,
``,
"
"
].join("");
diff --git a/src/binance-orderbook-trade/index.user.js b/src/binance-orderbook-trade/index.user.js
index d94baf6..52d4ce9 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.128
+// @version 2.7.129
// @author jackhai9
// @description 单击订单簿价格,按当前开仓/平仓 tab 自动填数量并执行下单,内置数量倍率面板
// @match https://www.binance.com/*/futures/*
@@ -340,6 +340,8 @@ import {
let tradeButtonCache = { mode: null, expiresAt: 0, buttons: [] };
let tradeScopeCache = { activeTab: null, expiresAt: 0, scopes: [] };
let ladderTask = null;
+ let activeLadderActionType = null;
+ let activeLadderPanelContext = null;
let cancelCurrentSymbolOpenOrdersTask = null;
let cancelCurrentSymbolOpenOrdersBlocksLadderActions = false;
let cancelNoOrdersFeedbackActive = false;
@@ -391,7 +393,7 @@ import {
style.id = styleId;
style.textContent = `
button[${NATIVE_ACTION_DISABLED_ATTR}="true"],
- #${PANEL_ID} button:disabled {
+ #${PANEL_ID} button:disabled:not([data-ladder-preserve-tone="true"]) {
background: ${DISABLED_CONTROL_BG} !important;
color: ${DISABLED_CONTROL_TEXT} !important;
border-color: ${DISABLED_CONTROL_BORDER} !important;
@@ -2486,11 +2488,21 @@ import {
return;
}
const spec = getLadderActionSpec(actionType);
+ if (!spec) {
+ setLadderStatus('未知阶梯动作');
+ return;
+ }
if (spec?.mode === 'CLOSE' && !isCloseSnapshotReady(actionSymbol)) {
setLadderStatus('仓位确认中');
return;
}
ladderStopRequested = false;
+ activeLadderActionType = actionType;
+ activeLadderPanelContext = {
+ mode: spec.mode,
+ symbol: actionSymbol,
+ precision: readCurrentOrderbookPrecisionValue(),
+ };
const feedbackStartedAt = performance.now();
setLadderStatus(`${spec?.label || '阶梯'} 准备中`);
const executionTask = (async () => {
@@ -2542,6 +2554,8 @@ import {
})
.finally(() => {
ladderTask = null;
+ activeLadderActionType = null;
+ activeLadderPanelContext = null;
ladderStopRequested = false;
scheduleRenderPanel();
});
@@ -3057,6 +3071,83 @@ import {
return readCurrentSymbolOpenOrderRows(root, symbol).filter((row) => row.key === key).length;
}
+ function readOpenOrderRowCancellationOutcome(symbol, key, previousCount, dialogsBefore) {
+ if (!isCurrentObservedSymbol(symbol)) return { status: 'symbol_changed' };
+ const dialog = findNewVisibleDialog(dialogsBefore);
+ if (dialog) return { status: 'dialog_open', dialog };
+ const activeRoot = getActiveOpenOrdersScope();
+ if (activeRoot && countOpenOrderRowsByKey(activeRoot, symbol, key) < previousCount) {
+ return { status: 'row_removed' };
+ }
+ return null;
+ }
+
+ async function waitForOpenOrderRowCancellationOutcome(symbol, key, previousCount, dialogsBefore) {
+ const deadline = Date.now() + LADDER_REPLACE_OPEN_ORDERS_CLEAR_TIMEOUT_MS;
+ while (true) {
+ const currentOutcome = readOpenOrderRowCancellationOutcome(
+ symbol,
+ key,
+ previousCount,
+ dialogsBefore,
+ );
+ if (currentOutcome) return currentOutcome;
+
+ const remainingMs = deadline - Date.now();
+ if (remainingMs <= 0) return { status: 'timeout' };
+ const observationRoot = getAccountOrdersObservationRoot() || document.body;
+ const accountSignal = createAccountOrdersMutationSignal(observationRoot);
+ const dialogSignal = createDialogMutationSignal(document);
+ if (!accountSignal || !dialogSignal) {
+ accountSignal?.dispose();
+ dialogSignal?.dispose();
+ throw new Error('Row-cancellation observer root is unavailable');
+ }
+
+ try {
+ const observedAccountVersion = accountSignal.version;
+ const observedDialogVersion = dialogSignal.version;
+ const observedOutcome = readOpenOrderRowCancellationOutcome(
+ symbol,
+ key,
+ previousCount,
+ dialogsBefore,
+ );
+ if (observedOutcome) return observedOutcome;
+ await Promise.race([
+ accountSignal.waitForChange(observedAccountVersion, remainingMs),
+ dialogSignal.waitForChange(observedDialogVersion, remainingMs),
+ ]);
+ } finally {
+ accountSignal.dispose();
+ dialogSignal.dispose();
+ }
+ }
+ }
+
+ async function confirmOpenOrderRowKeyCountBelow(symbol, key, previousCount) {
+ const deadline = Date.now() + LADDER_REPLACE_ROW_SETTLE_MS;
+ const observationRoot = getAccountOrdersObservationRoot() || document.body;
+ const mutationSignal = createAccountOrdersMutationSignal(observationRoot);
+ if (!mutationSignal) throw new Error('Account-orders observer root is unavailable');
+
+ try {
+ while (true) {
+ const observedVersion = mutationSignal.version;
+ if (!isCurrentObservedSymbol(symbol)) return false;
+ const activeRoot = getActiveOpenOrdersScope();
+ if (!activeRoot || countOpenOrderRowsByKey(activeRoot, symbol, key) >= previousCount) {
+ return false;
+ }
+ const remainingMs = deadline - Date.now();
+ if (remainingMs <= 0) return true;
+ await mutationSignal.waitForChange(observedVersion, remainingMs);
+ }
+ } finally {
+ mutationSignal.dispose();
+ }
+ }
+
async function waitForOpenOrderRowKeyCountBelow(symbol, key, previousCount) {
const deadline = Date.now() + LADDER_REPLACE_OPEN_ORDERS_CLEAR_TIMEOUT_MS;
const observationRoot = getAccountOrdersObservationRoot() || document.body;
@@ -3110,8 +3201,20 @@ import {
throw new Error('当前币挂单撤单入口点击失败,已停止重挂');
}
waitForTradeUiMutation({ timeoutMs: 800 });
- const dialog = await waitForNewVisibleDialog(dialogsBefore);
- if (dialog) {
+ const outcome = await waitForOpenOrderRowCancellationOutcome(
+ plan.symbol,
+ row.key,
+ previousKeyCount,
+ dialogsBefore,
+ );
+ if (outcome.status === 'symbol_changed') {
+ throw new Error('逐行撤单时交易对已变化');
+ }
+ if (outcome.status === 'timeout') {
+ throw new Error(`${plan.symbol} 当前币挂单仍存在,已停止重挂`);
+ }
+ if (outcome.status === 'dialog_open') {
+ const { dialog } = outcome;
setLadderStatus(`${plan.symbol} 单行撤单确认弹窗已打开`);
const dialogClosed = await waitForDialogToClose(dialog);
if (!dialogClosed) {
@@ -3119,9 +3222,12 @@ import {
error.name = 'DialogNotClosedError';
throw error;
}
+ if (!(await waitForOpenOrderRowKeyCountBelow(plan.symbol, row.key, previousKeyCount))) {
+ throw new Error(`${plan.symbol} 当前币挂单仍存在,已停止重挂`);
+ }
}
- if (!(await waitForOpenOrderRowKeyCountBelow(plan.symbol, row.key, previousKeyCount))) {
- throw new Error(`${plan.symbol} 当前币挂单仍存在,已停止重挂`);
+ if (!(await confirmOpenOrderRowKeyCountBelow(plan.symbol, row.key, previousKeyCount))) {
+ throw new Error(`${plan.symbol} 当前币挂单状态不稳定,已停止重挂`);
}
cancelQty = addDecimalStrings(cancelQty, row.qty);
}
@@ -3203,14 +3309,6 @@ import {
return null;
}
- async function waitForNewVisibleDialog(dialogsBefore) {
- return waitForDialogMutationState(
- document,
- () => findNewVisibleDialog(dialogsBefore),
- 1800,
- );
- }
-
async function waitForDialogToClose(dialog, timeoutMs = ROW_CANCEL_DIALOG_CLOSE_TIMEOUT_MS) {
return Boolean(await waitForDialogMutationState(
document,
@@ -4720,12 +4818,22 @@ import {
].join('');
}
- function ladderActionButton(actionType, label, tone, disabled = false) {
+ function ladderActionButton(actionType, label, tone, disabled = false, preserveTone = false) {
const isBuyTone = tone === 'BUY';
const borderColor = isBuyTone ? 'var(--color-Buy)' : 'var(--color-Sell)';
const background = isBuyTone ? 'var(--color-GreenAlpha01)' : 'var(--color-RedAlpha01)';
- const disabledAttrs = disabled ? ' disabled aria-disabled="true"' : '';
- return ``;
+ const disabledAttrs = disabled
+ ? ` disabled aria-disabled="true"${preserveTone ? ' data-ladder-preserve-tone="true"' : ''}`
+ : '';
+ const cursor = disabled ? 'not-allowed' : 'pointer';
+ return ``;
+ }
+
+ function ladderExecutionButton(actionType, label, tone, disabled = false) {
+ if (activeLadderActionType !== actionType) {
+ return ladderActionButton(actionType, label, tone, disabled, Boolean(activeLadderActionType));
+ }
+ return ``;
}
function getLadderControlSections(tradeMode, closeContext, symbol, precision) {
@@ -4751,8 +4859,8 @@ import {
ladderOptionRow(PANEL_COPY.field.interval, PANEL_COPY.tooltip.interval, LADDER_STEP_OPTIONS, getLadderStep(tradeMode, symbol, precision), 'step', ''),
],
actionButtons: [
- ladderActionButton('OPEN_LONG', PANEL_COPY.action.openLong, 'BUY', actionDisabled),
- ladderActionButton('OPEN_SHORT', PANEL_COPY.action.openShort, 'SELL', actionDisabled),
+ ladderExecutionButton('OPEN_LONG', PANEL_COPY.action.openLong, 'BUY', actionDisabled),
+ ladderExecutionButton('OPEN_SHORT', PANEL_COPY.action.openShort, 'SELL', actionDisabled),
],
};
}
@@ -4774,8 +4882,8 @@ import {
ladderOptionRow(PANEL_COPY.field.interval, PANEL_COPY.tooltip.interval, LADDER_STEP_OPTIONS, getLadderStep(tradeMode, symbol, precision), 'step', ''),
],
actionButtons: [
- ladderActionButton('CLOSE_LONG', PANEL_COPY.action.closeLong, 'SELL', closeLongDisabled),
- ladderActionButton('CLOSE_SHORT', PANEL_COPY.action.closeShort, 'BUY', closeShortDisabled),
+ ladderExecutionButton('CLOSE_LONG', PANEL_COPY.action.closeLong, 'SELL', closeLongDisabled),
+ ladderExecutionButton('CLOSE_SHORT', PANEL_COPY.action.closeShort, 'BUY', closeShortDisabled),
],
};
}
@@ -4783,9 +4891,10 @@ import {
function refreshLadderPanel(panel, tradeMode, closeContext) {
const body = panel.querySelector(`#${LADDER_BODY_ID}`);
const status = panel.querySelector(`#${LADDER_STATUS_ID}`);
- const mode = ['OPEN', 'CLOSE'].includes(tradeMode) ? tradeMode : null;
- const symbol = getCurrentSymbol();
- const precision = readCurrentOrderbookPrecisionValue();
+ const mode = activeLadderPanelContext?.mode
+ || (['OPEN', 'CLOSE'].includes(tradeMode) ? tradeMode : null);
+ const symbol = activeLadderPanelContext?.symbol || getCurrentSymbol();
+ const precision = activeLadderPanelContext?.precision || readCurrentOrderbookPrecisionValue();
if (body) {
const ladderRunning = !!ladderTask;
const cancelRunning = !!cancelCurrentSymbolOpenOrdersTask;
@@ -4795,14 +4904,11 @@ import {
noOrdersFeedback: cancelNoOrdersFeedbackActive,
});
const controlSections = getLadderControlSections(mode, closeContext, symbol, precision);
- const primaryActions = ladderRunning
- ? [``]
- : controlSections.actionButtons;
const bodyHtml = [
...controlSections.optionRows,
``,
'',
- ...primaryActions,
+ ...controlSections.actionButtons,
``,
'
',
].join('');
diff --git a/test/unit/binance-orderbook-trade/ladder-options.test.js b/test/unit/binance-orderbook-trade/ladder-options.test.js
index b7f5b43..8825fb7 100644
--- a/test/unit/binance-orderbook-trade/ladder-options.test.js
+++ b/test/unit/binance-orderbook-trade/ladder-options.test.js
@@ -246,8 +246,8 @@ test('ladder quantity levels and step options share one stable five-slot grid',
});
test('close ladder buttons match the Binance native long-short order', () => {
- const closeLongButton = "ladderActionButton('CLOSE_LONG', PANEL_COPY.action.closeLong, 'SELL', closeLongDisabled)";
- const closeShortButton = "ladderActionButton('CLOSE_SHORT', PANEL_COPY.action.closeShort, 'BUY', closeShortDisabled)";
+ const closeLongButton = "ladderExecutionButton('CLOSE_LONG', PANEL_COPY.action.closeLong, 'SELL', closeLongDisabled)";
+ const closeShortButton = "ladderExecutionButton('CLOSE_SHORT', PANEL_COPY.action.closeShort, 'BUY', closeShortDisabled)";
assert.notEqual(source.indexOf(closeLongButton), -1);
assert.notEqual(source.indexOf(closeShortButton), -1);
diff --git a/test/unit/binance-orderbook-trade/source-regressions.test.js b/test/unit/binance-orderbook-trade/source-regressions.test.js
index fafc825..c0e844b 100644
--- a/test/unit/binance-orderbook-trade/source-regressions.test.js
+++ b/test/unit/binance-orderbook-trade/source-regressions.test.js
@@ -623,7 +623,10 @@ test('ladder replacement cancels visible current-symbol same-direction rows up t
assert.match(cancelOpenOrderRowsBody, /currentRoot = refreshedRoot/);
assert.match(cancelOpenOrderRowsBody, /currentRoot = row\.root \|\| currentRoot/);
assert.match(cancelOpenOrderRowsBody, /clickDomTarget\(row\.cancelButton\)/);
- assert.match(cancelOpenOrderRowsBody, /waitForOpenOrderRowKeyCountBelow\(plan\.symbol,\s*row\.key,\s*previousKeyCount\)/);
+ assert.match(cancelOpenOrderRowsBody, /waitForOpenOrderRowCancellationOutcome\(/);
+ assert.match(cancelOpenOrderRowsBody, /outcome\.status === 'dialog_open'/);
+ assert.match(cancelOpenOrderRowsBody, /confirmOpenOrderRowKeyCountBelow\(plan\.symbol,\s*row\.key,\s*previousKeyCount\)/);
+ assert.doesNotMatch(cancelOpenOrderRowsBody, /waitForNewVisibleDialog/);
assert.match(cancelOpenOrderRowsBody, /const dialogClosed = await waitForDialogToClose\(dialog\)/);
assert.match(cancelOpenOrderRowsBody, /DialogNotClosedError/);
assert.doesNotMatch(cancelOpenOrderRowsBody, /waitForOpenOrderRowKeyCountBelow\(row\.root/);
@@ -639,6 +642,26 @@ test('ladder replacement cancels visible current-symbol same-direction rows up t
assert.doesNotMatch(waitForRowRemovalBody, /delay\(/);
assert.doesNotMatch(cancelOpenOrderRowsBody, /delay\(260\)/);
+ const cancellationOutcomeBody = readFunctionBody('readOpenOrderRowCancellationOutcome');
+ const dialogIndex = cancellationOutcomeBody.indexOf('findNewVisibleDialog(dialogsBefore)');
+ const rowCountIndex = cancellationOutcomeBody.indexOf('countOpenOrderRowsByKey');
+ assert.ok(dialogIndex >= 0 && rowCountIndex >= 0 && dialogIndex < rowCountIndex);
+ assert.match(cancellationOutcomeBody, /status: 'dialog_open'/);
+ assert.match(cancellationOutcomeBody, /status: 'row_removed'/);
+
+ const waitForOutcomeBody = readFunctionBody('waitForOpenOrderRowCancellationOutcome');
+ assert.match(waitForOutcomeBody, /createAccountOrdersMutationSignal/);
+ assert.match(waitForOutcomeBody, /createDialogMutationSignal/);
+ assert.match(waitForOutcomeBody, /Promise\.race/);
+ assert.match(waitForOutcomeBody, /accountSignal\.dispose\(\)/);
+ assert.match(waitForOutcomeBody, /dialogSignal\.dispose\(\)/);
+ assert.doesNotMatch(waitForOutcomeBody, /waitForNewVisibleDialog/);
+
+ const confirmRemovalBody = readFunctionBody('confirmOpenOrderRowKeyCountBelow');
+ assert.match(confirmRemovalBody, /LADDER_REPLACE_ROW_SETTLE_MS/);
+ assert.match(confirmRemovalBody, /countOpenOrderRowsByKey/);
+ assert.match(confirmRemovalBody, /createAccountOrdersMutationSignal/);
+
const cancelRowsBody = readFunctionBody('cancelCurrentSymbolOpenOrdersForPlan');
assert.match(cancelRowsBody, /if \(!isCurrentObservedSymbol\(symbol\) \|\| symbol !== plan\?\.symbol\)/);
assert.match(cancelRowsBody, /activateOpenOrdersBasicSubTab\(openOrdersScope\)[\s\S]*openOrdersScope = await waitForActiveOpenOrdersScope\(\)/);
@@ -857,7 +880,13 @@ test('cancel current-symbol open orders are single-flight and follow the native
assert.match(panelBody, /cancelCurrentSymbolOpenOrdersTask/);
assert.match(panelBody, /resolveCancelSymbolButtonPresentation\(\{/);
assert.match(panelBody, /noOrdersFeedback: cancelNoOrdersFeedbackActive/);
- assert.match(panelBody, /data-ladder-stop="true"[^`]*grid-column:span 2/);
+ assert.match(source, /let activeLadderActionType = null/);
+ assert.match(source, /let activeLadderPanelContext = null/);
+ assert.match(source, /data-ladder-preserve-tone="true"/);
+ assert.match(source, /button:disabled:not\(\[data-ladder-preserve-tone="true"\]\)/);
+ assert.match(source, /Boolean\(activeLadderActionType\)/);
+ assert.match(panelBody, /controlSections\.actionButtons/);
+ assert.doesNotMatch(panelBody, /grid-column:span 2/);
assert.match(panelBody, /grid-template-columns:repeat\(3,minmax\(0,1fr\)\)/);
assert.doesNotMatch(panelBody, /actionColumnCount/);
assert.match(panelBody, /data-ladder-cancel-symbol="true"[^`]*>\$\{PANEL_COPY\.action\.cancel\}<\/button>/);
@@ -901,7 +930,14 @@ test('cancel current-symbol open orders are single-flight and follow the native
const actionRowsBody = readFunctionBody('getLadderControlSections');
assert.match(actionRowsBody, /actionDisabled = ladderRunning \|\| cancelCurrentSymbolOpenOrdersBlocksLadderActions/);
assert.doesNotMatch(actionRowsBody, /!!cancelCurrentSymbolOpenOrdersTask/);
- assert.match(actionRowsBody, /ladderActionButton\('OPEN_LONG',[\s\S]*actionDisabled\)/);
+ assert.match(actionRowsBody, /ladderExecutionButton\('OPEN_LONG',[\s\S]*actionDisabled\)/);
+ assert.match(startBody, /activeLadderActionType = actionType/);
+ assert.match(startBody, /activeLadderPanelContext = \{[\s\S]*mode: spec\.mode,[\s\S]*symbol: actionSymbol,[\s\S]*precision: readCurrentOrderbookPrecisionValue\(\)/);
+ assert.match(startBody, /finally\(\(\) => \{[\s\S]*activeLadderActionType = null/);
+ assert.match(startBody, /finally\(\(\) => \{[\s\S]*activeLadderPanelContext = null/);
+ assert.match(panelBody, /const mode = activeLadderPanelContext\?\.mode/);
+ assert.match(panelBody, /const symbol = activeLadderPanelContext\?\.symbol/);
+ assert.match(panelBody, /const precision = activeLadderPanelContext\?\.precision/);
});
test('stable panel refreshes avoid writing unchanged text and state attributes', () => {
From 006eb2da5628a6b6980eeb16a86861d810492f24 Mon Sep 17 00:00:00 2001
From: LiZhenhai-MBP14 <5935568+jackhai9@users.noreply.github.com>
Date: Thu, 27 Aug 2026 11:30:53 +0800
Subject: [PATCH 2/2] test: preserve ladder action slots while running
---
.../specs/control-flows.pw.js | 24 +++++++++++++++----
1 file changed, 20 insertions(+), 4 deletions(-)
diff --git a/e2e/binance-orderbook/specs/control-flows.pw.js b/e2e/binance-orderbook/specs/control-flows.pw.js
index 7a537aa..af3c878 100644
--- a/e2e/binance-orderbook/specs/control-flows.pw.js
+++ b/e2e/binance-orderbook/specs/control-flows.pw.js
@@ -139,7 +139,7 @@ test('precision refresh explains when the complete visible trade list still lack
expect(errors).toEqual([]);
});
-test('starting a ladder disables every start action and exposes one stop control', async ({ page }) => {
+test('starting a ladder preserves action slots and turns only the active action into stop', async ({ page }) => {
const scenario = createCancelScenario({
ui: { tradeMode: 'OPEN', orderbookPrecision: '0.1' },
});
@@ -155,15 +155,31 @@ test('starting a ladder disables every start action and exposes one stop control
await expect(stop).toHaveCount(0);
const startLongRect = await readRect(startLong);
const startShortRect = await readRect(startShort);
+ const startShortTone = await startShort.evaluate((button) => {
+ const style = getComputedStyle(button);
+ return {
+ backgroundColor: style.backgroundColor,
+ borderColor: style.borderColor,
+ color: style.color,
+ };
+ });
const cancelRect = await readRect(cancel);
await installInteractionProbe(page, '[data-ladder-action="OPEN_LONG"]');
await startLong.click();
await expect(startLong).toHaveCount(0);
- await expect(startShort).toHaveCount(0);
+ await expect(startShort).toBeDisabled();
await expect(stop).toBeEnabled();
const stopRect = await readRect(stop);
- expect(stopRect.x).toBe(startLongRect.x);
- expect(stopRect.x + stopRect.width).toBe(startShortRect.x + startShortRect.width);
+ expect(stopRect).toEqual(startLongRect);
+ expect(await readRect(startShort)).toEqual(startShortRect);
+ expect(await startShort.evaluate((button) => {
+ const style = getComputedStyle(button);
+ return {
+ backgroundColor: style.backgroundColor,
+ borderColor: style.borderColor,
+ color: style.color,
+ };
+ })).toEqual(startShortTone);
expect(await readRect(cancel)).toEqual(cancelRect);
const submissionsBeforeStop = (await readFixtureState(page)).events
.filter((event) => event.type === 'order-submitted').length;