DEV Community

Bill Tu
Bill Tu

Posted on

Building a Trade Matching Engine from Scratch in Go

Every exchange — whether it trades stocks, crypto, or commodities — has a matching engine at its core. It is the component that takes buy and sell orders, finds compatible pairs, and executes trades. Despite being so fundamental, the inner workings of a matching engine are rarely discussed in the open. Most implementations live behind proprietary walls.

This article walks through MatchEngine, an open-source order matching engine written in Go. We will cover the core algorithm, the data structures behind the order book, and the design decisions that keep the codebase small and understandable.

Why Go?

Go is a natural fit for this kind of system:

  • Goroutines and mutexes make concurrency straightforward without the complexity of async runtimes.
  • The standard library is rich enough that zero external dependencies are needed.
  • Compilation to a single static binary makes deployment trivial.
  • The language's simplicity keeps the codebase readable — important for an open-source educational project.

The Domain Model

A matching engine operates on two fundamental entities: orders and trades.

Orders

An order represents a participant's intent to buy or sell a certain quantity at a certain price (or at any price, in the case of a market order).

type Order struct {
    ID        string
    Side      Side      // Buy or Sell
    Type      OrderType // Limit or Market
    Price     float64
    Quantity  float64
    Remaining float64
    Timestamp time.Time
}
Enter fullscreen mode Exit fullscreen mode

The Remaining field is key. When an order is partially filled, Remaining decreases while Quantity preserves the original size. This separation makes it easy to track fill progress without losing the original intent.

Two order types are supported:

  • Limit orders specify a maximum buy price or minimum sell price. They rest in the order book if no immediate match is available.
  • Market orders execute immediately at the best available price. They never enter the book — whatever isn't filled is simply dropped.

Trades

A trade is the result of two orders being matched:

type Trade struct {
    BuyOrderID  string
    SellOrderID string
    Price       float64
    Quantity    float64
    Timestamp   time.Time
}
Enter fullscreen mode Exit fullscreen mode

Simple and immutable. Once a trade is created, it is a historical fact.

The Order Book

The order book is the central data structure. It maintains two sorted lists:

  • Bids (buy orders): sorted by price descending, then by time ascending.
  • Asks (sell orders): sorted by price ascending, then by time ascending.

This sorting scheme implements price-time priority (also known as FIFO matching):

  1. The most aggressive price always gets matched first.
  2. Among orders at the same price, the earliest arrival wins.
Order Book for BTC/USD
─────────────────────────────
  Bids (Buy)    │  Asks (Sell)
─────────────────────────────
  10 @ 50200    │   5 @ 50300
   5 @ 50100    │   8 @ 50400
   3 @ 50000    │  12 @ 50500
─────────────────────────────
     Best Bid: 50200
     Best Ask: 50300
     Spread:   100
Enter fullscreen mode Exit fullscreen mode

The BestBid() and BestAsk() methods return the top of each side in O(1) since the slices are always sorted. The spread — the gap between the best bid and best ask — is a key indicator of market liquidity.

Why Sorted Slices Instead of a Tree?

Many production matching engines use red-black trees or skip lists for O(log n) insertion. We chose sorted slices with sort.SliceStable for a reason: clarity. This is an educational project first. The algorithmic trade-off is acceptable for moderate order volumes, and the code is immediately understandable to anyone who reads it.

Upgrading to a tree-based structure later is a localized change — it only affects the AddOrder method.

The Matching Algorithm

When a new order arrives, the engine attempts to match it against the opposite side of the book. The logic is symmetric for buys and sells, so let's trace through a buy order:

Incoming: BUY 8 BTC @ MARKET

Ask side of the book:
  s1: SELL 5 @ 50000
  s2: SELL 5 @ 50100

Step 1: Match against s1
  → Trade: 5 BTC @ 50000
  → s1 is fully filled (removed from book)
  → Incoming order remaining: 3

Step 2: Match against s2
  → Trade: 3 BTC @ 50100
  → s2 partially filled (remaining: 2, stays in book)
  → Incoming order remaining: 0 (fully filled)

Result: 2 trades executed
Enter fullscreen mode Exit fullscreen mode

The core matching loop is clean:

func (e *Engine) matchBuy(book *orderbook.OrderBook, order *model.Order) []model.Trade {
    var trades []model.Trade

    for !order.IsFilled() {
        bestAsk := book.BestAsk()
        if bestAsk == nil {
            break
        }
        if order.Type == model.Limit && bestAsk.Price > order.Price {
            break
        }
        trade := executeTrade(order, bestAsk, bestAsk.Price)
        trades = append(trades, trade)
    }

    return trades
}
Enter fullscreen mode Exit fullscreen mode

Three exit conditions:

  1. The incoming order is fully filled.
  2. The opposite side of the book is empty.
  3. For limit orders, the prices no longer cross (best ask is too expensive).

Market orders skip condition 3 — they consume whatever liquidity is available regardless of price.

Trade Execution

The executeTrade function handles the actual fill:

func executeTrade(buyOrder, sellOrder *model.Order, price float64) model.Trade {
    quantity := min(buyOrder.Remaining, sellOrder.Remaining)
    buyOrder.Remaining -= quantity
    sellOrder.Remaining -= quantity

    return model.Trade{
        BuyOrderID:  buyOrder.ID,
        SellOrderID: sellOrder.ID,
        Price:       price,
        Quantity:    quantity,
        Timestamp:   time.Now(),
    }
}
Enter fullscreen mode Exit fullscreen mode

The fill quantity is always the minimum of the two remaining amounts. This naturally handles both full fills and partial fills without any special-case logic.

Price Determination

The trade price is always the resting order's price — the order that was already in the book. This is standard exchange behavior: the passive side (maker) determines the price, and the aggressive side (taker) accepts it.

