decred.org/dcrdex@v1.0.5/client/mm/mm_simple_arb.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  	"bytes"
     8  	"context"
     9  	"fmt"
    10  	"math"
    11  	"sort"
    12  	"sync"
    13  	"sync/atomic"
    14  
    15  	"decred.org/dcrdex/client/core"
    16  	"decred.org/dcrdex/client/mm/libxc"
    17  	"decred.org/dcrdex/dex"
    18  	"decred.org/dcrdex/dex/calc"
    19  	"decred.org/dcrdex/dex/order"
    20  )
    21  
    22  // SimpleArbConfig is the configuration for an arbitrage bot that only places
    23  // orders when there is a profitable arbitrage opportunity.
    24  type SimpleArbConfig struct {
    25  	// ProfitTrigger is the minimum profit before a cross-exchange trade
    26  	// sequence is initiated. Range: 0 < ProfitTrigger << 1. For example, if
    27  	// the ProfitTrigger is 0.01 and a trade sequence would produce a 1% profit
    28  	// or better, a trade sequence will be initiated.
    29  	ProfitTrigger float64 `json:"profitTrigger"`
    30  	// MaxActiveArbs sets a limit on the number of active arbitrage sequences
    31  	// that can be open simultaneously.
    32  	MaxActiveArbs uint32 `json:"maxActiveArbs"`
    33  	// NumEpochsLeaveOpen is the number of epochs an arbitrage sequence will
    34  	// stay open if one or both of the orders were not filled.
    35  	NumEpochsLeaveOpen uint32 `json:"numEpochsLeaveOpen"`
    36  }
    37  
    38  func (c *SimpleArbConfig) copy() *SimpleArbConfig {
    39  	return &SimpleArbConfig{
    40  		ProfitTrigger:      c.ProfitTrigger,
    41  		MaxActiveArbs:      c.MaxActiveArbs,
    42  		NumEpochsLeaveOpen: c.NumEpochsLeaveOpen,
    43  	}
    44  }
    45  
    46  func (c *SimpleArbConfig) validate() error {
    47  	if c.ProfitTrigger <= 0 || c.ProfitTrigger > 1 {
    48  		return fmt.Errorf("profit trigger must be 0 < t <= 1, but got %v", c.ProfitTrigger)
    49  	}
    50  
    51  	if c.MaxActiveArbs == 0 {
    52  		return fmt.Errorf("must allow at least 1 active arb")
    53  	}
    54  
    55  	if c.NumEpochsLeaveOpen < 2 {
    56  		return fmt.Errorf("arbs must be left open for at least 2 epochs")
    57  	}
    58  
    59  	return nil
    60  }
    61  
    62  // arbSequence represents an attempted arbitrage sequence.
    63  type arbSequence struct {
    64  	dexOrder       *core.Order
    65  	cexOrderID     string
    66  	dexRate        uint64
    67  	cexRate        uint64
    68  	cexOrderFilled bool
    69  	dexOrderFilled bool
    70  	sellOnDEX      bool
    71  	startEpoch     uint64
    72  }
    73  
    74  type simpleArbMarketMaker struct {
    75  	*unifiedExchangeAdaptor
    76  	cex              botCexAdaptor
    77  	core             botCoreAdaptor
    78  	book             dexOrderBook
    79  	rebalanceRunning atomic.Bool
    80  
    81  	activeArbsMtx sync.RWMutex
    82  	activeArbs    []*arbSequence
    83  }
    84  
    85  var _ bot = (*simpleArbMarketMaker)(nil)
    86  
    87  func (a *simpleArbMarketMaker) cfg() *SimpleArbConfig {
    88  	return a.botCfg().SimpleArbConfig
    89  }
    90  
    91  // arbExists checks if an arbitrage opportunity exists.
    92  func (a *simpleArbMarketMaker) arbExists() (exists, sellOnDex bool, lotsToArb, dexRate, cexRate uint64, err error) {
    93  	sellOnDex = false
    94  	exists, lotsToArb, dexRate, cexRate, err = a.arbExistsOnSide(sellOnDex)
    95  	if err != nil || exists {
    96  		return
    97  	}
    98  
    99  	sellOnDex = true
   100  	exists, lotsToArb, dexRate, cexRate, err = a.arbExistsOnSide(sellOnDex)
   101  	if err != nil || exists {
   102  		return
   103  	}
   104  
   105  	return
   106  }
   107  
   108  // arbExistsOnSide checks if an arbitrage opportunity exists either when
   109  // buying or selling on the dex.
   110  func (a *simpleArbMarketMaker) arbExistsOnSide(sellOnDEX bool) (exists bool, lotsToArb, dexRate, cexRate uint64, err error) {
   111  	lotSize := a.lotSize.Load()
   112  	var prevProfit uint64
   113  
   114  	for numLots := uint64(1); ; numLots++ {
   115  		dexAvg, dexExtrema, dexFilled, err := a.book.VWAP(numLots, lotSize, !sellOnDEX)
   116  		if err != nil {
   117  			return false, 0, 0, 0, fmt.Errorf("error calculating dex VWAP: %w", err)
   118  		}
   119  		cexAvg, cexExtrema, cexFilled, err := a.CEX.VWAP(a.baseID, a.quoteID, sellOnDEX, numLots*lotSize)
   120  		if err != nil {
   121  			return false, 0, 0, 0, fmt.Errorf("error calculating cex VWAP: %w", err)
   122  		}
   123  
   124  		if !dexFilled || !cexFilled {
   125  			break
   126  		}
   127  
   128  		var buyRate, sellRate, buyAvg, sellAvg uint64
   129  		if sellOnDEX {
   130  			buyRate = cexExtrema
   131  			sellRate = dexExtrema
   132  			buyAvg = cexAvg
   133  			sellAvg = dexAvg
   134  		} else {
   135  			buyRate = dexExtrema
   136  			sellRate = cexExtrema
   137  			buyAvg = dexAvg
   138  			sellAvg = cexAvg
   139  		}
   140  
   141  		// For 1 lots, check balances in order to add insufficient balances to BotProblems
   142  		if buyRate >= sellRate && numLots > 1 {
   143  			break
   144  		}
   145  
   146  		dexSufficient, err := a.core.SufficientBalanceForDEXTrade(dexExtrema, numLots*lotSize, sellOnDEX)
   147  		if err != nil {
   148  			return false, 0, 0, 0, fmt.Errorf("error checking dex balance: %w", err)
   149  		}
   150  
   151  		cexSufficient := a.cex.SufficientBalanceForCEXTrade(a.baseID, a.quoteID, !sellOnDEX, cexExtrema, numLots*lotSize)
   152  		if !dexSufficient || !cexSufficient {
   153  			if numLots == 1 {
   154  				return false, 0, 0, 0, nil
   155  			} else {
   156  				break
   157  			}
   158  		}
   159  
   160  		if buyRate >= sellRate /* && numLots == 1 */ {
   161  			break
   162  		}
   163  
   164  		feesInQuoteUnits, err := a.core.OrderFeesInUnits(sellOnDEX, false, dexAvg)
   165  		if err != nil {
   166  			return false, 0, 0, 0, fmt.Errorf("error getting fees: %w", err)
   167  		}
   168  
   169  		qty := numLots * lotSize
   170  		quoteForBuy := calc.BaseToQuote(buyAvg, qty)
   171  		quoteFromSell := calc.BaseToQuote(sellAvg, qty)
   172  		if quoteFromSell-quoteForBuy <= feesInQuoteUnits {
   173  			break
   174  		}
   175  		profitInQuote := quoteFromSell - quoteForBuy - feesInQuoteUnits
   176  		profitInBase := calc.QuoteToBase((buyRate+sellRate)/2, profitInQuote)
   177  		if profitInBase < prevProfit || float64(profitInBase)/float64(qty) < a.cfg().ProfitTrigger {
   178  			break
   179  		}
   180  
   181  		prevProfit = profitInBase
   182  		lotsToArb = numLots
   183  		dexRate = dexExtrema
   184  		cexRate = cexExtrema
   185  	}
   186  
   187  	if lotsToArb > 0 {
   188  		a.log.Infof("arb opportunity - sellOnDex: %t, lotsToArb: %d, dexRate: %s, cexRate: %s: profit: %s",
   189  			sellOnDEX, lotsToArb, a.fmtRate(dexRate), a.fmtRate(cexRate), a.fmtBase(prevProfit))
   190  		return true, lotsToArb, dexRate, cexRate, nil
   191  	}
   192  
   193  	return false, 0, 0, 0, nil
   194  }
   195  
   196  // executeArb will execute an arbitrage sequence by placing orders on the dex
   197  // and cex. An entry will be added to the a.activeArbs slice if both orders
   198  // are successfully placed.
   199  func (a *simpleArbMarketMaker) executeArb(sellOnDex bool, lotsToArb, dexRate, cexRate, epoch uint64) {
   200  	a.log.Debugf("executing arb opportunity - sellOnDex: %v, lotsToArb: %v, dexRate: %v, cexRate: %v",
   201  		sellOnDex, lotsToArb, a.fmtRate(dexRate), a.fmtRate(cexRate))
   202  
   203  	a.activeArbsMtx.RLock()
   204  	numArbs := len(a.activeArbs)
   205  	a.activeArbsMtx.RUnlock()
   206  	if numArbs >= int(a.cfg().MaxActiveArbs) {
   207  		a.log.Info("cannot execute arb because already at max arbs")
   208  		return
   209  	}
   210  
   211  	if a.selfMatch(sellOnDex, dexRate) {
   212  		a.log.Info("cannot execute arb opportunity due to self-match")
   213  		return
   214  	}
   215  	// also check self-match on CEX?
   216  
   217  	lotSize := a.lotSize.Load()
   218  
   219  	// Hold the lock for this entire process because updates to the cex trade
   220  	// may come even before the Trade function has returned, and in order to
   221  	// be able to process them, the new arbSequence struct must already be in
   222  	// the activeArbs slice.
   223  	a.activeArbsMtx.Lock()
   224  	defer a.activeArbsMtx.Unlock()
   225  
   226  	// Place cex order first. If placing dex order fails then can freely cancel cex order.
   227  	cexTrade, err := a.cex.CEXTrade(a.ctx, a.baseID, a.quoteID, !sellOnDex, cexRate, lotsToArb*lotSize)
   228  	if err != nil {
   229  		a.log.Errorf("error placing cex order: %v", err)
   230  		return
   231  	}
   232  
   233  	dexOrder, err := a.core.DEXTrade(dexRate, lotsToArb*lotSize, sellOnDex)
   234  	if err != nil {
   235  		if err != nil {
   236  			a.log.Errorf("error placing dex order: %v", err)
   237  		}
   238  
   239  		err := a.cex.CancelTrade(a.ctx, a.baseID, a.quoteID, cexTrade.ID)
   240  		if err != nil {
   241  			a.log.Errorf("error canceling cex order: %v", err)
   242  			// TODO: keep retrying failed cancel
   243  		}
   244  		return
   245  	}
   246  
   247  	a.activeArbs = append(a.activeArbs, &arbSequence{
   248  		dexOrder:   dexOrder,
   249  		dexRate:    dexRate,
   250  		cexOrderID: cexTrade.ID,
   251  		cexRate:    cexRate,
   252  		sellOnDEX:  sellOnDex,
   253  		startEpoch: epoch,
   254  	})
   255  }
   256  
   257  func (a *simpleArbMarketMaker) sortedOrders() (buys, sells []*core.Order) {
   258  	buys, sells = make([]*core.Order, 0), make([]*core.Order, 0)
   259  
   260  	a.activeArbsMtx.RLock()
   261  	for _, arb := range a.activeArbs {
   262  		if arb.sellOnDEX {
   263  			sells = append(sells, arb.dexOrder)
   264  		} else {
   265  			buys = append(buys, arb.dexOrder)
   266  		}
   267  	}
   268  	a.activeArbsMtx.RUnlock()
   269  
   270  	sort.Slice(buys, func(i, j int) bool { return buys[i].Rate > buys[j].Rate })
   271  	sort.Slice(sells, func(i, j int) bool { return sells[i].Rate < sells[j].Rate })
   272  
   273  	return buys, sells
   274  }
   275  
   276  // selfMatch checks if a order could match with any other orders
   277  // already placed on the dex.
   278  func (a *simpleArbMarketMaker) selfMatch(sell bool, rate uint64) bool {
   279  	buys, sells := a.sortedOrders()
   280  
   281  	if sell && len(buys) > 0 && buys[0].Rate >= rate {
   282  		return true
   283  	}
   284  
   285  	if !sell && len(sells) > 0 && sells[0].Rate <= rate {
   286  		return true
   287  	}
   288  
   289  	return false
   290  }
   291  
   292  // cancelArbSequence will cancel both the dex and cex orders in an arb sequence
   293  // if they have not yet been filled.
   294  func (a *simpleArbMarketMaker) cancelArbSequence(arb *arbSequence) {
   295  	if !arb.cexOrderFilled {
   296  		err := a.cex.CancelTrade(a.ctx, a.baseID, a.quoteID, arb.cexOrderID)
   297  		if err != nil {
   298  			a.log.Errorf("failed to cancel cex trade ID %s: %v", arb.cexOrderID, err)
   299  		}
   300  	}
   301  
   302  	if !arb.dexOrderFilled {
   303  		err := a.core.Cancel(arb.dexOrder.ID)
   304  		if err != nil {
   305  			a.log.Errorf("failed to cancel dex order ID %s: %v", arb.dexOrder.ID, err)
   306  		}
   307  	}
   308  
   309  	// keep retrying if failed to cancel?
   310  }
   311  
   312  // removeActiveArb removes the active arb at index i.
   313  //
   314  // activeArbsMtx MUST be held when calling this function.
   315  func (a *simpleArbMarketMaker) removeActiveArb(i int) {
   316  	a.activeArbs[i] = a.activeArbs[len(a.activeArbs)-1]
   317  	a.activeArbs = a.activeArbs[:len(a.activeArbs)-1]
   318  }
   319  
   320  // handleCEXTradeUpdate is called when the CEX sends a notification that the
   321  // status of a trade has changed.
   322  func (a *simpleArbMarketMaker) handleCEXTradeUpdate(update *libxc.Trade) {
   323  	if !update.Complete {
   324  		return
   325  	}
   326  
   327  	a.activeArbsMtx.Lock()
   328  	defer a.activeArbsMtx.Unlock()
   329  
   330  	for i, arb := range a.activeArbs {
   331  		if arb.cexOrderID == update.ID {
   332  			arb.cexOrderFilled = true
   333  			if arb.dexOrderFilled {
   334  				a.removeActiveArb(i)
   335  			}
   336  			return
   337  		}
   338  	}
   339  }
   340  
   341  // handleDEXOrderUpdate is called when the DEX sends a notification that the
   342  // status of an order has changed.
   343  func (a *simpleArbMarketMaker) handleDEXOrderUpdate(o *core.Order) {
   344  	if o.Status <= order.OrderStatusBooked {
   345  		return
   346  	}
   347  
   348  	a.activeArbsMtx.Lock()
   349  	defer a.activeArbsMtx.Unlock()
   350  
   351  	for i, arb := range a.activeArbs {
   352  		if bytes.Equal(arb.dexOrder.ID, o.ID) {
   353  			arb.dexOrderFilled = true
   354  			if arb.cexOrderFilled {
   355  				a.removeActiveArb(i)
   356  			}
   357  			return
   358  		}
   359  	}
   360  }
   361  
   362  func (a *simpleArbMarketMaker) tryArb(newEpoch uint64) (exists, sellOnDEX bool, err error) {
   363  	if !(a.checkBotHealth(newEpoch) && a.tradingLimitNotReached(newEpoch)) {
   364  		return false, false, nil
   365  	}
   366  
   367  	exists, sellOnDex, lotsToArb, dexRate, cexRate, err := a.arbExists()
   368  	if err != nil {
   369  		return false, false, err
   370  	}
   371  	if a.log.Level() == dex.LevelTrace {
   372  		a.log.Tracef("%s rebalance. exists = %t, %s on dex, lots = %d, dex rate = %s, cex rate = %s",
   373  			a.name, exists, sellStr(sellOnDex), lotsToArb, a.fmtRate(dexRate), a.fmtRate(cexRate))
   374  	}
   375  	if exists {
   376  		// Execution will not happen if it would cause a self-match.
   377  		a.executeArb(sellOnDex, lotsToArb, dexRate, cexRate, newEpoch)
   378  	}
   379  
   380  	return exists, sellOnDex, nil
   381  }
   382  
   383  // rebalance checks if there is an arbitrage opportunity between the dex and cex,
   384  // and if so, executes trades to capitalize on it.
   385  func (a *simpleArbMarketMaker) rebalance(newEpoch uint64) {
   386  	if !a.rebalanceRunning.CompareAndSwap(false, true) {
   387  		return
   388  	}
   389  	defer a.rebalanceRunning.Store(false)
   390  	a.log.Tracef("rebalance: epoch %d", newEpoch)
   391  
   392  	actionTaken, err := a.tryTransfers(newEpoch)
   393  	if err != nil {
   394  		a.log.Errorf("Error performing transfers: %v", err)
   395  	} else if actionTaken {
   396  		return
   397  	}
   398  
   399  	epochReport := &EpochReport{EpochNum: newEpoch}
   400  
   401  	exists, sellOnDex, err := a.tryArb(newEpoch)
   402  	if err != nil {
   403  		epochReport.setPreOrderProblems(err)
   404  		a.unifiedExchangeAdaptor.updateEpochReport(epochReport)
   405  		return
   406  	}
   407  
   408  	a.unifiedExchangeAdaptor.updateEpochReport(epochReport)
   409  
   410  	a.activeArbsMtx.Lock()
   411  	remainingArbs := make([]*arbSequence, 0, len(a.activeArbs))
   412  	for _, arb := range a.activeArbs {
   413  		expired := newEpoch-arb.startEpoch > uint64(a.cfg().NumEpochsLeaveOpen)
   414  		oppositeDirectionArbFound := exists && sellOnDex != arb.sellOnDEX
   415  
   416  		if expired || oppositeDirectionArbFound {
   417  			a.cancelArbSequence(arb)
   418  		} else {
   419  			remainingArbs = append(remainingArbs, arb)
   420  		}
   421  	}
   422  	a.activeArbs = remainingArbs
   423  	a.activeArbsMtx.Unlock()
   424  
   425  	a.registerFeeGap()
   426  }
   427  
   428  func (a *simpleArbMarketMaker) distribution() (dist *distribution, err error) {
   429  	sellVWAP, buyVWAP, err := a.cexCounterRates(1, 1)
   430  	if err != nil {
   431  		return nil, fmt.Errorf("error getting cex counter-rates: %w", err)
   432  	}
   433  	// TODO: Adjust these rates to account for profit and fees.
   434  	sellFeesInBase, err := a.OrderFeesInUnits(true, true, sellVWAP)
   435  	if err != nil {
   436  		return nil, fmt.Errorf("error getting converted fees: %w", err)
   437  	}
   438  	lotSize, rateStep := a.lotSize.Load(), a.rateStep.Load()
   439  	adj := float64(sellFeesInBase)/float64(lotSize) + a.cfg().ProfitTrigger
   440  	sellRate := steppedRate(uint64(math.Round(float64(sellVWAP)*(1+adj))), rateStep)
   441  	buyFeesInBase, err := a.OrderFeesInUnits(false, true, buyVWAP)
   442  	if err != nil {
   443  		return nil, fmt.Errorf("error getting converted fees: %w", err)
   444  	}
   445  	adj = float64(buyFeesInBase)/float64(lotSize) + a.cfg().ProfitTrigger
   446  	buyRate := steppedRate(uint64(math.Round(float64(buyVWAP)/(1+adj))), rateStep)
   447  	perLot, err := a.lotCosts(sellRate, buyRate)
   448  	if perLot == nil {
   449  		return nil, fmt.Errorf("error getting lot costs: %w", err)
   450  	}
   451  	dist = a.newDistribution(perLot)
   452  	avgBaseLot, avgQuoteLot := float64(perLot.dexBase+perLot.cexBase)/2, float64(perLot.dexQuote+perLot.cexQuote)/2
   453  	baseLots := uint64(math.Round(float64(dist.baseInv.total) / avgBaseLot / 2))
   454  	quoteLots := uint64(math.Round(float64(dist.quoteInv.total) / avgQuoteLot / 2))
   455  	a.optimizeTransfers(dist, baseLots, quoteLots, baseLots*2, quoteLots*2)
   456  	return dist, nil
   457  }
   458  
   459  func (a *simpleArbMarketMaker) 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 (a *simpleArbMarketMaker) botLoop(ctx context.Context) (*sync.WaitGroup, error) {
   469  	book, bookFeed, err := a.core.SyncBook(a.host, a.baseID, a.quoteID)
   470  	if err != nil {
   471  		return nil, fmt.Errorf("failed to sync book: %v", err)
   472  	}
   473  	a.book = book
   474  
   475  	err = a.cex.SubscribeMarket(ctx, a.baseID, a.quoteID)
   476  	if err != nil {
   477  		bookFeed.Close()
   478  		return nil, fmt.Errorf("failed to subscribe to cex market: %v", err)
   479  	}
   480  
   481  	tradeUpdates := a.cex.SubscribeTradeUpdates()
   482  
   483  	var wg sync.WaitGroup
   484  	wg.Add(1)
   485  	go func() {
   486  		defer wg.Done()
   487  		defer bookFeed.Close()
   488  		for {
   489  			select {
   490  			case ni, ok := <-bookFeed.Next():
   491  				if !ok {
   492  					a.log.Error("Stopping bot due to nil book feed.")
   493  					a.kill()
   494  					return
   495  				}
   496  				switch epoch := ni.Payload.(type) {
   497  				case *core.ResolvedEpoch:
   498  					a.rebalance(epoch.Current)
   499  				}
   500  			case <-ctx.Done():
   501  				return
   502  			}
   503  		}
   504  	}()
   505  
   506  	wg.Add(1)
   507  	go func() {
   508  		defer wg.Done()
   509  		for {
   510  			select {
   511  			case update := <-tradeUpdates:
   512  				a.handleCEXTradeUpdate(update)
   513  			case <-ctx.Done():
   514  				return
   515  			}
   516  		}
   517  	}()
   518  
   519  	wg.Add(1)
   520  	go func() {
   521  		defer wg.Done()
   522  		orderUpdates := a.core.SubscribeOrderUpdates()
   523  		for {
   524  			select {
   525  			case n := <-orderUpdates:
   526  				a.handleDEXOrderUpdate(n)
   527  			case <-ctx.Done():
   528  				return
   529  			}
   530  		}
   531  	}()
   532  
   533  	a.registerFeeGap()
   534  
   535  	return &wg, nil
   536  }
   537  
   538  func (a *simpleArbMarketMaker) registerFeeGap() {
   539  	feeGap, err := feeGap(a.core, a.CEX, a.baseID, a.quoteID, a.lotSize.Load())
   540  	if err != nil {
   541  		a.log.Warnf("error getting fee-gap stats: %v", err)
   542  		return
   543  	}
   544  	a.unifiedExchangeAdaptor.registerFeeGap(feeGap)
   545  }
   546  
   547  func newSimpleArbMarketMaker(cfg *BotConfig, adaptorCfg *exchangeAdaptorCfg, log dex.Logger) (*simpleArbMarketMaker, error) {
   548  	if cfg.SimpleArbConfig == nil {
   549  		// implies bug in caller
   550  		return nil, fmt.Errorf("no arb config provided")
   551  	}
   552  
   553  	adaptor, err := newUnifiedExchangeAdaptor(adaptorCfg)
   554  	if err != nil {
   555  		return nil, fmt.Errorf("error constructing exchange adaptor: %w", err)
   556  	}
   557  
   558  	simpleArb := &simpleArbMarketMaker{
   559  		unifiedExchangeAdaptor: adaptor,
   560  		cex:                    adaptor,
   561  		core:                   adaptor,
   562  		activeArbs:             make([]*arbSequence, 0),
   563  	}
   564  	adaptor.setBotLoop(simpleArb.botLoop)
   565  	return simpleArb, nil
   566  }