decred.org/dcrdex@v1.0.3/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) Validate() error { 39 if c.ProfitTrigger <= 0 || c.ProfitTrigger > 1 { 40 return fmt.Errorf("profit trigger must be 0 < t <= 1, but got %v", c.ProfitTrigger) 41 } 42 43 if c.MaxActiveArbs == 0 { 44 return fmt.Errorf("must allow at least 1 active arb") 45 } 46 47 if c.NumEpochsLeaveOpen < 2 { 48 return fmt.Errorf("arbs must be left open for at least 2 epochs") 49 } 50 51 return nil 52 } 53 54 // arbSequence represents an attempted arbitrage sequence. 55 type arbSequence struct { 56 dexOrder *core.Order 57 cexOrderID string 58 dexRate uint64 59 cexRate uint64 60 cexOrderFilled bool 61 dexOrderFilled bool 62 sellOnDEX bool 63 startEpoch uint64 64 } 65 66 type simpleArbMarketMaker struct { 67 *unifiedExchangeAdaptor 68 cex botCexAdaptor 69 core botCoreAdaptor 70 cfgV atomic.Value // *SimpleArbConfig 71 book dexOrderBook 72 rebalanceRunning atomic.Bool 73 74 activeArbsMtx sync.RWMutex 75 activeArbs []*arbSequence 76 } 77 78 var _ bot = (*simpleArbMarketMaker)(nil) 79 80 func (a *simpleArbMarketMaker) cfg() *SimpleArbConfig { 81 return a.cfgV.Load().(*SimpleArbConfig) 82 } 83 84 // arbExists checks if an arbitrage opportunity exists. 85 func (a *simpleArbMarketMaker) arbExists() (exists, sellOnDex bool, lotsToArb, dexRate, cexRate uint64, err error) { 86 sellOnDex = false 87 exists, lotsToArb, dexRate, cexRate, err = a.arbExistsOnSide(sellOnDex) 88 if err != nil || exists { 89 return 90 } 91 92 sellOnDex = true 93 exists, lotsToArb, dexRate, cexRate, err = a.arbExistsOnSide(sellOnDex) 94 if err != nil || exists { 95 return 96 } 97 98 return 99 } 100 101 // arbExistsOnSide checks if an arbitrage opportunity exists either when 102 // buying or selling on the dex. 103 func (a *simpleArbMarketMaker) arbExistsOnSide(sellOnDEX bool) (exists bool, lotsToArb, dexRate, cexRate uint64, err error) { 104 lotSize := a.lotSize 105 var prevProfit uint64 106 107 for numLots := uint64(1); ; numLots++ { 108 dexAvg, dexExtrema, dexFilled, err := a.book.VWAP(numLots, a.lotSize, !sellOnDEX) 109 if err != nil { 110 return false, 0, 0, 0, fmt.Errorf("error calculating dex VWAP: %w", err) 111 } 112 cexAvg, cexExtrema, cexFilled, err := a.CEX.VWAP(a.baseID, a.quoteID, sellOnDEX, numLots*lotSize) 113 if err != nil { 114 return false, 0, 0, 0, fmt.Errorf("error calculating cex VWAP: %w", err) 115 } 116 117 if !dexFilled || !cexFilled { 118 break 119 } 120 121 var buyRate, sellRate, buyAvg, sellAvg uint64 122 if sellOnDEX { 123 buyRate = cexExtrema 124 sellRate = dexExtrema 125 buyAvg = cexAvg 126 sellAvg = dexAvg 127 } else { 128 buyRate = dexExtrema 129 sellRate = cexExtrema 130 buyAvg = dexAvg 131 sellAvg = cexAvg 132 } 133 134 // For 1 lots, check balances in order to add insufficient balances to BotProblems 135 if buyRate >= sellRate && numLots > 1 { 136 break 137 } 138 139 dexSufficient, err := a.core.SufficientBalanceForDEXTrade(dexExtrema, numLots*lotSize, sellOnDEX) 140 if err != nil { 141 return false, 0, 0, 0, fmt.Errorf("error checking dex balance: %w", err) 142 } 143 144 cexSufficient := a.cex.SufficientBalanceForCEXTrade(a.baseID, a.quoteID, !sellOnDEX, cexExtrema, numLots*lotSize) 145 if !dexSufficient || !cexSufficient { 146 if numLots == 1 { 147 return false, 0, 0, 0, nil 148 } else { 149 break 150 } 151 } 152 153 if buyRate >= sellRate /* && numLots == 1 */ { 154 break 155 } 156 157 feesInQuoteUnits, err := a.core.OrderFeesInUnits(sellOnDEX, false, dexAvg) 158 if err != nil { 159 return false, 0, 0, 0, fmt.Errorf("error getting fees: %w", err) 160 } 161 162 qty := numLots * lotSize 163 quoteForBuy := calc.BaseToQuote(buyAvg, qty) 164 quoteFromSell := calc.BaseToQuote(sellAvg, qty) 165 if quoteFromSell-quoteForBuy <= feesInQuoteUnits { 166 break 167 } 168 profitInQuote := quoteFromSell - quoteForBuy - feesInQuoteUnits 169 profitInBase := calc.QuoteToBase((buyRate+sellRate)/2, profitInQuote) 170 if profitInBase < prevProfit || float64(profitInBase)/float64(qty) < a.cfg().ProfitTrigger { 171 break 172 } 173 174 prevProfit = profitInBase 175 lotsToArb = numLots 176 dexRate = dexExtrema 177 cexRate = cexExtrema 178 } 179 180 if lotsToArb > 0 { 181 a.log.Infof("arb opportunity - sellOnDex: %t, lotsToArb: %d, dexRate: %s, cexRate: %s: profit: %s", 182 sellOnDEX, lotsToArb, a.fmtRate(dexRate), a.fmtRate(cexRate), a.fmtBase(prevProfit)) 183 return true, lotsToArb, dexRate, cexRate, nil 184 } 185 186 return false, 0, 0, 0, nil 187 } 188 189 // executeArb will execute an arbitrage sequence by placing orders on the dex 190 // and cex. An entry will be added to the a.activeArbs slice if both orders 191 // are successfully placed. 192 func (a *simpleArbMarketMaker) executeArb(sellOnDex bool, lotsToArb, dexRate, cexRate, epoch uint64) { 193 a.log.Debugf("executing arb opportunity - sellOnDex: %v, lotsToArb: %v, dexRate: %v, cexRate: %v", 194 sellOnDex, lotsToArb, a.fmtRate(dexRate), a.fmtRate(cexRate)) 195 196 a.activeArbsMtx.RLock() 197 numArbs := len(a.activeArbs) 198 a.activeArbsMtx.RUnlock() 199 if numArbs >= int(a.cfg().MaxActiveArbs) { 200 a.log.Info("cannot execute arb because already at max arbs") 201 return 202 } 203 204 if a.selfMatch(sellOnDex, dexRate) { 205 a.log.Info("cannot execute arb opportunity due to self-match") 206 return 207 } 208 // also check self-match on CEX? 209 210 // Hold the lock for this entire process because updates to the cex trade 211 // may come even before the Trade function has returned, and in order to 212 // be able to process them, the new arbSequence struct must already be in 213 // the activeArbs slice. 214 a.activeArbsMtx.Lock() 215 defer a.activeArbsMtx.Unlock() 216 217 // Place cex order first. If placing dex order fails then can freely cancel cex order. 218 cexTrade, err := a.cex.CEXTrade(a.ctx, a.baseID, a.quoteID, !sellOnDex, cexRate, lotsToArb*a.lotSize) 219 if err != nil { 220 a.log.Errorf("error placing cex order: %v", err) 221 return 222 } 223 224 dexOrder, err := a.core.DEXTrade(dexRate, lotsToArb*a.lotSize, sellOnDex) 225 if err != nil { 226 if err != nil { 227 a.log.Errorf("error placing dex order: %v", err) 228 } 229 230 err := a.cex.CancelTrade(a.ctx, a.baseID, a.quoteID, cexTrade.ID) 231 if err != nil { 232 a.log.Errorf("error canceling cex order: %v", err) 233 // TODO: keep retrying failed cancel 234 } 235 return 236 } 237 238 a.activeArbs = append(a.activeArbs, &arbSequence{ 239 dexOrder: dexOrder, 240 dexRate: dexRate, 241 cexOrderID: cexTrade.ID, 242 cexRate: cexRate, 243 sellOnDEX: sellOnDex, 244 startEpoch: epoch, 245 }) 246 } 247 248 func (a *simpleArbMarketMaker) sortedOrders() (buys, sells []*core.Order) { 249 buys, sells = make([]*core.Order, 0), make([]*core.Order, 0) 250 251 a.activeArbsMtx.RLock() 252 for _, arb := range a.activeArbs { 253 if arb.sellOnDEX { 254 sells = append(sells, arb.dexOrder) 255 } else { 256 buys = append(buys, arb.dexOrder) 257 } 258 } 259 a.activeArbsMtx.RUnlock() 260 261 sort.Slice(buys, func(i, j int) bool { return buys[i].Rate > buys[j].Rate }) 262 sort.Slice(sells, func(i, j int) bool { return sells[i].Rate < sells[j].Rate }) 263 264 return buys, sells 265 } 266 267 // selfMatch checks if a order could match with any other orders 268 // already placed on the dex. 269 func (a *simpleArbMarketMaker) selfMatch(sell bool, rate uint64) bool { 270 buys, sells := a.sortedOrders() 271 272 if sell && len(buys) > 0 && buys[0].Rate >= rate { 273 return true 274 } 275 276 if !sell && len(sells) > 0 && sells[0].Rate <= rate { 277 return true 278 } 279 280 return false 281 } 282 283 // cancelArbSequence will cancel both the dex and cex orders in an arb sequence 284 // if they have not yet been filled. 285 func (a *simpleArbMarketMaker) cancelArbSequence(arb *arbSequence) { 286 if !arb.cexOrderFilled { 287 err := a.cex.CancelTrade(a.ctx, a.baseID, a.quoteID, arb.cexOrderID) 288 if err != nil { 289 a.log.Errorf("failed to cancel cex trade ID %s: %v", arb.cexOrderID, err) 290 } 291 } 292 293 if !arb.dexOrderFilled { 294 err := a.core.Cancel(arb.dexOrder.ID) 295 if err != nil { 296 a.log.Errorf("failed to cancel dex order ID %s: %v", arb.dexOrder.ID, err) 297 } 298 } 299 300 // keep retrying if failed to cancel? 301 } 302 303 // removeActiveArb removes the active arb at index i. 304 // 305 // activeArbsMtx MUST be held when calling this function. 306 func (a *simpleArbMarketMaker) removeActiveArb(i int) { 307 a.activeArbs[i] = a.activeArbs[len(a.activeArbs)-1] 308 a.activeArbs = a.activeArbs[:len(a.activeArbs)-1] 309 } 310 311 // handleCEXTradeUpdate is called when the CEX sends a notification that the 312 // status of a trade has changed. 313 func (a *simpleArbMarketMaker) handleCEXTradeUpdate(update *libxc.Trade) { 314 if !update.Complete { 315 return 316 } 317 318 a.activeArbsMtx.Lock() 319 defer a.activeArbsMtx.Unlock() 320 321 for i, arb := range a.activeArbs { 322 if arb.cexOrderID == update.ID { 323 arb.cexOrderFilled = true 324 if arb.dexOrderFilled { 325 a.removeActiveArb(i) 326 } 327 return 328 } 329 } 330 } 331 332 // handleDEXOrderUpdate is called when the DEX sends a notification that the 333 // status of an order has changed. 334 func (a *simpleArbMarketMaker) handleDEXOrderUpdate(o *core.Order) { 335 if o.Status <= order.OrderStatusBooked { 336 return 337 } 338 339 a.activeArbsMtx.Lock() 340 defer a.activeArbsMtx.Unlock() 341 342 for i, arb := range a.activeArbs { 343 if bytes.Equal(arb.dexOrder.ID, o.ID) { 344 arb.dexOrderFilled = true 345 if arb.cexOrderFilled { 346 a.removeActiveArb(i) 347 } 348 return 349 } 350 } 351 } 352 353 func (a *simpleArbMarketMaker) tryArb(newEpoch uint64) (exists, sellOnDEX bool, err error) { 354 if !(a.checkBotHealth(newEpoch) && a.tradingLimitNotReached(newEpoch)) { 355 return false, false, nil 356 } 357 358 exists, sellOnDex, lotsToArb, dexRate, cexRate, err := a.arbExists() 359 if err != nil { 360 return false, false, err 361 } 362 if a.log.Level() == dex.LevelTrace { 363 a.log.Tracef("%s rebalance. exists = %t, %s on dex, lots = %d, dex rate = %s, cex rate = %s", 364 a.name, exists, sellStr(sellOnDex), lotsToArb, a.fmtRate(dexRate), a.fmtRate(cexRate)) 365 } 366 if exists { 367 // Execution will not happen if it would cause a self-match. 368 a.executeArb(sellOnDex, lotsToArb, dexRate, cexRate, newEpoch) 369 } 370 371 return exists, sellOnDex, nil 372 } 373 374 // rebalance checks if there is an arbitrage opportunity between the dex and cex, 375 // and if so, executes trades to capitalize on it. 376 func (a *simpleArbMarketMaker) rebalance(newEpoch uint64) { 377 if !a.rebalanceRunning.CompareAndSwap(false, true) { 378 return 379 } 380 defer a.rebalanceRunning.Store(false) 381 a.log.Tracef("rebalance: epoch %d", newEpoch) 382 383 actionTaken, err := a.tryTransfers(newEpoch) 384 if err != nil { 385 a.log.Errorf("Error performing transfers: %v", err) 386 } else if actionTaken { 387 return 388 } 389 390 epochReport := &EpochReport{EpochNum: newEpoch} 391 392 exists, sellOnDex, err := a.tryArb(newEpoch) 393 if err != nil { 394 epochReport.setPreOrderProblems(err) 395 a.unifiedExchangeAdaptor.updateEpochReport(epochReport) 396 return 397 } 398 399 a.unifiedExchangeAdaptor.updateEpochReport(epochReport) 400 401 a.activeArbsMtx.Lock() 402 remainingArbs := make([]*arbSequence, 0, len(a.activeArbs)) 403 for _, arb := range a.activeArbs { 404 expired := newEpoch-arb.startEpoch > uint64(a.cfg().NumEpochsLeaveOpen) 405 oppositeDirectionArbFound := exists && sellOnDex != arb.sellOnDEX 406 407 if expired || oppositeDirectionArbFound { 408 a.cancelArbSequence(arb) 409 } else { 410 remainingArbs = append(remainingArbs, arb) 411 } 412 } 413 a.activeArbs = remainingArbs 414 a.activeArbsMtx.Unlock() 415 416 a.registerFeeGap() 417 } 418 419 func (a *simpleArbMarketMaker) distribution() (dist *distribution, err error) { 420 sellVWAP, buyVWAP, err := a.cexCounterRates(1, 1) 421 if err != nil { 422 return nil, fmt.Errorf("error getting cex counter-rates: %w", err) 423 } 424 // TODO: Adjust these rates to account for profit and fees. 425 sellFeesInBase, err := a.OrderFeesInUnits(true, true, sellVWAP) 426 if err != nil { 427 return nil, fmt.Errorf("error getting converted fees: %w", err) 428 } 429 adj := float64(sellFeesInBase)/float64(a.lotSize) + a.cfg().ProfitTrigger 430 sellRate := steppedRate(uint64(math.Round(float64(sellVWAP)*(1+adj))), a.rateStep) 431 buyFeesInBase, err := a.OrderFeesInUnits(false, true, buyVWAP) 432 if err != nil { 433 return nil, fmt.Errorf("error getting converted fees: %w", err) 434 } 435 adj = float64(buyFeesInBase)/float64(a.lotSize) + a.cfg().ProfitTrigger 436 buyRate := steppedRate(uint64(math.Round(float64(buyVWAP)/(1+adj))), a.rateStep) 437 perLot, err := a.lotCosts(sellRate, buyRate) 438 if perLot == nil { 439 return nil, fmt.Errorf("error getting lot costs: %w", err) 440 } 441 dist = a.newDistribution(perLot) 442 avgBaseLot, avgQuoteLot := float64(perLot.dexBase+perLot.cexBase)/2, float64(perLot.dexQuote+perLot.cexQuote)/2 443 baseLots := uint64(math.Round(float64(dist.baseInv.total) / avgBaseLot / 2)) 444 quoteLots := uint64(math.Round(float64(dist.quoteInv.total) / avgQuoteLot / 2)) 445 a.optimizeTransfers(dist, baseLots, quoteLots, baseLots*2, quoteLots*2) 446 return dist, nil 447 } 448 449 func (a *simpleArbMarketMaker) tryTransfers(currEpoch uint64) (actionTaken bool, err error) { 450 dist, err := a.distribution() 451 if err != nil { 452 a.log.Errorf("distribution calculation error: %v", err) 453 return 454 } 455 return a.transfer(dist, currEpoch) 456 } 457 458 func (a *simpleArbMarketMaker) botLoop(ctx context.Context) (*sync.WaitGroup, error) { 459 book, bookFeed, err := a.core.SyncBook(a.host, a.baseID, a.quoteID) 460 if err != nil { 461 return nil, fmt.Errorf("failed to sync book: %v", err) 462 } 463 a.book = book 464 465 err = a.cex.SubscribeMarket(ctx, a.baseID, a.quoteID) 466 if err != nil { 467 bookFeed.Close() 468 return nil, fmt.Errorf("failed to subscribe to cex market: %v", err) 469 } 470 471 tradeUpdates := a.cex.SubscribeTradeUpdates() 472 473 var wg sync.WaitGroup 474 wg.Add(1) 475 go func() { 476 defer wg.Done() 477 defer bookFeed.Close() 478 for { 479 select { 480 case ni := <-bookFeed.Next(): 481 switch epoch := ni.Payload.(type) { 482 case *core.ResolvedEpoch: 483 a.rebalance(epoch.Current) 484 } 485 case <-ctx.Done(): 486 return 487 } 488 } 489 }() 490 491 wg.Add(1) 492 go func() { 493 defer wg.Done() 494 for { 495 select { 496 case update := <-tradeUpdates: 497 a.handleCEXTradeUpdate(update) 498 case <-ctx.Done(): 499 return 500 } 501 } 502 }() 503 504 wg.Add(1) 505 go func() { 506 defer wg.Done() 507 orderUpdates := a.core.SubscribeOrderUpdates() 508 for { 509 select { 510 case n := <-orderUpdates: 511 a.handleDEXOrderUpdate(n) 512 case <-ctx.Done(): 513 return 514 } 515 } 516 }() 517 518 a.registerFeeGap() 519 520 return &wg, nil 521 } 522 523 func (a *simpleArbMarketMaker) registerFeeGap() { 524 feeGap, err := feeGap(a.core, a.CEX, a.baseID, a.quoteID, a.lotSize) 525 if err != nil { 526 a.log.Warnf("error getting fee-gap stats: %v", err) 527 return 528 } 529 a.unifiedExchangeAdaptor.registerFeeGap(feeGap) 530 } 531 532 func (a *simpleArbMarketMaker) updateConfig(cfg *BotConfig) error { 533 if cfg.SimpleArbConfig == nil { 534 // implies bug in caller 535 return fmt.Errorf("no arb config provided") 536 } 537 a.cfgV.Store(cfg.SimpleArbConfig) 538 return nil 539 } 540 541 func newSimpleArbMarketMaker(cfg *BotConfig, adaptorCfg *exchangeAdaptorCfg, log dex.Logger) (*simpleArbMarketMaker, error) { 542 if cfg.SimpleArbConfig == nil { 543 // implies bug in caller 544 return nil, fmt.Errorf("no arb config provided") 545 } 546 547 adaptor, err := newUnifiedExchangeAdaptor(adaptorCfg) 548 if err != nil { 549 return nil, fmt.Errorf("error constructing exchange adaptor: %w", err) 550 } 551 552 simpleArb := &simpleArbMarketMaker{ 553 unifiedExchangeAdaptor: adaptor, 554 cex: adaptor, 555 core: adaptor, 556 activeArbs: make([]*arbSequence, 0), 557 } 558 simpleArb.cfgV.Store(cfg.SimpleArbConfig) 559 adaptor.setBotLoop(simpleArb.botLoop) 560 return simpleArb, nil 561 }