※ Bybit V5は機能追加のペースが速いAPIです。パラメータの最終的な正解は必ず公式ドキュメントで確認してください。本記事のコードはすべてテストネットで動作確認しています。
網羅的なコード一覧は公式のError Codesが正典です。この記事はbot実装で実際に踏むものに絞り、「で、どうコードを書くか」に集中します。
V5のretCodeは番号帯で大まかな領域が分かります。知らないコードに遭遇したとき、まず番号帯から当たりを付けるのが最短ルートです。
| 番号帯 | 領域 | 主な原因 |
|---|---|---|
| 0 | 成功 | ただし「受理された」だけで約定を意味しない |
| 10000〜10029 | 共通・認証・レート | 署名、タイムスタンプ、権限、IP、レート制限、サーバー障害 |
| 110001〜110137 | デリバティブの取引・ポジション | 注文・レバレッジ・TP/SL・リスクリミット・マージン |
| 170001〜170810 | 現物(Spot) | 現物固有の数量・価格・注文数の制約 |
| 34040 / 3400xx | UTAアカウント | TP/SL設定、口座アップグレード、リスク率 |
同じ「残高不足」でも、デリバティブは 110007、現物は 170131 と別コードです。「数量が小さすぎる」もデリバティブ110017・現物170136。カテゴリを横断して動くコードを書くなら、両方の番号帯をマッピングしておく必要があります。片方だけを見ていると、現物に切り替えた瞬間にハンドリングが機能しなくなります。
この記事の核心です。retCodeは意味ではなく「どう対処すべきか」で分類してください。3つしかありません。
署名エラー、パラメータ不正、権限不足、残高不足。何度投げても同じ結果です。リトライは無駄なだけでなく、レート制限を消費して他の処理を妨げます。即座に例外を上げて、人間に通知してください。
レート制限超過(10006)、サーバー内部エラー(10016)、タイムアウト(10000)。指数バックオフを入れて再試行します。ただし後述のCとの区別が決定的に重要です。
ネットワークタイムアウト、接続切断、10016。リクエストが取引所に届いて処理された可能性がある状態です。ここで素朴にリトライすると二重発注になります。まず実際の状態を照会し、結果を確定させてから判断します。
ここが最も重要な洞察です。10016(サーバー内部エラー)やタイムアウトは、読み取り操作ならB(安全にリトライ可)、書き込み操作ならC(照会が必要)です。
get_positions() がタイムアウトしても、何度呼んでも副作用はありません。しかし place_order() がタイムアウトした場合、注文は通っているかもしれません。同じコードでもリトライの安全性がまったく違います。
したがって分類は「retCode」と「操作の冪等性」の2軸で決める必要があります。retCodeだけを見たリトライ表は不完全です。
| code | 意味 | 実際の原因と対処 | 分類 |
|---|---|---|---|
| 10000 | サーバータイムアウト | 一時的な遅延。読み取りは再試行、書き込みは照会 | B / C |
| 10001 | パラメータ不正 | category の指定漏れ、数値を数値型で送っている(文字列必須)、accountType の誤り、シンボルの小文字 | A |
| 10002 | タイムスタンプが範囲外 | 時計のズレが recv_window 超過。NTP同期(w32tm /resync / chronyc makestep) | 同期後B |
| 10003 | APIキー不正 | キーと接続先の不一致(testnetキーを本番に送信)、キーの有効期限切れ | A |
| 10004 | 署名エラー | POSTのJSON直列化が署名対象と不一致、Secretに改行混入。記事02のダンプで切り分け | A |
| 10005 | 権限不足 | Read-Onlyキーで発注している。権限のあるキーに差し替え | A |
| 10006 | レート制限超過(UID) | エンドポイント別の秒間上限超過。指数バックオフ+流量制御(記事05) | B |
| 10010 | IPが許可リスト外 | キーに紐付けたIPと実行環境のIPが違う。VPS移設・IP変更時に踏む | A |
| 10014 | 重複リクエスト | 同一リクエストの多重送信。冪等IDの設計を見直す | A |
| 10016 | サーバー内部エラー | 書き込み操作では危険。注文が通っている可能性があるため必ず照会 | B / C |
| 10018 | IPレート制限超過 | 5秒600リクエストの上限。並列数を絞る。制限解除まで待つ | B(長め) |
| 10028 | 統合口座専用API | 通常口座で統合口座向けエンドポイントを呼んでいる | A |
| code | 意味 | 実際の原因と対処 | 分類 |
|---|---|---|---|
| 110001 | 注文が存在しない | キャンセル・修正時の競合。既に約定済みなら正常 | 無視 |
| 110004 | ウォレット残高不足 | 口座全体の残高が足りない | A |
| 110007 | 利用可能残高不足 | 残高はあるが未約定注文が証拠金を拘束している。古い注文を整理 | A |
| 110008 | 注文は完了またはキャンセル済み | 競合状態。正常として無視 | 無視 |
| 110009 | 条件注文が上限超過 | 古い条件注文を整理する | A |
| 110017 | 数量がゼロに切り捨てられる | qtyStep 未満の数量。発注前検証で弾く(記事13) | A |
| 110020 | 有効注文が500件超 | グリッドbotで頻発。本数を設計段階で制限(記事36) | A |
| 110024 | ポジション保有中はモード変更不可 | 先に全決済してから切り替える | A |
| 110025 110026 110027 | モード/マージンが変更されていない | 既に同じ設定。実質成功として無視 | 無視 |
| 110030 | orderIdの重複 | 同一注文の再送。冪等性が効いている | 無視 |
| 110043 | レバレッジが変更されていない | 既に同じ値。実質成功として無視(記事21) | 無視 |
| 110061 | PartialモードのTP/SLが20件超 | 階段の段数を減らす(記事14) | A |
| 110072 | orderLinkIdの重複 | 二重発注を防げた証拠。既存注文を照会して確定させる(記事17) | 無視 |
| 110092 110093 | トリガー価格の方向が矛盾 | triggerDirection と現在価格の関係が不整合。現在価格から自動判定させる(記事15) | A |
| 110094 | 想定元本が下限未満 | minNotionalValue を満たしていない | A |
| 110119 | RPIは承認MM限定 | timeInForce="RPI" は一般利用不可。PostOnly を使う | A |
| code | 意味 | 対処 | 分類 |
|---|---|---|---|
| 170007 | バックエンド応答待ちタイムアウト | 書き込みなら照会が必要 | C |
| 170032 | ネットワークエラー | 再試行可(読み取り) | B |
| 170131 | 残高不足 | デリバティブの110007に相当 | A |
| 170136 | 数量が最小未満 | 現物は basePrecision / minOrderAmt で条件が別 | A |
| 170141 | clientOrderIdの重複 | 110072の現物版。冪等性が効いている | 無視 |
| 170213 | 注文が存在しない | 110001の現物版 | 無視 |
| 170810 | 条件注文・TP/SL・通常注文の合計が500件超 | 現物グリッドで踏む | A |
retCodeを直接if文で分岐させると、コードが散らばって保守できません。例外の型で分類を表現します。
"""bybit_errors.py — retCodeを例外階層にマッピングする
本コードは本人利用に限り自由に使用可。再配布・商用利用・販売は禁止。
"""
from __future__ import annotations
from enum import Enum
from typing import Any
class Disposition(str, Enum):
"""このエラーをどう処理すべきか。"""
FATAL = "fatal" # A: リトライ不可。コードか設定の誤り
RETRY = "retry" # B: リトライ可。時間を置けば通る
RECONCILE = "verify" # C: 通ったか不明。状態照会が必要
BENIGN = "benign" # エラーだが実質成功。無視してよい
class BybitError(Exception):
"""全Bybit APIエラーの基底。"""
disposition = Disposition.FATAL
def __init__(self, ret_code: int, ret_msg: str = "",
endpoint: str = "", sent: Any = None,
ret_ext_info: Any = None):
super().__init__(f"[{ret_code}] {ret_msg} ({endpoint})")
self.ret_code = ret_code
self.ret_msg = ret_msg
self.endpoint = endpoint
self.sent = sent # 送信パラメータ(調査で必須)
self.ret_ext_info = ret_ext_info
def __repr__(self) -> str:
return (f"{type(self).__name__}(retCode={self.ret_code}, "
f"disposition={self.disposition.value})")
# ---- A: リトライ不可 --------------------------------------------------
class BybitFatalError(BybitError):
disposition = Disposition.FATAL
class BybitAuthError(BybitFatalError):
"""署名・キー・権限・IP。人間が設定を直すまで直らない。"""
class BybitParamError(BybitFatalError):
"""パラメータの誤り。コードのバグ。"""
class BybitInsufficientBalance(BybitFatalError):
"""残高・証拠金不足。資金かポジションを整理するまで直らない。"""
class BybitLimitExceeded(BybitFatalError):
"""注文数・TP/SL数などの上限超過。整理が必要。"""
# ---- B: リトライ可 ----------------------------------------------------
class BybitTransientError(BybitError):
disposition = Disposition.RETRY
class BybitRateLimitError(BybitTransientError):
"""レート制限。バックオフして再試行。"""
def __init__(self, *args, retry_after: float = 1.0, **kwargs):
super().__init__(*args, **kwargs)
self.retry_after = retry_after
class BybitIpBanError(BybitTransientError):
"""IPレート制限。約10分待つ必要がある。"""
# ---- C: 状態照会が必要 ------------------------------------------------
class BybitUnknownStateError(BybitError):
"""通ったかどうか分からない。照会してから判断する。"""
disposition = Disposition.RECONCILE
# ---- 実質成功 ---------------------------------------------------------
class BybitBenignError(BybitError):
"""エラーだが処理を止める必要がない。"""
disposition = Disposition.BENIGN
# ---- マッピング -------------------------------------------------------
# 「エラーだが無視してよい」コード
BENIGN_CODES: dict[int, str] = {
110001: "注文が存在しない(既に約定/キャンセル済み)",
110008: "注文は完了またはキャンセル済み",
110010: "注文はキャンセル済み",
110025: "ポジションモードは変更されていない",
110026: "マージンモードは変更されていない",
110027: "マージンは変更されていない",
110030: "orderIdの重複(同一注文の再送)",
110043: "レバレッジは変更されていない",
110072: "orderLinkIdの重複(二重発注を防止)",
110075: "リスクIDは変更されていない",
170141: "clientOrderIdの重複(現物)",
170142: "注文はキャンセル済み(現物)",
170213: "注文が存在しない(現物)",
34040: "TP/SLの値が既に設定済み",
}
AUTH_CODES = {10003, 10004, 10005, 10007, 10010}
PARAM_CODES = {10001, 10017, 10029, 110017, 110019, 110032, 110092, 110093,
110094, 110119, 170105, 170115, 170116, 170117, 170121,
170130, 170136, 170355}
BALANCE_CODES = {110004, 110006, 110007, 110012, 110014, 110044, 110045,
170033, 170131}
LIMIT_CODES = {110009, 110020, 110021, 110061, 170005, 170810}
RATE_CODES = {10006, 170222}
IP_BAN_CODES = {10018}
# 書き込み操作では「通ったか不明」、読み取りでは単なる再試行
AMBIGUOUS_CODES = {10000, 10016, 170007, 170146, 170147, 170234, 170310}
def classify(ret_code: int, *, is_write: bool = False) -> type[BybitError]:
"""retCodeと操作の種類から、対応する例外クラスを返す。
is_write=True は「副作用のある操作」(発注・修正・キャンセル・振替)。
同じretCodeでも読み取りと書き込みで扱いが変わる点が要点。
"""
if ret_code in BENIGN_CODES:
return BybitBenignError
if ret_code in AUTH_CODES:
return BybitAuthError
if ret_code in PARAM_CODES:
return BybitParamError
if ret_code in BALANCE_CODES:
return BybitInsufficientBalance
if ret_code in LIMIT_CODES:
return BybitLimitExceeded
if ret_code in RATE_CODES:
return BybitRateLimitError
if ret_code in IP_BAN_CODES:
return BybitIpBanError
if ret_code in AMBIGUOUS_CODES:
return BybitUnknownStateError if is_write else BybitTransientError
if ret_code == 10002:
# 時計ズレ。同期すれば直るのでリトライ扱いにするが、
# 連続する場合はNTPの問題なのでFATALとして通知したい
return BybitTransientError
return BybitFatalError # 未知のコードは安全側(止める)
def raise_for_ret_code(res: dict, endpoint: str = "", sent: Any = None,
*, is_write: bool = False) -> dict:
"""V5レスポンスを検査し、retCode!=0なら適切な例外を投げる。
BENIGNの場合は例外を投げず、resultを返す。
"""
code = res.get("retCode")
if code == 0:
return res.get("result") or {}
exc_cls = classify(code, is_write=is_write)
if exc_cls is BybitBenignError:
return res.get("result") or {}
raise exc_cls(code, res.get("retMsg", ""), endpoint=endpoint, sent=sent,
ret_ext_info=res.get("retExtInfo"))
def describe(ret_code: int) -> str:
"""retCodeの人間向け説明(ログ・通知用)。"""
if ret_code in BENIGN_CODES:
return f"{ret_code}: {BENIGN_CODES[ret_code]}(無視可)"
cls = classify(ret_code)
return f"{ret_code}: {cls.__name__} / 対処={cls.disposition.value}"
if __name__ == "__main__":
for code in (0, 10001, 10004, 10006, 10016, 110007, 110043, 110072, 999999):
if code == 0:
print("0: 成功")
continue
read = classify(code, is_write=False).__name__
write = classify(code, is_write=True).__name__
mark = " <- 読み書きで分類が変わる" if read != write else ""
print(f"{code:>7} 読み取り={read:<24} 書き込み={write:<24}{mark}")
"""retry.py — 例外の分類に応じてリトライを制御するデコレータ
本コードは本人利用に限り自由に使用可。再配布・商用利用・販売は禁止。
"""
from __future__ import annotations
import functools
import logging
import random
import time
from collections.abc import Callable
from typing import ParamSpec, TypeVar
import requests
from bybit_errors import (
BybitError, BybitIpBanError, BybitRateLimitError, BybitTransientError,
BybitUnknownStateError, Disposition,
)
log = logging.getLogger(__name__)
P = ParamSpec("P")
R = TypeVar("R")
# ネットワーク層の例外。これらも「通ったか不明」に該当する
NETWORK_ERRORS = (
requests.exceptions.Timeout,
requests.exceptions.ConnectionError,
requests.exceptions.ChunkedEncodingError,
)
IP_BAN_WAIT_SEC = 600.0 # IPレート制限は約10分
def with_retry(
max_attempts: int = 4,
base_delay: float = 0.5,
max_delay: float = 30.0,
is_write: bool = False,
on_unknown_state: Callable[[Exception], R | None] | None = None,
) -> Callable[[Callable[P, R]], Callable[P, R]]:
"""分類に応じてリトライする。
is_write=True の場合、「通ったか不明」な失敗では
リトライせずに on_unknown_state(照会処理)へ委ねる。
これが二重発注を防ぐ要点。
"""
def decorator(fn: Callable[P, R]) -> Callable[P, R]:
@functools.wraps(fn)
def wrapper(*args: P.args, **kwargs: P.kwargs) -> R:
delay = base_delay
last_exc: Exception | None = None
for attempt in range(1, max_attempts + 1):
try:
return fn(*args, **kwargs)
except NETWORK_ERRORS as e:
last_exc = e
if is_write:
log.warning(
"%s: 書き込み中に通信エラー。リトライせず照会します (%s)",
fn.__name__, type(e).__name__,
)
if on_unknown_state:
return on_unknown_state(e)
raise BybitUnknownStateError(
-1, f"通信エラー: {e}", endpoint=fn.__name__
) from e
log.info("%s: 通信エラー、再試行 %d/%d", fn.__name__,
attempt, max_attempts)
except BybitIpBanError as e:
last_exc = e
log.error("IPレート制限に到達。%.0f秒待機します", IP_BAN_WAIT_SEC)
time.sleep(IP_BAN_WAIT_SEC)
continue
except BybitRateLimitError as e:
last_exc = e
wait = max(delay, getattr(e, "retry_after", 0.0))
log.info("レート制限。%.2f秒待機 (%d/%d)", wait, attempt,
max_attempts)
time.sleep(wait * (0.5 + random.random()))
delay = min(delay * 2, max_delay)
continue
except BybitError as e:
last_exc = e
if e.disposition is Disposition.RECONCILE:
log.warning("%s: 状態不明。照会します [%d] %s",
fn.__name__, e.ret_code, e.ret_msg)
if on_unknown_state:
return on_unknown_state(e)
raise
if e.disposition is not Disposition.RETRY:
# FATAL: リトライしても無駄なので即座に上げる
log.error("%s: 回復不能 [%d] %s / 送信=%r",
fn.__name__, e.ret_code, e.ret_msg, e.sent)
raise
log.info("%s: 一時エラー [%d]、再試行 %d/%d",
fn.__name__, e.ret_code, attempt, max_attempts)
# 共通のバックオフ(ジッタ付き)
if attempt < max_attempts:
sleep_for = min(delay, max_delay) * (0.5 + random.random())
time.sleep(sleep_for)
delay = min(delay * 2, max_delay)
assert last_exc is not None
log.error("%s: %d回試行して失敗", fn.__name__, max_attempts)
raise last_exc
return wrapper
return decorator
if __name__ == "__main__":
logging.basicConfig(level=logging.INFO,
format="%(levelname)-7s %(message)s")
calls = {"n": 0}
@with_retry(max_attempts=4, base_delay=0.1)
def flaky_read() -> str:
calls["n"] += 1
if calls["n"] < 3:
raise BybitTransientError(10016, "Server error", endpoint="test")
return f"成功({calls['n']}回目)"
print(flaky_read())
@with_retry(is_write=True, on_unknown_state=lambda e: "照会にフォールバック")
def flaky_write() -> str:
raise BybitUnknownStateError(10016, "Server error", endpoint="place_order")
print(flaky_write())
「発注がタイムアウトした。注文は通ったのか?」——この問いに答える処理です。これが無いbotは、いつか必ず二重発注します。
"""recover.py — 状態不明になった注文の結末を確定させる
本コードは本人利用に限り自由に使用可。再配布・商用利用・販売は禁止。
前提: 発注時に必ず orderLinkId を自分で付けていること。
これが無いと「自分が出した注文」を特定できず、照会もできない。
"""
from __future__ import annotations
import logging
import time
from dataclasses import dataclass
from enum import Enum
log = logging.getLogger(__name__)
class OrderOutcome(str, Enum):
FILLED = "filled" # 約定済み
PARTIALLY = "partially" # 部分約定
OPEN = "open" # 板にある(未約定)
CANCELLED = "cancelled" # キャンセル済み
REJECTED = "rejected" # 拒否された
NOT_FOUND = "not_found" # 存在しない = 届いていなかった
@dataclass
class OrderState:
outcome: OrderOutcome
order_id: str | None = None
filled_qty: float = 0.0
avg_price: float = 0.0
status: str = ""
@property
def exists(self) -> bool:
return self.outcome is not OrderOutcome.NOT_FOUND
@property
def safe_to_resend(self) -> bool:
"""再送してよいのは「届いていなかった」と確認できた場合のみ。"""
return self.outcome is OrderOutcome.NOT_FOUND
# V5のOrderStatus -> 結末のマッピング
_STATUS_MAP = {
"New": OrderOutcome.OPEN,
"Untriggered": OrderOutcome.OPEN,
"Triggered": OrderOutcome.OPEN,
"PartiallyFilled": OrderOutcome.PARTIALLY,
"Filled": OrderOutcome.FILLED,
"Cancelled": OrderOutcome.CANCELLED,
"PartiallyFilledCanceled": OrderOutcome.PARTIALLY,
"Deactivated": OrderOutcome.CANCELLED,
"Rejected": OrderOutcome.REJECTED,
}
def resolve_order(http, order_link_id: str, symbol: str,
category: str = "linear",
attempts: int = 4, delay: float = 1.0) -> OrderState:
"""orderLinkIdで注文の結末を確定させる。
取引所側の反映に遅延があるため、NOT_FOUNDは数回確認してから確定させる。
ここを1回で判定すると「実は通っていた」を見逃して二重発注になる。
"""
for attempt in range(1, attempts + 1):
# 1) まず未約定注文を探す
res = http.get_open_orders(category=category, symbol=symbol,
orderLinkId=order_link_id)
rows = res.get("result", {}).get("list") or []
# 2) 見つからなければ履歴を探す(約定済み・キャンセル済み)
if not rows:
res = http.get_order_history(category=category, symbol=symbol,
orderLinkId=order_link_id)
rows = res.get("result", {}).get("list") or []
if rows:
o = rows[0]
status = o.get("orderStatus", "")
state = OrderState(
outcome=_STATUS_MAP.get(status, OrderOutcome.OPEN),
order_id=o.get("orderId"),
filled_qty=float(o.get("cumExecQty") or 0),
avg_price=float(o.get("avgPrice") or 0),
status=status,
)
log.info("照会: %s -> %s (status=%s, 約定=%s)",
order_link_id, state.outcome.value, status, state.filled_qty)
return state
if attempt < attempts:
log.info("照会: %s は未検出。%.1f秒後に再確認 (%d/%d)",
order_link_id, delay, attempt, attempts)
time.sleep(delay)
delay *= 2
log.warning("照会: %s は存在しません(届いていなかったと判断)", order_link_id)
return OrderState(outcome=OrderOutcome.NOT_FOUND)
def place_order_safely(http, params: dict, category: str = "linear",
max_resend: int = 1) -> dict:
"""タイムアウトしても二重発注しない発注。
params には必ず orderLinkId を含めること。
"""
link_id = params.get("orderLinkId")
if not link_id:
raise ValueError("orderLinkIdは必須です(冪等性が確保できません)")
symbol = params["symbol"]
for attempt in range(max_resend + 1):
try:
res = http.place_order(**params)
code = res["retCode"]
if code == 0:
return res["result"]
# 重複ID = 既に受理されている。照会して結果を返す
if code in (110072, 170141, 110030):
log.info("orderLinkId重複を検出。既存注文を照会します: %s", link_id)
state = resolve_order(http, link_id, symbol, category)
return {"orderLinkId": link_id, "orderId": state.order_id,
"_recovered": True, "_outcome": state.outcome.value}
raise RuntimeError(f"発注失敗 [{code}] {res['retMsg']}")
except Exception as e:
# 通信エラー・サーバーエラー -> 状態を確定させる
log.warning("発注が不確定になりました (%s)。照会します",
type(e).__name__)
state = resolve_order(http, link_id, symbol, category)
if state.exists:
log.info("実は通っていました: %s", state.outcome.value)
return {"orderLinkId": link_id, "orderId": state.order_id,
"_recovered": True, "_outcome": state.outcome.value}
if attempt < max_resend:
log.info("届いていなかったので再送します")
continue
raise
raise RuntimeError("発注を確定できませんでした")
if __name__ == "__main__":
logging.basicConfig(level=logging.INFO,
format="%(levelname)-7s %(message)s")
from client import make_client
c = make_client()
result = place_order_safely(c.http, {
"category": "linear", "symbol": "BTCUSDT", "side": "Buy",
"orderType": "Limit", "qty": "0.001", "price": "50000",
"timeInForce": "PostOnly",
"orderLinkId": f"safe-{int(time.time())}",
})
print(result)
resolve_order() が attempts=4 で繰り返し確認しているのは重要な設計です。取引所側の反映には遅延があり、「送信直後に照会したら見つからなかったが、実は数百ms後に反映された」ケースが実際にあります。1回の照会で「届いていなかった」と判断して再送すると、両方が通って二重発注になります。再送は最後の手段と位置づけ、確認を重ねてください。
これらはretCode != 0ですが、処理を止める理由がありません。むしろ例外にすると運用が不安定になります。
| code | 状況 | なぜ無視してよいか |
|---|---|---|
| 110043 | レバレッジ設定 | 既に望む値になっている。目的は達成済み |
| 110025 110026 / 110027 | モード・マージン設定 | 同上。冪等な設定操作の正常な結果 |
| 110001 110008 / 110010 | キャンセル・修正 | 対象が既に約定/キャンセル済み。キャンセルの目的は達成 |
| 110072 170141 | 発注 | 同じ注文が既に存在。二重発注を防げた成功(ただし照会して確定させる) |
| 34040 | TP/SL設定 | 既に同じ値。ただしパラメータ不足も同じコードなので初回設定時は区別する |
34040は「値が既に設定済み」と「必須パラメータが不足」の両方を意味します。単純に無視すると、パラメータの書き忘れでTP/SLが設定されないまま「成功」として通過します。初回設定時は無視せず、設定後に get_positions() で実際の値を照合してください(記事14のサンプル6)。「同じ値の再設定」だけを無視対象にするのが正しい扱いです。
retCodeが返る前に、HTTP層で失敗することもあります。retCodeだけを見ていると取りこぼします。
"""http_errors.py — HTTPステータスとネットワーク例外の扱い
本コードは本人利用に限り自由に使用可。再配布・商用利用・販売は禁止。
"""
from __future__ import annotations
import logging
import requests
from bybit_errors import (
BybitAuthError, BybitError, BybitIpBanError, BybitRateLimitError,
BybitTransientError, BybitUnknownStateError, raise_for_ret_code,
)
log = logging.getLogger(__name__)
def handle_response(resp: requests.Response, endpoint: str = "",
sent=None, *, is_write: bool = False) -> dict:
"""HTTPステータス -> retCode の順に検査する。"""
# ---- 1) レート制限ヘッダの監視(記事05で活用) -------------------
remaining = resp.headers.get("X-Bapi-Limit-Status")
limit = resp.headers.get("X-Bapi-Limit")
if remaining is not None and limit is not None:
try:
if int(remaining) <= 2:
log.warning("%s: レート残量わずか %s/%s", endpoint, remaining, limit)
except ValueError:
pass
# ---- 2) HTTPステータス --------------------------------------------
status = resp.status_code
if status == 403:
# IP単位のレート制限。約10分の制限がかかる
raise BybitIpBanError(
-403, "IP単位のレート制限に到達(約10分の制限)", endpoint=endpoint
)
if status == 401:
raise BybitAuthError(-401, "認証エラー(キー・署名を確認)",
endpoint=endpoint)
if status == 429:
raise BybitRateLimitError(-429, "リクエスト過多", endpoint=endpoint,
retry_after=float(resp.headers.get("Retry-After", 1)))
if 500 <= status < 600:
# サーバー側の障害。書き込みなら通っている可能性がある
cls = BybitUnknownStateError if is_write else BybitTransientError
raise cls(-status, f"サーバーエラー HTTP {status}", endpoint=endpoint)
if status != 200:
raise BybitError(-status, f"予期しないHTTPステータス {status}: "
f"{resp.text[:200]}", endpoint=endpoint)
# ---- 3) JSONのパース ---------------------------------------------
try:
data = resp.json()
except ValueError as e:
# HTMLのエラーページが返るケース(メンテナンス・CDNエラー)
cls = BybitUnknownStateError if is_write else BybitTransientError
raise cls(-1, f"JSONとして解釈できない応答: {resp.text[:200]}",
endpoint=endpoint) from e
# ---- 4) retCode ---------------------------------------------------
return raise_for_ret_code(data, endpoint=endpoint, sent=sent,
is_write=is_write)
HTTP_TROUBLE = """
症状ごとの一次切り分け:
HTTP 403 -> IP単位のレート制限。並列数を落とし10分待つ(記事05)
HTTP 401 -> キー・署名。接続先とキーの環境が一致しているか(記事02)
HTTP 5xx -> 取引所側の障害。書き込みは必ず照会(サンプル3)
タイムアウト -> 読み取りは再試行、書き込みは照会
ConnectionError -> ネットワークまたはDNS。VPSの疎通を確認
JSONパース失敗 -> メンテナンス中のHTMLページが返っている可能性
"""
if __name__ == "__main__":
print(HTTP_TROUBLE)
FATALなエラーは人間が気づく必要があります。ただし同じエラーを毎秒通知すると誰も見なくなります。重複抑制が必須です。
"""notify.py — エラー通知(Discord Webhook)と重複抑制
本コードは本人利用に限り自由に使用可。再配布・商用利用・販売は禁止。
"""
from __future__ import annotations
import logging
import os
import time
from collections import defaultdict
import requests
from bybit_errors import BybitError, Disposition
log = logging.getLogger(__name__)
WEBHOOK = os.getenv("DISCORD_WEBHOOK_URL", "")
# 同じ種類のエラーは、この秒数内は再通知しない
COOLDOWN_SEC = {
Disposition.FATAL: 300.0,
Disposition.RECONCILE: 60.0,
Disposition.RETRY: 1800.0, # 一時エラーは頻繁なので長めに抑制
Disposition.BENIGN: 0.0, # 通知しない
}
# 機密情報のマスク対象(キーが含まれる可能性のあるフィールド)
SECRET_KEYS = {"api_key", "apiKey", "api_secret", "sign", "X-BAPI-SIGN",
"X-BAPI-API-KEY", "secret", "password"}
def mask(obj):
"""送信パラメータからキー・署名を除去する。ログに残す前に必ず通す。"""
if isinstance(obj, dict):
return {k: ("****" if k in SECRET_KEYS else mask(v))
for k, v in obj.items()}
if isinstance(obj, (list, tuple)):
return [mask(v) for v in obj]
return obj
class Notifier:
def __init__(self, webhook: str = WEBHOOK, enabled: bool = True):
self.webhook = webhook
self.enabled = enabled and bool(webhook)
self._last_sent: dict[str, float] = {}
self._suppressed: dict[str, int] = defaultdict(int)
def _key(self, err: BybitError) -> str:
return f"{type(err).__name__}:{err.ret_code}:{err.endpoint}"
def send(self, text: str, *, force: bool = False) -> bool:
if not self.enabled and not force:
log.info("[通知(無効)] %s", text)
return False
try:
r = requests.post(self.webhook, json={"content": text[:1900]},
timeout=10)
return r.status_code < 300
except Exception as e:
log.error("通知の送信に失敗: %s", e)
return False
def on_error(self, err: BybitError) -> bool:
"""エラーを通知する。分類ごとにクールダウンを適用する。"""
cooldown = COOLDOWN_SEC.get(err.disposition, 300.0)
if cooldown <= 0:
return False
key = self._key(err)
now = time.monotonic()
last = self._last_sent.get(key, 0.0)
if now - last < cooldown:
self._suppressed[key] += 1
return False
suppressed = self._suppressed.pop(key, 0)
self._last_sent[key] = now
icon = {"fatal": "🛑", "verify": "⚠️", "retry": "🔁"}.get(
err.disposition.value, "ℹ️")
lines = [
f"{icon} **Bybit APIエラー** `{err.disposition.value}`",
f"retCode: `{err.ret_code}` retMsg: `{err.ret_msg}`",
f"endpoint: `{err.endpoint}`",
]
if err.sent:
lines.append(f"送信: `{mask(err.sent)}`")
if suppressed:
lines.append(f"_(同種のエラーを{suppressed}件抑制しました)_")
return self.send("\n".join(lines))
if __name__ == "__main__":
logging.basicConfig(level=logging.INFO)
from bybit_errors import BybitAuthError
n = Notifier(enabled=False)
err = BybitAuthError(10004, "error sign", endpoint="place_order",
sent={"symbol": "BTCUSDT", "api_key": "SECRET123"})
n.on_error(err)
n.on_error(err) # クールダウン中なので抑制される
print("マスク確認:", mask({"api_key": "abc", "symbol": "BTCUSDT"}))
どのretCodeが多いかを集計すると、botの構造的な弱点が見えます。「110017が多い」なら丸め処理のバグ、「10006が多い」なら流量設計の問題、と原因が絞れます。
"""error_stats.py — retCodeの発生統計を取り、改善すべき箇所を特定する
本コードは本人利用に限り自由に使用可。再配布・商用利用・販売は禁止。
"""
from __future__ import annotations
import json
import sqlite3
import time
from pathlib import Path
from bybit_errors import BybitError, classify, describe
DB = Path("data/error_log.sqlite")
SCHEMA = """
CREATE TABLE IF NOT EXISTS errors (
id INTEGER PRIMARY KEY AUTOINCREMENT,
ts REAL NOT NULL,
ret_code INTEGER NOT NULL,
ret_msg TEXT,
endpoint TEXT,
disposition TEXT,
sent TEXT
);
CREATE INDEX IF NOT EXISTS idx_errors_ts ON errors(ts);
CREATE INDEX IF NOT EXISTS idx_errors_code ON errors(ret_code);
"""
class ErrorRecorder:
def __init__(self, path: Path = DB):
path.parent.mkdir(parents=True, exist_ok=True)
self.conn = sqlite3.connect(str(path))
self.conn.executescript(SCHEMA)
self.conn.commit()
def record(self, err: BybitError) -> None:
from notify import mask
self.conn.execute(
"INSERT INTO errors (ts, ret_code, ret_msg, endpoint, disposition, sent)"
" VALUES (?, ?, ?, ?, ?, ?)",
(time.time(), err.ret_code, err.ret_msg, err.endpoint,
err.disposition.value,
json.dumps(mask(err.sent), ensure_ascii=False, default=str)),
)
self.conn.commit()
def summary(self, hours: float = 24.0) -> list[tuple]:
since = time.time() - hours * 3600
cur = self.conn.execute(
"SELECT ret_code, endpoint, disposition, COUNT(*) AS n,"
" MAX(ret_msg) AS sample"
" FROM errors WHERE ts >= ?"
" GROUP BY ret_code, endpoint"
" ORDER BY n DESC",
(since,),
)
return cur.fetchall()
def report(self, hours: float = 24.0) -> None:
rows = self.summary(hours)
if not rows:
print(f"直近{hours:.0f}時間のエラーはありません")
return
total = sum(r[3] for r in rows)
print(f"直近{hours:.0f}時間: 合計{total}件\n")
print(f"{'code':>8} {'endpoint':<22}{'分類':<10}{'件数':>7} メッセージ")
print("-" * 88)
for code, endpoint, disp, n, sample in rows[:25]:
print(f"{code:>8} {(endpoint or '-'):<22}{disp:<10}{n:>7} "
f"{(sample or '')[:34]}")
print("\n--- 改善の指針 ---")
by_code: dict[int, int] = {}
for code, _, _, n, _ in rows:
by_code[code] = by_code.get(code, 0) + n
hints = {
110017: "数量の丸め処理を見直す(記事13の発注前検証)",
110094: "最小注文金額のチェックを追加する",
110007: "未約定注文が証拠金を拘束していないか確認",
110020: "有効注文数が上限に近い。注文の寿命管理を追加",
10006: "レート制限。トークンバケットで流量制御(記事05)",
10018: "IPレート制限。並列数を削減する",
10002: "時計のズレ。NTP同期を設定する(記事38)",
10004: "署名の生成を確認(記事02)",
110072: "冪等性が機能している。照会処理があるか確認(記事17)",
}
for code, n in sorted(by_code.items(), key=lambda kv: -kv[1])[:8]:
hint = hints.get(code)
print(f" {describe(code)} ({n}件)")
if hint:
print(f" -> {hint}")
if __name__ == "__main__":
from bybit_errors import BybitParamError, BybitRateLimitError
rec = ErrorRecorder()
rec.record(BybitParamError(110017, "OrderQty will be truncated to zero",
endpoint="place_order",
sent={"qty": "0.0000001", "api_key": "SECRET"}))
rec.record(BybitRateLimitError(10006, "Too many visits",
endpoint="get_tickers"))
rec.report()
「botが動かない」ときの切り分けを自動化します。上から順に確認すれば、原因はほぼ特定できます。
"""diagnose.py — 「動かない」ときの一次切り分けを自動化する
本コードは本人利用に限り自由に使用可。再配布・商用利用・販売は禁止。
"""
from __future__ import annotations
import time
import requests
from config import REST_BASE, load_credentials
def check(name: str, fn) -> bool:
try:
detail = fn()
print(f" [OK] {name}" + (f" — {detail}" if detail else ""))
return True
except Exception as e:
print(f" [NG] {name} — {type(e).__name__}: {e}")
return False
def diagnose() -> None:
cred = load_credentials()
base = cred.base_url
print(f"環境: {cred.env.value} 接続先: {base}\n")
results: list[bool] = []
# 1) ネットワーク(公開エンドポイント・認証なし)
def net():
r = requests.get(f"{base}/v5/market/time", timeout=10)
r.raise_for_status()
return f"HTTP {r.status_code}"
results.append(check("1. ネットワーク疎通", net))
# 2) 時計のズレ
def clock():
t0 = time.time()
r = requests.get(f"{base}/v5/market/time", timeout=10)
t1 = time.time()
server_ms = int(r.json()["result"]["timeNano"]) / 1e6
skew = server_ms - (t0 + t1) / 2 * 1000
if abs(skew) > 2500:
raise RuntimeError(f"ズレ {skew:+.0f}ms(recv_window超過の危険 -> 10002)")
return f"ズレ {skew:+.0f}ms"
results.append(check("2. 時計の同期", clock))
# 3) 認証(署名が正しいか)
from client import make_client
c = make_client()
def auth():
res = c.http.get_api_key_information()
if res["retCode"] != 0:
raise RuntimeError(f"retCode={res['retCode']} {res['retMsg']}")
info = res["result"]
return (f"IP制限={info.get('ips') or 'なし'} "
f"期限={info.get('expiredAt') or '無期限'}")
results.append(check("3. 認証(キー・署名)", auth))
# 4) 権限
def perms():
info = c.http.get_api_key_information()["result"]
if info.get("readOnly") == 1:
raise RuntimeError("Read-Onlyキーです(発注はできません)")
active = {k: v for k, v in (info.get("permissions") or {}).items() if v}
return str(active)
results.append(check("4. キーの権限", perms))
# 5) 口座種別
def account():
res = c.http.get_wallet_balance(accountType="UNIFIED")
if res["retCode"] != 0:
raise RuntimeError(f"retCode={res['retCode']} {res['retMsg']} "
f"(accountType=CONTRACTかもしれません)")
eq = res["result"]["list"][0]["totalEquity"]
return f"統合口座 総資産={eq}"
results.append(check("5. 口座(残高取得)", account))
# 6) 銘柄仕様
def symbol():
res = c.http.get_instruments_info(category="linear", symbol="BTCUSDT")
row = res["result"]["list"][0]
return (f"tickSize={row['priceFilter']['tickSize']} "
f"qtyStep={row['lotSizeFilter']['qtyStep']} "
f"status={row.get('status')}")
results.append(check("6. 銘柄仕様の取得", symbol))
print()
if all(results):
print("すべて正常です。問題は個別のリクエストのパラメータにあります。")
print("失敗したリクエストの送信パラメータとretCodeをログから確認してください。")
else:
first_ng = results.index(False) + 1
print(f"最初に失敗したのは項目{first_ng}です。ここを先に解決してください。")
print("(上流の項目が失敗している状態で下流を調べても意味がありません)")
if __name__ == "__main__":
diagnose()
認証が通っていない状態で「発注のパラメータが悪いのでは」と調べても時間の無駄です。ネットワーク → 時計 → 認証 → 権限 → 口座 → 銘柄 → パラメータの順に潰してください。この診断スクリプトを1つ持っておくと、トラブル時の初動が数分で済みます。
requests の raise_for_status() では検出できないため、retCodeを検査する処理を1箇所に集約してください(サンプル1の raise_for_ret_code())。この関数を通さないパスがあると、そこだけエラーが無視されます。classify() は未知のコードを BybitFatalError に落としています。「分からないからリトライしてみる」は、書き込み操作では二重発注につながります。未知のコードを検出したら通知を飛ばし、公式のError Codesページで確認して分類表に追加する——という運用サイクルにしてください。max_retries / retry_delay / force_retry というパラメータがあり、一定の再試行を行います。ただし「書き込み操作で状態が不明なとき照会する」という判断はしてくれません。SDKのリトライは読み取り操作には便利ですが、発注系では自分で制御したほうが安全です。place_order のような副作用のある呼び出しは、サンプル3の place_order_safely() のようなラッパを通してください。recv_window(既定5000ms)を超えてズレています。「時々」なのがポイントで、通信遅延が大きい環境ではズレが窓に入るか入らないかの境界にいる可能性があります。対策は3つです。(1) chrony やNTPで時刻同期を常時行う。(2) recv_window を少し広げる(ただし広げすぎるとリプレイに対する安全性が下がります)。(3) サーバー時刻とのオフセットを測って補正する(記事02)。get_wallet_balance() の totalAvailableBalance(利用可能)と totalEquity(総資産)は別物です。get_open_orders() で未約定注文を確認し、不要なものをキャンセルしてください。他にはレバレッジ設定が低すぎる、リスクリミットの階層に引っかかっている(110013 / 110016)といった原因もあります(記事21)。mask() のようなフィルタをログ出力の直前に必ず通す設計にしてください。なお記事01で述べたとおり、bot用キーに出金権限を付けていなければ、漏洩時の被害は限定されます。投資は自己判断・自己責任でお願いいたします。本ブログの情報・ツール・ソフトウェアを参考・使用して、取引をされて何らかの損失を被られても、当サイトでは責任は一切負いません(間接的・付随的な障害・損失も含め一切の責任を負いません)。自己責任でご利用ください。
当サイトからダウンロードしたプログラム・EAは本人利用に限り完全無料でご利用いただけます。プログラムの全てまたは一部の複製・コピー・リバースエンジニアリング・改変・2次配布・商用利用・転売・販売等は一切禁止いたします。違反を発見した場合は法的処置を行います。
当サイト上に存在するソフトウェア・ツール・文章・画像・動画等の著作物を無断転載することを禁止します。引用の範囲を超えるものについては、法的処置を行います。
当サイトで配布するツール・ソフトウェアは無料提供ですのでサポートなどは行っておりません(逆に丁寧にサポートしているサイトはほとんど違法サイトですのでご注意ください。投資助言・代理業登録なしでサポート・助言することは法律で禁止されています)。万が一、ツールやソフトウェアに不具合や問題があっても当サイトでは修正・対応などができない場合があります。ご利用の環境でトラブルが発生しても当サイトでは助言することはできませんので自己責任でご利用ください。検索するなどして自分で解決してください。
当サイトでは取引所・証券会社へのリンクを貼っていますが、これは情報として提供するものであり、勧誘行為などは一切行っておりません。そのため、取引所・証券会社の登録方法・使い方・オススメ金融機関・オススメ金融商品などを説明した記事はサイト内にありませんので、ご自身で調べた上で判断ください(本当はサイト内で丁寧に説明したいのですが、仲介・取次ぎにあたる可能性があり第一種金融商品取引業登録を行っていない場合、違法となる可能性があるため出来ません。ご容赦ください)。
当サイトからリンクやバナーなどによって他のサイトに移動された場合、移動先サイトで提供される情報、サービス等について一切の責任を負いません。当サイト掲載情報やリンク先の掲載情報の合法性、正確性、道徳性、最新性、適切性、著作権の許諾や有無など、その内容については一切の保証を致しかねます。
当サイトは、ご訪問いただいた時間、訪問者のアクセス元のホスト名、IPアドレス、OSとブラウザのバージョン、リンク元のURL、モニターの情報等のパソコン環境情報を収集し、アクセス解析をしております。この目的は訪問者が検索サイトからご訪問いただいた場合、どのような検索で当サイトに来訪いただいたかの解析にのみ使用しており、第3者に公開はいたしません。また、個人を特定するものではありません。