Bot mmbot Rust 開発ログ

🛠️開発記録#279(2025/8/11)Ember流レイテンシ分解で作るMM Bot——実測p99とA/B実験で磨く本番運用

最終更新: 2025-08-11 (JST) / 対象: Binance Japan 現物 / 実装言語: Python 3.12 + Rust


はじめに——この記事で伝えること/背景と狙い

本記事は、Ember流のレイテンシ分解を中心に、MM(マーケットメイキング)Botを「設計→計測→実験→本番拡大」のサイクルで育てる実践知をまとめた技術記事です。以下をカバーします。

  • レイテンシ分解md→decide→prelat→request→ack)と段階別p99バジェットの設計
  • 観測・ダッシュボード(Prometheus/Grafana)の具体
  • 失敗前提の設計:エラー分類、降格(freeze/degrade/stop)、半開復帰
  • A/B実験ハーネスで発注条件をチューニングする方法
  • MMロジックの核(エントリ、sanity、staleness、在庫スキュー)

本記事の数値は 2025-08-11 時点の実測・設計に基づきます。

Ember流のレイテンシ分解などについては別記事で解説しています。

関連記事
🛠️開発記録#278(2025/8/11)レイテンシ分解:CEX / CEX×DEX / DEXで違う“速さ”——個人botterのための周波数表と設計原則

続きを見る


開発スナップショット(現状・完了フェーズ・次の一手)

  • 完了: Phase 6(Ember分解監視)、Phase 7(エラー処理/降格)、Phase 8(統合テスト)、Phase 9–10(運用準備/実験統合)
  • これから: Phase 11(本番・大規模テスト / 72h soak、Canary比率段階増)
  • SLO(運用指標): p95_total < 300ms, p99_total < 400ms, Timeout+429 < 0.5%, CB発動率 < 5%24h連続達成でGo)

技術スタックと構成図(Python×Rust/Prometheus/Grafana/Docker)

  • 言語: Python 3.12 + Rust(高速パス)
  • 通信: WebSocket(板)+ REST(発注/照会)
  • 監視: Prometheus + Grafana(5s間隔収集)
  • 運用: Docker / docker compose
flowchart LR
  WS["Binance WS (L2)"] -->|md| CORE["Core Engine"]
  REST["Binance REST"] -->|request / ack| CORE
  CORE --> STRAT["Strategy Layer"]
  STRAT --> RISK["Risk Manager"]
  CORE --> MON["Ember Latency Monitor"]
  MON --> PROM["Prometheus"]
  PROM --> GRAF["Grafana"]
  STRAT --> ABRUNNER["AB Experiment Runner"]


Ember流レイテンシ分解——ステージ定義とp99バジェット

ステージ: md → decide → prelat → request → ack

Stage役割p99 Budget (ms)
md板取り込み/正規化80
decide見積/エントリ判定40
prelat直前処理(丸め/整形)30
request送信〜到達220
ack応答確認/整合30
Total400

実装ポイント

  • 段階別の soft/hard timeout(0.8x / 1.25x)をユーティリティ化
  • asyncio.CancelledError正常キャンセルとして cancelled_total カウントへ
  • 記録は Histogram(ミリ秒)で、5–800ms の対数的バケットを使用

メトリクス設計——ヒストグラム粒度、Recording Rules、ダッシュボード

主要メトリクス(Prometheus名)

  • mm_stage_latency_ms{stage=*}(Histogram)
  • mm_request_timeouts_total{stage=*}(Counter)
  • mm_cancelled_total{reason=*}(Counter)
  • mm_cb_state{endpoint=*}(Gauge 0/1)
  • mm_book_age_ms{source=WS|REST}(Histogram)
  • mm_fill_slip_bp{symbol,side}(Histogram)
  • exp_stage_latency_ms{experiment,variant,stage}(Histogram; A/B)

Recording Rules(抜粋)

# recording_rules.yml
groups:
  - name: mm-recordings
    rules:
      - record: mm_stage_p99_ms
        expr: histogram_quantile(0.99, sum(rate(mm_stage_latency_ms_bucket[5m])) by (le, stage))
      - record: mm_stage_p95_ms
        expr: histogram_quantile(0.95, sum(rate(mm_stage_latency_ms_bucket[5m])) by (le, stage))

  - name: ab-recordings
    rules:
      - record: exp_latency_p99_ms
        expr: histogram_quantile(0.99, sum(rate(exp_stage_latency_ms_bucket[5m])) by (le, experiment, variant, stage))

