Bybit APIのバッチ注文で発注レイテンシを削減する
一括発注・一括修正・一括キャンセル

グリッドbotやマーケットメイクbotで数十本の注文を出し入れするとき、1本ずつRESTを叩くのは遅く、レート制限も食い、途中で失敗すると状態が中途半端に壊れます。バッチAPIは1リクエストで複数注文を扱えますが、本当の難所は「一部だけ成功したときのレスポンス構造」です。この記事ではサンプルコード7本で、部分成功を正しく扱うディスパッチャまで作ります。

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

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

目次

  1. バッチAPIの仕様と上限本数
  2. 一括発注と部分成功の判定
  3. 上限超えを自動分割するディスパッチャ
  4. グリッド全体を価格スライドさせる
  5. 一括キャンセルの使い分け
  6. 逐次 vs バッチのベンチマーク
  7. 失敗分だけを再送する冪等化
  8. よくある疑問Q&A

バッチAPIの仕様と上限本数

バッチAPIは1リクエストに複数の注文を詰めて送るだけの単純な仕組みに見えますが、上限本数・レート消費・エラーの返り方に固有の癖があります。

category1リクエストの上限レート消費備考
linear 20件 注文本数分 USDT/USDC無期限・先物
inverse 20件 注文本数分 コインマージン
option 20件 注文本数分 —
spot 10件 注文本数分 他カテゴリの半分。分割ロジックで固定値を書かないこと
レート制限は「1リクエスト=1」ではない

バッチ発注は注文本数分のレートを消費します。20本のバッチは20リクエスト分です。つまりバッチAPIの利点はレート節約ではなくレイテンシとアトミック性にあります。
「バッチにすればレート制限を回避できる」という理解は誤りで、その前提で組むと retCode=10006 に当たります。記事05の流量ゲートには本数を渡して消費させる必要があります。

観点逐次発注(20回)バッチ発注(1回×20本)
往復回数 20往復 1往復
レート消費 20 20(同じ)
所要時間 RTT×20(数百ms〜数秒) RTT×1
価格のズレ 後半の注文は古い価格基準になる 全件が同一時点
エラー処理 単純(1件ずつ判定) 複雑(部分成功の突き合わせが必要)

一括発注と部分成功の判定

バッチAPIの核心はトップレベルの retCode が 0 でも、個別の注文は失敗していることがある点です。result.list(注文ID)と retExtInfo.list(個別の結果コード)をインデックスで突き合わせる必要があります。

batch_place.pyサンプル1
"""batch_place.py — 一括発注と部分成功の正しい判定
本コードは本人利用に限り自由に使用可。再配布・商用利用・販売は禁止。

  レスポンス構造:
    {
      "retCode": 0,                    ← リクエスト全体の受理。個別の成否ではない
      "result": {
        "list": [
          {"category": "linear", "symbol": "BTCUSDT",
           "orderId": "xxx", "orderLinkId": "yyy"},   ← 失敗時は orderId が空文字
          ...
        ]
      },
      "retExtInfo": {
        "list": [
          {"code": 0, "msg": "OK"},                   ← ここが個別の成否
          {"code": 110007, "msg": "ab not enough for new order"},
          ...
        ]
      }
    }

  result.list と retExtInfo.list は同じ長さ・同じ順序。
  片方だけを見ると必ず事故る。
"""
from __future__ import annotations

from dataclasses import dataclass, field
from decimal import Decimal

from client import make_client
from symbol_rules import fetch_rules

# category ごとの1リクエスト上限
BATCH_LIMIT = {"linear": 20, "inverse": 20, "option": 20, "spot": 10}


@dataclass(frozen=True)
class OrderRequest:
    """1本分の注文。バッチにもそのまま単発にも使える。"""
    symbol: str
    side: str
    order_type: str
    qty: Decimal
    price: Decimal | None = None
    time_in_force: str = "GTC"
    order_link_id: str = ""
    reduce_only: bool = False
    post_only: bool = False

    def to_params(self, http, category: str) -> dict:
        rules = fetch_rules(http, self.symbol, category)
        p: dict = {
            "symbol": self.symbol,
            "side": self.side,
            "orderType": self.order_type,
            "qty": rules.format_qty(self.qty),
        }
        if self.price is not None:
            p["price"] = rules.format_price(self.price)
        if self.order_type == "Limit":
            p["timeInForce"] = "PostOnly" if self.post_only else self.time_in_force
        if self.order_link_id:
            p["orderLinkId"] = self.order_link_id
        if self.reduce_only:
            p["reduceOnly"] = True
        return p


@dataclass(frozen=True)
class BatchItemResult:
    index: int
    request: OrderRequest
    ok: bool
    order_id: str
    order_link_id: str
    code: int
    msg: str

    def line(self) -> str:
        head = f"  [{self.index:>2}] {self.request.symbol} " \
               f"{self.request.side} {self.request.qty}"
        if self.ok:
            return f"{head} → OK {self.order_id[:12]}..."
        return f"{head} → NG code={self.code} {self.msg}"


@dataclass
class BatchResult:
    items: list[BatchItemResult] = field(default_factory=list)
    ret_code: int = 0
    ret_msg: str = ""

    @property
    def succeeded(self) -> list[BatchItemResult]:
        return [i for i in self.items if i.ok]

    @property
    def failed(self) -> list[BatchItemResult]:
        return [i for i in self.items if not i.ok]

    @property
    def all_ok(self) -> bool:
        return bool(self.items) and not self.failed

    def summary(self) -> str:
        return (f"成功 {len(self.succeeded)} / 失敗 {len(self.failed)} "
                f"/ 合計 {len(self.items)}")


