Розроблення візуалізації DOM (Depth of Market)
DOM (Depth of Market), також відомий як Level 2 дані — це візуалізація усього стакана ордерів, а не тільки найкращої ціни. Професійні трейдери читають DOM як книгу: бачать стіни ліквідності, поглинення обсягів, спуфінг. Хорошо реалізований DOM — один з ключових аргументів для залучення професіоналів на біржу.
Структура DOM
DOM відображає два стовпці: bid (покупки) та ask (продажі) з агрегованими обсягами на кожному ціновому рівні.
BID ASK
Volume Price Price Volume
0.5 42,100 | 42,101 1.2
1.8 42,095 | 42,102 0.7
3.2 42,090 | 42,105 4.5 ← wall
0.4 42,085 | 42,110 0.9
2.1 42,080 | 42,115 1.1
"Стіна" (wall) — аномально великий обсяг на рівні — часто вказує на зону підтримки/опору. Трейдери стежать як ці обсяги з'являються, змінюються та зникають.
Реалізація WebSocket оновлень
DOM потребує мінімальної latency. Оновлення через WebSocket diff, клієнт підтримує локальну копію:
interface DOMState {
bids: Map<string, string>; // price -> size
asks: Map<string, string>;
sequence: number;
}
class DOMManager {
private state: DOMState = { bids: new Map(), asks: new Map(), sequence: 0 };
private ws: WebSocket;
async initialize(pair: string) {
// 1. Завантажуємо снепшот
const snap = await fetch(`/api/v1/markets/${pair}/orderbook?depth=100`).then(r => r.json());
snap.bids.forEach(([p, s]: string[]) => this.state.bids.set(p, s));
snap.asks.forEach(([p, s]: string[]) => this.state.asks.set(p, s));
this.state.sequence = snap.sequence;
// 2. Підписуємось на diff updates
this.ws = new WebSocket(`wss://api.exchange.com/ws`);
this.ws.send(JSON.stringify({ op: 'subscribe', channel: `orderbook.${pair}.100` }));
this.ws.onmessage = (e) => this.applyUpdate(JSON.parse(e.data));
}
private applyUpdate(msg: OrderBookDiff) {
if (msg.seq !== this.state.sequence + 1) {
this.reinitialize(); // gap — потрібен новий снепшот
return;
}
msg.bids.forEach(([p, s]: string[]) => {
if (s === '0') this.state.bids.delete(p);
else this.state.bids.set(p, s);
});
msg.asks.forEach(([p, s]: string[]) => {
if (s === '0') this.state.asks.delete(p);
else this.state.asks.set(p, s);
});
this.state.sequence = msg.seq;
this.notifyRenderers();
}
// Повертає топ N рівнів у потрібному форматі
getTopLevels(depth: number = 20) {
const bids = [...this.state.bids.entries()]
.map(([p, s]) => [parseFloat(p), parseFloat(s)] as [number, number])
.sort((a, b) => b[0] - a[0])
.slice(0, depth);
const asks = [...this.state.asks.entries()]
.map(([p, s]) => [parseFloat(p), parseFloat(s)] as [number, number])
.sort((a, b) => a[0] - b[0])
.slice(0, depth);
return { bids, asks };
}
}
Рендеринг DOM компонента
Високочастотні оновлення DOM (до 20–50 разів/сек на активних парах) потребують оптимізованого рендерингу. Використання звичайного React state з re-render при кожному оновленні дасть проблеми з продуктивністю.
import { useRef, useCallback } from 'react';
// Прямий DOM manipulation для hot path
const DOMRow = React.memo(({ price, size, total, maxTotal, side, highlight }: RowProps) => {
const rowRef = useRef<HTMLDivElement>(null);
// Оновлюємо DOM напрямку без React re-render
const update = useCallback((newSize: string, newTotal: number) => {
if (!rowRef.current) return;
const sizeEl = rowRef.current.querySelector('.size');
const depthEl = rowRef.current.querySelector('.depth-bar') as HTMLElement;
if (sizeEl) sizeEl.textContent = newSize;
if (depthEl) depthEl.style.width = `${(newTotal / maxTotal) * 100}%`;
}, [maxTotal]);
// Flash анімація при зміні
const flash = useCallback((direction: 'up' | 'down') => {
rowRef.current?.classList.add(`flash-${direction}`);
setTimeout(() => rowRef.current?.classList.remove(`flash-${direction}`), 300);
}, []);
return (
<div ref={rowRef} className={`dom-row ${side}`}>
<div className="depth-bar" style={{ width: `${(total/maxTotal)*100}%` }} />
<span className="price">{formatPrice(price)}</span>
<span className="size">{formatSize(size)}</span>
<span className="total">{formatSize(total)}</span>
</div>
);
});
Візуальні особливості професійного DOM
Підсвітка змін
// Detect changes between updates
function diffDOMStates(prev: DOMLevel[], curr: DOMLevel[]) {
const changes: Map<string, 'increased' | 'decreased' | 'new' | 'removed'> = new Map();
const prevMap = new Map(prev.map(l => [l.price, l.size]));
const currMap = new Map(curr.map(l => [l.price, l.size]));
for (const [price, size] of currMap) {
const prevSize = prevMap.get(price);
if (!prevSize) changes.set(price, 'new');
else if (size > prevSize) changes.set(price, 'increased');
else if (size < prevSize) changes.set(price, 'decreased');
}
for (const price of prevMap.keys()) {
if (!currMap.has(price)) changes.set(price, 'removed');
}
return changes;
}
Тик групування
// Користувач переключає групування: 1, 5, 10, 25, 100
function groupByTick(levels: DOMLevel[], tickSize: number): DOMLevel[] {
const grouped = new Map<number, number>();
for (const { price, size } of levels) {
const bucket = Math.floor(price / tickSize) * tickSize;
grouped.set(bucket, (grouped.get(bucket) ?? 0) + size);
}
return [...grouped.entries()]
.map(([price, size]) => ({ price, size }))
.sort((a, b) => b.price - a.price);
}
Cumulative volume visualization
Накопичувальний обсяг показує суммарну ліквідність до кожного рівня — видно, наскільки глибокий стакан:
function addCumulative(levels: DOMLevel[]): DOMLevelWithCum[] {
let cumulative = 0;
return levels.map(level => {
cumulative += level.size;
return { ...level, cumulative };
});
}
Продуктивність
На активних парах (BTC/USDT на крупних біржах) DOM може оновлюватися 10–50 разів/сек. Обмеження:
- Throttle updates: не більше 10 рендерів/сек для DOM (людське око не сприймає швидше)
- Virtual scrolling: якщо показуємо > 50 рівнів — FlashList або react-window
- Canvas rendering: для максимальної продуктивності можна рендерити DOM у Canvas замість HTML
// Throttle рендеринга до 10 fps
const throttledRender = useCallback(
throttle((domData: DOMData) => {
setDisplayData(domData);
}, 100), // 100ms = 10 fps
[]
);
Розроблення DOM візуалізатора з real-time оновленнями, групуванням тиків та підсвіткою змін: 3–5 тижнів.







