From 164d18f923ea1d24137457188428dbf70e34b5b7 Mon Sep 17 00:00:00 2001 From: LiZhenhai-MBP14 <5935568+jackhai9@users.noreply.github.com> Date: Fri, 28 Aug 2026 09:39:35 +0800 Subject: [PATCH 1/2] fix: preserve ladder progress in status messages --- scripts/binance-orderbook-trade.user.js | 188 +++++++++++++----- .../core/ladder-progress.js | 85 ++++++++ src/binance-orderbook-trade/index.user.js | 140 ++++++++----- .../ladder-progress.test.js | 87 ++++++++ .../source-regressions.test.js | 78 +++++++- 5 files changed, 472 insertions(+), 106 deletions(-) create mode 100644 src/binance-orderbook-trade/core/ladder-progress.js create mode 100644 test/unit/binance-orderbook-trade/ladder-progress.test.js diff --git a/scripts/binance-orderbook-trade.user.js b/scripts/binance-orderbook-trade.user.js index 0e8237b..b8d1e66 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.142 +// @version 2.7.143 // @author jackhai9 // @description 单击订单簿价格,按当前开仓/平仓 tab 自动填数量并执行下单,内置数量倍率面板 // @match https://www.binance.com/*/futures/* @@ -835,6 +835,67 @@ ); } + // src/binance-orderbook-trade/core/ladder-progress.js + function assertLadderProgress(progress) { + if (!progress || !Number.isInteger(progress.submittedOrders) || progress.submittedOrders < 0 || !Number.isInteger(progress.cancelledOrders) || progress.cancelledOrders < 0) { + throw new Error("Invalid ladder progress"); + } + } + function assertLadderLabel(label) { + if (typeof label !== "string" || label.trim() === "") { + throw new Error("Invalid ladder label"); + } + } + function assertLadderMessage(message) { + if (typeof message !== "string" || message.trim() === "") { + throw new Error("Invalid ladder progress message"); + } + } + function formatLadderProgressCounts(progress) { + assertLadderProgress(progress); + return `已挂 ${progress.submittedOrders} 笔 · 已撤 ${progress.cancelledOrders} 笔`; + } + function createLadderProgress() { + return { + submittedOrders: 0, + cancelledOrders: 0 + }; + } + function recordLadderSubmittedOrder(progress) { + assertLadderProgress(progress); + progress.submittedOrders += 1; + } + function recordLadderCancelledOrder(progress) { + assertLadderProgress(progress); + progress.cancelledOrders += 1; + } + function formatStoppedLadderProgress(label, progress) { + assertLadderLabel(label); + return `${label}已停止 · ${formatLadderProgressCounts(progress)}`; + } + function formatInterruptedLadderProgress(label, reason, progress) { + assertLadderLabel(label); + assertLadderMessage(reason); + return `${label}已中止:${reason} · ${formatLadderProgressCounts(progress)}`; + } + function formatFailedLadderProgress(label, message, progress) { + assertLadderLabel(label); + assertLadderMessage(message); + return `${label}失败:${message} · ${formatLadderProgressCounts(progress)}`; + } + function formatCompletedLadderProgress(label, completedOrders, totalOrders, progress) { + assertLadderLabel(label); + assertLadderProgress(progress); + if (!Number.isInteger(completedOrders) || completedOrders < 0 || !Number.isInteger(totalOrders) || totalOrders < 0) { + throw new Error("Invalid completed ladder total"); + } + if (completedOrders !== totalOrders) { + throw new Error("Completed ladder progress mismatch"); + } + const cancelledText = progress.cancelledOrders > 0 ? ` · 已撤 ${progress.cancelledOrders} 笔` : ""; + return `${label}已完成 · 已挂 ${completedOrders}/${totalOrders} 笔${cancelledText}`; + } + // src/binance-orderbook-trade/core/abort.js function getAbortReason(signal) { if (signal?.reason instanceof Error) return signal.reason; @@ -3911,7 +3972,7 @@ const settleHint = sawBusy ? "按钮已恢复但未收到明确成功反馈" : "未观察到提交按钮状态变化"; throw new Error(`未收到明确${label}成功反馈(${settleHint}),已停止;请核对当前委托/历史成交`); } - async function executeLadderPlan(plan, abortSignal = null) { + async function executeLadderPlan(plan, progress, abortSignal = null) { const priceInput = findPriceInput(); const qtyInput = findQtyInput(); if (!priceInput || !qtyInput) throw new Error("未找到价格或数量输入框"); @@ -3962,7 +4023,7 @@ try { throwIfAborted(abortSignal); button.click(); - setLadderStatus(`${plan.spec.label} ${done + 1}/${plan.orders.length} 确认中`); + setLadderStatus(`${plan.spec.label}挂单 ${done + 1}/${plan.orders.length} 确认中`); waitForTradeUiMutation({ timeoutMs: 500 }); await waitForOrderSubmitAcknowledgement( button, @@ -3971,7 +4032,6 @@ submitCaptureId, plan.spec.mode ); - throwIfAborted(abortSignal); } finally { endLadderSubmitResponseCapture(submitCaptureId); } @@ -3987,10 +4047,10 @@ } if (repriceAttempts >= LADDER_REPRICE_MAX_ATTEMPTS) { const codeText = lastRepriceApiErrorCode == null ? "" : `(错误码 ${lastRepriceApiErrorCode})`; - throw new Error(`盘口连续移动,已自动刷新 ${repriceAttempts} 次${codeText};已完成 ${done}/${plan.orders.length},已停止`); + throw new Error(`盘口连续移动,已自动刷新 ${repriceAttempts} 次${codeText};已挂 ${done}/${plan.orders.length} 笔后停止`); } repriceAttempts += 1; - setLadderStatus(`盘口已移动,刷新剩余 ${plan.orders.length - done} 档 (${repriceAttempts}/${LADDER_REPRICE_MAX_ATTEMPTS})`); + setLadderStatus(`${plan.spec.label}:盘口已移动,刷新剩余 ${plan.orders.length - done} 档 (${repriceAttempts}/${LADDER_REPRICE_MAX_ATTEMPTS})`); await delay(LADDER_REPRICE_DELAY_MS); throwIfAborted(abortSignal); if (ladderStopRequested) break; @@ -3998,37 +4058,39 @@ continue; } done++; - setLadderStatus(`${plan.spec.label} ${done}/${plan.orders.length}`); + recordLadderSubmittedOrder(progress); + setLadderStatus(`${plan.spec.label}已挂 ${done}/${plan.orders.length} 笔`); await delay(LADDER_ORDER_DELAY_MS); throwIfAborted(abortSignal); } return { done, repriceAttempts, lastRepriceApiErrorCode }; } async function startLadder(actionType) { + const spec = getLadderActionSpec2(actionType); + if (!spec) { + setLadderStatus("未知阶梯动作"); + return; + } const actionSymbol = getCurrentSymbol(); if (!isCurrentObservedSymbol(actionSymbol)) { - setLadderStatus("交易对正在切换"); + setLadderStatus(`${spec.label}尚未开始:交易对正在切换`); return; } if (cancelCurrentSymbolOpenOrdersTask) { - setLadderStatus("撤本币挂单处理中,请等待完成"); + setLadderStatus(`${spec.label}尚未开始:撤本币挂单处理中`); return; } if (ladderTask) { - setLadderStatus("正在执行,先点停止"); - return; - } - const spec = getLadderActionSpec2(actionType); - if (!spec) { - setLadderStatus("未知阶梯动作"); + setLadderStatus(`${spec.label}尚未开始:已有阶梯任务正在执行,请先停止`); return; } if (spec?.mode === "CLOSE" && !isCloseSnapshotReady(actionSymbol)) { - setLadderStatus("仓位确认中"); + setLadderStatus(`${spec.label}尚未开始:仓位确认中`); return; } ladderStopRequested = false; const abortController = new AbortController(); + const progress = createLadderProgress(); ladderAbortController = abortController; activeLadderActionType = actionType; activeLadderPanelContext = { @@ -4037,14 +4099,18 @@ precision: readCurrentOrderbookPrecisionValue() }; const feedbackStartedAt = performance.now(); - setLadderStatus(`${spec?.label || "阶梯"} 准备中`); + setLadderStatus(`${spec.label}准备中`); const executionTask = (async () => { const { plan, done, repriceAttempts, lastRepriceApiErrorCode - } = await runLadderPlanWithOpenOrderReplacement(actionType, abortController.signal); + } = await runLadderPlanWithOpenOrderReplacement( + actionType, + progress, + abortController.signal + ); return { plan, done, @@ -4066,24 +4132,42 @@ wasStopped }) => { if (!isCurrentObservedSymbol(actionSymbol)) { - setLadderStatus("交易对已切换"); + setLadderStatus(formatInterruptedLadderProgress( + spec.label, + "交易对已切换", + progress + )); return; } const diagnostics = formatLadderRepriceDiagnostics(repriceAttempts, lastRepriceApiErrorCode); setLadderStatus( - wasStopped ? `已停止 ${done}/${plan.orders.length}${diagnostics}` : `完成 ${done}/${plan.orders.length}${diagnostics}` + wasStopped ? `${formatStoppedLadderProgress(spec.label, progress)}${diagnostics}` : `${formatCompletedLadderProgress( + spec.label, + done, + plan.orders.length, + progress + )}${diagnostics}` ); }).catch((e) => { if (isLadderStoppedError(e)) { - if (isCurrentObservedSymbol(actionSymbol)) setLadderStatus("已停止"); + if (isCurrentObservedSymbol(actionSymbol)) { + setLadderStatus(formatStoppedLadderProgress(spec.label, progress)); + } return; } err("Maker 阶梯执行失败:", e); if (!isCurrentObservedSymbol(actionSymbol)) { - setLadderStatus("交易对已切换"); + setLadderStatus(formatInterruptedLadderProgress( + spec.label, + "交易对已切换", + progress + )); return; } - setLadderStatus(e?.message || "执行失败", e?.statusTitle); + const failureMessage = e?.message || "未知错误"; + const failureText = formatFailedLadderProgress(spec.label, failureMessage, progress); + const failureTitle = e?.statusTitle ? formatFailedLadderProgress(spec.label, e.statusTitle, progress) : failureText; + setLadderStatus(failureText, failureTitle); }).finally(() => { if (ladderAbortController === abortController) ladderAbortController = null; ladderTask = null; @@ -4101,10 +4185,12 @@ return; } ladderStopRequested = true; + const activeSpec = getLadderActionSpec2(activeLadderActionType); + if (!activeSpec) throw new Error("运行中的阶梯任务缺少动作类型"); if (ladderAbortController && !ladderAbortController.signal.aborted) { ladderAbortController.abort(createLadderStoppedError()); } - setLadderStatus("停止中"); + setLadderStatus(`${activeSpec.label}停止中`); scheduleRenderPanel(); } function getNormalizedText2(el) { @@ -4642,7 +4728,7 @@ const activeRoot = getActiveOpenOrdersScope2(); return Boolean(activeRoot && countOpenOrderRowsByKey(activeRoot, symbol, key) < previousCount); } - async function cancelOpenOrderRowsForPlan(root, plan, abortSignal = null) { + async function cancelOpenOrderRowsForPlan(root, plan, progress, abortSignal = null) { let cancelQty = "0"; let currentRoot = root; while (compareDecimalStrings(cancelQty, plan.totalQty) < 0) { @@ -4687,7 +4773,7 @@ } if (outcome.status === "dialog_open") { const { dialog } = outcome; - setLadderStatus(`${plan.symbol} 单行撤单确认弹窗已打开`); + setLadderStatus(`${plan.spec.label}:${plan.symbol} 单行撤单确认弹窗已打开`); const dialogClosed = await waitForDialogToClose( dialog, ROW_CANCEL_DIALOG_CLOSE_TIMEOUT_MS, @@ -4716,8 +4802,8 @@ )) { throw new Error(`${plan.symbol} 当前币挂单状态不稳定,已停止重挂`); } - throwIfAborted(abortSignal); cancelQty = addDecimalStrings(cancelQty, row.qty); + recordLadderCancelledOrder(progress); } return { ok: true, cancelQty }; } @@ -5254,12 +5340,15 @@ }, CANCEL_NO_ORDERS_FEEDBACK_MS); scheduleRenderPanel(); } - async function cancelCurrentSymbolOpenOrdersForPlan(plan, abortSignal = null) { + async function cancelCurrentSymbolOpenOrdersForPlan(plan, progress, abortSignal = null) { throwIfAborted(abortSignal); + const setPlanStepStatus = (message) => { + setLadderStatus(`${plan.spec.label}:${message}`); + }; const symbol = getCurrentSymbol(); if (!isCurrentObservedSymbol(symbol) || symbol !== plan?.symbol) { const message = "逐行撤单前交易对已变化"; - setLadderStatus(message); + setPlanStepStatus(message); return { ok: false, status: "symbol_changed", message }; } const previousAccountOrdersTabIdentity = getAccountOrdersTabIdentity2(findSelectedAccountOrdersTab2()); @@ -5268,19 +5357,19 @@ let symbolFilterOriginalChecked = null; let restoreTemporaryUiState = true; try { - setLadderStatus(`查找 ${symbol} 当前委托`); + setPlanStepStatus(`查找 ${symbol} 当前委托`); const tabReady = await activateOpenOrdersTab(abortSignal); throwIfAborted(abortSignal); if (!tabReady || !isCurrentObservedSymbol(symbol)) { const message = "当前委托页未就绪或交易对已变化"; - setLadderStatus(message); + setPlanStepStatus(message); return { ok: false, status: "tab_not_ready", message }; } openOrdersScope = await waitForActiveOpenOrdersScope(abortSignal); throwIfAborted(abortSignal); if (!openOrdersScope) { const message = "未定位到当前委托面板"; - setLadderStatus(message); + setPlanStepStatus(message); return { ok: false, status: "scope_not_found", message }; } previousOpenOrdersSubTabIdentity = getOpenOrdersSubTabIdentity2( @@ -5293,14 +5382,14 @@ throwIfAborted(abortSignal); if (!basicSubTabState.ready) { const message = "未定位到当前委托基础单"; - setLadderStatus(message); + setPlanStepStatus(message); return { ok: false, status: "basic_tab_not_ready", message }; } openOrdersScope = await waitForActiveOpenOrdersScope(abortSignal); throwIfAborted(abortSignal); if (!openOrdersScope) { const message = "未定位到当前委托面板"; - setLadderStatus(message); + setPlanStepStatus(message); return { ok: false, status: "scope_not_found", message }; } symbolFilterOriginalChecked = getCheckboxCheckedState( @@ -5314,14 +5403,14 @@ throwIfAborted(abortSignal); if (!symbolFilter.ok) { const message = "未确认只显示当前币挂单"; - setLadderStatus(message); + setPlanStepStatus(message); return { ok: false, status: "symbol_filter_not_confirmed", message }; } openOrdersScope = await waitForActiveOpenOrdersScope(abortSignal); throwIfAborted(abortSignal); if (!openOrdersScope) { const message = "未定位到当前委托面板"; - setLadderStatus(message); + setPlanStepStatus(message); return { ok: false, status: "scope_not_found", message }; } const rows = await waitForCurrentSymbolOpenOrderRows( @@ -5334,25 +5423,25 @@ if (!rows.length) { const directionLabel = getPlanDirectionLabel(plan); const message = `未定位到 ${symbol}${directionLabel ? ` ${directionLabel}` : ""} 当前币可逐行撤单的基础单`; - setLadderStatus(message); + setPlanStepStatus(message); return { ok: false, status: "rows_not_found", message }; } const rowsToCancel = selectOpenOrderRowsToCancelForPlan(plan, rows); if (!rowsToCancel.length) { const message = `未选中 ${symbol} 当前币待撤挂单`; - setLadderStatus(message); + setPlanStepStatus(message); return { ok: false, status: "rows_not_selected", message }; } - setLadderStatus(`${symbol} 撤销 ${rowsToCancel.length} 笔当前币挂单`); - await cancelOpenOrderRowsForPlan(openOrdersScope, plan, abortSignal); + setPlanStepStatus(`${symbol} 撤销 ${rowsToCancel.length} 笔当前币挂单`); + await cancelOpenOrderRowsForPlan(openOrdersScope, plan, progress, abortSignal); throwIfAborted(abortSignal); - setLadderStatus(`${symbol} 当前币挂单已替换,继续重挂`); + setPlanStepStatus(`${symbol} 当前币挂单已替换,继续重挂`); return { ok: true, status: "rows_cleared" }; } catch (e) { if (isLadderStoppedError(e)) throw e; if (e?.name === "DialogNotClosedError") restoreTemporaryUiState = false; const message = e?.message || "当前币挂单逐行撤销失败,已停止重挂"; - setLadderStatus(message); + setPlanStepStatus(message); const status = e?.name === "DialogNotClosedError" ? "dialog_not_closed" : "row_cancel_failed"; return { ok: false, status, message }; } finally { @@ -5360,7 +5449,7 @@ openOrdersScope = await waitForActiveOpenOrdersScope(); if (symbolFilterOriginalChecked === false) { const restored = openOrdersScope ? await restoreOpenOrdersSymbolFilter(openOrdersScope, symbolFilterOriginalChecked, symbol) : false; - if (!restored) setLadderStatus("未能恢复隐藏其他合约状态"); + if (!restored) setPlanStepStatus("未能恢复隐藏其他合约状态"); } if (previousOpenOrdersSubTabIdentity) { await restoreOpenOrdersSubTab(previousOpenOrdersSubTabIdentity, symbol); @@ -5374,7 +5463,7 @@ function formatLadderPlanStatus(plan) { const levelText = plan.levels === plan.requestedLevels ? `${plan.levels}档` : `${plan.levels}/${plan.requestedLevels}档`; const stepText = plan.ladderStep === DEFAULT_LADDER_STEP ? "" : `/幅${plan.ladderStep}`; - return `${plan.spec.label} ${plan.percent}%/${levelText}${stepText}`; + return `${plan.spec.label}计划:${plan.percent}%/${levelText}${stepText}`; } function isReplaceableCloseLadderOpenOrdersFailure(plan, error) { if (plan?.spec?.mode !== "CLOSE") return false; @@ -5397,8 +5486,10 @@ return getOpenLadderMinimumQtyReplacementPlan(error); } function formatOpenOrdersReplacementStatus(plan) { - if (plan?.spec?.mode === "OPEN") return `${plan.symbol} 同向开仓挂单可能占用可开数量,准备替换`; - return `${plan.symbol} 当前挂单占用可平数量,准备替换`; + if (plan?.spec?.mode === "OPEN") { + return `${plan.spec.label}:${plan.symbol} 同向开仓挂单可能占用可开数量,准备替换`; + } + return `${plan.spec.label}:${plan.symbol} 当前挂单占用可平数量,准备替换`; } function createLadderExpectedContext(plan) { return { @@ -5407,7 +5498,7 @@ precision: plan.precision }; } - async function runLadderPlanWithOpenOrderReplacement(actionType, abortSignal = null) { + async function runLadderPlanWithOpenOrderReplacement(actionType, progress, abortSignal = null) { let replacementContext = null; for (let attempt = 0; attempt < 2; attempt += 1) { throwIfAborted(abortSignal); @@ -5416,7 +5507,7 @@ plan = await buildLadderPlan(actionType, replacementContext); throwIfAborted(abortSignal); setLadderStatus(formatLadderPlanStatus(plan)); - const execution = await executeLadderPlan(plan, abortSignal); + const execution = await executeLadderPlan(plan, progress, abortSignal); return { plan, ...execution }; } catch (e) { const replacementPlan = getReplaceableLadderOpenOrdersPlan(plan, e); @@ -5426,6 +5517,7 @@ replacementContext = createLadderExpectedContext(replacementPlan); const result = await cancelCurrentSymbolOpenOrdersForPlan( replacementPlan, + progress, abortSignal ); throwIfAborted(abortSignal); diff --git a/src/binance-orderbook-trade/core/ladder-progress.js b/src/binance-orderbook-trade/core/ladder-progress.js new file mode 100644 index 0000000..de9fa1e --- /dev/null +++ b/src/binance-orderbook-trade/core/ladder-progress.js @@ -0,0 +1,85 @@ +function assertLadderProgress(progress) { + if ( + !progress + || !Number.isInteger(progress.submittedOrders) + || progress.submittedOrders < 0 + || !Number.isInteger(progress.cancelledOrders) + || progress.cancelledOrders < 0 + ) { + throw new Error('Invalid ladder progress'); + } +} + +function assertLadderLabel(label) { + if (typeof label !== 'string' || label.trim() === '') { + throw new Error('Invalid ladder label'); + } +} + +function assertLadderMessage(message) { + if (typeof message !== 'string' || message.trim() === '') { + throw new Error('Invalid ladder progress message'); + } +} + +function formatLadderProgressCounts(progress) { + assertLadderProgress(progress); + return `已挂 ${progress.submittedOrders} 笔 · 已撤 ${progress.cancelledOrders} 笔`; +} + +/** + * The shared object remains readable after an AbortSignal rejects the running ladder promise. + */ +export function createLadderProgress() { + return { + submittedOrders: 0, + cancelledOrders: 0, + }; +} + +export function recordLadderSubmittedOrder(progress) { + assertLadderProgress(progress); + progress.submittedOrders += 1; +} + +export function recordLadderCancelledOrder(progress) { + assertLadderProgress(progress); + progress.cancelledOrders += 1; +} + +export function formatStoppedLadderProgress(label, progress) { + assertLadderLabel(label); + return `${label}已停止 · ${formatLadderProgressCounts(progress)}`; +} + +export function formatInterruptedLadderProgress(label, reason, progress) { + assertLadderLabel(label); + assertLadderMessage(reason); + return `${label}已中止:${reason} · ${formatLadderProgressCounts(progress)}`; +} + +export function formatFailedLadderProgress(label, message, progress) { + assertLadderLabel(label); + assertLadderMessage(message); + return `${label}失败:${message} · ${formatLadderProgressCounts(progress)}`; +} + +export function formatCompletedLadderProgress(label, completedOrders, totalOrders, progress) { + assertLadderLabel(label); + assertLadderProgress(progress); + if ( + !Number.isInteger(completedOrders) + || completedOrders < 0 + || !Number.isInteger(totalOrders) + || totalOrders < 0 + ) { + throw new Error('Invalid completed ladder total'); + } + if (completedOrders !== totalOrders) { + throw new Error('Completed ladder progress mismatch'); + } + const cancelledText = progress.cancelledOrders > 0 + ? ` · 已撤 ${progress.cancelledOrders} 笔` + : ''; + return `${label}已完成 · 已挂 ${completedOrders}/${totalOrders} 笔${cancelledText}`; +} diff --git a/src/binance-orderbook-trade/index.user.js b/src/binance-orderbook-trade/index.user.js index 35b39da..c921fe1 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.142 +// @version 2.7.143 // @author jackhai9 // @description 单击订单簿价格,按当前开仓/平仓 tab 自动填数量并执行下单,内置数量倍率面板 // @match https://www.binance.com/*/futures/* @@ -77,6 +77,15 @@ import { getLadderPercentForMode, } from './core/ladder-plan.js'; import { keepInteractionFeedbackVisible } from './core/interaction-feedback.js'; +import { + createLadderProgress, + formatCompletedLadderProgress, + formatFailedLadderProgress, + formatInterruptedLadderProgress, + formatStoppedLadderProgress, + recordLadderCancelledOrder, + recordLadderSubmittedOrder, +} from './core/ladder-progress.js'; import { throwIfAborted, waitForPromiseOrAbort, @@ -2464,7 +2473,7 @@ import { throw new Error(`未收到明确${label}成功反馈(${settleHint}),已停止;请核对当前委托/历史成交`); } - async function executeLadderPlan(plan, abortSignal = null) { + async function executeLadderPlan(plan, progress, abortSignal = null) { const priceInput = findPriceInput(); const qtyInput = findQtyInput(); if (!priceInput || !qtyInput) throw new Error('未找到价格或数量输入框'); @@ -2520,7 +2529,7 @@ import { try { throwIfAborted(abortSignal); button.click(); - setLadderStatus(`${plan.spec.label} ${done + 1}/${plan.orders.length} 确认中`); + setLadderStatus(`${plan.spec.label}挂单 ${done + 1}/${plan.orders.length} 确认中`); waitForTradeUiMutation({ timeoutMs: 500 }); await waitForOrderSubmitAcknowledgement( button, @@ -2529,7 +2538,6 @@ import { submitCaptureId, plan.spec.mode, ); - throwIfAborted(abortSignal); } finally { endLadderSubmitResponseCapture(submitCaptureId); } @@ -2545,10 +2553,10 @@ import { } if (repriceAttempts >= LADDER_REPRICE_MAX_ATTEMPTS) { const codeText = lastRepriceApiErrorCode == null ? '' : `(错误码 ${lastRepriceApiErrorCode})`; - throw new Error(`盘口连续移动,已自动刷新 ${repriceAttempts} 次${codeText};已完成 ${done}/${plan.orders.length},已停止`); + throw new Error(`盘口连续移动,已自动刷新 ${repriceAttempts} 次${codeText};已挂 ${done}/${plan.orders.length} 笔后停止`); } repriceAttempts += 1; - setLadderStatus(`盘口已移动,刷新剩余 ${plan.orders.length - done} 档 (${repriceAttempts}/${LADDER_REPRICE_MAX_ATTEMPTS})`); + setLadderStatus(`${plan.spec.label}:盘口已移动,刷新剩余 ${plan.orders.length - done} 档 (${repriceAttempts}/${LADDER_REPRICE_MAX_ATTEMPTS})`); await delay(LADDER_REPRICE_DELAY_MS); throwIfAborted(abortSignal); if (ladderStopRequested) break; @@ -2556,7 +2564,8 @@ import { continue; } done++; - setLadderStatus(`${plan.spec.label} ${done}/${plan.orders.length}`); + recordLadderSubmittedOrder(progress); + setLadderStatus(`${plan.spec.label}已挂 ${done}/${plan.orders.length} 笔`); await delay(LADDER_ORDER_DELAY_MS); throwIfAborted(abortSignal); } @@ -2564,30 +2573,31 @@ import { } async function startLadder(actionType) { + const spec = getLadderActionSpec(actionType); + if (!spec) { + setLadderStatus('未知阶梯动作'); + return; + } const actionSymbol = getCurrentSymbol(); if (!isCurrentObservedSymbol(actionSymbol)) { - setLadderStatus('交易对正在切换'); + setLadderStatus(`${spec.label}尚未开始:交易对正在切换`); return; } if (cancelCurrentSymbolOpenOrdersTask) { - setLadderStatus('撤本币挂单处理中,请等待完成'); + setLadderStatus(`${spec.label}尚未开始:撤本币挂单处理中`); return; } if (ladderTask) { - setLadderStatus('正在执行,先点停止'); - return; - } - const spec = getLadderActionSpec(actionType); - if (!spec) { - setLadderStatus('未知阶梯动作'); + setLadderStatus(`${spec.label}尚未开始:已有阶梯任务正在执行,请先停止`); return; } if (spec?.mode === 'CLOSE' && !isCloseSnapshotReady(actionSymbol)) { - setLadderStatus('仓位确认中'); + setLadderStatus(`${spec.label}尚未开始:仓位确认中`); return; } ladderStopRequested = false; const abortController = new AbortController(); + const progress = createLadderProgress(); ladderAbortController = abortController; activeLadderActionType = actionType; activeLadderPanelContext = { @@ -2596,14 +2606,18 @@ import { precision: readCurrentOrderbookPrecisionValue(), }; const feedbackStartedAt = performance.now(); - setLadderStatus(`${spec?.label || '阶梯'} 准备中`); + setLadderStatus(`${spec.label}准备中`); const executionTask = (async () => { const { plan, done, repriceAttempts, lastRepriceApiErrorCode, - } = await runLadderPlanWithOpenOrderReplacement(actionType, abortController.signal); + } = await runLadderPlanWithOpenOrderReplacement( + actionType, + progress, + abortController.signal, + ); return { plan, done, @@ -2626,27 +2640,47 @@ import { wasStopped, }) => { if (!isCurrentObservedSymbol(actionSymbol)) { - setLadderStatus('交易对已切换'); + setLadderStatus(formatInterruptedLadderProgress( + spec.label, + '交易对已切换', + progress, + )); return; } const diagnostics = formatLadderRepriceDiagnostics(repriceAttempts, lastRepriceApiErrorCode); setLadderStatus( wasStopped - ? `已停止 ${done}/${plan.orders.length}${diagnostics}` - : `完成 ${done}/${plan.orders.length}${diagnostics}`, + ? `${formatStoppedLadderProgress(spec.label, progress)}${diagnostics}` + : `${formatCompletedLadderProgress( + spec.label, + done, + plan.orders.length, + progress, + )}${diagnostics}`, ); }) .catch((e) => { if (isLadderStoppedError(e)) { - if (isCurrentObservedSymbol(actionSymbol)) setLadderStatus('已停止'); + if (isCurrentObservedSymbol(actionSymbol)) { + setLadderStatus(formatStoppedLadderProgress(spec.label, progress)); + } return; } err('Maker 阶梯执行失败:', e); if (!isCurrentObservedSymbol(actionSymbol)) { - setLadderStatus('交易对已切换'); + setLadderStatus(formatInterruptedLadderProgress( + spec.label, + '交易对已切换', + progress, + )); return; } - setLadderStatus(e?.message || '执行失败', e?.statusTitle); + const failureMessage = e?.message || '未知错误'; + const failureText = formatFailedLadderProgress(spec.label, failureMessage, progress); + const failureTitle = e?.statusTitle + ? formatFailedLadderProgress(spec.label, e.statusTitle, progress) + : failureText; + setLadderStatus(failureText, failureTitle); }) .finally(() => { if (ladderAbortController === abortController) ladderAbortController = null; @@ -2666,10 +2700,12 @@ import { return; } ladderStopRequested = true; + const activeSpec = getLadderActionSpec(activeLadderActionType); + if (!activeSpec) throw new Error('运行中的阶梯任务缺少动作类型'); if (ladderAbortController && !ladderAbortController.signal.aborted) { ladderAbortController.abort(createLadderStoppedError()); } - setLadderStatus('停止中'); + setLadderStatus(`${activeSpec.label}停止中`); scheduleRenderPanel(); } @@ -3320,7 +3356,7 @@ import { return Boolean(activeRoot && countOpenOrderRowsByKey(activeRoot, symbol, key) < previousCount); } - async function cancelOpenOrderRowsForPlan(root, plan, abortSignal = null) { + async function cancelOpenOrderRowsForPlan(root, plan, progress, abortSignal = null) { let cancelQty = '0'; let currentRoot = root; while (compareDecimalStrings(cancelQty, plan.totalQty) < 0) { @@ -3365,7 +3401,7 @@ import { } if (outcome.status === 'dialog_open') { const { dialog } = outcome; - setLadderStatus(`${plan.symbol} 单行撤单确认弹窗已打开`); + setLadderStatus(`${plan.spec.label}:${plan.symbol} 单行撤单确认弹窗已打开`); const dialogClosed = await waitForDialogToClose( dialog, ROW_CANCEL_DIALOG_CLOSE_TIMEOUT_MS, @@ -3394,8 +3430,8 @@ import { ))) { throw new Error(`${plan.symbol} 当前币挂单状态不稳定,已停止重挂`); } - throwIfAborted(abortSignal); cancelQty = addDecimalStrings(cancelQty, row.qty); + recordLadderCancelledOrder(progress); } return { ok: true, cancelQty }; } @@ -4008,12 +4044,15 @@ import { scheduleRenderPanel(); } - async function cancelCurrentSymbolOpenOrdersForPlan(plan, abortSignal = null) { + async function cancelCurrentSymbolOpenOrdersForPlan(plan, progress, abortSignal = null) { throwIfAborted(abortSignal); + const setPlanStepStatus = (message) => { + setLadderStatus(`${plan.spec.label}:${message}`); + }; const symbol = getCurrentSymbol(); if (!isCurrentObservedSymbol(symbol) || symbol !== plan?.symbol) { const message = '逐行撤单前交易对已变化'; - setLadderStatus(message); + setPlanStepStatus(message); return { ok: false, status: 'symbol_changed', message }; } @@ -4024,12 +4063,12 @@ import { let restoreTemporaryUiState = true; try { - setLadderStatus(`查找 ${symbol} 当前委托`); + setPlanStepStatus(`查找 ${symbol} 当前委托`); const tabReady = await activateOpenOrdersTab(abortSignal); throwIfAborted(abortSignal); if (!tabReady || !isCurrentObservedSymbol(symbol)) { const message = '当前委托页未就绪或交易对已变化'; - setLadderStatus(message); + setPlanStepStatus(message); return { ok: false, status: 'tab_not_ready', message }; } @@ -4037,7 +4076,7 @@ import { throwIfAborted(abortSignal); if (!openOrdersScope) { const message = '未定位到当前委托面板'; - setLadderStatus(message); + setPlanStepStatus(message); return { ok: false, status: 'scope_not_found', message }; } @@ -4051,7 +4090,7 @@ import { throwIfAborted(abortSignal); if (!basicSubTabState.ready) { const message = '未定位到当前委托基础单'; - setLadderStatus(message); + setPlanStepStatus(message); return { ok: false, status: 'basic_tab_not_ready', message }; } @@ -4059,7 +4098,7 @@ import { throwIfAborted(abortSignal); if (!openOrdersScope) { const message = '未定位到当前委托面板'; - setLadderStatus(message); + setPlanStepStatus(message); return { ok: false, status: 'scope_not_found', message }; } @@ -4074,7 +4113,7 @@ import { throwIfAborted(abortSignal); if (!symbolFilter.ok) { const message = '未确认只显示当前币挂单'; - setLadderStatus(message); + setPlanStepStatus(message); return { ok: false, status: 'symbol_filter_not_confirmed', message }; } @@ -4082,7 +4121,7 @@ import { throwIfAborted(abortSignal); if (!openOrdersScope) { const message = '未定位到当前委托面板'; - setLadderStatus(message); + setPlanStepStatus(message); return { ok: false, status: 'scope_not_found', message }; } @@ -4096,27 +4135,27 @@ import { if (!rows.length) { const directionLabel = getPlanDirectionLabel(plan); const message = `未定位到 ${symbol}${directionLabel ? ` ${directionLabel}` : ''} 当前币可逐行撤单的基础单`; - setLadderStatus(message); + setPlanStepStatus(message); return { ok: false, status: 'rows_not_found', message }; } const rowsToCancel = selectOpenOrderRowsToCancelForPlan(plan, rows); if (!rowsToCancel.length) { const message = `未选中 ${symbol} 当前币待撤挂单`; - setLadderStatus(message); + setPlanStepStatus(message); return { ok: false, status: 'rows_not_selected', message }; } - setLadderStatus(`${symbol} 撤销 ${rowsToCancel.length} 笔当前币挂单`); - await cancelOpenOrderRowsForPlan(openOrdersScope, plan, abortSignal); + setPlanStepStatus(`${symbol} 撤销 ${rowsToCancel.length} 笔当前币挂单`); + await cancelOpenOrderRowsForPlan(openOrdersScope, plan, progress, abortSignal); throwIfAborted(abortSignal); - setLadderStatus(`${symbol} 当前币挂单已替换,继续重挂`); + setPlanStepStatus(`${symbol} 当前币挂单已替换,继续重挂`); return { ok: true, status: 'rows_cleared' }; } catch (e) { if (isLadderStoppedError(e)) throw e; if (e?.name === 'DialogNotClosedError') restoreTemporaryUiState = false; const message = e?.message || '当前币挂单逐行撤销失败,已停止重挂'; - setLadderStatus(message); + setPlanStepStatus(message); const status = e?.name === 'DialogNotClosedError' ? 'dialog_not_closed' : 'row_cancel_failed'; return { ok: false, status, message }; } finally { @@ -4126,7 +4165,7 @@ import { const restored = openOrdersScope ? await restoreOpenOrdersSymbolFilter(openOrdersScope, symbolFilterOriginalChecked, symbol) : false; - if (!restored) setLadderStatus('未能恢复隐藏其他合约状态'); + if (!restored) setPlanStepStatus('未能恢复隐藏其他合约状态'); } if (previousOpenOrdersSubTabIdentity) { await restoreOpenOrdersSubTab(previousOpenOrdersSubTabIdentity, symbol); @@ -4143,7 +4182,7 @@ import { ? `${plan.levels}档` : `${plan.levels}/${plan.requestedLevels}档`; const stepText = plan.ladderStep === DEFAULT_LADDER_STEP ? '' : `/幅${plan.ladderStep}`; - return `${plan.spec.label} ${plan.percent}%/${levelText}${stepText}`; + return `${plan.spec.label}计划:${plan.percent}%/${levelText}${stepText}`; } function isReplaceableCloseLadderOpenOrdersFailure(plan, error) { @@ -4177,8 +4216,10 @@ import { } function formatOpenOrdersReplacementStatus(plan) { - if (plan?.spec?.mode === 'OPEN') return `${plan.symbol} 同向开仓挂单可能占用可开数量,准备替换`; - return `${plan.symbol} 当前挂单占用可平数量,准备替换`; + if (plan?.spec?.mode === 'OPEN') { + return `${plan.spec.label}:${plan.symbol} 同向开仓挂单可能占用可开数量,准备替换`; + } + return `${plan.spec.label}:${plan.symbol} 当前挂单占用可平数量,准备替换`; } function createLadderExpectedContext(plan) { @@ -4189,7 +4230,7 @@ import { }; } - async function runLadderPlanWithOpenOrderReplacement(actionType, abortSignal = null) { + async function runLadderPlanWithOpenOrderReplacement(actionType, progress, abortSignal = null) { let replacementContext = null; for (let attempt = 0; attempt < 2; attempt += 1) { throwIfAborted(abortSignal); @@ -4198,7 +4239,7 @@ import { plan = await buildLadderPlan(actionType, replacementContext); throwIfAborted(abortSignal); setLadderStatus(formatLadderPlanStatus(plan)); - const execution = await executeLadderPlan(plan, abortSignal); + const execution = await executeLadderPlan(plan, progress, abortSignal); return { plan, ...execution }; } catch (e) { const replacementPlan = getReplaceableLadderOpenOrdersPlan(plan, e); @@ -4208,6 +4249,7 @@ import { replacementContext = createLadderExpectedContext(replacementPlan); const result = await cancelCurrentSymbolOpenOrdersForPlan( replacementPlan, + progress, abortSignal, ); throwIfAborted(abortSignal); diff --git a/test/unit/binance-orderbook-trade/ladder-progress.test.js b/test/unit/binance-orderbook-trade/ladder-progress.test.js new file mode 100644 index 0000000..2db1ba2 --- /dev/null +++ b/test/unit/binance-orderbook-trade/ladder-progress.test.js @@ -0,0 +1,87 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; + +import { + createLadderProgress, + formatCompletedLadderProgress, + formatFailedLadderProgress, + formatInterruptedLadderProgress, + formatStoppedLadderProgress, + recordLadderCancelledOrder, + recordLadderSubmittedOrder, +} from '../../../src/binance-orderbook-trade/core/ladder-progress.js'; + +test('stopped ladder status reports confirmed submitted and cancelled orders', () => { + const progress = createLadderProgress(); + + recordLadderSubmittedOrder(progress); + recordLadderSubmittedOrder(progress); + recordLadderCancelledOrder(progress); + + assert.deepEqual(progress, { + submittedOrders: 2, + cancelledOrders: 1, + }); + assert.equal( + formatStoppedLadderProgress('阶梯开空', progress), + '阶梯开空已停止 · 已挂 2 笔 · 已撤 1 笔', + ); +}); + +test('stopped ladder status keeps explicit zero counts', () => { + assert.equal( + formatStoppedLadderProgress('阶梯平多', createLadderProgress()), + '阶梯平多已停止 · 已挂 0 笔 · 已撤 0 笔', + ); +}); + +test('completed ladder status names the action and confirmed result', () => { + const progress = createLadderProgress(); + for (let index = 0; index < 5; index += 1) recordLadderSubmittedOrder(progress); + + assert.equal( + formatCompletedLadderProgress('阶梯平空', 5, 5, progress), + '阶梯平空已完成 · 已挂 5/5 笔', + ); + + recordLadderSubmittedOrder(progress); + recordLadderSubmittedOrder(progress); + recordLadderCancelledOrder(progress); + assert.equal( + formatCompletedLadderProgress('阶梯平空', 5, 5, progress), + '阶梯平空已完成 · 已挂 5/5 笔 · 已撤 1 笔', + ); +}); + +test('failed and interrupted ladder statuses retain confirmed progress', () => { + const progress = createLadderProgress(); + recordLadderSubmittedOrder(progress); + recordLadderCancelledOrder(progress); + + assert.equal( + formatFailedLadderProgress('阶梯开多', '数量框状态未稳定', progress), + '阶梯开多失败:数量框状态未稳定 · 已挂 1 笔 · 已撤 1 笔', + ); + assert.equal( + formatInterruptedLadderProgress('阶梯开多', '交易对已切换', progress), + '阶梯开多已中止:交易对已切换 · 已挂 1 笔 · 已撤 1 笔', + ); +}); + +test('ladder progress rejects invalid counters instead of masking them', () => { + assert.throws( + () => formatStoppedLadderProgress('阶梯开多', { submittedOrders: -1, cancelledOrders: 0 }), + /Invalid ladder progress/, + ); + assert.throws( + () => recordLadderCancelledOrder({ submittedOrders: 0, cancelledOrders: 1.5 }), + /Invalid ladder progress/, + ); + assert.throws( + () => formatCompletedLadderProgress('阶梯开多', 1, 2, { + submittedOrders: 1, + cancelledOrders: 0, + }), + /Completed ladder progress mismatch/, + ); +}); diff --git a/test/unit/binance-orderbook-trade/source-regressions.test.js b/test/unit/binance-orderbook-trade/source-regressions.test.js index 50b73f4..cae2432 100644 --- a/test/unit/binance-orderbook-trade/source-regressions.test.js +++ b/test/unit/binance-orderbook-trade/source-regressions.test.js @@ -235,9 +235,9 @@ test('ladder retries with restricted open-order replacement after supported feed assert.match(replacePlanBody, /getOpenLadderMinimumQtyReplacementPlan\(error\)/); const retryBody = readFunctionBody('runLadderPlanWithOpenOrderReplacement'); - assert.match(retryBody, /await executeLadderPlan\(plan,\s*abortSignal\)/); + assert.match(retryBody, /await executeLadderPlan\(plan,\s*progress,\s*abortSignal\)/); assert.match(retryBody, /getReplaceableLadderOpenOrdersPlan\(plan,\s*e\)/); - assert.match(retryBody, /cancelCurrentSymbolOpenOrdersForPlan\(\s*replacementPlan,\s*abortSignal/); + assert.match(retryBody, /cancelCurrentSymbolOpenOrdersForPlan\(\s*replacementPlan,\s*progress,\s*abortSignal/); assert.doesNotMatch(retryBody, /cancelCurrentSymbolOpenOrders\(\{\s*waitUntilCleared: true\s*\}\)/); assert.match(retryBody, /replacementContext = createLadderExpectedContext\(replacementPlan\)/); assert.match(retryBody, /buildLadderPlan\(actionType,\s*replacementContext\)/); @@ -246,7 +246,7 @@ test('ladder retries with restricted open-order replacement after supported feed const startBody = readFunctionBody('startLadder'); assert.match(startBody, /const spec = getLadderActionSpec\(actionType\)/); assert.doesNotMatch(startBody, /cancelCurrentSymbolOpenOrders\(\{\s*waitUntilCleared: true\s*\}\)/); - assert.match(startBody, /runLadderPlanWithOpenOrderReplacement\(actionType,\s*abortController\.signal\)/); + assert.match(startBody, /runLadderPlanWithOpenOrderReplacement\(\s*actionType,\s*progress,\s*abortController\.signal/); }); test('open and close ladders reprice only remaining orders after explicit maker conflicts', () => { @@ -556,7 +556,9 @@ test('ladder minimum quantity failure explains safe manual options', () => { assert.match(statusBody, /statusEl\.title =/); const startBody = readFunctionBody('startLadder'); - assert.match(startBody, /setLadderStatus\(e\?\.message \|\| '执行失败',\s*e\?\.statusTitle\)/); + assert.match(startBody, /const failureMessage = e\?\.message \|\| '未知错误'/); + assert.match(startBody, /const failureText = formatFailedLadderProgress\(spec\.label,\s*failureMessage,\s*progress\)/); + assert.match(startBody, /formatFailedLadderProgress\(spec\.label,\s*e\.statusTitle,\s*progress\)/); }); test('ladder actions keep only their final UI feedback visible for a minimum window', () => { @@ -732,12 +734,12 @@ test('stopping a ladder aborts replacement waits before another cancel or submit assert.match(source, /let ladderAbortController = null/); assert.match(startBody, /const abortController = new AbortController\(\)/); - assert.match(startBody, /runLadderPlanWithOpenOrderReplacement\(actionType,\s*abortController\.signal\)/); + assert.match(startBody, /runLadderPlanWithOpenOrderReplacement\(\s*actionType,\s*progress,\s*abortController\.signal/); assert.match(stopBody, /ladderAbortController\.abort\(createLadderStoppedError\(\)\)/); - assert.match(runBody, /cancelCurrentSymbolOpenOrdersForPlan\(\s*replacementPlan,\s*abortSignal/); + assert.match(runBody, /cancelCurrentSymbolOpenOrdersForPlan\(\s*replacementPlan,\s*progress,\s*abortSignal/); assert.match(runBody, /throwIfAborted\(abortSignal\)[\s\S]*buildLadderPlan/); assert.match(executeBody, /throwIfAborted\(abortSignal\)[\s\S]*button\.click\(\)/); - assert.match(cancelPlanBody, /cancelOpenOrderRowsForPlan\(openOrdersScope,\s*plan,\s*abortSignal\)/); + assert.match(cancelPlanBody, /cancelOpenOrderRowsForPlan\(openOrdersScope,\s*plan,\s*progress,\s*abortSignal\)/); assert.match(cancelPlanBody, /isLadderStoppedError\(e\)[\s\S]*throw e/); assert.match(cancelPlanBody, /previousOpenOrdersSubTabIdentity = getOpenOrdersSubTabIdentity\([\s\S]*activateOpenOrdersBasicSubTab\(/); assert.match(cancelPlanBody, /symbolFilterOriginalChecked = getCheckboxCheckedState\([\s\S]*ensureOpenOrdersLimitedToCurrentSymbol\(/); @@ -747,6 +749,64 @@ test('stopping a ladder aborts replacement waits before another cancel or submit assert.match(waitOutcomeBody, /waitForPromiseOrAbort\([\s\S]*abortSignal/); }); +test('stopping a ladder preserves confirmed submit and cancel progress', () => { + const startBody = readFunctionBody('startLadder'); + const runBody = readFunctionBody('runLadderPlanWithOpenOrderReplacement'); + const executeBody = readFunctionBody('executeLadderPlan'); + const cancelPlanBody = readFunctionBody('cancelCurrentSymbolOpenOrdersForPlan'); + const cancelRowsBody = readFunctionBody('cancelOpenOrderRowsForPlan'); + + assert.match(startBody, /const progress = createLadderProgress\(\)/); + assert.match(startBody, /runLadderPlanWithOpenOrderReplacement\(\s*actionType,\s*progress,\s*abortController\.signal/); + assert.match(startBody, /isLadderStoppedError\(e\)[\s\S]*formatStoppedLadderProgress\(spec\.label,\s*progress\)/); + assert.match(startBody, /formatCompletedLadderProgress\(\s*spec\.label,\s*done,\s*plan\.orders\.length,\s*progress/); + assert.match(runBody, /executeLadderPlan\(plan,\s*progress,\s*abortSignal\)/); + assert.match(runBody, /cancelCurrentSymbolOpenOrdersForPlan\(\s*replacementPlan,\s*progress,\s*abortSignal/); + assert.match(executeBody, /waitForOrderSubmitAcknowledgement\([\s\S]*recordLadderSubmittedOrder\(progress\)/); + assert.match(cancelPlanBody, /cancelOpenOrderRowsForPlan\(\s*openOrdersScope,\s*plan,\s*progress,\s*abortSignal/); + assert.match(cancelRowsBody, /confirmOpenOrderRowKeyCountBelow\([\s\S]*recordLadderCancelledOrder\(progress\)/); + + const acknowledgementIndex = executeBody.indexOf('await waitForOrderSubmitAcknowledgement('); + const acknowledgementFinallyIndex = executeBody.indexOf('} finally {', acknowledgementIndex); + const submitRecordIndex = executeBody.indexOf('recordLadderSubmittedOrder(progress)'); + assert.ok( + acknowledgementIndex >= 0 + && acknowledgementIndex < acknowledgementFinallyIndex + && acknowledgementFinallyIndex < submitRecordIndex, + ); + assert.doesNotMatch( + executeBody.slice(acknowledgementIndex, acknowledgementFinallyIndex), + /throwIfAborted/, + ); + + const cancellationConfirmationIndex = cancelRowsBody.indexOf('await confirmOpenOrderRowKeyCountBelow('); + const cancelRecordIndex = cancelRowsBody.indexOf('recordLadderCancelledOrder(progress)'); + assert.ok(cancellationConfirmationIndex >= 0 && cancellationConfirmationIndex < cancelRecordIndex); + assert.doesNotMatch(cancelRowsBody.slice(cancellationConfirmationIndex, cancelRecordIndex), /throwIfAborted/); +}); + +test('ladder task statuses name the active action and observed outcome', () => { + const executeBody = readFunctionBody('executeLadderPlan'); + const startBody = readFunctionBody('startLadder'); + const stopBody = readFunctionBody('stopLadder'); + const cancelPlanBody = readFunctionBody('cancelCurrentSymbolOpenOrdersForPlan'); + const planStatusBody = readFunctionBody('formatLadderPlanStatus'); + const replacementStatusBody = readFunctionBody('formatOpenOrdersReplacementStatus'); + + assert.match(executeBody, /setLadderStatus\(`\$\{plan\.spec\.label\}挂单 \$\{done \+ 1\}\/\$\{plan\.orders\.length\} 确认中`\)/); + assert.match(executeBody, /setLadderStatus\(`\$\{plan\.spec\.label\}已挂 \$\{done\}\/\$\{plan\.orders\.length\} 笔`\)/); + assert.match(executeBody, /setLadderStatus\(`\$\{plan\.spec\.label\}:盘口已移动/); + assert.match(startBody, /`\$\{spec\.label\}尚未开始:仓位确认中`/); + assert.match(startBody, /formatInterruptedLadderProgress\(\s*spec\.label,\s*'交易对已切换',\s*progress/); + assert.match(startBody, /formatFailedLadderProgress\(spec\.label,\s*failureMessage,\s*progress\)/); + assert.match(stopBody, /`\$\{activeSpec\.label\}停止中`/); + assert.match(cancelPlanBody, /setPlanStepStatus\(`\$\{symbol\} 撤销 \$\{rowsToCancel\.length\} 笔当前币挂单`\)/); + assert.match(planStatusBody, /`\$\{plan\.spec\.label\}计划:/); + assert.match(replacementStatusBody, /`\$\{plan\.spec\.label\}:/); + assert.match(cancelPlanBody, /const setPlanStepStatus = \(message\) =>/); + assert.doesNotMatch(cancelPlanBody, /setLadderStatus\(message\)/); +}); + test('bulk cancel settles hidden TradingView orders before opening the native dialog', () => { assert.match(tradingViewOrdersSource, /applyOverrides/); assert.match(tradingViewOrdersSource, /tradingProperties\.showOrders/); @@ -1002,7 +1062,7 @@ test('cancel current-symbol open orders are single-flight and follow the native assert.match(cancelRunBody, /cancelCurrentSymbolOpenOrdersBlocksLadderActions = true;[\s\S]*scheduleRenderPanel\(\);/); const startBody = readFunctionBody('startLadder'); - assert.match(startBody, /if \(cancelCurrentSymbolOpenOrdersTask\)[\s\S]*撤本币挂单处理中,请等待完成/); + assert.match(startBody, /if \(cancelCurrentSymbolOpenOrdersTask\)[\s\S]*`\$\{spec\.label\}尚未开始:撤本币挂单处理中`/); const actionRowsBody = readFunctionBody('getLadderControlSections'); assert.match(actionRowsBody, /actionDisabled = ladderRunning \|\| cancelCurrentSymbolOpenOrdersBlocksLadderActions/); assert.doesNotMatch(actionRowsBody, /!!cancelCurrentSymbolOpenOrdersTask/); @@ -1292,7 +1352,7 @@ test('confirmed close-quantity mutations bypass the generic trade UI debounce', test('pending close actions report position confirmation without starting execution', () => { const startBody = readFunctionBody('startLadder'); assert.match(startBody, /spec\?\.mode === 'CLOSE' && !isCloseSnapshotReady\(actionSymbol\)/); - assert.match(startBody, /setLadderStatus\('仓位确认中'\)/); + assert.match(startBody, /setLadderStatus\(`\$\{spec\.label\}尚未开始:仓位确认中`\)/); assert.match(source, /if \(getActiveTradeMode\(\) === 'CLOSE' && !isCloseSnapshotReady\(clickedSymbol\)\) \{\s*warn\('仓位确认中'\);\s*return;/); }); From 2516df100f8d50c040cf1d1f5c5bba84bf991d0e Mon Sep 17 00:00:00 2001 From: LiZhenhai-MBP14 <5935568+jackhai9@users.noreply.github.com> Date: Fri, 28 Aug 2026 09:43:21 +0800 Subject: [PATCH 2/2] test: verify explicit ladder result statuses --- e2e/binance-orderbook/specs/control-flows.pw.js | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/e2e/binance-orderbook/specs/control-flows.pw.js b/e2e/binance-orderbook/specs/control-flows.pw.js index 8a4e353..a1cec89 100644 --- a/e2e/binance-orderbook/specs/control-flows.pw.js +++ b/e2e/binance-orderbook/specs/control-flows.pw.js @@ -178,7 +178,8 @@ test('starting a ladder preserves action slots and turns only the active action const submissionsBeforeStop = (await readFixtureState(page)).events .filter((event) => event.type === 'order-submitted').length; await stop.click(); - await expect(panel.locator('#jh-binance-ladder-status')).toContainText('已停止'); + const status = panel.locator('#jh-binance-ladder-status'); + await expect(status).toContainText('阶梯开多已停止'); await expect(startLong).toBeEnabled(); await expect(startShort).toBeEnabled(); await expect(stop).toHaveCount(0); @@ -188,6 +189,9 @@ test('starting a ladder preserves action slots and turns only the active action const state = await readFixtureState(page); const submissionsAfterStop = state.events .filter((event) => event.type === 'order-submitted').length; + await expect(status).toHaveText( + `阶梯开多已停止 · 已挂 ${submissionsAfterStop} 笔 · 已撤 0 笔`, + ); expect(submissionsAfterStop).toBeLessThanOrEqual(submissionsBeforeStop + 1); await page.waitForTimeout(700); expect((await readFixtureState(page)).events @@ -248,7 +252,7 @@ test('a complete ladder submits the planned five native orders and restores cont const stop = panel.getByRole('button', { name: '停止开多' }); await startLong.click(); - await expect(panel.locator('#jh-binance-ladder-status')).toContainText('完成 5/5', { + await expect(panel.locator('#jh-binance-ladder-status')).toHaveText('阶梯开多已完成 · 已挂 5/5 笔', { timeout: 12_000, }); await expect(startLong).toBeEnabled();