Bot mmnot トレードロジック プログラミングスキル 開発ログ

🛠️開発記録#256(2025/6/23)負けを最小化するロジック ─ FR-MM 開発メモ

私は Bybit BTC/USDT パーペチュアルで メイカー手数料リベート 0 という “逆風設定” で MM ボットを動かしています。
しばらく開発を続けるうちに「板に貼り付けば勝てる」という思い込みが砕け散り、ようやく “負けを可視化し削る” という本質的な作業に着手しました。
この記事は、その開発過程を振りかえってバックテスト・バグ潰し・指標チューニングの全ログから

どうやって “死なないボット” まで持っていったか?

超具体的に 共有するものです。

第 0 章 TL;DR(私の失敗→修正サイクルを 30 秒で)

まずは “教訓のまとめ” からスタートします。

#取り組み痛み修正結果
1静的 0.1 bp でエントリ連射スリップ+手数料で ‐1 400 USDT(バックテスト時)動的 p90 + σ 閾値 導入DD 大幅に圧縮
2Exit Lag 3 s/ノー強制フラットnet_pos 残り → ロールオーバー爆死Lag 1 s + EOD 成行フラット残ポジ = 0 確認
3Fill=深度÷lot を 100 % 採用Fill 過剰見積もり→PnL 幻深度比例 α=3〜5Fill=6 %、現実に整合
4Maker リベートに頼る実運用は Rebate 0fee_w を閾値から除外し薄利拡大ネガティブ edge 把握
5“何となく Python” 実装NumPy⇆Polars で IndexError毎回 assert+Parquet dumpバグ検知が即日化

ここでの学び

  1. 勝ち筋探索 より先に 負け筋の絶縁
  2. データは “秒 OHLCV+L1” だけでは足りない。L2 深度・Trade テープ・RTT が必須。
  3. 強制フラットは「ロジックの保険」。在庫リスクは 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

と日付-時間で切るだけの単純構成。
Polarspl.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 movementdf_bt.join(df_l2, on="sec") で散布図
    • スリッページ分布df_tr からヒストグラム
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.concatmatplotlib で比較。

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. 板厚が薄い谷間時間帯をまるごとスキップ
    +1.5 bp で eff_spread<thr になるケースが 35 %→62 % に増え、
    “勝てない場” を踏みに行かなくなります。
  2. Lag 1 s に対して余裕を持った利幅
    1 s 待っても ask ≦ quote が成立しやすい幅が 1 bp 強。
    しきい値を上げると、市場が動いてもまだ「利幅が 0 を切らない」余白が生まれる。
  3. 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 では

  1. 注文約定通知 (fill)
  2. こちらが Exit 注文を送信
  3. それが板に刺さって約定

まで 最短でも 500 ms〜1 s かかる。
これをバックテストでは 「exit-lag = 1 s」 のようにパラメータ化しました。

Lag想定シナリオメリットデメリット
0 s理想状態(非現実)勝率最大実運用とかけ離れる
1 sWebSocket fill / REST exit実装容易、DD ↓約定数△15 %
3 sAPI 再試投 / リトライFill ↑DD ↑ / 在庫膨張
>3 sネットワーク遅延検証対象外ほぼ負け確

A/B テスト(α=5, spread=+1 bp シフト)

Lagfillsnet PnLmax DD
1 s6 468–300 USDT–300
3 s9 412–430 USDT–431
5 s12 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つ。

  1. Depth10 データだけではリアルスリップを精緻に再現できない
  2. 少し多めに見積もった方が実運用との差が小さい

余裕で勝てる設定より、ギリ勝てない設定で 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_allmarket_close で再現可

3-5 Lag & Slippage チューニング:TIPS

チェックポイント具体的な見方コマンド例
Fill 成功 / 失敗の時系列maker_fill 列 & quote_px▲ → exit_px● をプロット--dump-path debug.parquet → jupyter
Lag vs. DDLag を sweep し max_dd を比較for lag in 1 2 3; do ...
Slippage Sensitivityslip_bps を 0.3〜1.0 で試行--slip-bps 0.8
残ポジ有無最後の net_pos がゼロか assertテスト毎に df['net_pos'][-1]

3-6 ここまでのまとめ

  1. Lag 1 s × Slip 0.5 bp が「損を最小化する現実的な下限」。
  2. Lag が増えるほど DD は線形以上に悪化、fill 増は割に合わない。
  3. 残ポジが出ると DD が跳ねるので EOD 強制フラットは必須
  4. α≒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 変換ミスIndexErrorColumnNotFoundError がランダムに発火長さ/列名を都度 assert で監視

教訓:
“フラフラに残ったポジション” は最終バーで必ず潰す。
mask.sum() == maker_fill.sum()abs(net_pos[-1]) < 1e-8 をテストに焼き込む。


