Bybit APIエラーコード実践ハンドブック
retCode別の原因・リトライ可否・
自動リカバリのPython実装

Bybit APIのエラー対応で本当に難しいのは「コードの意味を調べること」ではなく、「このエラーはリトライしていいのか」を判断することです。判断を間違えると二重発注という最悪の事故になります。この記事ではretCodeを「リトライ可・不可・状態照会が必要」の3つに分類する考え方を軸に、例外階層・自動リカバリラッパ・冪等リカバリ・通知までをサンプルコード7本で実装します。

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

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

目次

  1. retCodeの体系|番号帯で当たりを付ける
  2. 最重要|リトライ可否の3分類
  3. 実装でよく踏むretCode逆引き表
  4. 例外階層を実装する
  5. 分類に基づく自動リトライラッパ
  6. タイムアウト時の冪等リカバリ
  7. 「エラーだが無視すべき」コード
  8. HTTPレベルのエラー|403・5xx・タイムアウト
  9. 通知と重複抑制
  10. エラー統計を取って傾向を掴む
  11. 自己診断スクリプト|まず何を疑うか
  12. よくある疑問Q&A

網羅的なコード一覧は公式のError Codesが正典です。この記事はbot実装で実際に踏むものに絞り、「で、どうコードを書くか」に集中します。

retCodeの体系|番号帯で当たりを付ける

V5のretCodeは番号帯で大まかな領域が分かります。知らないコードに遭遇したとき、まず番号帯から当たりを付けるのが最短ルートです。

番号帯領域主な原因
0成功ただし「受理された」だけで約定を意味しない
10000〜10029共通・認証・レート署名、タイムスタンプ、権限、IP、レート制限、サーバー障害
110001〜110137デリバティブの取引・ポジション注文・レバレッジ・TP/SL・リスクリミット・マージン
170001〜170810現物(Spot)現物固有の数量・価格・注文数の制約
34040 / 3400xxUTAアカウントTP/SL設定、口座アップグレード、リスク率
現物とデリバティブでコード体系が別

同じ「残高不足」でも、デリバティブは 110007、現物は 170131 と別コードです。「数量が小さすぎる」もデリバティブ110017・現物170136。カテゴリを横断して動くコードを書くなら、両方の番号帯をマッピングしておく必要があります。片方だけを見ていると、現物に切り替えた瞬間にハンドリングが機能しなくなります。

最重要|リトライ可否の3分類

この記事の核心です。retCodeは意味ではなく「どう対処すべきか」で分類してください。3つしかありません。

同じretCodeでも、操作の種類で分類が変わる

ここが最も重要な洞察です。10016(サーバー内部エラー)やタイムアウトは、読み取り操作ならB(安全にリトライ可)、書き込み操作ならC(照会が必要)です。

get_positions() がタイムアウトしても、何度呼んでも副作用はありません。しかし place_order() がタイムアウトした場合、注文は通っているかもしれません。同じコードでもリトライの安全性がまったく違います。

したがって分類は「retCode」と「操作の冪等性」の2軸で決める必要があります。retCodeだけを見たリトライ表は不完全です。

実装でよく踏むretCode逆引き表

共通・認証(10xxx)

code意味実際の原因と対処分類
10000サーバータイムアウト一時的な遅延。読み取りは再試行、書き込みは照会B / C
10001パラメータ不正category の指定漏れ、数値を数値型で送っている(文字列必須)、accountType の誤り、シンボルの小文字A
10002タイムスタンプが範囲外時計のズレが recv_window 超過。NTP同期(w32tm /resync / chronyc makestep)同期後B
10003APIキー不正キーと接続先の不一致(testnetキーを本番に送信)、キーの有効期限切れA
10004署名エラーPOSTのJSON直列化が署名対象と不一致、Secretに改行混入。記事02のダンプで切り分けA
10005権限不足Read-Onlyキーで発注している。権限のあるキーに差し替えA
10006レート制限超過(UID)エンドポイント別の秒間上限超過。指数バックオフ+流量制御(記事05)B
10010IPが許可リスト外キーに紐付けたIPと実行環境のIPが違う。VPS移設・IP変更時に踏むA
10014重複リクエスト同一リクエストの多重送信。冪等IDの設計を見直すA
10016サーバー内部エラー書き込み操作では危険。注文が通っている可能性があるため必ず照会B / C
10018IPレート制限超過5秒600リクエストの上限。並列数を絞る。制限解除まで待つB(長め)
10028統合口座専用API通常口座で統合口座向けエンドポイントを呼んでいるA

