decred.org/dcrdex@v1.0.5/client/mm/mm_simple_arb_test.go (about)

     1  package mm
     2  
     3  import (
     4  	"bytes"
     5  	"context"
     6  	"errors"
     7  	"fmt"
     8  	"math"
     9  	"testing"
    10  
    11  	"decred.org/dcrdex/client/core"
    12  	"decred.org/dcrdex/client/mm/libxc"
    13  	"decred.org/dcrdex/dex"
    14  	"decred.org/dcrdex/dex/calc"
    15  	"decred.org/dcrdex/dex/encode"
    16  	"decred.org/dcrdex/dex/order"
    17  )
    18  
    19  func TestArbRebalance(t *testing.T) {
    20  	lotSize := uint64(40e8)
    21  	baseID := uint32(42)
    22  	quoteID := uint32(0)
    23  
    24  	orderIDs := make([]order.OrderID, 5)
    25  	for i := 0; i < 5; i++ {
    26  		copy(orderIDs[i][:], encode.RandomBytes(32))
    27  	}
    28  
    29  	cexTradeIDs := make([]string, 0, 5)
    30  	for i := 0; i < 5; i++ {
    31  		cexTradeIDs = append(cexTradeIDs, fmt.Sprintf("%x", encode.RandomBytes(32)))
    32  	}
    33  
    34  	const currEpoch uint64 = 100
    35  	const numEpochsLeaveOpen uint32 = 10
    36  	const maxActiveArbs uint32 = 5
    37  	const profitTrigger float64 = 0.01
    38  	const feesInQuoteUnits uint64 = 5e5
    39  	const rateStep = 1e5
    40  
    41  	edgeSellRate := func(buyRate, qty uint64, profitable bool) uint64 {
    42  		quoteToBuy := calc.BaseToQuote(buyRate, qty)
    43  		reqFromSell := quoteToBuy + feesInQuoteUnits + calc.BaseToQuote(buyRate, uint64(float64(qty)*profitTrigger))
    44  		sellRate := calc.QuoteToBase(qty, reqFromSell) // quote * 1e8 / base = sellRate
    45  		var steps float64
    46  		if profitable {
    47  			steps = math.Ceil(float64(sellRate) / float64(rateStep))
    48  		} else {
    49  			steps = math.Floor(float64(sellRate) / float64(rateStep))
    50  		}
    51  		return uint64(steps) * rateStep
    52  	}
    53  
    54  	type testBooks struct {
    55  		dexBidsAvg     []uint64
    56  		dexBidsExtrema []uint64
    57  
    58  		dexAsksAvg     []uint64
    59  		dexAsksExtrema []uint64
    60  
    61  		cexBidsAvg     []uint64
    62  		cexBidsExtrema []uint64
    63  
    64  		cexAsksAvg     []uint64
    65  		cexAsksExtrema []uint64
    66  	}
    67  
    68  	noArbBooks := &testBooks{
    69  		dexBidsAvg:     []uint64{1.8e6, 1.7e6},
    70  		dexBidsExtrema: []uint64{1.7e6, 1.6e6},
    71  
    72  		dexAsksAvg:     []uint64{2e6, 2.5e6},
    73  		dexAsksExtrema: []uint64{2e6, 3e6},
    74  
    75  		cexBidsAvg:     []uint64{edgeSellRate(2e6, lotSize, false), 2.1e6},
    76  		cexBidsExtrema: []uint64{2.2e6, 1.9e6},
    77  
    78  		cexAsksAvg:     []uint64{2.4e6, 2.6e6},
    79  		cexAsksExtrema: []uint64{2.5e6, 2.7e6},
    80  	}
    81  
    82  	arbBuyOnDEXBooks := &testBooks{
    83  		dexBidsAvg:     []uint64{1.8e6, 1.7e6},
    84  		dexBidsExtrema: []uint64{1.7e6, 1.6e6},
    85  
    86  		dexAsksAvg:     []uint64{2e6, 2.5e6},
    87  		dexAsksExtrema: []uint64{2e6, 3e6},
    88  
    89  		cexBidsAvg:     []uint64{edgeSellRate(2e6, lotSize, true), 2.1e6},
    90  		cexBidsExtrema: []uint64{2.2e6, 1.9e6},
    91  
    92  		cexAsksAvg:     []uint64{2.4e6, 2.6e6},
    93  		cexAsksExtrema: []uint64{2.5e6, 2.7e6},
    94  	}
    95  
    96  	arbSellOnDEXBooks := &testBooks{
    97  		cexBidsAvg:     []uint64{1.8e6, 1.7e6},
    98  		cexBidsExtrema: []uint64{1.7e6, 1.6e6},
    99  
   100  		cexAsksAvg:     []uint64{2e6, 2.5e6},
   101  		cexAsksExtrema: []uint64{2e6, 3e6},
   102  
   103  		dexBidsAvg:     []uint64{edgeSellRate(2e6, lotSize, true), 2.1e6},
   104  		dexBidsExtrema: []uint64{2.2e6, 1.9e6},
   105  
   106  		dexAsksAvg:     []uint64{2.4e6, 2.6e6},
   107  		dexAsksExtrema: []uint64{2.5e6, 2.7e6},
   108  	}
   109  
   110  	arb2LotsBuyOnDEXBooks := &testBooks{
   111  		dexBidsAvg:     []uint64{1.8e6, 1.7e6},
   112  		dexBidsExtrema: []uint64{1.7e6, 1.6e6},
   113  
   114  		dexAsksAvg:     []uint64{2e6, 2e6, 2.5e6},
   115  		dexAsksExtrema: []uint64{2e6, 2e6, 3e6},
   116  
   117  		cexBidsAvg:     []uint64{2.3e6, 2.2e6, 2.1e6},
   118  		cexBidsExtrema: []uint64{2.2e6, 2.2e6, 1.9e6},
   119  
   120  		cexAsksAvg:     []uint64{2.4e6, 2.6e6},
   121  		cexAsksExtrema: []uint64{2.5e6, 2.7e6},
   122  	}
   123  
   124  	arb2LotsSellOnDEXBooks := &testBooks{
   125  		cexBidsAvg:     []uint64{1.8e6, 1.7e6},
   126  		cexBidsExtrema: []uint64{1.7e6, 1.6e6},
   127  
   128  		cexAsksAvg:     []uint64{2e6, 2e6, 2.5e6},
   129  		cexAsksExtrema: []uint64{2e6, 2e6, 3e6},
   130  
   131  		dexBidsAvg:     []uint64{edgeSellRate(2e6, lotSize, true), edgeSellRate(2e6, lotSize, true), 2.1e6},
   132  		dexBidsExtrema: []uint64{2.2e6, 2.2e6, 1.9e6},
   133  
   134  		dexAsksAvg:     []uint64{2.4e6, 2.6e6},
   135  		dexAsksExtrema: []uint64{2.5e6, 2.7e6},
   136  	}
   137  
   138  	// Arbing 2 lots worth would still be above profit trigger, but the
   139  	// second lot on its own would not be.
   140  	arb2LotsButOneWorth := &testBooks{
   141  		dexBidsAvg:     []uint64{1.8e6, 1.7e6},
   142  		dexBidsExtrema: []uint64{1.7e6, 1.6e6},
   143  
   144  		dexAsksAvg:     []uint64{2e6, 2.1e6},
   145  		dexAsksExtrema: []uint64{2e6, 2.2e6},
   146  
   147  		cexBidsAvg:     []uint64{2.3e6, 2.122e6},
   148  		cexBidsExtrema: []uint64{2.2e6, 2.1e6},
   149  
   150  		cexAsksAvg:     []uint64{2.4e6, 2.6e6},
   151  		cexAsksExtrema: []uint64{2.5e6, 2.7e6},
   152  	}
   153  
   154  	type test struct {
   155  		name          string
   156  		books         *testBooks
   157  		dexVWAPErr    error
   158  		cexVWAPErr    error
   159  		cexTradeErr   error
   160  		existingArbs  []*arbSequence
   161  		dexMaxBuyQty  uint64
   162  		dexMaxSellQty uint64
   163  		cexMaxBuyQty  uint64
   164  		cexMaxSellQty uint64
   165  
   166  		expectedDexOrder   *dexOrder
   167  		expectedCexOrder   *libxc.Trade
   168  		expectedDEXCancels []dex.Bytes
   169  		expectedCEXCancels []string
   170  	}
   171  
   172  	tests := []test{
   173  		// "no arb"
   174  		{
   175  			name:  "no arb",
   176  			books: noArbBooks,
   177  		},
   178  		// "1 lot, buy on dex, sell on cex"
   179  		{
   180  			name:          "1 lot, buy on dex, sell on cex",
   181  			books:         arbBuyOnDEXBooks,
   182  			dexMaxSellQty: 5 * lotSize,
   183  			dexMaxBuyQty:  5 * lotSize,
   184  			cexMaxSellQty: 5 * lotSize,
   185  			cexMaxBuyQty:  5 * lotSize,
   186  			expectedDexOrder: &dexOrder{
   187  				qty:  lotSize,
   188  				rate: 2e6,
   189  				sell: false,
   190  			},
   191  			expectedCexOrder: &libxc.Trade{
   192  				BaseID:  42,
   193  				QuoteID: 0,
   194  				Qty:     lotSize,
   195  				Rate:    2.2e6,
   196  				Sell:    true,
   197  			},
   198  		},
   199  		// "1 lot, sell on dex, buy on cex"
   200  		{
   201  			name:          "1 lot, sell on dex, buy on cex",
   202  			books:         arbSellOnDEXBooks,
   203  			dexMaxSellQty: 5 * lotSize,
   204  			dexMaxBuyQty:  5 * lotSize,
   205  			cexMaxSellQty: 5 * lotSize,
   206  			cexMaxBuyQty:  5 * lotSize,
   207  			expectedDexOrder: &dexOrder{
   208  				qty:  lotSize,
   209  				rate: 2.2e6,
   210  				sell: true,
   211  			},
   212  			expectedCexOrder: &libxc.Trade{
   213  				BaseID:  42,
   214  				QuoteID: 0,
   215  				Qty:     lotSize,
   216  				Rate:    2e6,
   217  				Sell:    false,
   218  			},
   219  		},
   220  		// "1 lot, buy on dex, sell on cex, but dex base balance not enough"
   221  		{
   222  			name:          "1 lot, buy on dex, sell on cex, but cex balance not enough",
   223  			books:         arbBuyOnDEXBooks,
   224  			dexMaxSellQty: 5 * lotSize,
   225  			dexMaxBuyQty:  5 * lotSize,
   226  			cexMaxSellQty: 0,
   227  			cexMaxBuyQty:  5 * lotSize,
   228  		},
   229  		// "2 lot, buy on dex, sell on cex, but dex quote balance only enough for 1"
   230  		{
   231  			name:          "2 lot, buy on dex, sell on cex, but dex quote balance only enough for 1",
   232  			books:         arb2LotsBuyOnDEXBooks,
   233  			dexMaxBuyQty:  1 * lotSize,
   234  			cexMaxSellQty: 5 * lotSize,
   235  			expectedDexOrder: &dexOrder{
   236  				qty:  lotSize,
   237  				rate: 2e6,
   238  				sell: false,
   239  			},
   240  			expectedCexOrder: &libxc.Trade{
   241  				BaseID:  42,
   242  				QuoteID: 0,
   243  				Qty:     lotSize,
   244  				Rate:    2.2e6,
   245  				Sell:    true,
   246  			},
   247  		},
   248  		// "2 lot, buy on cex, sell on dex, but cex quote balance only enough for 1"
   249  		{
   250  			name:          "2 lot, buy on cex, sell on dex, but cex quote balance only enough for 1",
   251  			books:         arb2LotsSellOnDEXBooks,
   252  			dexMaxSellQty: 5 * lotSize,
   253  			cexMaxBuyQty:  lotSize,
   254  			expectedDexOrder: &dexOrder{
   255  				qty:  lotSize,
   256  				rate: 2.2e6,
   257  				sell: true,
   258  			},
   259  			expectedCexOrder: &libxc.Trade{
   260  				BaseID:  42,
   261  				QuoteID: 0,
   262  				Qty:     lotSize,
   263  				Rate:    2e6,
   264  				Sell:    false,
   265  			},
   266  		},
   267  		// "2 lots arb still above profit trigger, but second not worth it on its own"
   268  		{
   269  			name:          "2 lots arb still above profit trigger, but second not worth it on its own",
   270  			books:         arb2LotsButOneWorth,
   271  			dexMaxSellQty: 5 * lotSize,
   272  			dexMaxBuyQty:  5 * lotSize,
   273  			cexMaxSellQty: 5 * lotSize,
   274  			cexMaxBuyQty:  5 * lotSize,
   275  			expectedDexOrder: &dexOrder{
   276  				qty:  lotSize,
   277  				rate: 2e6,
   278  				sell: false,
   279  			},
   280  			expectedCexOrder: &libxc.Trade{
   281  				BaseID:  42,
   282  				QuoteID: 0,
   283  				Qty:     lotSize,
   284  				Rate:    2.2e6,
   285  				Sell:    true,
   286  			},
   287  		},
   288  		// "cex no asks"
   289  		{
   290  			name: "cex no asks",
   291  			books: &testBooks{
   292  				dexBidsAvg:     []uint64{1.8e6, 1.7e6},
   293  				dexBidsExtrema: []uint64{1.7e6, 1.6e6},
   294  
   295  				dexAsksAvg:     []uint64{2e6, 2.5e6},
   296  				dexAsksExtrema: []uint64{2e6, 3e6},
   297  
   298  				cexBidsAvg:     []uint64{1.9e6, 1.8e6},
   299  				cexBidsExtrema: []uint64{1.85e6, 1.75e6},
   300  
   301  				cexAsksAvg:     []uint64{},
   302  				cexAsksExtrema: []uint64{},
   303  			},
   304  			dexMaxSellQty: 5 * lotSize,
   305  			dexMaxBuyQty:  5 * lotSize,
   306  			cexMaxSellQty: 5 * lotSize,
   307  			cexMaxBuyQty:  5 * lotSize,
   308  		},
   309  		// "dex no asks"
   310  		{
   311  			name: "dex no asks",
   312  			books: &testBooks{
   313  				dexBidsAvg:     []uint64{1.8e6, 1.7e6},
   314  				dexBidsExtrema: []uint64{1.7e6, 1.6e6},
   315  
   316  				dexAsksAvg:     []uint64{},
   317  				dexAsksExtrema: []uint64{},
   318  
   319  				cexBidsAvg:     []uint64{1.9e6, 1.8e6},
   320  				cexBidsExtrema: []uint64{1.85e6, 1.75e6},
   321  
   322  				cexAsksAvg:     []uint64{2.1e6, 2.2e6},
   323  				cexAsksExtrema: []uint64{2.2e6, 2.3e6},
   324  			},
   325  			dexMaxSellQty: 5 * lotSize,
   326  			dexMaxBuyQty:  5 * lotSize,
   327  			cexMaxSellQty: 5 * lotSize,
   328  			cexMaxBuyQty:  5 * lotSize,
   329  		},
   330  		// "self-match"
   331  		{
   332  			name:  "self-match",
   333  			books: arbSellOnDEXBooks,
   334  			existingArbs: []*arbSequence{{
   335  				dexOrder: &core.Order{
   336  					ID:   orderIDs[0][:],
   337  					Rate: 2.2e6,
   338  				},
   339  				cexOrderID: cexTradeIDs[0],
   340  				sellOnDEX:  false,
   341  				startEpoch: currEpoch - 2,
   342  			}},
   343  			dexMaxSellQty: 5 * lotSize,
   344  			dexMaxBuyQty:  5 * lotSize,
   345  			cexMaxSellQty: 5 * lotSize,
   346  			cexMaxBuyQty:  5 * lotSize,
   347  
   348  			expectedCEXCancels: []string{cexTradeIDs[0]},
   349  			expectedDEXCancels: []dex.Bytes{orderIDs[0][:]},
   350  		},
   351  		// "remove expired active arbs"
   352  		{
   353  			name:          "remove expired active arbs",
   354  			books:         noArbBooks,
   355  			dexMaxSellQty: 5 * lotSize,
   356  			dexMaxBuyQty:  5 * lotSize,
   357  			cexMaxSellQty: 5 * lotSize,
   358  			cexMaxBuyQty:  5 * lotSize,
   359  			existingArbs: []*arbSequence{
   360  				{
   361  					dexOrder: &core.Order{
   362  						ID: orderIDs[0][:],
   363  					},
   364  					cexOrderID: cexTradeIDs[0],
   365  					sellOnDEX:  false,
   366  					startEpoch: currEpoch - 2,
   367  				},
   368  				{
   369  					dexOrder: &core.Order{
   370  						ID: orderIDs[1][:],
   371  					},
   372  					cexOrderID: cexTradeIDs[1],
   373  					sellOnDEX:  false,
   374  					startEpoch: currEpoch - (uint64(numEpochsLeaveOpen) + 2),
   375  				},
   376  				{
   377  					dexOrder: &core.Order{
   378  						ID: orderIDs[2][:],
   379  					},
   380  					cexOrderID:     cexTradeIDs[2],
   381  					sellOnDEX:      false,
   382  					cexOrderFilled: true,
   383  					startEpoch:     currEpoch - (uint64(numEpochsLeaveOpen) + 2),
   384  				},
   385  				{
   386  					dexOrder: &core.Order{
   387  						ID: orderIDs[3][:],
   388  					},
   389  					cexOrderID:     cexTradeIDs[3],
   390  					sellOnDEX:      false,
   391  					dexOrderFilled: true,
   392  					startEpoch:     currEpoch - (uint64(numEpochsLeaveOpen) + 2),
   393  				},
   394  			},
   395  			expectedCEXCancels: []string{cexTradeIDs[1], cexTradeIDs[3]},
   396  			expectedDEXCancels: []dex.Bytes{orderIDs[1][:], orderIDs[2][:]},
   397  		},
   398  		// "already max active arbs"
   399  		{
   400  			name:          "already max active arbs",
   401  			books:         arbBuyOnDEXBooks,
   402  			dexMaxSellQty: 5 * lotSize,
   403  			dexMaxBuyQty:  5 * lotSize,
   404  			cexMaxSellQty: 5 * lotSize,
   405  			cexMaxBuyQty:  5 * lotSize,
   406  			existingArbs: []*arbSequence{
   407  				{
   408  					dexOrder: &core.Order{
   409  						ID: orderIDs[0][:],
   410  					},
   411  					cexOrderID: cexTradeIDs[0],
   412  					sellOnDEX:  false,
   413  					startEpoch: currEpoch - 1,
   414  				},
   415  				{
   416  					dexOrder: &core.Order{
   417  						ID: orderIDs[1][:],
   418  					},
   419  					cexOrderID: cexTradeIDs[2],
   420  					sellOnDEX:  false,
   421  					startEpoch: currEpoch - 2,
   422  				},
   423  				{
   424  					dexOrder: &core.Order{
   425  						ID: orderIDs[2][:],
   426  					},
   427  					cexOrderID: cexTradeIDs[2],
   428  					sellOnDEX:  false,
   429  					startEpoch: currEpoch - 3,
   430  				},
   431  				{
   432  					dexOrder: &core.Order{
   433  						ID: orderIDs[3][:],
   434  					},
   435  					cexOrderID: cexTradeIDs[3],
   436  					sellOnDEX:  false,
   437  					startEpoch: currEpoch - 4,
   438  				},
   439  				{
   440  					dexOrder: &core.Order{
   441  						ID: orderIDs[4][:],
   442  					},
   443  					cexOrderID: cexTradeIDs[4],
   444  					sellOnDEX:  false,
   445  					startEpoch: currEpoch - 5,
   446  				},
   447  			},
   448  		},
   449  		// "cex trade error"
   450  		{
   451  			name:          "cex trade error",
   452  			books:         arbBuyOnDEXBooks,
   453  			dexMaxSellQty: 5 * lotSize,
   454  			dexMaxBuyQty:  5 * lotSize,
   455  			cexMaxSellQty: 5 * lotSize,
   456  			cexMaxBuyQty:  5 * lotSize,
   457  			cexTradeErr:   errors.New(""),
   458  		},
   459  	}
   460  
   461  	runTest := func(test *test) {
   462  		t.Run(test.name, func(t *testing.T) {
   463  			cex := newTBotCEXAdaptor()
   464  			tcex := newTCEX()
   465  			tcex.vwapErr = test.cexVWAPErr
   466  			cex.tradeErr = test.cexTradeErr
   467  			cex.maxBuyQty = test.cexMaxBuyQty
   468  			cex.maxSellQty = test.cexMaxSellQty
   469  
   470  			tc := newTCore()
   471  			coreAdaptor := newTBotCoreAdaptor(tc)
   472  			coreAdaptor.buyFeesInQuote = feesInQuoteUnits
   473  			coreAdaptor.sellFeesInQuote = feesInQuoteUnits
   474  			coreAdaptor.maxBuyQty = test.dexMaxBuyQty
   475  			coreAdaptor.maxSellQty = test.dexMaxSellQty
   476  
   477  			if test.expectedDexOrder != nil {
   478  				coreAdaptor.tradeResult = &core.Order{
   479  					Qty:  test.expectedDexOrder.qty,
   480  					Rate: test.expectedDexOrder.rate,
   481  					Sell: test.expectedDexOrder.sell,
   482  				}
   483  			}
   484  
   485  			orderBook := &tOrderBook{
   486  				bidsVWAP: make(map[uint64]vwapResult),
   487  				asksVWAP: make(map[uint64]vwapResult),
   488  				vwapErr:  test.dexVWAPErr,
   489  			}
   490  			for i := range test.books.dexBidsAvg {
   491  				orderBook.bidsVWAP[uint64(i+1)] = vwapResult{test.books.dexBidsAvg[i], test.books.dexBidsExtrema[i]}
   492  			}
   493  			for i := range test.books.dexAsksAvg {
   494  				orderBook.asksVWAP[uint64(i+1)] = vwapResult{test.books.dexAsksAvg[i], test.books.dexAsksExtrema[i]}
   495  			}
   496  			for i := range test.books.cexBidsAvg {
   497  				tcex.bidsVWAP[uint64(i+1)*lotSize] = vwapResult{test.books.cexBidsAvg[i], test.books.cexBidsExtrema[i]}
   498  			}
   499  			for i := range test.books.cexAsksAvg {
   500  				tcex.asksVWAP[uint64(i+1)*lotSize] = vwapResult{test.books.cexAsksAvg[i], test.books.cexAsksExtrema[i]}
   501  			}
   502  
   503  			u := mustParseAdaptorFromMarket(&core.Market{
   504  				LotSize:  lotSize,
   505  				BaseID:   baseID,
   506  				QuoteID:  quoteID,
   507  				RateStep: 1e2,
   508  			})
   509  			u.clientCore.(*tCore).userParcels = 0
   510  			u.clientCore.(*tCore).parcelLimit = 1
   511  
   512  			a := &simpleArbMarketMaker{
   513  				unifiedExchangeAdaptor: u,
   514  				cex:                    cex,
   515  				core:                   coreAdaptor,
   516  				activeArbs:             test.existingArbs,
   517  			}
   518  			const sellSwapFees, sellRedeemFees = 3e5, 1e5
   519  			const buySwapFees, buyRedeemFees = 2e4, 1e4
   520  			const buyRate, sellRate = 1e7, 1.1e7
   521  			a.CEX = tcex
   522  			a.buyFees = &OrderFees{
   523  				LotFeeRange: &LotFeeRange{
   524  					Max: &LotFees{
   525  						Redeem: buyRedeemFees,
   526  					},
   527  					Estimated: &LotFees{
   528  						Swap:   buySwapFees,
   529  						Redeem: buyRedeemFees,
   530  					},
   531  				},
   532  				BookingFeesPerLot: buySwapFees,
   533  			}
   534  			a.sellFees = &OrderFees{
   535  				LotFeeRange: &LotFeeRange{
   536  					Max: &LotFees{
   537  						Redeem: sellRedeemFees,
   538  					},
   539  					Estimated: &LotFees{
   540  						Swap:   sellSwapFees,
   541  						Redeem: sellRedeemFees,
   542  					},
   543  				},
   544  				BookingFeesPerLot: sellSwapFees,
   545  			}
   546  			// arbEngine.setBotLoop(arbEngine.botLoop)
   547  			a.unifiedExchangeAdaptor.botCfgV.Store(&BotConfig{
   548  				SimpleArbConfig: &SimpleArbConfig{
   549  					ProfitTrigger:      profitTrigger,
   550  					MaxActiveArbs:      maxActiveArbs,
   551  					NumEpochsLeaveOpen: numEpochsLeaveOpen,
   552  				},
   553  			})
   554  			a.book = orderBook
   555  			a.rebalance(currEpoch)
   556  
   557  			// Check dex trade
   558  			if test.expectedDexOrder == nil != (coreAdaptor.lastTradePlaced == nil) {
   559  				t.Fatalf("%s: expected dex order %v but got %v", test.name, (test.expectedDexOrder != nil), (coreAdaptor.lastTradePlaced != nil))
   560  			}
   561  			if test.expectedDexOrder != nil {
   562  				if test.expectedDexOrder.rate != coreAdaptor.lastTradePlaced.rate {
   563  					t.Fatalf("%s: expected sell order rate %d but got %d", test.name, test.expectedDexOrder.rate, coreAdaptor.lastTradePlaced.rate)
   564  				}
   565  				if test.expectedDexOrder.qty != coreAdaptor.lastTradePlaced.qty {
   566  					t.Fatalf("%s: expected sell order qty %d but got %d", test.name, test.expectedDexOrder.qty, coreAdaptor.lastTradePlaced.qty)
   567  				}
   568  				if test.expectedDexOrder.sell != coreAdaptor.lastTradePlaced.sell {
   569  					t.Fatalf("%s: expected sell order sell %v but got %v", test.name, test.expectedDexOrder.sell, coreAdaptor.lastTradePlaced.sell)
   570  				}
   571  			}
   572  
   573  			// Check cex trade
   574  			if (test.expectedCexOrder == nil) != (cex.lastTrade == nil) {
   575  				t.Fatalf("%s: expected cex order %v but got %v", test.name, (test.expectedCexOrder != nil), (cex.lastTrade != nil))
   576  			}
   577  			if cex.lastTrade != nil &&
   578  				*cex.lastTrade != *test.expectedCexOrder {
   579  				t.Fatalf("%s: cex order %+v != expected %+v", test.name, cex.lastTrade, test.expectedCexOrder)
   580  			}
   581  
   582  			// Check dex cancels
   583  			if len(test.expectedDEXCancels) != len(tc.cancelsPlaced) {
   584  				t.Fatalf("%s: expected %d cancels but got %d", test.name, len(test.expectedDEXCancels), len(tc.cancelsPlaced))
   585  			}
   586  			for i := range test.expectedDEXCancels {
   587  				if !bytes.Equal(test.expectedDEXCancels[i], tc.cancelsPlaced[i][:]) {
   588  					t.Fatalf("%s: expected cancel %x but got %x", test.name, test.expectedDEXCancels[i], tc.cancelsPlaced[i])
   589  				}
   590  			}
   591  
   592  			// Check cex cancels
   593  			if len(test.expectedCEXCancels) != len(cex.cancelledTrades) {
   594  				t.Fatalf("%s: expected %d cex cancels but got %d", test.name, len(test.expectedCEXCancels), len(cex.cancelledTrades))
   595  			}
   596  			for i := range test.expectedCEXCancels {
   597  				if test.expectedCEXCancels[i] != cex.cancelledTrades[i] {
   598  					t.Fatalf("%s: expected cex cancel %s but got %s", test.name, test.expectedCEXCancels[i], cex.cancelledTrades[i])
   599  				}
   600  			}
   601  		})
   602  	}
   603  
   604  	for _, test := range tests {
   605  		runTest(&test)
   606  	}
   607  }
   608  
   609  func TestArbDexTradeUpdates(t *testing.T) {
   610  	orderIDs := make([]order.OrderID, 5)
   611  	for i := 0; i < 5; i++ {
   612  		copy(orderIDs[i][:], encode.RandomBytes(32))
   613  	}
   614  
   615  	cexTradeIDs := make([]string, 0, 5)
   616  	for i := 0; i < 5; i++ {
   617  		cexTradeIDs = append(cexTradeIDs, fmt.Sprintf("%x", encode.RandomBytes(32)))
   618  	}
   619  
   620  	type test struct {
   621  		name               string
   622  		activeArbs         []*arbSequence
   623  		updatedOrderID     []byte
   624  		updatedOrderStatus order.OrderStatus
   625  		expectedActiveArbs []*arbSequence
   626  	}
   627  
   628  	dexOrder := &core.Order{
   629  		ID: orderIDs[0][:],
   630  	}
   631  
   632  	tests := []*test{
   633  		{
   634  			name: "dex order still booked",
   635  			activeArbs: []*arbSequence{
   636  				{
   637  					dexOrder:   dexOrder,
   638  					cexOrderID: cexTradeIDs[0],
   639  				},
   640  			},
   641  			updatedOrderID:     orderIDs[0][:],
   642  			updatedOrderStatus: order.OrderStatusBooked,
   643  			expectedActiveArbs: []*arbSequence{
   644  				{
   645  					dexOrder:   dexOrder,
   646  					cexOrderID: cexTradeIDs[0],
   647  				},
   648  			},
   649  		},
   650  		{
   651  			name: "dex order executed, but cex not yet filled",
   652  			activeArbs: []*arbSequence{
   653  				{
   654  					dexOrder:   dexOrder,
   655  					cexOrderID: cexTradeIDs[0],
   656  				},
   657  			},
   658  			updatedOrderID:     orderIDs[0][:],
   659  			updatedOrderStatus: order.OrderStatusExecuted,
   660  			expectedActiveArbs: []*arbSequence{
   661  				{
   662  					dexOrder:       dexOrder,
   663  					cexOrderID:     cexTradeIDs[0],
   664  					dexOrderFilled: true,
   665  				},
   666  			},
   667  		},
   668  		{
   669  			name: "dex order executed, but cex already filled",
   670  			activeArbs: []*arbSequence{
   671  				{
   672  					dexOrder:       dexOrder,
   673  					cexOrderID:     cexTradeIDs[0],
   674  					cexOrderFilled: true,
   675  				},
   676  			},
   677  			updatedOrderID:     orderIDs[0][:],
   678  			updatedOrderStatus: order.OrderStatusExecuted,
   679  			expectedActiveArbs: []*arbSequence{},
   680  		},
   681  	}
   682  
   683  	runTest := func(test *test) {
   684  		cex := newTBotCEXAdaptor()
   685  		coreAdaptor := newTBotCoreAdaptor(newTCore())
   686  
   687  		ctx, cancel := context.WithCancel(context.Background())
   688  		defer cancel()
   689  
   690  		arbEngine := &simpleArbMarketMaker{
   691  			unifiedExchangeAdaptor: mustParseAdaptorFromMarket(&core.Market{
   692  				BaseID:  42,
   693  				QuoteID: 0,
   694  			}),
   695  			cex:        cex,
   696  			core:       coreAdaptor,
   697  			activeArbs: test.activeArbs,
   698  		}
   699  		arbEngine.clientCore = newTCore()
   700  		arbEngine.CEX = newTCEX()
   701  		arbEngine.ctx = ctx
   702  		arbEngine.setBotLoop(arbEngine.botLoop)
   703  		arbEngine.unifiedExchangeAdaptor.botCfgV.Store(&BotConfig{
   704  			SimpleArbConfig: &SimpleArbConfig{
   705  				ProfitTrigger:      0.01,
   706  				MaxActiveArbs:      5,
   707  				NumEpochsLeaveOpen: 10,
   708  			},
   709  		})
   710  
   711  		err := arbEngine.runBotLoop(ctx)
   712  		if err != nil {
   713  			t.Fatalf("%s: Connect error: %v", test.name, err)
   714  		}
   715  
   716  		coreAdaptor.orderUpdates <- &core.Order{
   717  			Status: test.updatedOrderStatus,
   718  			ID:     test.updatedOrderID,
   719  		}
   720  		coreAdaptor.orderUpdates <- &core.Order{}
   721  
   722  		if len(test.expectedActiveArbs) != len(arbEngine.activeArbs) {
   723  			t.Fatalf("%s: expected %d active arbs but got %d", test.name, len(test.expectedActiveArbs), len(arbEngine.activeArbs))
   724  		}
   725  
   726  		for i := range test.expectedActiveArbs {
   727  			if *arbEngine.activeArbs[i] != *test.expectedActiveArbs[i] {
   728  				t.Fatalf("%s: active arb %+v != expected active arb %+v", test.name, arbEngine.activeArbs[i], test.expectedActiveArbs[i])
   729  			}
   730  		}
   731  	}
   732  
   733  	for _, test := range tests {
   734  		runTest(test)
   735  	}
   736  }
   737  
   738  func TestCexTradeUpdates(t *testing.T) {
   739  	orderIDs := make([]order.OrderID, 5)
   740  	for i := 0; i < 5; i++ {
   741  		copy(orderIDs[i][:], encode.RandomBytes(32))
   742  	}
   743  
   744  	cexTradeIDs := make([]string, 0, 5)
   745  	for i := 0; i < 5; i++ {
   746  		cexTradeIDs = append(cexTradeIDs, fmt.Sprintf("%x", encode.RandomBytes(32)))
   747  	}
   748  
   749  	dexOrder := &core.Order{
   750  		ID: orderIDs[0][:],
   751  	}
   752  
   753  	type test struct {
   754  		name               string
   755  		activeArbs         []*arbSequence
   756  		updatedOrderID     string
   757  		orderComplete      bool
   758  		expectedActiveArbs []*arbSequence
   759  	}
   760  
   761  	tests := []*test{
   762  		{
   763  			name: "neither complete",
   764  			activeArbs: []*arbSequence{
   765  				{
   766  					dexOrder:   dexOrder,
   767  					cexOrderID: cexTradeIDs[0],
   768  				},
   769  			},
   770  			updatedOrderID: cexTradeIDs[0],
   771  			orderComplete:  false,
   772  			expectedActiveArbs: []*arbSequence{
   773  				{
   774  					dexOrder:   dexOrder,
   775  					cexOrderID: cexTradeIDs[0],
   776  				},
   777  			},
   778  		},
   779  		{
   780  			name: "cex complete, but dex order not complete",
   781  			activeArbs: []*arbSequence{
   782  				{
   783  					dexOrder:   dexOrder,
   784  					cexOrderID: cexTradeIDs[0],
   785  				},
   786  			},
   787  			updatedOrderID: cexTradeIDs[0],
   788  			orderComplete:  true,
   789  			expectedActiveArbs: []*arbSequence{
   790  				{
   791  					dexOrder:       dexOrder,
   792  					cexOrderID:     cexTradeIDs[0],
   793  					cexOrderFilled: true,
   794  				},
   795  			},
   796  		},
   797  		{
   798  			name: "both complete",
   799  			activeArbs: []*arbSequence{
   800  				{
   801  					dexOrder:       dexOrder,
   802  					cexOrderID:     cexTradeIDs[0],
   803  					dexOrderFilled: true,
   804  				},
   805  			},
   806  			updatedOrderID: cexTradeIDs[0],
   807  			orderComplete:  true,
   808  		},
   809  	}
   810  
   811  	runTest := func(test *test) {
   812  		cex := newTBotCEXAdaptor()
   813  		ctx, cancel := context.WithCancel(context.Background())
   814  		defer cancel()
   815  
   816  		arbEngine := &simpleArbMarketMaker{
   817  			unifiedExchangeAdaptor: mustParseAdaptorFromMarket(&core.Market{
   818  				BaseID:  42,
   819  				QuoteID: 0,
   820  			}),
   821  			cex:        cex,
   822  			core:       newTBotCoreAdaptor(newTCore()),
   823  			activeArbs: test.activeArbs,
   824  		}
   825  		arbEngine.ctx = ctx
   826  		arbEngine.CEX = newTCEX()
   827  		arbEngine.setBotLoop(arbEngine.botLoop)
   828  		arbEngine.unifiedExchangeAdaptor.botCfgV.Store(&BotConfig{
   829  			SimpleArbConfig: &SimpleArbConfig{
   830  				ProfitTrigger:      0.01,
   831  				MaxActiveArbs:      5,
   832  				NumEpochsLeaveOpen: 10,
   833  			},
   834  		})
   835  
   836  		err := arbEngine.runBotLoop(ctx)
   837  		if err != nil {
   838  			t.Fatalf("%s: Connect error: %v", test.name, err)
   839  		}
   840  
   841  		cex.tradeUpdates <- &libxc.Trade{
   842  			ID:       test.updatedOrderID,
   843  			Complete: test.orderComplete,
   844  		}
   845  		// send dummy update
   846  		cex.tradeUpdates <- &libxc.Trade{
   847  			ID: "",
   848  		}
   849  
   850  		if len(test.expectedActiveArbs) != len(arbEngine.activeArbs) {
   851  			t.Fatalf("%s: expected %d active arbs but got %d", test.name, len(test.expectedActiveArbs), len(arbEngine.activeArbs))
   852  		}
   853  		for i := range test.expectedActiveArbs {
   854  			if *arbEngine.activeArbs[i] != *test.expectedActiveArbs[i] {
   855  				t.Fatalf("%s: active arb %+v != expected active arb %+v", test.name, arbEngine.activeArbs[i], test.expectedActiveArbs[i])
   856  			}
   857  		}
   858  	}
   859  
   860  	for _, test := range tests {
   861  		runTest(test)
   862  	}
   863  }
   864  
   865  /*func TestArbBotProblems(t *testing.T) {
   866  	const baseID, quoteID = 42, 0
   867  	const lotSize uint64 = 5e9
   868  	const sellSwapFees, sellRedeemFees = 3e6, 1e6
   869  	const buySwapFees, buyRedeemFees = 2e5, 1e5
   870  	const buyRate, sellRate = 1e7, 1.1e7
   871  
   872  	type test struct {
   873  		name            string
   874  		userLimitTooLow bool
   875  		dexBalanceDefs  map[uint32]uint64
   876  		cexBalanceDefs  map[uint32]uint64
   877  
   878  		expBotProblems *BotProblems
   879  	}
   880  
   881  	updateBotProblems := func(f func(*BotProblems)) *BotProblems {
   882  		bp := newBotProblems()
   883  		f(bp)
   884  		return bp
   885  	}
   886  
   887  	tests := []*test{
   888  		{
   889  			name:           "no problems",
   890  			expBotProblems: newBotProblems(),
   891  		},
   892  		{
   893  			name:            "user limit too low",
   894  			userLimitTooLow: true,
   895  			expBotProblems: updateBotProblems(func(bp *BotProblems) {
   896  				bp.UserLimitTooLow = true
   897  			}),
   898  		},
   899  		{
   900  			name: "balance deficiencies",
   901  			dexBalanceDefs: map[uint32]uint64{
   902  				baseID:  lotSize + sellSwapFees,
   903  				quoteID: calc.BaseToQuote(buyRate, lotSize) + buySwapFees,
   904  			},
   905  			cexBalanceDefs: map[uint32]uint64{
   906  				baseID:  lotSize,
   907  				quoteID: calc.BaseToQuote(sellRate, lotSize),
   908  			},
   909  			expBotProblems: updateBotProblems(func(bp *BotProblems) {
   910  				// All these values are multiplied by 2 because the same deficiencies
   911  				// are returned for buys and sells, and they are summed.
   912  				bp.DEXBalanceDeficiencies = map[uint32]uint64{
   913  					baseID:  (lotSize + sellSwapFees) * 2,
   914  					quoteID: (calc.BaseToQuote(buyRate, lotSize) + buySwapFees) * 2,
   915  				}
   916  				bp.CEXBalanceDeficiencies = map[uint32]uint64{
   917  					baseID:  lotSize * 2,
   918  					quoteID: calc.BaseToQuote(sellRate, lotSize) * 2,
   919  				}
   920  			}),
   921  		},
   922  	}
   923  
   924  	runTest := func(tt *test) {
   925  		t.Run(tt.name, func(t *testing.T) {
   926  			cex := newTCEX()
   927  			mkt := &core.Market{
   928  				RateStep:   1e3,
   929  				AtomToConv: 1,
   930  				LotSize:    lotSize,
   931  				BaseID:     baseID,
   932  				QuoteID:    quoteID,
   933  			}
   934  			u := mustParseAdaptorFromMarket(mkt)
   935  			u.CEX = cex
   936  			u.botCfgV.Store(&BotConfig{})
   937  			c := newTCore()
   938  			if !tt.userLimitTooLow {
   939  				u.clientCore.(*tCore).userParcels = 0
   940  				u.clientCore.(*tCore).parcelLimit = 1
   941  			}
   942  			u.fiatRates.Store(map[uint32]float64{baseID: 1, quoteID: 1})
   943  			cexAdaptor := newTBotCEXAdaptor()
   944  			coreAdaptor := newTBotCoreAdaptor(c)
   945  			a := &simpleArbMarketMaker{
   946  				unifiedExchangeAdaptor: u,
   947  				cex:                    cexAdaptor,
   948  				core:                   coreAdaptor,
   949  			}
   950  
   951  			coreAdaptor.balanceDefs = tt.dexBalanceDefs
   952  			cexAdaptor.balanceDefs = tt.cexBalanceDefs
   953  
   954  			a.cfgV.Store(&SimpleArbConfig{})
   955  
   956  			cex.asksVWAP[lotSize] = vwapResult{
   957  				avg:     buyRate,
   958  				extrema: buyRate,
   959  			}
   960  			cex.bidsVWAP[lotSize] = vwapResult{
   961  				avg:     sellRate,
   962  				extrema: sellRate,
   963  			}
   964  
   965  			a.book = &tOrderBook{
   966  				bidsVWAP: map[uint64]vwapResult{
   967  					1: {
   968  						avg:     buyRate,
   969  						extrema: buyRate,
   970  					},
   971  				},
   972  				asksVWAP: map[uint64]vwapResult{
   973  					1: {
   974  						avg:     sellRate,
   975  						extrema: sellRate,
   976  					},
   977  				},
   978  			}
   979  
   980  			a.buyFees = &OrderFees{
   981  				LotFeeRange: &LotFeeRange{
   982  					Max: &LotFees{
   983  						Redeem: buyRedeemFees,
   984  						Swap:   buySwapFees,
   985  					},
   986  					Estimated: &LotFees{},
   987  				},
   988  				BookingFeesPerLot: buySwapFees,
   989  			}
   990  			a.sellFees = &OrderFees{
   991  				LotFeeRange: &LotFeeRange{
   992  					Max: &LotFees{
   993  						Redeem: sellRedeemFees,
   994  						Swap:   sellSwapFees,
   995  					},
   996  					Estimated: &LotFees{},
   997  				},
   998  				BookingFeesPerLot: sellSwapFees,
   999  			}
  1000  
  1001  			a.rebalance(1)
  1002  
  1003  			problems := a.problems()
  1004  			if !reflect.DeepEqual(tt.expBotProblems, problems) {
  1005  				t.Fatalf("expected bot problems %v, got %v", tt.expBotProblems, problems)
  1006  			}
  1007  		})
  1008  	}
  1009  
  1010  	for _, test := range tests {
  1011  		runTest(test)
  1012  	}
  1013  }
  1014  */