Bybit APIで成行・指値注文を出すPython実装
place_order全パラメータと
TIF(GTC/IOC/FOK/PostOnly)の使い分け

Bybit V5の発注は POST /v5/order/create ひとつで完結しますが、パラメータの組み合わせを誤ると「エラーにならないまま意図と違う注文が通る」という最も厄介な失敗をします。この記事では成行・指値の基本から、TIFの実務的な使い分け、価格・数量の丸め、amend_order による指値追従、注文タイムアウトの自動キャンセル、reduceOnly での安全決済までをサンプルコード9本で実装します。

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

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

目次

  1. まず動かす|最小の発注コード
  2. place_orderの全パラメータ一覧
  3. TIFの使い分け|GTC / IOC / FOK / PostOnly / RPI
  4. 発注前に必ず通す3つの検証
  5. 安全な発注ラッパを実装する(サンプル4)
  6. 現物の落とし穴|marketUnitとisLeverage
  7. 成行の暴走を止める|slippageTolerance
  8. amend_orderで指値を追従させる
  9. 未約定注文の監視と注文タイムアウト
  10. reduceOnlyで安全に決済する
  11. TIF別の約定率と実効コストを実測する
  12. 発注で頻出するエラーコード
  13. 実運用での注意点
  14. よくある疑問Q&A

この記事は記事01(pybit入門)で作った config.py / client.py / symbol_rules.py を再利用します。まだ手元にない場合は先にそちらを済ませてください。

まず動かす|最小の発注コード

結論から出します。テストネットで最小ロットの成行買いを出すコードです。

order_min.pyサンプル1
# 本コードは本人利用に限り自由に使用可。再配布・商用利用・販売は禁止。
# 必ず testnet (BYBIT_ENV=testnet) で実行してください。
from client import make_client

c = make_client()

res = c.http.place_order(
    category="linear",      # linear / inverse / spot / option
    symbol="BTCUSDT",
    side="Buy",             # Buy / Sell
    orderType="Market",     # Market / Limit
    qty="0.001",            # 文字列で渡す。数値型はエラーになることがある
)

print(res["retCode"], res["retMsg"])
print("orderId     :", res["result"]["orderId"])
print("orderLinkId :", res["result"]["orderLinkId"])

返ってくるのは orderId と orderLinkId だけで、約定価格は返りません。ここが重要です。

place_orderの成功は「受理」でしかない

retCode=0 は「注文が受理された」という意味であって、約定したという意味ではありません。成行でも即時約定が保証されるわけではなく、指値やPostOnlyなら当然約定していません。約定価格・約定数量を知るには、get_order_history() で照会するか、WebSocketの execution トピックを購読する必要があります(記事27)。

「発注APIが成功したから約定したはず」という前提でロジックを書くと、ポジションが無いのに有ると思い込んだbotが動き続けます。これは自動売買で最も頻度の高い設計ミスです。

place_orderの全パラメータ一覧

bot実装で判断が必要なものに絞って整理します。網羅表は公式のPlace Orderを参照してください。

必須パラメータ

パラメータ値注意点
categorylinear / inverse / spot / optionこれを間違えると「シンボルが存在しない」系のエラーになる
symbolBTCUSDT大文字のみ。小文字だとエラー
sideBuy / Sell大文字小文字を含めて完全一致が必要
orderTypeMarket / Limit条件注文は別パラメータ(triggerPrice)で表現する
qty"0.001"文字列。qtyStep の倍数でなければ拒否される

判断が必要な任意パラメータ

パラメータ既定値使う場面
price—指値で必須。成行では無視される
timeInForceLimit→GTC
Market→IOC
メイカー狙いなら PostOnly。次章で詳述
orderLinkId自動生成必ず自分で付ける。冪等化の鍵(記事17)。最大36文字・ユニーク
reduceOnlyfalse決済専用注文。誤って新規建てするのを防ぐ
positionIdx0ヘッジモードでは必須(1=Buy側 / 2=Sell側)。記事20
takeProfit
stopLoss
—発注と同時にTP/SLを付ける。記事14
triggerPrice—指定すると条件注文になる。記事15
marketUnitbaseCoin現物の成行のみ。USDT額で買いたいなら quoteCoin
isLeverage0現物で証拠金取引(借入)を使う場合に 1
slippageToleranceType
slippageTolerance
—成行の許容スリッページ。TickSize / Percent
closeOnTriggerfalse証拠金不足時でも決済を通したい場合
有効注文数の上限

無期限・先物は1銘柄あたり有効注文500件(超過すると retCode 110020)、条件注文は別枠で上限があり(110009)、Partialモードの TP/SL は20件まで(110061)です。現物は条件注文・TP/SL・通常注文の合計で500件(170810)。グリッドbotのように多数の指値を置く場合はこの制約に必ず当たるので、設計段階で本数を見積もってください(記事36)。

TIFの使い分け|GTC / IOC / FOK / PostOnly / RPI

