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