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 }