Bybit WebSocketをPythonで購読する完全ガイド
ticker・kline・orderbook・publicTradeと
asyncio自作クライアント

RESTで価格をポーリングするbotは、レート制限にも遅延にも不利です。WebSocketに移行すれば、板・約定・ローソク足をプッシュで受け取れます。この記事ではpybitでの最小コードから、linearのticker差分マージという見落としやすい落とし穴、確定足だけを処理する方法、asyncio による自作クライアント(20秒ping・自動再接続・断線時のREST穴埋め)までをサンプルコード9本で実装します。

確認日
2026-07-30
API バージョン
Bybit V5
Python
3.10 以上
pybit
5.17.0

※ Bybit V5は機能追加のペースが速いAPIです。パラメータの最終的な正解は必ず公式ドキュメントで確認してください。本記事のコードはすべてテストネットで動作確認しています。

目次

  1. まず動かす|20行でリアルタイム価格を受信
  2. エンドポイントとトピック一覧
  3. 最大の落とし穴|linearのtickerは差分配信
  4. 確定足だけを処理する|confirmフラグ
  5. publicTradeからリアルタイムバーを作る
  6. 購読上限と接続制限
  7. asyncioで自作する|ping・再接続・指数バックオフ
  8. 多銘柄購読マネージャ(チャンク分割)
  9. 断線を検知してRESTで穴埋めする
  10. 受信遅延を計測する
  11. コールバックを詰まらせない設計
  12. よくあるトラブルと対処
  13. よくある疑問Q&A

この記事は記事01(pybit入門)の config.py を前提にしますが、public WebSocketはAPIキー不要なのでキーなしでも全サンプルが動きます。

まず動かす|20行でリアルタイム価格を受信

ws_min.pyサンプル1
# 本コードは本人利用に限り自由に使用可。再配布・商用利用・販売は禁止。
import time

from pybit.unified_trading import WebSocket

# channel_type は購読するcategoryに対応する
#   linear / inverse / spot / option / private
ws = WebSocket(testnet=False, channel_type="linear")


def on_ticker(msg):
    d = msg["data"]
    # linearのtickerは差分配信。存在しないキーがあるのでgetで受ける(後述)
    print(f"{msg['ts']} {d['symbol']} last={d.get('lastPrice')} "
          f"bid={d.get('bid1Price')} ask={d.get('ask1Price')}")


ws.ticker_stream(symbol="BTCUSDT", callback=on_ticker)

# pybitは受信を別スレッドで回すため、メインスレッドは生かしておく必要がある
try:
    while True:
        time.sleep(1)
except KeyboardInterrupt:
    ws.exit()

public WebSocketは認証不要・本番の板をそのまま見られるので、testnetを使う必要がありません。データ収集や監視の用途なら、いきなり testnet=False で問題ありません(発注が絡まないため事故になりません)。

pybitのWebSocketは別スレッドで動く

ws.ticker_stream(...) は購読を登録してすぐ返ってきます。受信ループは内部のスレッドが回すため、メインスレッドが終了するとプロセスごと落ちます。上のコードの while True: time.sleep(1) はそのための「生存ループ」です。asyncio ベースで組みたい場合は後半の自作クライアント(サンプル6)を使ってください。

エンドポイントとトピック一覧

接続先URL

用途URL認証
現物wss://stream.bybit.com/v5/public/spot不要
USDT/USDC系wss://stream.bybit.com/v5/public/linear不要
コイン建てwss://stream.bybit.com/v5/public/inverse不要
オプションwss://stream.bybit.com/v5/public/option不要
注文・残高wss://stream.bybit.com/v5/private必要
WS発注wss://stream.bybit.com/v5/trade必要
テストネットwss://stream-testnet.bybit.com/...—
デモ(private)wss://stream-demo.bybit.com/v5/private必要
デモトレードのpublicは本番を使う

デモトレード環境でpublic(板・価格)は本番の stream.bybit.com を使い、privateだけ stream-demo.bybit.com を使います。デモは本番と同じ板で動くため、これで整合が取れます(記事27で扱います)。

主要な公開トピック

トピック内容配信頻度pybitメソッド
tickers.{symbol}価格・出来高・OI・資金調達率linear/inverse 100ms
spot 50ms
ticker_stream
kline.{interval}.{symbol}ローソク足(未確定含む)更新のたびkline_stream
orderbook.{depth}.{symbol}板(snapshot + delta)depthにより
10〜200ms
orderbook_stream
publicTrade.{symbol}約定ティック発生のたびtrade_stream
allLiquidation.{symbol}清算発生発生のたびall_liquidation_stream
priceLimit.{symbol}価格の上下限変化時price_limit_stream

orderbook の depth に指定できる値はカテゴリで異なります。

category指定可能なdepth配信間隔
linear / inverse / spot1 / 50 / 200 / 10001→10ms、50→20ms、200→100ms、1000→200ms
option25 / 10025→20ms、100→100ms
depth=1以外は差分更新なので、そのまま使えない