def place_batch(http, requests: list[OrderRequest], *,
                category: str = "linear") -> BatchResult:
    """一括発注し、個別の成否まで解決して返す。"""
    limit = BATCH_LIMIT.get(category, 10)
    if len(requests) > limit:
        raise ValueError(
            f"{category} のバッチ上限は {limit}件です "
            f"({len(requests)}件が指定されました)。"
            "batch_dispatch.py の分割を使ってください")
    if not requests:
        return BatchResult()

    payload = [r.to_params(http, category) for r in requests]
    r = http.place_batch_order(category=category, request=payload)

    # トップレベルが 0 以外 = リクエスト自体が受理されなかった(全件失敗)
    if r["retCode"] != 0:
        return BatchResult(
            items=[BatchItemResult(i, req, False, "", req.order_link_id,
                                   r["retCode"], r["retMsg"])
                   for i, req in enumerate(requests)],
            ret_code=r["retCode"], ret_msg=r["retMsg"])

    results = r["result"].get("list", [])
    ext = r.get("retExtInfo", {}).get("list", [])

    items = []
    for i, req in enumerate(requests):
        res = results[i] if i < len(results) else {}
        info = ext[i] if i < len(ext) else {"code": 0, "msg": ""}
        code = int(info.get("code", 0))
        order_id = res.get("orderId", "")
        # 個別成功の条件: code==0 かつ orderId が空でない
        items.append(BatchItemResult(
            index=i, request=req,
            ok=(code == 0 and bool(order_id)),
            order_id=order_id,
            order_link_id=res.get("orderLinkId", req.order_link_id),
            code=code, msg=info.get("msg", ""),
        ))

    return BatchResult(items=items, ret_code=0, ret_msg="OK")


if __name__ == "__main__":
    import time

    c = make_client()
    symbol = "BTCUSDT"

    r = c.http.get_tickers(category="linear", symbol=symbol)
    last = Decimal(r["result"]["list"][0]["lastPrice"])
    print(f"現在価格: {last}\n")

    stamp = int(time.time())
    reqs = []
    for k in range(1, 11):
        px = last * (Decimal(1) - Decimal("0.002") * k)
        reqs.append(OrderRequest(
            symbol=symbol, side="Buy", order_type="Limit",
            qty=Decimal("0.001"), price=px, post_only=True,
            order_link_id=f"bt-{stamp}-{k}"))

    print(f"=== 一括発注 {len(reqs)}件 ===")
    res = place_batch(c.http, reqs)
    for item in res.items:
        print(item.line())
    print(f"\n{res.summary()}")

    if res.failed:
        print("\n【失敗した注文】")
        for item in res.failed:
            print(f"  {item.request.order_link_id}: "
                  f"code={item.code} {item.msg}")
        print("\n※ retCode=0 でも個別に失敗することがある。")
        print("  『全部成功した』と決めつけると、")
        print("  グリッドに穴が空いた状態で運用が続く。")

上限超えを自動分割するディスパッチャ

batch_dispatch.pyサンプル2
"""batch_dispatch.py — 任意本数を上限で自動分割して送るディスパッチャ
本コードは本人利用に限り自由に使用可。再配布・商用利用・販売は禁止。

  50本の注文を投げたい → linear なら 20 + 20 + 10 の3リクエストに分割。
  分割の際に必ず守ること:
    - 記事05の流量ゲートに「本数」を消費させる(1リクエスト=1ではない)
    - 途中のチャンクが失敗しても、残りを送るか止めるかを選べるようにする
    - 全チャンクの結果を1つに集約して返す
"""
from __future__ import annotations

import time
from dataclasses import dataclass, field

from batch_place import (BATCH_LIMIT, BatchItemResult, BatchResult,
                         OrderRequest, place_batch)
from client import make_client


@dataclass(frozen=True)
class DispatchConfig:
    category: str = "linear"
    stop_on_chunk_failure: bool = False   # チャンク全滅で以降を中止するか
    sleep_between: float = 0.05           # チャンク間の間隔
    max_retry: int = 1                    # チャンク単位の再試行回数


@dataclass
class DispatchReport:
    chunks: int = 0
    elapsed_sec: float = 0.0
    items: list[BatchItemResult] = field(default_factory=list)
    aborted: bool = False

    @property
    def succeeded(self) -> list[BatchItemResult]:
        return [i for i in self.items if i.ok]

    @property
    def failed(self) -> list[BatchItemResult]:
        return [i for i in self.items if not i.ok]

    def summary(self) -> str:
        return (f"{self.chunks}チャンク / {self.elapsed_sec * 1000:.0f}ms / "
                f"成功 {len(self.succeeded)} 失敗 {len(self.failed)}"
                + ("  [中断]" if self.aborted else ""))


def chunked(items: list, size: int):
    for i in range(0, len(items), size):
        yield items[i:i + size]


def dispatch(http, requests: list[OrderRequest],
             cfg: DispatchConfig = DispatchConfig(),
             gate=None) -> DispatchReport:
    """任意本数を分割して一括発注する。

    gate: 記事05の RateGate。acquire(n) で本数分のトークンを消費させる。
    """
    limit = BATCH_LIMIT.get(cfg.category, 10)
    report = DispatchReport()
    started = time.perf_counter()
    offset = 0

    for chunk in chunked(requests, limit):
        if gate is not None:
            gate.acquire(len(chunk))       # 本数分を消費(1ではない)

        result: BatchResult | None = None
        for attempt in range(cfg.max_retry + 1):
            result = place_batch(http, chunk, category=cfg.category)
            if result.ret_code == 0:
                break
            # 10006(レート) / 10016(システム) は再試行の価値がある
            if result.ret_code in (10006, 10016) and attempt < cfg.max_retry:
                wait = 0.5 * (2 ** attempt)
                print(f"  [retry] retCode={result.ret_code} → {wait:.1f}秒待機")
                time.sleep(wait)
                continue
            break

        assert result is not None
        report.chunks += 1
        # インデックスを全体通番に振り直す
        for item in result.items:
            report.items.append(BatchItemResult(
                index=offset + item.index, request=item.request,
                ok=item.ok, order_id=item.order_id,
                order_link_id=item.order_link_id,
                code=item.code, msg=item.msg))
        offset += len(chunk)

        if cfg.stop_on_chunk_failure and not result.succeeded:
            report.aborted = True
            print(f"  チャンク全滅のため中断 "
                  f"(retCode={result.ret_code} {result.ret_msg})")
            break

        time.sleep(cfg.sleep_between)

    report.elapsed_sec = time.perf_counter() - started
    return report