timeInForce は「約定しなかった分をどう扱うか」を決めるパラメータです。手数料と約定率のトレードオフに直結するため、戦略ごとに意識して選ぶ必要があります。

TIF挙動手数料使う戦略
GTC キャンセルするまで板に残る メイカー
(即時約定時はテイカー)
通常の指値エントリー
IOC 即時約定できる分だけ約定し、残りは即キャンセル テイカー 確実に入りたいが価格上限は決めたい(成行の安全版)
FOK 全量約定できなければ全部キャンセル テイカー 部分約定が許されない裁定・両建ての片脚
PostOnly 板に乗るなら通す。即時約定してしまう価格なら注文自体が拒否 メイカー確定 グリッド・マーケットメイク・コスト重視の執行
RPI 個人投資家向け価格改善の指定 メイカー 承認されたマーケットメイカー限定。一般利用は不可
RPIは一般ユーザーは使えない

Enumに RPI が載っているため使えそうに見えますが、実際に投げると retCode 110119(無期限・先物)/ 170355(現物)「RPI orders restricted to approved market makers」で拒否されます。RPIはBybitに承認されたマーケットメイカー向けの機能です。一般の個人botでメイカー手数料を狙う場合は PostOnly を使ってください。

PostOnlyは「拒否される前提」で書く

PostOnlyの最大の注意点は、拒否がエラーではなく正常動作であることです。板に乗らない価格を出せば注文は成立しません。価格を1ティックずらして再試行するループが必要です。

post_only.pyサンプル2
"""post_only.py — PostOnly指値を、拒否されたら1ティックずらして再試行する
本コードは本人利用に限り自由に使用可。再配布・商用利用・販売は禁止。
"""
from __future__ import annotations

from decimal import Decimal

from client import make_client
from symbol_rules import fetch_rules, round_to_step

# PostOnlyが板を食ってしまう場合の拒否理由(rejectReason)
POST_ONLY_REJECT = "EC_PostOnlyWillTakeLiquidity"


def place_post_only(http, symbol: str, side: str, qty: Decimal,
                    category: str = "linear", max_retry: int = 5) -> dict | None:
    """必ずメイカーになる指値を置く。板に届かない場合は1ティックずつ退避する。"""
    rules = fetch_rules(http, symbol, category)
    tick = rules.tick_size

    for attempt in range(max_retry):
        ob = http.get_orderbook(category=category, symbol=symbol, limit=1)["result"]
        best_bid = Decimal(ob["b"][0][0])
        best_ask = Decimal(ob["a"][0][0])

        # 買いはベストビッド、売りはベストアスクに置く。
        # 再試行ごとに、板を食わない方向へ tick 分だけ退避させる。
        if side == "Buy":
            price = best_bid - tick * attempt
        else:
            price = best_ask + tick * attempt
        price = round_to_step(price, tick)

        res = http.place_order(
            category=category,
            symbol=symbol,
            side=side,
            orderType="Limit",
            qty=str(qty),
            price=str(price),
            timeInForce="PostOnly",
        )

        if res["retCode"] == 0:
            print(f"[OK] PostOnly受理 price={price} (試行{attempt + 1}回目)")
            return res["result"]

        # 拒否理由がPostOnly由来なら価格を変えて再試行、それ以外は諦める
        msg = res.get("retMsg", "")
        if POST_ONLY_REJECT in msg or "post only" in msg.lower():
            print(f"[retry] 板を食うため拒否 price={price} -> ずらして再試行")
            continue
        raise RuntimeError(f"発注失敗: {res['retCode']} {msg}")

    print("[NG] PostOnlyを置けませんでした(板の変動が速すぎる)")
    return None


if __name__ == "__main__":
    c = make_client()
    place_post_only(c.http, "BTCUSDT", "Buy", Decimal("0.001"))

なお受理された後も、板が動いて自分の注文がテイカーになる状況になれば取引所側でキャンセルされます。PostOnlyは「約定しないことがある」注文だと割り切り、約定を前提にした後続処理を書かないでください。

発注前に必ず通す3つの検証

発注が拒否される原因の大半は、次の3つのどれかです。発注ラッパでまとめて検証してしまえば、以後この種のエラーはほぼ消えます。

安全な発注ラッパを実装する

上の3検証と、orderLinkId の自動付与、retCodeの例外化をまとめた発注ラッパを作ります。以降の記事のbotはすべてこのクラス経由で発注します。

order_router.pyサンプル3
"""order_router.py — 検証・丸め・冪等ID付与をまとめた発注ラッパ
本コードは本人利用に限り自由に使用可。再配布・商用利用・販売は禁止。
"""
from __future__ import annotations

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

from symbol_rules import SymbolRules, fetch_rules, floor_to_step, round_to_step


class OrderRejected(ValueError):
    """発注前の自前検証で弾いた(APIには送っていない)"""


