2025年4月21日
前回までに「config切替+未約定キャンセル+SQLite&PnL算出」まで実装できたので、今回はテストネットでの動作確認とログ・Slack通知の出力を行い、realtime注文照会で出たエラーを確認しました。
🎯 今回の目的
- テストネットモードで Bot を動かす
- 実行ログ/Slack通知の挙動を確認
- Realtime 約定照会 (
/v5/order/realtime
) で発生するIllegal category
エラーの原因を把握
🔧 テスト手順チェックリスト
- テストネットモード切替
python MMbotbybit.py --mode testnet --lot 0.001
で起動- 内部で
config_testnet.json
を読み込む
- API キー&残高準備
- Testnet 側で Trade 権限付き API キーを作成
- Bybit Testnet の UI(Faucet)から USDT をチャージ
- 小ロットで発注
- スプレッド検出 → 指値発注
- タイムアウト → 自動キャンセル
- Slack にステータス通知
- 結果確認
- SQLite(
mmtrades.db
):未約定なのでレコードは増えず - Console / log file:
cycle end pnl=0.0000
の成功メッセージ - Slack:発注→キャンセル→PnL報告の通知が来る
- ログローテーション:
mm_bot_20250421.log
が生成/日次ZIP圧縮
- SQLite(
👇発注実行のコード
python MMbotbybit.py --mode testnet --lot 0.001
🚀 実行結果ログ抜粋
2025-04-21 10:57:04.742 | INFO | run_loop: Start MMBot mode=testnet 2025-04-21 10:57:05.628 | INFO | run_loop: spread OK → send orders 2025-04-21 10:57:06.842 | WARNING | wait_for_fill: realtime API error: {'retCode':10001,'retMsg':'Illegal category',…} ⚠️ did not fill within 30s → cancel 2025-04-21 10:57:35.163 | INFO | cancel_order: order.cancel ok=False raw={'retCode':110001,…} 2025-04-21 10:57:35.413 | SUCCESS | run_loop: cycle end pnl=0.0000
⚠️ 問題点
/v5/order/realtime
で返ってくる JSON が以下のようになり、data
フィールドが空のため約定状況を取得できない。
{"retCode":10001,"retMsg":"Illegal category", …}
- つまり、Bot 側の
category: "linear"
指定と realtime エンドポイントの組み合わせが 不適切 と思われる。
🛠️ 対処検討
- category パラメータを再確認
- 例えば
"spot"
用のエンドポイントでは"category":"spot"
が必要か?
- 例えば
- Bybit API ドキュメントを参照し、
/v5/order/realtime
が対応するcategory
値を明示する- 試しに
"inverse"
や"spot"
を渡してみる
- 必要であれば、realtime 取得を REST(
/v5/order/history
等)に切り替え
✍️ まとめ
- テストネットでのBotの基本フロー(板監視→発注→タイムアウト→キャンセル→通知)は問題なく動作
- realtime 約定照会で返る
Illegal category
を解消すれば、約定ケースの動作確認・PnL算出に進める - 次回はエンドポイント/パラメータの修正を行い、実際に Filled を取得できるかを検証します。
次回も引き続き、テストネット上での“約定検知”まで完了させ、Bot の安定稼働を目指します!

テストネットで実際に「Filled」を拾うには、単純に“サイズを小さくする”だけではなく、注文価格と流動性(板の厚み)を調整して「すぐに約定」させる必要があります。以下の項目は今後の宿題です。
1. リミット注文価格をもっと攻撃的にする
今は「bid−1」「ask+1」で注文を出しているので、ほぼ間違いなく板に載るものの、相手注文(マーケット参加者)がいないと約定しません。
- 買い:
price = ask
以上(たとえばask + 0
) - 売り:
price = bid
以下
こうすると、目の前にある最良板と“価格がかぶる”ため、ほぼ即座に約定します。
# 例:bid と ask をそのまま使って突っ込む buy_price = int(orderbook["ask"]) sell_price = int(orderbook["bid"])
2. マーケット注文(OrderType=”Market”)を使う
リミット価格の調整が面倒なら、最速で確実に約定する「マーケット注文」に切り替えるのが手っ取り早いです。
async def place_market_order(session, config, symbol, side, qty): path = "/v5/order/create" body = json.dumps({ "category": "linear", "symbol": symbol, "side": side, "orderType": "Market", "qty": str(qty), }) headers = create_signature(config, body) async with session.post(REST_BASE + path, headers=headers, data=body) as res: data = await res.json() if data.get("retCode") != 0: raise RuntimeError(data) return data["result"]["orderId"]
テスト用だけでもいいので、エントリー/イグジット両方をマーケット注文にすれば、ほぼ間違いなく「Filled」で返ってきます。
3. 超小ロット × テストネット板の厚み
テストネットの板は本番よりずっと薄い(流動性が低い)ので、
- lot=0.001 → 0.0005 → 0.0001
と徐々に下げていくと、「小さすぎて約定しない」リスクはなくなります。まずは 0.0001 あたりまで落としてみて、リミット価格を攻撃的に置けばすぐに「Filled」を拾えるはずです。
まとめ
- リミット注文なら「bid/askをまたぐ」思い切った価格設定
- もっと確実にやるならマーケット注文に切り替え
- 流動性に合わせてロットを 0.001 → 0.0001 まで落として試す
このあたりを調整すれば、テストネット上でも「Filled → record_trade → SQLite に書き込み → cycle end」で一連のフローを完全に検証できます。
👇おまけ:🏁 はじめてのMMBotテスト実行ガイド(Bybit Testnet版):自分用
✅ ステップ1:テストモードに切り替える
コードが「本番環境(mainnet)かテスト環境(testnet)か」を判断するフラグです。
# MMbotbybit.py の先頭付近にある設定 EXCHANGE_MODE = "testnet"
- なぜ必要?
テストネットは本物のお金を使わずに練習用の仮想USDTで注文できる環境です。 - やり方
- エディタ(VSCodeなど)で
MMbotbybit.py
を開く EXCHANGE_MODE = "mainnet"
を以下のように書き換えて保存
- エディタ(VSCodeなど)で
EXCHANGE_MODE = "testnet"
✅ ステップ2:APIキーの準備&Trade権限の付与
- Bybit テストネットのダッシュボードで APIキーを作成
- Trade(取引)権限 を必ずオンにする
- 生成された APIキー/シークレット を
config_testnet.json
に書き込む
// config_testnet.json(例) { "bybit": { "apiKey": "あなたのテストネットAPIキー", "secret": "あなたのテストネットシークレット" }, "slack_webhook_url": "あなたのSlack Webhook URL" }
💡 シークレットは一度しか見られないので、大切に保管しましょう!
✅ ステップ3:テスト用USDTをチャージ
Bybit テストネットには 無料のFaucetページ があります。
- テストネットにログイン
- 「Faucet(テスト資金ページ)」へ移動
1000 USDT
などを申請 → 即時アカウントに反映
💡 テスト用のUSDTなので減っても実損はありません。好きなだけ試しましょう!
✅ ステップ4:超小ロットで発注テスト
実際にBotを動かして、注文→約定→PnL計算までを一通りテストします。
- 1.ターミナル/コマンドラインを開く
- 2.Botのディレクトリへ移動
cd /path/to/MMBot/MMbotbybit
- 3.テスト用コマンドを実行
python MMbotbybit.py --mode testnet --lot 0.001
--lot 0.001
は とても少量(0.001 BTC分)を指定--mode testnet
でテストネットが有効
✅ ステップ5:動作確認ポイント
Botを 最低でも30秒〜1分は動かし続け、以下をチェックしましょう。
項目 | 期待される結果 |
---|---|
注文が通る | 発注のログ(Buy/Sell ID)が出る |
約定(Filled)判定 | PnL計算→cycle end pnl=… がコンソールに出る |
SQLite(mmtrades.db )の中身 | trades テーブルに Buy/Sell 各1行ずつ追加 |
Slack通知 | 「MMBot Report」メッセージが届く |
ログファイル生成 | mm_bot_YYYYMMDD.log が作られる |
タイムアウト自動キャンセル | 注文が埋まらなければ /v5/order/cancel が呼ばれ、ログに警告が出る |
日次ローテーション(翌日以降に要確認) | ログファイルが翌日分に切り替わり、古いファイルはZIP圧縮される |
🎯 まとめ
- テストモード切替 →
"testnet"
に設定 - Trade権限付きAPIキー の準備&
config_testnet.json
へ登録 - Faucetページ でテストUSDTをチャージ
- 超小ロット で
python MMbotbybit.py --mode testnet --lot 0.001
を実行 - 30秒~1分程度動かし、ログ/DB/Slack通知をチェック
これで「注文→約定→PnL算出→レコード保存→通知→キャンセル」まで一巡する動作が一通り動くはず。
慣れてきたらスプレッド閾値やロットサイズを調整して、Botの動きをさらに観察してみる。
👇ラジオで話したこと
🎙️ 開発記録 #188|テストネット実行とRealtime APIのつまずきポイント【仮想通貨Bot開発ログ】
こんにちは、よだかです。
今回は「仮想通貨Botのテストネット初回実行と、Realtime APIでのエラー検証」について、開発記録を兼ねてお話ししていきます。
🎯 今回の目的
前回までに、未約定キャンセルやSQLiteによる損益集計、ログ基盤などを整えてきました。
そこで今回は、それらを実際にテストネットで実行してみるというフェーズです。
目的は以下の3つ:
- Botをテストネットモードで起動し、基本動作の流れを確認
- Slack通知とロギングが正しく動くかチェック
- 約定状況を取得するための「Realtime API」で出るエラーの原因を洗い出す
🧪 テスト実行の流れ
では、実際のテスト手順についてご紹介します。
まずはBotの起動コマンドから:
python MMbotbybit.py --mode testnet --lot 0.001
これは、「テストネットモード」で「超小ロット(0.001BTC)」の発注を行うという設定です。
内部的には config_testnet.json
を読み込んで、本番とは違うAPIエンドポイントを使うようになっています。
次に準備するのがAPIキーとUSDTの残高。
Bybitのテストネットでは、Faucetという仕組みで仮想通貨をチャージできます。
✅ 発注→キャンセル→通知までの挙動確認
Botを起動すると、以下のような流れがログに記録されました:
- スプレッドを検知して指値注文を発行
- 約定しないまま30秒が経過 → 自動キャンセル
- Slackに「発注→キャンセル→PnL=0」の通知が来る
つまり、基本的な制御フローはきちんと動いていたというわけですね。
⚠️ ただし、ここで問題が発生しました…
それがこちら。
{"retCode":10001, "retMsg":"Illegal category", "data":{}}
Realtime API を叩いたとき、**“Illegal category”**というエラーが返ってきたんです。
本来は /v5/order/realtime
を使って、注文の約定状況を即時取得する予定だったんですが、category: "linear"
を指定してリクエストを投げても、「そんなカテゴリは存在しないよ」とAPI側に弾かれてしまった。
🔍 原因の仮説と調査方針
ここで考えられる原因は、指定しているcategoryの値がそのエンドポイントと合っていないという点。
BybitのAPIでは、たとえば:
- "linear":USDT建ての無期限先物
- "inverse":インバース型
- "spot":現物取引
…と、用途に応じたcategoryを指定する必要があるんですが、どうやら /v5/order/realtime
は linear
に対応していない可能性がある。
対応策としては:
- APIドキュメントで、このエンドポイントが対応しているcategoryを再確認
- 他のcategory(spot/inverse)で試してみる
- あるいは、Realtime APIではなく、RESTの
/v5/order/history
に切り替える案も検討中
📚 ログから見る実行結果の詳細
ログ出力を少し見てみましょう。
2025-04-21 10:57:04 | INFO | Start MMBot mode=testnet 2025-04-21 10:57:05 | INFO | spread OK → send orders 2025-04-21 10:57:06 | WARNING | wait_for_fill: realtime API error: Illegal category 2025-04-21 10:57:35 | INFO | cancel_order: order.cancel ok=False 2025-04-21 10:57:35 | SUCCESS | cycle end pnl=0.0000
ポイントは、realtime APIのエラーで約定状況が取れなかったにもかかわらず、
Botはそのままキャンセル処理に進み、損益を「0」として正常に記録できていることです。
🧭 今後の改善ポイント(宿題)
ここからは、私自身の今後のタスクとして考えている項目です。
1. リミット注文価格をもっと攻撃的に
今は「bid - 1」「ask + 1」で出しているので、ほぼ板に載るけれど約定はしにくい。
- 買い →
ask
そのままで出す - 売り →
bid
そのままで出す
とすれば、価格がかぶるので即約定する可能性が高まります。
2. マーケット注文で即約定させる
ちょっと強引ですが、「Market注文」にすれば100%約定します。
テスト用にはこの方が検証がしやすいかもしれません。
"orderType": "Market"
と指定するだけでOK。リミット価格のチューニングが不要になるので、時短にもなります。
3. ロットを極小にして板の厚みに対応
テストネットの板は本番よりもかなり薄いため、発注サイズが大きいとスルーされることがあります。
0.001 → 0.0005 → 0.0001
と小さくしていけば、流動性が乏しくても拾ってくれる確率が上がります。
✍️ まとめ
今回のテストでは、
- Botの起動・ログ出力・Slack通知など、基礎的な動作は問題なし
- ただし、Realtime APIの約定照会がうまく機能せず、「Illegal category」エラーが発生
- 今後は、マーケット注文の併用や、価格・ロットの調整で「約定」を狙いにいく予定
次回は、このRealtime APIのカテゴリ問題に対処しつつ、実際に「Filled(約定)」を拾って、SQLiteに記録→PnL計算までの一連の流れを完成させることが目標です。
🎧 ということで、今回は「テストネット初実行とRealtime APIの検証」についてお届けしました。
Bot開発って、コードの書き方だけじゃなくて、**“データをどう取得するか”“APIの裏の仕様をどう読むか”**という力も問われるので、本当に奥が深いなと実感しています。
次回も開発の裏側をしっかりお届けしていきますので、どうぞお楽しみに。
それではまた!
現在のコード:自分用:メインスクリプトのみ
""" MMBot v1.6 (2025‑04‑21) ──────────────────────────────────────────────────────────────────── 機能 1. スプレッド検出で指値 2 本 … place_limit_order() 2. タイムアウトで自動キャンセル … wait_for_fill() → cancel_order() 3. loguru でファイル & コンソールログ (日次ローテーション 7 日保持) 4. 約定結果を SQLite に保存し、サイクル毎に累計 PnL を計算 --config / --mode オプションで config_mainnet.json / config_testnet.json を自動選択 """ from __future__ import annotations import asyncio import json import time import hmac import hashlib import sqlite3 from argparse import ArgumentParser from pathlib import Path from urllib.parse import urlencode import aiohttp import pybotters from loguru import logger # ────── 0. 設定 ──────────────────────────────────────────── EXCHANGE_MODE: str # 'mainnet' or 'testnet' 実行時に上書き DB_FILE = Path("mmtrades.db") LOG_FILE = "mm_bot_{time:YYYYMMDD}.log" REST_BASE: str # 実行時に args.mode で上書き WS_PUBLIC: str # 同上 # ────── 1. 共通ユーティリティ ──────────────────────────────────── def create_signature(config: dict, body: str = "", params: dict | None = None) -> dict: timestamp = str(int(time.time() * 1000)) api_key = config["bybit"]["apiKey"] secret = config["bybit"]["secret"] recv_window = config.get("recv_window", "5000") if params: # ソートしたクエリ文字列を payload として署名 payload = urlencode(sorted(params.items())) else: payload = body or "" to_sign = timestamp + api_key + recv_window + payload signature = hmac.new(secret.encode(), to_sign.encode(), hashlib.sha256).hexdigest() return { "Content-Type": "application/json", "X-BAPI-API-KEY": api_key, "X-BAPI-TIMESTAMP": timestamp, "X-BAPI-SIGN": signature, "X-BAPI-RECV-WINDOW": recv_window, } async def place_limit_order(session: aiohttp.ClientSession, config: dict, symbol: str, side: str, qty: float, price: int) -> str: path = "/v5/order/create" body = json.dumps({ "category": "linear", "symbol": symbol, "side": side, "orderType": "Limit", "qty": str(qty), "price": str(price), "timeInForce": "GTC", }) headers = create_signature(config, body, None) async with session.post(REST_BASE + path, headers=headers, data=body) as res: data = await res.json() if data.get("retCode") != 0: raise RuntimeError(data) return data["result"]["orderId"] async def cancel_order(session: aiohttp.ClientSession, config: dict, symbol: str, order_id: str) -> bool: path = "/v5/order/cancel" body = json.dumps({"category": "linear", "symbol": symbol, "orderId": order_id}) headers = create_signature(config, body, None) async with session.post(REST_BASE + path, headers=headers, data=body) as res: j = await res.json() ok = j.get("retCode") == 0 logger.info(f"order.cancel id={order_id} ok={ok} raw={j}") return ok async def wait_for_fill(session: aiohttp.ClientSession, config: dict, symbol: str, order_id: str, timeout: int = 30, interval: float = 1.0 ) -> tuple[bool, float]: """ mode=testnet のときは /v5/order/history をポーリングし、 mode=mainnet のときは /v5/order/realtime をポーリング。 タイムアウトで自動キャンセル → (False, 0.0) """ end_at = time.time() + timeout # テストネットは history 経由 if EXCHANGE_MODE == "testnet": path = "/v5/order/history" while time.time() < end_at: params = {"symbol": symbol, "orderId": order_id} headers = create_signature(config, "", params) qs = urlencode(sorted(params.items())) url = f"{REST_BASE}{path}?{qs}" async with session.get(url, headers=headers) as resp: d = await resp.json() if d.get("retCode") == 0: for info in d.get("result", {}).get("data", []): if info.get("orderId") == order_id and info.get("orderStatus") == "Filled": return True, float(info.get("price", 0.0)) else: logger.warning(f"[{order_id}] history API error: {d}") await asyncio.sleep(interval) # mainnet(または fallback)は realtime 経由 path = "/v5/order/realtime" while time.time() < end_at: params = {"category": "linear", "symbol": symbol, "orderId": order_id} headers = create_signature(config, "", params) qs = urlencode(sorted(params.items())) url = f"{REST_BASE}{path}?{qs}" async with session.get(url, headers=headers) as resp: d = await resp.json() if d.get("retCode") == 0: data = d.get("result", {}).get("data") or [] if data: info = data[0] state = info.get("orderStatus") price = float(info.get("price", 0.0)) if state == "Filled": return True, price else: logger.warning(f"[{order_id}] realtime API error: {d}") await asyncio.sleep(interval) # タイムアウト → 自動キャンセル logger.warning(f"⚠️ [{order_id}] did not fill within {timeout}s → cancel") await cancel_order(session, config, symbol, order_id) return False, 0.0 async def notify_slack(webhook_url: str, message: str): async with aiohttp.ClientSession() as s: await s.post(webhook_url, json={"text": message}) # ────── 2. DB (SQLite) ──────────────────────────────────────────── def init_db(): conn = sqlite3.connect(DB_FILE) conn.execute( "CREATE TABLE IF NOT EXISTS trades(" "ts INTEGER, symbol TEXT, side TEXT, qty REAL, price REAL)" ) conn.commit() return conn def record_trade(conn: sqlite3.Connection, ts: int, symbol: str, side: str, qty: float, price: float): conn.execute("INSERT INTO trades VALUES (?,?,?,?,?)", (ts, symbol, side, qty, price)) conn.commit() def calc_pnl(conn: sqlite3.Connection, symbol: str) -> float: cur = conn.cursor() cur.execute( "SELECT side, qty, price FROM trades " "WHERE symbol=? ORDER BY ts DESC LIMIT 2", (symbol,), ) rows = cur.fetchall() if len(rows) == 2 and {r[0] for r in rows} == {"Buy", "Sell"}: sell = next(p for s, q, p in rows if s == "Sell") buy = next(p for s, q, p in rows if s == "Buy") qty = rows[0][1] return (sell - buy) * qty return 0.0 # ────── 3. メインループ ─────────────────────────────────────────── async def run_loop(symbol: str, qty: float, s_entry: float, config: dict, interval: int): logger.info(f"MMBot start mode={EXCHANGE_MODE}") conn = init_db() orderbook: dict[str, float] = {} async with pybotters.Client(base_url=WS_PUBLIC) as ws: async with aiohttp.ClientSession() as sess: await ws.ws_connect( WS_PUBLIC, send_json=[{"op":"subscribe","args":[f"orderbook.1.{symbol}"]}], hdlr_json=lambda m, w: ( orderbook.update({"ask": float(m["data"]["a"][0][0])}) if m.get("data") and m["data"].get("a") else None, orderbook.update({"bid": float(m["data"]["b"][0][0])}) if m.get("data") and m["data"].get("b") else None, ) ) while "bid" not in orderbook: await asyncio.sleep(0.5) while True: bid, ask = orderbook["bid"], orderbook["ask"] spread_pct = (ask - bid) / bid logger.debug(f"spread={spread_pct:.6f}") if spread_pct > s_entry: logger.info("spread OK → send orders") buy_p = int(bid - 1) sell_p = int(ask + 1) try: buy_id = await place_limit_order(sess, config, symbol, "Buy", qty, buy_p) sell_id = await place_limit_order(sess, config, symbol, "Sell", qty, sell_p) except Exception as e: logger.error(str(e)) await notify_slack(config["slack_webhook_url"], f"Order error: {e}") await asyncio.sleep(interval) continue filled_buy, price_buy = await wait_for_fill(sess, config, symbol, buy_id) filled_sell, price_sell = await wait_for_fill(sess, config, symbol, sell_id) ts = int(time.time() * 1000) if filled_buy: record_trade(conn, ts, symbol, "Buy", qty, price_buy) if filled_sell: record_trade(conn, ts, symbol, "Sell", qty, price_sell) pnl = calc_pnl(conn, symbol) logger.success(f"cycle end pnl={pnl:.4f}") msg = ( f"📈 *MMBot Report*\n" f"> Pair: `{symbol}` Spread: `{spread_pct:.5f}`\n" f"> 🟢 Buy @{buy_p} → {'Filled' if filled_buy else 'Canceled'}\n" f"> 🔴 Sell@{sell_p} → {'Filled' if filled_sell else 'Canceled'}\n" f"> PnL: `{pnl:.4f}`" ) await notify_slack(config["slack_webhook_url"], msg) await asyncio.sleep(interval) else: await asyncio.sleep(1) # ────── 4. CLI & Entry ─────────────────────────────────────────── def cli(): p = ArgumentParser() p.add_argument("--mode", choices=["mainnet","testnet"], default="mainnet", help="取引環境を選択") p.add_argument("--config", default=None, help="手動で設定ファイルを指定する場合") p.add_argument("--symbol", default="BTCUSDT") p.add_argument("--lot", type=float, default=0.01) p.add_argument("--s_entry",type=float, default=0.000001) p.add_argument("--interval",type=int, default=10) return p.parse_args() if __name__ == "__main__": args = cli() config_file = args.config or f"config_{args.mode}.json" if not Path(config_file).exists(): raise FileNotFoundError(f"設定ファイルが見つかりません: {config_file}") with open(config_file) as f: cfg = json.load(f) EXCHANGE_MODE = args.mode REST_BASE = { "mainnet": "https://api.bybit.com", "testnet": "https://api-testnet.bybit.com", }[EXCHANGE_MODE] WS_PUBLIC = { "mainnet": "wss://stream.bybit.com/v5/public/linear", "testnet": "wss://stream-testnet.bybit.com/v5/public/linear", }[EXCHANGE_MODE] # loguru 初期化 logger.remove() logger.add(lambda m: print(m, end="")) logger.add(LOG_FILE, rotation="00:00", retention="7 days", compression="zip") asyncio.run( run_loop( symbol = args.symbol, qty = args.lot, s_entry = args.s_entry, config = cfg, interval = args.interval, ) )