Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,4 +4,4 @@ An order id is a specific code containing both a slot index and a sequence numbe

The project consists of three modules. The first, `levels`, maintains queues of orders for each price level, tracking both price and volume. The second, `book`, organizes these levels by price for both buyers and sellers. The third, `matching`, handles the execution of limit orders, supporting GTC, IOC, FOK, and PostOnly order types. All modules have been thoroughly tested, including the use of randomized data to verify correctness against a reference model. The repository includes a benchmark comparing these queues to standard implementations based on `container/list`; the results show that adding and canceling orders is approximately 10 times faster. With one million active orders, performance is 8 times higher—all achieved without a single memory allocation.

Additionally, the price index implementation was benchmarked against red-black trees (from `emirpasic/gods`) and B-trees (from `google/btree`). The simple sorted array I used proved faster for searches: 2–8 times faster for narrow price ranges, 20–40% faster across the entire order book, and 10 times faster for depth-based traversal. Tree structures only performed better when frequent modifications to levels deep within the order book were required—a relatively uncommon operation. These benchmarks are not included in the repository to avoid adding unnecessary dependencies for testing.
The price index is implemented as a two-tier structure. Price levels are stored in blocks of 128 entries each, with a short chain of keys pointing to these blocks. In essence, this is a two-level B+ tree—a configuration sufficient for the order book of a single instrument. Both tiers consist of standard arrays without pointers. Searches begin at the best price and proceed deeper into the book; consequently, a search near the spread affects only one block, and adding an order deeper in the book requires modifying at most one block. Compared to a standard sorted array, this approach increases overhead by 1–3% during the most active operations, but it delivers significant performance gains: it is 2 times faster when modifying 1,000 levels deep in the book, 12 times faster at 10,000 levels, and 59 times faster at 50,000 levels. A benchmark comparing this structure to a simple array can be found in the `book` directory. Red-black trees (from `emirpasic/gods`) and B-trees (from `google/btree`) perform worse in these scenarios—they are 3–5 times slower near the spread and 10 times slower when traversing deeper levels. Furthermore, they underperform the block-based structure even during active trading and require separate memory allocation for each node. These specific measurements are not included in the repository to avoid adding unnecessary dependencies for testing.
306 changes: 306 additions & 0 deletions book/bench_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,306 @@
package book

import (
"math/rand"
"slices"
"strconv"
"testing"

"orderbook/levels"
)

// Run:
//
// go test -run '^$' -bench . -benchmem -count 3 ./book
//
// Compares the blocked side against a flat sorted slice of the same
// entries with the same gallop search. Bid semantics: best is the
// highest price.

const (
rndSize = 1 << 20
rndMask = rndSize - 1
)

var rnd = func() []uint32 {
r := rand.New(rand.NewSource(1))
out := make([]uint32, rndSize)
for i := range out {
out[i] = r.Uint32()
}
return out
}()

var (
sinkInt int
sinkU64 uint64
sinkBool bool
)

type flatSide struct {
entries []entry
}

func (s *flatSide) find(price uint64) (int, levels.LevelIndex, bool) {
i, ok := gallop(s.entries, true, price)
if !ok {
return i, -1, false
}
return i, levels.LevelIndex(s.entries[i].idx), true
}

func (s *flatSide) insertAt(i int, idx levels.LevelIndex, price uint64) {
s.entries = slices.Insert(s.entries, i, entry{price: price, idx: int32(idx)})
}

func (s *flatSide) removeAt(i int) {
s.entries = append(s.entries[:i], s.entries[i+1:]...)
}

// prices returns k distinct prices spread over a range four times larger,
// in random order, plus the leftover prices as a free pool.
func prices(k int) (live, free []uint64) {
all := make([]uint64, 4*k)
for i := range all {
all[i] = uint64(i + 1)
}
rand.New(rand.NewSource(2)).Shuffle(len(all), func(a, b int) { all[a], all[b] = all[b], all[a] })
return all[:k], all[k:]
}

func benchSide(live []uint64) *side {
s := &side{bid: true}
for i, p := range live {
j, _, _ := s.find(p)
s.insertAt(j, levels.LevelIndex(i), p)
}
return s
}

func benchFlat(live []uint64) *flatSide {
s := &flatSide{}
for i, p := range live {
j, _, _ := s.find(p)
s.insertAt(j, levels.LevelIndex(i), p)
}
return s
}

