decred.org/dcrdex@v1.0.3/client/mm/mm_arb_market_maker_test.go (about)

     1  // This code is available on the terms of the project LICENSE.md file,
     2  // also available online at https://blueoakcouncil.org/license/1.0.0.
     3  
     4  package mm
     5  
     6  import (
     7  	"context"
     8  	"sync"
     9  	"testing"
    10  
    11  	"decred.org/dcrdex/client/core"
    12  	"decred.org/dcrdex/client/mm/libxc"
    13  	"decred.org/dcrdex/client/orderbook"
    14  	"decred.org/dcrdex/dex/calc"
    15  	"decred.org/dcrdex/dex/encode"
    16  	"decred.org/dcrdex/dex/order"
    17  )
    18  
    19  func TestArbMMRebalance(t *testing.T) {
    20  	const baseID, quoteID = 42, 0
    21  	const lotSize uint64 = 5e9
    22  	const sellSwapFees, sellRedeemFees = 3e6, 1e6
    23  	const buySwapFees, buyRedeemFees = 2e5, 1e5
    24  	const buyRate, sellRate = 1e7, 1.1e7
    25  
    26  	var epok uint64
    27  	epoch := func() uint64 {
    28  		epok++
    29  		return epok
    30  	}
    31  
    32  	mkt := &core.Market{
    33  		RateStep:   1e3,
    34  		AtomToConv: 1,
    35  		LotSize:    lotSize,
    36  		BaseID:     baseID,
    37  		QuoteID:    quoteID,
    38  	}
    39  
    40  	cex := newTCEX()
    41  	u := mustParseAdaptorFromMarket(mkt)
    42  	u.CEX = cex
    43  	u.botCfgV.Store(&BotConfig{})
    44  	c := newTCore()
    45  	c.setWalletsAndExchange(mkt)
    46  	u.clientCore = c
    47  	u.fiatRates.Store(map[uint32]float64{baseID: 1, quoteID: 1})
    48  	a := &arbMarketMaker{
    49  		unifiedExchangeAdaptor: u,
    50  		cex:                    newTBotCEXAdaptor(),
    51  		core:                   newTBotCoreAdaptor(c),
    52  		pendingOrders:          make(map[order.OrderID]uint64),
    53  	}
    54  	a.buyFees = &OrderFees{
    55  		LotFeeRange: &LotFeeRange{
    56  			Max: &LotFees{
    57  				Redeem: buyRedeemFees,
    58  				Swap:   buySwapFees,
    59  			},
    60  			Estimated: &LotFees{},
    61  		},
    62  		BookingFeesPerLot: buySwapFees,
    63  	}
    64  	a.sellFees = &OrderFees{
    65  		LotFeeRange: &LotFeeRange{
    66  			Max: &LotFees{
    67  				Redeem: sellRedeemFees,
    68  				Swap:   sellSwapFees,
    69  			},
    70  			Estimated: &LotFees{},
    71  		},
    72  		BookingFeesPerLot: sellSwapFees,
    73  	}
    74  
    75  	var buyLots, sellLots, minDexBase, minCexBase /* totalBase, */, minDexQuote, minCexQuote /*, totalQuote */ uint64
    76  	setLots := func(buy, sell uint64) {
    77  		buyLots, sellLots = buy, sell
    78  		a.placementLotsV.Store(&placementLots{
    79  			baseLots:  sellLots,
    80  			quoteLots: buyLots,
    81  		})
    82  		a.cfgV.Store(&ArbMarketMakerConfig{
    83  			Profit: 0,
    84  			BuyPlacements: []*ArbMarketMakingPlacement{
    85  				{
    86  					Lots:       buyLots,
    87  					Multiplier: 1,
    88  				},
    89  			},
    90  			SellPlacements: []*ArbMarketMakingPlacement{
    91  				{
    92  					Lots:       sellLots,
    93  					Multiplier: 1,
    94  				},
    95  			},
    96  		})
    97  		cex.bidsVWAP[lotSize*buyLots] = vwapResult{
    98  			avg:     buyRate,
    99  			extrema: buyRate,
   100  		}
   101  		cex.asksVWAP[lotSize*sellLots] = vwapResult{
   102  			avg:     sellRate,
   103  			extrema: sellRate,
   104  		}
   105  		minDexBase = sellLots * (lotSize + sellSwapFees)
   106  		minCexBase = buyLots * lotSize
   107  		minDexQuote = calc.BaseToQuote(buyRate, buyLots*lotSize) + a.buyFees.BookingFeesPerLot*buyLots
   108  		minCexQuote = calc.BaseToQuote(sellRate, sellLots*lotSize)
   109  	}
   110  
   111  	setBals := func(assetID uint32, dexBal, cexBal uint64) {
   112  		a.baseDexBalances[assetID] = int64(dexBal)
   113  		a.baseCexBalances[assetID] = int64(cexBal)
   114  	}
   115  
   116  	type expectedPlacement struct {
   117  		sell bool
   118  		rate uint64
   119  		lots uint64
   120  	}
   121  
   122  	ep := func(sell bool, rate, lots uint64) *expectedPlacement {
   123  		return &expectedPlacement{sell: sell, rate: rate, lots: lots}
   124  	}
   125  
   126  	checkPlacements := func(ps ...*expectedPlacement) {
   127  		t.Helper()
   128  
   129  		if len(ps) != len(c.multiTradesPlaced) {
   130  			t.Fatalf("expected %d placements, got %d", len(ps), len(c.multiTradesPlaced))
   131  		}
   132  
   133  		var n int
   134  		for _, ord := range c.multiTradesPlaced {
   135  			for _, pl := range ord.Placements {
   136  				n++
   137  				if len(ps) < n {
   138  					t.Fatalf("too many placements")
   139  				}
   140  				p := ps[n-1]
   141  				if p.sell != ord.Sell {
   142  					t.Fatalf("expected placement %d to be sell = %t, got sell = %t", n-1, p.sell, ord.Sell)
   143  				}
   144  				if p.rate != pl.Rate {
   145  					t.Fatalf("placement %d: expected rate %d, but got %d", n-1, p.rate, pl.Rate)
   146  				}
   147  				if p.lots != pl.Qty/lotSize {
   148  					t.Fatalf("placement %d: expected %d lots, but got %d", n-1, p.lots, pl.Qty/lotSize)
   149  				}
   150  			}
   151  		}
   152  		c.multiTradesPlaced = nil
   153  		a.pendingDEXOrders = make(map[order.OrderID]*pendingDEXOrder)
   154  	}
   155  
   156  	setLots(1, 1)
   157  	setBals(baseID, minDexBase, minCexBase)
   158  	setBals(quoteID, minDexQuote, minCexQuote)
   159  
   160  	a.rebalance(epoch(), &orderbook.OrderBook{})
   161  	checkPlacements(ep(false, buyRate, 1), ep(true, sellRate, 1))
   162  
   163  	// base balance too low
   164  	setBals(baseID, minDexBase-1, minCexBase)
   165  	a.rebalance(epoch(), &orderbook.OrderBook{})
   166  	checkPlacements(ep(false, buyRate, 1))
   167  
   168  	// quote balance too low
   169  	setBals(baseID, minDexBase, minCexBase)
   170  	setBals(quoteID, minDexQuote-1, minCexQuote)
   171  	a.rebalance(epoch(), &orderbook.OrderBook{})
   172  	checkPlacements(ep(true, sellRate, 1))
   173  
   174  	// cex quote balance too low. Can't place sell.
   175  	setBals(quoteID, minDexQuote, minCexQuote-1)
   176  	a.rebalance(epoch(), &orderbook.OrderBook{})
   177  	checkPlacements(ep(false, buyRate, 1))
   178  
   179  	// cex base balance too low. Can't place buy.
   180  	setBals(baseID, minDexBase, minCexBase-1)
   181  	setBals(quoteID, minDexQuote, minCexQuote)
   182  	a.rebalance(epoch(), &orderbook.OrderBook{})
   183  	checkPlacements(ep(true, sellRate, 1))
   184  }
   185  
   186  func TestArbMarketMakerDEXUpdates(t *testing.T) {
   187  	const lotSize uint64 = 50e8
   188  	const profit float64 = 0.01
   189  
   190  	orderIDs := make([]order.OrderID, 5)
   191  	for i := 0; i < 5; i++ {
   192  		copy(orderIDs[i][:], encode.RandomBytes(32))
   193  	}
   194  
   195  	matchIDs := make([]order.MatchID, 5)
   196  	for i := 0; i < 5; i++ {
   197  		copy(matchIDs[i][:], encode.RandomBytes(32))
   198  	}
   199  
   200  	mkt := &core.Market{
   201  		RateStep:    1e3,
   202  		AtomToConv:  1,
   203  		LotSize:     lotSize,
   204  		BaseID:      42,
   205  		QuoteID:     0,
   206  		BaseSymbol:  "dcr",
   207  		QuoteSymbol: "btc",
   208  	}
   209  
   210  	type test struct {
   211  		name              string
   212  		pendingOrders     map[order.OrderID]uint64
   213  		orderUpdates      []*core.Order
   214  		expectedCEXTrades []*libxc.Trade
   215  	}
   216  
   217  	tests := []*test{
   218  		{
   219  			name: "one buy and one sell match, repeated",
   220  			pendingOrders: map[order.OrderID]uint64{
   221  				orderIDs[0]: 7.9e5,
   222  				orderIDs[1]: 6.1e5,
   223  			},
   224  			orderUpdates: []*core.Order{
   225  				{
   226  					ID:   orderIDs[0][:],
   227  					Sell: true,
   228  					Qty:  lotSize,
   229  					Rate: 8e5,
   230  					Matches: []*core.Match{
   231  						{
   232  							MatchID: matchIDs[0][:],
   233  							Qty:     lotSize,
   234  							Rate:    8e5,
   235  						},
   236  					},
   237  				},
   238  				{
   239  					ID:   orderIDs[1][:],
   240  					Sell: false,
   241  					Qty:  lotSize,
   242  					Rate: 6e5,
   243  					Matches: []*core.Match{
   244  						{
   245  							MatchID: matchIDs[1][:],
   246  							Qty:     lotSize,
   247  							Rate:    6e5,
   248  						},
   249  					},
   250  				},
   251  				{
   252  					ID:   orderIDs[0][:],
   253  					Sell: true,
   254  					Qty:  lotSize,
   255  					Rate: 8e5,
   256  					Matches: []*core.Match{
   257  						{
   258  							MatchID: matchIDs[0][:],
   259  							Qty:     lotSize,
   260  							Rate:    8e5,
   261  						},
   262  					},
   263  				},
   264  				{
   265  					ID:   orderIDs[1][:],
   266  					Sell: false,
   267  					Qty:  lotSize,
   268  					Rate: 6e5,
   269  					Matches: []*core.Match{
   270  						{
   271  							MatchID: matchIDs[1][:],
   272  							Qty:     lotSize,
   273  							Rate:    6e5,
   274  						},
   275  					},
   276  				},
   277  			},
   278  			expectedCEXTrades: []*libxc.Trade{
   279  				{
   280  					BaseID:  42,
   281  					QuoteID: 0,
   282  					Qty:     lotSize,
   283  					Rate:    7.9e5,
   284  					Sell:    false,
   285  				},
   286  				{
   287  					BaseID:  42,
   288  					QuoteID: 0,
   289  					Qty:     lotSize,
   290  					Rate:    6.1e5,
   291  					Sell:    true,
   292  				},
   293  				nil,
   294  				nil,
   295  			},
   296  		},
   297  	}
   298  
   299  	runTest := func(test *test) {
   300  		cex := newTBotCEXAdaptor()
   301  		tCore := newTCore()
   302  		coreAdaptor := newTBotCoreAdaptor(tCore)
   303  
   304  		ctx, cancel := context.WithCancel(context.Background())
   305  		defer cancel()
   306  
   307  		arbMM := &arbMarketMaker{
   308  			unifiedExchangeAdaptor: mustParseAdaptorFromMarket(mkt),
   309  			cex:                    cex,
   310  			core:                   coreAdaptor,
   311  			matchesSeen:            make(map[order.MatchID]bool),
   312  			cexTrades:              make(map[string]uint64),
   313  			pendingOrders:          test.pendingOrders,
   314  		}
   315  		arbMM.CEX = newTCEX()
   316  		arbMM.ctx = ctx
   317  		arbMM.setBotLoop(arbMM.botLoop)
   318  		arbMM.cfgV.Store(&ArbMarketMakerConfig{
   319  			Profit: profit,
   320  		})
   321  		arbMM.currEpoch.Store(123)
   322  		err := arbMM.runBotLoop(ctx)
   323  		if err != nil {
   324  			t.Fatalf("%s: unexpected error: %v", test.name, err)
   325  		}
   326  
   327  		for i, note := range test.orderUpdates {
   328  			cex.lastTrade = nil
   329  
   330  			coreAdaptor.orderUpdates <- note
   331  			coreAdaptor.orderUpdates <- &core.Order{} // Dummy update should have no effect
   332  
   333  			expectedCEXTrade := test.expectedCEXTrades[i]
   334  			if (expectedCEXTrade == nil) != (cex.lastTrade == nil) {
   335  				t.Fatalf("%s: expected cex order after update %d %v but got %v", test.name, i, (expectedCEXTrade != nil), (cex.lastTrade != nil))
   336  			}
   337  
   338  			if cex.lastTrade != nil &&
   339  				*cex.lastTrade != *expectedCEXTrade {
   340  				t.Fatalf("%s: cex order %+v != expected %+v", test.name, cex.lastTrade, expectedCEXTrade)
   341  			}
   342  		}
   343  	}
   344  
   345  	for _, test := range tests {
   346  		runTest(test)
   347  	}
   348  }
   349  
   350  func TestDEXPlacementRate(t *testing.T) {
   351  	type test struct {
   352  		name             string
   353  		counterTradeRate uint64
   354  		profit           float64
   355  		base             uint32
   356  		quote            uint32
   357  		fees             uint64
   358  		mkt              *market
   359  	}
   360  
   361  	tests := []*test{
   362  		{
   363  			name:             "dcr/btc",
   364  			counterTradeRate: 5e6,
   365  			profit:           0.03,
   366  			base:             42,
   367  			quote:            0,
   368  			fees:             4e5,
   369  			mkt: mustParseMarket(&core.Market{
   370  				BaseID:   42,
   371  				QuoteID:  0,
   372  				LotSize:  40e8,
   373  				RateStep: 1e2,
   374  			}),
   375  		},
   376  		{
   377  			name:             "btc/usdc.eth",
   378  			counterTradeRate: calc.MessageRateAlt(43000, 1e8, 1e6),
   379  			profit:           0.01,
   380  			base:             0,
   381  			quote:            60001,
   382  			fees:             5e5,
   383  			mkt: mustParseMarket(&core.Market{
   384  				BaseID:   0,
   385  				QuoteID:  60001,
   386  				LotSize:  5e6,
   387  				RateStep: 1e4,
   388  			}),
   389  		},
   390  		{
   391  			name:             "wbtc.polygon/usdc.eth",
   392  			counterTradeRate: calc.MessageRateAlt(43000, 1e8, 1e6),
   393  			profit:           0.02,
   394  			base:             966003,
   395  			quote:            60001,
   396  			fees:             3e5,
   397  			mkt: mustParseMarket(&core.Market{
   398  				BaseID:   966003,
   399  				QuoteID:  60001,
   400  				LotSize:  5e6,
   401  				RateStep: 1e4,
   402  			}),
   403  		},
   404  	}
   405  
   406  	runTest := func(tt *test) {
   407  		sellRate, err := dexPlacementRate(tt.counterTradeRate, true, tt.profit, tt.mkt, tt.fees, tLogger)
   408  		if err != nil {
   409  			t.Fatalf("%s: unexpected error: %v", tt.name, err)
   410  		}
   411  
   412  		expectedProfitableSellRate := uint64(float64(tt.counterTradeRate) * (1 + tt.profit))
   413  		additional := calc.BaseToQuote(sellRate, tt.mkt.lotSize) - calc.BaseToQuote(expectedProfitableSellRate, tt.mkt.lotSize)
   414  		if additional > tt.fees*101/100 || additional < tt.fees*99/100 {
   415  			t.Fatalf("%s: expected additional %d but got %d", tt.name, tt.fees, additional)
   416  		}
   417  
   418  		buyRate, err := dexPlacementRate(tt.counterTradeRate, false, tt.profit, tt.mkt, tt.fees, tLogger)
   419  		if err != nil {
   420  			t.Fatalf("%s: unexpected error: %v", tt.name, err)
   421  		}
   422  		expectedProfitableBuyRate := uint64(float64(tt.counterTradeRate) / (1 + tt.profit))
   423  		savings := calc.BaseToQuote(expectedProfitableBuyRate, tt.mkt.lotSize) - calc.BaseToQuote(buyRate, tt.mkt.lotSize)
   424  		if savings > tt.fees*101/100 || savings < tt.fees*99/100 {
   425  			t.Fatalf("%s: expected savings %d but got %d", tt.name, tt.fees, savings)
   426  		}
   427  	}
   428  
   429  	for _, test := range tests {
   430  		runTest(test)
   431  	}
   432  }
   433  
   434  func mustParseMarket(m *core.Market) *market {
   435  	mkt, err := parseMarket("host.com", m)
   436  	if err != nil {
   437  		panic(err.Error())
   438  	}
   439  	return mkt
   440  }
   441  
   442  func mustParseAdaptorFromMarket(m *core.Market) *unifiedExchangeAdaptor {
   443  	tCore := newTCore()
   444  	tCore.setWalletsAndExchange(m)
   445  
   446  	u := &unifiedExchangeAdaptor{
   447  		ctx:                context.Background(),
   448  		market:             mustParseMarket(m),
   449  		log:                tLogger,
   450  		botLooper:          botLooper(dummyLooper),
   451  		baseDexBalances:    make(map[uint32]int64),
   452  		baseCexBalances:    make(map[uint32]int64),
   453  		pendingDEXOrders:   make(map[order.OrderID]*pendingDEXOrder),
   454  		pendingCEXOrders:   make(map[string]*pendingCEXOrder),
   455  		eventLogDB:         newTEventLogDB(),
   456  		pendingDeposits:    make(map[string]*pendingDeposit),
   457  		pendingWithdrawals: make(map[string]*pendingWithdrawal),
   458  		clientCore:         tCore,
   459  		cexProblems:        newCEXProblems(),
   460  	}
   461  
   462  	u.botCfgV.Store(&BotConfig{
   463  		Host:    u.host,
   464  		BaseID:  u.baseID,
   465  		QuoteID: u.quoteID,
   466  	})
   467  
   468  	return u
   469  }
   470  
   471  func mustParseAdaptor(cfg *exchangeAdaptorCfg) *unifiedExchangeAdaptor {
   472  	if cfg.core.(*tCore).market == nil {
   473  		cfg.core.(*tCore).market = &core.Market{
   474  			BaseID:  cfg.mwh.BaseID,
   475  			QuoteID: cfg.mwh.QuoteID,
   476  			LotSize: 1e8,
   477  		}
   478  	}
   479  	cfg.log = tLogger
   480  	adaptor, err := newUnifiedExchangeAdaptor(cfg)
   481  	if err != nil {
   482  		panic(err.Error())
   483  	}
   484  	adaptor.ctx = context.Background()
   485  	adaptor.botLooper = botLooper(dummyLooper)
   486  	adaptor.botCfgV.Store(&BotConfig{})
   487  	return adaptor
   488  }
   489  
   490  func dummyLooper(ctx context.Context) (*sync.WaitGroup, error) {
   491  	var wg sync.WaitGroup
   492  	wg.Add(1)
   493  	go func() {
   494  		<-ctx.Done()
   495  		wg.Done()
   496  	}()
   497  	return &wg, nil
   498  }