if __name__ == "__main__":
    from decimal import Decimal

    c = make_client()
    symbol = "BTCUSDT"

    r = c.http.get_tickers(category="linear", symbol=symbol)
    last = Decimal(r["result"]["list"][0]["lastPrice"])

    stamp = int(time.time())
    reqs = []
    for k in range(1, 46):     # 45本 → 20+20+5 の3チャンク
        px = last * (Decimal(1) - Decimal("0.001") * k)
        reqs.append(OrderRequest(
            symbol=symbol, side="Buy", order_type="Limit",
            qty=Decimal("0.001"), price=px, post_only=True,
            order_link_id=f"dsp-{stamp}-{k:03d}"))

    print(f"=== {len(reqs)}本を分割発注 (上限 {BATCH_LIMIT['linear']}件/回) ===")
    report = dispatch(c.http, reqs)
    print(f"\n{report.summary()}")

    if report.failed:
        print("\n【失敗した注文】")
        codes: dict[int, int] = {}
        for item in report.failed:
            codes[item.code] = codes.get(item.code, 0) + 1
        for code, n in sorted(codes.items(), key=lambda kv: -kv[1]):
            sample = next(i for i in report.failed if i.code == code)
            print(f"  code={code} ×{n}件: {sample.msg}")

    print("\n※ 記事05の流量ゲートを gate= で渡すこと。")
    print("  45本のバッチは45リクエスト分のレートを消費する。")

グリッド全体を価格スライドさせる

価格が動いたときグリッド全体をずらす場合、全キャンセル→全再発注は最悪の選択です。板から一瞬消えるため約定機会を失い、レートも倍消費します。amend_batch_order なら価格だけを差し替えられます。

batch_amend.pyサンプル3
"""batch_amend.py — グリッド全体を一括で価格スライドさせる
本コードは本人利用に限り自由に使用可。再配布・商用利用・販売は禁止。

  amend_batch_order のポイント:
    - orderId か orderLinkId のどちらかで対象を指定する
      (orderLinkId で指定できるので、IDを保存していなくても操作できる)
    - price / qty / triggerPrice を変更できる
    - 変更しない項目は「省略」する。空文字を渡すとエラーになる
    - 既に約定・キャンセル済みの注文は 110001 で個別に失敗する
      (バッチ全体は成功扱いになるので、必ず個別判定すること)
"""
from __future__ import annotations

import time
from dataclasses import dataclass
from decimal import Decimal

from batch_place import BATCH_LIMIT, BatchItemResult, OrderRequest
from client import make_client
from symbol_rules import fetch_rules


@dataclass(frozen=True)
class AmendRequest:
    symbol: str
    order_id: str = ""
    order_link_id: str = ""
    price: Decimal | None = None
    qty: Decimal | None = None
    trigger_price: Decimal | None = None

    def to_params(self, http, category: str) -> dict:
        if not (self.order_id or self.order_link_id):
            raise ValueError("orderId か orderLinkId のどちらかが必要です")
        rules = fetch_rules(http, self.symbol, category)
        p: dict = {"symbol": self.symbol}
        if self.order_id:
            p["orderId"] = self.order_id
        else:
            p["orderLinkId"] = self.order_link_id
        # 変更しない項目は「渡さない」。空文字は 10001 になる
        if self.price is not None:
            p["price"] = rules.format_price(self.price)
        if self.qty is not None:
            p["qty"] = rules.format_qty(self.qty)
        if self.trigger_price is not None:
            p["triggerPrice"] = rules.format_price(self.trigger_price)
        return p


def amend_batch(http, requests: list[AmendRequest], *,
                category: str = "linear") -> list[BatchItemResult]:
    """一括修正。戻り値は place_batch と同じ形で個別成否を返す。"""
    limit = BATCH_LIMIT.get(category, 10)
    out: list[BatchItemResult] = []
    offset = 0

    for i in range(0, len(requests), limit):
        chunk = requests[i:i + limit]
        payload = [a.to_params(http, category) for a in chunk]
        r = http.amend_batch_order(category=category, request=payload)

        results = r["result"].get("list", []) if r["retCode"] == 0 else []
        ext = r.get("retExtInfo", {}).get("list", [])

        for j, req in enumerate(chunk):
            if r["retCode"] != 0:
                code, msg, oid = r["retCode"], r["retMsg"], ""
            else:
                info = ext[j] if j < len(ext) else {"code": 0, "msg": ""}
                res = results[j] if j < len(results) else {}
                code, msg = int(info.get("code", 0)), info.get("msg", "")
                oid = res.get("orderId", "")
            out.append(BatchItemResult(
                index=offset + j,
                request=OrderRequest(req.symbol, "", "", Decimal(0)),
                ok=(code == 0), order_id=oid,
                order_link_id=req.order_link_id, code=code, msg=msg))
        offset += len(chunk)
        time.sleep(0.05)

    return out