// spreadPrices returns k-10 static deep prices plus a 20-price region at the
// top of the book, half of it live and half free, so that churn stays within
// the ten best levels.
func spreadPrices(k int) (deep, live, free []uint64) {
deep = make([]uint64, k-10)
for i := range deep {
deep[i] = uint64(i + 1)
}
region := make([]uint64, 20)
for i := range region {
region[i] = uint64(k - 10 + i + 1)
}
rand.New(rand.NewSource(3)).Shuffle(len(region), func(a, b int) { region[a], region[b] = region[b], region[a] })
return deep, region[:10], region[10:]
}

var sizes = []int{100, 1_000, 10_000, 50_000}

func BenchmarkFind(b *testing.B) {
for _, k := range sizes {
live, _ := prices(k)
b.Run("blocks/k="+strconv.Itoa(k), func(b *testing.B) {
s := benchSide(live)
b.ReportAllocs()
b.ResetTimer()
for i := 0; i < b.N; i++ {
p, _, ok := s.find(live[rnd[i&rndMask]%uint32(k)])
sinkInt += p.i
sinkBool = ok
}
})
b.Run("flat/k="+strconv.Itoa(k), func(b *testing.B) {
s := benchFlat(live)
b.ReportAllocs()
b.ResetTimer()
for i := 0; i < b.N; i++ {
j, _, ok := s.find(live[rnd[i&rndMask]%uint32(k)])
sinkInt += j
sinkBool = ok
}
})
}
}

func BenchmarkFindNear(b *testing.B) {
for _, k := range sizes {
live, _ := prices(k)
sorted := slices.Clone(live)
slices.Sort(sorted)
b.Run("blocks/k="+strconv.Itoa(k), func(b *testing.B) {
s := benchSide(live)
b.ReportAllocs()
b.ResetTimer()
for i := 0; i < b.N; i++ {
p, _, ok := s.find(sorted[k-1-int(rnd[i&rndMask]%10)])
sinkInt += p.i
sinkBool = ok
}
})
b.Run("flat/k="+strconv.Itoa(k), func(b *testing.B) {
s := benchFlat(live)
b.ReportAllocs()
b.ResetTimer()
for i := 0; i < b.N; i++ {
j, _, ok := s.find(sorted[k-1-int(rnd[i&rndMask]%10)])
sinkInt += j
sinkBool = ok
}
})
}
}

func BenchmarkBest(b *testing.B) {
for _, k := range sizes {
live, _ := prices(k)
b.Run("blocks/k="+strconv.Itoa(k), func(b *testing.B) {
s := benchSide(live)
b.ReportAllocs()
b.ResetTimer()
for i := 0; i < b.N; i++ {
l, ok := s.best()
sinkInt += int(l)
sinkBool = ok
}
})
b.Run("flat/k="+strconv.Itoa(k), func(b *testing.B) {
s := benchFlat(live)
b.ReportAllocs()
b.ResetTimer()
for i := 0; i < b.N; i++ {
sinkU64 += s.entries[len(s.entries)-1].price
}
})
}
}

func BenchmarkChurn(b *testing.B) {
for _, k := range sizes {
b.Run("blocks/k="+strconv.Itoa(k), func(b *testing.B) {
live, free := prices(k)
s := benchSide(live)
b.ReportAllocs()
b.ResetTimer()
for i := 0; i < b.N; i++ {
a := int(rnd[i&rndMask] % uint32(k))
f := int(rnd[(i+1)&rndMask] % uint32(len(free)))
old, nw := live[a], free[f]
p, _, _ := s.find(old)
s.removeAt(p)
p, _, _ = s.find(nw)
s.insertAt(p, levels.LevelIndex(a), nw)
live[a], free[f] = nw, old
}
})
b.Run("flat/k="+strconv.Itoa(k), func(b *testing.B) {
live, free := prices(k)
s := benchFlat(live)
b.ReportAllocs()
b.ResetTimer()
for i := 0; i < b.N; i++ {
a := int(rnd[i&rndMask] % uint32(k))
f := int(rnd[(i+1)&rndMask] % uint32(len(free)))
old, nw := live[a], free[f]
j, _, _ := s.find(old)
s.removeAt(j)
j, _, _ = s.find(nw)
s.insertAt(j, levels.LevelIndex(a), nw)
live[a], free[f] = nw, old
}
})
}
}