orderbook.1.BTCUSDT はベスト気配1本だけなので毎回そのまま使えますが、depth 50以上は最初にsnapshot、以降はdeltaが届きます。deltaを正しく適用してローカル板を組み立てないと、静かに壊れた板で発注することになります。板の復元は難易度が高いため記事28で専用に扱います。この記事ではdepth=1のみ扱います。

最大の落とし穴|linearのtickerは差分配信

ここが日本語の解説でほぼ触れられていない、実装で必ず踏むポイントです。

category配信方式結果
spotsnapshotのみ毎回すべてのフィールドが揃っている
linear / inversesnapshot+deltadeltaには変化したフィールドだけが入る

つまり linear で msg["data"]["lastPrice"] と書くと、lastPriceが変化していないメッセージで KeyError になります。素朴に書いたコードが数分〜数時間後に突然落ちる、という嫌な壊れ方をします。正解はローカルに最新状態を保持し、deltaをマージすることです。

ticker_state.pyサンプル2
"""ticker_state.py — tickerのsnapshot/deltaをマージして常に完全な状態を保つ
本コードは本人利用に限り自由に使用可。再配布・商用利用・販売は禁止。
"""
from __future__ import annotations

import threading
import time
from dataclasses import dataclass, field

from pybit.unified_trading import WebSocket


@dataclass
class TickerStore:
    """symbol -> 最新の完全なtickerフィールド。deltaをマージして保持する。"""

    _state: dict[str, dict] = field(default_factory=dict)
    _lock: threading.Lock = field(default_factory=threading.Lock)
    _updated_at: dict[str, float] = field(default_factory=dict)

    def on_message(self, msg: dict) -> None:
        data = msg.get("data") or {}
        symbol = data.get("symbol")
        if not symbol:
            return

        with self._lock:
            if msg.get("type") == "snapshot":
                # snapshotは全フィールドが揃っているので置き換える
                self._state[symbol] = dict(data)
            else:
                # deltaは変化したフィールドのみ。既存状態に上書きする
                self._state.setdefault(symbol, {}).update(data)
            self._updated_at[symbol] = time.time()

    def get(self, symbol: str) -> dict | None:
        with self._lock:
            s = self._state.get(symbol)
            return dict(s) if s else None

    def price(self, symbol: str) -> float | None:
        s = self.get(symbol)
        return float(s["lastPrice"]) if s and s.get("lastPrice") else None

    def age(self, symbol: str) -> float:
        """最後に更新されてからの経過秒。断線検知に使う。"""
        with self._lock:
            t = self._updated_at.get(symbol)
        return float("inf") if t is None else time.time() - t


if __name__ == "__main__":
    store = TickerStore()
    ws = WebSocket(testnet=False, channel_type="linear")

    for sym in ("BTCUSDT", "ETHUSDT", "SOLUSDT"):
        ws.ticker_stream(symbol=sym, callback=store.on_message)

    try:
        while True:
            time.sleep(2)
            for sym in ("BTCUSDT", "ETHUSDT", "SOLUSDT"):
                s = store.get(sym)
                if not s:
                    continue
                # マージ済みなので、どのフィールドも安全に読める
                print(f"{sym:<9} last={s['lastPrice']:>12} "
                      f"funding={s.get('fundingRate', '-'):>10} "
                      f"OI={s.get('openInterest', '-'):>14} "
                      f"({store.age(sym):.1f}秒前)")
            print("-" * 70)
    except KeyboardInterrupt:
        ws.exit()
この「状態ストア」の型は他のトピックにも使える

「snapshotで置き換え、deltaでマージ、ロックで保護、更新時刻を持つ」というパターンは、板(記事28)でも注文・ポジション(記事27)でもそのまま使えます。WebSocketは「イベントの列」ではなく「状態の同期手段」として設計するのがコツです。age() を持たせておくと、後述のウォッチドッグがそのまま書けます。

確定足だけを処理する|confirmフラグ

kline トピックは未確定足も逐次配信されます。同じ足が何度も届き、値が変わり続けます。「終値でシグナルを判定する」ロジックをそのまま書くと、確定していない終値で何度も発注してしまいます。

判別は confirm フィールドです。true なら足が閉じ、値は確定して以後変わりません。

kline_confirmed.pyサンプル3
"""kline_confirmed.py — 確定足のみを処理してシグナル判定に渡す
本コードは本人利用に限り自由に使用可。再配布・商用利用・販売は禁止。
"""
from __future__ import annotations

import time
from collections import deque
from datetime import datetime, timezone

from pybit.unified_trading import WebSocket

JST = timezone.utc   # 表示をJSTにする場合は timezone(timedelta(hours=9))


