Building a High-Performance Limit Order Exchange in Java
A deep dive into designing and implementing a limit order matching engine, covering data structures, algorithms, and performance optimizations.
Anil Dabas
Software Engineer
Building a limit order exchange is one of the most fascinating challenges in financial software engineering. In this article, I'll walk through the key concepts and implementation details from my recent project.
What is a Limit Order Exchange?
A limit order exchange is a system that matches buy and sell orders based on price and time priority. When you place a limit order, you're specifying the maximum price you're willing to pay (for a buy) or the minimum price you're willing to accept (for a sell).
Core Data Structures
The Order Book
The heart of any exchange is the order book. It maintains two sides:
- Bid side: Buy orders, sorted by price (highest first)
- Ask side: Sell orders, sorted by price (lowest first)
public class OrderBook {
// Bids: highest price first
private final TreeMap<BigDecimal, Deque<Order>> bids =
new TreeMap<>(Comparator.reverseOrder());
// Asks: lowest price first
private final TreeMap<BigDecimal, Deque<Order>> asks =
new TreeMap<>();
public void addOrder(Order order) {
var book = order.side() == Side.BUY ? bids : asks;
book.computeIfAbsent(order.price(), k -> new LinkedList<>())
.addLast(order);
}
}
Why TreeMap?
I chose TreeMap because:
- O(log n) insertion and removal
- Natural ordering for price levels
- Efficient access to best bid/ask
Matching Algorithm
The matching algorithm is where the magic happens:
public List<Trade> match(Order incomingOrder) {
List<Trade> trades = new ArrayList<>();
var oppositeBook = incomingOrder.side() == Side.BUY ? asks : bids;
while (incomingOrder.remainingQuantity() > 0 && !oppositeBook.isEmpty()) {
var bestPrice = oppositeBook.firstKey();
// Check if prices cross
if (!pricesCross(incomingOrder, bestPrice)) {
break;
}
// Match against orders at best price
var ordersAtPrice = oppositeBook.get(bestPrice);
while (!ordersAtPrice.isEmpty() && incomingOrder.remainingQuantity() > 0) {
var restingOrder = ordersAtPrice.peekFirst();
var trade = executeTrade(incomingOrder, restingOrder);
trades.add(trade);
if (restingOrder.isFilled()) {
ordersAtPrice.removeFirst();
}
}
// Clean up empty price levels
if (ordersAtPrice.isEmpty()) {
oppositeBook.remove(bestPrice);
}
}
return trades;
}
Performance Optimizations
1. Lock-Free Structures
For high-throughput scenarios, consider lock-free data structures:
private final ConcurrentSkipListMap<BigDecimal, ConcurrentLinkedDeque<Order>> bids;
2. Object Pooling
Reduce GC pressure by pooling order objects:
private final ObjectPool<Order> orderPool = new ObjectPool<>(Order::new, 10000);
3. Primitive Collections
Use primitive collections for frequently accessed data:
// Instead of Map<Long, Order>
private final LongObjectHashMap<Order> ordersById = new LongObjectHashMap<>();
Handling Edge Cases
Self-Trading Prevention
if (incomingOrder.traderId() == restingOrder.traderId()) {
// Skip self-trade
continue;
}
Decimal Precision
Always use BigDecimal for prices:
public static final MathContext PRICE_CONTEXT =
new MathContext(8, RoundingMode.HALF_EVEN);
Lessons Learned
- Test extensively: Edge cases in matching logic can be costly
- Monitor latency: Track p99 latency, not just averages
- Design for failure: What happens when the system crashes mid-match?
Conclusion
Building an order matching engine requires careful attention to data structures, concurrency, and edge cases. The principles here apply to both traditional finance and cryptocurrency exchanges.
In my next article, I'll cover how to add support for different order types like market orders and stop-losses.