Skip to content

Matching Engine

The matching engine lives entirely in memory, maintaining a separate OrderBook for each token ID. When an order arrives, the engine tries to match it against available resting liquidity, produces fill records, and hands the outcome back to the order route for database persistence.


The engine is a MatchingEngine struct backed by a DashMap<String, RwLock<OrderBook>>, where the key is the token ID. This layout supports concurrent reads across different tokens while serializing writes within each individual book.

Each OrderBook maintains two sides:

SideData StructureOrdering
BidsBTreeMap<Reverse<Decimal>, VecDeque<OrderEntry>>Highest price first
AsksBTreeMap<Decimal, VecDeque<OrderEntry>>Lowest price first

Orders sitting at the same price level are queued FIFO — the earliest arrival fills first. Together, this implements price-time priority: the most aggressive price always wins, and ties break by arrival time.


When submit_order() runs:

  1. The engine locates (or creates) the OrderBook for the given token
  2. An OrderEntry is built from the order ID, owner, price, size, order type, and expiration
  3. The book’s submit() method tries to match:
    • BUY orders cross against asks priced at or below the order price
    • SELL orders cross against bids priced at or above the order price
  4. Each crossing produces a Fill containing maker order ID, taker order ID, price, and size
  5. Whatever size remains is placed on the book as a resting order (for GTC/GTD types)
  6. The engine returns a SubmitResult with fills, remaining size, and status
StatusMeaning
LIVEOrder is resting on the book with unfilled size
MATCHEDOrder was fully filled immediately
CANCELEDOrder was rejected (post-only crossing, FOK not fully filled)

A post-only order is guaranteed to land on the book as a maker. If a post-only BUY would cross the best ask (or a post-only SELL would cross the best bid), the engine rejects it outright with status CANCELED instead of letting it execute as a taker.

// Server-side check in submit_post_only():
let would_cross = match side {
"BUY" => best_ask.map_or(false, |ask| entry.price >= ask),
_ => best_bid.map_or(false, |bid| entry.price <= bid),
};
if would_cross {
return (vec![], entry.remaining_size, "CANCELED");
}

TypeMatchingRemainder
GTCFill what crosses, rest the remainderRests on book until filled or cancelled
GTDSame as GTCRests until filled, cancelled, or expiration
FOKMust fill entirely or not at allRejected if insufficient resting liquidity
FAKFill what is available immediatelyUnfilled portion is cancelled

A periodic sweep across all books removes expired GTD orders through expire_orders(now_epoch). Any order whose expiration timestamp has passed gets pulled from the book, and its ID is returned so the corresponding database record can be marked CANCELED.


The matching engine goes through a scheduled weekly restart for updates and memory reclamation.

DetailValue
ScheduleEvery Tuesday at 11:00 UTC
DurationApproximately 60-90 seconds
Behaviorclear_all() wipes in-memory books, then orders are restored from the database

While the restart is in progress, the API responds with HTTP 425 (Too Early) on order-related endpoints. Clients should retry with exponential backoff.

use std::time::Duration;
let mut delay = Duration::from_secs(1);
for _ in 0..10 {
let order = client
.limit_order()
.token_id(token_id)
.price(price)
.size(size)
.side(side)
.build()
.await?;
let signed = client.sign(&signer, order).await?;
match client.post_order(signed).await {
Ok(response) => return Ok(response),
Err(e) if e.is_status(425) => {
eprintln!("Engine restarting, retrying in {delay:?}...");
tokio::time::sleep(delay).await;
delay = (delay * 2).min(Duration::from_secs(30));
continue;
}
Err(e) => return Err(e),
}
}

At startup (and after each weekly restart), the engine rebuilds its in-memory state by calling restore_order(). Every order with status LIVE is loaded from the database and inserted into the appropriate book side without triggering any matching logic.

The restore() method on OrderBook slots the entry directly into the bids or asks structure, keeping the original created_at timestamp intact so time priority remains accurate.

// During server startup:
engine.restore_order(
token_id,
condition_id,
side,
OrderEntry {
order_id,
owner,
maker_address,
price,
remaining_size: original_size - size_matched,
order_type,
expiration,
created_at,
},
);

MethodDescription
submit_order()Match and place an order
cancel_order()Remove an order from the in-memory book
restore_order()Re-insert a resting order (startup)
best_bid()Highest bid price for a token
best_ask()Lowest ask price for a token
midpoint()Average of best bid and best ask
spread()Difference between best ask and best bid
depth()All bid and ask price levels with aggregated sizes
implied_price()Midpoint price used as the implied probability
expire_orders()Remove expired GTD orders across all books
clear_all()Wipe all in-memory books (weekly restart)