class BarAggregator:
    """確定足だけをdequeに溜め、確定のたびにコールバックを呼ぶ。"""

    def __init__(self, maxlen: int = 500):
        self.bars: deque[dict] = deque(maxlen=maxlen)
        self._last_start: int | None = None

    def on_message(self, msg: dict) -> None:
        for k in msg.get("data", []):
            if not k.get("confirm"):
                continue                      # 未確定足は無視

            start = int(k["start"])
            if start == self._last_start:
                continue                      # 同じ足の重複配信を弾く
            self._last_start = start

            bar = {
                "start": start,
                "time": datetime.fromtimestamp(start / 1000, JST),
                "open": float(k["open"]),
                "high": float(k["high"]),
                "low": float(k["low"]),
                "close": float(k["close"]),
                "volume": float(k["volume"]),
                "turnover": float(k["turnover"]),
            }
            self.bars.append(bar)
            self.on_bar_closed(bar)

    def on_bar_closed(self, bar: dict) -> None:
        """ここに戦略のロジックを書く。1本の確定足につき必ず1回だけ呼ばれる。"""
        closes = [b["close"] for b in self.bars]
        if len(closes) < 20:
            print(f"{bar['time']:%H:%M} close={bar['close']} (ウォームアップ中 "
                  f"{len(closes)}/20)")
            return

        sma20 = sum(closes[-20:]) / 20
        signal = "LONG" if bar["close"] > sma20 else "FLAT"
        print(f"{bar['time']:%H:%M} close={bar['close']:>10.2f} "
              f"sma20={sma20:>10.2f} -> {signal}")


if __name__ == "__main__":
    agg = BarAggregator()
    ws = WebSocket(testnet=False, channel_type="linear")

    # interval: 1,3,5,15,30,60,120,240,360,720,D,W,M
    ws.kline_stream(interval=1, symbol="BTCUSDT", callback=agg.on_message)

    try:
        while True:
            time.sleep(1)
    except KeyboardInterrupt:
        ws.exit()
起動直後は履歴がゼロ

WebSocketは「今から先」のデータしか流れてきません。移動平均やATRのような過去N本を必要とする指標は、起動直後に計算できません。実運用では起動時にRESTの get_kline() で過去足を取得してdequeを埋め(ウォームアップ)、その後WebSocketに引き継ぐ設計にします。RESTでの履歴取得は記事07で扱います。

publicTradeからリアルタイムバーを作る

publicTrade は約定1件ごとのティックです。kline よりも情報量が多く、取引所が用意していない足(15秒足、出来高バーなど)を自分で作れます。

フィールド意味使い道
T約定時刻(ms)遅延計測・時系列の基準
p / v価格 / 数量VWAP・出来高集計
Sテイカー側のBuy/Sell売買圧力(デルタ)の算出
L価格変化の方向(PlusTick等)ティックルールの代替
i約定ID重複排除
BT / RPIブロック取引 / RPI約定か通常フローから除外する判定

S がテイカーの方向である点が重要です。これにより「買い手が攻めた出来高」と「売り手が攻めた出来高」を分離でき、出来高デルタ(buy volume − sell volume)という需給指標が作れます。

tick_bars.pyサンプル4
"""tick_bars.py — publicTradeから任意秒数のバーと出来高デルタを生成する
本コードは本人利用に限り自由に使用可。再配布・商用利用・販売は禁止。
"""
from __future__ import annotations

import threading
import time
from dataclasses import dataclass, field

from pybit.unified_trading import WebSocket


@dataclass
class Bar:
    start_ms: int
    open: float
    high: float
    low: float
    close: float
    buy_vol: float = 0.0     # テイカー買いの出来高
    sell_vol: float = 0.0    # テイカー売りの出来高
    trades: int = 0

    @property
    def volume(self) -> float:
        return self.buy_vol + self.sell_vol

    @property
    def delta(self) -> float:
        """出来高デルタ。プラスなら買いが攻めている。"""
        return self.buy_vol - self.sell_vol

    @property
    def delta_ratio(self) -> float:
        return self.delta / self.volume if self.volume else 0.0


class TimeBarBuilder:
    """指定秒数のバーをティックから組み立てる。"""

    def __init__(self, seconds: int = 15, on_close=None):
        self.window_ms = seconds * 1000
        self.on_close = on_close
        self.current: Bar | None = None
        self._seen: set[str] = set()          # 約定IDで重複排除
        self._lock = threading.Lock()

    def on_message(self, msg: dict) -> None:
        with self._lock:
            for t in msg.get("data", []):
                self._add(t)

    def _add(self, t: dict) -> None:
        trade_id = t.get("i")
        if trade_id:
            if trade_id in self._seen:
                return                        # 再接続直後の重複配信に備える
            self._seen.add(trade_id)
            if len(self._seen) > 20_000:
                self._seen.clear()

        ts, price, qty = int(t["T"]), float(t["p"]), float(t["v"])
        bucket = ts - (ts % self.window_ms)

        if self.current is None or bucket != self.current.start_ms:
            if self.current is not None and self.on_close:
                self.on_close(self.current)
            self.current = Bar(start_ms=bucket, open=price, high=price,
                               low=price, close=price)

        b = self.current
        b.high = max(b.high, price)
        b.low = min(b.low, price)
        b.close = price
        b.trades += 1
        if t["S"] == "Buy":                   # Sはテイカー側の方向
            b.buy_vol += qty
        else:
            b.sell_vol += qty


