Bot mmnot トレードロジック 開発ログ

🛠️開発記録#249(2025/6/11)「スプレッドはいつ開くのか?── 板幅ベース戦略のためのバックテスト環境を作った話」

マーケットメイカー戦略において、「スプレッドが開いた瞬間にだけ発注する」ロジックは、効率よく期待値を積み上げる上で極めて重要なアプローチです。
しかし、その「スプレッドが開いたかどうか」をどう検知し、どのように記録・分析するか?という点については、意外と情報が整理されていません。

本記事では、自作のMarket Maker Botにおいて「秒足クロスによる全約定→即ヘッジ」という手数料ループから脱却するために、板幅(Top-of-Bookのbid/ask差)を基準としたスプレッド判定ロジックを導入するまでの一連の開発フローを紹介します。

Recorderの設計、OHLCとdepthの整合、バックテストパイプラインの構築、ログ監視、スプレッド分布の確認、そして手数料削減効果の検証まで。

Market Maker戦略のバックテストに苦しんでいる人にとって、シンプルかつ本質的な構造で“必要な瞬間だけ発注する”基盤をどう作るかという参考になれば嬉しいです。

このロジック改善は、単なる秒足クロスBotの修正ではなく、将来的にFunding Rateシグナルと組み合わせるための前提整備でもあります
FRMMbotのように「期待値がプラスになる瞬間だけ板に立つ」という設計を実現するためには、「その瞬間を見極める基準」が必要です。板幅(スプレッド)は、その1つのシンプルかつ強力な基準になります。

今回の取り組みは、FRシグナルやボラティリティの高まりといった指標を加味する前に、板幅という確かな観測可能変数に基づいて取引のon/offを制御できる仕組みを作るという重要な布石となります。

1. はじめに:なぜ板幅を観測したかったのか

私が最初に自作したMarket Maker(以下、MM)Botは、非常に素直な設計でした。
板の中心付近にMaker注文を両建てで置き、約定したら即座に反対側をTakerでヘッジしてポジションをフラットに戻す──いわゆる「秒足クロス型の即時ヘッジ戦略」です。

一見ロジックとしては正しい。しかし、バックテストしてみると驚くほど勝てない。

敗因は明白でした。秒足OHLCベースのバックテストでは、ほぼ100%の確率で「Maker→Taker」が毎秒起きる。そのため、1秒に1回Taker手数料を支払い(Makerリベートはごくわずか)、ネットで手数料コストが毎秒積み重なる構造になっていたのです。

例えば、1秒あたり0.005 BTCを取引していたとすると、24時間で約8,640回の発注になります。BybitのVIP0手数料(Maker −0.02%のリベート、Taker +0.06%のコスト)を考慮すると、純粋な手数料負担は0.06%−0.02%=0.04%です。仮にBTC価格を30,000 USDTとすると、1回あたり0.005 BTC×30,000 USDT=150 USDTの取引で0.06 USDT、24時間では約518 USDTのコストとなり、キャッシュ損失がわずかでもトータルで勝ち目がありません。

ここで私は改めて、「スプレッドが開いていないのに取引するのは無意味どころか逆効果」だと、肌感覚で理解しました。

秒足の約定ロジックではなく、“板幅” に従って取引タイミングを制御すべきである

この前提が、今回のバックテスト環境構築の出発点となりました。
次章では、このロジックを支えるためのRecorder(板情報取得・保存機構)の設計について紹介します。

2. Recorder の設計と実装:1秒粒度で Top-of-Book を取る

板幅を基準にエントリーする戦略を検証するには、まず精度の高いTop-of-Book(= 最良気配)データを正確に、かつ継続的に記録する仕組みが必要になります。
この役割を担うのが Recorder です。

🎯 要件定義はシンプル、だが運用要件はシビア

Recorder に求めた要件は以下の通りです:

  • Bybit の WebSocket 経由で orderbook.1 を購読
    • Top-of-Book を取得するため orderbook.1.BTCUSDT を購読
  • 1 秒ごとに bid / ask をパケット化して Parquet に保存
    • Python標準の time.time() を用いて、1 秒ごとに最新の bid/ask を収集
  • 長時間稼働に耐える自己復帰設計
    • Ping timeout や切断に対して自動で再接続
    • 曜日またぎやネットワーク断にも耐えるよう exponential backoff を実装
  • ファイル破損を防ぐ flush & close 処理
    • ParquetWriter による追記形式(append)を採用
    • Ctrl-C などの強制終了にも対応するため、finally: ブロックで確実に close

実装は asyncio ベースで、trade と depth の両チャネルを非同期で録画できるよう設計しています。これにより、FRMMbotの開発において "いつ・どの程度スプレッドが開いていたか" を定量的に分析する基盤が整いました。

🧩 ParquetWriter 方式での追記保存

Polars の write_parquet() では mode="a" による追記がサポートされていないため、pyarrow.ParquetWriter を使った追記処理を実装しました。
毎秒、bid / ask を1行ずつ保存し、日付が変わると自動的にファイルを切り替えます(UTC ローテーション)。