ダッシュボード要件

  • 段階別 p50/p95/p99 を同一X軸でオーバーレイ
  • 429/timeout/5xx の時系列と mm_cb_state の帯表示
  • slip_p95spread_entry_bp差分トレンド(実効エッジの可視化)

失敗前提の設計——エラー分類、CB、レート制御、freeze

分類(taxonomy)と既定アクション

  1. Transport(DNS/TCP/TLS/timeout)→ バックオフ+ジッター(デッドライン内)
  2. HTTP/429/5xxX-MBX-USED-WEIGHT-1m中央レート制御、429 即デグレード
  3. SemanticPRICE_FILTER, LOT_SIZE, MIN_NOTIONAL)→ 丸め/再見積り 1回
  4. Idempotency(重複発注・unknown order)→ clientOrderId 整合、キャンセルは成功相当扱い
  5. Concurrency/logic(inflight競合/連打)→ singleflight(order_key)、cancel 連打抑止

降格の状態遷移

stateDiagram-v2
  [*] --> Quoting
  Quoting --> Freeze: md_age_ms>gate || CB(open)
  Freeze --> Degraded: freeze>threshold || repeatedErrors
  Degraded --> Quoting: half-open success 3/5
  Degraded --> Stop: timeoutRate>budget || guardTripped
  Freeze --> Quoting: mdHealthy && CB(close)

中央レート制御

  • X-MBX-USED-WEIGHT-1m / orderCount1s を取り込み、全体+symbolの2層で制御
  • 429 検知で 即時スロットル+クールダウン、復帰は段階的

A/B実験ハーネス——variant設計と読み方

設計単位の例

  • post_only ∈ {on, off}
  • notional_bucket ∈ {small, medium, large}
  • vol_regime ∈ {calm, normal, turbulent}(直近の実測ボラで分類)

計測

  • exp_stage_latency_ms{experiment,variant,stage}共通フォーマットで観測
  • 主要判定は exp_latency_p99_msFill品質(slip_p95, cancel_p99) のセットで行う

運用Tips

  • 検定“だけ”より、差分の継続観測で採択/却下のループを速く回す
  • 仕様/市場が変わるため、勝ったvariantも定期再評価

context_id伝播とトレース——非同期の落とし穴と対策

問題例: bool is not callable(メソッド名と同名の属性で上書き)

対策

  • フィールド名は 先頭に__task, _running)で衝突回避
  • __post_init__add_order/remove_order/start_monitoringcallable か即時検査
  • contextvars による context_id の安全伝播(ログ/メトリクスに付与)
  • 背景タスクは start/stop を一本化asyncio.create_task+半開復帰)

トレードロジックの核(MM)——エントリ/サニティ/在庫スキュー

エントリの基礎

  • exec_spread(見積スプレッド−手数料−予想スリップ)がの区間でのみクオート
  • spread_entry_bp は安全側から開始し、slip_p95/cancel_p99自動フィードバックして微調整

sanity / staleness

  • quote_sanity_check: tick/lot/priceFilter/minNotional/最大乖離bp/在庫上限
  • staleness gate: md_age_ms ≤ 2×md_budget を満たすときのみ発注

在庫スキュー

  • Canary期は スキューOFF で統計収集 → 段階的ON(上限ガード付き)

SLOとGo/No-Go——24h基準と自動停止

  • SLO: p95_total < 300ms, p99_total < 400ms, Timeout+429 < 0.5%, CB発動率 < 5%
  • Go/No-Go: 24h 連続で達成+安全装置(連敗N/日次PnL/Reject率>1%)の自動停止が正しく動く

Alert(例)

# alert_rules.yml(抜粋)
groups:
  - name: mm-alerts
    rules:
      - alert: MMRequestP99TooHigh
        expr: mm_stage_p99_ms{stage="request"} > 220
        for: 5m
        labels: { severity: warning }
        annotations: { summary: "request p99 高止まり" }

      - alert: MMCircuitOpenPlace
        expr: mm_cb_state{endpoint="place"} == 1
        for: 5m
        labels: { severity: critical }
        annotations: { summary: "CB(place)がオープン" }

実測と学び——効いた改善/詰まりポイント

効いた改善

  • 段階別 timeout の統一(soft/hard)→ timeout+429 の合算率が安定
  • CB の半開復帰→ 再開時のスパイク低減
  • A/B の共通計装→ variant比較が“数クリック”で可視