def build_grid(center: Decimal, levels: int, step_pct: Decimal,
               symbol: str, qty: Decimal,
               link_prefix: str) -> list[OrderRequest]:
    """中心価格から等比グリッドを作る(記事36の簡易版)。"""
    reqs = []
    for k in range(1, levels + 1):
        buy_px = center * (Decimal(1) - step_pct * k)
        sell_px = center * (Decimal(1) + step_pct * k)
        reqs.append(OrderRequest(symbol, "Buy", "Limit", qty, buy_px,
                                 post_only=True,
                                 order_link_id=f"{link_prefix}-b{k:02d}"))
        reqs.append(OrderRequest(symbol, "Sell", "Limit", qty, sell_px,
                                 post_only=True,
                                 order_link_id=f"{link_prefix}-s{k:02d}"))
    return reqs


def slide_grid(http, link_prefix: str, symbol: str,
               old_center: Decimal, new_center: Decimal,
               levels: int, step_pct: Decimal, *,
               category: str = "linear") -> list[BatchItemResult]:
    """グリッド全体の中心をずらす。

    orderLinkId が規則的なので、注文IDを保存していなくても
    「どの注文をいくらに変えるか」を再計算できる。
    これが orderLinkId 設計(記事17)の実利。
    """
    amends = []
    for k in range(1, levels + 1):
        amends.append(AmendRequest(
            symbol, order_link_id=f"{link_prefix}-b{k:02d}",
            price=new_center * (Decimal(1) - step_pct * k)))
        amends.append(AmendRequest(
            symbol, order_link_id=f"{link_prefix}-s{k:02d}",
            price=new_center * (Decimal(1) + step_pct * k)))

    shift_bps = (new_center - old_center) / old_center * Decimal("10000")
    print(f"  グリッド中心 {old_center:.1f} → {new_center:.1f} "
          f"({shift_bps:+.1f}bps) / {len(amends)}本を修正")
    return amend_batch(http, amends, category=category)


if __name__ == "__main__":
    from batch_dispatch import dispatch

    c = make_client()
    symbol = "BTCUSDT"
    prefix = f"grid{int(time.time()) % 100000}"

    r = c.http.get_tickers(category="linear", symbol=symbol)
    center = Decimal(r["result"]["list"][0]["lastPrice"])
    levels, step = 8, Decimal("0.002")

    print(f"=== グリッド構築(中心 {center:.1f} / 上下{levels}段) ===")
    reqs = build_grid(center, levels, step, symbol,
                      Decimal("0.001"), prefix)
    report = dispatch(c.http, reqs)
    print(f"  {report.summary()}")

    time.sleep(2)

    print("\n=== グリッド全体を +0.3% スライド ===")
    new_center = center * Decimal("1.003")
    results = slide_grid(c.http, prefix, symbol, center, new_center,
                         levels, step)

    ok = sum(1 for x in results if x.ok)
    print(f"  修正成功 {ok} / {len(results)}")

    failed = [x for x in results if not x.ok]
    if failed:
        print("\n【修正できなかった注文】")
        for x in failed[:5]:
            print(f"  {x.order_link_id}: code={x.code} {x.msg}")
        print("  ※ 110001 は『注文が存在しない』= 既に約定した可能性が高い。")
        print("    その場合は修正ではなく新規発注が必要。")

    # 後片付け
    c.http.cancel_all_orders(category="linear", symbol=symbol)
    print("\n全注文をキャンセルしました。")

一括キャンセルの使い分け

メソッド対象レート消費用途
cancel_order() 1件 1 特定の1本だけ取り消す
cancel_batch_order() 指定した複数件 件数分 グリッドの一部だけ(買い側だけ等)
cancel_all_orders() 銘柄またはsettleCoin単位で全件 1 緊急停止・全撤退。最速かつ最安
batch_cancel.pyサンプル4
"""batch_cancel.py — 一括キャンセルの使い分けと安全な全撤退
本コードは本人利用に限り自由に使用可。再配布・商用利用・販売は禁止。

  重要:
    cancel_all_orders() はレート消費が「1」で最速。
    緊急停止(記事24のキルスイッチ)では必ずこちらを使う。
    ただし orderFilter を指定しないと条件注文が残る(記事15)。
"""
from __future__ import annotations

import time
from dataclasses import dataclass

from batch_place import BATCH_LIMIT, BatchItemResult, OrderRequest
from client import make_client
from decimal import Decimal


@dataclass(frozen=True)
class CancelRequest:
    symbol: str
    order_id: str = ""
    order_link_id: str = ""

    def to_params(self) -> dict:
        if not (self.order_id or self.order_link_id):
            raise ValueError("orderId か orderLinkId のどちらかが必要です")
        p = {"symbol": self.symbol}
        if self.order_id:
            p["orderId"] = self.order_id
        else:
            p["orderLinkId"] = self.order_link_id
        return p


def cancel_batch(http, requests: list[CancelRequest], *,
                 category: str = "linear") -> list[BatchItemResult]:
    """指定した注文だけを一括キャンセルする。"""
    limit = BATCH_LIMIT.get(category, 10)
    out: list[BatchItemResult] = []
    offset = 0

    for i in range(0, len(requests), limit):
        chunk = requests[i:i + limit]
        r = http.cancel_batch_order(
            category=category, request=[c.to_params() for c in chunk])

        results = r["result"].get("list", []) if r["retCode"] == 0 else []
        ext = r.get("retExtInfo", {}).get("list", [])

        for j, req in enumerate(chunk):
            if r["retCode"] != 0:
                code, msg, oid = r["retCode"], r["retMsg"], ""
            else:
                info = ext[j] if j < len(ext) else {"code": 0, "msg": ""}
                res = results[j] if j < len(results) else {}
                code, msg = int(info.get("code", 0)), info.get("msg", "")
                oid = res.get("orderId", "")
            out.append(BatchItemResult(
                index=offset + j,
                request=OrderRequest(req.symbol, "", "", Decimal(0)),
                ok=(code == 0), order_id=oid,
                order_link_id=req.order_link_id, code=code, msg=msg))
        offset += len(chunk)
        time.sleep(0.05)

    return out


