Lesson 2 — Order, Fill, FillResult
Question
Order represents a resting order; Fill represents an executed match; FillResult aggregates fills + status. Three structs that capture the lifecycle.
Principle (minimum model)
Orderstruct.order_id + account_id + market_id + side + order_type + size + limit_price + sequence + timestamp. The resting record.Fillstruct.maker_order_id + taker_order_id + price + size + fee_maker + fee_taker + timestamp. Per-match record.FillResultaggregates.order_id + filled_total + remaining + status + fills. Returned fromsubmit.enum OrderStatus.Open(still in book) /Filled(fully matched) /PartiallyFilled(matched some, rest open) /Cancelled(removed).- Conservation.
filled_total + remaining == initial_size. Invariant of the FillResult struct; checked by debug_assert. - Sequence numbers. Monotonically increasing; used for FIFO ordering at the same price level. Critical for fairness.
- Why three types. Order is the persistent state; Fill is the event; FillResult is the API return. Separating prevents bugs from mixing them.
Worked example + steps
Lesson 2 — Order, Fill, FillResult
Goal
Concepts you'll grasp in this lesson:
- Self-contained messages cross module boundaries cleanly —
Fillcarries bothmaker_order_idANDmaker_accounteven though one could be derived from the other; this decouples Fill consumers (precompiles, payload assembly) from the engine's internal index. - Separating "fills" from "remainder" is a type-level decision —
FillResult { fills, remaining_qty }makes a submit's two distinct outputs explicit, instead of overloadingVec<Fill>with a phantom remainder entry. - Compute, don't cache, for derived totals —
total_filled()is a method, not a field; caching would force every fill-list mutation to keep a counter in sync, while computing on demand keepsFillResulta pure data record. Copyreflects semantics, not convenience —Order(5 small fields, ~48 bytes) isCopy;FillResult(owns aVec<Fill>) is not.Copyonly fits when=is one bit-blit.
Verification:
cargo check -p openhl-clob
…still compiles.
Specific changes:
You'll have 3 record types in crates/clob/src/types.rs, built from Lesson 1's newtypes:
Order— the input to the matching engine (id, account, side, qty, order_type).Fill— the output of a single match between a maker and a taker (maker_order_id, taker_order_id, maker_account, taker_account, price, qty).FillResult— the wrapper around a submit's return:fills: Vec<Fill>+remaining_qty: Qty+ atotal_filled()helper.
That completes the type vocabulary. Lesson 3+ uses these types to build the matching state machine.
Recap
After Lesson 1, your crates/clob/src/types.rs has:
// Lesson 1 — field-level types
pub struct AccountId(pub u64);
pub struct OrderId(pub u64);
pub struct Price(pub u64);
pub struct Qty(pub u64);
pub enum Side { Buy, Sell }
pub enum OrderType { Limit { price: Price }, Market }
// + Display impls for OrderId, Price, Qty
About 65 lines. cargo check -p openhl-clob passes. What's missing: types that combine these — what does an order look like, what does a fill look like, what does the engine return after a submit. Lesson 2 fills exactly that gap.
Plan
Three records to add, all to the same types.rs:
Order— 5 fields, all from Lesson 1's types. The matching engine takes oneOrderand returns oneFillResult.Fill— 6 fields naming maker + taker explicitly. Both maker_order_id AND maker_account are stored because the chain integration (course 8) needs the account to credit/debit balances.FillResult— collects the fills plus the unmatched-and-not-rested remainder. Includes atotal_filled()helper so callers can ask "how much got matched?" without iterating.
The three records relate like this:
flowchart LR
Order["Order<br/>(taker)"] -->|submit| Engine["matching engine"]
Engine -->|returns| Result["FillResult"]
Result --> Fills["fills: Vec<Fill>"]
Result --> Rem["remaining_qty: Qty"]
Order is the engine's input; FillResult is the output, split into "what matched" (a list of Fills) and "what didn't" (remaining_qty). This is the single dataflow one submit_order call produces inside the Lessons 4–5 matching engine.
No new dependencies. No code changes outside types.rs. The lesson is ~35 lines of code.
Walk-through
Step 1: Add Order below OrderType
Open crates/clob/src/types.rs. After the OrderType enum, before the Display impls, add:
/// A new order entering the book or arriving as a taker.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub struct Order {
pub id: OrderId,
pub account: AccountId,
pub side: Side,
pub qty: Qty,
pub order_type: OrderType,
}
5 fields. All Copy — Order is 8 (OrderId) + 8 (AccountId) + 1 (Side) + 8 (Qty) + 16 (OrderType — discriminant + Price) = 41 bytes. With padding, around 48 bytes. Small enough to pass by value freely; we don't need Box<Order> or &Order in normal code.
Memory-layout note: under Rust's default
#[repr(Rust)], the compiler reorders fields automatically to align them optimally and minimize padding. The declaration order above is for human readability — you don't have to sacrifice it to get the smallest size. Confirm withstd::mem::size_of::<Order>().
The field order is meaningful:
idfirst — the most-used field (lookups, equality, debug).account— who placed it.side— Buy or Sell.qty— how much.order_typelast — the most complex field (an enum), and the field that controls dispatch (Limit vs Market drives different matching logic in Lessons 4–5).
Step 2: Add Fill
Below Order:
/// A fill between a maker (resting order) and a taker (incoming order).
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub struct Fill {
pub maker_order_id: OrderId,
pub taker_order_id: OrderId,
pub maker_account: AccountId,
pub taker_account: AccountId,
pub price: Price,
pub qty: Qty,
}
6 fields. The maker-vs-taker distinction is the most important concept in matching-engine code:
- Maker = the order that was already resting on the book. They "made" liquidity available; they get the better deal economically (usually a rebate on real exchanges).
- Taker = the incoming order that consumed liquidity. They paid the spread; on real exchanges, they pay the fee.
Each Fill represents one matched pair. A single taker order can produce multiple Fills if it crosses multiple maker price levels (e.g., a market buy that walks up the ask side, eating each resting ask in turn).
Note price is the maker's price — when a taker hits the book, it matches at the maker's resting price, not the taker's limit. Limit-buyer at $101 matching a resting limit-seller at $100 fills at $100 (maker's price); the buyer wins. This is "price-time priority" in action.
Step 3: Add FillResult + total_filled() helper
Below Fill:
/// Result of submitting a taker order.
///
/// `fills` is the list of matched fills, in order of execution. `remaining_qty`
/// is the leftover taker quantity that was *not* rested on the book (Market
/// orders discard their remainder; fully-filled Limit orders return zero).
/// A partially-filled Limit order that rested on the book also returns zero
/// here — the remainder is in the book, not in the return value.
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct FillResult {
pub fills: Vec<Fill>,
pub remaining_qty: Qty,
}
impl FillResult {
/// Total quantity matched across all fills.
#[must_use]
pub fn total_filled(&self) -> Qty {
Qty(self.fills.iter().map(|f| f.qty.0).sum())
}
}
Note FillResult is NOT Copy — it owns a Vec<Fill> which is heap-allocated. It's Clone for tests and debug paths, but the engine returns it by value (no clone needed on the happy path).
Three things in the doc comment that the Lesson 3+ code will rely on:
fillsis in order of execution. If a market buy walks 3 ask levels, fills[0] is the cheapest match, fills[1] is the next, fills[2] is the most expensive. This ordering matters for replay determinism (Lesson 8's proptest will assert this).remaining_qtyis for unrested taker quantity only. A Market order with remainder 100 means 100 units couldn't be matched at any price (because the book ran out of liquidity). A Limit order with remainder 0 might still have an unfilled remainder — but that remainder is now in the book as a resting order, not in the return value.total_filledis a helper, not a stored field. It's an O(N) sum over fills. We don't cache it because (a)Vec::len()is usually what callers really want when they just need "did anything fill?", and (b) the actual quantity total is only needed by tests/inspection code, where O(N) doesn't matter. On top of that,Vec<Fill>is contiguous memory — for small N (1-3 in practice) the iteration fits in a single CPU cache line and runs much faster thanO(N)notation suggests. Mechanical sympathy says: caching buys little here.
Step 4: Confirm lib.rs still re-exports everything
You wrote pub use types::*; in Lesson 1's lib.rs. That * automatically picks up the 3 new types you just added — no edit needed. Verify by quickly checking:
// crates/clob/src/lib.rs (no change needed)
pub mod types;
pub use types::*;
If your lib.rs has individual re-exports like pub use types::{AccountId, OrderId, ...};, you'd need to add the 3 new types. But * is what Lesson 1 set up, so you don't.
Test
cargo check -p openhl-clob
Still compiles. Output is the same as Lesson 1 (no new warnings or errors, just slightly more code being checked).
You can also do a quick sanity test that the types are visible from another crate, e.g., from a future crates/evm/Cargo.toml perspective. We don't add a dep yet (that's Lesson 9), but you can prove the types are public:
cargo doc -p openhl-clob --no-deps --open
Browse the rendered docs. You should see Order, Fill, FillResult listed under "Structs" alongside AccountId/OrderId/Price/Qty. total_filled should appear under FillResult's methods.
Common errors and fixes:
error[E0277]: 'FillResult' doesn't implement 'Copy'— you tried to#[derive(Copy)]onFillResult. It can't be Copy because of the innerVec<Fill>. RemoveCopyfrom its derive; leaveClone.error[E0599]: no method named 'total_filled' for ...— you wrote the helper outsideimpl FillResult { ... }. The function needs to be inside an impl block.warning: field 'X' is never read— you wrote a field but no test/usage references it. Ignore for now — Lesson 3+ will use everything. The matching engine has no consumers yet.
Design reflection
Three load-bearing decisions encoded here:
-
Fillis self-contained. Both maker_order_id AND maker_account are stored, even though one could be derived from the other given the order book's internal index. This decouples Fill consumers (precompiles, payload assembly, chain integration) from the engine's internal data structures. Self-contained messages are easier to pass across module boundaries than references back to live state. -
FillResultseparates "fills" from "remainder." A submit produces zero-or-more fills AND zero-or-one remainder. Modeling them as a singleVec<Fill>would force a "phantom fill" for the remainder, or special-case logic to detect it. The two-field record makes the types do the work. -
total_filled()is computed, not cached. Caching would force every fill-list mutation to update a counter — error-prone. Computing on demand keepsFillResulta pure data record with no derived state. The O(N) cost is negligible because N is typically 1-3 (single fills are most common; a market order eating 10 levels is unusual).
Answer key
cd ~/code/openhl-reference
git checkout 55a9dff
diff -u ~/code/my-openhl/crates/clob/src/types.rs ./crates/clob/src/types.rs
After Lesson 2, your types.rs is approximately the full ~109 lines of the reference. The only diff should be doc-comment wording / whitespace. Lesson 1 + Lesson 2 together = complete types.rs.
Return:
git checkout main
Common questions
Q: Why is Order Copy but FillResult isn't?
Order has 5 fields, all Copy (newtypes over u64 + small enums). Total ~48 bytes — cheap to memcpy. FillResult owns a Vec<Fill>, which is heap-allocated; copying it would require allocator calls. Copy is only for types where = is a single bit-blit. The trait reflects that semantic.
Q: Why does Fill have qty: Qty instead of just a u64?
Consistency with the rest of the engine. All quantities are Qty-typed; mixing u64 here would force conversions at the boundary (and risk forgetting them). The newtype discipline is per-engine, not per-struct.
Q: Could FillResult use Box<[Fill]> instead of Vec<Fill>?
Yes, slightly more memory-efficient for the "no more pushes" case. But Vec<Fill> is what submit_order builds incrementally (push on each match); converting to Box<[Fill]> at the end would be one extra allocation. Until profiling shows it matters, Vec is the simpler choice.
Q: What if a fill's qty is 0? Is that a valid Fill?
No — the matching engine in Lessons 4–5 will never produce a zero-qty Fill (it would mean "we matched 0 units," which is just "we didn't match"). The type system doesn't enforce this; the engine's invariants do. Tests in Lessons 7–8 will catch any regression.
Next lesson (Lesson 3)
The type vocabulary is complete. Lesson 3 introduces the matching state machine — the Book struct that holds resting bid/ask orders, plus the helper methods (best_bid, best_ask, accessors for inspecting the book). No submit logic yet (that's Lesson 4); just the data structure and the Reverse<Price> trick for walking bids in highest-first order.
Summary (3 lines)
- Three structs: Order (resting record) + Fill (per-match event) + FillResult (API return = filled_total + remaining + status + fills).
- OrderStatus enum (Open / Filled / PartiallyFilled / Cancelled). Sequence numbers for FIFO at same price level.
- Conservation: filled_total + remaining == initial_size. Next: Book struct + Reverse<Price> trick.