T-CREATOR

WebSocket 活用事例:金融トレーディング板情報の超低遅延配信アーキテクチャ

WebSocket 活用事例:金融トレーディング板情報の超低遅延配信アーキテクチャ

金融市場におけるトレーディングシステムでは、ミリ秒単位の遅延が収益性を大きく左右します。特に板情報(オーダーブック)のリアルタイム配信は、デイトレーダーやアルゴリズム取引において最も重要な情報源となるでしょう。本記事では、WebSocket を活用した超低遅延の板情報配信アーキテクチャについて、実装例とともに詳しく解説していきます。

従来の HTTP ポーリング方式では、刻々と変化する板情報をリアルタイムに取得することが困難でした。また、大量のクライアントに対して高頻度で更新情報を配信する必要があるため、サーバー負荷とネットワーク帯域の課題も深刻です。WebSocket はこれらの課題を解決し、双方向通信による低遅延配信を実現できる技術として注目されています。

背景

金融トレーディングにおける板情報配信では、従来から複数の技術が採用されてきました。

従来の板情報配信方式

初期のトレーディングシステムでは、HTTP による定期的なポーリングで板情報を取得していました。この方式では、クライアントが一定間隔(例えば 1 秒ごと)でサーバーにリクエストを送信し、最新の板情報を受け取ります。

しかし、ポーリング方式には以下の課題がありました。

  • リクエスト間隔の分だけ情報取得が遅延する
  • 更新がない場合でも無駄なリクエストが発生する
  • クライアント数の増加に伴いサーバー負荷が線形に増大する
  • HTTP ヘッダーのオーバーヘッドが大きい

これらの課題を解決するため、Long Polling や Server-Sent Events(SSE)などの技術も登場しましたが、真の双方向通信には対応できませんでした。

WebSocket の登場と金融分野での採用

WebSocket は 2011 年に RFC 6455 として標準化された、全二重通信を実現するプロトコルです。一度接続を確立すれば、サーバーとクライアント双方から任意のタイミングでデータを送信できます。

金融業界では、板情報配信において以下の理由から WebSocket の採用が進んでいます。

  • 接続確立後のオーバーヘッドが極めて小さい
  • サーバープッシュによる即座の情報配信が可能
  • 複数通貨ペア・銘柄の同時購読に対応しやすい
  • バイナリデータ送信により帯域を削減できる

以下の図は、従来の HTTP ポーリングと WebSocket による配信方式の違いを示しています。

mermaidsequenceDiagram
    participant C as クライアント
    participant S as サーバー
    participant M as 市場データ

    rect rgb(240, 240, 240)
        note right of C: HTTP ポーリング方式
        C->>S: リクエスト(1秒ごと)
        S->>C: 板情報(変更なし)
        M->>S: 価格更新
        C->>S: リクエスト
        S->>C: 板情報(最新)
        note right of C: 遅延: 最大1秒
    end

    rect rgb(230, 245, 255)
        note right of C: WebSocket 方式
        C->>S: WebSocket 接続
        S->>C: 接続確立
        M->>S: 価格更新
        S->>C: 板情報(即座にプッシュ)
        M->>S: 価格更新
        S->>C: 板情報(即座にプッシュ)
        note right of C: 遅延: ミリ秒単位
    end

HTTP ポーリングでは市場データの更新タイミングとリクエストタイミングがずれるため、最大でポーリング間隔分の遅延が発生します。一方、WebSocket では市場データの更新を検知した瞬間にクライアントへプッシュ配信できるため、遅延を最小限に抑えられるでしょう。

課題

金融トレーディング向けの板情報配信システムを構築する際、技術的に解決すべき課題が複数存在します。

超低遅延の実現

デイトレーダーやアルゴリズム取引では、ミリ秒単位の遅延が取引結果に影響を与えます。特に高頻度取引(HFT)では、数ミリ秒の差が大きな収益機会の損失につながることもあるでしょう。

板情報配信における遅延は、以下の要因で発生します。

  • 取引所からの市場データ取得遅延
  • サーバー内でのデータ処理時間
  • ネットワーク転送時間
  • クライアント側での描画処理時間

システム設計では、これら各段階の遅延を最小化する必要があります。

大量接続への対応

人気のある銘柄や通貨ペアでは、数万から数十万のクライアントが同時に板情報を購読するケースがあります。各クライアントに対して高頻度で更新情報を配信するため、サーバーには高いスループットが求められるでしょう。