class OrderFailed(RuntimeError):
    """APIがretCode!=0を返した"""

    def __init__(self, ret_code: int, ret_msg: str):
        super().__init__(f"retCode={ret_code} retMsg={ret_msg}")
        self.ret_code = ret_code
        self.ret_msg = ret_msg


@dataclass
class OrderRequest:
    symbol: str
    side: str                       # Buy / Sell
    qty: Decimal
    order_type: str = "Limit"       # Limit / Market
    price: Decimal | None = None
    category: str = "linear"
    time_in_force: str | None = None
    reduce_only: bool = False
    position_idx: int | None = None
    tag: str = "manual"             # 戦略名。orderLinkIdの先頭に入れて後で集計できる
    extra: dict = field(default_factory=dict)


class OrderRouter:
    """発注の単一窓口。ルールキャッシュを持ち、検証を通してから送る。"""

    def __init__(self, http, price_limit_check: bool = True):
        self.http = http
        self._rules: dict[tuple[str, str], SymbolRules] = {}
        self.price_limit_check = price_limit_check

    # ---- 銘柄仕様のキャッシュ ------------------------------------------
    def rules(self, symbol: str, category: str) -> SymbolRules:
        key = (category, symbol)
        if key not in self._rules:
            self._rules[key] = fetch_rules(self.http, symbol, category)
        return self._rules[key]

    # ---- 冪等ID --------------------------------------------------------
    @staticmethod
    def make_link_id(tag: str) -> str:
        """orderLinkIdは36文字以内・ユニーク。先頭に戦略タグを入れて後で集計する。
        完全な決定論的IDの作り方は記事17で扱う。
        """
        return f"{tag[:8]}-{int(time.time() * 1000)}-{uuid.uuid4().hex[:6]}"[:36]

    # ---- 検証 ----------------------------------------------------------
    def _validate(self, req: OrderRequest) -> tuple[Decimal, Decimal | None]:
        r = self.rules(req.symbol, req.category)

        qty = floor_to_step(req.qty, r.qty_step)
        if qty <= 0:
            raise OrderRejected(
                f"{req.symbol}: 数量が刻み幅{r.qty_step}で丸めるとゼロになります"
                f"(元の数量 {req.qty})"
            )
        if qty < r.min_qty:
            raise OrderRejected(f"{req.symbol}: 数量{qty} < 最小{r.min_qty}")
        if r.max_qty > 0 and qty > r.max_qty:
            raise OrderRejected(f"{req.symbol}: 数量{qty} > 最大{r.max_qty}")

        price = None
        if req.order_type == "Limit":
            if req.price is None:
                raise OrderRejected("Limit注文にpriceがありません")
            price = round_to_step(req.price, r.tick_size)
            if price <= 0:
                raise OrderRejected(f"価格が不正です: {price}")

            if r.min_notional > 0 and price * qty < r.min_notional:
                raise OrderRejected(
                    f"{req.symbol}: 想定元本{price * qty} < 最小{r.min_notional}"
                )
            if self.price_limit_check:
                self._check_price_limit(req, price)
        return qty, price

    def _check_price_limit(self, req: OrderRequest, price: Decimal) -> None:
        """現在価格から大きく外れた指値を、APIに送る前に弾く(桁ミス対策)。"""
        if req.category not in ("linear", "inverse"):
            return
        res = self.http.get_price_limit(category=req.category, symbol=req.symbol)
        if res.get("retCode") != 0:
            return   # 取得できない場合は検証をスキップ(発注自体は止めない)
        lim = res["result"]
        lo, hi = Decimal(lim["buyLmt"]), Decimal(lim["sellLmt"])
        if not (min(lo, hi) <= price <= max(lo, hi)):
            raise OrderRejected(
                f"{req.symbol}: 指値{price}が許容レンジ[{lo}, {hi}]の外です"
            )

    # ---- 発注 ----------------------------------------------------------
    def submit(self, req: OrderRequest) -> dict:
        qty, price = self._validate(req)

        params: dict = {
            "category": req.category,
            "symbol": req.symbol,
            "side": req.side,
            "orderType": req.order_type,
            "qty": str(qty),
            "orderLinkId": self.make_link_id(req.tag),
        }
        if price is not None:
            params["price"] = str(price)
        if req.time_in_force:
            params["timeInForce"] = req.time_in_force
        if req.reduce_only:
            params["reduceOnly"] = True
        if req.position_idx is not None:
            params["positionIdx"] = req.position_idx
        params.update(req.extra)

        res = self.http.place_order(**params)
        if res["retCode"] != 0:
            raise OrderFailed(res["retCode"], res["retMsg"])

        out = res["result"]
        out["_sent"] = params      # 何を送ったかを残す(障害調査で必須)
        return out


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

    c = make_client()
    router = OrderRouter(c.http)

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

    # 現在価格の2%下に、メイカー確定の買い指値
    result = router.submit(OrderRequest(
        symbol="BTCUSDT",
        side="Buy",
        qty=Decimal("0.001"),
        order_type="Limit",
        price=last * Decimal("0.98"),
        time_in_force="PostOnly",
        tag="demo",
    ))
    print(result["orderId"], result["orderLinkId"])

    # 検証で弾かれる例(小さすぎる数量)
    try:
        router.submit(OrderRequest(symbol="BTCUSDT", side="Buy",
                                   qty=Decimal("0.0000001"), order_type="Market"))
    except OrderRejected as e:
        print("想定どおり弾かれました:", e)