この形式を採用することで、Recorder を 24 時間以上連続で稼働させたとしてもファイルが壊れず、安全に1ファイルずつ蓄積されるようになりました。

🔒 バッファリングとログの可視性問題

Pythonの標準 print() はデフォルトでバッファリングされるため、ログを即座に確認したい場合には -u(unbuffered)モードでの起動が必要でした。

nohup poetry run python -u recorder.py > rec.log 2>&1 &

このような起動方法を使うことで、24時間録画中のログ進行状況を tail -f rec.log でリアルタイムに把握することが可能になります。


Recorder が正常に稼働することで、ついに**“現実の板状況に即した戦略シミュレーションの材料”**を手に入れました。
次章では、取得した depth データと OHLC データをどう整合させ、backtest パイプラインを組み立てていったかを解説します。

3. OHLCV生成とdepthデータの整合:秒単位でjoinできる形式にそろえる

Recorder が無事に動き出し、trade データと depth(Top-of-Book)データがそれぞれ別ファイルとして蓄積されるようになったとはいえ、そのままではバックテストで扱えません
なぜなら、trade は raw な tick データ(取引のたびに1行)であり、depth は WebSocket 由来の更新データで、しかも タイムスタンプはミリ秒精度(UNIX ms) になっているからです。

バックテストで必要なのは、「1秒ごとの市場状態」。つまり:

  • 1秒ごとの OHLCV(Open / High / Low / Close / Volume)
  • 1秒ごとの Top-of-Book(best_bid / best_ask)

この2つを “同じタイムスタンプ形式” で結合できる必要があります。


🔨 Trade → OHLCV 変換(bucketizer)

tradeデータからOHLCVを生成するために bucketizer.py を用意しました。
これは1秒単位で tick データをグルーピングし、最初の値をOpen、最大値をHigh、最小値をLow、最後の値をCloseとして出力します。

poetry run python bucketizer.py data/raw/trade_20250610.jsonl.gz

この実行により、data/ohlcv/1s/kline_1s_BTCUSDT_20250610.parquet が生成されます。ts 列には UNIXミリ秒(1000の倍数) が使われており、以降の処理と整合性があります。


🔧 Depth データを秒単位に整形

一方、Recorder によって保存された depth データは、高頻度で WebSocket が配信するミリ秒粒度の bid/ask 値を含んでいます。これを1秒単位に整形するには、以下のような処理を加えます:

