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