詰まりポイントと回避

  • グローバル初期化の罠 → 直接参照をやめ アクセサ関数に統一
  • メソッド影被り → dataclass フィールドに_接頭、callable ガード
  • p99の罠 → ヒストグラム設計とRecording Rulesの整備で再現性を担保

今後の拡張——p99=250msへ

  • ネットワーク: RTT短縮、TCP設定見直し、近傍リージョンへ
  • Adapter I/O: JSONデコード/整形のホットパス最適化(Rust側回収)
  • 内部処理: 完全オンメモリ比較、イベント駆動(ポーリング排除)
  • 言語選択: Python→Rust/C++への比較パス移管

付録——主要メトリクス一覧 / 参考スニペット

メトリクス一覧(抜粋)

  • mm_stage_latency_ms / mm_stage_p99_ms / mm_stage_p95_ms
  • mm_request_timeouts_total / mm_cancelled_total
  • mm_cb_state / mm_book_age_ms
  • mm_fill_slip_bp / exp_stage_latency_ms / exp_latency_p99_ms

計装スニペット(Python)

from prometheus_client import Histogram, Counter, Gauge

MM_STAGE_LAT = Histogram('mm_stage_latency_ms','mm stage latency (ms)', ['stage'],
    buckets=[5,10,20,40,80,120,160,220,300,400,800])
MM_TIMEOUTS = Counter('mm_request_timeouts_total','hard timeouts',['stage'])
MM_CANCELLED = Counter('mm_cancelled_total','cancelled (normal)',['reason'])
MM_CB_STATE = Gauge('mm_cb_state','circuit breaker state (0/1)',['endpoint'])

おわりに

“測れないものは速くならない”。Ember流の分解とA/Bの実験設計で、**本番可の“速さ”**を少しずつ積み上げていきます。

付録:ざっくりセルフレビューと改善アクション(実装メモ付き)

本文の設計に対して、実運用で効く改善を命名統一/計測精度/運用安全性の観点で差し込みます。


1) p99 は“和”で推定しない(Total を直取り)

from prometheus_client import Histogram

def exp_buckets(start=8.0, factor=1.10, count=45):
    v=[]; x=start
    for _ in range(count):
        v.append(float(f"{x:.6f}")); x *= factor   # 単調増加を厳守(重複回避)
    return v  # 800ms超まで届く設定

MM_TOTAL = Histogram(
    "mm_total_latency_ms",
    "End-to-end latency (md->ack)",
    ["symbol"],
    buckets=exp_buckets()
)
  • 判定の一次指標mm_total_latency_ms(p99/p95)。
  • stage のヒストグラムは原因追跡用に残す。

2) HTTP サブフェーズ(request=220ms を割る)

最小版(httpx:TTFB/RECV だけ)

import time, httpx
from prometheus_client import Histogram

HTTP_SUB = Histogram("mm_http_sub_ms", "HTTP sub-phase (approx)", ["phase"])

async with httpx.AsyncClient(http2=True, timeout=5.0) as c:
    req = c.build_request("POST", url, content=body)  # awaitしない
    t0 = time.perf_counter_ns()
    resp = await c.send(req, stream=True)
    t_ttfb = time.perf_counter_ns()
    async for _ in resp.aiter_raw():
        pass
    t_end = time.perf_counter_ns()
    await resp.aclose()

HTTP_SUB.labels("ttfb").observe((t_ttfb - t0)/1e6)
HTTP_SUB.labels("recv").observe((t_end - t_ttfb)/1e6)

詳細版(aiohttp:DNS/TCP/TLS まで)

  • TraceConfigon_dns_resolvehost_* / on_connection_create_* / on_request_* / on_response_* を貼る。
  • ラベルは**phase + 必要最小限**(高カーディナリティに注意)。variantは A/B 系に寄せる。

3) メトリクス命名の統一 & 追加

from prometheus_client import Counter

MM_HTTP_429 = Counter("mm_http_429_total", "HTTP 429 responses", ["endpoint"])
MM_ORDER_ATTEMPTS = Counter("mm_order_attempts_total", "order attempts", ["symbol"])
MM_CANCELLED = Counter(
    "mm_cancelled_total", "cancelled (normal)", ["reason"]
)  # reason ∈ {would_cross, stale_md, dupe, interference}

代替案:HTTP 系は mm_http_status_total{code="429"} と汎用にするのも可(メトリクスの乱立防止)。


4) PromQL(ゼロ割防止・二窓で運用)

