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 }