レッスン7 — hand-trace された unit test 9 個
問い
matching engine がコンパイルは通るが「正しく動く」証明はまだない。どんなテストを 何本 書けば、レッスン4-6 のロジックが実際に動くことを load-bearing に証明できるか?
原理(最小モデル)
- 網羅は本数でなく invariant 単位。 9 個のテストは「9 個の任意シナリオ」でなく、別個の invariant に 1:1 対応(empty-book / resting / walks-levels / respects-limit / FIFO / partial-market / cancel-found / cancel-not-found / no-cross)。invariant の一覧が短く明確だから 9 が正当化できる。
- hand-trace が proptest(レッスン8)の oracle。 proptest が 25-action 乱数列で fail したら、invariant を 1 つ切り出した hand-trace でデバッグする。proptest は増幅器、unit test は土台。
- helper 関数 > builder pattern。 位置引数の
limit(...)/market(...)が重複を取る最安の抽象(~5 行のテストに builder は儀式的すぎる)。 - ソース順序が優先度を表す /
assert_eq!>assert!。 no-cross を最後に置くのは「これが load-bearing な safety property」のシグナル。assert_eq!は失敗時に両辺を出す。
具体例
9 テスト = 9 invariant: ① empty-book に price なし ② resting が bid/ask を作る ③ market が best-ask を best-first ④ limit が price 内で walk ⑤ level 内 FIFO time priority ⑥ partial-market が remainder 破棄 ⑦ cancel-found ⑧ cancel-not-found ⑨ no-crossed-book(最強の safety property、最後)。
失敗例(誤解)
「9 個でなく 100 個書けばカバレッジが増える」は誤り — 多くが同じ path を exercise するなら冗長。「buy crosses ask」を 100 回 exercise しても 99 個は無駄。異なる invariant を exercise するテストだけが価値を足す。
ここまでで「invariant 単位の網羅」は着地した。ここから book.rs 末尾に #[cfg(test)] mod tests を組み立てる。コードは完全形。
🛑 予測。 9 個のうちどれが、
submit_limit::Buyが ask を 降順(最高値先)に辿るバグで失敗するか? ヒント:「best ask 先」を明示 assert しているテスト。(答え:buy_market_takes_best_ask(約定順逆転として)とlimit_buy_walks_asks_within_price(早期 stop バグとして — 降順だと最初に 105 に当たり limit 103 で即 stop し、本来マッチすべき 100 を素通り)。違う症状で 2 つが catch。)
ステップで組み立てる
Step 1: helper + テストモジュール(fn match_at_level の後、impl Book の外)
#[cfg(test)]
mod tests {
use super::*;
fn limit(id: u64, account: u64, side: Side, price: u64, qty: u64) -> Order {
Order {
id: OrderId(id),
account: AccountId(account),
side,
qty: Qty(qty),
order_type: OrderType::Limit {
price: Price(price),
},
}
}
fn market(id: u64, account: u64, side: Side, qty: u64) -> Order {
Order {
id: OrderId(id),
account: AccountId(account),
side,
qty: Qty(qty),
order_type: OrderType::Market,
}
}
#[test]
fn empty_book_has_no_best_prices() {
let book = Book::new();
assert_eq!(book.best_bid(), None);
assert_eq!(book.best_ask(), None);
assert_eq!(book.depth_bid(), 0);
assert_eq!(book.depth_ask(), 0);
}
#[test]
fn resting_limit_creates_bid_or_ask() {
let mut book = Book::new();
let r = book.submit(limit(1, 100, Side::Buy, 90, 10));
assert!(r.fills.is_empty());
assert_eq!(book.best_bid(), Some(Price(90)));
assert_eq!(book.best_ask(), None);
let r = book.submit(limit(2, 101, Side::Sell, 100, 5));
assert!(r.fills.is_empty());
assert_eq!(book.best_ask(), Some(Price(100)));
}
#[test]
fn buy_market_takes_best_ask() {
let mut book = Book::new();
book.submit(limit(1, 100, Side::Sell, 100, 5));
book.submit(limit(2, 101, Side::Sell, 105, 5));
let r = book.submit(market(99, 200, Side::Buy, 8));
assert_eq!(r.fills.len(), 2);
assert_eq!(r.fills[0].price, Price(100)); // best ask first
assert_eq!(r.fills[0].qty, Qty(5));
assert_eq!(r.fills[1].price, Price(105));
assert_eq!(r.fills[1].qty, Qty(3));
assert_eq!(r.remaining_qty, Qty(0));
assert_eq!(book.depth_ask(), 1); // ask @ 105 has 2 left
}
#[test]
fn limit_buy_walks_asks_within_price() {
let mut book = Book::new();
book.submit(limit(1, 100, Side::Sell, 100, 5));
book.submit(limit(2, 101, Side::Sell, 105, 5));
// Buy limit @ 103 — should only fill the 100-priced level.
let r = book.submit(limit(99, 200, Side::Buy, 103, 10));
assert_eq!(r.fills.len(), 1);
assert_eq!(r.fills[0].price, Price(100));
assert_eq!(r.fills[0].qty, Qty(5));
// Remainder rests as a bid @ 103.
assert_eq!(book.best_bid(), Some(Price(103)));
assert_eq!(book.depth_bid(), 1);
}
#[test]
fn price_time_priority_within_level() {
let mut book = Book::new();
book.submit(limit(1, 100, Side::Sell, 100, 5)); // first
book.submit(limit(2, 101, Side::Sell, 100, 5)); // same price, later
let r = book.submit(market(99, 200, Side::Buy, 7));
assert_eq!(r.fills.len(), 2);
assert_eq!(r.fills[0].maker_order_id, OrderId(1)); // first in, first out
assert_eq!(r.fills[0].qty, Qty(5));
assert_eq!(r.fills[1].maker_order_id, OrderId(2));
assert_eq!(r.fills[1].qty, Qty(2));
}
#[test]
fn market_with_insufficient_liquidity_returns_remaining() {
let mut book = Book::new();
book.submit(limit(1, 100, Side::Sell, 100, 3));
let r = book.submit(market(99, 200, Side::Buy, 10));
assert_eq!(r.fills.len(), 1);
assert_eq!(r.fills[0].qty, Qty(3));
assert_eq!(r.remaining_qty, Qty(7)); // market discards remainder
assert_eq!(book.depth_ask(), 0);
}
#[test]
fn cancel_removes_resting_order() {
let mut book = Book::new();
book.submit(limit(1, 100, Side::Buy, 90, 10));
assert_eq!(book.depth_bid(), 1);
assert!(book.cancel(OrderId(1)));
assert_eq!(book.depth_bid(), 0);
assert_eq!(book.best_bid(), None);
}
#[test]
fn cancel_unknown_returns_false() {
let mut book = Book::new();
assert!(!book.cancel(OrderId(999)));
}
#[test]
fn book_does_not_cross_after_match() {
let mut book = Book::new();
book.submit(limit(1, 100, Side::Sell, 100, 5));
book.submit(limit(2, 101, Side::Buy, 95, 5));
// Spread: bid 95, ask 100. No cross.
let bid = book.best_bid().unwrap();
let ask = book.best_ask().unwrap();
assert!(bid < ask);
// Now a buy @ 100 — fully fills, no resting.
book.submit(limit(3, 102, Side::Buy, 100, 5));
// Best bid is still 95 (from order 2). Ask is gone.
assert_eq!(book.best_bid(), Some(Price(95)));
assert_eq!(book.best_ask(), None);
}
}
helper は raw u64 を newtype で wrap するだけ(limit(1,100,Side::Sell,100,5) が 1 行で済む。positional は 5-field struct より速い)。テストは複雑さ順: empty-book → resting → market-walks → limit-respects → FIFO → partial → cancel × 2 → no-cross。book_does_not_cross_after_match が最強(crossed book = matching engine の根本的失敗)で、ソース末尾に置いて優先度を示す(実行はアルファベット順)。Test 7+8 のペアが cancel の成功/失敗 path を両方カバー。
答え合わせ
cd ~/code/openhl-reference && git checkout 55a9dff
diff -u ~/code/my-openhl/crates/clob/src/book.rs ./crates/clob/src/book.rs
git checkout main
本レッスン後、book.rs 末尾に test モジュール(9 tests + 2 helper)。参照には mod prop_tests もある(レッスン8)。
合格基準
cargo test -p openhl-clob
→ 9 テスト pass(アルファベット順で実行)。よくあるミス: fn limit を mod 外に書く / 約定順が逆(keys().next() の方向)/ FIFO でなく LIFO(push_back でなく push_front)/ 空 level cleanup 漏れ。
まとめ(3行)
- 9 テストは 9 個の別個 invariant に 1:1 対応 — 網羅は本数でなく invariant 単位で測る。
- hand-trace unit test が基礎、proptest(次レッスン)が増幅器。proptest fail 時はこれで isolate する。
- no-crossed-book を最後に置き、ソース順序で「最強の safety property」を示す。