従来の 1 スレッド 1 接続モデルでは、大量の WebSocket 接続を処理できません。イベント駆動型のアーキテクチャや効率的な配信機構が必要となります。

帯域幅の最適化

板情報は秒間数百回以上更新されることもあり、全ての更新を JSON 形式で送信すると膨大な帯域を消費します。

例えば、1 つの板情報を JSON で表現すると以下のようなサイズになります。

json{
  "symbol": "BTC/JPY",
  "timestamp": 1698765432000,
  "bids": [
    { "price": 4500000, "amount": 0.5 },
    { "price": 4499900, "amount": 1.2 }
  ],
  "asks": [
    { "price": 4500100, "amount": 0.8 },
    { "price": 4500200, "amount": 1.5 }
  ]
}

このデータは約 200 バイト程度ですが、秒間 100 回更新される場合、1 クライアントあたり 20KB/秒の帯域を消費します。1 万クライアントでは約 200MB/秒となり、サーバーの送信帯域が逼迫するでしょう。

データ整合性の保証

ネットワーク障害や一時的な接続断が発生した際、クライアントが最新の板情報を正確に復元できる仕組みが必要です。

以下の図は、板情報配信システムにおける主要な課題を整理したものです。

mermaidflowchart TB
    subgraph challenges["板情報配信の主要課題"]
        latency["超低遅延の実現<br/>目標: 10ms以内"]
        scale["大量接続への対応<br/>数万〜数十万接続"]
        bandwidth["帯域幅の最適化<br/>バイナリ化・差分配信"]
        consistency["データ整合性<br/>再接続時の同期"]
    end

    subgraph impacts["ビジネスへの影響"]
        trading["取引機会の損失"]
        cost["インフラコスト増大"]
        ux["ユーザー体験の低下"]
    end

    latency -.->|遅延大| trading
    scale -.->|対応不足| cost
    bandwidth -.->|帯域不足| cost
    consistency -.->|データ欠損| ux

    style challenges fill:#fff4e6
    style impacts fill:#ffe6e6

これらの課題は相互に関連しており、総合的なアーキテクチャ設計が求められます。例えば、帯域幅を削減するためにバイナリ形式を採用すると、データ整合性の検証が複雑になるといったトレードオフも存在するでしょう。

解決策

WebSocket を活用した超低遅延の板情報配信アーキテクチャでは、複数の技術的アプローチを組み合わせて課題を解決します。

システムアーキテクチャの全体像

板情報配信システムは、以下のレイヤーで構成されます。

mermaidflowchart LR
    subgraph exchange["取引所"]
        mkt["市場データ<br/>フィード"]
    end

    subgraph backend["バックエンド"]
        receiver["データ受信<br/>サービス"]
        processor["データ処理<br/>エンジン"]
        pubsub["Pub/Sub<br/>(Redis)"]
    end

    subgraph wsserver["WebSocket サーバー"]
        ws1["WS インスタンス1"]
        ws2["WS インスタンス2"]
        ws3["WS インスタンス3"]
    end

    subgraph clients["クライアント"]
        trader1["トレーダー1"]
        trader2["トレーダー2"]
        traderN["トレーダーN"]
    end

    mkt -->|専用線| receiver
    receiver --> processor
    processor -->|配信| pubsub
    pubsub -.->|購読| ws1
    pubsub -.->|購読| ws2
    pubsub -.->|購読| ws3
    ws1 -->|WebSocket| trader1
    ws2 -->|WebSocket| trader2
    ws3 -->|WebSocket| traderN

    style backend fill:#e3f2fd
    style wsserver fill:#f3e5f5
    style clients fill:#e8f5e9

この構成により、各レイヤーを独立してスケールでき、障害の影響範囲も局所化できます。

バイナリプロトコルによる帯域削減

JSON の代わりにバイナリ形式でデータを送信することで、帯域使用量を大幅に削減できます。

プロトコル設計

板情報のバイナリフォーマットを以下のように定義します。

| フィールド | バイト数 | 型 | 説明 | | # | --- | --- | --- | | 1 | メッセージタイプ | 1 | uint8 | 0x01: 全量, 0x02: 差分 | | 2 | シンボル ID | 2 | uint16 | 銘柄識別子 | | 3 | タイムスタンプ | 8 | uint64 | Unix ミリ秒 | | 4 | 買い板数 | 1 | uint8 | 買い注文の階層数 | | 5 | 売り板数 | 1 | uint8 | 売り注文の階層数 | | 6 | 板データ | 可変 | - | 価格・数量ペアの配列 |