def cancel_side(http, symbol: str, side: str, *,
                category: str = "linear") -> int:
    """片側(買いだけ/売りだけ)をキャンセルする。

    cancel_all_orders では片側だけを残せないので、
    未約定注文を取得して該当分をバッチキャンセルする。
    """
    from conditional_query import fetch_open_orders

    orders = fetch_open_orders(http, category=category, symbol=symbol,
                               order_filter="Order")
    targets = [CancelRequest(o.symbol, order_id=o.order_id)
               for o in orders if o.side == side]
    if not targets:
        return 0
    results = cancel_batch(http, targets, category=category)
    return sum(1 for x in results if x.ok)


def emergency_cancel_all(http, *, category: str = "linear",
                         settle_coin: str = "USDT") -> dict:
    """緊急全撤退。条件注文とTP/SLも含めて確実に消す。

    cancel_all_orders は orderFilter で対象種別が変わる。
    linear/inverse では orderFilter を省略すると
    通常注文と条件注文の両方が対象になるが、
    明示的に3種類を回すほうが取りこぼしがない。
    """
    counts = {}
    filters = ["Order", "StopOrder"]
    if category != "spot":
        filters.append("tpslOrder")

    for f in filters:
        params = {"category": category, "orderFilter": f}
        if category in ("linear", "inverse"):
            params["settleCoin"] = settle_coin
        r = http.cancel_all_orders(**params)
        if r["retCode"] != 0:
            print(f"  [warn] {f} のキャンセル失敗: "
                  f"{r['retCode']} {r['retMsg']}")
            counts[f] = -1
            continue
        counts[f] = len(r["result"].get("list", []))

    return counts


if __name__ == "__main__":
    from batch_amend import build_grid
    from batch_dispatch import dispatch

    c = make_client()
    symbol = "BTCUSDT"

    r = c.http.get_tickers(category="linear", symbol=symbol)
    center = Decimal(r["result"]["list"][0]["lastPrice"])

    prefix = f"cxl{int(time.time()) % 100000}"
    reqs = build_grid(center, 6, Decimal("0.002"), symbol,
                      Decimal("0.001"), prefix)
    print(f"=== グリッド {len(reqs)}本を発注 ===")
    print(f"  {dispatch(c.http, reqs).summary()}")

    time.sleep(2)

    print("\n=== 買い側だけキャンセル ===")
    n = cancel_side(c.http, symbol, "Buy")
    print(f"  {n}件をキャンセル")

    time.sleep(1)

    print("\n=== 緊急全撤退(cancel_all_orders) ===")
    started = time.perf_counter()
    counts = emergency_cancel_all(c.http)
    elapsed = (time.perf_counter() - started) * 1000
    for f, n in counts.items():
        print(f"  {f:<12}: {n}件")
    print(f"  所要 {elapsed:.0f}ms")

    print("\n※ 緊急停止では cancel_all_orders 一択。")
    print("  レート消費1・1往復で全件消える。")
    print("  50本をバッチキャンセルすると50消費・3往復かかる。")

逐次 vs バッチのベンチマーク

batch_benchmark.pyサンプル5
"""batch_benchmark.py — 逐次発注とバッチ発注を同条件で比較する
本コードは本人利用に限り自由に使用可。再配布・商用利用・販売は禁止。

  測るもの:
    - 総所要時間(発注命令から全件受理まで)
    - 1本あたりの平均レイテンシ
    - X-Bapi-Limit-Status の減り方(レート消費の実測)
    - 「最初の1本」と「最後の1本」の時刻差 = 価格基準のズレ

  testnet で実行すること。本番では約定してしまう。
"""
from __future__ import annotations

import statistics
import time
from dataclasses import dataclass, field
from decimal import Decimal

from batch_dispatch import dispatch
from batch_place import OrderRequest
from client import make_client
from symbol_rules import fetch_rules


@dataclass
class BenchResult:
    mode: str
    n_orders: int
    total_ms: float
    per_order_ms: float
    succeeded: int
    failed: int
    limit_before: int = -1
    limit_after: int = -1
    spread_ms: float = 0.0     # 最初と最後の受理時刻の差
    latencies: list[float] = field(default_factory=list)

    @property
    def rate_consumed(self) -> int:
        if self.limit_before < 0 or self.limit_after < 0:
            return -1
        return self.limit_before - self.limit_after

    def line(self) -> str:
        consumed = (str(self.rate_consumed)
                    if self.rate_consumed >= 0 else "不明")
        return (f"{self.mode:<12}"
                f"{self.total_ms:>9.0f}ms"
                f"{self.per_order_ms:>10.1f}ms/本"
                f"{self.succeeded:>6}/{self.n_orders:<4}"
                f"  レート消費 {consumed:>4}"
                f"  時刻差 {self.spread_ms:>7.0f}ms")


def _limit_status(http) -> int:
    """直近レスポンスの X-Bapi-Limit-Status を読む(記事05)。"""
    try:
        headers = getattr(http, "_last_headers", None) or {}
        return int(headers.get("X-Bapi-Limit-Status", -1))
    except Exception:
        return -1


def make_requests(http, symbol: str, n: int, prefix: str) -> list[OrderRequest]:
    r = http.get_tickers(category="linear", symbol=symbol)
    last = Decimal(r["result"]["list"][0]["lastPrice"])
    return [
        OrderRequest(symbol, "Buy", "Limit", Decimal("0.001"),
                     last * (Decimal(1) - Decimal("0.001") * (k + 1)),
                     post_only=True,
                     order_link_id=f"{prefix}-{k:03d}")
        for k in range(n)
    ]


