Разработка веб-терминала с графиками и стаканом

Проектируем и разрабатываем блокчейн-решения полного цикла: от архитектуры смарт-контрактов до запуска DeFi-протоколов, NFT-маркетплейсов и криптобирж. Аудит безопасности, токеномика, интеграция с существующей инфраструктурой.
Показано 1 из 1 услугВсе 1306 услуг
Разработка веб-терминала с графиками и стаканом
Сложная
от 2 недель до 3 месяцев
Часто задаваемые вопросы
Направления блокчейн-разработки
Этапы блокчейн-разработки
Последние работы
  • image_website-b2b-advance_0.png
    Разработка сайта компании B2B ADVANCE
    1221
  • image_web-applications_feedme_466_0.webp
    Разработка веб-приложения для компании FEEDME
    1163
  • image_websites_belfingroup_462_0.webp
    Разработка веб-сайта для компании БЕЛФИНГРУПП
    855
  • image_ecommerce_furnoro_435_0.webp
    Разработка интернет магазина для компании FURNORO
    1062
  • image_logo-advance_0.png
    Разработка логотипа компании B2B Advance
    561
  • image_crm_enviok_479_0.webp
    Разработка веб-приложения для компании Enviok
    828

Разработка веб-терминала с графиками и стаканом

Веб-торговый терминал с графиками и стаканом — самый технически насыщенный тип веб-приложений. Одновременно: 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" дольше нескольких секунд.