This document explains how the order book and matching engine work.
The platform implements a price-time priority matching engine:
- Orders match at the best available price first
- At the same price, earlier orders (by transaction ID) match first
Only limit orders are supported. To achieve market order behavior, set the limit price to the market's max settlement (for buys) or min settlement (for sells)—this ensures you match with any available counterparty.
order
├── id (auto-increment)
├── market_id
├── owner_id (account placing the order)
├── transaction_id (for time priority)
├── price (TEXT, stored as Decimal)
├── size (TEXT, stored as Decimal)
└── side (bid/offer)
The order_size table tracks size changes over time:
order_size
├── order_id
├── transaction_id
└── size
This enables historical replay of the order book at any point.
1. Validate order (price/size precision, market open, etc.)
2. Search for matching counterparty orders
3. Execute fills against matches
4. Post remaining size as resting order (if any)
5. Update balances and exposures
6. Broadcast updates
For incoming BID:
SELECT * FROM order
WHERE market_id = ?
AND side = 'offer'
AND CAST(price AS REAL) <= bid_price + 0.000001 -- Small tolerance
AND CAST(size AS REAL) > 0
ORDER BY CAST(price AS REAL) ASC, transaction_id ASCMatches cheapest offers first, then FIFO at each price.
For incoming OFFER:
SELECT * FROM order
WHERE market_id = ?
AND side = 'bid'
AND CAST(price AS REAL) >= offer_price - 0.000001
AND CAST(size AS REAL) > 0
ORDER BY CAST(price AS REAL) DESC, transaction_id ASCMatches highest bids first, then FIFO at each price.
for each matching_order in matches {
// Calculate fill size
fill_size = min(remaining_size, matching_order.size);
// Create fill record
fills.push(OrderFill {
id: matching_order.id,
owner_id: matching_order.owner_id,
size_filled: fill_size,
size_remaining: matching_order.size - fill_size,
price: matching_order.price,
side: matching_order.side,
});
// Update remaining
remaining_size -= fill_size;
if remaining_size == 0 {
break; // Fully filled
}
}New BID for 10 units at 50.00:
| Step | Matching Order | Fill | Result |
|---|---|---|---|
| 1 | OFFER 3 @ 48.00 | 3 units | OFFER consumed, 7 remaining |
| 2 | OFFER 5 @ 49.00 | 5 units | OFFER consumed, 2 remaining |
| 3 | OFFER 4 @ 50.00 | 2 units | OFFER partial (2 left), BID filled |
Result: BID fully filled, no resting order created.
Each fill creates a trade record:
trade
├── id
├── market_id
├── buyer_id
├── seller_id
├── transaction_id
├── price (fill price)
├── size (fill size)
└── buyer_is_taker (bool)
- Taker: The incoming (aggressive) order that initiates the match
- Maker: The resting order on the book
buyer_is_taker = true means the buyer placed the incoming order.
Balance updates modify account balances. On each fill:
// Buyer pays
buyer.balance -= size * price;
// Seller receives
seller.balance += size * price;Both updates happen atomically within the same database transaction.
The exposure_cache table tracks per-account-per-market positions (see also Portfolios):
exposure_cache
├── account_id
├── market_id
├── position (net quantity, + for long, - for short)
├── total_bid_size (sum of open bid sizes)
├── total_offer_size (sum of open offer sizes)
├── total_bid_value (sum of bid_size * bid_price)
└── total_offer_value (sum of offer_size * offer_price)
Updated immediately on:
- Order creation
- Order cancellation
- Trade execution
Used for fast portfolio calculations without re-aggregating all trades.
fn cancel_order(order_id, requesting_account_id) {
// Validate
- Order exists and size > 0
- Requester owns the order
- Market not paused
// Cancel
- Insert order_size record with size = 0
- Update exposure_cache
// Return confirmation
}Cancel all orders in a market (see WebSocket Protocol for message details):
Client ──── Out { market_id: 123, side: null } ────▶ Server
Options:
market_id: null→ Cancel in all marketsside: BID/OFFER→ Cancel only bids or offers- Skips paused markets
The frontend sorts orders for display:
// Bids: highest price first (best at top)
sortedBids: sort by price DESC
// Offers: lowest price first (best at top)
sortedOffers: sort by price ASC- Maximum 2 decimal places for both price and size
- Stored as TEXT (Decimal) to avoid floating-point errors
- Matching queries use small tolerance (0.000001) for float comparison
Before accepting an order:
| Check | Error |
|---|---|
| Price > 0 | InvalidPrice |
| Size > 0 | InvalidSize |
| Price precision ≤ 2 decimals | InvalidPrice |
| Size precision ≤ 2 decimals | InvalidSize |
| Market exists | MarketNotFound |
| Market not settled | MarketSettled |
| Market status = Open | MarketPaused |
| Price ≥ min_settlement | InvalidPrice |
| Price ≤ max_settlement | InvalidPrice |
CREATE INDEX idx_order_market_id_side_price
ON "order" ("market_id", "side", CAST("price" AS REAL))
WHERE CAST("size" AS REAL) > 0;Pre-computed exposures avoid aggregating all orders/trades on every portfolio request.
Monotonically increasing transaction IDs enable:
- Efficient FIFO ordering at price level
- Historical replay without timestamp parsing
- Atomic ordering guarantees
The order book state at any transaction can be reconstructed:
function ordersAtTransaction(orders, txId) {
return orders
.map(order => {
// Find size at that transaction
const sizeAtTx = order.sizes
.filter(s => s.transaction_id <= txId)
.sort((a, b) => b.transaction_id - a.transaction_id)[0];
return { ...order, size: sizeAtTx?.size ?? 0 };
})
.filter(o => o.size > 0);
}This powers the transaction slider in the UI for replaying market history.
- Architecture Overview - System overview
- WebSocket Protocol - Message formats