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 }