def bench_sequential(http, requests: list[OrderRequest],
                     category: str = "linear") -> BenchResult:
    """1本ずつ place_order を叩く。"""
    before = _limit_status(http)
    latencies: list[float] = []
    ok = 0
    started = time.perf_counter()
    first_done = last_done = 0.0

    for i, req in enumerate(requests):
        t0 = time.perf_counter()
        r = http.place_order(category=category, **req.to_params(http, category))
        t1 = time.perf_counter()
        latencies.append((t1 - t0) * 1000)
        if r["retCode"] == 0:
            ok += 1
        if i == 0:
            first_done = t1
        last_done = t1

    total = (time.perf_counter() - started) * 1000
    return BenchResult(
        mode="逐次", n_orders=len(requests), total_ms=total,
        per_order_ms=total / len(requests), succeeded=ok,
        failed=len(requests) - ok,
        limit_before=before, limit_after=_limit_status(http),
        spread_ms=(last_done - first_done) * 1000,
        latencies=latencies)


def bench_batch(http, requests: list[OrderRequest],
                category: str = "linear") -> BenchResult:
    """バッチで送る。"""
    before = _limit_status(http)
    started = time.perf_counter()
    report = dispatch(http, requests)
    total = (time.perf_counter() - started) * 1000

    return BenchResult(
        mode="バッチ", n_orders=len(requests), total_ms=total,
        per_order_ms=total / len(requests),
        succeeded=len(report.succeeded), failed=len(report.failed),
        limit_before=before, limit_after=_limit_status(http),
        spread_ms=total / max(report.chunks, 1),
        latencies=[total / max(report.chunks, 1)] * report.chunks)


def run_benchmark(symbol: str = "BTCUSDT", n: int = 20) -> None:
    c = make_client()
    if not getattr(c, "is_testnet", True):
        raise RuntimeError(
            "本番環境では実行しないでください(実注文が出ます)")

    print(f"=== 発注方式ベンチマーク ({symbol} / {n}本) ===\n")
    print(f"{'方式':<12}{'総時間':>11}{'1本あたり':>13}"
          f"{'成功':>10}{'':>14}{'':>12}")
    print("-" * 78)

    results = []
    for mode, fn in (("逐次", bench_sequential), ("バッチ", bench_batch)):
        prefix = f"bm{mode}{int(time.time()) % 100000}"
        reqs = make_requests(c.http, symbol, n, prefix)
        res = fn(c.http, reqs)
        results.append(res)
        print("  " + res.line())

        c.http.cancel_all_orders(category="linear", symbol=symbol)
        time.sleep(1.0)

    seq, bat = results
    if bat.total_ms > 0:
        print(f"\n  高速化: {seq.total_ms / bat.total_ms:.1f}倍")
    print(f"  レート消費: 逐次 {seq.rate_consumed} / "
          f"バッチ {bat.rate_consumed}  ← ほぼ同じになるはず")

    if seq.latencies:
        print(f"\n  逐次の1本あたりレイテンシ: "
              f"中央値 {statistics.median(seq.latencies):.0f}ms / "
              f"最大 {max(seq.latencies):.0f}ms")

    print("\n【結論】")
    print("  バッチの利点はレート節約ではなく:")
    print("    1) 往復回数が減るのでレイテンシが下がる")
    print("    2) 全件が同一時点の価格基準で発注される")
    print("  逐次だと最後の1本は数秒古い価格を基準にしている。")
    print("  グリッドやMMではこのズレが在庫の偏りを生む。")


if __name__ == "__main__":
    run_benchmark()
ヘッダの取得について

サンプル5の _limit_status() は http._last_headers を参照しています。pybit はレスポンスヘッダを既定では保持しないため、記事05で作ったヘッダ保存ラッパを通したクライアントを使ってください。素の HTTP インスタンスでは -1(不明)が返り、レート消費の比較だけがスキップされます。

失敗分だけを再送する冪等化

batch_idempotent.pyサンプル6
"""batch_idempotent.py — orderLinkIdで冪等化し、失敗分だけ再送する
本コードは本人利用に限り自由に使用可。再配布・商用利用・販売は禁止。

  バッチ発注で最も危険なのは「タイムアウトしたが実は通っていた」ケース。
  素朴に再送すると二重発注になる。

  対策(記事17の考え方をバッチに拡張):
    1. 全件に決定論的な orderLinkId を付ける
       (同じ意図 → 同じID。時刻やランダム値を混ぜない)
    2. 送信前に「その意図のIDが既に存在するか」を取引所に問い合わせる
    3. 存在しないものだけ送る
    4. 重複エラー(110072/110073)は「既に成功している」と解釈する
"""
from __future__ import annotations

import hashlib
import time
from dataclasses import dataclass, field
from decimal import Decimal

from batch_dispatch import DispatchConfig, dispatch
from batch_place import OrderRequest
from client import make_client

# orderLinkId 重複を示すエラーコード
DUPLICATE_CODES = {110072, 110073}
MAX_LINK_ID_LEN = 36


@dataclass(frozen=True)
class OrderIntent:
    """「何をしたいか」を表す。実行結果とは独立。"""
    symbol: str
    side: str
    price: Decimal
    qty: Decimal
    tag: str            # グリッドの段番号など、意図を識別する文字列
    epoch: str          # 同じ意図を再構築したい世代(グリッドの張り直し単位)

    def link_id(self) -> str:
        """決定論的なorderLinkIdを作る。

        同じ意図なら何度計算しても同じ値になることが必須。
        時刻やuuidを混ぜてはいけない(冪等性が壊れる)。
        """
        raw = f"{self.epoch}|{self.symbol}|{self.side}|{self.tag}|{self.price}"
        digest = hashlib.sha1(raw.encode()).hexdigest()[:16]
        link = f"{self.epoch}-{self.tag}-{digest}"
        return link[:MAX_LINK_ID_LEN]

    def to_request(self) -> OrderRequest:
        return OrderRequest(
            symbol=self.symbol, side=self.side, order_type="Limit",
            qty=self.qty, price=self.price, post_only=True,
            order_link_id=self.link_id())