「送った内容」をレスポンスに残す意味

out["_sent"] = params の1行は地味ですが、障害調査で決定的に効きます。想定外の約定が起きたとき、知りたいのは「取引所が何を返したか」ではなく「自分が何を送ったか」です。ログには必ず送信パラメータを残してください(記事39)。

現物の落とし穴|marketUnitとisLeverage

現物(category="spot")の成行注文には、無期限先物にはない仕様があります。買いと売りで qty の意味が変わるのです。

marketUnitqtyの意味用途
baseCoin
(既定)
基軸通貨の数量(例: 0.001 BTC)「0.001 BTC買う」
quoteCoin決済通貨の金額(例: 100 USDT)「100 USDT分買う」=積立・DCAはこちら
spot_market.pyサンプル4
"""spot_market.py — 現物成行を「USDT金額指定」で出す(DCA積立の基本形)
本コードは本人利用に限り自由に使用可。再配布・商用利用・販売は禁止。
"""
from client import make_client

c = make_client()

# 100 USDT分のBTCを成行で買う。
# marketUnit="quoteCoin" にすると qty は「USDTの金額」として解釈される。
res = c.http.place_order(
    category="spot",
    symbol="BTCUSDT",
    side="Buy",
    orderType="Market",
    qty="100",                 # 100 USDT(BTCの数量ではない)
    marketUnit="quoteCoin",
    orderLinkId="dca-20260730-01",
)
print(res["retCode"], res["result"])

# 売りの場合、baseCoin指定なら「保有BTCを何枚売るか」になる
res = c.http.place_order(
    category="spot",
    symbol="BTCUSDT",
    side="Sell",
    orderType="Market",
    qty="0.001",               # 0.001 BTC
    marketUnit="baseCoin",
)
print(res["retCode"], res["result"])
marketUnitの指定漏れは金額を1000倍間違える

marketUnit を指定せず(既定は baseCoin)に qty="100" を送ると、100 USDTではなく100 BTCの注文として解釈されます。実際には残高不足で弾かれることが多いですが、安価なアルトコインではそのまま通ってしまいます。現物の成行を扱うコードでは marketUnit を必ず明示してください。

また現物で借入を使う(証拠金取引)場合は isLeverage=1 が必要です。既定の 0 は自己資金のみの取引です。意図せず1にすると借入が発生し、金利と清算リスクを負います。

成行の暴走を止める|slippageTolerance

成行注文は「いくらでも良いから約定させる」注文です。流動性の薄い銘柄や急変時には、想定から大きく離れた価格で約定します。V5には許容スリッページを指定して、超えたら約定させない仕組みがあります。

market_with_guard.pyサンプル5
"""market_with_guard.py — スリッページ上限付きの成行と、IOC指値による代替
本コードは本人利用に限り自由に使用可。再配布・商用利用・販売は禁止。
"""
from __future__ import annotations

from decimal import Decimal

from client import make_client
from symbol_rules import fetch_rules, round_to_step


def market_with_slippage_cap(http, symbol: str, side: str, qty: str,
                             pct: str = "0.1", category: str = "linear") -> dict:
    """許容スリッページを%で指定した成行。超える場合は約定しない。"""
    return http.place_order(
        category=category,
        symbol=symbol,
        side=side,
        orderType="Market",
        qty=qty,
        slippageToleranceType="Percent",   # TickSize も指定可
        slippageTolerance=pct,             # "0.1" = 0.1%
    )


def ioc_limit_as_safe_market(http, symbol: str, side: str, qty: Decimal,
                             max_slip_pct: Decimal = Decimal("0.1"),
                             category: str = "linear") -> dict:
    """IOC指値で「価格上限付きの成行」を自作する。
    どの取引所でも使える汎用手法で、約定価格の上限を自分で完全に制御できる。
    """
    rules = fetch_rules(http, symbol, category)
    ob = http.get_orderbook(category=category, symbol=symbol, limit=1)["result"]

    if side == "Buy":
        ref = Decimal(ob["a"][0][0])                       # ベストアスク
        cap = ref * (1 + max_slip_pct / 100)               # これ以上では買わない
    else:
        ref = Decimal(ob["b"][0][0])                       # ベストビッド
        cap = ref * (1 - max_slip_pct / 100)               # これ以下では売らない

    return http.place_order(
        category=category,
        symbol=symbol,
        side=side,
        orderType="Limit",
        qty=str(qty),
        price=str(round_to_step(cap, rules.tick_size)),
        timeInForce="IOC",     # 上限内で約定できる分だけ約定、残りは即キャンセル
    )


