最終更新: 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 |
Total | 400 |
実装ポイント
- 段階別の 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_p95
とspread_entry_bp
の差分トレンド(実効エッジの可視化)
失敗前提の設計——エラー分類、CB、レート制御、freeze
分類(taxonomy)と既定アクション
- Transport(DNS/TCP/TLS/timeout)→ バックオフ+ジッター(デッドライン内)
- HTTP/429/5xx →
X-MBX-USED-WEIGHT-1m
で中央レート制御、429 即デグレード - Semantic(
PRICE_FILTER
,LOT_SIZE
,MIN_NOTIONAL
)→ 丸め/再見積り 1回 - Idempotency(重複発注・unknown order)→
clientOrderId
整合、キャンセルは成功相当扱い - 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_ms
と Fill品質(slip_p95, cancel_p99) のセットで行う
運用Tips
- 検定“だけ”より、差分の継続観測で採択/却下のループを速く回す
- 仕様/市場が変わるため、勝ったvariantも定期再評価
context_id伝播とトレース——非同期の落とし穴と対策
問題例: bool is not callable
(メソッド名と同名の属性で上書き)
対策
- フィールド名は 先頭に
_
(_task
,_running
)で衝突回避 __post_init__
でadd_order/remove_order/start_monitoring
が callable か即時検査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の実験設計で、**本番可の“速さ”**を少しずつ積み上げていきます。
今後の勝ち筋としては
・実効エッジを一本化して監視する
・スプレッド×キャンセル×在庫の三本柱を小刻みにする
・E2E p99を直取りして“request”を割る
だと思うので、2週間くらいかけて段階的にやる。"安定して勝つ"までの最後の伸びは多分技術より「運用の型」でだいぶ決まるんじゃないかな、と。— よだか(夜鷹/yodaka) (@yodakablog) August 11, 2025
付録:ざっくりセルフレビューと改善アクション(実装メモ付き)
本文の設計に対して、実運用で効く改善を命名統一/計測精度/運用安全性の観点で差し込みます。
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 まで)
TraceConfig
のon_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) デッドライン & バックオフの実装規約
- monotonic(
time.perf_counter_ns()
)だけで伝播(NTP 揺れ防止) - soft/hard:
0.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 実験の干渉回避・運用
clientOrderId
を variant ごとに名前空間分離(相互 cancel を構造的に回避)- 時間×シンボルの二軸シャーディング
- 週次の再トーナメント(市場変化で優劣が入れ替わる前提)
- **影約定(shadow fills)**で
cancel_cost_bp
を推定
13) ネットワークの“配線”
- DNS pinning/Session prewarm/Keep-Alive の生存監視(失効直前に軽パケット)
- 可能なら可用域近傍に配置して L3 RTT を先に削る。
14) アラート指針(バーンレート二段)
- 5m と 1h の二窓でバーンレート監視(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_total
・mm_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 空間/シャーディング/週次再戦)を運用へ