@dataclass
class IdempotentBatch:
    http: object
    category: str = "linear"
    intents: list[OrderIntent] = field(default_factory=list)

    def add(self, intent: OrderIntent) -> None:
        self.intents.append(intent)

    def _existing_link_ids(self, symbol: str) -> set[str]:
        """取引所側に既にある orderLinkId を集める。

        未約定注文だけでなく、直近の履歴も見る必要がある。
        「送ったが約定済み」を未送信と誤判定しないため。
        """
        found: set[str] = set()

        r = self.http.get_open_orders(category=self.category, symbol=symbol,
                                      limit=50)
        if r["retCode"] == 0:
            found |= {o.get("orderLinkId", "")
                      for o in r["result"]["list"]}

        # 直近の履歴(約定・キャンセル済みを含む)
        r = self.http.get_order_history(category=self.category, symbol=symbol,
                                        limit=50)
        if r["retCode"] == 0:
            found |= {o.get("orderLinkId", "")
                      for o in r["result"]["list"]}

        found.discard("")
        return found

    def submit(self, verbose: bool = True) -> dict:
        """未送信の意図だけを送る。"""
        symbols = {i.symbol for i in self.intents}
        existing: set[str] = set()
        for s in symbols:
            existing |= self._existing_link_ids(s)

        pending = [i for i in self.intents if i.link_id() not in existing]
        skipped = len(self.intents) - len(pending)

        if verbose:
            print(f"  意図 {len(self.intents)}件 / "
                  f"既存 {skipped}件をスキップ / "
                  f"送信 {len(pending)}件")

        if not pending:
            return {"sent": 0, "skipped": skipped,
                    "succeeded": 0, "duplicated": 0, "failed": 0}

        report = dispatch(self.http, [i.to_request() for i in pending],
                          DispatchConfig(category=self.category))

        # 重複エラーは「既に成功している」と解釈する
        duplicated = [x for x in report.failed if x.code in DUPLICATE_CODES]
        real_failed = [x for x in report.failed
                       if x.code not in DUPLICATE_CODES]

        if verbose and duplicated:
            print(f"  重複エラー {len(duplicated)}件 "
                  "→ 既に発注済みとみなす(二重発注を回避)")

        return {"sent": len(pending), "skipped": skipped,
                "succeeded": len(report.succeeded),
                "duplicated": len(duplicated),
                "failed": len(real_failed),
                "failures": [(x.order_link_id, x.code, x.msg)
                             for x in real_failed]}

    def retry_failed(self, result: dict, verbose: bool = True) -> dict:
        """失敗分だけを再送する。

        submit() を再度呼ぶだけで安全に再送できる。
        既に成功した分は _existing_link_ids で除外されるため。
        """
        if not result.get("failed"):
            return result
        if verbose:
            print(f"\n  失敗 {result['failed']}件を再送します")
        time.sleep(1.0)
        return self.submit(verbose=verbose)


if __name__ == "__main__":
    c = make_client()
    symbol = "BTCUSDT"

    r = c.http.get_tickers(category="linear", symbol=symbol)
    center = Decimal(r["result"]["list"][0]["lastPrice"])

    # epoch は「このグリッド世代」を表す。価格帯を丸めて世代を作ると、
    # 相場が大きく動いたときだけ世代が変わり、それ以外は冪等になる。
    epoch = f"g{int(center / 1000)}"

    batch = IdempotentBatch(c.http)
    for k in range(1, 13):
        px = (center * (Decimal(1) - Decimal("0.002") * k)).quantize(Decimal("0.1"))
        batch.add(OrderIntent(symbol, "Buy", px, Decimal("0.001"),
                              tag=f"b{k:02d}", epoch=epoch))

    print(f"=== 1回目の送信 (epoch={epoch}) ===")
    res1 = batch.submit()
    print(f"  {res1}")

    print("\n=== 同じ内容をもう一度送信(冪等性の確認) ===")
    res2 = batch.submit()
    print(f"  {res2}")
    print(f"\n  2回目の送信数: {res2['sent']}件 ← 0であるべき")

    if res2["sent"] == 0:
        print("  冪等性OK: 二重発注は発生しない")
    else:
        print("  [警告] 二重発注のおそれ。link_id の決定論性を確認すること")

    c.http.cancel_all_orders(category="linear", symbol=symbol)
    print("\n全注文をキャンセルしました。")
test_batch.pyサンプル7
"""test_batch.py — バッチ処理のロジックをネットワーク無しで検証する
本コードは本人利用に限り自由に使用可。再配布・商用利用・販売は禁止。

  pytest test_batch.py -v

  発注ロジックは「実際に注文を出さずに検証できる部分」が大半を占める。
  分割・突き合わせ・冪等性は、すべてオフラインでテストできる。
"""
from __future__ import annotations

from decimal import Decimal

import pytest

from batch_dispatch import chunked
from batch_idempotent import OrderIntent, MAX_LINK_ID_LEN
from batch_place import BATCH_LIMIT


def test_spot_limit_is_ten():
    """spot だけ上限が10。20で決め打ちすると spot で失敗する。"""
    assert BATCH_LIMIT["spot"] == 10
    assert BATCH_LIMIT["linear"] == 20


@pytest.mark.parametrize("total,size,expected", [
    (45, 20, [20, 20, 5]),
    (20, 20, [20]),
    (21, 20, [20, 1]),
    (0, 20, []),
    (7, 10, [7]),
])
def test_chunking(total, size, expected):
    """分割が正しいこと。境界値(ちょうど上限)を落とさない。"""
    items = list(range(total))
    assert [len(c) for c in chunked(items, size)] == expected