if __name__ == "__main__":
    c = make_client()
    print(market_with_slippage_cap(c.http, "BTCUSDT", "Buy", "0.001", pct="0.1"))
    print(ioc_limit_as_safe_market(c.http, "BTCUSDT", "Buy", Decimal("0.001")))
IOC指値のほうが移植性が高い

slippageTolerance はBybit固有のパラメータです。IOC指値で上限価格を自分で計算する方式なら、ロジックが取引所に依存せず、バックテスト(記事31)でも同じモデルで再現できます。「成行を使わず、IOC指値で価格上限を明示する」のは執行品質を管理するうえで実務的に有効な習慣です。

amend_orderで指値を追従させる

指値を価格に追従させたいとき、キャンセルしてから再発注するのは損です。往復2リクエストでレート制限を2回消費し、その隙間に板が動きます。amend_order なら1リクエストで、しかも板の順番(キュープライオリティ)を保てる場合があります。

amend.pyサンプル6
"""amend.py — 指値をベストビッド/アスクに追従させる(キャンセル+再発注を避ける)
本コードは本人利用に限り自由に使用可。再配布・商用利用・販売は禁止。
"""
from __future__ import annotations

import time
from decimal import Decimal

from client import make_client
from symbol_rules import fetch_rules, round_to_step


def chase_best_price(http, symbol: str, order_link_id: str, side: str,
                     category: str = "linear", interval: float = 1.0,
                     max_loops: int = 30) -> None:
    """自分の指値を、常にベスト気配に張り付かせる。"""
    rules = fetch_rules(http, symbol, category)
    last_price: Decimal | None = None

    for _ in range(max_loops):
        # 1) 自分の注文がまだ生きているか確認
        res = http.get_open_orders(category=category, symbol=symbol,
                                   orderLinkId=order_link_id)
        orders = res["result"]["list"]
        if not orders:
            print("注文が消えました(約定またはキャンセル済み)")
            return

        # 2) 目標価格をベスト気配から計算
        ob = http.get_orderbook(category=category, symbol=symbol, limit=1)["result"]
        target = Decimal(ob["b"][0][0]) if side == "Buy" else Decimal(ob["a"][0][0])
        target = round_to_step(target, rules.tick_size)

        # 3) 変わっていなければ何もしない(無駄なリクエストを打たない)
        if target == last_price:
            time.sleep(interval)
            continue

        r = http.amend_order(
            category=category,
            symbol=symbol,
            orderLinkId=order_link_id,   # orderIdでもよい
            price=str(target),
        )
        if r["retCode"] == 0:
            print(f"[amend] price -> {target}")
            last_price = target
        elif r["retCode"] in (110001, 110008):
            # 110001 注文が存在しない / 110008 完了済みまたはキャンセル済み
            print(f"[end] 注文は終了状態です: {r['retMsg']}")
            return
        else:
            print(f"[warn] amend失敗: {r['retCode']} {r['retMsg']}")

        time.sleep(interval)


if __name__ == "__main__":
    c = make_client()
    chase_best_price(c.http, "BTCUSDT", order_link_id="demo-chase-01", side="Buy")

amend_order は価格だけでなく数量・TP/SL・トリガー価格も変更できます。ただし約定済み数量より少ない数量には変更できません(110064 / 170313)。部分約定した注文を扱うときは注意してください。

未約定注文の監視と注文タイムアウト

指値を出したまま放置すると、忘れた注文が思わぬタイミングで約定します。すべての注文に寿命を持たせるのが安全な設計です。

order_ttl.pyサンプル7
"""order_ttl.py — 一定時間で未約定注文を自動キャンセルする(注文の寿命管理)
本コードは本人利用に限り自由に使用可。再配布・商用利用・販売は禁止。
"""
from __future__ import annotations

import time

from client import make_client


def cancel_stale_orders(http, category: str = "linear", settle_coin: str = "USDT",
                        ttl_sec: int = 300, tag_prefix: str | None = None) -> int:
    """createdTimeがttl_secより古い未約定注文をキャンセルする。

    tag_prefix を指定すると、その戦略が出した注文だけを対象にできる
    (orderLinkId の先頭に戦略名を入れておく前提。サンプル3参照)。
    """
    res = http.get_open_orders(category=category, settleCoin=settle_coin, limit=50)
    if res["retCode"] != 0:
        raise RuntimeError(f"get_open_orders失敗: {res['retMsg']}")

    now_ms = int(time.time() * 1000)
    cancelled = 0

    for o in res["result"]["list"]:
        age_sec = (now_ms - int(o["createdTime"])) / 1000
        if age_sec < ttl_sec:
            continue
        if tag_prefix and not o.get("orderLinkId", "").startswith(tag_prefix):
            continue

        r = http.cancel_order(
            category=category, symbol=o["symbol"], orderId=o["orderId"]
        )
        if r["retCode"] == 0:
            print(f"[cancel] {o['symbol']} {o['side']} {o['price']} "
                  f"({age_sec:.0f}秒経過)")
            cancelled += 1
        elif r["retCode"] in (110001, 110008, 110010):
            # 既に約定/キャンセル済み。競合状態なので警告不要
            pass
        else:
            print(f"[warn] cancel失敗 {o['orderId']}: {r['retCode']} {r['retMsg']}")
    return cancelled


