Lesson 5 — submit_market — orders that take any price
Question
Market orders match at any price until filled. submit_market reuses match_at_level with no price limit; cancel remaining if not fully filled.
Principle (minimum model)
submit_marketsignature.fn submit_market(&mut self, account, market, side, size) -> FillResult. No limit price field.- Reuse
match_at_level. Same inner loop; no limit-price check means it consumes whatever is available. - Slippage protection (optional). A
max_slippage_bpsparameter; if average fill price exceeds initial best price by > max_slippage, cancel the remainder. - Empty book → no match, cancel. Status = Cancelled; filled = 0.
- Partial fill. If the book only has some of the size, fill what's available + cancel the rest. Status = PartiallyFilled.
- Why market orders matter. Highest-priority taker; fastest matching. Production use: liquidation (must fill regardless of price).
- Tests. (1) Fully fillable market order. (2) Partially fillable. (3) Empty book.
Worked example + steps
Lesson 5 — submit_market — orders that take any price
Goal
Concepts you'll grasp in this lesson:
- Market = Limit minus the price check minus the rest step — same walk-while-crossing loop from Lesson 4, but without the
price <= limitguard and withoutrest_unfilled_remainder(). The semantic difference is in the missing code, which is why parameterizing the two with boolean flags would make both bodies harder to read. - Fill price is always the maker's price — Market orders don't supply a price; they accept what the book offers. "Price discovery" is the rule that the spread between best bid and best ask sets the price, not the taker's demand.
- One return type, two contracts —
FillResult::remaining_qtymeans "rested" for Limit (alwaysQty(0)) and "discarded" for Market (the actual leftover). The type is identical; the doc onFillResultnames both interpretations. - "Nothing happened" is a valid result, not an error — Market buy against an empty asks book returns
FillResult { fills: vec![], remaining_qty: order.qty }. The caller decides what to do with the leftover; the engine doesn't surface aResult. - Same
match_at_levelhelper, reused unchanged — Lesson 4's helper handles "maker partially filled" and "maker fully consumed" as one general path; Lesson 5 needed no fast paths, no special cases.
Verification:
cargo check -p openhl-clob
…still compiles, and the submit() dispatcher no longer panics on Market orders.
Specific changes:
submit_market()— the Market-order matcher. Structurally similar tosubmit_limitfrom Lesson 4, but with two key differences:- No price check — Market orders take whatever's available at any price.
- No rest-the-remainder — Market orders discard unmatched quantity; the leftover comes back in
FillResult::remaining_qty.
- Updated
submit()dispatcher — replace Lesson 4'stodo!("Market orders land in Lesson 5")withself.submit_market(order).
After Lesson 5 the matching engine is complete. Both Limit and Market orders produce real fills. Lesson 6 adds cancel; Lessons 7–8 add the test suite that proves the engine's invariants hold.
Recap
After Lesson 4, book.rs has:
pub fn submit(&mut self, order: Order) -> FillResult {
match order.order_type {
OrderType::Limit { price } => self.submit_limit(order, price),
OrderType::Market => todo!("Market orders land in Lesson 5"),
}
}
fn submit_limit(&mut self, order: Order, limit_price: Price) -> FillResult {
// ~60 lines: walk opposite side, match at-or-better, rest remainder
}
fn match_at_level(taker: &Order, price: Price, ...) -> Fill { ... }
If you call book.submit(market_order), it panics with todo!. Lesson 5 fixes that.
Plan
Two changes, both in crates/clob/src/book.rs:
- Add
submit_market()belowsubmit_limit(). Two branches (Buy/Sell), each a loop almost identical tosubmit_limit's loop — but without the limit-price comparison. - Edit
submit()to dispatch tosubmit_marketinstead of panicking.
No new types, no new helpers. We reuse match_at_level from Lesson 4 unchanged.
The lesson is short because Lesson 5 is what's left over after Lesson 4 did most of the work. The structural pattern is the same; the differences are what makes "market order" different from "limit order" semantically.
(Answer: Market case → fill [30 @ 100], remaining_qty = 20 (the unfilled portion is discarded — the caller sees it but it's not on the book). Limit case → fill [30 @ 100], remaining_qty = 0 (the 20 units rest on the book as a new bid at 100). Same fill, different fate for the leftover.)
Laid out as board state transitions:
START — asks: {100: [O_a(30)]}, bids: empty
taker: Buy qty=50
MARKET case (discard leftover) LIMIT @ 100 case (rest leftover)
──────────────────────────── ──────────────────────────────
walk asks (no price guard) walk asks while ask_price ≤ 100
100: consume O_a fully → Fill(30) 100: consume O_a fully → Fill(30)
asks empty, remaining = 20 asks empty, remaining = 20
leftover handling: leftover handling:
DISCARD (not added to book) REST as new bid @ 100
AFTER: AFTER:
asks: empty asks: empty
bids: empty ← 20 vanished bids: {100: [new(20)]} ← 20 rests
returned: returned:
FillResult { FillResult {
fills: [F1], fills: [F1],
remaining_qty: Qty(20) ← caller remaining_qty: Qty(0) ← rested
} }
Same matching loop, same fill. The only difference is the one step after the loop — Market just carries the remaining qty into remaining_qty and never touches the book; Limit inserts the remainder as a new resting order. In code, the difference is the presence or absence of the rest-the-remainder block.
Walk-through
Step 1: Add submit_market() to impl Book
In crates/clob/src/book.rs, inside the existing impl Book { ... } block (right after submit_limit), add:
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,
}
}
Compare side-by-side with submit_limit. The differences:
| What | submit_limit | submit_market |
|---|---|---|
| Price check inside loop | if best_price > limit_price { break } (Buy) | None — takes any price |
| Price check inside loop | if best_price < limit_price { break } (Sell) | None — takes any price |
| Rest-the-remainder after loop | if remaining.0 > 0 { ... push_back(resting) ... } | None — leftover is discarded |
remaining_qty in return | Always Qty(0) (rested or fully filled) | remaining (whatever's left over after matching) |
That's the entire delta. Same loop shape, two checks removed, one return value changed.
Step 2: Update submit() dispatcher
Find the dispatcher you wrote in Lesson 4:
pub fn submit(&mut self, order: Order) -> FillResult {
match order.order_type {
OrderType::Limit { price } => self.submit_limit(order, price),
OrderType::Market => todo!("Market orders land in Lesson 5"),
}
}
Replace the todo! with a real call:
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),
}
}
One line changed. The dispatcher's role hasn't expanded — it's still "type-driven routing, one line per arm." The implementations live in the dedicated methods.
Test
cargo check -p openhl-clob
Clean. No warnings about unused functions (every function declared in book.rs now has at least one caller — submit_market is called by submit, the submit_* private methods are called from inside Book, match_at_level is called by both submits).
Smoke test (delete after, as in Lesson 4):
#[cfg(test)]
mod smoke {
use super::*;
#[test]
fn market_buy_takes_what_it_can_then_discards() {
let mut book = Book::new();
// Place a single resting sell at 100 for 30 units.
book.submit(Order {
id: OrderId(1),
account: AccountId(1),
side: Side::Sell,
qty: Qty(30),
order_type: OrderType::Limit { price: Price(100) },
});
// Market buy for 50 — should match 30 at 100, leave 20 unfilled.
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));
// The 20 unfilled units are DISCARDED, not rested.
assert_eq!(result.remaining_qty, Qty(20));
assert_eq!(book.best_bid(), None); // no resting bid created
assert_eq!(book.best_ask(), None); // ask was consumed
}
#[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));
assert_eq!(book.best_bid(), None);
assert_eq!(book.best_ask(), None);
}
}
Run with cargo test -p openhl-clob smoke. Both should pass. Then delete the smoke module — Lessons 7–8 have the real test suite.
The contrast between the two smoke tests is the Lesson 5 lesson in miniature: whatever's left over after matching is discarded, regardless of whether the matching engine produced any fills or not. The book state after a Market order is exactly the book state minus the consumed liquidity — no resting orders added.
Common errors and fixes:
- Smoke test reports
result.remaining_qty == Qty(0)instead ofQty(20)— yoursubmit_market's finalFillResulthasremaining_qty: Qty(0)(probably copy-pasted fromsubmit_limit). It should beremaining_qty: remaining— the actual leftover quantity. book.best_bid()returnsSome(price)after Market Buy — yoursubmit_marketis hittingsubmit_limit's rest-the-remainder branch. That's because the loop fell through into shared code. Check thatsubmit_marketis its own function with its own finalFillResult— no shared "rest" logic.error: cannot find function 'submit_market' in '&mut Book'— typo insubmit()dispatcher. The method should beself.submit_market(order), called againstself.warning: unused variable: remainingin a wrong path — you might have writtenlet remaining_qty = ...instead ofremaining: remaining,in the FillResult. The field name isremaining_qty, the local variable isremaining(FillResult { fills, remaining_qty: remaining }).
Design reflection
Three load-bearing decisions encoded here:
-
submit_limitandsubmit_marketare separate functions, not parameterized. Even though the loops are 80% identical, the semantic difference (does the leftover rest or get discarded?) is in the missing code, not the code that's there. Parameterizing would require boolean flags likerest_remainder: boolandenforce_price: bool— turning the function bodies into branchy puzzles. The clear separation makes the two semantics easy to read independently. -
FillResult::remaining_qtycarries different meaning across order types. For Limit, it's alwaysQty(0)(rested or fully matched). For Market, it's the actual unfilled remainder. The type is the same; the contract differs. This is OK because the field doc onFillResult(Lesson 2) explicitly names both interpretations. -
Empty-book Market orders return cleanly, not via error. A Market buy against an empty asks book returns
FillResult { fills: vec![], remaining_qty: order.qty }. No error. This is the right default: the caller asked us to match, we matched as much as we could (zero), and we reported the leftover. "No liquidity available" isn't a runtime error (Result::Err) — it's a valid "zero-fill state transition." This matters especially in a consensus chain: makingsubmit_marketreturnResultforces every caller (EVM precompiles, the bridge, the upper transaction execution layer) to branch between "empty book" and "other errors," complicating gas accounting and state rollback. Keeping the function total (always returns aFillResult, for any input) means the upper layer only has to check "isfillsempty?" once — purely functional, predictable. Total + side-effect-free is the consensus state-machine discipline.
Answer key
cd ~/code/openhl-reference
git checkout 55a9dff
diff -u ~/code/my-openhl/crates/clob/src/book.rs ./crates/clob/src/book.rs
After Lesson 5, your book.rs is approximately the first ~190 lines of the reference. The remaining ~25 lines are cancel() (Lesson 6) and module exports.
Return:
git checkout main
Common questions
Q: What's the use case for an empty-book Market order to return cleanly instead of erroring? Production matching engines see this often: a thin market opens, the orderbook is empty briefly between fills, and a Market order arrives. The right behavior is "produce zero fills, report full remainder, let the caller decide what to do." The caller might retry later, switch to a Limit order, or surface an error to the user — but the matching engine itself doesn't decide.
Q: Why is the maker's resting price used, even though Market doesn't have its own price? The fill price is always the resting order's price (the maker's). Market orders don't supply a price; they accept whatever the book offers. The "price discovery" is what makes a market a market — the buyer doesn't dictate price; the spread between best bid and best ask does.
Q: Could a Market order produce a Fill with zero quantity?
No. match_at_level computes fill_qty = min(maker.qty, remaining). For this to be zero, either maker.qty or remaining would have to be zero. Both invariants are maintained: submit_market breaks out of the loop the moment remaining == 0, and a maker queue never has a zero-qty resting order (the matching code shrinks qty and pops the maker when it hits zero). So match_at_level is never called with either being zero.
Q: What about partial fills against multiple price levels?
Market orders handle this naturally. A 100-unit Market buy facing asks {99: [30 units], 100: [30 units], 101: [50 units]} produces three fills (30 @ 99, 30 @ 100, 40 @ 101). Each iteration of the loop calls match_at_level against the front of the next-best level; the loop continues until remaining == 0 or the book runs out. The walk-multiple-levels behavior is the same as for crossing Limit orders.
Next lesson (Lesson 6)
The matching engine handles submit. It can't handle cancel yet — a user who wants to remove their resting order before it gets filled has no way to do so. Lesson 6 adds cancel(order_id) -> bool:
- Linear scan through both bids and asks until the order is found.
- O(n) for now, where n is total resting orders. We'll address whether to add an O(1) index in a later openhl stage.
- Critically: drops the price level if cancellation leaves it empty (the same invariant
submitmaintains viaif queue.is_empty() { self.asks.remove(...) }).
Summary (3 lines)
submit_marketreusesmatch_at_levelwith no limit-price check. Cancel remainder if not fully filled.- Optional slippage protection via max_slippage_bps. Empty book → Cancelled, partial → PartiallyFilled.
- Production use: liquidation (must fill regardless of price). Three tests cover the cases. Next: cancel.