From 3ed4dc3e0ffca90936d888bdd74ecb5687e77e85 Mon Sep 17 00:00:00 2001
From: LiZhenhai-MBP14 <5935568+jackhai9@users.noreply.github.com>
Date: Tue, 1 Sep 2026 10:08:56 +0800
Subject: [PATCH] Coalesce continuous chart saves
---
scripts/binance-orderbook-trade.user.js | 214 ++++++++++++++++--
.../core/chart-save-coalescer.js | 103 +++++++++
src/binance-orderbook-trade/dom/trade-form.js | 43 ++++
src/binance-orderbook-trade/index.user.js | 55 ++++-
.../chart-save-coalescer.test.js | 184 ++++++++++++++-
.../source-regressions.test.js | 34 ++-
.../trade-form.test.js | 162 +++++++++++++
7 files changed, 766 insertions(+), 29 deletions(-)
diff --git a/scripts/binance-orderbook-trade.user.js b/scripts/binance-orderbook-trade.user.js
index 4ed00cc..f24fd01 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.177
+// @version 2.7.178
// @author jackhai9
// @description 单击订单簿价格,按当前开仓/平仓 tab 自动填数量并执行下单,内置数量倍率面板
// @match https://www.binance.com/*/futures/*
@@ -2186,6 +2186,40 @@
}
return matches.length === 1 ? matches[0] : null;
}
+ function createTradeInputResolver(ownerDocument, {
+ initialRoot = null,
+ panelId,
+ isVisibleElement
+ }) {
+ if (!ownerDocument?.querySelectorAll || typeof isVisibleElement !== "function") {
+ throw new Error("交易表单解析依赖异常");
+ }
+ let cachedRoot = initialRoot;
+ const resolveInputs = (options = null) => {
+ const requirePrice = options?.requirePrice !== false;
+ if (cachedRoot?.isConnected) {
+ const cachedInputs = findActiveTradeInputs(cachedRoot, {
+ panelId,
+ isVisibleElement,
+ requirePrice
+ });
+ if (cachedInputs) {
+ cachedRoot = cachedInputs.root;
+ return cachedInputs;
+ }
+ return null;
+ }
+ cachedRoot = null;
+ const discoveredInputs = findActiveTradeInputs(ownerDocument, {
+ panelId,
+ isVisibleElement,
+ requirePrice
+ });
+ if (discoveredInputs) cachedRoot = discoveredInputs.root;
+ return discoveredInputs;
+ };
+ return resolveInputs;
+ }
function createBoundedInputWriter({ writeValue, maxWriteAttempts }) {
if (typeof writeValue !== "function") {
throw new Error("输入框写入依赖异常");
@@ -2804,6 +2838,96 @@
throw new Error("图表保存接口未能恢复");
}
}
+ function createTradingViewRemoveSaveBurstController(api, {
+ settleQuietMs = 120,
+ maxWaitMs = 400,
+ setTimeoutFn = setTimeout,
+ clearTimeoutFn = clearTimeout
+ } = {}) {
+ validateTradingViewApi(api);
+ if (!Number.isFinite(settleQuietMs) || settleQuietMs <= 0) {
+ throw new Error("图表保存合并静默时间无效");
+ }
+ if (!Number.isFinite(maxWaitMs) || maxWaitMs < settleQuietMs) {
+ throw new Error("图表保存合并最长等待时间无效");
+ }
+ const sessionSaveChart = api.saveChart;
+ let activeBurst = null;
+ let removeEventCount = 0;
+ let saveRequestCount = 0;
+ let fullSaveCount = 0;
+ let stopped = false;
+ const clearBurstTimers = (burst) => {
+ if (burst.settleTimer !== null) clearTimeoutFn(burst.settleTimer);
+ if (burst.maxWaitTimer !== null) clearTimeoutFn(burst.maxWaitTimer);
+ burst.settleTimer = null;
+ burst.maxWaitTimer = null;
+ };
+ const flushActiveBurst = () => {
+ const burst = activeBurst;
+ if (!burst) return void 0;
+ clearBurstTimers(burst);
+ activeBurst = null;
+ restoreSaveChartMethod(
+ api,
+ burst.wrapper,
+ burst.originalSaveChart,
+ burst.originalDescriptor
+ );
+ if (!burst.pendingSave) return void 0;
+ fullSaveCount += 1;
+ return burst.originalSaveChart.apply(
+ burst.pendingSave.thisValue,
+ burst.pendingSave.args
+ );
+ };
+ const scheduleBurstSettle = (burst) => {
+ if (burst.settleTimer !== null) clearTimeoutFn(burst.settleTimer);
+ burst.settleTimer = setTimeoutFn(flushActiveBurst, settleQuietMs);
+ };
+ const startBurst = () => {
+ if (activeBurst || api.saveChart !== sessionSaveChart) return activeBurst;
+ const originalSaveChart = api.saveChart;
+ const originalDescriptor = Object.getOwnPropertyDescriptor(api, "saveChart");
+ const burst = {
+ maxWaitTimer: null,
+ originalDescriptor,
+ originalSaveChart,
+ pendingSave: null,
+ settleTimer: null,
+ wrapper: null
+ };
+ burst.wrapper = function removeSaveBurstWrapper(...args) {
+ saveRequestCount += 1;
+ burst.pendingSave = { thisValue: this, args };
+ return void 0;
+ };
+ api.saveChart = burst.wrapper;
+ if (api.saveChart !== burst.wrapper) {
+ throw new Error("图表保存接口无法启用删除事件合并");
+ }
+ activeBurst = burst;
+ burst.maxWaitTimer = setTimeoutFn(flushActiveBurst, maxWaitMs);
+ return burst;
+ };
+ const handleDrawingEvent = (_drawingId, eventType) => {
+ if (stopped || eventType !== "remove") return;
+ removeEventCount += 1;
+ const burst = startBurst();
+ if (burst) scheduleBurstSettle(burst);
+ };
+ api.subscribe("drawing_event", handleDrawingEvent);
+ return {
+ flush: flushActiveBurst,
+ stop() {
+ if (stopped) throw new Error("图表删除事件保存合并器已停止");
+ stopped = true;
+ api.unsubscribe("drawing_event", handleDrawingEvent);
+ flushActiveBurst();
+ return { fullSaveCount, removeEventCount, saveRequestCount };
+ }
+ };
+ }
async function coalesceTradingViewDrawingSaves(api, action, {
eventDiscoveryTimeoutMs = 800,
settleQuietMs = 50,
@@ -2924,6 +3048,28 @@
}
}
+ // src/binance-orderbook-trade/dom/tradingview-target.js
+ var CHART_ROOT_SELECTOR = ".chart-widget-root";
+ function hasVisibleBox(element) {
+ if (!element?.getClientRects().length) return false;
+ const rect = element.getBoundingClientRect();
+ return rect.width > 0 && rect.height > 0;
+ }
+ function findBinanceTradingViewTarget(document2) {
+ const chartRoots = Array.from(document2.querySelectorAll(CHART_ROOT_SELECTOR)).filter(hasVisibleBox);
+ if (!chartRoots.length) return null;
+ if (chartRoots.length > 1) {
+ throw new Error(`可见图表区域数量异常:${chartRoots.length}`);
+ }
+ const chartRoot = chartRoots[0];
+ const tradingViewApis = Array.from(chartRoot.querySelectorAll("iframe")).map((frame) => frame.contentWindow?.tradingViewApi).filter(Boolean);
+ if (!tradingViewApis.length) return null;
+ if (tradingViewApis.length > 1) {
+ throw new Error(`图表接口数量异常:${tradingViewApis.length}`);
+ }
+ return { chartRoot, tradingViewApi: tradingViewApis[0] };
+ }
+
// src/binance-orderbook-trade/core/cancel-dialog-decision.js
function resolveCancelDialogDecision({
seenDialog,
@@ -3113,28 +3259,6 @@
return cells.length >= MIN_OPEN_ORDER_COLUMNS ? cells : [];
}
- // src/binance-orderbook-trade/dom/tradingview-target.js
- var CHART_ROOT_SELECTOR = ".chart-widget-root";
- function hasVisibleBox(element) {
- if (!element?.getClientRects().length) return false;
- const rect = element.getBoundingClientRect();
- return rect.width > 0 && rect.height > 0;
- }
- function findBinanceTradingViewTarget(document2) {
- const chartRoots = Array.from(document2.querySelectorAll(CHART_ROOT_SELECTOR)).filter(hasVisibleBox);
- if (!chartRoots.length) return null;
- if (chartRoots.length > 1) {
- throw new Error(`可见图表区域数量异常:${chartRoots.length}`);
- }
- const chartRoot = chartRoots[0];
- const tradingViewApis = Array.from(chartRoot.querySelectorAll("iframe")).map((frame) => frame.contentWindow?.tradingViewApi).filter(Boolean);
- if (!tradingViewApis.length) return null;
- if (tradingViewApis.length > 1) {
- throw new Error(`图表接口数量异常:${tradingViewApis.length}`);
- }
- return { chartRoot, tradingViewApi: tradingViewApis[0] };
- }
-
// src/binance-orderbook-trade/dom/chart-orders.js
var ACTIVE_POPOVER_SELECTOR = ".bn-bubble.active";
var OPEN_ORDERS_LABEL_PATTERN = /^(?:当前委托|Open Orders)$/i;
@@ -3604,6 +3728,8 @@
const CANCEL_NO_ORDERS_FEEDBACK_MS = 600;
const CHART_ORDERS_MENU_TIMEOUT_MS = 1800;
const CHART_ORDERS_MENU_POLL_MS = 50;
+ const CONTINUOUS_CHART_REMOVE_SAVE_QUIET_MS = 120;
+ const CONTINUOUS_CHART_REMOVE_SAVE_MAX_WAIT_MS = 400;
const LADDER_MAKER_BUFFER_LEVELS = 1;
const LADDER_REPRICE_PAUSE_EVERY_ATTEMPTS = 5;
const LADDER_REPRICE_PAUSE_MS = 3e3;
@@ -3702,6 +3828,7 @@
let singleOrderTask = null;
let ladderAbortController = null;
let continuousLadderAbortController = null;
+ let continuousChartSaveCoalescer = null;
let activeLadderActionType = null;
let activeContinuousLadderActionType = null;
let activeContinuousLadderProgress = null;
@@ -5801,8 +5928,13 @@
);
}
const observationRoot = inputs.root;
+ const resolveSynchronizedTradeInputs = createTradeInputResolver(document, {
+ initialRoot: observationRoot,
+ panelId: PANEL_ID,
+ isVisibleElement
+ });
const readTradeState = createTradeInputStateReader({
- resolveInputs: findTradeInputs,
+ resolveInputs: resolveSynchronizedTradeInputs,
expectedPrice,
expectedQty,
includePrice: true,
@@ -6500,6 +6632,32 @@
formatContinuousLadderProgress(label, phase, progress, titleReason)
);
}
+ function startContinuousChartSaveCoalescing() {
+ try {
+ const target = findBinanceTradingViewTarget(document);
+ if (!target) return null;
+ return createTradingViewRemoveSaveBurstController(target.tradingViewApi, {
+ settleQuietMs: CONTINUOUS_CHART_REMOVE_SAVE_QUIET_MS,
+ maxWaitMs: CONTINUOUS_CHART_REMOVE_SAVE_MAX_WAIT_MS
+ });
+ } catch (error) {
+ warn("未启用连续交易图表保存合并:", error?.message || error);
+ return null;
+ }
+ }
+ function stopContinuousChartSaveCoalescing(coalescer) {
+ if (!coalescer) return;
+ try {
+ const result = coalescer.stop();
+ log("连续交易图表保存已合并", result);
+ } catch (error) {
+ err("连续交易图表保存合并清理失败:", error);
+ } finally {
+ if (continuousChartSaveCoalescer === coalescer) {
+ continuousChartSaveCoalescer = null;
+ }
+ }
+ }
async function startContinuousLadder(actionType) {
const spec = getLadderActionSpec2(actionType);
if (!spec || spec.mode !== "CLOSE") return startLadder(actionType);
@@ -6516,7 +6674,9 @@
const abortController = new AbortController();
const continuousProgress = createContinuousLadderProgress();
const positionCheckState = { checkedAt: Date.now(), retryAt: 0 };
+ const chartSaveCoalescer = startContinuousChartSaveCoalescing();
continuousLadderAbortController = abortController;
+ continuousChartSaveCoalescer = chartSaveCoalescer;
activeContinuousLadderActionType = actionType;
activeContinuousLadderProgress = continuousProgress;
const executionTask = (async () => {
@@ -6616,6 +6776,7 @@
);
return { status: "failed", error: e };
}).finally(() => {
+ stopContinuousChartSaveCoalescing(chartSaveCoalescer);
if (continuousLadderAbortController === abortController) {
continuousLadderAbortController = null;
}
@@ -10483,6 +10644,11 @@
scheduleChartOrdersRecovery();
document.addEventListener("visibilitychange", () => {
if (document.hidden) {
+ try {
+ continuousChartSaveCoalescer?.flush();
+ } catch (error) {
+ err("页面隐藏前图表保存刷新失败:", error);
+ }
stopTradingTimers();
stopRouteWatcher();
return;
diff --git a/src/binance-orderbook-trade/core/chart-save-coalescer.js b/src/binance-orderbook-trade/core/chart-save-coalescer.js
index 48df871..7f782a0 100644
--- a/src/binance-orderbook-trade/core/chart-save-coalescer.js
+++ b/src/binance-orderbook-trade/core/chart-save-coalescer.js
@@ -26,6 +26,109 @@ function restoreSaveChartMethod(api, wrapper, originalSaveChart, originalDescrip
}
}
+/**
+ * Listen throughout a continuous-close session, but replace saveChart only for
+ * a short window opened by a remove drawing event. Unrelated chart saves keep
+ * their original synchronous path, while consecutive order-line removals replay
+ * only their final cumulative snapshot.
+ */
+export function createTradingViewRemoveSaveBurstController(
+ api,
+ {
+ settleQuietMs = 120,
+ maxWaitMs = 400,
+ setTimeoutFn = setTimeout,
+ clearTimeoutFn = clearTimeout,
+ } = {},
+) {
+ validateTradingViewApi(api);
+ if (!Number.isFinite(settleQuietMs) || settleQuietMs <= 0) {
+ throw new Error('图表保存合并静默时间无效');
+ }
+ if (!Number.isFinite(maxWaitMs) || maxWaitMs < settleQuietMs) {
+ throw new Error('图表保存合并最长等待时间无效');
+ }
+
+ const sessionSaveChart = api.saveChart;
+ let activeBurst = null;
+ let removeEventCount = 0;
+ let saveRequestCount = 0;
+ let fullSaveCount = 0;
+ let stopped = false;
+
+ const clearBurstTimers = (burst) => {
+ if (burst.settleTimer !== null) clearTimeoutFn(burst.settleTimer);
+ if (burst.maxWaitTimer !== null) clearTimeoutFn(burst.maxWaitTimer);
+ burst.settleTimer = null;
+ burst.maxWaitTimer = null;
+ };
+ const flushActiveBurst = () => {
+ const burst = activeBurst;
+ if (!burst) return undefined;
+ clearBurstTimers(burst);
+ activeBurst = null;
+ restoreSaveChartMethod(
+ api,
+ burst.wrapper,
+ burst.originalSaveChart,
+ burst.originalDescriptor,
+ );
+ if (!burst.pendingSave) return undefined;
+ fullSaveCount += 1;
+ return burst.originalSaveChart.apply(
+ burst.pendingSave.thisValue,
+ burst.pendingSave.args,
+ );
+ };
+ const scheduleBurstSettle = (burst) => {
+ if (burst.settleTimer !== null) clearTimeoutFn(burst.settleTimer);
+ burst.settleTimer = setTimeoutFn(flushActiveBurst, settleQuietMs);
+ };
+ const startBurst = () => {
+ if (activeBurst || api.saveChart !== sessionSaveChart) return activeBurst;
+ const originalSaveChart = api.saveChart;
+ const originalDescriptor = Object.getOwnPropertyDescriptor(api, 'saveChart');
+ const burst = {
+ maxWaitTimer: null,
+ originalDescriptor,
+ originalSaveChart,
+ pendingSave: null,
+ settleTimer: null,
+ wrapper: null,
+ };
+ burst.wrapper = function removeSaveBurstWrapper(...args) {
+ saveRequestCount += 1;
+ burst.pendingSave = { thisValue: this, args };
+ return undefined;
+ };
+ api.saveChart = burst.wrapper;
+ if (api.saveChart !== burst.wrapper) {
+ throw new Error('图表保存接口无法启用删除事件合并');
+ }
+ activeBurst = burst;
+ burst.maxWaitTimer = setTimeoutFn(flushActiveBurst, maxWaitMs);
+ return burst;
+ };
+ const handleDrawingEvent = (_drawingId, eventType) => {
+ if (stopped || eventType !== 'remove') return;
+ removeEventCount += 1;
+ const burst = startBurst();
+ if (burst) scheduleBurstSettle(burst);
+ };
+ api.subscribe('drawing_event', handleDrawingEvent);
+
+ return {
+ flush: flushActiveBurst,
+ stop() {
+ if (stopped) throw new Error('图表删除事件保存合并器已停止');
+ stopped = true;
+ api.unsubscribe('drawing_event', handleDrawingEvent);
+ flushActiveBurst();
+ return { fullSaveCount, removeEventCount, saveRequestCount };
+ },
+ };
+}
+
/**
* 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/dom/trade-form.js b/src/binance-orderbook-trade/dom/trade-form.js
index 55a9f0d..eb04688 100644
--- a/src/binance-orderbook-trade/dom/trade-form.js
+++ b/src/binance-orderbook-trade/dom/trade-form.js
@@ -125,6 +125,49 @@ export function findActiveTradeInputs(ownerDocument, {
return matches.length === 1 ? matches[0] : null;
}
+/**
+ * Cache only the proven form owner. React may replace its controlled inputs on
+ * any frame, so every read still resolves the live descendants inside that
+ * small root. A connected root may be temporarily incomplete while React is
+ * replacing descendants, so only a disconnected root triggers full-document
+ * discovery and establishes the next owner.
+ */
+export function createTradeInputResolver(ownerDocument, {
+ initialRoot = null,
+ panelId,
+ isVisibleElement,
+}) {
+ if (!ownerDocument?.querySelectorAll || typeof isVisibleElement !== 'function') {
+ throw new Error('交易表单解析依赖异常');
+ }
+ let cachedRoot = initialRoot;
+ const resolveInputs = (options = null) => {
+ const requirePrice = options?.requirePrice !== false;
+ if (cachedRoot?.isConnected) {
+ const cachedInputs = findActiveTradeInputs(cachedRoot, {
+ panelId,
+ isVisibleElement,
+ requirePrice,
+ });
+ if (cachedInputs) {
+ cachedRoot = cachedInputs.root;
+ return cachedInputs;
+ }
+ return null;
+ }
+
+ cachedRoot = null;
+ const discoveredInputs = findActiveTradeInputs(ownerDocument, {
+ panelId,
+ isVisibleElement,
+ requirePrice,
+ });
+ if (discoveredInputs) cachedRoot = discoveredInputs.root;
+ return discoveredInputs;
+ };
+ return resolveInputs;
+}
+
export function createBoundedInputWriter({ writeValue, maxWriteAttempts }) {
if (typeof writeValue !== 'function') {
throw new Error('输入框写入依赖异常');
diff --git a/src/binance-orderbook-trade/index.user.js b/src/binance-orderbook-trade/index.user.js
index 78b3602..add481d 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.177
+// @version 2.7.178
// @author jackhai9
// @description 单击订单簿价格,按当前开仓/平仓 tab 自动填数量并执行下单,内置数量倍率面板
// @match https://www.binance.com/*/futures/*
@@ -162,6 +162,7 @@ import {
calculateFloatingPanelLayout,
collectTradeButtonsFromScopes,
createBoundedInputWriter,
+ createTradeInputResolver,
createTradeInputStateReader,
findActiveTradeInputs as findActiveTradeInputsDom,
findTradeFormRoot,
@@ -198,7 +199,11 @@ import {
createChartOrdersRecoveryRecord,
parseChartOrdersRecoveryRecord,
} from './core/chart-orders-recovery.js';
-import { coalesceTradingViewDrawingSaves } from './core/chart-save-coalescer.js';
+import {
+ coalesceTradingViewDrawingSaves,
+ createTradingViewRemoveSaveBurstController,
+} from './core/chart-save-coalescer.js';
+import { findBinanceTradingViewTarget } from './dom/tradingview-target.js';
import { resolveCancelDialogDecision } from './core/cancel-dialog-decision.js';
import {
classifyBinanceCancelAllDialogAction,
@@ -325,6 +330,8 @@ import { showUsdtRebalanceDialog } from './dom/usdt-rebalance-dialog.js';
const CANCEL_NO_ORDERS_FEEDBACK_MS = 600;
const CHART_ORDERS_MENU_TIMEOUT_MS = 1800;
const CHART_ORDERS_MENU_POLL_MS = 50;
+ const CONTINUOUS_CHART_REMOVE_SAVE_QUIET_MS = 120;
+ const CONTINUOUS_CHART_REMOVE_SAVE_MAX_WAIT_MS = 400;
const LADDER_MAKER_BUFFER_LEVELS = 1;
const LADDER_REPRICE_PAUSE_EVERY_ATTEMPTS = 5;
const LADDER_REPRICE_PAUSE_MS = 3000;
@@ -424,6 +431,7 @@ import { showUsdtRebalanceDialog } from './dom/usdt-rebalance-dialog.js';
let singleOrderTask = null;
let ladderAbortController = null;
let continuousLadderAbortController = null;
+ let continuousChartSaveCoalescer = null;
let activeLadderActionType = null;
let activeContinuousLadderActionType = null;
let activeContinuousLadderProgress = null;
@@ -2953,8 +2961,13 @@ import { showUsdtRebalanceDialog } from './dom/usdt-rebalance-dialog.js';
}
const observationRoot = inputs.root;
+ const resolveSynchronizedTradeInputs = createTradeInputResolver(document, {
+ initialRoot: observationRoot,
+ panelId: PANEL_ID,
+ isVisibleElement,
+ });
const readTradeState = createTradeInputStateReader({
- resolveInputs: findTradeInputs,
+ resolveInputs: resolveSynchronizedTradeInputs,
expectedPrice,
expectedQty,
includePrice: true,
@@ -3781,6 +3794,34 @@ import { showUsdtRebalanceDialog } from './dom/usdt-rebalance-dialog.js';
);
}
+ function startContinuousChartSaveCoalescing() {
+ try {
+ const target = findBinanceTradingViewTarget(document);
+ if (!target) return null;
+ return createTradingViewRemoveSaveBurstController(target.tradingViewApi, {
+ settleQuietMs: CONTINUOUS_CHART_REMOVE_SAVE_QUIET_MS,
+ maxWaitMs: CONTINUOUS_CHART_REMOVE_SAVE_MAX_WAIT_MS,
+ });
+ } catch (error) {
+ warn('未启用连续交易图表保存合并:', error?.message || error);
+ return null;
+ }
+ }
+
+ function stopContinuousChartSaveCoalescing(coalescer) {
+ if (!coalescer) return;
+ try {
+ const result = coalescer.stop();
+ log('连续交易图表保存已合并', result);
+ } catch (error) {
+ err('连续交易图表保存合并清理失败:', error);
+ } finally {
+ if (continuousChartSaveCoalescer === coalescer) {
+ continuousChartSaveCoalescer = null;
+ }
+ }
+ }
+
async function startContinuousLadder(actionType) {
const spec = getLadderActionSpec(actionType);
if (!spec || spec.mode !== 'CLOSE') return startLadder(actionType);
@@ -3799,7 +3840,9 @@ import { showUsdtRebalanceDialog } from './dom/usdt-rebalance-dialog.js';
const abortController = new AbortController();
const continuousProgress = createContinuousLadderProgress();
const positionCheckState = { checkedAt: Date.now(), retryAt: 0 };
+ const chartSaveCoalescer = startContinuousChartSaveCoalescing();
continuousLadderAbortController = abortController;
+ continuousChartSaveCoalescer = chartSaveCoalescer;
activeContinuousLadderActionType = actionType;
activeContinuousLadderProgress = continuousProgress;
@@ -3908,6 +3951,7 @@ import { showUsdtRebalanceDialog } from './dom/usdt-rebalance-dialog.js';
return { status: 'failed', error: e };
})
.finally(() => {
+ stopContinuousChartSaveCoalescing(chartSaveCoalescer);
if (continuousLadderAbortController === abortController) {
continuousLadderAbortController = null;
}
@@ -8393,6 +8437,11 @@ import { showUsdtRebalanceDialog } from './dom/usdt-rebalance-dialog.js';
document.addEventListener('visibilitychange', () => {
if (document.hidden) {
+ try {
+ continuousChartSaveCoalescer?.flush();
+ } catch (error) {
+ err('页面隐藏前图表保存刷新失败:', error);
+ }
stopTradingTimers();
stopRouteWatcher();
return;
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 4453e72..83c1f43 100644
--- a/test/unit/binance-orderbook-trade/chart-save-coalescer.test.js
+++ b/test/unit/binance-orderbook-trade/chart-save-coalescer.test.js
@@ -1,7 +1,10 @@
import test from 'node:test';
import assert from 'node:assert/strict';
-import { coalesceTradingViewDrawingSaves } from '../../../src/binance-orderbook-trade/core/chart-save-coalescer.js';
+import {
+ coalesceTradingViewDrawingSaves,
+ createTradingViewRemoveSaveBurstController,
+} from '../../../src/binance-orderbook-trade/core/chart-save-coalescer.js';
function createTradingViewApi() {
const listeners = new Map();
@@ -25,6 +28,185 @@ function createTradingViewApi() {
return { api, saved, listeners };
}
+function createManualTimers() {
+ let now = 0;
+ let sequence = 0;
+ const timers = new Map();
+ const setTimeoutFn = (callback, delayMs) => {
+ sequence += 1;
+ timers.set(sequence, { callback, at: now + delayMs });
+ return sequence;
+ };
+ const clearTimeoutFn = (timerId) => timers.delete(timerId);
+ const advance = (elapsedMs) => {
+ const deadline = now + elapsedMs;
+ while (true) {
+ const next = Array.from(timers.entries())
+ .filter(([, timer]) => timer.at <= deadline)
+ .sort((left, right) => left[1].at - right[1].at || left[0] - right[0])[0];
+ if (!next) break;
+ const [timerId, timer] = next;
+ timers.delete(timerId);
+ now = timer.at;
+ timer.callback();
+ }
+ now = deadline;
+ };
+ return { advance, clearTimeoutFn, setTimeoutFn, timers };
+}
+
+test('continuous remove-save controller leaves unrelated chart saves synchronous', () => {
+ const { api, saved } = createTradingViewApi();
+ const originalSaveChart = api.saveChart;
+ const timers = createManualTimers();
+ const coalescer = createTradingViewRemoveSaveBurstController(api, {
+ settleQuietMs: 20,
+ maxWaitMs: 100,
+ setTimeoutFn: timers.setTimeoutFn,
+ clearTimeoutFn: timers.clearTimeoutFn,
+ });
+
+ api.saveChart('unrelated');
+ assert.deepEqual(saved.map((entry) => entry.args), [['unrelated']]);
+
+ assert.deepEqual(coalescer.stop(), {
+ fullSaveCount: 0,
+ removeEventCount: 0,
+ saveRequestCount: 0,
+ });
+ assert.equal(api.saveChart, originalSaveChart);
+});
+
+test('continuous remove-save controller persists only the final save in one remove burst', () => {
+ const { api, saved } = createTradingViewApi();
+ const timers = createManualTimers();
+ const coalescer = createTradingViewRemoveSaveBurstController(api, {
+ settleQuietMs: 20,
+ maxWaitMs: 100,
+ setTimeoutFn: timers.setTimeoutFn,
+ clearTimeoutFn: timers.clearTimeoutFn,
+ });
+
+ api.emit('drawing_event', 'order-1', 'remove');
+ api.saveChart('snapshot-1');
+ timers.advance(10);
+ api.emit('drawing_event', 'order-2', 'remove');
+ api.saveChart('snapshot-2');
+ timers.advance(10);
+ api.emit('drawing_event', 'order-3', 'remove');
+ api.saveChart('snapshot-3');
+ timers.advance(19);
+ assert.deepEqual(saved, []);
+ timers.advance(1);
+ assert.deepEqual(saved.map((entry) => entry.args), [['snapshot-3']]);
+
+ assert.deepEqual(coalescer.stop(), {
+ fullSaveCount: 1,
+ removeEventCount: 3,
+ saveRequestCount: 3,
+ });
+});
+
+test('continuous remove-save controller flushes at its maximum wait during sustained removals', () => {
+ const { api, saved } = createTradingViewApi();
+ const timers = createManualTimers();
+ const coalescer = createTradingViewRemoveSaveBurstController(api, {
+ settleQuietMs: 50,
+ maxWaitMs: 100,
+ setTimeoutFn: timers.setTimeoutFn,
+ clearTimeoutFn: timers.clearTimeoutFn,
+ });
+
+ api.emit('drawing_event', 'order-1', 'remove');
+ api.saveChart('snapshot-1');
+ for (let index = 2; index <= 5; index += 1) {
+ timers.advance(20);
+ api.emit('drawing_event', `order-${index}`, 'remove');
+ api.saveChart(`snapshot-${index}`);
+ }
+ timers.advance(20);
+ assert.deepEqual(saved.map((entry) => entry.args), [['snapshot-5']]);
+
+ coalescer.stop();
+});
+
+test('continuous remove-save controller flushes pending state and restores the original method on stop', () => {
+ const { api, saved } = createTradingViewApi();
+ const originalSaveChart = api.saveChart;
+ const timers = createManualTimers();
+ const coalescer = createTradingViewRemoveSaveBurstController(api, {
+ settleQuietMs: 20,
+ maxWaitMs: 100,
+ setTimeoutFn: timers.setTimeoutFn,
+ clearTimeoutFn: timers.clearTimeoutFn,
+ });
+
+ api.emit('drawing_event', 'order-1', 'remove');
+ api.saveChart('pending-final');
+ assert.deepEqual(coalescer.stop(), {
+ fullSaveCount: 1,
+ removeEventCount: 1,
+ saveRequestCount: 1,
+ });
+ assert.deepEqual(saved.map((entry) => entry.args), [['pending-final']]);
+ assert.equal(api.saveChart, originalSaveChart);
+ assert.equal(timers.timers.size, 0);
+
+ api.saveChart('after-stop');
+ assert.deepEqual(saved.map((entry) => entry.args), [['pending-final'], ['after-stop']]);
+});
+
+test('continuous remove-save controller ignores non-remove drawing events', () => {
+ const { api, saved } = createTradingViewApi();
+ const coalescer = createTradingViewRemoveSaveBurstController(api);
+
+ api.emit('drawing_event', 'order-1', 'properties_changed');
+ api.saveChart('properties');
+
+ assert.deepEqual(saved.map((entry) => entry.args), [['properties']]);
+ assert.deepEqual(coalescer.stop(), {
+ fullSaveCount: 0,
+ removeEventCount: 0,
+ saveRequestCount: 0,
+ });
+});
+
+test('continuous remove-save controller does not replace another active save wrapper', () => {
+ const { api, saved } = createTradingViewApi();
+ const controller = createTradingViewRemoveSaveBurstController(api);
+ const sessionSaveChart = api.saveChart;
+ const foreignSaves = [];
+ api.saveChart = (...args) => foreignSaves.push(args);
+
+ api.emit('drawing_event', 'order-1', 'remove');
+ api.saveChart('foreign');
+ assert.deepEqual(foreignSaves, [['foreign']]);
+ assert.deepEqual(saved, []);
+
+ api.saveChart = sessionSaveChart;
+ assert.deepEqual(controller.stop(), {
+ fullSaveCount: 0,
+ removeEventCount: 1,
+ saveRequestCount: 0,
+ });
+});
+
+test('continuous remove-save controller restores the chart method when the final save throws', () => {
+ const { api, listeners } = createTradingViewApi();
+ const originalSaveChart = function saveChart() {
+ throw new Error('final save failed');
+ };
+ api.saveChart = originalSaveChart;
+ const controller = createTradingViewRemoveSaveBurstController(api);
+
+ api.emit('drawing_event', 'order-1', 'remove');
+ api.saveChart('pending');
+
+ assert.throws(() => controller.stop(), /final save failed/);
+ assert.equal(api.saveChart, originalSaveChart);
+ assert.equal(listeners.get('drawing_event')?.size, 0);
+});
+
for (const drawingCount of [1, 5, 70, 120, 199, 200]) {
test(`coalesces ${drawingCount} drawing saves into one final full save`, async () => {
const { api, saved, listeners } = createTradingViewApi();
diff --git a/test/unit/binance-orderbook-trade/source-regressions.test.js b/test/unit/binance-orderbook-trade/source-regressions.test.js
index a581679..c3d8106 100644
--- a/test/unit/binance-orderbook-trade/source-regressions.test.js
+++ b/test/unit/binance-orderbook-trade/source-regressions.test.js
@@ -180,7 +180,7 @@ test('trade input synchronization confirms live controlled values instead of sle
assert.doesNotMatch(setInputBody, /dispatchEvent\(new Event\('blur'/);
assert.equal((syncBody.match(/createTradeInputStateReader/g) || []).length, 1);
assert.match(syncBody, /createBoundedInputWriter/);
- assert.match(syncBody, /resolveInputs:\s*findTradeInputs/);
+ assert.match(syncBody, /resolveInputs:\s*resolveSynchronizedTradeInputs/);
assert.equal((syncBody.match(/writeValue:\s*writeTradeInputValue/g) || []).length, 1);
assert.match(syncBody, /requiredStableMismatchFrames:\s*TRADE_INPUT_SYNC_STABLE_FRAMES/);
assert.match(syncBody, /requiredStableMismatchMs:\s*stableDurationMs/);
@@ -788,6 +788,38 @@ test('Option or Alt click continuously repeats close ladders only after readines
assert.match(controlSectionsBody, /!!ladderTask \|\| !!continuousLadderTask/);
});
+test('continuous close coalesces only short remove-save bursts and restores the chart method', () => {
+ const startCoalescingBody = readFunctionBody('startContinuousChartSaveCoalescing');
+ const stopCoalescingBody = readFunctionBody('stopContinuousChartSaveCoalescing');
+ const continuousBody = readFunctionBody('startContinuousLadder');
+
+ assert.match(chartSaveCoalescerSource, /export function createTradingViewRemoveSaveBurstController/);
+ assert.match(chartSaveCoalescerSource, /eventType !== 'remove'/);
+ assert.match(chartSaveCoalescerSource, /api\.subscribe\('drawing_event', handleDrawingEvent\)/);
+ assert.match(chartSaveCoalescerSource, /api\.unsubscribe\('drawing_event', handleDrawingEvent\)/);
+ assert.match(chartSaveCoalescerSource, /burst\.originalSaveChart\.apply/);
+ assert.doesNotMatch(chartSaveCoalescerSource, /saveToJSON|orderUpdate|ordersFullUpdate/);
+ assert.match(startCoalescingBody, /findBinanceTradingViewTarget\(document\)/);
+ assert.match(startCoalescingBody, /CONTINUOUS_CHART_REMOVE_SAVE_QUIET_MS/);
+ assert.match(startCoalescingBody, /CONTINUOUS_CHART_REMOVE_SAVE_MAX_WAIT_MS/);
+ assert.match(continuousBody, /const chartSaveCoalescer = startContinuousChartSaveCoalescing\(\)/);
+ assert.match(continuousBody, /stopContinuousChartSaveCoalescing\(chartSaveCoalescer\)/);
+ assert.match(stopCoalescingBody, /coalescer\.stop\(\)/);
+ assert.match(source, /continuousChartSaveCoalescer\?\.flush\(\)/);
+});
+
+test('trade input frame synchronization reuses only its initially proven form root', () => {
+ const syncBody = readFunctionBody('syncTradeInputs');
+ const findBody = readFunctionBody('findTradeInputs');
+
+ assert.match(findBody, /findActiveTradeInputsDom\(document/);
+ assert.match(syncBody, /const inputs = findTradeInputs\(\)/);
+ assert.match(syncBody, /createTradeInputResolver\(document, \{/);
+ assert.match(syncBody, /initialRoot: observationRoot/);
+ assert.match(syncBody, /resolveInputs: resolveSynchronizedTradeInputs/);
+ assert.doesNotMatch(syncBody, /resolveInputs: findTradeInputs/);
+});
+
test('open ladder stops immediately only for a confirmed zero available balance', () => {
const readOpenQtyBody = readFunctionBody('readOpenBaseQtyForLadder');
assert.match(readOpenQtyBody, /isConfirmedZeroOpenBalance\(quantity\.qty\)/);
diff --git a/test/unit/binance-orderbook-trade/trade-form.test.js b/test/unit/binance-orderbook-trade/trade-form.test.js
index bc25a08..7664b9d 100644
--- a/test/unit/binance-orderbook-trade/trade-form.test.js
+++ b/test/unit/binance-orderbook-trade/trade-form.test.js
@@ -5,6 +5,7 @@ import assert from 'node:assert/strict';
import {
calculateFloatingPanelLayout,
createBoundedInputWriter,
+ createTradeInputResolver,
createTradeInputStateReader,
findActiveTradeInputs,
findTradeFormRoot,
@@ -431,6 +432,167 @@ test('active trade form can be resolved before a limit-price input is rendered',
assert.equal(form?.priceInput, null);
});
+test('trade input resolver scans the document once and then resolves live inputs inside the cached root', () => {
+ const dom = loadFixtureDom(`
+
+ `);
+ const { document } = dom.window;
+ const originalQuerySelectorAll = document.querySelectorAll.bind(document);
+ let documentScans = 0;
+ document.querySelectorAll = (...args) => {
+ documentScans += 1;
+ return originalQuerySelectorAll(...args);
+ };
+ const resolveInputs = createTradeInputResolver(document, {
+ panelId: 'jh-binance-close-qty-multiplier-panel',
+ isVisibleElement: () => true,
+ });
+
+ assert.equal(resolveInputs()?.qtyInput.id, 'unitAmount-close');
+ const scansAfterDiscovery = documentScans;
+ assert.ok(scansAfterDiscovery > 0);
+ assert.equal(resolveInputs()?.priceInput.id, 'limitPrice-close');
+ assert.equal(resolveInputs()?.qtyInput.id, 'unitAmount-close');
+ assert.equal(documentScans, scansAfterDiscovery);
+});
+
+test('trade input resolver starts from a proven root without another document scan', () => {
+ const dom = loadFixtureDom(`
+
+ `);
+ const { document } = dom.window;
+ const root = document.querySelector('#trade-form');
+ const originalQuerySelectorAll = document.querySelectorAll.bind(document);
+ let documentScans = 0;
+ document.querySelectorAll = (...args) => {
+ documentScans += 1;
+ return originalQuerySelectorAll(...args);
+ };
+ const resolveInputs = createTradeInputResolver(document, {
+ initialRoot: root,
+ panelId: 'jh-binance-close-qty-multiplier-panel',
+ isVisibleElement: () => true,
+ });
+
+ assert.equal(resolveInputs()?.qtyInput.id, 'unitAmount-close');
+ assert.equal(resolveInputs()?.priceInput.id, 'limitPrice-close');
+ assert.equal(documentScans, 0);
+});
+
+test('trade input resolver follows React descendant replacement without rescanning the document', () => {
+ const dom = loadFixtureDom(`
+
+ `);
+ const { document } = dom.window;
+ const originalQuerySelectorAll = document.querySelectorAll.bind(document);
+ let documentScans = 0;
+ document.querySelectorAll = (...args) => {
+ documentScans += 1;
+ return originalQuerySelectorAll(...args);
+ };
+ const resolveInputs = createTradeInputResolver(document, {
+ panelId: 'jh-binance-close-qty-multiplier-panel',
+ isVisibleElement: () => true,
+ });
+ const first = resolveInputs();
+ const scansAfterDiscovery = documentScans;
+ const replacementPrice = document.createElement('input');
+ replacementPrice.id = 'limitPrice-close-replacement';
+ const replacementQty = document.createElement('input');
+ replacementQty.id = 'unitAmount-close-replacement';
+ first.priceInput.replaceWith(replacementPrice);
+ first.qtyInput.replaceWith(replacementQty);
+
+ const second = resolveInputs();
+ assert.equal(second?.root, first.root);
+ assert.equal(second?.priceInput, replacementPrice);
+ assert.equal(second?.qtyInput, replacementQty);
+ assert.equal(documentScans, scansAfterDiscovery);
+});
+
+test('trade input resolver waits inside a connected root while React temporarily removes inputs', () => {
+ const dom = loadFixtureDom(`
+
+ `);
+ const { document } = dom.window;
+ const originalQuerySelectorAll = document.querySelectorAll.bind(document);
+ let documentScans = 0;
+ document.querySelectorAll = (...args) => {
+ documentScans += 1;
+ return originalQuerySelectorAll(...args);
+ };
+ const resolveInputs = createTradeInputResolver(document, {
+ panelId: 'jh-binance-close-qty-multiplier-panel',
+ isVisibleElement: () => true,
+ });
+ const first = resolveInputs();
+ const scansAfterDiscovery = documentScans;
+ first.priceInput.remove();
+ first.qtyInput.remove();
+
+ assert.equal(resolveInputs(), null);
+ assert.equal(first.root.isConnected, true);
+ assert.equal(documentScans, scansAfterDiscovery);
+
+ const replacementPrice = document.createElement('input');
+ replacementPrice.id = 'limitPrice-close-replacement';
+ const replacementQty = document.createElement('input');
+ replacementQty.id = 'unitAmount-close-replacement';
+ first.root.append(replacementPrice, replacementQty);
+
+ const recovered = resolveInputs();
+ assert.equal(recovered?.priceInput, replacementPrice);
+ assert.equal(recovered?.qtyInput, replacementQty);
+ assert.equal(documentScans, scansAfterDiscovery);
+});
+
+test('trade input resolver rediscovers the form after React replaces the cached root', () => {
+ const dom = loadFixtureDom(`
+
+
+
+ `);
+ const { document } = dom.window;
+ const resolveInputs = createTradeInputResolver(document, {
+ panelId: 'jh-binance-close-qty-multiplier-panel',
+ isVisibleElement: () => true,
+ });
+ const first = resolveInputs();
+ first.root.outerHTML = `
+
+ `;
+
+ const second = resolveInputs();
+ assert.equal(first.root.isConnected, false);
+ assert.equal(second?.root.id, 'trade-form-next');
+ assert.equal(second?.priceInput.id, 'limitPrice-open');
+ assert.equal(second?.qtyInput.id, 'unitAmount-open');
+});
+
test('trade input synchronization performs one post-transition write after a stable same-node rollback', () => {
const currentInputs = {
root: {},