diff --git a/docs/binance-orderbook-trade-development.md b/docs/binance-orderbook-trade-development.md index 965aff9..0366dbc 100644 --- a/docs/binance-orderbook-trade-development.md +++ b/docs/binance-orderbook-trade-development.md @@ -105,7 +105,7 @@ scripts/ ## 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 and does not read TradingView's private visible-price transform. Its vertical range is symmetric around the current best bid/ask midpoint and is derived only from the nearest local-book levels, so it must not be described as pixel-aligned with the TradingView price scale. +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 data session opens the official USD-M public `{symbol}@depth@100ms` stream before requesting `/fapi/v1/depth?limit=1000`. `core/depth-profile-book.js` applies the official `lastUpdateId`, `U`, `u`, and `pu` sequence contract and treats quantities as absolute values; zero removes a price level. `core/depth-profile-session.js` owns one inflight snapshot, at most three resynchronizations per connection, and at most five reconnect attempts. A terminal failure remains visible until the user collapses and reopens the profile or changes symbols. @@ -229,7 +229,7 @@ Run manual checks when behavior touches trading flow, DOM selectors, account ord - test open and close modes - verify rules-not-ready refuses to order - verify orderbook precision recommendation comes from latest-trade price movement, not from the current orderbook display precision -- verify the vertical depth profile renders beside the price axis in Basic and Trading View modes, does not block chart clicks, disappears in native Depth mode, and resumes with a fresh symbol-owned session after returning +- verify the vertical depth profile renders only in Trading View mode, is clipped to the main K-line pane, stays aligned after price movement/zoom/scale-mode changes, does not block chart clicks, and resumes with a fresh symbol-owned session after returning from Basic or native Depth mode - switch symbols and hide/show the tab while the profile is active; verify no old-symbol bars or stale status survive - verify the manual precision refresh button starts one longer sample round without auto-applying or scheduling background resampling - verify the precision apply button changes Binance orderbook precision only after an explicit user click diff --git a/scripts/binance-orderbook-trade.user.js b/scripts/binance-orderbook-trade.user.js index d521048..c1a9159 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.181 +// @version 2.7.182 // @author jackhai9 // @description 单击订单簿价格,按当前开仓/平仓 tab 自动填数量并执行下单,内置数量倍率面板 // @match https://www.binance.com/*/futures/* @@ -3795,6 +3795,7 @@ var DEPTH_PROFILE_ID = "jh-binance-depth-profile"; var STYLE_ID = "jh-binance-depth-profile-style"; var CHART_ROOT_SELECTOR2 = ".chart-widget-root"; + var PRICE_COORDINATE_SEARCH_STEPS = 13; function hasVisibleBox2(element) { if (!element?.getClientRects().length) return false; const rect = element.getBoundingClientRect(); @@ -3811,22 +3812,59 @@ if (frames.length > 1) { throw new Error(`Visible chart frame count is invalid: ${frames.length}`); } - if (frames.length === 1) { - const frame = frames[0]; - const host = frame.parentElement?.parentElement; - if (!host || !chartRoot.contains(host)) { - throw new Error("Visible chart frame host is invalid"); + if (!frames.length) return null; + const frame = frames[0]; + const host = frame.parentElement?.parentElement; + if (!host || !chartRoot.contains(host)) { + throw new Error("Visible chart frame host is invalid"); + } + return { chartRoot, frame, host }; + } + function getTradingViewDepthProfileGeometry(frame) { + const chart = frame?.contentWindow?.tradingViewApi?.activeChart?.(); + if (!chart) return null; + const paneHeights = chart.getAllPanesHeight?.(); + const panes = chart.getPanes?.(); + if (!Array.isArray(paneHeights) || !Array.isArray(panes) || !panes.length) return null; + const height = Number(paneHeights[0]); + const scale = panes[0]?.getMainSourcePriceScale?.(); + if (!(height > 0) || !scale) return null; + if (typeof scale.coordinateToPrice !== "function" || typeof scale.getVisiblePriceRange !== "function" || typeof scale.getMode !== "function" || typeof scale.isInverted !== "function") return null; + 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 [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); + const maxPrice = Math.max(topPrice, bottomPrice); + const descendsWithY = topPrice > bottomPrice; + if (sampledPrices.some((price, index) => !Number.isFinite(price) || index > 0 && (descendsWithY ? price >= sampledPrices[index - 1] : price <= sampledPrices[index - 1]))) return null; + return { + top: 0, + height, + minPrice, + maxPrice, + mode, + inverted, + priceToCoordinate(price) { + if (!Number.isFinite(price) || price < minPrice || price > maxPrice) return null; + if (price === topPrice) return 0; + if (price === bottomPrice) return height; + let low = 0; + 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)); + if (!Number.isFinite(middlePrice)) { + throw new Error("TradingView price coordinate is invalid"); + } + if (descendsWithY ? middlePrice > price : middlePrice < price) low = middle; + else high = middle; + } + return (low + high) / 2; } - return { chartRoot, frame, host }; - } - const basicChartHosts = Array.from( - chartRoot.querySelectorAll(".draggableCancel.h-full.relative") - ).filter((candidate) => hasVisibleBox2(candidate) && Array.from(candidate.querySelectorAll(".kline-container")).some(hasVisibleBox2) && Array.from(candidate.querySelectorAll("canvas")).some(hasVisibleBox2)); - if (basicChartHosts.length > 1) { - throw new Error(`Visible basic chart host count is invalid: ${basicChartHosts.length}`); - } - if (!basicChartHosts.length) return null; - return { chartRoot, frame: null, host: basicChartHosts[0] }; + }; } function installStyle(document2) { if (document2.getElementById(STYLE_ID)) return; @@ -3838,7 +3876,8 @@ z-index: 3; top: 0; right: 72px; - bottom: 0; + bottom: auto; + height: 0; width: 132px; overflow: visible; pointer-events: none; @@ -3953,17 +3992,29 @@ if (statusElement.textContent !== status) statusElement.textContent = status; if (statusElement.title !== statusTitle) statusElement.title = statusTitle; } + function setDepthProfileGeometry(root, geometry) { + const top = `${geometry.top}px`; + const height = `${geometry.height}px`; + if (root.style.top !== top) root.style.top = top; + if (root.style.height !== height) root.style.height = height; + } + function mapVisibleLevels(levels, geometry) { + return levels.map((level) => ({ + ...level, + y: geometry.priceToCoordinate(level.price) + })).filter((level) => Number.isFinite(level.y)); + } function drawSide(context, levels, profile, width, height, color) { - const priceRange = profile.maxPrice - profile.minPrice; + if (!levels.length) return; const levelHeight = Math.max(1, Math.min(5, height / (levels.length * 2.4))); context.fillStyle = color; for (const level of levels) { - const y = (profile.maxPrice - level.price) / priceRange * height; const barWidth = level.cumulative / profile.maxCumulative * width; - context.fillRect(width - barWidth, y - levelHeight / 2, barWidth, levelHeight); + context.fillRect(width - barWidth, level.y - levelHeight / 2, barWidth, levelHeight); } } - function renderDepthProfile(root, profile) { + function renderDepthProfile(root, profile, geometry, currentPrice) { + setDepthProfileGeometry(root, geometry); const canvas = root.querySelector(".jh-depth-profile-canvas"); const rect = canvas.getBoundingClientRect(); if (!(rect.width > 0) || !(rect.height > 0)) return false; @@ -3976,17 +4027,19 @@ if (canvas.height !== pixelHeight) canvas.height = pixelHeight; context.setTransform(devicePixelRatio, 0, 0, devicePixelRatio, 0, 0); context.clearRect(0, 0, rect.width, rect.height); - drawSide(context, profile.asks, profile, rect.width, rect.height, "rgba(246, 70, 93, .30)"); - drawSide(context, profile.bids, profile, rect.width, rect.height, "rgba(14, 203, 129, .30)"); - const priceRange = profile.maxPrice - profile.minPrice; - const midY = (profile.maxPrice - profile.midPrice) / priceRange * rect.height; + const asks = mapVisibleLevels(profile.asks, geometry); + const bids = mapVisibleLevels(profile.bids, geometry); + drawSide(context, asks, profile, rect.width, rect.height, "rgba(246, 70, 93, .30)"); + drawSide(context, bids, profile, rect.width, rect.height, "rgba(14, 203, 129, .30)"); + const currentPriceY = geometry.priceToCoordinate(currentPrice); + if (!Number.isFinite(currentPriceY)) return true; context.save(); context.strokeStyle = "rgba(240, 185, 11, .85)"; context.lineWidth = 1; context.setLineDash([3, 3]); context.beginPath(); - context.moveTo(0, midY + 0.5); - context.lineTo(rect.width, midY + 0.5); + context.moveTo(0, currentPriceY + 0.5); + context.lineTo(rect.width, currentPriceY + 0.5); context.stroke(); context.restore(); return true; @@ -4813,11 +4866,13 @@ let depthProfileEnabled = localStorage.getItem(DEPTH_PROFILE_ENABLED_KEY) !== "0"; let depthProfileSession = null; let depthProfileView = null; + let depthProfileFrame = null; let depthProfileData = null; let depthProfileStatus = { status: "connecting", detail: "" }; let depthProfileFailedSymbol = null; let depthProfileObserver = null; let depthProfileObserverRoot = null; + let depthProfileRenderQueued = false; let depthProfileSyncQueued = false; const controlledNativeButtons = /* @__PURE__ */ new Set(); let lastObservedSymbol = getCurrentSymbol(); @@ -11132,8 +11187,20 @@ const title = depthProfileStatus.detail ? `${text}: ${depthProfileStatus.detail}` : text; return { text, title }; } + function getCurrentDepthProfilePrice() { + const node = Array.from(document.querySelectorAll(".tradew-tradelist .price.emit-price")).find((candidate) => isVisibleElement(candidate)); + const price = parsePrice(node); + return price == null ? null : Number(price); + } function renderDepthProfileUi() { if (!depthProfileView?.isConnected) return; + const geometry = getTradingViewDepthProfileGeometry(depthProfileFrame); + if (!geometry) { + clearDepthProfile(depthProfileView); + scheduleDepthProfileSync(); + return; + } + setDepthProfileGeometry(depthProfileView, geometry); const status = depthProfileStatusPresentation(); setDepthProfileViewState(depthProfileView, { expanded: depthProfileEnabled, @@ -11143,11 +11210,24 @@ statusTitle: status.title }); if (depthProfileEnabled && depthProfileData) { - renderDepthProfile(depthProfileView, depthProfileData); + renderDepthProfile( + depthProfileView, + depthProfileData, + geometry, + getCurrentDepthProfilePrice() + ); } else { clearDepthProfile(depthProfileView); } } + function scheduleDepthProfileRender() { + if (depthProfileRenderQueued) return; + depthProfileRenderQueued = true; + window.requestAnimationFrame(() => { + depthProfileRenderQueued = false; + renderDepthProfileUi(); + }); + } function stopDepthProfileSession() { depthProfileSession?.stop(); depthProfileSession = null; @@ -11160,6 +11240,7 @@ function removeDepthProfileRuntimeView() { removeDepthProfileView(document); depthProfileView = null; + depthProfileFrame = null; } function scheduleDepthProfileSync() { if (depthProfileSyncQueued) return; @@ -11197,14 +11278,14 @@ if (depthProfileSession !== session || getCurrentSymbol() !== symbol) return; depthProfileData = profile; depthProfileStatus = { status: "ready", detail: "" }; - renderDepthProfileUi(); + scheduleDepthProfileRender(); }, onStatus(status) { if (depthProfileSession !== session || getCurrentSymbol() !== symbol) return; depthProfileStatus = status; if (status.status !== "ready") depthProfileData = null; if (status.status === "failed") depthProfileFailedSymbol = symbol; - renderDepthProfileUi(); + scheduleDepthProfileRender(); } }); depthProfileSession = session; @@ -11218,19 +11299,22 @@ } ensureDepthProfileObserver(); let target; + let geometry; try { target = findDepthProfileHost(document); + geometry = target ? getTradingViewDepthProfileGeometry(target.frame) : null; } catch (error) { stopDepthProfileSession(); removeDepthProfileRuntimeView(); err("depth profile host discovery failed:", error); return; } - if (!target) { + if (!target || !geometry) { stopDepthProfileSession(); removeDepthProfileRuntimeView(); return; } + depthProfileFrame = target.frame; depthProfileView = ensureDepthProfileView(document, target.host, { onToggle() { depthProfileEnabled = !depthProfileEnabled; @@ -11244,6 +11328,7 @@ syncDepthProfile(); } }); + setDepthProfileGeometry(depthProfileView, geometry); renderDepthProfileUi(); if (!depthProfileEnabled) return; const symbol = getCurrentSymbol(); diff --git a/src/binance-orderbook-trade/dom/depth-profile.js b/src/binance-orderbook-trade/dom/depth-profile.js index e2179ce..dec120a 100644 --- a/src/binance-orderbook-trade/dom/depth-profile.js +++ b/src/binance-orderbook-trade/dom/depth-profile.js @@ -2,6 +2,7 @@ export const DEPTH_PROFILE_ID = 'jh-binance-depth-profile'; const STYLE_ID = 'jh-binance-depth-profile-style'; const CHART_ROOT_SELECTOR = '.chart-widget-root'; +const PRICE_COORDINATE_SEARCH_STEPS = 13; function hasVisibleBox(element) { if (!element?.getClientRects().length) return false; @@ -21,27 +22,84 @@ export function findDepthProfileHost(document) { if (frames.length > 1) { throw new Error(`Visible chart frame count is invalid: ${frames.length}`); } - if (frames.length === 1) { - const frame = frames[0]; - const host = frame.parentElement?.parentElement; - if (!host || !chartRoot.contains(host)) { - throw new Error('Visible chart frame host is invalid'); - } - return { chartRoot, frame, host }; + if (!frames.length) return null; + const frame = frames[0]; + const host = frame.parentElement?.parentElement; + if (!host || !chartRoot.contains(host)) { + throw new Error('Visible chart frame host is invalid'); } + return { chartRoot, frame, host }; +} - const basicChartHosts = Array.from( - chartRoot.querySelectorAll('.draggableCancel.h-full.relative'), - ).filter((candidate) => ( - hasVisibleBox(candidate) - && Array.from(candidate.querySelectorAll('.kline-container')).some(hasVisibleBox) - && Array.from(candidate.querySelectorAll('canvas')).some(hasVisibleBox) - )); - if (basicChartHosts.length > 1) { - throw new Error(`Visible basic chart host count is invalid: ${basicChartHosts.length}`); - } - if (!basicChartHosts.length) return null; - return { chartRoot, frame: null, host: basicChartHosts[0] }; +export function getTradingViewDepthProfileGeometry(frame) { + // Binance embeds a same-origin TradingView build. Keep this optional adapter isolated and + // fail closed when its runtime contract changes instead of drawing against an invented scale. + const chart = frame?.contentWindow?.tradingViewApi?.activeChart?.(); + if (!chart) return null; + const paneHeights = chart.getAllPanesHeight?.(); + const panes = chart.getPanes?.(); + if (!Array.isArray(paneHeights) || !Array.isArray(panes) || !panes.length) return null; + const height = Number(paneHeights[0]); + const scale = panes[0]?.getMainSourcePriceScale?.(); + if (!(height > 0) || !scale) return null; + if ( + typeof scale.coordinateToPrice !== 'function' + || typeof scale.getVisiblePriceRange !== 'function' + || typeof scale.getMode !== 'function' + || typeof scale.isInverted !== 'function' + ) return null; + + 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 [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); + const maxPrice = Math.max(topPrice, bottomPrice); + const descendsWithY = topPrice > bottomPrice; + if (sampledPrices.some((price, index) => ( + !Number.isFinite(price) + || (index > 0 && (descendsWithY + ? price >= sampledPrices[index - 1] + : price <= sampledPrices[index - 1])) + ))) return null; + return { + top: 0, + height, + minPrice, + maxPrice, + mode, + inverted, + priceToCoordinate(price) { + if (!Number.isFinite(price) || price < minPrice || price > maxPrice) return null; + if (price === topPrice) return 0; + if (price === bottomPrice) return height; + let low = 0; + 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)); + if (!Number.isFinite(middlePrice)) { + throw new Error('TradingView price coordinate is invalid'); + } + if (descendsWithY ? middlePrice > price : middlePrice < price) low = middle; + else high = middle; + } + return (low + high) / 2; + }, + }; } function installStyle(document) { @@ -54,7 +112,8 @@ function installStyle(document) { z-index: 3; top: 0; right: 72px; - bottom: 0; + bottom: auto; + height: 0; width: 132px; overflow: visible; pointer-events: none; @@ -177,18 +236,32 @@ export function setDepthProfileViewState(root, { if (statusElement.title !== statusTitle) statusElement.title = statusTitle; } +export function setDepthProfileGeometry(root, geometry) { + const top = `${geometry.top}px`; + const height = `${geometry.height}px`; + if (root.style.top !== top) root.style.top = top; + if (root.style.height !== height) root.style.height = height; +} + +function mapVisibleLevels(levels, geometry) { + return levels.map((level) => ({ + ...level, + y: geometry.priceToCoordinate(level.price), + })).filter((level) => Number.isFinite(level.y)); +} + function drawSide(context, levels, profile, width, height, color) { - const priceRange = profile.maxPrice - profile.minPrice; + if (!levels.length) return; const levelHeight = Math.max(1, Math.min(5, height / (levels.length * 2.4))); context.fillStyle = color; for (const level of levels) { - const y = ((profile.maxPrice - level.price) / priceRange) * height; const barWidth = (level.cumulative / profile.maxCumulative) * width; - context.fillRect(width - barWidth, y - levelHeight / 2, barWidth, levelHeight); + context.fillRect(width - barWidth, level.y - levelHeight / 2, barWidth, levelHeight); } } -export function renderDepthProfile(root, profile) { +export function renderDepthProfile(root, profile, geometry, currentPrice) { + setDepthProfileGeometry(root, geometry); const canvas = root.querySelector('.jh-depth-profile-canvas'); const rect = canvas.getBoundingClientRect(); if (!(rect.width > 0) || !(rect.height > 0)) return false; @@ -202,18 +275,20 @@ export function renderDepthProfile(root, profile) { context.setTransform(devicePixelRatio, 0, 0, devicePixelRatio, 0, 0); context.clearRect(0, 0, rect.width, rect.height); - drawSide(context, profile.asks, profile, rect.width, rect.height, 'rgba(246, 70, 93, .30)'); - drawSide(context, profile.bids, profile, rect.width, rect.height, 'rgba(14, 203, 129, .30)'); + const asks = mapVisibleLevels(profile.asks, geometry); + const bids = mapVisibleLevels(profile.bids, geometry); + drawSide(context, asks, profile, rect.width, rect.height, 'rgba(246, 70, 93, .30)'); + drawSide(context, bids, profile, rect.width, rect.height, 'rgba(14, 203, 129, .30)'); - const priceRange = profile.maxPrice - profile.minPrice; - const midY = ((profile.maxPrice - profile.midPrice) / priceRange) * rect.height; + const currentPriceY = geometry.priceToCoordinate(currentPrice); + if (!Number.isFinite(currentPriceY)) return true; context.save(); context.strokeStyle = 'rgba(240, 185, 11, .85)'; context.lineWidth = 1; context.setLineDash([3, 3]); context.beginPath(); - context.moveTo(0, midY + 0.5); - context.lineTo(rect.width, midY + 0.5); + context.moveTo(0, currentPriceY + 0.5); + context.lineTo(rect.width, currentPriceY + 0.5); context.stroke(); context.restore(); return true; diff --git a/src/binance-orderbook-trade/index.user.js b/src/binance-orderbook-trade/index.user.js index 8d72080..4f82f89 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.181 +// @version 2.7.182 // @author jackhai9 // @description 单击订单簿价格,按当前开仓/平仓 tab 自动填数量并执行下单,内置数量倍率面板 // @match https://www.binance.com/*/futures/* @@ -209,8 +209,10 @@ import { clearDepthProfile, ensureDepthProfileView, findDepthProfileHost, + getTradingViewDepthProfileGeometry, removeDepthProfileView, renderDepthProfile, + setDepthProfileGeometry, setDepthProfileViewState, } from './dom/depth-profile.js'; import { resolveCancelDialogDecision } from './core/cancel-dialog-decision.js'; @@ -492,11 +494,13 @@ import { showUsdtRebalanceDialog } from './dom/usdt-rebalance-dialog.js'; let depthProfileEnabled = localStorage.getItem(DEPTH_PROFILE_ENABLED_KEY) !== '0'; let depthProfileSession = null; let depthProfileView = null; + let depthProfileFrame = null; let depthProfileData = null; let depthProfileStatus = { status: 'connecting', detail: '' }; let depthProfileFailedSymbol = null; let depthProfileObserver = null; let depthProfileObserverRoot = null; + let depthProfileRenderQueued = false; let depthProfileSyncQueued = false; const controlledNativeButtons = new Set(); let lastObservedSymbol = getCurrentSymbol(); @@ -7925,8 +7929,22 @@ import { showUsdtRebalanceDialog } from './dom/usdt-rebalance-dialog.js'; return { text, title }; } + function getCurrentDepthProfilePrice() { + const node = Array.from(document.querySelectorAll('.tradew-tradelist .price.emit-price')) + .find((candidate) => isVisibleElement(candidate)); + const price = parsePrice(node); + return price == null ? null : Number(price); + } + function renderDepthProfileUi() { if (!depthProfileView?.isConnected) return; + const geometry = getTradingViewDepthProfileGeometry(depthProfileFrame); + if (!geometry) { + clearDepthProfile(depthProfileView); + scheduleDepthProfileSync(); + return; + } + setDepthProfileGeometry(depthProfileView, geometry); const status = depthProfileStatusPresentation(); setDepthProfileViewState(depthProfileView, { expanded: depthProfileEnabled, @@ -7936,12 +7954,26 @@ import { showUsdtRebalanceDialog } from './dom/usdt-rebalance-dialog.js'; statusTitle: status.title, }); if (depthProfileEnabled && depthProfileData) { - renderDepthProfile(depthProfileView, depthProfileData); + renderDepthProfile( + depthProfileView, + depthProfileData, + geometry, + getCurrentDepthProfilePrice(), + ); } else { clearDepthProfile(depthProfileView); } } + function scheduleDepthProfileRender() { + if (depthProfileRenderQueued) return; + depthProfileRenderQueued = true; + window.requestAnimationFrame(() => { + depthProfileRenderQueued = false; + renderDepthProfileUi(); + }); + } + function stopDepthProfileSession() { depthProfileSession?.stop(); depthProfileSession = null; @@ -7956,6 +7988,7 @@ import { showUsdtRebalanceDialog } from './dom/usdt-rebalance-dialog.js'; function removeDepthProfileRuntimeView() { removeDepthProfileView(document); depthProfileView = null; + depthProfileFrame = null; } function scheduleDepthProfileSync() { @@ -7997,14 +8030,14 @@ import { showUsdtRebalanceDialog } from './dom/usdt-rebalance-dialog.js'; if (depthProfileSession !== session || getCurrentSymbol() !== symbol) return; depthProfileData = profile; depthProfileStatus = { status: 'ready', detail: '' }; - renderDepthProfileUi(); + scheduleDepthProfileRender(); }, onStatus(status) { if (depthProfileSession !== session || getCurrentSymbol() !== symbol) return; depthProfileStatus = status; if (status.status !== 'ready') depthProfileData = null; if (status.status === 'failed') depthProfileFailedSymbol = symbol; - renderDepthProfileUi(); + scheduleDepthProfileRender(); }, }); depthProfileSession = session; @@ -8019,20 +8052,23 @@ import { showUsdtRebalanceDialog } from './dom/usdt-rebalance-dialog.js'; } ensureDepthProfileObserver(); let target; + let geometry; try { target = findDepthProfileHost(document); + geometry = target ? getTradingViewDepthProfileGeometry(target.frame) : null; } catch (error) { stopDepthProfileSession(); removeDepthProfileRuntimeView(); err('depth profile host discovery failed:', error); return; } - if (!target) { + if (!target || !geometry) { stopDepthProfileSession(); removeDepthProfileRuntimeView(); return; } + depthProfileFrame = target.frame; depthProfileView = ensureDepthProfileView(document, target.host, { onToggle() { depthProfileEnabled = !depthProfileEnabled; @@ -8046,6 +8082,7 @@ import { showUsdtRebalanceDialog } from './dom/usdt-rebalance-dialog.js'; syncDepthProfile(); }, }); + setDepthProfileGeometry(depthProfileView, geometry); renderDepthProfileUi(); if (!depthProfileEnabled) return; diff --git a/test/dom/binance-orderbook-trade/depth-profile.test.js b/test/dom/binance-orderbook-trade/depth-profile.test.js index 0d9393b..08c87b5 100644 --- a/test/dom/binance-orderbook-trade/depth-profile.test.js +++ b/test/dom/binance-orderbook-trade/depth-profile.test.js @@ -7,8 +7,10 @@ import { DEPTH_PROFILE_ID, ensureDepthProfileView, findDepthProfileHost, + getTradingViewDepthProfileGeometry, removeDepthProfileView, renderDepthProfile, + setDepthProfileGeometry, setDepthProfileViewState, } from '../../../src/binance-orderbook-trade/dom/depth-profile.js'; @@ -24,7 +26,28 @@ function createChartDom({ hiddenFrame = false } = {}) { `); } -test('finds the visible chart frame host without requiring TradingView private APIs', () => { +function installTradingViewApi(frame, { + height = 200, + mode = 0, + inverted = false, + coordinateToPrice = (coordinate) => 110 - coordinate / 10, +} = {}) { + const scale = { + coordinateToPrice, + getMode: () => mode, + getVisiblePriceRange: () => ({ from: 90, to: 110 }), + isInverted: () => inverted, + }; + frame.contentWindow.tradingViewApi = { + activeChart: () => ({ + getAllPanesHeight: () => [height, 80, 120], + getPanes: () => [{ getMainSourcePriceScale: () => scale }], + }), + }; + return scale; +} + +test('finds the visible TradingView frame host', () => { const dom = createChartDom(); const target = findDepthProfileHost(dom.window.document); @@ -37,7 +60,7 @@ test('returns null while Binance mounts the native depth chart instead of a visi assert.equal(findDepthProfileHost(dom.window.document), null); }); -test('finds the visible Binance basic chart host without matching the native depth chart', () => { +test('does not mount on Binance Basic because it has no verified price-coordinate contract', () => { const dom = loadFixtureDom(`
@@ -45,13 +68,65 @@ test('finds the visible Binance basic chart host without matching the native dep
`); - const target = findDepthProfileHost(dom.window.document); + assert.equal(findDepthProfileHost(dom.window.document), null); +}); - assert.equal(target.frame, null); - assert.equal(target.host.className, 'draggableCancel h-full relative'); +test('maps prices through the active TradingView main-pane scale', () => { + const dom = createChartDom(); + const frame = dom.window.document.querySelector('iframe'); + installTradingViewApi(frame); - dom.window.document.querySelector('.kline-container').remove(); - assert.equal(findDepthProfileHost(dom.window.document), null); + const geometry = getTradingViewDepthProfileGeometry(frame); + + assert.equal(geometry.top, 0); + assert.equal(geometry.height, 200); + assert.equal(geometry.minPrice, 90); + assert.equal(geometry.maxPrice, 110); + assert.ok(Math.abs(geometry.priceToCoordinate(100) - 100) < 0.02); + assert.equal(geometry.priceToCoordinate(111), null); +}); + +test('maps prices correctly on logarithmic and inverted TradingView scales', () => { + const logarithmic = createChartDom(); + const logarithmicFrame = logarithmic.window.document.querySelector('iframe'); + installTradingViewApi(logarithmicFrame, { + height: 100, + mode: 1, + coordinateToPrice: (coordinate) => 1000 * ((100 / 1000) ** (coordinate / 100)), + }); + const logarithmicGeometry = getTradingViewDepthProfileGeometry(logarithmicFrame); + assert.equal(logarithmicGeometry.mode, 1); + assert.ok(Math.abs(logarithmicGeometry.priceToCoordinate(Math.sqrt(100_000)) - 50) < 0.02); + + const inverted = createChartDom(); + const invertedFrame = inverted.window.document.querySelector('iframe'); + installTradingViewApi(invertedFrame, { + height: 100, + inverted: true, + coordinateToPrice: (coordinate) => 90 + coordinate / 5, + }); + const invertedGeometry = getTradingViewDepthProfileGeometry(invertedFrame); + assert.equal(invertedGeometry.inverted, true); + assert.ok(Math.abs(invertedGeometry.priceToCoordinate(100) - 50) < 0.02); +}); + +test('fails closed when the TradingView price-scale adapter is unavailable', () => { + const dom = createChartDom(); + const frame = dom.window.document.querySelector('iframe'); + assert.equal(getTradingViewDepthProfileGeometry(frame), null); + + frame.contentWindow.tradingViewApi = { activeChart: () => ({}) }; + assert.equal(getTradingViewDepthProfileGeometry(frame), null); +}); + +test('fails closed when TradingView returns a non-monotonic price transform', () => { + const dom = createChartDom(); + const frame = dom.window.document.querySelector('iframe'); + installTradingViewApi(frame, { + coordinateToPrice: (coordinate) => (coordinate === 100 ? 111 : 110 - coordinate / 10), + }); + + assert.equal(getTradingViewDepthProfileGeometry(frame), null); }); test('rejects ambiguous visible chart roots', () => { @@ -124,7 +199,7 @@ test('reapplying the same view state does not create observer churn', async () = assert.equal(mutations.length, 0); }); -test('draws bid and ask bars plus the current-price divider', () => { +test('draws bid and ask bars plus the latest-trade divider', () => { const dom = createChartDom(); const { document } = dom.window; const { host } = findDepthProfileHost(document); @@ -133,9 +208,9 @@ test('draws bid and ask bars plus the current-price divider', () => { root.querySelector('canvas').getContext = () => ({ beginPath: () => calls.push('beginPath'), clearRect: () => calls.push('clearRect'), - fillRect: () => calls.push('fillRect'), + fillRect: (...args) => calls.push(['fillRect', ...args]), lineTo: () => calls.push('lineTo'), - moveTo: () => calls.push('moveTo'), + moveTo: (...args) => calls.push(['moveTo', ...args]), restore: () => calls.push('restore'), save: () => calls.push('save'), setLineDash: () => calls.push('setLineDash'), @@ -143,18 +218,42 @@ test('draws bid and ask bars plus the current-price divider', () => { stroke: () => calls.push('stroke'), }); + const geometry = { + top: 0, + height: 240, + priceToCoordinate: (price) => ({ 100: 180, 100.5: 140, 101: 80 }[price] ?? null), + }; assert.equal(renderDepthProfile(root, { minPrice: 99, maxPrice: 102, - midPrice: 100.5, + midPrice: 99.5, maxCumulative: 5, bids: [{ price: 100, cumulative: 5 }], - asks: [{ price: 101, cumulative: 4 }], - }), true); - assert.equal(calls.filter((call) => call === 'fillRect').length, 2); + asks: [{ price: 101, cumulative: 4 }, { price: 105, cumulative: 5 }], + }, geometry, 100.5), true); + assert.equal(root.style.height, '240px'); + assert.equal(calls.filter((call) => Array.isArray(call) && call[0] === 'fillRect').length, 2); + assert.deepEqual( + calls.filter((call) => Array.isArray(call) && call[0] === 'fillRect').map((call) => call[2]), + [77.5, 177.5], + ); + assert.deepEqual(calls.find((call) => Array.isArray(call) && call[0] === 'moveTo'), ['moveTo', 0, 140.5]); assert.equal(calls.includes('stroke'), true); calls.length = 0; clearDepthProfile(root); assert.deepEqual(calls, ['save', 'setTransform', 'clearRect', 'restore']); }); + +test('updates root geometry without rewriting unchanged styles', () => { + const dom = createChartDom(); + const { document } = dom.window; + const { host } = findDepthProfileHost(document); + const root = ensureDepthProfileView(document, host, { onToggle: () => {} }); + + setDepthProfileGeometry(root, { top: 4, height: 320 }); + assert.equal(root.style.top, '4px'); + assert.equal(root.style.height, '320px'); + setDepthProfileGeometry(root, { top: 4, height: 320 }); + assert.equal(root.getAttribute('style'), 'top: 4px; height: 320px;'); +});