※ Bybit V5は機能追加のペースが速いAPIです。パラメータの最終的な正解は必ず公式ドキュメントで確認してください。本記事のコードはすべてテストネットで動作確認しています。
この記事は記事01(pybit入門)の config.py を前提にしますが、public WebSocketはAPIキー不要なのでキーなしでも全サンプルが動きます。
# 本コードは本人利用に限り自由に使用可。再配布・商用利用・販売は禁止。
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 で問題ありません(発注が絡まないため事故になりません)。
ws.ticker_stream(...) は購読を登録してすぐ返ってきます。受信ループは内部のスレッドが回すため、メインスレッドが終了するとプロセスごと落ちます。上のコードの while True: time.sleep(1) はそのための「生存ループ」です。asyncio ベースで組みたい場合は後半の自作クライアント(サンプル6)を使ってください。
| 用途 | 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(板・価格)は本番の 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 / spot | 1 / 50 / 200 / 1000 | 1→10ms、50→20ms、200→100ms、1000→200ms |
| option | 25 / 100 | 25→20ms、100→100ms |
orderbook.1.BTCUSDT はベスト気配1本だけなので毎回そのまま使えますが、depth 50以上は最初にsnapshot、以降はdeltaが届きます。deltaを正しく適用してローカル板を組み立てないと、静かに壊れた板で発注することになります。板の復元は難易度が高いため記事28で専用に扱います。この記事ではdepth=1のみ扱います。
ここが日本語の解説でほぼ触れられていない、実装で必ず踏むポイントです。
| category | 配信方式 | 結果 |
|---|---|---|
| spot | snapshotのみ | 毎回すべてのフィールドが揃っている |
| linear / inverse | snapshot+delta | deltaには変化したフィールドだけが入る |
つまり linear で msg["data"]["lastPrice"] と書くと、lastPriceが変化していないメッセージで KeyError になります。素朴に書いたコードが数分〜数時間後に突然落ちる、という嫌な壊れ方をします。正解はローカルに最新状態を保持し、deltaをマージすることです。
"""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() を持たせておくと、後述のウォッチドッグがそのまま書けます。
kline トピックは未確定足も逐次配信されます。同じ足が何度も届き、値が変わり続けます。「終値でシグナルを判定する」ロジックをそのまま書くと、確定していない終値で何度も発注してしまいます。
判別は confirm フィールドです。true なら足が閉じ、値は確定して以後変わりません。
"""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 は約定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 — 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・オプションで別カウント) |
| ping | 20秒ごとの送信が推奨 |
接続に失敗したら即座に再接続するコードを書くと、エラー時に秒間数十回の接続試行になり、5分500接続の上限を一瞬で超えます。その結果しばらく接続できなくなり、botが完全に止まります。再接続には必ず指数バックオフとジッタを入れてください(サンプル6)。これはBybitに限らずWebSocket実装の基本です。
pybitのWebSocketはスレッドベースで手軽ですが、非同期botに組み込むなら自作したほうが素直です。再接続戦略・購読の復元・メトリクスを自分で制御できます。
"""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())
websockets ライブラリには標準のWebSocket ping/pong(フレームレベル)機能があり、既定で有効です。しかしBybitはJSONの {"op": "ping"} による独自のハートビートを期待します。両方が動くと挙動が読みづらくなるため、ライブラリ側を None で無効化し、Bybit方式に統一しています。
全銘柄を監視するスキャナを作る場合、購読トピック数が数百になります。argsの文字数上限(21,000文字)と現物の10args制限を守りつつ、接続を分散させる必要があります。
"""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))
WebSocketは「切れる」より「静かに止まる」ほうが怖いです。TCP接続は生きているのにデータが流れてこない状態は、例外が飛ばないため気づけません。最終受信時刻を監視するウォッチドッグが必須です。
"""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 — 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をやってはいけません。受信ループが止まり、メッセージが溜まって遅延が膨らみ、最終的に接続が切れます。
print() の大量出力(端末がボトルネックになる)time.sleep()正解はキューに入れて即座に返すことです。処理は別タスク・別スレッドで行います。
"""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) |
/v5/public/spot と /v5/public/linear で分かれているため、接続を2本張る必要があります。現物と無期限の裁定戦略(記事35)では両方の接続が必要になるので、サンプル5のクライアントを2つインスタンス化して asyncio.gather() でまとめて回します。msg["ts"] はBybitがそのメッセージを送信した時刻、data内の T(publicTrade)や timestamp(kline)はイベント自体が発生した時刻です。遅延計測には ts と受信時刻の差、イベントの時系列整列には data 内の時刻を使います。両者の差は取引所内部の処理時間にあたります。wss://stream.bybit.com/v5/private)で、認証が必要です。order / execution / position / wallet のトピックを購読すると、約定や残高の変化がプッシュされます。RESTポーリングを大幅に削減できるため実運用では必須です。記事27で扱います。u(update id)の連続性を検証して欠番があれば再購読する必要があります。この処理は間違えやすく、しかも間違えてもエラーにならず静かに壊れるため、記事28で専用に解説します。それまでは depth=1 か REST の get_orderbook() を使ってください。投資は自己判断・自己責任でお願いいたします。本ブログの情報・ツール・ソフトウェアを参考・使用して、取引をされて何らかの損失を被られても、当サイトでは責任は一切負いません(間接的・付随的な障害・損失も含め一切の責任を負いません)。自己責任でご利用ください。
当サイトからダウンロードしたプログラム・EAは本人利用に限り完全無料でご利用いただけます。プログラムの全てまたは一部の複製・コピー・リバースエンジニアリング・改変・2次配布・商用利用・転売・販売等は一切禁止いたします。違反を発見した場合は法的処置を行います。
当サイト上に存在するソフトウェア・ツール・文章・画像・動画等の著作物を無断転載することを禁止します。引用の範囲を超えるものについては、法的処置を行います。
当サイトで配布するツール・ソフトウェアは無料提供ですのでサポートなどは行っておりません(逆に丁寧にサポートしているサイトはほとんど違法サイトですのでご注意ください。投資助言・代理業登録なしでサポート・助言することは法律で禁止されています)。万が一、ツールやソフトウェアに不具合や問題があっても当サイトでは修正・対応などができない場合があります。ご利用の環境でトラブルが発生しても当サイトでは助言することはできませんので自己責任でご利用ください。検索するなどして自分で解決してください。
当サイトでは取引所・証券会社へのリンクを貼っていますが、これは情報として提供するものであり、勧誘行為などは一切行っておりません。そのため、取引所・証券会社の登録方法・使い方・オススメ金融機関・オススメ金融商品などを説明した記事はサイト内にありませんので、ご自身で調べた上で判断ください(本当はサイト内で丁寧に説明したいのですが、仲介・取次ぎにあたる可能性があり第一種金融商品取引業登録を行っていない場合、違法となる可能性があるため出来ません。ご容赦ください)。
当サイトからリンクやバナーなどによって他のサイトに移動された場合、移動先サイトで提供される情報、サービス等について一切の責任を負いません。当サイト掲載情報やリンク先の掲載情報の合法性、正確性、道徳性、最新性、適切性、著作権の許諾や有無など、その内容については一切の保証を致しかねます。
当サイトは、ご訪問いただいた時間、訪問者のアクセス元のホスト名、IPアドレス、OSとブラウザのバージョン、リンク元のURL、モニターの情報等のパソコン環境情報を収集し、アクセス解析をしております。この目的は訪問者が検索サイトからご訪問いただいた場合、どのような検索で当サイトに来訪いただいたかの解析にのみ使用しており、第3者に公開はいたしません。また、個人を特定するものではありません。