各板データ(価格・数量ペア)は以下の形式で表現します。

| フィールド | バイト数 | 型 | 説明 | | # | --- | --- | --- | | 1 | 価格 | 4 | uint32 | 最小単位での価格 | | 2 | 数量 | 4 | float32 | 注文数量 |

この形式では、10 階層の板情報(買い 5 階層、売り 5 階層)を約 93 バイトで表現できます。JSON 形式の約 200 バイトと比較して、50%以上の削減になるでしょう。

バイナリエンコーディングの実装

Node.js での実装例を示します。

typescript// バイナリメッセージのエンコーダー

interface OrderBookLevel {
  price: number;
  amount: number;
}

interface OrderBook {
  symbol: string;
  timestamp: number;
  bids: OrderBookLevel[];
  asks: OrderBookLevel[];
}

メッセージタイプの定義を行います。

typescript// メッセージタイプ定義

enum MessageType {
  FULL = 0x01, // 全量データ
  DELTA = 0x02, // 差分データ
  HEARTBEAT = 0x03, // ハートビート
}

バイナリエンコーダーのメインロジックを実装します。

typescript// 板情報をバイナリ形式にエンコード

function encodeOrderBook(
  orderBook: OrderBook,
  symbolId: number,
  messageType: MessageType = MessageType.FULL
): Buffer {
  // ヘッダー部分のサイズを計算(13バイト)
  const headerSize = 13;

  // データ部分のサイズを計算(各レベル8バイト)
  const dataSize =
    (orderBook.bids.length + orderBook.asks.length) * 8;

  // 全体のバッファを確保
  const buffer = Buffer.allocUnsafe(headerSize + dataSize);

  let offset = 0;

  // メッセージタイプ(1バイト)
  buffer.writeUInt8(messageType, offset);
  offset += 1;

  return buffer;
}

ヘッダー情報を書き込みます。

typescript// ヘッダー情報の書き込み

// シンボルID(2バイト)
buffer.writeUInt16BE(symbolId, offset);
offset += 2;

// タイムスタンプ(8バイト、BigInt使用)
buffer.writeBigUInt64BE(
  BigInt(orderBook.timestamp),
  offset
);
offset += 8;

// 買い板数(1バイト)
buffer.writeUInt8(orderBook.bids.length, offset);
offset += 1;

// 売り板数(1バイト)
buffer.writeUInt8(orderBook.asks.length, offset);
offset += 1;

板データ本体を書き込みます。

typescript// 買い板データの書き込み

for (const bid of orderBook.bids) {
  // 価格(4バイト、整数化)
  buffer.writeUInt32BE(Math.floor(bid.price), offset);
  offset += 4;

  // 数量(4バイト、float32)
  buffer.writeFloatBE(bid.amount, offset);
  offset += 4;
}

売り板データも同様に書き込みます。

typescript// 売り板データの書き込み

  for (const ask of orderBook.asks) {
    // 価格(4バイト、整数化)
    buffer.writeUInt32BE(Math.floor(ask.price), offset);
    offset += 4;

    // 数量(4バイト、float32)
    buffer.writeFloatBE(ask.amount, offset);
    offset += 4;
  }

  return buffer;
}

このエンコーダーを使用することで、板情報を効率的にバイナリ形式に変換できます。

差分配信による更新最適化

全量データを毎回送信する代わりに、前回からの差分のみを送信することで、さらに帯域を削減できます。

差分検出アルゴリズム

前回の板情報と現在の板情報を比較し、変更があった階層のみを抽出します。

typescript// 板情報の差分を検出

interface OrderBookDelta {
  symbol: string;
  timestamp: number;
  bidChanges: Map<number, number | null>; // price -> amount (null は削除)
  askChanges: Map<number, number | null>;
}

function calculateDelta(
  previous: OrderBook,
  current: OrderBook
): OrderBookDelta | null {
  const bidChanges = new Map<number, number | null>();
  const askChanges = new Map<number, number | null>();

  // 変更検出のロジックは次のブロックで実装

  return null; // 仮のreturn
}

買い板の変更を検出します。

