diff --git a/docs/binance-orderbook-trade-development.md b/docs/binance-orderbook-trade-development.md index f8a88b5..c7a9e05 100644 --- a/docs/binance-orderbook-trade-development.md +++ b/docs/binance-orderbook-trade-development.md @@ -181,6 +181,8 @@ Continuous-session feedback stays in the shared ladder status row and uses `连 The active continuous-close control keeps a compact direction-specific stop action (`停止平多` / `停止平空`) on one line throughout both execution and inter-round waiting. Before the native submit control is ready, the status places `等待按钮恢复` immediately after the continuous action name. Only after the fixed cooldown actually begins may it show `1s 后继续` in that same priority position; this is a static duration label, not a countdown. `停止中`, `已停止`, `失败`, and `已中止` use the same phase slot. The button must not temporarily revert to a ladder-start action between rounds. +When a continuous close receives the observed Binance private-page response `code=90802025`, `success=false`, and the max-open-orders message, classify it as a confirmed no-submit capacity rejection. Load the complete lazy current-orders list before ranking rows; the first 50 rendered rows are not a complete candidate set. Release up to 100 current-symbol, same-close-direction Basic limit orders farthest from the current trade price, preserving the nearest orders that are most likely to complete the close. Resume the same unsubmitted ladder level so already confirmed levels are not duplicated. Permit this recovery once per round; a second confirmed capacity rejection stops the session. Do not use Cancel All, and do not touch open-direction, opposite-direction, conditional, or protection orders. If a selected row disappears because it fills while recovery is running, treat that slot as already released but count a cancellation only after the script confirms its own row-cancel action. + When selecting account-order tabs, scope to the bottom account-orders tab group. Do not globally match `当前委托` or `Open Orders`. When a pane is found through `aria-controls`, confirm it contains current-orders controls such as `隐藏其他合约` or `全撤`. Binance may reuse pane ids in unrelated tab systems. @@ -228,6 +230,7 @@ Run manual checks when behavior touches trading flow, DOM selectors, account ord - reload while the native cancel-all dialog is open; verify the next page load restores the original chart OpenOrders setting from the same-tab recovery journal - replace close ladder orders when existing reduce-only close orders occupy the closeable quantity - replace open ladder orders only by current-symbol same-direction basic open-order rows; verify no cancel-all path or conditional/protection orders are used +- reach the 200-open-order boundary during continuous close, verify the script fully loads the lazy current-order list, cancels at most 100 farthest current-symbol same-direction Basic rows, preserves nearest rows, resumes the rejected unfinished level without duplicating confirmed levels, and stops if the same capacity rejection repeats in that round - verify SVG cancel controls work when the visible cancel target has no native `.click()` method - verify account-orders tab and hide-other-symbol state are restored - verify the userscript version or live behavior after a Tampermonkey update before continuing live tests diff --git a/scripts/binance-orderbook-trade.user.js b/scripts/binance-orderbook-trade.user.js index 3b2a4d4..aef4aff 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.172 +// @version 2.7.173 // @author jackhai9 // @description 单击订单簿价格,按当前开仓/平仓 tab 自动填数量并执行下单,内置数量倍率面板 // @match https://www.binance.com/*/futures/* @@ -1625,6 +1625,41 @@ return baseAsset; } + // src/binance-orderbook-trade/core/open-order-capacity.js + function getDecimalDistance(left, right) { + const comparison = compareDecimalStrings(left, right); + if (comparison == null) throw new Error("Open-order distance input is invalid"); + return comparison >= 0 ? subtractDecimalStrings(left, right) : subtractDecimalStrings(right, left); + } + function selectFarthestOpenOrders(rows, referencePrice, limit) { + const normalizedReference = normalizeDecimalString(referencePrice); + if (!isPositiveDecimalString(normalizedReference)) { + throw new Error("Open-order reference price is invalid"); + } + if (!Number.isInteger(limit) || limit <= 0) { + throw new Error("Open-order cancellation limit is invalid"); + } + if (!Array.isArray(rows)) throw new Error("Open-order rows are invalid"); + const ranked = rows.map((row, index) => { + if (!row || typeof row.key !== "string" || row.key === "") { + throw new Error("Open-order row key is invalid"); + } + const price = normalizeDecimalString(row.price); + if (!isPositiveDecimalString(price)) throw new Error("Open-order row price is invalid"); + return { + row, + index, + distance: getDecimalDistance(price, normalizedReference) + }; + }); + ranked.sort((left, right) => { + const distanceComparison = compareDecimalStrings(left.distance, right.distance); + if (distanceComparison == null) throw new Error("Open-order distance comparison failed"); + return distanceComparison === 0 ? left.index - right.index : -distanceComparison; + }); + return ranked.slice(0, limit).map(({ row }) => row); + } + // src/binance-orderbook-trade/core/order-feedback.js function isPotentialOrderFeedbackText(text) { if (!text) return false; @@ -1666,9 +1701,13 @@ return hasPostOnlyOrder && hasMakerExecutionConflict && hasRejection; } var BINANCE_POST_ONLY_MAKER_REJECT_CODES = /* @__PURE__ */ new Set([-5022, 90805022]); + var BINANCE_MAX_OPEN_ORDERS_ERROR_CODE = 90802025; function isBinancePostOnlyMakerRejectCode(code) { return BINANCE_POST_ONLY_MAKER_REJECT_CODES.has(code); } + function isBinanceMaxOpenOrdersErrorCode(code) { + return code === BINANCE_MAX_OPEN_ORDERS_ERROR_CODE; + } function getBinanceApiErrorCode(payload) { if (!payload || typeof payload !== "object" || Array.isArray(payload)) return null; if (Number.isSafeInteger(payload.code)) return payload.code === 0 ? null : payload.code; @@ -3499,6 +3538,9 @@ const MULTIPLIER_PRESS_FEEDBACK_MS = 140; const LADDER_REPLACE_OPEN_ORDERS_CLEAR_TIMEOUT_MS = 6500; const LADDER_REPLACE_ROW_SETTLE_MS = 240; + const MAX_OPEN_ORDERS_RECOVERY_CANCEL_COUNT = 100; + const OPEN_ORDERS_LAZY_LOAD_SETTLE_MS = 700; + const OPEN_ORDERS_LAZY_LOAD_TIMEOUT_MS = 7e3; const CANCEL_OPEN_ORDERS_CLEAR_SETTLE_MS = 1200; const ROW_CANCEL_DIALOG_CLOSE_TIMEOUT_MS = 6e4; const CANCEL_DIALOG_DISCOVERY_TIMEOUT_MS = 1800; @@ -4377,15 +4419,21 @@ }); return; } + const payloadSummary = summarizeBinancePlaceOrderPayload(payload); capture.responseDiagnostics.push({ ...responseHeaders, bodyKind: "json", - payloadSummary: summarizeBinancePlaceOrderPayload(payload), + payloadSummary, errorName: null }); const code = getBinanceApiErrorCode(payload); if (code != null) { - capture.apiErrors.push({ requestUrl, code }); + capture.apiErrors.push({ + requestUrl, + code, + message: payloadSummary.message, + success: payloadSummary.success + }); return; } if (response.ok && isBinancePlaceOrderSuccessPayload(payload)) { @@ -5505,12 +5553,23 @@ if (error?.ladderFailureKind === "maker_price_conflict") return error.safeNoSubmit === true; return isBinancePostOnlyMakerRejectCode(error?.binanceCode) && error.safeNoSubmit === true; } + function isRecoverableMaxOpenOrdersFailure(plan, error, allowRecovery) { + return allowRecovery === true && plan?.spec?.mode === "CLOSE" && error?.ladderFailureKind === "max_open_orders" && isBinanceMaxOpenOrdersErrorCode(error.binanceCode) && error.safeNoSubmit === true; + } function createLadderSubmitApiError(apiErrorCode) { const error = new Error(`Maker 挂单被拒绝(错误码 ${apiErrorCode})`); error.binanceCode = apiErrorCode; error.safeNoSubmit = true; return error; } + function createLadderMaxOpenOrdersError(apiError) { + if (apiError.success !== false) throw new Error("最大挂单限制响应缺少 success=false"); + const error = new Error(`${apiError.message || "达到最大下单限制"}(错误码 ${apiError.code})`); + error.binanceCode = apiError.code; + error.ladderFailureKind = "max_open_orders"; + error.safeNoSubmit = true; + return error; + } function formatLadderRepriceDiagnostics(repriceAttempts, lastRepriceApiErrorCode) { if (repriceAttempts <= 0) return ""; const zhCode = lastRepriceApiErrorCode == null ? "" : `,错误码 ${lastRepriceApiErrorCode}`; @@ -5826,6 +5885,9 @@ if (capturedApiErrors.length === 1 && isBinancePostOnlyMakerRejectCode(capturedApiErrors[0].code)) { throw createLadderSubmitApiError(capturedApiErrors[0].code); } + if (capturedApiErrors.length === 1 && isBinanceMaxOpenOrdersErrorCode(capturedApiErrors[0].code)) { + throw createLadderMaxOpenOrdersError(capturedApiErrors[0]); + } let failureActivity = activity.status === "failure" ? activity : null; if (!failureActivity && capturedApiErrors.length > 0) { failureActivity = await waitForOrderFailureFeedback( @@ -5847,7 +5909,7 @@ const settleHint = responseObservation.settled ? `下单请求已返回,但结果未识别${responseDiagnostic ? `:${responseDiagnostic}` : ""}` : sawBusy ? "下单按钮已恢复,但下单请求仍未返回" : "下单请求仍未返回"; throw new Error(`未确认${label}成功(${settleHint}),已停止;请在当前委托和历史成交中核对`); } - async function executeLadderPlan(plan, progress, setExecutionStatus, abortSignal = null) { + async function executeLadderPlan(plan, progress, setExecutionStatus, abortSignal = null, options = null) { const priceInput = findPriceInput(); const qtyInput = findQtyInput(); if (!priceInput) { @@ -5861,6 +5923,7 @@ let consecutiveRepriceAttempts = 0; let lastRepriceApiErrorCode = null; let previousAcknowledgedInputs = null; + let maxOpenOrdersRecoveryAttempts = 0; while (done < plan.orders.length) { throwIfAborted(abortSignal); if (ladderStopRequested) break; @@ -5947,6 +6010,28 @@ }; } } catch (e) { + if (isRecoverableMaxOpenOrdersFailure( + plan, + e, + options?.allowMaxOpenOrdersRecovery + )) { + if (maxOpenOrdersRecoveryAttempts > 0) { + throw new Error("已释放挂单名额,但本轮仍达到最大下单限制,已停止"); + } + maxOpenOrdersRecoveryAttempts += 1; + const recovery = await cancelCurrentSymbolOpenOrdersForPlan( + plan, + progress, + setExecutionStatus, + abortSignal, + { strategy: "farthest_for_capacity" } + ); + throwIfAborted(abortSignal); + if (!recovery?.ok) { + throw new Error(recovery?.message || "未能释放挂单名额,已停止"); + } + continue; + } if (!isRetryableLadderMakerPriceFailure(plan, e)) throw e; if (isBinancePostOnlyMakerRejectCode(e?.binanceCode)) { lastRepriceApiErrorCode = e.binanceCode; @@ -6079,7 +6164,8 @@ actionType, progress, setExecutionStatus, - abortController.signal + abortController.signal, + { allowMaxOpenOrdersRecovery: continuousSession && spec.mode === "CLOSE" } ); return { plan, @@ -6740,18 +6826,22 @@ const cellText = cells.slice(0, 10).map((cell) => (cell.textContent || "").replace(/\s+/g, " ").trim()).join("|"); return cellText || (row.textContent || "").replace(/\s+/g, " ").trim(); } - function readCurrentSymbolOpenOrderRows(root, symbol, plan = null) { - if (!root || !symbol) return []; + function readOpenOrderRowElements(root) { return findOpenOrderRowElements(root, { isVisibleElement, isRowCancelIcon: (icon) => matchesBinancePageText( icon.getAttribute("aria-label"), BINANCE_PAGE_TEXT.accountOrders.rowCancel ) - }).map((row) => { + }); + } + function readCurrentSymbolOpenOrderRows(root, symbol, plan = null) { + if (!root || !symbol) return []; + return readOpenOrderRowElements(root).map((row) => { const cells = findOpenOrderRowCells(row); const symbolText = (cells[1]?.textContent || "").replace(/\s+/g, " ").trim(); const sideText = (cells[3]?.textContent || "").replace(/\s+/g, " ").trim(); + const price = normalizeDecimalString((cells[4]?.textContent || "").replace(/,/g, "").trim()); const qty = normalizeDecimalString((cells[5]?.textContent || "").replace(/,/g, "").match(/\d+(?:\.\d+)?/)?.[0] || ""); const cancelButton = findOpenOrderRowCancelButton(row); return { @@ -6760,11 +6850,12 @@ cells, symbolText, sideText, + price, qty, cancelButton, key: getOpenOrderRowKey(cells, row) }; - }).filter((row) => isOpenOrderRowCurrentSymbol(row.symbolText, symbol) && isOpenOrderRowForPlan(row.sideText, plan) && row.qty && isPositiveDecimalString(row.qty) && row.cancelButton); + }).filter((row) => isOpenOrderRowCurrentSymbol(row.symbolText, symbol) && isOpenOrderRowForPlan(row.sideText, plan) && row.price && isPositiveDecimalString(row.price) && row.qty && isPositiveDecimalString(row.qty) && row.cancelButton); } function isOpenOrderRowCurrentSymbol(symbolText, symbol) { const tokens = String(symbolText || "").toUpperCase().match(/[A-Z0-9_]+/g) || []; @@ -6821,6 +6912,67 @@ state = transitioned || readCurrentSymbolOpenOrderRowsState(root, symbol, plan); return state?.rows || []; } + function findOpenOrderRowsScrollContainer(root) { + const firstRow = readOpenOrderRowElements(root)[0] || null; + let candidate = firstRow?.parentElement || null; + while (candidate && root.contains(candidate)) { + if (candidate.scrollHeight > candidate.clientHeight + 2) return candidate; + if (candidate === root) break; + candidate = candidate.parentElement; + } + return null; + } + function scrollOpenOrderRowsToBottom(scrollContainer) { + scrollContainer.scrollTop = scrollContainer.scrollHeight; + scrollContainer.dispatchEvent(new Event("scroll", { bubbles: true })); + } + async function loadAllCurrentSymbolOpenOrderRows(root, symbol, plan, abortSignal = null) { + const deadline = Date.now() + OPEN_ORDERS_LAZY_LOAD_TIMEOUT_MS; + let stableEndPasses = 0; + let currentRoot = root; + while (Date.now() < deadline) { + throwIfAborted(abortSignal); + if (!isCurrentObservedSymbol(symbol)) throw new Error("加载待撤挂单时交易对已变化"); + currentRoot = getActiveOpenOrdersScope2() || currentRoot; + if (!currentRoot) throw new Error("加载待撤挂单时当前委托面板已消失"); + const beforeCount = readOpenOrderRowElements(currentRoot).length; + const scrollContainer = findOpenOrderRowsScrollContainer(currentRoot); + if (!scrollContainer) return readCurrentSymbolOpenOrderRows(currentRoot, symbol, plan); + scrollOpenOrderRowsToBottom(scrollContainer); + const remainingMs = deadline - Date.now(); + const growth = await waitForAccountOrdersState(() => { + const refreshedRoot = getActiveOpenOrdersScope2(); + if (!refreshedRoot || !isCurrentObservedSymbol(symbol)) return null; + const loadedCount = readOpenOrderRowElements(refreshedRoot).length; + return loadedCount > beforeCount ? { root: refreshedRoot, loadedCount } : null; + }, Math.min(OPEN_ORDERS_LAZY_LOAD_SETTLE_MS, remainingMs), abortSignal); + throwIfAborted(abortSignal); + currentRoot = growth?.root || getActiveOpenOrdersScope2() || currentRoot; + const afterCount = readOpenOrderRowElements(currentRoot).length; + if (afterCount > beforeCount) { + stableEndPasses = 0; + continue; + } + if (afterCount < beforeCount) { + stableEndPasses = 0; + continue; + } + const refreshedScrollContainer = findOpenOrderRowsScrollContainer(currentRoot); + const reachedBottom = !refreshedScrollContainer || refreshedScrollContainer.scrollTop + refreshedScrollContainer.clientHeight >= refreshedScrollContainer.scrollHeight - 2; + stableEndPasses = reachedBottom ? stableEndPasses + 1 : 0; + if (stableEndPasses >= 2) { + return readCurrentSymbolOpenOrderRows(currentRoot, symbol, plan); + } + } + throw new Error("当前委托完整列表 7 秒内未加载完成"); + } + function readOpenOrdersDistanceReferencePrice() { + const referencePrice = normalizeDecimalString(getLatestTradePrices()[0] || ""); + if (!referencePrice || !isPositiveDecimalString(referencePrice)) { + throw new Error("未读取到当前成交价,无法选择最远挂单"); + } + return referencePrice; + } function selectOpenOrderRowsToCancelForPlan(plan, rows, options = null) { const { allowPartial = false } = options || {}; const rowsToCancel = []; @@ -6949,6 +7101,72 @@ const activeRoot = getActiveOpenOrdersScope2(); return Boolean(activeRoot && countOpenOrderRowsByKey(activeRoot, symbol, key) < previousCount); } + async function cancelOneOpenOrderRowForPlan(row, plan, progress, setExecutionStatus, abortSignal = null) { + if (!isCurrentObservedSymbol(plan.symbol)) throw new Error("撤销待替换挂单前交易对已变化"); + const previousKeyCount = countOpenOrderRowsByKey(row.root, plan.symbol, row.key); + if (!row.cancelButton?.isConnected || !isVisibleElement(row.cancelButton)) { + if (previousKeyCount === 0) return { status: "already_removed", qty: row.qty }; + throw new Error("待替换挂单的撤单按钮已失效,已停止重新挂单"); + } + const dialogsBefore = new Set(getVisibleDialogs()); + throwIfAborted(abortSignal); + if (!clickDomTarget(row.cancelButton)) { + throw new Error("待替换挂单的撤单按钮点击失败,已停止重新挂单"); + } + waitForTradeUiMutation({ timeoutMs: 800 }); + const outcome = await waitForOpenOrderRowCancellationOutcome( + plan.symbol, + row.key, + previousKeyCount, + dialogsBefore, + abortSignal + ); + if (outcome.status === "symbol_changed") { + throw new Error("撤销待替换挂单时交易对已变化"); + } + if (outcome.status === "timeout") { + throw new Error("待替换挂单仍存在,已停止重新挂单"); + } + if (outcome.status === "dialog_open") { + const { dialog } = outcome; + setExecutionStatus( + combineLocalizedText([ + plan.spec.statusLabel, + localizedText("单行撤单确认弹窗已打开", "Single-order cancellation dialog opened") + ], ":"), + localizedText("单行撤单确认弹窗已打开", "Single-order cancellation dialog opened") + ); + const dialogClosed = await waitForDialogToClose( + dialog, + ROW_CANCEL_DIALOG_CLOSE_TIMEOUT_MS, + abortSignal + ); + if (!dialogClosed) { + const error = new Error("单行撤单确认弹窗仍未关闭,未恢复页面状态"); + error.name = "DialogNotClosedError"; + throw error; + } + if (!await waitForOpenOrderRowKeyCountBelow( + plan.symbol, + row.key, + previousKeyCount, + abortSignal + )) { + throw new Error("待替换挂单仍存在,已停止重新挂单"); + } + } + throwIfAborted(abortSignal); + if (!await confirmOpenOrderRowKeyCountBelow( + plan.symbol, + row.key, + previousKeyCount, + abortSignal + )) { + throw new Error("待替换挂单状态未稳定,已停止重新挂单"); + } + recordLadderCancelledOrder(progress); + return { status: "cancelled", qty: row.qty }; + } async function cancelOpenOrderRowsForPlan(root, plan, progress, setExecutionStatus, abortSignal = null) { let cancelQty = "0"; let currentRoot = root; @@ -6968,71 +7186,55 @@ throw new Error("同向可撤挂单数量不足,已停止重新挂单"); } currentRoot = row.root || currentRoot; - const previousKeyCount = countOpenOrderRowsByKey(row.root, plan.symbol, row.key); - if (!row.cancelButton?.isConnected || !isVisibleElement(row.cancelButton)) { - if (previousKeyCount === 0) continue; - throw new Error("待替换挂单的撤单按钮已失效,已停止重新挂单"); - } - const dialogsBefore = new Set(getVisibleDialogs()); - throwIfAborted(abortSignal); - if (!clickDomTarget(row.cancelButton)) { - throw new Error("待替换挂单的撤单按钮点击失败,已停止重新挂单"); - } - waitForTradeUiMutation({ timeoutMs: 800 }); - const outcome = await waitForOpenOrderRowCancellationOutcome( - plan.symbol, - row.key, - previousKeyCount, - dialogsBefore, + const result = await cancelOneOpenOrderRowForPlan( + row, + plan, + progress, + setExecutionStatus, abortSignal ); - if (outcome.status === "symbol_changed") { - throw new Error("撤销待替换挂单时交易对已变化"); - } - if (outcome.status === "timeout") { - throw new Error("待替换挂单仍存在,已停止重新挂单"); - } - if (outcome.status === "dialog_open") { - const { dialog } = outcome; - setExecutionStatus( - combineLocalizedText([ - plan.spec.statusLabel, - localizedText("单行撤单确认弹窗已打开", "Single-order cancellation dialog opened") - ], ":"), - localizedText("单行撤单确认弹窗已打开", "Single-order cancellation dialog opened") - ); - const dialogClosed = await waitForDialogToClose( - dialog, - ROW_CANCEL_DIALOG_CLOSE_TIMEOUT_MS, - abortSignal - ); - if (!dialogClosed) { - const error = new Error("单行撤单确认弹窗仍未关闭,未恢复页面状态"); - error.name = "DialogNotClosedError"; - throw error; - } - if (!await waitForOpenOrderRowKeyCountBelow( + if (result.status === "already_removed") continue; + cancelQty = addDecimalStrings(cancelQty, result.qty); + } + return { ok: true, cancelQty }; + } + async function cancelFarthestOpenOrderRowsForPlan(root, plan, targets, progress, setExecutionStatus, abortSignal = null) { + let releasedCount = 0; + let cancelledCount = 0; + let currentRoot = root; + for (const target of targets) { + throwIfAborted(abortSignal); + if (!isCurrentObservedSymbol(plan.symbol)) throw new Error("释放挂单名额时交易对已变化"); + currentRoot = getActiveOpenOrdersScope2() || currentRoot; + let row = readCurrentSymbolOpenOrderRows(currentRoot, plan.symbol, plan).find((candidate) => candidate.key === target.key) || null; + if (!row) { + const completeRows = await loadAllCurrentSymbolOpenOrderRows( + currentRoot, plan.symbol, - row.key, - previousKeyCount, + plan, abortSignal - )) { - throw new Error("待替换挂单仍存在,已停止重新挂单"); - } + ); + currentRoot = getActiveOpenOrdersScope2() || currentRoot; + row = completeRows.find((candidate) => candidate.key === target.key) || null; } - throwIfAborted(abortSignal); - if (!await confirmOpenOrderRowKeyCountBelow( - plan.symbol, - row.key, - previousKeyCount, - abortSignal - )) { - throw new Error("待替换挂单状态未稳定,已停止重新挂单"); + if (row) { + const result = await cancelOneOpenOrderRowForPlan( + row, + plan, + progress, + setExecutionStatus, + abortSignal + ); + if (result.status === "cancelled") cancelledCount += 1; } - cancelQty = addDecimalStrings(cancelQty, row.qty); - recordLadderCancelledOrder(progress); + releasedCount += 1; + const detail = localizedText( + `释放挂单名额 ${releasedCount}/${targets.length}`, + `Freeing order slots ${releasedCount}/${targets.length}` + ); + setExecutionStatus(combineLocalizedText([plan.spec.statusLabel, detail], ":"), detail); } - return { ok: true, cancelQty }; + return { ok: true, releasedCount, cancelledCount }; } async function setHideOtherSymbolChecked(root, desiredChecked, symbol = getCurrentSymbol(), abortSignal = null) { throwIfAborted(abortSignal); @@ -7703,7 +7905,7 @@ }, CANCEL_NO_ORDERS_FEEDBACK_MS); scheduleRenderPanel(); } - async function cancelCurrentSymbolOpenOrdersForPlan(plan, progress, setExecutionStatus, abortSignal = null) { + async function cancelCurrentSymbolOpenOrdersForPlan(plan, progress, setExecutionStatus, abortSignal = null, options = null) { throwIfAborted(abortSignal); const setPlanStepStatus = (message) => { const localizedMessage = localizeKnownUiStatus(message); @@ -7718,7 +7920,11 @@ setPlanStepStatus(message); return { ok: false, status: "symbol_changed", message }; } + const capacityRecovery = options?.strategy === "farthest_for_capacity"; const previousAccountOrdersTabIdentity = getAccountOrdersTabIdentity2(findSelectedAccountOrdersTab2()); + const previousOpenOrdersScrollTop = findOpenOrderRowsScrollContainer( + getActiveOpenOrdersScope2() + )?.scrollTop ?? null; let openOrdersScope = null; let previousOpenOrdersSubTabIdentity = null; let symbolFilterOriginalChecked = null; @@ -7785,7 +7991,12 @@ setPlanStepStatus(message); return { ok: false, status: "scope_not_found", message }; } - const rows = await waitForCurrentSymbolOpenOrderRows( + const rows = capacityRecovery ? await loadAllCurrentSymbolOpenOrderRows( + openOrdersScope, + symbol, + plan, + abortSignal + ) : await waitForCurrentSymbolOpenOrderRows( openOrdersScope, symbol, plan, @@ -7798,6 +8009,40 @@ setPlanStepStatus(message); return { ok: false, status: "rows_not_found", message }; } + if (capacityRecovery) { + const referencePrice = readOpenOrdersDistanceReferencePrice(); + const targets = selectFarthestOpenOrders( + rows, + referencePrice, + MAX_OPEN_ORDERS_RECOVERY_CANCEL_COUNT + ); + const preparingDetail = localizedText( + `达到挂单上限,准备撤销距当前价最远的 ${targets.length} 笔同向挂单`, + `Order limit reached; cancelling the ${targets.length} farthest same-direction orders` + ); + setExecutionStatus( + combineLocalizedText([plan.spec.statusLabel, preparingDetail], ":"), + preparingDetail + ); + const result = await cancelFarthestOpenOrderRowsForPlan( + openOrdersScope, + plan, + targets, + progress, + setExecutionStatus, + abortSignal + ); + throwIfAborted(abortSignal); + const completedDetail = localizedText( + `已释放 ${result.releasedCount} 个挂单名额,继续本轮`, + `Freed ${result.releasedCount} order slots; continuing this round` + ); + setExecutionStatus( + combineLocalizedText([plan.spec.statusLabel, completedDetail], ":"), + completedDetail + ); + return { ok: true, status: "capacity_released", ...result }; + } const rowsToCancel = selectOpenOrderRowsToCancelForPlan(plan, rows); if (!rowsToCancel.length) { const message = "未选中待替换挂单"; @@ -7832,6 +8077,16 @@ if (previousOpenOrdersSubTabIdentity) { await restoreOpenOrdersSubTab(previousOpenOrdersSubTabIdentity, symbol); } + if (previousOpenOrdersScrollTop !== null) { + openOrdersScope = await waitForActiveOpenOrdersScope(); + const scrollContainer = findOpenOrderRowsScrollContainer(openOrdersScope); + if (scrollContainer) { + scrollContainer.scrollTop = Math.min( + previousOpenOrdersScrollTop, + scrollContainer.scrollHeight + ); + } + } if (isCurrentObservedSymbol(symbol)) { await restoreAccountOrdersTab(previousAccountOrdersTabIdentity, symbol); } @@ -7897,7 +8152,7 @@ precision: plan.precision }; } - async function runLadderPlanWithOpenOrderReplacement(actionType, progress, setExecutionStatus, abortSignal = null) { + async function runLadderPlanWithOpenOrderReplacement(actionType, progress, setExecutionStatus, abortSignal = null, options = null) { let replacementContext = null; for (let attempt = 0; attempt < 2; attempt += 1) { throwIfAborted(abortSignal); @@ -7911,7 +8166,8 @@ plan, progress, setExecutionStatus, - abortSignal + abortSignal, + options ); return { plan, ...execution }; } catch (e) { diff --git a/src/binance-orderbook-trade/core/open-order-capacity.js b/src/binance-orderbook-trade/core/open-order-capacity.js new file mode 100644 index 0000000..0f1c759 --- /dev/null +++ b/src/binance-orderbook-trade/core/open-order-capacity.js @@ -0,0 +1,50 @@ +import { + compareDecimalStrings, + isPositiveDecimalString, + normalizeDecimalString, + subtractDecimalStrings, +} from './decimal.js'; + +function getDecimalDistance(left, right) { + const comparison = compareDecimalStrings(left, right); + if (comparison == null) throw new Error('Open-order distance input is invalid'); + return comparison >= 0 + ? subtractDecimalStrings(left, right) + : subtractDecimalStrings(right, left); +} + +/** + * Returns the farthest orders without mutating the DOM-derived input order. + * Equal distances retain their original list order so duplicate Binance rows + * can still be cancelled one at a time through their occurrence count. + */ +export function selectFarthestOpenOrders(rows, referencePrice, limit) { + const normalizedReference = normalizeDecimalString(referencePrice); + if (!isPositiveDecimalString(normalizedReference)) { + throw new Error('Open-order reference price is invalid'); + } + if (!Number.isInteger(limit) || limit <= 0) { + throw new Error('Open-order cancellation limit is invalid'); + } + if (!Array.isArray(rows)) throw new Error('Open-order rows are invalid'); + + const ranked = rows.map((row, index) => { + if (!row || typeof row.key !== 'string' || row.key === '') { + throw new Error('Open-order row key is invalid'); + } + const price = normalizeDecimalString(row.price); + if (!isPositiveDecimalString(price)) throw new Error('Open-order row price is invalid'); + return { + row, + index, + distance: getDecimalDistance(price, normalizedReference), + }; + }); + + ranked.sort((left, right) => { + const distanceComparison = compareDecimalStrings(left.distance, right.distance); + if (distanceComparison == null) throw new Error('Open-order distance comparison failed'); + return distanceComparison === 0 ? left.index - right.index : -distanceComparison; + }); + return ranked.slice(0, limit).map(({ row }) => row); +} diff --git a/src/binance-orderbook-trade/core/order-feedback.js b/src/binance-orderbook-trade/core/order-feedback.js index 3d6efc4..e7919c2 100644 --- a/src/binance-orderbook-trade/core/order-feedback.js +++ b/src/binance-orderbook-trade/core/order-feedback.js @@ -85,11 +85,16 @@ export function isPostOnlyMakerRejectionFeedback(text) { } const BINANCE_POST_ONLY_MAKER_REJECT_CODES = new Set([-5022, 90805022]); +const BINANCE_MAX_OPEN_ORDERS_ERROR_CODE = 90802025; export function isBinancePostOnlyMakerRejectCode(code) { return BINANCE_POST_ONLY_MAKER_REJECT_CODES.has(code); } +export function isBinanceMaxOpenOrdersErrorCode(code) { + return code === BINANCE_MAX_OPEN_ORDERS_ERROR_CODE; +} + export function getBinanceApiErrorCode(payload) { if (!payload || typeof payload !== 'object' || Array.isArray(payload)) return null; if (Number.isSafeInteger(payload.code)) return payload.code === 0 ? null : payload.code; diff --git a/src/binance-orderbook-trade/index.user.js b/src/binance-orderbook-trade/index.user.js index a1092b4..623ab11 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.172 +// @version 2.7.173 // @author jackhai9 // @description 单击订单簿价格,按当前开仓/平仓 tab 自动填数量并执行下单,内置数量倍率面板 // @match https://www.binance.com/*/futures/* @@ -117,10 +117,12 @@ import { waitForPromiseOrAbort, } from './core/abort.js'; import { formatStatusBaseAsset } from './core/status-symbol.js'; +import { selectFarthestOpenOrders } from './core/open-order-capacity.js'; import { evaluateOrderSubmitAcknowledgement, formatBinancePlaceOrderResponseDiagnostic, getBinanceApiErrorCode, + isBinanceMaxOpenOrdersErrorCode, isBinancePlaceOrderSuccessPayload, isBinancePostOnlyMakerRejectCode, isOpenLadderOpenOrdersCapacityFeedback, @@ -312,6 +314,9 @@ import { showUsdtRebalanceDialog } from './dom/usdt-rebalance-dialog.js'; const MULTIPLIER_PRESS_FEEDBACK_MS = 140; const LADDER_REPLACE_OPEN_ORDERS_CLEAR_TIMEOUT_MS = 6500; const LADDER_REPLACE_ROW_SETTLE_MS = 240; + const MAX_OPEN_ORDERS_RECOVERY_CANCEL_COUNT = 100; + const OPEN_ORDERS_LAZY_LOAD_SETTLE_MS = 700; + const OPEN_ORDERS_LAZY_LOAD_TIMEOUT_MS = 7000; const CANCEL_OPEN_ORDERS_CLEAR_SETTLE_MS = 1200; const ROW_CANCEL_DIALOG_CLOSE_TIMEOUT_MS = 60000; const CANCEL_DIALOG_DISCOVERY_TIMEOUT_MS = 1800; @@ -1365,15 +1370,21 @@ import { showUsdtRebalanceDialog } from './dom/usdt-rebalance-dialog.js'; }); return; } + const payloadSummary = summarizeBinancePlaceOrderPayload(payload); capture.responseDiagnostics.push({ ...responseHeaders, bodyKind: 'json', - payloadSummary: summarizeBinancePlaceOrderPayload(payload), + payloadSummary, errorName: null, }); const code = getBinanceApiErrorCode(payload); if (code != null) { - capture.apiErrors.push({ requestUrl, code }); + capture.apiErrors.push({ + requestUrl, + code, + message: payloadSummary.message, + success: payloadSummary.success, + }); return; } if (response.ok && isBinancePlaceOrderSuccessPayload(payload)) { @@ -2704,6 +2715,16 @@ import { showUsdtRebalanceDialog } from './dom/usdt-rebalance-dialog.js'; return isBinancePostOnlyMakerRejectCode(error?.binanceCode) && error.safeNoSubmit === true; } + function isRecoverableMaxOpenOrdersFailure(plan, error, allowRecovery) { + return ( + allowRecovery === true + && plan?.spec?.mode === 'CLOSE' + && error?.ladderFailureKind === 'max_open_orders' + && isBinanceMaxOpenOrdersErrorCode(error.binanceCode) + && error.safeNoSubmit === true + ); + } + function createLadderSubmitApiError(apiErrorCode) { const error = new Error(`Maker 挂单被拒绝(错误码 ${apiErrorCode})`); error.binanceCode = apiErrorCode; @@ -2711,6 +2732,15 @@ import { showUsdtRebalanceDialog } from './dom/usdt-rebalance-dialog.js'; return error; } + function createLadderMaxOpenOrdersError(apiError) { + if (apiError.success !== false) throw new Error('最大挂单限制响应缺少 success=false'); + const error = new Error(`${apiError.message || '达到最大下单限制'}(错误码 ${apiError.code})`); + error.binanceCode = apiError.code; + error.ladderFailureKind = 'max_open_orders'; + error.safeNoSubmit = true; + return error; + } + function formatLadderRepriceDiagnostics(repriceAttempts, lastRepriceApiErrorCode) { if (repriceAttempts <= 0) return ''; const zhCode = lastRepriceApiErrorCode == null ? '' : `,错误码 ${lastRepriceApiErrorCode}`; @@ -3090,6 +3120,12 @@ import { showUsdtRebalanceDialog } from './dom/usdt-rebalance-dialog.js'; ) { throw createLadderSubmitApiError(capturedApiErrors[0].code); } + if ( + capturedApiErrors.length === 1 + && isBinanceMaxOpenOrdersErrorCode(capturedApiErrors[0].code) + ) { + throw createLadderMaxOpenOrdersError(capturedApiErrors[0]); + } let failureActivity = activity.status === 'failure' ? activity : null; if (!failureActivity && capturedApiErrors.length > 0) { failureActivity = await waitForOrderFailureFeedback( @@ -3126,7 +3162,13 @@ import { showUsdtRebalanceDialog } from './dom/usdt-rebalance-dialog.js'; throw new Error(`未确认${label}成功(${settleHint}),已停止;请在当前委托和历史成交中核对`); } - async function executeLadderPlan(plan, progress, setExecutionStatus, abortSignal = null) { + async function executeLadderPlan( + plan, + progress, + setExecutionStatus, + abortSignal = null, + options = null, + ) { const priceInput = findPriceInput(); const qtyInput = findQtyInput(); if (!priceInput) { @@ -3141,6 +3183,7 @@ import { showUsdtRebalanceDialog } from './dom/usdt-rebalance-dialog.js'; let consecutiveRepriceAttempts = 0; let lastRepriceApiErrorCode = null; let previousAcknowledgedInputs = null; + let maxOpenOrdersRecoveryAttempts = 0; while (done < plan.orders.length) { throwIfAborted(abortSignal); if (ladderStopRequested) break; @@ -3232,6 +3275,28 @@ import { showUsdtRebalanceDialog } from './dom/usdt-rebalance-dialog.js'; }; } } catch (e) { + if (isRecoverableMaxOpenOrdersFailure( + plan, + e, + options?.allowMaxOpenOrdersRecovery, + )) { + if (maxOpenOrdersRecoveryAttempts > 0) { + throw new Error('已释放挂单名额,但本轮仍达到最大下单限制,已停止'); + } + maxOpenOrdersRecoveryAttempts += 1; + const recovery = await cancelCurrentSymbolOpenOrdersForPlan( + plan, + progress, + setExecutionStatus, + abortSignal, + { strategy: 'farthest_for_capacity' }, + ); + throwIfAborted(abortSignal); + if (!recovery?.ok) { + throw new Error(recovery?.message || '未能释放挂单名额,已停止'); + } + continue; + } if (!isRetryableLadderMakerPriceFailure(plan, e)) throw e; if (isBinancePostOnlyMakerRejectCode(e?.binanceCode)) { lastRepriceApiErrorCode = e.binanceCode; @@ -3370,6 +3435,7 @@ import { showUsdtRebalanceDialog } from './dom/usdt-rebalance-dialog.js'; progress, setExecutionStatus, abortController.signal, + { allowMaxOpenOrdersRecovery: continuousSession && spec.mode === 'CLOSE' }, ); return { plan, @@ -4138,19 +4204,24 @@ import { showUsdtRebalanceDialog } from './dom/usdt-rebalance-dialog.js'; return cellText || (row.textContent || '').replace(/\s+/g, ' ').trim(); } - function readCurrentSymbolOpenOrderRows(root, symbol, plan = null) { - if (!root || !symbol) return []; + function readOpenOrderRowElements(root) { return findOpenOrderRowElements(root, { isVisibleElement, isRowCancelIcon: (icon) => matchesBinancePageText( icon.getAttribute('aria-label'), BINANCE_PAGE_TEXT.accountOrders.rowCancel, ), - }) + }); + } + + function readCurrentSymbolOpenOrderRows(root, symbol, plan = null) { + if (!root || !symbol) return []; + return readOpenOrderRowElements(root) .map((row) => { const cells = findOpenOrderRowCells(row); const symbolText = (cells[1]?.textContent || '').replace(/\s+/g, ' ').trim(); const sideText = (cells[3]?.textContent || '').replace(/\s+/g, ' ').trim(); + const price = normalizeDecimalString((cells[4]?.textContent || '').replace(/,/g, '').trim()); const qty = normalizeDecimalString((cells[5]?.textContent || '').replace(/,/g, '').match(/\d+(?:\.\d+)?/)?.[0] || ''); const cancelButton = findOpenOrderRowCancelButton(row); return { @@ -4159,6 +4230,7 @@ import { showUsdtRebalanceDialog } from './dom/usdt-rebalance-dialog.js'; cells, symbolText, sideText, + price, qty, cancelButton, key: getOpenOrderRowKey(cells, row), @@ -4167,6 +4239,8 @@ import { showUsdtRebalanceDialog } from './dom/usdt-rebalance-dialog.js'; .filter((row) => ( isOpenOrderRowCurrentSymbol(row.symbolText, symbol) && isOpenOrderRowForPlan(row.sideText, plan) && + row.price && + isPositiveDecimalString(row.price) && row.qty && isPositiveDecimalString(row.qty) && row.cancelButton @@ -4240,6 +4314,84 @@ import { showUsdtRebalanceDialog } from './dom/usdt-rebalance-dialog.js'; return state?.rows || []; } + function findOpenOrderRowsScrollContainer(root) { + const firstRow = readOpenOrderRowElements(root)[0] || null; + let candidate = firstRow?.parentElement || null; + while (candidate && root.contains(candidate)) { + if (candidate.scrollHeight > candidate.clientHeight + 2) return candidate; + if (candidate === root) break; + candidate = candidate.parentElement; + } + return null; + } + + function scrollOpenOrderRowsToBottom(scrollContainer) { + scrollContainer.scrollTop = scrollContainer.scrollHeight; + scrollContainer.dispatchEvent(new Event('scroll', { bubbles: true })); + } + + async function loadAllCurrentSymbolOpenOrderRows( + root, + symbol, + plan, + abortSignal = null, + ) { + const deadline = Date.now() + OPEN_ORDERS_LAZY_LOAD_TIMEOUT_MS; + let stableEndPasses = 0; + let currentRoot = root; + + while (Date.now() < deadline) { + throwIfAborted(abortSignal); + if (!isCurrentObservedSymbol(symbol)) throw new Error('加载待撤挂单时交易对已变化'); + currentRoot = getActiveOpenOrdersScope() || currentRoot; + if (!currentRoot) throw new Error('加载待撤挂单时当前委托面板已消失'); + + const beforeCount = readOpenOrderRowElements(currentRoot).length; + const scrollContainer = findOpenOrderRowsScrollContainer(currentRoot); + if (!scrollContainer) return readCurrentSymbolOpenOrderRows(currentRoot, symbol, plan); + + scrollOpenOrderRowsToBottom(scrollContainer); + const remainingMs = deadline - Date.now(); + const growth = await waitForAccountOrdersState(() => { + const refreshedRoot = getActiveOpenOrdersScope(); + if (!refreshedRoot || !isCurrentObservedSymbol(symbol)) return null; + const loadedCount = readOpenOrderRowElements(refreshedRoot).length; + return loadedCount > beforeCount ? { root: refreshedRoot, loadedCount } : null; + }, Math.min(OPEN_ORDERS_LAZY_LOAD_SETTLE_MS, remainingMs), abortSignal); + + throwIfAborted(abortSignal); + currentRoot = growth?.root || getActiveOpenOrdersScope() || currentRoot; + const afterCount = readOpenOrderRowElements(currentRoot).length; + if (afterCount > beforeCount) { + stableEndPasses = 0; + continue; + } + if (afterCount < beforeCount) { + stableEndPasses = 0; + continue; + } + + const refreshedScrollContainer = findOpenOrderRowsScrollContainer(currentRoot); + const reachedBottom = !refreshedScrollContainer || ( + refreshedScrollContainer.scrollTop + refreshedScrollContainer.clientHeight + >= refreshedScrollContainer.scrollHeight - 2 + ); + stableEndPasses = reachedBottom ? stableEndPasses + 1 : 0; + if (stableEndPasses >= 2) { + return readCurrentSymbolOpenOrderRows(currentRoot, symbol, plan); + } + } + throw new Error('当前委托完整列表 7 秒内未加载完成'); + } + + function readOpenOrdersDistanceReferencePrice() { + const referencePrice = normalizeDecimalString(getLatestTradePrices()[0] || ''); + if (!referencePrice || !isPositiveDecimalString(referencePrice)) { + throw new Error('未读取到当前成交价,无法选择最远挂单'); + } + return referencePrice; + } + function selectOpenOrderRowsToCancelForPlan(plan, rows, options = null) { const { allowPartial = false } = options || {}; const rowsToCancel = []; @@ -4397,6 +4549,79 @@ import { showUsdtRebalanceDialog } from './dom/usdt-rebalance-dialog.js'; return Boolean(activeRoot && countOpenOrderRowsByKey(activeRoot, symbol, key) < previousCount); } + async function cancelOneOpenOrderRowForPlan( + row, + plan, + progress, + setExecutionStatus, + abortSignal = null, + ) { + if (!isCurrentObservedSymbol(plan.symbol)) throw new Error('撤销待替换挂单前交易对已变化'); + const previousKeyCount = countOpenOrderRowsByKey(row.root, plan.symbol, row.key); + if (!row.cancelButton?.isConnected || !isVisibleElement(row.cancelButton)) { + if (previousKeyCount === 0) return { status: 'already_removed', qty: row.qty }; + throw new Error('待替换挂单的撤单按钮已失效,已停止重新挂单'); + } + const dialogsBefore = new Set(getVisibleDialogs()); + throwIfAborted(abortSignal); + if (!clickDomTarget(row.cancelButton)) { + throw new Error('待替换挂单的撤单按钮点击失败,已停止重新挂单'); + } + waitForTradeUiMutation({ timeoutMs: 800 }); + const outcome = await waitForOpenOrderRowCancellationOutcome( + plan.symbol, + row.key, + previousKeyCount, + dialogsBefore, + abortSignal, + ); + if (outcome.status === 'symbol_changed') { + throw new Error('撤销待替换挂单时交易对已变化'); + } + if (outcome.status === 'timeout') { + throw new Error('待替换挂单仍存在,已停止重新挂单'); + } + if (outcome.status === 'dialog_open') { + const { dialog } = outcome; + setExecutionStatus( + combineLocalizedText([ + plan.spec.statusLabel, + localizedText('单行撤单确认弹窗已打开', 'Single-order cancellation dialog opened'), + ], ':'), + localizedText('单行撤单确认弹窗已打开', 'Single-order cancellation dialog opened'), + ); + const dialogClosed = await waitForDialogToClose( + dialog, + ROW_CANCEL_DIALOG_CLOSE_TIMEOUT_MS, + abortSignal, + ); + if (!dialogClosed) { + const error = new Error('单行撤单确认弹窗仍未关闭,未恢复页面状态'); + error.name = 'DialogNotClosedError'; + throw error; + } + if (!(await waitForOpenOrderRowKeyCountBelow( + plan.symbol, + row.key, + previousKeyCount, + abortSignal, + ))) { + throw new Error('待替换挂单仍存在,已停止重新挂单'); + } + } + throwIfAborted(abortSignal); + if (!(await confirmOpenOrderRowKeyCountBelow( + plan.symbol, + row.key, + previousKeyCount, + abortSignal, + ))) { + throw new Error('待替换挂单状态未稳定,已停止重新挂单'); + } + recordLadderCancelledOrder(progress); + return { status: 'cancelled', qty: row.qty }; + } + async function cancelOpenOrderRowsForPlan( root, plan, @@ -4422,71 +4647,66 @@ import { showUsdtRebalanceDialog } from './dom/usdt-rebalance-dialog.js'; throw new Error('同向可撤挂单数量不足,已停止重新挂单'); } currentRoot = row.root || currentRoot; - const previousKeyCount = countOpenOrderRowsByKey(row.root, plan.symbol, row.key); - if (!row.cancelButton?.isConnected || !isVisibleElement(row.cancelButton)) { - if (previousKeyCount === 0) continue; - throw new Error('待替换挂单的撤单按钮已失效,已停止重新挂单'); - } - const dialogsBefore = new Set(getVisibleDialogs()); - throwIfAborted(abortSignal); - if (!clickDomTarget(row.cancelButton)) { - throw new Error('待替换挂单的撤单按钮点击失败,已停止重新挂单'); - } - waitForTradeUiMutation({ timeoutMs: 800 }); - const outcome = await waitForOpenOrderRowCancellationOutcome( - plan.symbol, - row.key, - previousKeyCount, - dialogsBefore, + const result = await cancelOneOpenOrderRowForPlan( + row, + plan, + progress, + setExecutionStatus, abortSignal, ); - if (outcome.status === 'symbol_changed') { - throw new Error('撤销待替换挂单时交易对已变化'); - } - if (outcome.status === 'timeout') { - throw new Error('待替换挂单仍存在,已停止重新挂单'); - } - if (outcome.status === 'dialog_open') { - const { dialog } = outcome; - setExecutionStatus( - combineLocalizedText([ - plan.spec.statusLabel, - localizedText('单行撤单确认弹窗已打开', 'Single-order cancellation dialog opened'), - ], ':'), - localizedText('单行撤单确认弹窗已打开', 'Single-order cancellation dialog opened'), - ); - const dialogClosed = await waitForDialogToClose( - dialog, - ROW_CANCEL_DIALOG_CLOSE_TIMEOUT_MS, - abortSignal, - ); - if (!dialogClosed) { - const error = new Error('单行撤单确认弹窗仍未关闭,未恢复页面状态'); - error.name = 'DialogNotClosedError'; - throw error; - } - if (!(await waitForOpenOrderRowKeyCountBelow( + if (result.status === 'already_removed') continue; + cancelQty = addDecimalStrings(cancelQty, result.qty); + } + return { ok: true, cancelQty }; + } + + async function cancelFarthestOpenOrderRowsForPlan( + root, + plan, + targets, + progress, + setExecutionStatus, + abortSignal = null, + ) { + let releasedCount = 0; + let cancelledCount = 0; + let currentRoot = root; + + for (const target of targets) { + throwIfAborted(abortSignal); + if (!isCurrentObservedSymbol(plan.symbol)) throw new Error('释放挂单名额时交易对已变化'); + currentRoot = getActiveOpenOrdersScope() || currentRoot; + let row = readCurrentSymbolOpenOrderRows(currentRoot, plan.symbol, plan) + .find((candidate) => candidate.key === target.key) || null; + if (!row) { + const completeRows = await loadAllCurrentSymbolOpenOrderRows( + currentRoot, plan.symbol, - row.key, - previousKeyCount, + plan, abortSignal, - ))) { - throw new Error('待替换挂单仍存在,已停止重新挂单'); - } + ); + currentRoot = getActiveOpenOrdersScope() || currentRoot; + row = completeRows.find((candidate) => candidate.key === target.key) || null; } - throwIfAborted(abortSignal); - if (!(await confirmOpenOrderRowKeyCountBelow( - plan.symbol, - row.key, - previousKeyCount, - abortSignal, - ))) { - throw new Error('待替换挂单状态未稳定,已停止重新挂单'); + + if (row) { + const result = await cancelOneOpenOrderRowForPlan( + row, + plan, + progress, + setExecutionStatus, + abortSignal, + ); + if (result.status === 'cancelled') cancelledCount += 1; } - cancelQty = addDecimalStrings(cancelQty, row.qty); - recordLadderCancelledOrder(progress); + releasedCount += 1; + const detail = localizedText( + `释放挂单名额 ${releasedCount}/${targets.length}`, + `Freeing order slots ${releasedCount}/${targets.length}`, + ); + setExecutionStatus(combineLocalizedText([plan.spec.statusLabel, detail], ':'), detail); } - return { ok: true, cancelQty }; + return { ok: true, releasedCount, cancelledCount }; } async function setHideOtherSymbolChecked( @@ -5258,6 +5478,7 @@ import { showUsdtRebalanceDialog } from './dom/usdt-rebalance-dialog.js'; progress, setExecutionStatus, abortSignal = null, + options = null, ) { throwIfAborted(abortSignal); const setPlanStepStatus = (message) => { @@ -5274,7 +5495,11 @@ import { showUsdtRebalanceDialog } from './dom/usdt-rebalance-dialog.js'; return { ok: false, status: 'symbol_changed', message }; } + const capacityRecovery = options?.strategy === 'farthest_for_capacity'; const previousAccountOrdersTabIdentity = getAccountOrdersTabIdentity(findSelectedAccountOrdersTab()); + const previousOpenOrdersScrollTop = findOpenOrderRowsScrollContainer( + getActiveOpenOrdersScope(), + )?.scrollTop ?? null; let openOrdersScope = null; let previousOpenOrdersSubTabIdentity = null; let symbolFilterOriginalChecked = null; @@ -5348,12 +5573,19 @@ import { showUsdtRebalanceDialog } from './dom/usdt-rebalance-dialog.js'; return { ok: false, status: 'scope_not_found', message }; } - const rows = await waitForCurrentSymbolOpenOrderRows( - openOrdersScope, - symbol, - plan, - abortSignal, - ); + const rows = capacityRecovery + ? await loadAllCurrentSymbolOpenOrderRows( + openOrdersScope, + symbol, + plan, + abortSignal, + ) + : await waitForCurrentSymbolOpenOrderRows( + openOrdersScope, + symbol, + plan, + abortSignal, + ); throwIfAborted(abortSignal); if (!rows.length) { const directionLabel = getPlanDirectionLabel(plan); @@ -5362,6 +5594,41 @@ import { showUsdtRebalanceDialog } from './dom/usdt-rebalance-dialog.js'; return { ok: false, status: 'rows_not_found', message }; } + if (capacityRecovery) { + const referencePrice = readOpenOrdersDistanceReferencePrice(); + const targets = selectFarthestOpenOrders( + rows, + referencePrice, + MAX_OPEN_ORDERS_RECOVERY_CANCEL_COUNT, + ); + const preparingDetail = localizedText( + `达到挂单上限,准备撤销距当前价最远的 ${targets.length} 笔同向挂单`, + `Order limit reached; cancelling the ${targets.length} farthest same-direction orders`, + ); + setExecutionStatus( + combineLocalizedText([plan.spec.statusLabel, preparingDetail], ':'), + preparingDetail, + ); + const result = await cancelFarthestOpenOrderRowsForPlan( + openOrdersScope, + plan, + targets, + progress, + setExecutionStatus, + abortSignal, + ); + throwIfAborted(abortSignal); + const completedDetail = localizedText( + `已释放 ${result.releasedCount} 个挂单名额,继续本轮`, + `Freed ${result.releasedCount} order slots; continuing this round`, + ); + setExecutionStatus( + combineLocalizedText([plan.spec.statusLabel, completedDetail], ':'), + completedDetail, + ); + return { ok: true, status: 'capacity_released', ...result }; + } + const rowsToCancel = selectOpenOrderRowsToCancelForPlan(plan, rows); if (!rowsToCancel.length) { const message = '未选中待替换挂单'; @@ -5399,6 +5666,16 @@ import { showUsdtRebalanceDialog } from './dom/usdt-rebalance-dialog.js'; if (previousOpenOrdersSubTabIdentity) { await restoreOpenOrdersSubTab(previousOpenOrdersSubTabIdentity, symbol); } + if (previousOpenOrdersScrollTop !== null) { + openOrdersScope = await waitForActiveOpenOrdersScope(); + const scrollContainer = findOpenOrderRowsScrollContainer(openOrdersScope); + if (scrollContainer) { + scrollContainer.scrollTop = Math.min( + previousOpenOrdersScrollTop, + scrollContainer.scrollHeight, + ); + } + } if (isCurrentObservedSymbol(symbol)) { await restoreAccountOrdersTab(previousAccountOrdersTabIdentity, symbol); } @@ -5493,6 +5770,7 @@ import { showUsdtRebalanceDialog } from './dom/usdt-rebalance-dialog.js'; progress, setExecutionStatus, abortSignal = null, + options = null, ) { let replacementContext = null; for (let attempt = 0; attempt < 2; attempt += 1) { @@ -5508,6 +5786,7 @@ import { showUsdtRebalanceDialog } from './dom/usdt-rebalance-dialog.js'; progress, setExecutionStatus, abortSignal, + options, ); return { plan, ...execution }; } catch (e) { diff --git a/test/unit/binance-orderbook-trade/open-order-capacity.test.js b/test/unit/binance-orderbook-trade/open-order-capacity.test.js new file mode 100644 index 0000000..72089bb --- /dev/null +++ b/test/unit/binance-orderbook-trade/open-order-capacity.test.js @@ -0,0 +1,58 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; + +import { selectFarthestOpenOrders } from '../../../src/binance-orderbook-trade/core/open-order-capacity.js'; + +test('selects the farthest open orders from the live reference price', () => { + const rows = [ + { key: 'near-high', price: '0.1610' }, + { key: 'far-high', price: '0.18596' }, + { key: 'far-low', price: '0.1400' }, + { key: 'near-low', price: '0.1605' }, + ]; + + assert.deepEqual( + selectFarthestOpenOrders(rows, '0.1608', 2).map(({ key }) => key), + ['far-high', 'far-low'], + ); + assert.deepEqual(rows.map(({ key }) => key), [ + 'near-high', + 'far-high', + 'far-low', + 'near-low', + ]); +}); + +test('retains list order for equal distances and caps the selection', () => { + const rows = [ + { key: 'first', price: '11' }, + { key: 'second', price: '9' }, + { key: 'third', price: '12' }, + ]; + + assert.deepEqual( + selectFarthestOpenOrders(rows, '10', 2).map(({ key }) => key), + ['third', 'first'], + ); +}); + +test('keeps the nearest order when releasing one hundred slots', () => { + const rows = Array.from({ length: 101 }, (_, index) => ({ + key: `row-${index}`, + price: String(100 + index), + })); + + const selected = selectFarthestOpenOrders(rows, '100', 100); + + assert.equal(selected.length, 100); + assert.equal(selected[0].key, 'row-100'); + assert.equal(selected.at(-1).key, 'row-1'); + assert.equal(selected.some(({ key }) => key === 'row-0'), false); +}); + +test('rejects incomplete capacity-recovery inputs', () => { + assert.throws(() => selectFarthestOpenOrders([], '', 100), /reference price/); + assert.throws(() => selectFarthestOpenOrders([], '1', 0), /cancellation limit/); + assert.throws(() => selectFarthestOpenOrders([{ key: '', price: '1' }], '1', 1), /row key/); + assert.throws(() => selectFarthestOpenOrders([{ key: 'row', price: '-' }], '1', 1), /row price/); +}); diff --git a/test/unit/binance-orderbook-trade/order-feedback.test.js b/test/unit/binance-orderbook-trade/order-feedback.test.js index 2931f6a..98c738a 100644 --- a/test/unit/binance-orderbook-trade/order-feedback.test.js +++ b/test/unit/binance-orderbook-trade/order-feedback.test.js @@ -7,6 +7,7 @@ import { formatBinancePlaceOrderResponseDiagnostic, getBinanceApiErrorCode, isBinancePlaceOrderSuccessPayload, + isBinanceMaxOpenOrdersErrorCode, isBinancePostOnlyMakerRejectCode, isOpenLadderOpenOrdersCapacityFeedback, isPostOnlyMakerRejectionFeedback, @@ -197,6 +198,12 @@ test('recognizes only verified Binance Post Only maker rejection codes', () => { assert.equal(isBinancePostOnlyMakerRejectCode(90805021), false); }); +test('recognizes the live Binance maximum open-orders rejection code', () => { + assert.equal(isBinanceMaxOpenOrdersErrorCode(90802025), true); + assert.equal(isBinanceMaxOpenOrdersErrorCode('90802025'), false); + assert.equal(isBinanceMaxOpenOrdersErrorCode(90805022), false); +}); + test('recognizes Post Only maker-execution rejection feedback without exact-message matching', () => { assert.equal(isPostOnlyMakerRejectionFeedback( '由于该只做Maker订单(Post Only)未作为Maker执行,因此将被拒绝。该订单不会记录在订单历史记录中。' diff --git a/test/unit/binance-orderbook-trade/source-regressions.test.js b/test/unit/binance-orderbook-trade/source-regressions.test.js index 1ef6080..2d4b0fd 100644 --- a/test/unit/binance-orderbook-trade/source-regressions.test.js +++ b/test/unit/binance-orderbook-trade/source-regressions.test.js @@ -264,6 +264,36 @@ test('ladder retries with restricted open-order replacement after supported feed assert.match(startBody, /runLadderPlanWithOpenOrderReplacement\(\s*actionType,\s*progress,\s*setExecutionStatus,\s*abortController\.signal/); }); +test('continuous close recovers a confirmed max-open-orders rejection by freeing farthest slots', () => { + const acknowledgementBody = readFunctionBody('waitForOrderSubmitAcknowledgement'); + const executeBody = readFunctionBody('executeLadderPlan'); + const loadRowsBody = readFunctionBody('loadAllCurrentSymbolOpenOrderRows'); + const cancelPlanBody = readFunctionBody('cancelCurrentSymbolOpenOrdersForPlan'); + const startBody = readFunctionBody('startLadder'); + + assert.match(source, /const MAX_OPEN_ORDERS_RECOVERY_CANCEL_COUNT = 100;/); + assert.match(acknowledgementBody, /isBinanceMaxOpenOrdersErrorCode\(capturedApiErrors\[0\]\.code\)/); + assert.match(acknowledgementBody, /createLadderMaxOpenOrdersError\(capturedApiErrors\[0\]\)/); + assert.match(source, /if \(apiError\.success !== false\) throw new Error\('最大挂单限制响应缺少 success=false'\)/); + assert.match(startBody, /allowMaxOpenOrdersRecovery:\s*continuousSession && spec\.mode === 'CLOSE'/); + assert.match(executeBody, /isRecoverableMaxOpenOrdersFailure\([\s\S]*options\?\.allowMaxOpenOrdersRecovery/); + assert.match(executeBody, /strategy:\s*'farthest_for_capacity'/); + assert.match(executeBody, /if \(maxOpenOrdersRecoveryAttempts > 0\)/); + + const recoveryIndex = executeBody.indexOf("{ strategy: 'farthest_for_capacity' }"); + const retryIndex = executeBody.indexOf('continue;', recoveryIndex); + const doneIndex = executeBody.indexOf('done++;', recoveryIndex); + assert.ok(recoveryIndex >= 0 && recoveryIndex < retryIndex && retryIndex < doneIndex); + + assert.match(loadRowsBody, /scrollOpenOrderRowsToBottom\(scrollContainer\)/); + assert.match(loadRowsBody, /loadedCount > beforeCount/); + assert.match(loadRowsBody, /stableEndPasses >= 2/); + assert.match(loadRowsBody, /OPEN_ORDERS_LAZY_LOAD_TIMEOUT_MS/); + assert.match(cancelPlanBody, /loadAllCurrentSymbolOpenOrderRows\([\s\S]*selectFarthestOpenOrders\(/); + assert.match(cancelPlanBody, /MAX_OPEN_ORDERS_RECOVERY_CANCEL_COUNT/); + assert.doesNotMatch(cancelPlanBody, /findCurrentSymbolCancelAllButton/); +}); + test('open and close ladders reprice only remaining orders after explicit maker conflicts', () => { const retryBody = readFunctionBody('isRetryableLadderMakerPriceFailure'); assert.match(retryBody, /plan\?\.spec\?\.mode !== 'OPEN'/); @@ -293,7 +323,7 @@ test('open and close ladders reprice only remaining orders after explicit maker assert.match(observeBody, /response\.ok/); assert.match(observeBody, /isBinancePlaceOrderSuccessPayload\(payload\)/); assert.match(observeBody, /capture\.apiSuccesses\.push\(\{ requestUrl \}\)/); - assert.match(observeBody, /capture\.apiErrors\.push\(\{ requestUrl, code \}\)/); + assert.match(observeBody, /code,\s*message: payloadSummary\.message,\s*success: payloadSummary\.success/); assert.match(observeBody, /capture\.responseDiagnostics\.push/); assert.match(observeBody, /bodyKind: 'non_json'/); assert.match(observeBody, /bodyKind: 'invalid_json'/); @@ -771,8 +801,10 @@ test('user-facing trading failures preserve one precise reason and shared termin test('ladder replacement cancels visible current-symbol same-direction rows up to planned quantity', () => { const readRowsBody = readFunctionBody('readCurrentSymbolOpenOrderRows'); - assert.match(readRowsBody, /findOpenOrderRowElements\(root/); - assert.match(readRowsBody, /BINANCE_PAGE_TEXT\.accountOrders\.rowCancel/); + const readRowElementsBody = readFunctionBody('readOpenOrderRowElements'); + assert.match(readRowsBody, /readOpenOrderRowElements\(root\)/); + assert.match(readRowElementsBody, /findOpenOrderRowElements\(root/); + assert.match(readRowElementsBody, /BINANCE_PAGE_TEXT\.accountOrders\.rowCancel/); assert.doesNotMatch(readRowsBody, /list-item-container/); assert.match(readRowsBody, /cells\[5\]/); assert.match(readRowsBody, /sideText/); @@ -829,6 +861,7 @@ test('ladder replacement cancels visible current-symbol same-direction rows up t assert.match(rowStateBody, /rows: \[\]/); const cancelOpenOrderRowsBody = readFunctionBody('cancelOpenOrderRowsForPlan'); + const cancelOneRowBody = readFunctionBody('cancelOneOpenOrderRowForPlan'); assert.match(cancelOpenOrderRowsBody, /let currentRoot = root/); assert.match(cancelOpenOrderRowsBody, /readCurrentSymbolOpenOrderRows\(currentRoot,\s*plan\.symbol,\s*plan\)/); assert.match(cancelOpenOrderRowsBody, /const remainingQty = subtractDecimalStrings\(plan\.totalQty,\s*cancelQty\)/); @@ -838,15 +871,16 @@ test('ladder replacement cancels visible current-symbol same-direction rows up t assert.match(cancelOpenOrderRowsBody, /const refreshedRoot = getActiveOpenOrdersScope\(\)/); assert.match(cancelOpenOrderRowsBody, /currentRoot = refreshedRoot/); assert.match(cancelOpenOrderRowsBody, /currentRoot = row\.root \|\| currentRoot/); - assert.match(cancelOpenOrderRowsBody, /clickDomTarget\(row\.cancelButton\)/); - assert.match(cancelOpenOrderRowsBody, /waitForOpenOrderRowCancellationOutcome\(/); - assert.match(cancelOpenOrderRowsBody, /outcome\.status === 'dialog_open'/); - assert.match(cancelOpenOrderRowsBody, /confirmOpenOrderRowKeyCountBelow\(\s*plan\.symbol,\s*row\.key,\s*previousKeyCount,\s*abortSignal/); - assert.doesNotMatch(cancelOpenOrderRowsBody, /waitForNewVisibleDialog/); - assert.match(cancelOpenOrderRowsBody, /const dialogClosed = await waitForDialogToClose\([\s\S]*abortSignal/); - assert.match(cancelOpenOrderRowsBody, /DialogNotClosedError/); - assert.doesNotMatch(cancelOpenOrderRowsBody, /waitForOpenOrderRowKeyCountBelow\(row\.root/); - assert.doesNotMatch(cancelOpenOrderRowsBody, /row\.cancelButton\.click\(\)/); + assert.match(cancelOpenOrderRowsBody, /cancelOneOpenOrderRowForPlan\(/); + assert.match(cancelOneRowBody, /clickDomTarget\(row\.cancelButton\)/); + assert.match(cancelOneRowBody, /waitForOpenOrderRowCancellationOutcome\(/); + assert.match(cancelOneRowBody, /outcome\.status === 'dialog_open'/); + assert.match(cancelOneRowBody, /confirmOpenOrderRowKeyCountBelow\(\s*plan\.symbol,\s*row\.key,\s*previousKeyCount,\s*abortSignal/); + assert.doesNotMatch(cancelOneRowBody, /waitForNewVisibleDialog/); + assert.match(cancelOneRowBody, /const dialogClosed = await waitForDialogToClose\([\s\S]*abortSignal/); + assert.match(cancelOneRowBody, /DialogNotClosedError/); + assert.doesNotMatch(cancelOneRowBody, /waitForOpenOrderRowKeyCountBelow\(row\.root/); + assert.doesNotMatch(cancelOneRowBody, /row\.cancelButton\.click\(\)/); assert.doesNotMatch(cancelOpenOrderRowsBody, /for \(const row of rowsToCancel\)/); const waitForRowRemovalBody = readFunctionBody('waitForOpenOrderRowKeyCountBelow'); @@ -902,6 +936,7 @@ test('stopping a ladder aborts replacement waits before another cancel or submit const executeBody = readFunctionBody('executeLadderPlan'); const cancelPlanBody = readFunctionBody('cancelCurrentSymbolOpenOrdersForPlan'); const cancelRowsBody = readFunctionBody('cancelOpenOrderRowsForPlan'); + const cancelOneRowBody = readFunctionBody('cancelOneOpenOrderRowForPlan'); const waitOutcomeBody = readFunctionBody('waitForOpenOrderRowCancellationOutcome'); assert.match(source, /let ladderAbortController = null/); @@ -916,9 +951,10 @@ test('stopping a ladder aborts replacement waits before another cancel or submit 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\(/); - assert.match(cancelRowsBody, /throwIfAborted\(abortSignal\)[\s\S]*clickDomTarget\(row\.cancelButton\)/); - assert.match(cancelRowsBody, /waitForOpenOrderRowCancellationOutcome\([\s\S]*abortSignal/); - assert.match(cancelRowsBody, /waitForDialogToClose\([\s\S]*abortSignal/); + assert.match(cancelRowsBody, /throwIfAborted\(abortSignal\)[\s\S]*cancelOneOpenOrderRowForPlan\(/); + assert.match(cancelOneRowBody, /throwIfAborted\(abortSignal\)[\s\S]*clickDomTarget\(row\.cancelButton\)/); + assert.match(cancelOneRowBody, /waitForOpenOrderRowCancellationOutcome\([\s\S]*abortSignal/); + assert.match(cancelOneRowBody, /waitForDialogToClose\([\s\S]*abortSignal/); assert.match(waitOutcomeBody, /waitForPromiseOrAbort\([\s\S]*abortSignal/); }); @@ -928,6 +964,7 @@ test('stopping a ladder preserves confirmed submit and cancel progress', () => { const executeBody = readFunctionBody('executeLadderPlan'); const cancelPlanBody = readFunctionBody('cancelCurrentSymbolOpenOrdersForPlan'); const cancelRowsBody = readFunctionBody('cancelOpenOrderRowsForPlan'); + const cancelOneRowBody = readFunctionBody('cancelOneOpenOrderRowForPlan'); assert.match(startBody, /const progress = createLadderProgress\(\)/); assert.match(startBody, /runLadderPlanWithOpenOrderReplacement\(\s*actionType,\s*progress,\s*setExecutionStatus,\s*abortController\.signal/); @@ -938,7 +975,7 @@ test('stopping a ladder preserves confirmed submit and cancel progress', () => { assert.match(runBody, /cancelCurrentSymbolOpenOrdersForPlan\(\s*replacementPlan,\s*progress,\s*setExecutionStatus,\s*abortSignal/); assert.match(executeBody, /waitForOrderSubmitAcknowledgement\([\s\S]*recordLadderSubmittedOrder\(progress\)/); assert.match(cancelPlanBody, /cancelOpenOrderRowsForPlan\(\s*openOrdersScope,\s*plan,\s*progress,\s*setExecutionStatus,\s*abortSignal/); - assert.match(cancelRowsBody, /confirmOpenOrderRowKeyCountBelow\([\s\S]*recordLadderCancelledOrder\(progress\)/); + assert.match(cancelOneRowBody, /confirmOpenOrderRowKeyCountBelow\([\s\S]*recordLadderCancelledOrder\(progress\)/); const acknowledgementIndex = executeBody.indexOf('await waitForOrderSubmitAcknowledgement('); const acknowledgementFinallyIndex = executeBody.indexOf('} finally {', acknowledgementIndex); @@ -953,10 +990,10 @@ test('stopping a ladder preserves confirmed submit and cancel progress', () => { /throwIfAborted/, ); - const cancellationConfirmationIndex = cancelRowsBody.indexOf('await confirmOpenOrderRowKeyCountBelow('); - const cancelRecordIndex = cancelRowsBody.indexOf('recordLadderCancelledOrder(progress)'); + const cancellationConfirmationIndex = cancelOneRowBody.indexOf('await confirmOpenOrderRowKeyCountBelow('); + const cancelRecordIndex = cancelOneRowBody.indexOf('recordLadderCancelledOrder(progress)'); assert.ok(cancellationConfirmationIndex >= 0 && cancellationConfirmationIndex < cancelRecordIndex); - assert.doesNotMatch(cancelRowsBody.slice(cancellationConfirmationIndex, cancelRecordIndex), /throwIfAborted/); + assert.doesNotMatch(cancelOneRowBody.slice(cancellationConfirmationIndex, cancelRecordIndex), /throwIfAborted/); }); test('ladder task statuses name the active action and observed outcome', () => {