「動いてるっぽい」を「正しく動く」に変える――それが本稿のテーマです。
先日、MMBotのクォート計算で Ask が mid×1.5 に跳ね上がる事故兆候を起点に、bp→比率の取り違えという根本原因を特定し、Sanity設計(ask<=bid/implied_bpガード)と[QUOTE_PATH]+implied_bpの可視化で再発を封じました。さらに、1%→5%→10%の90分Canary運用を、停止基準・しきい値・Slack通知・Kill Switch・ロールバック手順まで含めて“コピペで動く”レベルでまとめました。
Sanity設計=「クォートと板の常識範囲を外れたら発注前に止めるための最小限の安全機構」
本記事内では quotes.py / canary.py / ops CLI の最小実装と、Grafanaで監視するための指標(implied_bp P99、newOrder 2xx率、cancel P99、滑りP95)も提示。壊さず速く出すための標準装備を手にして、即日Canary(カナリアテスト)を回せます。
本記事でいう「Canary」は、**段階的本番投入(カナリアテスト)**を意味します。最初は1%から始め、SLOを満たす限り5%→10%と拡大し、逸脱時は即時停止・ロールバックします。
⚠️本記事は投資助言ではありません。筆者の開発ログとして公開しています。実際のトレードbotの運用は大きなリスクを伴います。仮想通貨botの開発に取り組む方はご自身で十分に調査をした上で自己責任で行うようにして下さい。
壊さず速く出す:MMBotのSanity設計とCanary運用90分プラン
はじめに — この記事でやること
本稿は、「動いてるっぽい」を「正しく動く」へ引き上げるための設計ノートです。
以下を最短ルートで共有します。
- 事故の兆候と即時停止の判断基準
- 根本原因(bp→比率の誤変換/half-spreadの扱い)と再現可能な説明
- 一元化・最小化・可視化の修正方針
- 変換関数の単一化
- ±half-spread + tick丸めの標準実装
- [QUOTE_PATH] と implied_bp による品質ログ
ゴール:クォート精度(implied_total_bps)= 設定値 ±0.1bp を安定達成し、Canary(1%→5%→10%)に安全に進む。
事故の兆候 — Ask が mid×1.5 になった
新MMbot、なんか妙な挙動するなーと思ったらAskが mid×1.5 で算出されるようになっていた。根本原因はbp→比率変換ミスと半スプレッドの取り扱い。危ねぇ。テスト運転大事。
— よだか(夜鷹/yodaka) (@yodakablog) August 8, 2025
症状のログ
[STRATEGY_SPREAD] BTCJPY - Market: 0.00bp, Extra: 5bp, Total: 5.00bp [STRATEGY_QUOTE] BTCJPY - Bid: ¥17,152,327, Ask: ¥25,738,901, Spread: 5.00bp, Position: 0.000
- mid ≈ ¥17,159,268 に対し ask ≈ mid × 1.5。
- 想定の 5bp(=0.05%)から 数桁外れたスプレッドが発生。
なぜすぐに止めるべきか
- 価格保護違反:
ask <= bidでなくても、bp逸脱は即事故の予兆。 - 在庫・PnLの破壊:片側に極端に寄ると、不必要な在庫偏り・追随損失が加速。
- 設計誤りの系統バグ:単位変換は全シンボルに波及するため、発見時点で即凍結が合理的。
根本原因 — bp→比率変換ミスと半スプレッドの取り扱い
5bp = 0.0005(0.05%)/half-spread = total_bps×0.5
- 定義:1bp = 0.0001(=0.01%)
- 5bp は 0.0005(=0.05%)
- half-spread は「総スプレッドを半分」:
half = total_bps × 0.5 × 1e-4
正しい式(mid基準):
bid = mid × (1 − half)ask = mid × (1 + half)
誤りの典型:
5bp → 0.5(= 50%)や/10,/100の桁ズレ- halfを適用せず総スプレッドを片側に全部載せる
例)mid=17,159,268 / total_bps=5
half=0.00025 → 片側±約4,290円(合計約8,580円)が正。
tick丸め・在庫スキューとの干渉
- tick丸め:
bid = floor(bid_f / tick) × tickask = ceil(ask_f / tick) × tick
→ 片側の丸め方向が固定のため、わずかに広がるのは正常(誤差0.1bp未満を狙う)。 - 在庫スキュー:
在庫の偏りに応じて half を補正する実装(例:half' = half × (1 + k·inv))が誤変換と合成すると、
bp逸脱がさらに増幅。Canary初期はスキューOFFが安全。
修正の設計 — 一元化・最小化・可視化
bps_to_frac() の単一化
def bps_to_frac(bps: float) -> float:
# 1 bp = 0.0001 = 0.01%
return float(bps) * 1e-4
def half_spread_frac(total_bps: float) -> float:
return 0.5 * bps_to_frac(total_bps)
- 単位変換はこの関数だけに集約(直書き禁止)。
- grepで
/10,/100などの直割りを撲滅。
±half-spread + tick丸めの標準実装
import math
def compute_quotes(mid: float, total_bps: float, tick: float) -> tuple[float, float]:
h = half_spread_frac(total_bps) # = total_bps * 0.5 * 1e-4
bid_f = mid * (1.0 - h)
ask_f = mid * (1.0 + h)
bid = math.floor(bid_f / tick) * tick # 下方向へ丸め
ask = math.ceil(ask_f / tick) * tick # 上方向へ丸め
return bid, ask
- 丸め方向を固定しておくと、再現性とレビュー性が高い。
- 後段で
implied_total_bps = ((ask - bid) / mid) * 1e4で逆算し、
設定bps±0.1bp以内かをサニティ判定。
サニティ例:
def sanity(mid, bid, ask, total_bps, max_bps=100.0):
if ask <= bid:
raise RuntimeError("ask<=bid")
implied = ((ask - bid) / mid) * 1e4
if implied > max(2*total_bps, max_bps):
raise RuntimeError(f"bps deviation: implied={implied:.2f} vs cfg={total_bps:.2f}")
[QUOTE_PATH] と implied_bp ログで経路と品質を見える化
path = "rust" if use_rust else "py"
bid, ask = compute_quotes(mid, total_bps, tick) # or rust側
implied = ((ask - bid) / mid) * 1e4
logger.info("[QUOTE_PATH] %s mid=%.0f cfg_bp=%.2f bid=%.0f ask=%.0f implied_bp=%.2f",
path, mid, total_bps, bid, ask, implied)
- [QUOTE_PATH]:Rust/Pythonどちらが実行されたかを毎回記録。
- implied_bp:設定値と突き合わせて品質を即座に可視化。
- しきい値(例:±0.1bp/P99)を満たさない場合はCanary停止の自動化へ接続。
以上の最小セットで、単位変換ミスと半スプレッド適用の系統エラーを潰し、
見える化→自動停止まで一気通貫にします。次は安全装置とCanary手順(Go/No-Go、段階昇格、即時ロールバック)をまとめます。
安全装置 — 壊れないための前提条件
1) ask<=bid ガード/implied_total_bps ガード
最低限の二重フェイルセーフ。発注前に必ず通す。
# src/mm_bot/quotes.py(抜粋)
def quote_sanity_check(mid: float, bid: float, ask: float, cfg_bps: float,
max_bps: float = 100.0) -> None:
if ask <= bid:
raise RuntimeError(f"[QUOTE_SANITY_FAIL] ask<=bid mid={mid} bid={bid} ask={ask}")
implied = ((ask - bid) / mid) * 1e4 # bpに逆変換
if implied > max(2 * cfg_bps, max_bps):
raise RuntimeError(
f"[QUOTE_SANITY_FAIL] implied_bps={implied:.2f} > limit "
f"(cfg={cfg_bps:.2f}, max={max_bps:.2f})"
)
ask<=bid:即座に発注禁止(ロジック破綻の合図)。implied_total_bps:設定値の2倍 or 100bpを超えたら停止。
(値は保守的に。Canary安定後に締めていくのが安全。)
2) Stale Book 検知(L2遅延 > 500ms)/価格ジャンプ > 5σで凍結
材料(板・価格)が古い/異常に跳ねたら、出さないが正解。
# どこかの RiskGate で
now = monotonic_ns()
l2_age_ms = (now - ctx.last_l2_ns) / 1e6
if l2_age_ms > 500: # ★ L2遅延
return Freeze("stale_book")
# 価格ジャンプ:直近NのEMA/STDで判定
z = abs(ctx.mid - ctx.mid_ema) / max(ctx.mid_std, 1e-9)
if z > 5.0: # ★ 5σ
return Freeze("price_jump_5sigma_30s") # 例:30s 凍結
- Stale Book:板が古いのに発注するとスリッページ地獄。
- 5σルール:フェイクやニュースでの瞬間乱高下に追随しない保険。
3) 即時停止(Kill Switch)と Slack 通知
人間の反応を待たない。止めてから知らせる。
# src/mm_bot/runtime/canary.py
def disable_live(reason: str):
(SW_DIR / "disable_live").touch()
log.error("[KILL_SWITCH] disabled live trading – reason=%s", reason)
notify(f":octagonal_sign: KILL SWITCH – {reason}")
# 失敗トリガ例
try:
quote_sanity_check(mid, bid, ask, cfg_bps)
except Exception as e:
disable_live(f"sanity_fail: {e}")
cancel_all_open_orders(symbol) # 実装側で用意
raise
Slack(Incoming Webhook)の軽量通知:
# src/mm_bot/runtime/alerts.py
def notify(text: str):
url = os.getenv("SLACK_WEBHOOK_URL")
if not url: return
body = json.dumps({"text": text}).encode()
urllib.request.urlopen(urllib.request.Request(url, data=body,
headers={"Content-Type": "application/json"}), timeout=3)
テスト — 壊れていないを証明する
スモーク:tools/quote_smoke.py
手元で入力→期待幅を即確認。
例:
PYTHONPATH=src python tools/quote_smoke.py --mid 17159268 --bps 5 --tick 1 # => implied_total_bps ≈ 5.00bp(±丸め誤差)
ユニット:tests/test_quotes.py
- 変換の一元化が壊れてないか。
- tick丸め後の逆算bpが設定値±0.1bpに収まるか。
- 失敗系で Sanity がきちんと爆発するか。
def test_bps_to_frac():
assert bps_to_frac(5) == 0.0005
@pytest.mark.parametrize("tick", [1, 5, 10])
def test_compute_quotes_py_5bp(tick):
mid = 17159268.0
bid, ask = compute_quotes_py(mid, 5.0, tick)
implied = ((ask - bid) / mid) * 1e4
assert abs(implied - 5.0) <= 0.1
assert (bid % tick) == 0 and (ask % tick) == 0
def test_sanity_overspread_raises():
mid = 100.0
with pytest.raises(RuntimeError):
quote_sanity_check(mid, 50.0, 200.0, cfg_bps=5.0) # わざと過大
期待値:implied_total_bps ≈ 設定bps (±0.1bp)
→ ここが安定すれば、上流のズレ(単位・丸め・スキュー)はほぼ潰せている。
Canary Runbook — 1% → 5% → 10% の 90分プラン
Go/No-Go 基準
昇格 or 退避の明文化。P99 で見るのがコツ。
- Quote精度(P99):
|implied_bp - cfg_bp| ≤ 0.1bp - 発注成功率:
newOrder 2xx > 99.5% - キャンセル遅延(P99):
< 250ms - 日次損失上限:未到達(到達=即停止)
いずれか 未達 → 即 Rollback(Canary=0 / Kill Switch)。
環境変数と tools/ops.py
フラグの外出しで、即断即応。
# 起動時の例
export CANARY_MODE=1 CANARY_FRACTION=0.01 \
MAX_DAILY_LOSS_JPY=5000 MAX_OPEN_NOTIONAL_JPY=100000 \
USE_RUST_QUOTES=1 FORCE_PY_QUOTES=0 SLACK_WEBHOOK_URL=...
# 途中で変更(永続スイッチファイル)
python tools/ops.py set_fraction 0.05 # 5%
python tools/ops.py disable_live --reason "bp_deviation"
python tools/ops.py enable_live
ロールアウト手順(合計 90 分)
- T+00:00〜00:15:1%
監視:implied_bp P99 / newOrder率 / cancel P99 / リジェクトコード - T+00:15〜01:00:5%(基準クリアなら)
監視:Shadow vs Live のspread差・滑り・約定率 - T+01:00〜01:30:10%(問題なければ)
ロールバック(自動降格・全注文キャンセル)
自動降格トリガ例(どれか1つでも発火):
implied_bp P99 > 0.1bpnewOrder 2xx ≤ 99.5%cancel P99 ≥ 250msdaily_pnl ≤ -MAX_DAILY_LOSS_JPY連続リジェクト ≥ 3/sanity_fail ≥ 1
アクション:
set_fraction 0.0(Canary即ゼロ)disable_live --reason "<trigger>"(Kill Switch)cancel_all_open_orders(symbol)(全注文キャンセル)- Slack通知(トリガ・メトリクス・直近ログへのリンク)
重要:“止める→知らせる→原因究明” の順番を守る。
原因究明より先に、継続ダメージをゼロにするのが Canary の流儀。
この章までで、壊さないためのガードと90分の実戦運用が整いました。
次は監視ダッシュボードの項目/インシデントの書式/再現手順をまとめます。
監視としきい値 — 見続けるものだけが守られる
対象メトリクス / 目標(SLO) / アラート閾値(SLA)を最初に固定します。集計は直近5分ローリング(Canary中)を基本に。
1) implied_bp P99
- 定義:
implied_bp = ((ask - bid) / mid) * 1e4
監視値は 誤差:err_bp = |implied_bp - cfg_bp| - SLO:
P99(err_bp) ≤ 0.10 bp - Alert:
P99(err_bp) > 0.10 bpが60秒連続でWarn、180秒でCrit - 実装例(メトリクス案)
mm_quote_err_bp_bucket{le="..."} # histogrammm_quote_cfg_bp # gauge(設定値確認用)
2) newOrder 2xx率
- 定義:
2xx率 = 成功(2xx) / 全発注(Shadow分は除外) - SLO:
> 99.5% - Alert:
≤ 99.5%が連続2分 →Warn、連続5分 →Crit - 実装例
mm_order_new_total{result="2xx|4xx|5xx|reject"}(counter)- 失敗の内訳ラベル:
code="-1013" など
3) cancel P99
- 定義:
送信→ACKのレイテンシ(ms) - SLO:
P99 < 250 ms - Alert:
P99 ≥ 250 msが90秒でWarn、180秒でCrit - 実装例
mm_cancel_latency_ms_bucket{le="..."} # histogram
4) 滑り(slippage)P95
- 定義:
slip_bp = |(exec_price - quote_ref) / mid_ref| * 1e4
(サイド別も取ると原因特定が早い:side="buy|sell") - SLO:
P95 ≤ 0.50 bp - Alert:
P95 > 0.50 bpが3分継続 →Warn - 実装例
mm_exec_slippage_bp_bucket{side="buy|sell"}
付帯で Stale Book Age(前章)も監視:
mm_l2_age_ms > 500で新規発注停止、通知のみ(Killは発火しない)。
リジェクトコードと即時アラート
“コード別”に即時通知し、原因→対処をテンプレ化します(例は一般的なCEX系)。
-1013(FILTERS/最小数量・price/tick・minNotional不一致)→ 設定/丸めの不整合-2010(NEW_ORDER_REJECTED:取引所側拒否の総称)→ パラメータ再検証-2011(CANCEL_REJECTED)→ 既に約定/消滅のパス確認-1022(署名不正)→ 即停止(認証レイヤの破綻)-1016(一時停止/メンテ)→ 自動降格+リトライ間隔延長
通知フォーマット(Slack):
:rotating_light: ORDER REJECT pair=BTCJPY side=BUY q=0.001 px=17160000 code=-1013 reason=MIN_NOTIONAL action=halt new-orders 60s / check filters / widen tick-rounding
アクションの原則:止める → 伝える → 直す。-1022 と サニティ失敗は即 Kill Switch(次節のロールバック手順へ)。
成果
- クォート精度:
implied_total_bpsの逆算誤差 0.00 bp(丸め内) - テスト:7/7 ケース成功(変換・tick・異常系でSanity発火)
- Shadow:安定稼働/Rust・Python経路とも [QUOTE_PATH] で可視化済み
- ドキュメント:
development_status.md / implementation_status.md / performance_benchmark_plan.mdを更新
Canary 前の“壊れない最低条件”を満たし、1% → 5% → 10% の段階昇格に進める状態。
学び — 再発防止のための原則
- 単位は最初に殺す
bps_to_frac()に集約。/10/100などの生計算は禁止。
- 安全装置は先に入れる
ask<=bidとimplied_bpガードを発注前に。- 逸脱時は自動停止→通知で“ダメージ最小化”を徹底。
- ログで道筋を残す
- [QUOTE_PATH](Rust/Py)と
implied_bpを毎回出す。 - 事後解析は“ログがあるかないか”で難易度が100倍変わる。
- [QUOTE_PATH](Rust/Py)と
この3点だけは設計の不変条件として固定。以後の最適化(在庫スキュー、アグレッシブ化、手数料最小化)はこの防壁の外側でやる。
次の一手
Canaryで在庫スキューOFFのまま統計収集 → 段階的に再有効化
- 次のフェーズ (Canary 1–10%)では inventory skew=OFF。まずは純粋な±half-spreadの品質を固める。
- 収集する分布(5分ローリング + 1時間集計)
err_bp = |implied_bp - cfg_bp|の P50/P90/P99slippage_bp(buy/sell別)の P95newOrder 2xx率、cancel P99、L2_age_ms P99- リジェクトコードの発生率(code別)
- 再有効化プラン(Phase 7 手前の段階的導入)
k_inv=0→0.05→0.10と段階上げ(各段階で1–3時間監視)- しきい値:
err_bp P99 ≤ 0.1bpかつslip P95 ≤ 0.5bpを維持 - 在庫上限:
|net_pos_jpy| ≤ MaxOpenNotional/3をSLO化 - いずれか崩れたら即
k_invを直前値にロールバック
ダッシュボード整備/Incidentゼロで本番比率拡大
Grafana(Prometheus)例:
- Quote精度
histogram_quantile(0.99, sum by (le)(rate(mm_quote_err_bp_bucket[5m])))
- 発注成功率(2xx)
sum(rate(mm_order_new_total{result="2xx"}[5m])) /
sum(rate(mm_order_new_total[5m]))
- キャンセルP99
histogram_quantile(0.99, sum by (le)(rate(mm_cancel_latency_ms_bucket[5m])))
- 滑りP95(買い/売り)
histogram_quantile(0.95, sum by (le,side)(rate(mm_exec_slippage_bp_bucket[5m])))
- 補助:
mm_l2_age_ms P99、mm_sanity_fail_total、mm_reject_total{code=...}、mm_canary_fraction、mm_kill_switch_state
本番比率の拡大条件(Incidentゼロ規定)
- 連続 6時間:全SLO達成(上記4本柱 + L2/Rejectが正常)
- Incident=0(Kill発火・P99逸脱連続Crit・連続Reject≥3 のいずれも無し)
- 満たせば
canary_fraction: 10% → 25% → 50% → 100%を段階昇格(各段階 6–24時間)
付録
主要コード抜粋(quotes.py / canary.py / ops CLI)
src/mm_bot/quotes.py
import math
def bps_to_frac(bps: float) -> float:
return float(bps) * 1e-4 # 1bp = 0.0001
def half_spread_frac(total_bps: float) -> float:
return 0.5 * bps_to_frac(total_bps)
def compute_quotes_py(mid: float, total_bps: float, tick: float):
h = half_spread_frac(total_bps)
bid_f = mid * (1.0 - h)
ask_f = mid * (1.0 + h)
bid = math.floor(bid_f / tick) * tick
ask = math.ceil(ask_f / tick) * tick
quote_sanity_check(mid, bid, ask, total_bps)
return bid, ask
def quote_sanity_check(mid: float, bid: float, ask: float, cfg_bps: float,
max_bps: float = 100.0) -> None:
if ask <= bid:
raise RuntimeError(f"[QUOTE_SANITY_FAIL] ask<=bid mid={mid} bid={bid} ask={ask}")
implied = ((ask - bid) / mid) * 1e4
if implied > max(2 * cfg_bps, max_bps):
raise RuntimeError(f"[QUOTE_SANITY_FAIL] implied_bps={implied:.2f} cfg={cfg_bps:.2f}")
src/mm_bot/runtime/canary.py
from __future__ import annotations
import os, random, pathlib, logging
from dataclasses import dataclass
from mm_bot.runtime.alerts import notify
log = logging.getLogger(__name__)
SW_DIR = pathlib.Path(os.getenv("SWITCH_DIR", "var/switches")).resolve()
SW_DIR.mkdir(parents=True, exist_ok=True)
@dataclass
class RtContext:
cycle_id: int
daily_pnl_jpy: float
open_notional_jpy: float
def _flag(name: str) -> pathlib.Path:
return SW_DIR / name
def _read_fraction() -> float:
v = os.getenv("CANARY_FRACTION", "").strip()
if v:
try: return max(0.0, min(1.0, float(v)))
except: pass
fp = _flag("canary_fraction")
if fp.exists():
try: return max(0.0, min(1.0, float(fp.read_text().strip())))
except: pass
return 0.0
def kill_switch_on() -> bool:
return _flag("disable_live").exists() or os.getenv("CANARY_MODE", "0") == "0"
def should_live_trade(ctx: RtContext) -> bool:
if kill_switch_on():
return False
if ctx.daily_pnl_jpy <= -float(os.getenv("MAX_DAILY_LOSS_JPY", "5000")):
notify(":no_entry: Daily loss reached — disabling live")
_flag("disable_live").touch()
return False
if ctx.open_notional_jpy >= float(os.getenv("MAX_OPEN_NOTIONAL_JPY", "100000")):
return False
return (os.getenv("CANARY_MODE", "0") == "1") and (random.random() < _read_fraction())
def disable_live(reason: str):
_flag("disable_live").touch()
log.error("[KILL_SWITCH] disabled live trading – reason=%s", reason)
notify(f":octagonal_sign: KILL SWITCH – {reason}")
tools/ops.py(手元操作)
import argparse, pathlib, os
SW = pathlib.Path(os.getenv("SWITCH_DIR", "var/switches")).resolve()
SW.mkdir(parents=True, exist_ok=True)
if __name__ == "__main__":
p = argparse.ArgumentParser()
sub = p.add_subparsers(dest="cmd", required=True)
d = sub.add_parser("disable_live"); d.add_argument("--reason", default="manual")
sub.add_parser("enable_live")
f = sub.add_parser("set_fraction"); f.add_argument("value", type=float)
a = p.parse_args()
if a.cmd == "disable_live":
(SW / "disable_live").touch(); print("[OK] live disabled:", a.reason)
elif a.cmd == "enable_live":
try: (SW / "disable_live").unlink()
except FileNotFoundError: ...
print("[OK] live enabled")
elif a.cmd == "set_fraction":
(SW / "canary_fraction").write_text(str(a.value))
print("[OK] CANARY_FRACTION set to", a.value)
docker-compose.override.yml と環境変数一覧
services:
mm-bot:
environment:
- CANARY_MODE=1 # 1=有効 / 0=無効
- CANARY_FRACTION=0.01 # 1%から開始(opsで変更可)
- MAX_DAILY_LOSS_JPY=5000 # 日次損失の即停止ライン
- MAX_OPEN_NOTIONAL_JPY=100000 # 建玉上限(在庫ガード)
- USE_RUST_QUOTES=1 # Rust経路を使う
- FORCE_PY_QUOTES=0 # 強制Python fallback
- SLACK_WEBHOOK_URL=${SLACK_WEBHOOK_URL}
- SWITCH_DIR=/app/var/switches
volumes:
- ./var/switches:/app/var/switches
【環境変数の意味】
CANARY_MODE:CanaryルーティングON/OFFCANARY_FRACTION:実取引へ回す比率(0.0–1.0)MAX_DAILY_LOSS_JPY:損失が閾値超過で自動KillMAX_OPEN_NOTIONAL_JPY:在庫(想定元本)の上限USE_RUST_QUOTES/FORCE_PY_QUOTES:クォート実装の切替SLACK_WEBHOOK_URL:Slack通知(未設定なら黙る)SWITCH_DIR:Kill/比率スイッチの永続ディレクトリ(ops.py連携)
最後に
“壊さず速く出す”はスローガンじゃなく手順です。
今回やったのは、単位を最初に殺し、発注前に安全装置を置き、経路と品質をログで見える化し、逸脱したら止める→伝える→直すを機械的に回す――ただそれだけ。これで「動いてるっぽい」は卒業できます。
あとは Canaries を 1%→5%→10% と上げながら統計を貯め、在庫スキューを段階再有効化、ダッシュボードで SLO を監視し続ければ、本番比率は自然に上がっていくはず。
現場に“止めずに守る”仕組みを――良いクォートを。