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

     1  //go:build !harness && !botlive
     2  
     3  package mm
     4  
     5  import (
     6  	"math"
     7  	"testing"
     8  
     9  	"decred.org/dcrdex/client/core"
    10  	"decred.org/dcrdex/dex/calc"
    11  )
    12  
    13  type tBasicMMCalculator struct {
    14  	bp    uint64
    15  	bpErr error
    16  
    17  	hs uint64
    18  }
    19  
    20  var _ basicMMCalculator = (*tBasicMMCalculator)(nil)
    21  
    22  func (r *tBasicMMCalculator) basisPrice() (uint64, error) {
    23  	return r.bp, r.bpErr
    24  }
    25  func (r *tBasicMMCalculator) halfSpread(basisPrice uint64) (uint64, error) {
    26  	return r.hs, nil
    27  }
    28  
    29  func (r *tBasicMMCalculator) feeGapStats(basisPrice uint64) (*FeeGapStats, error) {
    30  	return &FeeGapStats{FeeGap: r.hs * 2}, nil
    31  }
    32  func TestBasisPrice(t *testing.T) {
    33  	mkt := &core.Market{
    34  		RateStep:   1,
    35  		BaseID:     42,
    36  		QuoteID:    0,
    37  		AtomToConv: 1,
    38  	}
    39  
    40  	tests := []*struct {
    41  		name        string
    42  		oraclePrice uint64
    43  		fiatRate    uint64
    44  		exp         uint64
    45  	}{
    46  		{
    47  			name:        "oracle price",
    48  			oraclePrice: 2000,
    49  			fiatRate:    1900,
    50  			exp:         2000,
    51  		},
    52  		{
    53  			name:        "failed sanity check",
    54  			oraclePrice: 2000,
    55  			fiatRate:    1850, // mismatch > 5%
    56  			exp:         0,
    57  		},
    58  		{
    59  			name:        "no oracle price",
    60  			oraclePrice: 0,
    61  			fiatRate:    1000,
    62  			exp:         1000,
    63  		},
    64  		{
    65  			name:        "no oracle price or fiat rate",
    66  			oraclePrice: 0,
    67  			fiatRate:    0,
    68  			exp:         0,
    69  		},
    70  	}
    71  
    72  	for _, tt := range tests {
    73  		oracle := &tOracle{
    74  			marketPrice: mkt.MsgRateToConventional(tt.oraclePrice),
    75  		}
    76  
    77  		tCore := newTCore()
    78  		adaptor := newTBotCoreAdaptor(tCore)
    79  		adaptor.fiatExchangeRate = tt.fiatRate
    80  
    81  		calculator := &basicMMCalculatorImpl{
    82  			market: mustParseMarket(mkt),
    83  			oracle: oracle,
    84  			cfg:    &BasicMarketMakingConfig{},
    85  			log:    tLogger,
    86  			core:   adaptor,
    87  		}
    88  
    89  		rate, _ := calculator.basisPrice()
    90  		if rate != tt.exp {
    91  			t.Fatalf("%s: %d != %d", tt.name, rate, tt.exp)
    92  		}
    93  	}
    94  }
    95  
    96  func TestBreakEvenHalfSpread(t *testing.T) {
    97  	tests := []*struct {
    98  		name                 string
    99  		basisPrice           uint64
   100  		mkt                  *core.Market
   101  		buyFeesInBaseUnits   uint64
   102  		sellFeesInBaseUnits  uint64
   103  		buyFeesInQuoteUnits  uint64
   104  		sellFeesInQuoteUnits uint64
   105  		singleLotFeesErr     error
   106  		expErr               bool
   107  	}{
   108  		{
   109  			name:   "basis price = 0 not allowed",
   110  			expErr: true,
   111  			mkt: &core.Market{
   112  				LotSize: 20e8,
   113  				BaseID:  42,
   114  				QuoteID: 0,
   115  			},
   116  		},
   117  		{
   118  			name:       "dcr/btc",
   119  			basisPrice: 5e7, // 0.4 BTC/DCR, quote lot = 8 BTC
   120  			mkt: &core.Market{
   121  				LotSize: 20e8,
   122  				BaseID:  42,
   123  				QuoteID: 0,
   124  			},
   125  			buyFeesInBaseUnits:   2.2e6,
   126  			sellFeesInBaseUnits:  2e6,
   127  			buyFeesInQuoteUnits:  calc.BaseToQuote(2.2e6, 5e7),
   128  			sellFeesInQuoteUnits: calc.BaseToQuote(2e6, 5e7),
   129  		},
   130  		{
   131  			name:       "btc/usdc.eth",
   132  			basisPrice: calc.MessageRateAlt(43000, 1e8, 1e6),
   133  			mkt: &core.Market{
   134  				BaseID:  0,
   135  				QuoteID: 60001,
   136  				LotSize: 1e7,
   137  			},
   138  			buyFeesInBaseUnits:   1e6,
   139  			sellFeesInBaseUnits:  2e6,
   140  			buyFeesInQuoteUnits:  calc.BaseToQuote(calc.MessageRateAlt(43000, 1e8, 1e6), 1e6),
   141  			sellFeesInQuoteUnits: calc.BaseToQuote(calc.MessageRateAlt(43000, 1e8, 1e6), 2e6),
   142  		},
   143  	}
   144  
   145  	for _, tt := range tests {
   146  		tCore := newTCore()
   147  		coreAdaptor := newTBotCoreAdaptor(tCore)
   148  		coreAdaptor.buyFeesInBase = tt.buyFeesInBaseUnits
   149  		coreAdaptor.sellFeesInBase = tt.sellFeesInBaseUnits
   150  		coreAdaptor.buyFeesInQuote = tt.buyFeesInQuoteUnits
   151  		coreAdaptor.sellFeesInQuote = tt.sellFeesInQuoteUnits
   152  
   153  		calculator := &basicMMCalculatorImpl{
   154  			market: mustParseMarket(tt.mkt),
   155  			core:   coreAdaptor,
   156  			log:    tLogger,
   157  		}
   158  
   159  		halfSpread, err := calculator.halfSpread(tt.basisPrice)
   160  		if (err != nil) != tt.expErr {
   161  			t.Fatalf("expErr = %t, err = %v", tt.expErr, err)
   162  		}
   163  		if tt.expErr {
   164  			continue
   165  		}
   166  
   167  		afterSell := calc.BaseToQuote(tt.basisPrice+halfSpread, tt.mkt.LotSize)
   168  		afterBuy := calc.QuoteToBase(tt.basisPrice-halfSpread, afterSell)
   169  		fees := afterBuy - tt.mkt.LotSize
   170  		expectedFees := tt.buyFeesInBaseUnits + tt.sellFeesInBaseUnits
   171  
   172  		if expectedFees > fees*10001/10000 || expectedFees < fees*9999/10000 {
   173  			t.Fatalf("%s: expected fees %d, got %d", tt.name, expectedFees, fees)
   174  		}
   175  
   176  	}
   177  }
   178  
   179  func TestUpdateLotSize(t *testing.T) {
   180  	tests := []struct {
   181  		name           string
   182  		placements     []*OrderPlacement
   183  		originalSize   uint64
   184  		newSize        uint64
   185  		wantPlacements []*OrderPlacement
   186  	}{
   187  		{
   188  			name: "simple halving",
   189  			placements: []*OrderPlacement{
   190  				{Lots: 2, GapFactor: 1.0},
   191  				{Lots: 4, GapFactor: 2.0},
   192  			},
   193  			originalSize: 100,
   194  			newSize:      200,
   195  			wantPlacements: []*OrderPlacement{
   196  				{Lots: 1, GapFactor: 1.0},
   197  				{Lots: 2, GapFactor: 2.0},
   198  			},
   199  		},
   200  		{
   201  			name: "rounding up",
   202  			placements: []*OrderPlacement{
   203  				{Lots: 3, GapFactor: 1.0},
   204  				{Lots: 1, GapFactor: 1.0},
   205  			},
   206  			originalSize: 100,
   207  			newSize:      160,
   208  			wantPlacements: []*OrderPlacement{
   209  				{Lots: 2, GapFactor: 1.0},
   210  			},
   211  		},
   212  		{
   213  			name: "minimum 1 lot",
   214  			placements: []*OrderPlacement{
   215  				{Lots: 1, GapFactor: 1.0},
   216  				{Lots: 1, GapFactor: 1.0},
   217  				{Lots: 1, GapFactor: 1.0},
   218  			},
   219  			originalSize: 100,
   220  			newSize:      250,
   221  			wantPlacements: []*OrderPlacement{
   222  				{Lots: 1, GapFactor: 1.0},
   223  			},
   224  		},
   225  	}
   226  
   227  	for _, tt := range tests {
   228  		t.Run(tt.name, func(t *testing.T) {
   229  			got := updateLotSize(tt.placements, tt.originalSize, tt.newSize)
   230  			if len(got) != len(tt.wantPlacements) {
   231  				t.Fatalf("got %d placements, want %d", len(got), len(tt.wantPlacements))
   232  			}
   233  			for i := range got {
   234  				if got[i].Lots != tt.wantPlacements[i].Lots {
   235  					t.Errorf("placement %d: got %d lots, want %d", i, got[i].Lots, tt.wantPlacements[i].Lots)
   236  				}
   237  				if got[i].GapFactor != tt.wantPlacements[i].GapFactor {
   238  					t.Errorf("placement %d: got %f gap factor, want %f", i, got[i].GapFactor, tt.wantPlacements[i].GapFactor)
   239  				}
   240  			}
   241  		})
   242  	}
   243  }
   244  
   245  func TestBasicMMRebalance(t *testing.T) {
   246  	const basisPrice uint64 = 5e6
   247  	const halfSpread uint64 = 2e5
   248  	const rateStep uint64 = 1e3
   249  	const atomToConv float64 = 1
   250  
   251  	calculator := &tBasicMMCalculator{
   252  		bp: basisPrice,
   253  		hs: halfSpread,
   254  	}
   255  
   256  	type test struct {
   257  		name              string
   258  		strategy          GapStrategy
   259  		cfgBuyPlacements  []*OrderPlacement
   260  		cfgSellPlacements []*OrderPlacement
   261  
   262  		expBuyPlacements  []*TradePlacement
   263  		expSellPlacements []*TradePlacement
   264  	}
   265  	tests := []*test{
   266  		{
   267  			name:     "multiplier",
   268  			strategy: GapStrategyMultiplier,
   269  			cfgBuyPlacements: []*OrderPlacement{
   270  				{Lots: 1, GapFactor: 3},
   271  				{Lots: 2, GapFactor: 2},
   272  				{Lots: 3, GapFactor: 1},
   273  			},
   274  			cfgSellPlacements: []*OrderPlacement{
   275  				{Lots: 3, GapFactor: 1},
   276  				{Lots: 2, GapFactor: 2},
   277  				{Lots: 1, GapFactor: 3},
   278  			},
   279  			expBuyPlacements: []*TradePlacement{
   280  				{Lots: 1, Rate: steppedRate(basisPrice-3*halfSpread, rateStep)},
   281  				{Lots: 2, Rate: steppedRate(basisPrice-2*halfSpread, rateStep)},
   282  				{Lots: 3, Rate: steppedRate(basisPrice-1*halfSpread, rateStep)},
   283  			},
   284  			expSellPlacements: []*TradePlacement{
   285  				{Lots: 3, Rate: steppedRate(basisPrice+1*halfSpread, rateStep)},
   286  				{Lots: 2, Rate: steppedRate(basisPrice+2*halfSpread, rateStep)},
   287  				{Lots: 1, Rate: steppedRate(basisPrice+3*halfSpread, rateStep)},
   288  			},
   289  		},
   290  		{
   291  			name:     "percent",
   292  			strategy: GapStrategyPercent,
   293  			cfgBuyPlacements: []*OrderPlacement{
   294  				{Lots: 1, GapFactor: 0.05},
   295  				{Lots: 2, GapFactor: 0.1},
   296  				{Lots: 3, GapFactor: 0.15},
   297  			},
   298  			cfgSellPlacements: []*OrderPlacement{
   299  				{Lots: 3, GapFactor: 0.15},
   300  				{Lots: 2, GapFactor: 0.1},
   301  				{Lots: 1, GapFactor: 0.05},
   302  			},
   303  			expBuyPlacements: []*TradePlacement{
   304  				{Lots: 1, Rate: steppedRate(basisPrice-uint64(math.Round((float64(basisPrice)*0.05))), rateStep)},
   305  				{Lots: 2, Rate: steppedRate(basisPrice-uint64(math.Round((float64(basisPrice)*0.1))), rateStep)},
   306  				{Lots: 3, Rate: steppedRate(basisPrice-uint64(math.Round((float64(basisPrice)*0.15))), rateStep)},
   307  			},
   308  			expSellPlacements: []*TradePlacement{
   309  				{Lots: 3, Rate: steppedRate(basisPrice+uint64(math.Round((float64(basisPrice)*0.15))), rateStep)},
   310  				{Lots: 2, Rate: steppedRate(basisPrice+uint64(math.Round((float64(basisPrice)*0.1))), rateStep)},
   311  				{Lots: 1, Rate: steppedRate(basisPrice+uint64(math.Round((float64(basisPrice)*0.05))), rateStep)},
   312  			},
   313  		},
   314  		{
   315  			name:     "percent-plus",
   316  			strategy: GapStrategyPercentPlus,
   317  			cfgBuyPlacements: []*OrderPlacement{
   318  				{Lots: 1, GapFactor: 0.05},
   319  				{Lots: 2, GapFactor: 0.1},
   320  				{Lots: 3, GapFactor: 0.15},
   321  			},
   322  			cfgSellPlacements: []*OrderPlacement{
   323  				{Lots: 3, GapFactor: 0.15},
   324  				{Lots: 2, GapFactor: 0.1},
   325  				{Lots: 1, GapFactor: 0.05},
   326  			},
   327  			expBuyPlacements: []*TradePlacement{
   328  				{Lots: 1, Rate: steppedRate(basisPrice-halfSpread-uint64(math.Round((float64(basisPrice)*0.05))), rateStep)},
   329  				{Lots: 2, Rate: steppedRate(basisPrice-halfSpread-uint64(math.Round((float64(basisPrice)*0.1))), rateStep)},
   330  				{Lots: 3, Rate: steppedRate(basisPrice-halfSpread-uint64(math.Round((float64(basisPrice)*0.15))), rateStep)},
   331  			},
   332  			expSellPlacements: []*TradePlacement{
   333  				{Lots: 3, Rate: steppedRate(basisPrice+halfSpread+uint64(math.Round((float64(basisPrice)*0.15))), rateStep)},
   334  				{Lots: 2, Rate: steppedRate(basisPrice+halfSpread+uint64(math.Round((float64(basisPrice)*0.1))), rateStep)},
   335  				{Lots: 1, Rate: steppedRate(basisPrice+halfSpread+uint64(math.Round((float64(basisPrice)*0.05))), rateStep)},
   336  			},
   337  		},
   338  		{
   339  			name:     "absolute",
   340  			strategy: GapStrategyAbsolute,
   341  			cfgBuyPlacements: []*OrderPlacement{
   342  				{Lots: 1, GapFactor: .01},
   343  				{Lots: 2, GapFactor: .03},
   344  				{Lots: 3, GapFactor: .06},
   345  			},
   346  			cfgSellPlacements: []*OrderPlacement{
   347  				{Lots: 3, GapFactor: .06},
   348  				{Lots: 2, GapFactor: .03},
   349  				{Lots: 1, GapFactor: .01},
   350  			},
   351  			expBuyPlacements: []*TradePlacement{
   352  				{Lots: 1, Rate: steppedRate(basisPrice-1e6, rateStep)},
   353  				{Lots: 2, Rate: steppedRate(basisPrice-3e6, rateStep)},
   354  			},
   355  			expSellPlacements: []*TradePlacement{
   356  				{Lots: 3, Rate: steppedRate(basisPrice+6e6, rateStep)},
   357  				{Lots: 2, Rate: steppedRate(basisPrice+3e6, rateStep)},
   358  				{Lots: 1, Rate: steppedRate(basisPrice+1e6, rateStep)},
   359  			},
   360  		},
   361  		{
   362  			name:     "absolute-plus",
   363  			strategy: GapStrategyAbsolutePlus,
   364  			cfgBuyPlacements: []*OrderPlacement{
   365  				{Lots: 1, GapFactor: .01},
   366  				{Lots: 2, GapFactor: .03},
   367  				{Lots: 3, GapFactor: .06},
   368  			},
   369  			cfgSellPlacements: []*OrderPlacement{
   370  				{Lots: 3, GapFactor: .06},
   371  				{Lots: 2, GapFactor: .03},
   372  				{Lots: 1, GapFactor: .01},
   373  			},
   374  			expBuyPlacements: []*TradePlacement{
   375  				{Lots: 1, Rate: steppedRate(basisPrice-halfSpread-1e6, rateStep)},
   376  				{Lots: 2, Rate: steppedRate(basisPrice-halfSpread-3e6, rateStep)},
   377  			},
   378  			expSellPlacements: []*TradePlacement{
   379  				{Lots: 3, Rate: steppedRate(basisPrice+halfSpread+6e6, rateStep)},
   380  				{Lots: 2, Rate: steppedRate(basisPrice+halfSpread+3e6, rateStep)},
   381  				{Lots: 1, Rate: steppedRate(basisPrice+halfSpread+1e6, rateStep)},
   382  			},
   383  		},
   384  	}
   385  
   386  	for _, tt := range tests {
   387  		t.Run(tt.name, func(t *testing.T) {
   388  			const lotSize = 5e9
   389  			const baseID, quoteID = 42, 0
   390  			mm := &basicMarketMaker{
   391  				unifiedExchangeAdaptor: mustParseAdaptorFromMarket(&core.Market{
   392  					RateStep:   rateStep,
   393  					AtomToConv: atomToConv,
   394  					LotSize:    lotSize,
   395  					BaseID:     baseID,
   396  					QuoteID:    quoteID,
   397  				}),
   398  				calculator: calculator,
   399  			}
   400  			tcore := newTCore()
   401  			tcore.setWalletsAndExchange(&core.Market{
   402  				BaseID:  baseID,
   403  				QuoteID: quoteID,
   404  			})
   405  			mm.clientCore = tcore
   406  			mm.botCfgV.Store(&BotConfig{})
   407  			mm.fiatRates.Store(map[uint32]float64{baseID: 1, quoteID: 1})
   408  			const sellSwapFees, sellRedeemFees = 3e6, 1e6
   409  			const buySwapFees, buyRedeemFees = 2e5, 1e5
   410  			mm.buyFees = &OrderFees{
   411  				LotFeeRange: &LotFeeRange{
   412  					Max: &LotFees{
   413  						Redeem: buyRedeemFees,
   414  						Swap:   buySwapFees,
   415  					},
   416  					Estimated: &LotFees{},
   417  				},
   418  				BookingFeesPerLot: buySwapFees,
   419  			}
   420  			mm.sellFees = &OrderFees{
   421  				LotFeeRange: &LotFeeRange{
   422  					Max: &LotFees{
   423  						Redeem: sellRedeemFees,
   424  						Swap:   sellSwapFees,
   425  					},
   426  					Estimated: &LotFees{},
   427  				},
   428  				BookingFeesPerLot: sellSwapFees,
   429  			}
   430  			mm.baseDexBalances[baseID] = lotSize * 50
   431  			mm.baseCexBalances[baseID] = lotSize * 50
   432  			mm.baseDexBalances[quoteID] = int64(calc.BaseToQuote(basisPrice, lotSize*50))
   433  			mm.baseCexBalances[quoteID] = int64(calc.BaseToQuote(basisPrice, lotSize*50))
   434  			mm.unifiedExchangeAdaptor.botCfgV.Store(&BotConfig{
   435  				BasicMMConfig: &BasicMarketMakingConfig{
   436  					GapStrategy:    tt.strategy,
   437  					BuyPlacements:  tt.cfgBuyPlacements,
   438  					SellPlacements: tt.cfgSellPlacements,
   439  				}})
   440  
   441  			mm.rebalance(100)
   442  
   443  			if len(tcore.multiTradesPlaced) != 2 {
   444  				t.Fatal("expected both buy and sell orders placed")
   445  			}
   446  			buys, sells := tcore.multiTradesPlaced[0], tcore.multiTradesPlaced[1]
   447  
   448  			expOrdersN := len(tt.expBuyPlacements) + len(tt.expSellPlacements)
   449  			if len(buys.Placements)+len(sells.Placements) != expOrdersN {
   450  				t.Fatalf("expected %d orders, got %d", expOrdersN, len(buys.Placements)+len(sells.Placements))
   451  			}
   452  
   453  			buyRateLots := make(map[uint64]uint64, len(buys.Placements))
   454  			for _, p := range buys.Placements {
   455  				buyRateLots[p.Rate] = p.Qty / lotSize
   456  			}
   457  			for _, expBuy := range tt.expBuyPlacements {
   458  				if lots, found := buyRateLots[expBuy.Rate]; !found {
   459  					t.Fatalf("buy rate %d not found", expBuy.Rate)
   460  				} else {
   461  					if expBuy.Lots != lots {
   462  						t.Fatalf("wrong lots %d for buy at rate %d", lots, expBuy.Rate)
   463  					}
   464  				}
   465  			}
   466  			sellRateLots := make(map[uint64]uint64, len(sells.Placements))
   467  			for _, p := range sells.Placements {
   468  				sellRateLots[p.Rate] = p.Qty / lotSize
   469  			}
   470  			for _, expSell := range tt.expSellPlacements {
   471  				if lots, found := sellRateLots[expSell.Rate]; !found {
   472  					t.Fatalf("sell rate %d not found", expSell.Rate)
   473  				} else {
   474  					if expSell.Lots != lots {
   475  						t.Fatalf("wrong lots %d for sell at rate %d", lots, expSell.Rate)
   476  					}
   477  				}
   478  			}
   479  		})
   480  	}
   481  }