depth = (
    pl.from_pandas(pq.read_table("depth_20250610.parquet").to_pandas())
    .with_columns((pl.col("ts") // 1000).alias("sec"))  # 秒単位へ変換
    .group_by("sec")
    .agg(
        pl.col("bid").first().alias("best_bid"),
        pl.col("ask").first().alias("best_ask"),
    )
)

この操作により、1秒に1行だけのTop-of-Bookデータが得られるようになり、OHLCVデータとのjoinが可能になります。


🔗 データの結合

最終的に、以下のようにして2つのデータを統合します:

df = ohlcv.join(depth, on="sec", how="inner").drop_nulls(["best_bid", "best_ask"])

ここで得られる df は、「1秒ごとに価格推移とTop-of-Bookが揃った状態のデータフレーム」となります。


このように、Recorderで得たTop-of-Book情報と、tradeから変換したOHLCVを秒単位で結合できることで、板幅に応じた正確なエントリー条件判定が可能になります。
次章では、いよいよこの整合済みデータを使って、板幅ベースのエントリーロジックをどう実装し、KPI(手数料・fill率・Net PnL)を評価していくのかをご紹介します。

4. Backtester 実装:スプレッドが広がった瞬間だけ Maker 発注

整合済みの OHLCV × depth データが手に入ったことで、ようやく「板幅が開いた瞬間だけエントリーする戦略」のシミュレーションが可能になりました。
この章では、実際にどのようにロジックを組み、何をもって「広がった」と定義し、どのようにKPIを評価したのかをご紹介します。


🎯 目指す設計:「板が開いて、次足でヒットしたらエントリー成立」

設計思想はシンプルです:

  1. 現在の秒足で板幅が spread ≥ しきい値 を満たしたら、その瞬間に quote(Maker 発注価格)を出す
  2. 次の秒足で bid がその quote をクロスしたら fill 成立とみなす
  3. fill 成立時に Maker + Taker の手数料を計上する
  4. Net PnL は現時点では “期待値0の中立 exit” として、手数料合計=損失とする

🔧 ロジックの実装ステップ

  1. 板幅条件を定義
spread = pl.col("best_ask") - pl.col("best_bid")
df = df.with_columns(
    pl.when(spread >= args.spread)
      .then(pl.col("best_bid") + spread / 2)
      .otherwise(None)
      .alias("quote_px")
)

これにより、「板幅が十分広ければ、その midpoint に Maker quote を出す」という処理になります。


  1. fill 判定(次足でクロス)
df = df.with_columns([
    (pl.col("quote_px").shift(-1).is_not_null() &
     (pl.col("best_bid").shift(-1) >= pl.col("quote_px"))
    ).alias("maker_fill")
])

「次の秒足で bid が quote を上回っていれば fill 成立」とみなします。
これは 「約定の事実」ではなく「成立可能だったか?」 に注目する設計です。


  1. KPI の計算
fills       = df.filter(pl.col("maker_fill"))
n_fills     = fills.height
total_sec   = df.height
fill_ratio  = n_fills / total_sec * 100

notional_usd = args.notional_btc * df["open"].mean()
fee_maker    = notional_usd * args.maker_fee * n_fills
fee_taker    = notional_usd * args.taker_fee * n_fills
total_fee    = fee_maker + fee_taker
net_pnl      = -total_fee  # 現状、純粋な手数料のみを損失とみなす

Maker fill が成立した回数だけ手数料を積み上げ、秒足ごとの fill 率(≪30%が理想)と手数料総額を出します。


🧪 実際に試してみた結果(1時間録画時点)

Date (UTC)            : 20250610
Rows (seconds)        : 2,103
Maker fills           : 1
Maker fill ratio      : 0.05%
Fee (maker) [USDT]    : 0.11
Fee (taker) [USDT]    : 0.33
Total fee  [USDT]     : 0.44
Net PnL    [USDT]     : -0.44

この結果からはっきり分かることがあります:

  • スプレッドが広がる瞬間は 非常に少ない(fill=1)
  • だが、そのぶん 手数料は0.4 USDTしか発生していない
    秒足クロス型の“1日800 USDT 手数料地獄”から完全に抜け出せた

この基盤が整ったことで、今後は「スプレッドが開いているだけではなく、そこにFRシグナルやボラティリティ指標が重なったときだけ発注する」といった高度な戦略へと進化させられるようになります。

5. 検証と次フェーズ:Recorder を 24 h 回すための工夫と未来展望

1時間程度のテスト録画とバックテストで、スプレッドベースのエントリーロジックが「確かに機能する」ことは確認できました。
しかし、本当に戦略として有効かどうかを判断するには、もっと長期間のデータで検証する必要があります。

この章では、Recorder を 24 時間以上安定稼働させるための運用Tipsと、今回整備した基盤をどうFRMMbotに拡張していけるかという未来の展望をまとめます。


🕒 Recorder を長時間動かすためのポイント

1. nohup + バックグラウンド実行で安定運用

nohup poetry run python -u recorder.py > rec.log 2>&1 &
  • -u をつけてPythonをunbufferedモードにすることで、ログが即時書き込まれるようになります
  • tail -f rec.log で状態監視が可能

2. PING_SECS = 8 で WebSocket の keepalive を安定化

Bybitは WebSocket Ping/Pong が10秒以上止まると切断されるため、8秒間隔で ping を送るように設定。これにより、1日稼働しても安定した接続を維持できます。

3. 出力ファイルは ParquetWriter によって追記形式で保存

ファイル破損を防ぐため、Recorderは finally: ブロックで ParquetWriter.close() を呼ぶ構成にしています。
これにより、Ctrl-C や OS側の強制終了でも 収集したデータが失われにくい構造になっています。


📈 長時間データで見えてくるもの

  • スプレッドが広がるタイミングには明確な偏りがある(時間帯や相場のボラティリティと連動)
  • fillが起きやすいスプレッド値には傾向がある(例えば 0.1 USDT 〜 0.5 USDT の範囲)
  • 安定してエントリー可能な時間帯を特定できれば、Botの稼働時間帯を制御することも可能になる

🔮 今後の拡張ポイント(FRMMbotへの接続)

今回構築した「スプレッドに基づく取引機会の検出基盤」は、**FRMMbotの設計における“物理レイヤー”**とも言える土台です。

ここから先は、次のような要素を段階的に追加していくことができます:

機能目的
Funding Rate (FR) のリアルタイム取得「ポジションを建てたときの期待収益が正かどうか」を判断するため
ボラティリティ指標の導入スプレッドが広がっていても“ボラ急変で逆に踏まれる”リスクを避けるため
EMAスプレッドやヒストリカルスプレッド分布との比較「一時的な開き」か「構造的なチャンス」かを判別するため

これらを加味していくことで、FRMMbotは「板が広がっていて、かつFRが自分に有利で、逆行リスクも低い」という極めて限定的な瞬間にだけエントリーする、期待値正の高精度戦略へと進化していきます。


終わりに

本記事では、「板幅が開いた瞬間だけ発注する」というシンプルな戦略アイディアを、Recorder → Parquet化 → 秒整合 → 条件定義 → KPI評価という構造的アプローチで形にしてきました。

このプロセスを通じて見えてきたのは、戦略の善し悪しはロジックの“中身”だけでなく、“データ取得と判定条件の構造”に大きく左右されるということでした。

いま、FRMMbotは基礎体力を身につけた段階にあります。
ここからさらに、環境に適応し、賢く生き延びるBotへと育てていくフェーズが始まります。

-Bot, mmnot, トレードロジック, 開発ログ