def print_bar(b: Bar) -> None:
    print(f"{time.strftime('%H:%M:%S', time.localtime(b.start_ms / 1000))} "
          f"O={b.open:>10.2f} H={b.high:>10.2f} L={b.low:>10.2f} C={b.close:>10.2f} "
          f"vol={b.volume:>8.3f} delta={b.delta:>+8.3f} "
          f"({b.delta_ratio:>+6.1%}) trades={b.trades:>4d}")


if __name__ == "__main__":
    builder = TimeBarBuilder(seconds=15, on_close=print_bar)
    ws = WebSocket(testnet=False, channel_type="linear")
    ws.trade_stream(symbol="BTCUSDT", callback=builder.on_message)

    try:
        while True:
            time.sleep(1)
    except KeyboardInterrupt:
        ws.exit()

ティックから作れるバーには、時間で区切る以外にも出来高で区切るバー・売買代金で区切るバーがあり、統計的性質が時間足より良いことが知られています。長期のティックデータの入手方法とあわせて記事08で扱います。

購読上限と接続制限

制限内容
1リクエストのargs数現物は10個まで。無期限・先物は明示の上限なし
1接続のargs数オプションは2,000個まで
argsの文字数配列全体で21,000文字まで
新規接続レート1ドメインあたり5分で500接続まで
同時接続数マーケットデータは1 IPあたり最大1,000接続(現物・linear・inverse・オプションで別カウント)
ping20秒ごとの送信が推奨
再接続ループが最も危険

接続に失敗したら即座に再接続するコードを書くと、エラー時に秒間数十回の接続試行になり、5分500接続の上限を一瞬で超えます。その結果しばらく接続できなくなり、botが完全に止まります。再接続には必ず指数バックオフとジッタを入れてください(サンプル6)。これはBybitに限らずWebSocket実装の基本です。

asyncioで自作する|ping・再接続・指数バックオフ

pybitのWebSocketはスレッドベースで手軽ですが、非同期botに組み込むなら自作したほうが素直です。再接続戦略・購読の復元・メトリクスを自分で制御できます。

ws_client.pyサンプル5
"""ws_client.py — asyncio + websockets によるBybit public WSクライアント
本コードは本人利用に限り自由に使用可。再配布・商用利用・販売は禁止。

  pip install websockets orjson
"""
from __future__ import annotations

import asyncio
import contextlib
import random
import time
from collections.abc import Awaitable, Callable

import orjson
import websockets

PUBLIC_URL = "wss://stream.bybit.com/v5/public/{channel}"
TESTNET_URL = "wss://stream-testnet.bybit.com/v5/public/{channel}"

PING_INTERVAL = 20.0        # 公式推奨は20秒
Handler = Callable[[dict], Awaitable[None] | None]


class BybitPublicWS:
    def __init__(self, channel: str = "linear", testnet: bool = False):
        base = TESTNET_URL if testnet else PUBLIC_URL
        self.url = base.format(channel=channel)
        self._handlers: dict[str, list[Handler]] = {}
        self._topics: set[str] = set()
        self._ws: websockets.WebSocketClientProtocol | None = None
        self.last_msg_at: float = 0.0
        self.reconnects: int = 0

    # ---- 購読登録(接続前でも後でも呼べる) ----------------------------
    def subscribe(self, topic: str, handler: Handler) -> None:
        self._topics.add(topic)
        self._handlers.setdefault(topic, []).append(handler)

    async def _send_subscribe(self, topics: list[str]) -> None:
        if not topics or self._ws is None:
            return
        # 現物は1リクエスト10argsまで。安全側に倒して10件ずつ送る
        for i in range(0, len(topics), 10):
            chunk = topics[i:i + 10]
            await self._ws.send(orjson.dumps({"op": "subscribe", "args": chunk}))
            await asyncio.sleep(0.05)

    # ---- ping ---------------------------------------------------------
    async def _ping_loop(self) -> None:
        while True:
            await asyncio.sleep(PING_INTERVAL)
            if self._ws is None:
                return
            with contextlib.suppress(Exception):
                await self._ws.send(orjson.dumps({"op": "ping"}))

    # ---- 受信 ---------------------------------------------------------
    async def _recv_loop(self) -> None:
        assert self._ws is not None
        async for raw in self._ws:
            self.last_msg_at = time.time()
            msg = orjson.loads(raw)

            # 制御メッセージ(pong / subscribe結果)
            if "op" in msg:
                if msg["op"] == "subscribe" and not msg.get("success", True):
                    print(f"[ws] 購読失敗: {msg}")
                continue

            topic = msg.get("topic")
            if not topic:
                continue
            for h in self._handlers.get(topic, []):
                result = h(msg)
                if asyncio.iscoroutine(result):
                    await result

    # ---- 本体(自動再接続つき) -----------------------------------------
    async def run(self) -> None:
        backoff = 1.0
        while True:
            try:
                async with websockets.connect(
                    self.url,
                    ping_interval=None,        # Bybit独自のping/pongを使うので無効化
                    max_queue=2048,
                    open_timeout=10,
                    close_timeout=5,
                ) as ws:
                    self._ws = ws
                    self.last_msg_at = time.time()
                    print(f"[ws] 接続 {self.url}")

                    await self._send_subscribe(sorted(self._topics))
                    ping_task = asyncio.create_task(self._ping_loop())
                    try:
                        await self._recv_loop()
                    finally:
                        ping_task.cancel()
                        with contextlib.suppress(asyncio.CancelledError):
                            await ping_task

                backoff = 1.0                  # 正常終了したらバックオフをリセット

            except asyncio.CancelledError:
                raise
            except Exception as e:
                self.reconnects += 1
                # 指数バックオフ + ジッタ。上限30秒。
                # これを入れないと5分500接続の上限に即到達する
                wait = min(backoff, 30.0) * (0.5 + random.random())
                print(f"[ws] 切断 ({type(e).__name__}: {e}) -> {wait:.1f}秒後に再接続 "
                      f"(通算{self.reconnects}回)")
                await asyncio.sleep(wait)
                backoff = min(backoff * 2, 30.0)
            finally:
                self._ws = None