func BenchmarkChurnSpread(b *testing.B) {
for _, k := range sizes {
b.Run("blocks/k="+strconv.Itoa(k), func(b *testing.B) {
deep, live, free := spreadPrices(k)
s := benchSide(append(deep, live...))
b.ReportAllocs()
b.ResetTimer()
for i := 0; i < b.N; i++ {
a := int(rnd[i&rndMask] % 10)
f := int(rnd[(i+1)&rndMask] % 10)
old, nw := live[a], free[f]
p, _, _ := s.find(old)
s.removeAt(p)
p, _, _ = s.find(nw)
s.insertAt(p, levels.LevelIndex(a), nw)
live[a], free[f] = nw, old
}
})
b.Run("flat/k="+strconv.Itoa(k), func(b *testing.B) {
deep, live, free := spreadPrices(k)
s := benchFlat(append(deep, live...))
b.ReportAllocs()
b.ResetTimer()
for i := 0; i < b.N; i++ {
a := int(rnd[i&rndMask] % 10)
f := int(rnd[(i+1)&rndMask] % 10)
old, nw := live[a], free[f]
j, _, _ := s.find(old)
s.removeAt(j)
j, _, _ = s.find(nw)
s.insertAt(j, levels.LevelIndex(a), nw)
live[a], free[f] = nw, old
}
})
}
}

func BenchmarkDepth(b *testing.B) {
live, _ := prices(1_000)
for _, n := range []int{10, 50} {
b.Run("cursor/n="+strconv.Itoa(n), func(b *testing.B) {
s := benchSide(live)
b.ReportAllocs()
b.ResetTimer()
for i := 0; i < b.N; i++ {
var sum int
cu, ok := s.cursor()
for d := 0; d < n && ok; d++ {
sum += int(cu.idx())
ok = cu.prev()
}
sinkInt += sum
}
})
b.Run("depth/n="+strconv.Itoa(n), func(b *testing.B) {
s := benchSide(live)
out := make([]levels.LevelIndex, n)
b.ReportAllocs()
b.ResetTimer()
for i := 0; i < b.N; i++ {
var sum int
for _, l := range out[:s.depth(out)] {
sum += int(l)
}
sinkInt += sum
}
})
b.Run("flat/n="+strconv.Itoa(n), func(b *testing.B) {
s := benchFlat(live)
out := make([]levels.LevelIndex, n)
b.ReportAllocs()
b.ResetTimer()
for i := 0; i < b.N; i++ {
for d := range out {
out[d] = levels.LevelIndex(s.entries[len(s.entries)-1-d].idx)
}
var sum int
for _, l := range out {
sum += int(l)
}
sinkInt += sum
}
})
}
}
45 changes: 30 additions & 15 deletions book/book.go
Original file line number Diff line number Diff line change
Expand Up @@ -41,15 +41,15 @@ func (b *Book) Rest(s Side, h levels.Handle, price, qty uint64) error {
}
sd := b.side(s)

i, found := sd.find(b.levels, price)
p, l, found := sd.find(price)
if found {
b.levels.Rest(h, sd.levels[i], qty)
b.levels.Rest(h, l, qty)
return nil
}

lvl := b.levels.NewLevel(price)
b.levels.Rest(h, lvl, qty)
sd.insertAt(i, lvl)
sd.insertAt(p, lvl, price)
return nil
}

Expand All @@ -65,14 +65,24 @@ func (b *Book) Add(s Side, seq, price, qty uint64) (levels.Handle, error) {
return h, nil
}

func (b *Book) LevelAt(s Side, depth int) (price, totalQty uint64, ok bool) {
sd := b.side(s)
i := len(sd.levels) - 1 - depth
if i < 0 {
type Cursor struct {
b *Book
cu cursor
ok bool
}

func (b *Book) Cursor(s Side) Cursor {
cu, ok := b.side(s).cursor()
return Cursor{b: b, cu: cu, ok: ok}
}

func (c *Cursor) Next() (price, totalQty uint64, ok bool) {
if !c.ok {
return 0, 0, false
}
l := sd.levels[i]
return b.levels.Price(l), b.levels.TotalQty(l), true
l := c.cu.idx()
c.ok = c.cu.prev()
return c.b.levels.Price(l), c.b.levels.TotalQty(l), true
}

func (b *Book) Cancel(h levels.Handle) (uint64, error) {
Expand Down Expand Up @@ -133,16 +143,21 @@ func (b *Book) side(s Side) *side {
func (b *Book) dropLevel(idx levels.LevelIndex) {
price := b.levels.Price(idx)

i, found := b.bids.find(b.levels, price)
if found && b.bids.levels[i] == idx {
b.bids.removeAt(i)
sd, other := &b.asks, &b.bids
if n := len(b.bids.chain); n > 0 && price <= b.bids.chain[n-1].price {
sd, other = &b.bids, &b.asks
}

p, l, found := sd.find(price)
if found && l == idx {
sd.removeAt(p)
b.levels.FreeLevel(idx)
return
}

i, found = b.asks.find(b.levels, price)
if found && b.asks.levels[i] == idx {
b.asks.removeAt(i)
p, l, found = other.find(price)
if found && l == idx {
other.removeAt(p)
b.levels.FreeLevel(idx)
return
}
Expand Down
Loading
Loading