4-2 データドリルダウンは Parquet → Jupyter が最速

  1. バックテスト実行時に
--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)
  1. 問題区間(例えばドローダウン最大点±30 秒)だけ切り出し → Matplotlib で可視化

これで「何が fill され、どの lag で exit に失敗したか」を秒単位で追える。
CSV ではなく 列型を保ったままの Parquet に絞るだけで、調査速度が数倍になるのを痛感した。


4-3 σウォーミングアップ & 動的閾値の罠

実弾 bot では、起動直後は

if sigma_samples < 60: skip

と丸めていたが、シミュレーションでは σ=0 で閾値が極端に緩くなる時間帯 が発生。
対策は二つだけ。

  1. ウォームアップ完了前は発注禁止
  2. thr = max(p90 + k·σ , p90*floor) のように下限フロアを入れておく

これにより「σゼロで暴走 → 在庫過大」の事故がほぼ消滅した。


4-4 バグを“見つける”より“作らない”──再利用チェックリスト

チェックポイントWhy
assert df['net_pos'][-1] == 0EOD 強制 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.py5 min で σ > 2×MA(σ) なら発注禁止

ポイント: A は「最悪でも助かる」、B で “今日は撤収”、C は “今は静観”。
各レイヤは独立にログを吐き、発動理由がダッシュボードに残るようにする。


5-2 デイリー運用フロー(UTC 基準)

時刻タスクコマンド備考
00:00Recorder 起動 → L2/Trade/RTT 全ストリーム収集docker compose up recorder
00:15Backtester リグレッション(前日分)./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:30mmbot 起動 (当日テーブル読み込み)python mmbot.py --spread-tbl <latest>
12:00Mid-day health-check & DD シート更新python audit.py --sync notion
23:45Bot 自動停止 & ポジ一括クローズAPI POST /v5/position/closeAll
23:50Recorder 停止 → Parquet 圧縮 & アップロードaws s3 sync raw/ s3://…/daily=D/

5-3 “負けを最小化する”ロジック設定要点

パラメータ実弾値なぜ?
Lag1 s 固定exit 成功率 80 %超・DD ≤ 0.1 %eq
kσ シフト+1.5 bp日次ロス最小・機会 4 割維持
lot_btc0.002 (≒200 USDT notion)σスパイクでも LiqPrice > 2 ×ATR
fill-prob α3–5実 fill率 ≒ Backtest ±10 % 以内
EOD Taker 強制 flatONポジ持ち越し=翌日 σ 計算破綻

現状、これで 日次 DD を ≤1 % eq に抑えつつ、機会損失を許容範囲に収められます。


5-4 ログ/メトリクス──“後で困らない”ための必須項目

種別収集粒度目的
Spread L2100 ms実戦での p90, σ リコンピュート
Trade Tape全約定fill-prob & slippage 実測
σ Tracker1 s (rolling 60s)異常検知/シグナルガードの根拠
Bot Events全状態遷移擬似タイムトラベルで再現テスト
PnL Snapshots1 minDD 曲線と EOD マッチング

5-5 “不測の事態”ハンドリング手順

  1. 発注失敗率が 3 % 超え
    • 取引所障害か API quota 超過 → 即 AutoStop・手動監視へ
  2. σ がゼロに張り付く
    • テープ欠損の疑い → Recorder プロセスを再起動
  3. 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   ← 全ガードレール発火履歴

要点

  1. ETLは Parquet→Parquet のストリーム処理
    • join/agg は Polars Lazy で 2 〜 3 min/日。
  2. 欠損シンボルは “NA row” を残す(再現テストで時系列揃え)
  3. reports は必ず CSV or JSON
    • Grafana, Notion, Slack への転送がラクになる。

6-2 5つの KPI と “見る順番”

