From 3de14c69bd1a2c12986301a27f1123842b46d533 Mon Sep 17 00:00:00 2001 From: LiZhenhai-MBP14 <5935568+jackhai9@users.noreply.github.com> Date: Sat, 5 Sep 2026 01:18:11 +0800 Subject: [PATCH] fix(orderbook): stabilize Bollinger markers and coalesce chart saves --- docs/binance-orderbook-trade-development.md | 32 +- scripts/binance-orderbook-trade.user.js | 611 ++++++++++++--- .../core/bearish-bollinger-pattern.js | 34 +- .../core/chart-marker-save-controller.js | 174 +++++ .../dom/depth-profile.js | 15 +- .../dom/tradingview-bearish-alerts.js | 268 +++++-- src/binance-orderbook-trade/index.user.js | 185 ++++- .../depth-profile.test.js | 87 +++ .../tradingview-bearish-alerts.test.js | 705 +++++++++++++++++- .../bearish-bollinger-pattern.test.js | 21 + .../chart-marker-save-controller.test.js | 219 ++++++ .../chart-marker-save-entrypoints.test.js | 77 ++ .../source-regressions.test.js | 3 +- 13 files changed, 2242 insertions(+), 189 deletions(-) create mode 100644 src/binance-orderbook-trade/core/chart-marker-save-controller.js create mode 100644 test/unit/binance-orderbook-trade/chart-marker-save-controller.test.js create mode 100644 test/unit/binance-orderbook-trade/chart-marker-save-entrypoints.test.js diff --git a/docs/binance-orderbook-trade-development.md b/docs/binance-orderbook-trade-development.md index 393a271..125d5f2 100644 --- a/docs/binance-orderbook-trade-development.md +++ b/docs/binance-orderbook-trade-development.md @@ -120,14 +120,44 @@ The chart alert is timeframe-agnostic and evaluates closed bars only. It support A bearish setup requires the Bollinger middle line to cross down through SMA60 while the band center is declining. A bullish setup is generated by the exact price-axis mirror: OHLC values are transformed as `open=-open`, `high=-low`, `low=-high`, `close=-close`, and the indicator axes are transformed as `middle=-middle`, `upper=-lower`, `lower=-upper`, `ma60=-ma60`. This yields a middle-line cross up through SMA60, pre-cross closes in the middle/upper channel, and post-cross middle-line support without maintaining a second drifting detector. Both directions use the same warning/confirmation/reversal lifecycle. Bearish warning dots are red and remain above the candle high; bullish warning dots are green and remain below the candle low. Bearish confirmation is a red down arrow and bullish confirmation is a green up arrow. A reversal uses the opposite colored/directional arrow. Mirrored marker prices remain on the corresponding side of the candle (bullish confirmation below the low, bearish confirmation above the high). If overlapping setups in one direction reverse on the same candle, the newest setup owns that direction's visual reversal; opposite-direction signals retain distinct IDs and are both rendered. -The current Binance `trading-platform-30` chart runtime exposes `exportData()` as row-major numeric-keyed OHLC objects. The parser deliberately enforces that observed contract and fails if the schema changes. Every asynchronous export and marker creation revalidates the active chart instance, route symbol, and resolution so a stale task cannot annotate a newly selected chart. +The current Binance `trading-platform-30` chart runtime exposes `exportData()` as row-major numeric-keyed OHLC objects. The parser deliberately enforces that observed contract and fails if the schema changes. It validates the entire export before filtering closed bars: intraday timestamps must lie on the UTC interval grid, D/W timestamps must be at UTC midnight, weekly timestamps must be Mondays, and positive timestamp deltas must be multiples of the bar duration. Missing bars remain valid; multi-day and multi-week feeds do not have to share the Unix epoch's phase. Off-grid or incompatible-spacing snapshots are recoverable and never reach detection. + +`dataReady()` alone is insufficient: Binance's chart implementation checks whether data is nonempty, not whether an interval switch has completed. A chart-owned interval session subscribes to `onIntervalChanged()` and `onDataLoaded()`. An interval change increments a revision and blocks exports until data completion; callbacks never export or mutate drawings. Every asynchronous export and marker creation revalidates the session identity/revision as well as chart instance, route symbol, and resolution. This rejects stale work even after a rapid `1S -> 1 -> 1S` switch. Stop, page hiding, teardown, and chart replacement dispose the session independently of deferred drawing removal. + +Each interval session uses a private subscription owner token. The observed Binance chart integration calls `unsubscribeAll(null)` on both data-loaded and interval-changed channels when binding its own callbacks. Sharing the null owner lets that native initialization silently remove our callbacks, leaving a running monitor stuck waiting for data that has already arrived. Cleanup uses the same private token and exact callbacks; it never clears native or other-script subscriptions. + +The exposed chart API can exist before its internal model during initial loading. Target discovery and current-target validation use the observed Trading Platform 30 `hasModel()` contract before reading `resolution()`. A missing model is an expected not-ready state, not a fatal error; the existing poll resumes when the model exists. Model readiness does not replace the interval/data session guard. + +Indicator calculation traverses each fixed window directly instead of allocating sliced/mapped close arrays for every bar. Summation order is preserved exactly, including population variance, to avoid changing threshold decisions through floating-point drift. Stable marker audits read each shape handle once while retaining the full point/property checks. The asynchronous render loop yields a browser task after 32 signals or 8 ms of batch work; each resumed batch refreshes native shape ownership and revalidates generation, chart session, and drawing-mutation ownership. This is a cooperative budget checked between native calls, not a hard limit on an individual native API call. It adds no recurring timer and does not reduce history coverage or audit frequency. Context cleanup and obsolete-marker deletion remain synchronous; host chart loading/rendering and those removal phases are not covered by the batch budget. + +`window.__TM_CLOSE_LONG_DEBUG__.bollingerAlertState` is an on-demand diagnostic snapshot of timer/task presence, context/session readiness, cached/rendered signal counts, and boolean drawing-mutation owners. It contains no order details, does not export candles or audit drawings, and adds no periodic work. Native model/data readiness is reported separately from session readiness so a waiting session is not mistaken for expensive calculation or a zero-signal window. Alert markers use TradingView's drawing API. Every detected signal in the loaded window is rendered; there is no recent-signal truncation. Each direction allows up to 1,000 simultaneous signals, for a shared maximum of 2,000, and an over-limit window is rejected before any partial marker mutation. Marker ownership is tracked by signal ID, but the live shape list remains authoritative: externally evicted marker IDs are discarded from the registry and recreated without removing or changing foreign drawings. A typed OHLC/time-order snapshot race is treated as recoverable: existing markers and cached signals remain in place and the next poll retries. Schema, nonnumeric data, chart API, band-width, and time-alignment contract failures remain fail-closed and clear the alert layer. Current live evidence shows that removing even a `disableSave` marker emits `drawing_event` and `saveChart`, so alert reconciliation pauses during every existing order-line drawing/save owner. Symbol changes, non-trading routes, hidden documents, and page teardown stop or clear the alert lifecycle. +Existing owned markers are also checked for timestamp, resolved price, current signal price/type/direction, native shape name, color/icon and interval visibility. A changed marker is recreated; an unchanged marker is not rewritten. The price read back at creation is retained separately from the detector's requested price to tolerate host price normalization without perpetual recreation. Native `intervalsVisibilities` overrides restrict each marker to its originating interval bucket, so `1S` drawings cannot appear on a minute chart while physical cleanup is blocked. TradingView groups 60+ minute resolutions into integer-hour buckets: this matches Binance's standard hour intervals but does not provide distinct native visibility for nonstandard intervals such as 60 and 90 minutes. The session revision still invalidates computation on every interval change. Unsupported `fixedSize` overrides are not sent to arrow drawings; the current live arrow API does not expose that property. + +Retiring a context always invalidates it immediately. Its layer remains in a cleanup set until owned markers and outstanding asynchronous creations have finished; late creations are owned before checking currentness. No late callback removes a drawing while a trade/save owner is busy. The existing poll drains retired layers when safe, without deleting user or other-script drawings or making additional market requests. + +### Marker Save Bursts + +The observed Binance Trading Platform 30 integration schedules a full `widget.save` 100 ms after every non-click/non-move drawing event. `disableSave` excludes temporary markers from the saved JSON but does not suppress these events. A native CPU profile of timeframe switching attributed the main scripting hotspot to repeated chart serialization, especially unchanged parallel-channel properties; marker audits and indicator detection were not the dominant sampled branch. + +`core/chart-marker-save-controller.js` installs one stable base `saveChart` wrapper per API in a WeakMap. Only actual Bollinger marker creation, publication and deletion arm its burst; unchanged audits do not. Default callback saves during that burst share one complete serialization after 150 ms of quiet, capped at 1,000 ms per burst. Every pending callback receives a separate JSON snapshot, including all saveable user drawings. Callback failures are reported at a separate asynchronous job boundary, without skipping later callbacks or interrupting an unrelated explicit save. No drawings are deleted or excluded to accelerate serialization. + +This is deliberately not a fully transparent public-API replacement: default `saveChart(callback)` callers during a marker burst receive a deferred callback and no synchronous callback return value. Binance's observed autosave caller does not use that return value, but a third-party default caller in the same window has this limitation. Idle calls, explicit options (including `includeDrawings: false`), unusual arguments and foreign receivers remain synchronous. An outer order-save wrapper remains authoritative; the base never restores over it. This optimization does not cover independent Strategy 27 or manual-drawing bursts outside a Bollinger mutation window. + +Before starting a continuous-order save owner, toggling order-line visibility, or opening the native cancellation confirmation, the workflow drains pending marker mutations and their delayed-save tail. Asynchronous native creations remain counted through completion, including stale hidden results. An independent 150 ms mutation tail survives an explicit save interrupting the burst. Draining blocks new marker mutations, has a 2,000 ms timeout that refuses the chart workflow before its next action, and supports immediate continuous-task abort. The cancellation confirmation callback remains synchronous; the script does not delay, repeat or confirm a financial click. `clear()` remains synchronous and save timers settle afterward. The controller has no idle recurring timer or retained serialized snapshot; on-demand `bollingerAlertState.markerSaveStats` exposes aggregate counts only. + +Native asynchronous shape creation can automatically enable the interval active when it resolves. Markers therefore start with `visible: false`. Only a current session with drawing mutation ownership may synchronously publish the marker using `setProperties`, restoring its originating interval mask and setting `visible: true`; properties are read back before registration. Stale or busy results remain hidden until safe cleanup. Native visibility normalizes second resolutions of at least 60 seconds into integer-minute buckets as well. + ## Vertical Depth Profile The optional depth profile is a userscript-owned canvas beside the TradingView price axis. It does not clone Binance's native Depth React component. A feature-gated adapter reads the active TradingView main pane height and maps each depth price through the pane's current price scale, including logarithmic and inverted modes. The divider uses Binance's latest visible trade price rather than the order-book midpoint so it follows TradingView's live-price line. If that contract is unavailable or invalid, the profile fails closed instead of falling back to an approximate scale. Binance Basic and native Depth modes do not expose the verified coordinate contract, so the profile is hidden in those modes. +The geometry adapter checks the native `hasModel()` contract before reading pane heights or panes. A missing readiness API or not-yet-created model returns `null` through the existing not-ready geometry lifecycle, avoiding first-refresh `Value is null` errors without catching pane errors or adding delays. + +Every synchronous depth render creates a fresh geometry snapshot. Its 13-step inverse coordinate search memoizes native coordinate-to-price samples by the exact numeric coordinate, shared across that render's levels and initial validation samples. Search branches, precision, pixel-row ownership and cumulative quantities are unchanged. No sample survives into the next geometry, so zoom, logarithmic/inverted mode, and native scale changes are resampled. This removes repeated native calls at shared search-tree nodes without adding timers or caching depth prices/results. Invalid initial samples still reject the geometry; invalid intermediate search samples still throw. + The userscript installs `core/binance-native-depth-source.js` at `document-start` and passively observes the native `/fapi/v1/rpiDepth?limit=1000` response and `{symbol}@rpiDepth@500ms` messages. It preserves Binance's original `fetch` result and WebSocket instances and never opens a second depth connection. `core/depth-profile-book.js` applies the observed `lastUpdateId`, `U`, `u`, and `pu` sequence contract and treats quantities as absolute values; zero removes a price level. The profile keeps every active price accumulated from the native stream and defers pixel-row aggregation to rendering, so distant levels are not discarded by the snapshot limit. Rendering remains sparse: a row is painted only when at least one real price level maps to that chart pixel. `core/depth-profile-session.js` only subscribes the active symbol to that page-owned source. A sequence gap waits for Binance's native resynchronization instead of issuing a userscript-owned retry request, while a changed private RPI contract fails the profile explicitly without blocking Binance's own request. The session must stop and invalidate old work on symbol change, non-trading routes, hidden documents, and `pagehide`. The overlay canvas uses `pointer-events: none`; only its compact collapse control may receive pointer input. Do not connect this visualization book to ladder pricing or any trading decision. diff --git a/scripts/binance-orderbook-trade.user.js b/scripts/binance-orderbook-trade.user.js index af5125c..5f34c50 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.191 +// @version 2.7.198 // @author jackhai9 // @description 单击订单簿价格,按当前开仓/平仓 tab 自动填数量并执行下单,内置数量倍率面板 // @match https://www.binance.com/*/futures/* @@ -3400,6 +3400,166 @@ return { chartRoot, tradingViewApi: tradingViewApis[0] }; } + // src/binance-orderbook-trade/core/chart-marker-save-controller.js + var controllers = /* @__PURE__ */ new WeakMap(); + var QUIET_MS = 150; + var MAX_BURST_MS = 1e3; + var DRAIN_TIMEOUT_MS = 2e3; + function installTradingViewMarkerSaveController(api, { + onError = (error) => { + throw error; + }, + setTimeoutFn = setTimeout, + clearTimeoutFn = clearTimeout + } = {}) { + if (controllers.has(api)) return controllers.get(api); + if (typeof api?.saveChart !== "function") { + throw new Error("TradingView marker save API is unavailable"); + } + const originalSaveChart = api.saveChart; + let burst = null; + let tailTimer = null; + let mutations = 0; + let draining = 0; + let saveRequests = 0; + let serializations = 0; + let callbackCount = 0; + let failureCount = 0; + const idleWaiters = /* @__PURE__ */ new Set(); + const busy = () => burst !== null || mutations !== 0 || tailTimer !== null; + function notifyIdle() { + if (busy()) return; + for (const resolve of idleWaiters) resolve(); + idleWaiters.clear(); + } + function reportErrors(errors) { + if (errors.length === 0) return; + failureCount += errors.length; + setTimeoutFn(() => onError(new AggregateError(errors, "TradingView marker save burst failed")), 0); + } + function flush() { + const pending = burst; + if (!pending) return; + burst = null; + clearTimeoutFn(pending.quietTimer); + clearTimeoutFn(pending.maxTimer); + const errors = []; + try { + if (pending.callbacks.length > 0) { + serializations += 1; + originalSaveChart.call(api, (snapshot) => { + const json = JSON.stringify(snapshot); + for (const callback of pending.callbacks) { + try { + callbackCount += 1; + callback(JSON.parse(json)); + } catch (error) { + errors.push(error); + } + } + }); + } + } catch (error) { + errors.push(error); + } finally { + pending.callbacks.length = 0; + notifyIdle(); + reportErrors(errors); + } + } + function scheduleQuiet() { + clearTimeoutFn(burst.quietTimer); + burst.quietTimer = setTimeoutFn(flush, QUIET_MS); + } + function markMutation() { + if (tailTimer !== null) clearTimeoutFn(tailTimer); + tailTimer = setTimeoutFn(() => { + tailTimer = null; + notifyIdle(); + }, QUIET_MS); + if (!burst) { + burst = { callbacks: [], quietTimer: null, maxTimer: setTimeoutFn(flush, MAX_BURST_MS) }; + } + scheduleQuiet(); + } + function markerSaveChart(...args) { + const defaultCall = this === api && args.length <= 2 && typeof args[0] === "function" && args[1] === void 0; + if (api.saveChart !== markerSaveChart || !defaultCall) { + flush(); + return originalSaveChart.apply(this, args); + } + if (!burst) return originalSaveChart.apply(this, args); + saveRequests += 1; + burst.callbacks.push(args[0]); + scheduleQuiet(); + return void 0; + } + api.saveChart = markerSaveChart; + if (api.saveChart !== markerSaveChart) { + throw new Error("TradingView marker save wrapper could not be installed"); + } + const controller = Object.freeze({ + canMutate: () => draining === 0 && api.saveChart === markerSaveChart, + beginMutation() { + if (!controller.canMutate()) { + throw new Error("TradingView marker mutation overlaps a chart save owner"); + } + mutations += 1; + markMutation(); + let finished = false; + return () => { + if (finished) throw new Error("TradingView marker mutation finished twice"); + finished = true; + mutations -= 1; + markMutation(); + }; + }, + async runAfterIdle(action, { signal } = {}) { + throwIfAborted(signal); + draining += 1; + let timeout = null; + let wake = null; + try { + if (busy()) { + await waitForPromiseOrAbort(new Promise((resolve, reject) => { + wake = resolve; + idleWaiters.add(wake); + timeout = setTimeoutFn(() => { + const error = new Error("TradingView marker saves did not finish before the chart operation"); + error.name = "TradingViewMarkerSaveDrainTimeoutError"; + reject(error); + }, DRAIN_TIMEOUT_MS); + }), signal); + } + if (busy()) throw new Error("TradingView marker save drain was invalidated"); + throwIfAborted(signal); + return await action(); + } finally { + if (timeout !== null) clearTimeoutFn(timeout); + if (wake !== null) idleWaiters.delete(wake); + draining -= 1; + } + }, + getStats: () => ({ + busy: busy(), + mutations, + draining, + saveRequests, + serializations, + callbackCount, + failureCount, + pendingCallbacks: burst?.callbacks.length || 0 + }) + }); + controllers.set(api, controller); + return controller; + } + function afterTradingViewMarkerSaves(api, action, options) { + throwIfAborted(options?.signal); + const controller = controllers.get(api); + return controller ? controller.runAfterIdle(action, options) : action(); + } + // src/binance-orderbook-trade/core/depth-profile-book.js var DepthProfileSequenceError = class extends Error { constructor(message) { @@ -3921,6 +4081,7 @@ function getTradingViewDepthProfileGeometry(frame) { const chart = frame?.contentWindow?.tradingViewApi?.activeChart?.(); if (!chart) return null; + if (typeof chart.hasModel !== "function" || !chart.hasModel()) return null; const paneHeights = chart.getAllPanesHeight?.(); const panes = chart.getPanes?.(); if (!Array.isArray(paneHeights) || !Array.isArray(panes) || !panes.length) return null; @@ -3940,7 +4101,14 @@ const visibleRange = scale.getVisiblePriceRange(); const mode = scale.getMode(); const inverted = scale.isInverted(); - const sampledPrices = [0, height / 4, height / 2, height * 3 / 4, height].map((coordinate) => Number(scale.coordinateToPrice(coordinate))); + const coordinatePriceCache = /* @__PURE__ */ new Map(); + function readCoordinatePrice(coordinate) { + if (!coordinatePriceCache.has(coordinate)) { + coordinatePriceCache.set(coordinate, Number(scale.coordinateToPrice(coordinate))); + } + return coordinatePriceCache.get(coordinate); + } + const sampledPrices = [0, height / 4, height / 2, height * 3 / 4, height].map(readCoordinatePrice); const [topPrice, , , , bottomPrice] = sampledPrices; if (!visibleRange || !Number.isFinite(Number(visibleRange.from)) || !Number.isFinite(Number(visibleRange.to)) || !Number.isInteger(mode) || typeof inverted !== "boolean" || !Number.isFinite(topPrice) || !Number.isFinite(bottomPrice) || topPrice === bottomPrice) return null; const minPrice = Math.min(topPrice, bottomPrice); @@ -3963,7 +4131,7 @@ let high = height; for (let index = 0; index < PRICE_COORDINATE_SEARCH_STEPS; index += 1) { const middle = (low + high) / 2; - const middlePrice = Number(scale.coordinateToPrice(middle)); + const middlePrice = readCoordinatePrice(middle); if (!Number.isFinite(middlePrice)) { throw new Error("TradingView price coordinate is invalid"); } @@ -4827,14 +4995,6 @@ } } } - function average(values) { - return values.reduce((sum, value) => sum + value, 0) / values.length; - } - function calculatePopulationStdDev(values, mean) { - return Math.sqrt( - values.reduce((sum, value) => sum + (value - mean) ** 2, 0) / values.length - ); - } function calculateBollingerIndicatorBars(bars, directionLabel) { assertBars(bars, directionLabel); const config = BOLLINGER_PATTERN; @@ -4842,16 +5002,23 @@ if (index < config.maPeriod - 1) { return { ...bar, middle: null, upper: null, lower: null, ma60: null }; } - const bollingerCloses = bars.slice(index - config.bollingerPeriod + 1, index + 1).map((item) => item.close); - const maCloses = bars.slice(index - config.maPeriod + 1, index + 1).map((item) => item.close); - const middle = average(bollingerCloses); - const deviation = calculatePopulationStdDev(bollingerCloses, middle) * config.bollingerStdDev; + const start = index - config.bollingerPeriod + 1; + let closeSum = 0; + for (let cursor = start; cursor <= index; cursor += 1) closeSum += bars[cursor].close; + const middle = closeSum / config.bollingerPeriod; + let squaredDeviationSum = 0; + for (let cursor = start; cursor <= index; cursor += 1) { + squaredDeviationSum += (bars[cursor].close - middle) ** 2; + } + const deviation = Math.sqrt(squaredDeviationSum / config.bollingerPeriod) * config.bollingerStdDev; + let maSum = 0; + for (let cursor = index - config.maPeriod + 1; cursor <= index; cursor += 1) maSum += bars[cursor].close; return { ...bar, middle, upper: middle + deviation, lower: middle - deviation, - ma60: average(maCloses) + ma60: maSum / config.maPeriod }; }); } @@ -5069,6 +5236,9 @@ "exportData", "getAllShapes", "getShapeById", + "hasModel", + "onDataLoaded", + "onIntervalChanged", "removeEntity", "resolution", "symbol" @@ -5078,17 +5248,17 @@ } } } - function readLiveShapeIds(chart) { + function readLiveShapes(chart) { const shapes = chart.getAllShapes(); if (!Array.isArray(shapes)) { throw new Error("TradingView Bollinger alert shape list is invalid"); } - const ids = /* @__PURE__ */ new Set(); + const ids = /* @__PURE__ */ new Map(); for (const [index, shape] of shapes.entries()) { - if (typeof shape?.id !== "string" || shape.id.length === 0) { + if (typeof shape?.id !== "string" || shape.id.length === 0 || typeof shape.name !== "string") { throw new Error(`TradingView Bollinger alert shape ${index} id is invalid`); } - ids.add(shape.id); + ids.set(shape.id, shape.name); } return ids; } @@ -5109,12 +5279,86 @@ } throw new Error(`TradingView Bollinger alert resolution is unsupported: ${resolution}`); } + function bollingerIntervalVisibility(resolution) { + const seconds = tradingViewResolutionToSeconds(resolution); + const value = String(resolution).toUpperCase(); + const visibility = { + ticks: false, + seconds: false, + minutes: false, + hours: false, + days: false, + weeks: false, + months: false, + ranges: false + }; + let unit; + let count; + if (value.endsWith("W")) { + unit = "weeks"; + count = seconds / 604800; + } else if (value.endsWith("D")) { + unit = "days"; + count = seconds / 86400; + } else if (seconds < 60) { + unit = "seconds"; + count = seconds; + } else if (value.endsWith("S") || seconds < 3600) { + unit = "minutes"; + count = Math.floor(seconds / 60); + } else { + unit = "hours"; + count = Math.floor(seconds / 3600); + } + visibility[unit] = true; + visibility[`${unit}From`] = count; + visibility[`${unit}To`] = count; + return visibility; + } + function createBollingerIntervalSession(chart) { + const intervalChanged = chart.onIntervalChanged(); + const dataLoaded = chart.onDataLoaded(); + for (const subscription of [intervalChanged, dataLoaded]) { + if (typeof subscription?.subscribe !== "function" || typeof subscription.unsubscribe !== "function") { + throw new Error("TradingView Bollinger interval subscription is unavailable"); + } + } + let revision = 0; + let disposed = false; + let awaitingData = !chart.dataReady(); + const owner = {}; + function invalidate() { + revision += 1; + awaitingData = true; + } + function complete() { + awaitingData = false; + } + intervalChanged.subscribe(owner, invalidate); + dataLoaded.subscribe(owner, complete); + return Object.freeze({ + get revision() { + return revision; + }, + isCurrent(candidate) { + return !disposed && !awaitingData && candidate === revision && chart.dataReady(); + }, + dispose() { + if (disposed) return; + disposed = true; + revision += 1; + intervalChanged.unsubscribe(owner, invalidate); + dataLoaded.unsubscribe(owner, complete); + } + }); + } function findBearishBollingerChartTarget(document2, expectedRouteSymbol) { const baseTarget = findBinanceTradingViewTarget(document2); if (!baseTarget) return null; const chart = baseTarget.tradingViewApi.activeChart?.(); if (!chart) return null; assertChartContract(chart); + if (!chart.hasModel()) return null; const resolution = chart.resolution(); const resolutionSeconds = tradingViewResolutionToSeconds(resolution); const routeSymbol = routeSymbolFromChartSymbol(chart.symbol()); @@ -5137,6 +5381,7 @@ const chart = baseTarget.tradingViewApi.activeChart?.(); if (!chart) return false; assertChartContract(chart); + if (!chart.hasModel()) return false; return baseTarget.chartRoot === target.chartRoot && baseTarget.tradingViewApi === target.tradingViewApi && chart === target.chart && chart.resolution() === target.resolution && routeSymbolFromChartSymbol(chart.symbol()) === target.routeSymbol; } function assertExportSchema(schema) { @@ -5168,7 +5413,7 @@ } return bar; } - function parseClosedTradingViewBars(exported, { resolutionSeconds, observedAtSeconds }) { + function parseClosedTradingViewBars(exported, { resolutionSeconds, observedAtSeconds, resolution }) { if (!Number.isSafeInteger(resolutionSeconds) || resolutionSeconds < 1) { throw new Error("TradingView Bollinger alert resolution seconds are invalid"); } @@ -5180,12 +5425,25 @@ throw new Error("TradingView Bollinger alert export data is invalid"); } const bars = exported.data.map(parseExportRow); + const gridSeconds = Math.min(resolutionSeconds, 86400); + for (const [index, bar] of bars.entries()) { + if (bar.time % gridSeconds !== 0 || String(resolution).toUpperCase().endsWith("W") && new Date(bar.time * 1e3).getUTCDay() !== 1) { + throw new TradingViewBarSnapshotInconsistentError( + `TradingView Bollinger alert export interval grid is invalid at ${index}` + ); + } + } for (let index = 1; index < bars.length; index += 1) { if (bars[index].time <= bars[index - 1].time) { throw new TradingViewBarSnapshotInconsistentError( `TradingView Bollinger alert export order is invalid at ${index}` ); } + if ((bars[index].time - bars[index - 1].time) % resolutionSeconds !== 0) { + throw new TradingViewBarSnapshotInconsistentError( + `TradingView Bollinger alert export interval spacing is invalid at ${index}` + ); + } } return bars.filter((bar) => bar.time + resolutionSeconds <= observedAtSeconds); } @@ -5269,15 +5527,19 @@ signals }; } - async function exportClosedTradingViewBars(target, observedAtMs = Date.now()) { - if (!target.chart.dataReady()) return null; + async function exportClosedTradingViewBars(target, session, observedAtMs = Date.now()) { + const revision = session.revision; + const isCurrent = () => session.isCurrent(revision) && target.chart.resolution() === target.resolution && routeSymbolFromChartSymbol(target.chart.symbol()) === target.routeSymbol; + if (!isCurrent()) return null; const exported = await target.chart.exportData({ includedStudies: [] }); + if (!isCurrent()) return null; return parseClosedTradingViewBars(exported, { resolutionSeconds: target.resolutionSeconds, + resolution: target.resolution, observedAtSeconds: observedAtMs / 1e3 }); } - function markerOptions(signal) { + function markerOptions(signal, resolution) { const direction = signal.direction; if (direction !== "bearish" && direction !== "bullish") { throw new Error(`TradingView Bollinger alert signal direction is invalid: ${direction}`); @@ -5296,6 +5558,8 @@ shape: "icon", icon: 61713, overrides: { + visible: true, + intervalsVisibilities: bollingerIntervalVisibility(resolution), color: isBullish ? "#0ECB81" : "#F6465D", size: 10 } @@ -5306,9 +5570,10 @@ ...common, shape: isBullish ? "arrow_up" : "arrow_down", overrides: { + visible: true, + intervalsVisibilities: bollingerIntervalVisibility(resolution), color: isBullish ? "#0ECB81" : "#F6465D", - arrowColor: isBullish ? "#0ECB81" : "#F6465D", - fixedSize: true + arrowColor: isBullish ? "#0ECB81" : "#F6465D" } }; } @@ -5317,36 +5582,34 @@ ...common, shape: isBullish ? "arrow_down" : "arrow_up", overrides: { + visible: true, + intervalsVisibilities: bollingerIntervalVisibility(resolution), color: isBullish ? "#F6465D" : "#0ECB81", - arrowColor: isBullish ? "#F6465D" : "#0ECB81", - fixedSize: true + arrowColor: isBullish ? "#F6465D" : "#0ECB81" } }; } throw new Error(`TradingView Bollinger alert signal type is invalid: ${signal.type}`); } - function verifyResolvedTime(chart, id, requestedTime) { - const shape = chart.getShapeById(id); + function readMarkerPoint(shape) { const points = shape?.getPoints?.(); - if (!Array.isArray(points) || points.length !== 1 || points[0].time !== requestedTime) { - throw new Error(`TradingView Bollinger alert time alignment failed for ${requestedTime}`); + if (!Array.isArray(points) || points.length !== 1 || !Number.isInteger(points[0].time) || !Number.isFinite(points[0].price)) { + throw new Error("TradingView Bollinger alert marker point is invalid"); } + return points[0]; } - async function createAlignedMarker(chart, signal) { - const id = await chart.createShape({ - time: signal.time, - price: signal.markerPrice - }, markerOptions(signal)); - if (typeof id !== "string" || id.length === 0) { - throw new Error("TradingView returned an invalid Bollinger alert shape id"); + function markerPropertiesMatch(shape, options) { + const properties = shape.getProperties(); + if (!properties || typeof properties !== "object") { + throw new Error("TradingView Bollinger alert marker properties are invalid"); } - try { - verifyResolvedTime(chart, id, signal.time); - } catch (error) { - chart.removeEntity(id); - throw error; + if (options.icon !== void 0 && properties.icon !== options.icon) return false; + for (const [key, expected] of Object.entries(options.overrides)) { + if (key === "intervalsVisibilities") { + if (!properties[key] || Object.entries(expected).some(([unit, value]) => properties[key][unit] !== value)) return false; + } else if (properties[key] !== expected) return false; } - return id; + return true; } function normalizeSignal(signal, index, defaultDirection) { if (!signal || typeof signal !== "object") { @@ -5361,10 +5624,34 @@ } return signal.direction === direction ? signal : { ...signal, direction }; } - function createMarkerLayer(target, defaultDirection) { + function createMarkerLayer(target, defaultDirection, { + canMutate: canMutateExternally = () => true, + onSaveError, + yieldToBrowser = () => new Promise((resolve) => setTimeout(resolve, 0)) + } = {}) { const { chart } = target; + const saveController = installTradingViewMarkerSaveController(target.tradingViewApi, { onError: onSaveError }); + const canMutate = () => canMutateExternally() && saveController.canMutate(); const registry = /* @__PURE__ */ new Map(); + const pendingMarkers = /* @__PURE__ */ new Set(); let generation = 0; + let creating = 0; + function mutate(action) { + const finish = saveController.beginMutation(); + try { + return action(); + } finally { + finish(); + } + } + function removePendingMarkers() { + if (pendingMarkers.size === 0 || !canMutate()) return; + const liveShapeIds = readLiveShapes(chart); + for (const id of pendingMarkers) { + if (liveShapeIds.has(id)) mutate(() => chart.removeEntity(id)); + pendingMarkers.delete(id); + } + } function discardMissingSignals(liveShapeIds) { for (const [signalId, record] of registry) { if (!liveShapeIds.has(record.markerId)) registry.delete(signalId); @@ -5374,7 +5661,7 @@ const record = registry.get(signalId); if (!record) return; if (liveShapeIds.has(record.markerId)) { - chart.removeEntity(record.markerId); + mutate(() => chart.removeEntity(record.markerId)); liveShapeIds.delete(record.markerId); } registry.delete(signalId); @@ -5401,38 +5688,92 @@ } } const requestedGeneration = generation; - if (!isCurrent()) return false; - const liveShapeIds = readLiveShapeIds(chart); + if (!isCurrent() || !canMutate()) return false; + removePendingMarkers(); + let liveShapeIds = readLiveShapes(chart); discardMissingSignals(liveShapeIds); const nextIds = new Set(normalizedSignals.map((signal) => signal.id)); for (const signalId of [...registry.keys()]) { if (!nextIds.has(signalId)) removeSignal(signalId, liveShapeIds); } + let batchStartedAt = performance.now(); + let batchOps = 0; for (const signal of normalizedSignals) { - if (registry.has(signal.id)) continue; - if (!isCurrent()) return false; - const markerId = await createAlignedMarker(chart, signal); - if (requestedGeneration !== generation || !isCurrent()) { - chart.removeEntity(markerId); - return false; + if (batchOps > 0 && (batchOps >= 32 || performance.now() - batchStartedAt >= 8)) { + await yieldToBrowser(); + if (requestedGeneration !== generation || !isCurrent() || !canMutate()) return false; + liveShapeIds = readLiveShapes(chart); + discardMissingSignals(liveShapeIds); + batchStartedAt = performance.now(); + batchOps = 0; + } + if (requestedGeneration !== generation || !isCurrent() || !canMutate()) return false; + batchOps += 1; + const options = markerOptions(signal, target.resolution); + const existing = registry.get(signal.id); + if (existing) { + const shape = chart.getShapeById(existing.markerId); + const point = readMarkerPoint(shape); + if (point.time === signal.time && point.price === existing.resolvedPrice && existing.markerPrice === signal.markerPrice && existing.type === signal.type && existing.direction === signal.direction && liveShapeIds.get(existing.markerId) === options.shape && markerPropertiesMatch(shape, options)) continue; + removeSignal(signal.id, liveShapeIds); + } + const finishCreation = saveController.beginMutation(); + creating += 1; + try { + const markerId = await chart.createShape({ time: signal.time, price: signal.markerPrice }, { + ...options, + overrides: { ...options.overrides, visible: false } + }); + if (typeof markerId !== "string" || markerId.length === 0) { + throw new Error("TradingView returned an invalid Bollinger alert shape id"); + } + pendingMarkers.add(markerId); + if (requestedGeneration !== generation || !isCurrent() || !canMutate()) return false; + const shape = chart.getShapeById(markerId); + const point = readMarkerPoint(shape); + if (point.time !== signal.time) { + throw new Error(`TradingView Bollinger alert time alignment failed for ${signal.time}`); + } + if (requestedGeneration !== generation || !isCurrent() || !canMutate()) return false; + mutate(() => shape.setProperties(options.overrides, false)); + if (!markerPropertiesMatch(shape, options)) { + throw new Error("TradingView Bollinger alert marker properties were not applied"); + } + registry.set(signal.id, { + markerId, + resolvedPrice: point.price, + markerPrice: signal.markerPrice, + type: signal.type, + direction: signal.direction + }); + pendingMarkers.delete(markerId); + } finally { + finishCreation(); + creating -= 1; + removePendingMarkers(); } - registry.set(signal.id, { markerId }); } return true; }, clear() { generation += 1; - const liveShapeIds = readLiveShapeIds(chart); + if (!canMutate()) return false; + removePendingMarkers(); + const liveShapeIds = readLiveShapes(chart); discardMissingSignals(liveShapeIds); for (const signalId of [...registry.keys()]) removeSignal(signalId, liveShapeIds); + return creating === 0 && pendingMarkers.size === 0; }, get size() { return registry.size; + }, + get saveStats() { + return saveController.getStats(); } }); } - function createBollingerMarkerLayer(target) { - return createMarkerLayer(target, void 0); + function createBollingerMarkerLayer(target, options) { + return createMarkerLayer(target, void 0, options); } // src/binance-orderbook-trade/index.user.js @@ -5699,6 +6040,8 @@ let bearishBollingerAlertTimer = null; let bearishBollingerAlertTask = null; let bearishBollingerAlertContext = null; + let bollingerIntervalSession = null; + const retiredBollingerLayers = /* @__PURE__ */ new Set(); const controlledNativeButtons = /* @__PURE__ */ new Set(); let lastObservedSymbol = getCurrentSymbol(); const MODE_HINT_ID = "jh-binance-trade-mode-hint"; @@ -5756,37 +6099,69 @@ }); } function clearBearishBollingerAlertContext() { - if (!bearishBollingerAlertContext) return true; + if (bearishBollingerAlertContext) { + retiredBollingerLayers.add(bearishBollingerAlertContext.layer); + bearishBollingerAlertContext = null; + } + return clearRetiredBollingerLayers(); + } + function clearRetiredBollingerLayers() { if (isTradingViewDrawingMutationBusy()) return false; - bearishBollingerAlertContext.layer.clear(); - bearishBollingerAlertContext = null; - return true; + for (const layer of retiredBollingerLayers) { + if (layer.clear()) retiredBollingerLayers.delete(layer); + } + return retiredBollingerLayers.size === 0; + } + function disposeBollingerIntervalSession() { + if (bollingerIntervalSession) { + bollingerIntervalSession.session.dispose(); + bollingerIntervalSession = null; + } } function isBearishBollingerAlertContextCurrent(context) { - return bearishBollingerAlertContext === context && !document.hidden && isFuturesTradingPage() && !isTradingViewDrawingMutationBusy() && getCurrentSymbol() === context.routeSymbol && isBearishBollingerChartTargetCurrent(document, context.target); + return bearishBollingerAlertContext === context && context.intervalSession === bollingerIntervalSession?.session && context.intervalSession.isCurrent(context.intervalRevision) && !document.hidden && isFuturesTradingPage() && !isTradingViewDrawingMutationBusy() && getCurrentSymbol() === context.routeSymbol && isBearishBollingerChartTargetCurrent(document, context.target); } async function synchronizeBearishBollingerAlerts() { - if (document.hidden || !isFuturesTradingPage() || isTradingViewDrawingMutationBusy()) return; + if (document.hidden || !isFuturesTradingPage()) return; const routeSymbol = getCurrentSymbol(); if (!routeSymbol) return; - if (bearishBollingerAlertContext && bearishBollingerAlertContext.routeSymbol !== routeSymbol && !clearBearishBollingerAlertContext()) return; let target; try { target = findBearishBollingerChartTarget(document, routeSymbol); } catch (error) { + disposeBollingerIntervalSession(); clearBearishBollingerAlertContext(); - err("布林带形态预警已停止:", error); + err("Bollinger chart lookup failed for this sample:", error); return; } - if (!target) return; - const contextMatches = bearishBollingerAlertContext && bearishBollingerAlertContext.target.chart === target.chart && bearishBollingerAlertContext.routeSymbol === routeSymbol && bearishBollingerAlertContext.resolution === target.resolution; + if (!target) { + disposeBollingerIntervalSession(); + clearBearishBollingerAlertContext(); + return; + } + if (!bollingerIntervalSession || bollingerIntervalSession.chart !== target.chart || bollingerIntervalSession.routeSymbol !== routeSymbol) { + disposeBollingerIntervalSession(); + bollingerIntervalSession = { + chart: target.chart, + routeSymbol, + session: createBollingerIntervalSession(target.chart) + }; + } + const intervalSession = bollingerIntervalSession.session; + const contextMatches = bearishBollingerAlertContext && bearishBollingerAlertContext.target.chart === target.chart && bearishBollingerAlertContext.target.chartRoot === target.chartRoot && bearishBollingerAlertContext.target.tradingViewApi === target.tradingViewApi && bearishBollingerAlertContext.routeSymbol === routeSymbol && bearishBollingerAlertContext.resolution === target.resolution && bearishBollingerAlertContext.intervalSession === intervalSession && bearishBollingerAlertContext.intervalRevision === intervalSession.revision; if (!contextMatches) { if (!clearBearishBollingerAlertContext()) return; + if (!intervalSession.isCurrent(intervalSession.revision) || isTradingViewDrawingMutationBusy()) return; bearishBollingerAlertContext = { routeSymbol, resolution: target.resolution, + intervalSession, + intervalRevision: intervalSession.revision, target, - layer: createBollingerMarkerLayer(target), + layer: createBollingerMarkerLayer(target, { + canMutate: () => !isTradingViewDrawingMutationBusy(), + onSaveError: (error) => err("Bollinger chart save failed:", error) + }), failed: false, cleanupPending: false, lastProcessedClosedBarsWindowKey: null, @@ -5794,6 +6169,7 @@ lastProcessedSignals: null }; } + if (isTradingViewDrawingMutationBusy() || !clearRetiredBollingerLayers()) return; const context = bearishBollingerAlertContext; if (context.cleanupPending) { context.layer.clear(); @@ -5801,7 +6177,7 @@ } if (context.failed || bearishBollingerAlertTask) return; const task = (async () => { - const bars = await exportClosedTradingViewBars(context.target); + const bars = await exportClosedTradingViewBars(context.target, context.intervalSession); if (!bars || !isBearishBollingerAlertContextCurrent(context)) return; if (bars.length === 0) return; const result = await reconcileBearishBollingerAlertWindow({ @@ -5822,7 +6198,7 @@ })(); bearishBollingerAlertTask = task; task.catch((error) => { - if (bearishBollingerAlertContext !== context) return; + if (bearishBollingerAlertContext !== context || context.intervalSession !== bollingerIntervalSession?.session || context.intervalRevision !== context.intervalSession.revision) return; const failureKind = applyBollingerAlertTaskFailure(context, error); if (failureKind === "retry") { warn("布林带形态预警本轮快照不一致,保留现有标记并等待下一次采样:", error); @@ -5844,6 +6220,42 @@ function stopBearishBollingerAlertMonitor() { if (bearishBollingerAlertTimer) clearInterval(bearishBollingerAlertTimer); bearishBollingerAlertTimer = null; + disposeBollingerIntervalSession(); + clearBearishBollingerAlertContext(); + } + function getBollingerAlertDiagnostics() { + const context = bearishBollingerAlertContext; + const session = bollingerIntervalSession?.session || null; + const chart = bollingerIntervalSession?.chart || context?.target.chart || null; + const nativeModelReady = chart ? chart.hasModel() : null; + const ownerFlags = { + ladderTask: ladderTask !== null, + continuousLadderTask: continuousLadderTask !== null, + singleOrderTask: singleOrderTask !== null, + cancelCurrentSymbolOpenOrdersTask: cancelCurrentSymbolOpenOrdersTask !== null, + chartOrdersRecoveryTask: chartOrdersRecoveryTask !== null, + continuousChartSaveController: continuousChartSaveController !== null + }; + return { + timerRunning: bearishBollingerAlertTimer !== null, + taskPending: bearishBollingerAlertTask !== null, + contextPresent: context !== null, + failed: context ? context.failed : null, + cleanupPending: context ? context.cleanupPending : null, + cachedSignalCount: context?.lastProcessedSignals === null || !context ? null : context.lastProcessedSignals.length, + layerSize: context ? context.layer.size : null, + markerSaveStats: context ? context.layer.saveStats : null, + retiredCount: retiredBollingerLayers.size, + sessionPresent: session !== null, + sessionRevision: session ? session.revision : null, + contextIntervalRevision: context ? context.intervalRevision : null, + sessionMatchesContext: context && session ? context.intervalSession === session : null, + sessionCurrent: session && nativeModelReady ? session.isCurrent(session.revision) : null, + nativeModelReady, + nativeDataReady: nativeModelReady ? chart.dataReady() : null, + mutationBlocked: Object.values(ownerFlags).some(Boolean), + ownerFlags + }; } function parseJsonSafe(raw) { if (!raw || typeof raw !== "string") return null; @@ -8596,16 +9008,23 @@ formatContinuousLadderProgress(label, phase, progress, titleReason) ); } - function startContinuousChartSaveCoalescing() { + async function startContinuousChartSaveCoalescing(signal, actionSymbol) { try { const target = findBinanceTradingViewTarget(document); if (!target) return null; - 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 - }); + return await afterTradingViewMarkerSaves(target.tradingViewApi, () => { + throwIfAborted(signal); + if (!isCurrentObservedSymbol(actionSymbol) || findBinanceTradingViewTarget(document)?.tradingViewApi !== target.tradingViewApi) { + throw createLadderStoppedError(); + } + 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 + }); + }, { signal }); } catch (error) { + if (isLadderStoppedError(error) || error.name === "TradingViewMarkerSaveDrainTimeoutError") throw error; warn("未启用连续交易图表保存合并:", error?.message || error); return null; } @@ -8639,12 +9058,16 @@ const abortController = new AbortController(); const continuousProgress = createContinuousLadderProgress(); const positionCheckState = { checkedAt: Date.now(), retryAt: 0 }; - const chartSaveCoalescer = startContinuousChartSaveCoalescing(); + let chartSaveCoalescer = null; continuousLadderAbortController = abortController; continuousChartSaveController = chartSaveCoalescer; activeContinuousLadderActionType = actionType; activeContinuousLadderProgress = continuousProgress; const executionTask = (async () => { + await Promise.resolve(); + chartSaveCoalescer = await startContinuousChartSaveCoalescing(abortController.signal, actionSymbol); + continuousChartSaveController = chartSaveCoalescer; + throwIfAborted(abortController.signal); while (true) { throwIfAborted(abortController.signal); const outcome = await startLadder( @@ -9853,15 +10276,20 @@ throw new Error("图表“显示当前委托”菜单未关闭"); } } - async function toggleBinanceChartOrdersWithCoalescedSave(target, checkbox, expectedChecked, expectDrawingEvents) { + async function toggleBinanceChartOrdersWithCoalescedSave(target, expectedChecked, expectDrawingEvents) { if (typeof expectDrawingEvents !== "boolean") { throw new Error("图表委托线保存参数异常"); } let popoverCloseOutcomePromise = null; - const coalescingOutcome = await coalesceTradingViewDrawingSaves( + const coalescingOutcome = await afterTradingViewMarkerSaves(target.tradingViewApi, () => coalesceTradingViewDrawingSaves( target.tradingViewApi, async () => { - checkbox.click(); + assertSameBinanceChartOrdersTarget(target, getBinanceChartOrdersTarget2()); + const current = findActiveBinanceChartOrdersPopover(document, target, isVisibleElement); + if (!current || current.checked === expectedChecked) { + throw new Error("Chart orders checkbox changed while waiting for marker saves"); + } + current.checkbox.click(); await waitForBinanceChartOrdersPopover(target, expectedChecked); popoverCloseOutcomePromise = closeBinanceChartOrdersPopover(target).then( () => null, @@ -9869,7 +10297,7 @@ ); }, expectDrawingEvents ? {} : { eventDiscoveryTimeoutMs: 0 } - ).then( + )).then( (result2) => ({ result: result2, error: null }), (error) => ({ result: null, error }) ); @@ -9889,7 +10317,6 @@ if (!current.checked) { await toggleBinanceChartOrdersWithCoalescedSave( target, - current.checkbox, true, true ); @@ -10061,6 +10488,18 @@ setLadderStatus(message); return { ok: false, status: "cancel_button_not_found", message }; } + const saveTarget = findBinanceTradingViewTarget(document); + if (saveTarget) await afterTradingViewMarkerSaves(saveTarget.tradingViewApi, () => { + }); + if (!isCurrentObservedSymbol(symbol)) { + throw new Error("Symbol changed while waiting for chart marker saves"); + } + openOrdersScope = await waitForActiveOpenOrdersScope(); + if (!isCurrentObservedSymbol(symbol) || !openOrdersScope || !isOpenOrdersScopeConfirmedForSymbol(openOrdersScope, symbol)) { + throw new Error("Current-symbol orders scope changed while waiting for chart marker saves"); + } + cancelAllButton = findCurrentSymbolCancelAllButton(openOrdersScope); + if (!cancelAllButton) throw new Error("Cancel control changed while waiting for chart marker saves"); const dialogDecisionWatcher = createBinanceCancelAllDialogDecisionWatcher({ onConfirmed: armChartSaveCoalescing }); @@ -12671,6 +13110,7 @@ installUiSyncObservers(); function clearSymbolOwnedRuntimeState(symbol) { stopDepthProfileSession(); + disposeBollingerIntervalSession(); clearBearishBollingerAlertContext(); depthProfileData = null; depthProfileFailedSymbol = null; @@ -12815,6 +13255,9 @@ } window.__TM_CLOSE_LONG_DEBUG__ = { cfg: CFG, + get bollingerAlertState() { + return getBollingerAlertDiagnostics(); + }, get continuousChartSaveStats() { return continuousChartSaveController?.getStats() || null; }, diff --git a/src/binance-orderbook-trade/core/bearish-bollinger-pattern.js b/src/binance-orderbook-trade/core/bearish-bollinger-pattern.js index c45c430..57f3ace 100644 --- a/src/binance-orderbook-trade/core/bearish-bollinger-pattern.js +++ b/src/binance-orderbook-trade/core/bearish-bollinger-pattern.js @@ -106,16 +106,6 @@ function assertIndicatorBars(indicatorBars, directionLabel) { } } -function average(values) { - return values.reduce((sum, value) => sum + value, 0) / values.length; -} - -function calculatePopulationStdDev(values, mean) { - return Math.sqrt( - values.reduce((sum, value) => sum + ((value - mean) ** 2), 0) / values.length, - ); -} - function calculateBollingerIndicatorBars(bars, directionLabel) { assertBars(bars, directionLabel); const config = BOLLINGER_PATTERN; @@ -123,21 +113,25 @@ function calculateBollingerIndicatorBars(bars, directionLabel) { if (index < config.maPeriod - 1) { return { ...bar, middle: null, upper: null, lower: null, ma60: null }; } - const bollingerCloses = bars - .slice(index - config.bollingerPeriod + 1, index + 1) - .map((item) => item.close); - const maCloses = bars - .slice(index - config.maPeriod + 1, index + 1) - .map((item) => item.close); - const middle = average(bollingerCloses); - const deviation = calculatePopulationStdDev(bollingerCloses, middle) - * config.bollingerStdDev; + // Preserve the original ascending summation order at threshold boundaries, + // without allocating four temporary window arrays per historical candle. + const start = index - config.bollingerPeriod + 1; + let closeSum = 0; + for (let cursor = start; cursor <= index; cursor += 1) closeSum += bars[cursor].close; + const middle = closeSum / config.bollingerPeriod; + let squaredDeviationSum = 0; + for (let cursor = start; cursor <= index; cursor += 1) { + squaredDeviationSum += (bars[cursor].close - middle) ** 2; + } + const deviation = Math.sqrt(squaredDeviationSum / config.bollingerPeriod) * config.bollingerStdDev; + let maSum = 0; + for (let cursor = index - config.maPeriod + 1; cursor <= index; cursor += 1) maSum += bars[cursor].close; return { ...bar, middle, upper: middle + deviation, lower: middle - deviation, - ma60: average(maCloses), + ma60: maSum / config.maPeriod, }; }); } diff --git a/src/binance-orderbook-trade/core/chart-marker-save-controller.js b/src/binance-orderbook-trade/core/chart-marker-save-controller.js new file mode 100644 index 0000000..a233954 --- /dev/null +++ b/src/binance-orderbook-trade/core/chart-marker-save-controller.js @@ -0,0 +1,174 @@ +import { throwIfAborted, waitForPromiseOrAbort } from './abort.js'; + +const controllers = new WeakMap(); +const QUIET_MS = 150; +const MAX_BURST_MS = 1000; +const DRAIN_TIMEOUT_MS = 2000; + +/** + * Binance schedules a full chart save 100ms after each drawing event, including + * disableSave drawings. Keep one stable base wrapper beneath order-save owners. + * Only our marker mutation bursts opt into deferred default saves. During that + * window callbacks receive the final complete snapshot, but synchronous callback + * return values cannot be preserved. Explicit options and idle calls stay native. + */ +export function installTradingViewMarkerSaveController(api, { + onError = (error) => { throw error; }, + setTimeoutFn = setTimeout, + clearTimeoutFn = clearTimeout, +} = {}) { + if (controllers.has(api)) return controllers.get(api); + if (typeof api?.saveChart !== 'function') { + throw new Error('TradingView marker save API is unavailable'); + } + const originalSaveChart = api.saveChart; + let burst = null; + let tailTimer = null; + let mutations = 0; + let draining = 0; + let saveRequests = 0; + let serializations = 0; + let callbackCount = 0; + let failureCount = 0; + const idleWaiters = new Set(); + const busy = () => burst !== null || mutations !== 0 || tailTimer !== null; + function notifyIdle() { + if (busy()) return; + for (const resolve of idleWaiters) resolve(); + idleWaiters.clear(); + } + function reportErrors(errors) { + if (errors.length === 0) return; + failureCount += errors.length; + // A previous deferred callback must not throw into an unrelated explicit + // save. Report all burst failures at their own asynchronous job boundary. + setTimeoutFn(() => onError(new AggregateError(errors, 'TradingView marker save burst failed')), 0); + } + function flush() { + const pending = burst; + if (!pending) return; + burst = null; + clearTimeoutFn(pending.quietTimer); + clearTimeoutFn(pending.maxTimer); + const errors = []; + try { + if (pending.callbacks.length > 0) { + serializations += 1; + originalSaveChart.call(api, (snapshot) => { + // The native API already serializes JSON. Reparse for every callback so + // a host save handler cannot mutate another caller's chart snapshot. + const json = JSON.stringify(snapshot); + for (const callback of pending.callbacks) { + try { + callbackCount += 1; + callback(JSON.parse(json)); + } catch (error) { + errors.push(error); + } + } + }); + } + } catch (error) { + errors.push(error); + } finally { + pending.callbacks.length = 0; + notifyIdle(); + reportErrors(errors); + } + } + function scheduleQuiet() { + clearTimeoutFn(burst.quietTimer); + burst.quietTimer = setTimeoutFn(flush, QUIET_MS); + } + function markMutation() { + // Explicit saves may flush/disarm a burst before Binance's delayed callbacks + // arrive. The independent tail still keeps a new order-save owner waiting. + if (tailTimer !== null) clearTimeoutFn(tailTimer); + tailTimer = setTimeoutFn(() => { tailTimer = null; notifyIdle(); }, QUIET_MS); + if (!burst) { + burst = { callbacks: [], quietTimer: null, maxTimer: setTimeoutFn(flush, MAX_BURST_MS) }; + } + scheduleQuiet(); + } + function markerSaveChart(...args) { + const defaultCall = this === api && args.length <= 2 + && typeof args[0] === 'function' && args[1] === undefined; + if (api.saveChart !== markerSaveChart || !defaultCall) { + flush(); + return originalSaveChart.apply(this, args); + } + if (!burst) return originalSaveChart.apply(this, args); + saveRequests += 1; + burst.callbacks.push(args[0]); + scheduleQuiet(); + return undefined; + } + api.saveChart = markerSaveChart; + if (api.saveChart !== markerSaveChart) { + throw new Error('TradingView marker save wrapper could not be installed'); + } + const controller = Object.freeze({ + canMutate: () => draining === 0 && api.saveChart === markerSaveChart, + beginMutation() { + if (!controller.canMutate()) { + throw new Error('TradingView marker mutation overlaps a chart save owner'); + } + mutations += 1; + markMutation(); + let finished = false; + return () => { + if (finished) throw new Error('TradingView marker mutation finished twice'); + finished = true; + mutations -= 1; + // Native createShape can outlive the first quiet/max window. Completion + // always opens a fresh tail, including stale creations never published. + markMutation(); + }; + }, + async runAfterIdle(action, { signal } = {}) { + throwIfAborted(signal); + draining += 1; + let timeout = null; + let wake = null; + try { + if (busy()) { + await waitForPromiseOrAbort(new Promise((resolve, reject) => { + wake = resolve; + idleWaiters.add(wake); + timeout = setTimeoutFn(() => { + const error = new Error('TradingView marker saves did not finish before the chart operation'); + error.name = 'TradingViewMarkerSaveDrainTimeoutError'; + reject(error); + }, DRAIN_TIMEOUT_MS); + }), signal); + } + // No new mutation can start while draining; asynchronous creations are + // counted until completion and its 100ms native save tail has settled. + if (busy()) throw new Error('TradingView marker save drain was invalidated'); + throwIfAborted(signal); + return await action(); + } finally { + if (timeout !== null) clearTimeoutFn(timeout); + if (wake !== null) idleWaiters.delete(wake); + draining -= 1; + } + }, + getStats: () => ({ + busy: busy(), mutations, draining, saveRequests, serializations, callbackCount, + failureCount, pendingCallbacks: burst?.callbacks.length || 0, + }), + }); + controllers.set(api, controller); + return controller; +} + +export function getTradingViewMarkerSaveController(api) { + return controllers.get(api) || null; +} + +/** Call the outer owner's installer in the same continuation that confirms idle. */ +export function afterTradingViewMarkerSaves(api, action, options) { + throwIfAborted(options?.signal); + const controller = controllers.get(api); + return controller ? controller.runAfterIdle(action, options) : action(); +} diff --git a/src/binance-orderbook-trade/dom/depth-profile.js b/src/binance-orderbook-trade/dom/depth-profile.js index b2e4116..8bac16c 100644 --- a/src/binance-orderbook-trade/dom/depth-profile.js +++ b/src/binance-orderbook-trade/dom/depth-profile.js @@ -38,6 +38,8 @@ export function getTradingViewDepthProfileGeometry(frame) { // fail closed when its runtime contract changes instead of drawing against an invented scale. const chart = frame?.contentWindow?.tradingViewApi?.activeChart?.(); if (!chart) return null; + // Binance exposes the chart API before its model; pane access requires that model. + if (typeof chart.hasModel !== 'function' || !chart.hasModel()) return null; const paneHeights = chart.getAllPanesHeight?.(); const panes = chart.getPanes?.(); if (!Array.isArray(paneHeights) || !Array.isArray(panes) || !panes.length) return null; @@ -70,8 +72,17 @@ export function getTradingViewDepthProfileGeometry(frame) { const visibleRange = scale.getVisiblePriceRange(); const mode = scale.getMode(); const inverted = scale.isInverted(); + // Each synchronous render takes a fresh geometry. Reuse shared search-tree + // samples only within that snapshot, never across zoom or scale changes. + const coordinatePriceCache = new Map(); + function readCoordinatePrice(coordinate) { + if (!coordinatePriceCache.has(coordinate)) { + coordinatePriceCache.set(coordinate, Number(scale.coordinateToPrice(coordinate))); + } + return coordinatePriceCache.get(coordinate); + } const sampledPrices = [0, height / 4, height / 2, height * 3 / 4, height] - .map((coordinate) => Number(scale.coordinateToPrice(coordinate))); + .map(readCoordinatePrice); const [topPrice, , , , bottomPrice] = sampledPrices; if ( !visibleRange @@ -109,7 +120,7 @@ export function getTradingViewDepthProfileGeometry(frame) { let high = height; for (let index = 0; index < PRICE_COORDINATE_SEARCH_STEPS; index += 1) { const middle = (low + high) / 2; - const middlePrice = Number(scale.coordinateToPrice(middle)); + const middlePrice = readCoordinatePrice(middle); if (!Number.isFinite(middlePrice)) { throw new Error('TradingView price coordinate is invalid'); } diff --git a/src/binance-orderbook-trade/dom/tradingview-bearish-alerts.js b/src/binance-orderbook-trade/dom/tradingview-bearish-alerts.js index 4f826bd..8eebb57 100644 --- a/src/binance-orderbook-trade/dom/tradingview-bearish-alerts.js +++ b/src/binance-orderbook-trade/dom/tradingview-bearish-alerts.js @@ -1,4 +1,5 @@ import { findBinanceTradingViewTarget } from './tradingview-target.js'; +import { installTradingViewMarkerSaveController } from '../core/chart-marker-save-controller.js'; import { TradingViewBarSnapshotInconsistentError, } from '../core/bearish-bollinger-pattern.js'; @@ -18,6 +19,9 @@ function assertChartContract(chart) { 'exportData', 'getAllShapes', 'getShapeById', + 'hasModel', + 'onDataLoaded', + 'onIntervalChanged', 'removeEntity', 'resolution', 'symbol', @@ -28,17 +32,17 @@ function assertChartContract(chart) { } } -function readLiveShapeIds(chart) { +function readLiveShapes(chart) { const shapes = chart.getAllShapes(); if (!Array.isArray(shapes)) { throw new Error('TradingView Bollinger alert shape list is invalid'); } - const ids = new Set(); + const ids = new Map(); for (const [index, shape] of shapes.entries()) { - if (typeof shape?.id !== 'string' || shape.id.length === 0) { + if (typeof shape?.id !== 'string' || shape.id.length === 0 || typeof shape.name !== 'string') { throw new Error(`TradingView Bollinger alert shape ${index} id is invalid`); } - ids.add(shape.id); + ids.set(shape.id, shape.name); } return ids; } @@ -61,12 +65,75 @@ export function tradingViewResolutionToSeconds(resolution) { throw new Error(`TradingView Bollinger alert resolution is unsupported: ${resolution}`); } +/** Native drawings must stay hidden outside their originating interval, including while saves defer removal. */ +export function bollingerIntervalVisibility(resolution) { + const seconds = tradingViewResolutionToSeconds(resolution); + const value = String(resolution).toUpperCase(); + const visibility = { + ticks: false, seconds: false, minutes: false, hours: false, + days: false, weeks: false, months: false, ranges: false, + }; + let unit; + let count; + if (value.endsWith('W')) { unit = 'weeks'; count = seconds / 604800; } + else if (value.endsWith('D')) { unit = 'days'; count = seconds / 86400; } + else if (seconds < 60) { unit = 'seconds'; count = seconds; } + else if (value.endsWith('S') || seconds < 3600) { unit = 'minutes'; count = Math.floor(seconds / 60); } + else { unit = 'hours'; count = Math.floor(seconds / 3600); } + // TradingView groups 60+ minute intervals into integer-hour visibility buckets. + visibility[unit] = true; + visibility[`${unit}From`] = count; + visibility[`${unit}To`] = count; + return visibility; +} + +/** + * dataReady() in Binance's chart runtime only checks for nonempty data. A revision + * and the data-completed event are needed to exclude old bars during A -> B -> A. + * Event callbacks never export or mutate drawings: interval notification precedes + * the host's data reset, and drawing/save owners may still be busy. + */ +export function createBollingerIntervalSession(chart) { + const intervalChanged = chart.onIntervalChanged(); + const dataLoaded = chart.onDataLoaded(); + for (const subscription of [intervalChanged, dataLoaded]) { + if (typeof subscription?.subscribe !== 'function' || typeof subscription.unsubscribe !== 'function') { + throw new Error('TradingView Bollinger interval subscription is unavailable'); + } + } + let revision = 0; + let disposed = false; + let awaitingData = !chart.dataReady(); + // Binance clears its null-owned listeners when rebinding chart callbacks. + // A private owner keeps that host cleanup from deleting our readiness session. + const owner = {}; + function invalidate() { revision += 1; awaitingData = true; } + function complete() { awaitingData = false; } + intervalChanged.subscribe(owner, invalidate); + dataLoaded.subscribe(owner, complete); + return Object.freeze({ + get revision() { return revision; }, + isCurrent(candidate) { + return !disposed && !awaitingData && candidate === revision && chart.dataReady(); + }, + dispose() { + if (disposed) return; + disposed = true; + revision += 1; + intervalChanged.unsubscribe(owner, invalidate); + dataLoaded.unsubscribe(owner, complete); + }, + }); +} + export function findBearishBollingerChartTarget(document, expectedRouteSymbol) { const baseTarget = findBinanceTradingViewTarget(document); if (!baseTarget) return null; const chart = baseTarget.tradingViewApi.activeChart?.(); if (!chart) return null; assertChartContract(chart); + // The API object is exposed before its model exists during first refresh. + if (!chart.hasModel()) return null; const resolution = chart.resolution(); const resolutionSeconds = tradingViewResolutionToSeconds(resolution); const routeSymbol = routeSymbolFromChartSymbol(chart.symbol()); @@ -90,6 +157,7 @@ export function isBearishBollingerChartTargetCurrent(document, target) { const chart = baseTarget.tradingViewApi.activeChart?.(); if (!chart) return false; assertChartContract(chart); + if (!chart.hasModel()) return false; return ( baseTarget.chartRoot === target.chartRoot && baseTarget.tradingViewApi === target.tradingViewApi @@ -132,7 +200,7 @@ function parseExportRow(row, index) { export function parseClosedTradingViewBars( exported, - { resolutionSeconds, observedAtSeconds }, + { resolutionSeconds, observedAtSeconds, resolution }, ) { if (!Number.isSafeInteger(resolutionSeconds) || resolutionSeconds < 1) { throw new Error('TradingView Bollinger alert resolution seconds are invalid'); @@ -147,12 +215,30 @@ export function parseClosedTradingViewBars( // Binance's current trading-platform-30 runtime exports one numeric-keyed object per bar. // This deliberately follows that live contract instead of TradingView's generic column model. const bars = exported.data.map(parseExportRow); + // Multi-day/week feeds need not share the Unix epoch's phase. Intraday bars use + // UTC boundaries; D/W bars start at UTC midnight, with weekly bars on Monday. + const gridSeconds = Math.min(resolutionSeconds, 86400); + for (const [index, bar] of bars.entries()) { + if ( + bar.time % gridSeconds !== 0 + || (String(resolution).toUpperCase().endsWith('W') && new Date(bar.time * 1000).getUTCDay() !== 1) + ) { + throw new TradingViewBarSnapshotInconsistentError( + `TradingView Bollinger alert export interval grid is invalid at ${index}`, + ); + } + } for (let index = 1; index < bars.length; index += 1) { if (bars[index].time <= bars[index - 1].time) { throw new TradingViewBarSnapshotInconsistentError( `TradingView Bollinger alert export order is invalid at ${index}`, ); } + if ((bars[index].time - bars[index - 1].time) % resolutionSeconds !== 0) { + throw new TradingViewBarSnapshotInconsistentError( + `TradingView Bollinger alert export interval spacing is invalid at ${index}`, + ); + } } return bars.filter((bar) => bar.time + resolutionSeconds <= observedAtSeconds); } @@ -264,16 +350,22 @@ export async function reconcileBearishBollingerAlertWindow({ }; } -export async function exportClosedTradingViewBars(target, observedAtMs = Date.now()) { - if (!target.chart.dataReady()) return null; +export async function exportClosedTradingViewBars(target, session, observedAtMs = Date.now()) { + const revision = session.revision; + const isCurrent = () => session.isCurrent(revision) + && target.chart.resolution() === target.resolution + && routeSymbolFromChartSymbol(target.chart.symbol()) === target.routeSymbol; + if (!isCurrent()) return null; const exported = await target.chart.exportData({ includedStudies: [] }); + if (!isCurrent()) return null; return parseClosedTradingViewBars(exported, { resolutionSeconds: target.resolutionSeconds, + resolution: target.resolution, observedAtSeconds: observedAtMs / 1_000, }); } -function markerOptions(signal) { +function markerOptions(signal, resolution) { const direction = signal.direction; if (direction !== 'bearish' && direction !== 'bullish') { throw new Error(`TradingView Bollinger alert signal direction is invalid: ${direction}`); @@ -292,6 +384,8 @@ function markerOptions(signal) { shape: 'icon', icon: 0xf111, overrides: { + visible: true, + intervalsVisibilities: bollingerIntervalVisibility(resolution), color: isBullish ? '#0ECB81' : '#F6465D', size: 10, }, @@ -302,9 +396,10 @@ function markerOptions(signal) { ...common, shape: isBullish ? 'arrow_up' : 'arrow_down', overrides: { + visible: true, + intervalsVisibilities: bollingerIntervalVisibility(resolution), color: isBullish ? '#0ECB81' : '#F6465D', arrowColor: isBullish ? '#0ECB81' : '#F6465D', - fixedSize: true, }, }; } @@ -313,38 +408,36 @@ function markerOptions(signal) { ...common, shape: isBullish ? 'arrow_down' : 'arrow_up', overrides: { + visible: true, + intervalsVisibilities: bollingerIntervalVisibility(resolution), color: isBullish ? '#F6465D' : '#0ECB81', arrowColor: isBullish ? '#F6465D' : '#0ECB81', - fixedSize: true, }, }; } throw new Error(`TradingView Bollinger alert signal type is invalid: ${signal.type}`); } -function verifyResolvedTime(chart, id, requestedTime) { - const shape = chart.getShapeById(id); +function readMarkerPoint(shape) { const points = shape?.getPoints?.(); - if (!Array.isArray(points) || points.length !== 1 || points[0].time !== requestedTime) { - throw new Error(`TradingView Bollinger alert time alignment failed for ${requestedTime}`); + if (!Array.isArray(points) || points.length !== 1 || !Number.isInteger(points[0].time) || !Number.isFinite(points[0].price)) { + throw new Error('TradingView Bollinger alert marker point is invalid'); } + return points[0]; } -async function createAlignedMarker(chart, signal) { - const id = await chart.createShape({ - time: signal.time, - price: signal.markerPrice, - }, markerOptions(signal)); - if (typeof id !== 'string' || id.length === 0) { - throw new Error('TradingView returned an invalid Bollinger alert shape id'); - } - try { - verifyResolvedTime(chart, id, signal.time); - } catch (error) { - chart.removeEntity(id); - throw error; - } - return id; +function markerPropertiesMatch(shape, options) { + const properties = shape.getProperties(); + if (!properties || typeof properties !== 'object') { + throw new Error('TradingView Bollinger alert marker properties are invalid'); + } + if (options.icon !== undefined && properties.icon !== options.icon) return false; + for (const [key, expected] of Object.entries(options.overrides)) { + if (key === 'intervalsVisibilities') { + if (!properties[key] || Object.entries(expected).some(([unit, value]) => properties[key][unit] !== value)) return false; + } else if (properties[key] !== expected) return false; + } + return true; } function normalizeSignal(signal, index, defaultDirection) { @@ -361,10 +454,32 @@ function normalizeSignal(signal, index, defaultDirection) { return signal.direction === direction ? signal : { ...signal, direction }; } -function createMarkerLayer(target, defaultDirection) { +function createMarkerLayer(target, defaultDirection, { + canMutate: canMutateExternally = () => true, + onSaveError, + yieldToBrowser = () => new Promise((resolve) => setTimeout(resolve, 0)), +} = {}) { const { chart } = target; + const saveController = installTradingViewMarkerSaveController(target.tradingViewApi, { onError: onSaveError }); + const canMutate = () => canMutateExternally() && saveController.canMutate(); const registry = new Map(); + const pendingMarkers = new Set(); let generation = 0; + let creating = 0; + + function mutate(action) { + const finish = saveController.beginMutation(); + try { return action(); } finally { finish(); } + } + + function removePendingMarkers() { + if (pendingMarkers.size === 0 || !canMutate()) return; + const liveShapeIds = readLiveShapes(chart); + for (const id of pendingMarkers) { + if (liveShapeIds.has(id)) mutate(() => chart.removeEntity(id)); + pendingMarkers.delete(id); + } + } function discardMissingSignals(liveShapeIds) { for (const [signalId, record] of registry) { @@ -376,7 +491,7 @@ function createMarkerLayer(target, defaultDirection) { const record = registry.get(signalId); if (!record) return; if (liveShapeIds.has(record.markerId)) { - chart.removeEntity(record.markerId); + mutate(() => chart.removeEntity(record.markerId)); liveShapeIds.delete(record.markerId); } registry.delete(signalId); @@ -407,41 +522,104 @@ function createMarkerLayer(target, defaultDirection) { } } const requestedGeneration = generation; - if (!isCurrent()) return false; - const liveShapeIds = readLiveShapeIds(chart); + if (!isCurrent() || !canMutate()) return false; + removePendingMarkers(); + let liveShapeIds = readLiveShapes(chart); discardMissingSignals(liveShapeIds); const nextIds = new Set(normalizedSignals.map((signal) => signal.id)); for (const signalId of [...registry.keys()]) { if (!nextIds.has(signalId)) removeSignal(signalId, liveShapeIds); } + let batchStartedAt = performance.now(); + let batchOps = 0; for (const signal of normalizedSignals) { - if (registry.has(signal.id)) continue; - if (!isCurrent()) return false; - const markerId = await createAlignedMarker(chart, signal); - if (requestedGeneration !== generation || !isCurrent()) { - chart.removeEntity(markerId); - return false; + // Awaiting native shape creation may resolve as a microtask. Explicitly + // yield large audits/rebuilds so input and paint can run between batches. + if (batchOps > 0 && (batchOps >= 32 || performance.now() - batchStartedAt >= 8)) { + await yieldToBrowser(); + if (requestedGeneration !== generation || !isCurrent() || !canMutate()) return false; + liveShapeIds = readLiveShapes(chart); + discardMissingSignals(liveShapeIds); + batchStartedAt = performance.now(); + batchOps = 0; + } + if (requestedGeneration !== generation || !isCurrent() || !canMutate()) return false; + batchOps += 1; + const options = markerOptions(signal, target.resolution); + const existing = registry.get(signal.id); + if (existing) { + const shape = chart.getShapeById(existing.markerId); + const point = readMarkerPoint(shape); + if ( + point.time === signal.time && point.price === existing.resolvedPrice + && existing.markerPrice === signal.markerPrice + && existing.type === signal.type && existing.direction === signal.direction + && liveShapeIds.get(existing.markerId) === options.shape + && markerPropertiesMatch(shape, options) + ) continue; + removeSignal(signal.id, liveShapeIds); + } + const finishCreation = saveController.beginMutation(); + creating += 1; + try { + // Native creation enables the interval active after its async loader. + // Keep pending drawings hidden until the originating session can publish. + const markerId = await chart.createShape({ time: signal.time, price: signal.markerPrice }, { + ...options, + overrides: { ...options.overrides, visible: false }, + }); + if (typeof markerId !== 'string' || markerId.length === 0) { + throw new Error('TradingView returned an invalid Bollinger alert shape id'); + } + // Own the result before checking the epoch. Late results may need to wait + // for a trade/save owner, and must never become untracked foreign drawings. + pendingMarkers.add(markerId); + if (requestedGeneration !== generation || !isCurrent() || !canMutate()) return false; + const shape = chart.getShapeById(markerId); + const point = readMarkerPoint(shape); + if (point.time !== signal.time) { + throw new Error(`TradingView Bollinger alert time alignment failed for ${signal.time}`); + } + if (requestedGeneration !== generation || !isCurrent() || !canMutate()) return false; + mutate(() => shape.setProperties(options.overrides, false)); + if (!markerPropertiesMatch(shape, options)) { + throw new Error('TradingView Bollinger alert marker properties were not applied'); + } + registry.set(signal.id, { + markerId, resolvedPrice: point.price, markerPrice: signal.markerPrice, + type: signal.type, direction: signal.direction, + }); + pendingMarkers.delete(markerId); + } finally { + finishCreation(); + creating -= 1; + removePendingMarkers(); } - registry.set(signal.id, { markerId }); } return true; }, clear() { generation += 1; - const liveShapeIds = readLiveShapeIds(chart); + if (!canMutate()) return false; + removePendingMarkers(); + const liveShapeIds = readLiveShapes(chart); discardMissingSignals(liveShapeIds); for (const signalId of [...registry.keys()]) removeSignal(signalId, liveShapeIds); + return creating === 0 && pendingMarkers.size === 0; }, get size() { return registry.size; }, + get saveStats() { + return saveController.getStats(); + }, }); } -export function createBollingerMarkerLayer(target) { - return createMarkerLayer(target, undefined); +export function createBollingerMarkerLayer(target, options) { + return createMarkerLayer(target, undefined, options); } -export function createBearishBollingerMarkerLayer(target) { - return createMarkerLayer(target, 'bearish'); +export function createBearishBollingerMarkerLayer(target, options) { + return createMarkerLayer(target, 'bearish', options); } diff --git a/src/binance-orderbook-trade/index.user.js b/src/binance-orderbook-trade/index.user.js index 9d768a7..1fd10f9 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.191 +// @version 2.7.198 // @author jackhai9 // @description 单击订单簿价格,按当前开仓/平仓 tab 自动填数量并执行下单,内置数量倍率面板 // @match https://www.binance.com/*/futures/* @@ -204,6 +204,9 @@ import { createTradingViewRemovalSaveController, } from './core/chart-save-coalescer.js'; import { findBinanceTradingViewTarget } from './dom/tradingview-target.js'; +import { + afterTradingViewMarkerSaves, +} from './core/chart-marker-save-controller.js'; import { installBinanceNativeDepthSource } from './core/binance-native-depth-source.js'; import { createDepthProfileSession } from './core/depth-profile-session.js'; import { @@ -241,6 +244,7 @@ import { isBollingerDrawingMutationBlocked, } from './core/bearish-bollinger-pattern.js'; import { + createBollingerIntervalSession, createBollingerMarkerLayer, exportClosedTradingViewBars, findBearishBollingerChartTarget, @@ -521,6 +525,8 @@ import { let bearishBollingerAlertTimer = null; let bearishBollingerAlertTask = null; let bearishBollingerAlertContext = null; + let bollingerIntervalSession = null; + const retiredBollingerLayers = new Set(); const controlledNativeButtons = new Set(); let lastObservedSymbol = getCurrentSymbol(); @@ -593,16 +599,33 @@ import { } function clearBearishBollingerAlertContext() { - if (!bearishBollingerAlertContext) return true; + if (bearishBollingerAlertContext) { + retiredBollingerLayers.add(bearishBollingerAlertContext.layer); + bearishBollingerAlertContext = null; + } + return clearRetiredBollingerLayers(); + } + + function clearRetiredBollingerLayers() { if (isTradingViewDrawingMutationBusy()) return false; - bearishBollingerAlertContext.layer.clear(); - bearishBollingerAlertContext = null; - return true; + for (const layer of retiredBollingerLayers) { + if (layer.clear()) retiredBollingerLayers.delete(layer); + } + return retiredBollingerLayers.size === 0; + } + + function disposeBollingerIntervalSession() { + if (bollingerIntervalSession) { + bollingerIntervalSession.session.dispose(); + bollingerIntervalSession = null; + } } function isBearishBollingerAlertContextCurrent(context) { return ( bearishBollingerAlertContext === context + && context.intervalSession === bollingerIntervalSession?.session + && context.intervalSession.isCurrent(context.intervalRevision) && !document.hidden && isFuturesTradingPage() && !isTradingViewDrawingMutationBusy() @@ -612,37 +635,60 @@ import { } async function synchronizeBearishBollingerAlerts() { - if (document.hidden || !isFuturesTradingPage() || isTradingViewDrawingMutationBusy()) return; + if (document.hidden || !isFuturesTradingPage()) return; const routeSymbol = getCurrentSymbol(); if (!routeSymbol) return; - if ( - bearishBollingerAlertContext - && bearishBollingerAlertContext.routeSymbol !== routeSymbol - && !clearBearishBollingerAlertContext() - ) return; - let target; try { target = findBearishBollingerChartTarget(document, routeSymbol); } catch (error) { + disposeBollingerIntervalSession(); + clearBearishBollingerAlertContext(); + err('Bollinger chart lookup failed for this sample:', error); + return; + } + if (!target) { + disposeBollingerIntervalSession(); clearBearishBollingerAlertContext(); - err('布林带形态预警已停止:', error); return; } - if (!target) return; + + if ( + !bollingerIntervalSession + || bollingerIntervalSession.chart !== target.chart + || bollingerIntervalSession.routeSymbol !== routeSymbol + ) { + disposeBollingerIntervalSession(); + bollingerIntervalSession = { + chart: target.chart, + routeSymbol, + session: createBollingerIntervalSession(target.chart), + }; + } + const intervalSession = bollingerIntervalSession.session; const contextMatches = bearishBollingerAlertContext && bearishBollingerAlertContext.target.chart === target.chart + && bearishBollingerAlertContext.target.chartRoot === target.chartRoot + && bearishBollingerAlertContext.target.tradingViewApi === target.tradingViewApi && bearishBollingerAlertContext.routeSymbol === routeSymbol - && bearishBollingerAlertContext.resolution === target.resolution; + && bearishBollingerAlertContext.resolution === target.resolution + && bearishBollingerAlertContext.intervalSession === intervalSession + && bearishBollingerAlertContext.intervalRevision === intervalSession.revision; if (!contextMatches) { if (!clearBearishBollingerAlertContext()) return; + if (!intervalSession.isCurrent(intervalSession.revision) || isTradingViewDrawingMutationBusy()) return; bearishBollingerAlertContext = { routeSymbol, resolution: target.resolution, + intervalSession, + intervalRevision: intervalSession.revision, target, - layer: createBollingerMarkerLayer(target), + layer: createBollingerMarkerLayer(target, { + canMutate: () => !isTradingViewDrawingMutationBusy(), + onSaveError: (error) => err('Bollinger chart save failed:', error), + }), failed: false, cleanupPending: false, lastProcessedClosedBarsWindowKey: null, @@ -651,6 +697,8 @@ import { }; } + if (isTradingViewDrawingMutationBusy() || !clearRetiredBollingerLayers()) return; + const context = bearishBollingerAlertContext; if (context.cleanupPending) { context.layer.clear(); @@ -658,7 +706,7 @@ import { } if (context.failed || bearishBollingerAlertTask) return; const task = (async () => { - const bars = await exportClosedTradingViewBars(context.target); + const bars = await exportClosedTradingViewBars(context.target, context.intervalSession); if (!bars || !isBearishBollingerAlertContextCurrent(context)) return; if (bars.length === 0) return; const result = await reconcileBearishBollingerAlertWindow({ @@ -679,7 +727,11 @@ import { })(); bearishBollingerAlertTask = task; task.catch((error) => { - if (bearishBollingerAlertContext !== context) return; + if ( + bearishBollingerAlertContext !== context + || context.intervalSession !== bollingerIntervalSession?.session + || context.intervalRevision !== context.intervalSession.revision + ) return; const failureKind = applyBollingerAlertTaskFailure(context, error); if (failureKind === 'retry') { // TradingView can expose one feed-update race through exportData(). Keep the @@ -706,6 +758,46 @@ import { function stopBearishBollingerAlertMonitor() { if (bearishBollingerAlertTimer) clearInterval(bearishBollingerAlertTimer); bearishBollingerAlertTimer = null; + // Invalidate even while a trade/save owner defers physical marker removal. + disposeBollingerIntervalSession(); + clearBearishBollingerAlertContext(); + } + + /** On-demand lifecycle diagnostics; never exports market data or mutates drawings. */ + function getBollingerAlertDiagnostics() { + const context = bearishBollingerAlertContext; + const session = bollingerIntervalSession?.session || null; + const chart = bollingerIntervalSession?.chart || context?.target.chart || null; + const nativeModelReady = chart ? chart.hasModel() : null; + const ownerFlags = { + ladderTask: ladderTask !== null, + continuousLadderTask: continuousLadderTask !== null, + singleOrderTask: singleOrderTask !== null, + cancelCurrentSymbolOpenOrdersTask: cancelCurrentSymbolOpenOrdersTask !== null, + chartOrdersRecoveryTask: chartOrdersRecoveryTask !== null, + continuousChartSaveController: continuousChartSaveController !== null, + }; + return { + timerRunning: bearishBollingerAlertTimer !== null, + taskPending: bearishBollingerAlertTask !== null, + contextPresent: context !== null, + failed: context ? context.failed : null, + cleanupPending: context ? context.cleanupPending : null, + cachedSignalCount: context?.lastProcessedSignals === null || !context + ? null : context.lastProcessedSignals.length, + layerSize: context ? context.layer.size : null, + markerSaveStats: context ? context.layer.saveStats : null, + retiredCount: retiredBollingerLayers.size, + sessionPresent: session !== null, + sessionRevision: session ? session.revision : null, + contextIntervalRevision: context ? context.intervalRevision : null, + sessionMatchesContext: context && session ? context.intervalSession === session : null, + sessionCurrent: session && nativeModelReady ? session.isCurrent(session.revision) : null, + nativeModelReady, + nativeDataReady: nativeModelReady ? chart.dataReady() : null, + mutationBlocked: Object.values(ownerFlags).some(Boolean), + ownerFlags, + }; } function parseJsonSafe(raw) { @@ -4009,16 +4101,24 @@ import { ); } - function startContinuousChartSaveCoalescing() { + async function startContinuousChartSaveCoalescing(signal, actionSymbol) { try { const target = findBinanceTradingViewTarget(document); if (!target) return null; - 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, - }); + return await afterTradingViewMarkerSaves(target.tradingViewApi, () => { + throwIfAborted(signal); + if (!isCurrentObservedSymbol(actionSymbol) + || findBinanceTradingViewTarget(document)?.tradingViewApi !== target.tradingViewApi) { + throw createLadderStoppedError(); + } + 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, + }); + }, { signal }); } catch (error) { + if (isLadderStoppedError(error) || error.name === 'TradingViewMarkerSaveDrainTimeoutError') throw error; warn('未启用连续交易图表保存合并:', error?.message || error); return null; } @@ -4056,13 +4156,18 @@ import { const abortController = new AbortController(); const continuousProgress = createContinuousLadderProgress(); const positionCheckState = { checkedAt: Date.now(), retryAt: 0 }; - const chartSaveCoalescer = startContinuousChartSaveCoalescing(); + let chartSaveCoalescer = null; continuousLadderAbortController = abortController; continuousChartSaveController = chartSaveCoalescer; activeContinuousLadderActionType = actionType; activeContinuousLadderProgress = continuousProgress; const executionTask = (async () => { + // Publish the single-flight task before waiting, blocking new marker work. + await Promise.resolve(); + chartSaveCoalescer = await startContinuousChartSaveCoalescing(abortController.signal, actionSymbol); + continuousChartSaveController = chartSaveCoalescer; + throwIfAborted(abortController.signal); while (true) { throwIfAborted(abortController.signal); const outcome = await startLadder( @@ -5483,7 +5588,6 @@ import { async function toggleBinanceChartOrdersWithCoalescedSave( target, - checkbox, expectedChecked, expectDrawingEvents, ) { @@ -5491,10 +5595,15 @@ import { throw new Error('图表委托线保存参数异常'); } let popoverCloseOutcomePromise = null; - const coalescingOutcome = await coalesceTradingViewDrawingSaves( + const coalescingOutcome = await afterTradingViewMarkerSaves(target.tradingViewApi, () => coalesceTradingViewDrawingSaves( target.tradingViewApi, async () => { - checkbox.click(); + assertSameBinanceChartOrdersTarget(target, getBinanceChartOrdersTarget()); + const current = findActiveBinanceChartOrdersPopover(document, target, isVisibleElement); + if (!current || current.checked === expectedChecked) { + throw new Error('Chart orders checkbox changed while waiting for marker saves'); + } + current.checkbox.click(); await waitForBinanceChartOrdersPopover(target, expectedChecked); // Start hiding native setup UI immediately, but keep its failure outside // the action so the coalescer can still replay the final chart snapshot. @@ -5504,7 +5613,7 @@ import { ); }, expectDrawingEvents ? {} : { eventDiscoveryTimeoutMs: 0 }, - ).then( + )).then( (result) => ({ result, error: null }), (error) => ({ result: null, error }), ); @@ -5528,7 +5637,6 @@ import { if (!current.checked) { await toggleBinanceChartOrdersWithCoalescedSave( target, - current.checkbox, true, true, ); @@ -5718,6 +5826,21 @@ import { return { ok: false, status: 'cancel_button_not_found', message }; } + // Drain before opening the native confirmation; its onConfirmed hook must + // remain synchronous and must never delay or replay a financial click. + const saveTarget = findBinanceTradingViewTarget(document); + if (saveTarget) await afterTradingViewMarkerSaves(saveTarget.tradingViewApi, () => {}); + if (!isCurrentObservedSymbol(symbol)) { + throw new Error('Symbol changed while waiting for chart marker saves'); + } + // React may replace the control during the bounded drain. + openOrdersScope = await waitForActiveOpenOrdersScope(); + if (!isCurrentObservedSymbol(symbol) || !openOrdersScope + || !isOpenOrdersScopeConfirmedForSymbol(openOrdersScope, symbol)) { + throw new Error('Current-symbol orders scope changed while waiting for chart marker saves'); + } + cancelAllButton = findCurrentSymbolCancelAllButton(openOrdersScope); + if (!cancelAllButton) throw new Error('Cancel control changed while waiting for chart marker saves'); const dialogDecisionWatcher = createBinanceCancelAllDialogDecisionWatcher({ onConfirmed: armChartSaveCoalescing, }); @@ -8716,6 +8839,7 @@ import { // ── 切换币种 / 首次进入时触发杠杆重置 ── function clearSymbolOwnedRuntimeState(symbol) { stopDepthProfileSession(); + disposeBollingerIntervalSession(); clearBearishBollingerAlertContext(); depthProfileData = null; depthProfileFailedSymbol = null; @@ -8875,6 +8999,7 @@ import { window.__TM_CLOSE_LONG_DEBUG__ = { cfg: CFG, + get bollingerAlertState() { return getBollingerAlertDiagnostics(); }, get continuousChartSaveStats() { return continuousChartSaveController?.getStats() || null; }, diff --git a/test/dom/binance-orderbook-trade/depth-profile.test.js b/test/dom/binance-orderbook-trade/depth-profile.test.js index 11fac99..1bca401 100644 --- a/test/dom/binance-orderbook-trade/depth-profile.test.js +++ b/test/dom/binance-orderbook-trade/depth-profile.test.js @@ -57,6 +57,7 @@ function installTradingViewApi(frame, { }; frame.contentWindow.tradingViewApi = { activeChart: () => ({ + hasModel: () => true, getAllPanesHeight: () => [height, 80, 120], getPanes: () => [{ getMainSourcePriceScale: () => scale }], }), @@ -128,6 +129,92 @@ test('maps prices correctly on logarithmic and inverted TradingView scales', () assert.ok(Math.abs(invertedGeometry.priceToCoordinate(100) - 50) < 0.02); }); +test('waits for the native chart model before reading panes and recovers when ready', () => { + const frame = createChartDom().window.document.querySelector('iframe'); + installTradingViewApi(frame); + const chart = frame.contentWindow.tradingViewApi.activeChart(); + frame.contentWindow.tradingViewApi.activeChart = () => chart; + const readHeights = chart.getAllPanesHeight; + const readPanes = chart.getPanes; + let ready = false; + let paneReads = 0; + chart.hasModel = () => ready; + chart.getAllPanesHeight = () => { + paneReads += 1; + assert.equal(ready, true, 'pane height requires a ready model'); + return readHeights(); + }; + chart.getPanes = () => { + paneReads += 1; + assert.equal(ready, true, 'pane access requires a ready model'); + return readPanes(); + }; + assert.equal(getTradingViewDepthProfileGeometry(frame), null); + assert.equal(paneReads, 0); + ready = true; + const geometry = getTradingViewDepthProfileGeometry(frame); + assert.equal(paneReads, 2); + assert.ok(Math.abs(geometry.priceToCoordinate(100) - 100) < 0.02); + delete chart.hasModel; + assert.equal(getTradingViewDepthProfileGeometry(frame), null); + assert.equal(paneReads, 2); +}); + +test('reuses native scale samples within one geometry without changing binary-search coordinates', () => { + for (const variant of [ + { mode: 0, inverted: false, convert: (y) => 110 - y / 10 }, + { mode: 1, inverted: false, convert: (y) => 1000 * (0.1 ** (y / 200)) }, + { mode: 0, inverted: true, convert: (y) => 90 + y / 10 }, + ]) { + const frame = createChartDom().window.document.querySelector('iframe'); + const reads = new Map(); + installTradingViewApi(frame, { + ...variant, + coordinateToPrice: (y) => { + reads.set(y, (reads.get(y) || 0) + 1); + return variant.convert(y); + }, + }); + const geometry = getTradingViewDepthProfileGeometry(frame); + const prices = Array.from({ length: 1000 }, (_, i) => variant.convert(20 + i * 0.03)); + for (const price of prices) { + let low = 0; + let high = 200; + for (let step = 0; step < 13; step += 1) { + const middle = (low + high) / 2; + const middlePrice = variant.convert(middle); + if (variant.inverted ? middlePrice < price : middlePrice > price) low = middle; + else high = middle; + } + assert.equal(geometry.priceToCoordinate(price), (low + high) / 2); + } + assert.equal(Math.max(...reads.values()), 1); + assert.ok(reads.size < prices.length * 13 / 4); + } +}); + +test('a new geometry samples the changed native scale instead of reusing the previous frame', () => { + const frame = createChartDom().window.document.querySelector('iframe'); + const scale = installTradingViewApi(frame); + const before = getTradingViewDepthProfileGeometry(frame); + const oldY = before.priceToCoordinate(100); + let reads = 0; + scale.coordinateToPrice = (y) => { reads += 1; return 120 - y / 5; }; + const after = getTradingViewDepthProfileGeometry(frame); + assert.ok(Math.abs(after.priceToCoordinate(110) - 50) < 0.02); + assert.ok(reads > 5); + assert.ok(Math.abs(oldY - 100) < 0.02); +}); + +test('invalid native samples inside the binary search still fail explicitly', () => { + const frame = createChartDom().window.document.querySelector('iframe'); + installTradingViewApi(frame, { + coordinateToPrice: (y) => y === 25 ? NaN : 110 - y / 10, + }); + const geometry = getTradingViewDepthProfileGeometry(frame); + assert.throws(() => geometry.priceToCoordinate(108), /TradingView price coordinate is invalid/); +}); + test('fails closed when the TradingView price-scale adapter is unavailable', () => { const dom = createChartDom(); const frame = dom.window.document.querySelector('iframe'); diff --git a/test/dom/binance-orderbook-trade/tradingview-bearish-alerts.test.js b/test/dom/binance-orderbook-trade/tradingview-bearish-alerts.test.js index e1b01dd..596134e 100644 --- a/test/dom/binance-orderbook-trade/tradingview-bearish-alerts.test.js +++ b/test/dom/binance-orderbook-trade/tradingview-bearish-alerts.test.js @@ -1,5 +1,8 @@ import assert from 'node:assert/strict'; import test from 'node:test'; +import { readFile } from 'node:fs/promises'; +import * as alertApi from '../../../src/binance-orderbook-trade/dom/tradingview-bearish-alerts.js'; +import { getTradingViewMarkerSaveController } from '../../../src/binance-orderbook-trade/core/chart-marker-save-controller.js'; import { loadFixtureDom } from '../../helpers/dom.js'; import { @@ -24,6 +27,138 @@ import { TradingViewBarSnapshotInconsistentError, } from '../../../src/binance-orderbook-trade/core/bearish-bollinger-pattern.js'; +const monitorSource = await readFile(new URL('../../../src/binance-orderbook-trade/index.user.js', import.meta.url), 'utf8'); + +test('native marker creation and clear save bursts preserve foreign drawings without arming stable audits', async () => { + const fixture = createChartDom(); + fixture.addForeignShape('user-channel'); + const snapshots = []; + const scheduleSave = () => setTimeout(() => fixture.tradingViewApi.saveChart((value) => snapshots.push(value)), 100); + const create = fixture.chart.createShape; + fixture.chart.createShape = async (...args) => { + const id = await create(...args); + const shape = fixture.chart.getShapeById(id); + const update = shape.setProperties.bind(shape); + shape.setProperties = (...properties) => { update(...properties); scheduleSave(); }; + scheduleSave(); + return id; + }; + const remove = fixture.chart.removeEntity; + fixture.chart.removeEntity = (id) => { remove(id); scheduleSave(); }; + const target = findBearishBollingerChartTarget(fixture.dom.window.document, 'BTRUSDT'); + const layer = createBollingerMarkerLayer(target); + const controller = getTradingViewMarkerSaveController(fixture.tradingViewApi); + const signals = Array.from({ length: 5 }, (_, i) => ({ + id: `signal-${i}`, direction: 'bearish', type: 'warning', time: 60 * (i + 1), markerPrice: 13, + })); + assert.equal(await layer.render(signals, { isCurrent: () => true }), true); + await controller.runAfterIdle(() => {}); + assert.equal(layer.saveStats.saveRequests, 10); + assert.equal(layer.saveStats.serializations, 1); + assert.equal(snapshots.length, 10); + assert.deepEqual(snapshots[0], { drawings: [{ id: 'user-channel' }] }); + assert.equal(await layer.render(signals, { isCurrent: () => true }), true); + assert.equal(layer.saveStats.busy, false); + assert.equal(layer.saveStats.serializations, 1); + assert.equal(layer.clear(), true); + assert.equal(layer.saveStats.busy, true); + await controller.runAfterIdle(() => {}); + assert.equal(layer.saveStats.saveRequests, 15); + assert.equal(layer.saveStats.serializations, 2); + assert.equal(snapshots.length, 15); + assert.deepEqual([...fixture.shapes.keys()], ['user-channel']); + assert.deepEqual(snapshots.at(-1), { drawings: [{ id: 'user-channel' }] }); +}); + +test('an outer save drain waits for native creation, leaves its late result hidden, and preserves cleanup ownership', async () => { + const fixture = createChartDom({ deferredCreate: true }); + fixture.addForeignShape('user-channel'); + const target = findBearishBollingerChartTarget(fixture.dom.window.document, 'BTRUSDT'); + const layer = createBollingerMarkerLayer(target); + const rendering = layer.render([ + { id: 'warning', direction: 'bearish', type: 'warning', time: 60, markerPrice: 13 }, + ], { isCurrent: () => true }); + const controller = getTradingViewMarkerSaveController(fixture.tradingViewApi); + let starts = 0; + const drain = controller.runAfterIdle(() => { starts += 1; }); + assert.equal(starts, 0); + assert.equal(layer.saveStats.mutations, 1); + fixture.releaseCreate(); + assert.equal(await rendering, false); + assert.equal(fixture.shapes.get('shape-1').properties.overrides.visible, false); + assert.equal(fixture.propertyUpdates.length, 0); + await drain; + assert.equal(starts, 1); + assert.equal(layer.clear(), true); + assert.deepEqual([...fixture.shapes.keys()], ['user-channel']); +}); + +/** Execute the production monitor functions, without the unrelated trading/bootstrap side effects. */ +function createMonitorHarness(fixture, dependencyOverrides = {}) { + const start = monitorSource.indexOf(' function clearBearishBollingerAlertContext()'); + const end = monitorSource.indexOf(' function parseJsonSafe(', start); + assert.ok(start > 0 && end > start); + let busy = false; + let hidden = false; + Object.defineProperty(fixture.dom.window.document, 'hidden', { get: () => hidden }); + let symbol = 'BTRUSDT'; + const errors = []; + let detectorCalls = 0; + const dependencies = { + ...alertApi, + document: fixture.dom.window.document, + getCurrentSymbol: () => symbol, + isFuturesTradingPage: () => true, + isTradingViewDrawingMutationBusy: () => busy, + applyBollingerAlertTaskFailure, + detectBollingerSignals: (bars) => { + detectorCalls += 1; + return [{ id: `${bars[0].time}:warning`, direction: 'bearish', type: 'warning', time: bars[0].time, markerPrice: 13 }]; + }, + err: (...args) => errors.push(args), + warn: () => {}, + setInterval: () => 1, + clearInterval: () => {}, + BEARISH_BOLLINGER_ALERT_POLL_MS: 1000, + ...dependencyOverrides, + }; + const factory = new Function(...Object.keys(dependencies), ` + let bearishBollingerAlertTimer = null; + let bearishBollingerAlertTask = null; + let bearishBollingerAlertContext = null; + let bollingerIntervalSession = null; + const retiredBollingerLayers = new Set(); + const ladderTask = null, continuousLadderTask = null, singleOrderTask = null; + const cancelCurrentSymbolOpenOrdersTask = null, chartOrdersRecoveryTask = null; + const continuousChartSaveController = null; + ${monitorSource.slice(start, end)} + return { + tick: synchronizeBearishBollingerAlerts, + stop: stopBearishBollingerAlertMonitor, + get task() { return bearishBollingerAlertTask; }, + get context() { return bearishBollingerAlertContext; }, + get session() { return bollingerIntervalSession; }, + get retiredCount() { return retiredBollingerLayers.size; }, + get diagnostics() { return getBollingerAlertDiagnostics(); }, + }; + `); + const monitor = factory(...Object.values(dependencies)); + return { + monitor, errors, + get detectorCalls() { return detectorCalls; }, + setBusy(value) { busy = value; }, + setHidden(value) { hidden = value; }, + setSymbol(value) { symbol = value; }, + async tick() { + // A real timer tick runs after the previous task's catch/finally microtasks. + await Promise.resolve(); + await monitor.tick(); + if (monitor.task) await monitor.task; + await Promise.resolve(); + }, + }; +} + function createChartDom({ resolution = '1', symbol = 'BTRUSDT@PRICETYPE=LAST', @@ -33,22 +168,56 @@ function createChartDom({ const dom = loadFixtureDom('
'); const shapes = new Map(); const removed = []; + const createdOptions = []; + const propertyUpdates = []; let nextId = 1; let releaseCreate = null; let currentResolution = resolution; let currentSymbol = symbol; + let modelReady = true; + function subscription() { + const listeners = new Map(); + return { + subscribe(owner, callback) { listeners.set(callback, owner); }, + unsubscribe(owner, callback) { assert.equal(listeners.get(callback), owner); listeners.delete(callback); }, + unsubscribeAll(owner) { + for (const [callback, registeredOwner] of listeners) { + if (registeredOwner === owner) listeners.delete(callback); + } + }, + fire(...args) { for (const callback of listeners.keys()) callback(...args); }, + get size() { return listeners.size; }, + }; + } + const intervalChanged = subscription(); + const dataLoaded = subscription(); const chart = { - resolution: () => currentResolution, + resolution: () => { assert.equal(modelReady, true, 'resolution requires a chart model'); return currentResolution; }, symbol: () => currentSymbol, - dataReady: () => true, + hasModel: () => modelReady, + dataReady: () => modelReady, + onIntervalChanged: () => intervalChanged, + onDataLoaded: () => dataLoaded, exportData: async () => ({ schema: [], data: [] }), async createShape(point, properties) { + createdOptions.push(structuredClone(properties)); if (deferredCreate) await new Promise((resolve) => { releaseCreate = resolve; }); const id = `shape-${nextId++}`; + const currentVisibility = alertApi.bollingerIntervalVisibility(currentResolution); + const nativeVisibility = { ...properties.overrides.intervalsVisibilities }; + // Native creation enables the interval active when the async loader resolves. + for (const [key, value] of Object.entries(currentVisibility)) { + if (value !== false) nativeVisibility[key] = value; + } shapes.set(id, { point: { ...point, time: point.time + shiftSeconds }, - properties, + properties: { ...properties, overrides: { ...properties.overrides, intervalsVisibilities: nativeVisibility } }, getPoints() { return [this.point]; }, + getProperties() { return { ...this.properties.overrides, icon: this.properties.icon }; }, + setProperties(overrides, saveDefaults) { + propertyUpdates.push({ id, overrides: structuredClone(overrides), saveDefaults }); + Object.assign(this.properties.overrides, overrides); + }, }); return id; }, @@ -66,16 +235,25 @@ function createChartDom({ let activeChart = chart; const tradingViewApi = { activeChart: () => activeChart, + saveChart: (callback) => callback({ + drawings: [...shapes].filter(([, record]) => !record.properties.disableSave).map(([id]) => ({ id })), + }), }; dom.window.document.querySelector('iframe').contentWindow.tradingViewApi = tradingViewApi; return { dom, chart, + tradingViewApi, + intervalChanged, + dataLoaded, shapes, removed, + createdOptions, + propertyUpdates, + setModelReady: (value) => { modelReady = value; }, releaseCreate: () => releaseCreate(), setActiveChart: (value) => { activeChart = value; }, - setResolution: (value) => { currentResolution = value; }, + setResolution: (value) => { currentResolution = value; intervalChanged.fire(value); }, setSymbol: (value) => { currentSymbol = value; }, evictShape: (id) => { shapes.delete(id); }, addForeignShape(id = 'foreign-shape') { @@ -114,6 +292,405 @@ test('maps TradingView time resolutions to exact bar durations', () => { assert.throws(() => tradingViewResolutionToSeconds('1M'), /unsupported/); }); +test('native null-owner cleanup preserves alert subscriptions and their readiness transitions', () => { + const fixture = createChartDom(); + let ready = false; + fixture.chart.dataReady = () => ready; + const session = alertApi.createBollingerIntervalSession(fixture.chart); + fixture.dataLoaded.subscribe(null, () => {}); + fixture.intervalChanged.subscribe(null, () => {}); + fixture.dataLoaded.unsubscribeAll(null); + fixture.intervalChanged.unsubscribeAll(null); + assert.equal(fixture.dataLoaded.size, 1); + assert.equal(fixture.intervalChanged.size, 1); + ready = true; + assert.equal(session.isCurrent(0), false); + fixture.dataLoaded.fire(); + assert.equal(session.isCurrent(0), true); + fixture.intervalChanged.fire('5'); + assert.equal(session.revision, 1); + assert.equal(session.isCurrent(1), false); + fixture.dataLoaded.fire(); + assert.equal(session.isCurrent(1), true); + session.dispose(); + assert.equal(fixture.dataLoaded.size, 0); + assert.equal(fixture.intervalChanged.size, 0); + assert.equal(session.isCurrent(1), false); +}); + +test('rejects second bars under a minute target and accepts the replacement minute history', () => { + const row = (time) => ({ 0: time, 1: 10, 2: 12, 3: 9, 4: 11 }); + assert.throws(() => parseClosedTradingViewBars(exportResult( + Array.from({ length: 120 }, (_, index) => row(3600 + index)), + ), { resolutionSeconds: 60, observedAtSeconds: 4000 }), + isTradingViewBarSnapshotInconsistentError); + const bars = parseClosedTradingViewBars(exportResult([row(3600), row(3660), row(3780)]), { + resolutionSeconds: 60, observedAtSeconds: 3840, + }); + assert.deepEqual(bars.map(bar => bar.time), [3600, 3660, 3780]); +}); + +test('validates the entire export including off-grid bars that are not closed yet', () => { + assert.throws(() => parseClosedTradingViewBars(exportResult([ + { 0: 3600, 1: 10, 2: 12, 3: 9, 4: 11 }, + { 0: 3661, 1: 10, 2: 12, 3: 9, 4: 11 }, + ]), { resolutionSeconds: 60, observedAtSeconds: 3662 }), + isTradingViewBarSnapshotInconsistentError); +}); + +test('accepts multi-day and Monday weekly bars without Unix-epoch phase assumptions', () => { + for (const [resolutionSeconds, times] of [ + [3 * 86400, [86400, 4 * 86400, 10 * 86400]], + [7 * 86400, [4 * 86400, 11 * 86400, 25 * 86400]], + ]) { + const bars = parseClosedTradingViewBars(exportResult(times.map(time => ( + { 0: time, 1: 10, 2: 12, 3: 9, 4: 11 } + ))), { resolutionSeconds, observedAtSeconds: 40 * 86400 }); + assert.deepEqual(bars.map(bar => bar.time), times); + } +}); + +test('interval sessions invalidate A-B-A exports and wait for data completion despite nonempty old data', async () => { + const { chart, setResolution, dataLoaded, intervalChanged } = createChartDom({ resolution: '1S' }); + const session = alertApi.createBollingerIntervalSession(chart); + let releaseExport; + chart.exportData = () => new Promise(resolve => { releaseExport = resolve; }); + const target = { chart, resolution: '1S', resolutionSeconds: 1, routeSymbol: 'BTRUSDT' }; + const pending = alertApi.exportClosedTradingViewBars(target, session, 4000000); + const revision = session.revision; + setResolution('1'); + assert.equal(chart.dataReady(), true); + assert.equal(session.isCurrent(session.revision), false); + setResolution('1S'); + dataLoaded.fire(); + assert.equal(session.isCurrent(revision), false); + assert.equal(session.isCurrent(session.revision), true); + releaseExport(exportResult([{ 0: 3600, 1: 10, 2: 12, 3: 9, 4: 11 }])); + assert.equal(await pending, null); + session.dispose(); + assert.equal(session.isCurrent(session.revision), false); + assert.equal(intervalChanged.size, 0); + assert.equal(dataLoaded.size, 0); +}); + +test('reconciles moved points, changed signal prices and altered owned colors without touching foreign drawings', async () => { + const { dom, shapes, removed, addForeignShape } = createChartDom(); + const layer = createBollingerMarkerLayer(findBearishBollingerChartTarget(dom.window.document, 'BTRUSDT')); + const signal = { id: 'drift', direction: 'bearish', type: 'warning', time: 120, markerPrice: 10 }; + addForeignShape(); + await layer.render([signal], { isCurrent: () => true }); + shapes.get('shape-1').point.time = 60; + await layer.render([signal], { isCurrent: () => true }); + assert.deepEqual(shapes.get('shape-2').point, { time: 120, price: 10 }); + shapes.get('shape-2').point.price = 12; + await layer.render([signal], { isCurrent: () => true }); + assert.deepEqual(shapes.get('shape-3').point, { time: 120, price: 10 }); + await layer.render([{ ...signal, markerPrice: 11 }], { isCurrent: () => true }); + assert.equal(shapes.get('shape-4').point.price, 11); + shapes.get('shape-4').properties.overrides.color = '#000000'; + await layer.render([{ ...signal, markerPrice: 11 }], { isCurrent: () => true }); + assert.equal(shapes.get('shape-5').properties.overrides.color, '#F6465D'); + shapes.get('shape-5').properties.shape = 'arrow_down'; + await layer.render([{ ...signal, markerPrice: 11 }], { isCurrent: () => true }); + assert.equal(shapes.get('shape-6').properties.shape, 'icon'); + assert.deepEqual(removed, ['shape-1', 'shape-2', 'shape-3', 'shape-4', 'shape-5']); + assert.equal(shapes.has('foreign-shape'), true); +}); + +test('native interval visibility hides second markers on minutes even while cleanup is busy', async () => { + const { dom, shapes, removed, setResolution } = createChartDom({ resolution: '1S' }); + let busy = false; + const layer = createBollingerMarkerLayer(findBearishBollingerChartTarget(dom.window.document, 'BTRUSDT'), { + canMutate: () => !busy, + }); + await layer.render([{ id: 'second', direction: 'bearish', type: 'warning', time: 3601, markerPrice: 10 }], { + isCurrent: () => true, + }); + const visibility = shapes.get('shape-1').properties.overrides.intervalsVisibilities; + assert.equal(visibility.seconds, true); + assert.equal(visibility.secondsFrom, 1); + assert.equal(visibility.secondsTo, 1); + assert.equal(visibility.minutes, false); + busy = true; + setResolution('1'); + assert.equal(layer.clear(), false); + assert.deepEqual(removed, []); + busy = false; + assert.equal(layer.clear(), true); + assert.equal(shapes.size, 0); +}); + +test('retains late async marker ownership until a busy chart permits cleanup', async () => { + const { dom, shapes, removed, releaseCreate, setResolution, createdOptions, propertyUpdates } = createChartDom({ resolution: '1S', deferredCreate: true }); + let busy = false; + let current = true; + const layer = createBollingerMarkerLayer(findBearishBollingerChartTarget(dom.window.document, 'BTRUSDT'), { + canMutate: () => !busy, + }); + const pending = layer.render([{ id: 'late', direction: 'bearish', type: 'warning', time: 3601, markerPrice: 10 }], { + isCurrent: () => current, + }); + await Promise.resolve(); + assert.equal(layer.clear(), false); + current = false; + busy = true; + setResolution('1'); + releaseCreate(); + assert.equal(await pending, false); + assert.equal(shapes.size, 1); + assert.equal(createdOptions[0].overrides.visible, false); + assert.equal(shapes.get('shape-1').getProperties().intervalsVisibilities.minutes, true); + assert.equal(shapes.get('shape-1').getProperties().visible, false); + assert.deepEqual(propertyUpdates, []); + assert.deepEqual(removed, []); + busy = false; + assert.equal(layer.clear(), true); + assert.equal(shapes.size, 0); + assert.deepEqual(removed, ['shape-1']); +}); + +test('publishes a current hidden creation only after restoring its exact interval mask', async () => { + const fixture = createChartDom({ resolution: '1S', deferredCreate: true }); + const layer = createBollingerMarkerLayer(findBearishBollingerChartTarget(fixture.dom.window.document, 'BTRUSDT')); + const pending = layer.render([{ id: 'publish', direction: 'bullish', type: 'confirmed', time: 3601, markerPrice: 10 }], { + isCurrent: () => true, + }); + fixture.setResolution('1'); + fixture.releaseCreate(); + assert.equal(await pending, true); + assert.equal(fixture.createdOptions[0].overrides.visible, false); + assert.equal(fixture.propertyUpdates.length, 1); + assert.equal(fixture.propertyUpdates[0].saveDefaults, false); + const properties = fixture.shapes.get('shape-1').getProperties(); + assert.equal(properties.visible, true); + assert.equal(properties.intervalsVisibilities.minutes, false); + assert.equal(properties.intervalsVisibilities.seconds, true); + assert.equal(properties.arrowColor, '#0ECB81'); +}); + +test('production monitor retires old interval before readiness and resumes with minute data', async () => { + const fixture = createChartDom({ resolution: '1S' }); + const harness = createMonitorHarness(fixture); + fixture.chart.exportData = async () => exportResult([ + { 0: 3601, 1: 10, 2: 12, 3: 9, 4: 11 }, + { 0: 3602, 1: 10, 2: 12, 3: 9, 4: 11 }, + ]); + await harness.tick(); + const session = harness.monitor.session.session; + assert.equal(fixture.shapes.size, 1); + harness.setBusy(true); + fixture.setResolution('1'); + await harness.tick(); + assert.equal(harness.monitor.context, null); + assert.equal(harness.monitor.retiredCount, 1); + assert.equal(fixture.removed.length, 0); + assert.equal(harness.detectorCalls, 1); + harness.setBusy(false); + await harness.tick(); + assert.equal(fixture.shapes.size, 0); + assert.equal(harness.monitor.context, null); + assert.equal(session.isCurrent(session.revision), false); + fixture.chart.exportData = async () => exportResult([ + { 0: 3600, 1: 10, 2: 12, 3: 9, 4: 11 }, + { 0: 3660, 1: 10, 2: 12, 3: 9, 4: 11 }, + ]); + fixture.dataLoaded.fire(); + await harness.tick(); + assert.equal(harness.monitor.context.resolution, '1'); + assert.equal(fixture.shapes.size, 1); + assert.deepEqual(fixture.shapes.get('shape-2').point, { time: 3600, price: 13 }); + assert.equal(fixture.shapes.get('shape-2').properties.overrides.intervalsVisibilities.seconds, false); + assert.equal(harness.detectorCalls, 2); + assert.deepEqual(harness.errors, []); + harness.monitor.stop(); +}); + +test('production monitor stop disposes subscriptions and invalidates busy pending export', async () => { + const fixture = createChartDom({ resolution: '1S' }); + const harness = createMonitorHarness(fixture); + let releaseExport; + fixture.chart.exportData = () => new Promise(resolve => { releaseExport = resolve; }); + await harness.monitor.tick(); + const oldTask = harness.monitor.task; + const oldSession = harness.monitor.session.session; + harness.setBusy(true); + harness.setHidden(true); + harness.monitor.stop(); + assert.equal(harness.monitor.context, null); + assert.equal(harness.monitor.session, null); + assert.equal(oldSession.isCurrent(oldSession.revision), false); + assert.equal(fixture.intervalChanged.size, 0); + assert.equal(fixture.dataLoaded.size, 0); + releaseExport(exportResult([{ 0: 3601, 1: 10, 2: 12, 3: 9, 4: 11 }])); + await oldTask; + assert.equal(fixture.shapes.size, 0); + assert.equal(harness.detectorCalls, 0); + harness.setBusy(false); + harness.setHidden(false); + fixture.chart.exportData = async () => exportResult([{ 0: 3601, 1: 10, 2: 12, 3: 9, 4: 11 }]); + await harness.tick(); + assert.equal(harness.monitor.retiredCount, 0); + assert.equal(fixture.intervalChanged.size, 1); + assert.equal(fixture.shapes.size, 1); + harness.monitor.stop(); + assert.deepEqual(harness.errors, []); +}); + +test('production monitor retains a retired layer until late creation and busy cleanup finish', async () => { + const fixture = createChartDom({ resolution: '1S', deferredCreate: true }); + const harness = createMonitorHarness(fixture); + fixture.chart.exportData = async () => exportResult([{ 0: 3601, 1: 10, 2: 12, 3: 9, 4: 11 }]); + await harness.monitor.tick(); + await Promise.resolve(); + const oldTask = harness.monitor.task; + harness.setBusy(true); + fixture.setResolution('1'); + await harness.monitor.tick(); + assert.equal(harness.monitor.context, null); + fixture.releaseCreate(); + await oldTask; + assert.equal(harness.monitor.retiredCount, 1); + assert.equal(fixture.shapes.size, 1); + assert.deepEqual(fixture.removed, []); + harness.setBusy(false); + await harness.tick(); + assert.equal(harness.monitor.retiredCount, 0); + assert.equal(fixture.shapes.size, 0); + assert.deepEqual(fixture.removed, ['shape-1']); + harness.monitor.stop(); +}); + +test('production monitor detects a round-trip interval switch between polls', async () => { + const fixture = createChartDom({ resolution: '1S' }); + const harness = createMonitorHarness(fixture); + fixture.chart.exportData = async () => exportResult([{ 0: 3601, 1: 10, 2: 12, 3: 9, 4: 11 }]); + await harness.tick(); + const originalContext = harness.monitor.context; + fixture.setResolution('1'); + fixture.setResolution('1S'); + await harness.tick(); + assert.equal(harness.monitor.context, null); + assert.equal(fixture.shapes.size, 0); + assert.equal(harness.detectorCalls, 1); + fixture.dataLoaded.fire(); + await harness.tick(); + assert.notEqual(harness.monitor.context, originalContext); + assert.equal(harness.monitor.context.intervalRevision, 2); + assert.equal(fixture.shapes.size, 1); + assert.equal(harness.detectorCalls, 2); + harness.monitor.stop(); +}); + +test('production monitor disposes old chart subscriptions even when removal is busy', async () => { + const fixture = createChartDom({ resolution: '1S' }); + const harness = createMonitorHarness(fixture); + fixture.chart.exportData = async () => exportResult([{ 0: 3601, 1: 10, 2: 12, 3: 9, 4: 11 }]); + await harness.tick(); + const replacement = createChartDom({ resolution: '1S' }); + replacement.chart.exportData = fixture.chart.exportData; + harness.setBusy(true); + fixture.setActiveChart(replacement.chart); + await harness.tick(); + assert.equal(fixture.intervalChanged.size, 0); + assert.equal(fixture.dataLoaded.size, 0); + assert.equal(harness.monitor.context, null); + assert.equal(harness.monitor.retiredCount, 1); + assert.deepEqual(fixture.removed, []); + harness.setBusy(false); + await harness.tick(); + assert.equal(fixture.shapes.size, 0); + assert.equal(replacement.shapes.size, 1); + assert.equal(harness.monitor.context.target.chart, replacement.chart); + harness.monitor.stop(); + assert.equal(replacement.intervalChanged.size, 0); +}); + +test('uses native visibility buckets for Binance seconds, minutes, hours, days and weeks', () => { + for (const [resolution, unit, count] of [ + ['1S', 'seconds', 1], ['90S', 'minutes', 1], ['15', 'minutes', 15], ['60', 'hours', 1], + ['4H', 'hours', 4], ['1D', 'days', 1], ['1W', 'weeks', 1], + ]) { + const visibility = alertApi.bollingerIntervalVisibility(resolution); + assert.equal(visibility[unit], true); + assert.equal(visibility[`${unit}From`], count); + assert.equal(visibility[`${unit}To`], count); + assert.deepEqual(Object.entries(visibility).filter(([, value]) => value === true).map(([key]) => key), [unit]); + } +}); + +test('rejects weekly bars off Monday while preserving legitimate multiweek gaps', () => { + const row = time => ({ 0: time, 1: 10, 2: 12, 3: 9, 4: 11 }); + assert.throws(() => parseClosedTradingViewBars(exportResult([row(0), row(604800)]), { + resolution: '1W', resolutionSeconds: 604800, observedAtSeconds: 2000000, + }), isTradingViewBarSnapshotInconsistentError); + assert.deepEqual(parseClosedTradingViewBars(exportResult([row(345600), row(1555200)]), { + resolution: '1W', resolutionSeconds: 604800, observedAtSeconds: 3000000, + }).map(bar => bar.time), [345600, 1555200]); +}); + +test('waits for the model before reading a chart target and invalidates a torn-down model', () => { + const fixture = createChartDom(); + fixture.setModelReady(false); + assert.equal(findBearishBollingerChartTarget(fixture.dom.window.document, 'BTRUSDT'), null); + fixture.setModelReady(true); + const target = findBearishBollingerChartTarget(fixture.dom.window.document, 'BTRUSDT'); + assert.equal(target.resolution, '1'); + fixture.setModelReady(false); + assert.equal(isBearishBollingerChartTargetCurrent(fixture.dom.window.document, target), false); +}); + +test('production monitor waits through first-refresh model creation without errors and then renders', async () => { + const fixture = createChartDom(); + const harness = createMonitorHarness(fixture); + fixture.chart.exportData = async () => exportResult([{ 0: 60, 1: 10, 2: 12, 3: 9, 4: 11 }]); + fixture.setModelReady(false); + await harness.tick(); + assert.deepEqual(harness.errors, []); + assert.equal(harness.monitor.context, null); + assert.equal(fixture.intervalChanged.size, 0); + assert.equal(fixture.shapes.size, 0); + fixture.setModelReady(true); + await harness.tick(); + assert.equal(harness.monitor.context.resolution, '1'); + assert.equal(fixture.shapes.size, 1); + assert.deepEqual(harness.errors, []); + harness.monitor.stop(); +}); + +test('on-demand diagnostics distinguish active, awaiting-data and torn-down states without drawing calls', async () => { + const fixture = createChartDom(); + const harness = createMonitorHarness(fixture); + fixture.chart.exportData = async () => exportResult([{ 0: 60, 1: 10, 2: 12, 3: 9, 4: 11 }]); + assert.equal(harness.monitor.diagnostics.contextPresent, false); + assert.equal(harness.monitor.diagnostics.nativeModelReady, null); + await harness.tick(); + const originalGetAllShapes = fixture.chart.getAllShapes; + fixture.chart.getAllShapes = () => { throw new Error('Diagnostics must not audit drawings'); }; + fixture.chart.exportData = () => { throw new Error('Diagnostics must not export data'); }; + assert.deepEqual(harness.monitor.diagnostics, { + timerRunning: false, taskPending: false, contextPresent: true, failed: false, + cleanupPending: false, cachedSignalCount: 1, layerSize: 1, retiredCount: 0, + markerSaveStats: { busy: true, mutations: 0, draining: 0, saveRequests: 0, + serializations: 0, callbackCount: 0, failureCount: 0, pendingCallbacks: 0 }, + sessionPresent: true, sessionRevision: 0, contextIntervalRevision: 0, + sessionMatchesContext: true, sessionCurrent: true, + nativeModelReady: true, nativeDataReady: true, mutationBlocked: false, + ownerFlags: { ladderTask: false, continuousLadderTask: false, singleOrderTask: false, + cancelCurrentSymbolOpenOrdersTask: false, chartOrdersRecoveryTask: false, + continuousChartSaveController: false }, + }); + fixture.setResolution('5'); + assert.equal(harness.monitor.diagnostics.sessionRevision, 1); + assert.equal(harness.monitor.diagnostics.sessionCurrent, false); + assert.equal(harness.monitor.diagnostics.nativeDataReady, true); + fixture.setModelReady(false); + assert.equal(harness.monitor.diagnostics.nativeModelReady, false); + assert.equal(harness.monitor.diagnostics.nativeDataReady, null); + assert.equal(harness.monitor.diagnostics.sessionCurrent, null); + fixture.chart.getAllShapes = originalGetAllShapes; + harness.monitor.stop(); +}); + test('requires the current symbol and complete bearish-alert chart API', () => { const { dom, @@ -669,9 +1246,10 @@ test('rejects an abnormal marker count before mutating the existing layer', asyn }); test('applies the marker limit to the combined bullish and bearish layer', async () => { - const { dom, shapes, removed } = createChartDom(); + const { dom, chart, shapes, removed, createdOptions, propertyUpdates } = createChartDom(); const target = findBearishBollingerChartTarget(dom.window.document, 'BTRUSDT'); - const layer = createBollingerMarkerLayer(target); + let yields = 0; + const layer = createBollingerMarkerLayer(target, { yieldToBrowser: async () => { yields += 1; } }); const signals = Array.from({ length: MAX_BOLLINGER_MARKERS }, (_, index) => ({ id: `combined-${index}`, direction: index % 2 === 0 ? 'bearish' : 'bullish', @@ -683,6 +1261,26 @@ test('applies the marker limit to the combined bullish and bearish layer', async assert.equal(await layer.render(signals, { isCurrent: () => true }), true); assert.equal(shapes.size, MAX_BOLLINGER_MARKERS); + const reads = { handles: 0, points: 0, properties: 0 }; + yields = 0; + const getShapeById = chart.getShapeById; + chart.getShapeById = (id) => { reads.handles += 1; return getShapeById(id); }; + for (const shape of shapes.values()) { + const getPoints = shape.getPoints.bind(shape); + const getProperties = shape.getProperties.bind(shape); + shape.getPoints = () => { reads.points += 1; return getPoints(); }; + shape.getProperties = () => { reads.properties += 1; return getProperties(); }; + } + assert.equal(await layer.render(signals, { isCurrent: () => true }), true); + assert.ok(yields >= Math.floor((MAX_BOLLINGER_MARKERS - 1) / 32)); + assert.deepEqual(reads, { + handles: MAX_BOLLINGER_MARKERS, + points: MAX_BOLLINGER_MARKERS, + properties: MAX_BOLLINGER_MARKERS, + }); + assert.equal(createdOptions.length, MAX_BOLLINGER_MARKERS); + assert.equal(propertyUpdates.length, MAX_BOLLINGER_MARKERS); + await assert.rejects( layer.render([ ...signals, @@ -700,6 +1298,101 @@ test('applies the marker limit to the combined bullish and bearish layer', async assert.deepEqual(removed, []); }); +function batchSignals() { + return Array.from({ length: 64 }, (_, index) => ({ + id: `batch-${index}`, direction: 'bearish', type: 'warning', + time: 120 + index * 60, markerPrice: 10, + })); +} + +test('a real render batch yields a browser task before finishing', async () => { + const fixture = createChartDom(); + const target = findBearishBollingerChartTarget(fixture.dom.window.document, 'BTRUSDT'); + const layer = createBollingerMarkerLayer(target); + const task = layer.render(batchSignals(), { isCurrent: () => true }); + await new Promise((resolve) => setTimeout(resolve, 0)); + assert.ok(fixture.shapes.size > 0 && fixture.shapes.size <= 32); + assert.equal(await task, true); + assert.equal(fixture.shapes.size, 64); +}); + +for (const reason of ['stale', 'busy', 'clear']) { + test(`a ${reason} transition during a render yield stops the old batch`, async () => { + const fixture = createChartDom(); + const target = findBearishBollingerChartTarget(fixture.dom.window.document, 'BTRUSDT'); + let current = true; + let busy = false; + let countAtYield = 0; + const layer = createBollingerMarkerLayer(target, { + canMutate: () => !busy, + yieldToBrowser: async () => { + countAtYield = fixture.createdOptions.length; + if (reason === 'stale') current = false; + if (reason === 'busy') busy = true; + if (reason === 'clear') assert.equal(layer.clear(), true); + }, + }); + const result = await reconcileBearishBollingerAlertWindow({ + bars: [{ time: 120, open: 10, high: 12, low: 9, close: 11 }], + cachedWindowKey: null, cachedSignals: null, + detectSignals: batchSignals, + renderSignals: (signals) => layer.render(signals, { isCurrent: () => current }), + }); + assert.ok(countAtYield > 0 && countAtYield <= 32); + assert.equal(fixture.createdOptions.length, countAtYield); + assert.equal(fixture.propertyUpdates.length, countAtYield); + assert.equal(result.rendered, false); + assert.equal(fixture.shapes.size, reason === 'clear' ? 0 : countAtYield); + }); +} + +test('a marker evicted during a yield is recreated from the refreshed shape list', async () => { + const fixture = createChartDom(); + const target = findBearishBollingerChartTarget(fixture.dom.window.document, 'BTRUSDT'); + let evict = false; + let evicted = null; + const layer = createBollingerMarkerLayer(target, { + yieldToBrowser: async () => { + if (!evict) return; + evict = false; + evicted = [...fixture.shapes.keys()].at(-1); + fixture.evictShape(evicted); + }, + }); + const signals = batchSignals(); + assert.equal(await layer.render(signals, { isCurrent: () => true }), true); + evict = true; + assert.equal(await layer.render(signals, { isCurrent: () => true }), true); + assert.equal(fixture.shapes.has(evicted), false); + assert.equal(fixture.shapes.size, 64); + assert.equal(fixture.createdOptions.length, 65); + assert.equal(fixture.shapes.get('shape-65').point.time, signals.at(-1).time); + assert.deepEqual(fixture.removed, []); +}); + +test('production monitor does not commit a batch interrupted by an interval switch', async () => { + const fixture = createChartDom(); + const harness = createMonitorHarness(fixture, { + detectBollingerSignals: batchSignals, + createBollingerMarkerLayer: (target, options) => createBollingerMarkerLayer(target, { + ...options, + yieldToBrowser: async () => { fixture.setResolution('5'); }, + }), + }); + fixture.chart.exportData = async () => exportResult([ + { 0: 3600, 1: 10, 2: 12, 3: 9, 4: 11 }, + { 0: 3660, 1: 10, 2: 12, 3: 9, 4: 11 }, + ]); + await harness.tick(); + assert.ok(fixture.shapes.size > 0 && fixture.shapes.size <= 32); + assert.equal(harness.monitor.context.lastProcessedClosedBarsWindowKey, null); + assert.equal(harness.monitor.context.lastProcessedClosedBarsContentSnapshot, null); + assert.equal(harness.monitor.context.lastProcessedSignals, null); + assert.deepEqual(harness.errors, []); + harness.monitor.stop(); + assert.equal(fixture.shapes.size, 0); +}); + test('preserves the full per-direction capacity when both directions are present', async () => { const { dom, shapes } = createChartDom(); const target = findBearishBollingerChartTarget(dom.window.document, 'BTRUSDT'); diff --git a/test/unit/binance-orderbook-trade/bearish-bollinger-pattern.test.js b/test/unit/binance-orderbook-trade/bearish-bollinger-pattern.test.js index c6d22b3..f0c2306 100644 --- a/test/unit/binance-orderbook-trade/bearish-bollinger-pattern.test.js +++ b/test/unit/binance-orderbook-trade/bearish-bollinger-pattern.test.js @@ -106,6 +106,27 @@ test('keeps bullish indicator values on the original price axis', () => { ); }); +test('preserves exact indicator arithmetic without allocating sliced close windows', () => { + for (const scale of [0.000001, 1, 100000000]) { + const bars = createOhlcBars(512).map((bar, index) => { + const close = scale * (3 + Math.sin(index / 7) + Math.cos(index / 31)); + return { ...bar, open: close, high: close + scale, low: close - scale, close }; + }); + const expected = bars.map((bar, index) => { + if (index < 59) return { ...bar, middle: null, upper: null, lower: null, ma60: null }; + const closes = bars.slice(index - 19, index + 1).map(item => item.close); + const maCloses = bars.slice(index - 59, index + 1).map(item => item.close); + const middle = closes.reduce((sum, close) => sum + close, 0) / 20; + const deviation = Math.sqrt(closes.reduce((sum, close) => sum + ((close - middle) ** 2), 0) / 20) * 2; + return { ...bar, middle, upper: middle + deviation, lower: middle - deviation, ma60: maCloses.reduce((sum, close) => sum + close, 0) / 60 }; + }); + let slices = 0; + bars.slice = (...args) => { slices += 1; return Array.prototype.slice.apply(bars, args); }; + assert.deepEqual(calculateBearishBollingerIndicatorBars(bars), expected); + assert.equal(slices, 0); + } +}); + test('implements bullish detection as the strict price-axis mirror of bearish detection', () => { const bearishPattern = createIndicatorPattern(); const bullishBars = mirrorIndicatorBars(bearishPattern.bars); diff --git a/test/unit/binance-orderbook-trade/chart-marker-save-controller.test.js b/test/unit/binance-orderbook-trade/chart-marker-save-controller.test.js new file mode 100644 index 0000000..1cef05c --- /dev/null +++ b/test/unit/binance-orderbook-trade/chart-marker-save-controller.test.js @@ -0,0 +1,219 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import * as subject from '../../../src/binance-orderbook-trade/core/chart-marker-save-controller.js'; + +function fixture() { + let now = 0; + let sequence = 0; + let serializations = 0; + const timers = new Map(); + const errors = []; + const calls = []; + const state = { drawings: [{ id: 'user-channel', color: 'blue' }] }; + const api = { + saveChart(callback, options) { + serializations += 1; + calls.push({ receiver: this, options }); + return callback?.(JSON.parse(JSON.stringify(state))); + }, + }; + const native = api.saveChart; + const controller = subject.installTradingViewMarkerSaveController(api, { + onError: (error) => errors.push(error), + setTimeoutFn(callback, delay) { + const id = ++sequence; + timers.set(id, { callback, at: now + delay }); + return id; + }, + clearTimeoutFn: (id) => timers.delete(id), + }); + function advance(ms) { + const end = now + ms; + while (true) { + const next = [...timers].filter(([, timer]) => timer.at <= end) + .sort((a, b) => a[1].at - b[1].at || a[0] - b[0])[0]; + if (!next) break; + const [id, timer] = next; + timers.delete(id); + now = timer.at; + timer.callback(); + } + now = end; + } + return { api, native, controller, advance, errors, state, calls, timers, + get serializations() { return serializations; } }; +} + +test('idle marker controller preserves synchronous callback, receiver, and return', () => { + const f = fixture(); + let seen; + const result = f.api.saveChart((state) => { seen = state; return 42; }); + assert.equal(result, 42); + assert.deepEqual(seen, f.state); + assert.equal(f.serializations, 1); + assert.equal(f.calls[0].receiver, f.api); + assert.equal(f.timers.size, 0); +}); + +test('marker burst serializes once and delivers a separate complete snapshot to every callback', () => { + const f = fixture(); + f.controller.beginMutation()(); + f.advance(100); + const snapshots = []; + for (let i = 0; i < 100; i += 1) { + assert.equal(f.api.saveChart((value) => { + assert.equal(value.drawings[0].color, 'blue'); + snapshots.push(value); + value.drawings[0].color = `callback-${i}`; + }), undefined); + } + assert.equal(f.serializations, 0); + f.advance(150); + assert.equal(f.serializations, 1); + assert.equal(snapshots.length, 100); + assert.equal(new Set(snapshots).size, 100); + assert.equal(snapshots[0].drawings[0].color, 'callback-0'); + assert.equal(snapshots[99].drawings[0].color, 'callback-99'); + assert.equal(f.state.drawings[0].color, 'blue'); + assert.equal(f.controller.getStats().pendingCallbacks, 0); + assert.equal(f.controller.getStats().busy, false); + assert.equal(f.timers.size, 0); +}); + +test('callback errors do not skip later callbacks or interrupt explicit save options', () => { + const f = fixture(); + f.controller.beginMutation()(); + let completed = 0; + f.api.saveChart(() => { throw new Error('first callback failed'); }); + f.api.saveChart(() => { completed += 1; }); + const options = { includeDrawings: false }; + assert.equal(f.api.saveChart(() => 'explicit-result', options), 'explicit-result'); + assert.equal(completed, 1); + assert.equal(f.serializations, 2); + assert.equal(f.calls[1].options, options); + f.advance(0); + assert.equal(f.errors.length, 1); + assert.equal(f.errors[0].errors[0].message, 'first callback failed'); +}); + +test('foreign receiver, options and non-callback arguments stay synchronous', () => { + const f = fixture(); + for (const options of [{}, { includeDrawings: false }]) { + f.controller.beginMutation()(); + assert.equal(f.api.saveChart(() => 7, options), 7); + assert.equal(f.calls.at(-1).options, options); + } + f.controller.beginMutation()(); + const receiver = {}; + assert.equal(f.api.saveChart.call(receiver, () => 8), 8); + assert.equal(f.calls.at(-1).receiver, receiver); + f.controller.beginMutation()(); + assert.equal(f.api.saveChart(), undefined); + assert.equal(f.serializations, 4); +}); + +test('base wrapper never captures or restores across an outer save owner', () => { + const f = fixture(); + const base = f.api.saveChart; + f.controller.beginMutation()(); + f.api.saveChart(() => 1); + function outer(...args) { return base.apply(this, args); } + f.api.saveChart = outer; + assert.equal(f.api.saveChart(() => 2), 2); + assert.equal(f.api.saveChart, outer); + assert.equal(f.serializations, 2); + assert.equal(f.controller.canMutate(), false); +}); + +test('maximum burst window bounds serialization under a continuous event stream', () => { + const f = fixture(); + for (let i = 0; i < 10; i += 1) { + f.controller.beginMutation()(); + f.api.saveChart(() => {}); + f.advance(100); + } + assert.equal(f.serializations, 1); + f.advance(50); + assert.equal(f.controller.getStats().busy, false); +}); + +test('an explicit save cannot end the mutation tail before the native delayed callback', async () => { + const f = fixture(); + f.controller.beginMutation()(); + f.advance(10); + assert.equal(f.api.saveChart(() => 3, {}), 3); + let starts = 0; + const drain = f.controller.runAfterIdle(() => { starts += 1; }); + f.advance(90); + assert.equal(f.api.saveChart(() => 4), 4); + await Promise.resolve(); + assert.equal(starts, 0); + f.advance(50); + await drain; + assert.equal(starts, 1); + assert.equal(f.serializations, 2); +}); + +test('drain blocks new mutations and waits for asynchronous creation and its delayed saves', async () => { + const f = fixture(); + const finish = f.controller.beginMutation(); + let ownerStarted = false; + const drain = f.controller.runAfterIdle(() => { ownerStarted = true; return 17; }); + assert.equal(f.controller.canMutate(), false); + f.advance(500); + await Promise.resolve(); + assert.equal(ownerStarted, false); + finish(); + f.advance(100); + f.api.saveChart(() => {}); + f.advance(149); + await Promise.resolve(); + assert.equal(ownerStarted, false); + f.advance(1); + assert.equal(await drain, 17); + assert.equal(ownerStarted, true); + assert.equal(f.serializations, 1); + assert.equal(f.controller.canMutate(), true); + assert.equal(f.timers.size, 0); +}); + +test('stalled native creation rejects bounded drain without starting the outer action', async () => { + const f = fixture(); + const finish = f.controller.beginMutation(); + let starts = 0; + const drain = f.controller.runAfterIdle(() => { starts += 1; }); + const rejected = assert.rejects(drain, { name: 'TradingViewMarkerSaveDrainTimeoutError' }); + f.advance(2000); + await rejected; + assert.equal(starts, 0); + finish(); + f.advance(150); + assert.equal(f.controller.canMutate(), true); + assert.equal(f.timers.size, 0); +}); + +test('one API installs one base wrapper and another API has independent state', () => { + const f = fixture(); + const base = f.api.saveChart; + assert.equal(subject.installTradingViewMarkerSaveController(f.api), f.controller); + assert.equal(f.api.saveChart, base); + const other = fixture(); + f.controller.beginMutation()(); + assert.equal(other.controller.getStats().busy, false); + f.advance(150); +}); + +test('stop aborts a pending drain immediately without starting an outer owner', async () => { + const f = fixture(); + f.controller.beginMutation()(); + const abort = new AbortController(); + const reason = new Error('Stopped'); + let starts = 0; + const drain = f.controller.runAfterIdle(() => { starts += 1; }, { signal: abort.signal }); + abort.abort(reason); + await assert.rejects(drain, (error) => error === reason); + assert.equal(starts, 0); + assert.equal(f.controller.getStats().draining, 0); + f.advance(150); + assert.equal(f.timers.size, 0); +}); diff --git a/test/unit/binance-orderbook-trade/chart-marker-save-entrypoints.test.js b/test/unit/binance-orderbook-trade/chart-marker-save-entrypoints.test.js new file mode 100644 index 0000000..d483dd6 --- /dev/null +++ b/test/unit/binance-orderbook-trade/chart-marker-save-entrypoints.test.js @@ -0,0 +1,77 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { readFile } from 'node:fs/promises'; +import { throwIfAborted } from '../../../src/binance-orderbook-trade/core/abort.js'; +import { + afterTradingViewMarkerSaves, + installTradingViewMarkerSaveController, +} from '../../../src/binance-orderbook-trade/core/chart-marker-save-controller.js'; + +const source = await readFile(new URL('../../../src/binance-orderbook-trade/index.user.js', import.meta.url), 'utf8'); + +/** Execute the production startup boundary without bootstrapping trading UI. */ +function startupFixture() { + const api = { saveChart: (callback) => callback({ drawings: [] }) }; + const controller = installTradingViewMarkerSaveController(api); + let currentApi = api; + let symbolCurrent = true; + let starts = 0; + const warnings = []; + const dependencies = { + document: {}, afterTradingViewMarkerSaves, throwIfAborted, + findBinanceTradingViewTarget: () => ({ tradingViewApi: currentApi }), + isCurrentObservedSymbol: () => symbolCurrent, + createLadderStoppedError: () => Object.assign(new Error('Stopped'), { name: 'LadderStoppedError' }), + isLadderStoppedError: (error) => error.name === 'LadderStoppedError', + createTradingViewContinuousSaveController: () => { starts += 1; return 'outer-owner'; }, + warn: (...args) => warnings.push(args), + CONTINUOUS_CHART_REMOVE_SAVE_QUIET_MS: 120, + CONTINUOUS_CHART_REMOVE_SAVE_MAX_WAIT_MS: 400, + CONTINUOUS_CHART_SUBMIT_EVENT_WAIT_MS: 250, + }; + const start = source.indexOf(' async function startContinuousChartSaveCoalescing('); + const end = source.indexOf(' function stopContinuousChartSaveCoalescing(', start); + assert.ok(start > 0 && end > start); + const run = new Function(...Object.keys(dependencies), `${source.slice(start, end)}; return startContinuousChartSaveCoalescing;`)(...Object.values(dependencies)); + return { controller, run, warnings, + replaceChart() { currentApi = {}; }, + changeSymbol() { symbolCurrent = false; }, + get starts() { return starts; } }; +} + +test('production continuous startup waits for marker creation before installing its owner', async () => { + const f = startupFixture(); + const finish = f.controller.beginMutation(); + const task = f.run(new AbortController().signal, 'BTRUSDT'); + assert.equal(f.starts, 0); + finish(); + assert.equal(await task, 'outer-owner'); + assert.equal(f.starts, 1); + assert.deepEqual(f.warnings, []); +}); + +for (const change of ['changeSymbol', 'replaceChart']) { + test(`production continuous startup rejects ${change} during marker drain`, async () => { + const f = startupFixture(); + const finish = f.controller.beginMutation(); + const task = f.run(new AbortController().signal, 'BTRUSDT'); + f[change](); + finish(); + await assert.rejects(task, { name: 'LadderStoppedError' }); + assert.equal(f.starts, 0); + assert.deepEqual(f.warnings, []); + }); +} + +test('production continuous startup propagates Stop without optional-optimization fallback', async () => { + const f = startupFixture(); + const finish = f.controller.beginMutation(); + const abort = new AbortController(); + const reason = Object.assign(new Error('Stopped'), { name: 'LadderStoppedError' }); + const task = f.run(abort.signal, 'BTRUSDT'); + abort.abort(reason); + await assert.rejects(task, (error) => error === reason); + assert.equal(f.starts, 0); + assert.deepEqual(f.warnings, []); + finish(); +}); diff --git a/test/unit/binance-orderbook-trade/source-regressions.test.js b/test/unit/binance-orderbook-trade/source-regressions.test.js index dcc032e..bb4b769 100644 --- a/test/unit/binance-orderbook-trade/source-regressions.test.js +++ b/test/unit/binance-orderbook-trade/source-regressions.test.js @@ -836,7 +836,8 @@ test('continuous close captures only owned order-line saves and restores the cha 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, /let chartSaveCoalescer = null/); + assert.match(continuousBody, /chartSaveCoalescer = await startContinuousChartSaveCoalescing\(abortController.signal, actionSymbol\)/); assert.match(continuousBody, /startLadder\(\s*actionType,\s*continuousProgress,\s*chartSaveCoalescer/); assert.match(continuousBody, /stopContinuousChartSaveCoalescing\(chartSaveCoalescer\)/); assert.match(stopCoalescingBody, /coalescer\.stop\(\)/);