# 総レイテンシ p99(5m窓)
histogram_quantile(
  0.99,
  sum(rate(mm_total_latency_ms_bucket[5m])) by (le)
)
# HTTP TTFB p99(5m窓)
histogram_quantile(
  0.99,
  sum(rate(mm_http_sub_ms_bucket{phase="ttfb"}[5m])) by (le)
)
# エラーレート(5m窓・ゼロ割防止)
(
  sum(rate(mm_request_timeouts_total[5m])) +
  sum(rate(mm_http_429_total[5m]))
)
/ clamp_min(sum(rate(mm_order_attempts_total[5m])), 1)

**短窓(2m)安定窓(5m)**の両方を用意(スパイク検出と可用性の両立)。


5) Cancelled を 4 群で分解

  • would_cross(post_only 起因の“賢い撤退”)
  • stale_md(鮮度不足)
  • dupe(重複・冪等)
  • interference(競合・連打)
    → A/B 比較で would_cross を別箱にしないと“慎重さ”と“遅さ”が混ざる。

6) デッドライン & バックオフの実装規約

  • monotonictime.perf_counter_ns())だけで伝播(NTP 揺れ防止)
  • soft/hard0.8x / 1.25x
  • in-flight 上限+ジッター付きバックオフ(冷却中の新規 enqueue を抑制)
from contextvars import ContextVar
import time
DL_NS = ContextVar("deadline_ns", default=0)

def start_deadline(ms: float): DL_NS.set(time.perf_counter_ns() + int(ms*1e6))
def time_left_ms() -> float:  return max(0.0, (DL_NS.get() - time.perf_counter_ns())/1e6)

7) CB & レート制御(復帰は指数段階)

  • 復帰:10% → 25% → 50% → 100%(テストしやすい固定段階)
  • レート制御は APIキー全体シンボル別の二層。per-symbol floor で局所飢餓を回避。

8) “真実性”を上げる計測

  • mm_md_age_ms{at=decide|send|ack}地点別に記録
  • staleness gate はレジーム別に切替:calm=1.0x / normal=1.5x / turbulent=2.0x

9) Rust ホットパス(prelat の一括処理)

  • 丸め/整形/HMAC 署名を Rust で一括。pyo3 でゼロコピーを狙う(borrow/PyBuffer)。
  • FFI 呼び出しはバッチ化して回数を減らす。

10) 実効エッジで一本化(トレーダー視点)

edge_realized_bp
  = spread_entry_bp
  - fees_bp
  - slip_bp          # 部分約定の tail は別計上
  - inv_cost_bp
  - cancel_cost_bp   # lost-queue + 機会損失の proxy
  • 追加メトリクス:exp_edge_realized_bp{variant}, exp_cancel_cost_bp{variant}

11) Post-only とキュー位置

  • would-cross 直後は 50–80ms の遅延再提示で再クロス率を下げる。
  • キュー位置の代理変数(best price 滞在時間、成行トリガ回数)を持つ。

12) A/B 実験の干渉回避・運用

  • clientOrderIdvariant ごとに名前空間分離(相互 cancel を構造的に回避)
  • 時間×シンボルの二軸シャーディング
  • 週次の再トーナメント(市場変化で優劣が入れ替わる前提)
  • **影約定(shadow fills)**で cancel_cost_bp を推定

13) ネットワークの“配線”

  • DNS pinning/Session prewarm/Keep-Alive の生存監視(失効直前に軽パケット)
  • 可能なら可用域近傍に配置して L3 RTT を先に削る。

14) アラート指針(バーンレート二段)

  • 5m1h の二窓でバーンレート監視(WARN=日次14.4×相当/CRIT=時次6×相当)
  • mm_cb_state 帯表示に 429/5xx のスタック面を重ね、原因→結果の一目化。

15) 実装チェックリスト

  • mm_total_latency_ms(指数バケット)を実装、Recording Rules(2m/5m)追加
  • mm_http_sub_ms{phase} を実装(httpx:TTFB/RECV、詳細は aiohttp Trace)
  • mm_http_429_totalmm_order_attempts_total を追加
  • mm_cancelled_total{reason∈4群} を実装
  • deadline を monotonic で伝播、バックオフに in-flight 上限
  • CB 復帰を 10→25→50→100% に固定、二層レート+per-symbol floor
  • mm_md_age_ms{at} を導入、staleness gate をレジーム別に
  • prelat を Rust へ一括移管(ゼロコピー・バッチ FFI)
  • 実効エッジ系 exp_*_bp を導入
  • A/B 干渉回避(ID 空間/シャーディング/週次再戦)を運用へ

-Bot, mmbot, Rust, 開発ログ