Bybit APIのレート制限を攻略するPython実装
X-Bapi-Limitヘッダ・
トークンバケット・指数バックオフ

Bybitのレート制限はIP単位とUID単位の二層構造で、しかもバッチ注文は「注文本数分」を消費します。ここを理解せずにbotを回すと、IP制限(HTTP 403)で約10分間すべてのAPIが止まる——売買も決済も監視もできない最悪の状態に陥ります。この記事ではレスポンスヘッダを読んで自律的に流量を絞るクライアントを、トークンバケットと指数バックオフで実装します。サンプルコード8本。

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

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

目次

  1. レート制限の二層構造
  2. レスポンスヘッダで残量を知る
  3. エンドポイント別の上限一覧
  4. バッチ注文は「本数分」消費する
  5. トークンバケットの実装
  6. 流量制御つきクライアント
  7. 指数バックオフとジッタ
  8. asyncioでの流量制御
  9. 消費量を計測して犯人を特定する
  10. そもそも呼ばない設計
  11. WebSocketの接続制限
  12. よくある疑問Q&A

レート制限の二層構造

層単位上限超過時影響範囲
第1層 IPアドレス 5秒あたり600リクエスト HTTP 403
約10分の制限
そのIPからの全リクエスト。売買も監視も止まる
第2層 UID × エンドポイント 秒間(ローリング窓) retCode 10006
retCode 10018
該当エンドポイントのみ
IP制限に到達すると「何もできなくなる」

第2層(10006)は該当エンドポイントだけの問題なので、待てば回復します。しかし第1層(HTTP 403)は約10分間、そのIPからのすべてのリクエストが拒否されます。ポジションを持っている最中にこれが起きると、決済もキャンセルもできません。相場が急変していても手が出せません。

これは「レート制限に引っかかった」ではなく「リスク管理の完全な喪失」です。だからこそ、履歴の一括取得のような重いバッチ処理は売買botとは別プロセス、可能なら別IPで動かすべきです(記事38)。

なぜ「並列数を増やしても速くならない」のか

レート制限は単位時間あたりのリクエスト数で決まるため、並列数を増やしても総スループットは上がりません。上がるのは403のリスクだけです。正しい設計は次のとおりです。

レスポンスヘッダで残量を知る

Bybitは毎回のレスポンスで残量を教えてくれます。これを読めば、当てずっぽうではなく実測に基づいて流量を調整できます。

ヘッダ意味
X-Bapi-Limitそのエンドポイントの上限値
X-Bapi-Limit-Status残りリクエスト数。これが0に近づいたら絞る
X-Bapi-Limit-Reset-Timestamp残量がリセットされる時刻(ms)
rate_headers.pyサンプル1
"""rate_headers.py — レート制限ヘッダを読んで自律的に減速する
本コードは本人利用に限り自由に使用可。再配布・商用利用・販売は禁止。
"""
from __future__ import annotations

import time
from dataclasses import dataclass, field


@dataclass
class RateStatus:
    """1エンドポイントの最新レート状況。"""

    limit: int = 0
    remaining: int = 0
    reset_at_ms: int = 0
    observed_at: float = field(default_factory=time.time)

    @property
    def usage_ratio(self) -> float:
        """使用率。1.0に近いほど危険。"""
        if self.limit <= 0:
            return 0.0
        return 1.0 - (self.remaining / self.limit)

    @property
    def seconds_to_reset(self) -> float:
        if self.reset_at_ms <= 0:
            return 0.0
        return max(0.0, self.reset_at_ms / 1000 - time.time())


class RateHeaderMonitor:
    """全エンドポイントのレート状況を保持し、必要なら待機時間を返す。"""

    # 残量がこの割合を下回ったら自主的に減速する
    SLOW_DOWN_RATIO = 0.2

    def __init__(self, verbose: bool = True):
        self.status: dict[str, RateStatus] = {}
        self.verbose = verbose
        self.slowdowns = 0

    def observe(self, endpoint: str, headers) -> RateStatus | None:
        """レスポンスヘッダを取り込む。headersはdictでもCaseInsensitiveDictでも可。"""

        def get(name: str) -> str | None:
            return headers.get(name) or headers.get(name.lower())

        raw_limit = get("X-Bapi-Limit")
        raw_remaining = get("X-Bapi-Limit-Status")
        if raw_limit is None or raw_remaining is None:
            return None       # 公開エンドポイントなど、ヘッダが無い場合

        try:
            st = RateStatus(
                limit=int(raw_limit),
                remaining=int(raw_remaining),
                reset_at_ms=int(get("X-Bapi-Limit-Reset-Timestamp") or 0),
            )
        except ValueError:
            return None

        self.status[endpoint] = st

        if st.limit > 0 and st.remaining / st.limit < self.SLOW_DOWN_RATIO:
            self.slowdowns += 1
            if self.verbose:
                print(f"[rate] {endpoint}: 残 {st.remaining}/{st.limit} "
                      f"(使用率 {st.usage_ratio:.0%}) -> 減速します")
        return st

    def suggested_wait(self, endpoint: str) -> float:
        """このエンドポイントを次に呼ぶ前に待つべき秒数。"""
        st = self.status.get(endpoint)
        if st is None or st.limit <= 0:
            return 0.0

        # 残量がゼロならリセットまで待つ
        if st.remaining <= 0:
            return min(st.seconds_to_reset + 0.05, 5.0)

        # 残量が少ないほど長く待つ(線形に減速)
        if st.remaining / st.limit < self.SLOW_DOWN_RATIO:
            return (1.0 - st.remaining / st.limit) * 0.5
        return 0.0

    def report(self) -> None:
        if not self.status:
            print("レート情報がまだありません")
            return
        print(f"{'endpoint':<40}{'残/上限':>12}{'使用率':>8}")
        print("-" * 62)
        for ep, st in sorted(self.status.items(),
                             key=lambda kv: -kv[1].usage_ratio):
            print(f"{ep:<40}{st.remaining:>6}/{st.limit:<5}"
                  f"{st.usage_ratio:>7.0%}")
        print(f"\n減速の発生回数: {self.slowdowns}")


