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