現物の matching engine は、厳しい制約に囲まれた小さな状態機械だ。price-time priority、部分約定、バースト負荷でも動かない latency の裾。ここでは order book、マッチングループ、ジャーナル、リプレイ用ハーネスをどう作るか、そして言語が助けてくれなくなる境目を扱う。
現物取引所の matching engine の仕事は狭い。順序づけられたコマンド列 ― 新規、キャンセル、訂正 ― を受け取り、price-time priority のもとで order book に適用し、約定・板更新・拒否・受理確認という順序づけられたイベント列を出す。
難しさはすべて、その仕事の上に積まれた3つの制約から来る。同じ入力を再生すれば結果は毎回同一でなければならない。一部約定した注文の残数はキューでの位置を保たなければならない。そしてバーストが来ても latency の裾は動いてはならない。
板は2つのサイドからなり、それぞれ価格順のレベルの集まりである。レベルは数値ではない ― その価格にある待機注文の、到着順のキューである。マッチングは最良レベルに絶えず触れ、奥のレベルにはめったに触れない。構造は優雅さではなく、そのアクセスパターンに合わせて選ぶ。
intrusive なキューとインデックスハンドルの帰結として、待機注文は生きている間メモリ上を動かない。キューでの位置はリンクの性質であって、たまたま置かれている場所の性質ではない。それが後の部分約定を安く済ませる。
入ってきた攻撃側の注文は、反対側を最良気配から内側へたどる。各レベルでは FIFO キューを先頭からたどる。そのレベルの価格が入ってきた注文にとって受け入れられなくなるか、入ってきた数量がゼロになった時点で止まる。
一部約定した待機注文は順番を保つ。残数は元の到着シーケンスのままキューの先頭に留まる。約定が変えるのは数量だけだからだ。一部約定した攻撃側の注文が単純な指値であれば、自分の価格レベルの末尾に新しい到着シーケンスで待機注文として並ぶ ― それは今到着したのであって、以前ではない。
注文種別の意味論は、このループの内側ではなく境界で決める。Immediate-or-cancel は残数を板に置かず捨てる。Fill-or-kill はまず空打ちで確認し、全量執行するか拒否する。Post-only は到着時に交差するなら拒否する。これらをループの外に置くことで、板の状態が変わる場所はループだけに保たれる。
自己約定防止、最小数量、tick とロットの検証も、ループの前に置くべきものだ。マッチングに届いた注文はすでに正しい形だと証明されている。だからループには、速度を落としたり判断が食い違ったりするエラー分岐が無い。
平均 latency が問題になることはめったにない。問題はバースト中の最悪値だ。エンジンが最も重要になるのはそのときであり、stop-the-world の停止が最も起きやすいのもそのときである。garbage collector を持つマネージドランタイムでは、その停止はあなたではなくコレクタが決め、ごみを生んだバーストの真ん中で起きる。
手動のアロケーションも同じ問題の小さい版だ。汎用アロケータはフリーリストをたどり、ロックを取り、カーネルにメモリを要求することがある。そしてそれをした呼び出しこそが裾に現れる。どちらの場合も対処は同じ。ホットパスではそもそもアロケートしない。
エンジンが決定論的であるとは、同じ入力列が、別のマシンでも1年後でも、バイト単位で同じ出力列を生むということだ。マッチング経路の中で実時間、スレッドのスケジューリング、ハッシュの反復順を読むものはすべて、その性質を壊す。
したがってタイムスタンプは入力であり、エンジンが自分で読むものではない。シーケンサはコマンドを受理したときに時刻を刻み、マッチングループはその刻印をデータとして扱う。乱数が要るなら、シードがジャーナルの一部になっている生成器から得る。
復旧は、マッチングが動いた後に取って付ける機能ではない。エンジンは受理したコマンドを順序どおり追記専用のジャーナルに書き、メモリ上の板はそのジャーナルを畳んだ結果にすぎない。クラッシュ後の再構築とは、それを再生することだ。
ジャーナルを書くのはエンジンだが、永続性はストレージ経路の性質であり、確認応答を返す前に何台のマシンがその記録を持っているかの性質である。これはレプリケーションとハードウェアの決定であり、復旧時間が実際に決まるのはそこだ。
決定論性こそがエンジンをテスト可能にする。同じ入力が同じ出力を返すから、記録したセッションはそのまま回帰テストになり、一度見つけた不具合は追い回すのではなく正確に再現できる。
参照モデルは見た目以上の価値がある。同じ仕様から書かれた2つの実装は、仕様が曖昧だったところでちょうど食い違う。そしてマッチング規則の端は曖昧さだらけだ ― 交差する指値、残数ゼロ、約定と競合するキャンセル。
Rust はループ自体を速くするというより、問題のひとつのカテゴリを消す。garbage collector が無いので、背後で勝手に停止が計画されることはない。所有権のおかげで、単一書き手という規律はコードレビューが気づくものではなく、コンパイラが強制するものになる。ライフタイムのない言語では間違えやすいスラブハンドルと intrusive なリンクも、ここでは検査できる。デバッグビルドでの整数オーバーフローの panic は、板を静かに壊す種類のバグを捕まえる。
正直に線を引けば、裾の latency を決めるものの大半は言語ではない:
Rust には代償もある。borrow checker は、まだ形の定まらない設計の最初の数週間を遅くする。取引所固有のプロトコルのエコシステムは古い言語より薄い。ロックフリー構造まわりの unsafe ブロックは、他の言語の同等コードと同じレビューの規律を要する。Rust を選ぶのは、latency の裾と、単一書き手のコアにおけるメモリ安全性についての判断であって、開発者の快適さについての判断ではない。
amBrain は2019年からアルメニア・エレバンでトレーディング基盤を作り、ホットパスは Rust で書いてきた ― マーケットデータの配信は 5 ms 未満、pre-trade リスクチェックは 1 ms 未満。matching engine を設計中で、板の構造やジャーナル形式、リプレイ用ハーネスを一緒に見ていきたいなら、その話はホットパスの1行目を書く前にしておく価値がある。
当社のエンジニアリングチームはFinTechソリューションを専門としています。プロジェクトの実現方法についてご相談ください。