優先KPI何を見る?閾値例アクション
日次 DD (%eq)min(equity) / start_eq –1–1 %lot 半分に縮小
Fill Efficiencyfills_real / fills_expected< 0.7α↓ or lot↓
σ Guard Hits発火回数 / 24h> 5kσ +0.5 bp シフト
Slip per Trade (bp)`avgentry-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 は単純ルール:
    1. GuardHits ≤ 2 回 → k を –0.5 下げ(チャンス増)
    2. 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 min10 min 経過 & GR-1 OK
GR-3
(インフラ)
API/L2 欠損・時刻ズレRTT > 1 s 連続 3 回
or missing_ticks > 0.5 %
kill -9 bot & Slack pingSRE が再起動

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 テスト方法 ― “シミュレーテッド崖落ち”

  1. Backtester にガード条件を注入
    • sec= Nmid_px *= 0.97 の瞬間ドロップを挿入
    • lagdepth_qty をゼロにする fault event も生成
  2. --guard-sim オプションで再生
    • 期待: GR-1 → panic_exit で net_pos=0、止まる
  3. 現場と同じ Slack Webhook をテスト用にリダイレクト
    • “どの msg が何秒遅れで来るか” まで計測

7-4 復帰フローをRunbook 化する

STEPオーナーコマンド/URL期待
1. DD/ガード原因の確認on-callreports/${date}/guard_hits.logトリガ判定一致
2. Recorder 欠損確認on-calletl_check.sh ${date}missing_ticks ≤ 0.5 %
3. Bot 起動on-calldocker compose up -d mmbotSlack OK ping
4. 30 min 監視on-callGrafana dashboardDD <0.2 %

7-5 “負けを最小化”ガードの効果測定

メトリクスガード前ガード後改善
日次最大 DD–3.6 %–1.2 %▼66 %
ボラ極端時の平均 Slip (bp)4.11.7▼58 %
Bot ダウンタイム (min/月)19038▼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 costOKX, Binance Fundingネット Funding 戦略 (arb)

Recorder 拡張点

  1. Funding Snapshot を 30 s ごとパース・保存(CSV/Parquet)。
  2. depth_orderbook同期タイムスタンプ で join 可能にする。
  3. 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_btc0.0020.0005~0.001初期 DD を 10 USDT 未満 に抑える
max_position5×lot3×lotFunding バースト/急落対策
exit_lag1 s1 s 固定>1 s でスリップ・残ポジ増
maker_fee0 ~ −2 bp0リベート 0 前提で評価
taker_fee5.5 bp取引所値手動設定ミス防止

9-2 ガードレールとアラート設定

ガード IDTrigger処置通知
GR-1Funding < −0.02 % & net_pos > 0全注文 cancel + 全ポジ flattenSlack #alert
GR-2Equity DD > −1 %/hlot_btc ×0.5, spread_thr +0.5 bpPagerDuty
GR-3API error > 5/minスクリプト自動再起動OpsGenie

実装ヒント
アラート送信は asyncio.gather() で取引ロジックと並列実行し、
通知遅延で order loop が止まらない設計に。


9-3 モニタリング・ダッシュボード

  1. PnL Breakdown – Gross Edge / Slip / Funding を色分け
  2. Spread vs Threshold 曲線 – 実効スプレッドを秒単位でプロット
  3. Position Heat-map – 時間 × NetPos の 2D ヒート図
  4. 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 → MarketCloseExit API rate-limit / WS lag
DD > 想定値 ×2lot_btc = 0 で凍結Spread spike / Funding flip
Equity flat but fills>0手数料設定 or Slip バグtaker_fee typo / depth 異常

9-7 チェックリスト

  • lot_btc 初期値 0.0005
  • exit_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.pkl2 週
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

  1. M1(Day 14):Fill 誤差 < 5 %/Slip 誤差 < 8 %
  2. M2(Day 35):Backtest Sharpe > 1.2/日次 DD<-1 %
  3. M3(Day 60):Testnet 30 日連続 +PnL
  4. M4(Day 90):メインネット累積 +収益転換

ゴールイメージ
① データの“解像度”を上げる → ② モデルで薄い Edge を取りこぼさない →
③ 在庫&DD リスクを自動制御 → ④ アセット分散でボラ捕捉 → ⑤ サイズ掛けて収益化。

これが、負けを最小化したうえで “勝てる FR-MM” を組み上げるためのロードマップです。

おわりに — “負けを最小化する”その先へ

本稿では、

  1. データ解像度を高める
  2. 動的エントリ & リスク制御で損失を抑え込む
  3. 検証-→実弾のループを高速化する

――という三段跳びで「マーケットメイキングの“負け筋”を潰し、勝ち筋を露わにする」思考プロセスをまとめました。

バックテスターの修正に始まり、Recorder 拡張・シミュレータ自動化・RL ポリシーまで道のりは長いですが、“真のコスト”=機会損失を可視化できれば、実弾検証は一気に加速します。

  • タスクの優先順位はロードマップ表の通り。焦点は P0–P2(データ強化 & モデル精度)にあります。
  • KPI は “Fill/Slip 誤差” と “日次 DD” を並走で追うこと。

Next step

  1. Recorder に L2 + Trade テープを実装し、S3 パイプラインを敷く
  2. 収集 1 週間で Fill/Slip モデルを学習 → Backtest Sharpe の改善度を確認
  3. 改善が確認できたら RL/PPO に着手し、Testnet で 30 日ランを回す

あとは **「回しながら学ぶ」**だけです。失敗ログも将来の“反向きシグナル”になり得ます。地道な記録が、やがて alpha を生み出す――そのプロセス自体が、今回の一番の学びでした。

-Bot, mmnot, トレードロジック, プログラミングスキル, 開発ログ