typescript// 買い板の変更検出

// 現在の買い板をMapに変換(価格をキーに)
const currentBids = new Map(
  current.bids.map((b) => [b.price, b.amount])
);

const previousBids = new Map(
  previous.bids.map((b) => [b.price, b.amount])
);

// 新規追加・変更された価格レベルを検出
currentBids.forEach((amount, price) => {
  const prevAmount = previousBids.get(price);
  if (prevAmount === undefined || prevAmount !== amount) {
    bidChanges.set(price, amount);
  }
});

// 削除された価格レベルを検出
previousBids.forEach((amount, price) => {
  if (!currentBids.has(price)) {
    bidChanges.set(price, null); // null は削除を意味
  }
});

売り板も同様に変更を検出します。

typescript// 売り板の変更検出

// 現在の売り板をMapに変換
const currentAsks = new Map(
  current.asks.map((a) => [a.price, a.amount])
);

const previousAsks = new Map(
  previous.asks.map((a) => [a.price, a.amount])
);

// 新規追加・変更された価格レベルを検出
currentAsks.forEach((amount, price) => {
  const prevAmount = previousAsks.get(price);
  if (prevAmount === undefined || prevAmount !== amount) {
    askChanges.set(price, amount);
  }
});

// 削除された価格レベルを検出
previousAsks.forEach((amount, price) => {
  if (!currentAsks.has(price)) {
    askChanges.set(price, null);
  }
});

差分がない場合は null を返します。

typescript// 差分の有無を確認して結果を返す

  // 変更がない場合は null を返す
  if (bidChanges.size === 0 && askChanges.size === 0) {
    return null;
  }

  return {
    symbol: current.symbol,
    timestamp: current.timestamp,
    bidChanges,
    askChanges
  };
}

この差分検出により、価格変動が少ない状況では送信データ量を大幅に削減できるでしょう。

Redis Pub/Sub による効率的な配信

複数の WebSocket サーバーインスタンス間で板情報を共有するため、Redis の Pub/Sub 機能を活用します。

Redis Pub/Sub の構成

データ処理エンジンが Redis にパブリッシュし、各 WebSocket サーバーがサブスクライブします。

typescript// Redis Pub/Sub パブリッシャーの実装

import Redis from 'ioredis';

class OrderBookPublisher {
  private redis: Redis;

  constructor(redisUrl: string) {
    this.redis = new Redis(redisUrl);
  }

  async publish(
    symbol: string,
    data: Buffer
  ): Promise<void> {
    const channel = `orderbook:${symbol}`;
    await this.redis.publish(channel, data);
  }
}

WebSocket サーバー側では、Redis からのメッセージを受信してクライアントに配信します。

typescript// Redis Pub/Sub サブスクライバーの実装

class OrderBookSubscriber {
  private redis: Redis;
  private handlers: Map<string, (data: Buffer) => void>;

  constructor(redisUrl: string) {
    this.redis = new Redis(redisUrl);
    this.handlers = new Map();

    // メッセージ受信ハンドラーを設定
    this.redis.on('messageBuffer', (channel, message) => {
      const channelStr = channel.toString();
      const handler = this.handlers.get(channelStr);
      if (handler) {
        handler(message);
      }
    });
  }
}

購読管理の機能を追加します。

typescript// チャンネル購読管理

  async subscribe(
    symbol: string,
    handler: (data: Buffer) => void
  ): Promise<void> {
    const channel = `orderbook:${symbol}`;
    this.handlers.set(channel, handler);
    await this.redis.subscribe(channel);
  }

  async unsubscribe(symbol: string): Promise<void> {
    const channel = `orderbook:${symbol}`;
    this.handlers.delete(channel);
    await this.redis.unsubscribe(channel);
  }
}

Redis Pub/Sub を使用することで、WebSocket サーバーを水平スケールしても、全てのクライアントに同一の板情報を配信できます。

WebSocket サーバーの実装

高性能な WebSocket サーバーを構築するため、Node.js の ws ライブラリを使用します。

基本的なサーバー構成

typescript// WebSocket サーバーの基本構成

import WebSocket from 'ws';
import { createServer } from 'http';

class OrderBookWebSocketServer {
  private wss: WebSocket.Server;
  private subscriber: OrderBookSubscriber;
  private clientSubscriptions: Map<WebSocket, Set<string>>;

