decred.org/dcrdex@v1.0.5/client/mm/mm_basic.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  	"time"
    14  
    15  	"decred.org/dcrdex/client/core"
    16  	"decred.org/dcrdex/dex"
    17  	"decred.org/dcrdex/dex/calc"
    18  	"decred.org/dcrdex/dex/utils"
    19  )
    20  
    21  // GapStrategy is a specifier for an algorithm to choose the maker bot's target
    22  // spread.
    23  type GapStrategy string
    24  
    25  const (
    26  	// GapStrategyMultiplier calculates the spread by multiplying the
    27  	// break-even gap by the specified multiplier, 1 <= r <= 100.
    28  	GapStrategyMultiplier GapStrategy = "multiplier"
    29  	// GapStrategyAbsolute sets the spread to the rate difference.
    30  	GapStrategyAbsolute GapStrategy = "absolute"
    31  	// GapStrategyAbsolutePlus sets the spread to the rate difference plus the
    32  	// break-even gap.
    33  	GapStrategyAbsolutePlus GapStrategy = "absolute-plus"
    34  	// GapStrategyPercent sets the spread as a ratio of the mid-gap rate.
    35  	// 0 <= r <= 0.1
    36  	GapStrategyPercent GapStrategy = "percent"
    37  	// GapStrategyPercentPlus sets the spread as a ratio of the mid-gap rate
    38  	// plus the break-even gap.
    39  	GapStrategyPercentPlus GapStrategy = "percent-plus"
    40  )
    41  
    42  // OrderPlacement represents the distance from the mid-gap and the
    43  // amount of lots that should be placed at this distance.
    44  type OrderPlacement struct {
    45  	// Lots is the max number of lots to place at this distance from the
    46  	// mid-gap rate. If there is not enough balance to place this amount
    47  	// of lots, the max that can be afforded will be placed.
    48  	Lots uint64 `json:"lots"`
    49  
    50  	// GapFactor controls the gap width in a way determined by the GapStrategy.
    51  	GapFactor float64 `json:"gapFactor"`
    52  }
    53  
    54  // BasicMarketMakingConfig is the configuration for a simple market
    55  // maker that places orders on both sides of the order book.
    56  type BasicMarketMakingConfig struct {
    57  	// GapStrategy selects an algorithm for calculating the distance from
    58  	// the basis price to place orders.
    59  	GapStrategy GapStrategy `json:"gapStrategy"`
    60  
    61  	// SellPlacements is a list of order placements for sell orders.
    62  	// The orders are prioritized from the first in this list to the
    63  	// last.
    64  	SellPlacements []*OrderPlacement `json:"sellPlacements"`
    65  
    66  	// BuyPlacements is a list of order placements for buy orders.
    67  	// The orders are prioritized from the first in this list to the
    68  	// last.
    69  	BuyPlacements []*OrderPlacement `json:"buyPlacements"`
    70  
    71  	// DriftTolerance is how far away from an ideal price orders can drift
    72  	// before they are replaced (units: ratio of price). Default: 0.1%.
    73  	// 0 <= x <= 0.01.
    74  	DriftTolerance float64 `json:"driftTolerance"`
    75  }
    76  
    77  func needBreakEvenHalfSpread(strat GapStrategy) bool {
    78  	return strat == GapStrategyAbsolutePlus || strat == GapStrategyPercentPlus || strat == GapStrategyMultiplier
    79  }
    80  
    81  func (c *BasicMarketMakingConfig) validate() error {
    82  	if c.DriftTolerance == 0 {
    83  		c.DriftTolerance = 0.001
    84  	}
    85  	if c.DriftTolerance < 0 || c.DriftTolerance > 0.01 {
    86  		return fmt.Errorf("drift tolerance %f out of bounds", c.DriftTolerance)
    87  	}
    88  
    89  	if c.GapStrategy != GapStrategyMultiplier &&
    90  		c.GapStrategy != GapStrategyPercent &&
    91  		c.GapStrategy != GapStrategyPercentPlus &&
    92  		c.GapStrategy != GapStrategyAbsolute &&
    93  		c.GapStrategy != GapStrategyAbsolutePlus {
    94  		return fmt.Errorf("unknown gap strategy %q", c.GapStrategy)
    95  	}
    96  
    97  	validatePlacement := func(p *OrderPlacement) error {
    98  		var limits [2]float64
    99  		switch c.GapStrategy {
   100  		case GapStrategyMultiplier:
   101  			limits = [2]float64{1, 100}
   102  		case GapStrategyPercent, GapStrategyPercentPlus:
   103  			limits = [2]float64{0, 0.1}
   104  		case GapStrategyAbsolute, GapStrategyAbsolutePlus:
   105  			limits = [2]float64{0, math.MaxFloat64} // validate at < spot price at creation time
   106  		default:
   107  			return fmt.Errorf("unknown gap strategy %q", c.GapStrategy)
   108  		}
   109  
   110  		if p.GapFactor < limits[0] || p.GapFactor > limits[1] {
   111  			return fmt.Errorf("%s gap factor %f is out of bounds %+v", c.GapStrategy, p.GapFactor, limits)
   112  		}
   113  
   114  		return nil
   115  	}
   116  
   117  	sellPlacements := make(map[float64]bool, len(c.SellPlacements))
   118  	for _, p := range c.SellPlacements {
   119  		if _, duplicate := sellPlacements[p.GapFactor]; duplicate {
   120  			return fmt.Errorf("duplicate sell placement %f", p.GapFactor)
   121  		}
   122  		sellPlacements[p.GapFactor] = true
   123  		if err := validatePlacement(p); err != nil {
   124  			return fmt.Errorf("invalid sell placement: %w", err)
   125  		}
   126  	}
   127  
   128  	buyPlacements := make(map[float64]bool, len(c.BuyPlacements))
   129  	for _, p := range c.BuyPlacements {
   130  		if _, duplicate := buyPlacements[p.GapFactor]; duplicate {
   131  			return fmt.Errorf("duplicate buy placement %f", p.GapFactor)
   132  		}
   133  		buyPlacements[p.GapFactor] = true
   134  		if err := validatePlacement(p); err != nil {
   135  			return fmt.Errorf("invalid buy placement: %w", err)
   136  		}
   137  	}
   138  
   139  	return nil
   140  }
   141  
   142  func (c *BasicMarketMakingConfig) copy() *BasicMarketMakingConfig {
   143  	cfg := *c
   144  
   145  	copyOrderPlacement := func(p *OrderPlacement) *OrderPlacement {
   146  		return &OrderPlacement{
   147  			Lots:      p.Lots,
   148  			GapFactor: p.GapFactor,
   149  		}
   150  	}
   151  
   152  	cfg.SellPlacements = utils.Map(c.SellPlacements, copyOrderPlacement)
   153  	cfg.BuyPlacements = utils.Map(c.BuyPlacements, copyOrderPlacement)
   154  
   155  	return &cfg
   156  }
   157  
   158  func updateLotSize(placements []*OrderPlacement, originalLotSize, newLotSize uint64) (updatedPlacements []*OrderPlacement) {
   159  	var qtyCounter uint64
   160  	for _, p := range placements {
   161  		qtyCounter += p.Lots * originalLotSize
   162  	}
   163  	newPlacements := make([]*OrderPlacement, 0, len(placements))
   164  	for _, p := range placements {
   165  		lots := uint64(math.Round((float64(p.Lots) * float64(originalLotSize)) / float64(newLotSize)))
   166  		lots = utils.Max(lots, 1)
   167  		maxLots := qtyCounter / newLotSize
   168  		lots = utils.Min(lots, maxLots)
   169  		if lots == 0 {
   170  			continue
   171  		}
   172  		qtyCounter -= lots * newLotSize
   173  		newPlacements = append(newPlacements, &OrderPlacement{
   174  			Lots:      lots,
   175  			GapFactor: p.GapFactor,
   176  		})
   177  	}
   178  
   179  	return newPlacements
   180  }
   181  
   182  // updateLotSize modifies the number of lots in each placement in the event
   183  // of a lot size change. It will place as many lots as possible without
   184  // exceeding the total quantity placed using the original lot size.
   185  //
   186  // This function is NOT thread safe.
   187  func (c *BasicMarketMakingConfig) updateLotSize(originalLotSize, newLotSize uint64) {
   188  	c.SellPlacements = updateLotSize(c.SellPlacements, originalLotSize, newLotSize)
   189  	c.BuyPlacements = updateLotSize(c.BuyPlacements, originalLotSize, newLotSize)
   190  }
   191  
   192  type basicMMCalculator interface {
   193  	basisPrice() (bp uint64, err error)
   194  	halfSpread(uint64) (uint64, error)
   195  	feeGapStats(uint64) (*FeeGapStats, error)
   196  }
   197  
   198  type basicMMCalculatorImpl struct {
   199  	*market
   200  	oracle oracle
   201  	core   botCoreAdaptor
   202  	cfg    *BasicMarketMakingConfig
   203  	log    dex.Logger
   204  }
   205  
   206  var errNoBasisPrice = errors.New("no oracle or fiat rate available")
   207  var errOracleFiatMismatch = errors.New("oracle rate and fiat rate mismatch")
   208  
   209  // basisPrice calculates the basis price for the market maker.
   210  // The mid-gap of the dex order book is used, and if oracles are
   211  // available, and the oracle weighting is > 0, the oracle price
   212  // is used to adjust the basis price.
   213  // If the dex market is empty, but there are oracles available and
   214  // oracle weighting is > 0, the oracle rate is used.
   215  // If the dex market is empty and there are either no oracles available
   216  // or oracle weighting is 0, the fiat rate is used.
   217  // If there is no fiat rate available, the empty market rate in the
   218  // configuration is used.
   219  func (b *basicMMCalculatorImpl) basisPrice() (uint64, error) {
   220  	oracleRate := b.msgRate(b.oracle.getMarketPrice(b.baseID, b.quoteID))
   221  	b.log.Tracef("oracle rate = %s", b.fmtRate(oracleRate))
   222  
   223  	rateFromFiat := b.core.ExchangeRateFromFiatSources()
   224  	rateStep := b.rateStep.Load()
   225  	if rateFromFiat == 0 {
   226  		b.log.Meter("basisPrice_nofiat_"+b.market.name, time.Hour).Warn(
   227  			"No fiat-based rate estimate(s) available for sanity check for %s", b.market.name,
   228  		)
   229  		if oracleRate == 0 { // steppedRate(0, x) => x, so we have to handle this.
   230  			return 0, errNoBasisPrice
   231  		}
   232  		return steppedRate(oracleRate, rateStep), nil
   233  	}
   234  	if oracleRate == 0 {
   235  		b.log.Meter("basisPrice_nooracle_"+b.market.name, time.Hour).Infof(
   236  			"No oracle rate available. Using fiat-derived basis rate = %s for %s", b.fmtRate(rateFromFiat), b.market.name,
   237  		)
   238  		return steppedRate(rateFromFiat, rateStep), nil
   239  	}
   240  	mismatch := math.Abs((float64(oracleRate) - float64(rateFromFiat)) / float64(oracleRate))
   241  	const maxOracleFiatMismatch = 0.05
   242  	if mismatch > maxOracleFiatMismatch {
   243  		b.log.Meter("basisPrice_sanity_fail+"+b.market.name, time.Minute*20).Warnf(
   244  			"Oracle rate sanity check failed for %s. oracle rate = %s, rate from fiat = %s",
   245  			b.market.name, b.market.fmtRate(oracleRate), b.market.fmtRate(rateFromFiat),
   246  		)
   247  		return 0, errOracleFiatMismatch
   248  	}
   249  
   250  	return steppedRate(oracleRate, rateStep), nil
   251  }
   252  
   253  // halfSpread calculates the distance from the mid-gap where if you sell a lot
   254  // at the basis price plus half-gap, then buy a lot at the basis price minus
   255  // half-gap, you will have one lot of the base asset plus the total fees in
   256  // base units. Since the fees are in base units, basis price can be used to
   257  // convert the quote fees to base units. In the case of tokens, the fees are
   258  // converted using fiat rates.
   259  func (b *basicMMCalculatorImpl) halfSpread(basisPrice uint64) (uint64, error) {
   260  	feeStats, err := b.feeGapStats(basisPrice)
   261  	if err != nil {
   262  		return 0, err
   263  	}
   264  	return feeStats.FeeGap / 2, nil
   265  }
   266  
   267  // FeeGapStats is info about market and fee state. The intepretation of the
   268  // various statistics may vary slightly with bot type.
   269  type FeeGapStats struct {
   270  	BasisPrice    uint64 `json:"basisPrice"`
   271  	RemoteGap     uint64 `json:"remoteGap"`
   272  	FeeGap        uint64 `json:"feeGap"`
   273  	RoundTripFees uint64 `json:"roundTripFees"` // base units
   274  }
   275  
   276  func (b *basicMMCalculatorImpl) feeGapStats(basisPrice uint64) (*FeeGapStats, error) {
   277  	if basisPrice == 0 { // prevent divide by zero later
   278  		return nil, fmt.Errorf("basis price cannot be zero")
   279  	}
   280  
   281  	sellFeesInBaseUnits, err := b.core.OrderFeesInUnits(true, true, basisPrice)
   282  	if err != nil {
   283  		return nil, fmt.Errorf("error getting sell fees in base units: %w", err)
   284  	}
   285  
   286  	buyFeesInBaseUnits, err := b.core.OrderFeesInUnits(false, true, basisPrice)
   287  	if err != nil {
   288  		return nil, fmt.Errorf("error getting buy fees in base units: %w", err)
   289  	}
   290  
   291  	/*
   292  	 * g = half-gap
   293  	 * r = basis price (atomic ratio)
   294  	 * l = lot size
   295  	 * f = total fees in base units
   296  	 *
   297  	 * We must choose a half-gap such that:
   298  	 * (r + g) * l / (r - g) = l + f
   299  	 *
   300  	 * This means that when you sell a lot at the basis price plus half-gap,
   301  	 * then buy a lot at the basis price minus half-gap, you will have one
   302  	 * lot of the base asset plus the total fees in base units.
   303  	 *
   304  	 * Solving for g, you get:
   305  	 * g = f * r / (f + 2l)
   306  	 */
   307  
   308  	f := sellFeesInBaseUnits + buyFeesInBaseUnits
   309  	l := b.lotSize.Load()
   310  
   311  	r := float64(basisPrice) / calc.RateEncodingFactor
   312  	g := float64(f) * r / float64(f+2*l)
   313  
   314  	halfGap := uint64(math.Round(g * calc.RateEncodingFactor))
   315  
   316  	if b.log.Level() == dex.LevelTrace {
   317  		b.log.Tracef("halfSpread: basis price = %s, lot size = %s, aggregate fees = %s, half-gap = %s, sell fees = %s, buy fees = %s",
   318  			b.fmtRate(basisPrice), b.fmtBase(l), b.fmtBaseFees(f), b.fmtRate(halfGap),
   319  			b.fmtBaseFees(sellFeesInBaseUnits), b.fmtBaseFees(buyFeesInBaseUnits))
   320  	}
   321  
   322  	return &FeeGapStats{
   323  		BasisPrice:    basisPrice,
   324  		FeeGap:        halfGap * 2,
   325  		RoundTripFees: f,
   326  	}, nil
   327  }
   328  
   329  type basicMarketMaker struct {
   330  	*unifiedExchangeAdaptor
   331  	core             botCoreAdaptor
   332  	oracle           oracle
   333  	rebalanceRunning atomic.Bool
   334  	calculator       basicMMCalculator
   335  }
   336  
   337  var _ bot = (*basicMarketMaker)(nil)
   338  
   339  func (m *basicMarketMaker) cfg() *BasicMarketMakingConfig {
   340  	return m.botCfg().BasicMMConfig
   341  }
   342  
   343  func (m *basicMarketMaker) orderPrice(basisPrice, feeAdj uint64, sell bool, gapFactor float64) uint64 {
   344  	var adj uint64
   345  
   346  	// Apply the base strategy.
   347  	switch m.cfg().GapStrategy {
   348  	case GapStrategyMultiplier:
   349  		adj = uint64(math.Round(float64(feeAdj) * gapFactor))
   350  	case GapStrategyPercent, GapStrategyPercentPlus:
   351  		adj = uint64(math.Round(gapFactor * float64(basisPrice)))
   352  	case GapStrategyAbsolute, GapStrategyAbsolutePlus:
   353  		adj = m.msgRate(gapFactor)
   354  	}
   355  
   356  	// Add the break-even to the "-plus" strategies
   357  	switch m.cfg().GapStrategy {
   358  	case GapStrategyAbsolutePlus, GapStrategyPercentPlus:
   359  		adj += feeAdj
   360  	}
   361  
   362  	adj = steppedRate(adj, m.rateStep.Load())
   363  
   364  	if sell {
   365  		return basisPrice + adj
   366  	}
   367  
   368  	if basisPrice < adj {
   369  		return 0
   370  	}
   371  
   372  	return basisPrice - adj
   373  }
   374  
   375  func (m *basicMarketMaker) ordersToPlace() (buyOrders, sellOrders []*TradePlacement, err error) {
   376  	basisPrice, err := m.calculator.basisPrice()
   377  	if err != nil {
   378  		return nil, nil, err
   379  	}
   380  
   381  	feeGap, err := m.calculator.feeGapStats(basisPrice)
   382  	if err != nil {
   383  		return nil, nil, fmt.Errorf("error calculating fee gap stats: %w", err)
   384  	}
   385  
   386  	m.registerFeeGap(feeGap)
   387  	var feeAdj uint64
   388  	if needBreakEvenHalfSpread(m.cfg().GapStrategy) {
   389  		feeAdj = feeGap.FeeGap / 2
   390  	}
   391  
   392  	if m.log.Level() == dex.LevelTrace {
   393  		m.log.Tracef("ordersToPlace %s, basis price = %s, break-even fee adjustment = %s",
   394  			m.name, m.fmtRate(basisPrice), m.fmtRate(feeAdj))
   395  	}
   396  
   397  	orders := func(orderPlacements []*OrderPlacement, sell bool) []*TradePlacement {
   398  		placements := make([]*TradePlacement, 0, len(orderPlacements))
   399  		for i, p := range orderPlacements {
   400  			rate := m.orderPrice(basisPrice, feeAdj, sell, p.GapFactor)
   401  
   402  			if m.log.Level() == dex.LevelTrace {
   403  				m.log.Tracef("ordersToPlace.orders: %s placement # %d, gap factor = %f, rate = %s, %+v",
   404  					sellStr(sell), i, p.GapFactor, m.fmtRate(rate), rate)
   405  			}
   406  
   407  			lots := p.Lots
   408  			if rate == 0 {
   409  				lots = 0
   410  			}
   411  			placements = append(placements, &TradePlacement{
   412  				Rate: rate,
   413  				Lots: lots,
   414  			})
   415  		}
   416  		return placements
   417  	}
   418  
   419  	buyOrders = orders(m.cfg().BuyPlacements, false)
   420  	sellOrders = orders(m.cfg().SellPlacements, true)
   421  	return buyOrders, sellOrders, nil
   422  }
   423  
   424  func (m *basicMarketMaker) rebalance(newEpoch uint64) {
   425  	if !m.rebalanceRunning.CompareAndSwap(false, true) {
   426  		return
   427  	}
   428  	defer m.rebalanceRunning.Store(false)
   429  
   430  	m.log.Tracef("rebalance: epoch %d", newEpoch)
   431  
   432  	if !m.checkBotHealth(newEpoch) {
   433  		m.tryCancelOrders(m.ctx, &newEpoch, false)
   434  		return
   435  	}
   436  
   437  	var buysReport, sellsReport *OrderReport
   438  	buyOrders, sellOrders, determinePlacementsErr := m.ordersToPlace()
   439  	if determinePlacementsErr != nil {
   440  		m.tryCancelOrders(m.ctx, &newEpoch, false)
   441  	} else {
   442  		_, buysReport = m.multiTrade(buyOrders, false, m.cfg().DriftTolerance, newEpoch)
   443  		_, sellsReport = m.multiTrade(sellOrders, true, m.cfg().DriftTolerance, newEpoch)
   444  	}
   445  
   446  	epochReport := &EpochReport{
   447  		BuysReport:  buysReport,
   448  		SellsReport: sellsReport,
   449  		EpochNum:    newEpoch,
   450  	}
   451  	epochReport.setPreOrderProblems(determinePlacementsErr)
   452  	m.updateEpochReport(epochReport)
   453  }
   454  
   455  func (m *basicMarketMaker) botLoop(ctx context.Context) (*sync.WaitGroup, error) {
   456  	_, bookFeed, err := m.core.SyncBook(m.host, m.baseID, m.quoteID)
   457  	if err != nil {
   458  		return nil, fmt.Errorf("failed to sync book: %v", err)
   459  	}
   460  
   461  	m.calculator = &basicMMCalculatorImpl{
   462  		market: m.market,
   463  		oracle: m.oracle,
   464  		core:   m.core,
   465  		cfg:    m.cfg(),
   466  		log:    m.log,
   467  	}
   468  
   469  	// Process book updates
   470  	var wg sync.WaitGroup
   471  	wg.Add(1)
   472  	go func() {
   473  		defer wg.Done()
   474  		defer bookFeed.Close()
   475  		for {
   476  			select {
   477  			case ni, ok := <-bookFeed.Next():
   478  				if !ok {
   479  					m.log.Error("Stopping bot due to nil book feed.")
   480  					m.kill()
   481  					return
   482  				}
   483  				switch epoch := ni.Payload.(type) {
   484  				case *core.ResolvedEpoch:
   485  					m.rebalance(epoch.Current)
   486  				}
   487  			case <-ctx.Done():
   488  				return
   489  			}
   490  		}
   491  	}()
   492  
   493  	return &wg, nil
   494  }
   495  
   496  // RunBasicMarketMaker starts a basic market maker bot.
   497  func newBasicMarketMaker(cfg *BotConfig, adaptorCfg *exchangeAdaptorCfg, oracle oracle, log dex.Logger) (*basicMarketMaker, error) {
   498  	if cfg.BasicMMConfig == nil {
   499  		// implies bug in caller
   500  		return nil, errors.New("no market making config provided")
   501  	}
   502  
   503  	adaptor, err := newUnifiedExchangeAdaptor(adaptorCfg)
   504  	if err != nil {
   505  		return nil, fmt.Errorf("error constructing exchange adaptor: %w", err)
   506  	}
   507  
   508  	err = cfg.BasicMMConfig.validate()
   509  	if err != nil {
   510  		return nil, fmt.Errorf("invalid market making config: %v", err)
   511  	}
   512  
   513  	basicMM := &basicMarketMaker{
   514  		unifiedExchangeAdaptor: adaptor,
   515  		core:                   adaptor,
   516  		oracle:                 oracle,
   517  	}
   518  	adaptor.setBotLoop(basicMM.botLoop)
   519  	return basicMM, nil
   520  }