# ---- 使用例 -----------------------------------------------------------
async def main() -> None:
    ws = BybitPublicWS(channel="linear")

    async def on_trade(msg: dict) -> None:
        for t in msg["data"]:
            print(f"trade {t['s']} {t['S']:<4} {t['p']} x {t['v']}")

    def on_ticker(msg: dict) -> None:          # 同期関数も渡せる
        d = msg["data"]
        if "lastPrice" in d:
            print(f"tick  {d['symbol']} {d['lastPrice']}")

    ws.subscribe("publicTrade.BTCUSDT", on_trade)
    ws.subscribe("tickers.BTCUSDT", on_ticker)

    await ws.run()


if __name__ == "__main__":
    with contextlib.suppress(KeyboardInterrupt):
        asyncio.run(main())
ping_interval=None にする理由

websockets ライブラリには標準のWebSocket ping/pong(フレームレベル)機能があり、既定で有効です。しかしBybitはJSONの {"op": "ping"} による独自のハートビートを期待します。両方が動くと挙動が読みづらくなるため、ライブラリ側を None で無効化し、Bybit方式に統一しています。

多銘柄購読マネージャ(チャンク分割)

全銘柄を監視するスキャナを作る場合、購読トピック数が数百になります。argsの文字数上限(21,000文字)と現物の10args制限を守りつつ、接続を分散させる必要があります。

multi_symbol.pyサンプル6
"""multi_symbol.py — 数百銘柄を複数接続に分散して購読する
本コードは本人利用に限り自由に使用可。再配布・商用利用・販売は禁止。
"""
from __future__ import annotations

import asyncio

from pybit.unified_trading import HTTP

from ws_client import BybitPublicWS

MAX_TOPICS_PER_CONN = 100      # 1接続あたりの購読数(控えめに設定する)


def liquid_symbols(min_turnover: float = 20_000_000, limit: int = 200) -> list[str]:
    """24h売買代金でフィルタしたUSDT無期限の銘柄リストを返す。"""
    http = HTTP(testnet=False)
    rows = http.get_tickers(category="linear")["result"]["list"]
    rows = [r for r in rows
            if r["symbol"].endswith("USDT") and float(r["turnover24h"]) >= min_turnover]
    rows.sort(key=lambda r: float(r["turnover24h"]), reverse=True)
    return [r["symbol"] for r in rows[:limit]]


class MultiSymbolFeed:
    """トピックを複数接続に分割して購読する。"""

    def __init__(self, channel: str = "linear"):
        self.channel = channel
        self.clients: list[BybitPublicWS] = []
        self.prices: dict[str, float] = {}

    def _on_ticker(self, msg: dict) -> None:
        d = msg["data"]
        if "lastPrice" in d:                    # deltaでlastPriceが来た時だけ更新
            self.prices[d["symbol"]] = float(d["lastPrice"])

    async def run(self, symbols: list[str]) -> None:
        topics = [f"tickers.{s}" for s in symbols]

        for i in range(0, len(topics), MAX_TOPICS_PER_CONN):
            chunk = topics[i:i + MAX_TOPICS_PER_CONN]
            client = BybitPublicWS(channel=self.channel)
            for t in chunk:
                client.subscribe(t, self._on_ticker)
            self.clients.append(client)

        print(f"{len(symbols)}銘柄を{len(self.clients)}接続で購読します")

        # 接続の確立を少しずつずらす(5分500接続の制限に配慮)
        tasks = []
        for c in self.clients:
            tasks.append(asyncio.create_task(c.run()))
            await asyncio.sleep(0.3)

        tasks.append(asyncio.create_task(self._report()))
        await asyncio.gather(*tasks)

    async def _report(self) -> None:
        while True:
            await asyncio.sleep(10)
            recon = sum(c.reconnects for c in self.clients)
            print(f"[状況] 価格取得済み {len(self.prices)}銘柄 / 再接続通算 {recon}回")


if __name__ == "__main__":
    syms = liquid_symbols()
    asyncio.run(MultiSymbolFeed().run(syms))

断線を検知してRESTで穴埋めする