  constructor(port: number, redisUrl: string) {
    const server = createServer();
    this.wss = new WebSocket.Server({ server });
    this.subscriber = new OrderBookSubscriber(redisUrl);
    this.clientSubscriptions = new Map();

    this.setupWebSocketHandlers();
    server.listen(port);
  }
}

WebSocket 接続のハンドラーを設定します。

typescript// WebSocket 接続ハンドラーの設定

  private setupWebSocketHandlers(): void {
    this.wss.on('connection', (ws: WebSocket) => {
      // クライアントの購読情報を初期化
      this.clientSubscriptions.set(ws, new Set());

      // メッセージ受信ハンドラー
      ws.on('message', (message: string) => {
        this.handleClientMessage(ws, message);
      });

      // 切断ハンドラー
      ws.on('close', () => {
        this.handleClientDisconnect(ws);
      });
    });
  }

クライアントからのメッセージ処理を実装します。

typescript// クライアントメッセージの処理

  private async handleClientMessage(
    ws: WebSocket,
    message: string
  ): Promise<void> {
    try {
      const request = JSON.parse(message);

      if (request.type === 'subscribe') {
        await this.handleSubscribe(ws, request.symbol);
      } else if (request.type === 'unsubscribe') {
        await this.handleUnsubscribe(ws, request.symbol);
      }
    } catch (error) {
      console.error('メッセージ処理エラー:', error);
    }
  }

購読処理を実装します。

typescript// 銘柄購読処理

  private async handleSubscribe(
    ws: WebSocket,
    symbol: string
  ): Promise<void> {
    const subscriptions = this.clientSubscriptions.get(ws);
    if (!subscriptions) return;

    // すでに購読済みの場合はスキップ
    if (subscriptions.has(symbol)) return;

    subscriptions.add(symbol);

    // Redis からのメッセージを受信したらクライアントに転送
    await this.subscriber.subscribe(symbol, (data: Buffer) => {
      if (ws.readyState === WebSocket.OPEN) {
        ws.send(data, { binary: true });
      }
    });
  }

購読解除と切断処理を実装します。

typescript// 購読解除と切断処理

  private async handleUnsubscribe(
    ws: WebSocket,
    symbol: string
  ): Promise<void> {
    const subscriptions = this.clientSubscriptions.get(ws);
    if (!subscriptions) return;

    subscriptions.delete(symbol);
    await this.subscriber.unsubscribe(symbol);
  }

  private handleClientDisconnect(ws: WebSocket): void {
    // クライアントが購読していた全ての銘柄を解除
    const subscriptions = this.clientSubscriptions.get(ws);
    if (subscriptions) {
      subscriptions.forEach(symbol => {
        this.subscriber.unsubscribe(symbol);
      });
    }
    this.clientSubscriptions.delete(ws);
  }
}

この実装により、クライアントは任意の銘柄を動的に購読・解除でき、サーバーは効率的にデータを配信できます。

クライアント側の実装

ブラウザ上で動作するトレーディング画面のクライアント実装を示します。

WebSocket 接続の確立

typescript// クライアント側 WebSocket 接続

class OrderBookClient {
  private ws: WebSocket | null = null;
  private orderBooks: Map<string, OrderBook> = new Map();
  private listeners: Map<
    string,
    Set<(book: OrderBook) => void>
  > = new Map();

  constructor(private serverUrl: string) {}

  connect(): Promise<void> {
    return new Promise((resolve, reject) => {
      this.ws = new WebSocket(this.serverUrl);
      this.ws.binaryType = 'arraybuffer';

      // 接続完了
      this.ws.onopen = () => resolve();

      // エラー処理
      this.ws.onerror = (error) => reject(error);
    });
  }
}

バイナリメッセージの受信処理を実装します。

typescript// バイナリメッセージのデコード処理

  private setupMessageHandler(): void {
    if (!this.ws) return;

    this.ws.onmessage = (event: MessageEvent) => {
      if (event.data instanceof ArrayBuffer) {
        const buffer = Buffer.from(event.data);
        const orderBook = this.decodeOrderBook(buffer);

        // 板情報を更新
        this.orderBooks.set(orderBook.symbol, orderBook);

        // リスナーに通知
        const listeners = this.listeners.get(orderBook.symbol);
        if (listeners) {
          listeners.forEach(callback => callback(orderBook));
        }
      }
    };
  }

