diff --git a/scripts/binance-orderbook-trade.user.js b/scripts/binance-orderbook-trade.user.js index c1a9159..2a94b23 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.182 +// @version 2.7.183 // @author jackhai9 // @description 单击订单簿价格,按当前开仓/平仓 tab 自动填数量并执行下单,内置数量倍率面板 // @match https://www.binance.com/*/futures/* @@ -3408,6 +3408,7 @@ } }; var MAX_BUFFERED_UPDATES = 500; + var DEPTH_PROFILE_LEVELS_PER_SIDE = 1e3; function assertSymbol(value) { if (typeof value !== "string" || !/^[A-Z0-9_]+$/.test(value)) { throw new Error("Invalid depth profile symbol"); @@ -3565,7 +3566,9 @@ return { ...level, cumulative }; }); } - function buildDepthProfile(book, { levelsPerSide = 60 } = {}) { + function buildDepthProfile(book, { + levelsPerSide = DEPTH_PROFILE_LEVELS_PER_SIDE + } = {}) { if (!book.ready) throw new Error("Depth profile book is not ready"); if (!Number.isInteger(levelsPerSide) || levelsPerSide <= 0) { throw new Error("Invalid depth profile level count"); @@ -3596,7 +3599,6 @@ } // src/binance-orderbook-trade/core/depth-profile-session.js - var DEPTH_SNAPSHOT_LIMIT = 1e3; var MAX_RESYNCS_PER_CONNECTION = 3; var MAX_RECONNECT_ATTEMPTS = 5; var RESYNC_DELAY_MS = 1e3; @@ -3633,7 +3635,7 @@ let reconnectAttempts = 0; let resyncAttempts = 0; let book = createDepthProfileBook(symbol); - const snapshotUrl = `https://fapi.binance.com/fapi/v1/depth?symbol=${symbol}&limit=${DEPTH_SNAPSHOT_LIMIT}`; + const snapshotUrl = `https://fapi.binance.com/fapi/v1/depth?symbol=${symbol}&limit=${DEPTH_PROFILE_LEVELS_PER_SIDE}`; const streamUrl = `wss://fstream.binance.com/public/ws/${symbol.toLowerCase()}@depth@100ms`; function emitStatus(status, detail = "") { if (!active) return; @@ -3998,21 +4000,35 @@ 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 bucketVisibleLevels(levels, geometry) { + const lastRow = Math.max(0, Math.ceil(geometry.height) - 1); + const buckets = /* @__PURE__ */ new Map(); + for (const level of levels) { + const coordinate = geometry.priceToCoordinate(level.price); + if (!Number.isFinite(coordinate)) continue; + const y = Math.max(0, Math.min(lastRow, Math.round(coordinate))); + const current = buckets.get(y); + if (!current || level.cumulative > current.cumulative) { + buckets.set(y, { ...level, y }); + } + } + return [...buckets.values()].sort((left, right) => left.y - right.y); } - function drawSide(context, levels, profile, width, height, color) { + function drawSide(context, levels, maxVisibleCumulative, width, color) { 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 barWidth = level.cumulative / profile.maxCumulative * width; - context.fillRect(width - barWidth, level.y - levelHeight / 2, barWidth, levelHeight); + const barWidth = level.cumulative / maxVisibleCumulative * width; + context.fillRect(width - barWidth, level.y, barWidth, 1); } } + function getMaxVisibleCumulative(...levelGroups) { + let maximum = 0; + for (const levels of levelGroups) { + for (const level of levels) maximum = Math.max(maximum, level.cumulative); + } + return maximum; + } function renderDepthProfile(root, profile, geometry, currentPrice) { setDepthProfileGeometry(root, geometry); const canvas = root.querySelector(".jh-depth-profile-canvas"); @@ -4027,10 +4043,13 @@ if (canvas.height !== pixelHeight) canvas.height = pixelHeight; context.setTransform(devicePixelRatio, 0, 0, devicePixelRatio, 0, 0); context.clearRect(0, 0, rect.width, 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 asks = bucketVisibleLevels(profile.asks, geometry); + const bids = bucketVisibleLevels(profile.bids, geometry); + const maxVisibleCumulative = getMaxVisibleCumulative(asks, bids); + if (maxVisibleCumulative > 0) { + drawSide(context, asks, maxVisibleCumulative, rect.width, "rgba(246, 70, 93, .30)"); + drawSide(context, bids, maxVisibleCumulative, rect.width, "rgba(14, 203, 129, .30)"); + } const currentPriceY = geometry.priceToCoordinate(currentPrice); if (!Number.isFinite(currentPriceY)) return true; context.save(); diff --git a/src/binance-orderbook-trade/core/depth-profile-book.js b/src/binance-orderbook-trade/core/depth-profile-book.js index e8f4144..1be9683 100644 --- a/src/binance-orderbook-trade/core/depth-profile-book.js +++ b/src/binance-orderbook-trade/core/depth-profile-book.js @@ -6,6 +6,7 @@ export class DepthProfileSequenceError extends Error { } const MAX_BUFFERED_UPDATES = 500; +export const DEPTH_PROFILE_LEVELS_PER_SIDE = 1000; function assertSymbol(value) { if (typeof value !== 'string' || !/^[A-Z0-9_]+$/.test(value)) { @@ -187,7 +188,9 @@ function addCumulativeQuantity(levels) { }); } -export function buildDepthProfile(book, { levelsPerSide = 60 } = {}) { +export function buildDepthProfile(book, { + levelsPerSide = DEPTH_PROFILE_LEVELS_PER_SIDE, +} = {}) { if (!book.ready) throw new Error('Depth profile book is not ready'); if (!Number.isInteger(levelsPerSide) || levelsPerSide <= 0) { throw new Error('Invalid depth profile level count'); diff --git a/src/binance-orderbook-trade/core/depth-profile-session.js b/src/binance-orderbook-trade/core/depth-profile-session.js index ec7f0ea..391227e 100644 --- a/src/binance-orderbook-trade/core/depth-profile-session.js +++ b/src/binance-orderbook-trade/core/depth-profile-session.js @@ -2,11 +2,11 @@ import { applyDepthProfileSnapshot, buildDepthProfile, createDepthProfileBook, + DEPTH_PROFILE_LEVELS_PER_SIDE, DepthProfileSequenceError, pushDepthProfileUpdate, } from './depth-profile-book.js'; -const DEPTH_SNAPSHOT_LIMIT = 1000; const MAX_RESYNCS_PER_CONNECTION = 3; const MAX_RECONNECT_ATTEMPTS = 5; const RESYNC_DELAY_MS = 1000; @@ -47,7 +47,7 @@ export function createDepthProfileSession(options) { let resyncAttempts = 0; let book = createDepthProfileBook(symbol); - const snapshotUrl = `https://fapi.binance.com/fapi/v1/depth?symbol=${symbol}&limit=${DEPTH_SNAPSHOT_LIMIT}`; + const snapshotUrl = `https://fapi.binance.com/fapi/v1/depth?symbol=${symbol}&limit=${DEPTH_PROFILE_LEVELS_PER_SIDE}`; const streamUrl = `wss://fstream.binance.com/public/ws/${symbol.toLowerCase()}@depth@100ms`; function emitStatus(status, detail = '') { diff --git a/src/binance-orderbook-trade/dom/depth-profile.js b/src/binance-orderbook-trade/dom/depth-profile.js index dec120a..ed4e185 100644 --- a/src/binance-orderbook-trade/dom/depth-profile.js +++ b/src/binance-orderbook-trade/dom/depth-profile.js @@ -243,23 +243,40 @@ export function setDepthProfileGeometry(root, geometry) { 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 bucketVisibleLevels(levels, geometry) { + const lastRow = Math.max(0, Math.ceil(geometry.height) - 1); + const buckets = new Map(); + for (const level of levels) { + const coordinate = geometry.priceToCoordinate(level.price); + if (!Number.isFinite(coordinate)) continue; + const y = Math.max(0, Math.min(lastRow, Math.round(coordinate))); + const current = buckets.get(y); + // Several exchange prices can map to one chart pixel. The outermost level has the + // largest cumulative quantity and preserves the complete depth represented by that row. + if (!current || level.cumulative > current.cumulative) { + buckets.set(y, { ...level, y }); + } + } + return [...buckets.values()].sort((left, right) => left.y - right.y); } -function drawSide(context, levels, profile, width, height, color) { +function drawSide(context, levels, maxVisibleCumulative, width, color) { 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 barWidth = (level.cumulative / profile.maxCumulative) * width; - context.fillRect(width - barWidth, level.y - levelHeight / 2, barWidth, levelHeight); + const barWidth = (level.cumulative / maxVisibleCumulative) * width; + context.fillRect(width - barWidth, level.y, barWidth, 1); } } +function getMaxVisibleCumulative(...levelGroups) { + let maximum = 0; + for (const levels of levelGroups) { + for (const level of levels) maximum = Math.max(maximum, level.cumulative); + } + return maximum; +} + export function renderDepthProfile(root, profile, geometry, currentPrice) { setDepthProfileGeometry(root, geometry); const canvas = root.querySelector('.jh-depth-profile-canvas'); @@ -275,10 +292,13 @@ export function renderDepthProfile(root, profile, geometry, currentPrice) { context.setTransform(devicePixelRatio, 0, 0, devicePixelRatio, 0, 0); context.clearRect(0, 0, rect.width, 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 asks = bucketVisibleLevels(profile.asks, geometry); + const bids = bucketVisibleLevels(profile.bids, geometry); + const maxVisibleCumulative = getMaxVisibleCumulative(asks, bids); + if (maxVisibleCumulative > 0) { + drawSide(context, asks, maxVisibleCumulative, rect.width, 'rgba(246, 70, 93, .30)'); + drawSide(context, bids, maxVisibleCumulative, rect.width, 'rgba(14, 203, 129, .30)'); + } const currentPriceY = geometry.priceToCoordinate(currentPrice); if (!Number.isFinite(currentPriceY)) return true; diff --git a/src/binance-orderbook-trade/index.user.js b/src/binance-orderbook-trade/index.user.js index 4f82f89..16c189d 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.182 +// @version 2.7.183 // @author jackhai9 // @description 单击订单簿价格,按当前开仓/平仓 tab 自动填数量并执行下单,内置数量倍率面板 // @match https://www.binance.com/*/futures/* diff --git a/test/dom/binance-orderbook-trade/depth-profile.test.js b/test/dom/binance-orderbook-trade/depth-profile.test.js index 08c87b5..1cb66b1 100644 --- a/test/dom/binance-orderbook-trade/depth-profile.test.js +++ b/test/dom/binance-orderbook-trade/depth-profile.test.js @@ -235,7 +235,7 @@ test('draws bid and ask bars plus the latest-trade divider', () => { 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], + [80, 180], ); assert.deepEqual(calls.find((call) => Array.isArray(call) && call[0] === 'moveTo'), ['moveTo', 0, 140.5]); assert.equal(calls.includes('stroke'), true); @@ -245,6 +245,52 @@ test('draws bid and ask bars plus the latest-trade divider', () => { assert.deepEqual(calls, ['save', 'setTransform', 'clearRect', 'restore']); }); +test('draws one bar per visible CSS pixel row and scales width to visible depth', () => { + const dom = createChartDom(); + const { document } = dom.window; + const { host } = findDepthProfileHost(document); + const root = ensureDepthProfileView(document, host, { onToggle: () => {} }); + const fillRects = []; + const canvas = root.querySelector('canvas'); + canvas.getContext = () => ({ + beginPath: () => {}, + clearRect: () => {}, + fillRect: (...args) => fillRects.push(args), + lineTo: () => {}, + moveTo: () => {}, + restore: () => {}, + save: () => {}, + setLineDash: () => {}, + setTransform: () => {}, + stroke: () => {}, + }); + + const geometry = { + top: 0, + height: 240, + priceToCoordinate: (price) => ({ + 99: 180.4, + 101: 80.2, + 102: 80.4, + }[price] ?? null), + }; + renderDepthProfile(root, { + maxCumulative: 1_000, + bids: [{ price: 99, cumulative: 4 }], + asks: [ + { price: 101, cumulative: 3 }, + { price: 102, cumulative: 8 }, + { price: 110, cumulative: 1_000 }, + ], + }, geometry, 100); + + assert.equal(fillRects.length, 2); + assert.deepEqual(fillRects.map(([, y, , height]) => [y, height]), [[80, 1], [180, 1]]); + assert.equal(fillRects[0][0], 0); + assert.equal(fillRects[0][2], canvas.getBoundingClientRect().width); + assert.equal(fillRects[1][2], canvas.getBoundingClientRect().width / 2); +}); + test('updates root geometry without rewriting unchanged styles', () => { const dom = createChartDom(); const { document } = dom.window; diff --git a/test/unit/binance-orderbook-trade/depth-profile-book.test.js b/test/unit/binance-orderbook-trade/depth-profile-book.test.js index 2e5f23a..ae5c772 100644 --- a/test/unit/binance-orderbook-trade/depth-profile-book.test.js +++ b/test/unit/binance-orderbook-trade/depth-profile-book.test.js @@ -5,6 +5,7 @@ import { applyDepthProfileSnapshot, buildDepthProfile, createDepthProfileBook, + DEPTH_PROFILE_LEVELS_PER_SIDE, DepthProfileSequenceError, pushDepthProfileUpdate, } from '../../../src/binance-orderbook-trade/core/depth-profile-book.js'; @@ -120,3 +121,20 @@ test('builds a symmetric vertical price range with cumulative depth', () => { assert.deepEqual(profile.asks.map((level) => level.cumulative), [4, 10, 20]); assert.equal(profile.maxCumulative, 20); }); + +test('builds every level available from the 1000-level snapshot by default', () => { + const book = createDepthProfileBook('BTCUSDT'); + book.ready = true; + for (let index = 0; index < DEPTH_PROFILE_LEVELS_PER_SIDE + 1; index += 1) { + book.bids.set(String(100 - index / 1000), '1'); + book.asks.set(String(101 + index / 1000), '1'); + } + + const profile = buildDepthProfile(book); + + assert.equal(DEPTH_PROFILE_LEVELS_PER_SIDE, 1000); + assert.equal(profile.bids.length, 1000); + assert.equal(profile.asks.length, 1000); + assert.equal(profile.bids.at(-1).cumulative, 1000); + assert.equal(profile.asks.at(-1).cumulative, 1000); +});