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: {},