Skip to content

Commit

Permalink
client/mm: Simple arb bot auto-rebalancing
Browse files Browse the repository at this point in the history
This implements auto-rebalancing in the simple arbitrage strategy. This is
an optional functionality that allows the user to specify a minimum
balance of each asset that should be on both the DEX and the CEX. If the
balance dips below this amount, then either a deposit or withdrawal will
be done to have an equal amount of the asset on both exchanges.
  • Loading branch information
martonp committed Oct 13, 2023
1 parent 1cffa5b commit a5151ea
Show file tree
Hide file tree
Showing 2 changed files with 545 additions and 151 deletions.
135 changes: 113 additions & 22 deletions client/mm/mm_simple_arb.go
Original file line number Diff line number Diff line change
Expand Up @@ -21,8 +21,6 @@ import (
// SimpleArbConfig is the configuration for an arbitrage bot that only places
// orders when there is a profitable arbitrage opportunity.
type SimpleArbConfig struct {
// CEXName is the name of the cex that the bot will arbitrage.
CEXName string `json:"cexName"`
// ProfitTrigger is the minimum profit before a cross-exchange trade
// sequence is initiated. Range: 0 < ProfitTrigger << 1. For example, if
// the ProfitTrigger is 0.01 and a trade sequence would produce a 1% profit
Expand All @@ -38,6 +36,14 @@ type SimpleArbConfig struct {
BaseOptions map[string]string `json:"baseOptions"`
// QuoteOptions are the multi-order options for the quote asset wallet.
QuoteOptions map[string]string `json:"quoteOptions"`
// AutoRebalance set to true means that if the base or quote asset balance
// dips below MinBaseAmt or MinQuoteAmt respectively, the bot will deposit
// or withdraw funds from the CEX to have an equal amount on both the DEX
// and the CEX. If it is not possible to bring both the DEX and CEX balances
// above the minimum amount, no action will be taken.
AutoRebalance bool `json:"autoRebalance"`
MinBaseAmt uint64 `json:"minBaseAmt"`
MinQuoteAmt uint64 `json:"minQuoteAmt"`
}

func (c *SimpleArbConfig) Validate() error {
Expand Down Expand Up @@ -73,20 +79,97 @@ type simpleArbMarketMaker struct {
host string
baseID uint32
quoteID uint32
cex libxc.CEX
cex cex
// cexTradeUpdatesID is passed to the Trade function of the cex
// so that the cex knows to send update notifications for the
// trade back to this bot.
cexTradeUpdatesID int
core clientCore
log dex.Logger
cfg *SimpleArbConfig
mkt *core.Market
book dexOrderBook
rebalanceRunning atomic.Bool
core clientCore
log dex.Logger
cfg *SimpleArbConfig
mkt *core.Market
book dexOrderBook
rebalanceRunning atomic.Bool

activeArbsMtx sync.RWMutex
activeArbs []*arbSequence

// If pendingBaseRebalance/pendingQuoteRebalance are true, it means
// there is a pending deposit/withdrawal of the base/quote asset,
// and no other deposits/withdrawals of that asset should happen
// until it is complete.
pendingBaseRebalance atomic.Bool
pendingQuoteRebalance atomic.Bool
}

// rebalanceAsset checks if the balance of an asset on the dex and cex are
// below the minimum amount, and if so, deposits or withdraws funds from the
// CEX to make the balances equal. If it is not possible to bring both the DEX
// and CEX balances above the minimum amount, no action will be taken.
func (a *simpleArbMarketMaker) rebalanceAsset(base bool) {
var assetID uint32
var minAmount uint64
if base {
assetID = a.baseID
minAmount = a.cfg.MinBaseAmt
} else {
assetID = a.quoteID
minAmount = a.cfg.MinQuoteAmt
}

dexBalance, err := a.core.AssetBalance(assetID)
if err != nil {
a.log.Errorf("Error getting asset %d balance: %v", assetID, err)
return
}

cexBalance, err := a.cex.Balance(assetID)
if err != nil {
a.log.Errorf("Error getting asset %d balance on cex: %v", assetID, err)
return
}

if (dexBalance.Available+cexBalance.Available)/2 < minAmount {
a.log.Warnf("Cannot rebalance asset %d because balance is too low on both DEX and CEX", assetID)
return
}

var requireDeposit bool
if cexBalance.Available < minAmount {
requireDeposit = true
} else if dexBalance.Available >= minAmount {
// No need for withdrawal or deposit.
return
}

onConfirm := func() {
if base {
a.pendingBaseRebalance.Store(false)
} else {
a.pendingQuoteRebalance.Store(false)
}
}

if requireDeposit {
amt := (dexBalance.Available+cexBalance.Available)/2 - cexBalance.Available
err = a.cex.Deposit(a.ctx, assetID, amt, onConfirm)
if err != nil {
a.log.Errorf("Error depositing %d to cex: %v", assetID, err)
return
}
} else {
amt := (dexBalance.Available+cexBalance.Available)/2 - dexBalance.Available
err = a.cex.Withdraw(a.ctx, assetID, amt, onConfirm)
if err != nil {
a.log.Errorf("Error withdrawing %d from cex: %v", assetID, err)
return
}
}

if base {
a.pendingBaseRebalance.Store(true)
} else {
a.pendingQuoteRebalance.Store(true)
}
}

// rebalance checks if there is an arbitrage opportunity between the dex and cex,
Expand Down Expand Up @@ -118,20 +201,29 @@ func (a *simpleArbMarketMaker) rebalance(newEpoch uint64) {
}
}

if a.cfg.AutoRebalance && len(remainingArbs) == 0 {
if !a.pendingBaseRebalance.Load() {
a.rebalanceAsset(true)
}
if !a.pendingQuoteRebalance.Load() {
a.rebalanceAsset(false)
}
}

a.activeArbs = remainingArbs
}

// arbExists checks if an arbitrage opportunity exists.
func (a *simpleArbMarketMaker) arbExists() (exists, sellOnDex bool, lotsToArb, dexRate, cexRate uint64) {
cexBaseBalance, err := a.cex.Balance(dex.BipIDSymbol(a.baseID))
cexBaseBalance, err := a.cex.Balance(a.baseID)
if err != nil {
a.log.Errorf("failed to get cex balance for %v: %v", dex.BipIDSymbol(a.baseID), err)
a.log.Errorf("failed to get cex balance for %v: %v", a.baseID, err)
return false, false, 0, 0, 0
}

cexQuoteBalance, err := a.cex.Balance(dex.BipIDSymbol(a.quoteID))
cexQuoteBalance, err := a.cex.Balance(a.quoteID)
if err != nil {
a.log.Errorf("failed to get cex balance for %v: %v", dex.BipIDSymbol(a.quoteID), err)
a.log.Errorf("failed to get cex balance for %v: %v", a.quoteID, err)
return false, false, 0, 0, 0
}

Expand Down Expand Up @@ -193,7 +285,7 @@ func (a *simpleArbMarketMaker) arbExistsOnSide(sellOnDEX bool, cexBaseBalance, c
}
}

cexAvg, cexExtrema, cexFilled, err := a.cex.VWAP(dex.BipIDSymbol(a.baseID), dex.BipIDSymbol(a.quoteID), sellOnDEX, numLots*lotSize)
cexAvg, cexExtrema, cexFilled, err := a.cex.VWAP(a.baseID, a.quoteID, sellOnDEX, numLots*lotSize)
if err != nil {
a.log.Errorf("error calculating cex VWAP: %v", err)
return
Expand Down Expand Up @@ -269,7 +361,7 @@ func (a *simpleArbMarketMaker) executeArb(sellOnDex bool, lotsToArb, dexRate, ce
defer a.activeArbsMtx.Unlock()

// Place cex order first. If placing dex order fails then can freely cancel cex order.
cexTradeID, err := a.cex.Trade(a.ctx, dex.BipIDSymbol(a.baseID), dex.BipIDSymbol(a.quoteID), !sellOnDex, cexRate, lotsToArb*a.mkt.LotSize, a.cexTradeUpdatesID)
cexTradeID, err := a.cex.Trade(a.ctx, a.baseID, a.quoteID, !sellOnDex, cexRate, lotsToArb*a.mkt.LotSize)
if err != nil {
a.log.Errorf("error placing cex order: %v", err)
return
Expand Down Expand Up @@ -303,7 +395,7 @@ func (a *simpleArbMarketMaker) executeArb(sellOnDex bool, lotsToArb, dexRate, ce
a.log.Errorf("expected 1 dex order, got %v", len(dexOrders))
}

err := a.cex.CancelTrade(a.ctx, dex.BipIDSymbol(a.baseID), dex.BipIDSymbol(a.quoteID), cexTradeID)
err := a.cex.CancelTrade(a.ctx, a.baseID, a.quoteID, cexTradeID)
if err != nil {
a.log.Errorf("error canceling cex order: %v", err)
// TODO: keep retrying failed cancel
Expand Down Expand Up @@ -360,7 +452,7 @@ func (a *simpleArbMarketMaker) selfMatch(sell bool, rate uint64) bool {
// if they have not yet been filled.
func (a *simpleArbMarketMaker) cancelArbSequence(arb *arbSequence) {
if !arb.cexOrderFilled {
err := a.cex.CancelTrade(a.ctx, dex.BipIDSymbol(a.baseID), dex.BipIDSymbol(a.quoteID), arb.cexOrderID)
err := a.cex.CancelTrade(a.ctx, a.baseID, a.quoteID, arb.cexOrderID)
if err != nil {
a.log.Errorf("failed to cancel cex trade ID %s: %v", arb.cexOrderID, err)
}
Expand Down Expand Up @@ -445,15 +537,14 @@ func (a *simpleArbMarketMaker) run() {
}
a.book = book

err = a.cex.SubscribeMarket(a.ctx, dex.BipIDSymbol(a.baseID), dex.BipIDSymbol(a.quoteID))
err = a.cex.SubscribeMarket(a.ctx, a.baseID, a.quoteID)
if err != nil {
a.log.Errorf("Failed to subscribe to cex market: %v", err)
return
}

tradeUpdates, unsubscribe, tradeUpdatesID := a.cex.SubscribeTradeUpdates()
tradeUpdates, unsubscribe := a.cex.SubscribeTradeUpdates()
defer unsubscribe()
a.cexTradeUpdatesID = tradeUpdatesID

wg := &sync.WaitGroup{}

Expand Down Expand Up @@ -516,7 +607,7 @@ func (a *simpleArbMarketMaker) cancelAllOrders() {
}
}

func RunSimpleArbBot(ctx context.Context, cfg *BotConfig, c clientCore, cex libxc.CEX, log dex.Logger) {
func RunSimpleArbBot(ctx context.Context, cfg *BotConfig, c clientCore, cex cex, log dex.Logger) {
if cfg.SimpleArbConfig == nil {
// implies bug in caller
log.Errorf("No arb config provided. Exiting.")
Expand Down
Loading

0 comments on commit a5151ea

Please sign in to comment.