Thread Safety

The engine uses a sync.Mutex to serialize access to the order books:

type Engine struct {
    mu     sync.Mutex
    books  map[string]*orderbook.OrderBook
    trades []model.Trade
}
Enter fullscreen mode Exit fullscreen mode

Every public method (SubmitOrder, CancelOrder, GetTrades, GetOrderBook) acquires the lock before accessing shared state. This is a deliberate choice for simplicity — a single lock per engine instance.

For higher throughput, you could:

  • Use per-symbol locks (one mutex per order book) to allow parallel matching across different symbols.
  • Use a lock-free ring buffer for trade output.
  • Shard the engine by symbol across multiple goroutines.

But for correctness and clarity, a single mutex is the right starting point.

Multi-Symbol Support

The engine maintains a map from symbol to order book:

books map[string]*orderbook.OrderBook
Enter fullscreen mode Exit fullscreen mode

Order books are created lazily on first use. Symbols are completely isolated — a trade in BTC/USD has zero impact on the ETH/USD book. This is verified in the test suite:

func TestMultipleSymbols(t *testing.T) {
    e := New()
    e.SubmitOrder("AAPL", model.NewLimitOrder("a-s1", model.Sell, 150.0, 10))
    e.SubmitOrder("GOOG", model.NewLimitOrder("g-s1", model.Sell, 2800.0, 5))

    trades, _ := e.SubmitOrder("AAPL", model.NewLimitOrder("a-b1", model.Buy, 150.0, 10))
    // 1 AAPL trade, GOOG book unaffected
}
Enter fullscreen mode Exit fullscreen mode

Order Cancellation

Cancellation is straightforward — find the order by ID and remove it:

func (e *Engine) CancelOrder(symbol, orderID string) bool {
    e.mu.Lock()
    defer e.mu.Unlock()

    book, ok := e.books[symbol]
    if !ok {
        return false
    }
    return book.RemoveOrder(orderID)
}
Enter fullscreen mode Exit fullscreen mode

The boolean return tells the caller whether the order existed. Cancelling an already-filled or already-cancelled order returns false.

Test Coverage

The test suite covers the critical matching scenarios:

Test What It Verifies
TestLimitOrderMatching Basic limit order crossing
TestPartialFill Partial fills leave remainder in book
TestMarketOrder Market orders sweep multiple price levels
TestNoMatchWhenPricesDontCross Orders rest in book when spread exists
TestCancelOrder Cancel removes order, double-cancel fails
TestPriceTimePriority FIFO ordering at same price level
TestMultipleSymbols Symbol isolation
TestInvalidOrders Nil, negative price, zero quantity rejected

Run them with:

go test ./... -v
Enter fullscreen mode Exit fullscreen mode

Project Structure

.
├── pkg/
│   ├── model/        # Order and Trade types
│   ├── orderbook/    # Sorted order book with price-time priority
│   └── engine/       # Core matching logic + tests
├── cmd/
│   └── example/      # Runnable demo
├── go.mod
├── LICENSE
└── README.md
Enter fullscreen mode Exit fullscreen mode

The separation between model, orderbook, and engine is intentional:

  • model has zero dependencies — pure data types.
  • orderbook depends only on model — it knows how to store and sort orders, but not how to match them.
  • engine orchestrates everything — it owns the matching algorithm and coordinates order books.

This layering makes each package independently testable and replaceable.

What's Next?

This engine is a solid foundation, but a production system would need more:

  • Persistent storage: Write trades and order state to a database or write-ahead log for crash recovery.
  • Decimal precision: Replace float64 with a fixed-point decimal type (like shopspring/decimal) to avoid floating-point rounding issues in financial calculations.
  • Order types: Stop orders, stop-limit orders, fill-or-kill (FOK), immediate-or-cancel (IOC), and good-till-cancelled (GTC) with expiry.
  • Event streaming: Publish order book updates and trades to a message queue (Kafka, NATS) for downstream consumers.
  • REST/gRPC API: Expose the engine over the network so clients can submit orders programmatically.
  • Benchmarking: Add Go benchmarks to measure throughput (orders/second) and latency (time-to-match).
  • Optimized data structures: Replace sorted slices with a red-black tree or skip list for O(log n) insertion at scale.

Each of these is a natural extension point. The clean package boundaries make it possible to evolve one layer without rewriting the others.

Running the Demo

git clone https://github.com/iwtxokhtd83/MatchEngine.git
cd MatchEngine
go run cmd/example/main.go
Enter fullscreen mode Exit fullscreen mode

Output:

=== Order Matching Engine Demo ===

Placing sell orders...
  SELL 1.0 @ 50000
  SELL 2.0 @ 50100
  SELL 1.5 @ 50200

Placing buy limit order: BUY 0.5 @ 50000...
  TRADE: b1 <-> s1 | 0.5000 @ 50000.00

Placing market buy order: BUY 2.0 (market)...
  TRADE: b2 <-> s1 | 0.5000 @ 50000.00
  TRADE: b2 <-> s2 | 1.5000 @ 50100.00

--- Order Book ---
Bids:
Asks:
  s2: 0.5000 @ 50100.00
  s3: 1.5000 @ 50200.00

Total trades executed: 3
Enter fullscreen mode Exit fullscreen mode

Conclusion

A matching engine doesn't have to be mysterious. At its core, it is a sorted data structure and a loop that compares prices. The complexity in production systems comes from the surrounding infrastructure — networking, persistence, monitoring, and regulatory compliance — not from the matching logic itself.

MatchEngine strips away that complexity to expose the algorithm in its purest form. Fork it, extend it, break it, learn from it. That's what open source is for.

GitHub: https://github.com/iwtxokhtd83/MatchEngine
License: MIT

Top comments (0)