def cancel_all(http, category: str = "linear", symbol: str | None = None,
               settle_coin: str = "USDT") -> dict:
    """全キャンセル。緊急時はこちら(1リクエストで済む)。"""
    params = {"category": category}
    if symbol:
        params["symbol"] = symbol
    else:
        params["settleCoin"] = settle_coin
    return http.cancel_all_orders(**params)


if __name__ == "__main__":
    c = make_client()
    n = cancel_stale_orders(c.http, ttl_sec=300, tag_prefix="demo")
    print(f"{n}件キャンセルしました")
cancel_orderの「失敗」は失敗でないことが多い

キャンセルしようとした瞬間に約定していれば、110001(注文が存在しない)や110008(完了済み)が返ります。これは競合状態として正常で、リトライしても意味がありません。上のコードのようにこれらのコードは黙って無視し、それ以外だけを警告するのが正しい扱いです。エラーコードの体系的な分類は記事06で扱います。

reduceOnlyで安全に決済する

決済のつもりで出した注文が、数量を間違えて逆方向の新規ポジションを建てるのは典型的な事故です。reduceOnly=True を付ければ、ポジションを減らす方向にしか作用しません。

close_position.pyサンプル8
"""close_position.py — reduceOnlyによる安全な決済(全決済・部分決済)
本コードは本人利用に限り自由に使用可。再配布・商用利用・販売は禁止。
"""
from __future__ import annotations

from decimal import Decimal

from client import make_client
from symbol_rules import fetch_rules, floor_to_step


def close_position(http, symbol: str, category: str = "linear",
                   ratio: Decimal = Decimal("1.0"), use_market: bool = True) -> dict | None:
    """保有ポジションを ratio の割合だけ決済する。ratio=1.0 で全決済。"""
    res = http.get_positions(category=category, symbol=symbol)
    if res["retCode"] != 0:
        raise RuntimeError(f"get_positions失敗: {res['retMsg']}")

    positions = [p for p in res["result"]["list"] if Decimal(p["size"]) > 0]
    if not positions:
        print(f"{symbol}: ポジションなし")
        return None

    pos = positions[0]
    size = Decimal(pos["size"])
    rules = fetch_rules(http, symbol, category)

    qty = floor_to_step(size * ratio, rules.qty_step)
    if qty <= 0:
        print(f"{symbol}: 決済数量が刻み幅未満です(size={size} ratio={ratio})")
        return None

    # 保有と逆方向に出す
    side = "Sell" if pos["side"] == "Buy" else "Buy"

    params = {
        "category": category,
        "symbol": symbol,
        "side": side,
        "orderType": "Market" if use_market else "Limit",
        "qty": str(qty),
        "reduceOnly": True,          # これが安全弁。新規建てにならない
    }
    # ヘッジモードでは決済対象の側を明示する必要がある
    if int(pos.get("positionIdx", 0)) != 0:
        params["positionIdx"] = int(pos["positionIdx"])

    if not use_market:
        params["price"] = pos["markPrice"]
        params["timeInForce"] = "PostOnly"

    r = http.place_order(**params)
    if r["retCode"] != 0:
        raise RuntimeError(f"決済失敗: {r['retCode']} {r['retMsg']}")

    print(f"[close] {symbol} {side} {qty} (保有 {size} の {ratio:.0%})")
    return r["result"]


if __name__ == "__main__":
    c = make_client()
    close_position(c.http, "BTCUSDT", ratio=Decimal("0.5"))   # 半分決済
    # close_position(c.http, "BTCUSDT")                        # 全決済

reduceOnly は「数量が保有量を超えていた場合、超過分を切り捨てる」ではなく「新規建てにならないように取引所が制御する」働きをします。ヘッジモードでは positionIdx の指定が必須なので、上のコードのように保有ポジションから引き継いでください。

TIF別の約定率と実効コストを実測する

「PostOnlyのほうが手数料が安いから得」とは限りません。約定しないことによる機会損失が手数料差を上回ることがあります。これは銘柄・時間帯・戦略の性質で変わるため、自分の環境で測るしかありません。

tif_bench.pyサンプル9
"""tif_bench.py — PostOnly / IOC / Market の約定率と実効コストを実測する
本コードは本人利用に限り自由に使用可。再配布・商用利用・販売は禁止。
testnetは板が薄く結果が歪むため、本番の最小ロットで測ることを推奨。
"""
from __future__ import annotations

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

