FABRKNT
Build OpenHL CLOB — adding the matching engine
CLOB types
Lesson 3 of 13·CONTENT20 min50 XP

Treat this page as a workbench, not a blog post. The goal is to extract a reusable mental model from the source and carry it into the rest of the Fabrknt stack.

Course
Build OpenHL CLOB — adding the matching engine
Lesson role
CONTENT
Sequence
3 / 13

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)

  • Order struct. order_id + account_id + market_id + side + order_type + size + limit_price + sequence + timestamp. The resting record.
  • Fill struct. maker_order_id + taker_order_id + price + size + fee_maker + fee_taker + timestamp. Per-match record.
  • FillResult aggregates. order_id + filled_total + remaining + status + fills. Returned from submit.
  • 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 cleanlyFill carries both maker_order_id AND maker_account even 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 decisionFillResult { fills, remaining_qty } makes a submit's two distinct outputs explicit, instead of overloading Vec<Fill> with a phantom remainder entry.
  • Compute, don't cache, for derived totalstotal_filled() is a method, not a field; caching would force every fill-list mutation to keep a counter in sync, while computing on demand keeps FillResult a pure data record.
  • Copy reflects semantics, not convenienceOrder (5 small fields, ~48 bytes) is Copy; FillResult (owns a Vec<Fill>) is not. Copy only 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 + a total_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:

  1. Order — 5 fields, all from Lesson 1's types. The matching engine takes one Order and returns one FillResult.
  2. 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.
  3. FillResult — collects the fills plus the unmatched-and-not-rested remainder. Includes a total_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&lt;Fill&gt;"]
    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 with std::mem::size_of::<Order>().

The field order is meaningful:

  • id first — the most-used field (lookups, equality, debug).
  • account — who placed it.
  • side — Buy or Sell.
  • qty — how much.
  • order_type last — 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:

  1. fills is 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).
  2. remaining_qty is 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.
  3. total_filled is 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 than O(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)] on FillResult. It can't be Copy because of the inner Vec<Fill>. Remove Copy from its derive; leave Clone.
  • error[E0599]: no method named 'total_filled' for ... — you wrote the helper outside impl 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:

  1. Fill is 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.

  2. FillResult separates "fills" from "remainder." A submit produces zero-or-more fills AND zero-or-one remainder. Modeling them as a single Vec<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.

  3. total_filled() is computed, not cached. Caching would force every fill-list mutation to update a counter — error-prone. Computing on demand keeps FillResult a 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.