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 }