Разработка веб-терминала с графиками и стаканом
Веб-торговый терминал с графиками и стаканом — самый технически насыщенный тип веб-приложений. Одновременно: real-time данные в нескольких потоках, визуализация финансовых данных, высокочастотные обновления UI, сложная бизнес-логика. Архитектурные решения принятые в начале определяют масштабируемость и производительность на годы вперёд.
Стек технологий
Frontend: React 18 + TypeScript, Zustand (state management), TradingView Lightweight Charts или Advanced Charts, react-virtual (виртуализация), WebSocket через кастомный хук.
Backend: FastAPI (Python) или Fastify (Node.js) для WebSocket gateway, Redis для кэширования состояния стакана и pub/sub.
Data Layer: ClickHouse или TimescaleDB для исторических OHLCV данных.
Граф данных
Binance WS → Exchange Connector → Redis PubSub → WS Gateway → Browser
↘
ClickHouse (persist)
Browser подписывается на Gateway через WebSocket. Gateway мультиплексирует данные от биржи для всех подключённых клиентов.
Стакан (Order Book): детали реализации
Order book обновляется через дифференциальные апдейты. Binance даёт снапшот через REST и последующие diff'ы через WebSocket:
class OrderBookManager {
private bids: Map<number, number> = new Map();
private asks: Map<number, number> = new Map();
private lastUpdateId: number = 0;
private buffer: OrderBookDiff[] = [];
async initialize(symbol: string) {
// 1. Подписываемся на diff stream
const ws = this.connectToStream(`${symbol.toLowerCase()}@depth`);
// 2. Получаем снапшот (после подписки!)
const snapshot = await fetchOrderBookSnapshot(symbol, 1000);
// 3. Применяем снапшот
this.lastUpdateId = snapshot.lastUpdateId;
this.bids = new Map(snapshot.bids.map(([p, q]) => [+p, +q]));
this.asks = new Map(snapshot.asks.map(([p, q]) => [+p, +q]));
// 4. Применяем буферизированные дифы, пропуская устаревшие
for (const diff of this.buffer) {
if (diff.U <= this.lastUpdateId + 1 && diff.u >= this.lastUpdateId + 1) {
this.applyDiff(diff);
}
}
this.buffer = [];
}
applyDiff(diff: OrderBookDiff) {
// Proверяем последовательность
if (diff.U !== this.lastUpdateId + 1) {
console.error('Gap detected, reinitializing...');
this.initialize(this.symbol);
return;
}
for (const [price, qty] of diff.b) {
if (+qty === 0) this.bids.delete(+price);
else this.bids.set(+price, +qty);
}
for (const [price, qty] of diff.a) {
if (+qty === 0) this.asks.delete(+price);
else this.asks.set(+price, +qty);
}
this.lastUpdateId = diff.u;
this.notifySubscribers();
}
getTopLevels(depth: number = 20) {
const sortedBids = [...this.bids.entries()]
.sort(([a], [b]) => b - a)
.slice(0, depth);
const sortedAsks = [...this.asks.entries()]
.sort(([a], [b]) => a - b)
.slice(0, depth);
return { bids: sortedBids, asks: sortedAsks };
}
}
Компонент стакана с виртуализацией
import { useVirtualizer } from '@tanstack/react-virtual';
import { useOrderBook } from '../hooks/useOrderBook';
const ORDER_BOOK_ROW_HEIGHT = 20;
export function OrderBook({ symbol }: { symbol: string }) {
const { bids, asks, spread } = useOrderBook(symbol);
const parentRef = React.useRef<HTMLDivElement>(null);
const bidVirtualizer = useVirtualizer({
count: bids.length,
getScrollElement: () => parentRef.current,
estimateSize: () => ORDER_BOOK_ROW_HEIGHT,
overscan: 5,
});
return (
<div className="order-book">
<div className="asks-section">
{asks.slice(0, 20).reverse().map(([price, qty, total]) => (
<OrderBookRow key={price} side="ask" price={price} qty={qty} total={total} />
))}
</div>
<div className="spread-row">
Spread: {spread.toFixed(2)} ({spreadBps.toFixed(2)} bps)
</div>
<div ref={parentRef} className="bids-section">
<div style={{ height: bidVirtualizer.getTotalSize() }}>
{bidVirtualizer.getVirtualItems().map((virtualItem) => {
const [price, qty, total] = bids[virtualItem.index];
return (
<div
key={virtualItem.key}
style={{ transform: `translateY(${virtualItem.start}px)` }}
>
<OrderBookRow side="bid" price={price} qty={qty} total={total} />
</div>
);
})}
</div>
</div>
</div>
);
}
Графики: TradingView Lightweight Charts
Для большинства web-терминалов TradingView Lightweight Charts — оптимальный выбор:
import { createChart, CandlestickSeries } from 'lightweight-charts';
function TradingChart({ symbol }: { symbol: string }) {
const chartContainerRef = useRef<HTMLDivElement>(null);
const seriesRef = useRef<CandlestickSeries>();
useEffect(() => {
const chart = createChart(chartContainerRef.current!, {
width: chartContainerRef.current!.clientWidth,
height: 400,
layout: {
background: { color: '#1a1a2e' },
textColor: '#d1d4dc',
},
grid: {
vertLines: { color: '#2d2d4e' },
horzLines: { color: '#2d2d4e' },
},
crosshair: { mode: 1 },
timeScale: { borderColor: '#485c7b', timeVisible: true },
});
const candleSeries = chart.addCandlestickSeries({
upColor: '#26a69a',
downColor: '#ef5350',
borderUpColor: '#26a69a',
borderDownColor: '#ef5350',
});
seriesRef.current = candleSeries;
// Загружаем исторические данные
loadHistoricalData(symbol, '1h').then((candles) => {
candleSeries.setData(candles);
chart.timeScale().fitContent();
});
// Подписка на новые свечи
const unsubscribe = wsGateway.subscribe(`candle:${symbol}:1h`, (candle) => {
candleSeries.update(candle);
});
return () => {
unsubscribe();
chart.remove();
};
}, [symbol]);
return <div ref={chartContainerRef} className="chart-container" />;
}
Производительность
Критические точки оптимизации:
Throttle updates — стакан может получать 50+ обновлений в секунду. Применяем все diff'ы в памяти, но рендерим не чаще чем через requestAnimationFrame:
let pendingRender = false;
function scheduleRender() {
if (!pendingRender) {
pendingRender = true;
requestAnimationFrame(() => {
pendingRender = false;
renderOrderBook();
});
}
}
Immutable updates с memo — React.memo + useMemo для строк стакана, чтобы избежать лишних рендеров.
Canvas вместо DOM для depth chart — кумулятивный объём стакана на Canvas работает значительно быстрее DOM для частых обновлений.
WebSocket Connection Management
export function useWebSocket(url: string) {
const wsRef = useRef<WebSocket | null>(null);
const reconnectTimerRef = useRef<NodeJS.Timeout>();
const connect = useCallback(() => {
wsRef.current = new WebSocket(url);
wsRef.current.onclose = (e) => {
if (!e.wasClean) {
// Exponential backoff
const delay = Math.min(1000 * 2 ** reconnectCount, 30000);
reconnectTimerRef.current = setTimeout(connect, delay);
}
};
wsRef.current.onerror = () => wsRef.current?.close();
}, [url]);
useEffect(() => {
connect();
return () => {
clearTimeout(reconnectTimerRef.current);
wsRef.current?.close();
};
}, [connect]);
return wsRef;
}
Правильный reconnect с exponential backoff — обязательная часть production-терминала. Пользователь не должен видеть "disconnected" дольше нескольких секунд.







