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

レッスン5 — submit_market — 任意の価格を取る order

問い

Market order(価格を持たず、book が提示する価格を受け入れる)をどう実装するか? Limit との差分はコードのどこに宿るか?

原理(最小モデル)

  • Market = Limit から price check と rest 処理を抜いたもの。 レッスン4 と同じ「クロスする限り辿る」ループだが price <= limit ガードがなく、rest-the-remainder もない。意味の差は 存在しないコード に宿る — boolean フラグでパラメタ化すると両方の本体が読めなくなる。
  • 約定価格は常に maker の価格。 Market は価格を持たず book の提示を受ける。価格発見 = best bid と best ask のスプレッドが価格を決める規則。
  • 同じ戻り型、異なる契約。 remaining_qty は Limit では「rest した分」(常に Qty(0))、Market では「破棄した分」(実際の残量)。型は同じ、FillResult の doc が両解釈を明示。
  • 「何も起きなかった」はエラーでなく有効な結果。 空 book への Market buy は FillResult { fills: vec![], remaining_qty: order.qty } を返す(Result::Err を投げない)。

具体例

ask = {100:[O_a(30)]}、Buy qty 50:

MARKET(leftover を破棄)              LIMIT @100(leftover を rest)
walk asks(price guard なし)          walk asks while ask_price ≤ 100
  100: O_a 完全消費 → Fill(30)          100: O_a 完全消費 → Fill(30)
  remaining = 20                       remaining = 20
leftover: DISCARD                      leftover: REST as bid @100
AFTER bids: empty(20 が消えた)        AFTER bids: {100:[new(20)]}
remaining_qty: Qty(20)                 remaining_qty: Qty(0)

同じ約定、leftover の運命だけが違う。差分は「rest-the-remainder ブロックの有無」。

失敗例(誤解)

「Market Buy に limit_price = u64::MAX、Sell に Price(0) を渡して submit_limit を呼べばいい」は誤り — price-check 除去には効くが rest-the-remainder は残る。約定しなかった qty を u64::MAX で rest しようとし、最高価格の phantom resting bid を作り、入ってくる sell を即マッチしてしまう。2 関数・2 意味論、別々に保つ。


ここまでで「Market は Limit のサブセット・leftover の運命」は着地した。ここから組み立てる(matching engine がこれで完成)。コードは完全形。

🛑 予測。 ask = {100:[O_a(30)]} に 50 unit の Market buy。約定と remaining_qty は? 同じ book で Limit buy @100 なら?(答え: Market → Fill(30 @100), remaining_qty=20(破棄、book に乗らない)。Limit → Fill(30 @100), remaining_qty=0(20 が bid @100 に rest)。)

ステップで組み立てる

Step 1: submit_market()impl Book 内、submit_limit の後)

    fn submit_market(&mut self, order: Order) -> FillResult {
        let mut remaining = order.qty;
        let mut fills = Vec::new();

        match order.side {
            Side::Buy => loop {
                if remaining.0 == 0 {
                    break;
                }
                let Some(best_price) = self.asks.keys().next().copied() else {
                    break;
                };
                let queue = self
                    .asks
                    .get_mut(&best_price)
                    .expect("price level exists by construction");
                fills.push(match_at_level(&order, best_price, queue, &mut remaining));
                if queue.is_empty() {
                    self.asks.remove(&best_price);
                }
            },
            Side::Sell => loop {
                if remaining.0 == 0 {
                    break;
                }
                let Some(best_rev) = self.bids.keys().next().copied() else {
                    break;
                };
                let queue = self
                    .bids
                    .get_mut(&best_rev)
                    .expect("price level exists by construction");
                fills.push(match_at_level(&order, best_rev.0, queue, &mut remaining));
                if queue.is_empty() {
                    self.bids.remove(&best_rev);
                }
            },
        }

        FillResult {
            fills,
            remaining_qty: remaining,
        }
    }

submit_limit との差分は 3 つだけ: ループ内の price check なし(任意価格で取る)/ ループ後の rest-the-remainder なし(leftover 破棄)/ return の remaining_qtyQty(0) でなく remaining(実際の残量)。ループの形は同じ、check を 2 個削り return 値を 1 個変えるだけ。

Step 2: submit() dispatcher 更新(todo! を置換)

pub fn submit(&mut self, order: Order) -> FillResult {
    match order.order_type {
        OrderType::Limit { price } => self.submit_limit(order, price),
        OrderType::Market => self.submit_market(order),
    }
}

1 行変更。dispatcher は「型駆動ルーティング、arm ごと 1 行」のまま。

答え合わせ

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 の最初の ~190 行。残り ~25 行は cancel()(レッスン6)。

合格基準

cargo check -p openhl-clob

clean(全関数に caller がいて unused 警告なし)。一時 smoke test(後で削除):

#[cfg(test)]
mod smoke {
    use super::*;

    #[test]
    fn market_buy_takes_what_it_can_then_discards() {
        let mut book = Book::new();
        book.submit(Order {
            id: OrderId(1),
            account: AccountId(1),
            side: Side::Sell,
            qty: Qty(30),
            order_type: OrderType::Limit { price: Price(100) },
        });
        let result = book.submit(Order {
            id: OrderId(2),
            account: AccountId(2),
            side: Side::Buy,
            qty: Qty(50),
            order_type: OrderType::Market,
        });
        assert_eq!(result.fills.len(), 1);
        assert_eq!(result.fills[0].qty, Qty(30));
        assert_eq!(result.fills[0].price, Price(100));
        assert_eq!(result.remaining_qty, Qty(20)); // DISCARDED, not rested
        assert_eq!(book.best_bid(), None);
        assert_eq!(book.best_ask(), None);
    }

    #[test]
    fn market_buy_against_empty_book_returns_full_remainder() {
        let mut book = Book::new();
        let result = book.submit(Order {
            id: OrderId(1),
            account: AccountId(1),
            side: Side::Buy,
            qty: Qty(50),
            order_type: OrderType::Market,
        });
        assert_eq!(result.fills.len(), 0);
        assert_eq!(result.remaining_qty, Qty(50));
    }
}

cargo test -p openhl-clob smoke で確認 → pass、その後 smoke を削除。よくあるミス: final FillResultremaining_qty: Qty(0)(submit_limit から copy-paste)にする / Market 後に best_bid()Some(rest-the-remainder に fall through)。

まとめ(3行)

  • Market = Limit から price check + rest を抜いたもの。差は存在しないコードに宿るので、別関数に保つ(フラグ化しない)。
  • remaining_qty は同じ型で契約が違う(Limit=常に0、Market=破棄量)。約定価格は常に maker の価格。
  • 空 book への Market は Result::Err でなく fills 空 + full remainder を返す — total + side-effect-free が consensus state machine の規律。