WebSocketは「切れる」より「静かに止まる」ほうが怖いです。TCP接続は生きているのにデータが流れてこない状態は、例外が飛ばないため気づけません。最終受信時刻を監視するウォッチドッグが必須です。

watchdog.pyサンプル7
"""watchdog.py — 受信停止(沈黙)の検知と、RESTによる欠損足の穴埋め
本コードは本人利用に限り自由に使用可。再配布・商用利用・販売は禁止。
"""
from __future__ import annotations

import asyncio
import time

from pybit.unified_trading import HTTP

from ws_client import BybitPublicWS


class GapFiller:
    """WebSocketが止まっていた区間の確定足をRESTで補完する。"""

    def __init__(self, symbol: str, interval: str = "1", category: str = "linear"):
        self.http = HTTP(testnet=False)
        self.symbol = symbol
        self.interval = interval
        self.category = category
        self.last_bar_start: int | None = None

    def note_bar(self, start_ms: int) -> None:
        self.last_bar_start = start_ms

    def fill(self) -> list[list[str]]:
        """last_bar_start以降の足をRESTで取得する。"""
        if self.last_bar_start is None:
            return []
        res = self.http.get_kline(
            category=self.category, symbol=self.symbol,
            interval=self.interval,
            start=self.last_bar_start + 1, limit=1000,
        )
        if res["retCode"] != 0:
            print(f"[gapfill] 失敗: {res['retMsg']}")
            return []
        # V5のklineは新しい順で返るので、時系列に直す
        rows = list(reversed(res["result"]["list"]))
        if rows:
            print(f"[gapfill] {len(rows)}本を補完しました")
            self.last_bar_start = int(rows[-1][0])
        return rows


async def watchdog(ws: BybitPublicWS, filler: GapFiller,
                  silence_sec: float = 60.0, check_sec: float = 10.0) -> None:
    """一定時間データが来なければ警告し、復帰時にRESTで穴埋めする。"""
    was_silent = False

    while True:
        await asyncio.sleep(check_sec)

        if ws.last_msg_at == 0:
            continue
        silence = time.time() - ws.last_msg_at

        if silence > silence_sec:
            if not was_silent:
                print(f"[警告] {silence:.0f}秒間データが来ていません。"
                      f"エントリーを停止してください")
                was_silent = True
            # ここで記事24のサーキットブレーカーをREDUCE_ONLYに落とすのが実務的
        elif was_silent:
            print(f"[復帰] データ受信を再開しました(沈黙 {silence:.0f}秒)")
            was_silent = False
            filler.fill()          # 欠損区間を埋める


async def main() -> None:
    ws = BybitPublicWS(channel="linear")
    filler = GapFiller("BTCUSDT", interval="1")

    def on_kline(msg: dict) -> None:
        for k in msg["data"]:
            if k.get("confirm"):
                filler.note_bar(int(k["start"]))
                print(f"bar close {k['start']} {k['close']}")

    ws.subscribe("kline.1.BTCUSDT", on_kline)

    await asyncio.gather(ws.run(), watchdog(ws, filler))


if __name__ == "__main__":
    asyncio.run(main())
「データが来ない」は取引停止の理由になる

価格が更新されていないのに、直前の価格を信じて発注を続けるbotは非常に危険です。データの鮮度をリスク管理の入力にする——つまり一定秒数以上更新がなければ新規エントリーを止める——という設計を最初から入れてください。サーキットブレーカーとして体系化したものは記事24で扱います。

受信遅延を計測する

WebSocketに変えて速くなったつもりでも、実際の遅延は測らないとわかりません。メッセージには送信側のタイムスタンプが入っているので、受信時刻との差で片道遅延の目安が得られます。

latency.pyサンプル8
"""latency.py — WebSocket受信遅延の分布を測る(VPSリージョン選定の判断材料)
本コードは本人利用に限り自由に使用可。再配布・商用利用・販売は禁止。

注意: ローカル時計がズレていると測定値もズレる。
      先に記事01の check_clock_skew() でズレを確認しておくこと。
"""
from __future__ import annotations

import asyncio
import statistics
import time

from ws_client import BybitPublicWS


class LatencyMonitor:
    def __init__(self, window: int = 2000):
        self.samples: list[float] = []
        self.window = window

    def on_message(self, msg: dict) -> None:
        # msg["ts"] はBybit側の送信時刻(ms)
        sent = msg.get("ts")
        if not sent:
            return
        delay_ms = time.time() * 1000 - int(sent)
        self.samples.append(delay_ms)
        if len(self.samples) > self.window:
            del self.samples[: len(self.samples) - self.window]

    def report(self) -> str:
        if len(self.samples) < 10:
            return "サンプル収集中..."
        s = sorted(self.samples)
        p = lambda q: s[min(int(len(s) * q), len(s) - 1)]
        return (f"n={len(s):5d}  p50={statistics.median(s):7.1f}ms  "
                f"p90={p(0.90):7.1f}ms  p99={p(0.99):7.1f}ms  max={s[-1]:7.1f}ms")


