From a5151ea0827911be34a0f270de009da9d04990f5 Mon Sep 17 00:00:00 2001 From: martonp Date: Fri, 13 Oct 2023 03:06:24 -0400 Subject: [PATCH] client/mm: Simple arb bot auto-rebalancing 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. --- client/mm/mm_simple_arb.go | 135 ++++++-- client/mm/mm_simple_arb_test.go | 561 ++++++++++++++++++++++++-------- 2 files changed, 545 insertions(+), 151 deletions(-) diff --git a/client/mm/mm_simple_arb.go b/client/mm/mm_simple_arb.go index 06a6a051e7..fea01b1a3b 100644 --- a/client/mm/mm_simple_arb.go +++ b/client/mm/mm_simple_arb.go @@ -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 @@ -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 { @@ -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, @@ -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 } @@ -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 @@ -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 @@ -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 @@ -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) } @@ -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{} @@ -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.") diff --git a/client/mm/mm_simple_arb_test.go b/client/mm/mm_simple_arb_test.go index 5d21a4f583..641e839a04 100644 --- a/client/mm/mm_simple_arb_test.go +++ b/client/mm/mm_simple_arb_test.go @@ -30,39 +30,51 @@ type dexOrder struct { } type cexOrder struct { - baseSymbol, quoteSymbol string - qty, rate uint64 - sell bool + baseID, quoteID uint32 + qty, rate uint64 + sell bool } -type tCEX struct { - bidsVWAP map[uint64]vwapResult - asksVWAP map[uint64]vwapResult - vwapErr error - balances map[string]*libxc.ExchangeBalance - balanceErr error - - tradeID string - tradeErr error - lastTrade *cexOrder - - cancelledTrades []string - cancelTradeErr error +type withdrawArgs struct { + address string + amt uint64 + assetID uint32 +} - tradeUpdates chan *libxc.TradeUpdate - tradeUpdatesID int +type tCEX struct { + bidsVWAP map[uint64]vwapResult + asksVWAP map[uint64]vwapResult + vwapErr error + balances map[uint32]*libxc.ExchangeBalance + balanceErr error + tradeID string + tradeErr error + lastTrade *cexOrder + cancelledTrades []string + cancelTradeErr error + tradeUpdates chan *libxc.TradeUpdate + tradeUpdatesID int + lastConfirmDepositTx string + confirmDepositAmt uint64 + depositConfirmed bool + depositAddress string + withdrawAmt uint64 + withdrawTxID string + lastWithdrawArgs *withdrawArgs } func newTCEX() *tCEX { return &tCEX{ bidsVWAP: make(map[uint64]vwapResult), asksVWAP: make(map[uint64]vwapResult), - balances: make(map[string]*libxc.ExchangeBalance), + balances: make(map[uint32]*libxc.ExchangeBalance), cancelledTrades: make([]string, 0), tradeUpdates: make(chan *libxc.TradeUpdate), } } +var _ libxc.CEX = (*tCEX)(nil) + func (c *tCEX) Connect(ctx context.Context) (*sync.WaitGroup, error) { return nil, nil } @@ -72,29 +84,30 @@ func (c *tCEX) Balances() (map[uint32]*libxc.ExchangeBalance, error) { func (c *tCEX) Markets() ([]*libxc.Market, error) { return nil, nil } -func (c *tCEX) Balance(symbol string) (*libxc.ExchangeBalance, error) { - return c.balances[symbol], c.balanceErr +func (c *tCEX) Balance(assetID uint32) (*libxc.ExchangeBalance, error) { + return c.balances[assetID], c.balanceErr } -func (c *tCEX) Trade(ctx context.Context, baseSymbol, quoteSymbol string, sell bool, rate, qty uint64, updaterID int) (string, error) { +func (c *tCEX) Trade(ctx context.Context, baseID, quoteID uint32, sell bool, rate, qty uint64, updaterID int) (string, error) { if c.tradeErr != nil { return "", c.tradeErr } - c.lastTrade = &cexOrder{baseSymbol, quoteSymbol, qty, rate, sell} + c.lastTrade = &cexOrder{baseID, quoteID, qty, rate, sell} return c.tradeID, nil } -func (c *tCEX) CancelTrade(ctx context.Context, baseSymbol, quoteSymbol, tradeID string) error { +func (c *tCEX) CancelTrade(ctx context.Context, seID, quoteID uint32, tradeID string) error { if c.cancelTradeErr != nil { return c.cancelTradeErr } c.cancelledTrades = append(c.cancelledTrades, tradeID) return nil } -func (c *tCEX) SubscribeMarket(ctx context.Context, baseSymbol, quoteSymbol string) error { +func (c *tCEX) SubscribeMarket(ctx context.Context, baseID, quoteID uint32) error { return nil } -func (c *tCEX) UnsubscribeMarket(baseSymbol, quoteSymbol string) { +func (c *tCEX) UnsubscribeMarket(baseID, quoteID uint32) error { + return nil } -func (c *tCEX) VWAP(baseSymbol, quoteSymbol string, sell bool, qty uint64) (vwap, extrema uint64, filled bool, err error) { +func (c *tCEX) VWAP(baseID, quoteID uint32, sell bool, qty uint64) (vwap, extrema uint64, filled bool, err error) { if c.vwapErr != nil { return 0, 0, false, c.vwapErr } @@ -119,8 +132,113 @@ func (c *tCEX) SubscribeTradeUpdates() (<-chan *libxc.TradeUpdate, func(), int) func (c *tCEX) SubscribeCEXUpdates() (<-chan interface{}, func()) { return nil, func() {} } +func (c *tCEX) GetDepositAddress(ctx context.Context, assetID uint32) (string, error) { + return c.depositAddress, nil +} -var _ libxc.CEX = (*tCEX)(nil) +func (c *tCEX) Withdraw(ctx context.Context, assetID uint32, qty uint64, address string, onComplete func(uint64, string)) error { + c.lastWithdrawArgs = &withdrawArgs{ + address: address, + amt: qty, + assetID: assetID, + } + onComplete(c.withdrawAmt, c.withdrawTxID) + return nil +} + +func (c *tCEX) ConfirmDeposit(ctx context.Context, txID string, onConfirm func(bool, uint64)) { + c.lastConfirmDepositTx = txID + onConfirm(c.depositConfirmed, c.confirmDepositAmt) +} + +type tWrappedCEX struct { + bidsVWAP map[uint64]vwapResult + asksVWAP map[uint64]vwapResult + vwapErr error + balances map[uint32]*libxc.ExchangeBalance + balanceErr error + tradeID string + tradeErr error + lastTrade *cexOrder + cancelledTrades []string + cancelTradeErr error + tradeUpdates chan *libxc.TradeUpdate + lastWithdrawArgs *withdrawArgs + lastDepositArgs *withdrawArgs + confirmDeposit func() + confirmWithdraw func() +} + +func newTWrappedCEX() *tWrappedCEX { + return &tWrappedCEX{ + bidsVWAP: make(map[uint64]vwapResult), + asksVWAP: make(map[uint64]vwapResult), + balances: make(map[uint32]*libxc.ExchangeBalance), + cancelledTrades: make([]string, 0), + tradeUpdates: make(chan *libxc.TradeUpdate), + } +} + +var _ cex = (*tWrappedCEX)(nil) + +func (c *tWrappedCEX) Balance(assetID uint32) (*libxc.ExchangeBalance, error) { + return c.balances[assetID], c.balanceErr +} +func (c *tWrappedCEX) CancelTrade(ctx context.Context, baseID, quoteID uint32, tradeID string) error { + if c.cancelTradeErr != nil { + return c.cancelTradeErr + } + c.cancelledTrades = append(c.cancelledTrades, tradeID) + return nil +} +func (c *tWrappedCEX) SubscribeMarket(ctx context.Context, baseID, quoteID uint32) error { + return nil +} +func (c *tWrappedCEX) SubscribeTradeUpdates() (updates <-chan *libxc.TradeUpdate, unsubscribe func()) { + return c.tradeUpdates, func() {} +} +func (c *tWrappedCEX) Trade(ctx context.Context, baseID, quoteID uint32, sell bool, rate, qty uint64) (string, error) { + if c.tradeErr != nil { + return "", c.tradeErr + } + c.lastTrade = &cexOrder{baseID, quoteID, qty, rate, sell} + return c.tradeID, nil +} +func (c *tWrappedCEX) VWAP(baseID, quoteID uint32, sell bool, qty uint64) (vwap, extrema uint64, filled bool, err error) { + if c.vwapErr != nil { + return 0, 0, false, c.vwapErr + } + + if sell { + res, found := c.asksVWAP[qty] + if !found { + return 0, 0, false, nil + } + return res.avg, res.extrema, true, nil + } + + res, found := c.bidsVWAP[qty] + if !found { + return 0, 0, false, nil + } + return res.avg, res.extrema, true, nil +} +func (c *tWrappedCEX) Deposit(ctx context.Context, assetID uint32, amount uint64, onConfirm func()) error { + c.lastDepositArgs = &withdrawArgs{ + assetID: assetID, + amt: amount, + } + c.confirmDeposit = onConfirm + return nil +} +func (c *tWrappedCEX) Withdraw(ctx context.Context, assetID uint32, amount uint64, onConfirm func()) error { + c.lastWithdrawArgs = &withdrawArgs{ + assetID: assetID, + amt: amount, + } + c.confirmWithdraw = onConfirm + return nil +} func TestArbRebalance(t *testing.T) { mkt := &core.Market{ @@ -244,6 +362,11 @@ func TestArbRebalance(t *testing.T) { cexAsksExtrema: []uint64{2.5e6, 2.7e6}, } + type assetAmt struct { + assetID uint32 + amt uint64 + } + type test struct { name string books *testBooks @@ -251,17 +374,26 @@ func TestArbRebalance(t *testing.T) { dexMaxBuy *core.MaxOrderEstimate dexMaxSellErr error dexMaxBuyErr error - cexBalances map[string]*libxc.ExchangeBalance - dexVWAPErr error - cexVWAPErr error - cexTradeErr error - existingArbs []*arbSequence + // The strategy uses maxSell/maxBuy to determine how much it can trade. + // dexBalances is just used for auto rebalancing. + dexBalances map[uint32]uint64 + cexBalances map[uint32]*libxc.ExchangeBalance + dexVWAPErr error + cexVWAPErr error + cexTradeErr error + existingArbs []*arbSequence + pendingBaseRebalance bool + pendingQuoteRebalance bool + autoRebalance bool + minBaseAmt uint64 + minQuoteAmt uint64 expectedDexOrder *dexOrder expectedCexOrder *cexOrder expectedDEXCancels []dex.Bytes expectedCEXCancels []string - //expectedActiveArbs []*arbSequence + expectedWithdrawal *assetAmt + expectedDeposit *assetAmt } tests := []test{ @@ -279,9 +411,9 @@ func TestArbRebalance(t *testing.T) { Lots: 5, }, }, - cexBalances: map[string]*libxc.ExchangeBalance{ - "btc": {Available: 1e19}, - "dcr": {Available: 1e19}, + cexBalances: map[uint32]*libxc.ExchangeBalance{ + 0: {Available: 1e19}, + 42: {Available: 1e19}, }, }, // "1 lot, buy on dex, sell on cex" @@ -298,9 +430,9 @@ func TestArbRebalance(t *testing.T) { Lots: 5, }, }, - cexBalances: map[string]*libxc.ExchangeBalance{ - "btc": {Available: 1e19}, - "dcr": {Available: 1e19}, + cexBalances: map[uint32]*libxc.ExchangeBalance{ + 0: {Available: 1e19}, + 42: {Available: 1e19}, }, expectedDexOrder: &dexOrder{ lots: 1, @@ -308,11 +440,11 @@ func TestArbRebalance(t *testing.T) { sell: false, }, expectedCexOrder: &cexOrder{ - baseSymbol: "dcr", - quoteSymbol: "btc", - qty: mkt.LotSize, - rate: 2.2e6, - sell: true, + baseID: 42, + quoteID: 0, + qty: mkt.LotSize, + rate: 2.2e6, + sell: true, }, }, // "1 lot, buy on dex, sell on cex, but dex base balance not enough" @@ -329,9 +461,9 @@ func TestArbRebalance(t *testing.T) { Lots: 5, }, }, - cexBalances: map[string]*libxc.ExchangeBalance{ - "btc": {Available: 1e19}, - "dcr": {Available: mkt.LotSize / 2}, + cexBalances: map[uint32]*libxc.ExchangeBalance{ + 0: {Available: 1e19}, + 42: {Available: mkt.LotSize / 2}, }, }, // "2 lot, buy on dex, sell on cex, but dex quote balance only enough for 1" @@ -349,9 +481,9 @@ func TestArbRebalance(t *testing.T) { }, }, - cexBalances: map[string]*libxc.ExchangeBalance{ - "btc": {Available: 1e19}, - "dcr": {Available: 1e19}, + cexBalances: map[uint32]*libxc.ExchangeBalance{ + 0: {Available: 1e19}, + 42: {Available: 1e19}, }, expectedDexOrder: &dexOrder{ @@ -360,11 +492,11 @@ func TestArbRebalance(t *testing.T) { sell: false, }, expectedCexOrder: &cexOrder{ - baseSymbol: "dcr", - quoteSymbol: "btc", - qty: mkt.LotSize, - rate: 2.2e6, - sell: true, + baseID: 42, + quoteID: 0, + qty: mkt.LotSize, + rate: 2.2e6, + sell: true, }, }, // "1 lot, sell on dex, buy on cex" @@ -381,9 +513,9 @@ func TestArbRebalance(t *testing.T) { Lots: 5, }, }, - cexBalances: map[string]*libxc.ExchangeBalance{ - "btc": {Available: 1e19}, - "dcr": {Available: 1e19}, + cexBalances: map[uint32]*libxc.ExchangeBalance{ + 0: {Available: 1e19}, + 42: {Available: 1e19}, }, expectedDexOrder: &dexOrder{ lots: 1, @@ -391,11 +523,11 @@ func TestArbRebalance(t *testing.T) { sell: true, }, expectedCexOrder: &cexOrder{ - baseSymbol: "dcr", - quoteSymbol: "btc", - qty: mkt.LotSize, - rate: 2e6, - sell: false, + baseID: 42, + quoteID: 0, + qty: mkt.LotSize, + rate: 2e6, + sell: false, }, }, // "2 lot, buy on cex, sell on dex, but cex quote balance only enough for 1" @@ -412,9 +544,9 @@ func TestArbRebalance(t *testing.T) { Lots: 5, }, }, - cexBalances: map[string]*libxc.ExchangeBalance{ - "btc": {Available: calc.BaseToQuote(2e6, mkt.LotSize*3/2)}, - "dcr": {Available: 1e19}, + cexBalances: map[uint32]*libxc.ExchangeBalance{ + 0: {Available: calc.BaseToQuote(2e6, mkt.LotSize*3/2)}, + 42: {Available: 1e19}, }, expectedDexOrder: &dexOrder{ lots: 1, @@ -422,11 +554,11 @@ func TestArbRebalance(t *testing.T) { sell: true, }, expectedCexOrder: &cexOrder{ - baseSymbol: "dcr", - quoteSymbol: "btc", - qty: mkt.LotSize, - rate: 2e6, - sell: false, + baseID: 42, + quoteID: 0, + qty: mkt.LotSize, + rate: 2e6, + sell: false, }, }, // "1 lot, sell on dex, buy on cex" @@ -443,9 +575,9 @@ func TestArbRebalance(t *testing.T) { Lots: 5, }, }, - cexBalances: map[string]*libxc.ExchangeBalance{ - "btc": {Available: 1e19}, - "dcr": {Available: 1e19}, + cexBalances: map[uint32]*libxc.ExchangeBalance{ + 0: {Available: 1e19}, + 42: {Available: 1e19}, }, expectedDexOrder: &dexOrder{ lots: 1, @@ -453,11 +585,11 @@ func TestArbRebalance(t *testing.T) { sell: true, }, expectedCexOrder: &cexOrder{ - baseSymbol: "dcr", - quoteSymbol: "btc", - qty: mkt.LotSize, - rate: 2e6, - sell: false, + baseID: 42, + quoteID: 0, + qty: mkt.LotSize, + rate: 2e6, + sell: false, }, }, // "2 lots arb still above profit trigger, but second not worth it on its own" @@ -474,9 +606,9 @@ func TestArbRebalance(t *testing.T) { Lots: 5, }, }, - cexBalances: map[string]*libxc.ExchangeBalance{ - "btc": {Available: 1e19}, - "dcr": {Available: 1e19}, + cexBalances: map[uint32]*libxc.ExchangeBalance{ + 0: {Available: 1e19}, + 42: {Available: 1e19}, }, expectedDexOrder: &dexOrder{ lots: 1, @@ -484,11 +616,11 @@ func TestArbRebalance(t *testing.T) { sell: false, }, expectedCexOrder: &cexOrder{ - baseSymbol: "dcr", - quoteSymbol: "btc", - qty: mkt.LotSize, - rate: 2.2e6, - sell: true, + baseID: 42, + quoteID: 0, + qty: mkt.LotSize, + rate: 2.2e6, + sell: true, }, }, // "2 lot, buy on cex, sell on dex, but cex quote balance only enough for 1" @@ -505,9 +637,9 @@ func TestArbRebalance(t *testing.T) { Lots: 5, }, }, - cexBalances: map[string]*libxc.ExchangeBalance{ - "btc": {Available: calc.BaseToQuote(2e6, mkt.LotSize*3/2)}, - "dcr": {Available: 1e19}, + cexBalances: map[uint32]*libxc.ExchangeBalance{ + 0: {Available: calc.BaseToQuote(2e6, mkt.LotSize*3/2)}, + 42: {Available: 1e19}, }, expectedDexOrder: &dexOrder{ lots: 1, @@ -515,11 +647,11 @@ func TestArbRebalance(t *testing.T) { sell: true, }, expectedCexOrder: &cexOrder{ - baseSymbol: "dcr", - quoteSymbol: "btc", - qty: mkt.LotSize, - rate: 2e6, - sell: false, + baseID: 42, + quoteID: 0, + qty: mkt.LotSize, + rate: 2e6, + sell: false, }, }, // "cex no asks" @@ -549,9 +681,9 @@ func TestArbRebalance(t *testing.T) { }, }, - cexBalances: map[string]*libxc.ExchangeBalance{ - "btc": {Available: 1e19}, - "dcr": {Available: 1e19}, + cexBalances: map[uint32]*libxc.ExchangeBalance{ + 0: {Available: 1e19}, + 42: {Available: 1e19}, }, }, // "dex no asks" @@ -581,9 +713,9 @@ func TestArbRebalance(t *testing.T) { }, }, - cexBalances: map[string]*libxc.ExchangeBalance{ - "btc": {Available: 1e19}, - "dcr": {Available: 1e19}, + cexBalances: map[uint32]*libxc.ExchangeBalance{ + 0: {Available: 1e19}, + 42: {Available: 1e19}, }, }, // "dex max sell error" @@ -595,9 +727,9 @@ func TestArbRebalance(t *testing.T) { Lots: 5, }, }, - cexBalances: map[string]*libxc.ExchangeBalance{ - "btc": {Available: 1e19}, - "dcr": {Available: 1e19}, + cexBalances: map[uint32]*libxc.ExchangeBalance{ + 0: {Available: 1e19}, + 42: {Available: 1e19}, }, dexMaxSellErr: errors.New(""), }, @@ -610,9 +742,9 @@ func TestArbRebalance(t *testing.T) { Lots: 5, }, }, - cexBalances: map[string]*libxc.ExchangeBalance{ - "btc": {Available: 1e19}, - "dcr": {Available: 1e19}, + cexBalances: map[uint32]*libxc.ExchangeBalance{ + 0: {Available: 1e19}, + 42: {Available: 1e19}, }, dexMaxBuyErr: errors.New(""), }, @@ -630,9 +762,9 @@ func TestArbRebalance(t *testing.T) { Lots: 5, }, }, - cexBalances: map[string]*libxc.ExchangeBalance{ - "btc": {Available: 1e19}, - "dcr": {Available: 1e19}, + cexBalances: map[uint32]*libxc.ExchangeBalance{ + 0: {Available: 1e19}, + 42: {Available: 1e19}, }, dexVWAPErr: errors.New(""), }, @@ -650,9 +782,9 @@ func TestArbRebalance(t *testing.T) { Lots: 5, }, }, - cexBalances: map[string]*libxc.ExchangeBalance{ - "btc": {Available: 1e19}, - "dcr": {Available: 1e19}, + cexBalances: map[uint32]*libxc.ExchangeBalance{ + 0: {Available: 1e19}, + 42: {Available: 1e19}, }, cexVWAPErr: errors.New(""), }, @@ -671,9 +803,9 @@ func TestArbRebalance(t *testing.T) { }, }, - cexBalances: map[string]*libxc.ExchangeBalance{ - "btc": {Available: 1e19}, - "dcr": {Available: 1e19}, + cexBalances: map[uint32]*libxc.ExchangeBalance{ + 0: {Available: 1e19}, + 42: {Available: 1e19}, }, existingArbs: []*arbSequence{{ @@ -741,9 +873,9 @@ func TestArbRebalance(t *testing.T) { }, expectedCEXCancels: []string{cexTradeIDs[1], cexTradeIDs[3]}, expectedDEXCancels: []dex.Bytes{orderIDs[1][:], orderIDs[2][:]}, - cexBalances: map[string]*libxc.ExchangeBalance{ - "btc": {Available: 1e19}, - "dcr": {Available: 1e19}, + cexBalances: map[uint32]*libxc.ExchangeBalance{ + 0: {Available: 1e19}, + 42: {Available: 1e19}, }, }, // "already max active arbs" @@ -760,9 +892,9 @@ func TestArbRebalance(t *testing.T) { Lots: 5, }, }, - cexBalances: map[string]*libxc.ExchangeBalance{ - "btc": {Available: 1e19}, - "dcr": {Available: 1e19}, + cexBalances: map[uint32]*libxc.ExchangeBalance{ + 0: {Available: 1e19}, + 42: {Available: 1e19}, }, existingArbs: []*arbSequence{ { @@ -821,16 +953,112 @@ func TestArbRebalance(t *testing.T) { Lots: 5, }, }, - cexBalances: map[string]*libxc.ExchangeBalance{ - "btc": {Available: 1e19}, - "dcr": {Available: 1e19}, + cexBalances: map[uint32]*libxc.ExchangeBalance{ + 0: {Available: 1e19}, + 42: {Available: 1e19}, }, cexTradeErr: errors.New(""), }, + // "no arb, base needs withdrawal, quote needs deposit" + { + name: "no arb, base needs withdrawal, quote needs deposit", + books: noArbBooks, + dexMaxSell: &core.MaxOrderEstimate{ + Swap: &asset.SwapEstimate{ + Lots: 5, + }, + }, + dexMaxBuy: &core.MaxOrderEstimate{ + Swap: &asset.SwapEstimate{ + Lots: 5, + }, + }, + dexBalances: map[uint32]uint64{ + 42: 1e14, + 0: 1e17, + }, + cexBalances: map[uint32]*libxc.ExchangeBalance{ + 42: {Available: 1e19}, + 0: {Available: 1e10}, + }, + autoRebalance: true, + minBaseAmt: 1e16, + minQuoteAmt: 1e12, + expectedWithdrawal: &assetAmt{ + assetID: 42, + amt: 4.99995e18, + }, + expectedDeposit: &assetAmt{ + assetID: 0, + amt: 4.9999995e16, + }, + }, + // "no arb, quote needs withdrawal, base needs deposit" + { + name: "no arb, quote needs withdrawal, base needs deposit", + books: noArbBooks, + dexMaxSell: &core.MaxOrderEstimate{ + Swap: &asset.SwapEstimate{ + Lots: 5, + }, + }, + dexMaxBuy: &core.MaxOrderEstimate{ + Swap: &asset.SwapEstimate{ + Lots: 5, + }, + }, + dexBalances: map[uint32]uint64{ + 42: 1e19, + 0: 1e10, + }, + cexBalances: map[uint32]*libxc.ExchangeBalance{ + 42: {Available: 1e14}, + 0: {Available: 1e17}, + }, + autoRebalance: true, + minBaseAmt: 1e16, + minQuoteAmt: 1e12, + expectedWithdrawal: &assetAmt{ + assetID: 0, + amt: 4.9999995e16, + }, + expectedDeposit: &assetAmt{ + assetID: 42, + amt: 4.99995e18, + }, + }, + // "no arb, quote needs withdrawal, base needs deposit, already pending" + { + name: "no arb, quote needs withdrawal, base needs deposit, already pending", + books: noArbBooks, + dexMaxSell: &core.MaxOrderEstimate{ + Swap: &asset.SwapEstimate{ + Lots: 5, + }, + }, + dexMaxBuy: &core.MaxOrderEstimate{ + Swap: &asset.SwapEstimate{ + Lots: 5, + }, + }, + dexBalances: map[uint32]uint64{ + 42: 1e19, + 0: 1e10, + }, + cexBalances: map[uint32]*libxc.ExchangeBalance{ + 42: {Available: 1e14}, + 0: {Available: 1e17}, + }, + autoRebalance: true, + minBaseAmt: 1e16, + minQuoteAmt: 1e12, + pendingBaseRebalance: true, + pendingQuoteRebalance: true, + }, } runTest := func(test *test) { - cex := newTCEX() + cex := newTWrappedCEX() cex.vwapErr = test.cexVWAPErr cex.balances = test.cexBalances cex.tradeErr = test.cexTradeErr @@ -840,6 +1068,7 @@ func TestArbRebalance(t *testing.T) { tCore.maxSellEstimate = test.dexMaxSell tCore.maxSellErr = test.dexMaxSellErr tCore.maxBuyErr = test.dexMaxBuyErr + tCore.setAssetBalances(test.dexBalances) if test.expectedDexOrder != nil { tCore.multiTradeResult = []*core.Order{ { @@ -882,9 +1111,15 @@ func TestArbRebalance(t *testing.T) { ProfitTrigger: profitTrigger, MaxActiveArbs: maxActiveArbs, NumEpochsLeaveOpen: numEpochsLeaveOpen, + AutoRebalance: test.autoRebalance, + MinBaseAmt: test.minBaseAmt, + MinQuoteAmt: test.minQuoteAmt, }, } + arbEngine.pendingBaseRebalance.Store(test.pendingBaseRebalance) + arbEngine.pendingQuoteRebalance.Store(test.pendingQuoteRebalance) + go arbEngine.run() dummyNote := &core.BookUpdate{} @@ -967,6 +1202,74 @@ func TestArbRebalance(t *testing.T) { t.Fatalf("%s: expected cex cancel %s but got %s", test.name, test.expectedCEXCancels[i], cex.cancelledTrades[i]) } } + + // Test auto rebalancing + expectBasePending := test.pendingBaseRebalance + expectQuotePending := test.pendingQuoteRebalance + if test.expectedWithdrawal != nil { + if cex.lastWithdrawArgs == nil { + t.Fatalf("%s: expected withdrawal %+v but got none", test.name, test.expectedWithdrawal) + } + if test.expectedWithdrawal.assetID != cex.lastWithdrawArgs.assetID { + t.Fatalf("%s: expected withdrawal asset %d but got %d", test.name, test.expectedWithdrawal.assetID, cex.lastWithdrawArgs.assetID) + } + if test.expectedWithdrawal.amt != cex.lastWithdrawArgs.amt { + t.Fatalf("%s: expected withdrawal amt %d but got %d", test.name, test.expectedWithdrawal.amt, cex.lastWithdrawArgs.amt) + } + if test.expectedWithdrawal.assetID == arbEngine.baseID { + expectBasePending = true + } else { + expectQuotePending = true + } + } else if cex.lastWithdrawArgs != nil { + t.Fatalf("%s: expected no withdrawal but got %+v", test.name, cex.lastWithdrawArgs) + } + if test.expectedDeposit != nil { + if cex.lastDepositArgs == nil { + t.Fatalf("%s: expected deposit %+v but got none", test.name, test.expectedDeposit) + } + if test.expectedDeposit.assetID != cex.lastDepositArgs.assetID { + t.Fatalf("%s: expected deposit asset %d but got %d", test.name, test.expectedDeposit.assetID, cex.lastDepositArgs.assetID) + } + if test.expectedDeposit.amt != cex.lastDepositArgs.amt { + t.Fatalf("%s: expected deposit amt %d but got %d", test.name, test.expectedDeposit.amt, cex.lastDepositArgs.amt) + } + if test.expectedDeposit.assetID == arbEngine.baseID { + expectBasePending = true + } else { + expectQuotePending = true + } + + } else if cex.lastDepositArgs != nil { + t.Fatalf("%s: expected no deposit but got %+v", test.name, cex.lastDepositArgs) + } + if expectBasePending != arbEngine.pendingBaseRebalance.Load() { + t.Fatalf("%s: expected base pending %v but got %v", test.name, expectBasePending, !expectBasePending) + } + if expectQuotePending != arbEngine.pendingQuoteRebalance.Load() { + t.Fatalf("%s: expected base pending %v but got %v", test.name, expectBasePending, !expectBasePending) + } + + // Make sure that when withdraw/deposit is confirmed, the pending field + // gets set back to false. + if cex.confirmWithdraw != nil { + cex.confirmWithdraw() + if cex.lastWithdrawArgs.assetID == arbEngine.baseID && arbEngine.pendingBaseRebalance.Load() { + t.Fatalf("%s: pending base rebalance was not reset after confirmation", test.name) + } + if cex.lastWithdrawArgs.assetID != arbEngine.baseID && arbEngine.pendingQuoteRebalance.Load() { + t.Fatalf("%s: pending quote rebalance was not reset after confirmation", test.name) + } + } + if cex.confirmDeposit != nil { + cex.confirmDeposit() + if cex.lastDepositArgs.assetID == arbEngine.baseID && arbEngine.pendingBaseRebalance.Load() { + t.Fatalf("%s: pending base rebalance was not reset after confirmation", test.name) + } + if cex.lastDepositArgs.assetID != arbEngine.baseID && arbEngine.pendingQuoteRebalance.Load() { + t.Fatalf("%s: pending quote rebalance was not reset after confirmation", test.name) + } + } } for _, test := range tests { @@ -1049,7 +1352,7 @@ func TestArbDexTradeUpdates(t *testing.T) { } runTest := func(test *test) { - cex := newTCEX() + cex := newTWrappedCEX() tCore := newTCore() ctx, cancel := context.WithCancel(context.Background()) @@ -1171,7 +1474,7 @@ func TestCexTradeUpdates(t *testing.T) { } runTest := func(test *test) { - cex := newTCEX() + cex := newTWrappedCEX() tCore := newTCore() ctx, cancel := context.WithCancel(context.Background())