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 }