async def main() -> None:
    mon = LatencyMonitor()
    ws = BybitPublicWS(channel="linear")
    ws.subscribe("publicTrade.BTCUSDT", mon.on_message)
    ws.subscribe("tickers.BTCUSDT", mon.on_message)

    async def printer() -> None:
        while True:
            await asyncio.sleep(10)
            print(f"[遅延] {mon.report()}")

    await asyncio.gather(ws.run(), printer())


if __name__ == "__main__":
    asyncio.run(main())

この数値はVPSのリージョン選定の根拠になります。日本の自宅回線とシンガポール等のVPSで測り比べると、差がはっきり出ます。低遅延が要る戦略(マーケットメイク・記事37)では、ここが成績を左右します。VPS環境の構築は記事38で扱います。

コールバックを詰まらせない設計

WebSocketのコールバック内で重い処理・ブロッキングI/Oをやってはいけません。受信ループが止まり、メッセージが溜まって遅延が膨らみ、最終的に接続が切れます。

コールバックの中でやってはいけないこと

正解はキューに入れて即座に返すことです。処理は別タスク・別スレッドで行います。

queue_pipeline.pyサンプル9
"""queue_pipeline.py — 受信と処理を分離し、遅れたら古いデータを捨てる
本コードは本人利用に限り自由に使用可。再配布・商用利用・販売は禁止。
"""
from __future__ import annotations

import asyncio
import time

from ws_client import BybitPublicWS

QUEUE_MAX = 10_000


class Pipeline:
    def __init__(self):
        self.q: asyncio.Queue[dict] = asyncio.Queue(maxsize=QUEUE_MAX)
        self.dropped = 0
        self.processed = 0

    def enqueue(self, msg: dict) -> None:
        """コールバックはこれだけ。絶対にブロックしない。"""
        try:
            self.q.put_nowait(msg)
        except asyncio.QueueFull:
            # 溢れたら「古いものを捨てて新しいものを入れる」。
            # 相場データは鮮度が命なので、古いデータを守る意味がない。
            self.dropped += 1
            try:
                self.q.get_nowait()
                self.q.put_nowait(msg)
            except (asyncio.QueueEmpty, asyncio.QueueFull):
                pass

    async def worker(self, name: str) -> None:
        """重い処理はここ。複数起動して並列化してもよい。"""
        while True:
            msg = await self.q.get()
            try:
                await self._handle(msg)
                self.processed += 1
            except Exception as e:
                print(f"[{name}] 処理エラー: {type(e).__name__}: {e}")
            finally:
                self.q.task_done()

    async def _handle(self, msg: dict) -> None:
        # ここでDB書き込み・指標計算・発注判断などを行う
        await asyncio.sleep(0)          # 実処理の代わり

    async def stats(self) -> None:
        while True:
            await asyncio.sleep(10)
            print(f"[pipeline] 処理={self.processed} 破棄={self.dropped} "
                  f"待ち={self.q.qsize()}")
            if self.q.qsize() > QUEUE_MAX * 0.5:
                print("  [警告] キューが溜まっています。処理が追いついていません")


async def main() -> None:
    pipe = Pipeline()
    ws = BybitPublicWS(channel="linear")

    for sym in ("BTCUSDT", "ETHUSDT", "SOLUSDT"):
        ws.subscribe(f"publicTrade.{sym}", pipe.enqueue)
        ws.subscribe(f"tickers.{sym}", pipe.enqueue)

    await asyncio.gather(
        ws.run(),
        pipe.worker("w1"),
        pipe.worker("w2"),
        pipe.stats(),
    )


if __name__ == "__main__":
    asyncio.run(main())
溢れたら古いデータを捨てる

一般的なキュー設計では「溢れたら新しいデータを捨てる(reject)」が定石ですが、相場データは逆です。10秒前の価格を守って最新の価格を捨てるのは無意味です。上のコードのように古いものから捨てる実装にし、同時に破棄件数をメトリクスとして監視して、処理能力不足に気づけるようにしてください。

よくあるトラブルと対処

症状原因対処
突然 KeyError: 'lastPrice'linear/inverseのtickerが差分配信状態ストアでマージする(サンプル2)
数分で接続が切れるpingを送っていない20秒ごとに {"op":"ping"} を送信
再接続できなくなる再接続ループが上限に到達指数バックオフ+ジッタを入れる
購読が無反応channel_typeとトピックの不一致BTCUSDTは linear と spot 双方に存在。接続先を確認
同じ足が何度も処理される未確定足を処理しているconfirm を判定する(サンプル3)
徐々に遅延が増えるコールバックが重いキューで受信と処理を分離(サンプル9)
再接続後に約定が重複再送されたティックを再処理約定ID(i)で重複排除(サンプル4)
現物で購読が一部通らない1リクエスト10args制限10件ずつに分割して送る(サンプル5)
参考リンク(一次情報)

よくある疑問Q&A

