※ Bybit V5は機能追加のペースが速いAPIです。パラメータの最終的な正解は必ず公式ドキュメントで確認してください。本記事のコードはすべてテストネットで動作確認しています。
当サイトではMT4/MT5のEA(Expert Advisor)を配布していますが、仮想通貨の自動売買では取引所APIを直接叩く方式が主流です。両者の違いを整理しておきます。
| 観点 | MT4/MT5 + EA(MQL) | Bybit V5 API + Python |
|---|---|---|
| 言語 | MQL4 / MQL5(C++系) | Python(pandas・scipyがそのまま使える) |
| 実行環境 | Windows + MT4/MT5の常時起動が必須 | Linux VPSでプロセスを回すだけ |
| データ取得 | ブローカー配信の足に依存 | 板・ティック・OI・資金調達率まで取得可 |
| 統計・機械学習 | 実質困難(外部DLL連携が必要) | statsmodels / scikit-learn を直接利用 |
| リアルタイム受信 | OnTick()イベント | WebSocketで板差分・約定を購読 |
| 難易度 | EAを入れるだけなら簡単 | 注文管理・例外処理を自分で書く必要あり |
Pythonの自由度は圧倒的ですが、代わりに「MT4が面倒を見てくれていた部分」を全部自分で書く必要があります。具体的には、通信エラーのリトライ、二重発注の防止、レート制限の遵守、bot再起動時のポジション同期などです。このシリーズはそこを1本ずつ潰していく構成になっています。
Bybit V5は、以前はバラバラだった現物・デリバティブ・オプションのAPIを1仕様に統合したものです。実装上は category というパラメータ1つで対象商品を切り替えます。
| category | 対象 | 代表シンボル | 数量の単位 |
|---|---|---|---|
| linear | USDT/USDC無期限・USDT先物 | BTCUSDT | BTC(基軸通貨) |
| inverse | コイン建て無期限・先物 | BTCUSD | USD(枚数) |
| spot | 現物 | BTCUSDT | BTC または USDT |
| option | オプション | BTC-31JUL26-90000-C | コンタクト数 |
category の指定を間違えると「シンボルが存在しない」という一見わけのわからないエラーになります。BTCUSDT は linear と spot の両方に存在し、同じシンボル名で別の商品です。この設計は記事03で徹底的に扱います。
pybit 5.17.0 はPython 3.10以上が必要です。まずプロジェクトディレクトリを作り、仮想環境を切ります。
mkdir bybit-bot
cd bybit-bot
# 仮想環境の作成
python -m venv .venv
# 有効化(Windows PowerShell)
.venv\Scripts\Activate.ps1
# 有効化(macOS / Linux)
source .venv/bin/activate
python --version # 3.10 以上であることを確認
pip install -U pip
pip install -r requirements.txt
# Bybit公式Python SDK(HTTP + WebSocketの両方を含む)
pybit>=5.17.0
# データ処理
pandas>=2.2
pyarrow>=17.0 # Parquet保存用(記事07で使用)
# 設定・通信
python-dotenv>=1.0 # .envからAPIキーを読む
requests>=2.32 # 署名を自作する場合(サンプル10)
# 開発補助
rich>=13.7 # CLIでの表示を見やすくする
pybit は Bybit の従業員が保守している公式SDKで、V5のRESTとWebSocketの両方をカバーしています。ccxt という汎用ライブラリもありますが、V5固有のパラメータ(tpslMode、positionIdx、orderFilter など)を細かく制御したい場合は pybit か自作クライアントのほうが素直です。
APIキーをGitHubに push する事故は本当によく起きます。コードを1行も書く前に .gitignore に .venv/ と .env を書いてください。公開リポジトリに漏れたキーは、数分で自動収集ボットに拾われます。
Bybitの公開エンドポイント(Market系)はAPIキー不要です。まずキーの発行を後回しにして、通信できることを確認しましょう。
# 本コードは本人利用に限り自由に使用可。再配布・商用利用・販売は禁止。
from pybit.unified_trading import HTTP
session = HTTP(testnet=True) # Market系エンドポイントはAPIキー不要
res = session.get_tickers(category="linear", symbol="BTCUSDT")
t = res["result"]["list"][0]
print(f"シンボル : {t['symbol']}")
print(f"最終価格 : {t['lastPrice']}")
print(f"24h変化率 : {float(t['price24hPcnt']) * 100:+.2f} %")
print(f"資金調達率 : {float(t['fundingRate']) * 100:+.4f} %")
print(f"未決済建玉 : {t['openInterest']}")
シンボル : BTCUSDT
最終価格 : 118342.50
24h変化率 : +1.83 %
資金調達率 : +0.0100 %
未決済建玉 : 58234.117
これが動けばネットワーク面は問題ありません。ここから先、残高照会・発注・ポジション取得にはAPIキーと署名が必要になります。
Bybitのアカウント設定からAPIキーを発行します。キーには2種類あります。
| 方式 | 秘密鍵の管理 | 署名アルゴリズム | 向いている用途 |
|---|---|---|---|
| システム生成 | Bybitが生成し、発行時に1回だけ表示 | HMAC-SHA256 | 個人bot(本記事はこちら) |
| 自己生成(RSA) | 自分でRSA鍵ペアを作り公開鍵だけ登録。秘密鍵はBybitに渡らない | RSA-SHA256 | 秘密鍵を外部に出したくない運用 |
ここが最重要です。1つのキーに全権限を付けるのは設計ミスです。用途別に分けます。
| キーの用途 | Read | Trade(発注) | Transfer(振替) | Withdrawal(出金) |
|---|---|---|---|---|
| データ収集・分析 | ○ | × | × | × |
| 売買bot | ○ | ○ | × | × |
| 資金配分bot | ○ | × | ○ | × |
| 監視ダッシュボード | ○ | × | × | × |
bot用のAPIキーに出金権限を付ける理由はひとつもありません。VPSが侵害されても、ソースをGitHubに誤commitしても、出金権限がなければ資金は外部へ出せません(最悪でも「変な取引をされる」で止まります)。出金権限が付いたキーの漏洩は、取り返しがつきません。
同様に、バックテストやデータ収集用のスクリプトにはRead-Onlyキーを渡してください。売買権限のあるキーを分析用Jupyterノートブックで使うと、セルの誤実行で発注してしまいます。
APIキーには接続元IPアドレスの制限を掛けられます。VPSで運用する場合はVPSのグローバルIPを登録してください。キーが漏れても、そのIP以外からは使えません。
なお、IPアドレスを紐付けていないキーには有効期限が設定される仕様になっています。期限切れに気づかずbotが止まる事故を避けるため、発行時に管理画面で有効期限を確認し、カレンダーに更新日を入れておくことを強く推奨します(期限のルールは変更される可能性があるため、必ず管理画面の表示を確認してください)。
.gitignore に .env を追加したBybitには本番以外に2種類の練習環境があり、これを混同すると「キーが通らない」「板が薄すぎて検証にならない」といった問題にぶつかります。
| 環境 | REST ベースURL | アカウント | 板・価格 | 向いている用途 |
|---|---|---|---|---|
| mainnet | api.bybit.com (別名 api.bytick.com) |
本番 | 本物 | 本番運用 |
| testnet | api-testnet.bybit.com | testnet専用に別途登録 | 薄い・価格が乖離 | APIの疎通・パラメータ確認 |
| demo trading | api-demo.bybit.com | 本番アカウント内の別UID | 本番と同じ | 戦略ロジックの実戦テスト |
使い分けの結論はこうです。
demo trading のキーは、本番アカウントにログインした状態でデモトレード画面に切り替えてから発行します(デモは独立したUIDを持ちます)。本番キーを api-demo.bybit.com に投げても通りませんし、逆も同様です。また、testnetサイト側でデモトレードのキーを作る意味はないため、Bybitのドキュメントでも非推奨とされています。
WebSocketも環境ごとに別で、demoのprivateストリームは wss://stream-demo.bybit.com、公開データは本番の wss://stream.bybit.com を使います(記事26・27で扱います)。
なお日本国内向けには api.manepa.jp のような地域別ドメインも用意されています。自分のアカウントがどのエンティティに属しているかで接続先が変わる場合があるため、キーが正しいのに認証エラーが出るときは接続先ドメインを疑ってください。
デモ口座への資金投入もAPIから行えます。1リクエストあたりの上限は BTC 15 / ETH 200 / USDT・USDC 各 100,000 です。
# demo trading 口座に仮想資金を投入する(api-demo.bybit.com 専用)
from pybit.unified_trading import HTTP
session = HTTP(demo=True, api_key="DEMO_KEY", api_secret="DEMO_SECRET")
res = session._submit_request(
method="POST",
path=f"{session.endpoint}/v5/account/demo-apply-money",
query={"adjustType": 0, "utaDemoApplyMoney": [{"coin": "USDT", "amountStr": "100000"}]},
auth=True,
)
print(res["retCode"], res["retMsg"])
pybit に専用メソッドが用意されていない場合は上記のように内部の _submit_request() を使えますが、私有メソッドに依存するのは望ましくありません。サンプル10の自作クライアントを使えばこうした未実装エンドポイントも素直に叩けます。
環境ごとにキーが違うので、.env にプレフィックス付きで並べて、コードから環境名で引く形にします。
# 既定の接続先: testnet / demo / mainnet
BYBIT_ENV=testnet
# testnet.bybit.com で発行したキー
BYBIT_TESTNET_API_KEY=xxxxxxxxxxxxxxxxxxxx
BYBIT_TESTNET_API_SECRET=xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx
# デモトレード画面で発行したキー
BYBIT_DEMO_API_KEY=
BYBIT_DEMO_API_SECRET=
# 本番キー(出金権限なし・IP制限あり)
BYBIT_MAINNET_API_KEY=
BYBIT_MAINNET_API_SECRET=
"""config.py — 環境ごとのAPIキーを型安全に読み込む
本コードは本人利用に限り自由に使用可。再配布・商用利用・販売は禁止。
"""
from __future__ import annotations
import os
from dataclasses import dataclass
from enum import Enum
from pathlib import Path
from dotenv import load_dotenv
load_dotenv(Path(__file__).with_name(".env"))
class Env(str, Enum):
TESTNET = "testnet"
DEMO = "demo"
MAINNET = "mainnet"
_PREFIX = {
Env.TESTNET: "BYBIT_TESTNET",
Env.DEMO: "BYBIT_DEMO",
Env.MAINNET: "BYBIT_MAINNET",
}
# pybit に渡すフラグと、自作クライアント用のベースURL
REST_BASE = {
Env.TESTNET: "https://api-testnet.bybit.com",
Env.DEMO: "https://api-demo.bybit.com",
Env.MAINNET: "https://api.bybit.com",
}
@dataclass(frozen=True)
class Credentials:
env: Env
api_key: str
api_secret: str
@property
def testnet(self) -> bool:
return self.env is Env.TESTNET
@property
def demo(self) -> bool:
return self.env is Env.DEMO
@property
def base_url(self) -> str:
return REST_BASE[self.env]
def masked(self) -> str:
"""ログ出力用。キーの先頭4文字だけ見せる。"""
return f"{self.env.value}:{self.api_key[:4]}****"
def load_credentials(env: str | None = None) -> Credentials:
resolved = Env(env or os.getenv("BYBIT_ENV", "testnet"))
prefix = _PREFIX[resolved]
key = os.getenv(f"{prefix}_API_KEY", "").strip()
secret = os.getenv(f"{prefix}_API_SECRET", "").strip()
if not key or not secret:
raise RuntimeError(
f"{prefix}_API_KEY / {prefix}_API_SECRET が .env に設定されていません "
f"(BYBIT_ENV={resolved.value})"
)
return Credentials(env=resolved, api_key=key, api_secret=secret)
masked() を用意しているのは、ログにAPIキーを平文で出さないためです。障害調査時に「どのキーで動いていたか」は知りたいが、キー本体はログに残したくない。この癖は最初から付けておくと後で助かります(監視・ロギングの設計は記事39)。
Credentials から pybit の HTTP セッションを組み立てるファクトリを作ります。本番接続時だけ明示的な確認を要求するガードも入れておきます。
"""client.py — pybitセッションのファクトリと本番誤爆ガード
本コードは本人利用に限り自由に使用可。再配布・商用利用・販売は禁止。
"""
from __future__ import annotations
import os
from dataclasses import dataclass
from pybit.unified_trading import HTTP
from config import Credentials, Env, load_credentials
@dataclass(frozen=True)
class BybitClient:
"""環境情報を保持したまま持ち回せるクライアント。"""
env: Env
http: HTTP
cred: Credentials
@property
def is_live(self) -> bool:
return self.env is Env.MAINNET
def make_client(env: str | None = None, *, allow_mainnet: bool = False) -> BybitClient:
cred = load_credentials(env)
# --- 本番誤爆ガード -------------------------------------------------
# 引数で明示許可し、かつ環境変数 BYBIT_ALLOW_LIVE=1 が立っていないと
# mainnetには接続させない。二重の鍵をかけておく。
if cred.env is Env.MAINNET:
if not allow_mainnet:
raise PermissionError(
"mainnet接続には make_client(..., allow_mainnet=True) が必要です"
)
if os.getenv("BYBIT_ALLOW_LIVE") != "1":
raise PermissionError(
"mainnet接続には環境変数 BYBIT_ALLOW_LIVE=1 が必要です"
)
# -------------------------------------------------------------------
http = HTTP(
testnet=cred.testnet,
demo=cred.demo,
api_key=cred.api_key,
api_secret=cred.api_secret,
recv_window=5000, # 署名の有効時間(ms)。時計がズレると10002が出る
timeout=10, # 秒
max_retries=3, # pybit内蔵のリトライ回数
retry_delay=3, # 秒
log_requests=False, # デバッグ時のみTrue(キーはログに出ない)
)
return BybitClient(env=cred.env, http=http, cred=cred)
if __name__ == "__main__":
c = make_client()
print(f"接続先: {c.cred.masked()} endpoint={c.http.endpoint}")
BYBIT_ENV=mainnet を .env に書いたまま検証スクリプトを実行して本番に発注してしまう、というのは実際に頻発する事故です。「コード側の明示フラグ」と「環境変数」の両方が揃わないと本番に繋がらない設計にしておけば、片方を忘れても事故になりません。これはコストゼロの保険です。
認証付きリクエストを初めて投げる前に、ローカル時計とBybitサーバー時刻のズレを測ってください。V5の署名にはタイムスタンプが含まれ、recv_window(既定5000ms)を超えてズレているとすべてのリクエストが retCode=10002 で落ちます。「キーは正しいのに認証が通らない」の犯人は、ほぼこれです。
"""check_connection.py — 疎通確認・時刻ズレ検出・権限確認
本コードは本人利用に限り自由に使用可。再配布・商用利用・販売は禁止。
"""
from __future__ import annotations
import time
from pybit.unified_trading import HTTP
from client import make_client
RECV_WINDOW_MS = 5000
def check_clock_skew(http: HTTP, samples: int = 5) -> float:
"""サーバー時刻とローカル時刻の差(ms)を返す。正なら自分の時計が遅れている。"""
skews, rtts = [], []
for _ in range(samples):
t0 = time.time()
res = http.get_server_time()
t1 = time.time()
# timeNano(ns) のほうが精度が高い
server_ms = int(res["result"]["timeNano"]) / 1_000_000
local_ms = (t0 + t1) / 2 * 1000 # 往復の中点をローカル時刻とみなす
skews.append(server_ms - local_ms)
rtts.append((t1 - t0) * 1000)
time.sleep(0.2)
skew = sorted(skews)[len(skews) // 2] # 中央値でRTTのばらつきを吸収
rtt = sorted(rtts)[len(rtts) // 2]
print(f" RTT(中央値) : {rtt:8.1f} ms")
print(f" 時刻ズレ : {skew:+8.1f} ms")
if abs(skew) > RECV_WINDOW_MS * 0.5:
print(" [警告] ズレが大きすぎます。retCode=10002 の原因になります。")
print(" Windows : w32tm /resync")
print(" Linux : sudo chronyc makestep")
return skew
def check_permissions(http: HTTP) -> None:
"""キーの権限・有効期限・IP制限を確認する。"""
res = http.get_api_key_information()
info = res["result"]
print(f" キー種別 : {'RSA' if info.get('type') == 2 else 'HMAC'}")
print(f" 有効期限 : {info.get('expiredAt') or '(無期限)'}")
print(f" IP制限 : {info.get('ips') or '(なし)'}")
print(f" 読み取り専用 : {'はい' if info.get('readOnly') == 1 else 'いいえ'}")
perms = {k: v for k, v in (info.get("permissions") or {}).items() if v}
print(f" 権限 : {perms}")
def main() -> None:
c = make_client()
print(f"[1] 接続先 {c.cred.masked()} {c.http.endpoint}")
print("[2] 時刻同期チェック")
check_clock_skew(c.http)
print("[3] APIキー権限チェック")
check_permissions(c.http)
print("[4] 残高取得(認証テスト)")
res = c.http.get_wallet_balance(accountType="UNIFIED")
print(f" retCode={res['retCode']} retMsg={res['retMsg']}")
print(" OK: 認証付きリクエストが通りました")
if __name__ == "__main__":
main()
[1] 接続先 testnet:AbCd**** https://api-testnet.bybit.com
[2] 時刻同期チェック
RTT(中央値) : 182.4 ms
時刻ズレ : -12.7 ms
[3] APIキー権限チェック
キー種別 : HMAC
有効期限 : 1786579200000
IP制限 : ['203.0.113.10']
読み取り専用 : いいえ
権限 : {'Wallet': ['AccountTransfer'], 'ContractTrade': ['Order', 'Position']}
[4] 残高取得(認証テスト)
retCode=0 retMsg=OK
OK: 認証付きリクエストが通りました
クラウドVPSは時計が正確だと思われがちですが、コンテナや仮想化環境では平気で数秒ズレます。本番運用では chrony を入れて、さらに上記の check_clock_skew() を定期実行してズレをメトリクスとして監視してください(記事38・39)。
get_tickers() は symbol を省略するとそのcategoryの全銘柄を1回で返します。Bybitのlinearは数百銘柄あるため、これだけで簡易スクリーナーが作れます。
ここで重要なのは、V5のレスポンスは数値もすべて文字列で返ってくるという点です。float() 変換を忘れて文字列比較になり「価格の大小判定がおかしい」というバグは初学者の定番です。
"""screener.py — 全銘柄ティッカーをDataFrame化して流動性でスクリーニング
本コードは本人利用に限り自由に使用可。再配布・商用利用・販売は禁止。
"""
from __future__ import annotations
import pandas as pd
from pybit.unified_trading import HTTP
from client import make_client
# V5は数値も文字列で返るため、明示的に数値化する対象を列挙しておく
NUMERIC_COLS = [
"lastPrice", "indexPrice", "markPrice", "prevPrice24h", "price24hPcnt",
"highPrice24h", "lowPrice24h", "turnover24h", "volume24h",
"openInterest", "openInterestValue", "fundingRate",
"bid1Price", "bid1Size", "ask1Price", "ask1Size",
]
def fetch_tickers(http: HTTP, category: str = "linear") -> pd.DataFrame:
res = http.get_tickers(category=category)
if res["retCode"] != 0:
raise RuntimeError(f"get_tickers失敗: {res['retCode']} {res['retMsg']}")
df = pd.DataFrame(res["result"]["list"])
for col in NUMERIC_COLS:
if col in df.columns:
df[col] = pd.to_numeric(df[col], errors="coerce")
# 実効スプレッド(bps = 万分率)。執行コストの一次近似になる
df["spread_bps"] = (df["ask1Price"] - df["bid1Price"]) / df["lastPrice"] * 10_000
# 資金調達率の年率換算。※8時間ごと収受(1日3回)を仮定した概算。
# 実際の収受間隔は銘柄ごとに異なる(instruments-infoのfundingInterval)。
# 正確な計算は記事10で扱う。
df["funding_apr_pct"] = df["fundingRate"] * 3 * 365 * 100
df["chg24h_pct"] = df["price24hPcnt"] * 100
return df
def screen_usdt_perp(df: pd.DataFrame, min_turnover_usd: float = 50_000_000) -> pd.DataFrame:
"""USDT無期限のうち、24h売買代金が閾値以上の銘柄を流動性順に返す。"""
out = df[df["symbol"].str.endswith("USDT")].copy()
out = out[out["turnover24h"] >= min_turnover_usd]
out = out.sort_values("turnover24h", ascending=False)
return out[[
"symbol", "lastPrice", "chg24h_pct", "turnover24h",
"spread_bps", "funding_apr_pct", "openInterestValue",
]].reset_index(drop=True)
if __name__ == "__main__":
c = make_client()
tickers = fetch_tickers(c.http, category="linear")
print(f"取得銘柄数: {len(tickers)}")
top = screen_usdt_perp(tickers)
pd.set_option("display.width", 160)
pd.set_option("display.float_format", lambda v: f"{v:,.4f}")
print(top.head(15).to_string())
# 資金調達率が高い=ロングが過熱している銘柄(記事35のアービトラージ候補)
print("\n--- 資金調達率(年率)上位 ---")
print(top.nlargest(10, "funding_apr_pct")[
["symbol", "funding_apr_pct", "turnover24h", "spread_bps"]
].to_string())
このコードは category="spot" に変えれば現物にもそのまま使えます(ただし現物には fundingRate や openInterest が存在しないため、該当列は欠損になります)。
アルトコインの無期限先物は、ボラティリティが高い代わりにスプレッドが数十bpsに開いていることがあります。往復で0.5%取られる銘柄で数時間の値幅を狙う戦略は、バックテストで勝っていても実運用では成立しません。銘柄選定の段階で spread_bps によるフィルタを入れておくと、後段の検証が現実的になります(記事12・31)。
Bybitの統合取引口座(UTA: Unified Trading Account)では、現物・無期限・オプションで証拠金が共有されます。そのため残高レスポンスは「口座全体のサマリ」+「通貨ごとの明細」という2階層構造になっています。
"""wallet.py — 統合口座の残高とリスク指標を読む
本コードは本人利用に限り自由に使用可。再配布・商用利用・販売は禁止。
"""
from __future__ import annotations
from pybit.unified_trading import HTTP
from client import make_client
def f(value: str | None) -> float:
"""V5は「該当なし」を空文字で返すことがあるので安全に数値化する。"""
if value is None or value == "":
return 0.0
return float(value)
def show_wallet(http: HTTP, account_type: str = "UNIFIED") -> dict:
res = http.get_wallet_balance(accountType=account_type)
if res["retCode"] != 0:
raise RuntimeError(f"get_wallet_balance失敗: {res['retCode']} {res['retMsg']}")
acct = res["result"]["list"][0]
print("=== 口座サマリ (USD換算) ===")
print(f" 総資産 : {f(acct['totalEquity']):>14,.2f}")
print(f" 総証拠金残高 : {f(acct['totalMarginBalance']):>14,.2f}")
print(f" 利用可能証拠金 : {f(acct['totalAvailableBalance']):>14,.2f}")
print(f" 使用中(初期証拠金): {f(acct['totalInitialMargin']):>14,.2f}")
print(f" 維持証拠金 : {f(acct['totalMaintenanceMargin']):>14,.2f}")
print(f" 無期限の含み損益 : {f(acct['totalPerpUPL']):>+14,.2f}")
# 口座維持率。1.0(100%)に近づくと強制決済が発生する
mmr = f(acct.get("accountMMRate"))
imr = f(acct.get("accountIMRate"))
print(f" 口座維持率(MMR) : {mmr * 100:>13.3f} %")
print(f" 口座初期率(IMR) : {imr * 100:>13.3f} %")
if mmr > 0.5:
print(" [警告] 維持率が50%を超えています。清算距離が近い状態です。")
print("\n=== 通貨別明細 ===")
print(f" {'coin':<8}{'残高':>16}{'含み損益':>14}{'USD換算':>14}")
for coin in acct["coin"]:
bal = f(coin["walletBalance"])
if bal == 0 and f(coin["unrealisedPnl"]) == 0:
continue
print(
f" {coin['coin']:<8}{bal:>16,.6f}"
f"{f(coin['unrealisedPnl']):>+14,.2f}{f(coin['usdValue']):>14,.2f}"
)
return acct
if __name__ == "__main__":
c = make_client()
show_wallet(c.http)
ここで出てくる accountMMRate(口座維持率)は、リスク管理の中心になる指標です。この値が1.0に近づくと強制決済が発生します。清算価格の自前計算とストレステストは記事22で扱いますが、bot運用ではこの1つの数値を常時監視するだけでも生存率が大きく変わります。
統合口座なら UNIFIED、旧来の通常口座(未アップグレード)なら CONTRACT を指定します。自分の口座種別が不明な場合は get_account_info() で確認できます。誤った accountType を渡すとパラメータエラー(10001)になります。
発注に進む前に、絶対に避けて通れない処理があります。価格と数量の丸めです。
銘柄ごとに「価格の刻み幅(tickSize)」「数量の刻み幅(qtyStep)」「最小注文数量(minOrderQty)」が決まっており、これに合わない値を送ると発注が拒否されます。しかも float で計算すると 0.1 + 0.2 = 0.30000000000000004 のような誤差が出て、刻み幅に一致しなくなります。価格と数量は必ず Decimal で扱ってください。
"""symbol_rules.py — 銘柄仕様の取得とDecimalによる丸め
本コードは本人利用に限り自由に使用可。再配布・商用利用・販売は禁止。
"""
from __future__ import annotations
from dataclasses import dataclass
from decimal import ROUND_DOWN, ROUND_HALF_UP, Decimal
from pybit.unified_trading import HTTP
from client import make_client
@dataclass(frozen=True)
class SymbolRules:
symbol: str
category: str
tick_size: Decimal # 価格の刻み幅
qty_step: Decimal # 数量の刻み幅
min_qty: Decimal # 最小注文数量
max_qty: Decimal # 最大注文数量(成行/指値で異なる場合あり)
min_notional: Decimal # 最小注文金額
max_leverage: Decimal # 最大レバレッジ
def fetch_rules(http: HTTP, symbol: str, category: str = "linear") -> SymbolRules:
res = http.get_instruments_info(category=category, symbol=symbol)
if res["retCode"] != 0:
raise RuntimeError(f"get_instruments_info失敗: {res['retCode']} {res['retMsg']}")
items = res["result"]["list"]
if not items:
raise LookupError(f"{symbol} は category={category} に存在しません")
info = items[0]
pf = info["priceFilter"]
lf = info["lotSizeFilter"]
lev = info.get("leverageFilter", {})
return SymbolRules(
symbol=info["symbol"],
category=category,
tick_size=Decimal(pf["tickSize"]),
qty_step=Decimal(lf.get("qtyStep") or lf.get("basePrecision") or "0"),
min_qty=Decimal(lf.get("minOrderQty") or "0"),
max_qty=Decimal(lf.get("maxOrderQty") or "0"),
min_notional=Decimal(lf.get("minNotionalValue") or lf.get("minOrderAmt") or "0"),
max_leverage=Decimal(lev.get("maxLeverage") or "1"),
)
def floor_to_step(value: Decimal, step: Decimal) -> Decimal:
"""刻み幅に切り捨てで揃える。数量はこちら(過大発注を防ぐ)。"""
if step == 0:
return value
return (value / step).to_integral_value(rounding=ROUND_DOWN) * step
def round_to_step(value: Decimal, step: Decimal) -> Decimal:
"""刻み幅に四捨五入で揃える。価格はこちら。"""
if step == 0:
return value
return (value / step).to_integral_value(rounding=ROUND_HALF_UP) * step
def prepare_order(rules: SymbolRules, price: Decimal, notional_usd: Decimal
) -> tuple[Decimal, Decimal]:
"""想定金額(USD)から、発注可能な (価格, 数量) を組み立てる。"""
px = round_to_step(price, rules.tick_size)
qty = floor_to_step(notional_usd / px, rules.qty_step)
if qty < rules.min_qty:
raise ValueError(
f"数量 {qty} が最小注文数量 {rules.min_qty} を下回ります"
f"({notional_usd} USD では足りません)"
)
if rules.min_notional > 0 and px * qty < rules.min_notional:
raise ValueError(f"注文金額 {px * qty} が最小 {rules.min_notional} 未満です")
return px, qty
if __name__ == "__main__":
c = make_client()
r = fetch_rules(c.http, "BTCUSDT", "linear")
print(r)
# 現在価格の0.5%下に、想定100USDの指値を置く場合の値を作る
last = Decimal(
c.http.get_tickers(category="linear", symbol="BTCUSDT")["result"]["list"][0]["lastPrice"]
)
px, qty = prepare_order(r, last * Decimal("0.995"), Decimal("100"))
print(f"発注価格={px} 数量={qty} 想定金額={px * qty:.2f} USD")
数量を四捨五入すると、意図より大きいポジションを持つ可能性があります。特にレバレッジを掛けている場合、丸め方向の誤りはリスク量の誤りに直結します。数量は常に ROUND_DOWN(切り捨て)、価格は ROUND_HALF_UP を既定にしてください。
また spot と linear では lotSizeFilter のフィールド名が異なります(現物は basePrecision / minOrderAmt)。上のコードは両方を吸収していますが、カテゴリ別の差異は記事03で全面的に整理します。
pybitは便利ですが、以下のような場面では自分で署名を作れたほうが速いです。
asyncio / aiohttp で完全非同期にしたいV5の署名ルールはシンプルですが、GETとPOSTで署名対象が違うのがハマりどころです。
| メソッド | 署名対象文字列 |
|---|---|
| GET | timestamp + apiKey + recvWindow + queryString |
| POST | timestamp + apiKey + recvWindow + jsonBodyString |
POSTで最も多い失敗は、署名に使ったJSON文字列と実際に送信したJSON文字列が違うケースです。requests の json= 引数を使うと requests 側が独自に直列化するため、自分が json.dumps() した文字列とスペースの有無が変わり、署名が一致しません。必ず data= に同じ文字列を渡してください。
"""bybit_raw.py — pybit非依存のV5最小クライアント(HMAC-SHA256署名)
本コードは本人利用に限り自由に使用可。再配布・商用利用・販売は禁止。
"""
from __future__ import annotations
import hashlib
import hmac
import json
import time
from typing import Any
from urllib.parse import urlencode
import requests
BASE_URLS = {
"mainnet": "https://api.bybit.com",
"testnet": "https://api-testnet.bybit.com",
"demo": "https://api-demo.bybit.com",
}
class BybitAPIError(RuntimeError):
def __init__(self, ret_code: int, ret_msg: str, ext: Any = None):
super().__init__(f"retCode={ret_code} retMsg={ret_msg}")
self.ret_code = ret_code
self.ret_msg = ret_msg
self.ext = ext
class BybitRaw:
def __init__(
self,
api_key: str = "",
api_secret: str = "",
env: str = "testnet",
recv_window: int = 5000,
timeout: int = 10,
):
self.api_key = api_key
self.api_secret = api_secret
self.base = BASE_URLS[env]
self.recv_window = recv_window
self.timeout = timeout
self.session = requests.Session()
self.session.headers.update({"Referer": "codetribe"}) # 任意
# ---- 署名 --------------------------------------------------------
def _sign(self, timestamp: str, payload: str) -> str:
"""V5署名: timestamp + apiKey + recvWindow + (queryString | jsonBody)"""
message = f"{timestamp}{self.api_key}{self.recv_window}{payload}"
return hmac.new(
self.api_secret.encode("utf-8"),
message.encode("utf-8"),
hashlib.sha256,
).hexdigest()
def _auth_headers(self, payload: str) -> dict[str, str]:
ts = str(int(time.time() * 1000))
return {
"X-BAPI-API-KEY": self.api_key,
"X-BAPI-TIMESTAMP": ts,
"X-BAPI-RECV-WINDOW": str(self.recv_window),
"X-BAPI-SIGN": self._sign(ts, payload),
"X-BAPI-SIGN-TYPE": "2", # 2 = HMAC-SHA256
"Content-Type": "application/json",
}
# ---- リクエスト --------------------------------------------------
def get(self, path: str, params: dict | None = None, *, auth: bool = True) -> dict:
# 署名対象のqueryStringと、実際に送るqueryStringを同一にするため
# ここで一度だけ文字列化し、それをURLに直接連結する
query = urlencode(params or {})
headers = self._auth_headers(query) if auth else {}
url = f"{self.base}{path}" + (f"?{query}" if query else "")
r = self.session.get(url, headers=headers, timeout=self.timeout)
return self._unwrap(r)
def post(self, path: str, body: dict | None = None) -> dict:
# 署名した文字列と送信する文字列を完全に一致させる。
# requestsのjson=は独自に直列化するので使わない(署名不一致の原因)。
payload = json.dumps(body or {}, separators=(",", ":"))
headers = self._auth_headers(payload)
r = self.session.post(
f"{self.base}{path}", data=payload, headers=headers, timeout=self.timeout
)
return self._unwrap(r)
# ---- レスポンス処理 ----------------------------------------------
@staticmethod
def _unwrap(r: requests.Response) -> dict:
# レート制限の残量はヘッダで返る(記事05で活用)
limit_status = r.headers.get("X-Bapi-Limit-Status")
if limit_status is not None and int(limit_status) < 3:
print(f"[warn] レート残量わずか: {limit_status}")
if r.status_code == 403:
raise BybitAPIError(-403, "IP単位のレート制限に到達(約10分の制限)")
r.raise_for_status()
data = r.json()
if data.get("retCode") != 0:
raise BybitAPIError(data.get("retCode"), data.get("retMsg", ""),
data.get("retExtInfo"))
return data.get("result") or {}
if __name__ == "__main__":
from config import load_credentials
cred = load_credentials()
api = BybitRaw(cred.api_key, cred.api_secret, env=cred.env.value)
# 1) 公開エンドポイント(認証なし)
srv = api.get("/v5/market/time", auth=False)
print("server time:", srv["timeSecond"])
# 2) 認証付きGET
wallet = api.get("/v5/account/wallet-balance", {"accountType": "UNIFIED"})
print("総資産:", wallet["list"][0]["totalEquity"])
# 3) 認証付きPOST(テストネットでのみ実行してください)
# order = api.post("/v5/order/create", {
# "category": "linear", "symbol": "BTCUSDT", "side": "Buy",
# "orderType": "Limit", "qty": "0.001", "price": "50000",
# "timeInForce": "PostOnly",
# })
# print("orderId:", order["orderId"])
retCode=10004(sign error)は情報が少なく、原因の切り分けが難しいエラーです。署名対象文字列をそのままダンプするのが最短ルートです。
"""debug_sign.py — 署名エラー(10004)の切り分けヘルパ
本コードは本人利用に限り自由に使用可。再配布・商用利用・販売は禁止。
"""
from __future__ import annotations
import hashlib
import hmac
import json
import time
from urllib.parse import urlencode
def dump_signature(api_key: str, api_secret: str, *,
method: str = "GET",
params: dict | None = None,
recv_window: int = 5000) -> None:
ts = str(int(time.time() * 1000))
if method.upper() == "GET":
payload = urlencode(params or {})
else:
payload = json.dumps(params or {}, separators=(",", ":"))
message = f"{ts}{api_key}{recv_window}{payload}"
sign = hmac.new(api_secret.encode(), message.encode(), hashlib.sha256).hexdigest()
print("--- 署名の内訳 -------------------------------------------")
print(f"timestamp : {ts}")
print(f"apiKey : {api_key[:4]}**** (長さ {len(api_key)})")
print(f"recvWindow : {recv_window}")
print(f"payload : {payload!r}")
print(f"署名対象 : {message[:20]}...{message[-40:]!r}")
print(f"X-BAPI-SIGN : {sign}")
print("----------------------------------------------------------")
print("チェック項目:")
print(" 1. payload は実際に送信する文字列と1文字も違わないか")
print(" (POSTで requests の json= を使っていないか)")
print(" 2. apiKey / apiSecret の前後に改行・空白が混じっていないか")
print(" 3. 接続先ドメインとキーの環境が一致しているか")
print(" (testnetのキーを api.bybit.com に投げていないか)")
print(" 4. timestamp のズレが recvWindow 以内か(10002ならこれ)")
if __name__ == "__main__":
from config import load_credentials
cred = load_credentials()
dump_signature(cred.api_key, cred.api_secret,
method="GET", params={"accountType": "UNIFIED"})
APIキーをブラウザからコピーする際に、末尾に改行や全角スペースが混入して10004になるケースが非常に多いです。config.py で .strip() を掛けているのはこのためです。それでも直らない場合は上の len(api_key) を確認してください。
RSA署名の実装、非同期クライアント、オフセット補正付きタイムスタンプなどは記事02で詳しく扱います。
V5のすべてのエンドポイントは、同じ封筒(エンベロープ)で結果を返します。
{
"retCode": 0, // 0 が成功。それ以外はすべてエラー
"retMsg": "OK", // エラーメッセージ
"result": { ... }, // 実データ。多くは {"list": [...], "nextPageCursor": "..."}
"retExtInfo": {}, // バッチ処理時の個別結果などが入る
"time": 1785000000000 // サーバー時刻(ms)
}
V5は業務エラーをHTTPステータス200+retCode≠0で返します。requests の raise_for_status() だけでは検知できません。必ず retCode を確認するラッパを通してください。pybitも retCode≠0 を例外にせず辞書で返す場合があるため、自分で retCode を見るコードを1箇所に集約するのが安全です。
"""unwrap.py — retCodeを1箇所で処理するラッパ
本コードは本人利用に限り自由に使用可。再配布・商用利用・販売は禁止。
"""
from __future__ import annotations
from typing import Any
class BybitAPIError(RuntimeError):
def __init__(self, ret_code: int, ret_msg: str, ext: Any = None):
super().__init__(f"retCode={ret_code} retMsg={ret_msg}")
self.ret_code = ret_code
self.ret_msg = ret_msg
self.ext = ext
@property
def retryable(self) -> bool:
# レート制限・サーバー内部エラーは時間を置けば通る可能性がある
return self.ret_code in {10006, 10016}
@property
def needs_reconcile(self) -> bool:
# 「注文が見つからない」系は、送れているのに結果を取り違えている
# 可能性があるため、取引所側の状態照会が必要(記事17)
return self.ret_code in {110001, 110004}
# エラーだが実質成功として扱ってよいコード
BENIGN_CODES = {
110043, # レバレッジ変更なし(既に同じ値が設定されている)
}
def unwrap(res: dict) -> dict:
"""V5レスポンスからresultを取り出す。retCode≠0なら例外にする。"""
code = res.get("retCode")
if code == 0:
return res.get("result") or {}
if code in BENIGN_CODES:
return res.get("result") or {}
raise BybitAPIError(code, res.get("retMsg", ""), res.get("retExtInfo"))
# 使用例
if __name__ == "__main__":
from client import make_client
c = make_client()
try:
result = unwrap(c.http.get_wallet_balance(accountType="UNIFIED"))
print(result["list"][0]["totalEquity"])
except BybitAPIError as e:
print(f"APIエラー: {e} リトライ可={e.retryable}")
履歴系のエンドポイントは result.nextPageCursor でページを繰ります。これも1箇所に共通化しておくと、以降の記事のコードがすべて短くなります。
from collections.abc import Callable, Iterator
def paginate(fetch: Callable[..., dict], *, limit: int = 200,
max_pages: int = 100, **kwargs) -> Iterator[dict]:
"""cursorベースのV5エンドポイントを全件たどる。
使用例:
for row in paginate(c.http.get_executions,
category="linear", symbol="BTCUSDT"):
print(row["execTime"], row["execPrice"])
"""
cursor = None
for _ in range(max_pages):
params = dict(kwargs, limit=limit)
if cursor:
params["cursor"] = cursor
result = unwrap(fetch(**params))
rows = result.get("list") or []
yield from rows
cursor = result.get("nextPageCursor")
if not cursor or not rows:
return
| retCode | 意味 | 実際の原因と対処 | リトライ |
|---|---|---|---|
| 10001 | パラメータ不正 | category の指定漏れ、シンボル名の大文字小文字、accountType の誤り、数値を数値型で送っている(V5は文字列で渡す) |
不可 |
| 10002 | タイムスタンプ範囲外 | ローカル時計のズレが recv_window を超過。NTP同期(w32tm /resync / chronyc makestep)。サンプル5で計測 |
同期後に可 |
| 10003 | APIキー不正 | 環境とキーの不一致(testnetキーを本番に送信)、キーの有効期限切れ、IP制限に引っかかっている | 不可 |
| 10004 | 署名エラー | 署名対象文字列と送信内容の不一致(POSTのJSON直列化)、Secretに改行混入。サンプル10でダンプ | 不可 |
| 10005 | 権限不足 | Read-Onlyキーで発注しようとしている。該当権限を持つキーに差し替える | 不可 |
| 10006 | レート制限超過 | UID×エンドポイント単位の秒間上限を超過。指数バックオフ+トークンバケット(記事05) | 可 |
| 10016 | サーバー内部エラー | Bybit側の一時的障害。発注系では危険。注文が通っている可能性があるので状態照会してから再送(記事17) | 照会後 |
| 110007 | 残高不足 | 利用可能証拠金(totalAvailableBalance)を超える発注。既存の未約定注文が証拠金を拘束していないか確認 |
不可 |
| 110043 | レバレッジ変更なし | すでに同じレバレッジが設定されている。実質成功なので無視してよい(記事21) | 無視 |
| HTTP 403 | IP単位のレート制限 | 5秒あたり600リクエストの上限を超過。約10分の制限がかかる。並列数を絞る(記事05) | 10分後 |
retCodeの一覧は公式のError Codesページが正典です。上表は「bot実装で実際に踏むもの」に絞っています。網羅的な逆引きと自動リカバリの実装は記事06で扱います。
ここまでで「データを取る」「残高を見る」ができました。しかし発注を伴う本番運用に進むには、あと5つの壁があります。それぞれ専用記事で扱いますが、全体像を先に示しておきます。
BybitはIP単位(5秒600リクエスト、超過でHTTP 403+約10分の制限)とUID×エンドポイント単位(秒間、超過でretCode 10006)の二層で制限します。X-Bapi-Limit-Status ヘッダを見て自律的に流量を絞る実装が必要です。→ 記事05
タイムアウトで例外が出たとき、注文は「通っていない」とは限りません。素朴にリトライすると同じ注文が2回通ります。orderLinkId を決定論的に生成して冪等化し、bot再起動時には取引所側の状態と突き合わせる(リコンサイル)必要があります。→ 記事17
価格やポジションをRESTで秒間ポーリングするのは、レート制限的にも遅延的にも不利です。板の差分更新・約定通知はWebSocketで受け取ります。→ 記事26・27・28
botは必ず暴走します。日次ドローダウン上限、連敗停止、外部からのキルスイッチを戦略より先に実装してください。「儲ける機能」より「損失を止める機能」のほうが期待値への寄与が大きいです。→ 記事24
手数料・スリッページ・資金調達率を織り込まないバックテストは、ほぼ確実に過大な成績を出します。さらにパラメータを大量に試せば、偶然良く見えるものが必ず見つかります。Walk-Forward検証とDeflated Sharpe Ratioで割り引いて評価してください。→ 記事31・32
1日目:本記事のサンプル1〜8をtestnetで動かす。
2〜3日目:記事07でローカルにOHLCVを蓄積する(データが手元にないと何も検証できません)。
4〜5日目:記事13でtestnetに最小ロットの指値・成行を出し、記事18で自分の約定履歴を集計する。
6〜7日目:記事31でバックテスト基盤を作る。戦略に手を付けるのはここから。
順番を逆にして「まず勝てるロジックを探す」から入ると、検証環境がないため何が良いのか判断できず、最終的に他人のパラメータを写すだけになります。
ccxt、Bybitに絞って細かい機能を使い切りたいならpybitまたは自作クライアントです。V5固有のパラメータ(tpslMode、positionIdx、orderFilter、slippageTolerance など)はccxtの共通インターフェースからは扱いにくく、結局 params に生の辞書を渡す形になるため、抽象化の利点が薄れます。本シリーズはBybit専用の前提でpybit+自作クライアントを併用します。.envを更新→bot再起動→旧キー削除の順に行うと無停止に近い形で交代できます。api.bybit.com では使えません(retCode 10003)。また本番キーにIP制限を掛けている場合、実行環境のグローバルIPが登録済みか確認してください。加えて、所属エンティティによって接続先ドメインが異なる場合があります(日本国内向けの api.manepa.jp など)。float()すると誤差が入るため、価格・数量はDecimalで扱い、統計計算に入る直前でだけfloatにするのが正しい扱い方です。サンプル8の丸め関数がこの前提で書かれています。fetch_rules() で取得できます。金額を増やすのは、記事18の執行品質の集計と記事32の過剰最適化検証を通過してからにしてください。/v2/private/order/create や /spot/v3/... など)はそのままでは動かないと考えてください。V5の /v5/... パスであることを確認するのが、記事やコードを参考にする際の第一のフィルタになります。投資は自己判断・自己責任でお願いいたします。本ブログの情報・ツール・ソフトウェアを参考・使用して、取引をされて何らかの損失を被られても、当サイトでは責任は一切負いません(間接的・付随的な障害・損失も含め一切の責任を負いません)。自己責任でご利用ください。
当サイトからダウンロードしたプログラム・EAは本人利用に限り完全無料でご利用いただけます。プログラムの全てまたは一部の複製・コピー・リバースエンジニアリング・改変・2次配布・商用利用・転売・販売等は一切禁止いたします。違反を発見した場合は法的処置を行います。
当サイト上に存在するソフトウェア・ツール・文章・画像・動画等の著作物を無断転載することを禁止します。引用の範囲を超えるものについては、法的処置を行います。
当サイトで配布するツール・ソフトウェアは無料提供ですのでサポートなどは行っておりません(逆に丁寧にサポートしているサイトはほとんど違法サイトですのでご注意ください。投資助言・代理業登録なしでサポート・助言することは法律で禁止されています)。万が一、ツールやソフトウェアに不具合や問題があっても当サイトでは修正・対応などができない場合があります。ご利用の環境でトラブルが発生しても当サイトでは助言することはできませんので自己責任でご利用ください。検索するなどして自分で解決してください。
当サイトでは取引所・証券会社へのリンクを貼っていますが、これは情報として提供するものであり、勧誘行為などは一切行っておりません。そのため、取引所・証券会社の登録方法・使い方・オススメ金融機関・オススメ金融商品などを説明した記事はサイト内にありませんので、ご自身で調べた上で判断ください(本当はサイト内で丁寧に説明したいのですが、仲介・取次ぎにあたる可能性があり第一種金融商品取引業登録を行っていない場合、違法となる可能性があるため出来ません。ご容赦ください)。
当サイトからリンクやバナーなどによって他のサイトに移動された場合、移動先サイトで提供される情報、サービス等について一切の責任を負いません。当サイト掲載情報やリンク先の掲載情報の合法性、正確性、道徳性、最新性、適切性、著作権の許諾や有無など、その内容については一切の保証を致しかねます。
当サイトは、ご訪問いただいた時間、訪問者のアクセス元のホスト名、IPアドレス、OSとブラウザのバージョン、リンク元のURL、モニターの情報等のパソコン環境情報を収集し、アクセス解析をしております。この目的は訪問者が検索サイトからご訪問いただいた場合、どのような検索で当サイトに来訪いただいたかの解析にのみ使用しており、第3者に公開はいたしません。また、個人を特定するものではありません。