diff --git a/market/orderbook/replay.go b/market/orderbook/replay.go new file mode 100755 index 00000000..79289f43 --- /dev/null +++ b/market/orderbook/replay.go @@ -0,0 +1,99 @@ +package orderbook + +import ( + "fmt" + + "github.com/shopspring/decimal" + "github.com/tent-of-trials/market/types" +) + +type DeltaSide string + +const ( + DeltaBid DeltaSide = "bid" + DeltaAsk DeltaSide = "ask" +) + +type ReplayDelta struct { + Symbol types.Symbol `json:"symbol"` + Side DeltaSide `json:"side"` + Price decimal.Decimal `json:"price"` + Size decimal.Decimal `json:"size"` + Expected int `json:"expected_depth"` +} + +type ReplayMismatch struct { + Step int `json:"step"` + Symbol types.Symbol `json:"symbol"` + Side DeltaSide `json:"side"` + Price decimal.Decimal `json:"price"` + Size decimal.Decimal `json:"size"` + ExpectedDepth int `json:"expected_depth"` + ActualDepth int `json:"actual_depth"` +} + +func (m ReplayMismatch) Summary() string { + return fmt.Sprintf( + "delta step %d diverged for %s %s price=%s size=%s expected_depth=%d actual_depth=%d", + m.Step, + m.Symbol, + m.Side, + m.Price.String(), + m.Size.String(), + m.ExpectedDepth, + m.ActualDepth, + ) +} + +func ReplayDeltas(symbol types.Symbol, deltas []ReplayDelta) (*ReplayMismatch, error) { + book := NewOrderBook(symbol, Config{MaxDepth: len(deltas) + 1}) + for i, delta := range deltas { + if delta.Symbol != "" && delta.Symbol != symbol { + mismatch := ReplayMismatch{ + Step: i + 1, + Symbol: delta.Symbol, + Side: delta.Side, + Price: delta.Price, + Size: delta.Size, + ExpectedDepth: delta.Expected, + ActualDepth: 0, + } + return &mismatch, nil + } + + order := &types.Order{ + Symbol: symbol, + Type: types.Limit, + Price: delta.Price, + Quantity: delta.Size, + RemainingQty: delta.Size, + } + if delta.Side == DeltaBid { + order.Side = types.Buy + } else { + order.Side = types.Sell + } + + if _, err := book.AddOrder(order); err != nil { + return nil, err + } + + actualDepth := len(book.GetBids()) + if delta.Side == DeltaAsk { + actualDepth = len(book.GetAsks()) + } + if actualDepth != delta.Expected { + mismatch := ReplayMismatch{ + Step: i + 1, + Symbol: symbol, + Side: delta.Side, + Price: delta.Price, + Size: delta.Size, + ExpectedDepth: delta.Expected, + ActualDepth: actualDepth, + } + return &mismatch, nil + } + } + return nil, nil +} diff --git a/market/orderbook/replay_test.go b/market/orderbook/replay_test.go new file mode 100755 index 00000000..75525f5a --- /dev/null +++ b/market/orderbook/replay_test.go @@ -0,0 +1,72 @@ +package orderbook + +import ( + "strings" + "testing" + + "github.com/shopspring/decimal" + "github.com/tent-of-trials/market/types" +) + +func dec(value string) decimal.Decimal { + return decimal.RequireFromString(value) +} + +func TestReplayDeltasPassesMatchingFixture(t *testing.T) { + mismatch, err := ReplayDeltas(types.Symbol("BTC-USD"), []ReplayDelta{ + {Symbol: "BTC-USD", Side: DeltaBid, Price: dec("100"), Size: dec("1.5"), Expected: 1}, + {Symbol: "BTC-USD", Side: DeltaBid, Price: dec("101"), Size: dec("2.0"), Expected: 2}, + {Symbol: "BTC-USD", Side: DeltaAsk, Price: dec("102"), Size: dec("0.5"), Expected: 1}, + }) + if err != nil { + t.Fatalf("ReplayDeltas returned error: %v", err) + } + if mismatch != nil { + t.Fatalf("expected no mismatch, got %s", mismatch.Summary()) + } +} + +func TestReplayDeltasReportsFirstDivergentStep(t *testing.T) { + mismatch, err := ReplayDeltas(types.Symbol("BTC-USD"), []ReplayDelta{ + {Symbol: "BTC-USD", Side: DeltaBid, Price: dec("100"), Size: dec("1.5"), Expected: 1}, + {Symbol: "BTC-USD", Side: DeltaAsk, Price: dec("102"), Size: dec("0.5"), Expected: 2}, + {Symbol: "BTC-USD", Side: DeltaBid, Price: dec("101"), Size: dec("2.0"), Expected: 2}, + }) + if err != nil { + t.Fatalf("ReplayDeltas returned error: %v", err) + } + if mismatch == nil { + t.Fatal("expected mismatch") + } + + if mismatch.Step != 2 { + t.Fatalf("expected mismatch at step 2, got %d", mismatch.Step) + } + if mismatch.Symbol != "BTC-USD" || mismatch.Side != DeltaAsk { + t.Fatalf("unexpected mismatch market: %+v", mismatch) + } + if !mismatch.Price.Equal(dec("102")) || !mismatch.Size.Equal(dec("0.5")) { + t.Fatalf("unexpected mismatch price/size: %+v", mismatch) + } + if mismatch.ExpectedDepth != 2 || mismatch.ActualDepth != 1 { + t.Fatalf("unexpected mismatch depths: %+v", mismatch) + } +} + +func TestReplayMismatchSummaryIsCompactAndActionable(t *testing.T) { + mismatch := ReplayMismatch{ + Step: 3, + Symbol: "ETH-USD", + Side: DeltaBid, + Price: dec("2500"), + Size: dec("4"), + ExpectedDepth: 2, + ActualDepth: 1, + } + summary := mismatch.Summary() + for _, want := range []string{"step 3", "ETH-USD", "bid", "price=2500", "size=4", "expected_depth=2", "actual_depth=1"} { + if !strings.Contains(summary, want) { + t.Fatalf("summary %q missing %q", summary, want) + } + } +}