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 }