バイナリデータのデコーダーを実装します。

typescript// バイナリ板情報のデコード

  private decodeOrderBook(buffer: Buffer): OrderBook {
    let offset = 0;

    // メッセージタイプ(1バイト)
    const messageType = buffer.readUInt8(offset);
    offset += 1;

    // シンボルID(2バイト)
    const symbolId = buffer.readUInt16BE(offset);
    offset += 2;

    // タイムスタンプ(8バイト)
    const timestamp = Number(buffer.readBigUInt64BE(offset));
    offset += 8;

    // 買い板数・売り板数(各1バイト)
    const bidCount = buffer.readUInt8(offset);
    offset += 1;
    const askCount = buffer.readUInt8(offset);
    offset += 1;

    return this.decodeOrderLevels(buffer, offset, bidCount, askCount, symbolId, timestamp);
  }

価格レベルのデコード処理を実装します。

typescript// 価格レベルのデコード

  private decodeOrderLevels(
    buffer: Buffer,
    offset: number,
    bidCount: number,
    askCount: number,
    symbolId: number,
    timestamp: number
  ): OrderBook {
    const bids: OrderBookLevel[] = [];
    const asks: OrderBookLevel[] = [];

    // 買い板のデコード
    for (let i = 0; i < bidCount; i++) {
      const price = buffer.readUInt32BE(offset);
      offset += 4;
      const amount = buffer.readFloatBE(offset);
      offset += 4;
      bids.push({ price, amount });
    }

    // 売り板のデコード
    for (let i = 0; i < askCount; i++) {
      const price = buffer.readUInt32BE(offset);
      offset += 4;
      const amount = buffer.readFloatBE(offset);
      offset += 4;
      asks.push({ price, amount });
    }

    return {
      symbol: `SYMBOL_${symbolId}`,
      timestamp,
      bids,
      asks
    };
  }

銘柄の購読機能を実装します。

typescript// 銘柄購読とコールバック登録

  subscribe(
    symbol: string,
    callback: (book: OrderBook) => void
  ): void {
    // リスナーを登録
    if (!this.listeners.has(symbol)) {
      this.listeners.set(symbol, new Set());
    }
    this.listeners.get(symbol)!.add(callback);

    // サーバーに購読リクエストを送信
    if (this.ws && this.ws.readyState === WebSocket.OPEN) {
      this.ws.send(JSON.stringify({
        type: 'subscribe',
        symbol
      }));
    }
  }

  unsubscribe(symbol: string): void {
    this.listeners.delete(symbol);

    if (this.ws && this.ws.readyState === WebSocket.OPEN) {
      this.ws.send(JSON.stringify({
        type: 'unsubscribe',
        symbol
      }));
    }
  }
}

このクライアント実装により、トレーディング画面は複数の銘柄を同時に購読し、リアルタイムに板情報を更新できます。

具体例

実際のトレーディングシステムにおける、WebSocket を活用した板情報配信の実装例を示します。

使用例:BTC/JPY の板情報リアルタイム表示

React を使用したトレーディング画面のコンポーネント実装です。

typescript// React コンポーネントでの利用例

import React, { useEffect, useState } from 'react';

interface OrderBookDisplayProps {
  symbol: string;
}

const OrderBookDisplay: React.FC<OrderBookDisplayProps> = ({
  symbol,
}) => {
  const [orderBook, setOrderBook] =
    useState<OrderBook | null>(null);
  const [client] = useState(
    () => new OrderBookClient('wss://trading.example.com')
  );

  useEffect(() => {
    // WebSocket接続を確立
    client.connect().then(() => {
      // 銘柄を購読
      client.subscribe(symbol, (book) => {
        setOrderBook(book);
      });
    });

    // クリーンアップ
    return () => {
      client.unsubscribe(symbol);
    };
  }, [symbol, client]);

  return <div>板情報表示(次のブロックで実装)</div>;
};

板情報の表示部分を実装します。

typescript// 板情報の表示UI

if (!orderBook) {
  return <div>読み込み中...</div>;
}

