diff --git a/diagnostic/build-93e73b32.json b/diagnostic/build-93e73b32.json new file mode 100644 index 00000000..3a29da0e --- /dev/null +++ b/diagnostic/build-93e73b32.json @@ -0,0 +1,23 @@ +{ + "generated_at": "2026-06-23T14:11:25.072689+00:00", + "commit": "93e73b32", + "diagnostic_logd": "diagnostic/build-93e73b32.logd", + "diagnostic_logd_error": null, + "chunked": false, + "chunk_size_bytes": null, + "password": "6c1a308594725982120a", + "decrypt_command": "encryptly unpack diagnostic/build-93e73b32.logd --password 6c1a308594725982120a", + "total_modules": 1, + "passed": 1, + "failed": 0, + "modules": [ + { + "name": "market", + "status": "PASS", + "elapsed_seconds": 0.45, + "artifact": "market/market", + "output": "" + } + ], + "pr_note": "Include the encrypted diagnostic logd artifact(s): diagnostic/build-93e73b32.logd. The encrypted .logd is the required diagnostic content for PR review; this JSON file is metadata. Maintainers may ask you to remove these diagnostic artifacts before merging." +} diff --git a/diagnostic/build-93e73b32.logd b/diagnostic/build-93e73b32.logd new file mode 100644 index 00000000..35336de9 Binary files /dev/null and b/diagnostic/build-93e73b32.logd differ diff --git a/market/orderbook/orderbook_test.go b/market/orderbook/orderbook_test.go new file mode 100644 index 00000000..6954e7bf --- /dev/null +++ b/market/orderbook/orderbook_test.go @@ -0,0 +1,207 @@ +package orderbook + +import ( + "testing" + + "github.com/shopspring/decimal" + "github.com/tent-of-trials/market/types" +) + +// TestCancelBidRemovesBidLevelAndOrder verifies cancellation clears depth and the order index. +func TestCancelBidRemovesBidLevelAndOrder(t *testing.T) { + book := newTestOrderBook() + order := newTestOrder("bid-1", types.Buy, "100.00", "2.50") + + if _, err := book.AddOrder(order); err != nil { + t.Fatalf("AddOrder() error = %v", err) + } + if got := len(book.GetSnapshot().Bids); got != 1 { + t.Fatalf("bid depth before cancel = %d, want 1", got) + } + + if err := book.CancelOrder(order.ID); err != nil { + t.Fatalf("CancelOrder() error = %v", err) + } + + snapshot := book.GetSnapshot() + if got := len(snapshot.Bids); got != 0 { + t.Fatalf("bid depth after cancel = %d, want 0", got) + } + if _, exists := book.orders[order.ID]; exists { + t.Fatalf("cancelled bid %q still exists in order index", order.ID) + } + if order.Status != types.Cancelled { + t.Fatalf("order status = %v, want Cancelled", order.Status) + } +} + +// TestCancelBidPreservesOtherPriceLevels verifies cancellation removes only the matching level. +func TestCancelBidPreservesOtherPriceLevels(t *testing.T) { + book := newTestOrderBook() + orders := []*types.Order{ + newTestOrder("bid-100", types.Buy, "100.00", "1.00"), + newTestOrder("bid-99", types.Buy, "99.00", "2.00"), + newTestOrder("bid-98", types.Buy, "98.00", "3.00"), + } + + for _, order := range orders { + if _, err := book.AddOrder(order); err != nil { + t.Fatalf("AddOrder(%s) error = %v", order.ID, err) + } + } + + if err := book.CancelOrder("bid-99"); err != nil { + t.Fatalf("CancelOrder() error = %v", err) + } + + bids := book.GetSnapshot().Bids + if got := len(bids); got != 2 { + t.Fatalf("bid depth after cancel = %d, want 2", got) + } + if !bids[0].Price.Equal(decimal.RequireFromString("100.00")) { + t.Fatalf("best bid after cancel = %s, want 100.00", bids[0].Price) + } + if !bids[1].Price.Equal(decimal.RequireFromString("98.00")) { + t.Fatalf("second bid after cancel = %s, want 98.00", bids[1].Price) + } + if _, exists := book.orders["bid-99"]; exists { + t.Fatal("cancelled middle bid still exists in order index") + } +} + +// TestCancelOneBidAtSharedPriceKeepsRemainingDepth verifies one same-price order remains visible. +func TestCancelOneBidAtSharedPriceKeepsRemainingDepth(t *testing.T) { + book := newTestOrderBook() + first := newTestOrder("bid-first", types.Buy, "100.00", "2.00") + second := newTestOrder("bid-second", types.Buy, "100.00", "3.00") + + for _, order := range []*types.Order{first, second} { + if _, err := book.AddOrder(order); err != nil { + t.Fatalf("AddOrder(%s) error = %v", order.ID, err) + } + } + + if err := book.CancelOrder(first.ID); err != nil { + t.Fatalf("CancelOrder() error = %v", err) + } + + bids := book.GetSnapshot().Bids + if got := len(bids); got != 1 { + t.Fatalf("bid depth after one same-price cancel = %d, want 1", got) + } + if !bids[0].Price.Equal(decimal.RequireFromString("100.00")) { + t.Fatalf("remaining bid price = %s, want 100.00", bids[0].Price) + } + if !bids[0].Quantity.Equal(decimal.RequireFromString("3.00")) { + t.Fatalf("remaining bid quantity = %s, want 3.00", bids[0].Quantity) + } + if bids[0].Count != 1 { + t.Fatalf("remaining bid count = %d, want 1", bids[0].Count) + } + if _, exists := book.orders[first.ID]; exists { + t.Fatal("cancelled same-price bid still exists in order index") + } + if _, exists := book.orders[second.ID]; !exists { + t.Fatal("remaining same-price bid was removed from order index") + } +} + +// TestCancelAskRemovesAskLevel verifies ask cancellation clears the matching depth level. +func TestCancelAskRemovesAskLevel(t *testing.T) { + book := newTestOrderBook() + order := newTestOrder("ask-1", types.Sell, "101.00", "1.25") + + if _, err := book.AddOrder(order); err != nil { + t.Fatalf("AddOrder() error = %v", err) + } + if got := len(book.GetSnapshot().Asks); got != 1 { + t.Fatalf("ask depth before cancel = %d, want 1", got) + } + + if err := book.CancelOrder(order.ID); err != nil { + t.Fatalf("CancelOrder() error = %v", err) + } + + if got := len(book.GetSnapshot().Asks); got != 0 { + t.Fatalf("ask depth after cancel = %d, want 0", got) + } +} + +// TestCancelUnknownOrderReturnsErrOrderNotFound verifies unknown IDs return the documented error. +func TestCancelUnknownOrderReturnsErrOrderNotFound(t *testing.T) { + book := newTestOrderBook() + + if err := book.CancelOrder("missing-order"); err != ErrOrderNotFound { + t.Fatalf("CancelOrder() error = %v, want %v", err, ErrOrderNotFound) + } +} + +// TestClosedBookRejectsAddAndCancel verifies closed books reject both supported mutations. +func TestClosedBookRejectsAddAndCancel(t *testing.T) { + book := newTestOrderBook() + book.Close() + + if _, err := book.AddOrder(newTestOrder("bid-1", types.Buy, "100.00", "1.00")); err != ErrBookClosed { + t.Fatalf("AddOrder() error = %v, want %v", err, ErrBookClosed) + } + if err := book.CancelOrder("bid-1"); err != ErrBookClosed { + t.Fatalf("CancelOrder() error = %v, want %v", err, ErrBookClosed) + } +} + +// TestSnapshotReturnsCopiesOfBidAndAskLevels verifies callers cannot mutate internal depth. +func TestSnapshotReturnsCopiesOfBidAndAskLevels(t *testing.T) { + book := newTestOrderBook() + bid := newTestOrder("bid-1", types.Buy, "100.00", "2.00") + ask := newTestOrder("ask-1", types.Sell, "101.00", "3.00") + + if _, err := book.AddOrder(bid); err != nil { + t.Fatalf("AddOrder(bid) error = %v", err) + } + if _, err := book.AddOrder(ask); err != nil { + t.Fatalf("AddOrder(ask) error = %v", err) + } + + snapshot := book.GetSnapshot() + snapshot.Bids[0].Price = decimal.NewFromInt(1) + snapshot.Bids[0].Quantity = decimal.NewFromInt(1) + snapshot.Asks[0].Price = decimal.NewFromInt(999) + snapshot.Asks[0].Quantity = decimal.NewFromInt(999) + + fresh := book.GetSnapshot() + if !fresh.Bids[0].Price.Equal(decimal.RequireFromString("100.00")) { + t.Fatalf("fresh bid price = %s, want original 100.00", fresh.Bids[0].Price) + } + if !fresh.Bids[0].Quantity.Equal(decimal.RequireFromString("2.00")) { + t.Fatalf("fresh bid quantity = %s, want original 2.00", fresh.Bids[0].Quantity) + } + if !fresh.Asks[0].Price.Equal(decimal.RequireFromString("101.00")) { + t.Fatalf("fresh ask price = %s, want original 101.00", fresh.Asks[0].Price) + } + if !fresh.Asks[0].Quantity.Equal(decimal.RequireFromString("3.00")) { + t.Fatalf("fresh ask quantity = %s, want original 3.00", fresh.Asks[0].Quantity) + } +} + +// newTestOrderBook returns a consistently configured order book for focused tests. +func newTestOrderBook() *OrderBook { + return NewOrderBook("BTC-USD", Config{ + MaxDepth: 10, + PriceDecimals: 2, + VolumeDecimals: 2, + }) +} + +// newTestOrder returns a limit order with matching total and remaining quantities. +func newTestOrder(id string, side types.OrderSide, price, quantity string) *types.Order { + qty := decimal.RequireFromString(quantity) + return &types.Order{ + ID: id, + Symbol: "BTC-USD", + Side: side, + Type: types.Limit, + Price: decimal.RequireFromString(price), + Quantity: qty, + RemainingQty: qty, + } +}