pybitのWebSocketと自作、どちらを使うべきですか?
単一銘柄の監視や試作なら pybit で十分です。asyncioベースのbotに組み込む、再接続戦略を制御したい、購読数が多い、メトリクスを取りたい——このいずれかに該当するなら自作(サンプル5)を推奨します。pybitはスレッドベースなので、asyncioのイベントループと組み合わせるとコールバックからのループアクセスに注意が必要になります。
RESTポーリングとWebSocket、どちらが速いですか?
WebSocketです。RESTは1リクエストごとにHTTPの往復が発生し、さらにレート制限に縛られるため、実質的な更新頻度が上がりません。WebSocketならティッカーは100ms間隔(現物50ms)でプッシュされます。ただし「発注」に関してはRESTでも十分な場面が多く、レイテンシを詰めたい場合はWS発注(記事29)という選択肢があります。
1つの接続で現物とlinearの両方を購読できますか?
できません。エンドポイントが /v5/public/spot と /v5/public/linear で分かれているため、接続を2本張る必要があります。現物と無期限の裁定戦略(記事35)では両方の接続が必要になるので、サンプル5のクライアントを2つインスタンス化して asyncio.gather() でまとめて回します。
msg["ts"] と data内の時刻フィールドは何が違いますか?
msg["ts"] はBybitがそのメッセージを送信した時刻、data内の T(publicTrade)や timestamp(kline)はイベント自体が発生した時刻です。遅延計測には ts と受信時刻の差、イベントの時系列整列には data 内の時刻を使います。両者の差は取引所内部の処理時間にあたります。
WebSocketでポジションや注文の状態も取れますか?
取れますが、それは private ストリーム(wss://stream.bybit.com/v5/private)で、認証が必要です。order / execution / position / wallet のトピックを購読すると、約定や残高の変化がプッシュされます。RESTポーリングを大幅に削減できるため実運用では必須です。記事27で扱います。
受信したデータをそのまま保存したいのですが
コールバックで直接ファイルに書くとブロッキングで詰まります。サンプル9のキュー経由で、ワーカー側が一定件数バッファしてから一括書き込みする形にしてください。長期保存ならParquet(記事07)が扱いやすく、ティックレベルの大量データはDuckDBやPolarsが有効です(記事08)。
板情報(depth 50)を購読したら値がおかしくなりました
depth 50以上はsnapshot+delta方式なので、届いたメッセージをそのまま板として扱うと壊れます。数量0は削除を意味し、u(update id)の連続性を検証して欠番があれば再購読する必要があります。この処理は間違えやすく、しかも間違えてもエラーにならず静かに壊れるため、記事28で専用に解説します。それまでは depth=1 か REST の get_orderbook() を使ってください。

注意事項・免責事項

投資は自己判断・自己責任でお願いいたします。本ブログの情報・ツール・ソフトウェアを参考・使用して、取引をされて何らかの損失を被られても、当サイトでは責任は一切負いません(間接的・付随的な障害・損失も含め一切の責任を負いません)。自己責任でご利用ください。

当サイトからダウンロードしたプログラム・EAは本人利用に限り完全無料でご利用いただけます。プログラムの全てまたは一部の複製・コピー・リバースエンジニアリング・改変・2次配布・商用利用・転売・販売等は一切禁止いたします。違反を発見した場合は法的処置を行います。

当サイト上に存在するソフトウェア・ツール・文章・画像・動画等の著作物を無断転載することを禁止します。引用の範囲を超えるものについては、法的処置を行います。

当サイトで配布するツール・ソフトウェアは無料提供ですのでサポートなどは行っておりません(逆に丁寧にサポートしているサイトはほとんど違法サイトですのでご注意ください。投資助言・代理業登録なしでサポート・助言することは法律で禁止されています)。万が一、ツールやソフトウェアに不具合や問題があっても当サイトでは修正・対応などができない場合があります。ご利用の環境でトラブルが発生しても当サイトでは助言することはできませんので自己責任でご利用ください。検索するなどして自分で解決してください。

当サイトでは取引所・証券会社へのリンクを貼っていますが、これは情報として提供するものであり、勧誘行為などは一切行っておりません。そのため、取引所・証券会社の登録方法・使い方・オススメ金融機関・オススメ金融商品などを説明した記事はサイト内にありませんので、ご自身で調べた上で判断ください(本当はサイト内で丁寧に説明したいのですが、仲介・取次ぎにあたる可能性があり第一種金融商品取引業登録を行っていない場合、違法となる可能性があるため出来ません。ご容赦ください)。

当サイトからリンクやバナーなどによって他のサイトに移動された場合、移動先サイトで提供される情報、サービス等について一切の責任を負いません。当サイト掲載情報やリンク先の掲載情報の合法性、正確性、道徳性、最新性、適切性、著作権の許諾や有無など、その内容については一切の保証を致しかねます。

当サイトは、ご訪問いただいた時間、訪問者のアクセス元のホスト名、IPアドレス、OSとブラウザのバージョン、リンク元のURL、モニターの情報等のパソコン環境情報を収集し、アクセス解析をしております。この目的は訪問者が検索サイトからご訪問いただいた場合、どのような検索で当サイトに来訪いただいたかの解析にのみ使用しており、第3者に公開はいたしません。また、個人を特定するものではありません。