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);