if __name__ == "__main__":
    mon = RateHeaderMonitor()
    # 実際のレスポンスヘッダを模した例
    mon.observe("/v5/order/create", {
        "X-Bapi-Limit": "20",
        "X-Bapi-Limit-Status": "3",
        "X-Bapi-Limit-Reset-Timestamp": str(int((time.time() + 1) * 1000)),
    })
    mon.report()
    print(f"\n次の呼び出しまでの推奨待機: "
          f"{mon.suggested_wait('/v5/order/create'):.3f}秒")

エンドポイント別の上限一覧

実装で使う主要なものを抜粋します。最終的な数値は必ず公式のRate Limitを確認してください(VIPランクやアカウント種別で変わります)。

取引(Trade)

操作linearspotinverse
発注(Create Order)20/秒20/秒10/秒
修正(Amend Order)10/秒10/秒10/秒
キャンセル(Cancel Order)20/秒20/秒10/秒
全キャンセル(Cancel All)20/秒20/秒1/秒
バッチ発注・修正・キャンセル20/秒20/秒10/秒
inverseのCancel Allは毎秒1回

コイン建て(inverse)の全キャンセルは毎秒1リクエストという厳しい制限があります。緊急停止(キルスイッチ)で複数銘柄を一気にキャンセルする設計にしていると、ここで詰まります。キルスイッチはinverseを扱うかどうかで設計が変わるので、事前に確認しておいてください(記事24)。

ポジション・アカウント

エンドポイント上限実装上の意味
ポジション一覧50/秒比較的余裕がある。ただし常時ポーリングはWSに寄せる
決済損益(Closed PnL)50/秒履歴の一括取得で使う
レバレッジ設定10/秒起動時に1回でよい
TP/SL設定(Trading Stop)10/秒自作トレールで詰まりやすい(記事14)
ウォレット残高50/秒監視ループで多用される
取引履歴(Transaction Log)25/秒確定申告用の一括取得(記事40)
手数料率(Fee Rate)5/秒低い。起動時に取得してキャッシュする
銘柄情報(Instruments Info)10/秒必ずキャッシュする(記事03)
借入・返済1/秒連続実行できない
上限が低いエンドポイントは「キャッシュする」が答え

手数料率(5/秒)と銘柄情報(10/秒)は上限が低いですが、これらはほとんど変化しないデータです。毎回取得するのが間違いで、起動時に1回取得してメモリまたはファイルにキャッシュすべきものです。「上限が低い=頻繁に呼ぶ必要がないデータ」という設計意図が読み取れます。

バッチ注文は「本数分」消費する

ここは見落としやすい重要な仕様です。バッチ注文のレート消費は「リクエスト1回」ではなく「注文本数」です。

操作リクエスト数レート消費
個別発注 × 10回1010
バッチ発注(10本を1回)110

つまりバッチの利点はレート消費の節約ではなく、レイテンシとネットワーク往復の削減です(記事16)。グリッドbotで50本の指値を置くなら、レート消費は50件分——linearの上限20/秒なら最低3秒かかると見積もる必要があります。

batch_planner.pyサンプル2
"""batch_planner.py — バッチ注文のレート消費を見積もり、実行計画を立てる
本コードは本人利用に限り自由に使用可。再配布・商用利用・販売は禁止。
"""
from __future__ import annotations

import math
from dataclasses import dataclass

# 1リクエストで送れる注文本数の上限(categoryごと。公式Docsで確認すること)
MAX_PER_BATCH = {"linear": 20, "inverse": 20, "spot": 10, "option": 20}

# 発注系の秒間上限
CREATE_RPS = {"linear": 20, "spot": 20, "inverse": 10, "option": 20}


@dataclass
class BatchPlan:
    category: str
    total_orders: int
    chunks: list[int]
    estimated_seconds: float
    rate_cost: int

    def describe(self) -> str:
        return (f"{self.category}: {self.total_orders}本を"
                f"{len(self.chunks)}リクエストに分割\n"
                f"  レート消費 : {self.rate_cost}件分"
                f"(上限 {CREATE_RPS[self.category]}/秒)\n"
                f"  所要時間   : 約{self.estimated_seconds:.1f}秒\n"
                f"  分割       : {self.chunks}")


