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  }