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

Проєктуємо та розробляємо блокчейн-рішення повного циклу: від архітектури смарт-контрактів до запуску DeFi-протоколів, NFT-маркетплейсів та криптобірж. Аудит безпеки, токеноміка, інтеграція з наявною інфраструктурою.
Показано 1 з 1Усі 1306 послуг
Розробка веб-терміналу з графіками та стаканом
Складний
від 2 тижнів до 3 місяців
Часті запитання

Напрямки блокчейн-розробки

Етапи блокчейн-розробки

Останні роботи

  • image_website-b2b-advance_0.webp
    Розробка сайту компанії B2B ADVANCE
    1288
  • image_web-applications_feedme_466_0.webp
    Розробка веб-додатків для компанії FEEDME
    1198
  • image_websites_belfingroup_462_0.webp
    Розробка веб-сайту для компанії БЕЛФІНГРУП
    902
  • image_ecommerce_furnoro_435_0.webp
    Розробка інтернет магазину для компанії FURNORO
    1122
  • image_logo-advance_0.webp
    Розробка логотипу компанії B2B Advance
    589
  • image_crm_enviok_479_0.webp
    Розробка веб-додатків для компанії Enviok
    859

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

Веб-терміналу торгівлі з графіками та стаканом — найбільш технічно насичений тип веб-додатків. Одночасно: дані в реальному часі у кількох потоках, візуалізація фінансових даних, високочастотні оновлення UI, складна бізнес-логіка. Архітектурні рішення, прийняті на початку, визначають масштабованість та продуктивність на роки вперед.

Стек технологій

Frontend: React 18 + TypeScript, Zustand (керування станом), TradingView Lightweight Charts або Advanced Charts, react-virtual (віртуалізація), WebSocket через користувацький hook.

Backend: FastAPI (Python) або Fastify (Node.js) для WebSocket gateway, Redis для кешування стану стакану та pub/sub.

Шар даних: ClickHouse або TimescaleDB для історичних даних OHLCV.

Граф потоку даних

Binance WS → Exchange Connector → Redis PubSub → WS Gateway → Browser
                                              ↘
                                        ClickHouse (persist)

Браузер підписується на Gateway через WebSocket. Gateway мультиплексує дані від біржі для всіх підключених клієнтів.

Стакан ордерів: деталі реалізації

Стакан ордерів оновлюється через диференціальні оновлення. Binance надає снимок через REST та подальші відмінності через 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) {
    // Перевіряємо послідовність
    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

Для більшості веб-терміналів 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" />;
}

Продуктивність

Критичні точки оптимізації:

Регулювання оновлень — стакан може отримувати 50+ оновлень на секунду. Застосовуємо всі різниці в пам'яті, але рендеримо не частіше ніж через requestAnimationFrame:

let pendingRender = false;

function scheduleRender() {
  if (!pendingRender) {
    pendingRender = true;
    requestAnimationFrame(() => {
      pendingRender = false;
      renderOrderBook();
    });
  }
}

Незмінні оновлення з memo — React.memo + useMemo для рядків стакану, щоб запобігти непотрібним перерендеруванням.

Canvas замість DOM для depth chart — кумулятивний обсяг стакану на Canvas працює значно швидше DOM для частих оновлень.

Керування підключенням WebSocket

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) {
        // Експоненціальна затримка
        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;
}

Правильне повторне з'єднання з експоненціальною затримкою — обов'язкова частина виробничого терміналу. Користувач не повинен бачити "disconnected" довше кількох секунд.