return (
  <div className='orderbook-container'>
    <h2>{orderBook.symbol}</h2>
    <div className='orderbook-timestamp'>
      更新時刻:{' '}
      {new Date(orderBook.timestamp).toLocaleTimeString()}
    </div>

    <table className='orderbook-table'>
      <thead>
        <tr>
          <th>価格(売り)</th>
          <th>数量</th>
        </tr>
      </thead>
      <tbody>
        {/* 売り板を表示(価格の低い順) */}
        {orderBook.asks
          .slice()
          .reverse()
          .map((ask, index) => (
            <tr key={`ask-${index}`} className='ask-row'>
              <td className='price'>
                {ask.price.toLocaleString()}
              </td>
              <td className='amount'>
                {ask.amount.toFixed(4)}
              </td>
            </tr>
          ))}
      </tbody>
    </table>
  </div>
);

買い板の表示部分を追加します。

typescript// 買い板の表示UI

      <table className="orderbook-table">
        <thead>
          <tr>
            <th>価格(買い)</th>
            <th>数量</th>
          </tr>
        </thead>
        <tbody>
          {/* 買い板を表示(価格の高い順) */}
          {orderBook.bids.map((bid, index) => (
            <tr key={`bid-${index}`} className="bid-row">
              <td className="price">{bid.price.toLocaleString()}</td>
              <td className="amount">{bid.amount.toFixed(4)}</td>
            </tr>
          ))}
        </tbody>
      </table>
    </div>
  );
};

export default OrderBookDisplay;

この React コンポーネントは、指定された銘柄の板情報をリアルタイムに表示します。WebSocket 経由で受信した更新が即座に画面に反映されるでしょう。

パフォーマンス測定の実装

システムの遅延を測定し、モニタリングする仕組みを実装します。

typescript// 遅延測定機能の実装

class LatencyMonitor {
  private latencies: number[] = [];
  private maxSamples = 1000;

  recordLatency(serverTimestamp: number): void {
    const now = Date.now();
    const latency = now - serverTimestamp;

    this.latencies.push(latency);

    // サンプル数を制限
    if (this.latencies.length > this.maxSamples) {
      this.latencies.shift();
    }
  }
}

統計情報の計算機能を追加します。

typescript// 遅延統計の計算

  getStatistics(): {
    average: number;
    median: number;
    p95: number;
    p99: number;
  } {
    if (this.latencies.length === 0) {
      return { average: 0, median: 0, p95: 0, p99: 0 };
    }

    const sorted = [...this.latencies].sort((a, b) => a - b);
    const sum = sorted.reduce((acc, val) => acc + val, 0);

    const average = sum / sorted.length;
    const median = sorted[Math.floor(sorted.length / 2)];
    const p95 = sorted[Math.floor(sorted.length * 0.95)];
    const p99 = sorted[Math.floor(sorted.length * 0.99)];

    return { average, median, p95, p99 };
  }

  reset(): void {
    this.latencies = [];
  }
}

クライアントに遅延測定機能を統合します。

typescript// クライアントでの遅延測定

class OrderBookClientWithMonitoring extends OrderBookClient {
  private latencyMonitor = new LatencyMonitor();

  protected decodeOrderBook(buffer: Buffer): OrderBook {
    const orderBook = super.decodeOrderBook(buffer);

    // サーバーのタイムスタンプを使って遅延を記録
    this.latencyMonitor.recordLatency(orderBook.timestamp);

    return orderBook;
  }

  getLatencyStatistics() {
    return this.latencyMonitor.getStatistics();
  }
}

この遅延測定機能により、エンドツーエンドの配信遅延を継続的に監視できます。

再接続とデータ同期の実装

ネットワーク障害からの自動復旧機能を実装します。

typescript// 自動再接続機能

class ResilientOrderBookClient extends OrderBookClient {
  private reconnectInterval = 5000; // 5秒
  private maxReconnectAttempts = 10;
  private reconnectAttempts = 0;
  private isIntentionalClose = false;

  connect(): Promise<void> {
    return super.connect().then(() => {
      this.reconnectAttempts = 0;
      this.setupAutoReconnect();
    });
  }
}

再接続ロジックを実装します。

typescript// 再接続ハンドラーの設定

  private setupAutoReconnect(): void {
    if (!this.ws) return;

    this.ws.onclose = (event) => {
      // 意図的な切断の場合は再接続しない
      if (this.isIntentionalClose) {
        return;
      }

      console.log('WebSocket接続が切断されました。再接続を試みます...');
      this.attemptReconnect();
    };
  }

  private attemptReconnect(): void {
    if (this.reconnectAttempts >= this.maxReconnectAttempts) {
      console.error('再接続の最大試行回数に達しました');
      return;
    }

    this.reconnectAttempts++;

    setTimeout(() => {
      console.log(`再接続試行 ${this.reconnectAttempts}/${this.maxReconnectAttempts}`);
      this.connect().catch(() => {
        this.attemptReconnect();
      });
    }, this.reconnectInterval);
  }

