※ Bybit V5は機能追加のペースが速いAPIです。パラメータの最終的な正解は必ず公式ドキュメントで確認してください。本記事のコードはすべてテストネットで動作確認しています。
| 層 | 単位 | 上限 | 超過時 | 影響範囲 |
|---|---|---|---|---|
| 第1層 | IPアドレス | 5秒あたり600リクエスト | HTTP 403 約10分の制限 |
そのIPからの全リクエスト。売買も監視も止まる |
| 第2層 | UID × エンドポイント | 秒間(ローリング窓) | retCode 10006 retCode 10018 |
該当エンドポイントのみ |
第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 — レート制限ヘッダを読んで自律的に減速する
本コードは本人利用に限り自由に使用可。再配布・商用利用・販売は禁止。
"""
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ランクやアカウント種別で変わります)。
| 操作 | linear | spot | inverse |
|---|---|---|---|
| 発注(Create Order) | 20/秒 | 20/秒 | 10/秒 |
| 修正(Amend Order) | 10/秒 | 10/秒 | 10/秒 |
| キャンセル(Cancel Order) | 20/秒 | 20/秒 | 10/秒 |
| 全キャンセル(Cancel All) | 20/秒 | 20/秒 | 1/秒 |
| バッチ発注・修正・キャンセル | 20/秒 | 20/秒 | 10/秒 |
コイン建て(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回 | 10 | 10 |
| バッチ発注(10本を1回) | 1 | 10 |
つまりバッチの利点はレート消費の節約ではなく、レイテンシとネットワーク往復の削減です(記事16)。グリッドbotで50本の指値を置くなら、レート消費は50件分——linearの上限20/秒なら最低3秒かかると見積もる必要があります。
"""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 — スレッドセーフなトークンバケットと階層的レートゲート
本コードは本人利用に限り自由に使用可。再配布・商用利用・販売は禁止。
"""
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 — 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 — レート制限に特化した指数バックオフ
本コードは本人利用に限り自由に使用可。再配布・商用利用・販売は禁止。
"""
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']}回目で成功)")
"""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 — エンドポイント別のリクエスト消費を計測・可視化する
本コードは本人利用に限り自由に使用可。再配布・商用利用・販売は禁止。
"""
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()
流量制御は対症療法です。本質的な解決は「リクエストを減らす」ことです。優先順位はこうなります。
銘柄仕様(tickSize / qtyStep)・手数料率・リスクリミット表は、実質的に変化しません。起動時に1回取得してファイルまたはメモリに置くだけで、消費が劇的に減ります。上限が低いエンドポイント(手数料率5/秒)ほどこれが重要です。
価格・板・ポジション・注文状態・残高は、すべてWebSocketでプッシュを受けられます。1秒ごとのポーリングをやめるだけで、消費が数十分の1になります(記事26・27)。しかも遅延も改善します。
自作トレール(記事14)で「SLが実際に改善するときだけ更新する」ようにしたのがこの例です。「毎秒必ず呼ぶ」ループを「変化があったときだけ呼ぶ」に変えると、平常時の消費がほぼゼロになります。
履歴の一括取得(記事07・記事40)は数千リクエストに達します。売買botと同じプロセス・同じIPで走らせないでください。403が出れば売買も止まります。
"""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())
| 制限 | 内容 | 実装上の注意 |
|---|---|---|
| 新規接続レート | 1ドメイン5分あたり500接続 | 再接続ループにバックオフが無いと一瞬で到達 |
| 同時接続数 | 1 IPあたり最大1,000 (spot / linear / inverse / option で別カウント) | 通常のbotでは問題にならない |
| 1リクエストのargs | 現物は10個まで | 購読を10件ずつに分割する |
| argsの文字数 | 配列全体で21,000文字 | 多銘柄購読で当たる |
接続が切れた瞬間に無条件で再接続するコードは、エラー時に秒間数十回の接続試行を行い、5分500接続の上限を数十秒で使い切ります。その後はしばらく一切接続できません——データも来ず、WS発注もできない状態です。記事26のサンプル5のように、指数バックオフとジッタを必ず入れてください。
max_retries / retry_delay がありますが、(1) レスポンスヘッダを見た予防的な減速はしない、(2) エンドポイント別の流量制御はしない、(3) 書き込み操作でのリトライの安全性を判断しない——という限界があります。特に(3)は二重発注につながるため危険です(記事06)。本記事のゲートを併用するか、署名を自作したクライアント(記事02)に組み込んでください。RateGate は公式の上限に対して70〜80%程度に設定しています。この余裕は「他のプロセスが同じIPを使っている」「リトライで一時的に増える」「WebSocketの再接続が重なる」といった想定外を吸収するためです。上限の100%で回す設計は、必ずどこかで破綻します。速度が本当に必要な場面(マーケットメイク・記事37)だけ、計測しながら上限に近づけてください。投資は自己判断・自己責任でお願いいたします。本ブログの情報・ツール・ソフトウェアを参考・使用して、取引をされて何らかの損失を被られても、当サイトでは責任は一切負いません(間接的・付随的な障害・損失も含め一切の責任を負いません)。自己責任でご利用ください。
当サイトからダウンロードしたプログラム・EAは本人利用に限り完全無料でご利用いただけます。プログラムの全てまたは一部の複製・コピー・リバースエンジニアリング・改変・2次配布・商用利用・転売・販売等は一切禁止いたします。違反を発見した場合は法的処置を行います。
当サイト上に存在するソフトウェア・ツール・文章・画像・動画等の著作物を無断転載することを禁止します。引用の範囲を超えるものについては、法的処置を行います。
当サイトで配布するツール・ソフトウェアは無料提供ですのでサポートなどは行っておりません(逆に丁寧にサポートしているサイトはほとんど違法サイトですのでご注意ください。投資助言・代理業登録なしでサポート・助言することは法律で禁止されています)。万が一、ツールやソフトウェアに不具合や問題があっても当サイトでは修正・対応などができない場合があります。ご利用の環境でトラブルが発生しても当サイトでは助言することはできませんので自己責任でご利用ください。検索するなどして自分で解決してください。
当サイトでは取引所・証券会社へのリンクを貼っていますが、これは情報として提供するものであり、勧誘行為などは一切行っておりません。そのため、取引所・証券会社の登録方法・使い方・オススメ金融機関・オススメ金融商品などを説明した記事はサイト内にありませんので、ご自身で調べた上で判断ください(本当はサイト内で丁寧に説明したいのですが、仲介・取次ぎにあたる可能性があり第一種金融商品取引業登録を行っていない場合、違法となる可能性があるため出来ません。ご容赦ください)。
当サイトからリンクやバナーなどによって他のサイトに移動された場合、移動先サイトで提供される情報、サービス等について一切の責任を負いません。当サイト掲載情報やリンク先の掲載情報の合法性、正確性、道徳性、最新性、適切性、著作権の許諾や有無など、その内容については一切の保証を致しかねます。
当サイトは、ご訪問いただいた時間、訪問者のアクセス元のホスト名、IPアドレス、OSとブラウザのバージョン、リンク元のURL、モニターの情報等のパソコン環境情報を収集し、アクセス解析をしております。この目的は訪問者が検索サイトからご訪問いただいた場合、どのような検索で当サイトに来訪いただいたかの解析にのみ使用しており、第3者に公開はいたしません。また、個人を特定するものではありません。