decred.org/dcrdex@v1.0.3/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 TestBasicMMRebalance(t *testing.T) { 180 const basisPrice uint64 = 5e6 181 const halfSpread uint64 = 2e5 182 const rateStep uint64 = 1e3 183 const atomToConv float64 = 1 184 185 calculator := &tBasicMMCalculator{ 186 bp: basisPrice, 187 hs: halfSpread, 188 } 189 190 type test struct { 191 name string 192 strategy GapStrategy 193 cfgBuyPlacements []*OrderPlacement 194 cfgSellPlacements []*OrderPlacement 195 196 expBuyPlacements []*TradePlacement 197 expSellPlacements []*TradePlacement 198 } 199 tests := []*test{ 200 { 201 name: "multiplier", 202 strategy: GapStrategyMultiplier, 203 cfgBuyPlacements: []*OrderPlacement{ 204 {Lots: 1, GapFactor: 3}, 205 {Lots: 2, GapFactor: 2}, 206 {Lots: 3, GapFactor: 1}, 207 }, 208 cfgSellPlacements: []*OrderPlacement{ 209 {Lots: 3, GapFactor: 1}, 210 {Lots: 2, GapFactor: 2}, 211 {Lots: 1, GapFactor: 3}, 212 }, 213 expBuyPlacements: []*TradePlacement{ 214 {Lots: 1, Rate: steppedRate(basisPrice-3*halfSpread, rateStep)}, 215 {Lots: 2, Rate: steppedRate(basisPrice-2*halfSpread, rateStep)}, 216 {Lots: 3, Rate: steppedRate(basisPrice-1*halfSpread, rateStep)}, 217 }, 218 expSellPlacements: []*TradePlacement{ 219 {Lots: 3, Rate: steppedRate(basisPrice+1*halfSpread, rateStep)}, 220 {Lots: 2, Rate: steppedRate(basisPrice+2*halfSpread, rateStep)}, 221 {Lots: 1, Rate: steppedRate(basisPrice+3*halfSpread, rateStep)}, 222 }, 223 }, 224 { 225 name: "percent", 226 strategy: GapStrategyPercent, 227 cfgBuyPlacements: []*OrderPlacement{ 228 {Lots: 1, GapFactor: 0.05}, 229 {Lots: 2, GapFactor: 0.1}, 230 {Lots: 3, GapFactor: 0.15}, 231 }, 232 cfgSellPlacements: []*OrderPlacement{ 233 {Lots: 3, GapFactor: 0.15}, 234 {Lots: 2, GapFactor: 0.1}, 235 {Lots: 1, GapFactor: 0.05}, 236 }, 237 expBuyPlacements: []*TradePlacement{ 238 {Lots: 1, Rate: steppedRate(basisPrice-uint64(math.Round((float64(basisPrice)*0.05))), rateStep)}, 239 {Lots: 2, Rate: steppedRate(basisPrice-uint64(math.Round((float64(basisPrice)*0.1))), rateStep)}, 240 {Lots: 3, Rate: steppedRate(basisPrice-uint64(math.Round((float64(basisPrice)*0.15))), rateStep)}, 241 }, 242 expSellPlacements: []*TradePlacement{ 243 {Lots: 3, Rate: steppedRate(basisPrice+uint64(math.Round((float64(basisPrice)*0.15))), rateStep)}, 244 {Lots: 2, Rate: steppedRate(basisPrice+uint64(math.Round((float64(basisPrice)*0.1))), rateStep)}, 245 {Lots: 1, Rate: steppedRate(basisPrice+uint64(math.Round((float64(basisPrice)*0.05))), rateStep)}, 246 }, 247 }, 248 { 249 name: "percent-plus", 250 strategy: GapStrategyPercentPlus, 251 cfgBuyPlacements: []*OrderPlacement{ 252 {Lots: 1, GapFactor: 0.05}, 253 {Lots: 2, GapFactor: 0.1}, 254 {Lots: 3, GapFactor: 0.15}, 255 }, 256 cfgSellPlacements: []*OrderPlacement{ 257 {Lots: 3, GapFactor: 0.15}, 258 {Lots: 2, GapFactor: 0.1}, 259 {Lots: 1, GapFactor: 0.05}, 260 }, 261 expBuyPlacements: []*TradePlacement{ 262 {Lots: 1, Rate: steppedRate(basisPrice-halfSpread-uint64(math.Round((float64(basisPrice)*0.05))), rateStep)}, 263 {Lots: 2, Rate: steppedRate(basisPrice-halfSpread-uint64(math.Round((float64(basisPrice)*0.1))), rateStep)}, 264 {Lots: 3, Rate: steppedRate(basisPrice-halfSpread-uint64(math.Round((float64(basisPrice)*0.15))), rateStep)}, 265 }, 266 expSellPlacements: []*TradePlacement{ 267 {Lots: 3, Rate: steppedRate(basisPrice+halfSpread+uint64(math.Round((float64(basisPrice)*0.15))), rateStep)}, 268 {Lots: 2, Rate: steppedRate(basisPrice+halfSpread+uint64(math.Round((float64(basisPrice)*0.1))), rateStep)}, 269 {Lots: 1, Rate: steppedRate(basisPrice+halfSpread+uint64(math.Round((float64(basisPrice)*0.05))), rateStep)}, 270 }, 271 }, 272 { 273 name: "absolute", 274 strategy: GapStrategyAbsolute, 275 cfgBuyPlacements: []*OrderPlacement{ 276 {Lots: 1, GapFactor: .01}, 277 {Lots: 2, GapFactor: .03}, 278 {Lots: 3, GapFactor: .06}, 279 }, 280 cfgSellPlacements: []*OrderPlacement{ 281 {Lots: 3, GapFactor: .06}, 282 {Lots: 2, GapFactor: .03}, 283 {Lots: 1, GapFactor: .01}, 284 }, 285 expBuyPlacements: []*TradePlacement{ 286 {Lots: 1, Rate: steppedRate(basisPrice-1e6, rateStep)}, 287 {Lots: 2, Rate: steppedRate(basisPrice-3e6, rateStep)}, 288 }, 289 expSellPlacements: []*TradePlacement{ 290 {Lots: 3, Rate: steppedRate(basisPrice+6e6, rateStep)}, 291 {Lots: 2, Rate: steppedRate(basisPrice+3e6, rateStep)}, 292 {Lots: 1, Rate: steppedRate(basisPrice+1e6, rateStep)}, 293 }, 294 }, 295 { 296 name: "absolute-plus", 297 strategy: GapStrategyAbsolutePlus, 298 cfgBuyPlacements: []*OrderPlacement{ 299 {Lots: 1, GapFactor: .01}, 300 {Lots: 2, GapFactor: .03}, 301 {Lots: 3, GapFactor: .06}, 302 }, 303 cfgSellPlacements: []*OrderPlacement{ 304 {Lots: 3, GapFactor: .06}, 305 {Lots: 2, GapFactor: .03}, 306 {Lots: 1, GapFactor: .01}, 307 }, 308 expBuyPlacements: []*TradePlacement{ 309 {Lots: 1, Rate: steppedRate(basisPrice-halfSpread-1e6, rateStep)}, 310 {Lots: 2, Rate: steppedRate(basisPrice-halfSpread-3e6, rateStep)}, 311 }, 312 expSellPlacements: []*TradePlacement{ 313 {Lots: 3, Rate: steppedRate(basisPrice+halfSpread+6e6, rateStep)}, 314 {Lots: 2, Rate: steppedRate(basisPrice+halfSpread+3e6, rateStep)}, 315 {Lots: 1, Rate: steppedRate(basisPrice+halfSpread+1e6, rateStep)}, 316 }, 317 }, 318 } 319 320 for _, tt := range tests { 321 t.Run(tt.name, func(t *testing.T) { 322 const lotSize = 5e9 323 const baseID, quoteID = 42, 0 324 mm := &basicMarketMaker{ 325 unifiedExchangeAdaptor: mustParseAdaptorFromMarket(&core.Market{ 326 RateStep: rateStep, 327 AtomToConv: atomToConv, 328 LotSize: lotSize, 329 BaseID: baseID, 330 QuoteID: quoteID, 331 }), 332 calculator: calculator, 333 } 334 tcore := newTCore() 335 tcore.setWalletsAndExchange(&core.Market{ 336 BaseID: baseID, 337 QuoteID: quoteID, 338 }) 339 mm.clientCore = tcore 340 mm.botCfgV.Store(&BotConfig{}) 341 mm.fiatRates.Store(map[uint32]float64{baseID: 1, quoteID: 1}) 342 const sellSwapFees, sellRedeemFees = 3e6, 1e6 343 const buySwapFees, buyRedeemFees = 2e5, 1e5 344 mm.buyFees = &OrderFees{ 345 LotFeeRange: &LotFeeRange{ 346 Max: &LotFees{ 347 Redeem: buyRedeemFees, 348 Swap: buySwapFees, 349 }, 350 Estimated: &LotFees{}, 351 }, 352 BookingFeesPerLot: buySwapFees, 353 } 354 mm.sellFees = &OrderFees{ 355 LotFeeRange: &LotFeeRange{ 356 Max: &LotFees{ 357 Redeem: sellRedeemFees, 358 Swap: sellSwapFees, 359 }, 360 Estimated: &LotFees{}, 361 }, 362 BookingFeesPerLot: sellSwapFees, 363 } 364 mm.baseDexBalances[baseID] = lotSize * 50 365 mm.baseCexBalances[baseID] = lotSize * 50 366 mm.baseDexBalances[quoteID] = int64(calc.BaseToQuote(basisPrice, lotSize*50)) 367 mm.baseCexBalances[quoteID] = int64(calc.BaseToQuote(basisPrice, lotSize*50)) 368 mm.cfgV.Store(&BasicMarketMakingConfig{ 369 GapStrategy: tt.strategy, 370 BuyPlacements: tt.cfgBuyPlacements, 371 SellPlacements: tt.cfgSellPlacements, 372 }) 373 mm.rebalance(100) 374 375 if len(tcore.multiTradesPlaced) != 2 { 376 t.Fatal("expected both buy and sell orders placed") 377 } 378 buys, sells := tcore.multiTradesPlaced[0], tcore.multiTradesPlaced[1] 379 380 expOrdersN := len(tt.expBuyPlacements) + len(tt.expSellPlacements) 381 if len(buys.Placements)+len(sells.Placements) != expOrdersN { 382 t.Fatalf("expected %d orders, got %d", expOrdersN, len(buys.Placements)+len(sells.Placements)) 383 } 384 385 buyRateLots := make(map[uint64]uint64, len(buys.Placements)) 386 for _, p := range buys.Placements { 387 buyRateLots[p.Rate] = p.Qty / lotSize 388 } 389 for _, expBuy := range tt.expBuyPlacements { 390 if lots, found := buyRateLots[expBuy.Rate]; !found { 391 t.Fatalf("buy rate %d not found", expBuy.Rate) 392 } else { 393 if expBuy.Lots != lots { 394 t.Fatalf("wrong lots %d for buy at rate %d", lots, expBuy.Rate) 395 } 396 } 397 } 398 sellRateLots := make(map[uint64]uint64, len(sells.Placements)) 399 for _, p := range sells.Placements { 400 sellRateLots[p.Rate] = p.Qty / lotSize 401 } 402 for _, expSell := range tt.expSellPlacements { 403 if lots, found := sellRateLots[expSell.Rate]; !found { 404 t.Fatalf("sell rate %d not found", expSell.Rate) 405 } else { 406 if expSell.Lots != lots { 407 t.Fatalf("wrong lots %d for sell at rate %d", lots, expSell.Rate) 408 } 409 } 410 } 411 }) 412 } 413 }