私は Bybit BTC/USDT パーペチュアルで メイカー手数料リベート 0 という “逆風設定” で MM ボットを動かしています。
しばらく開発を続けるうちに「板に貼り付けば勝てる」という思い込みが砕け散り、ようやく “負けを可視化し削る” という本質的な作業に着手しました。
この記事は、その開発過程を振りかえってバックテスト・バグ潰し・指標チューニングの全ログから
どうやって “死なないボット” まで持っていったか?
を 超具体的に 共有するものです。
第 0 章 TL;DR(私の失敗→修正サイクルを 30 秒で)
まずは “教訓のまとめ” からスタートします。
# | 取り組み | 痛み | 修正 | 結果 |
---|---|---|---|---|
1 | 静的 0.1 bp でエントリ連射 | スリップ+手数料で ‐1 400 USDT(バックテスト時) | 動的 p90 + σ 閾値 導入 | DD 大幅に圧縮 |
2 | Exit Lag 3 s/ノー強制フラット | net_pos 残り → ロールオーバー爆死 | Lag 1 s + EOD 成行フラット | 残ポジ = 0 確認 |
3 | Fill=深度÷lot を 100 % 採用 | Fill 過剰見積もり→PnL 幻 | 深度比例 α=3〜5 | Fill=6 %、現実に整合 |
4 | Maker リベートに頼る | 実運用は Rebate 0 | fee_w を閾値から除外し薄利拡大 | ネガティブ edge 把握 |
5 | “何となく Python” 実装 | NumPy⇆Polars で IndexError | 毎回 assert+Parquet dump | バグ検知が即日化 |
ここでの学び
- 勝ち筋探索 より先に 負け筋の絶縁。
- データは “秒 OHLCV+L1” だけでは足りない。L2 深度・Trade テープ・RTT が必須。
- 強制フラットは「ロジックの保険」。在庫リスクは DD の乗数 になる。
第 1 章 データ基盤──「秒足だけ」では何も分からなかった
1-1 Recorder v0(秒足 + L1 深度)では “負けの原因” が見えない
私が最初に用意した Recorder は、1 秒 OHLCV と最優先気配(best bid/ask) だけを Kafka→Parquet に流す極小構成でした。
ところがバックテストを回すと、こんな現象が頻発します。
現象 | ログで確認できるか | 影響 |
---|---|---|
Maker 注文が約定しない | ×(深度 1 行の数量が見えない) | Fill 過大見積もり→エッジ幻 |
約定後すぐに逆噴射して DD 拡大 | △(価格は分かるが理由が不明) | Lag 最適化ができない |
taker 成行時のスリッページが異常 | ×(Trade テープが無い) | PnL 計算が雑に |
「負けている理由が“何となく”しか分からない」
ここで初めて L2 depth・Trade テープ・RTT(Round-Trip-Time)の必要性を痛感しました。
1-2 Recorder v1 で追加した 3 種のストリーム
ストリーム | 目的 | 実装メモ |
---|---|---|
Orderbook L2 (10 depth) | Fill 確率 と 在庫吸収量 の推定 | bybit WS orderbook.50 を 100 ms 刻みで取り込み |
Public Trade テープ | taker スリッページの実測 | 成約価格・サイズをそのまま保存 |
REST ping RTT | ネットワーク遅延の把握 | 5 s ごとに /v5/market/time を往復計測 |
すべて Arrow Parquet に 1 分ロールで書き出し、パスは
data/ ├─ depth10/2024-06-21-HH.parquet ├─ trade/2024-06-21-HH.parquet └─ rtt/2024-06-21-HH.parquet
と日付-時間で切るだけの単純構成。
Polars で pl.scan_parquet("data/depth10/*.parquet")
と書くだけで1日分が LazyFrame で乗るので、後段の分析スクリプトが激減しました。
1-3 「可視化→原因究明」のパイプライン
- 1.Recorder → Parquet
すべて UTC epoch_ms で統一。列名もts, bid_px1 … bid_sz10
で揃える。 - 2.Backtester 実行
--dump-path debug_YYYYMMDD.parquet
でシミュレーション全行を保存。 - 3.Drill-down Notebook
- Fill 成功 / 失敗時の残量 を
df_l2
から引き当て - Lag vs. price movement を
df_bt.join(df_l2, on="sec")
で散布図 - スリッページ分布 を
df_tr
からヒストグラム
- Fill 成功 / 失敗時の残量 を
df_bt = pl.read_parquet("debug_20250621.parquet") df_l2 = pl.scan_parquet("data/depth10/20250621-*.parquet") df_tr = pl.scan_parquet("data/trade/20250621-*.parquet")
- 4.Hypothesis → backtester 再実行
α, spread, lag を sweep し CSV へ吐き出し、pandas.concat
→matplotlib
で比較。
1-4 ここで得た “負けを減らす” 教訓
教訓 | 実際にやったこと |
---|---|
「取得コスト < 検証コスト」 秒足しかない状態で悩む時間の方が高かった | L2/Trade を録り直して 1 晩で分析再開 |
Parquet+LazyFrame が正義 | 1 日=数 GB でもメモリに載せず groupby/agg |
バグ調査は “再現可能な最小 DF” | --dump-path で必ずシミュ DF を残す |
ネットワーク遅延は毎日ブレる | RTT をログに残し、異常日をバックテスト対象から除外 |
次章では、このデータ基盤の上で 「スプレッド閾値をどう動的化したか」──
p90 テーブルの作り方と、shift_tbl.py
を使った ±bp シフト検証 を詳しく掘り下げます。
第 2 章 スプレッドは“動く目安”── p90 テーブルと ±bp シフト実験
2-1 静的しきい値(0.1 bp)の限界
秒足バックテストを最初に回したとき、私は 「ask − bid ≥ 0.1 bp ならエントリ」 という超シンプルなしきい値で走らせました。
結果はご存じのとおり──
- fill は多いが PnL は右肩下がり
- アジア早朝〜欧州入りの谷間で連敗
- 深夜にだけわずかなプラスが出る
板が薄い時間帯ほど “0.1 bp ですら取りづらい”
—— 実働ロボのログを眺めてようやく腑に落ちました。
2-2 「時間帯ごとに p90 を取る」という発想
解決策は単純で、1 時間ごとに実測スプレッドの 90 パーセンタイルをしきい値にする だけ。
SQL 風 pseudo-code で書くとこうです。
SELECT hour, PERCENTILE(spread, 0.9) AS p90_bp FROM depth10 GROUP BY hour
実装は Python 20 行。make_spread_tbl.py
の核心部は
hr = ((df["ts"] // 1_000) % 86_400) // 3_600 tbl = (pl.DataFrame({"hr": hr, "sp": df["ask_px"] - df["bid_px"]}) .group_by("hr") .agg(pl.quantile("sp", 0.9).alias("p90"))) tbl.write_json(f"spread_tbl_abs_{day}.json", indent=2)
生成される JSON は↓のような配列辞書です。
{ "0": 18.8, "1": 19.1, "2": null, "3": null, "4": 17.7, "5": 17.6, "6": 15.8, "7": 17.7, ... "23": 17.1 }
null
時間帯は取引停止 or データ欠損なので 0 bp 扱い。
2-3 shift_tbl.py ─ “+k bp” を O(1) で量産
p90 は日ごとに 1 〜 2 bp 平均が違う。
毎回 JSON を手で直すのは拷問なので シフト専用スクリプト を書きました。
# +1.5 bp ずらして保存 poetry run python scripts/shift_tbl.py spread_tbl_20250621.json 1.5 \ > spread_tbl_shift+1_5.json
ロジックは単純――辞書値に全部 +shift
して write。
これで -2 〜 +\1.5 bp の 10 テーブルを一気に作り、下のシェルで総当たり。
for s in -2 -1.5 -1 -0.5 0 0.5 1 1.5; do tbl=spread_tbl_shift${s//./_}.json poetry run python backtester.py 20250621 \ --spread-tbl "$tbl" ... --csv-out "scan${s//./_}.csv" done
ベストは「+1.5 bp シフト × Lag 1 s × α = 5」
—— net PnL 最小(≒損失最小)を示した組み合わせです。
2-4 なぜ +1.5 bp が“ちょうど良い”のか?
- 板厚が薄い谷間時間帯をまるごとスキップ
+1.5 bp でeff_spread<thr
になるケースが 35 %→62 % に増え、
“勝てない場” を踏みに行かなくなります。 - Lag 1 s に対して余裕を持った利幅
1 s 待っても ask ≦ quote が成立しやすい幅が 1 bp 強。
しきい値を上げると、市場が動いてもまだ「利幅が 0 を切らない」余白が生まれる。 - fill 数は 25 % 減止まりで、DD は半減
損失日 (DD<-500 USDT
) が 5→2 日に。
“エントリしない勇気” が結局 PnL を守る という教訓。
2-5 コード片:Backtester 側の動的しきい値処理
hr = (df["sec"] % 86_400) // 3_600 # 0-23 thr = hr.replace(spread_tbl).fill_null(spread) # p90 or fallback eff = df["best_ask"] - df["best_bid"] - fee_w mask = eff.to_numpy() >= thr.to_numpy() # NumPy で高速判定 quote_px = np.where(mask, df["best_bid"] + (eff + fee_w)/2, np.nan) df = df.with_columns(pl.Series("quote_px", quote_px))
Polars の replace()
は 辞書キーが str でも OK なので、
JSON そのまま投げ込むだけでマッピング完了です。
2-6 Spread 閾値チューニング:学んだコツ
コツ | Why |
---|---|
p90→bp シフトは +0.5 bp 刻みが十分 | ±0.25 bp で PnL 差は誤差の範囲だった |
Lag を変えたら必ず再チューニング | Lag 3 s にすると “必要幅” が +0.7 bp ほど膨らむ |
出来れば日別に表を作る | 祝日・CPI・FOMC で p90 が 2 bp ずれることがある |
null 時間帯は “取らない” 方がマシ | 0 bp だと真夜中の誤エントリが復活する |
次章では、損失の根源になりやすい Lag とスリッページ をどう測り、
Lag 1 s が最適 と結論づけた検証手順を掘り下げます。
第 3 章 「遅れ」は毒にも薬にも──Lag・スリッページ・強制クローズの最適解
3-1 Lag とは何か?──Maker 約定→ポジション解消までの待ち時間
実運用では Maker 注文が約定した瞬間に “利益確定用の Exit” が入る。
ところが Bybit API では
- 注文約定通知 (fill)
- こちらが Exit 注文を送信
- それが板に刺さって約定
まで 最短でも 500 ms〜1 s かかる。
これをバックテストでは 「exit-lag = 1 s」 のようにパラメータ化しました。
Lag | 想定シナリオ | メリット | デメリット |
---|---|---|---|
0 s | 理想状態(非現実) | 勝率最大 | 実運用とかけ離れる |
1 s | WebSocket fill / REST exit | 実装容易、DD ↓ | 約定数△15 % |
3 s | API 再試投 / リトライ | Fill ↑ | DD ↑ / 在庫膨張 |
>3 s | ネットワーク遅延 | 検証対象外 | ほぼ負け確 |
A/B テスト(α=5, spread=+1 bp シフト)
Lag | fills | net PnL | max DD |
---|---|---|---|
1 s | 6 468 | –300 USDT | –300 |
3 s | 9 412 | –430 USDT | –431 |
5 s | 12 857 | –910 USDT | –1 120 |
結論:Lag を 1 s に抑えるだけで DD が 3 分の 1
—— “速さは最大の防御” を数字で再確認。
3-2 Slippage モデルを 0.5 bp 固定にした理由
Exit を Taker 成行 で投げる場合、
板厚 500 USDT 程度なら 0.2 bp も動かず約定します。
ただし 「薄い夜中 × 価格急騰」 シナリオでは 1 bp 以上滑る。
そこで
slip = lot * exit_px * (slip_bps * 1e-4) # slip_bps = 0.5
taker_cost = lot * exit_px * taker_fee + slip
という “常に 0.5 bp 上乗せ” の単純モデルを採用。
理由は2つ。
- Depth10 データだけではリアルスリップを精緻に再現できない
- 少し多めに見積もった方が実運用との差が小さい
余裕で勝てる設定より、ギリ勝てない設定で Pass した戦略の方が強い。
3-3 fill-prob モデル:板数量 × α で確率化
Maker 注文が fill する確率は「自分のロット vs. 板の数量」にほぼ比例します。
私の近似式は P_fill = min( 1 , (bid_qty / lot) * alpha )
- α = 1 … 板 100 % 食わないと約定しない
- α = 3 〜 5 … 実測 fill とほぼ一致
- α > 7 … 約定を過大評価(見かけの PnL↑で実運用負け)
コードは NumPy 一行。
prob = np.clip((bid_qty_np[i] / lot) * alpha, 0.0, 1.0) if rng.random() >= prob: continue
3-4 EOD 強制フラット(案 B)の実装
Lag 3 s で試した際に頻発した net_pos residual error
を潰すため、
終端で残っているポジションは Taker 成行で強制決済 するロジックを追加。
residual = delta_pos.sum() if abs(residual) > 1e-8: exit_px = mid_np[-1] slip = abs(residual) * exit_px * (slip_bps * 1e-4) taker_c = abs(residual) * exit_px * taker_fee + slip realized[-1] += -residual * exit_px - taker_c delta_pos[-1] += -residual
- 残ポジ 0.006 BTC でも 1 クリックで強制フラット
- Backtest ではもちろん、実運用も
cancel_all
→market_close
で再現可
3-5 Lag & Slippage チューニング:TIPS
チェックポイント | 具体的な見方 | コマンド例 |
---|---|---|
Fill 成功 / 失敗の時系列 | maker_fill 列 & quote_px▲ → exit_px● をプロット | --dump-path debug.parquet → jupyter |
Lag vs. DD | Lag を sweep し max_dd を比較 | for lag in 1 2 3; do ... |
Slippage Sensitivity | slip_bps を 0.3〜1.0 で試行 | --slip-bps 0.8 |
残ポジ有無 | 最後の net_pos がゼロか assert | テスト毎に df['net_pos'][-1] |
3-6 ここまでのまとめ
- Lag 1 s × Slip 0.5 bp が「損を最小化する現実的な下限」。
- Lag が増えるほど DD は線形以上に悪化、fill 増は割に合わない。
- 残ポジが出ると DD が跳ねるので EOD 強制フラットは必須。
- α≒5 までは PnL/Fill の改善余地があるが、
α を上げすぎると机上の空論 になる。
次章では、在庫(ネットポジション)とドローダウン管理 をどうリンクさせ、
「ポジション上限 0.008 → 0.002 BTC」へ絞って損失を抑えたプロセスを解説します。
第4章 “数字が嘘をつく瞬間”──バックテスト・デバッグ暗黒面
TL;DR
- 「残ポジ≠0 問題」を見逃すと PnL が粉飾決算になる
- Polars × NumPy の配列長ずれは “静かに” 仕込み忘れを誘発する
--dump-path
で 1 秒ごとのポジションを吐き出し、可視化→原因究明のループを最速化すべし
4-1 “net_pos residual” に騙された一日
バックテストを 24 時間回したところ、まれに net_pos residual=…
でクラッシュ。
要因を洗ってみると──
原因 | 症状 | 修正パッチ |
---|---|---|
テイカー強制決済ロジックが 終値付近の fill を取りこぼす | 残ポジが 0.008 BTC 残存 → PnL が根拠なくプラス | EOD で未約定分を mid 成行で flat |
np.where で作った quote_px が NaN 混在 | maker_fill.sum() と delta_pos の累計が一致しない | assert np.isfinite(quote_px[maker_fill]).all() を追加 |
NumPy ↔ Polars 変換ミス | IndexError や ColumnNotFoundError がランダムに発火 | 長さ/列名を都度 assert で監視 |
教訓:
“フラフラに残ったポジション” は最終バーで必ず潰す。mask.sum() == maker_fill.sum()
やabs(net_pos[-1]) < 1e-8
をテストに焼き込む。
4-2 データドリルダウンは Parquet → Jupyter が最速
- バックテスト実行時に
--dump-path debug_YYYYMMDD.parquet
を指定
2. Jupyter / Polars で
df = pl.read_parquet("debug_20250621.parquet") df.filter(pl.col("maker_fill")).select("sec","quote_px","realized").head(10)
- 問題区間(例えばドローダウン最大点±30 秒)だけ切り出し → Matplotlib で可視化
これで「何が fill され、どの lag で exit に失敗したか」を秒単位で追える。
CSV ではなく 列型を保ったままの Parquet に絞るだけで、調査速度が数倍になるのを痛感した。
4-3 σウォーミングアップ & 動的閾値の罠
実弾 bot では、起動直後は
if sigma_samples < 60: skip
と丸めていたが、シミュレーションでは σ=0 で閾値が極端に緩くなる時間帯 が発生。
対策は二つだけ。
- ウォームアップ完了前は発注禁止
thr = max(p90 + k·σ , p90*floor)
のように下限フロアを入れておく
これにより「σゼロで暴走 → 在庫過大」の事故がほぼ消滅した。
4-4 バグを“見つける”より“作らない”──再利用チェックリスト
チェックポイント | Why |
---|---|
assert df['net_pos'][-1] == 0 | EOD 強制 flat が効いているか |
assert maker_fill.sum() == (delta_pos < 0).sum() | エントリとポジ減算の整合 |
assert not df.is_null().any().any() | 欠損値由来の NaN 伝播 |
assert len(thr_np) == df.height | 動的閾値ベクトルの長さ不一致 |
CI で 3 日分ランダム seed を毎 push テスト | 典型的ヒューマンバグを自動捕捉 |
次章は「なぜ実弾ブレークイーブンが見えないのか?」──
残る課題を洗い、メインネットで “負けを最小化” する運用手順を詰めます。
第5章 “負けを最小化して勝ち筋をあぶり出す”──メインネット運用ガイド
TL;DR
本章は「まだ+エッジが薄い」状態で実弾を回すときの 安全策 と 学習サイクル をまとめた実践ドキュメント。
目標は ① 想定外の大負けを防ぎ、② 検証に必要な生ログを欠損なく回収する ことに尽きます。
5-1 3レイヤー・ガードレール構成
レイヤー | 守るもの | 実装スイッチ | 具体的な閾値例 |
---|---|---|---|
A. 取引所レバ制限 | アカウント破綻 | bybit:set_leverage 5x | “証拠金 20 % 消費で強制手動停止” |
B. bot ローカル Risk Guard | 日次最大 DD | .env→DAY_RISK_LIMIT_USDT= –100 | “日次 PnL –100 USDT 突破で AutoStop ” |
C. シグナル・ガード | 異常 fill / σスパイク | sigma_guard.py | 5 min で σ > 2×MA(σ) なら発注禁止 |
ポイント: A は「最悪でも助かる」、B で “今日は撤収”、C は “今は静観”。
各レイヤは独立にログを吐き、発動理由がダッシュボードに残るようにする。
5-2 デイリー運用フロー(UTC 基準)
時刻 | タスク | コマンド備考 |
---|---|---|
00:00 | Recorder 起動 → L2/Trade/RTT 全ストリーム収集 | docker compose up recorder |
00:15 | Backtester リグレッション(前日分) | ./ci_backtest.sh $(date -d '-1 day' +%Y%m%d) |
00:25 | スプレッド p90 + kσ テーブル生成 | python scripts/make_spread_tbl.py D ; shift_tbl.py +1.5 |
00:30 | mmbot 起動 (当日テーブル読み込み) | python mmbot.py --spread-tbl <latest> |
12:00 | Mid-day health-check & DD シート更新 | python audit.py --sync notion |
23:45 | Bot 自動停止 & ポジ一括クローズ | API POST /v5/position/closeAll |
23:50 | Recorder 停止 → Parquet 圧縮 & アップロード | aws s3 sync raw/ s3://…/daily=D/ |
5-3 “負けを最小化する”ロジック設定要点
パラメータ | 実弾値 | なぜ? |
---|---|---|
Lag | 1 s 固定 | exit 成功率 80 %超・DD ≤ 0.1 %eq |
kσ シフト | +1.5 bp | 日次ロス最小・機会 4 割維持 |
lot_btc | 0.002 (≒200 USDT notion) | σスパイクでも LiqPrice > 2 ×ATR |
fill-prob α | 3–5 | 実 fill率 ≒ Backtest ±10 % 以内 |
EOD Taker 強制 flat | ON | ポジ持ち越し=翌日 σ 計算破綻 |
現状、これで 日次 DD を ≤1 % eq に抑えつつ、機会損失を許容範囲に収められます。
5-4 ログ/メトリクス──“後で困らない”ための必須項目
種別 | 収集粒度 | 目的 |
---|---|---|
Spread L2 | 100 ms | 実戦での p90, σ リコンピュート |
Trade Tape | 全約定 | fill-prob & slippage 実測 |
σ Tracker | 1 s (rolling 60s) | 異常検知/シグナルガードの根拠 |
Bot Events | 全状態遷移 | 擬似タイムトラベルで再現テスト |
PnL Snapshots | 1 min | DD 曲線と EOD マッチング |
5-5 “不測の事態”ハンドリング手順
- 発注失敗率が 3 % 超え
- 取引所障害か API quota 超過 → 即
AutoStop
・手動監視へ
- 取引所障害か API quota 超過 → 即
- σ がゼロに張り付く
- テープ欠損の疑い → Recorder プロセスを再起動
- DD > 1 % eq 連発
- スプレッドテーブルを即日再学習 (
make_spread_tbl.py
) - lot_size を 0.001 に半減、翌日まで継続観察
- スプレッドテーブルを即日再学習 (
小結 —— “負けを限定しながら、データで勝ち筋を買う”
- ガードレール3段活用 で「一発退場」を物理的に防ぐ
- 常時 Recorder で「情報の取りこぼしゼロ」を保証
- 運用⇔解析を 24h サイクル にして、翌日の 閾値・lot を即反映
こうして “失敗コストを定額サブスク化” しつつ、真にプラスエッジなパターン をデータから炙り出す──
それが今フェーズの開発・検証ロードマップです。
第6章 “データで磨く”──ログ解析と継続チューニング
ここでやること
メインネット実弾で溜めた生ログ を、毎日どう削って・眺めて・数字にするか──
バックテスト ⇒ 現場ログ ⇒ リトレーニング を 24 h で回す “改善ループ” の実戦手順です。
6-1 日次ログパイプライン(UTC+0)
raw/ ← Recorder が吐く Parquet(L2・Tape・RTT) └── yyyy-mm-dd/ ├─ orderbook_*.parquet ├─ trades_*.parquet └─ rtt_*.parquet etl/ └── yyyy-mm-dd/ ├─ ob_clean.parquet ← 欠損/重複 row を除去 ├─ tape_norm.parquet← pnl_calc 用に型変換 ├─ spread_tbl.json ← make_spread_tbl.py (p90) └─ sigma_series.parquet reports/ └── yyyy-mm-dd/ ├─ pnl_hourly.csv ← dd, slope の日内変化 └─ guard_hits.log ← 全ガードレール発火履歴
要点
- ETLは Parquet→Parquet のストリーム処理
- join/agg は Polars Lazy で 2 〜 3 min/日。
- 欠損シンボルは “NA row” を残す(再現テストで時系列揃え)
- reports は必ず CSV or JSON
- Grafana, Notion, Slack への転送がラクになる。
6-2 5つの KPI と “見る順番”
優先 | KPI | 何を見る? | 閾値例 | アクション |
---|---|---|---|---|
① | 日次 DD (%eq) | min(equity) / start_eq –1 | –1 % | lot 半分に縮小 |
② | Fill Efficiency | fills_real / fills_expected | < 0.7 | α↓ or lot↓ |
③ | σ Guard Hits | 発火回数 / 24h | > 5 | kσ +0.5 bp シフト |
④ | Slip per Trade (bp) | `avg | entry-exit | – spread` |
⑤ | Missing Ticks (%) | 欠損 row / 86 400 | > 0.5 % | Recorder 再起動 |
コツ: ①❯②❯③…の順にグラフを開く。
DD が許容内なら次の指標へ── “木を見て森を見失わない” 優先度を固定。
6-3 p90 + kσ テーブルの自動リラーニング
# 前日データで p90 再計算 make_spread_tbl.py 20250622 > spread_tbl_abs_20250622.json # kσ を +1.0, +1.5 sweep for k in 1 1.5; do shift_tbl.py spread_tbl_abs_20250622.json $k \ > spread_tbl_shift+${k}.json done # Guard Hits と DD の実績で翌日テーブルを選択 choose_tbl.py reports/20250622/guard_hits.log \ reports/20250622/pnl_hourly.csv
- choose_tbl.py は単純ルール:
- GuardHits ≤ 2 回 → k を –0.5 下げ(チャンス増)
- DD > 1 % eq → k を +0.5 上げ(守り寄り)
- “2 日連続で hits=0 & DD<0.3 %” になったら lot×1.2 倍。
6-4 slippage モデルの再学習
- 1.Trade Tape から entry ↔ exit ペアを紐付け
df['lag'] = df.groupby('order_id').cumcount() # maker fill→exit slip = df.query("lag==1").price - df.query("lag==0").price
- 2.lag=1 s の slip 分布 → オフセット
slip_bps = p90(slip)*1e4
- 3.Backtester に
--slip-bps
を上書きし、追試 → KPI②③ を再確認。
6-5 “現場ログ ⇒ Backtest で完全再現” チェック
python backtester.py 20250622 \ --ohlcv etl/20250622/ob_clean.parquet \ --depth etl/20250622/ob_clean.parquet \ --funding data/funding/bybit_fr_20250622.csv \ --spread-tbl spread_tbl_used.json \ --lot-btc 0.002 --exit-lag 1 --fill-prob-mode depth \ --alpha 5 --fee-in-spread --maker-fee -0.0002 \ --dump-path replay_20250622.parquet
- replay PnL ≒ 現場 PnL ±1 bp がゴール
- 乖離が大きい時は
- Depth 欠損 → Recorder
- fill-prob モデル → α
- slip モデル →
slip_bps
を疑う、の3択で済む。
まとめ:“負けを買ってデータを貯める” が次フェーズの主戦略
- 毎日 24h で ETL→KPI→テーブル更新 の 学習ループ を回す
- ガードレール3段で 最大損失をサブスク化
- ログ欠損ゼロ ➜ “再現 Backtest” が成立 ➜ 改善ポイントが一意に割り出せる
こうして「損小さく、学び重く」を徹底すれば、
真にプラスサムな kσ・Lag・α の組み合わせ がデータから必ず浮かび上がる──
これが、FR-MM 開発を次のステージへ進めるためのコツです。
第7章 “負けを止める”──ガードレール実装レシピ
ここでやること
破滅的なドローダウン(DD)・想定外のスリッページ・API 停止――
そんな “即死イベント” を 100 ms 以内 に検知し、
自動で建玉を閉じてストラテジを凍結 するための実装手順をまとめます。
7-1 3段階ガードの全体像
レイヤ | 目的 | トリガ条件(例) | 動作 | 復帰条件 |
---|---|---|---|---|
GR-1 (ポジション) | DD/在庫の即時制限 | ` | net_pos | > 0.01 BTC <br> equity_drawdown < –1 %` |
GR-2 (マーケット) | 異常ボラ・流動性枯渇 | eff_spread > 3·p90 かつ depth_ask<min_qty | → アルゴ一時停止 10 min | 10 min 経過 & GR-1 OK |
GR-3 (インフラ) | API/L2 欠損・時刻ズレ | RTT > 1 s 連続 3 回 or missing_ticks > 0.5 % | → kill -9 bot & Slack ping | SRE が再起動 |
7-2 実装コードスニペット(FastAPI + asyncio)
class GuardRail: def __init__(self, cfg: dict, ex: BybitClient): self.ex = ex self.cfg = cfg self.hit = defaultdict(int) # {'GR-1': 0, ...} async def poll(self): pos = await self.ex.position() eq = await self.ex.equity() ob = await self.ex.orderbook() # depth=1 rtt = self.ex.last_rtt() # ---- GR-1: ポジション/DD ---- if abs(pos.qty) > self.cfg['pos_max'] \ or eq.drawdown_pct < self.cfg['dd_pct']: await self._panic_exit('GR-1') # ---- GR-2: マーケット品質 ---- spread = ob.best_ask - ob.best_bid if spread > self.cfg['spread_factor']*self.cfg['p90'] \ and ob.best_ask_qty < self.cfg['min_qty']: await self._pause('GR-2', 600) # ---- GR-3: インフラ ---- if rtt > 1.0: self.hit['GR-3'] += 1 else: self.hit['GR-3'] = 0 if self.hit['GR-3'] >= 3: await self._panic_exit('GR-3', kill=True) async def _panic_exit(self, tag, kill=False): logger.warning(f"[{tag}] PANIC EXIT!") await self.ex.cancel_all() await self.ex.market_exit() notify_slack(f":rotating_light: {tag} triggered, bot halted.") if kill: os._exit(1) async def _pause(self, tag, secs): logger.warning(f"[{tag}] pause {secs}s") self.ex.set_enabled(False) await asyncio.sleep(secs) self.ex.set_enabled(True)
7-3 テスト方法 ― “シミュレーテッド崖落ち”
- Backtester にガード条件を注入
- sec= N で
mid_px *= 0.97
の瞬間ドロップを挿入 lag
やdepth_qty
をゼロにする fault event も生成
- sec= N で
--guard-sim
オプションで再生- 期待: GR-1 → panic_exit で net_pos=0、止まる
- 現場と同じ Slack Webhook をテスト用にリダイレクト
- “どの msg が何秒遅れで来るか” まで計測
7-4 復帰フローをRunbook 化する
STEP | オーナー | コマンド/URL | 期待 |
---|---|---|---|
1. DD/ガード原因の確認 | on-call | reports/${date}/guard_hits.log | トリガ判定一致 |
2. Recorder 欠損確認 | on-call | etl_check.sh ${date} | missing_ticks ≤ 0.5 % |
3. Bot 起動 | on-call | docker compose up -d mmbot | Slack OK ping |
4. 30 min 監視 | on-call | Grafana dashboard | DD <0.2 % |
7-5 “負けを最小化”ガードの効果測定
メトリクス | ガード前 | ガード後 | 改善 |
---|---|---|---|
日次最大 DD | –3.6 % | –1.2 % | ▼66 % |
ボラ極端時の平均 Slip (bp) | 4.1 | 1.7 | ▼58 % |
Bot ダウンタイム (min/月) | 190 | 38 | ▼80 % |
結論: ガードを「強く・早く」掛ける方が 学習効率も PnL も向上。
負けを “早く確定損” にしてデータ化し、次のテーブル学習に回す──
これが FR-MM の実戦ロードマップ です。
次章では、「Funding バイアス×スキャルピング」 をどう重ねるか――
実弾データで得た新しい勝ち筋を、具体的なオーダーフローに落とし込んでいきます。
第8章 Funding × スキャルピング──「もらい‐抜け」で積むミニα
ここでやること
先物⇆現物の資金調達率(Funding Rate)ゆがみを、
1 秒足スキャル に重ねて “薄く・速く” 拾う小技を解説します。
狙いは「メインのスプレッド戦略が“張らない”局面でも +ε の収益源を確保する」こと。
8-1 なぜ Funding を重ねるのか?
課題 | 従来のスプレッド戦略だけ | Funding を重ねた場合 |
---|---|---|
閾値超え待ち時間 | 長い(待機中 ≒ 無収益) | Funding バイアス単体でポジ保有 → 利回り発生 |
σ_warm-up 中 | 取引ゼロ | “低レバ 逆張り‐Funding” で小さく回る |
DD からのリカバリ | Release を待つのみ | Funding α で赤字を食い止めながら回復 |
8-2 インプットに追加すべきデータ
データ | 取得先 / 期間 | 用途 |
---|---|---|
Funding Rate tick (8 h or 1 h 前後) | Bybit API v5/market/tickers | 予測用 fr_t+1 |
限月間ベーシス | CME/Binance Perp vs Spot | 現物ヘッジ可否 & バイアス検証 |
Funding 返済履歴 | Exchange billing API | 実際の cash-flow 検算 |
Cross‐exchange borrow cost | OKX, Binance Funding | ネット Funding 戦略 (arb) |
Recorder 拡張点
- Funding Snapshot を 30 s ごとパース・保存(CSV/Parquet)。
depth_orderbook
と 同期タイムスタンプ で join 可能にする。- Dataclass の
FundingSlice(sec, fr, basis)
を backtester に渡す。
8-3 バックテスター側のロジック追加
def funding_cashflow(pos_qty, fr, interval=8*60*60): """fr: 年率 (decimal) → interval 分割の現金流に換算""" cash = pos_qty * fr * interval / (365*24*60*60) return cash # USDT 建て
- calc() 内で
fund_cf = funding_cashflow(net_pos.shift(1), df["fr"]) realized += fund_cf
- ガードレール連動
Funding が “負” でかつ Spread アルファ無 → 自動 Exit。
8-4 実戦オーダーフロー
- 1.FR バイアスシグナル
bias = fr_now - fr_8h_avg if bias < -0.02%: # 支払側 avoid_long_maker() if bias > +0.02%: # 受取側 allow_long_maker(size=min(lot, bias_factor))
- 2.スキャルピング Entry
- Funding 受取方向の maker grid を 0.5 σ 分だけ “奥” に置く
- Exit は Lag = 1 s taker(手数料は Funding が補填)
- 3.EOD 決済
- Funding 直前には 強制フラット → 次の 8 h セッションへ
8-5 検証指標
指標 | 追加前 | 追加後 | 目標 |
---|---|---|---|
日次収益の非稼働時間あたり PnL | ≈0 | > 0.1 bp/h | +0.1 bp/h |
Funding 由来 PnL 比率 | 0 % | > 15 % | 分散源確保 |
DD リカバリ所要時間 | 14 h | < 8 h | -40 % |
8-6 実装チェックリスト
- Funding Snapshot が L1/Trade と同タイム粒度で保存されている
- Backtester で
funding_cost
が 非ゼロ になる - ガードレール
GR-1
で “Funding 負担ポジ” を通知 - Grafana に Funding PnL パネル 追加済み
ここまで出来れば:
- メインのスプレッド α が “沈黙” しても、
Funding スキャルで サブ α を得られる- ガードで「払うだけ」の地獄を 0.5 s 以内に遮断
- 複数のミニαを “隙間なく” 重ね、負けを 連続時間 で最小化する
次章では、実弾投入のチェックリスト と
モニタリング・アラート設計 をまとめ、
「安全に、かつ早く」プロダクションへ移すステップを解説します。
第9章 デプロイ直前チェックリスト──“本番で事故らない”ための 8 項目
ゴール:
テストネットを離れ、メインネットで実弾を流す前に
「これだけは確認しておく」チェック項目を一枚にまとめます。
30 USDT の損失を 30 k USDT に拡大させない 最後のセーフティネット。
9-1 リスク・パラメータ確認
項目 | 既定値 | 推奨 Before Prod | 理由 |
---|---|---|---|
lot_btc | 0.002 | 0.0005~0.001 | 初期 DD を 10 USDT 未満 に抑える |
max_position | 5×lot | 3×lot | Funding バースト/急落対策 |
exit_lag | 1 s | 1 s 固定 | >1 s でスリップ・残ポジ増 |
maker_fee | 0 ~ −2 bp | 0 | リベート 0 前提で評価 |
taker_fee | 5.5 bp | 取引所値 | 手動設定ミス防止 |
9-2 ガードレールとアラート設定
ガード ID | Trigger | 処置 | 通知 |
---|---|---|---|
GR-1 | Funding < −0.02 % & net_pos > 0 | 全注文 cancel + 全ポジ flatten | Slack #alert |
GR-2 | Equity DD > −1 %/h | lot_btc ×0.5, spread_thr +0.5 bp | PagerDuty |
GR-3 | API error > 5/min | スクリプト自動再起動 | OpsGenie |
実装ヒント
アラート送信はasyncio.gather()
で取引ロジックと並列実行し、
通知遅延で order loop が止まらない設計に。
9-3 モニタリング・ダッシュボード
- PnL Breakdown – Gross Edge / Slip / Funding を色分け
- Spread vs Threshold 曲線 – 実効スプレッドを秒単位でプロット
- Position Heat-map – 時間 × NetPos の 2D ヒート図
- API RTT + Error Rate – 400/429/503 をスタックエリアで
9-4 デプロイ‐フロー(runbook)
graph TD A[Git merge develop→main] -->|Tag vX.Y| B(CI build docker) B --> C[Testnet smoke] C -->|all green| D[Manual approve] D --> E[Prod k8s roll-out] E --> F[Health-check 60s] F -->|OK| G[Scale lot_btc ↑ by Ansible] F -->|NG| H[Auto rollback←previous tag]
9-5 “ローリング立ち上げ”のコツ
- lot_btc を 1/10 で開始 → Equity DD が 0 なら 30 min ごとに
×2
- Spread 閾値は +1.5 bp → 4 h 問題無ければ −0.25 bp ずつ下げる
- Funding-α を 遅延 ON(warm-up=1 h)で開始
- 24 h 経過後、Parquet ログを 自動圧縮 & S3 へアーカイブ
9-6 “万一”の手動トラブルシュート
症状 | 即時アクション | 根本原因チェック |
---|---|---|
NetPos ≠ 0 & 注文無 | CancelAll → MarketClose | Exit API rate-limit / WS lag |
DD > 想定値 ×2 | lot_btc = 0 で凍結 | Spread spike / Funding flip |
Equity flat but fills>0 | 手数料設定 or Slip バグ | taker_fee typo / depth 異常 |
9-7 チェックリスト
lot_btc
初期値 0.0005exit_lag
= 1 s 固定- ガード GR-1~3 動作確認(Testnet)
- Slack & PagerDuty Webhook OK
- Grafana dashboard UID 保存済
- S3 バケット/パス権限 OK
- Runbook 最新版を
/doc/runbook.md
にコミット - Tag
vProd-YYYYMMDD
を push
これで、いつでも“実弾”を流せる状態です。
“負けを最小化”するフェーズは終わり。
次はいよいよ「薄いエッジを実収益に変え、
ダウンサイドを限定しつつサイズを掛ける」拡張へ向かいます。
今後 90 日のロードマップ ─ “負けを最小化 → 勝ちを積み上げる”5ステップ
フェーズ | 目標 | 主要タスク | 期待アウトプット | 目安期間 |
---|---|---|---|---|
P0 データ強化 | L2 & トレードテープを常時取得 | - Recorder に depth/50ms + publicTrade + ws RTT 実装- AWS S3 自動アップロード+ Glue カタログ作成 | 10 GB/日 のローデータ + Athena で即クエリ可 | 週 1 |
P1 モデル精度 | Fill / Slip 推定誤差 < 5 % | - 双方向深さによる Fill モデル再学習 (lightGBM) - Slip = f(depth, vol, lag) 回帰 | fill_prob.pkl / slip_reg.pkl | 2 週 |
P2 動的エントリ | p90+σ 閾値 → RL/PPOシフター | - 状態: spread, σ, netPos, funding - 報酬: edge – λ·DD - gym-like Sim に接続 | entry_policy.pt (Tensor) | 3 週 |
P3 在庫リスク制御 | MaxDD < -1 %/day | - NetPos KPI → PID で lot_btc 動的調整 - Funding バイアスをヘッジ (逆サイド taker) | risk_ctl.py モジュール | 2 週 |
P4 マルチアセット | BTC 以外で Edge 確認 | - ETH / SOL / DOGE に Recorder 拡張 - Backtest パラ並列化 (Ray) | アセット別 PnL レポート | 2 週 |
P5 段階的本番拡大 | 実収益 > 0(累計) | - P0-P4 成果を Prod ブランチへ - ロールアウト Runbook で lot ×2→×4… | 30 日連続プラス | 4 週 |
マイルストーン & KPI
- M1(Day 14):Fill 誤差 < 5 %/Slip 誤差 < 8 %
- M2(Day 35):Backtest Sharpe > 1.2/日次 DD<-1 %
- M3(Day 60):Testnet 30 日連続 +PnL
- M4(Day 90):メインネット累積 +収益転換
ゴールイメージ:
① データの“解像度”を上げる → ② モデルで薄い Edge を取りこぼさない →
③ 在庫&DD リスクを自動制御 → ④ アセット分散でボラ捕捉 → ⑤ サイズ掛けて収益化。
これが、負けを最小化したうえで “勝てる FR-MM” を組み上げるためのロードマップです。
おわりに — “負けを最小化する”その先へ
本稿では、
- データ解像度を高める
- 動的エントリ & リスク制御で損失を抑え込む
- 検証-→実弾のループを高速化する
――という三段跳びで「マーケットメイキングの“負け筋”を潰し、勝ち筋を露わにする」思考プロセスをまとめました。
バックテスターの修正に始まり、Recorder 拡張・シミュレータ自動化・RL ポリシーまで道のりは長いですが、“真のコスト”=機会損失を可視化できれば、実弾検証は一気に加速します。
- タスクの優先順位はロードマップ表の通り。焦点は P0–P2(データ強化 & モデル精度)にあります。
- KPI は “Fill/Slip 誤差” と “日次 DD” を並走で追うこと。
Next step
- Recorder に L2 + Trade テープを実装し、S3 パイプラインを敷く
- 収集 1 週間で Fill/Slip モデルを学習 → Backtest Sharpe の改善度を確認
- 改善が確認できたら RL/PPO に着手し、Testnet で 30 日ランを回す
あとは **「回しながら学ぶ」**だけです。失敗ログも将来の“反向きシグナル”になり得ます。地道な記録が、やがて alpha を生み出す――そのプロセス自体が、今回の一番の学びでした。