diff --git a/e2e/binance-orderbook/fixtures/binance-futures.js b/e2e/binance-orderbook/fixtures/binance-futures.js index 2e7c4d1..68b6298 100644 --- a/e2e/binance-orderbook/fixtures/binance-futures.js +++ b/e2e/binance-orderbook/fixtures/binance-futures.js @@ -335,7 +335,9 @@ export function renderBinanceFuturesFixture(scenario) { record('cancel-requested', { symbol: scenario.currentSymbol }); setTimeout(() => { if (scenario.host.clearMode === 'currentSymbol') { + const removedOrders = currentOrders().map((item) => ({ ...item })); state.orders = state.orders.filter((item) => item.symbol !== scenario.currentSymbol); + scheduleChartOrderRemovals(removedOrders); } closeDialog('confirm'); renderAccountWidget(); @@ -402,6 +404,25 @@ export function renderBinanceFuturesFixture(scenario) { }, }; + function scheduleChartOrderRemovals(orders) { + if (!state.showOrders) return; + orders.forEach((order, index) => { + setTimeout(() => { + tradingViewApi.emit('drawing_event', 'order-' + order.id, 'remove'); + record('chart-save-requested', { + checked: true, + index, + orderId: order.id, + }); + tradingViewApi.saveChart({ + checked: true, + drawingCount: Math.max(0, orders.length - index - 1), + finalOrderId: order.id, + }); + }, scenario.host.mutationDelayMs + (index * 2)); + }); + } + function renderChartOrdersPopover() { chartOrdersPopover.innerHTML = '
仓位
' + diff --git a/e2e/binance-orderbook/specs/cancel-covering-matrix.pw.js b/e2e/binance-orderbook/specs/cancel-covering-matrix.pw.js index e4febd1..0710b79 100644 --- a/e2e/binance-orderbook/specs/cancel-covering-matrix.pw.js +++ b/e2e/binance-orderbook/specs/cancel-covering-matrix.pw.js @@ -51,7 +51,9 @@ for (const entry of CANCEL_COVERING_SCENARIOS) { await expect(page.getByRole('button', { name: '无挂单' })).toBeVisible(); } else { await expect(page.getByRole('dialog')).toBeVisible(); - await expect.poll(async () => (await readFixtureState(page)).showOrders).toBe(false); + await expect.poll(async () => (await readFixtureState(page)).showOrders).toBe( + scenario.ui.showOrders, + ); await page.getByRole('button', { name: vector.dialogOutcome === 'confirm' ? '确认' : '取消', }).click(); @@ -79,19 +81,16 @@ for (const entry of CANCEL_COVERING_SCENARIOS) { hasCurrentOrders && vector.dialogOutcome === 'confirm' ? 1 : 0, ); const currentOrderCount = currentSymbolOrders(scenario).length; - const expectedChartToggleCount = hasCurrentOrders && scenario.ui.showOrders ? 2 : 0; - const expectedChartSaveBurstCount = expectedChartToggleCount === 0 - ? 0 - : vector.dialogOutcome === 'confirm' ? 1 : 2; - const expectedChartSaveRequestCount = expectedChartSaveBurstCount * currentOrderCount; - expect(state.events.filter((event) => event.type === 'chart-orders-checked')).toHaveLength( - expectedChartToggleCount, - ); + const expectsChartRemovalSave = hasCurrentOrders + && scenario.ui.showOrders + && vector.dialogOutcome === 'confirm'; + const expectedChartSaveRequestCount = expectsChartRemovalSave ? currentOrderCount : 0; + expect(state.events.filter((event) => event.type === 'chart-orders-checked')).toHaveLength(0); expect(state.events.filter((event) => event.type === 'chart-save-requested')).toHaveLength( expectedChartSaveRequestCount, ); expect(state.events.filter((event) => event.type === 'chart-saved')).toHaveLength( - expectedChartSaveBurstCount, + expectsChartRemovalSave ? 1 : 0, ); expect(errors).toEqual([]); }); diff --git a/e2e/binance-orderbook/specs/cancel-current-symbol.pw.js b/e2e/binance-orderbook/specs/cancel-current-symbol.pw.js index 82234e9..53c2563 100644 --- a/e2e/binance-orderbook/specs/cancel-current-symbol.pw.js +++ b/e2e/binance-orderbook/specs/cancel-current-symbol.pw.js @@ -92,7 +92,7 @@ test('cancelling the native dialog preserves current and other orders and restor await page.getByRole('button', { name: '撤单' }).click(); await expect(page.getByRole('dialog')).toBeVisible(); - await expect.poll(async () => (await readFixtureState(page)).showOrders).toBe(false); + await expect.poll(async () => (await readFixtureState(page)).showOrders).toBe(true); await page.getByRole('button', { name: '取消' }).click(); await expect(page.getByText('撤单已取消')).toBeVisible(); @@ -105,7 +105,7 @@ test('cancelling the native dialog preserves current and other orders and restor expect(errors).toEqual([]); }); -test('a 70-order drawing burst split across tasks performs one full save per toggle', async ({ page }) => { +test('a 70-order confirmed cancellation keeps drawings visible and performs one final full save', async ({ page }) => { const orders = Array.from({ length: 70 }, (_, index) => ({ id: `current-${index + 1}`, symbol: 'HYPEUSDT', @@ -123,40 +123,26 @@ test('a 70-order drawing burst split across tasks performs one full save per tog await page.getByRole('button', { name: '撤单' }).click(); await expect(page.getByRole('dialog')).toBeVisible(); - await expect.poll(async () => (await readFixtureState(page)).showOrders).toBe(false); - await page.getByRole('button', { name: '取消' }).click(); - await expect(page.getByText('撤单已取消')).toBeVisible(); + await expect.poll(async () => (await readFixtureState(page)).showOrders).toBe(true); + await page.getByRole('button', { name: '确认' }).click(); + await expect(page.getByText('撤单已完成')).toBeVisible(); const state = await readFixtureState(page); - expect(state.orders).toEqual(orders); + expect(state.orders).toEqual([]); expect( state.events.filter((event) => event.type === 'chart-save-requested'), - ).toHaveLength(140); - expect(state.events.filter((event) => event.type === 'chart-saved')).toHaveLength(2); + ).toHaveLength(70); + expect(state.events.filter((event) => event.type === 'chart-saved')).toHaveLength(1); expect( state.events .filter((event) => event.type === 'chart-orders-checked') .map((event) => event.value), - ).toEqual([false, true]); - for (const checked of [false, true]) { - const checkedIndex = state.events.findIndex( - (event) => event.type === 'chart-orders-checked' && event.value === checked, - ); - const popoverClosedIndex = state.events.findIndex( - (event, index) => index > checkedIndex && event.type === 'chart-orders-popover-closed', - ); - const finalSaveRequestIndex = state.events.findLastIndex( - (event) => event.type === 'chart-save-requested' && event.checked === checked, - ); - const fullSaveIndex = state.events.findIndex( - (event, index) => index > finalSaveRequestIndex - && event.type === 'chart-saved' - && event.snapshot.checked === checked, - ); - expect(popoverClosedIndex).toBeGreaterThan(checkedIndex); - expect(popoverClosedIndex).toBeLessThan(finalSaveRequestIndex); - expect(popoverClosedIndex).toBeLessThan(fullSaveIndex); - } + ).toEqual([]); + const finalSaveRequestIndex = state.events.findLastIndex( + (event) => event.type === 'chart-save-requested', + ); + const fullSaveIndex = state.events.findIndex((event) => event.type === 'chart-saved'); + expect(fullSaveIndex).toBeGreaterThan(finalSaveRequestIndex); await expectRestoredState(page, scenario); const probe = await finishInteractionProbe(page); assertResponsiveInteraction(expect, probe); @@ -164,7 +150,7 @@ test('a 70-order drawing burst split across tasks performs one full save per tog expect(errors).toEqual([]); }); -test('a popover close failure does not discard coalesced chart saves', async ({ page }) => { +test('bulk cancel no longer depends on the chart orders popover', async ({ page }) => { const scenario = createCancelScenario({ positions: POSITION_SETS.current, orders: ORDER_SETS.current, @@ -174,17 +160,19 @@ test('a popover close failure does not discard coalesced chart saves', async ({ const { errors } = await openUserscriptScenario(page, scenario); await page.getByRole('button', { name: '撤单' }).click(); - await expect(page.getByText('未能恢复图表当前委托显示')).toBeVisible({ timeout: 6_000 }); + await expect(page.getByRole('dialog')).toBeVisible(); + await page.getByRole('button', { name: '取消' }).click(); + await expect(page.getByText('撤单已取消')).toBeVisible(); const state = await readFixtureState(page); expect(state.dialogOpen).toBe(false); expect(state.orders).toEqual(ORDER_SETS.current); expect(state.showOrders).toBe(true); - expect(state.events.filter((event) => event.type === 'chart-save-requested')).toHaveLength(2); - expect(state.events.filter((event) => event.type === 'chart-saved')).toHaveLength(2); + expect(state.events.filter((event) => event.type === 'chart-save-requested')).toHaveLength(0); + expect(state.events.filter((event) => event.type === 'chart-saved')).toHaveLength(0); expect( state.events.filter((event) => event.type === 'chart-orders-popover-close-requested'), - ).toHaveLength(2); + ).toHaveLength(0); expect(errors).toEqual([]); }); @@ -317,7 +305,7 @@ test('a real pagehide aborts dialog tracking without mutating orders', async ({ const state = await readFixtureState(page); expect(state.orders).toEqual(ORDER_SETS.current); expect(state.events.filter((event) => event.type === 'cancel-requested')).toEqual([]); - expect(state.showOrders).toBe(false); + expect(state.showOrders).toBe(true); expect(state.hideOtherSymbols).toBe(true); expect(errors).toEqual([]); }); @@ -360,11 +348,7 @@ for (const dialogMode of ['extraAction', 'missingPrimary']) { const state = await readFixtureState(page); expect(state.orders).toEqual(ORDER_SETS.current); expect(state.events.filter((event) => event.type === 'cancel-requested')).toEqual([]); - expect( - state.events - .filter((event) => event.type === 'chart-orders-checked') - .map((event) => event.value), - ).toEqual([false, true]); + expect(state.events.filter((event) => event.type === 'chart-orders-checked')).toEqual([]); expect(state.showOrders).toBe(true); expect(state.hideOtherSymbols).toBe(true); expect(errors).toEqual([]); diff --git a/scripts/binance-orderbook-trade.user.js b/scripts/binance-orderbook-trade.user.js index 44719dc..6ce4573 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.179 +// @version 2.7.180 // @author jackhai9 // @description 单击订单簿价格,按当前开仓/平仓 tab 自动填数量并执行下单,内置数量倍率面板 // @match https://www.binance.com/*/futures/* @@ -2792,10 +2792,6 @@ // src/binance-orderbook-trade/core/chart-orders-recovery.js var CHART_ORDERS_RECOVERY_STORAGE_KEY = "binance-orderbook-trade:chart-orders-recovery:v2"; - function createChartOrdersRecoveryRecord(nowMs) { - if (!Number.isFinite(nowMs)) throw new Error("图表委托线恢复时间无效"); - return JSON.stringify({ version: 2, originalChecked: true, createdAtMs: nowMs }); - } function parseChartOrdersRecoveryRecord(rawValue, nowMs) { if (rawValue === null) return { status: "missing", record: null }; if (!Number.isFinite(nowMs)) throw new Error("图表委托线恢复当前时间无效"); @@ -3099,6 +3095,169 @@ } }; } + function createTradingViewRemovalSaveController(api, { + settleQuietMs = 140, + maxWaitMs = 600, + eventDiscoveryMs = 250, + setTimeoutFn = setTimeout, + clearTimeoutFn = clearTimeout + } = {}) { + validateTradingViewApi(api); + if (!Number.isFinite(settleQuietMs) || settleQuietMs <= 0) { + throw new Error("删除事件保存合并静默时间无效"); + } + if (!Number.isFinite(maxWaitMs) || maxWaitMs < settleQuietMs) { + throw new Error("删除事件保存合并最长等待时间无效"); + } + if (!Number.isFinite(eventDiscoveryMs) || eventDiscoveryMs < 0) { + throw new Error("删除事件发现时间无效"); + } + const sessionSaveChart = api.saveChart; + const sessionDescriptor = Object.getOwnPropertyDescriptor(api, "saveChart"); + let activeBurst = null; + let controllerError = null; + let discoveryTimer = null; + let finished = false; + let fullSaveCount = 0; + let pendingFinalSave = null; + let removeEventCount = 0; + let saveRequestCount = 0; + let synchronousSaveCount = 0; + let firstRemoveResolve; + const firstRemove = new Promise((resolve) => { + firstRemoveResolve = resolve; + }); + const getStats = () => ({ + fullSaveCount, + removeEventCount, + saveRequestCount, + synchronousSaveCount + }); + const monitoredSaveChart = function monitoredRemovalSessionSaveChart(...args) { + synchronousSaveCount += 1; + pendingFinalSave = null; + return sessionSaveChart.apply(this, args); + }; + const clearBurstTimers = (burst) => { + if (burst.settleTimer !== null) clearTimeoutFn(burst.settleTimer); + if (burst.maxWaitTimer !== null) clearTimeoutFn(burst.maxWaitTimer); + burst.settleTimer = null; + burst.maxWaitTimer = null; + }; + const finishBurst = () => { + const burst = activeBurst; + if (!burst) return; + clearBurstTimers(burst); + activeBurst = null; + if (api.saveChart !== burst.wrapper) { + controllerError || (controllerError = new Error("图表保存接口在删除事件合并期间发生变化")); + pendingFinalSave = null; + burst.resolve(); + return; + } + restoreSaveChartMethod( + api, + burst.wrapper, + burst.originalSaveChart, + burst.originalDescriptor + ); + if (burst.pendingSave) pendingFinalSave = burst.pendingSave; + burst.resolve(); + }; + const scheduleBurstSettle = (burst) => { + if (burst.settleTimer !== null) clearTimeoutFn(burst.settleTimer); + burst.settleTimer = setTimeoutFn(finishBurst, settleQuietMs); + }; + const startBurst = () => { + if (activeBurst) return activeBurst; + if (api.saveChart !== monitoredSaveChart) { + controllerError || (controllerError = new Error("图表保存接口正被其他操作占用")); + return null; + } + let resolve; + const settled = new Promise((settle) => { + resolve = settle; + }); + const burst = { + maxWaitTimer: null, + originalDescriptor: Object.getOwnPropertyDescriptor(api, "saveChart"), + originalSaveChart: api.saveChart, + pendingSave: null, + resolve, + settleTimer: null, + settled, + wrapper: null + }; + burst.wrapper = function removalSaveBurstWrapper(...args) { + saveRequestCount += 1; + burst.pendingSave = { thisValue: this, args }; + }; + api.saveChart = burst.wrapper; + if (api.saveChart !== burst.wrapper) { + throw new Error("图表保存接口无法启用删除事件合并"); + } + activeBurst = burst; + burst.maxWaitTimer = setTimeoutFn(finishBurst, maxWaitMs); + return burst; + }; + const handleDrawingEvent = (_drawingId, eventType) => { + if (finished || eventType !== "remove") return; + removeEventCount += 1; + if (removeEventCount === 1) firstRemoveResolve(); + try { + const burst = startBurst(); + if (burst) scheduleBurstSettle(burst); + } catch (error) { + controllerError || (controllerError = error); + } + }; + api.subscribe("drawing_event", handleDrawingEvent); + try { + api.saveChart = monitoredSaveChart; + if (api.saveChart !== monitoredSaveChart) { + throw new Error("图表保存接口无法启用删除事件监视"); + } + } catch (error) { + api.unsubscribe("drawing_event", handleDrawingEvent); + throw error; + } + return { + getStats, + async finish() { + if (finished) throw new Error("删除事件保存合并已结束"); + if (removeEventCount === 0 && eventDiscoveryMs > 0) { + await Promise.race([ + firstRemove, + new Promise((resolve) => { + discoveryTimer = setTimeoutFn(resolve, eventDiscoveryMs); + }) + ]); + } + if (discoveryTimer !== null) clearTimeoutFn(discoveryTimer); + discoveryTimer = null; + if (activeBurst) await activeBurst.settled; + finished = true; + api.unsubscribe("drawing_event", handleDrawingEvent); + if (api.saveChart === monitoredSaveChart) { + restoreSaveChartMethod( + api, + monitoredSaveChart, + sessionSaveChart, + sessionDescriptor + ); + } else { + controllerError || (controllerError = new Error("图表保存接口在删除事件监视期间发生变化")); + pendingFinalSave = null; + } + if (pendingFinalSave) { + fullSaveCount += 1; + sessionSaveChart.apply(pendingFinalSave.thisValue, pendingFinalSave.args); + } + if (controllerError) throw controllerError; + return getStats(); + } + }; + } async function coalesceTradingViewDrawingSaves(api, action, { eventDiscoveryTimeoutMs = 800, settleQuietMs = 50, @@ -4344,13 +4503,12 @@ ["读取挂单时交易对已变化", "Symbol changed while reading open orders"], ["未找到当前委托的全撤按钮", "Cancel All was not found in Open Orders"], ["撤单前交易对已变化", "Symbol changed before cancellation"], - ["未能准备撤单页面,未打开确认弹窗", "Could not prepare the cancellation page; confirmation was not opened"], - ["准备撤单时交易对已变化", "Symbol changed while preparing cancellation"], ["准备撤单时未找到当前委托面板", "Open Orders panel was not found while preparing cancellation"], ["准备撤单时未确认仅显示当前交易对挂单", "Could not confirm this-symbol-only orders while preparing cancellation"], ["准备撤单时未找到全撤按钮", "Cancel All was not found while preparing cancellation"], ["撤单确认弹窗已打开", "Cancellation confirmation opened"], ["撤单确认弹窗结构异常,未执行弹窗操作", "Cancellation dialog changed; no dialog action was taken"], + ["未能准备撤单图表保存,未执行撤单", "Could not prepare chart saving; cancellation was not executed"], ["确认撤单前交易对已变化", "Symbol changed before cancellation was confirmed"], ["未识别到撤单确认弹窗,未继续撤单流程", "Cancellation dialog was not detected; cancellation stopped"], ["撤单已取消", "Cancellation cancelled"], @@ -4362,7 +4520,7 @@ ["当前交易对挂单仍存在,撤单未完成", "Open orders still exist for this symbol; cancellation is incomplete"], ["原挂单已撤,继续阶梯挂单", "Previous orders cancelled; continuing ladder placement"], ["撤单已完成", "Cancellation completed"], - ["未能恢复图表当前委托显示", "Could not restore chart open-order display"], + ["撤单已执行,但图表保存合并失败", "Cancellation executed, but chart-save coalescing failed"], ["阶梯任务运行中,请先停止阶梯挂单", "A ladder task is running; stop it first"], ["连续交易运行中,请先停止阶梯挂单", "Continuous trading is running; stop it first"], ["正在读取账户再平衡计划", "Loading account rebalance plan"], @@ -7900,7 +8058,11 @@ abortSignal )); } - function createBinanceCancelAllDialogDecisionWatcher() { + function createBinanceCancelAllDialogDecisionWatcher(options = null) { + const { onConfirmed } = options || {}; + if (onConfirmed !== void 0 && typeof onConfirmed !== "function") { + throw new Error("撤单确认回调无效"); + } const lifecycleController = new AbortController(); const dialogSignal = createDialogMutationSignal(document); if (!dialogSignal) throw new Error("撤单确认弹窗状态无法观察,已停止"); @@ -7910,12 +8072,16 @@ seenDialog: false, dialogSignal }; + const recordResolvedAction = (action) => { + if (!action || watcher.action) return; + if (action === "confirmed") onConfirmed?.(); + watcher.action = action; + }; const recordAction = (eventTarget) => { const contract = findBinanceCancelAllDialog(document, isVisibleElement); if (!contract) return; watcher.seenDialog = true; - const action = classifyBinanceCancelAllDialogAction(contract, eventTarget); - if (action && !watcher.action) watcher.action = action; + recordResolvedAction(classifyBinanceCancelAllDialogAction(contract, eventTarget)); }; const rejectInvalidDialogAction = (event, error) => { event.preventDefault(); @@ -7942,7 +8108,7 @@ event.key, document.activeElement || event.target ); - if (action && !watcher.action) watcher.action = action; + recordResolvedAction(action); } catch (error) { rejectInvalidDialogAction(event, error); } finally { @@ -8000,12 +8166,6 @@ function getBinanceChartOrdersTarget2() { return getBinanceChartOrdersTarget(document); } - function writeChartOrdersRecoveryRecord() { - sessionStorage.setItem( - CHART_ORDERS_RECOVERY_STORAGE_KEY, - createChartOrdersRecoveryRecord(Date.now()) - ); - } function clearChartOrdersRecoveryRecord() { sessionStorage.removeItem(CHART_ORDERS_RECOVERY_STORAGE_KEY); } @@ -8129,34 +8289,15 @@ fullSaves: result.fullSaveCount }); } - async function hideBinanceChartOrdersForBulkCancel(target, state) { - const current = await openBinanceChartOrdersPopover(target); - state.originalChecked = current.checked; - if (current.checked) { - writeChartOrdersRecoveryRecord(); - state.changed = true; - await toggleBinanceChartOrdersWithCoalescedSave( - target, - current.checkbox, - false, - true - ); - return; - } - await closeBinanceChartOrdersPopover(target); - } - async function restoreBinanceChartOrdersAfterBulkCancel(target, state, expectDrawingEvents) { - if (typeof expectDrawingEvents !== "boolean") { - throw new Error("图表委托线保存参数异常"); - } + async function restoreLegacyHiddenBinanceChartOrders(target) { assertSameBinanceChartOrdersTarget(target, getBinanceChartOrdersTarget2()); const current = await openBinanceChartOrdersPopover(target); - if (current.checked !== state.originalChecked) { + if (!current.checked) { await toggleBinanceChartOrdersWithCoalescedSave( target, current.checkbox, - state.originalChecked, - expectDrawingEvents + true, + true ); } else { await closeBinanceChartOrdersPopover(target); @@ -8177,10 +8318,7 @@ } const target = findBinanceChartOrdersTarget(document); if (!target) return { status: "target_not_ready" }; - await restoreBinanceChartOrdersAfterBulkCancel(target, { - originalChecked: recovery.record.originalChecked, - changed: true - }, true); + await restoreLegacyHiddenBinanceChartOrders(target); chartOrdersRecoveryPendingAtStartup = false; chartOrdersRecoveryLastError = null; log("已恢复刷新前的图表当前委托显示状态"); @@ -8221,11 +8359,30 @@ let previousOpenOrdersSubTabIdentity = null; let symbolFilterOriginalChecked = null; let restoreTemporaryUiState = true; - let chartOrdersTarget = null; - const chartOrdersState = { originalChecked: null, changed: false }; - let restoreChartOrdersState = true; - let chartOrdersDefinitivelyCleared = false; + let chartRemovalSaveController = null; let successStatusMessage = null; + const armChartSaveCoalescing = () => { + try { + if (chartRemovalSaveController) { + throw new Error("撤单图表保存合并已启动"); + } + const target = findBinanceTradingViewTarget(document); + if (!target) throw new Error("未找到图表保存接口"); + chartRemovalSaveController = createTradingViewRemovalSaveController( + target.tradingViewApi + ); + } catch (error) { + error.name = "ChartSaveCoalescingError"; + throw error; + } + }; + const finishChartSaveCoalescing = async () => { + if (!chartRemovalSaveController) return; + const controller = chartRemovalSaveController; + chartRemovalSaveController = null; + const result = await controller.finish(); + log("撤单图表保存已合并", result); + }; try { const tabReady = await activateOpenOrdersTab(); if (!isCurrentObservedSymbol(symbol)) { @@ -8293,20 +8450,6 @@ } cancelCurrentSymbolOpenOrdersBlocksLadderActions = true; scheduleRenderPanel(); - try { - chartOrdersTarget = getBinanceChartOrdersTarget2(); - await hideBinanceChartOrdersForBulkCancel(chartOrdersTarget, chartOrdersState); - } catch (e) { - emit("ERR", "撤单前隐藏图表当前委托失败", e); - const message = "未能准备撤单页面,未打开确认弹窗"; - setLadderStatus(message); - return { ok: false, status: "chart_orders_not_hidden", message }; - } - if (!isCurrentObservedSymbol(symbol)) { - const message = "准备撤单时交易对已变化"; - setLadderStatus(message); - return { ok: false, status: "symbol_changed", message }; - } openOrdersScope = await waitForActiveOpenOrdersScope(); if (!openOrdersScope || !isCurrentObservedSymbol(symbol)) { const message = "准备撤单时未找到当前委托面板"; @@ -8324,7 +8467,9 @@ setLadderStatus(message); return { ok: false, status: "cancel_button_not_found", message }; } - const dialogDecisionWatcher = createBinanceCancelAllDialogDecisionWatcher(); + const dialogDecisionWatcher = createBinanceCancelAllDialogDecisionWatcher({ + onConfirmed: armChartSaveCoalescing + }); let dialogDecision; try { cancelAllButton.click(); @@ -8335,16 +8480,20 @@ ); } catch (error) { restoreTemporaryUiState = false; - emit("ERR", "币安撤单确认弹窗结构异常", error); - const message = "撤单确认弹窗结构异常,未执行弹窗操作"; + const chartSaveSetupFailed = error?.name === "ChartSaveCoalescingError"; + emit("ERR", chartSaveSetupFailed ? "撤单图表保存合并启动失败" : "币安撤单确认弹窗结构异常", error); + const message = chartSaveSetupFailed ? "未能准备撤单图表保存,未执行撤单" : "撤单确认弹窗结构异常,未执行弹窗操作"; setLadderStatus(message); - return { ok: false, status: "dialog_contract_invalid", message }; + return { + ok: false, + status: chartSaveSetupFailed ? "chart_save_not_ready" : "dialog_contract_invalid", + message + }; } finally { dialogDecisionWatcher.dispose(); } if (dialogDecision.status === "aborted") { restoreTemporaryUiState = false; - restoreChartOrdersState = false; const interruptedBaseAsset = formatStatusBaseAsset(symbol); const message = `原交易对 ${interruptedBaseAsset} 页面已离开,撤单确认跟踪已停止`; setLadderStatus(message); @@ -8395,10 +8544,17 @@ setLadderStatus(message); return { ok: false, status: "not_cleared", message }; } - chartOrdersDefinitivelyCleared = clearResult.definitivelyCleared === true; successStatusMessage = waitUntilCleared ? "原挂单已撤,继续阶梯挂单" : "撤单已完成"; return { ok: true, status: "cleared" }; } finally { + let chartSaveCoalescingSucceeded = true; + try { + await finishChartSaveCoalescing(); + } catch (error) { + chartSaveCoalescingSucceeded = false; + emit("ERR", "撤单图表保存合并失败", error); + setLadderStatus("撤单已执行,但图表保存合并失败"); + } let temporaryUiRestoreSucceeded = true; if (restoreTemporaryUiState && isCurrentObservedSymbol(symbol)) { openOrdersScope = await waitForActiveOpenOrdersScope(); @@ -8416,22 +8572,7 @@ temporaryUiRestoreSucceeded = await restoreAccountOrdersTab(previousAccountOrdersTabIdentity, symbol) && temporaryUiRestoreSucceeded; } } - let chartOrdersRestoreSucceeded = true; - if (restoreChartOrdersState && chartOrdersState.changed) { - try { - const chartOrdersStillDefinitivelyCleared = chartOrdersDefinitivelyCleared && getOpenOrdersTabCount() === 0; - await restoreBinanceChartOrdersAfterBulkCancel( - chartOrdersTarget, - chartOrdersState, - !chartOrdersStillDefinitivelyCleared - ); - } catch (e) { - chartOrdersRestoreSucceeded = false; - emit("ERR", "恢复图表当前委托显示失败", e); - setLadderStatus("未能恢复图表当前委托显示"); - } - } - if (restoreTemporaryUiState && isCurrentObservedSymbol(symbol) && chartOrdersRestoreSucceeded && temporaryUiRestoreSucceeded && successStatusMessage) { + if (restoreTemporaryUiState && isCurrentObservedSymbol(symbol) && chartSaveCoalescingSucceeded && temporaryUiRestoreSucceeded && successStatusMessage) { setLadderStatus(successStatusMessage); } } diff --git a/src/binance-orderbook-trade/core/chart-save-coalescer.js b/src/binance-orderbook-trade/core/chart-save-coalescer.js index 5f7a9c5..b7c9472 100644 --- a/src/binance-orderbook-trade/core/chart-save-coalescer.js +++ b/src/binance-orderbook-trade/core/chart-save-coalescer.js @@ -307,6 +307,184 @@ export function createTradingViewContinuousSaveController( }; } +/** + * A native bulk cancellation can emit many drawing removals over several short + * bursts. Removal-triggered saves are held until the lifecycle finishes. Saves + * outside a burst still run synchronously and supersede older held snapshots. + */ +export function createTradingViewRemovalSaveController( + api, + { + settleQuietMs = 140, + maxWaitMs = 600, + eventDiscoveryMs = 250, + setTimeoutFn = setTimeout, + clearTimeoutFn = clearTimeout, + } = {}, +) { + validateTradingViewApi(api); + if (!Number.isFinite(settleQuietMs) || settleQuietMs <= 0) { + throw new Error('删除事件保存合并静默时间无效'); + } + if (!Number.isFinite(maxWaitMs) || maxWaitMs < settleQuietMs) { + throw new Error('删除事件保存合并最长等待时间无效'); + } + if (!Number.isFinite(eventDiscoveryMs) || eventDiscoveryMs < 0) { + throw new Error('删除事件发现时间无效'); + } + + const sessionSaveChart = api.saveChart; + const sessionDescriptor = Object.getOwnPropertyDescriptor(api, 'saveChart'); + let activeBurst = null; + let controllerError = null; + let discoveryTimer = null; + let finished = false; + let fullSaveCount = 0; + let pendingFinalSave = null; + let removeEventCount = 0; + let saveRequestCount = 0; + let synchronousSaveCount = 0; + let firstRemoveResolve; + const firstRemove = new Promise((resolve) => { + firstRemoveResolve = resolve; + }); + + const getStats = () => ({ + fullSaveCount, + removeEventCount, + saveRequestCount, + synchronousSaveCount, + }); + const monitoredSaveChart = function monitoredRemovalSessionSaveChart(...args) { + synchronousSaveCount += 1; + pendingFinalSave = null; + return sessionSaveChart.apply(this, args); + }; + const clearBurstTimers = (burst) => { + if (burst.settleTimer !== null) clearTimeoutFn(burst.settleTimer); + if (burst.maxWaitTimer !== null) clearTimeoutFn(burst.maxWaitTimer); + burst.settleTimer = null; + burst.maxWaitTimer = null; + }; + const finishBurst = () => { + const burst = activeBurst; + if (!burst) return; + clearBurstTimers(burst); + activeBurst = null; + if (api.saveChart !== burst.wrapper) { + controllerError ||= new Error('图表保存接口在删除事件合并期间发生变化'); + pendingFinalSave = null; + burst.resolve(); + return; + } + restoreSaveChartMethod( + api, + burst.wrapper, + burst.originalSaveChart, + burst.originalDescriptor, + ); + if (burst.pendingSave) pendingFinalSave = burst.pendingSave; + burst.resolve(); + }; + const scheduleBurstSettle = (burst) => { + if (burst.settleTimer !== null) clearTimeoutFn(burst.settleTimer); + burst.settleTimer = setTimeoutFn(finishBurst, settleQuietMs); + }; + const startBurst = () => { + if (activeBurst) return activeBurst; + if (api.saveChart !== monitoredSaveChart) { + controllerError ||= new Error('图表保存接口正被其他操作占用'); + return null; + } + let resolve; + const settled = new Promise((settle) => { + resolve = settle; + }); + const burst = { + maxWaitTimer: null, + originalDescriptor: Object.getOwnPropertyDescriptor(api, 'saveChart'), + originalSaveChart: api.saveChart, + pendingSave: null, + resolve, + settleTimer: null, + settled, + wrapper: null, + }; + burst.wrapper = function removalSaveBurstWrapper(...args) { + saveRequestCount += 1; + burst.pendingSave = { thisValue: this, args }; + }; + api.saveChart = burst.wrapper; + if (api.saveChart !== burst.wrapper) { + throw new Error('图表保存接口无法启用删除事件合并'); + } + activeBurst = burst; + burst.maxWaitTimer = setTimeoutFn(finishBurst, maxWaitMs); + return burst; + }; + const handleDrawingEvent = (_drawingId, eventType) => { + if (finished || eventType !== 'remove') return; + removeEventCount += 1; + if (removeEventCount === 1) firstRemoveResolve(); + try { + const burst = startBurst(); + if (burst) scheduleBurstSettle(burst); + } catch (error) { + controllerError ||= error; + } + }; + + api.subscribe('drawing_event', handleDrawingEvent); + try { + api.saveChart = monitoredSaveChart; + if (api.saveChart !== monitoredSaveChart) { + throw new Error('图表保存接口无法启用删除事件监视'); + } + } catch (error) { + api.unsubscribe('drawing_event', handleDrawingEvent); + throw error; + } + + return { + getStats, + async finish() { + if (finished) throw new Error('删除事件保存合并已结束'); + + if (removeEventCount === 0 && eventDiscoveryMs > 0) { + await Promise.race([ + firstRemove, + new Promise((resolve) => { + discoveryTimer = setTimeoutFn(resolve, eventDiscoveryMs); + }), + ]); + } + if (discoveryTimer !== null) clearTimeoutFn(discoveryTimer); + discoveryTimer = null; + if (activeBurst) await activeBurst.settled; + + finished = true; + api.unsubscribe('drawing_event', handleDrawingEvent); + if (api.saveChart === monitoredSaveChart) { + restoreSaveChartMethod( + api, + monitoredSaveChart, + sessionSaveChart, + sessionDescriptor, + ); + } else { + controllerError ||= new Error('图表保存接口在删除事件监视期间发生变化'); + pendingFinalSave = null; + } + if (pendingFinalSave) { + fullSaveCount += 1; + sessionSaveChart.apply(pendingFinalSave.thisValue, pendingFinalSave.args); + } + if (controllerError) throw controllerError; + return getStats(); + }, + }; +} + /** * Binance schedules one complete chart save for every broker drawing event. * The last request contains the cumulative final state, so one burst can be diff --git a/src/binance-orderbook-trade/index.user.js b/src/binance-orderbook-trade/index.user.js index 1fe1705..6b26b90 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.179 +// @version 2.7.180 // @author jackhai9 // @description 单击订单簿价格,按当前开仓/平仓 tab 自动填数量并执行下单,内置数量倍率面板 // @match https://www.binance.com/*/futures/* @@ -196,12 +196,12 @@ import { } from './core/panel-options.js'; import { CHART_ORDERS_RECOVERY_STORAGE_KEY, - createChartOrdersRecoveryRecord, parseChartOrdersRecoveryRecord, } from './core/chart-orders-recovery.js'; import { coalesceTradingViewDrawingSaves, createTradingViewContinuousSaveController, + createTradingViewRemovalSaveController, } from './core/chart-save-coalescer.js'; import { findBinanceTradingViewTarget } from './dom/tradingview-target.js'; import { resolveCancelDialogDecision } from './core/cancel-dialog-decision.js'; @@ -864,13 +864,12 @@ import { showUsdtRebalanceDialog } from './dom/usdt-rebalance-dialog.js'; ['读取挂单时交易对已变化', 'Symbol changed while reading open orders'], ['未找到当前委托的全撤按钮', 'Cancel All was not found in Open Orders'], ['撤单前交易对已变化', 'Symbol changed before cancellation'], - ['未能准备撤单页面,未打开确认弹窗', 'Could not prepare the cancellation page; confirmation was not opened'], - ['准备撤单时交易对已变化', 'Symbol changed while preparing cancellation'], ['准备撤单时未找到当前委托面板', 'Open Orders panel was not found while preparing cancellation'], ['准备撤单时未确认仅显示当前交易对挂单', 'Could not confirm this-symbol-only orders while preparing cancellation'], ['准备撤单时未找到全撤按钮', 'Cancel All was not found while preparing cancellation'], ['撤单确认弹窗已打开', 'Cancellation confirmation opened'], ['撤单确认弹窗结构异常,未执行弹窗操作', 'Cancellation dialog changed; no dialog action was taken'], + ['未能准备撤单图表保存,未执行撤单', 'Could not prepare chart saving; cancellation was not executed'], ['确认撤单前交易对已变化', 'Symbol changed before cancellation was confirmed'], ['未识别到撤单确认弹窗,未继续撤单流程', 'Cancellation dialog was not detected; cancellation stopped'], ['撤单已取消', 'Cancellation cancelled'], @@ -882,7 +881,7 @@ import { showUsdtRebalanceDialog } from './dom/usdt-rebalance-dialog.js'; ['当前交易对挂单仍存在,撤单未完成', 'Open orders still exist for this symbol; cancellation is incomplete'], ['原挂单已撤,继续阶梯挂单', 'Previous orders cancelled; continuing ladder placement'], ['撤单已完成', 'Cancellation completed'], - ['未能恢复图表当前委托显示', 'Could not restore chart open-order display'], + ['撤单已执行,但图表保存合并失败', 'Cancellation executed, but chart-save coalescing failed'], ['阶梯任务运行中,请先停止阶梯挂单', 'A ladder task is running; stop it first'], ['连续交易运行中,请先停止阶梯挂单', 'Continuous trading is running; stop it first'], ['正在读取账户再平衡计划', 'Loading account rebalance plan'], @@ -5090,7 +5089,11 @@ import { showUsdtRebalanceDialog } from './dom/usdt-rebalance-dialog.js'; )); } - function createBinanceCancelAllDialogDecisionWatcher() { + function createBinanceCancelAllDialogDecisionWatcher(options = null) { + const { onConfirmed } = options || {}; + if (onConfirmed !== undefined && typeof onConfirmed !== 'function') { + throw new Error('撤单确认回调无效'); + } const lifecycleController = new AbortController(); const dialogSignal = createDialogMutationSignal(document); if (!dialogSignal) throw new Error('撤单确认弹窗状态无法观察,已停止'); @@ -5101,12 +5104,16 @@ import { showUsdtRebalanceDialog } from './dom/usdt-rebalance-dialog.js'; dialogSignal, }; + const recordResolvedAction = (action) => { + if (!action || watcher.action) return; + if (action === 'confirmed') onConfirmed?.(); + watcher.action = action; + }; const recordAction = (eventTarget) => { const contract = findBinanceCancelAllDialog(document, isVisibleElement); if (!contract) return; watcher.seenDialog = true; - const action = classifyBinanceCancelAllDialogAction(contract, eventTarget); - if (action && !watcher.action) watcher.action = action; + recordResolvedAction(classifyBinanceCancelAllDialogAction(contract, eventTarget)); }; const rejectInvalidDialogAction = (event, error) => { // Do not let Binance execute an action that the script can no longer classify or recover from. @@ -5134,7 +5141,7 @@ import { showUsdtRebalanceDialog } from './dom/usdt-rebalance-dialog.js'; event.key, document.activeElement || event.target, ); - if (action && !watcher.action) watcher.action = action; + recordResolvedAction(action); } catch (error) { rejectInvalidDialogAction(event, error); } finally { @@ -5200,13 +5207,6 @@ import { showUsdtRebalanceDialog } from './dom/usdt-rebalance-dialog.js'; return getBinanceChartOrdersTargetDom(document); } - function writeChartOrdersRecoveryRecord() { - sessionStorage.setItem( - CHART_ORDERS_RECOVERY_STORAGE_KEY, - createChartOrdersRecoveryRecord(Date.now()), - ); - } - function clearChartOrdersRecoveryRecord() { sessionStorage.removeItem(CHART_ORDERS_RECOVERY_STORAGE_KEY); } @@ -5351,39 +5351,15 @@ import { showUsdtRebalanceDialog } from './dom/usdt-rebalance-dialog.js'; }); } - async function hideBinanceChartOrdersForBulkCancel(target, state) { - const current = await openBinanceChartOrdersPopover(target); - state.originalChecked = current.checked; - if (current.checked) { - writeChartOrdersRecoveryRecord(); - state.changed = true; - await toggleBinanceChartOrdersWithCoalescedSave( - target, - current.checkbox, - false, - true, - ); - return; - } - await closeBinanceChartOrdersPopover(target); - } - - async function restoreBinanceChartOrdersAfterBulkCancel( - target, - state, - expectDrawingEvents, - ) { - if (typeof expectDrawingEvents !== 'boolean') { - throw new Error('图表委托线保存参数异常'); - } + async function restoreLegacyHiddenBinanceChartOrders(target) { assertSameBinanceChartOrdersTarget(target, getBinanceChartOrdersTarget()); const current = await openBinanceChartOrdersPopover(target); - if (current.checked !== state.originalChecked) { + if (!current.checked) { await toggleBinanceChartOrdersWithCoalescedSave( target, current.checkbox, - state.originalChecked, - expectDrawingEvents, + true, + true, ); } else { await closeBinanceChartOrdersPopover(target); @@ -5406,10 +5382,7 @@ import { showUsdtRebalanceDialog } from './dom/usdt-rebalance-dialog.js'; const target = findBinanceChartOrdersTargetDom(document); if (!target) return { status: 'target_not_ready' }; - await restoreBinanceChartOrdersAfterBulkCancel(target, { - originalChecked: recovery.record.originalChecked, - changed: true, - }, true); + await restoreLegacyHiddenBinanceChartOrders(target); chartOrdersRecoveryPendingAtStartup = false; chartOrdersRecoveryLastError = null; log('已恢复刷新前的图表当前委托显示状态'); @@ -5460,11 +5433,30 @@ import { showUsdtRebalanceDialog } from './dom/usdt-rebalance-dialog.js'; let previousOpenOrdersSubTabIdentity = null; let symbolFilterOriginalChecked = null; let restoreTemporaryUiState = true; - let chartOrdersTarget = null; - const chartOrdersState = { originalChecked: null, changed: false }; - let restoreChartOrdersState = true; - let chartOrdersDefinitivelyCleared = false; + let chartRemovalSaveController = null; let successStatusMessage = null; + const armChartSaveCoalescing = () => { + try { + if (chartRemovalSaveController) { + throw new Error('撤单图表保存合并已启动'); + } + const target = findBinanceTradingViewTarget(document); + if (!target) throw new Error('未找到图表保存接口'); + chartRemovalSaveController = createTradingViewRemovalSaveController( + target.tradingViewApi, + ); + } catch (error) { + error.name = 'ChartSaveCoalescingError'; + throw error; + } + }; + const finishChartSaveCoalescing = async () => { + if (!chartRemovalSaveController) return; + const controller = chartRemovalSaveController; + chartRemovalSaveController = null; + const result = await controller.finish(); + log('撤单图表保存已合并', result); + }; try { const tabReady = await activateOpenOrdersTab(); @@ -5537,22 +5529,6 @@ import { showUsdtRebalanceDialog } from './dom/usdt-rebalance-dialog.js'; cancelCurrentSymbolOpenOrdersBlocksLadderActions = true; scheduleRenderPanel(); - try { - chartOrdersTarget = getBinanceChartOrdersTarget(); - await hideBinanceChartOrdersForBulkCancel(chartOrdersTarget, chartOrdersState); - } catch (e) { - emit('ERR', '撤单前隐藏图表当前委托失败', e); - const message = '未能准备撤单页面,未打开确认弹窗'; - setLadderStatus(message); - return { ok: false, status: 'chart_orders_not_hidden', message }; - } - - if (!isCurrentObservedSymbol(symbol)) { - const message = '准备撤单时交易对已变化'; - setLadderStatus(message); - return { ok: false, status: 'symbol_changed', message }; - } - openOrdersScope = await waitForActiveOpenOrdersScope(); if (!openOrdersScope || !isCurrentObservedSymbol(symbol)) { const message = '准备撤单时未找到当前委托面板'; @@ -5571,7 +5547,9 @@ import { showUsdtRebalanceDialog } from './dom/usdt-rebalance-dialog.js'; return { ok: false, status: 'cancel_button_not_found', message }; } - const dialogDecisionWatcher = createBinanceCancelAllDialogDecisionWatcher(); + const dialogDecisionWatcher = createBinanceCancelAllDialogDecisionWatcher({ + onConfirmed: armChartSaveCoalescing, + }); let dialogDecision; try { cancelAllButton.click(); @@ -5582,16 +5560,24 @@ import { showUsdtRebalanceDialog } from './dom/usdt-rebalance-dialog.js'; ); } catch (error) { restoreTemporaryUiState = false; - emit('ERR', '币安撤单确认弹窗结构异常', error); - const message = '撤单确认弹窗结构异常,未执行弹窗操作'; + const chartSaveSetupFailed = error?.name === 'ChartSaveCoalescingError'; + emit('ERR', chartSaveSetupFailed + ? '撤单图表保存合并启动失败' + : '币安撤单确认弹窗结构异常', error); + const message = chartSaveSetupFailed + ? '未能准备撤单图表保存,未执行撤单' + : '撤单确认弹窗结构异常,未执行弹窗操作'; setLadderStatus(message); - return { ok: false, status: 'dialog_contract_invalid', message }; + return { + ok: false, + status: chartSaveSetupFailed ? 'chart_save_not_ready' : 'dialog_contract_invalid', + message, + }; } finally { dialogDecisionWatcher.dispose(); } if (dialogDecision.status === 'aborted') { restoreTemporaryUiState = false; - restoreChartOrdersState = false; const interruptedBaseAsset = formatStatusBaseAsset(symbol); const message = `原交易对 ${interruptedBaseAsset} 页面已离开,撤单确认跟踪已停止`; setLadderStatus(message); @@ -5645,12 +5631,20 @@ import { showUsdtRebalanceDialog } from './dom/usdt-rebalance-dialog.js'; setLadderStatus(message); return { ok: false, status: 'not_cleared', message }; } - chartOrdersDefinitivelyCleared = clearResult.definitivelyCleared === true; successStatusMessage = waitUntilCleared ? '原挂单已撤,继续阶梯挂单' : '撤单已完成'; return { ok: true, status: 'cleared' }; } finally { + let chartSaveCoalescingSucceeded = true; + try { + await finishChartSaveCoalescing(); + } catch (error) { + chartSaveCoalescingSucceeded = false; + emit('ERR', '撤单图表保存合并失败', error); + setLadderStatus('撤单已执行,但图表保存合并失败'); + } + let temporaryUiRestoreSucceeded = true; if (restoreTemporaryUiState && isCurrentObservedSymbol(symbol)) { openOrdersScope = await waitForActiveOpenOrdersScope(); @@ -5673,27 +5667,10 @@ import { showUsdtRebalanceDialog } from './dom/usdt-rebalance-dialog.js'; } } - let chartOrdersRestoreSucceeded = true; - if (restoreChartOrdersState && chartOrdersState.changed) { - try { - const chartOrdersStillDefinitivelyCleared = - chartOrdersDefinitivelyCleared && getOpenOrdersTabCount() === 0; - await restoreBinanceChartOrdersAfterBulkCancel( - chartOrdersTarget, - chartOrdersState, - !chartOrdersStillDefinitivelyCleared, - ); - } catch (e) { - chartOrdersRestoreSucceeded = false; - emit('ERR', '恢复图表当前委托显示失败', e); - setLadderStatus('未能恢复图表当前委托显示'); - } - } - if ( restoreTemporaryUiState && isCurrentObservedSymbol(symbol) && - chartOrdersRestoreSucceeded && + chartSaveCoalescingSucceeded && temporaryUiRestoreSucceeded && successStatusMessage ) { diff --git a/test/unit/binance-orderbook-trade/chart-save-coalescer.test.js b/test/unit/binance-orderbook-trade/chart-save-coalescer.test.js index 9a3190c..1a1f865 100644 --- a/test/unit/binance-orderbook-trade/chart-save-coalescer.test.js +++ b/test/unit/binance-orderbook-trade/chart-save-coalescer.test.js @@ -4,6 +4,7 @@ import assert from 'node:assert/strict'; import { coalesceTradingViewDrawingSaves, createTradingViewContinuousSaveController, + createTradingViewRemovalSaveController, } from '../../../src/binance-orderbook-trade/core/chart-save-coalescer.js'; function createTradingViewApi() { @@ -83,6 +84,239 @@ function createManualTimers() { return { advance, clearTimeoutFn, setTimeoutFn, timers }; } +test('bulk removal controller keeps unrelated saves synchronous before the first remove', async () => { + const { api, saved, listeners } = createTradingViewApi(); + const originalSaveChart = api.saveChart; + const controller = createTradingViewRemovalSaveController(api, { eventDiscoveryMs: 0 }); + + assert.notEqual(api.saveChart, originalSaveChart); + api.saveChart('unrelated'); + assert.deepEqual(saved.map((entry) => entry.args), [['unrelated']]); + assert.deepEqual(await controller.finish(), { + fullSaveCount: 0, + removeEventCount: 0, + saveRequestCount: 0, + synchronousSaveCount: 1, + }); + + assert.equal(api.saveChart, originalSaveChart); + assert.deepEqual(saved.map((entry) => entry.args), [['unrelated']]); + assert.equal(listeners.get('drawing_event')?.size, 0); +}); + +test('bulk removal controller persists one final snapshot across separate remove bursts', async () => { + const { api, saved, listeners } = createTradingViewApi(); + const originalSaveChart = api.saveChart; + const timers = createManualTimers(); + const controller = createTradingViewRemovalSaveController(api, { + settleQuietMs: 20, + maxWaitMs: 100, + eventDiscoveryMs: 10, + setTimeoutFn: timers.setTimeoutFn, + clearTimeoutFn: timers.clearTimeoutFn, + }); + + api.emit('drawing_event', 'order-1', 'remove'); + api.saveChart('snapshot-1'); + timers.advance(20); + assert.notEqual(api.saveChart, originalSaveChart); + assert.deepEqual(saved, []); + + api.saveChart('unrelated-between-bursts'); + api.emit('drawing_event', 'order-2', 'remove'); + api.saveChart('snapshot-2'); + const completion = controller.finish(); + timers.advance(20); + const result = await completion; + + assert.deepEqual(result, { + fullSaveCount: 1, + removeEventCount: 2, + saveRequestCount: 2, + synchronousSaveCount: 1, + }); + assert.deepEqual(saved.map((entry) => entry.args), [ + ['unrelated-between-bursts'], + ['snapshot-2'], + ]); + assert.equal(api.saveChart, originalSaveChart); + assert.equal(listeners.get('drawing_event')?.size, 0); +}); + +test('bulk removal controller waits briefly for delayed remove events before finishing', async () => { + const { api, saved } = createTradingViewApi(); + const timers = createManualTimers(); + const controller = createTradingViewRemovalSaveController(api, { + settleQuietMs: 20, + maxWaitMs: 100, + eventDiscoveryMs: 30, + setTimeoutFn: timers.setTimeoutFn, + clearTimeoutFn: timers.clearTimeoutFn, + }); + + const completion = controller.finish(); + timers.advance(10); + api.emit('drawing_event', 'order-1', 'remove'); + api.saveChart('snapshot-1'); + timers.advance(20); + + assert.deepEqual(await completion, { + fullSaveCount: 1, + removeEventCount: 1, + saveRequestCount: 1, + synchronousSaveCount: 0, + }); + assert.deepEqual(saved.map((entry) => entry.args), [['snapshot-1']]); +}); + +test('bulk removal controller ignores non-remove drawing events', async () => { + const { api, saved } = createTradingViewApi(); + const controller = createTradingViewRemovalSaveController(api, { eventDiscoveryMs: 0 }); + + api.emit('drawing_event', 'order-1', 'properties_changed'); + api.saveChart('unrelated'); + + assert.deepEqual(await controller.finish(), { + fullSaveCount: 0, + removeEventCount: 0, + saveRequestCount: 0, + synchronousSaveCount: 1, + }); + assert.deepEqual(saved.map((entry) => entry.args), [['unrelated']]); +}); + +test('bulk removal controller discards an older removal snapshot after a later synchronous save', async () => { + const { api, saved } = createTradingViewApi(); + const timers = createManualTimers(); + const controller = createTradingViewRemovalSaveController(api, { + settleQuietMs: 20, + maxWaitMs: 100, + eventDiscoveryMs: 0, + setTimeoutFn: timers.setTimeoutFn, + clearTimeoutFn: timers.clearTimeoutFn, + }); + + api.emit('drawing_event', 'order-1', 'remove'); + api.saveChart('removal-snapshot'); + timers.advance(20); + api.saveChart('newer-unrelated-snapshot'); + + assert.deepEqual(await controller.finish(), { + fullSaveCount: 0, + removeEventCount: 1, + saveRequestCount: 1, + synchronousSaveCount: 1, + }); + assert.deepEqual(saved.map((entry) => entry.args), [['newer-unrelated-snapshot']]); +}); + +test('bulk removal controller preserves an externally replaced chart save method', async () => { + const { api, saved, listeners } = createTradingViewApi(); + const timers = createManualTimers(); + const controller = createTradingViewRemovalSaveController(api, { + settleQuietMs: 20, + maxWaitMs: 100, + eventDiscoveryMs: 0, + setTimeoutFn: timers.setTimeoutFn, + clearTimeoutFn: timers.clearTimeoutFn, + }); + + api.emit('drawing_event', 'order-1', 'remove'); + api.saveChart('old-removal-snapshot'); + const foreignSaves = []; + const foreignSaveChart = (...args) => foreignSaves.push(args); + api.saveChart = foreignSaveChart; + api.saveChart('newer-foreign-snapshot'); + timers.advance(20); + + await assert.rejects(controller.finish(), /图表保存接口在删除事件合并期间发生变化/); + assert.equal(api.saveChart, foreignSaveChart); + assert.deepEqual(saved, []); + assert.deepEqual(foreignSaves, [['newer-foreign-snapshot']]); + assert.equal(listeners.get('drawing_event')?.size, 0); +}); + +test('bulk removal controller drops a settled snapshot after external save ownership changes', async () => { + const { api, saved } = createTradingViewApi(); + const timers = createManualTimers(); + const controller = createTradingViewRemovalSaveController(api, { + settleQuietMs: 20, + maxWaitMs: 100, + eventDiscoveryMs: 0, + setTimeoutFn: timers.setTimeoutFn, + clearTimeoutFn: timers.clearTimeoutFn, + }); + + api.emit('drawing_event', 'order-1', 'remove'); + api.saveChart('old-removal-snapshot'); + timers.advance(20); + const foreignSaves = []; + const foreignSaveChart = (...args) => foreignSaves.push(args); + api.saveChart = foreignSaveChart; + api.saveChart('newer-foreign-snapshot'); + + await assert.rejects( + controller.finish(), + /图表保存接口在删除事件监视期间发生变化/, + ); + assert.equal(api.saveChart, foreignSaveChart); + assert.deepEqual(saved, []); + assert.deepEqual(foreignSaves, [['newer-foreign-snapshot']]); +}); + +test('bulk removal controller restores the chart API before a final save error', async () => { + const { api, listeners } = createTradingViewApi(); + const originalSaveChart = function saveChart() { + throw new Error('final save failed'); + }; + api.saveChart = originalSaveChart; + const timers = createManualTimers(); + const controller = createTradingViewRemovalSaveController(api, { + settleQuietMs: 20, + maxWaitMs: 100, + eventDiscoveryMs: 0, + setTimeoutFn: timers.setTimeoutFn, + clearTimeoutFn: timers.clearTimeoutFn, + }); + + api.emit('drawing_event', 'order-1', 'remove'); + api.saveChart('snapshot-1'); + const completion = controller.finish(); + timers.advance(20); + + await assert.rejects(completion, /final save failed/); + assert.equal(api.saveChart, originalSaveChart); + assert.equal(listeners.get('drawing_event')?.size, 0); +}); + +test('bulk removal controller settles after an external replacement when the original save throws', async () => { + const { api, listeners } = createTradingViewApi(); + api.saveChart = function saveChart() { + throw new Error('original save failed'); + }; + const timers = createManualTimers(); + const controller = createTradingViewRemovalSaveController(api, { + settleQuietMs: 20, + maxWaitMs: 100, + eventDiscoveryMs: 0, + setTimeoutFn: timers.setTimeoutFn, + clearTimeoutFn: timers.clearTimeoutFn, + }); + + api.emit('drawing_event', 'order-1', 'remove'); + api.saveChart('pending-snapshot'); + const foreignSaveChart = () => {}; + api.saveChart = foreignSaveChart; + timers.advance(20); + + await assert.rejects( + controller.finish(), + /图表保存接口在删除事件合并期间发生变化/, + ); + assert.equal(api.saveChart, foreignSaveChart); + assert.equal(listeners.get('drawing_event')?.size, 0); +}); + test('continuous remove-save controller leaves unrelated chart saves synchronous', () => { const { api, saved } = createTradingViewApi(); const originalSaveChart = api.saveChart; diff --git a/test/unit/binance-orderbook-trade/source-regressions.test.js b/test/unit/binance-orderbook-trade/source-regressions.test.js index b67641e..2352867 100644 --- a/test/unit/binance-orderbook-trade/source-regressions.test.js +++ b/test/unit/binance-orderbook-trade/source-regressions.test.js @@ -1120,71 +1120,32 @@ test('panel statuses omit the current full symbol and compact the retained inter assert.doesNotMatch(source, /点击时 \$\{qtyPlan\.symbol\}/); }); -test('bulk cancel removes real chart order drawings with one coalesced save before opening the native dialog', () => { +test('bulk cancel keeps chart orders visible and coalesces their removal saves after native confirmation', () => { assert.match(chartSaveCoalescerSource, /api\.subscribe\('drawing_event'/); assert.match(chartSaveCoalescerSource, /function coalescedSaveChart/); assert.match(chartSaveCoalescerSource, /originalSaveChart\.apply\(pendingSave\.thisValue, pendingSave\.args\)/); assert.match(source, /coalesceTradingViewDrawingSaves/); assert.doesNotMatch(source, /tradingProperties\.showOrders/); - - const toggleBody = readFunctionBody('toggleBinanceChartOrdersWithCoalescedSave'); - assert.match(toggleBody, /coalesceTradingViewDrawingSaves/); - assert.match(toggleBody, /checkbox\.click\(\)/); - assert.match(toggleBody, /waitForBinanceChartOrdersPopover\(target, expectedChecked\)/); - assert.match(toggleBody, /expectDrawingEvents \? \{\} : \{ eventDiscoveryTimeoutMs: 0 \}/); - - const hideBody = readFunctionBody('hideBinanceChartOrdersForBulkCancel'); - assert.match(hideBody, /state\.originalChecked = current\.checked/); - assert.match(hideBody, /writeChartOrdersRecoveryRecord\(\)/); - assert.match(hideBody, /state\.changed = true/); - assert.match(hideBody, /toggleBinanceChartOrdersWithCoalescedSave\([\s\S]*false,[\s\S]*true/); - assert.match(hideBody, /closeBinanceChartOrdersPopover\(target\)/); - - const restoreBody = readFunctionBody('restoreBinanceChartOrdersAfterBulkCancel'); - assert.match(restoreBody, /assertSameBinanceChartOrdersTarget\(target, getBinanceChartOrdersTarget\(\)\)/); - assert.match(restoreBody, /current\.checked !== state\.originalChecked/); - assert.match(restoreBody, /toggleBinanceChartOrdersWithCoalescedSave/); - assert.match(restoreBody, /clearChartOrdersRecoveryRecord\(\)/); + assert.doesNotMatch(source, /hideBinanceChartOrdersForBulkCancel/); const cancelBody = readFunctionBody('runCancelCurrentSymbolOpenOrders'); - const targetIndex = cancelBody.indexOf('chartOrdersTarget = getBinanceChartOrdersTarget()'); - const hideIndex = cancelBody.indexOf( - 'await hideBinanceChartOrdersForBulkCancel(chartOrdersTarget, chartOrdersState)' - ); + const armIndex = cancelBody.indexOf('armChartSaveCoalescing'); const destructiveClickIndex = cancelBody.indexOf('cancelAllButton.click()'); - const watcherIndex = cancelBody.indexOf('createBinanceCancelAllDialogDecisionWatcher()'); - const chartRestoreIndex = cancelBody.indexOf('await restoreBinanceChartOrdersAfterBulkCancel('); - const symbolGuardedRestoreIndex = cancelBody.indexOf('if (restoreTemporaryUiState && isCurrentObservedSymbol(symbol))'); - const postHideBody = cancelBody.slice(hideIndex); - const freshScopeIndex = postHideBody.indexOf('openOrdersScope = await waitForActiveOpenOrdersScope()'); - const freshFilterIndex = postHideBody.indexOf('isOpenOrdersScopeConfirmedForSymbol(openOrdersScope, symbol)'); - const freshButtonIndex = postHideBody.indexOf( - 'cancelAllButton = findCurrentSymbolCancelAllButton(openOrdersScope)' - ); - const postHideClickIndex = postHideBody.indexOf('cancelAllButton.click()'); + const watcherIndex = cancelBody.indexOf('createBinanceCancelAllDialogDecisionWatcher('); + const clearIndex = cancelBody.indexOf('waitForCurrentSymbolOpenOrdersCleared(openOrdersScope, symbol)'); + const finishIndex = cancelBody.indexOf('await finishChartSaveCoalescing()'); - assert.ok(targetIndex !== -1 && hideIndex !== -1); + assert.ok(armIndex !== -1); assert.ok(watcherIndex !== -1 && destructiveClickIndex !== -1); - assert.ok(targetIndex < hideIndex); - assert.ok(hideIndex < destructiveClickIndex); + assert.ok(armIndex < destructiveClickIndex); assert.ok(watcherIndex < destructiveClickIndex, 'dialog decision watcher must exist before destructive click'); - assert.ok(freshScopeIndex !== -1 && freshFilterIndex !== -1 && freshButtonIndex !== -1); - assert.ok( - freshScopeIndex < freshFilterIndex && freshFilterIndex < freshButtonIndex && freshButtonIndex < postHideClickIndex, - 'the active scope, symbol filter, and cancel button must be reacquired after hiding' - ); - assert.match(cancelBody, /status: 'chart_orders_not_hidden'/); + assert.ok(clearIndex < finishIndex, 'coalescing must remain active until open orders are cleared'); + assert.match(cancelBody, /onConfirmed: armChartSaveCoalescing/); + assert.match(cancelBody, /createTradingViewRemovalSaveController/); assert.match(cancelBody, /dialogDecision\.status === 'cancelled'[\s\S]*status: 'cancelled'/); assert.match(cancelBody, /dialogDecision\.status === 'cancelled'[\s\S]*return \{ ok: false, status: 'cancelled'[\s\S]*waitForCurrentSymbolOpenOrdersCleared/); assert.match(cancelBody, /dialogDecisionWatcher\.dispose\(\)/); - assert.match(cancelBody, /restoreChartOrdersState = false[\s\S]*status: 'aborted'/); - assert.ok(chartRestoreIndex !== -1 && symbolGuardedRestoreIndex !== -1); - assert.ok( - symbolGuardedRestoreIndex < chartRestoreIndex, - 'temporary account UI must recover before chart-order visibility' - ); - assert.match(cancelBody, /if \(restoreTemporaryUiState && isCurrentObservedSymbol\(symbol\)\)[\s\S]*await restoreAccountOrdersTab\([\s\S]*let chartOrdersRestoreSucceeded/); - assert.match(cancelBody, /let chartOrdersRestoreSucceeded[\s\S]*await restoreBinanceChartOrdersAfterBulkCancel\(/); + assert.match(cancelBody, /finally \{[\s\S]*await finishChartSaveCoalescing\(\)/); }); test('bulk cancel distinguishes native confirm from cancellation before clear polling', () => { @@ -1195,6 +1156,7 @@ test('bulk cancel distinguishes native confirm from cancellation before clear po assert.match(watcherBody, /document\.addEventListener\('keydown', handleKeydown, true\)/); assert.match(watcherBody, /findBinanceCancelAllDialog\(document, isVisibleElement\)/); assert.match(watcherBody, /classifyBinanceCancelAllDialogAction\(contract, eventTarget\)/); + assert.match(watcherBody, /if \(action === 'confirmed'\) onConfirmed\?\.\(\);\s*watcher\.action = action/); assert.match(watcherBody, /event\.key !== 'Escape' && event\.key !== 'Enter' && event\.key !== ' '/); assert.match(watcherBody, /classifyBinanceCancelAllDialogKeyboardAction\(/); @@ -1205,16 +1167,16 @@ test('bulk cancel distinguishes native confirm from cancellation before clear po assert.doesNotMatch(decisionBody, /closeDeadline/); const cancelBody = readFunctionBody('runCancelCurrentSymbolOpenOrders'); - const watcherIndex = cancelBody.indexOf('createBinanceCancelAllDialogDecisionWatcher()'); + const watcherIndex = cancelBody.indexOf('createBinanceCancelAllDialogDecisionWatcher('); const clickIndex = cancelBody.indexOf('cancelAllButton.click()'); const cancelledIndex = cancelBody.indexOf("dialogDecision.status === 'cancelled'"); const clearIndex = cancelBody.indexOf('waitForCurrentSymbolOpenOrdersCleared(openOrdersScope, symbol)'); assert.ok(watcherIndex < clickIndex, 'decision watcher must be installed before opening the dialog'); assert.ok(clickIndex < cancelledIndex && cancelledIndex < clearIndex); assert.match(cancelBody, /status: 'dialog_not_found'/); - assert.match(cancelBody, /status: 'dialog_contract_invalid'/); + assert.match(cancelBody, /status: chartSaveSetupFailed \? 'chart_save_not_ready' : 'dialog_contract_invalid'/); const invalidDialogBranch = cancelBody.match( - /catch \(error\) \{\s*restoreTemporaryUiState = false;([\s\S]*?)status: 'dialog_contract_invalid'/, + /catch \(error\) \{\s*restoreTemporaryUiState = false;([\s\S]*?)status: chartSaveSetupFailed/, )?.[1] || ''; assert.notEqual(invalidDialogBranch, ''); assert.doesNotMatch(invalidDialogBranch, /restoreChartOrdersState = false/); @@ -1223,26 +1185,19 @@ test('bulk cancel distinguishes native confirm from cancellation before clear po test('chart Open Orders reload recovery remains pending until restoration succeeds', () => { assert.match(source, /chartOrdersRecoveryPendingAtStartup =\s*sessionStorage\.getItem\(CHART_ORDERS_RECOVERY_STORAGE_KEY\) !== null/); + assert.doesNotMatch(source, /writeChartOrdersRecoveryRecord/); - const hideBody = readFunctionBody('hideBinanceChartOrdersForBulkCancel'); - const stateIndex = hideBody.indexOf('state.originalChecked = current.checked'); - const writeIndex = hideBody.indexOf('writeChartOrdersRecoveryRecord()'); - const toggleIndex = hideBody.indexOf('toggleBinanceChartOrdersWithCoalescedSave('); - assert.ok( - stateIndex !== -1 - && writeIndex !== -1 - && toggleIndex !== -1 - && stateIndex < writeIndex - && writeIndex < toggleIndex, - ); + const restoreBody = readFunctionBody('restoreLegacyHiddenBinanceChartOrders'); + assert.match(restoreBody, /if \(!current\.checked\)/); + assert.match(restoreBody, /toggleBinanceChartOrdersWithCoalescedSave\([\s\S]*true,[\s\S]*true/); + assert.match(restoreBody, /clearChartOrdersRecoveryRecord\(\)/); const recoverBody = readFunctionBody('recoverChartOrdersStateAfterReload'); assert.match(recoverBody, /recovery\.status === 'invalid'/); assert.doesNotMatch(recoverBody, /expired/); assert.match(recoverBody, /clearChartOrdersRecoveryRecord\(\)/); assert.match(recoverBody, /findBinanceChartOrdersTargetDom\(document\)/); - assert.match(recoverBody, /originalChecked: recovery\.record\.originalChecked/); - assert.match(recoverBody, /restoreBinanceChartOrdersAfterBulkCancel\(target/); + assert.match(recoverBody, /restoreLegacyHiddenBinanceChartOrders\(target\)/); const scheduleBody = readFunctionBody('scheduleChartOrdersRecovery'); assert.match(scheduleBody, /!chartOrdersRecoveryPendingAtStartup/); @@ -1308,9 +1263,8 @@ test('cancel current-symbol open orders wait for confirmed clearing before resto assert.ok(clearWaitIndex < successIndex, 'clearing must be confirmed before success'); assert.ok(successIndex < cleanupIndex && cleanupIndex < restoreIndex, 'page state restores only after the clear result'); - assert.match(cancelBody, /chartOrdersDefinitivelyCleared = clearResult\.definitivelyCleared === true/); - assert.match(cancelBody, /chartOrdersDefinitivelyCleared && getOpenOrdersTabCount\(\) === 0/); - assert.match(cancelBody, /!chartOrdersStillDefinitivelyCleared/); + const chartSaveFinishIndex = cancelBody.indexOf('await finishChartSaveCoalescing()'); + assert.ok(successIndex < chartSaveFinishIndex && chartSaveFinishIndex < restoreIndex); }); test('cancel current-symbol open orders are single-flight and follow the native dialog lifecycle', () => { @@ -1341,8 +1295,8 @@ test('cancel current-symbol open orders are single-flight and follow the native const cancelBody = readFunctionBody('runCancelCurrentSymbolOpenOrders'); assert.match(cancelBody, /restoreTemporaryUiState = false/); - assert.match(cancelBody, /restoreChartOrdersState = false/); assert.match(cancelBody, /dialogDecision\.status === 'aborted'[\s\S]*status: 'aborted'/); + assert.match(cancelBody, /finally\s*\{[\s\S]*await finishChartSaveCoalescing\(\)/); assert.doesNotMatch(cancelBody, /dialogDecision\.status === 'dialog_not_closed'/); assert.match(cancelBody, /finally\s*\{[\s\S]*if \(restoreTemporaryUiState && isCurrentObservedSymbol\(symbol\)\)/); @@ -1388,7 +1342,7 @@ test('cancel current-symbol open orders are single-flight and follow the native assert.match(cancelRunBody, /撤单确认弹窗已打开/); assert.match(cancelRunBody, /撤单已确认,等待挂单清空/); assert.match(cancelRunBody, /未能恢复隐藏其他合约状态/); - assert.match(cancelRunBody, /未能恢复图表当前委托显示/); + assert.match(cancelRunBody, /撤单已执行,但图表保存合并失败/); const noOrdersReturnIndex = cancelRunBody.indexOf("status: 'no_orders'"); const blockLadderActionsIndex = cancelRunBody.indexOf('cancelCurrentSymbolOpenOrdersBlocksLadderActions = true'); assert.notEqual(noOrdersReturnIndex, -1);