From 91306c2bbe264ee0b5d70268de0efec85dbc0e88 Mon Sep 17 00:00:00 2001 From: LiZhenhai-MBP14 <5935568+jackhai9@users.noreply.github.com> Date: Tue, 1 Sep 2026 11:12:48 +0800 Subject: [PATCH] Coalesce ladder submit chart saves --- scripts/binance-orderbook-trade.user.js | 288 ++++++++-- .../core/chart-save-coalescer.js | 230 +++++++- src/binance-orderbook-trade/index.user.js | 77 ++- .../chart-save-coalescer.test.js | 504 +++++++++++++++++- .../source-regressions.test.js | 30 +- 5 files changed, 1024 insertions(+), 105 deletions(-) diff --git a/scripts/binance-orderbook-trade.user.js b/scripts/binance-orderbook-trade.user.js index f24fd01..44719dc 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.178 +// @version 2.7.179 // @author jackhai9 // @description 单击订单簿价格,按当前开仓/平仓 tab 自动填数量并执行下单,内置数量倍率面板 // @match https://www.binance.com/*/futures/* @@ -2838,9 +2838,15 @@ throw new Error("图表保存接口未能恢复"); } } - function createTradingViewRemoveSaveBurstController(api, { + function readTradingViewDrawingToolName(api, drawingId) { + const shape = api.activeChart?.().getShapeById?.(String(drawingId)); + return shape?.lineDataSource?.()?.toolname || null; + } + function createTradingViewContinuousSaveController(api, { settleQuietMs = 120, maxWaitMs = 400, + submitEventDiscoveryMs = 250, + getDrawingToolName = readTradingViewDrawingToolName, setTimeoutFn = setTimeout, clearTimeoutFn = clearTimeout } = {}) { @@ -2851,12 +2857,39 @@ if (!Number.isFinite(maxWaitMs) || maxWaitMs < settleQuietMs) { throw new Error("图表保存合并最长等待时间无效"); } + if (!Number.isFinite(submitEventDiscoveryMs) || submitEventDiscoveryMs < 0) { + throw new Error("订单线事件等待时间无效"); + } + if (typeof getDrawingToolName !== "function") { + throw new Error("订单线类型解析依赖异常"); + } const sessionSaveChart = api.saveChart; let activeBurst = null; + let activeRound = null; + let activeSubmitCapture = null; let removeEventCount = 0; + let orderEventCount = 0; let saveRequestCount = 0; let fullSaveCount = 0; + let deferredSubmitSaveCount = 0; + let sequence = 0; let stopped = false; + const getStats = () => ({ + deferredSubmitSaveCount, + fullSaveCount, + orderEventCount, + removeEventCount, + saveRequestCount + }); + const finishSubmitCapture = (capture, status) => { + if (!capture || capture.settled) return; + if (capture.discoveryTimer !== null) clearTimeoutFn(capture.discoveryTimer); + capture.discoveryTimer = null; + capture.settled = true; + capture.status = status; + if (activeSubmitCapture === capture) activeSubmitCapture = null; + capture.resolve({ matched: capture.matched, status }); + }; const clearBurstTimers = (burst) => { if (burst.settleTimer !== null) clearTimeoutFn(burst.settleTimer); if (burst.maxWaitTimer !== null) clearTimeoutFn(burst.maxWaitTimer); @@ -2868,18 +2901,44 @@ 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 saveChartWasReplaced = api.saveChart !== burst.wrapper; + if (!saveChartWasReplaced) { + restoreSaveChartMethod( + api, + burst.wrapper, + burst.originalSaveChart, + burst.originalDescriptor + ); + } + try { + if (!burst.pendingSave) return void 0; + if (saveChartWasReplaced) { + if (activeRound?.pendingSave) activeRound.pendingSave = null; + fullSaveCount += 1; + return burst.originalSaveChart.apply( + burst.pendingSave.thisValue, + burst.pendingSave.args + ); + } + if (burst.deferToRound && activeRound) { + activeRound.pendingSave = burst.pendingSave; + deferredSubmitSaveCount += 1; + return void 0; + } + if (activeRound?.pendingSave) activeRound.pendingSave = null; + fullSaveCount += 1; + return burst.originalSaveChart.apply( + burst.pendingSave.thisValue, + burst.pendingSave.args + ); + } finally { + if (burst.submitCapture) { + finishSubmitCapture( + burst.submitCapture, + saveChartWasReplaced ? "save-chart-replaced" : "captured" + ); + } + } }; const scheduleBurstSettle = (burst) => { if (burst.settleTimer !== null) clearTimeoutFn(burst.settleTimer); @@ -2891,13 +2950,15 @@ const originalDescriptor = Object.getOwnPropertyDescriptor(api, "saveChart"); const burst = { maxWaitTimer: null, + deferToRound: false, originalDescriptor, originalSaveChart, pendingSave: null, settleTimer: null, + submitCapture: null, wrapper: null }; - burst.wrapper = function removeSaveBurstWrapper(...args) { + burst.wrapper = function continuousSaveBurstWrapper(...args) { saveRequestCount += 1; burst.pendingSave = { thisValue: this, args }; return void 0; @@ -2910,21 +2971,131 @@ burst.maxWaitTimer = setTimeoutFn(flushActiveBurst, maxWaitMs); return burst; }; - const handleDrawingEvent = (_drawingId, eventType) => { - if (stopped || eventType !== "remove") return; - removeEventCount += 1; + const flushRoundPendingSave = () => { + const pendingSave = activeRound?.pendingSave || null; + if (!pendingSave) return void 0; + activeRound.pendingSave = null; + fullSaveCount += 1; + return api.saveChart.apply(pendingSave.thisValue, pendingSave.args); + }; + const handleDrawingEvent = (drawingId, eventType) => { + if (stopped || IGNORED_DRAWING_EVENT_TYPES.has(eventType)) return; + if (eventType === "remove") { + removeEventCount += 1; + if (activeBurst?.deferToRound) flushActiveBurst(); + const burst2 = startBurst(); + if (burst2) scheduleBurstSettle(burst2); + return; + } + const capture = activeSubmitCapture; + if (!capture || capture.settled) return; + let toolName = null; + try { + toolName = getDrawingToolName(api, drawingId); + } catch { + return; + } + if (toolName !== "LineToolOrder") return; + orderEventCount += 1; + capture.matched = true; + if (capture.discoveryTimer !== null) clearTimeoutFn(capture.discoveryTimer); + capture.discoveryTimer = null; + if (activeBurst && !activeBurst.deferToRound) flushActiveBurst(); const burst = startBurst(); - if (burst) scheduleBurstSettle(burst); + if (!burst) { + finishSubmitCapture(capture, "save-chart-busy"); + return; + } + burst.deferToRound = true; + burst.submitCapture = capture; + scheduleBurstSettle(burst); }; api.subscribe("drawing_event", handleDrawingEvent); return { - flush: flushActiveBurst, + getStats, + beginRound() { + if (stopped) throw new Error("连续图表保存控制器已停止"); + if (activeRound) throw new Error("已有图表保存轮次正在执行"); + if (activeSubmitCapture) throw new Error("上一笔订单线捕获尚未结束"); + flushActiveBurst(); + sequence += 1; + activeRound = { id: sequence, pendingSave: null }; + return activeRound; + }, + beginSubmitCapture(round) { + if (stopped) throw new Error("连续图表保存控制器已停止"); + if (!activeRound || round !== activeRound) { + throw new Error("图表保存轮次不匹配"); + } + if (activeSubmitCapture) throw new Error("已有订单线保存捕获正在执行"); + let resolve; + const promise = new Promise((settle) => { + resolve = settle; + }); + sequence += 1; + const capture = { + discoveryTimer: null, + id: sequence, + matched: false, + promise, + resolve, + settled: false, + status: null + }; + activeSubmitCapture = capture; + capture.discoveryTimer = setTimeoutFn( + () => finishSubmitCapture(capture, "no-order-event"), + submitEventDiscoveryMs + ); + return capture; + }, + async completeSubmitCapture(capture) { + if (!capture || capture !== activeSubmitCapture) { + if (capture?.settled) return { matched: capture.matched, status: capture.status }; + throw new Error("订单线保存捕获不匹配"); + } + return await capture.promise; + }, + endRound(round) { + if (!activeRound || round !== activeRound) { + throw new Error("结束的图表保存轮次不匹配"); + } + if (activeSubmitCapture) { + throw new Error("结束图表保存轮次时仍有订单线捕获"); + } + flushActiveBurst(); + const pendingSave = activeRound.pendingSave; + activeRound = null; + if (pendingSave) { + fullSaveCount += 1; + api.saveChart.apply(pendingSave.thisValue, pendingSave.args); + } + return getStats(); + }, + flush() { + flushActiveBurst(); + if (activeSubmitCapture) finishSubmitCapture(activeSubmitCapture, "flushed"); + return flushRoundPendingSave(); + }, stop() { - if (stopped) throw new Error("图表删除事件保存合并器已停止"); + if (stopped) throw new Error("连续图表保存控制器已停止"); stopped = true; api.unsubscribe("drawing_event", handleDrawingEvent); - flushActiveBurst(); - return { fullSaveCount, removeEventCount, saveRequestCount }; + let cleanupError = null; + try { + flushActiveBurst(); + } catch (error) { + cleanupError = error; + } + if (activeSubmitCapture) finishSubmitCapture(activeSubmitCapture, "stopped"); + try { + flushRoundPendingSave(); + } catch (error) { + cleanupError || (cleanupError = error); + } + activeRound = null; + if (cleanupError) throw cleanupError; + return getStats(); } }; } @@ -3730,6 +3901,7 @@ 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 CONTINUOUS_CHART_SUBMIT_EVENT_WAIT_MS = 250; const LADDER_MAKER_BUFFER_LEVELS = 1; const LADDER_REPRICE_PAUSE_EVERY_ATTEMPTS = 5; const LADDER_REPRICE_PAUSE_MS = 3e3; @@ -3828,7 +4000,7 @@ let singleOrderTask = null; let ladderAbortController = null; let continuousLadderAbortController = null; - let continuousChartSaveCoalescer = null; + let continuousChartSaveController = null; let activeLadderActionType = null; let activeContinuousLadderActionType = null; let activeContinuousLadderProgress = null; @@ -6249,8 +6421,12 @@ if (!CFG.SAFE_MODE) { const previousFeedback = takeOrderFeedbackSnapshot(); const submitCaptureId = beginLadderSubmitResponseCapture(); + let chartSubmitCapture = null; try { throwIfAborted(abortSignal); + chartSubmitCapture = options?.chartSaveController?.beginSubmitCapture( + options.chartSaveRound + ) || null; button.click(); setExecutionStatus( localizedActionStatus( @@ -6274,6 +6450,18 @@ ); } finally { endLadderSubmitResponseCapture(submitCaptureId); + if (chartSubmitCapture) { + try { + await options.chartSaveController.completeSubmitCapture(chartSubmitCapture); + } catch (error) { + err("订单线图表保存捕获清理失败:", error); + try { + options.chartSaveController.flush(); + } catch (flushError) { + err("订单线图表保存刷新失败:", flushError); + } + } + } } previousAcknowledgedInputs = { submittedPrice: synchronizedInputs.submittedPrice, @@ -6354,7 +6542,7 @@ } return { done, repriceAttempts, lastRepriceApiErrorCode }; } - async function startLadder(actionType, continuousProgress = null) { + async function startLadder(actionType, continuousProgress = null, chartSaveController = null) { const spec = getLadderActionSpec2(actionType); if (!spec) { setLadderStatus("未知阶梯动作"); @@ -6421,6 +6609,7 @@ } setLadderStatus(singleStatus); }; + const chartSaveRound = chartSaveController?.beginRound() || null; invalidateUsdtRebalanceEligibility(); ladderAbortController = abortController; activeLadderActionType = actionType; @@ -6435,7 +6624,7 @@ localizedActionStatus(spec.statusLabel, "准备中", " preparing"), localizedText("准备中", "Preparing") ); - const executionTask = (async () => { + const executeRound = async () => { const { plan, done, @@ -6446,7 +6635,11 @@ progress, setExecutionStatus, abortController.signal, - { allowMaxOpenOrdersRecovery: continuousSession && spec.mode === "CLOSE" } + { + allowMaxOpenOrdersRecovery: continuousSession && spec.mode === "CLOSE", + chartSaveController, + chartSaveRound + } ); return { plan, @@ -6455,6 +6648,25 @@ lastRepriceApiErrorCode, wasStopped: ladderStopRequested }; + }; + const executionTask = (async () => { + let result; + try { + result = await executeRound(); + } catch (executionError) { + if (chartSaveRound) { + try { + chartSaveController.endRound(chartSaveRound); + } catch (chartSaveError) { + if (chartSaveError !== executionError) { + err("阶梯轮次失败后的图表保存清理失败:", chartSaveError); + } + } + } + throw executionError; + } + if (chartSaveRound) chartSaveController.endRound(chartSaveRound); + return result; })(); ladderTask = keepInteractionFeedbackVisible(executionTask, { startedAtMs: feedbackStartedAt, @@ -6636,9 +6848,10 @@ try { const target = findBinanceTradingViewTarget(document); if (!target) return null; - return createTradingViewRemoveSaveBurstController(target.tradingViewApi, { + return createTradingViewContinuousSaveController(target.tradingViewApi, { settleQuietMs: CONTINUOUS_CHART_REMOVE_SAVE_QUIET_MS, - maxWaitMs: CONTINUOUS_CHART_REMOVE_SAVE_MAX_WAIT_MS + maxWaitMs: CONTINUOUS_CHART_REMOVE_SAVE_MAX_WAIT_MS, + submitEventDiscoveryMs: CONTINUOUS_CHART_SUBMIT_EVENT_WAIT_MS }); } catch (error) { warn("未启用连续交易图表保存合并:", error?.message || error); @@ -6653,8 +6866,8 @@ } catch (error) { err("连续交易图表保存合并清理失败:", error); } finally { - if (continuousChartSaveCoalescer === coalescer) { - continuousChartSaveCoalescer = null; + if (continuousChartSaveController === coalescer) { + continuousChartSaveController = null; } } } @@ -6676,13 +6889,17 @@ const positionCheckState = { checkedAt: Date.now(), retryAt: 0 }; const chartSaveCoalescer = startContinuousChartSaveCoalescing(); continuousLadderAbortController = abortController; - continuousChartSaveCoalescer = chartSaveCoalescer; + continuousChartSaveController = chartSaveCoalescer; activeContinuousLadderActionType = actionType; activeContinuousLadderProgress = continuousProgress; const executionTask = (async () => { while (true) { throwIfAborted(abortController.signal); - const outcome = await startLadder(actionType, continuousProgress); + const outcome = await startLadder( + actionType, + continuousProgress, + chartSaveCoalescer + ); let recovery = null; if (!outcome?.progress) { if (outcome.status !== "not_started") return outcome; @@ -10645,7 +10862,7 @@ document.addEventListener("visibilitychange", () => { if (document.hidden) { try { - continuousChartSaveCoalescer?.flush(); + continuousChartSaveController?.flush(); } catch (error) { err("页面隐藏前图表保存刷新失败:", error); } @@ -10668,6 +10885,9 @@ } window.__TM_CLOSE_LONG_DEBUG__ = { cfg: CFG, + get continuousChartSaveStats() { + return continuousChartSaveController?.getStats() || null; + }, get cachedCloseState() { return getCachedCloseState(getCurrentSymbol()); }, diff --git a/src/binance-orderbook-trade/core/chart-save-coalescer.js b/src/binance-orderbook-trade/core/chart-save-coalescer.js index 7f782a0..5f7a9c5 100644 --- a/src/binance-orderbook-trade/core/chart-save-coalescer.js +++ b/src/binance-orderbook-trade/core/chart-save-coalescer.js @@ -26,17 +26,27 @@ function restoreSaveChartMethod(api, wrapper, originalSaveChart, originalDescrip } } +function readTradingViewDrawingToolName(api, drawingId) { + const shape = api.activeChart?.().getShapeById?.(String(drawingId)); + // The current Binance runtime exposes the public line data source here; + // avoid coupling the userscript to TradingView's private `_source` field. + return shape?.lineDataSource?.()?.toolname || null; +} + /** - * 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. + * Binance schedules a complete chart serialization 100ms after every broker + * drawing event. A submit capture is armed by our own button click, but it does + * not replace saveChart until the matching LineToolOrder event arrives. The + * wrapper is restored after that short event burst, while only the final + * cumulative submit snapshot is replayed at the end of the ladder round. */ -export function createTradingViewRemoveSaveBurstController( +export function createTradingViewContinuousSaveController( api, { settleQuietMs = 120, maxWaitMs = 400, + submitEventDiscoveryMs = 250, + getDrawingToolName = readTradingViewDrawingToolName, setTimeoutFn = setTimeout, clearTimeoutFn = clearTimeout, } = {}, @@ -48,14 +58,42 @@ export function createTradingViewRemoveSaveBurstController( if (!Number.isFinite(maxWaitMs) || maxWaitMs < settleQuietMs) { throw new Error('图表保存合并最长等待时间无效'); } + if (!Number.isFinite(submitEventDiscoveryMs) || submitEventDiscoveryMs < 0) { + throw new Error('订单线事件等待时间无效'); + } + if (typeof getDrawingToolName !== 'function') { + throw new Error('订单线类型解析依赖异常'); + } const sessionSaveChart = api.saveChart; let activeBurst = null; + let activeRound = null; + let activeSubmitCapture = null; let removeEventCount = 0; + let orderEventCount = 0; let saveRequestCount = 0; let fullSaveCount = 0; + let deferredSubmitSaveCount = 0; + let sequence = 0; let stopped = false; + const getStats = () => ({ + deferredSubmitSaveCount, + fullSaveCount, + orderEventCount, + removeEventCount, + saveRequestCount, + }); + const finishSubmitCapture = (capture, status) => { + if (!capture || capture.settled) return; + if (capture.discoveryTimer !== null) clearTimeoutFn(capture.discoveryTimer); + capture.discoveryTimer = null; + capture.settled = true; + capture.status = status; + if (activeSubmitCapture === capture) activeSubmitCapture = null; + capture.resolve({ matched: capture.matched, status }); + }; + const clearBurstTimers = (burst) => { if (burst.settleTimer !== null) clearTimeoutFn(burst.settleTimer); if (burst.maxWaitTimer !== null) clearTimeoutFn(burst.maxWaitTimer); @@ -67,18 +105,44 @@ export function createTradingViewRemoveSaveBurstController( 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 saveChartWasReplaced = api.saveChart !== burst.wrapper; + if (!saveChartWasReplaced) { + restoreSaveChartMethod( + api, + burst.wrapper, + burst.originalSaveChart, + burst.originalDescriptor, + ); + } + try { + if (!burst.pendingSave) return undefined; + if (saveChartWasReplaced) { + if (activeRound?.pendingSave) activeRound.pendingSave = null; + fullSaveCount += 1; + return burst.originalSaveChart.apply( + burst.pendingSave.thisValue, + burst.pendingSave.args, + ); + } + if (burst.deferToRound && activeRound) { + activeRound.pendingSave = burst.pendingSave; + deferredSubmitSaveCount += 1; + return undefined; + } + if (activeRound?.pendingSave) activeRound.pendingSave = null; + fullSaveCount += 1; + return burst.originalSaveChart.apply( + burst.pendingSave.thisValue, + burst.pendingSave.args, + ); + } finally { + if (burst.submitCapture) { + finishSubmitCapture( + burst.submitCapture, + saveChartWasReplaced ? 'save-chart-replaced' : 'captured', + ); + } + } }; const scheduleBurstSettle = (burst) => { if (burst.settleTimer !== null) clearTimeoutFn(burst.settleTimer); @@ -90,13 +154,15 @@ export function createTradingViewRemoveSaveBurstController( const originalDescriptor = Object.getOwnPropertyDescriptor(api, 'saveChart'); const burst = { maxWaitTimer: null, + deferToRound: false, originalDescriptor, originalSaveChart, pendingSave: null, settleTimer: null, + submitCapture: null, wrapper: null, }; - burst.wrapper = function removeSaveBurstWrapper(...args) { + burst.wrapper = function continuousSaveBurstWrapper(...args) { saveRequestCount += 1; burst.pendingSave = { thisValue: this, args }; return undefined; @@ -109,22 +175,134 @@ export function createTradingViewRemoveSaveBurstController( burst.maxWaitTimer = setTimeoutFn(flushActiveBurst, maxWaitMs); return burst; }; - const handleDrawingEvent = (_drawingId, eventType) => { - if (stopped || eventType !== 'remove') return; - removeEventCount += 1; + const flushRoundPendingSave = () => { + const pendingSave = activeRound?.pendingSave || null; + if (!pendingSave) return undefined; + activeRound.pendingSave = null; + fullSaveCount += 1; + return api.saveChart.apply(pendingSave.thisValue, pendingSave.args); + }; + const handleDrawingEvent = (drawingId, eventType) => { + if (stopped || IGNORED_DRAWING_EVENT_TYPES.has(eventType)) return; + if (eventType === 'remove') { + removeEventCount += 1; + if (activeBurst?.deferToRound) flushActiveBurst(); + const burst = startBurst(); + if (burst) scheduleBurstSettle(burst); + return; + } + + const capture = activeSubmitCapture; + if (!capture || capture.settled) return; + let toolName = null; + try { + toolName = getDrawingToolName(api, drawingId); + } catch { + return; + } + if (toolName !== 'LineToolOrder') return; + + orderEventCount += 1; + capture.matched = true; + if (capture.discoveryTimer !== null) clearTimeoutFn(capture.discoveryTimer); + capture.discoveryTimer = null; + if (activeBurst && !activeBurst.deferToRound) flushActiveBurst(); const burst = startBurst(); - if (burst) scheduleBurstSettle(burst); + if (!burst) { + finishSubmitCapture(capture, 'save-chart-busy'); + return; + } + burst.deferToRound = true; + burst.submitCapture = capture; + scheduleBurstSettle(burst); }; api.subscribe('drawing_event', handleDrawingEvent); return { - flush: flushActiveBurst, + getStats, + beginRound() { + if (stopped) throw new Error('连续图表保存控制器已停止'); + if (activeRound) throw new Error('已有图表保存轮次正在执行'); + if (activeSubmitCapture) throw new Error('上一笔订单线捕获尚未结束'); + flushActiveBurst(); + sequence += 1; + activeRound = { id: sequence, pendingSave: null }; + return activeRound; + }, + beginSubmitCapture(round) { + if (stopped) throw new Error('连续图表保存控制器已停止'); + if (!activeRound || round !== activeRound) { + throw new Error('图表保存轮次不匹配'); + } + if (activeSubmitCapture) throw new Error('已有订单线保存捕获正在执行'); + let resolve; + const promise = new Promise((settle) => { + resolve = settle; + }); + sequence += 1; + const capture = { + discoveryTimer: null, + id: sequence, + matched: false, + promise, + resolve, + settled: false, + status: null, + }; + activeSubmitCapture = capture; + capture.discoveryTimer = setTimeoutFn( + () => finishSubmitCapture(capture, 'no-order-event'), + submitEventDiscoveryMs, + ); + return capture; + }, + async completeSubmitCapture(capture) { + if (!capture || capture !== activeSubmitCapture) { + if (capture?.settled) return { matched: capture.matched, status: capture.status }; + throw new Error('订单线保存捕获不匹配'); + } + return await capture.promise; + }, + endRound(round) { + if (!activeRound || round !== activeRound) { + throw new Error('结束的图表保存轮次不匹配'); + } + if (activeSubmitCapture) { + throw new Error('结束图表保存轮次时仍有订单线捕获'); + } + flushActiveBurst(); + const pendingSave = activeRound.pendingSave; + activeRound = null; + if (pendingSave) { + fullSaveCount += 1; + api.saveChart.apply(pendingSave.thisValue, pendingSave.args); + } + return getStats(); + }, + flush() { + flushActiveBurst(); + if (activeSubmitCapture) finishSubmitCapture(activeSubmitCapture, 'flushed'); + return flushRoundPendingSave(); + }, stop() { - if (stopped) throw new Error('图表删除事件保存合并器已停止'); + if (stopped) throw new Error('连续图表保存控制器已停止'); stopped = true; api.unsubscribe('drawing_event', handleDrawingEvent); - flushActiveBurst(); - return { fullSaveCount, removeEventCount, saveRequestCount }; + let cleanupError = null; + try { + flushActiveBurst(); + } catch (error) { + cleanupError = error; + } + if (activeSubmitCapture) finishSubmitCapture(activeSubmitCapture, 'stopped'); + try { + flushRoundPendingSave(); + } catch (error) { + cleanupError ||= error; + } + activeRound = null; + if (cleanupError) throw cleanupError; + return getStats(); }, }; } diff --git a/src/binance-orderbook-trade/index.user.js b/src/binance-orderbook-trade/index.user.js index add481d..1fe1705 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.178 +// @version 2.7.179 // @author jackhai9 // @description 单击订单簿价格,按当前开仓/平仓 tab 自动填数量并执行下单,内置数量倍率面板 // @match https://www.binance.com/*/futures/* @@ -201,7 +201,7 @@ import { } from './core/chart-orders-recovery.js'; import { coalesceTradingViewDrawingSaves, - createTradingViewRemoveSaveBurstController, + createTradingViewContinuousSaveController, } from './core/chart-save-coalescer.js'; import { findBinanceTradingViewTarget } from './dom/tradingview-target.js'; import { resolveCancelDialogDecision } from './core/cancel-dialog-decision.js'; @@ -332,6 +332,7 @@ import { showUsdtRebalanceDialog } from './dom/usdt-rebalance-dialog.js'; 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 CONTINUOUS_CHART_SUBMIT_EVENT_WAIT_MS = 250; const LADDER_MAKER_BUFFER_LEVELS = 1; const LADDER_REPRICE_PAUSE_EVERY_ATTEMPTS = 5; const LADDER_REPRICE_PAUSE_MS = 3000; @@ -431,7 +432,7 @@ import { showUsdtRebalanceDialog } from './dom/usdt-rebalance-dialog.js'; let singleOrderTask = null; let ladderAbortController = null; let continuousLadderAbortController = null; - let continuousChartSaveCoalescer = null; + let continuousChartSaveController = null; let activeLadderActionType = null; let activeContinuousLadderActionType = null; let activeContinuousLadderProgress = null; @@ -3366,8 +3367,12 @@ import { showUsdtRebalanceDialog } from './dom/usdt-rebalance-dialog.js'; if (!CFG.SAFE_MODE) { const previousFeedback = takeOrderFeedbackSnapshot(); const submitCaptureId = beginLadderSubmitResponseCapture(); + let chartSubmitCapture = null; try { throwIfAborted(abortSignal); + chartSubmitCapture = options?.chartSaveController?.beginSubmitCapture( + options.chartSaveRound, + ) || null; button.click(); setExecutionStatus( localizedActionStatus( @@ -3391,6 +3396,18 @@ import { showUsdtRebalanceDialog } from './dom/usdt-rebalance-dialog.js'; ); } finally { endLadderSubmitResponseCapture(submitCaptureId); + if (chartSubmitCapture) { + try { + await options.chartSaveController.completeSubmitCapture(chartSubmitCapture); + } catch (error) { + err('订单线图表保存捕获清理失败:', error); + try { + options.chartSaveController.flush(); + } catch (flushError) { + err('订单线图表保存刷新失败:', flushError); + } + } + } } previousAcknowledgedInputs = { submittedPrice: synchronizedInputs.submittedPrice, @@ -3474,7 +3491,11 @@ import { showUsdtRebalanceDialog } from './dom/usdt-rebalance-dialog.js'; return { done, repriceAttempts, lastRepriceApiErrorCode }; } - async function startLadder(actionType, continuousProgress = null) { + async function startLadder( + actionType, + continuousProgress = null, + chartSaveController = null, + ) { const spec = getLadderActionSpec(actionType); if (!spec) { setLadderStatus('未知阶梯动作'); @@ -3543,6 +3564,7 @@ import { showUsdtRebalanceDialog } from './dom/usdt-rebalance-dialog.js'; } setLadderStatus(singleStatus); }; + const chartSaveRound = chartSaveController?.beginRound() || null; invalidateUsdtRebalanceEligibility(); ladderAbortController = abortController; activeLadderActionType = actionType; @@ -3557,7 +3579,7 @@ import { showUsdtRebalanceDialog } from './dom/usdt-rebalance-dialog.js'; localizedActionStatus(spec.statusLabel, '准备中', ' preparing'), localizedText('准备中', 'Preparing'), ); - const executionTask = (async () => { + const executeRound = async () => { const { plan, done, @@ -3568,7 +3590,11 @@ import { showUsdtRebalanceDialog } from './dom/usdt-rebalance-dialog.js'; progress, setExecutionStatus, abortController.signal, - { allowMaxOpenOrdersRecovery: continuousSession && spec.mode === 'CLOSE' }, + { + allowMaxOpenOrdersRecovery: continuousSession && spec.mode === 'CLOSE', + chartSaveController, + chartSaveRound, + }, ); return { plan, @@ -3577,6 +3603,25 @@ import { showUsdtRebalanceDialog } from './dom/usdt-rebalance-dialog.js'; lastRepriceApiErrorCode, wasStopped: ladderStopRequested, }; + }; + const executionTask = (async () => { + let result; + try { + result = await executeRound(); + } catch (executionError) { + if (chartSaveRound) { + try { + chartSaveController.endRound(chartSaveRound); + } catch (chartSaveError) { + if (chartSaveError !== executionError) { + err('阶梯轮次失败后的图表保存清理失败:', chartSaveError); + } + } + } + throw executionError; + } + if (chartSaveRound) chartSaveController.endRound(chartSaveRound); + return result; })(); ladderTask = keepInteractionFeedbackVisible(executionTask, { startedAtMs: feedbackStartedAt, @@ -3798,9 +3843,10 @@ import { showUsdtRebalanceDialog } from './dom/usdt-rebalance-dialog.js'; try { const target = findBinanceTradingViewTarget(document); if (!target) return null; - return createTradingViewRemoveSaveBurstController(target.tradingViewApi, { + return createTradingViewContinuousSaveController(target.tradingViewApi, { settleQuietMs: CONTINUOUS_CHART_REMOVE_SAVE_QUIET_MS, maxWaitMs: CONTINUOUS_CHART_REMOVE_SAVE_MAX_WAIT_MS, + submitEventDiscoveryMs: CONTINUOUS_CHART_SUBMIT_EVENT_WAIT_MS, }); } catch (error) { warn('未启用连续交易图表保存合并:', error?.message || error); @@ -3816,8 +3862,8 @@ import { showUsdtRebalanceDialog } from './dom/usdt-rebalance-dialog.js'; } catch (error) { err('连续交易图表保存合并清理失败:', error); } finally { - if (continuousChartSaveCoalescer === coalescer) { - continuousChartSaveCoalescer = null; + if (continuousChartSaveController === coalescer) { + continuousChartSaveController = null; } } } @@ -3842,14 +3888,18 @@ import { showUsdtRebalanceDialog } from './dom/usdt-rebalance-dialog.js'; const positionCheckState = { checkedAt: Date.now(), retryAt: 0 }; const chartSaveCoalescer = startContinuousChartSaveCoalescing(); continuousLadderAbortController = abortController; - continuousChartSaveCoalescer = chartSaveCoalescer; + continuousChartSaveController = chartSaveCoalescer; activeContinuousLadderActionType = actionType; activeContinuousLadderProgress = continuousProgress; const executionTask = (async () => { while (true) { throwIfAborted(abortController.signal); - const outcome = await startLadder(actionType, continuousProgress); + const outcome = await startLadder( + actionType, + continuousProgress, + chartSaveCoalescer, + ); let recovery = null; if (!outcome?.progress) { if (outcome.status !== 'not_started') return outcome; @@ -8438,7 +8488,7 @@ import { showUsdtRebalanceDialog } from './dom/usdt-rebalance-dialog.js'; document.addEventListener('visibilitychange', () => { if (document.hidden) { try { - continuousChartSaveCoalescer?.flush(); + continuousChartSaveController?.flush(); } catch (error) { err('页面隐藏前图表保存刷新失败:', error); } @@ -8464,6 +8514,9 @@ import { showUsdtRebalanceDialog } from './dom/usdt-rebalance-dialog.js'; window.__TM_CLOSE_LONG_DEBUG__ = { cfg: CFG, + get continuousChartSaveStats() { + return continuousChartSaveController?.getStats() || null; + }, get cachedCloseState() { return getCachedCloseState(getCurrentSymbol()); }, get displayCloseState() { return lastDisplayCloseState; }, get closeGuard() { return closeGuard; }, 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 83c1f43..9a3190c 100644 --- a/test/unit/binance-orderbook-trade/chart-save-coalescer.test.js +++ b/test/unit/binance-orderbook-trade/chart-save-coalescer.test.js @@ -3,13 +3,23 @@ import assert from 'node:assert/strict'; import { coalesceTradingViewDrawingSaves, - createTradingViewRemoveSaveBurstController, + createTradingViewContinuousSaveController, } from '../../../src/binance-orderbook-trade/core/chart-save-coalescer.js'; function createTradingViewApi() { const listeners = new Map(); const saved = []; + const drawingToolNames = new Map(); const api = { + activeChart() { + return { + getShapeById(drawingId) { + const toolname = drawingToolNames.get(String(drawingId)); + if (!toolname) throw new Error('There is no such shape'); + return { lineDataSource: () => ({ toolname }) }; + }, + }; + }, saveChart(...args) { saved.push({ thisValue: this, args }); }, @@ -25,7 +35,25 @@ function createTradingViewApi() { for (const callback of listeners.get(name) || []) callback(...args); }, }; - return { api, saved, listeners }; + return { + api, + saved, + listeners, + setDrawingToolName(drawingId, toolname) { + drawingToolNames.set(String(drawingId), toolname); + }, + }; +} + +function expectedContinuousStats(overrides = {}) { + return { + deferredSubmitSaveCount: 0, + fullSaveCount: 0, + orderEventCount: 0, + removeEventCount: 0, + saveRequestCount: 0, + ...overrides, + }; } function createManualTimers() { @@ -59,7 +87,7 @@ 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, { + const coalescer = createTradingViewContinuousSaveController(api, { settleQuietMs: 20, maxWaitMs: 100, setTimeoutFn: timers.setTimeoutFn, @@ -69,18 +97,14 @@ test('continuous remove-save controller leaves unrelated chart saves synchronous api.saveChart('unrelated'); assert.deepEqual(saved.map((entry) => entry.args), [['unrelated']]); - assert.deepEqual(coalescer.stop(), { - fullSaveCount: 0, - removeEventCount: 0, - saveRequestCount: 0, - }); + assert.deepEqual(coalescer.stop(), expectedContinuousStats()); 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, { + const coalescer = createTradingViewContinuousSaveController(api, { settleQuietMs: 20, maxWaitMs: 100, setTimeoutFn: timers.setTimeoutFn, @@ -100,17 +124,17 @@ test('continuous remove-save controller persists only the final save in one remo timers.advance(1); assert.deepEqual(saved.map((entry) => entry.args), [['snapshot-3']]); - assert.deepEqual(coalescer.stop(), { + assert.deepEqual(coalescer.stop(), expectedContinuousStats({ 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, { + const coalescer = createTradingViewContinuousSaveController(api, { settleQuietMs: 50, maxWaitMs: 100, setTimeoutFn: timers.setTimeoutFn, @@ -134,7 +158,7 @@ test('continuous remove-save controller flushes pending state and restores the o const { api, saved } = createTradingViewApi(); const originalSaveChart = api.saveChart; const timers = createManualTimers(); - const coalescer = createTradingViewRemoveSaveBurstController(api, { + const coalescer = createTradingViewContinuousSaveController(api, { settleQuietMs: 20, maxWaitMs: 100, setTimeoutFn: timers.setTimeoutFn, @@ -143,11 +167,11 @@ test('continuous remove-save controller flushes pending state and restores the o api.emit('drawing_event', 'order-1', 'remove'); api.saveChart('pending-final'); - assert.deepEqual(coalescer.stop(), { + assert.deepEqual(coalescer.stop(), expectedContinuousStats({ 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); @@ -158,22 +182,18 @@ test('continuous remove-save controller flushes pending state and restores the o test('continuous remove-save controller ignores non-remove drawing events', () => { const { api, saved } = createTradingViewApi(); - const coalescer = createTradingViewRemoveSaveBurstController(api); + const coalescer = createTradingViewContinuousSaveController(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, - }); + assert.deepEqual(coalescer.stop(), expectedContinuousStats()); }); test('continuous remove-save controller does not replace another active save wrapper', () => { const { api, saved } = createTradingViewApi(); - const controller = createTradingViewRemoveSaveBurstController(api); + const controller = createTradingViewContinuousSaveController(api); const sessionSaveChart = api.saveChart; const foreignSaves = []; api.saveChart = (...args) => foreignSaves.push(args); @@ -184,11 +204,7 @@ test('continuous remove-save controller does not replace another active save wra assert.deepEqual(saved, []); api.saveChart = sessionSaveChart; - assert.deepEqual(controller.stop(), { - fullSaveCount: 0, - removeEventCount: 1, - saveRequestCount: 0, - }); + assert.deepEqual(controller.stop(), expectedContinuousStats({ removeEventCount: 1 })); }); test('continuous remove-save controller restores the chart method when the final save throws', () => { @@ -197,7 +213,7 @@ test('continuous remove-save controller restores the chart method when the final throw new Error('final save failed'); }; api.saveChart = originalSaveChart; - const controller = createTradingViewRemoveSaveBurstController(api); + const controller = createTradingViewContinuousSaveController(api); api.emit('drawing_event', 'order-1', 'remove'); api.saveChart('pending'); @@ -207,6 +223,438 @@ test('continuous remove-save controller restores the chart method when the final assert.equal(listeners.get('drawing_event')?.size, 0); }); +test('continuous submit captures five order-line saves and replays only the final round snapshot', async () => { + const { + api, + saved, + setDrawingToolName, + } = createTradingViewApi(); + const originalSaveChart = api.saveChart; + const timers = createManualTimers(); + const controller = createTradingViewContinuousSaveController(api, { + settleQuietMs: 20, + maxWaitMs: 100, + submitEventDiscoveryMs: 10, + setTimeoutFn: timers.setTimeoutFn, + clearTimeoutFn: timers.clearTimeoutFn, + }); + const round = controller.beginRound(); + + for (let index = 1; index <= 5; index += 1) { + const drawingId = `order-${index}`; + setDrawingToolName(drawingId, 'LineToolOrder'); + const capture = controller.beginSubmitCapture(round); + api.emit('drawing_event', drawingId, 'properties_changed'); + api.saveChart(`snapshot-${index}`); + const completion = controller.completeSubmitCapture(capture); + timers.advance(20); + assert.deepEqual(await completion, { matched: true, status: 'captured' }); + assert.equal(api.saveChart, originalSaveChart); + assert.deepEqual(saved, []); + } + + assert.deepEqual(controller.endRound(round), expectedContinuousStats({ + deferredSubmitSaveCount: 5, + fullSaveCount: 1, + orderEventCount: 5, + saveRequestCount: 5, + })); + assert.deepEqual(saved.map((entry) => entry.args), [['snapshot-5']]); + controller.stop(); +}); + +test('continuous submit capture keeps the final save from multiple order-line events', async () => { + const { api, saved, setDrawingToolName } = createTradingViewApi(); + const timers = createManualTimers(); + const controller = createTradingViewContinuousSaveController(api, { + settleQuietMs: 20, + maxWaitMs: 100, + setTimeoutFn: timers.setTimeoutFn, + clearTimeoutFn: timers.clearTimeoutFn, + }); + const round = controller.beginRound(); + const capture = controller.beginSubmitCapture(round); + setDrawingToolName('order-1', 'LineToolOrder'); + setDrawingToolName('order-2', 'LineToolOrder'); + + api.emit('drawing_event', 'order-1', 'properties_changed'); + api.saveChart('snapshot-1'); + timers.advance(10); + api.emit('drawing_event', 'order-2', 'properties_changed'); + api.saveChart('snapshot-2'); + const completion = controller.completeSubmitCapture(capture); + timers.advance(20); + + assert.deepEqual(await completion, { matched: true, status: 'captured' }); + controller.endRound(round); + assert.deepEqual(saved.map((entry) => entry.args), [['snapshot-2']]); + assert.deepEqual(controller.stop(), expectedContinuousStats({ + deferredSubmitSaveCount: 1, + fullSaveCount: 1, + orderEventCount: 2, + saveRequestCount: 2, + })); +}); + +test('continuous submit capture keeps an existing remove burst independent', async () => { + const { api, saved, setDrawingToolName } = createTradingViewApi(); + const timers = createManualTimers(); + const controller = createTradingViewContinuousSaveController(api, { + settleQuietMs: 20, + maxWaitMs: 100, + setTimeoutFn: timers.setTimeoutFn, + clearTimeoutFn: timers.clearTimeoutFn, + }); + const round = controller.beginRound(); + const capture = controller.beginSubmitCapture(round); + setDrawingToolName('order-1', 'LineToolOrder'); + + api.emit('drawing_event', 'removed-order', 'remove'); + api.saveChart('remove-snapshot'); + api.emit('drawing_event', 'order-1', 'properties_changed'); + api.saveChart('order-snapshot'); + const completion = controller.completeSubmitCapture(capture); + timers.advance(20); + + assert.deepEqual(await completion, { matched: true, status: 'captured' }); + assert.deepEqual(saved.map((entry) => entry.args), [['remove-snapshot']]); + controller.endRound(round); + assert.deepEqual(saved.map((entry) => entry.args), [ + ['remove-snapshot'], + ['order-snapshot'], + ]); + controller.stop(); +}); + +test('continuous remove burst supersedes an older deferred submit snapshot', async () => { + const { api, saved, setDrawingToolName } = createTradingViewApi(); + const timers = createManualTimers(); + const controller = createTradingViewContinuousSaveController(api, { + settleQuietMs: 20, + maxWaitMs: 100, + setTimeoutFn: timers.setTimeoutFn, + clearTimeoutFn: timers.clearTimeoutFn, + }); + const round = controller.beginRound(); + const capture = controller.beginSubmitCapture(round); + setDrawingToolName('order-1', 'LineToolOrder'); + + api.emit('drawing_event', 'order-1', 'properties_changed'); + api.saveChart('order-snapshot'); + api.emit('drawing_event', 'removed-order', 'remove'); + api.saveChart('newer-remove-snapshot'); + timers.advance(20); + + assert.deepEqual(await controller.completeSubmitCapture(capture), { + matched: true, + status: 'captured', + }); + controller.endRound(round); + assert.deepEqual(saved.map((entry) => entry.args), [['newer-remove-snapshot']]); + controller.stop(); +}); + +test('continuous submit capture ignores position lines and leaves their saves synchronous', async () => { + const { api, saved, setDrawingToolName } = createTradingViewApi(); + const timers = createManualTimers(); + const controller = createTradingViewContinuousSaveController(api, { + settleQuietMs: 20, + maxWaitMs: 100, + submitEventDiscoveryMs: 10, + setTimeoutFn: timers.setTimeoutFn, + clearTimeoutFn: timers.clearTimeoutFn, + }); + const round = controller.beginRound(); + const capture = controller.beginSubmitCapture(round); + setDrawingToolName('position-1', 'LineToolPosition'); + + api.emit('drawing_event', 'position-1', 'properties_changed'); + api.saveChart('position-snapshot'); + const completion = controller.completeSubmitCapture(capture); + timers.advance(10); + + assert.deepEqual(await completion, { matched: false, status: 'no-order-event' }); + assert.deepEqual(saved.map((entry) => entry.args), [['position-snapshot']]); + controller.endRound(round); + controller.stop(); +}); + +test('continuous submit capture ignores click and move events for order lines', async () => { + const { api, saved, setDrawingToolName } = createTradingViewApi(); + const timers = createManualTimers(); + const controller = createTradingViewContinuousSaveController(api, { + submitEventDiscoveryMs: 10, + setTimeoutFn: timers.setTimeoutFn, + clearTimeoutFn: timers.clearTimeoutFn, + }); + const round = controller.beginRound(); + const capture = controller.beginSubmitCapture(round); + setDrawingToolName('order-1', 'LineToolOrder'); + + api.emit('drawing_event', 'order-1', 'click'); + api.emit('drawing_event', 'order-1', 'move'); + api.saveChart('interaction-snapshot'); + const completion = controller.completeSubmitCapture(capture); + timers.advance(10); + + assert.deepEqual(await completion, { matched: false, status: 'no-order-event' }); + assert.deepEqual(saved.map((entry) => entry.args), [['interaction-snapshot']]); + controller.endRound(round); + controller.stop(); +}); + +test('continuous submit capture restores saveChart before unrelated saves outside the capture', async () => { + const { api, saved, setDrawingToolName } = createTradingViewApi(); + const originalSaveChart = api.saveChart; + const timers = createManualTimers(); + const controller = createTradingViewContinuousSaveController(api, { + settleQuietMs: 20, + maxWaitMs: 100, + setTimeoutFn: timers.setTimeoutFn, + clearTimeoutFn: timers.clearTimeoutFn, + }); + const round = controller.beginRound(); + const capture = controller.beginSubmitCapture(round); + setDrawingToolName('order-1', 'LineToolOrder'); + + api.emit('drawing_event', 'order-1', 'properties_changed'); + api.saveChart('order-snapshot'); + const completion = controller.completeSubmitCapture(capture); + timers.advance(20); + await completion; + assert.equal(api.saveChart, originalSaveChart); + + api.saveChart('unrelated-snapshot'); + assert.deepEqual(saved.map((entry) => entry.args), [['unrelated-snapshot']]); + controller.endRound(round); + assert.deepEqual(saved.map((entry) => entry.args), [ + ['unrelated-snapshot'], + ['order-snapshot'], + ]); + controller.stop(); +}); + +test('continuous submit capture flushes the pending round snapshot on stop', async () => { + const { api, saved, listeners, setDrawingToolName } = createTradingViewApi(); + const originalSaveChart = api.saveChart; + const timers = createManualTimers(); + const controller = createTradingViewContinuousSaveController(api, { + settleQuietMs: 20, + maxWaitMs: 100, + setTimeoutFn: timers.setTimeoutFn, + clearTimeoutFn: timers.clearTimeoutFn, + }); + const round = controller.beginRound(); + const capture = controller.beginSubmitCapture(round); + setDrawingToolName('order-1', 'LineToolOrder'); + api.emit('drawing_event', 'order-1', 'properties_changed'); + api.saveChart('pending-round'); + const completion = controller.completeSubmitCapture(capture); + timers.advance(20); + await completion; + + assert.deepEqual(controller.stop(), expectedContinuousStats({ + deferredSubmitSaveCount: 1, + fullSaveCount: 1, + orderEventCount: 1, + saveRequestCount: 1, + })); + assert.deepEqual(saved.map((entry) => entry.args), [['pending-round']]); + assert.equal(api.saveChart, originalSaveChart); + assert.equal(listeners.get('drawing_event')?.size, 0); +}); + +test('continuous submit capture flushes pending state while keeping the round active', async () => { + const { api, saved, setDrawingToolName } = createTradingViewApi(); + const timers = createManualTimers(); + const controller = createTradingViewContinuousSaveController(api, { + settleQuietMs: 20, + maxWaitMs: 100, + setTimeoutFn: timers.setTimeoutFn, + clearTimeoutFn: timers.clearTimeoutFn, + }); + const round = controller.beginRound(); + setDrawingToolName('order-1', 'LineToolOrder'); + const firstCapture = controller.beginSubmitCapture(round); + api.emit('drawing_event', 'order-1', 'properties_changed'); + api.saveChart('snapshot-1'); + const firstCompletion = controller.completeSubmitCapture(firstCapture); + timers.advance(20); + await firstCompletion; + + controller.flush(); + assert.deepEqual(saved.map((entry) => entry.args), [['snapshot-1']]); + + setDrawingToolName('order-2', 'LineToolOrder'); + const secondCapture = controller.beginSubmitCapture(round); + api.emit('drawing_event', 'order-2', 'properties_changed'); + api.saveChart('snapshot-2'); + const secondCompletion = controller.completeSubmitCapture(secondCapture); + timers.advance(20); + await secondCompletion; + + controller.endRound(round); + assert.deepEqual(saved.map((entry) => entry.args), [['snapshot-1'], ['snapshot-2']]); + controller.stop(); +}); + +test('continuous submit capture does not wait for discovery after a lifecycle flush', async () => { + const { api } = createTradingViewApi(); + const controller = createTradingViewContinuousSaveController(api); + const round = controller.beginRound(); + const capture = controller.beginSubmitCapture(round); + + controller.flush(); + + assert.deepEqual(await controller.completeSubmitCapture(capture), { + matched: false, + status: 'flushed', + }); + controller.endRound(round); + controller.stop(); +}); + +test('continuous submit ownership expires from the moment capture is armed', async () => { + const { api } = createTradingViewApi(); + const timers = createManualTimers(); + const controller = createTradingViewContinuousSaveController(api, { + submitEventDiscoveryMs: 10, + setTimeoutFn: timers.setTimeoutFn, + clearTimeoutFn: timers.clearTimeoutFn, + }); + const round = controller.beginRound(); + const capture = controller.beginSubmitCapture(round); + + timers.advance(10); + + assert.deepEqual(await controller.completeSubmitCapture(capture), { + matched: false, + status: 'no-order-event', + }); + controller.endRound(round); + controller.stop(); +}); + +test('continuous submit capture skips optimization when another save wrapper is active', async () => { + const { api, saved, setDrawingToolName } = createTradingViewApi(); + const controller = createTradingViewContinuousSaveController(api); + const round = controller.beginRound(); + const capture = controller.beginSubmitCapture(round); + const foreignSaves = []; + const sessionSaveChart = api.saveChart; + api.saveChart = (...args) => foreignSaves.push(args); + setDrawingToolName('order-1', 'LineToolOrder'); + + api.emit('drawing_event', 'order-1', 'properties_changed'); + assert.deepEqual(await controller.completeSubmitCapture(capture), { + matched: true, + status: 'save-chart-busy', + }); + api.saveChart('foreign-order-snapshot'); + assert.deepEqual(foreignSaves, [['foreign-order-snapshot']]); + assert.deepEqual(saved, []); + + api.saveChart = sessionSaveChart; + controller.endRound(round); + controller.stop(); +}); + +test('continuous submit capture preserves a wrapper installed during its active burst', async () => { + const { api, saved, setDrawingToolName } = createTradingViewApi(); + const timers = createManualTimers(); + const controller = createTradingViewContinuousSaveController(api, { + settleQuietMs: 20, + maxWaitMs: 100, + setTimeoutFn: timers.setTimeoutFn, + clearTimeoutFn: timers.clearTimeoutFn, + }); + const round = controller.beginRound(); + const capture = controller.beginSubmitCapture(round); + const sessionSaveChart = api.saveChart; + setDrawingToolName('order-1', 'LineToolOrder'); + api.emit('drawing_event', 'order-1', 'properties_changed'); + api.saveChart('pending-round'); + + const foreignSaves = []; + const replacedSaveChart = api.saveChart; + api.saveChart = function foreignSaveChart(...args) { + foreignSaves.push({ thisValue: this, args }); + return replacedSaveChart.apply(this, args); + }; + timers.advance(20); + + assert.deepEqual(await controller.completeSubmitCapture(capture), { + matched: true, + status: 'save-chart-replaced', + }); + assert.deepEqual(foreignSaves, []); + assert.deepEqual(saved.map((entry) => entry.args), [['pending-round']]); + assert.notEqual(api.saveChart, sessionSaveChart); + + controller.endRound(round); + api.saveChart = sessionSaveChart; + controller.stop(); +}); + +test('continuous submit final replay preserves a wrapper installed after capture', async () => { + const { api, saved, setDrawingToolName } = createTradingViewApi(); + const timers = createManualTimers(); + const controller = createTradingViewContinuousSaveController(api, { + settleQuietMs: 20, + maxWaitMs: 100, + setTimeoutFn: timers.setTimeoutFn, + clearTimeoutFn: timers.clearTimeoutFn, + }); + const round = controller.beginRound(); + const capture = controller.beginSubmitCapture(round); + setDrawingToolName('order-1', 'LineToolOrder'); + api.emit('drawing_event', 'order-1', 'properties_changed'); + api.saveChart('pending-round'); + const completion = controller.completeSubmitCapture(capture); + timers.advance(20); + await completion; + + const sessionSaveChart = api.saveChart; + const foreignSaves = []; + api.saveChart = function foreignSaveChart(...args) { + foreignSaves.push({ thisValue: this, args }); + return sessionSaveChart.apply(this, args); + }; + controller.endRound(round); + + assert.deepEqual(foreignSaves.map((entry) => entry.args), [['pending-round']]); + assert.deepEqual(saved.map((entry) => entry.args), [['pending-round']]); + api.saveChart = sessionSaveChart; + controller.stop(); +}); + +test('continuous submit final replay restores saveChart when the original save throws', async () => { + const { api, setDrawingToolName } = createTradingViewApi(); + const originalSaveChart = function saveChart() { + throw new Error('round save failed'); + }; + api.saveChart = originalSaveChart; + const timers = createManualTimers(); + const controller = createTradingViewContinuousSaveController(api, { + settleQuietMs: 20, + maxWaitMs: 100, + setTimeoutFn: timers.setTimeoutFn, + clearTimeoutFn: timers.clearTimeoutFn, + }); + const round = controller.beginRound(); + const capture = controller.beginSubmitCapture(round); + setDrawingToolName('order-1', 'LineToolOrder'); + api.emit('drawing_event', 'order-1', 'properties_changed'); + api.saveChart('pending-round'); + const completion = controller.completeSubmitCapture(capture); + timers.advance(20); + await completion; + + assert.throws(() => controller.endRound(round), /round save failed/); + assert.equal(api.saveChart, originalSaveChart); + controller.stop(); +}); + 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 c3d8106..b67641e 100644 --- a/test/unit/binance-orderbook-trade/source-regressions.test.js +++ b/test/unit/binance-orderbook-trade/source-regressions.test.js @@ -750,7 +750,10 @@ test('Option or Alt click continuously repeats close ladders only after readines assert.match(source, /startContinuousLadder\(actionType\)/); assert.match(continuousBody, /spec\.mode !== 'CLOSE'\) return startLadder\(actionType\)/); assert.match(continuousBody, /while \(true\)/); - assert.match(continuousBody, /await startLadder\(actionType, continuousProgress\)/); + assert.match( + continuousBody, + /await startLadder\(\s*actionType,\s*continuousProgress,\s*chartSaveCoalescer/, + ); assert.match(continuousBody, /recordContinuousLadderRound\(continuousProgress, outcome\)/); assert.match(continuousBody, /resolveContinuousLadderRecovery\(outcome\.error\)/); assert.match(continuousBody, /recovery\?\.cooldownMs/); @@ -788,13 +791,20 @@ 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', () => { +test('continuous close captures only owned order-line saves and restores the chart method', () => { const startCoalescingBody = readFunctionBody('startContinuousChartSaveCoalescing'); const stopCoalescingBody = readFunctionBody('stopContinuousChartSaveCoalescing'); const continuousBody = readFunctionBody('startContinuousLadder'); + const startLadderBody = readFunctionBody('startLadder'); + const executeBody = readFunctionBody('executeLadderPlan'); - assert.match(chartSaveCoalescerSource, /export function createTradingViewRemoveSaveBurstController/); - assert.match(chartSaveCoalescerSource, /eventType !== 'remove'/); + assert.match(chartSaveCoalescerSource, /export function createTradingViewContinuousSaveController/); + assert.match(chartSaveCoalescerSource, /toolName !== 'LineToolOrder'/); + assert.match(chartSaveCoalescerSource, /IGNORED_DRAWING_EVENT_TYPES\.has\(eventType\)/); + assert.match(chartSaveCoalescerSource, /eventType === 'remove'/); + assert.match(chartSaveCoalescerSource, /beginSubmitCapture/); + assert.match(chartSaveCoalescerSource, /completeSubmitCapture/); + assert.match(chartSaveCoalescerSource, /getStats/); assert.match(chartSaveCoalescerSource, /api\.subscribe\('drawing_event', handleDrawingEvent\)/); assert.match(chartSaveCoalescerSource, /api\.unsubscribe\('drawing_event', handleDrawingEvent\)/); assert.match(chartSaveCoalescerSource, /burst\.originalSaveChart\.apply/); @@ -802,10 +812,20 @@ test('continuous close coalesces only short remove-save bursts and restores the 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(startCoalescingBody, /CONTINUOUS_CHART_SUBMIT_EVENT_WAIT_MS/); assert.match(continuousBody, /const chartSaveCoalescer = startContinuousChartSaveCoalescing\(\)/); + assert.match(continuousBody, /startLadder\(\s*actionType,\s*continuousProgress,\s*chartSaveCoalescer/); assert.match(continuousBody, /stopContinuousChartSaveCoalescing\(chartSaveCoalescer\)/); assert.match(stopCoalescingBody, /coalescer\.stop\(\)/); - assert.match(source, /continuousChartSaveCoalescer\?\.flush\(\)/); + assert.match(startLadderBody, /chartSaveController\?\.beginRound\(\)/); + assert.match(startLadderBody, /chartSaveController\.endRound\(chartSaveRound\)/); + assert.match(executeBody, /chartSaveController\?\.beginSubmitCapture\(/); + assert.match(executeBody, /chartSaveController\.completeSubmitCapture\(chartSubmitCapture\)/); + assert.ok(executeBody.indexOf('beginSubmitCapture') < executeBody.indexOf('button.click()')); + assert.ok(executeBody.indexOf('button.click()') < executeBody.indexOf('waitForOrderSubmitAcknowledgement')); + assert.ok(executeBody.indexOf('waitForOrderSubmitAcknowledgement') < executeBody.indexOf('completeSubmitCapture')); + assert.match(source, /continuousChartSaveController\?\.flush\(\)/); + assert.match(source, /continuousChartSaveController\?\.getStats\(\)/); }); test('trade input frame synchronization reuses only its initially proven form root', () => {