decred.org/dcrdex@v1.0.5/client/mm/mm_arb_market_maker.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 "errors" 9 "fmt" 10 "math" 11 "sync" 12 "sync/atomic" 13 14 "decred.org/dcrdex/client/core" 15 "decred.org/dcrdex/client/mm/libxc" 16 "decred.org/dcrdex/client/orderbook" 17 "decred.org/dcrdex/dex" 18 "decred.org/dcrdex/dex/calc" 19 "decred.org/dcrdex/dex/order" 20 "decred.org/dcrdex/dex/utils" 21 ) 22 23 // ArbMarketMakingPlacement is the configuration for an order placement 24 // on the DEX order book based on the existing orders on a CEX order book. 25 type ArbMarketMakingPlacement struct { 26 Lots uint64 `json:"lots"` 27 Multiplier float64 `json:"multiplier"` 28 } 29 30 // ArbMarketMakerConfig is the configuration for a market maker that places 31 // orders on both sides of the DEX order book, at rates where there are 32 // profitable counter trades on a CEX order book. Whenever a DEX order is 33 // filled, the opposite trade will immediately be made on the CEX. 34 // 35 // Each placement in BuyPlacements and SellPlacements represents an order 36 // that will be made on the DEX order book. The first placement will be 37 // placed at a rate closest to the CEX mid-gap, and each subsequent one 38 // will get farther. 39 // 40 // The bot calculates the extrema rate on the CEX order book where it can 41 // buy or sell the quantity of lots specified in the placement multiplied 42 // by the multiplier amount. This will be the rate of the expected counter 43 // trade. The bot will then place an order on the DEX order book where if 44 // both trades are filled, the bot will earn the profit specified in the 45 // configuration. 46 // 47 // The multiplier is important because it ensures that even if some of the 48 // trades closest to the mid-gap on the CEX order book are filled before 49 // the bot's orders on the DEX are matched, the bot will still be able to 50 // earn the expected profit. 51 // 52 // Consider the following example: 53 // 54 // Market: 55 // DCR/BTC, lot size = 10 DCR. 56 // 57 // Sell Placements: 58 // 1. { Lots: 1, Multiplier: 1.5 } 59 // 2. { Lots 1, Multiplier: 1.0 } 60 // 61 // Profit: 62 // 0.01 (1%) 63 // 64 // CEX Asks: 65 // 1. 10 DCR @ .005 BTC/DCR 66 // 2. 10 DCR @ .006 BTC/DCR 67 // 3. 10 DCR @ .007 BTC/DCR 68 // 69 // For the first placement, the bot will find the rate at which it can 70 // buy 15 DCR (1 lot * 1.5 multiplier). This rate is .006 BTC/DCR. Therefore, 71 // it will place place a sell order at .00606 BTC/DCR (.006 BTC/DCR * 1.01). 72 // 73 // For the second placement, the bot will go deeper into the CEX order book 74 // and find the rate at which it can buy 25 DCR. This is the previous 15 DCR 75 // used for the first placement plus the Quantity * Multiplier of the second 76 // placement. This rate is .007 BTC/DCR. Therefore it will place a sell order 77 // at .00707 BTC/DCR (.007 BTC/DCR * 1.01). 78 type ArbMarketMakerConfig struct { 79 BuyPlacements []*ArbMarketMakingPlacement `json:"buyPlacements"` 80 SellPlacements []*ArbMarketMakingPlacement `json:"sellPlacements"` 81 Profit float64 `json:"profit"` 82 DriftTolerance float64 `json:"driftTolerance"` 83 NumEpochsLeaveOpen uint64 `json:"orderPersistence"` 84 } 85 86 func (a *ArbMarketMakerConfig) copy() *ArbMarketMakerConfig { 87 c := *a 88 89 copyArbMarketMakingPlacement := func(p *ArbMarketMakingPlacement) *ArbMarketMakingPlacement { 90 return &ArbMarketMakingPlacement{ 91 Lots: p.Lots, 92 Multiplier: p.Multiplier, 93 } 94 } 95 c.BuyPlacements = utils.Map(a.BuyPlacements, copyArbMarketMakingPlacement) 96 c.SellPlacements = utils.Map(a.SellPlacements, copyArbMarketMakingPlacement) 97 98 return &c 99 } 100 101 func (a *ArbMarketMakerConfig) validate() error { 102 if len(a.BuyPlacements) == 0 && len(a.SellPlacements) == 0 { 103 return fmt.Errorf("no placements") 104 } 105 106 if a.Profit <= 0 { 107 return fmt.Errorf("profit must be greater than 0") 108 } 109 110 if a.DriftTolerance < 0 || a.DriftTolerance > 0.01 { 111 return fmt.Errorf("drift tolerance %f out of bounds", a.DriftTolerance) 112 } 113 114 if a.NumEpochsLeaveOpen < 2 { 115 return fmt.Errorf("arbs must be left open for at least 2 epochs") 116 } 117 118 return nil 119 } 120 121 // updateLotSize modifies the number of lots in each placement in the event 122 // of a lot size change. It will place as many lots as possible without 123 // exceeding the total quantity placed using the original lot size. 124 // 125 // This function is NOT thread safe. 126 func (c *ArbMarketMakerConfig) updateLotSize(originalLotSize, newLotSize uint64) { 127 b2a := func(p *OrderPlacement) *ArbMarketMakingPlacement { 128 return &ArbMarketMakingPlacement{ 129 Lots: p.Lots, 130 Multiplier: p.GapFactor, 131 } 132 } 133 a2b := func(p *ArbMarketMakingPlacement) *OrderPlacement { 134 return &OrderPlacement{ 135 Lots: p.Lots, 136 GapFactor: p.Multiplier, 137 } 138 } 139 update := func(placements []*ArbMarketMakingPlacement) []*ArbMarketMakingPlacement { 140 return utils.Map(updateLotSize(utils.Map(placements, a2b), originalLotSize, newLotSize), b2a) 141 } 142 c.SellPlacements = update(c.SellPlacements) 143 c.BuyPlacements = update(c.BuyPlacements) 144 } 145 146 func (a *ArbMarketMakerConfig) placementLots() *placementLots { 147 var baseLots, quoteLots uint64 148 for _, p := range a.BuyPlacements { 149 quoteLots += p.Lots 150 } 151 for _, p := range a.SellPlacements { 152 baseLots += p.Lots 153 } 154 return &placementLots{ 155 baseLots: baseLots, 156 quoteLots: quoteLots, 157 } 158 } 159 160 type placementLots struct { 161 baseLots uint64 162 quoteLots uint64 163 } 164 165 type arbMarketMaker struct { 166 *unifiedExchangeAdaptor 167 cex botCexAdaptor 168 core botCoreAdaptor 169 book dexOrderBook 170 rebalanceRunning atomic.Bool 171 currEpoch atomic.Uint64 172 173 matchesMtx sync.Mutex 174 matchesSeen map[order.MatchID]bool 175 pendingOrders map[order.OrderID]uint64 // orderID -> rate for counter trade on cex 176 177 cexTradesMtx sync.RWMutex 178 cexTrades map[string]uint64 179 } 180 181 var _ bot = (*arbMarketMaker)(nil) 182 183 func (a *arbMarketMaker) cfg() *ArbMarketMakerConfig { 184 return a.botCfg().ArbMarketMakerConfig 185 } 186 187 func (a *arbMarketMaker) handleCEXTradeUpdate(update *libxc.Trade) { 188 if update.Complete { 189 a.cexTradesMtx.Lock() 190 delete(a.cexTrades, update.ID) 191 a.cexTradesMtx.Unlock() 192 return 193 } 194 } 195 196 // tradeOnCEX executes a trade on the CEX. 197 func (a *arbMarketMaker) tradeOnCEX(rate, qty uint64, sell bool) { 198 a.cexTradesMtx.Lock() 199 defer a.cexTradesMtx.Unlock() 200 201 cexTrade, err := a.cex.CEXTrade(a.ctx, a.baseID, a.quoteID, sell, rate, qty) 202 if err != nil { 203 a.log.Errorf("Error sending trade to CEX: %v", err) 204 return 205 } 206 207 // Keep track of the epoch in which the trade was sent to the CEX. This way 208 // the bot can cancel the trade if it is not filled after a certain number 209 // of epochs. 210 a.cexTrades[cexTrade.ID] = a.currEpoch.Load() 211 } 212 213 func (a *arbMarketMaker) processDEXOrderUpdate(o *core.Order) { 214 var orderID order.OrderID 215 copy(orderID[:], o.ID) 216 217 a.matchesMtx.Lock() 218 defer a.matchesMtx.Unlock() 219 220 cexRate, found := a.pendingOrders[orderID] 221 if !found { 222 return 223 } 224 225 for _, match := range o.Matches { 226 var matchID order.MatchID 227 copy(matchID[:], match.MatchID) 228 229 if !a.matchesSeen[matchID] { 230 a.matchesSeen[matchID] = true 231 a.tradeOnCEX(cexRate, match.Qty, !o.Sell) 232 } 233 } 234 235 if !o.Status.IsActive() { 236 delete(a.pendingOrders, orderID) 237 for _, match := range o.Matches { 238 var matchID order.MatchID 239 copy(matchID[:], match.MatchID) 240 delete(a.matchesSeen, matchID) 241 } 242 } 243 } 244 245 // cancelExpiredCEXTrades cancels any trades on the CEX that have been open for 246 // more than the number of epochs specified in the config. 247 func (a *arbMarketMaker) cancelExpiredCEXTrades() { 248 currEpoch := a.currEpoch.Load() 249 250 a.cexTradesMtx.RLock() 251 defer a.cexTradesMtx.RUnlock() 252 253 for tradeID, epoch := range a.cexTrades { 254 if currEpoch-epoch >= a.cfg().NumEpochsLeaveOpen { 255 err := a.cex.CancelTrade(a.ctx, a.baseID, a.quoteID, tradeID) 256 if err != nil { 257 a.log.Errorf("Error canceling CEX trade %s: %v", tradeID, err) 258 } 259 260 a.log.Infof("Cex trade %s was cancelled before it was filled", tradeID) 261 } 262 } 263 } 264 265 // dexPlacementRate calculates the rate at which an order should be placed on 266 // the DEX order book based on the rate of the counter trade on the CEX. The 267 // rate is calculated so that the difference in rates between the DEX and the 268 // CEX will pay for the network fees and still leave the configured profit. 269 func dexPlacementRate(cexRate uint64, sell bool, profitRate float64, mkt *market, feesInQuoteUnits uint64, log dex.Logger) (uint64, error) { 270 var unadjustedRate uint64 271 if sell { 272 unadjustedRate = uint64(math.Round(float64(cexRate) * (1 + profitRate))) 273 } else { 274 unadjustedRate = uint64(math.Round(float64(cexRate) / (1 + profitRate))) 275 } 276 277 lotSize, rateStep := mkt.lotSize.Load(), mkt.rateStep.Load() 278 rateAdj := rateAdjustment(feesInQuoteUnits, lotSize) 279 280 if log.Level() <= dex.LevelTrace { 281 log.Tracef("%s %s placement rate: cexRate = %s, profitRate = %.3f, unadjustedRate = %s, rateAdj = %s, fees = %s", 282 mkt.name, sellStr(sell), mkt.fmtRate(cexRate), profitRate, mkt.fmtRate(unadjustedRate), mkt.fmtRate(rateAdj), mkt.fmtQuoteFees(feesInQuoteUnits), 283 ) 284 } 285 286 if sell { 287 return steppedRate(unadjustedRate+rateAdj, rateStep), nil 288 } 289 290 if rateAdj > unadjustedRate { 291 return 0, fmt.Errorf("rate adjustment required for fees %d > rate %d", rateAdj, unadjustedRate) 292 } 293 294 return steppedRate(unadjustedRate-rateAdj, rateStep), nil 295 } 296 297 func rateAdjustment(feesInQuoteUnits, lotSize uint64) uint64 { 298 return uint64(math.Round(float64(feesInQuoteUnits) / float64(lotSize) * calc.RateEncodingFactor)) 299 } 300 301 // dexPlacementRate calculates the rate at which an order should be placed on 302 // the DEX order book based on the rate of the counter trade on the CEX. The 303 // logic is in the dexPlacementRate function, so that it can be separately 304 // tested. 305 func (a *arbMarketMaker) dexPlacementRate(cexRate uint64, sell bool) (uint64, error) { 306 feesInQuoteUnits, err := a.OrderFeesInUnits(sell, false, cexRate) 307 if err != nil { 308 return 0, fmt.Errorf("error getting fees in quote units: %w", err) 309 } 310 return dexPlacementRate(cexRate, sell, a.cfg().Profit, a.market, feesInQuoteUnits, a.log) 311 } 312 313 type arbMMPlacementReason struct { 314 Depth uint64 `json:"depth"` 315 CEXTooShallow bool `json:"cexFilled"` 316 } 317 318 func (a *arbMarketMaker) ordersToPlace() (buys, sells []*TradePlacement, err error) { 319 orders := func(cfgPlacements []*ArbMarketMakingPlacement, sellOnDEX bool) ([]*TradePlacement, error) { 320 newPlacements := make([]*TradePlacement, 0, len(cfgPlacements)) 321 var cumulativeCEXDepth uint64 322 for i, cfgPlacement := range cfgPlacements { 323 cumulativeCEXDepth += uint64(float64(cfgPlacement.Lots*a.lotSize.Load()) * cfgPlacement.Multiplier) 324 _, extrema, filled, err := a.CEX.VWAP(a.baseID, a.quoteID, sellOnDEX, cumulativeCEXDepth) 325 if err != nil { 326 return nil, fmt.Errorf("error getting CEX VWAP: %w", err) 327 } 328 329 if a.log.Level() == dex.LevelTrace { 330 a.log.Tracef("%s placement orders: %s placement # %d, lots = %d, extrema = %s, filled = %t", 331 a.name, sellStr(sellOnDEX), i, cfgPlacement.Lots, a.fmtRate(extrema), filled, 332 ) 333 } 334 335 if !filled { 336 a.log.Infof("CEX %s side has < %s on the orderbook.", sellStr(!sellOnDEX), a.fmtBase(cumulativeCEXDepth)) 337 newPlacements = append(newPlacements, &TradePlacement{}) 338 continue 339 } 340 341 placementRate, err := a.dexPlacementRate(extrema, sellOnDEX) 342 if err != nil { 343 return nil, fmt.Errorf("error calculating DEX placement rate: %w", err) 344 } 345 346 newPlacements = append(newPlacements, &TradePlacement{ 347 Rate: placementRate, 348 Lots: cfgPlacement.Lots, 349 CounterTradeRate: extrema, 350 }) 351 } 352 353 return newPlacements, nil 354 } 355 356 buys, err = orders(a.cfg().BuyPlacements, false) 357 if err != nil { 358 return 359 } 360 361 sells, err = orders(a.cfg().SellPlacements, true) 362 return 363 } 364 365 // distribution parses the current inventory distribution and checks if better 366 // distributions are possible via deposit or withdrawal. 367 func (a *arbMarketMaker) distribution() (dist *distribution, err error) { 368 placements := a.cfg().placementLots() 369 if placements.baseLots == 0 && placements.quoteLots == 0 { 370 return nil, errors.New("zero placement lots?") 371 } 372 dexSellLots, dexBuyLots := placements.baseLots, placements.quoteLots 373 dexBuyRate, dexSellRate, err := a.cexCounterRates(dexSellLots, dexBuyLots) 374 if err != nil { 375 return nil, fmt.Errorf("error getting cex counter-rates: %w", err) 376 } 377 adjustedBuy, err := a.dexPlacementRate(dexBuyRate, false) 378 if err != nil { 379 return nil, fmt.Errorf("error getting adjusted buy rate: %v", err) 380 } 381 adjustedSell, err := a.dexPlacementRate(dexSellRate, true) 382 if err != nil { 383 return nil, fmt.Errorf("error getting adjusted sell rate: %v", err) 384 } 385 386 perLot, err := a.lotCosts(adjustedBuy, adjustedSell) 387 if perLot == nil { 388 return nil, fmt.Errorf("error getting lot costs: %w", err) 389 } 390 dist = a.newDistribution(perLot) 391 a.optimizeTransfers(dist, dexSellLots, dexBuyLots, dexSellLots, dexBuyLots) 392 return dist, nil 393 } 394 395 // rebalance is called on each new epoch. It will calculate the rates orders 396 // need to be placed on the DEX orderbook based on the CEX orderbook, and 397 // potentially update the orders on the DEX orderbook. It will also process 398 // and potentially needed withdrawals and deposits, and finally cancel any 399 // trades on the CEX that have been open for more than the number of epochs 400 // specified in the config. 401 func (a *arbMarketMaker) rebalance(epoch uint64, book *orderbook.OrderBook) { 402 if !a.rebalanceRunning.CompareAndSwap(false, true) { 403 return 404 } 405 defer a.rebalanceRunning.Store(false) 406 a.log.Tracef("rebalance: epoch %d", epoch) 407 408 currEpoch := a.currEpoch.Load() 409 if epoch <= currEpoch { 410 return 411 } 412 a.currEpoch.Store(epoch) 413 414 if !a.checkBotHealth(epoch) { 415 a.tryCancelOrders(a.ctx, &epoch, false) 416 return 417 } 418 419 actionTaken, err := a.tryTransfers(currEpoch) 420 if err != nil { 421 a.log.Errorf("Error performing transfers: %v", err) 422 } else if actionTaken { 423 return 424 } 425 426 var buysReport, sellsReport *OrderReport 427 buyOrders, sellOrders, determinePlacementsErr := a.ordersToPlace() 428 if determinePlacementsErr != nil { 429 a.tryCancelOrders(a.ctx, &epoch, false) 430 } else { 431 var buys, sells map[order.OrderID]*dexOrderInfo 432 buys, buysReport = a.multiTrade(buyOrders, false, a.cfg().DriftTolerance, currEpoch) 433 for id, ord := range buys { 434 a.matchesMtx.Lock() 435 a.pendingOrders[id] = ord.counterTradeRate 436 a.matchesMtx.Unlock() 437 } 438 439 sells, sellsReport = a.multiTrade(sellOrders, true, a.cfg().DriftTolerance, currEpoch) 440 for id, ord := range sells { 441 a.matchesMtx.Lock() 442 a.pendingOrders[id] = ord.counterTradeRate 443 a.matchesMtx.Unlock() 444 } 445 } 446 447 epochReport := &EpochReport{ 448 BuysReport: buysReport, 449 SellsReport: sellsReport, 450 EpochNum: epoch, 451 } 452 epochReport.setPreOrderProblems(determinePlacementsErr) 453 a.updateEpochReport(epochReport) 454 455 a.cancelExpiredCEXTrades() 456 a.registerFeeGap() 457 } 458 459 func (a *arbMarketMaker) tryTransfers(currEpoch uint64) (actionTaken bool, err error) { 460 dist, err := a.distribution() 461 if err != nil { 462 a.log.Errorf("distribution calculation error: %v", err) 463 return 464 } 465 return a.transfer(dist, currEpoch) 466 } 467 468 func feeGap(core botCoreAdaptor, cex libxc.CEX, baseID, quoteID uint32, lotSize uint64) (*FeeGapStats, error) { 469 s := &FeeGapStats{ 470 BasisPrice: cex.MidGap(baseID, quoteID), 471 } 472 _, buy, filled, err := cex.VWAP(baseID, quoteID, false, lotSize) 473 if err != nil { 474 return nil, fmt.Errorf("VWAP buy error: %w", err) 475 } 476 if !filled { 477 return s, nil 478 } 479 _, sell, filled, err := cex.VWAP(baseID, quoteID, true, lotSize) 480 if err != nil { 481 return nil, fmt.Errorf("VWAP sell error: %w", err) 482 } 483 if !filled { 484 return s, nil 485 } 486 s.RemoteGap = sell - buy 487 sellFeesInBaseUnits, err := core.OrderFeesInUnits(true, true, sell) 488 if err != nil { 489 return nil, fmt.Errorf("error getting sell fees: %w", err) 490 } 491 buyFeesInBaseUnits, err := core.OrderFeesInUnits(false, true, buy) 492 if err != nil { 493 return nil, fmt.Errorf("error getting buy fees: %w", err) 494 } 495 s.RoundTripFees = sellFeesInBaseUnits + buyFeesInBaseUnits 496 feesInQuoteUnits := calc.BaseToQuote((sell+buy)/2, s.RoundTripFees) 497 s.FeeGap = rateAdjustment(feesInQuoteUnits, lotSize) 498 return s, nil 499 } 500 501 func (a *arbMarketMaker) registerFeeGap() { 502 feeGap, err := feeGap(a.core, a.CEX, a.baseID, a.quoteID, a.lotSize.Load()) 503 if err != nil { 504 a.log.Warnf("error getting fee-gap stats: %v", err) 505 return 506 } 507 a.unifiedExchangeAdaptor.registerFeeGap(feeGap) 508 } 509 510 func (a *arbMarketMaker) botLoop(ctx context.Context) (*sync.WaitGroup, error) { 511 book, bookFeed, err := a.core.SyncBook(a.host, a.baseID, a.quoteID) 512 if err != nil { 513 return nil, fmt.Errorf("failed to sync book: %v", err) 514 } 515 a.book = book 516 517 err = a.cex.SubscribeMarket(ctx, a.baseID, a.quoteID) 518 if err != nil { 519 bookFeed.Close() 520 return nil, fmt.Errorf("failed to subscribe to cex market: %v", err) 521 } 522 523 tradeUpdates := a.cex.SubscribeTradeUpdates() 524 525 var wg sync.WaitGroup 526 wg.Add(1) 527 go func() { 528 defer wg.Done() 529 defer bookFeed.Close() 530 for { 531 select { 532 case ni, ok := <-bookFeed.Next(): 533 if !ok { 534 a.log.Error("Stopping bot due to nil book feed.") 535 a.kill() 536 return 537 } 538 switch epoch := ni.Payload.(type) { 539 case *core.ResolvedEpoch: 540 a.rebalance(epoch.Current, book) 541 } 542 case <-ctx.Done(): 543 return 544 } 545 } 546 }() 547 548 wg.Add(1) 549 go func() { 550 defer wg.Done() 551 for { 552 select { 553 case update := <-tradeUpdates: 554 a.handleCEXTradeUpdate(update) 555 case <-ctx.Done(): 556 return 557 } 558 } 559 }() 560 561 wg.Add(1) 562 go func() { 563 defer wg.Done() 564 orderUpdates := a.core.SubscribeOrderUpdates() 565 for { 566 select { 567 case n := <-orderUpdates: 568 a.processDEXOrderUpdate(n) 569 case <-ctx.Done(): 570 return 571 } 572 } 573 }() 574 575 wg.Add(1) 576 go func() { 577 defer wg.Done() 578 <-ctx.Done() 579 }() 580 581 a.registerFeeGap() 582 583 return &wg, nil 584 } 585 586 func newArbMarketMaker(cfg *BotConfig, adaptorCfg *exchangeAdaptorCfg, log dex.Logger) (*arbMarketMaker, error) { 587 if cfg.ArbMarketMakerConfig == nil { 588 // implies bug in caller 589 return nil, errors.New("no arb market maker config provided") 590 } 591 592 adaptor, err := newUnifiedExchangeAdaptor(adaptorCfg) 593 if err != nil { 594 return nil, fmt.Errorf("error constructing exchange adaptor: %w", err) 595 } 596 597 arbMM := &arbMarketMaker{ 598 unifiedExchangeAdaptor: adaptor, 599 cex: adaptor, 600 core: adaptor, 601 matchesSeen: make(map[order.MatchID]bool), 602 pendingOrders: make(map[order.OrderID]uint64), 603 cexTrades: make(map[string]uint64), 604 } 605 606 adaptor.setBotLoop(arbMM.botLoop) 607 return arbMM, nil 608 }