diff --git a/scripts/binance-orderbook-trade.user.js b/scripts/binance-orderbook-trade.user.js index 6cd7f58..e00b53e 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.133 +// @version 2.7.134 // @author jackhai9 // @description 单击订单簿价格,按当前开仓/平仓 tab 自动填数量并执行下单,内置数量倍率面板 // @match https://www.binance.com/*/futures/* @@ -835,6 +835,36 @@ ); } + // src/binance-orderbook-trade/core/abort.js + function getAbortReason(signal) { + if (signal?.reason instanceof Error) return signal.reason; + const error = new Error("Operation aborted"); + error.name = "AbortError"; + return error; + } + function throwIfAborted(signal) { + if (signal?.aborted) throw getAbortReason(signal); + } + function waitForPromiseOrAbort(task, signal) { + if (!signal) return Promise.resolve(task); + throwIfAborted(signal); + return new Promise((resolve, reject) => { + let settled = false; + const finish = (callback, value) => { + if (settled) return; + settled = true; + signal.removeEventListener("abort", onAbort); + callback(value); + }; + const onAbort = () => finish(reject, getAbortReason(signal)); + signal.addEventListener("abort", onAbort, { once: true }); + Promise.resolve(task).then( + (value) => finish(resolve, value), + (error) => finish(reject, error) + ); + }); + } + // src/binance-orderbook-trade/core/order-feedback.js function isPotentialOrderFeedbackText(text) { if (!text) return false; @@ -949,7 +979,8 @@ } }; } - async function waitForAccountOrdersMutationState(observationRoot, readState, timeoutMs) { + async function waitForAccountOrdersMutationState(observationRoot, readState, timeoutMs, abortSignal = null) { + throwIfAborted(abortSignal); const currentState = readState(); if (currentState) return currentState; const signal = createAccountOrdersMutationSignal(observationRoot); @@ -957,12 +988,16 @@ const deadline = Date.now() + timeoutMs; try { while (true) { + throwIfAborted(abortSignal); const version = signal.version; const state = readState(); if (state) return state; const remainingMs = deadline - Date.now(); if (remainingMs <= 0) return readState(); - await signal.waitForChange(version, remainingMs); + await waitForPromiseOrAbort( + signal.waitForChange(version, remainingMs), + abortSignal + ); } } finally { signal.dispose(); @@ -1734,7 +1769,8 @@ } }; } - async function waitForDialogMutationState(document2, readState, timeoutMs) { + async function waitForDialogMutationState(document2, readState, timeoutMs, abortSignal = null) { + throwIfAborted(abortSignal); const currentState = readState(); if (currentState) return currentState; const signal = createDialogMutationSignal(document2); @@ -1742,12 +1778,16 @@ const deadline = Date.now() + timeoutMs; try { while (true) { + throwIfAborted(abortSignal); const version = signal.version; const state = readState(); if (state) return state; const remainingMs = deadline - Date.now(); if (remainingMs <= 0) return readState(); - await signal.waitForChange(version, remainingMs); + await waitForPromiseOrAbort( + signal.waitForChange(version, remainingMs), + abortSignal + ); } } finally { signal.dispose(); @@ -1999,6 +2039,7 @@ let tradeButtonCache = { mode: null, expiresAt: 0, buttons: [] }; let tradeScopeCache = { activeTab: null, expiresAt: 0, scopes: [] }; let ladderTask = null; + let ladderAbortController = null; let activeLadderActionType = null; let activeLadderPanelContext = null; let cancelCurrentSymbolOpenOrdersTask = null; @@ -2145,6 +2186,14 @@ window.setTimeout(resolve, ms); }); } + function createLadderStoppedError() { + const error = new Error("已停止"); + error.name = "LadderStoppedError"; + return error; + } + function isLadderStoppedError(error) { + return error?.name === "LadderStoppedError"; + } function getLadderOpenPercent(symbol = getCurrentSymbol(), precision = readCurrentOrderbookPrecisionValue()) { return loadModeSymbolPrecisionNumberOption( localStorage, @@ -3643,7 +3692,7 @@ const settleHint = sawBusy ? "按钮已恢复但未收到明确成功反馈" : "未观察到提交按钮状态变化"; throw new Error(`未收到明确${label}成功反馈(${settleHint}),已停止;请核对当前委托/历史成交`); } - async function executeLadderPlan(plan) { + async function executeLadderPlan(plan, abortSignal = null) { const priceInput = findPriceInput(); const qtyInput = findQtyInput(); if (!priceInput || !qtyInput) throw new Error("未找到价格或数量输入框"); @@ -3651,11 +3700,13 @@ let repriceAttempts = 0; let lastRepriceApiErrorCode = null; while (done < plan.orders.length) { + throwIfAborted(abortSignal); if (ladderStopRequested) break; const order = plan.orders[done]; try { assertLadderExecutionContext(plan); if (!await ensurePostOnlyOrderType()) throw new Error("执行中只做Maker (Post Only) 状态丢失,请刷新页面后重试"); + throwIfAborted(abortSignal); assertLadderExecutionContext(plan); assertLadderMakerPrice(plan, order.price); const currentPriceInput = findPriceInput(); @@ -3665,6 +3716,7 @@ priceLabel: "计划价", qtyLabel: "计划量" }); + throwIfAborted(abortSignal); const submittedPrice = synchronizedInputs.submittedPrice; assertLadderExecutionContext(plan); assertLadderMakerPrice(plan, submittedPrice); @@ -3676,6 +3728,7 @@ const previousFeedback = takeOrderFeedbackSnapshot(); const submitCaptureId = beginLadderSubmitResponseCapture(); try { + throwIfAborted(abortSignal); button.click(); setLadderStatus(`${plan.spec.label} ${done + 1}/${plan.orders.length} 确认中`); waitForTradeUiMutation({ timeoutMs: 500 }); @@ -3686,6 +3739,7 @@ submitCaptureId, plan.spec.mode ); + throwIfAborted(abortSignal); } finally { endLadderSubmitResponseCapture(submitCaptureId); } @@ -3702,6 +3756,7 @@ repriceAttempts += 1; setLadderStatus(`盘口已移动,刷新剩余 ${plan.orders.length - done} 档 (${repriceAttempts}/${LADDER_REPRICE_MAX_ATTEMPTS})`); await delay(LADDER_REPRICE_DELAY_MS); + throwIfAborted(abortSignal); if (ladderStopRequested) break; refreshRemainingLadderOrders(plan, done); continue; @@ -3709,6 +3764,7 @@ done++; setLadderStatus(`${plan.spec.label} ${done}/${plan.orders.length}`); await delay(LADDER_ORDER_DELAY_MS); + throwIfAborted(abortSignal); } return { done, repriceAttempts, lastRepriceApiErrorCode }; } @@ -3736,6 +3792,8 @@ return; } ladderStopRequested = false; + const abortController = new AbortController(); + ladderAbortController = abortController; activeLadderActionType = actionType; activeLadderPanelContext = { mode: spec.mode, @@ -3750,7 +3808,7 @@ done, repriceAttempts, lastRepriceApiErrorCode - } = await runLadderPlanWithOpenOrderReplacement(actionType); + } = await runLadderPlanWithOpenOrderReplacement(actionType, abortController.signal); return { plan, done, @@ -3780,6 +3838,10 @@ wasStopped ? `已停止 ${done}/${plan.orders.length}${diagnostics}` : `完成 ${done}/${plan.orders.length}${diagnostics}` ); }).catch((e) => { + if (isLadderStoppedError(e)) { + if (isCurrentObservedSymbol(actionSymbol)) setLadderStatus("已停止"); + return; + } err("Maker 阶梯执行失败:", e); if (!isCurrentObservedSymbol(actionSymbol)) { setLadderStatus("交易对已切换"); @@ -3787,6 +3849,7 @@ } setLadderStatus(e?.message || "执行失败", e?.statusTitle); }).finally(() => { + if (ladderAbortController === abortController) ladderAbortController = null; ladderTask = null; activeLadderActionType = null; activeLadderPanelContext = null; @@ -3802,6 +3865,9 @@ return; } ladderStopRequested = true; + if (ladderAbortController && !ladderAbortController.signal.aborted) { + ladderAbortController.abort(createLadderStoppedError()); + } setLadderStatus("停止中"); scheduleRenderPanel(); } @@ -3833,19 +3899,26 @@ const tabGroup = getAccountOrdersTabGroup2(tab); return tabGroup?.closest(".react-grid-item") || tabGroup?.parentElement || null; } - function waitForAccountOrdersState(readState, timeoutMs) { + function waitForAccountOrdersState(readState, timeoutMs, abortSignal = null) { const observationRoot = getAccountOrdersObservationRoot() || document.body; - return waitForAccountOrdersMutationState(observationRoot, readState, timeoutMs); + return waitForAccountOrdersMutationState( + observationRoot, + readState, + timeoutMs, + abortSignal + ); } - async function activateOpenOrdersTab() { + async function activateOpenOrdersTab(abortSignal = null) { + throwIfAborted(abortSignal); const tab = findOpenOrdersTab2(); if (!tab) return false; if (tab.getAttribute("aria-selected") === "true") return true; + throwIfAborted(abortSignal); tab.click(); const activeTab = await waitForAccountOrdersState(() => { const freshTab = findOpenOrdersTab2(); return freshTab?.getAttribute("aria-selected") === "true" ? freshTab : null; - }, 2200); + }, 2200, abortSignal); return Boolean(activeTab); } async function restoreAccountOrdersTab(previousTabIdentity, symbol = null) { @@ -3885,10 +3958,15 @@ function findOpenOrdersSubTabByIdentity2(root, identity) { return findOpenOrdersSubTabByIdentity(root, identity, { isVisibleElement }); } - async function waitForActiveOpenOrdersScope() { - return waitForAccountOrdersState(() => getActiveOpenOrdersScope2(), 2200); + async function waitForActiveOpenOrdersScope(abortSignal = null) { + return waitForAccountOrdersState( + () => getActiveOpenOrdersScope2(), + 2200, + abortSignal + ); } - async function activateOpenOrdersBasicSubTab(root) { + async function activateOpenOrdersBasicSubTab(root, abortSignal = null) { + throwIfAborted(abortSignal); const previousSubTabIdentity = getOpenOrdersSubTabIdentity2(findSelectedOpenOrdersSubTab2(root)); const basicTab = findOpenOrdersBasicSubTab2(root); if (!basicTab) { @@ -3900,12 +3978,13 @@ if (basicTab.getAttribute("aria-selected") === "true") { return { ready: true, previousSubTabIdentity }; } + throwIfAborted(abortSignal); basicTab.click(); const selectedBasicTab = await waitForAccountOrdersState(() => { const scope = getActiveOpenOrdersScope2(); const freshBasicTab = scope ? findOpenOrdersBasicSubTab2(scope) : null; return freshBasicTab?.getAttribute("aria-selected") === "true" ? freshBasicTab : null; - }, 2200); + }, 2200, abortSignal); return { ready: Boolean(selectedBasicTab), previousSubTabIdentity @@ -4185,16 +4264,17 @@ } return null; } - async function waitForCurrentSymbolOpenOrderRows(root, symbol, plan = null) { + async function waitForCurrentSymbolOpenOrderRows(root, symbol, plan = null, abortSignal = null) { let state = await waitForAccountOrdersState( () => readCurrentSymbolOpenOrderRowsState(root, symbol, plan), - 1600 + 1600, + abortSignal ); if (state?.status !== "other_direction") return state?.rows || []; const transitioned = await waitForAccountOrdersState(() => { const nextState = readCurrentSymbolOpenOrderRowsState(root, symbol, plan); return nextState?.status !== "other_direction" ? nextState : null; - }, LADDER_REPLACE_ROW_SETTLE_MS); + }, LADDER_REPLACE_ROW_SETTLE_MS, abortSignal); state = transitioned || readCurrentSymbolOpenOrderRowsState(root, symbol, plan); return state?.rows || []; } @@ -4230,9 +4310,10 @@ } return null; } - async function waitForOpenOrderRowCancellationOutcome(symbol, key, previousCount, dialogsBefore) { + async function waitForOpenOrderRowCancellationOutcome(symbol, key, previousCount, dialogsBefore, abortSignal = null) { const deadline = Date.now() + LADDER_REPLACE_OPEN_ORDERS_CLEAR_TIMEOUT_MS; while (true) { + throwIfAborted(abortSignal); const currentOutcome = readOpenOrderRowCancellationOutcome( symbol, key, @@ -4260,23 +4341,27 @@ dialogsBefore ); if (observedOutcome) return observedOutcome; - await Promise.race([ - accountSignal.waitForChange(observedAccountVersion, remainingMs), - dialogSignal.waitForChange(observedDialogVersion, remainingMs) - ]); + await waitForPromiseOrAbort( + Promise.race([ + accountSignal.waitForChange(observedAccountVersion, remainingMs), + dialogSignal.waitForChange(observedDialogVersion, remainingMs) + ]), + abortSignal + ); } finally { accountSignal.dispose(); dialogSignal.dispose(); } } } - async function confirmOpenOrderRowKeyCountBelow(symbol, key, previousCount) { + async function confirmOpenOrderRowKeyCountBelow(symbol, key, previousCount, abortSignal = null) { const deadline = Date.now() + LADDER_REPLACE_ROW_SETTLE_MS; const observationRoot = getAccountOrdersObservationRoot() || document.body; const mutationSignal = createAccountOrdersMutationSignal(observationRoot); if (!mutationSignal) throw new Error("Account-orders observer root is unavailable"); try { while (true) { + throwIfAborted(abortSignal); const observedVersion = mutationSignal.version; if (!isCurrentObservedSymbol(symbol)) return false; const activeRoot = getActiveOpenOrdersScope2(); @@ -4285,19 +4370,23 @@ } const remainingMs = deadline - Date.now(); if (remainingMs <= 0) return true; - await mutationSignal.waitForChange(observedVersion, remainingMs); + await waitForPromiseOrAbort( + mutationSignal.waitForChange(observedVersion, remainingMs), + abortSignal + ); } } finally { mutationSignal.dispose(); } } - async function waitForOpenOrderRowKeyCountBelow(symbol, key, previousCount) { + async function waitForOpenOrderRowKeyCountBelow(symbol, key, previousCount, abortSignal = null) { const deadline = Date.now() + LADDER_REPLACE_OPEN_ORDERS_CLEAR_TIMEOUT_MS; const observationRoot = getAccountOrdersObservationRoot() || document.body; const mutationSignal = createAccountOrdersMutationSignal(observationRoot); if (!mutationSignal) throw new Error("Account-orders observer root is unavailable"); try { while (true) { + throwIfAborted(abortSignal); const observedVersion = mutationSignal.version; if (!isCurrentObservedSymbol(symbol)) return false; const activeRoot2 = getActiveOpenOrdersScope2(); @@ -4306,7 +4395,10 @@ } const remainingMs = deadline - Date.now(); if (remainingMs <= 0) break; - await mutationSignal.waitForChange(observedVersion, remainingMs); + await waitForPromiseOrAbort( + mutationSignal.waitForChange(observedVersion, remainingMs), + abortSignal + ); } } finally { mutationSignal.dispose(); @@ -4314,10 +4406,11 @@ const activeRoot = getActiveOpenOrdersScope2(); return Boolean(activeRoot && countOpenOrderRowsByKey(activeRoot, symbol, key) < previousCount); } - async function cancelOpenOrderRowsForPlan(root, plan) { + async function cancelOpenOrderRowsForPlan(root, plan, abortSignal = null) { let cancelQty = "0"; let currentRoot = root; while (compareDecimalStrings(cancelQty, plan.totalQty) < 0) { + throwIfAborted(abortSignal); if (!isCurrentObservedSymbol(plan.symbol)) throw new Error("逐行撤单前交易对已变化"); const remainingQty = subtractDecimalStrings(plan.totalQty, cancelQty); const refreshedRoot = getActiveOpenOrdersScope2(); @@ -4338,6 +4431,7 @@ throw new Error("当前币挂单撤单入口已失效,已停止重挂"); } const dialogsBefore = new Set(getVisibleDialogs()); + throwIfAborted(abortSignal); if (!clickDomTarget(row.cancelButton)) { throw new Error("当前币挂单撤单入口点击失败,已停止重挂"); } @@ -4346,7 +4440,8 @@ plan.symbol, row.key, previousKeyCount, - dialogsBefore + dialogsBefore, + abortSignal ); if (outcome.status === "symbol_changed") { throw new Error("逐行撤单时交易对已变化"); @@ -4357,24 +4452,41 @@ if (outcome.status === "dialog_open") { const { dialog } = outcome; setLadderStatus(`${plan.symbol} 单行撤单确认弹窗已打开`); - const dialogClosed = await waitForDialogToClose(dialog); + const dialogClosed = await waitForDialogToClose( + dialog, + ROW_CANCEL_DIALOG_CLOSE_TIMEOUT_MS, + abortSignal + ); if (!dialogClosed) { const error = new Error(`${plan.symbol} 单行撤单确认弹窗仍未关闭,未恢复页面状态`); error.name = "DialogNotClosedError"; throw error; } - if (!await waitForOpenOrderRowKeyCountBelow(plan.symbol, row.key, previousKeyCount)) { + if (!await waitForOpenOrderRowKeyCountBelow( + plan.symbol, + row.key, + previousKeyCount, + abortSignal + )) { throw new Error(`${plan.symbol} 当前币挂单仍存在,已停止重挂`); } } - if (!await confirmOpenOrderRowKeyCountBelow(plan.symbol, row.key, previousKeyCount)) { + throwIfAborted(abortSignal); + if (!await confirmOpenOrderRowKeyCountBelow( + plan.symbol, + row.key, + previousKeyCount, + abortSignal + )) { throw new Error(`${plan.symbol} 当前币挂单状态不稳定,已停止重挂`); } + throwIfAborted(abortSignal); cancelQty = addDecimalStrings(cancelQty, row.qty); } return { ok: true, cancelQty }; } - async function setHideOtherSymbolChecked(root, desiredChecked, symbol = getCurrentSymbol()) { + async function setHideOtherSymbolChecked(root, desiredChecked, symbol = getCurrentSymbol(), abortSignal = null) { + throwIfAborted(abortSignal); if (!isCurrentObservedSymbol(symbol)) return false; const checkbox = findHideOtherSymbolCheckbox(root); if (!checkbox) return false; @@ -4382,16 +4494,18 @@ if (currentChecked === desiredChecked) return true; if (currentChecked === null) return false; if (!isCurrentObservedSymbol(symbol)) return false; + throwIfAborted(abortSignal); checkbox.click(); const updatedCheckbox = await waitForAccountOrdersState(() => { if (!isCurrentObservedSymbol(symbol)) return null; const currentRoot = getActiveOpenOrdersScope2(); const currentCheckbox = findHideOtherSymbolCheckbox(currentRoot); return getCheckboxCheckedState(currentCheckbox) === desiredChecked ? currentCheckbox : null; - }, 1e3); + }, 1e3, abortSignal); return Boolean(updatedCheckbox) && isCurrentObservedSymbol(symbol); } - async function ensureOpenOrdersLimitedToCurrentSymbol(root, symbol) { + async function ensureOpenOrdersLimitedToCurrentSymbol(root, symbol, abortSignal = null) { + throwIfAborted(abortSignal); const checkbox = findHideOtherSymbolCheckbox(root); if (!checkbox) { return { @@ -4406,7 +4520,12 @@ originalChecked }; } - if (!originalChecked && !await setHideOtherSymbolChecked(root, true, symbol)) { + if (!originalChecked && !await setHideOtherSymbolChecked( + root, + true, + symbol, + abortSignal + )) { return { ok: false, originalChecked }; } const settledRoot = await waitForAccountOrdersState(() => { @@ -4420,7 +4539,7 @@ filterChecked: getCheckboxCheckedState(currentCheckbox), cancelAllAvailable: Boolean(cancelAllButton) }) ? currentRoot : null; - }, 1600); + }, 1600, abortSignal); return { ok: Boolean(settledRoot) && isCurrentObservedSymbol(symbol), originalChecked @@ -4443,11 +4562,12 @@ } return null; } - async function waitForDialogToClose(dialog, timeoutMs = ROW_CANCEL_DIALOG_CLOSE_TIMEOUT_MS) { + async function waitForDialogToClose(dialog, timeoutMs = ROW_CANCEL_DIALOG_CLOSE_TIMEOUT_MS, abortSignal = null) { return Boolean(await waitForDialogMutationState( document, () => !dialog.isConnected || !isVisibleElement(dialog) ? true : null, - timeoutMs + timeoutMs, + abortSignal )); } function createBinanceCancelAllDialogDecisionWatcher() { @@ -4895,7 +5015,8 @@ }, CANCEL_NO_ORDERS_FEEDBACK_MS); scheduleRenderPanel(); } - async function cancelCurrentSymbolOpenOrdersForPlan(plan) { + async function cancelCurrentSymbolOpenOrdersForPlan(plan, abortSignal = null) { + throwIfAborted(abortSignal); const symbol = getCurrentSymbol(); if (!isCurrentObservedSymbol(symbol) || symbol !== plan?.symbol) { const message = "逐行撤单前交易对已变化"; @@ -4909,45 +5030,68 @@ let restoreTemporaryUiState = true; try { setLadderStatus(`查找 ${symbol} 当前委托`); - const tabReady = await activateOpenOrdersTab(); + const tabReady = await activateOpenOrdersTab(abortSignal); + throwIfAborted(abortSignal); if (!tabReady || !isCurrentObservedSymbol(symbol)) { const message = "当前委托页未就绪或交易对已变化"; setLadderStatus(message); return { ok: false, status: "tab_not_ready", message }; } - openOrdersScope = await waitForActiveOpenOrdersScope(); + openOrdersScope = await waitForActiveOpenOrdersScope(abortSignal); + throwIfAborted(abortSignal); if (!openOrdersScope) { const message = "未定位到当前委托面板"; setLadderStatus(message); return { ok: false, status: "scope_not_found", message }; } - const basicSubTabState = await activateOpenOrdersBasicSubTab(openOrdersScope); - previousOpenOrdersSubTabIdentity = basicSubTabState.previousSubTabIdentity; + previousOpenOrdersSubTabIdentity = getOpenOrdersSubTabIdentity2( + findSelectedOpenOrdersSubTab2(openOrdersScope) + ); + const basicSubTabState = await activateOpenOrdersBasicSubTab( + openOrdersScope, + abortSignal + ); + throwIfAborted(abortSignal); if (!basicSubTabState.ready) { const message = "未定位到当前委托基础单"; setLadderStatus(message); return { ok: false, status: "basic_tab_not_ready", message }; } - openOrdersScope = await waitForActiveOpenOrdersScope(); + openOrdersScope = await waitForActiveOpenOrdersScope(abortSignal); + throwIfAborted(abortSignal); if (!openOrdersScope) { const message = "未定位到当前委托面板"; setLadderStatus(message); return { ok: false, status: "scope_not_found", message }; } - const symbolFilter = await ensureOpenOrdersLimitedToCurrentSymbol(openOrdersScope, symbol); - symbolFilterOriginalChecked = symbolFilter.originalChecked; + symbolFilterOriginalChecked = getCheckboxCheckedState( + findHideOtherSymbolCheckbox(openOrdersScope) + ); + const symbolFilter = await ensureOpenOrdersLimitedToCurrentSymbol( + openOrdersScope, + symbol, + abortSignal + ); + throwIfAborted(abortSignal); if (!symbolFilter.ok) { const message = "未确认只显示当前币挂单"; setLadderStatus(message); return { ok: false, status: "symbol_filter_not_confirmed", message }; } - openOrdersScope = await waitForActiveOpenOrdersScope(); + openOrdersScope = await waitForActiveOpenOrdersScope(abortSignal); + throwIfAborted(abortSignal); if (!openOrdersScope) { const message = "未定位到当前委托面板"; setLadderStatus(message); return { ok: false, status: "scope_not_found", message }; } - const rows = await waitForCurrentSymbolOpenOrderRows(openOrdersScope, symbol, plan); + const rows = await waitForCurrentSymbolOpenOrderRows( + openOrdersScope, + symbol, + plan, + abortSignal + ); + throwIfAborted(abortSignal); if (!rows.length) { const directionLabel = getPlanDirectionLabel(plan); const message = `未定位到 ${symbol}${directionLabel ? ` ${directionLabel}` : ""} 当前币可逐行撤单的基础单`; @@ -4961,10 +5105,12 @@ return { ok: false, status: "rows_not_selected", message }; } setLadderStatus(`${symbol} 撤销 ${rowsToCancel.length} 笔当前币挂单`); - await cancelOpenOrderRowsForPlan(openOrdersScope, plan); + await cancelOpenOrderRowsForPlan(openOrdersScope, plan, abortSignal); + throwIfAborted(abortSignal); setLadderStatus(`${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); @@ -5022,14 +5168,16 @@ precision: plan.precision }; } - async function runLadderPlanWithOpenOrderReplacement(actionType) { + async function runLadderPlanWithOpenOrderReplacement(actionType, abortSignal = null) { let replacementContext = null; for (let attempt = 0; attempt < 2; attempt += 1) { + throwIfAborted(abortSignal); let plan = null; try { plan = await buildLadderPlan(actionType, replacementContext); + throwIfAborted(abortSignal); setLadderStatus(formatLadderPlanStatus(plan)); - const execution = await executeLadderPlan(plan); + const execution = await executeLadderPlan(plan, abortSignal); return { plan, ...execution }; } catch (e) { const replacementPlan = getReplaceableLadderOpenOrdersPlan(plan, e); @@ -5037,7 +5185,11 @@ assertLadderExecutionContext(replacementPlan); setLadderStatus(formatOpenOrdersReplacementStatus(replacementPlan)); replacementContext = createLadderExpectedContext(replacementPlan); - const result = await cancelCurrentSymbolOpenOrdersForPlan(replacementPlan); + const result = await cancelCurrentSymbolOpenOrdersForPlan( + replacementPlan, + abortSignal + ); + throwIfAborted(abortSignal); if (!result?.ok) throw new Error(result.message || "当前币挂单未替换,已停止重挂"); } } diff --git a/src/binance-orderbook-trade/core/abort.js b/src/binance-orderbook-trade/core/abort.js new file mode 100644 index 0000000..acde892 --- /dev/null +++ b/src/binance-orderbook-trade/core/abort.js @@ -0,0 +1,32 @@ +function getAbortReason(signal) { + if (signal?.reason instanceof Error) return signal.reason; + const error = new Error('Operation aborted'); + error.name = 'AbortError'; + return error; +} + +export function throwIfAborted(signal) { + if (signal?.aborted) throw getAbortReason(signal); +} + +export function waitForPromiseOrAbort(task, signal) { + if (!signal) return Promise.resolve(task); + throwIfAborted(signal); + + return new Promise((resolve, reject) => { + let settled = false; + const finish = (callback, value) => { + if (settled) return; + settled = true; + signal.removeEventListener('abort', onAbort); + callback(value); + }; + const onAbort = () => finish(reject, getAbortReason(signal)); + + signal.addEventListener('abort', onAbort, { once: true }); + Promise.resolve(task).then( + (value) => finish(resolve, value), + (error) => finish(reject, error), + ); + }); +} diff --git a/src/binance-orderbook-trade/dom/account-orders.js b/src/binance-orderbook-trade/dom/account-orders.js index 64b261b..a002c22 100644 --- a/src/binance-orderbook-trade/dom/account-orders.js +++ b/src/binance-orderbook-trade/dom/account-orders.js @@ -9,6 +9,10 @@ import { parseBinanceTabCount, startsWithBinancePageText, } from '../contracts/binance-page-text.js'; +import { + throwIfAborted, + waitForPromiseOrAbort, +} from '../core/abort.js'; function getNormalizedText(el) { return normalizeText(el?.textContent || ''); @@ -62,7 +66,13 @@ export function createAccountOrdersMutationSignal(observationRoot) { }; } -export async function waitForAccountOrdersMutationState(observationRoot, readState, timeoutMs) { +export async function waitForAccountOrdersMutationState( + observationRoot, + readState, + timeoutMs, + abortSignal = null, +) { + throwIfAborted(abortSignal); const currentState = readState(); if (currentState) return currentState; const signal = createAccountOrdersMutationSignal(observationRoot); @@ -71,12 +81,16 @@ export async function waitForAccountOrdersMutationState(observationRoot, readSta try { while (true) { + throwIfAborted(abortSignal); const version = signal.version; const state = readState(); if (state) return state; const remainingMs = deadline - Date.now(); if (remainingMs <= 0) return readState(); - await signal.waitForChange(version, remainingMs); + await waitForPromiseOrAbort( + signal.waitForChange(version, remainingMs), + abortSignal, + ); } } finally { signal.dispose(); diff --git a/src/binance-orderbook-trade/dom/cancel-all-dialog.js b/src/binance-orderbook-trade/dom/cancel-all-dialog.js index cbba148..becc682 100644 --- a/src/binance-orderbook-trade/dom/cancel-all-dialog.js +++ b/src/binance-orderbook-trade/dom/cancel-all-dialog.js @@ -2,6 +2,10 @@ import { BINANCE_PAGE_TEXT, includesBinancePageText, } from '../contracts/binance-page-text.js'; +import { + throwIfAborted, + waitForPromiseOrAbort, +} from '../core/abort.js'; const CANCEL_ALL_DIALOG_CANDIDATE_SELECTOR = '[role="dialog"], [class*="modal"], [class*="Modal"]'; @@ -125,7 +129,13 @@ export function createDialogMutationSignal(document) { }; } -export async function waitForDialogMutationState(document, readState, timeoutMs) { +export async function waitForDialogMutationState( + document, + readState, + timeoutMs, + abortSignal = null, +) { + throwIfAborted(abortSignal); const currentState = readState(); if (currentState) return currentState; const signal = createDialogMutationSignal(document); @@ -134,12 +144,16 @@ export async function waitForDialogMutationState(document, readState, timeoutMs) try { while (true) { + throwIfAborted(abortSignal); const version = signal.version; const state = readState(); if (state) return state; const remainingMs = deadline - Date.now(); if (remainingMs <= 0) return readState(); - await signal.waitForChange(version, remainingMs); + await waitForPromiseOrAbort( + signal.waitForChange(version, remainingMs), + abortSignal, + ); } } finally { signal.dispose(); diff --git a/src/binance-orderbook-trade/index.user.js b/src/binance-orderbook-trade/index.user.js index b108c6e..2ff7375 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.133 +// @version 2.7.134 // @author jackhai9 // @description 单击订单簿价格,按当前开仓/平仓 tab 自动填数量并执行下单,内置数量倍率面板 // @match https://www.binance.com/*/futures/* @@ -77,6 +77,10 @@ import { getLadderPercentForMode, } from './core/ladder-plan.js'; import { keepInteractionFeedbackVisible } from './core/interaction-feedback.js'; +import { + throwIfAborted, + waitForPromiseOrAbort, +} from './core/abort.js'; import { evaluateOrderSubmitAcknowledgement, getBinanceApiErrorCode, @@ -342,6 +346,7 @@ import { let tradeButtonCache = { mode: null, expiresAt: 0, buttons: [] }; let tradeScopeCache = { activeTab: null, expiresAt: 0, scopes: [] }; let ladderTask = null; + let ladderAbortController = null; let activeLadderActionType = null; let activeLadderPanelContext = null; let cancelCurrentSymbolOpenOrdersTask = null; @@ -518,6 +523,16 @@ import { }); } + function createLadderStoppedError() { + const error = new Error('已停止'); + error.name = 'LadderStoppedError'; + return error; + } + + function isLadderStoppedError(error) { + return error?.name === 'LadderStoppedError'; + } + function getLadderOpenPercent( symbol = getCurrentSymbol(), precision = readCurrentOrderbookPrecisionValue(), @@ -2388,7 +2403,7 @@ import { throw new Error(`未收到明确${label}成功反馈(${settleHint}),已停止;请核对当前委托/历史成交`); } - async function executeLadderPlan(plan) { + async function executeLadderPlan(plan, abortSignal = null) { const priceInput = findPriceInput(); const qtyInput = findQtyInput(); if (!priceInput || !qtyInput) throw new Error('未找到价格或数量输入框'); @@ -2397,11 +2412,13 @@ import { let repriceAttempts = 0; let lastRepriceApiErrorCode = null; while (done < plan.orders.length) { + throwIfAborted(abortSignal); if (ladderStopRequested) break; const order = plan.orders[done]; try { assertLadderExecutionContext(plan); if (!(await ensurePostOnlyOrderType())) throw new Error('执行中只做Maker (Post Only) 状态丢失,请刷新页面后重试'); + throwIfAborted(abortSignal); assertLadderExecutionContext(plan); assertLadderMakerPrice(plan, order.price); @@ -2413,6 +2430,7 @@ import { priceLabel: '计划价', qtyLabel: '计划量', }); + throwIfAborted(abortSignal); const submittedPrice = synchronizedInputs.submittedPrice; assertLadderExecutionContext(plan); assertLadderMakerPrice(plan, submittedPrice); @@ -2426,6 +2444,7 @@ import { const previousFeedback = takeOrderFeedbackSnapshot(); const submitCaptureId = beginLadderSubmitResponseCapture(); try { + throwIfAborted(abortSignal); button.click(); setLadderStatus(`${plan.spec.label} ${done + 1}/${plan.orders.length} 确认中`); waitForTradeUiMutation({ timeoutMs: 500 }); @@ -2436,6 +2455,7 @@ import { submitCaptureId, plan.spec.mode, ); + throwIfAborted(abortSignal); } finally { endLadderSubmitResponseCapture(submitCaptureId); } @@ -2452,6 +2472,7 @@ import { repriceAttempts += 1; setLadderStatus(`盘口已移动,刷新剩余 ${plan.orders.length - done} 档 (${repriceAttempts}/${LADDER_REPRICE_MAX_ATTEMPTS})`); await delay(LADDER_REPRICE_DELAY_MS); + throwIfAborted(abortSignal); if (ladderStopRequested) break; refreshRemainingLadderOrders(plan, done); continue; @@ -2459,6 +2480,7 @@ import { done++; setLadderStatus(`${plan.spec.label} ${done}/${plan.orders.length}`); await delay(LADDER_ORDER_DELAY_MS); + throwIfAborted(abortSignal); } return { done, repriceAttempts, lastRepriceApiErrorCode }; } @@ -2487,6 +2509,8 @@ import { return; } ladderStopRequested = false; + const abortController = new AbortController(); + ladderAbortController = abortController; activeLadderActionType = actionType; activeLadderPanelContext = { mode: spec.mode, @@ -2501,7 +2525,7 @@ import { done, repriceAttempts, lastRepriceApiErrorCode, - } = await runLadderPlanWithOpenOrderReplacement(actionType); + } = await runLadderPlanWithOpenOrderReplacement(actionType, abortController.signal); return { plan, done, @@ -2535,6 +2559,10 @@ import { ); }) .catch((e) => { + if (isLadderStoppedError(e)) { + if (isCurrentObservedSymbol(actionSymbol)) setLadderStatus('已停止'); + return; + } err('Maker 阶梯执行失败:', e); if (!isCurrentObservedSymbol(actionSymbol)) { setLadderStatus('交易对已切换'); @@ -2543,6 +2571,7 @@ import { setLadderStatus(e?.message || '执行失败', e?.statusTitle); }) .finally(() => { + if (ladderAbortController === abortController) ladderAbortController = null; ladderTask = null; activeLadderActionType = null; activeLadderPanelContext = null; @@ -2559,6 +2588,9 @@ import { return; } ladderStopRequested = true; + if (ladderAbortController && !ladderAbortController.signal.aborted) { + ladderAbortController.abort(createLadderStoppedError()); + } setLadderStatus('停止中'); scheduleRenderPanel(); } @@ -2604,22 +2636,29 @@ import { * the stable account-orders component and re-read the semantic state instead * of sleeping or retaining nodes from the previous render. */ - function waitForAccountOrdersState(readState, timeoutMs) { + function waitForAccountOrdersState(readState, timeoutMs, abortSignal = null) { // The document root is observed only while React has temporarily removed // the verified account widget; normal waits remain scoped to that widget. const observationRoot = getAccountOrdersObservationRoot() || document.body; - return waitForAccountOrdersMutationState(observationRoot, readState, timeoutMs); + return waitForAccountOrdersMutationState( + observationRoot, + readState, + timeoutMs, + abortSignal, + ); } - async function activateOpenOrdersTab() { + async function activateOpenOrdersTab(abortSignal = null) { + throwIfAborted(abortSignal); const tab = findOpenOrdersTab(); if (!tab) return false; if (tab.getAttribute('aria-selected') === 'true') return true; + throwIfAborted(abortSignal); tab.click(); const activeTab = await waitForAccountOrdersState(() => { const freshTab = findOpenOrdersTab(); return freshTab?.getAttribute('aria-selected') === 'true' ? freshTab : null; - }, 2200); + }, 2200, abortSignal); return Boolean(activeTab); } @@ -2667,11 +2706,16 @@ import { return findOpenOrdersSubTabByIdentityDom(root, identity, { isVisibleElement }); } - async function waitForActiveOpenOrdersScope() { - return waitForAccountOrdersState(() => getActiveOpenOrdersScope(), 2200); + async function waitForActiveOpenOrdersScope(abortSignal = null) { + return waitForAccountOrdersState( + () => getActiveOpenOrdersScope(), + 2200, + abortSignal, + ); } - async function activateOpenOrdersBasicSubTab(root) { + async function activateOpenOrdersBasicSubTab(root, abortSignal = null) { + throwIfAborted(abortSignal); const previousSubTabIdentity = getOpenOrdersSubTabIdentity(findSelectedOpenOrdersSubTab(root)); const basicTab = findOpenOrdersBasicSubTab(root); if (!basicTab) { @@ -2683,12 +2727,13 @@ import { if (basicTab.getAttribute('aria-selected') === 'true') { return { ready: true, previousSubTabIdentity }; } + throwIfAborted(abortSignal); basicTab.click(); const selectedBasicTab = await waitForAccountOrdersState(() => { const scope = getActiveOpenOrdersScope(); const freshBasicTab = scope ? findOpenOrdersBasicSubTab(scope) : null; return freshBasicTab?.getAttribute('aria-selected') === 'true' ? freshBasicTab : null; - }, 2200); + }, 2200, abortSignal); return { ready: Boolean(selectedBasicTab), previousSubTabIdentity, @@ -3019,17 +3064,23 @@ import { return null; } - async function waitForCurrentSymbolOpenOrderRows(root, symbol, plan = null) { + async function waitForCurrentSymbolOpenOrderRows( + root, + symbol, + plan = null, + abortSignal = null, + ) { let state = await waitForAccountOrdersState( () => readCurrentSymbolOpenOrderRowsState(root, symbol, plan), 1600, + abortSignal, ); if (state?.status !== 'other_direction') return state?.rows || []; const transitioned = await waitForAccountOrdersState(() => { const nextState = readCurrentSymbolOpenOrderRowsState(root, symbol, plan); return nextState?.status !== 'other_direction' ? nextState : null; - }, LADDER_REPLACE_ROW_SETTLE_MS); + }, LADDER_REPLACE_ROW_SETTLE_MS, abortSignal); state = transitioned || readCurrentSymbolOpenOrderRowsState(root, symbol, plan); return state?.rows || []; } @@ -3072,9 +3123,16 @@ import { return null; } - async function waitForOpenOrderRowCancellationOutcome(symbol, key, previousCount, dialogsBefore) { + async function waitForOpenOrderRowCancellationOutcome( + symbol, + key, + previousCount, + dialogsBefore, + abortSignal = null, + ) { const deadline = Date.now() + LADDER_REPLACE_OPEN_ORDERS_CLEAR_TIMEOUT_MS; while (true) { + throwIfAborted(abortSignal); const currentOutcome = readOpenOrderRowCancellationOutcome( symbol, key, @@ -3104,10 +3162,13 @@ import { dialogsBefore, ); if (observedOutcome) return observedOutcome; - await Promise.race([ - accountSignal.waitForChange(observedAccountVersion, remainingMs), - dialogSignal.waitForChange(observedDialogVersion, remainingMs), - ]); + await waitForPromiseOrAbort( + Promise.race([ + accountSignal.waitForChange(observedAccountVersion, remainingMs), + dialogSignal.waitForChange(observedDialogVersion, remainingMs), + ]), + abortSignal, + ); } finally { accountSignal.dispose(); dialogSignal.dispose(); @@ -3115,7 +3176,12 @@ import { } } - async function confirmOpenOrderRowKeyCountBelow(symbol, key, previousCount) { + async function confirmOpenOrderRowKeyCountBelow( + symbol, + key, + previousCount, + abortSignal = null, + ) { const deadline = Date.now() + LADDER_REPLACE_ROW_SETTLE_MS; const observationRoot = getAccountOrdersObservationRoot() || document.body; const mutationSignal = createAccountOrdersMutationSignal(observationRoot); @@ -3123,6 +3189,7 @@ import { try { while (true) { + throwIfAborted(abortSignal); const observedVersion = mutationSignal.version; if (!isCurrentObservedSymbol(symbol)) return false; const activeRoot = getActiveOpenOrdersScope(); @@ -3131,14 +3198,22 @@ import { } const remainingMs = deadline - Date.now(); if (remainingMs <= 0) return true; - await mutationSignal.waitForChange(observedVersion, remainingMs); + await waitForPromiseOrAbort( + mutationSignal.waitForChange(observedVersion, remainingMs), + abortSignal, + ); } } finally { mutationSignal.dispose(); } } - async function waitForOpenOrderRowKeyCountBelow(symbol, key, previousCount) { + async function waitForOpenOrderRowKeyCountBelow( + symbol, + key, + previousCount, + abortSignal = null, + ) { const deadline = Date.now() + LADDER_REPLACE_OPEN_ORDERS_CLEAR_TIMEOUT_MS; const observationRoot = getAccountOrdersObservationRoot() || document.body; const mutationSignal = createAccountOrdersMutationSignal(observationRoot); @@ -3146,6 +3221,7 @@ import { try { while (true) { + throwIfAborted(abortSignal); const observedVersion = mutationSignal.version; if (!isCurrentObservedSymbol(symbol)) return false; const activeRoot = getActiveOpenOrdersScope(); @@ -3154,7 +3230,10 @@ import { } const remainingMs = deadline - Date.now(); if (remainingMs <= 0) break; - await mutationSignal.waitForChange(observedVersion, remainingMs); + await waitForPromiseOrAbort( + mutationSignal.waitForChange(observedVersion, remainingMs), + abortSignal, + ); } } finally { mutationSignal.dispose(); @@ -3163,10 +3242,11 @@ import { return Boolean(activeRoot && countOpenOrderRowsByKey(activeRoot, symbol, key) < previousCount); } - async function cancelOpenOrderRowsForPlan(root, plan) { + async function cancelOpenOrderRowsForPlan(root, plan, abortSignal = null) { let cancelQty = '0'; let currentRoot = root; while (compareDecimalStrings(cancelQty, plan.totalQty) < 0) { + throwIfAborted(abortSignal); if (!isCurrentObservedSymbol(plan.symbol)) throw new Error('逐行撤单前交易对已变化'); const remainingQty = subtractDecimalStrings(plan.totalQty, cancelQty); const refreshedRoot = getActiveOpenOrdersScope(); @@ -3187,6 +3267,7 @@ import { throw new Error('当前币挂单撤单入口已失效,已停止重挂'); } const dialogsBefore = new Set(getVisibleDialogs()); + throwIfAborted(abortSignal); if (!clickDomTarget(row.cancelButton)) { throw new Error('当前币挂单撤单入口点击失败,已停止重挂'); } @@ -3196,6 +3277,7 @@ import { row.key, previousKeyCount, dialogsBefore, + abortSignal, ); if (outcome.status === 'symbol_changed') { throw new Error('逐行撤单时交易对已变化'); @@ -3206,25 +3288,47 @@ import { if (outcome.status === 'dialog_open') { const { dialog } = outcome; setLadderStatus(`${plan.symbol} 单行撤单确认弹窗已打开`); - const dialogClosed = await waitForDialogToClose(dialog); + const dialogClosed = await waitForDialogToClose( + dialog, + ROW_CANCEL_DIALOG_CLOSE_TIMEOUT_MS, + abortSignal, + ); if (!dialogClosed) { const error = new Error(`${plan.symbol} 单行撤单确认弹窗仍未关闭,未恢复页面状态`); error.name = 'DialogNotClosedError'; throw error; } - if (!(await waitForOpenOrderRowKeyCountBelow(plan.symbol, row.key, previousKeyCount))) { + if (!(await waitForOpenOrderRowKeyCountBelow( + plan.symbol, + row.key, + previousKeyCount, + abortSignal, + ))) { throw new Error(`${plan.symbol} 当前币挂单仍存在,已停止重挂`); } } - if (!(await confirmOpenOrderRowKeyCountBelow(plan.symbol, row.key, previousKeyCount))) { + throwIfAborted(abortSignal); + if (!(await confirmOpenOrderRowKeyCountBelow( + plan.symbol, + row.key, + previousKeyCount, + abortSignal, + ))) { throw new Error(`${plan.symbol} 当前币挂单状态不稳定,已停止重挂`); } + throwIfAborted(abortSignal); cancelQty = addDecimalStrings(cancelQty, row.qty); } return { ok: true, cancelQty }; } - async function setHideOtherSymbolChecked(root, desiredChecked, symbol = getCurrentSymbol()) { + async function setHideOtherSymbolChecked( + root, + desiredChecked, + symbol = getCurrentSymbol(), + abortSignal = null, + ) { + throwIfAborted(abortSignal); if (!isCurrentObservedSymbol(symbol)) return false; const checkbox = findHideOtherSymbolCheckbox(root); if (!checkbox) return false; @@ -3232,17 +3336,19 @@ import { if (currentChecked === desiredChecked) return true; if (currentChecked === null) return false; if (!isCurrentObservedSymbol(symbol)) return false; + throwIfAborted(abortSignal); checkbox.click(); const updatedCheckbox = await waitForAccountOrdersState(() => { if (!isCurrentObservedSymbol(symbol)) return null; const currentRoot = getActiveOpenOrdersScope(); const currentCheckbox = findHideOtherSymbolCheckbox(currentRoot); return getCheckboxCheckedState(currentCheckbox) === desiredChecked ? currentCheckbox : null; - }, 1000); + }, 1000, abortSignal); return Boolean(updatedCheckbox) && isCurrentObservedSymbol(symbol); } - async function ensureOpenOrdersLimitedToCurrentSymbol(root, symbol) { + async function ensureOpenOrdersLimitedToCurrentSymbol(root, symbol, abortSignal = null) { + throwIfAborted(abortSignal); const checkbox = findHideOtherSymbolCheckbox(root); if (!checkbox) { return { @@ -3257,7 +3363,12 @@ import { originalChecked, }; } - if (!originalChecked && !(await setHideOtherSymbolChecked(root, true, symbol))) { + if (!originalChecked && !(await setHideOtherSymbolChecked( + root, + true, + symbol, + abortSignal, + ))) { return { ok: false, originalChecked }; } const settledRoot = await waitForAccountOrdersState(() => { @@ -3271,7 +3382,7 @@ import { filterChecked: getCheckboxCheckedState(currentCheckbox), cancelAllAvailable: Boolean(cancelAllButton), }) ? currentRoot : null; - }, 1600); + }, 1600, abortSignal); return { ok: Boolean(settledRoot) && isCurrentObservedSymbol(symbol), originalChecked, @@ -3299,11 +3410,16 @@ import { return null; } - async function waitForDialogToClose(dialog, timeoutMs = ROW_CANCEL_DIALOG_CLOSE_TIMEOUT_MS) { + async function waitForDialogToClose( + dialog, + timeoutMs = ROW_CANCEL_DIALOG_CLOSE_TIMEOUT_MS, + abortSignal = null, + ) { return Boolean(await waitForDialogMutationState( document, () => (!dialog.isConnected || !isVisibleElement(dialog) ? true : null), timeoutMs, + abortSignal, )); } @@ -3810,7 +3926,8 @@ import { scheduleRenderPanel(); } - async function cancelCurrentSymbolOpenOrdersForPlan(plan) { + async function cancelCurrentSymbolOpenOrdersForPlan(plan, abortSignal = null) { + throwIfAborted(abortSignal); const symbol = getCurrentSymbol(); if (!isCurrentObservedSymbol(symbol) || symbol !== plan?.symbol) { const message = '逐行撤单前交易对已变化'; @@ -3826,51 +3943,74 @@ import { try { setLadderStatus(`查找 ${symbol} 当前委托`); - const tabReady = await activateOpenOrdersTab(); + const tabReady = await activateOpenOrdersTab(abortSignal); + throwIfAborted(abortSignal); if (!tabReady || !isCurrentObservedSymbol(symbol)) { const message = '当前委托页未就绪或交易对已变化'; setLadderStatus(message); return { ok: false, status: 'tab_not_ready', message }; } - openOrdersScope = await waitForActiveOpenOrdersScope(); + openOrdersScope = await waitForActiveOpenOrdersScope(abortSignal); + throwIfAborted(abortSignal); if (!openOrdersScope) { const message = '未定位到当前委托面板'; setLadderStatus(message); return { ok: false, status: 'scope_not_found', message }; } - const basicSubTabState = await activateOpenOrdersBasicSubTab(openOrdersScope); - previousOpenOrdersSubTabIdentity = basicSubTabState.previousSubTabIdentity; + previousOpenOrdersSubTabIdentity = getOpenOrdersSubTabIdentity( + findSelectedOpenOrdersSubTab(openOrdersScope), + ); + const basicSubTabState = await activateOpenOrdersBasicSubTab( + openOrdersScope, + abortSignal, + ); + throwIfAborted(abortSignal); if (!basicSubTabState.ready) { const message = '未定位到当前委托基础单'; setLadderStatus(message); return { ok: false, status: 'basic_tab_not_ready', message }; } - openOrdersScope = await waitForActiveOpenOrdersScope(); + openOrdersScope = await waitForActiveOpenOrdersScope(abortSignal); + throwIfAborted(abortSignal); if (!openOrdersScope) { const message = '未定位到当前委托面板'; setLadderStatus(message); return { ok: false, status: 'scope_not_found', message }; } - const symbolFilter = await ensureOpenOrdersLimitedToCurrentSymbol(openOrdersScope, symbol); - symbolFilterOriginalChecked = symbolFilter.originalChecked; + symbolFilterOriginalChecked = getCheckboxCheckedState( + findHideOtherSymbolCheckbox(openOrdersScope), + ); + const symbolFilter = await ensureOpenOrdersLimitedToCurrentSymbol( + openOrdersScope, + symbol, + abortSignal, + ); + throwIfAborted(abortSignal); if (!symbolFilter.ok) { const message = '未确认只显示当前币挂单'; setLadderStatus(message); return { ok: false, status: 'symbol_filter_not_confirmed', message }; } - openOrdersScope = await waitForActiveOpenOrdersScope(); + openOrdersScope = await waitForActiveOpenOrdersScope(abortSignal); + throwIfAborted(abortSignal); if (!openOrdersScope) { const message = '未定位到当前委托面板'; setLadderStatus(message); return { ok: false, status: 'scope_not_found', message }; } - const rows = await waitForCurrentSymbolOpenOrderRows(openOrdersScope, symbol, plan); + const rows = await waitForCurrentSymbolOpenOrderRows( + openOrdersScope, + symbol, + plan, + abortSignal, + ); + throwIfAborted(abortSignal); if (!rows.length) { const directionLabel = getPlanDirectionLabel(plan); const message = `未定位到 ${symbol}${directionLabel ? ` ${directionLabel}` : ''} 当前币可逐行撤单的基础单`; @@ -3886,10 +4026,12 @@ import { } setLadderStatus(`${symbol} 撤销 ${rowsToCancel.length} 笔当前币挂单`); - await cancelOpenOrderRowsForPlan(openOrdersScope, plan); + await cancelOpenOrderRowsForPlan(openOrdersScope, plan, abortSignal); + throwIfAborted(abortSignal); setLadderStatus(`${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); @@ -3965,14 +4107,16 @@ import { }; } - async function runLadderPlanWithOpenOrderReplacement(actionType) { + async function runLadderPlanWithOpenOrderReplacement(actionType, abortSignal = null) { let replacementContext = null; for (let attempt = 0; attempt < 2; attempt += 1) { + throwIfAborted(abortSignal); let plan = null; try { plan = await buildLadderPlan(actionType, replacementContext); + throwIfAborted(abortSignal); setLadderStatus(formatLadderPlanStatus(plan)); - const execution = await executeLadderPlan(plan); + const execution = await executeLadderPlan(plan, abortSignal); return { plan, ...execution }; } catch (e) { const replacementPlan = getReplaceableLadderOpenOrdersPlan(plan, e); @@ -3980,7 +4124,11 @@ import { assertLadderExecutionContext(replacementPlan); setLadderStatus(formatOpenOrdersReplacementStatus(replacementPlan)); replacementContext = createLadderExpectedContext(replacementPlan); - const result = await cancelCurrentSymbolOpenOrdersForPlan(replacementPlan); + const result = await cancelCurrentSymbolOpenOrdersForPlan( + replacementPlan, + abortSignal, + ); + throwIfAborted(abortSignal); if (!result?.ok) throw new Error(result.message || '当前币挂单未替换,已停止重挂'); } } diff --git a/test/dom/binance-orderbook-trade/account-orders.test.js b/test/dom/binance-orderbook-trade/account-orders.test.js index eac9737..6738004 100644 --- a/test/dom/binance-orderbook-trade/account-orders.test.js +++ b/test/dom/binance-orderbook-trade/account-orders.test.js @@ -261,6 +261,25 @@ test('mutation wait reacts when Binance updates row text in place', async () => assert.ok(Date.now() - startedAt < 100, 'characterData should resolve through the observer, not timeout'); }); +test('mutation wait aborts immediately with the caller-provided reason', async () => { + const { window } = loadFixtureDom(openOrdersHtml); + const observationRoot = window.document.querySelector('#account-orders'); + const controller = new AbortController(); + const reason = new Error('ladder stopped'); + const startedAt = Date.now(); + const wait = waitForAccountOrdersMutationState( + observationRoot, + () => null, + 5000, + controller.signal, + ); + + controller.abort(reason); + + await assert.rejects(wait, (error) => error === reason); + assert.ok(Date.now() - startedAt < 100, 'abort should not wait for the mutation deadline'); +}); + test('account-orders mutation signal wakes once for a relevant subtree change', async () => { const { window } = loadFixtureDom(openOrdersHtml); const observationRoot = window.document.querySelector('#account-orders'); diff --git a/test/unit/binance-orderbook-trade/cancel-all-dialog.test.js b/test/unit/binance-orderbook-trade/cancel-all-dialog.test.js index e1d38be..9630538 100644 --- a/test/unit/binance-orderbook-trade/cancel-all-dialog.test.js +++ b/test/unit/binance-orderbook-trade/cancel-all-dialog.test.js @@ -130,3 +130,22 @@ test('dialog mutation state resolves when React removes the dialog wrapper', asy assert.equal(await pending, 'closed'); }); + +test('dialog mutation state aborts immediately with the caller-provided reason', async () => { + const dom = loadFixtureDom(createDialogMarkup()); + const { document } = dom.window; + const controller = new AbortController(); + const reason = new Error('ladder stopped'); + const startedAt = Date.now(); + const pending = waitForDialogMutationState( + document, + () => null, + 5000, + controller.signal, + ); + + controller.abort(reason); + + await assert.rejects(pending, (error) => error === reason); + assert.ok(Date.now() - startedAt < 100, 'abort should not wait for the dialog deadline'); +}); diff --git a/test/unit/binance-orderbook-trade/source-regressions.test.js b/test/unit/binance-orderbook-trade/source-regressions.test.js index d2326ec..581ec73 100644 --- a/test/unit/binance-orderbook-trade/source-regressions.test.js +++ b/test/unit/binance-orderbook-trade/source-regressions.test.js @@ -194,9 +194,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\)/); + assert.match(retryBody, /await executeLadderPlan\(plan,\s*abortSignal\)/); assert.match(retryBody, /getReplaceableLadderOpenOrdersPlan\(plan,\s*e\)/); - assert.match(retryBody, /cancelCurrentSymbolOpenOrdersForPlan\(replacementPlan\)/); + assert.match(retryBody, /cancelCurrentSymbolOpenOrdersForPlan\(\s*replacementPlan,\s*abortSignal/); assert.doesNotMatch(retryBody, /cancelCurrentSymbolOpenOrders\(\{\s*waitUntilCleared: true\s*\}\)/); assert.match(retryBody, /replacementContext = createLadderExpectedContext\(replacementPlan\)/); assert.match(retryBody, /buildLadderPlan\(actionType,\s*replacementContext\)/); @@ -205,7 +205,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\)/); + assert.match(startBody, /runLadderPlanWithOpenOrderReplacement\(actionType,\s*abortController\.signal\)/); }); test('open and close ladders reprice only remaining orders after explicit maker conflicts', () => { @@ -626,9 +626,9 @@ test('ladder replacement cancels visible current-symbol same-direction rows up t assert.match(cancelOpenOrderRowsBody, /clickDomTarget\(row\.cancelButton\)/); assert.match(cancelOpenOrderRowsBody, /waitForOpenOrderRowCancellationOutcome\(/); assert.match(cancelOpenOrderRowsBody, /outcome\.status === 'dialog_open'/); - assert.match(cancelOpenOrderRowsBody, /confirmOpenOrderRowKeyCountBelow\(plan\.symbol,\s*row\.key,\s*previousKeyCount\)/); + 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\(dialog\)/); + 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\(\)/); @@ -665,9 +665,9 @@ test('ladder replacement cancels visible current-symbol same-direction rows up t const cancelRowsBody = readFunctionBody('cancelCurrentSymbolOpenOrdersForPlan'); assert.match(cancelRowsBody, /if \(!isCurrentObservedSymbol\(symbol\) \|\| symbol !== plan\?\.symbol\)/); - assert.match(cancelRowsBody, /activateOpenOrdersBasicSubTab\(openOrdersScope\)[\s\S]*openOrdersScope = await waitForActiveOpenOrdersScope\(\)/); + assert.match(cancelRowsBody, /activateOpenOrdersBasicSubTab\(\s*openOrdersScope,\s*abortSignal[\s\S]*openOrdersScope = await waitForActiveOpenOrdersScope\(abortSignal\)/); assert.match(cancelRowsBody, /if \(!openOrdersScope\) \{\s*const message = '未定位到当前委托面板'/); - assert.match(cancelRowsBody, /waitForCurrentSymbolOpenOrderRows\(openOrdersScope,\s*symbol,\s*plan\)/); + assert.match(cancelRowsBody, /waitForCurrentSymbolOpenOrderRows\(\s*openOrdersScope,\s*symbol,\s*plan,\s*abortSignal/); assert.doesNotMatch(cancelRowsBody, /const openOrdersCount = getOpenOrdersTabCount\(\)/); assert.match(cancelRowsBody, /getPlanDirectionLabel\(plan\)/); assert.match(cancelRowsBody, /selectOpenOrderRowsToCancelForPlan\(plan,\s*rows\)/); @@ -680,6 +680,32 @@ test('ladder replacement cancels visible current-symbol same-direction rows up t assert.doesNotMatch(cancelRowsBody, /findCurrentSymbolCancelAllButton/); }); +test('stopping a ladder aborts replacement waits before another cancel or submit click', () => { + const startBody = readFunctionBody('startLadder'); + const stopBody = readFunctionBody('stopLadder'); + const runBody = readFunctionBody('runLadderPlanWithOpenOrderReplacement'); + const executeBody = readFunctionBody('executeLadderPlan'); + const cancelPlanBody = readFunctionBody('cancelCurrentSymbolOpenOrdersForPlan'); + const cancelRowsBody = readFunctionBody('cancelOpenOrderRowsForPlan'); + const waitOutcomeBody = readFunctionBody('waitForOpenOrderRowCancellationOutcome'); + + assert.match(source, /let ladderAbortController = null/); + assert.match(startBody, /const abortController = new AbortController\(\)/); + assert.match(startBody, /runLadderPlanWithOpenOrderReplacement\(actionType,\s*abortController\.signal\)/); + assert.match(stopBody, /ladderAbortController\.abort\(createLadderStoppedError\(\)\)/); + assert.match(runBody, /cancelCurrentSymbolOpenOrdersForPlan\(\s*replacementPlan,\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, /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(waitOutcomeBody, /waitForPromiseOrAbort\([\s\S]*abortSignal/); +}); + test('bulk cancel hides TradingView orders before opening the native dialog and restores them independently', () => { assert.match(tradingViewOrdersSource, /applyOverrides/); assert.match(tradingViewOrdersSource, /tradingProperties\.showOrders/); @@ -1249,7 +1275,7 @@ test('cancel flow rechecks the captured symbol before destructive click and clea const accountWaitBody = readFunctionBody('waitForAccountOrdersState'); assert.match(accountWaitBody, /getAccountOrdersObservationRoot\(\) \|\| document\.body/); - assert.match(accountWaitBody, /waitForAccountOrdersMutationState\(observationRoot, readState, timeoutMs\)/); + assert.match(accountWaitBody, /waitForAccountOrdersMutationState\(\s*observationRoot,\s*readState,\s*timeoutMs,\s*abortSignal/); const activateTabBody = readFunctionBody('activateOpenOrdersTab'); assert.doesNotMatch(activateTabBody, /delay\(/);