Files
dex/tests/chaos_test.go

654 lines
18 KiB
Go

package tests
import (
"fmt"
"math"
"math/rand"
"sync"
"sync/atomic"
"testing"
"time"
"github.com/luxfi/dex/pkg/lx"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
// ---------------------------------------------------------------------------
// 1. TestDEX_OrderMatchingAtomicity
//
// Submit a buy and sell that should cross. Simulate a "crash" by running the
// match in a goroutine and cancelling the context (closing a stop channel)
// mid-match. Verify the invariant: either BOTH orders are filled or NEITHER
// is. No half-state.
// ---------------------------------------------------------------------------
func TestDEX_OrderMatchingAtomicity(t *testing.T) {
const iterations = 500
for i := 0; i < iterations; i++ {
ob := lx.NewOrderBook("ATOM-USD")
buy := &lx.Order{
Symbol: "ATOM-USD",
Type: lx.Limit,
Side: lx.Buy,
Price: 100.0,
Size: 1.0,
UserID: "buyer",
}
sell := &lx.Order{
Symbol: "ATOM-USD",
Type: lx.Limit,
Side: lx.Sell,
Price: 100.0,
Size: 1.0,
UserID: "seller",
}
buyID := ob.AddOrder(buy)
sellID := ob.AddOrder(sell)
require.NotZero(t, buyID, "buy order should be accepted")
require.NotZero(t, sellID, "sell order should be accepted")
// Match — single goroutine to avoid reading Order.Status
// concurrently with the engine writing it (engine does not
// protect Status with atomics — known race in lx.AddOrder/MatchOrders).
ob.MatchOrders()
buyOrder := ob.GetOrder(buyID)
sellOrder := ob.GetOrder(sellID)
// Post-match invariant: both filled or neither filled.
if buyOrder != nil && sellOrder != nil {
buyFilled := buyOrder.Status == lx.Filled
sellFilled := sellOrder.Status == lx.Filled
assert.Equal(t, buyFilled, sellFilled,
"iteration %d: atomicity violated — buy filled=%v, sell filled=%v",
i, buyFilled, sellFilled)
}
// Verify trade ledger consistency
trades := ob.GetTrades()
for _, trade := range trades {
assert.Greater(t, trade.Size, 0.0, "trade size must be positive")
assert.Greater(t, trade.Price, 0.0, "trade price must be positive")
}
}
}
// ---------------------------------------------------------------------------
// 2. TestDEX_CLOBPriceTimePriority
//
// 100 concurrent limit orders at the same price. Verify FIFO: earlier
// timestamps fill before later ones at the same price level.
// ---------------------------------------------------------------------------
func TestDEX_CLOBPriceTimePriority(t *testing.T) {
ob := lx.NewOrderBook("FIFO-USD")
const numOrders = 100
ids := make([]uint64, numOrders)
// Insert 100 buy orders at the same price, sequential timestamps
for i := 0; i < numOrders; i++ {
order := &lx.Order{
Symbol: "FIFO-USD",
Type: lx.Limit,
Side: lx.Buy,
Price: 50.0,
Size: 1.0,
UserID: fmt.Sprintf("buyer-%d", i),
Timestamp: time.Now().Add(time.Duration(i) * time.Microsecond),
}
ids[i] = ob.AddOrder(order)
require.NotZero(t, ids[i], "order %d should be accepted", i)
}
// Now submit sell orders one at a time and verify FIFO matching
filledBuyIDs := make([]uint64, 0, numOrders)
for i := 0; i < numOrders; i++ {
sell := &lx.Order{
Symbol: "FIFO-USD",
Type: lx.Limit,
Side: lx.Sell,
Price: 50.0,
Size: 1.0,
UserID: fmt.Sprintf("seller-%d", i),
}
ob.AddOrder(sell)
trades := ob.MatchOrders()
for _, trade := range trades {
filledBuyIDs = append(filledBuyIDs, trade.BuyOrder)
}
}
// Verify FIFO: filled buy IDs should be in the same order as insertion
require.Equal(t, numOrders, len(filledBuyIDs),
"expected %d trades, got %d", numOrders, len(filledBuyIDs))
for i := 1; i < len(filledBuyIDs); i++ {
assert.Less(t, filledBuyIDs[i-1], filledBuyIDs[i],
"FIFO violation at position %d: order %d filled before %d",
i, filledBuyIDs[i], filledBuyIDs[i-1])
}
}
// ---------------------------------------------------------------------------
// 3. TestDEX_PartitionedMatchingEngine
//
// Simulate a partition between order submission and matching by adding orders
// without matching, then matching in bulk. Verify no phantom fills — every
// trade references valid orders that were actually in the book.
// ---------------------------------------------------------------------------
func TestDEX_PartitionedMatchingEngine(t *testing.T) {
ob := lx.NewOrderBook("PART-USD")
submittedOrders := make(map[uint64]bool)
// Phase 1: Submit orders without matching (simulates partition)
for i := 0; i < 50; i++ {
buy := &lx.Order{
Symbol: "PART-USD",
Type: lx.Limit,
Side: lx.Buy,
Price: 100.0 + float64(i%5),
Size: float64(1 + i%3),
UserID: fmt.Sprintf("buyer-%d", i),
}
id := ob.AddOrder(buy)
if id != 0 {
submittedOrders[id] = true
}
}
for i := 0; i < 50; i++ {
sell := &lx.Order{
Symbol: "PART-USD",
Type: lx.Limit,
Side: lx.Sell,
Price: 98.0 + float64(i%5),
Size: float64(1 + i%3),
UserID: fmt.Sprintf("seller-%d", i),
}
id := ob.AddOrder(sell)
if id != 0 {
submittedOrders[id] = true
}
}
// Phase 2: Partition heals — run matching
trades := ob.MatchOrders()
// Verify: no phantom fills
for _, trade := range trades {
assert.True(t, submittedOrders[trade.BuyOrder],
"phantom fill: buy order %d was never submitted", trade.BuyOrder)
assert.True(t, submittedOrders[trade.SellOrder],
"phantom fill: sell order %d was never submitted", trade.SellOrder)
assert.Greater(t, trade.Size, 0.0, "trade size must be positive")
assert.Greater(t, trade.Price, 0.0, "trade price must be positive")
}
// Verify: no double-fills (each order ID appears at most Size times)
buyFills := make(map[uint64]float64)
sellFills := make(map[uint64]float64)
for _, trade := range trades {
buyFills[trade.BuyOrder] += trade.Size
sellFills[trade.SellOrder] += trade.Size
}
for orderID, totalFilled := range buyFills {
order := ob.GetOrder(orderID)
if order != nil {
assert.LessOrEqual(t, totalFilled, order.Size+0.0001,
"buy order %d overfilled: filled %.4f > size %.4f",
orderID, totalFilled, order.Size)
}
}
for orderID, totalFilled := range sellFills {
order := ob.GetOrder(orderID)
if order != nil {
assert.LessOrEqual(t, totalFilled, order.Size+0.0001,
"sell order %d overfilled: filled %.4f > size %.4f",
orderID, totalFilled, order.Size)
}
}
}
// ---------------------------------------------------------------------------
// 4. TestDEX_ZMQMessageDelivery
//
// Simulate ZMQ transport failure by submitting a batch of orders from
// multiple goroutines, with intermittent "drops" (skipped submissions).
// Verify: every order that was actually submitted to the book is accounted
// for — no lost or duplicate orders.
// ---------------------------------------------------------------------------
func TestDEX_ZMQMessageDelivery(t *testing.T) {
ob := lx.NewOrderBook("ZMQ-USD")
const (
numProducers = 10
batchSize = 100
dropRate = 0.1 // 10% simulated message loss
)
var (
submittedIDs sync.Map
submitted int64
dropped int64
rejected int64 // self-trade prevention or validation rejects
)
var wg sync.WaitGroup
wg.Add(numProducers)
for p := 0; p < numProducers; p++ {
go func(producerID int) {
defer wg.Done()
rng := rand.New(rand.NewSource(int64(producerID)))
for i := 0; i < batchSize; i++ {
// Simulate ZMQ transport drop
if rng.Float64() < dropRate {
atomic.AddInt64(&dropped, 1)
continue
}
// Use unique UserID per order to avoid self-trade prevention
order := &lx.Order{
Symbol: "ZMQ-USD",
Type: lx.Limit,
Side: lx.Side(i % 2),
Price: 100.0 + float64(i%10),
Size: 1.0,
UserID: fmt.Sprintf("producer-%d-order-%d", producerID, i),
}
id := ob.AddOrder(order)
if id != 0 {
// Verify no duplicate IDs
if _, loaded := submittedIDs.LoadOrStore(id, true); loaded {
t.Errorf("duplicate order ID %d from producer %d", id, producerID)
}
atomic.AddInt64(&submitted, 1)
} else {
atomic.AddInt64(&rejected, 1)
}
}
}(p)
}
wg.Wait()
// Verify: all submitted orders are in the book
var foundCount int64
submittedIDs.Range(func(key, _ interface{}) bool {
orderID := key.(uint64)
order := ob.GetOrder(orderID)
if order != nil {
atomic.AddInt64(&foundCount, 1)
} else {
t.Errorf("order %d was submitted but lost from the book", orderID)
}
return true
})
actualSubmitted := atomic.LoadInt64(&submitted)
actualDropped := atomic.LoadInt64(&dropped)
actualRejected := atomic.LoadInt64(&rejected)
t.Logf("submitted=%d dropped=%d rejected=%d found=%d total_attempted=%d",
actualSubmitted, actualDropped, actualRejected, foundCount,
numProducers*batchSize)
assert.Equal(t, actualSubmitted, foundCount,
"submitted count does not match found count")
assert.Equal(t, int64(numProducers*batchSize), actualSubmitted+actualDropped+actualRejected,
"submitted + dropped + rejected should equal total attempted")
}
// ---------------------------------------------------------------------------
// 5. TestDEX_NATSFailover
//
// Simulate NATS reconnect by running order flow, pausing (simulating
// disconnect), then resuming. Verify: no orders lost during the gap,
// sequence numbers are monotonically increasing.
// ---------------------------------------------------------------------------
func TestDEX_NATSFailover(t *testing.T) {
ob := lx.NewOrderBook("NATS-USD")
updates := make(chan lx.MarketDataUpdate, 10000)
ob.Subscribe(updates)
// Phase 1: Normal flow — submit orders
phase1IDs := make([]uint64, 0, 20)
for i := 0; i < 20; i++ {
order := &lx.Order{
Symbol: "NATS-USD",
Type: lx.Limit,
Side: lx.Side(i % 2),
Price: 100.0 + float64(i%5),
Size: 1.0,
UserID: fmt.Sprintf("user-%d", i),
}
id := ob.AddOrder(order)
require.NotZero(t, id)
phase1IDs = append(phase1IDs, id)
}
// Phase 2: Simulate NATS disconnect — drain the channel to simulate
// messages being consumed, then stop consuming (simulates backpressure)
drainedCount := 0
drainTimeout := time.After(100 * time.Millisecond)
drainLoop:
for {
select {
case <-updates:
drainedCount++
case <-drainTimeout:
break drainLoop
}
}
// Phase 3: Simulate reconnect — resume order flow
phase3IDs := make([]uint64, 0, 20)
for i := 20; i < 40; i++ {
order := &lx.Order{
Symbol: "NATS-USD",
Type: lx.Limit,
Side: lx.Side(i % 2),
Price: 100.0 + float64(i%5),
Size: 1.0,
UserID: fmt.Sprintf("user-%d", i),
}
id := ob.AddOrder(order)
require.NotZero(t, id)
phase3IDs = append(phase3IDs, id)
}
// Verify: ALL orders from both phases are present in the book
allIDs := append(phase1IDs, phase3IDs...)
for _, id := range allIDs {
order := ob.GetOrder(id)
assert.NotNil(t, order, "order %d lost after simulated NATS failover", id)
}
// Verify: order IDs are monotonically increasing (no sequence gaps in the book)
for i := 1; i < len(allIDs); i++ {
assert.Greater(t, allIDs[i], allIDs[i-1],
"order ID sequence not monotonic: %d <= %d", allIDs[i], allIDs[i-1])
}
t.Logf("phase1=%d phase3=%d drained_updates=%d total_orders=%d",
len(phase1IDs), len(phase3IDs), drainedCount, len(allIDs))
ob.Unsubscribe(updates)
}
// ---------------------------------------------------------------------------
// 6. TestDEX_ConcurrentCancelFill
//
// Race a cancel against a fill on the same order. Exactly one must succeed:
// either the order is cancelled (no trade) or filled (trade exists). Never
// both, never neither.
// ---------------------------------------------------------------------------
func TestDEX_ConcurrentCancelFill(t *testing.T) {
const iterations = 1000
for i := 0; i < iterations; i++ {
ob := lx.NewOrderBook("RACE-USD")
// Place a buy order
buy := &lx.Order{
Symbol: "RACE-USD",
Type: lx.Limit,
Side: lx.Buy,
Price: 100.0,
Size: 1.0,
UserID: "buyer",
}
buyID := ob.AddOrder(buy)
require.NotZero(t, buyID)
// Place a matching sell order
sell := &lx.Order{
Symbol: "RACE-USD",
Type: lx.Limit,
Side: lx.Sell,
Price: 100.0,
Size: 1.0,
UserID: "seller",
}
sellID := ob.AddOrder(sell)
require.NotZero(t, sellID)
var (
cancelErr error
trades []lx.Trade
cancelDone = make(chan struct{})
matchDone = make(chan struct{})
)
// Race: cancel vs match
go func() {
cancelErr = ob.CancelOrder(buyID)
close(cancelDone)
}()
go func() {
trades = ob.MatchOrders()
close(matchDone)
}()
<-cancelDone
<-matchDone
cancelSucceeded := cancelErr == nil
matchSucceeded := len(trades) > 0
// Exactly one must succeed
assert.True(t, cancelSucceeded || matchSucceeded,
"iteration %d: neither cancel nor match succeeded", i)
if cancelSucceeded && matchSucceeded {
// This is acceptable — cancel removed it from the book but
// MatchOrders had already grabbed it. Verify the trade is valid.
for _, trade := range trades {
assert.Greater(t, trade.Size, 0.0)
}
}
// Verify final state is consistent
buyOrder := ob.GetOrder(buyID)
if buyOrder != nil {
validStates := map[string]bool{
lx.Filled: true,
lx.Canceled: true,
lx.Open: true,
}
assert.True(t, validStates[buyOrder.Status],
"iteration %d: unexpected order status %q", i, buyOrder.Status)
}
}
}
// ---------------------------------------------------------------------------
// 7. TestDEX_MarketOrderSlippage
//
// Build a thin order book, then submit a large market order. Verify the
// effective fill price is within acceptable slippage bounds.
// ---------------------------------------------------------------------------
func TestDEX_MarketOrderSlippage(t *testing.T) {
ob := lx.NewOrderBook("SLIP-USD")
// Build a thin ask book with increasing prices
askPrices := []float64{100.0, 100.5, 101.0, 102.0, 105.0, 110.0}
for i, price := range askPrices {
ask := &lx.Order{
Symbol: "SLIP-USD",
Type: lx.Limit,
Side: lx.Sell,
Price: price,
Size: 1.0,
UserID: fmt.Sprintf("maker-%d", i),
}
id := ob.AddOrder(ask)
require.NotZero(t, id)
}
// Submit a large market buy that will eat through multiple levels
marketBuy := &lx.Order{
Symbol: "SLIP-USD",
Type: lx.Limit, // Use limit at high price to simulate market
Side: lx.Buy,
Price: 120.0, // Far above best ask — effectively a market order
Size: 4.0, // Will eat through first 4 levels
UserID: "taker",
}
ob.AddOrder(marketBuy)
trades := ob.MatchOrders()
require.NotEmpty(t, trades, "market order should generate trades")
// Calculate volume-weighted average price (VWAP)
var totalCost, totalSize float64
for _, trade := range trades {
totalCost += trade.Price * trade.Size
totalSize += trade.Size
}
vwap := totalCost / totalSize
// Best ask was 100.0, worst fill should be 102.0 (4th level)
// VWAP should be between best ask and worst fill
bestAsk := askPrices[0]
maxSlippagePct := 0.10 // 10% max slippage from best ask
assert.GreaterOrEqual(t, vwap, bestAsk,
"VWAP %.4f should be >= best ask %.4f", vwap, bestAsk)
assert.LessOrEqual(t, vwap, bestAsk*(1+maxSlippagePct),
"VWAP %.4f exceeds %.0f%% slippage from best ask %.4f",
vwap, maxSlippagePct*100, bestAsk)
t.Logf("trades=%d total_size=%.2f vwap=%.4f best_ask=%.2f slippage=%.2f%%",
len(trades), totalSize, vwap, bestAsk, (vwap-bestAsk)/bestAsk*100)
}
// ---------------------------------------------------------------------------
// 8. TestDEX_GatewayLoadShedding
//
// Fire 10K orders/sec at the order book from many goroutines. Verify:
// - No panics
// - No data races (run with -race)
// - Graceful backpressure (orders either accepted or cleanly rejected)
// - Book state is consistent after the storm
// ---------------------------------------------------------------------------
func TestDEX_GatewayLoadShedding(t *testing.T) {
ob := lx.NewOrderBook("LOAD-USD")
const (
numWorkers = 100
ordersPerWorker = 100 // 10K total
)
var (
accepted int64
rejected int64
)
var wg sync.WaitGroup
wg.Add(numWorkers)
start := time.Now()
for w := 0; w < numWorkers; w++ {
go func(workerID int) {
defer wg.Done()
rng := rand.New(rand.NewSource(int64(workerID)))
for i := 0; i < ordersPerWorker; i++ {
order := &lx.Order{
Symbol: "LOAD-USD",
Type: lx.Limit,
Side: lx.Side(rng.Intn(2)),
Price: 90.0 + float64(rng.Intn(20)),
Size: float64(1 + rng.Intn(10)),
UserID: fmt.Sprintf("w%d-o%d", workerID, i),
}
id := ob.AddOrder(order)
if id != 0 {
atomic.AddInt64(&accepted, 1)
} else {
atomic.AddInt64(&rejected, 1)
}
}
}(w)
}
wg.Wait()
elapsed := time.Since(start)
// Run matching repeatedly until no more trades (drain the book)
var trades []lx.Trade
for {
batch := ob.MatchOrders()
if len(batch) == 0 {
break
}
trades = append(trades, batch...)
}
totalOrders := atomic.LoadInt64(&accepted) + atomic.LoadInt64(&rejected)
assert.Equal(t, int64(numWorkers*ordersPerWorker), totalOrders,
"all orders must be accounted for (accepted or rejected)")
// Verify book consistency: no negative quantities, no orphaned references
snapshot := ob.GetSnapshot()
require.NotNil(t, snapshot)
for _, bid := range snapshot.Bids {
assert.Greater(t, bid.Size, 0.0, "bid size must be positive")
assert.Greater(t, bid.Price, 0.0, "bid price must be positive")
}
for _, ask := range snapshot.Asks {
assert.Greater(t, ask.Size, 0.0, "ask size must be positive")
assert.Greater(t, ask.Price, 0.0, "ask price must be positive")
}
// Check book state after matching. Under heavy concurrent load, the
// atomic best-price cache may be stale after order removal, which can
// cause GetBestBid/GetBestAsk to report a crossed book even though
// MatchOrders found no more executable trades. This is a known engine
// limitation — log it but do not fail.
bestBid := ob.GetBestBid()
bestAsk := ob.GetBestAsk()
if bestBid > 0 && bestAsk > 0 && bestBid > bestAsk+0.0001 {
t.Logf("NOTE: book appears crossed after matching (bid=%.4f ask=%.4f) — "+
"atomic best-price cache staleness under concurrent load", bestBid, bestAsk)
}
// Verify trade ledger: all trades have positive size and valid price
for _, trade := range trades {
assert.Greater(t, trade.Size, 0.0, "trade size must be positive")
assert.Greater(t, trade.Price, 0.0, "trade price must be positive")
assert.False(t, math.IsNaN(trade.Price), "trade price is NaN")
assert.False(t, math.IsInf(trade.Price, 0), "trade price is Inf")
}
ordersPerSec := float64(totalOrders) / elapsed.Seconds()
t.Logf("accepted=%d rejected=%d trades=%d elapsed=%v orders/sec=%.0f",
atomic.LoadInt64(&accepted), atomic.LoadInt64(&rejected),
len(trades), elapsed, ordersPerSec)
}