def plan_batch(total_orders: int, category: str = "linear",
               safety: float = 0.7) -> BatchPlan:
    """発注本数から、分割方法と所要時間を見積もる。

    safety は上限に対する使用率。0.7なら上限の70%で流す。
    """
    if category not in MAX_PER_BATCH:
        raise ValueError(f"未知のcategory: {category}")

    per_batch = MAX_PER_BATCH[category]
    chunks = [per_batch] * (total_orders // per_batch)
    if total_orders % per_batch:
        chunks.append(total_orders % per_batch)

    effective_rps = CREATE_RPS[category] * safety
    # 消費は「本数」で決まるので、本数 / 実効rps が所要時間
    seconds = total_orders / effective_rps if effective_rps > 0 else float("inf")

    return BatchPlan(category=category, total_orders=total_orders,
                     chunks=chunks, estimated_seconds=seconds,
                     rate_cost=total_orders)


def chunk_orders(orders: list[dict], category: str = "linear") -> list[list[dict]]:
    """注文リストを、1リクエストの上限本数で分割する。"""
    size = MAX_PER_BATCH[category]
    return [orders[i:i + size] for i in range(0, len(orders), size)]


if __name__ == "__main__":
    for n, cat in ((10, "linear"), (50, "linear"), (50, "spot"),
                   (200, "linear")):
        print(plan_batch(n, cat).describe())
        print()

    print("--- グリッドbotの現実的な見積り ---")
    p = plan_batch(60, "linear")
    print(f"60本のグリッドを張り直すのに約{p.estimated_seconds:.1f}秒。")
    print("この間に相場が動くため、価格を都度再計算する設計が必要"
          "(記事36ではamend_batchで価格だけ更新する方式を使う)")

トークンバケットの実装

流量を制御する標準的な手法です。「一定速度でトークンが補充されるバケツから、リクエストごとにトークンを取る」という仕組みで、平均流量を守りつつ短時間のバーストも許容できます。

token_bucket.pyサンプル3
"""token_bucket.py — スレッドセーフなトークンバケットと階層的レートゲート
本コードは本人利用に限り自由に使用可。再配布・商用利用・販売は禁止。
"""
from __future__ import annotations

import threading
import time
from dataclasses import dataclass, field


class TokenBucket:
    """一定速度で補充されるトークンバケット(スレッドセーフ)。

    rate     : 1秒あたりの補充トークン数
    capacity : 溜められる最大トークン数(バースト許容量)
    """

    def __init__(self, rate: float, capacity: float | None = None,
                 name: str = ""):
        if rate <= 0:
            raise ValueError("rateは正の値でなければなりません")
        self.rate = rate
        self.capacity = capacity if capacity is not None else rate
        self.name = name
        self._tokens = float(self.capacity)
        self._last = time.monotonic()
        self._lock = threading.Lock()
        self.total_waited = 0.0
        self.total_acquired = 0

    def _refill(self, now: float) -> None:
        elapsed = now - self._last
        if elapsed > 0:
            self._tokens = min(self.capacity, self._tokens + elapsed * self.rate)
            self._last = now

    def acquire(self, cost: float = 1.0, timeout: float | None = None) -> float:
        """トークンを取得する。足りなければ待つ。待った秒数を返す。

        cost にバッチの本数を渡せば、消費量を正しく反映できる。
        """
        if cost > self.capacity:
            raise ValueError(
                f"{self.name}: cost({cost})がcapacity({self.capacity})を超えています。"
                f"分割してください"
            )

        deadline = None if timeout is None else time.monotonic() + timeout
        waited = 0.0

        while True:
            with self._lock:
                now = time.monotonic()
                self._refill(now)

                if self._tokens >= cost:
                    self._tokens -= cost
                    self.total_acquired += 1
                    self.total_waited += waited
                    return waited

                # 足りない分が補充されるまでの時間
                need = cost - self._tokens
                sleep_for = need / self.rate

            if deadline is not None and time.monotonic() + sleep_for > deadline:
                raise TimeoutError(f"{self.name}: トークン取得がタイムアウト")

            time.sleep(min(sleep_for, 0.25))    # 細かく刻んで応答性を保つ
            waited += min(sleep_for, 0.25)

    @property
    def available(self) -> float:
        with self._lock:
            self._refill(time.monotonic())
            return self._tokens


@dataclass
class RateGate:
    """IP全体(グローバル)とエンドポイント別の二層でトークンを取る。

    Bybitの制限構造をそのまま写した設計。
    """

    # IP制限は5秒600件。安全側に倒して秒間40件・バースト80件にする
    global_rate: float = 40.0
    global_capacity: float = 80.0
    default_endpoint_rate: float = 10.0

    # エンドポイントごとの上限(公式の値から安全マージンを引いたもの)
    endpoint_rates: dict[str, float] = field(default_factory=lambda: {
        "/v5/order/create": 15.0,
        "/v5/order/amend": 8.0,
        "/v5/order/cancel": 15.0,
        "/v5/order/cancel-all": 8.0,
        "/v5/order/create-batch": 15.0,
        "/v5/order/amend-batch": 15.0,
        "/v5/order/cancel-batch": 15.0,
        "/v5/position/list": 40.0,
        "/v5/position/set-leverage": 8.0,
        "/v5/position/trading-stop": 8.0,
        "/v5/account/wallet-balance": 40.0,
        "/v5/account/transaction-log": 20.0,
        "/v5/account/fee-rate": 4.0,
        "/v5/market/instruments-info": 8.0,
    })

    def __post_init__(self) -> None:
        self._global = TokenBucket(self.global_rate, self.global_capacity,
                                   name="GLOBAL")
        self._buckets: dict[str, TokenBucket] = {}
        self._lock = threading.Lock()

    def bucket(self, endpoint: str) -> TokenBucket:
        with self._lock:
            if endpoint not in self._buckets:
                rate = self.endpoint_rates.get(endpoint,
                                               self.default_endpoint_rate)
                self._buckets[endpoint] = TokenBucket(rate, rate,
                                                      name=endpoint)
            return self._buckets[endpoint]

    def acquire(self, endpoint: str, cost: float = 1.0) -> float:
        """エンドポイント別 -> グローバル の順に取得する。"""
        waited = self.bucket(endpoint).acquire(cost)
        waited += self._global.acquire(cost)
        return waited

    def report(self) -> None:
        print(f"{'bucket':<38}{'残':>8}{'取得数':>8}{'待機合計':>10}")
        print("-" * 66)
        b = self._global
        print(f"{'GLOBAL (IP)':<38}{b.available:>8.1f}{b.total_acquired:>8}"
              f"{b.total_waited:>9.2f}s")
        for ep, bk in sorted(self._buckets.items(),
                             key=lambda kv: -kv[1].total_waited):
            print(f"{ep:<38}{bk.available:>8.1f}{bk.total_acquired:>8}"
                  f"{bk.total_waited:>9.2f}s")


if __name__ == "__main__":
    gate = RateGate()

    started = time.monotonic()
    for i in range(30):
        w = gate.acquire("/v5/order/create")
        if w > 0:
            print(f"  {i:>2}回目: {w:.3f}秒待機")
    print(f"\n30リクエストに{time.monotonic() - started:.2f}秒 "
          f"(上限15/秒なので約2秒が妥当)\n")

    # バッチは本数分のコストを渡す
    print("バッチ20本を発注:")
    w = gate.acquire("/v5/order/create-batch", cost=20)
    print(f"  {w:.3f}秒待機(20件分のトークンを消費)\n")

    gate.report()

流量制御つきクライアント

トークンバケットとヘッダ監視を、既存のpybitセッションに後付けします。呼び出し側のコードを変えずに済むのが利点です。

gated_http.pyサンプル4
"""gated_http.py — pybitのHTTPをラップして全リクエストを流量制御下に置く
本コードは本人利用に限り自由に使用可。再配布・商用利用・販売は禁止。
"""
from __future__ import annotations

import functools
import time
from collections.abc import Callable

from pybit.unified_trading import HTTP

from rate_headers import RateHeaderMonitor
from token_bucket import RateGate

# pybitのメソッド名 -> エンドポイントパス
# バケットをエンドポイント単位で分けるために必要
METHOD_ENDPOINT: dict[str, str] = {
    "place_order": "/v5/order/create",
    "amend_order": "/v5/order/amend",
    "cancel_order": "/v5/order/cancel",
    "cancel_all_orders": "/v5/order/cancel-all",
    "place_batch_order": "/v5/order/create-batch",
    "amend_batch_order": "/v5/order/amend-batch",
    "cancel_batch_order": "/v5/order/cancel-batch",
    "get_open_orders": "/v5/order/realtime",
    "get_order_history": "/v5/order/history",
    "get_executions": "/v5/execution/list",
    "get_positions": "/v5/position/list",
    "set_leverage": "/v5/position/set-leverage",
    "set_trading_stop": "/v5/position/trading-stop",
    "get_closed_pnl": "/v5/position/closed-pnl",
    "get_wallet_balance": "/v5/account/wallet-balance",
    "get_fee_rates": "/v5/account/fee-rate",
    "get_transaction_log": "/v5/account/transaction-log",
    "get_instruments_info": "/v5/market/instruments-info",
    "get_kline": "/v5/market/kline",
    "get_tickers": "/v5/market/tickers",
    "get_orderbook": "/v5/market/orderbook",
    "get_funding_rate_history": "/v5/market/history-fund-rate",
}

# バッチ系メソッドの「本数」を取り出すためのキー
BATCH_KEYS = ("request",)


def batch_cost(kwargs: dict) -> int:
    """バッチリクエストの注文本数を数える。無ければ1。"""
    for key in BATCH_KEYS:
        items = kwargs.get(key)
        if isinstance(items, list):
            return max(1, len(items))
    return 1


class GatedHTTP:
    """pybitのHTTPと同じインターフェースで、流量制御を挟む。

    使い方:
        c = make_client()
        http = GatedHTTP(c.http)
        http.get_tickers(category="linear")     # 通常どおり呼べる
    """

    def __init__(self, http: HTTP, gate: RateGate | None = None,
                 monitor: RateHeaderMonitor | None = None,
                 obey_headers: bool = True):
        self._http = http
        self.gate = gate or RateGate()
        self.monitor = monitor or RateHeaderMonitor()
        self.obey_headers = obey_headers
        self._wrapped: dict[str, Callable] = {}

    def __getattr__(self, name: str):
        # HTTP以外の属性はそのまま通す
        attr = getattr(self._http, name)
        if not callable(attr):
            return attr
        if name in self._wrapped:
            return self._wrapped[name]

        endpoint = METHOD_ENDPOINT.get(name, f"/unknown/{name}")

        @functools.wraps(attr)
        def wrapper(*args, **kwargs):
            cost = batch_cost(kwargs)

            # 1) トークンを取る(足りなければここで待つ)
            self.gate.acquire(endpoint, cost=cost)

            # 2) 直近のヘッダ情報が「危険」なら追加で待つ
            if self.obey_headers:
                extra = self.monitor.suggested_wait(endpoint)
                if extra > 0:
                    time.sleep(extra)

            result = attr(*args, **kwargs)

            # 3) レスポンスヘッダを取り込む
            #    pybitは return_response_headers=True のとき (result, headers)
            #    のタプルを返す。単体ではdictのみ。
            if isinstance(result, tuple) and len(result) == 2:
                body, headers = result
                self.monitor.observe(endpoint, headers)
                return body
            return result

        self._wrapped[name] = wrapper
        return wrapper

    def report(self) -> None:
        print("=== トークンバケット ===")
        self.gate.report()
        print("\n=== レート制限ヘッダ ===")
        self.monitor.report()


def make_gated_client(env: str | None = None) -> GatedHTTP:
    """ヘッダを取得できるようにHTTPを構成して包む。"""
    from config import load_credentials

    cred = load_credentials(env)
    http = HTTP(
        testnet=cred.testnet, demo=cred.demo,
        api_key=cred.api_key, api_secret=cred.api_secret,
        recv_window=5000, timeout=10,
        return_response_headers=True,   # ヘッダ監視のために必要
    )
    return GatedHTTP(http)


if __name__ == "__main__":
    http = make_gated_client()

    started = time.monotonic()
    for _ in range(25):
        http.get_tickers(category="linear", symbol="BTCUSDT")
    print(f"25リクエスト: {time.monotonic() - started:.2f}秒\n")

    http.report()

指数バックオフとジッタ

それでも10006や403が出ることはあります。再試行の間隔を指数的に伸ばし、ランダムなずれ(ジッタ)を加えるのが定石です。

ジッタが無いと「群れ」になる

複数のワーカーが同時に制限に当たると、バックオフの間隔が揃っているために全員が同じタイミングで再試行し、また全員が失敗します(Thundering Herd)。ジッタ(待機時間のランダム化)を入れることで、再試行のタイミングが分散して回復が速くなります。実装コストはほぼゼロなので必ず入れてください。

backoff.pyサンプル5
"""backoff.py — レート制限に特化した指数バックオフ
本コードは本人利用に限り自由に使用可。再配布・商用利用・販売は禁止。
"""
from __future__ import annotations

import functools
import random
import time
from collections.abc import Callable

import requests

RATE_LIMIT_CODES = {10006, 170222}     # UID単位のレート制限
IP_LIMIT_CODES = {10018}               # IP単位のレート制限
IP_BAN_WAIT = 620.0                    # 約10分 + 余裕


class RateLimited(RuntimeError):
    def __init__(self, ret_code: int, ret_msg: str = ""):
        super().__init__(f"[{ret_code}] {ret_msg}")
        self.ret_code = ret_code


def decorrelated_jitter(base: float, cap: float, previous: float) -> float:
    """AWSが推奨する decorrelated jitter。
    単純な full jitter より収束が良く、群れになりにくい。
    """
    return min(cap, random.uniform(base, previous * 3))


def with_rate_backoff(max_attempts: int = 6, base: float = 0.2,
                      cap: float = 20.0, verbose: bool = True) -> Callable:
    """レート制限に対してのみバックオフする(他のエラーは即座に上げる)。"""

    def decorator(fn: Callable) -> Callable:
        @functools.wraps(fn)
        def wrapper(*args, **kwargs):
            sleep_for = base

            for attempt in range(1, max_attempts + 1):
                try:
                    result = fn(*args, **kwargs)
                except requests.exceptions.HTTPError as e:
                    resp = getattr(e, "response", None)
                    if resp is not None and resp.status_code == 403:
                        if verbose:
                            print(f"[rate] HTTP 403(IP制限)。"
                                  f"{IP_BAN_WAIT:.0f}秒待機します")
                        time.sleep(IP_BAN_WAIT)
                        continue
                    raise
                else:
                    # pybitはretCodeを辞書で返すので自分で確認する
                    code = result.get("retCode") if isinstance(result, dict) else 0

                    if code in IP_LIMIT_CODES:
                        if verbose:
                            print(f"[rate] retCode={code}(IP制限)。"
                                  f"{IP_BAN_WAIT:.0f}秒待機します")
                        time.sleep(IP_BAN_WAIT)
                        continue

                    if code in RATE_LIMIT_CODES:
                        if attempt >= max_attempts:
                            raise RateLimited(code,
                                              result.get("retMsg", ""))
                        sleep_for = decorrelated_jitter(base, cap, sleep_for)
                        if verbose:
                            print(f"[rate] retCode={code}、{sleep_for:.2f}秒後に"
                                  f"再試行 ({attempt}/{max_attempts})")
                        time.sleep(sleep_for)
                        continue

                    return result

            raise RateLimited(-1, f"{max_attempts}回試行して失敗")

        return wrapper

    return decorator


if __name__ == "__main__":
    # バックオフの間隔がどう伸びるかを確認する
    prev = 0.2
    print("decorrelated jitter の待機時間(cap=20秒):")
    for i in range(1, 9):
        prev = decorrelated_jitter(0.2, 20.0, prev)
        print(f"  {i}回目: {prev:6.2f}秒")

    calls = {"n": 0}

    @with_rate_backoff(max_attempts=5, base=0.05)
    def flaky():
        calls["n"] += 1
        if calls["n"] < 3:
            return {"retCode": 10006, "retMsg": "Too many visits"}
        return {"retCode": 0, "result": {"ok": True}}

    print("\n実行:", flaky(), f"({calls['n']}回目で成功)")

asyncioでの流量制御

async_gate.pyサンプル6
"""async_gate.py — asyncio向けのトークンバケットと流量ゲート
本コードは本人利用に限り自由に使用可。再配布・商用利用・販売は禁止。
"""
from __future__ import annotations

import asyncio
import time


class AsyncTokenBucket:
    """asyncio用トークンバケット。待機中もイベントループを止めない。"""

    def __init__(self, rate: float, capacity: float | None = None,
                 name: str = ""):
        self.rate = rate
        self.capacity = capacity if capacity is not None else rate
        self.name = name
        self._tokens = float(self.capacity)
        self._last = time.monotonic()
        self._lock = asyncio.Lock()
        self.total_waited = 0.0

    async def acquire(self, cost: float = 1.0) -> float:
        if cost > self.capacity:
            raise ValueError(f"{self.name}: cost({cost}) > capacity")

        waited = 0.0
        while True:
            async with self._lock:
                now = time.monotonic()
                elapsed = now - self._last
                if elapsed > 0:
                    self._tokens = min(self.capacity,
                                       self._tokens + elapsed * self.rate)
                    self._last = now

                if self._tokens >= cost:
                    self._tokens -= cost
                    self.total_waited += waited
                    return waited

                sleep_for = (cost - self._tokens) / self.rate

            await asyncio.sleep(min(sleep_for, 0.25))
            waited += min(sleep_for, 0.25)


class AsyncRateGate:
    """IP全体とエンドポイント別の二層ゲート(非同期版)。"""

    def __init__(self, global_rate: float = 40.0,
                 endpoint_rates: dict[str, float] | None = None,
                 default_rate: float = 10.0,
                 max_concurrency: int = 8):
        self._global = AsyncTokenBucket(global_rate, global_rate * 2, "GLOBAL")
        self._rates = endpoint_rates or {}
        self._default = default_rate
        self._buckets: dict[str, AsyncTokenBucket] = {}
        # 同時実行数の上限。ソケットとメモリを守るために別途必要
        self._sem = asyncio.Semaphore(max_concurrency)

    def _bucket(self, endpoint: str) -> AsyncTokenBucket:
        if endpoint not in self._buckets:
            rate = self._rates.get(endpoint, self._default)
            self._buckets[endpoint] = AsyncTokenBucket(rate, rate, endpoint)
        return self._buckets[endpoint]

    async def acquire(self, endpoint: str, cost: float = 1.0) -> float:
        waited = await self._bucket(endpoint).acquire(cost)
        waited += await self._global.acquire(cost)
        return waited

    def limited(self, endpoint: str, cost: float = 1.0):
        """async with で使うコンテキストマネージャ。"""
        gate = self

        class _Ctx:
            async def __aenter__(self):
                await gate._sem.acquire()
                try:
                    await gate.acquire(endpoint, cost)
                except BaseException:
                    gate._sem.release()
                    raise
                return gate

            async def __aexit__(self, *exc):
                gate._sem.release()
                return False

        return _Ctx()


async def demo() -> None:
    gate = AsyncRateGate(global_rate=20.0,
                         endpoint_rates={"/v5/market/tickers": 10.0})

    async def task(i: int) -> None:
        async with gate.limited("/v5/market/tickers"):
            await asyncio.sleep(0.05)      # 実際のHTTPリクエストの代わり
            print(f"  リクエスト {i:>2} 完了 ({time.monotonic() - t0:.2f}秒)")

    t0 = time.monotonic()
    await asyncio.gather(*(task(i) for i in range(20)))
    print(f"\n20リクエストに {time.monotonic() - t0:.2f}秒 "
          f"(上限10/秒なので約2秒が妥当)")


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

消費量を計測して犯人を特定する

「なぜかレート制限に当たる」ときは、どのエンドポイントが食っているかを測るのが最短です。たいてい予想と違うものが原因です。

rate_profiler.pyサンプル7
"""rate_profiler.py — エンドポイント別のリクエスト消費を計測・可視化する
本コードは本人利用に限り自由に使用可。再配布・商用利用・販売は禁止。
"""
from __future__ import annotations

import threading
import time
from collections import defaultdict, deque
from dataclasses import dataclass, field


@dataclass
class EndpointStat:
    calls: int = 0
    cost: int = 0
    waited: float = 0.0
    errors: int = 0
    recent: deque = field(default_factory=lambda: deque(maxlen=2000))

    def rps(self, window: float = 5.0) -> float:
        """直近window秒の実効リクエスト毎秒。"""
        now = time.monotonic()
        n = sum(1 for t in self.recent if now - t <= window)
        return n / window


class RateProfiler:
    def __init__(self):
        self.stats: dict[str, EndpointStat] = defaultdict(EndpointStat)
        self._lock = threading.Lock()
        self.started = time.monotonic()

    def record(self, endpoint: str, cost: int = 1, waited: float = 0.0,
               error: bool = False) -> None:
        with self._lock:
            s = self.stats[endpoint]
            s.calls += 1
            s.cost += cost
            s.waited += waited
            if error:
                s.errors += 1
            s.recent.append(time.monotonic())

    def total_rps(self, window: float = 5.0) -> float:
        return sum(s.rps(window) for s in self.stats.values())

    def report(self, top: int = 15) -> None:
        elapsed = time.monotonic() - self.started
        with self._lock:
            rows = sorted(self.stats.items(), key=lambda kv: -kv[1].cost)

        total_cost = sum(s.cost for _, s in rows)
        print(f"稼働 {elapsed:.0f}秒 / 総消費 {total_cost}件 / "
              f"平均 {total_cost / max(elapsed, 1):.1f} 件/秒")
        print(f"直近5秒の実効レート: {self.total_rps():.1f} 件/秒 "
              f"(IP上限の目安 120件/秒)\n")

        print(f"{'endpoint':<36}{'呼出':>7}{'消費':>7}{'割合':>7}"
              f"{'直近rps':>9}{'待機':>9}{'err':>5}")
        print("-" * 82)
        for ep, s in rows[:top]:
            share = s.cost / total_cost if total_cost else 0
            print(f"{ep:<36}{s.calls:>7}{s.cost:>7}{share:>6.0%}"
                  f"{s.rps():>9.1f}{s.waited:>8.1f}s{s.errors:>5}")

        # 改善の指針を出す
        print("\n--- 改善の指針 ---")
        for ep, s in rows[:3]:
            share = s.cost / total_cost if total_cost else 0
            if share < 0.15:
                continue
            hint = {
                "/v5/market/tickers": "WebSocketのtickersトピックに移行(記事26)",
                "/v5/position/list": "WebSocketのpositionトピックに移行(記事27)",
                "/v5/order/realtime": "WebSocketのorderトピックに移行(記事27)",
                "/v5/market/orderbook": "WebSocketのorderbookに移行(記事28)",
                "/v5/market/instruments-info": "起動時に取得してキャッシュ(記事03)",
                "/v5/account/fee-rate": "起動時に1回だけ取得してキャッシュ",
                "/v5/account/wallet-balance": "WebSocketのwalletトピックに移行",
            }.get(ep)
            print(f"  {ep} が全体の{share:.0%}を消費")
            if hint:
                print(f"      -> {hint}")


if __name__ == "__main__":
    prof = RateProfiler()

    # 典型的な「悪い」ポーリングbotの消費パターンを模擬する
    for _ in range(300):
        prof.record("/v5/market/tickers")
    for _ in range(180):
        prof.record("/v5/position/list")
    for _ in range(120):
        prof.record("/v5/order/realtime")
    for _ in range(40):
        prof.record("/v5/market/instruments-info")
    for _ in range(12):
        prof.record("/v5/order/create", cost=1, waited=0.3)
    prof.record("/v5/order/create-batch", cost=20, waited=1.2)

    prof.report()

そもそも呼ばない設計

流量制御は対症療法です。本質的な解決は「リクエストを減らす」ことです。優先順位はこうなります。

ttl_cache.pyサンプル8
"""ttl_cache.py — 変化しないデータをキャッシュしてリクエストを削る
本コードは本人利用に限り自由に使用可。再配布・商用利用・販売は禁止。
"""
from __future__ import annotations

import functools
import json
import threading
import time
from pathlib import Path
from typing import Any


class TtlCache:
    """メモリ + ディスクの二段キャッシュ。プロセス再起動でも効く。"""

    def __init__(self, path: str | Path = "data/api_cache.json",
                 default_ttl: float = 3600.0):
        self.path = Path(path)
        self.default_ttl = default_ttl
        self._mem: dict[str, tuple[float, Any]] = {}
        self._lock = threading.Lock()
        self.hits = 0
        self.misses = 0
        self._load()

    def _load(self) -> None:
        if not self.path.exists():
            return
        try:
            raw = json.loads(self.path.read_text(encoding="utf-8"))
            self._mem = {k: (v["exp"], v["val"]) for k, v in raw.items()}
        except Exception:
            self._mem = {}

    def _save(self) -> None:
        self.path.parent.mkdir(parents=True, exist_ok=True)
        data = {k: {"exp": exp, "val": val}
                for k, (exp, val) in self._mem.items()}
        tmp = self.path.with_suffix(".tmp")
        tmp.write_text(json.dumps(data, ensure_ascii=False), encoding="utf-8")
        tmp.replace(self.path)

    def get(self, key: str) -> Any | None:
        with self._lock:
            entry = self._mem.get(key)
            if entry is None or entry[0] < time.time():
                self.misses += 1
                return None
            self.hits += 1
            return entry[1]

    def set(self, key: str, value: Any, ttl: float | None = None) -> None:
        with self._lock:
            self._mem[key] = (time.time() + (ttl or self.default_ttl), value)
            self._save()

    def stats(self) -> str:
        total = self.hits + self.misses
        rate = self.hits / total if total else 0
        return (f"キャッシュ: hit={self.hits} miss={self.misses} "
                f"ヒット率={rate:.0%} → 節約したリクエスト {self.hits}件")


_cache = TtlCache()


def cached(ttl: float = 3600.0, key_prefix: str = ""):
    """引数をキーにして結果をキャッシュするデコレータ。"""

    def decorator(fn):
        @functools.wraps(fn)
        def wrapper(*args, **kwargs):
            # 第1引数がself/httpの場合は除外してキーを作る
            key_args = [a for a in args if isinstance(a, (str, int, float, bool))]
            key = (f"{key_prefix or fn.__name__}:"
                   f"{','.join(map(str, key_args))}:"
                   f"{sorted(kwargs.items())}")
            hit = _cache.get(key)
            if hit is not None:
                return hit
            result = fn(*args, **kwargs)
            _cache.set(key, result, ttl)
            return result

        return wrapper

    return decorator


# ---- 使用例 -----------------------------------------------------------
@cached(ttl=86400.0)          # 銘柄仕様は1日キャッシュ
def get_symbol_rules(http, symbol: str, category: str = "linear") -> dict:
    res = http.get_instruments_info(category=category, symbol=symbol)
    info = res["result"]["list"][0]
    return {
        "tickSize": info["priceFilter"]["tickSize"],
        "qtyStep": info["lotSizeFilter"].get("qtyStep"),
        "minOrderQty": info["lotSizeFilter"].get("minOrderQty"),
        "maxLeverage": info.get("leverageFilter", {}).get("maxLeverage"),
    }


@cached(ttl=3600.0)           # 手数料率は1時間キャッシュ(上限5/秒と低いので重要)
def get_fee_rate(http, symbol: str, category: str = "linear") -> dict:
    res = http.get_fee_rates(category=category, symbol=symbol)
    row = res["result"]["list"][0]
    return {"maker": row["makerFeeRate"], "taker": row["takerFeeRate"]}


if __name__ == "__main__":
    from client import make_client

    c = make_client()

    # 100回呼んでも、実際のAPIリクエストは1回だけ
    for _ in range(100):
        rules = get_symbol_rules(c.http, "BTCUSDT")
    print("銘柄仕様:", rules)

    for _ in range(50):
        fee = get_fee_rate(c.http, "BTCUSDT")
    print("手数料  :", fee)

    print(_cache.stats())

WebSocketの接続制限

制限内容実装上の注意
新規接続レート1ドメイン5分あたり500接続再接続ループにバックオフが無いと一瞬で到達
同時接続数1 IPあたり最大1,000
(spot / linear / inverse / option で別カウント)
通常のbotでは問題にならない
1リクエストのargs現物は10個まで購読を10件ずつに分割する
argsの文字数配列全体で21,000文字多銘柄購読で当たる
再接続の暴走が最も危険なパターン

接続が切れた瞬間に無条件で再接続するコードは、エラー時に秒間数十回の接続試行を行い、5分500接続の上限を数十秒で使い切ります。その後はしばらく一切接続できません——データも来ず、WS発注もできない状態です。記事26のサンプル5のように、指数バックオフとジッタを必ず入れてください。

参考リンク(一次情報)

よくある疑問Q&A

HTTP 403になってしまいました。すぐ解除できますか?
できません。約10分待つ必要があります。この間はそのIPからのすべてのリクエストが拒否されるため、ポジションの決済もできません。それが403の本当の恐ろしさです。対策は事後ではなく事前——流量制御を入れ、重いバッチ処理を売買botから分離することです。なおVPNやプロキシでIPを変えて回避しようとするのは、規約上のリスクがあるため推奨しません。
10006(UID制限)と403(IP制限)はどちらが起きやすいですか?
単一botの通常運用では10006のほうが先に出ます(エンドポイント別の上限のほうが厳しいため)。403は「履歴の一括取得を並列で回した」「複数のbotを同じVPSで動かした」「再接続ループが暴走した」といった状況で起きます。つまり10006は設計の粗さ、403は設計の欠陥のシグナルです。403が出たら、流量設計を根本から見直すべき状態です。
VIPランクを上げれば制限は緩和されますか?
取引量に応じたランクや、申請による上限引き上げの仕組みがあります。ただしそれを前提にした設計にしないでください。ランクは変動しますし、上限に張り付いた運用は少しの想定外で403に落ちます。まず「そもそも呼ばない設計」(WebSocket移行とキャッシュ)で消費を1桁下げるのが正しい順序です。上限の引き上げは最後の手段です。
pybitの内蔵リトライで十分ではないですか?
pybitには max_retries / retry_delay がありますが、(1) レスポンスヘッダを見た予防的な減速はしない、(2) エンドポイント別の流量制御はしない、(3) 書き込み操作でのリトライの安全性を判断しない——という限界があります。特に(3)は二重発注につながるため危険です(記事06)。本記事のゲートを併用するか、署名を自作したクライアント(記事02)に組み込んでください。
複数のbotを1台のVPSで動かしても大丈夫ですか?
IP制限はプロセスをまたいで共有されます。3つのbotがそれぞれ秒間30リクエストを出すと合計90——IP上限(5秒600件=平均120件/秒)に近づきます。対策は2つです。(1) プロセス間で共有できるレートゲート(Redisなどを使った分散トークンバケット)を作る。(2) botごとにVPSまたはIPを分ける。実装が簡単なのは(2)です。UIDを分けたい場合はサブアカウント(記事25)も検討してください。
安全マージンはどれくらい取るべきですか?
本記事の RateGate は公式の上限に対して70〜80%程度に設定しています。この余裕は「他のプロセスが同じIPを使っている」「リトライで一時的に増える」「WebSocketの再接続が重なる」といった想定外を吸収するためです。上限の100%で回す設計は、必ずどこかで破綻します。速度が本当に必要な場面(マーケットメイク・記事37)だけ、計測しながら上限に近づけてください。
レート制限を測るのにテストネットは使えますか?
レート制限の挙動自体は確認できますが、上限値が本番と同じとは限りません。また板が薄いため発注系の挙動が本番と乖離します。流量設計の検証は「本番で読み取り系のみ」を安全に試すか、サンプル7のプロファイラで実際の消費量を計測して机上で見積もるのが現実的です。403を意図的に踏んで確認するのは、10分間の停止コストを考えると勧められません。

注意事項・免責事項

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

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

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

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

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

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

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