取引・ポジション(110xxx)

code意味実際の原因と対処分類
110001注文が存在しないキャンセル・修正時の競合。既に約定済みなら正常無視
110004ウォレット残高不足口座全体の残高が足りないA
110007利用可能残高不足残高はあるが未約定注文が証拠金を拘束している。古い注文を整理A
110008注文は完了またはキャンセル済み競合状態。正常として無視無視
110009条件注文が上限超過古い条件注文を整理するA
110017数量がゼロに切り捨てられるqtyStep 未満の数量。発注前検証で弾く(記事13)A
110020有効注文が500件超グリッドbotで頻発。本数を設計段階で制限(記事36)A
110024ポジション保有中はモード変更不可先に全決済してから切り替えるA
110025
110026
110027
モード/マージンが変更されていない既に同じ設定。実質成功として無視無視
110030orderIdの重複同一注文の再送。冪等性が効いている無視
110043レバレッジが変更されていない既に同じ値。実質成功として無視(記事21)無視
110061PartialモードのTP/SLが20件超階段の段数を減らす(記事14)A
110072orderLinkIdの重複二重発注を防げた証拠。既存注文を照会して確定させる(記事17)無視
110092
110093
トリガー価格の方向が矛盾triggerDirection と現在価格の関係が不整合。現在価格から自動判定させる(記事15)A
110094想定元本が下限未満minNotionalValue を満たしていないA
110119RPIは承認MM限定timeInForce="RPI" は一般利用不可。PostOnly を使うA

現物(170xxx)

code意味対処分類
170007バックエンド応答待ちタイムアウト書き込みなら照会が必要C
170032ネットワークエラー再試行可(読み取り)B
170131残高不足デリバティブの110007に相当A
170136数量が最小未満現物は basePrecision / minOrderAmt で条件が別A
170141clientOrderIdの重複110072の現物版。冪等性が効いている無視
170213注文が存在しない110001の現物版無視
170810条件注文・TP/SL・通常注文の合計が500件超現物グリッドで踏むA

例外階層を実装する

retCodeを直接if文で分岐させると、コードが散らばって保守できません。例外の型で分類を表現します。

