FABRKNT
Step 2. CLOB — マッチングエンジンの追加とステートマシンの統合
テスト
レッスン 8 / 13·CONTENT35 分70 XP
コース
Step 2. CLOB — マッチングエンジンの追加とステートマシンの統合
レッスンの役割
CONTENT
順序
8 / 13

レッスン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」を示す。