from client import make_client
from symbol_rules import fetch_rules, round_to_step


@dataclass
class Trial:
    tif: str
    filled: bool
    mid_at_send: Decimal
    avg_price: Decimal | None = None

    @property
    def slippage_bps(self) -> float | None:
        """発注時ミッドからの乖離(bps)。プラスは不利側。"""
        if not self.filled or self.avg_price is None:
            return None
        diff = (self.avg_price - self.mid_at_send) / self.mid_at_send
        return float(diff) * 10_000


@dataclass
class Summary:
    tif: str
    trials: list[Trial] = field(default_factory=list)

    def report(self) -> str:
        n = len(self.trials)
        fills = [t for t in self.trials if t.filled]
        rate = len(fills) / n * 100 if n else 0
        slips = [t.slippage_bps for t in fills if t.slippage_bps is not None]
        med = statistics.median(slips) if slips else float("nan")
        return (f"{self.tif:<9} 試行{n:3d}  約定率{rate:5.1f}%  "
                f"スリッページ中央値{med:+7.2f} bps")


def mid_price(http, symbol: str, category: str) -> Decimal:
    ob = http.get_orderbook(category=category, symbol=symbol, limit=1)["result"]
    return (Decimal(ob["b"][0][0]) + Decimal(ob["a"][0][0])) / 2


def run_trial(http, symbol: str, tif: str, qty: Decimal,
              category: str = "linear", wait_sec: float = 3.0) -> Trial:
    rules = fetch_rules(http, symbol, category)
    mid = mid_price(http, symbol, category)
    link_id = f"bench-{tif}-{int(time.time() * 1000)}"

    params = {
        "category": category, "symbol": symbol, "side": "Buy",
        "qty": str(qty), "orderLinkId": link_id,
    }
    if tif == "Market":
        params["orderType"] = "Market"
    else:
        params["orderType"] = "Limit"
        params["timeInForce"] = tif
        # PostOnlyはベストビッド、IOCはベストアスク(=即約定を狙う)
        ob = http.get_orderbook(category=category, symbol=symbol, limit=1)["result"]
        px = Decimal(ob["b"][0][0]) if tif == "PostOnly" else Decimal(ob["a"][0][0])
        params["price"] = str(round_to_step(px, rules.tick_size))

    res = http.place_order(**params)
    if res["retCode"] != 0:
        # PostOnlyが板を食って拒否された場合も「約定せず」として記録する
        return Trial(tif=tif, filled=False, mid_at_send=mid)

    time.sleep(wait_sec)

    hist = http.get_order_history(category=category, orderLinkId=link_id)
    rows = hist["result"]["list"]
    if not rows:
        return Trial(tif=tif, filled=False, mid_at_send=mid)

    o = rows[0]
    filled_qty = Decimal(o.get("cumExecQty") or "0")
    if filled_qty <= 0:
        # 未約定なら片付けてから終了
        http.cancel_order(category=category, symbol=symbol, orderLinkId=link_id)
        return Trial(tif=tif, filled=False, mid_at_send=mid)

    return Trial(tif=tif, filled=True, mid_at_send=mid,
                 avg_price=Decimal(o["avgPrice"]))


if __name__ == "__main__":
    c = make_client()
    QTY = Decimal("0.001")
    summaries = {tif: Summary(tif) for tif in ("PostOnly", "IOC", "Market")}

    for _ in range(10):
        for tif, s in summaries.items():
            s.trials.append(run_trial(c.http, "BTCUSDT", tif, QTY))
            # 建った分は毎回decloseして条件を揃える(記事のclose_positionを使う)
            time.sleep(1)

    print("\n=== TIF別の執行品質 ===")
    for s in summaries.values():
        print(s.report())
この計測が後々の判断材料になる

ここで得られる「約定率」と「スリッページ中央値」は、バックテストの約定モデル(記事31)に直接入れる数値です。PostOnlyの約定確率を楽観的に見積もったバックテストは、実運用で必ず期待を下回ります。自分の環境で測った実測値をバックテストに戻すという循環を作れると、検証の精度が一段上がります。

発注で頻出するエラーコード

