diff --git a/README.md b/README.md index 6b92255..d48fe24 100644 --- a/README.md +++ b/README.md @@ -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. \ No newline at end of file +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. \ No newline at end of file diff --git a/book/bench_test.go b/book/bench_test.go new file mode 100644 index 0000000..b6d9691 --- /dev/null +++ b/book/bench_test.go @@ -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 + } + }) + } +} diff --git a/book/book.go b/book/book.go index 7f5124c..f8dc9b7 100644 --- a/book/book.go +++ b/book/book.go @@ -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 } @@ -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) { @@ -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 } diff --git a/book/book_test.go b/book/book_test.go index 08f64e5..64f76c3 100644 --- a/book/book_test.go +++ b/book/book_test.go @@ -132,9 +132,10 @@ func TestFrontFill(t *testing.T) { require.PanicsWithValue(t, "levels: Fill: stale handle", func() { book.Fill(h1, 1) }) } -func TestLevelAt(t *testing.T) { +func TestCursor(t *testing.T) { book := New(10, 10) - _, _, ok := book.LevelAt(Buy, 0) + c := book.Cursor(Buy) + _, _, ok := c.Next() require.False(t, ok) _, err := book.Add(Buy, 1, 100, 10) @@ -148,34 +149,38 @@ func TestLevelAt(t *testing.T) { _, err = book.Add(Sell, 5, 103, 1) require.NoError(t, err) - price, totalQty, ok := book.LevelAt(Buy, 0) + c = book.Cursor(Buy) + price, totalQty, ok := c.Next() require.True(t, ok) require.Equal(t, uint64(100), price) require.Equal(t, uint64(10), totalQty) - price, totalQty, ok = book.LevelAt(Buy, 1) + price, totalQty, ok = c.Next() require.True(t, ok) require.Equal(t, uint64(99), price) require.Equal(t, uint64(2), totalQty) - price, totalQty, ok = book.LevelAt(Buy, 2) + price, totalQty, ok = c.Next() require.True(t, ok) require.Equal(t, uint64(98), price) require.Equal(t, uint64(3), totalQty) - _, _, ok = book.LevelAt(Buy, 3) + _, _, ok = c.Next() + require.False(t, ok) + _, _, ok = c.Next() require.False(t, ok) - price, totalQty, ok = book.LevelAt(Sell, 0) + c = book.Cursor(Sell) + price, totalQty, ok = c.Next() require.True(t, ok) require.Equal(t, uint64(101), price) require.Equal(t, uint64(7), totalQty) - price, totalQty, ok = book.LevelAt(Sell, 1) + price, totalQty, ok = c.Next() require.True(t, ok) require.Equal(t, uint64(103), price) require.Equal(t, uint64(1), totalQty) - _, _, ok = book.LevelAt(Sell, 2) + _, _, ok = c.Next() require.False(t, ok) } diff --git a/book/side.go b/book/side.go index ad9e514..41b926b 100644 --- a/book/side.go +++ b/book/side.go @@ -6,31 +6,48 @@ import ( "orderbook/levels" ) +const blockCap = 128 + +type block struct { + n int32 + entries [blockCap]entry +} + +type entry struct { + price uint64 + idx int32 +} + type side struct { - levels []levels.LevelIndex + blocks []block + chain []entry bid bool } -func (s *side) find(lv *levels.Levels, price uint64) (int, bool) { - n := len(s.levels) +type pos struct { + c, i int +} + +func gallop(s []entry, bid bool, price uint64) (int, bool) { + n := len(s) if n == 0 { return 0, false } r := n - 1 - rPrice := lv.Price(s.levels[r]) + rPrice := s[r].price if rPrice == price { return r, true } - if (s.bid && rPrice < price) || - (!s.bid && rPrice > price) { + if (bid && rPrice < price) || + (!bid && rPrice > price) { return n, false } l, step := r, 1 for l >= 0 { - lPrice := lv.Price(s.levels[l]) - if (s.bid && lPrice <= price) || (!s.bid && lPrice >= price) { + lPrice := s[l].price + if (bid && lPrice <= price) || (!bid && lPrice >= price) { break } r = l @@ -43,13 +60,13 @@ func (s *side) find(lv *levels.Levels, price uint64) (int, bool) { for l <= r { m := l + (r-l)/2 - mPrice := lv.Price(s.levels[m]) + mPrice := s[m].price if mPrice == price { return m, true } - if (s.bid && mPrice > price) || - (!s.bid && mPrice < price) { + if (bid && mPrice > price) || + (!bid && mPrice < price) { r = m - 1 } else { l = m + 1 @@ -59,17 +76,140 @@ func (s *side) find(lv *levels.Levels, price uint64) (int, bool) { return l, false } -func (s *side) insertAt(i int, idx levels.LevelIndex) { - s.levels = slices.Insert(s.levels, i, idx) +func (s *side) find(price uint64) (pos, levels.LevelIndex, bool) { + if len(s.chain) == 0 { + return pos{0, 0}, -1, false + } + + c := len(s.chain) - 1 + if key := s.chain[c].price; (s.bid && price < key) || (!s.bid && price > key) { + var found bool + c, found = gallop(s.chain, s.bid, price) + if found { + return pos{c, 0}, levels.LevelIndex(s.blocks[s.chain[c].idx].entries[0].idx), true + } + if c > 0 { + c-- + } + } + + b := &s.blocks[s.chain[c].idx] + i, found := gallop(b.entries[:b.n], s.bid, price) + if !found { + return pos{c, i}, -1, false + } + return pos{c, i}, levels.LevelIndex(b.entries[i].idx), true +} + +func (s *side) insertAt(p pos, idx levels.LevelIndex, price uint64) { + if len(s.chain) == 0 { + s.blocks = append(s.blocks, block{n: 1, entries: [blockCap]entry{{price: price, idx: int32(idx)}}}) + s.chain = append(s.chain, entry{price: price, idx: int32(len(s.blocks) - 1)}) + return + } + + k := s.chain[p.c].idx + if s.blocks[k].n == blockCap { + m := int32(blockCap / 2) + s.blocks = append(s.blocks, block{n: blockCap - m}) + nk := int32(len(s.blocks) - 1) + copy(s.blocks[nk].entries[:], s.blocks[k].entries[m:]) + s.blocks[k].n = m + s.chain = slices.Insert(s.chain, p.c+1, entry{price: s.blocks[nk].entries[0].price, idx: nk}) + if p.i > int(m) { + p.c++ + p.i -= int(m) + k = nk + } + } + + b := &s.blocks[k] + copy(b.entries[p.i+1:b.n+1], b.entries[p.i:b.n]) + b.entries[p.i] = entry{price: price, idx: int32(idx)} + b.n++ + if p.i == 0 { + s.chain[p.c].price = price + } } -func (s *side) removeAt(i int) { - s.levels = append(s.levels[:i], s.levels[i+1:]...) +func (s *side) removeAt(p pos) { + k := s.chain[p.c].idx + b := &s.blocks[k] + copy(b.entries[p.i:b.n-1], b.entries[p.i+1:b.n]) + b.n-- + + if b.n == 0 { + s.chain = append(s.chain[:p.c], s.chain[p.c+1:]...) + last := int32(len(s.blocks) - 1) + if k != last { + s.blocks[k] = s.blocks[last] + c, _ := gallop(s.chain, s.bid, s.blocks[k].entries[0].price) + s.chain[c].idx = k + } + s.blocks = s.blocks[:last] + return + } + if p.i == 0 { + s.chain[p.c].price = b.entries[0].price + } +} + +func (s *side) at(p pos) levels.LevelIndex { + return levels.LevelIndex(s.blocks[s.chain[p.c].idx].entries[p.i].idx) +} + +func (s *side) depth(out []levels.LevelIndex) int { + d := 0 + for c := len(s.chain) - 1; c >= 0 && d < len(out); c-- { + b := &s.blocks[s.chain[c].idx] + es := b.entries[:b.n] + for j := len(es) - 1; j >= 0 && d < len(out); j-- { + out[d] = levels.LevelIndex(es[j].idx) + d++ + } + } + return d +} + +type cursor struct { + s *side + b *block + c int + i int +} + +func (s *side) cursor() (cursor, bool) { + c := len(s.chain) - 1 + if c < 0 { + return cursor{}, false + } + b := &s.blocks[s.chain[c].idx] + return cursor{s: s, b: b, c: c, i: int(b.n) - 1}, true +} + +func (cu *cursor) idx() levels.LevelIndex { + return levels.LevelIndex(cu.b.entries[cu.i].idx) +} + +func (cu *cursor) prev() bool { + if cu.i > 0 { + cu.i-- + return true + } + if cu.c == 0 { + return false + } + cu.c-- + cu.b = &cu.s.blocks[cu.s.chain[cu.c].idx] + cu.i = int(cu.b.n) - 1 + return true } func (s *side) best() (levels.LevelIndex, bool) { - if len(s.levels) == 0 { + if len(s.chain) == 0 { return -1, false } - return s.levels[len(s.levels)-1], true + c := s.chain[len(s.chain)-1] + b := &s.blocks[c.idx] + return levels.LevelIndex(b.entries[b.n-1].idx), true } diff --git a/book/side_random_test.go b/book/side_random_test.go new file mode 100644 index 0000000..1030909 --- /dev/null +++ b/book/side_random_test.go @@ -0,0 +1,129 @@ +package book + +import ( + "fmt" + "math/rand" + "testing" + + "github.com/stretchr/testify/require" + + "orderbook/levels" +) + +// Reference model: one sorted slice of entries, best price at the end, +// exactly what side must look like when flattened. + +func modelFind(m []entry, bid bool, price uint64) (int, bool) { + for i, e := range m { + if e.price == price { + return i, true + } + if (bid && e.price > price) || (!bid && e.price < price) { + return i, false + } + } + return len(m), false +} + +// compareSide checks the side against the model and its own invariants: +// flattened entries, chain keys, block fill, block arena size, best, +// cursor walk and depth. +func compareSide(t *testing.T, s *side, m []entry) { + t.Helper() + + require.Equal(t, m, flat(s)) + require.Len(t, s.blocks, len(s.chain), "block arena vs chain") + + seen := map[int32]bool{} + for c, ce := range s.chain { + require.False(t, seen[ce.idx], "block %d linked twice", ce.idx) + seen[ce.idx] = true + b := &s.blocks[ce.idx] + require.Greater(t, b.n, int32(0), "chain %d: empty block", c) + require.LessOrEqual(t, b.n, int32(blockCap), "chain %d: overfull block", c) + require.Equal(t, b.entries[0].price, ce.price, "chain %d: key", c) + } + + best, ok := s.best() + require.Equal(t, len(m) > 0, ok) + if ok { + require.Equal(t, levels.LevelIndex(m[len(m)-1].idx), best) + } + cu, ok := s.cursor() + require.Equal(t, len(m) > 0, ok) + for d := len(m) - 1; d >= 0; d-- { + require.True(t, ok, "cursor at %d", d) + require.Equal(t, levels.LevelIndex(m[d].idx), cu.idx(), "cursor at %d", d) + ok = cu.prev() + } + require.False(t, ok) + + out := make([]levels.LevelIndex, len(m)+1) + require.Equal(t, len(m), s.depth(out)) + for d := range m { + require.Equal(t, levels.LevelIndex(m[len(m)-1-d].idx), out[d], "depth %d", d) + } + if len(m) > 3 { + require.Equal(t, 3, s.depth(out[:3])) + require.Equal(t, levels.LevelIndex(m[len(m)-3].idx), out[2]) + } +} + +func TestSideRandomAgainstModel(t *testing.T) { + for _, bid := range []bool{true, false} { + for seed := int64(1); seed <= 3; seed++ { + t.Run(fmt.Sprintf("bid=%v/seed=%d", bid, seed), func(t *testing.T) { + runSideRandom(t, bid, seed) + }) + } + } +} + +func runSideRandom(t *testing.T, bid bool, seed int64) { + rng := rand.New(rand.NewSource(seed)) + s := side{bid: bid} + var m []entry + var nextIdx int32 + + // Three phases: grow to several blocks, churn at a stable size, + // shrink back to empty. Insert probability per phase. + phases := []struct{ steps, insertPct int }{{6000, 85}, {6000, 50}, {6000, 15}} + step := 0 + for _, ph := range phases { + for k := 0; k < ph.steps; k++ { + step++ + if len(m) == 0 || rng.Intn(100) < ph.insertPct { + price := uint64(rng.Intn(10000) + 1) + mi, found := modelFind(m, bid, price) + p, _, ok := s.find(price) + require.Equal(t, found, ok, "step %d: find %d", step, price) + if found { + require.Equal(t, levels.LevelIndex(m[mi].idx), s.at(p), "step %d: find %d", step, price) + continue + } + nextIdx++ + e := entry{price: price, idx: nextIdx} + s.insertAt(p, levels.LevelIndex(nextIdx), price) + m = append(m[:mi], append([]entry{e}, m[mi:]...)...) + } else { + mi := rng.Intn(len(m)) + p, _, ok := s.find(m[mi].price) + require.True(t, ok, "step %d: remove %d", step, m[mi].price) + require.Equal(t, levels.LevelIndex(m[mi].idx), s.at(p), "step %d: remove %d", step, m[mi].price) + s.removeAt(p) + m = append(m[:mi], m[mi+1:]...) + } + + if step%200 == 0 { + compareSide(t, &s, m) + } + } + } + for len(m) > 0 { + p, _, ok := s.find(m[0].price) + require.True(t, ok) + s.removeAt(p) + m = m[1:] + } + compareSide(t, &s, m) +} diff --git a/book/side_test.go b/book/side_test.go index 47c2d26..00713fb 100644 --- a/book/side_test.go +++ b/book/side_test.go @@ -1,7 +1,6 @@ package book import ( - "slices" "testing" "github.com/stretchr/testify/require" @@ -9,271 +8,437 @@ import ( "orderbook/levels" ) -func makeLevels() (*levels.Levels, []levels.LevelIndex) { - l := levels.New(100, 100) - l.NewLevel(0) - l.NewLevel(0) - l6 := l.NewLevel(6) - l.NewLevel(0) - l8 := l.NewLevel(8) - l2 := l.NewLevel(2) - l.NewLevel(0) - l.NewLevel(0) - l.NewLevel(0) - l3 := l.NewLevel(3) - l.NewLevel(0) - l.NewLevel(0) - l7 := l.NewLevel(7) - l.NewLevel(0) - l4 := l.NewLevel(4) - return l, []levels.LevelIndex{l2, l3, l4, l6, l7, l8} +func newSide(bid bool, entries []entry) side { + s := side{bid: bid} + for _, e := range entries { + p, _, _ := s.find(e.price) + s.insertAt(p, levels.LevelIndex(e.idx), e.price) + } + return s } -func TestBidFind(t *testing.T) { - l, lvls := makeLevels() - s := side{ - bid: true, - levels: lvls, +func flat(s *side) []entry { + out := []entry{} + for _, c := range s.chain { + b := &s.blocks[c.idx] + out = append(out, b.entries[:b.n]...) } + return out +} - idx, ok := s.find(l, 4) +func TestBidFind(t *testing.T) { + s := newSide(true, []entry{ + {price: 2, idx: 10}, + {price: 3, idx: 12}, + {price: 4, idx: 37}, + {price: 6, idx: 99}, + {price: 7, idx: 45}, + {price: 8, idx: 18}, + }) + + idx, _, ok := s.find(4) require.True(t, ok) - require.Equal(t, 2, idx) + require.Equal(t, pos{0, 2}, idx) - idx, ok = s.find(l, 5) + idx, _, ok = s.find(5) require.False(t, ok) - require.Equal(t, 3, idx) + require.Equal(t, pos{0, 3}, idx) - idx, ok = s.find(l, 6) + idx, _, ok = s.find(6) require.True(t, ok) - require.Equal(t, 3, idx) + require.Equal(t, pos{0, 3}, idx) - idx, ok = s.find(l, 1) + idx, _, ok = s.find(1) require.False(t, ok) - require.Equal(t, 0, idx) + require.Equal(t, pos{0, 0}, idx) - idx, ok = s.find(l, 8) + idx, _, ok = s.find(8) require.True(t, ok) - require.Equal(t, 5, idx) + require.Equal(t, pos{0, 5}, idx) - idx, ok = s.find(l, 9) + idx, _, ok = s.find(9) require.False(t, ok) - require.Equal(t, 6, idx) + require.Equal(t, pos{0, 6}, idx) } func TestAskFind(t *testing.T) { - l, lvls := makeLevels() - slices.Reverse(lvls) - s := side{ - bid: false, - levels: lvls, - } - - idx, ok := s.find(l, 4) + s := newSide(false, []entry{ + {price: 8, idx: 18}, + {price: 7, idx: 45}, + {price: 6, idx: 99}, + {price: 4, idx: 37}, + {price: 3, idx: 12}, + {price: 2, idx: 10}, + }) + + idx, _, ok := s.find(4) require.True(t, ok) - require.Equal(t, 3, idx) + require.Equal(t, pos{0, 3}, idx) - idx, ok = s.find(l, 5) + idx, _, ok = s.find(5) require.False(t, ok) - require.Equal(t, 3, idx) + require.Equal(t, pos{0, 3}, idx) - idx, ok = s.find(l, 6) + idx, _, ok = s.find(6) require.True(t, ok) - require.Equal(t, 2, idx) + require.Equal(t, pos{0, 2}, idx) - idx, ok = s.find(l, 1) + idx, _, ok = s.find(1) require.False(t, ok) - require.Equal(t, 6, idx) + require.Equal(t, pos{0, 6}, idx) - idx, ok = s.find(l, 8) + idx, _, ok = s.find(8) require.True(t, ok) - require.Equal(t, 0, idx) + require.Equal(t, pos{0, 0}, idx) - idx, ok = s.find(l, 9) + idx, _, ok = s.find(9) require.False(t, ok) - require.Equal(t, 0, idx) + require.Equal(t, pos{0, 0}, idx) } func TestBidInsert(t *testing.T) { - l, lvls := makeLevels() - l2, l3, l4, l6, l7, l8 := lvls[0], lvls[1], lvls[2], lvls[3], lvls[4], lvls[5] - s := side{ - bid: true, - levels: slices.Grow(lvls, 1), - } - - l5 := l.NewLevel(5) - idx, ok := s.find(l, 5) + s := newSide(true, []entry{ + {price: 2, idx: 10}, + {price: 3, idx: 12}, + {price: 4, idx: 37}, + {price: 6, idx: 99}, + {price: 7, idx: 45}, + {price: 8, idx: 18}, + }) + + idx, _, ok := s.find(5) require.False(t, ok) - require.Equal(t, 3, idx) - - s.insertAt(idx, l5) - require.Equal(t, []levels.LevelIndex{l2, l3, l4, l5, l6, l7, l8}, s.levels) - - l1 := l.NewLevel(1) - idx, ok = s.find(l, 1) + require.Equal(t, pos{0, 3}, idx) + + s.insertAt(idx, 67, 5) + require.Equal(t, []entry{ + {2, 10}, + {3, 12}, + {4, 37}, + {5, 67}, + {6, 99}, + {7, 45}, + {8, 18}, + }, flat(&s)) + + idx, _, ok = s.find(1) require.False(t, ok) - require.Equal(t, 0, idx) - - s.insertAt(idx, l1) - require.Equal(t, []levels.LevelIndex{l1, l2, l3, l4, l5, l6, l7, l8}, s.levels) + require.Equal(t, pos{0, 0}, idx) + + s.insertAt(idx, 81, 1) + require.Equal(t, []entry{ + {1, 81}, + {2, 10}, + {3, 12}, + {4, 37}, + {5, 67}, + {6, 99}, + {7, 45}, + {8, 18}, + }, flat(&s)) best, found := s.best() require.True(t, found) - require.Equal(t, l8, best) + require.Equal(t, levels.LevelIndex(18), best) - l9 := l.NewLevel(9) - idx, ok = s.find(l, 9) + idx, _, ok = s.find(9) require.False(t, ok) - require.Equal(t, 8, idx) - - s.insertAt(idx, l9) - require.Equal(t, []levels.LevelIndex{l1, l2, l3, l4, l5, l6, l7, l8, l9}, s.levels) + require.Equal(t, pos{0, 8}, idx) + + s.insertAt(idx, 22, 9) + require.Equal(t, []entry{ + {1, 81}, + {2, 10}, + {3, 12}, + {4, 37}, + {5, 67}, + {6, 99}, + {7, 45}, + {8, 18}, + {9, 22}, + }, flat(&s)) best, found = s.best() require.True(t, found) - require.Equal(t, l9, best) + require.Equal(t, levels.LevelIndex(22), best) } func TestAskInsert(t *testing.T) { - l, lvls := makeLevels() - l2, l3, l4, l6, l7, l8 := lvls[0], lvls[1], lvls[2], lvls[3], lvls[4], lvls[5] - slices.Reverse(lvls) - s := side{ - bid: false, - levels: slices.Grow(lvls, 1), - } - - l5 := l.NewLevel(5) - idx, ok := s.find(l, 5) + s := newSide(false, []entry{ + {price: 8, idx: 18}, + {price: 7, idx: 45}, + {price: 6, idx: 99}, + {price: 4, idx: 37}, + {price: 3, idx: 12}, + {price: 2, idx: 10}, + }) + + idx, _, ok := s.find(5) require.False(t, ok) - require.Equal(t, 3, idx) - - s.insertAt(idx, l5) - require.Equal(t, []levels.LevelIndex{l8, l7, l6, l5, l4, l3, l2}, s.levels) + require.Equal(t, pos{0, 3}, idx) + + s.insertAt(idx, 67, 5) + require.Equal(t, []entry{ + {8, 18}, + {7, 45}, + {6, 99}, + {5, 67}, + {4, 37}, + {3, 12}, + {2, 10}, + }, flat(&s)) best, found := s.best() require.True(t, found) - require.Equal(t, l2, best) + require.Equal(t, levels.LevelIndex(10), best) - l1 := l.NewLevel(1) - idx, ok = s.find(l, 1) + idx, _, ok = s.find(1) require.False(t, ok) - require.Equal(t, 7, idx) - - s.insertAt(idx, l1) - require.Equal(t, []levels.LevelIndex{l8, l7, l6, l5, l4, l3, l2, l1}, s.levels) + require.Equal(t, pos{0, 7}, idx) + + s.insertAt(idx, 81, 1) + require.Equal(t, []entry{ + {8, 18}, + {7, 45}, + {6, 99}, + {5, 67}, + {4, 37}, + {3, 12}, + {2, 10}, + {1, 81}, + }, flat(&s)) best, found = s.best() require.True(t, found) - require.Equal(t, l1, best) + require.Equal(t, levels.LevelIndex(81), best) - l9 := l.NewLevel(9) - idx, ok = s.find(l, 9) + idx, _, ok = s.find(9) require.False(t, ok) - require.Equal(t, 0, idx) - - s.insertAt(idx, l9) - require.Equal(t, []levels.LevelIndex{l9, l8, l7, l6, l5, l4, l3, l2, l1}, s.levels) + require.Equal(t, pos{0, 0}, idx) + + s.insertAt(idx, 22, 9) + require.Equal(t, []entry{ + {9, 22}, + {8, 18}, + {7, 45}, + {6, 99}, + {5, 67}, + {4, 37}, + {3, 12}, + {2, 10}, + {1, 81}, + }, flat(&s)) } func TestBidRemove(t *testing.T) { - l, lvls := makeLevels() - l2, l3, l6, l7, l8 := lvls[0], lvls[1], lvls[3], lvls[4], lvls[5] - s := side{ - bid: true, - levels: lvls, - } - - idx, ok := s.find(l, 4) + s := newSide(true, []entry{ + {price: 2, idx: 10}, + {price: 3, idx: 12}, + {price: 4, idx: 37}, + {price: 6, idx: 99}, + {price: 7, idx: 45}, + {price: 8, idx: 18}, + }) + + idx, _, ok := s.find(4) require.True(t, ok) - require.Equal(t, 2, idx) + require.Equal(t, pos{0, 2}, idx) s.removeAt(idx) - require.Equal(t, []levels.LevelIndex{l2, l3, l6, l7, l8}, s.levels) + require.Equal(t, []entry{ + {2, 10}, + {3, 12}, + {6, 99}, + {7, 45}, + {8, 18}, + }, flat(&s)) best, found := s.best() require.True(t, found) - require.Equal(t, l8, best) + require.Equal(t, levels.LevelIndex(18), best) - idx, ok = s.find(l, 2) + idx, _, ok = s.find(2) require.True(t, ok) - require.Equal(t, 0, idx) + require.Equal(t, pos{0, 0}, idx) s.removeAt(idx) - require.Equal(t, []levels.LevelIndex{l3, l6, l7, l8}, s.levels) - - idx, ok = s.find(l, 8) + require.Equal(t, []entry{ + {3, 12}, + {6, 99}, + {7, 45}, + {8, 18}, + }, flat(&s)) + + idx, _, ok = s.find(8) require.True(t, ok) - require.Equal(t, 3, idx) + require.Equal(t, pos{0, 3}, idx) s.removeAt(idx) - require.Equal(t, []levels.LevelIndex{l3, l6, l7}, s.levels) + require.Equal(t, []entry{ + {3, 12}, + {6, 99}, + {7, 45}, + }, flat(&s)) best, found = s.best() require.True(t, found) - require.Equal(t, l7, best) + require.Equal(t, levels.LevelIndex(45), best) + } func TestAskRemove(t *testing.T) { - l, lvls := makeLevels() - l2, l3, l6, l7, l8 := lvls[0], lvls[1], lvls[3], lvls[4], lvls[5] - slices.Reverse(lvls) - s := side{ - bid: false, - levels: lvls, - } - - idx, ok := s.find(l, 4) + s := newSide(false, []entry{ + {price: 8, idx: 18}, + {price: 7, idx: 45}, + {price: 6, idx: 99}, + {price: 4, idx: 37}, + {price: 3, idx: 12}, + {price: 2, idx: 10}, + }) + + idx, _, ok := s.find(4) require.True(t, ok) - require.Equal(t, 3, idx) + require.Equal(t, pos{0, 3}, idx) s.removeAt(idx) - require.Equal(t, []levels.LevelIndex{l8, l7, l6, l3, l2}, s.levels) + require.Equal(t, []entry{ + {8, 18}, + {7, 45}, + {6, 99}, + {3, 12}, + {2, 10}, + }, flat(&s)) best, found := s.best() require.True(t, found) - require.Equal(t, l2, best) + require.Equal(t, levels.LevelIndex(10), best) - idx, ok = s.find(l, 2) + idx, _, ok = s.find(2) require.True(t, ok) - require.Equal(t, 4, idx) + require.Equal(t, pos{0, 4}, idx) s.removeAt(idx) - require.Equal(t, []levels.LevelIndex{l8, l7, l6, l3}, s.levels) + require.Equal(t, []entry{ + {8, 18}, + {7, 45}, + {6, 99}, + {3, 12}, + }, flat(&s)) best, found = s.best() require.True(t, found) - require.Equal(t, l3, best) + require.Equal(t, levels.LevelIndex(12), best) - idx, ok = s.find(l, 8) + idx, _, ok = s.find(8) require.True(t, ok) - require.Equal(t, 0, idx) + require.Equal(t, pos{0, 0}, idx) s.removeAt(idx) - require.Equal(t, []levels.LevelIndex{l7, l6, l3}, s.levels) + require.Equal(t, []entry{ + {7, 45}, + {6, 99}, + {3, 12}, + }, flat(&s)) } func TestEmpty(t *testing.T) { - _, lvls := makeLevels() - l7 := lvls[4] - s := side{bid: true} + s := newSide(true, nil) best, found := s.best() require.False(t, found) require.Equal(t, levels.LevelIndex(-1), best) - s.insertAt(0, l7) - require.Equal(t, []levels.LevelIndex{l7}, s.levels) + s.insertAt(pos{0, 0}, 45, 7) + require.Equal(t, []entry{{7, 45}}, flat(&s)) best, found = s.best() require.True(t, found) - require.Equal(t, l7, best) + require.Equal(t, levels.LevelIndex(45), best) - s.removeAt(0) - require.Empty(t, s.levels) + s.removeAt(pos{0, 0}) + require.Equal(t, []entry{}, flat(&s)) best, found = s.best() require.False(t, found) require.Equal(t, levels.LevelIndex(-1), best) } + +func TestSplit(t *testing.T) { + s := newSide(true, nil) + for p := uint64(2); p <= 2*blockCap; p += 2 { + i, _, ok := s.find(p) + require.False(t, ok) + s.insertAt(i, levels.LevelIndex(p), p) + } + require.Len(t, s.chain, 1) + require.Len(t, s.blocks, 1) + require.Equal(t, int32(blockCap), s.blocks[0].n) + + i, _, ok := s.find(2*blockCap + 2) + require.False(t, ok) + require.Equal(t, pos{0, blockCap}, i) + s.insertAt(i, 2*blockCap+2, 2*blockCap+2) + require.Len(t, s.chain, 2) + require.Len(t, s.blocks, 2) + require.Equal(t, entry{2, 0}, s.chain[0]) + require.Equal(t, entry{blockCap + 2, 1}, s.chain[1]) + require.Equal(t, int32(blockCap/2), s.blocks[0].n) + require.Equal(t, int32(blockCap/2+1), s.blocks[1].n) + + i, _, ok = s.find(blockCap + 2) + require.True(t, ok) + require.Equal(t, pos{1, 0}, i) + i, _, ok = s.find(blockCap) + require.True(t, ok) + require.Equal(t, pos{0, blockCap/2 - 1}, i) + + i, _, ok = s.find(blockCap + 1) + require.False(t, ok) + require.Equal(t, pos{0, blockCap / 2}, i) + s.insertAt(i, blockCap+1, blockCap+1) + require.Equal(t, int32(blockCap/2+1), s.blocks[0].n) + require.Equal(t, entry{blockCap + 2, 1}, s.chain[1]) + + all := flat(&s) + require.Len(t, all, blockCap+2) + for j := 1; j < len(all); j++ { + require.Less(t, all[j-1].price, all[j].price) + } + + best, found := s.best() + require.True(t, found) + require.Equal(t, levels.LevelIndex(2*blockCap+2), best) + cu, ok := s.cursor() + require.True(t, ok) + require.Equal(t, levels.LevelIndex(2*blockCap+2), cu.idx()) + for d := 0; d < blockCap/2; d++ { + require.True(t, cu.prev()) + } + require.Equal(t, levels.LevelIndex(blockCap+2), cu.idx()) + require.True(t, cu.prev()) + require.Equal(t, levels.LevelIndex(blockCap+1), cu.idx()) + for d := blockCap/2 + 1; d < blockCap+1; d++ { + require.True(t, cu.prev()) + } + require.Equal(t, levels.LevelIndex(2), cu.idx()) + require.False(t, cu.prev()) + + for len(s.chain) == 2 { + s.removeAt(pos{0, 0}) + } + require.Len(t, s.blocks, 1) + require.Equal(t, entry{blockCap + 2, 0}, s.chain[0]) + require.Equal(t, int32(blockCap/2+1), s.blocks[0].n) + all = flat(&s) + require.Len(t, all, blockCap/2+1) + require.Equal(t, uint64(blockCap+2), all[0].price) + require.Equal(t, uint64(2*blockCap+2), all[len(all)-1].price) + + for len(s.chain) == 1 { + s.removeAt(pos{0, 0}) + } + require.Empty(t, s.blocks) + require.Equal(t, []entry{}, flat(&s)) + _, found = s.best() + require.False(t, found) +} diff --git a/matching/engine.go b/matching/engine.go index 1086106..6f3368d 100644 --- a/matching/engine.go +++ b/matching/engine.go @@ -55,15 +55,16 @@ func (e *Engine) Place(order Order) (Report, error) { switch order.TIF { case PostOnly: - if price, _, ok := e.book.LevelAt(opp, 0); ok && crossed(order, price) { + if price, _, ok := e.book.Best(opp); ok && crossed(order, price) { e.book.Release(taker) report.Status = Rejected return report, nil } case FOK: var available uint64 - for depth := 0; available < order.Qty; depth++ { - price, totalQty, ok := e.book.LevelAt(opp, depth) + c := e.book.Cursor(opp) + for available < order.Qty { + price, totalQty, ok := c.Next() if !ok || !crossed(order, price) { e.book.Release(taker) report.Status = Rejected