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;