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  }