※ Bybit V5は機能追加のペースが速いAPIです。パラメータの最終的な正解は必ず公式ドキュメントで確認してください。本記事のコードはすべてテストネットで動作確認しています。
この記事は記事01(pybit入門)で作った config.py / client.py / symbol_rules.py を再利用します。まだ手元にない場合は先にそちらを済ませてください。
結論から出します。テストネットで最小ロットの成行買いを出すコードです。
# 本コードは本人利用に限り自由に使用可。再配布・商用利用・販売は禁止。
# 必ず 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 だけで、約定価格は返りません。ここが重要です。
retCode=0 は「注文が受理された」という意味であって、約定したという意味ではありません。成行でも即時約定が保証されるわけではなく、指値やPostOnlyなら当然約定していません。約定価格・約定数量を知るには、get_order_history() で照会するか、WebSocketの execution トピックを購読する必要があります(記事27)。
「発注APIが成功したから約定したはず」という前提でロジックを書くと、ポジションが無いのに有ると思い込んだbotが動き続けます。これは自動売買で最も頻度の高い設計ミスです。
bot実装で判断が必要なものに絞って整理します。網羅表は公式のPlace Orderを参照してください。
| パラメータ | 値 | 注意点 |
|---|---|---|
| category | linear / inverse / spot / option | これを間違えると「シンボルが存在しない」系のエラーになる |
| symbol | BTCUSDT | 大文字のみ。小文字だとエラー |
| side | Buy / Sell | 大文字小文字を含めて完全一致が必要 |
| orderType | Market / Limit | 条件注文は別パラメータ(triggerPrice)で表現する |
| qty | "0.001" | 文字列。qtyStep の倍数でなければ拒否される |
| パラメータ | 既定値 | 使う場面 |
|---|---|---|
| price | — | 指値で必須。成行では無視される |
| timeInForce | Limit→GTC Market→IOC | メイカー狙いなら PostOnly。次章で詳述 |
| orderLinkId | 自動生成 | 必ず自分で付ける。冪等化の鍵(記事17)。最大36文字・ユニーク |
| reduceOnly | false | 決済専用注文。誤って新規建てするのを防ぐ |
| positionIdx | 0 | ヘッジモードでは必須(1=Buy側 / 2=Sell側)。記事20 |
| takeProfit stopLoss | — | 発注と同時にTP/SLを付ける。記事14 |
| triggerPrice | — | 指定すると条件注文になる。記事15 |
| marketUnit | baseCoin | 現物の成行のみ。USDT額で買いたいなら quoteCoin |
| isLeverage | 0 | 現物で証拠金取引(借入)を使う場合に 1 |
| slippageToleranceType slippageTolerance | — | 成行の許容スリッページ。TickSize / Percent |
| closeOnTrigger | false | 証拠金不足時でも決済を通したい場合 |
無期限・先物は1銘柄あたり有効注文500件(超過すると retCode 110020)、条件注文は別枠で上限があり(110009)、Partialモードの TP/SL は20件まで(110061)です。現物は条件注文・TP/SL・通常注文の合計で500件(170810)。グリッドbotのように多数の指値を置く場合はこの制約に必ず当たるので、設計段階で本数を見積もってください(記事36)。
timeInForce は「約定しなかった分をどう扱うか」を決めるパラメータです。手数料と約定率のトレードオフに直結するため、戦略ごとに意識して選ぶ必要があります。
| TIF | 挙動 | 手数料 | 使う戦略 |
|---|---|---|---|
| GTC | キャンセルするまで板に残る | メイカー (即時約定時はテイカー) |
通常の指値エントリー |
| IOC | 即時約定できる分だけ約定し、残りは即キャンセル | テイカー | 確実に入りたいが価格上限は決めたい(成行の安全版) |
| FOK | 全量約定できなければ全部キャンセル | テイカー | 部分約定が許されない裁定・両建ての片脚 |
| PostOnly | 板に乗るなら通す。即時約定してしまう価格なら注文自体が拒否 | メイカー確定 | グリッド・マーケットメイク・コスト重視の執行 |
| RPI | 個人投資家向け価格改善の指定 | メイカー | 承認されたマーケットメイカー限定。一般利用は不可 |
Enumに RPI が載っているため使えそうに見えますが、実際に投げると retCode 110119(無期限・先物)/ 170355(現物)「RPI orders restricted to approved market makers」で拒否されます。RPIはBybitに承認されたマーケットメイカー向けの機能です。一般の個人botでメイカー手数料を狙う場合は PostOnly を使ってください。
PostOnlyの最大の注意点は、拒否がエラーではなく正常動作であることです。板に乗らない価格を出せば注文は成立しません。価格を1ティックずらして再試行するループが必要です。
"""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つのどれかです。発注ラッパでまとめて検証してしまえば、以後この種のエラーはほぼ消えます。
価格は tickSize、数量は qtyStep の整数倍でなければ拒否されます。float 演算の誤差でズレるため Decimal で計算します。数量は切り捨て(過大なポジションを避ける)、価格は四捨五入が基本です。
minOrderQty を下回れば拒否(数量が丸めでゼロになると 110017)、想定元本が minNotionalValue 未満でも拒否されます(110094)。小さすぎる注文はエラーになる前に自分で弾くのが正しい設計です。
Bybitは現在価格から大きく離れた指値を拒否します(110003 / 110120 / 110121)。get_price_limit() で上下限を取得できます。バグで桁を間違えた注文をここで止められます。
上の3検証と、orderLinkId の自動付与、retCodeの例外化をまとめた発注ラッパを作ります。以降の記事のbotはすべてこのクラス経由で発注します。
"""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)。
現物(category="spot")の成行注文には、無期限先物にはない仕様があります。買いと売りで qty の意味が変わるのです。
| marketUnit | qtyの意味 | 用途 |
|---|---|---|
| baseCoin (既定) | 基軸通貨の数量(例: 0.001 BTC) | 「0.001 BTC買う」 |
| quoteCoin | 決済通貨の金額(例: 100 USDT) | 「100 USDT分買う」=積立・DCAはこちら |
"""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 を指定せず(既定は baseCoin)に qty="100" を送ると、100 USDTではなく100 BTCの注文として解釈されます。実際には残高不足で弾かれることが多いですが、安価なアルトコインではそのまま通ってしまいます。現物の成行を扱うコードでは marketUnit を必ず明示してください。
また現物で借入を使う(証拠金取引)場合は isLeverage=1 が必要です。既定の 0 は自己資金のみの取引です。意図せず1にすると借入が発生し、金利と清算リスクを負います。
成行注文は「いくらでも良いから約定させる」注文です。流動性の薄い銘柄や急変時には、想定から大きく離れた価格で約定します。V5には許容スリッページを指定して、超えたら約定させない仕組みがあります。
"""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")))
slippageTolerance はBybit固有のパラメータです。IOC指値で上限価格を自分で計算する方式なら、ロジックが取引所に依存せず、バックテスト(記事31)でも同じモデルで再現できます。「成行を使わず、IOC指値で価格上限を明示する」のは執行品質を管理するうえで実務的に有効な習慣です。
指値を価格に追従させたいとき、キャンセルしてから再発注するのは損です。往復2リクエストでレート制限を2回消費し、その隙間に板が動きます。amend_order なら1リクエストで、しかも板の順番(キュープライオリティ)を保てる場合があります。
"""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 — 一定時間で未約定注文を自動キャンセルする(注文の寿命管理)
本コードは本人利用に限り自由に使用可。再配布・商用利用・販売は禁止。
"""
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}件キャンセルしました")
キャンセルしようとした瞬間に約定していれば、110001(注文が存在しない)や110008(完了済み)が返ります。これは競合状態として正常で、リトライしても意味がありません。上のコードのようにこれらのコードは黙って無視し、それ以外だけを警告するのが正しい扱いです。エラーコードの体系的な分類は記事06で扱います。
決済のつもりで出した注文が、数量を間違えて逆方向の新規ポジションを建てるのは典型的な事故です。reduceOnly=True を付ければ、ポジションを減らす方向にしか作用しません。
"""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 の指定が必須なので、上のコードのように保有ポジションから引き継いでください。
「PostOnlyのほうが手数料が安いから得」とは限りません。約定しないことによる機会損失が手数料差を上回ることがあります。これは銘柄・時間帯・戦略の性質で変わるため、自分の環境で測るしかありません。
"""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は設計段階で本数を制限 |
| 110072 | orderLinkIdの重複 | 冪等性が効いた証拠。同じ注文の二重送信を防げている(記事17) |
| 110001 110008 / 110010 | 注文が存在しない/完了済み | キャンセル・修正時の競合状態。正常として無視する |
| 110043 | レバレッジ変更なし | 既に同じ値。実質成功として無視(記事21) |
| 110119 170355 | RPIは承認MM限定 | timeInForce="RPI" は一般利用不可。PostOnly を使う |
| 110064 170313 | 約定済み数量より少ない修正 | 部分約定済みの注文を縮小しようとしている。残数量を確認してから修正 |
| 110023 | 縮小のみ可能 | リスクリミットや上場終了前などで新規建てが制限されている |
| 170136 | 現物の数量が下限未満 | 現物は basePrecision / minOrderAmt で条件が異なる |
発注系のレート上限はlinear / spotが毎秒20件、inverseが毎秒10件(Amend Orderは毎秒10件)が目安です。さらにバッチ注文は「注文本数分」を消費します。20本のバッチ発注は20件分の消費です。グリッドbotのように大量の注文を出し入れする設計では、この計算を先にやってください(記事05・記事16)。
ネットワークタイムアウトや retCode 10016(サーバー内部エラー)が返ったとき、注文は通っているかもしれません。素朴にリトライすると二重発注になります。必ず orderLinkId を付けておき、例外時は get_order_history(orderLinkId=...) で実際の状態を照会してから判断してください。この設計は記事17で本格的に扱います。
ローカルに持っているポジション情報は、必ず取引所側と乖離します(イベント欠損、bot再起動、手動介入、清算)。発注の直前に get_positions() で確認する、あるいはWebSocketで同期しつつ定期的にRESTで突き合わせる(記事20・27)設計にしてください。
pybit 5.17.0 には pre_check_order()(POST /v5/order/pre-check)が含まれており、実際に発注せずに証拠金への影響を事前検証できます。大きなロットを扱う場合や、複数注文の合算影響を確認したい場合に有用です。パラメータは公式ドキュメントで確認してください。
0.1 + 0.2 = 0.30000000000000004 のような浮動小数点誤差がそのまま文字列化され、qtyStep の倍数から外れて拒否されます。価格・数量は Decimal で計算し、str() で文字列化して渡すのが唯一正しい方法です。price を渡さないコードにしてください。価格上限を付けたいなら成行ではなくIOC指値(サンプル5)を使います。retCode と rejectReason を必ずログに残し、拒否と約定を区別してください。get_executions() の isMaker フィールドで実際にメイカーだったか確認できます(記事18)。get_order_history() の avgPrice が加重平均約定価格、cumExecQty が累計約定数量、cumExecFee が累計手数料です。約定1件ずつの明細が必要なら get_executions() を使います。執行品質を評価するなら明細単位で見るほうが情報量が多く、Maker/Takerの内訳も分かります(記事18)。positionIdx の指定が必須です(1=Buy側、2=Sell側)。ワンウェイモードの 0 を送ると拒否されます。逆に、ワンウェイモードで1や2を送っても拒否されます。現在のモードは get_positions() のレスポンスの positionIdx から判別できます(記事20)。position / execution トピックでイベントを受けて進める設計(記事27・記事30)にしてください。どうしてもRESTで待つ場合は、リトライ上限とタイムアウトを設けたポーリングにします。orderFilter の指定によります。無指定の場合の挙動はcategoryごとに異なるため、条件注文だけ・TP/SLだけを消したいときは orderFilter(Order / StopOrder / tpslOrder)を明示してください。緊急停止で「全部消す」意図なら、orderFilter を変えて複数回呼ぶのが確実です(記事24)。投資は自己判断・自己責任でお願いいたします。本ブログの情報・ツール・ソフトウェアを参考・使用して、取引をされて何らかの損失を被られても、当サイトでは責任は一切負いません(間接的・付随的な障害・損失も含め一切の責任を負いません)。自己責任でご利用ください。
当サイトからダウンロードしたプログラム・EAは本人利用に限り完全無料でご利用いただけます。プログラムの全てまたは一部の複製・コピー・リバースエンジニアリング・改変・2次配布・商用利用・転売・販売等は一切禁止いたします。違反を発見した場合は法的処置を行います。
当サイト上に存在するソフトウェア・ツール・文章・画像・動画等の著作物を無断転載することを禁止します。引用の範囲を超えるものについては、法的処置を行います。
当サイトで配布するツール・ソフトウェアは無料提供ですのでサポートなどは行っておりません(逆に丁寧にサポートしているサイトはほとんど違法サイトですのでご注意ください。投資助言・代理業登録なしでサポート・助言することは法律で禁止されています)。万が一、ツールやソフトウェアに不具合や問題があっても当サイトでは修正・対応などができない場合があります。ご利用の環境でトラブルが発生しても当サイトでは助言することはできませんので自己責任でご利用ください。検索するなどして自分で解決してください。
当サイトでは取引所・証券会社へのリンクを貼っていますが、これは情報として提供するものであり、勧誘行為などは一切行っておりません。そのため、取引所・証券会社の登録方法・使い方・オススメ金融機関・オススメ金融商品などを説明した記事はサイト内にありませんので、ご自身で調べた上で判断ください(本当はサイト内で丁寧に説明したいのですが、仲介・取次ぎにあたる可能性があり第一種金融商品取引業登録を行っていない場合、違法となる可能性があるため出来ません。ご容赦ください)。
当サイトからリンクやバナーなどによって他のサイトに移動された場合、移動先サイトで提供される情報、サービス等について一切の責任を負いません。当サイト掲載情報やリンク先の掲載情報の合法性、正確性、道徳性、最新性、適切性、著作権の許諾や有無など、その内容については一切の保証を致しかねます。
当サイトは、ご訪問いただいた時間、訪問者のアクセス元のホスト名、IPアドレス、OSとブラウザのバージョン、リンク元のURL、モニターの情報等のパソコン環境情報を収集し、アクセス解析をしております。この目的は訪問者が検索サイトからご訪問いただいた場合、どのような検索で当サイトに来訪いただいたかの解析にのみ使用しており、第3者に公開はいたしません。また、個人を特定するものではありません。