retCode意味原因と対処
110017数量がゼロに切り捨てられるqtyStep より小さい数量を送っている。発注前に検証で弾く(サンプル3)
110094想定元本が下限未満minNotionalValue を満たしていない。数量を増やすか発注を見送る
110003
110120 / 110121
価格が許容レンジ外現在価格から離れすぎた指値。桁ミスの可能性を疑う。get_price_limit() で事前確認
110007利用可能残高不足未約定注文が証拠金を拘束していないか確認。レバレッジ設定も影響(記事21)
110020有効注文が500件超古い注文をキャンセルする(サンプル7)。グリッドbotは設計段階で本数を制限
110072orderLinkIdの重複冪等性が効いた証拠。同じ注文の二重送信を防げている(記事17)
110001
110008 / 110010
注文が存在しない/完了済みキャンセル・修正時の競合状態。正常として無視する
110043レバレッジ変更なし既に同じ値。実質成功として無視(記事21)
110119
170355
RPIは承認MM限定timeInForce="RPI" は一般利用不可。PostOnly を使う
110064
170313
約定済み数量より少ない修正部分約定済みの注文を縮小しようとしている。残数量を確認してから修正
110023縮小のみ可能リスクリミットや上場終了前などで新規建てが制限されている
170136現物の数量が下限未満現物は basePrecision / minOrderAmt で条件が異なる

実運用での注意点

1. 発注のレート制限を把握する

発注系のレート上限はlinear / spotが毎秒20件、inverseが毎秒10件(Amend Orderは毎秒10件)が目安です。さらにバッチ注文は「注文本数分」を消費します。20本のバッチ発注は20件分の消費です。グリッドbotのように大量の注文を出し入れする設計では、この計算を先にやってください(記事05・記事16)。

2. 「タイムアウトした注文」は通っている可能性がある

ネットワークタイムアウトや retCode 10016(サーバー内部エラー)が返ったとき、注文は通っているかもしれません。素朴にリトライすると二重発注になります。必ず orderLinkId を付けておき、例外時は get_order_history(orderLinkId=...) で実際の状態を照会してから判断してください。この設計は記事17で本格的に扱います。

3. 発注前に「今の自分のポジション」を信じない

ローカルに持っているポジション情報は、必ず取引所側と乖離します(イベント欠損、bot再起動、手動介入、清算)。発注の直前に get_positions() で確認する、あるいはWebSocketで同期しつつ定期的にRESTで突き合わせる(記事20・27)設計にしてください。

pre_check_orderという選択肢

pybit 5.17.0 には pre_check_order()(POST /v5/order/pre-check)が含まれており、実際に発注せずに証拠金への影響を事前検証できます。大きなロットを扱う場合や、複数注文の合算影響を確認したい場合に有用です。パラメータは公式ドキュメントで確認してください。

参考リンク(一次情報)

よくある疑問Q&A

qtyを数値型(float)で渡すとどうなりますか?
retCode 10001(パラメータエラー)になることがあります。また通ったとしても、0.1 + 0.2 = 0.30000000000000004 のような浮動小数点誤差がそのまま文字列化され、qtyStep の倍数から外れて拒否されます。価格・数量は Decimal で計算し、str() で文字列化して渡すのが唯一正しい方法です。
成行注文にpriceを付けたらどうなりますか?
無視されます。エラーにならないため、「価格を指定したつもりの成行」が意図せず通ってしまいます。これは危険なので、成行を出すときは price を渡さないコードにしてください。価格上限を付けたいなら成行ではなくIOC指値(サンプル5)を使います。
PostOnlyで出したのにテイカー手数料になりました
PostOnlyでテイカーになることはありません。発生しうるのは、PostOnlyの注文が拒否されたことに気づかず、フォールバックの成行が実行されていたケースです。retCode と rejectReason を必ずログに残し、拒否と約定を区別してください。get_executions() の isMaker フィールドで実際にメイカーだったか確認できます(記事18)。
1つの注文で複数回に分かれて約定した場合、平均価格はどう取りますか?
get_order_history() の avgPrice が加重平均約定価格、cumExecQty が累計約定数量、cumExecFee が累計手数料です。約定1件ずつの明細が必要なら get_executions() を使います。執行品質を評価するなら明細単位で見るほうが情報量が多く、Maker/Takerの内訳も分かります(記事18)。
両建て(ヘッジモード)で発注が通りません
ヘッジモードでは positionIdx の指定が必須です(1=Buy側、2=Sell側)。ワンウェイモードの 0 を送ると拒否されます。逆に、ワンウェイモードで1や2を送っても拒否されます。現在のモードは get_positions() のレスポンスの positionIdx から判別できます(記事20)。
注文を出した直後にget_positionsを呼んでもポジションが増えていません
発注の受理と、ポジションへの反映には時間差があります。成行でも即座に反映されるとは限りません。ポジションの反映を待ってから次の処理に進むような同期的なコードは書かず、WebSocketの position / execution トピックでイベントを受けて進める設計(記事27・記事30)にしてください。どうしてもRESTで待つ場合は、リトライ上限とタイムアウトを設けたポーリングにします。
cancel_all_ordersは条件注文やTP/SLも消えますか?
orderFilter の指定によります。無指定の場合の挙動はcategoryごとに異なるため、条件注文だけ・TP/SLだけを消したいときは orderFilter(Order / StopOrder / tpslOrder)を明示してください。緊急停止で「全部消す」意図なら、orderFilter を変えて複数回呼ぶのが確実です(記事24)。

注意事項・免責事項

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

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

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

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

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

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

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