diff --git a/docs/binance-orderbook-trade-development.md b/docs/binance-orderbook-trade-development.md index 1cb3fdc..add000d 100644 --- a/docs/binance-orderbook-trade-development.md +++ b/docs/binance-orderbook-trade-development.md @@ -139,6 +139,23 @@ The userscript installs `core/binance-native-depth-source.js` at `document-start 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. +Compact depth labels are painted inside the existing 132px canvas. Each pixel row +keeps its largest cumulative quantity for the bar and separately sums all real +level quantities for its label, retaining the full minimum-to-maximum price band. +Labels identify band quantities in the native book's units, not cumulative totals +or changes over time. Candidates must add at least 8 CSS pixels of depth at the +current visible scale; larger quantities win, with at most two labels per side. +Both sides share collision checks against other labels, the latest-trade divider, +the collapse control, and visible status text. + +Each label uses 11px text, a 16px height, and its measured width up to 88px. Price +and compact quantity are shown when they fit; a long price band uses quantity +alone instead of truncating it or pretending it belongs to a single price. +Quantities use up to three significant digits and omit repeated asset names. +Labels stay within the canvas, follow inverted scales, and are omitted when no +space remains. They repaint and clear with the depth bars without adding DOM +nodes, listeners, timers, or cross-frame state. + ## Symbol Identity `src/shared/binance-symbol.js` owns the Binance identifier character contract: diff --git a/e2e/binance-orderbook/helpers/depth-profile-fixture.js b/e2e/binance-orderbook/helpers/depth-profile-fixture.js new file mode 100644 index 0000000..98f7305 --- /dev/null +++ b/e2e/binance-orderbook/helpers/depth-profile-fixture.js @@ -0,0 +1,211 @@ +import { createCancelScenario } from '../scenarios/cancel-current-symbol.js'; +import { openUserscriptScenario } from './userscript-page.js'; + +export const DEPTH_PROFILE_SELECTOR = '#jh-binance-depth-profile'; +export const DEPTH_LABEL_SYMBOL = 'LSKUSDT'; +export const DEPTH_LABEL_LEVELS = { + asks: [ + ['1.55', '5000'], + ['1.7', '8000'], + ['1.7003', '12000'], + ['1.8', '3800000'], + ['2', '2400000'], + ], + bids: [['1.49', '12000'], ['1.45', '8000'], ['1.3', '620000']], +}; + +/** Observe only the final userscript canvas frame; native drawing still runs. */ +function installDepthLabelProbe() { + const state = { + drawing: { serial: 0, rectangles: [], texts: [] }, + socketCount: 0, + chartClicks: 0, + chartReady: false, + updateId: 102, + }; + window.__DEPTH_LABEL_FIXTURE__ = state; + const prototype = CanvasRenderingContext2D.prototype; + const belongsToDepthProfile = (context) => ( + context.canvas.matches('#jh-binance-depth-profile .jh-depth-profile-canvas') + ); + const originalClear = prototype.clearRect; + prototype.clearRect = function (...args) { + const result = Reflect.apply(originalClear, this, args); + if (belongsToDepthProfile(this)) { + state.drawing = { serial: state.drawing.serial + 1, rectangles: [], texts: [] }; + } + return result; + }; + const originalFillRect = prototype.fillRect; + prototype.fillRect = function (x, y, width, height) { + const result = Reflect.apply(originalFillRect, this, [x, y, width, height]); + if (belongsToDepthProfile(this)) { + state.drawing.rectangles.push({ x, y, width, height, fillStyle: this.fillStyle }); + } + return result; + }; + const originalFillText = prototype.fillText; + prototype.fillText = function (...args) { + const result = Reflect.apply(originalFillText, this, args); + if (belongsToDepthProfile(this)) { + const [text, x, y] = args; + state.drawing.texts.push({ + text, x, y, + width: this.measureText(text).width, + font: this.font, + fillStyle: this.fillStyle, + }); + } + return result; + }; + + // The generated native adapter observes this page-owned socket without networking. + window.WebSocket = class extends EventTarget { + constructor() { + super(); + state.socketCount += 1; + } + }; +} + +function readChartLayoutInPage() { + const chart = document.querySelector('.chart-widget-root'); + const frame = chart.querySelector('iframe'); + const axis = frame.contentDocument.querySelector('.chart-markup-table.price-axis-container'); + const box = (element) => { + const { x, y, width, height } = element.getBoundingClientRect(); + return { x, y, width, height }; + }; + return { chart: box(chart), frame: box(frame), axis: box(axis) }; +} + +export async function readDepthChartLayout(page) { + return page.evaluate(readChartLayoutInPage); +} + +export async function readDepthDrawing(page) { + return page.evaluate(() => window.__DEPTH_LABEL_FIXTURE__.drawing); +} + +/** + * Mount a local TradingView geometry contract and feed the generated userscript's + * real native-depth adapter. Returned state and the chart locator also support + * screenshot inspection without any production connection. + */ +export async function openDepthLabelScenario(page, { + levels = DEPTH_LABEL_LEVELS, + currentPrice = 1.5, +} = {}) { + await page.route('**/*', (route) => route.abort('blockedbyclient')); + const evidence = await openUserscriptScenario(page, createCancelScenario({ + name: 'depth-profile-compact-labels', + currentSymbol: DEPTH_LABEL_SYMBOL, + }), { + beforeOrderbook: `(${installDepthLabelProbe.toString()})();`, + }); + const snapshotRequests = []; + await page.route('https://www.binance.com/fapi/v1/rpiDepth**', async (route) => { + const url = new URL(route.request().url()); + snapshotRequests.push({ + symbol: url.searchParams.get('symbol'), + limit: url.searchParams.get('limit'), + }); + await route.fulfill({ + status: 200, + contentType: 'application/json', + body: JSON.stringify({ lastUpdateId: 101, ...levels }), + }); + }); + + await page.evaluate(async ({ currentPrice, symbol }) => { + const previousFrame = document.querySelector('.chart-widget-root iframe'); + const chartApi = previousFrame.contentWindow.tradingViewApi; + const host = document.createElement('div'); + host.id = 'depth-chart-fixture-host'; + host.style.cssText = 'position:relative;height:260px'; + const wrapper = document.createElement('div'); + wrapper.style.height = '100%'; + const frame = document.createElement('iframe'); + frame.title = 'TradingView depth label fixture'; + frame.srcdoc = ` + +
+ 2.22.0 + 1.81.6 + 1.41.2 +
+ `; + const loaded = new Promise((resolve) => frame.addEventListener('load', resolve, { once: true })); + wrapper.append(frame); + host.append(wrapper); + previousFrame.replaceWith(host); + await loaded; + const surface = frame.contentDocument.querySelector('#depth-chart-surface'); + surface.querySelector('span').textContent = `${symbol} · Local depth fixture`; + surface.addEventListener('click', () => { window.__DEPTH_LABEL_FIXTURE__.chartClicks += 1; }); + const tradeList = document.querySelector('.tradew-tradelist'); + tradeList.querySelectorAll('.price.emit-price').forEach((node) => { + node.textContent = currentPrice === null ? '—' : String(currentPrice); + }); + const scale = { + coordinateToPrice: (y) => 2.2 - y / 260, + getVisiblePriceRange: () => ({ from: 1.2, to: 2.2 }), + getMode: () => 0, + isInverted: () => false, + }; + frame.contentWindow.tradingViewApi = { + ...chartApi, + activeChart: () => ({ + hasModel: () => window.__DEPTH_LABEL_FIXTURE__.chartReady, + getAllPanesHeight: () => [260], + getPanes: () => [{ getMainSourcePriceScale: () => scale }], + }), + }; + }, { currentPrice, symbol: DEPTH_LABEL_SYMBOL }); + const initialLayout = await readDepthChartLayout(page); + await page.evaluate(() => { + window.__DEPTH_LABEL_FIXTURE__.chartReady = true; + window.dispatchEvent(new Event('resize')); + }); + await page.locator(`${DEPTH_PROFILE_SELECTOR} canvas`).waitFor({ state: 'visible' }); + + await page.evaluate(async (symbol) => { + const state = window.__DEPTH_LABEL_FIXTURE__; + state.socket = new WebSocket('wss://depth-label-fixture.invalid/ws'); + const response = fetch(`/fapi/v1/rpiDepth?${new URLSearchParams({ symbol, limit: '1000' })}`); + state.socket.dispatchEvent(new MessageEvent('message', { data: JSON.stringify({ + stream: `${symbol.toLowerCase()}@rpiDepth@500ms`, + data: { e: 'depthUpdate', s: symbol, st: 1, U: 100, u: 102, pu: 99, b: [], a: [] }, + }) })); + await (await response).json(); + }, DEPTH_LABEL_SYMBOL); + await page.waitForFunction(() => ( + window.__TM_CLOSE_LONG_DEBUG__.nativeDepthState.status.status === 'ready' + )); + return { ...evidence, snapshotRequests, initialLayout }; +} + +export async function emitDepthLabelUpdate(page, { asks, bids }) { + await page.evaluate(({ asks, bids, symbol }) => { + const state = window.__DEPTH_LABEL_FIXTURE__; + const previousUpdateId = state.updateId; + state.updateId += 1; + state.socket.dispatchEvent(new MessageEvent('message', { data: JSON.stringify({ + stream: `${symbol.toLowerCase()}@rpiDepth@500ms`, + data: { + e: 'depthUpdate', s: symbol, st: 1, + U: state.updateId, u: state.updateId, pu: previousUpdateId, + b: bids, a: asks, + }, + }) })); + }, { asks, bids, symbol: DEPTH_LABEL_SYMBOL }); +} diff --git a/e2e/binance-orderbook/specs/depth-profile-labels.pw.js b/e2e/binance-orderbook/specs/depth-profile-labels.pw.js new file mode 100644 index 0000000..34b2246 --- /dev/null +++ b/e2e/binance-orderbook/specs/depth-profile-labels.pw.js @@ -0,0 +1,155 @@ +import { test, expect } from '../test.js'; +import { readFixtureState } from '../helpers/userscript-page.js'; +import { + DEPTH_LABEL_SYMBOL, + DEPTH_PROFILE_SELECTOR, + emitDepthLabelUpdate, + openDepthLabelScenario, + readDepthChartLayout, + readDepthDrawing, +} from '../helpers/depth-profile-fixture.js'; + +const labelTexts = async (page) => (await readDepthDrawing(page)).texts.map(({ text }) => text).sort(); +const backgrounds = (drawing) => drawing.rectangles.filter(({ height }) => height === 16); +const intersects = (left, right) => ( + left.x < right.x + right.width && left.x + left.width > right.x + && left.y < right.y + right.height && left.y + left.height > right.y +); + +async function expectCompactLabelGeometry(page, { currentPriceY = null } = {}) { + const drawing = await readDepthDrawing(page); + const boxes = backgrounds(drawing); + const canvas = await page.locator(`${DEPTH_PROFILE_SELECTOR} canvas`).boundingBox(); + const toggle = await page.locator(`${DEPTH_PROFILE_SELECTOR} button`).boundingBox(); + const toggleInCanvas = { ...toggle, x: toggle.x - canvas.x, y: toggle.y - canvas.y }; + expect(canvas.width).toBe(132); + expect(canvas.height).toBe(260); + expect(boxes).toHaveLength(drawing.texts.length); + expect(boxes.length).toBeGreaterThan(0); + expect(boxes.length).toBeLessThanOrEqual(4); + for (let index = 0; index < boxes.length; index += 1) { + const box = boxes[index]; + const text = drawing.texts[index]; + expect(box.width).toBeLessThanOrEqual(88); + expect(box.x).toBeGreaterThanOrEqual(0); + expect(box.x + box.width).toBeLessThanOrEqual(canvas.width); + expect(box.y).toBeGreaterThanOrEqual(0); + expect(box.y + box.height).toBeLessThanOrEqual(canvas.height); + expect(text.font).toMatch(/^11px /); + expect(text.width + 6).toBeLessThanOrEqual(box.width + 0.01); + expect(text.x).toBeCloseTo(box.x + 3, 6); + expect(intersects(box, toggleInCanvas)).toBe(false); + if (currentPriceY !== null) { + expect(box.y + box.height <= currentPriceY || box.y >= currentPriceY + 1).toBe(true); + } + for (const other of boxes.slice(index + 1)) expect(intersects(box, other)).toBe(false); + } + const alpha = await page.locator(`${DEPTH_PROFILE_SELECTOR} canvas`).evaluate((element, boxes) => ( + boxes.map((box) => element.getContext('2d').getImageData( + Math.floor((box.x + box.width - 2) * devicePixelRatio), + Math.floor((box.y + box.height - 2) * devicePixelRatio), 1, 1, + ).data[3]) + ), boxes); + expect(alpha).toEqual(boxes.map(() => 255)); + return { drawing, boxes, canvas }; +} + +async function expectIsolatedReadOnlyFixture(page, evidence) { + expect(evidence.errors).toEqual([]); + expect(evidence.snapshotRequests).toEqual([{ symbol: DEPTH_LABEL_SYMBOL, limit: '1000' }]); + expect(await page.evaluate(() => window.__DEPTH_LABEL_FIXTURE__.socketCount)).toBe(1); + const state = await readFixtureState(page); + expect(state.orders).toEqual([]); + expect(state.events.filter(({ type }) => type === 'order-submitted' || type === 'cancel-requested')) + .toEqual([]); +} + +async function attachChart(page, testInfo, name) { + const path = testInfo.outputPath(name); + await page.locator('.chart-widget-root').screenshot({ path }); + await testInfo.attach(name, { + path, + contentType: 'image/png', + }); +} + +test('compact depth quantities preserve cumulative bars, chart layout and click-through', async ({ page }, testInfo) => { + const evidence = await openDepthLabelScenario(page); + await expect.poll(() => labelTexts(page)).toEqual(['1.3 · 620K', '1.8 · 3.8M', '2 · 2.4M']); + const { drawing, boxes, canvas } = await expectCompactLabelGeometry(page, { currentPriceY: 182 }); + const largeAskBar = drawing.rectangles.find((rectangle) => ( + rectangle.height === 1 && rectangle.fillStyle === '#f6465d' && rectangle.y === 104 + )); + expect(largeAskBar.width).toBeCloseTo(3_825_000 / 6_225_000 * 132, 6); + expect(largeAskBar.x + largeAskBar.width).toBeCloseTo(132, 6); + expect(await readDepthChartLayout(page)).toEqual(evidence.initialLayout); + expect(evidence.initialLayout.frame).toMatchObject({ width: 698, height: 260 }); + expect(evidence.initialLayout.axis).toMatchObject({ width: 60, height: 260 }); + expect(await page.locator(DEPTH_PROFILE_SELECTOR).evaluate((root) => ({ + pointerEvents: getComputedStyle(root).pointerEvents, + canvasPointerEvents: getComputedStyle(root.querySelector('canvas')).pointerEvents, + childTags: [...root.children].map(({ tagName }) => tagName), + }))).toEqual({ pointerEvents: 'none', canvasPointerEvents: 'none', childTags: ['CANVAS', 'BUTTON', 'DIV'] }); + + const label = boxes[0]; + await page.mouse.click(canvas.x + label.x + label.width / 2, canvas.y + label.y + label.height / 2); + expect(await page.evaluate(() => window.__DEPTH_LABEL_FIXTURE__.chartClicks)).toBe(1); + await expectIsolatedReadOnlyFixture(page, evidence); + await attachChart(page, testInfo, 'compact-depth-labels.png'); +}); + +test('pixel-row quantities remain readable with no latest price and a wide price band', async ({ page }, testInfo) => { + const evidence = await openDepthLabelScenario(page, { + currentPrice: null, + levels: { + asks: [['1.8', '1500000'], ['1.8008', '2300000'], ['2', '2400000'], ['2.1', '2000000']], + bids: [['1.49', '600000'], ['1.4', '700000'], ['1.3', '900000']], + }, + }); + await expect.poll(() => labelTexts(page)).toEqual(['1.3 · 900K', '1.4 · 700K', '2 · 2.4M', '3.8M']); + const { drawing } = await expectCompactLabelGeometry(page); + await expect(page.locator('.tradew-tradelist .price.emit-price').first()).toHaveText('—'); + const aggregatedBar = drawing.rectangles.find((rectangle) => ( + rectangle.height === 1 && rectangle.fillStyle === '#f6465d' && rectangle.y === 104 + )); + expect(aggregatedBar.width).toBeCloseTo(3_800_000 / 8_200_000 * 132, 6); + expect(drawing.rectangles.filter((rectangle) => ( + rectangle.height === 1 && rectangle.fillStyle === '#f6465d' && rectangle.y === 104 + ))).toHaveLength(1); + expect(await readDepthChartLayout(page)).toEqual(evidence.initialLayout); + await expectIsolatedReadOnlyFixture(page, evidence); + await attachChart(page, testInfo, 'aggregated-depth-labels.png'); +}); + +test('native updates, collapse and disconnect remove stale depth label pixels', async ({ page }, testInfo) => { + const evidence = await openDepthLabelScenario(page); + await expect.poll(() => labelTexts(page)).toContain('1.8 · 3.8M'); + await emitDepthLabelUpdate(page, { + asks: [['1.8', '1200'], ['2', '0'], ['2.05', '2600000']], + bids: [['1.3', '0'], ['1.35', '900000']], + }); + const updatedTexts = ['1.35 · 900K', '2.05 · 2.6M']; + await expect.poll(() => labelTexts(page)).toEqual(updatedTexts); + await expectCompactLabelGeometry(page, { currentPriceY: 182 }); + await attachChart(page, testInfo, 'updated-depth-labels.png'); + + const root = page.locator(DEPTH_PROFILE_SELECTOR); + await root.locator('button').click(); + await expect(root).toHaveAttribute('data-expanded', 'false'); + await expect(root.locator('canvas')).toBeHidden(); + await expect.poll(() => labelTexts(page)).toEqual([]); + expect(await readDepthChartLayout(page)).toEqual(evidence.initialLayout); + await root.locator('button').click(); + await expect(root).toHaveAttribute('data-expanded', 'true'); + await expect.poll(() => labelTexts(page)).toEqual(updatedTexts); + expect(await readDepthChartLayout(page)).toEqual(evidence.initialLayout); + + await page.evaluate(() => window.__DEPTH_LABEL_FIXTURE__.socket.dispatchEvent(new Event('close'))); + await expect(root.locator('.jh-depth-profile-status')).toHaveText('重新连接深度'); + await expect.poll(() => labelTexts(page)).toEqual([]); + expect(await root.locator('canvas').evaluate((canvas) => ( + canvas.getContext('2d').getImageData(0, 0, canvas.width, canvas.height).data.every((value) => value === 0) + ))).toBe(true); + await expectIsolatedReadOnlyFixture(page, evidence); + await attachChart(page, testInfo, 'cleared-depth-labels.png'); +}); diff --git a/scripts/binance-orderbook-trade.user.js b/scripts/binance-orderbook-trade.user.js index ce33da5..84c2b66 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.206 +// @version 2.7.207 // @author jackhai9 // @description 单击订单簿价格,按当前开仓/平仓 tab 自动填数量并执行下单,内置数量倍率面板 // @match https://www.binance.com/*/futures/* @@ -4138,6 +4138,17 @@ var PRICE_AXIS_SELECTOR = ".chart-markup-table.price-axis-container"; var PRICE_COORDINATE_SEARCH_STEPS = 13; var GEOMETRY_TOLERANCE_PX = 1; + var DEPTH_LABEL_MAX_WIDTH = 88; + var DEPTH_LABEL_HEIGHT = 16; + var DEPTH_LABEL_PADDING = 3; + var DEPTH_LABEL_GAP = 2; + var DEPTH_LABEL_MIN_STEP = 8; + var DEPTH_LABELS_PER_SIDE = 2; + var DEPTH_QUANTITY_FORMAT = new Intl.NumberFormat("en-US", { + notation: "compact", + maximumSignificantDigits: 3, + useGrouping: false + }); function hasVisibleBox2(element) { if (!element?.getClientRects().length) return false; const rect = element.getBoundingClientRect(); @@ -4232,6 +4243,8 @@ style.id = STYLE_ID; style.textContent = ` #${DEPTH_PROFILE_ID} { + --jh-depth-label-background: var(--color-BasicBg, #fff); + --jh-depth-label-color: var(--color-SecondaryText, #474d57); position: absolute; z-index: 3; top: 0; @@ -4367,8 +4380,16 @@ 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 }); + if (!current) { + buckets.set(y, { ...level, y, minPrice: level.price, maxPrice: level.price }); + continue; + } + current.quantity += level.quantity; + current.minPrice = Math.min(current.minPrice, level.price); + current.maxPrice = Math.max(current.maxPrice, level.price); + if (level.cumulative > current.cumulative) { + current.price = level.price; + current.cumulative = level.cumulative; } } return [...buckets.values()].sort((left, right) => left.y - right.y); @@ -4388,6 +4409,71 @@ } return maximum; } + function depthLabelBoxesOverlap(left, right) { + return left.x < right.x + right.width + DEPTH_LABEL_GAP && left.x + left.width + DEPTH_LABEL_GAP > right.x && left.y < right.y + right.height + DEPTH_LABEL_GAP && left.y + left.height + DEPTH_LABEL_GAP > right.y; + } + function depthLabelObstacles(root, canvasRect) { + const controls = [root.querySelector("[data-depth-profile-toggle]")]; + const status = root.querySelector(".jh-depth-profile-status"); + if (status.textContent) controls.push(status); + return controls.map((element) => element.getBoundingClientRect()).filter((rect) => rect.width > 0 && rect.height > 0).map((rect) => ({ + x: rect.left - canvasRect.left, + y: rect.top - canvasRect.top, + width: rect.width, + height: rect.height + })); + } + function drawDepthLabels(root, context, { + asks, + bids, + maxVisibleCumulative, + rect, + inverted, + currentPriceY + }) { + const candidates = [ + ...asks.map((level) => ({ ...level, side: "ask" })), + ...bids.map((level) => ({ ...level, side: "bid" })) + ].filter((level) => level.quantity / maxVisibleCumulative * rect.width >= DEPTH_LABEL_MIN_STEP).sort((left, right) => right.quantity - left.quantity || left.minPrice - right.minPrice); + if (!candidates.length) return; + const style = root.ownerDocument.defaultView.getComputedStyle(root); + const background = style.getPropertyValue("--jh-depth-label-background").trim(); + const color = style.getPropertyValue("--jh-depth-label-color").trim(); + const occupied = depthLabelObstacles(root, rect); + const counts = { ask: 0, bid: 0 }; + const maxWidth = Math.min(DEPTH_LABEL_MAX_WIDTH, rect.width - 2); + context.save(); + context.font = `11px ${style.fontFamily}`; + context.textAlign = "left"; + context.textBaseline = "middle"; + for (const level of candidates) { + if (counts[level.side] === DEPTH_LABELS_PER_SIDE) continue; + const quantity = DEPTH_QUANTITY_FORMAT.format(level.quantity); + const price = level.minPrice === level.maxPrice ? String(level.minPrice) : `${level.minPrice}–${level.maxPrice}`; + let text = `${price} · ${quantity}`; + let width = Math.ceil(context.measureText(text).width) + DEPTH_LABEL_PADDING * 2; + if (width > maxWidth) { + text = quantity; + width = Math.ceil(context.measureText(text).width) + DEPTH_LABEL_PADDING * 2; + } + if (width > maxWidth) continue; + const above = level.side === "ask" ? !inverted : inverted; + const y = above ? level.y - DEPTH_LABEL_HEIGHT - DEPTH_LABEL_GAP : level.y + DEPTH_LABEL_GAP; + if (y < 0 || y + DEPTH_LABEL_HEIGHT > rect.height) continue; + if (Number.isFinite(currentPriceY) && currentPriceY >= y - DEPTH_LABEL_GAP && currentPriceY <= y + DEPTH_LABEL_HEIGHT + DEPTH_LABEL_GAP) continue; + const edge = rect.width * (1 - level.cumulative / maxVisibleCumulative); + const x = Math.max(1, Math.min(edge + DEPTH_LABEL_GAP, rect.width - width - 1)); + const box = { x, y, width, height: DEPTH_LABEL_HEIGHT }; + if (occupied.some((other) => depthLabelBoxesOverlap(box, other))) continue; + context.fillStyle = background; + context.fillRect(x, y, width, DEPTH_LABEL_HEIGHT); + context.fillStyle = color; + context.fillText(text, x + DEPTH_LABEL_PADDING, y + DEPTH_LABEL_HEIGHT / 2); + occupied.push(box); + counts[level.side] += 1; + } + context.restore(); + } function renderDepthProfile(root, profile, geometry, currentPrice) { setDepthProfileGeometry(root, geometry); const canvas = root.querySelector(".jh-depth-profile-canvas"); @@ -4410,16 +4496,27 @@ drawSide(context, bids, maxVisibleCumulative, rect.width, "#0ecb81"); } 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, currentPriceY + 0.5); - context.lineTo(rect.width, currentPriceY + 0.5); - context.stroke(); - context.restore(); + if (Number.isFinite(currentPriceY)) { + context.save(); + context.strokeStyle = "rgba(240, 185, 11, .85)"; + context.lineWidth = 1; + context.setLineDash([3, 3]); + context.beginPath(); + context.moveTo(0, currentPriceY + 0.5); + context.lineTo(rect.width, currentPriceY + 0.5); + context.stroke(); + context.restore(); + } + if (maxVisibleCumulative > 0) { + drawDepthLabels(root, context, { + asks, + bids, + maxVisibleCumulative, + rect, + inverted: geometry.inverted, + currentPriceY + }); + } return true; } function clearDepthProfile(root) { diff --git a/src/binance-orderbook-trade/dom/depth-profile.js b/src/binance-orderbook-trade/dom/depth-profile.js index fb4fa17..18a6a36 100644 --- a/src/binance-orderbook-trade/dom/depth-profile.js +++ b/src/binance-orderbook-trade/dom/depth-profile.js @@ -5,6 +5,17 @@ const CHART_ROOT_SELECTOR = '.chart-widget-root'; const PRICE_AXIS_SELECTOR = '.chart-markup-table.price-axis-container'; const PRICE_COORDINATE_SEARCH_STEPS = 13; const GEOMETRY_TOLERANCE_PX = 1; +const DEPTH_LABEL_MAX_WIDTH = 88; +const DEPTH_LABEL_HEIGHT = 16; +const DEPTH_LABEL_PADDING = 3; +const DEPTH_LABEL_GAP = 2; +const DEPTH_LABEL_MIN_STEP = 8; +const DEPTH_LABELS_PER_SIDE = 2; +const DEPTH_QUANTITY_FORMAT = new Intl.NumberFormat('en-US', { + notation: 'compact', + maximumSignificantDigits: 3, + useGrouping: false, +}); function hasVisibleBox(element) { if (!element?.getClientRects().length) return false; @@ -138,6 +149,8 @@ function installStyle(document) { style.id = STYLE_ID; style.textContent = ` #${DEPTH_PROFILE_ID} { + --jh-depth-label-background: var(--color-BasicBg, #fff); + --jh-depth-label-color: var(--color-SecondaryText, #474d57); position: absolute; z-index: 3; top: 0; @@ -282,10 +295,18 @@ function bucketVisibleLevels(levels, geometry) { 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 }); + if (!current) { + buckets.set(y, { ...level, y, minPrice: level.price, maxPrice: level.price }); + continue; + } + // The bar needs the outermost cumulative value, while its label needs every + // real quantity in this pixel row, including prices hidden by the outermost one. + current.quantity += level.quantity; + current.minPrice = Math.min(current.minPrice, level.price); + current.maxPrice = Math.max(current.maxPrice, level.price); + if (level.cumulative > current.cumulative) { + current.price = level.price; + current.cumulative = level.cumulative; } } return [...buckets.values()].sort((left, right) => left.y - right.y); @@ -308,6 +329,92 @@ function getMaxVisibleCumulative(...levelGroups) { return maximum; } +function depthLabelBoxesOverlap(left, right) { + return left.x < right.x + right.width + DEPTH_LABEL_GAP + && left.x + left.width + DEPTH_LABEL_GAP > right.x + && left.y < right.y + right.height + DEPTH_LABEL_GAP + && left.y + left.height + DEPTH_LABEL_GAP > right.y; +} + +function depthLabelObstacles(root, canvasRect) { + const controls = [root.querySelector('[data-depth-profile-toggle]')]; + const status = root.querySelector('.jh-depth-profile-status'); + if (status.textContent) controls.push(status); + return controls.map((element) => element.getBoundingClientRect()) + .filter((rect) => rect.width > 0 && rect.height > 0) + .map((rect) => ({ + x: rect.left - canvasRect.left, + y: rect.top - canvasRect.top, + width: rect.width, + height: rect.height, + })); +} + +/** + * Labels describe visible price bands, not cumulative depth or changes over time. + * Keep their paint inside the existing canvas so hints never widen the overlay or + * create DOM mutations that schedule another chart-host synchronization. + */ +function drawDepthLabels(root, context, { + asks, bids, maxVisibleCumulative, rect, inverted, currentPriceY, +}) { + const candidates = [ + ...asks.map((level) => ({ ...level, side: 'ask' })), + ...bids.map((level) => ({ ...level, side: 'bid' })), + ].filter((level) => ( + level.quantity / maxVisibleCumulative * rect.width >= DEPTH_LABEL_MIN_STEP + )).sort((left, right) => right.quantity - left.quantity || left.minPrice - right.minPrice); + if (!candidates.length) return; + + const style = root.ownerDocument.defaultView.getComputedStyle(root); + const background = style.getPropertyValue('--jh-depth-label-background').trim(); + const color = style.getPropertyValue('--jh-depth-label-color').trim(); + const occupied = depthLabelObstacles(root, rect); + const counts = { ask: 0, bid: 0 }; + const maxWidth = Math.min(DEPTH_LABEL_MAX_WIDTH, rect.width - 2); + context.save(); + context.font = `11px ${style.fontFamily}`; + context.textAlign = 'left'; + context.textBaseline = 'middle'; + + for (const level of candidates) { + if (counts[level.side] === DEPTH_LABELS_PER_SIDE) continue; + const quantity = DEPTH_QUANTITY_FORMAT.format(level.quantity); + const price = level.minPrice === level.maxPrice + ? String(level.minPrice) + : `${level.minPrice}–${level.maxPrice}`; + let text = `${price} · ${quantity}`; + let width = Math.ceil(context.measureText(text).width) + DEPTH_LABEL_PADDING * 2; + // A long price band uses the quantity-only layout; never truncate a price or + // assign an entire band's quantity to one representative price. + if (width > maxWidth) { + text = quantity; + width = Math.ceil(context.measureText(text).width) + DEPTH_LABEL_PADDING * 2; + } + if (width > maxWidth) continue; + const above = level.side === 'ask' ? !inverted : inverted; + const y = above + ? level.y - DEPTH_LABEL_HEIGHT - DEPTH_LABEL_GAP + : level.y + DEPTH_LABEL_GAP; + if (y < 0 || y + DEPTH_LABEL_HEIGHT > rect.height) continue; + if (Number.isFinite(currentPriceY) + && currentPriceY >= y - DEPTH_LABEL_GAP + && currentPriceY <= y + DEPTH_LABEL_HEIGHT + DEPTH_LABEL_GAP) continue; + const edge = rect.width * (1 - level.cumulative / maxVisibleCumulative); + const x = Math.max(1, Math.min(edge + DEPTH_LABEL_GAP, rect.width - width - 1)); + const box = { x, y, width, height: DEPTH_LABEL_HEIGHT }; + if (occupied.some((other) => depthLabelBoxesOverlap(box, other))) continue; + + context.fillStyle = background; + context.fillRect(x, y, width, DEPTH_LABEL_HEIGHT); + context.fillStyle = color; + context.fillText(text, x + DEPTH_LABEL_PADDING, y + DEPTH_LABEL_HEIGHT / 2); + occupied.push(box); + counts[level.side] += 1; + } + context.restore(); +} + export function renderDepthProfile(root, profile, geometry, currentPrice) { setDepthProfileGeometry(root, geometry); const canvas = root.querySelector('.jh-depth-profile-canvas'); @@ -332,16 +439,22 @@ export function renderDepthProfile(root, profile, geometry, currentPrice) { } 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, currentPriceY + 0.5); - context.lineTo(rect.width, currentPriceY + 0.5); - context.stroke(); - context.restore(); + if (Number.isFinite(currentPriceY)) { + context.save(); + context.strokeStyle = 'rgba(240, 185, 11, .85)'; + context.lineWidth = 1; + context.setLineDash([3, 3]); + context.beginPath(); + context.moveTo(0, currentPriceY + 0.5); + context.lineTo(rect.width, currentPriceY + 0.5); + context.stroke(); + context.restore(); + } + if (maxVisibleCumulative > 0) { + drawDepthLabels(root, context, { + asks, bids, maxVisibleCumulative, rect, inverted: geometry.inverted, currentPriceY, + }); + } return true; } diff --git a/src/binance-orderbook-trade/index.user.js b/src/binance-orderbook-trade/index.user.js index 1e8863a..accc9e9 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.206 +// @version 2.7.207 // @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 cfb3469..5c1cf7f 100644 --- a/test/dom/binance-orderbook-trade/depth-profile.test.js +++ b/test/dom/binance-orderbook-trade/depth-profile.test.js @@ -347,10 +347,14 @@ test('draws bid and ask bars plus the latest-trade divider', () => { const root = ensureDepthProfileView(document, host, { onToggle: () => {} }); const calls = []; const fillStyles = []; - root.querySelector('canvas').getContext = () => ({ + const canvas = root.querySelector('canvas'); + canvas.getBoundingClientRect = () => ({ width: 132, height: 240, left: 0, top: 0 }); + canvas.getContext = () => ({ beginPath: () => calls.push('beginPath'), clearRect: () => calls.push('clearRect'), fillRect: (...args) => calls.push(['fillRect', ...args]), + fillText: () => {}, + measureText: (text) => ({ width: text.length * 6 }), lineTo: () => calls.push('lineTo'), moveTo: (...args) => calls.push(['moveTo', ...args]), restore: () => calls.push('restore'), @@ -364,6 +368,7 @@ test('draws bid and ask bars plus the latest-trade divider', () => { const geometry = { top: 0, height: 240, + inverted: false, priceToCoordinate: (price) => ({ 100: 180, 100.5: 140, 101: 80 }[price] ?? null), }; assert.equal(renderDepthProfile(root, { @@ -371,17 +376,18 @@ test('draws bid and ask bars plus the latest-trade divider', () => { maxPrice: 102, midPrice: 99.5, maxCumulative: 5, - bids: [{ price: 100, cumulative: 5 }], - asks: [{ price: 101, cumulative: 4 }, { price: 105, cumulative: 5 }], + bids: [{ price: 100, quantity: 5, cumulative: 5 }], + asks: [{ price: 101, quantity: 4, cumulative: 4 }, { price: 105, quantity: 1, 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(fillStyles, [ + const bars = calls.filter((call) => Array.isArray(call) && call[0] === 'fillRect' && call[4] === 1); + assert.equal(bars.length, 2); + assert.deepEqual(fillStyles.slice(0, 2), [ '#f6465d', '#0ecb81', ]); assert.deepEqual( - calls.filter((call) => Array.isArray(call) && call[0] === 'fillRect').map((call) => call[2]), + bars.map((call) => call[2]), [80, 180], ); assert.deepEqual(calls.find((call) => Array.isArray(call) && call[0] === 'moveTo'), ['moveTo', 0, 140.5]); @@ -399,10 +405,13 @@ test('draws one bar per visible CSS pixel row and scales width to visible depth' const root = ensureDepthProfileView(document, host, { onToggle: () => {} }); const fillRects = []; const canvas = root.querySelector('canvas'); + canvas.getBoundingClientRect = () => ({ width: 132, height: 240, left: 0, top: 0 }); canvas.getContext = () => ({ beginPath: () => {}, clearRect: () => {}, - fillRect: (...args) => fillRects.push(args), + fillRect: (...args) => { if (args[3] === 1) fillRects.push(args); }, + fillText: () => {}, + measureText: (text) => ({ width: text.length * 6 }), lineTo: () => {}, moveTo: () => {}, restore: () => {}, @@ -415,6 +424,7 @@ test('draws one bar per visible CSS pixel row and scales width to visible depth' const geometry = { top: 0, height: 240, + inverted: false, priceToCoordinate: (price) => ({ 99: 180.4, 101: 80.2, @@ -423,11 +433,11 @@ test('draws one bar per visible CSS pixel row and scales width to visible depth' }; renderDepthProfile(root, { maxCumulative: 1_000, - bids: [{ price: 99, cumulative: 4 }], + bids: [{ price: 99, quantity: 4, cumulative: 4 }], asks: [ - { price: 101, cumulative: 3 }, - { price: 102, cumulative: 8 }, - { price: 110, cumulative: 1_000 }, + { price: 101, quantity: 3, cumulative: 3 }, + { price: 102, quantity: 5, cumulative: 8 }, + { price: 110, quantity: 992, cumulative: 1_000 }, ], }, geometry, 100); @@ -438,6 +448,155 @@ test('draws one bar per visible CSS pixel row and scales width to visible depth' assert.equal(fillRects[1][2], canvas.getBoundingClientRect().width / 2); }); +function createLabelRenderer({ width = 132, height = 240, inverted = false, coordinates } = {}) { + const dom = createChartDom(); + const { document } = dom.window; + const { host } = findDepthProfileHost(document); + const root = ensureDepthProfileView(document, host, { onToggle: () => {} }); + const canvas = root.querySelector('canvas'); + canvas.getBoundingClientRect = () => ({ width, height, left: 0, top: 0 }); + root.querySelector('[data-depth-profile-toggle]').getBoundingClientRect = () => ({ + left: width - 28, top: 8, width: 24, height: 24, + }); + const rectangles = []; + const labels = []; + const context = { + beginPath() {}, + clearRect() { rectangles.length = 0; labels.length = 0; }, + fillRect(x, y, boxWidth, boxHeight) { + rectangles.push({ x, y, width: boxWidth, height: boxHeight, color: this.fillStyle }); + }, + fillText(text, x, y) { labels.push({ text, x, y, font: this.font }); }, + measureText: (text) => ({ width: text.length * 6 }), + lineTo() {}, + moveTo() {}, + restore() {}, + save() {}, + setLineDash() {}, + setTransform() {}, + stroke() {}, + }; + canvas.getContext = () => context; + const geometry = { + top: 0, + height, + rightInset: 88, + inverted, + priceToCoordinate: (price) => coordinates[price] ?? null, + }; + return { + root, + labels, + rectangles, + render: (profile, currentPrice = null) => renderDepthProfile(root, profile, geometry, currentPrice), + }; +} + +function depthLevels(entries) { + let cumulative = 0; + return entries.map(([price, quantity]) => { + cumulative += quantity; + return { price, quantity, cumulative }; + }); +} + +test('labels the complete visible pixel-row quantity, not its last level or offscreen cumulative depth', () => { + const view = createLabelRenderer({ coordinates: { 99: 180.4, 101: 80.2, 102: 80.4 } }); + view.render({ + maxCumulative: 1_000, + bids: depthLevels([[99, 4]]), + asks: depthLevels([[101, 3], [102, 5], [110, 992]]), + }); + + assert.deepEqual(view.labels.map(({ text }) => text), ['101–102 · 8', '99 · 4']); + assert.deepEqual(view.rectangles.filter(({ height }) => height === 1), [ + { x: 0, y: 80, width: 132, height: 1, color: '#f6465d' }, + { x: 66, y: 180, width: 66, height: 1, color: '#0ecb81' }, + ]); +}); + +test('chooses at most two large quantity steps per side instead of the largest cumulative bars', () => { + const view = createLabelRenderer({ coordinates: { 99: 220, 101: 180, 102: 160, 103: 140, 104: 120, 105: 100, 106: 80 } }); + view.render({ + bids: depthLevels([[99, 1]]), + asks: depthLevels([[101, 300], [102, 5], [103, 200], [104, 4], [105, 100], [106, 3]]), + }); + + assert.deepEqual(view.labels.map(({ text }) => text), ['101 · 300', '103 · 200']); +}); + +test('uses quantity-only text when a complete price band is too wide and keeps every label inside the canvas', () => { + const view = createLabelRenderer({ coordinates: { 0.00010001: 80.2, 0.00010002: 80.4, 0.00009: 180 } }); + view.render({ + bids: depthLevels([[0.00009, 620_000]]), + asks: depthLevels([[0.00010001, 2_400_000], [0.00010002, 1_400_000]]), + }); + + assert.deepEqual(view.labels.map(({ text }) => text), ['3.8M', '620K']); + for (const box of view.rectangles.filter(({ height }) => height === 16)) { + assert.ok(box.width <= 88); + assert.ok(box.x >= 0 && box.x + box.width <= 132); + assert.ok(box.y >= 0 && box.y + box.height <= 240); + } + assert.deepEqual(view.rectangles.filter(({ height }) => height === 16).map(({ width }) => width), [30, 30]); +}); + +test('formats quantities without trailing zeroes, unit-boundary errors, or rounding small nonzero amounts to zero', () => { + const view = createLabelRenderer({ coordinates: { 101: 80 } }); + for (const [quantity, expected] of [[2_400_000, '101 · 2.4M'], [999_950, '101 · 1M'], [0.00002, '101 · 0.00002']]) { + view.render({ bids: [], asks: depthLevels([[101, quantity]]) }); + assert.deepEqual(view.labels.map(({ text }) => text), [expected]); + } +}); + +test('keeps nearby labels apart and does not cover the current-price divider', () => { + const view = createLabelRenderer({ coordinates: { 99: 150, 100: 110, 101: 125, 102: 95, 103: 90 } }); + view.render({ + bids: depthLevels([[99, 80]]), + asks: depthLevels([[101, 70], [102, 100], [103, 90]]), + }, 100); + + assert.deepEqual(view.labels.map(({ text }) => text), ['102 · 100', '99 · 80']); + const boxes = view.rectangles.filter(({ height }) => height === 16); + assert.ok(boxes.every((box) => box.y + box.height < 110 || box.y > 110)); + assert.ok(boxes[0].y + boxes[0].height < boxes[1].y); +}); + +test('places bid and ask labels on opposite sides of a shared pixel row, including inverted scales', () => { + for (const inverted of [false, true]) { + const coordinates = inverted ? { 99: 100.2, 101: 100.4 } : { 99: 100.4, 101: 100.2 }; + const view = createLabelRenderer({ inverted, coordinates }); + view.render({ bids: depthLevels([[99, 9]]), asks: depthLevels([[101, 10]]) }); + + assert.deepEqual(view.labels.map(({ text }) => text), ['101 · 10', '99 · 9']); + assert.deepEqual(view.rectangles.filter(({ height }) => height === 16).map(({ y }) => y), inverted ? [102, 82] : [82, 102]); + } +}); + +test('omits labels that would overlap the collapse button or extend past the vertical canvas boundary', () => { + const view = createLabelRenderer({ coordinates: { 99: 220, 101: 38, 102: 10 } }); + view.render({ + bids: depthLevels([[99, 1_000]]), + asks: depthLevels([[101, 200], [102, 100]]), + }); + assert.deepEqual(view.labels.map(({ text }) => text), ['99 · 1K']); + + const narrow = createLabelRenderer({ width: 20, coordinates: { 101: 80 } }); + narrow.render({ bids: [], asks: depthLevels([[101, 620_000]]) }); + assert.deepEqual(narrow.labels, []); +}); + +test('repaints changed quantities and removes old labels when the depth canvas clears', () => { + const view = createLabelRenderer({ coordinates: { 101: 80 } }); + view.render({ bids: [], asks: depthLevels([[101, 3_800_000]]) }); + assert.deepEqual(view.labels.map(({ text }) => text), ['101 · 3.8M']); + view.render({ bids: [], asks: depthLevels([[101, 1_500_000]]) }); + assert.deepEqual(view.labels.map(({ text }) => text), ['101 · 1.5M']); + clearDepthProfile(view.root); + assert.deepEqual(view.labels, []); + assert.deepEqual(view.rectangles, []); +}); + test('updates root geometry without rewriting unchanged styles', () => { const dom = createChartDom(); const { document } = dom.window;