購読状態の復元機能を追加します。

typescript// 再接続時の購読復元

  private previousSubscriptions: Set<string> = new Set();

  subscribe(symbol: string, callback: (book: OrderBook) => void): void {
    super.subscribe(symbol, callback);
    this.previousSubscriptions.add(symbol);
  }

  private restoreSubscriptions(): void {
    // 再接続時に以前の購読を復元
    this.previousSubscriptions.forEach(symbol => {
      const listeners = this.listeners.get(symbol);
      if (listeners && listeners.size > 0) {
        const callback = listeners.values().next().value;
        this.subscribe(symbol, callback);
      }
    });
  }

  disconnect(): void {
    this.isIntentionalClose = true;
    if (this.ws) {
      this.ws.close();
    }
  }
}

これにより、一時的なネットワーク障害が発生しても、自動的に再接続して購読状態を復元できます。

システム構成図と処理フロー

以下の図は、実際の運用環境における板情報配信システムの処理フローを示しています。

mermaidsequenceDiagram
    participant Exchange as 取引所
    participant Receiver as データ受信
    participant Processor as データ処理
    participant Redis as Redis Pub/Sub
    participant WSServer as WebSocketサーバー
    participant Client as クライアント

    Exchange->>Receiver: 市場データ配信<br/>(専用線)
    Receiver->>Processor: 板情報raw data

    Processor->>Processor: 差分検出
    Processor->>Processor: バイナリエンコード

    Processor->>Redis: Publish<br/>(バイナリ)

    Redis->>WSServer: Subscribe<br/>(各インスタンス)

    WSServer->>Client: WebSocket送信<br/>(バイナリ)

    Note over Client: 受信時刻を記録
    Client->>Client: デコード・描画

    Note over Exchange,Client: エンドツーエンド遅延: 10ms以内

このフローにおいて、各処理段階での遅延を最小化することで、取引所からクライアントまでのエンドツーエンドの遅延を 10 ミリ秒以内に抑えることが可能になります。

主要な最適化ポイントは以下の通りです。

  • データ受信サービスは専用線で取引所と接続し、ネットワーク遅延を最小化
  • データ処理エンジンは差分検出とバイナリエンコードを高速に実行
  • Redis Pub/Sub はメモリ上で動作し、配信遅延が極めて小さい
  • WebSocket サーバーはバイナリデータをそのまま転送するため、変換処理が不要
  • クライアントはバイナリデコードを最適化し、描画更新を効率化

これらの技術を組み合わせることで、数万のクライアントに対して超低遅延の板情報配信を実現できるでしょう。

まとめ

本記事では、WebSocket を活用した金融トレーディング向けの超低遅延板情報配信アーキテクチャについて解説しました。

従来の HTTP ポーリング方式では実現が困難だったミリ秒単位の遅延要求に対して、以下の技術的アプローチで解決策を示しました。

  • バイナリプロトコルによる帯域削減(JSON 比で 50%以上の削減)
  • 差分配信による不要なデータ送信の排除
  • Redis Pub/Sub による効率的な配信基盤
  • WebSocket による双方向リアルタイム通信
  • 自動再接続とデータ同期による高可用性

これらの技術を組み合わせることで、エンドツーエンドの遅延を 10 ミリ秒以内に抑え、数万のクライアントに対して同時配信できるシステムを構築できます。

実装のポイントとしては、各レイヤーでの遅延を計測し、ボトルネックを特定することが重要です。また、バイナリプロトコルの設計では、帯域削減とデータ整合性のバランスを考慮する必要があるでしょう。

今後の発展として、以下の技術も検討に値します。

  • HTTP/3(QUIC)による更なる遅延削減
  • WebAssembly を使ったクライアント側デコード処理の高速化
  • gRPC Streaming による型安全なプロトコル実装
  • 機械学習による板情報の予測配信

金融トレーディングシステムにおいて、低遅延は競争優位性の源泉となります。本記事で紹介したアーキテクチャが、皆様のシステム構築の参考になれば幸いです。

関連リンク