def test_chunking_preserves_order_and_content():
    """分割で要素が消えたり順序が変わったりしないこと。"""
    items = list(range(45))
    flat = [x for c in chunked(items, 20) for x in c]
    assert flat == items


def test_link_id_is_deterministic():
    """同じ意図からは必ず同じIDが出ること(冪等性の前提)。"""
    a = OrderIntent("BTCUSDT", "Buy", Decimal("100000"),
                    Decimal("0.001"), "b01", "g120")
    b = OrderIntent("BTCUSDT", "Buy", Decimal("100000"),
                    Decimal("0.001"), "b01", "g120")
    assert a.link_id() == b.link_id()


def test_link_id_differs_on_price_change():
    """価格が変われば別の注文なのでIDも変わること。"""
    a = OrderIntent("BTCUSDT", "Buy", Decimal("100000"),
                    Decimal("0.001"), "b01", "g120")
    b = OrderIntent("BTCUSDT", "Buy", Decimal("100100"),
                    Decimal("0.001"), "b01", "g120")
    assert a.link_id() != b.link_id()


def test_link_id_length_within_limit():
    """orderLinkId は36文字以内。超えると 10001 で拒否される。"""
    intent = OrderIntent("1000PEPEUSDT", "Sell", Decimal("0.00001234"),
                         Decimal("100000"), "s99", "g0001")
    assert len(intent.link_id()) <= MAX_LINK_ID_LEN


def test_batch_result_partial_success():
    """retCode=0 でも個別失敗があれば all_ok は False。"""
    from batch_place import BatchItemResult, BatchResult, OrderRequest

    req = OrderRequest("BTCUSDT", "Buy", "Limit", Decimal("0.001"))
    result = BatchResult(items=[
        BatchItemResult(0, req, True, "id1", "", 0, "OK"),
        BatchItemResult(1, req, False, "", "", 110007, "insufficient balance"),
    ], ret_code=0, ret_msg="OK")

    assert result.ret_code == 0        # 全体は成功
    assert not result.all_ok           # だが個別に失敗がある
    assert len(result.succeeded) == 1
    assert len(result.failed) == 1


def test_empty_batch_is_safe():
    """空リストを渡してもエラーにならないこと。"""
    from batch_place import BatchResult

    result = BatchResult()
    assert not result.all_ok
    assert result.succeeded == []
参考リンク(一次情報)

よくある疑問Q&A

バッチにすればレート制限を節約できますか
できません。バッチ発注は注文本数分のレートを消費します。20本のバッチは20リクエスト分です。
バッチの利点は往復回数の削減(レイテンシ)と全件が同一時点の価格基準になることです。記事05の流量ゲートには、サンプル2のように gate.acquire(len(chunk)) で本数分を消費させてください。1で消費させると必ず 10006 に当たります。
retCode=0 なのに注文が出ていません
バッチAPIのトップレベルの retCode はリクエストが受理されたかを示すだけで、個別注文の成否ではありません。retExtInfo.list[i].code を必ず確認してください。
サンプル1の place_batch() は result.list と retExtInfo.list をインデックスで突き合わせ、code==0 かつ orderId が空でない ものだけを成功と判定します。片方だけを見る実装は、グリッドに穴が空いたまま運用が続く事故につながります。
上限本数を超えるとどうなりますか
リクエスト全体が拒否されます(部分的に処理されることはありません)。linear/inverse/optionは20件、spotは10件です。
この差を忘れて20で決め打ちすると、spotで全滅します。サンプル2の BATCH_LIMIT のようにcategoryから引く辞書にしておき、コード中に数値を直書きしないでください。
amend_batch_orderで価格を変更したいのですが、変更しない項目はどうしますか
省略してください。空文字を渡すと 10001 になります。サンプル3の AmendRequest.to_params() は None の項目をパラメータから除外しています。
また orderId の代わりに orderLinkId で対象を指定できるため、注文IDを保存していなくてもグリッドを操作できます。これが規則的な orderLinkId 設計(記事17)の実利です。
全キャンセルはバッチとcancel_allのどちらが速いですか
cancel_all_orders() です。レート消費が1、1往復で全件消えます。50本をバッチキャンセルすると50消費・3往復かかります。
緊急停止(記事24のキルスイッチ)では必ず cancel_all_orders を使ってください。ただし orderFilter を指定しないと条件注文やTP/SLが残ります。サンプル4の emergency_cancel_all() は3種類すべてを回して取りこぼしを防ぎます。
バッチ送信がタイムアウトしました。再送していいですか
そのまま再送すると二重発注になります。タイムアウトは「届かなかった」ではなく「結果が分からない」状態です。
サンプル6のように、全件に決定論的な orderLinkId(同じ意図なら何度計算しても同じ値)を付け、送信前に get_open_orders と get_order_history で既存IDを照会してください。重複エラー 110072 / 110073 は「既に成功している」と解釈します。
複数銘柄を1つのバッチに混ぜられますか
同じ category なら混ぜられます。category はリクエスト全体で1つ指定し、symbol は注文ごとに指定するためです。
ただし異なるcategoryは混ぜられません(linearとspotを1リクエストにはできない)。複数カテゴリを扱うディスパッチャでは、category別にグループ化してから分割してください。
バッチの一部だけが失敗しました。どう扱うべきですか
エラーコードで対応を分けてください。110007(残高不足)は再送しても同じ結果なので、資金を確認するかサイズを落とします。10006(レート)や 10016(システム)は待機して再試行する価値があります。110072 / 110073(重複)は既に成功しています。
サンプル2の dispatch() はチャンク単位の再試行を、サンプル6の retry_failed() は個別注文の安全な再送を担当します。詳細なコード別の分岐は記事06を参照してください。

注意事項・免責事項

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

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

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

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

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

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

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