bybit_errors.pyサンプル1
"""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サンプル2
"""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サンプル3
"""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)
NOT_FOUNDを1回で確定させない

resolve_order() が attempts=4 で繰り返し確認しているのは重要な設計です。取引所側の反映には遅延があり、「送信直後に照会したら見つからなかったが、実は数百ms後に反映された」ケースが実際にあります。1回の照会で「届いていなかった」と判断して再送すると、両方が通って二重発注になります。再送は最後の手段と位置づけ、確認を重ねてください。

「エラーだが無視すべき」コード

これらはretCode != 0ですが、処理を止める理由がありません。むしろ例外にすると運用が不安定になります。

code状況なぜ無視してよいか
110043レバレッジ設定既に望む値になっている。目的は達成済み
110025
110026 / 110027
モード・マージン設定同上。冪等な設定操作の正常な結果
110001
110008 / 110010
キャンセル・修正対象が既に約定/キャンセル済み。キャンセルの目的は達成
110072
170141
発注同じ注文が既に存在。二重発注を防げた成功(ただし照会して確定させる)
34040TP/SL設定既に同じ値。ただしパラメータ不足も同じコードなので初回設定時は区別する
34040だけは注意が必要

34040は「値が既に設定済み」と「必須パラメータが不足」の両方を意味します。単純に無視すると、パラメータの書き忘れでTP/SLが設定されないまま「成功」として通過します。初回設定時は無視せず、設定後に get_positions() で実際の値を照合してください(記事14のサンプル6)。「同じ値の再設定」だけを無視対象にするのが正しい扱いです。

HTTPレベルのエラー|403・5xx・タイムアウト

retCodeが返る前に、HTTP層で失敗することもあります。retCodeだけを見ていると取りこぼします。

http_errors.pyサンプル4
"""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サンプル5
"""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サンプル6
"""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サンプル7
"""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つ持っておくと、トラブル時の初動が数分で済みます。

よくある疑問Q&A

HTTPが200なのにエラーというのはどういうことですか?
V5は業務エラーをHTTP 200 + retCode≠0で返す設計です。「リクエストは正しく届いて処理された。その結果がエラーだった」という意味なので、HTTP層としては成功しています。requests の raise_for_status() では検出できないため、retCodeを検査する処理を1箇所に集約してください(サンプル1の raise_for_ret_code())。この関数を通さないパスがあると、そこだけエラーが無視されます。
未知のretCodeが来たらどうすべきですか?
FATAL(止める)として扱うのが安全です。サンプル1の classify() は未知のコードを BybitFatalError に落としています。「分からないからリトライしてみる」は、書き込み操作では二重発注につながります。未知のコードを検出したら通知を飛ばし、公式のError Codesページで確認して分類表に追加する——という運用サイクルにしてください。
pybitは自動でリトライしてくれませんか?
pybitには max_retries / retry_delay / force_retry というパラメータがあり、一定の再試行を行います。ただし「書き込み操作で状態が不明なとき照会する」という判断はしてくれません。SDKのリトライは読み取り操作には便利ですが、発注系では自分で制御したほうが安全です。place_order のような副作用のある呼び出しは、サンプル3の place_order_safely() のようなラッパを通してください。
10002(タイムスタンプ)が時々出ます
ローカル時計が recv_window(既定5000ms)を超えてズレています。「時々」なのがポイントで、通信遅延が大きい環境ではズレが窓に入るか入らないかの境界にいる可能性があります。対策は3つです。(1) chrony やNTPで時刻同期を常時行う。(2) recv_window を少し広げる(ただし広げすぎるとリプレイに対する安全性が下がります)。(3) サーバー時刻とのオフセットを測って補正する(記事02)。
110007(利用可能残高不足)が出るが残高はあります
未約定注文が証拠金を拘束しているのが典型的な原因です。get_wallet_balance() の totalAvailableBalance(利用可能)と totalEquity(総資産)は別物です。get_open_orders() で未約定注文を確認し、不要なものをキャンセルしてください。他にはレバレッジ設定が低すぎる、リスクリミットの階層に引っかかっている(110013 / 110016)といった原因もあります(記事21)。
エラーログにAPIキーが出てしまいました
キーを即座に無効化して再発行してください。ログファイル・監視サービス・Discord等に一度出てしまったものは回収できないと考えるべきです。今後の対策として、サンプル5の mask() のようなフィルタをログ出力の直前に必ず通す設計にしてください。なお記事01で述べたとおり、bot用キーに出金権限を付けていなければ、漏洩時の被害は限定されます。
エラーが出たらbotを止めるべきですか?
分類次第です。BENIGNは無視、RETRYは再試行、RECONCILEは照会。FATALのうち「残高不足」「注文数上限」は新規エントリーだけ止めて既存ポジションの管理は継続、「認証エラー」「権限不足」は何もできないので完全停止して通知——というように段階的に縮退させるのが実務的です。この段階的な縮退(NORMAL → REDUCE_ONLY → FLATTEN → HALTED)をサーキットブレーカーとして体系化したものは記事24で扱います。

注意事項・免責事項

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

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

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

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

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

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

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