Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
17 changes: 17 additions & 0 deletions docs/binance-orderbook-trade-development.md
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
211 changes: 211 additions & 0 deletions e2e/binance-orderbook/helpers/depth-profile-fixture.js
Original file line number Diff line number Diff line change
@@ -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 = `<!doctype html><html><head><style>
html, body { margin: 0; height: 100%; font: 11px Arial, sans-serif; color: #707a8a; }
#depth-chart-surface { position: absolute; inset: 0 60px 0 0; border: 0; padding: 12px;
background: repeating-linear-gradient(to bottom, #fff 0 51px, #f0f1f2 51px 52px);
color: #707a8a; text-align: left; cursor: crosshair; }
#depth-chart-surface span { position: absolute; top: 12px; left: 12px; }
.price-axis-container { position: absolute; top: 0; right: 0; width: 60px; height: 260px;
border-left: 1px solid #eaecef; box-sizing: border-box; background: #fff; }
.price-axis-container span { position: absolute; left: 8px; }
</style></head><body>
<button id="depth-chart-surface" aria-label="Chart interaction surface"><span></span></button>
<div class="chart-markup-table price-axis-container">
<span style="top:0">2.2</span><span style="top:46px">2.0</span>
<span style="top:98px">1.8</span><span style="top:150px">1.6</span>
<span style="top:202px">1.4</span><span style="bottom:0">1.2</span>
</div>
</body></html>`;
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 });
}
155 changes: 155 additions & 0 deletions e2e/binance-orderbook/specs/depth-profile-labels.pw.js
Original file line number Diff line number Diff line change
@@ -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');
});
Loading
Loading