diff --git a/book/book.go b/book/book.go index 4f1fbbd..456f563 100644 --- a/book/book.go +++ b/book/book.go @@ -29,21 +29,39 @@ func New(ordersCap, levelsCap int) *Book { asks: side{bid: false}, } } +func (b *Book) Reserve(seq uint64) levels.Handle { + return b.levels.Reserve(seq) +} -func (b *Book) Add(s Side, seq, price, qty uint64) (levels.Handle, error) { +func (b *Book) Rest(s Side, h levels.Handle, price, qty uint64) error { if qty == 0 { - return levels.NoHandle, levels.ErrZeroQty + b.levels.Release(h) + return levels.ErrZeroQty } sd := b.side(s) i, found := sd.find(b.levels, price) if found { - return b.levels.Push(sd.levels[i], seq, qty) + b.levels.Rest(h, sd.levels[i], qty) + return nil } lvl := b.levels.NewLevel(price) sd.insertAt(i, lvl) - return b.levels.Push(lvl, seq, qty) + b.levels.Rest(h, lvl, qty) + return nil +} + +func (b *Book) Release(h levels.Handle) { + b.levels.Release(h) +} + +func (b *Book) Add(s Side, seq, price, qty uint64) (levels.Handle, error) { + h := b.Reserve(seq) + if err := b.Rest(s, h, price, qty); err != nil { + return levels.NoHandle, err + } + return h, nil } func (b *Book) LevelAt(s Side, depth int) (price, totalQty uint64, ok bool) { diff --git a/book/book_test.go b/book/book_test.go index 9d8646c..08f64e5 100644 --- a/book/book_test.go +++ b/book/book_test.go @@ -73,6 +73,19 @@ func TestBook(t *testing.T) { require.Equal(t, uint64(1), totalQty) require.PanicsWithValue(t, "book: invalid side", func() { _, _ = book.Add(3, 7, 1, 1) }) + + h := book.Reserve(9) + require.Equal(t, uint64(9), h.Seq()) + require.ErrorIs(t, book.Rest(Buy, h, 100, 0), levels.ErrZeroQty) + _, _, ok = book.Best(Buy) + require.False(t, ok) + _, err = book.Cancel(h) + require.ErrorIs(t, err, levels.ErrNotFound) + + h = book.Reserve(10) + book.Release(h) + _, err = book.Cancel(h) + require.ErrorIs(t, err, levels.ErrNotFound) } func TestFrontFill(t *testing.T) { diff --git a/levels/levels.go b/levels/levels.go index fc3ea7c..bb7afef 100644 --- a/levels/levels.go +++ b/levels/levels.go @@ -23,15 +23,16 @@ func makeHandle(index int32, seq uint64) Handle { return Handle(seq<> indexBits } type LevelIndex int32 +const ( + unlinked LevelIndex = -1 + reserved LevelIndex = -2 +) + type level struct { price uint64 totalQty uint64 @@ -68,13 +69,13 @@ func (l *Levels) alloc() int32 { panic("levels: alloc: order capacity exhausted") } index := int32(len(l.orders)) - l.orders = append(l.orders, order{qty: 0, prev: -1, next: -1}) + l.orders = append(l.orders, order{qty: 0, prev: -1, next: -1, level: unlinked}) return index } func (l *Levels) free(index int32) { o := &l.orders[index] - o.level = -1 + o.level = unlinked o.qty = 0 o.prev = -1 o.next = l.freeOrder diff --git a/levels/levels_test.go b/levels/levels_test.go index 84b6cb7..88f902a 100644 --- a/levels/levels_test.go +++ b/levels/levels_test.go @@ -45,9 +45,16 @@ func checkInvariants(t *testing.T, l *Levels) { levelsLen-- } + for _, o := range l.orders { + if o.level == reserved { + require.Greater(t, ordersLen, 0) + ordersLen-- + } + } + n := l.freeOrder for n != -1 { - require.Equal(t, LevelIndex(-1), l.orders[n].level) + require.Equal(t, unlinked, l.orders[n].level) require.Equal(t, int32(-1), l.orders[n].prev) require.Greater(t, ordersLen, 0) n = l.orders[n].next @@ -277,3 +284,55 @@ func TestHandlePacking(t *testing.T) { require.Equal(t, int32(0), NoHandle.index()) require.Equal(t, uint64(0), NoHandle.Seq()) } + +func TestReserve(t *testing.T) { + l := New(10, 10) + lvl := l.NewLevel(5) + + h := l.Reserve(7) + require.Equal(t, uint64(7), h.Seq()) + require.Equal(t, int32(0), h.index()) + require.Equal(t, reserved, l.orders[0].level) + checkInvariants(t, l) + + _, _, _, err := l.Cancel(h) + require.ErrorIs(t, err, ErrNotFound) + _, err = l.Qty(h) + require.ErrorIs(t, err, ErrNotFound) + _, _, ok := l.Front(lvl) + require.False(t, ok) + + require.PanicsWithValue(t, "levels: Rest: invalid qty", func() { l.Rest(h, lvl, 0) }) + + l.Rest(h, lvl, 10) + require.Equal(t, lvl, l.orders[0].level) + require.Equal(t, uint64(10), l.levels[lvl].totalQty) + fh, qty, ok := l.Front(lvl) + require.True(t, ok) + require.Equal(t, h, fh) + require.Equal(t, uint64(10), qty) + checkInvariants(t, l) + + require.PanicsWithValue(t, "levels: Rest: order is not reserved", func() { l.Rest(h, lvl, 10) }) + require.PanicsWithValue(t, "levels: Release: order is not reserved", func() { l.Release(h) }) + + h2 := l.Reserve(8) + require.Equal(t, int32(1), h2.index()) + checkInvariants(t, l) + + l.Release(h2) + require.Equal(t, unlinked, l.orders[1].level) + require.Equal(t, int32(1), l.freeOrder) + checkInvariants(t, l) + + require.PanicsWithValue(t, "levels: Release: order is not reserved", func() { l.Release(h2) }) + require.PanicsWithValue(t, "levels: Rest: order is not reserved", func() { l.Rest(h2, lvl, 10) }) + + h3 := l.Reserve(9) + require.Equal(t, h2.index(), h3.index()) + require.PanicsWithValue(t, "levels: Release: order is not reserved", func() { l.Release(h2) }) + l.Release(h3) + checkInvariants(t, l) + + require.PanicsWithValue(t, "levels: Reserve: invalid seq", func() { l.Reserve(0) }) +} diff --git a/levels/order.go b/levels/order.go index 3949148..7a89397 100644 --- a/levels/order.go +++ b/levels/order.go @@ -8,37 +8,65 @@ type order struct { next int32 } -func (l *Levels) Push(level LevelIndex, seq, qty uint64) (Handle, error) { - if qty == 0 { - return NoHandle, ErrZeroQty +func (l *Levels) Reserve(seq uint64) Handle { + if seq == 0 || seq > seqMax { + panic("levels: Reserve: invalid seq") } - lvl := l.getLevel(level) + index := l.alloc() + o := &l.orders[index] + o.seq = seq + o.level = reserved + return makeHandle(index, seq) +} - if seq == 0 || seq > seqMax { - panic("levels: Push: invalid seq") +func (l *Levels) Rest(h Handle, lvl LevelIndex, qty uint64) { + if qty == 0 { + panic("levels: Rest: invalid qty") } - index := l.alloc() + index, seq := h.index(), h.Seq() + o := &l.orders[index] + if o.level != reserved || o.seq != seq { + panic("levels: Rest: order is not reserved") + } + lv := l.getLevel(lvl) - o.level = level + o.level = lvl o.qty = qty - o.seq = seq - lvl.totalQty += qty + lv.totalQty += qty + + if lv.head == -1 { + lv.head = index + lv.tail = index + return + } - if lvl.head == -1 { - lvl.head = index - lvl.tail = index + o.prev = lv.tail + l.orders[lv.tail].next = index + lv.tail = index +} - return makeHandle(index, seq), nil +func (l *Levels) Release(h Handle) { + index := h.index() + o := &l.orders[index] + if o.level != reserved || o.seq != h.Seq() { + panic("levels: Release: order is not reserved") + } + l.free(index) +} + +func (l *Levels) Push(level LevelIndex, seq, qty uint64) (Handle, error) { + if qty == 0 { + return NoHandle, ErrZeroQty } + l.getLevel(level) - o.prev = lvl.tail - l.orders[lvl.tail].next = index - lvl.tail = index - return makeHandle(index, seq), nil + h := l.Reserve(seq) + l.Rest(h, level, qty) + return h, nil } func (l *Levels) Cancel(h Handle) (LevelIndex, uint64, bool, error) { diff --git a/levels/random_test.go b/levels/random_test.go index 4dcecac..d029ac0 100644 --- a/levels/random_test.go +++ b/levels/random_test.go @@ -96,6 +96,7 @@ func runRandom(t *testing.T, seed int64, steps int) { model := map[LevelIndex]*modelLevel{} var liveLevels []LevelIndex // keys of model, for picking a random level var stale []Handle // handles of cancelled and fully filled orders + var pending []Handle // reserved handles not yet rested or released var seq uint64 // order sequence, owned by the caller of Push pickLevel := func() LevelIndex { @@ -137,7 +138,7 @@ func runRandom(t *testing.T, seed int64, steps int) { model[id] = &modelLevel{price: price} liveLevels = append(liveLevels, id) - case op < 50: // push + case op < 40: // push if len(liveLevels) == 0 { continue } @@ -148,6 +149,30 @@ func runRandom(t *testing.T, seed int64, steps int) { require.NoError(t, err, "step %d", step) model[id].orders = append(model[id].orders, modelOrder{h: h, qty: qty}) + case op < 45: // reserve a slot, settle it on a later step + seq++ + pending = append(pending, l.Reserve(seq)) + + case op < 50: // rest or release a pending reservation + if len(pending) == 0 { + continue + } + i := rng.Intn(len(pending)) + h := pending[i] + pending[i] = pending[len(pending)-1] + pending = pending[:len(pending)-1] + _, _, _, err := l.Cancel(h) + require.ErrorIs(t, err, ErrNotFound, "step %d: cancel of a reserved order", step) + if len(liveLevels) == 0 || rng.Intn(2) == 0 { + l.Release(h) + stale = append(stale, h) + continue + } + id := pickLevel() + qty := uint64(rng.Intn(100) + 1) + l.Rest(h, id, qty) + model[id].orders = append(model[id].orders, modelOrder{h: h, qty: qty}) + case op < 70: // cancel a live order id, i, ok := pickOrder() if !ok { @@ -221,4 +246,10 @@ func runRandom(t *testing.T, seed int64, steps int) { compareModel(t, l, model) } } + + for _, h := range pending { + l.Release(h) + } + checkInvariants(t, l) + compareModel(t, l, model) } diff --git a/matching/engine.go b/matching/engine.go index 110f9a8..572edcf 100644 --- a/matching/engine.go +++ b/matching/engine.go @@ -15,14 +15,19 @@ var ( type Engine struct { book *book.Book + sink TradeSink lastOrderID uint64 lastTradeID uint64 } -func New(ordersCap, levelsCap int) *Engine { +func New(ordersCap, levelsCap int, sink TradeSink) (*Engine, error) { + if sink == nil { + return nil, errors.New("sink cannot be nil") + } return &Engine{ book: book.New(ordersCap, levelsCap), - } + sink: sink, + }, nil } func (e *Engine) Place(order Order) (Report, error) { @@ -37,8 +42,9 @@ func (e *Engine) Place(order Order) (Report, error) { } e.lastOrderID++ + taker := e.book.Reserve(e.lastOrderID) report := Report{ - OrderID: uint64(levels.SeqHandle(e.lastOrderID)), + OrderID: uint64(taker), RemainingQty: order.Qty, } opp := order.Side.Opposite() @@ -46,51 +52,56 @@ 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) { + e.book.Release(taker) report.Status = Rejected return report, nil } case FOK: - var sum uint64 - for depth := 0; sum < order.Qty; depth++ { + var available uint64 + for depth := 0; available < order.Qty; depth++ { price, totalQty, ok := e.book.LevelAt(opp, depth) if !ok || !crossed(order, price) { + e.book.Release(taker) report.Status = Rejected return report, nil } - sum += totalQty + available += totalQty } } for report.RemainingQty > 0 { - h, price, qty, ok := e.book.Front(opp) + maker, price, makerQty, ok := e.book.Front(opp) if !ok || !crossed(order, price) { break } - take := min(report.RemainingQty, qty) - remaining := e.book.Fill(h, take) + take := min(report.RemainingQty, makerQty) + makerRemaining := e.book.Fill(maker, take) e.lastTradeID++ - report.Trades = append(report.Trades, Trade{ + report.RemainingQty -= take + + e.sink.OnTrade(Trade{ ID: e.lastTradeID, - MakerOrderID: uint64(h), + TakerOrderID: uint64(taker), + MakerOrderID: uint64(maker), Price: price, Qty: take, - MakerRemaining: remaining, + TakerRemaining: report.RemainingQty, + MakerRemaining: makerRemaining, }) - report.RemainingQty -= take } switch { case report.RemainingQty == 0: + e.book.Release(taker) report.Status = Filled case order.TIF == IOC: + e.book.Release(taker) report.Status = Canceled default: - h, err := e.book.Add(order.Side, e.lastOrderID, order.Price, report.RemainingQty) - if err != nil { - return Report{}, err + if err := e.book.Rest(order.Side, taker, order.Price, report.RemainingQty); err != nil { + panic(err) } - report.OrderID = uint64(h) - if len(report.Trades) == 0 { + if report.RemainingQty == order.Qty { report.Status = Open } else { report.Status = PartiallyFilled diff --git a/matching/engine_test.go b/matching/engine_test.go index 98a6e86..6ee8d3e 100644 --- a/matching/engine_test.go +++ b/matching/engine_test.go @@ -9,8 +9,13 @@ import ( "orderbook/levels" ) +type trades []Trade + +func (s *trades) OnTrade(t Trade) { *s = append(*s, t) } + func TestPlace(t *testing.T) { - e := New(10, 10) + var got trades + e, _ := New(10, 10, &got) _, err := e.Place(Order{TIF: GTC, Side: book.Buy, Price: 100, Qty: 0}) require.ErrorIs(t, err, ErrZeroQty) @@ -19,34 +24,36 @@ func TestPlace(t *testing.T) { _, err = e.Place(Order{TIF: 0, Side: book.Buy, Price: 100, Qty: 1}) require.ErrorIs(t, err, ErrInvalidTIF) + got = got[:0] r1, err := e.Place(Order{TIF: GTC, Side: book.Buy, Price: 100, Qty: 10}) require.NoError(t, err) require.Equal(t, Open, r1.Status) require.Equal(t, uint64(10), r1.RemainingQty) - require.Empty(t, r1.Trades) + require.Empty(t, got) require.Equal(t, uint64(1), levels.Handle(r1.OrderID).Seq()) - require.NotEqual(t, uint64(levels.SeqHandle(1)), r1.OrderID) r2, err := e.Place(Order{TIF: GTC, Side: book.Buy, Price: 99, Qty: 5}) require.NoError(t, err) require.Equal(t, Open, r2.Status) + got = got[:0] r3, err := e.Place(Order{TIF: GTC, Side: book.Sell, Price: 100, Qty: 4}) require.NoError(t, err) require.Equal(t, Filled, r3.Status) require.Equal(t, uint64(0), r3.RemainingQty) - require.Equal(t, uint64(levels.SeqHandle(3)), r3.OrderID) - require.Equal(t, []Trade{{ID: 1, MakerOrderID: r1.OrderID, Price: 100, Qty: 4, MakerRemaining: 6}}, r3.Trades) + require.Equal(t, uint64(3), levels.Handle(r3.OrderID).Seq()) + require.Equal(t, trades{{ID: 1, TakerOrderID: r3.OrderID, MakerOrderID: r1.OrderID, Price: 100, Qty: 4, TakerRemaining: 0, MakerRemaining: 6}}, got) + got = got[:0] r4, err := e.Place(Order{TIF: IOC, Side: book.Sell, Price: 99, Qty: 20}) require.NoError(t, err) require.Equal(t, Canceled, r4.Status) require.Equal(t, uint64(9), r4.RemainingQty) - require.Equal(t, uint64(levels.SeqHandle(4)), r4.OrderID) - require.Equal(t, []Trade{ - {ID: 2, MakerOrderID: r1.OrderID, Price: 100, Qty: 6, MakerRemaining: 0}, - {ID: 3, MakerOrderID: r2.OrderID, Price: 99, Qty: 5, MakerRemaining: 0}, - }, r4.Trades) + require.Equal(t, uint64(4), levels.Handle(r4.OrderID).Seq()) + require.Equal(t, trades{ + {ID: 2, TakerOrderID: r4.OrderID, MakerOrderID: r1.OrderID, Price: 100, Qty: 6, TakerRemaining: 14, MakerRemaining: 0}, + {ID: 3, TakerOrderID: r4.OrderID, MakerOrderID: r2.OrderID, Price: 99, Qty: 5, TakerRemaining: 9, MakerRemaining: 0}, + }, got) _, _, ok := e.book.Best(book.Buy) require.False(t, ok) @@ -54,38 +61,42 @@ func TestPlace(t *testing.T) { require.NoError(t, err) require.Equal(t, Open, r5.Status) + got = got[:0] r6, err := e.Place(Order{TIF: FOK, Side: book.Buy, Price: 101, Qty: 4}) require.NoError(t, err) require.Equal(t, Rejected, r6.Status) require.Equal(t, uint64(4), r6.RemainingQty) - require.Empty(t, r6.Trades) + require.Empty(t, got) _, totalQty, ok := e.book.Best(book.Sell) require.True(t, ok) require.Equal(t, uint64(3), totalQty) + got = got[:0] r7, err := e.Place(Order{TIF: FOK, Side: book.Buy, Price: 101, Qty: 3}) require.NoError(t, err) require.Equal(t, Filled, r7.Status) - require.Len(t, r7.Trades, 1) + require.Len(t, got, 1) _, _, ok = e.book.Best(book.Sell) require.False(t, ok) + got = got[:0] r8, err := e.Place(Order{TIF: GTC, Side: book.Sell, Price: 101, Qty: 3}) require.NoError(t, err) r9, err := e.Place(Order{TIF: PostOnly, Side: book.Buy, Price: 101, Qty: 1}) require.NoError(t, err) require.Equal(t, Rejected, r9.Status) - require.Empty(t, r9.Trades) + require.Empty(t, got) r10, err := e.Place(Order{TIF: PostOnly, Side: book.Buy, Price: 100, Qty: 1}) require.NoError(t, err) require.Equal(t, Open, r10.Status) + got = got[:0] r11, err := e.Place(Order{TIF: GTC, Side: book.Buy, Price: 102, Qty: 5}) require.NoError(t, err) require.Equal(t, PartiallyFilled, r11.Status) require.Equal(t, uint64(2), r11.RemainingQty) - require.Equal(t, []Trade{{ID: 5, MakerOrderID: r8.OrderID, Price: 101, Qty: 3, MakerRemaining: 0}}, r11.Trades) + require.Equal(t, trades{{ID: 5, TakerOrderID: r11.OrderID, MakerOrderID: r8.OrderID, Price: 101, Qty: 3, TakerRemaining: 2, MakerRemaining: 0}}, got) price, totalQty, ok := e.book.Best(book.Buy) require.True(t, ok) require.Equal(t, uint64(102), price) @@ -93,7 +104,8 @@ func TestPlace(t *testing.T) { } func TestCancel(t *testing.T) { - e := New(10, 10) + var got trades + e, _ := New(10, 10, &got) r1, err := e.Place(Order{TIF: GTC, Side: book.Buy, Price: 100, Qty: 10}) require.NoError(t, err) diff --git a/matching/models.go b/matching/models.go index e2ddd63..de0bcbc 100644 --- a/matching/models.go +++ b/matching/models.go @@ -30,13 +30,18 @@ type Report struct { OrderID uint64 RemainingQty uint64 Status Status - Trades []Trade } type Trade struct { ID uint64 + TakerOrderID uint64 MakerOrderID uint64 Price uint64 Qty uint64 + TakerRemaining uint64 MakerRemaining uint64 } + +type TradeSink interface { + OnTrade(Trade) +}