diff --git a/client/mm/config.go b/client/mm/config.go index a6649d242f..6b0278ed14 100644 --- a/client/mm/config.go +++ b/client/mm/config.go @@ -37,14 +37,28 @@ type CEXConfig struct { APISecret string `json:"apiSecret"` } -// BotCEXCfg specifies the CEX that a bot uses and the initial balances -// that should be allocated to the bot on that CEX. +// AutoRebalanceConfig determines how the bot will automatically rebalance its +// assets between the CEX and DEX. If the base or quote asset dips below the +// minimum amount, a transfer will take place, but only if both balances can be +// brought above the minimum amount and the transfer amount would be above the +// minimum transfer amount. +type AutoRebalanceConfig struct { + MinBaseAmt uint64 `json:"minBaseAmt"` + MinBaseTransfer uint64 `json:"minBaseTransfer"` + MinQuoteAmt uint64 `json:"minQuoteAmt"` + MinQuoteTransfer uint64 `json:"minQuoteTransfer"` +} + +// BotCEXCfg specifies the CEX that a bot uses, the initial balances +// that should be allocated to the bot on that CEX, and the configuration +// for automatically rebalancing between the CEX and DEX. type BotCEXCfg struct { - Name string `json:"name"` - BaseBalanceType BalanceType `json:"baseBalanceType"` - BaseBalance uint64 `json:"baseBalance"` - QuoteBalanceType BalanceType `json:"quoteBalanceType"` - QuoteBalance uint64 `json:"quoteBalance"` + Name string `json:"name"` + BaseBalanceType BalanceType `json:"baseBalanceType"` + BaseBalance uint64 `json:"baseBalance"` + QuoteBalanceType BalanceType `json:"quoteBalanceType"` + QuoteBalance uint64 `json:"quoteBalance"` + AutoRebalance *AutoRebalanceConfig `json:"autoRebalance"` } // BotConfig is the configuration for a market making bot. @@ -66,6 +80,9 @@ type BotConfig struct { QuoteFeeAssetBalanceType BalanceType `json:"quoteFeeAssetBalanceType"` QuoteFeeAssetBalance uint64 `json:"quoteFeeAssetBalance"` + BaseWalletOptions map[string]string `json:"baseWalletOptions"` + QuoteWalletOptions map[string]string `json:"quoteWalletOptions"` + // Only applicable for arb bots. CEXCfg *BotCEXCfg `json:"cexCfg"` @@ -91,15 +108,3 @@ func (c *BotConfig) requiresCEX() bool { func dexMarketID(host string, base, quote uint32) string { return fmt.Sprintf("%s-%d-%d", host, base, quote) } - -// AutoRebalanceConfig determines how the bot will automatically rebalance its -// assets between the CEX and DEX. If the base or quote asset dips below the -// minimum amount, a transfer will take place, but only if both balances can be -// brought above the minimum amount and the transfer amount would be above the -// minimum transfer amount. -type AutoRebalanceConfig struct { - MinBaseAmt uint64 `json:"minBaseAmt"` - MinBaseTransfer uint64 `json:"minBaseTransfer"` - MinQuoteAmt uint64 `json:"minQuoteAmt"` - MinQuoteTransfer uint64 `json:"minQuoteTransfer"` -} diff --git a/client/mm/exchange_adaptor.go b/client/mm/exchange_adaptor.go index c37bc66ef6..10fead57a1 100644 --- a/client/mm/exchange_adaptor.go +++ b/client/mm/exchange_adaptor.go @@ -7,6 +7,8 @@ import ( "context" "errors" "fmt" + "math" + "sort" "strings" "sync" "sync/atomic" @@ -29,40 +31,61 @@ type botBalance struct { Pending uint64 `json:"pending"` } -// botCoreAdaptor is an interface used by bots to access functionality -// implemented by client.Core. There are slight differences with the methods -// of client.Core. One example is AssetBalance is renamed to DEXBalance and -// returns a a botBalance instead of a *core.WalletBalance. +// multiTradePlacement represents a placement to be made on a DEX order book +// using the MultiTrade function. A non-zero counterTradeRate indicates that +// the bot intends to make a counter-trade on a CEX when matches are made on +// the DEX, and this must be taken into consideration in combination with the +// bot's balance on the CEX when deciding how many lots to place. This +// information is also used when considering deposits and withdrawals. +type multiTradePlacement struct { + lots uint64 + rate uint64 + counterTradeRate uint64 +} + +// orderFees represents the fees that will be required for a single lot of a +// dex order. +type orderFees struct { + swap uint64 + redemption uint64 + refund uint64 + funding uint64 +} + +// botCoreAdaptor is an interface used by bots to access DEX related +// functions. Common functionality used by multiple market making +// strategies is implemented here. The functions in this interface +// do not need to take assetID parameters, as the bot will only be +// trading on a single DEX market. type botCoreAdaptor interface { - NotificationFeed() *core.NoteFeed SyncBook(host string, base, quote uint32) (*orderbook.OrderBook, core.BookFeed, error) - SingleLotFees(form *core.SingleLotFeesForm) (uint64, uint64, uint64, error) Cancel(oidB dex.Bytes) error - MaxBuy(host string, base, quote uint32, rate uint64) (*core.MaxOrderEstimate, error) - MaxSell(host string, base, quote uint32) (*core.MaxOrderEstimate, error) - DEXBalance(assetID uint32) (*botBalance, error) - MultiTrade(pw []byte, form *core.MultiTradeForm) ([]*core.Order, error) - MaxFundingFees(fromAsset uint32, host string, numTrades uint32, fromSettings map[string]string) (uint64, error) - FiatConversionRates() map[uint32]float64 + DEXTrade(rate, qty uint64, sell bool) (*core.Order, error) ExchangeMarket(host string, base, quote uint32) (*core.Market, error) + MultiTrade(placements []*multiTradePlacement, sell bool, driftTolerance float64, currEpoch uint64, dexReserves, cexReserves map[uint32]uint64) []*order.OrderID + CancelAllOrders() bool + ExchangeRateFromFiatSources() uint64 + OrderFeesInUnits(sell, base bool, rate uint64) (uint64, error) + SubscribeOrderUpdates() (updates <-chan *core.Order) + SufficientBalanceForDEXTrade(rate, qty uint64, sell bool) (bool, error) } -// botCexAdaptor is an interface used by bots to access functionality -// related to a CEX. Some of the functions are implemented by libxc.CEX, but -// have some differences, since this interface is meant to be used by only -// one caller. For example, Trade does not take a subscriptionID, and -// SubscribeTradeUpdates does not return one. Deposit is not available -// on libxc.CEX as it involves sending funds from the DEX wallet, but it is -// exposed here. +// botCexAdaptor is an interface used by bots to access CEX related +// functions. Common functionality used by multiple market making +// strategies is implemented here. The functions in this interface +// take assetID parameters, unlike botCoreAdaptor, to support a +// multi-hop strategy. type botCexAdaptor interface { - CEXBalance(assetID uint32) (*botBalance, error) CancelTrade(ctx context.Context, baseID, quoteID uint32, tradeID string) error SubscribeMarket(ctx context.Context, baseID, quoteID uint32) error SubscribeTradeUpdates() (updates <-chan *libxc.Trade, unsubscribe func()) - Trade(ctx context.Context, baseID, quoteID uint32, sell bool, rate, qty uint64) (*libxc.Trade, error) + CEXTrade(ctx context.Context, baseID, quoteID uint32, sell bool, rate, qty uint64) (*libxc.Trade, error) + SufficientBalanceForCEXTrade(baseID, quoteID uint32, sell bool, rate, qty uint64) (bool, error) VWAP(baseID, quoteID uint32, sell bool, qty uint64) (vwap, extrema uint64, filled bool, err error) - Deposit(ctx context.Context, assetID uint32, amount uint64, onConfirm func()) error - Withdraw(ctx context.Context, assetID uint32, amount uint64, onConfirm func()) error + PrepareRebalance(ctx context.Context, assetID uint32) (rebalance int64, dexReserves, cexReserves uint64) + FreeUpFunds(assetID uint32, cex bool, amt uint64, currEpoch uint64) + Deposit(ctx context.Context, assetID uint32, amount uint64) error + Withdraw(ctx context.Context, assetID uint32, amount uint64) error } // pendingWithdrawal represents a withdrawal from a CEX that has been @@ -99,6 +122,7 @@ type pendingDEXOrder struct { availableDiff map[uint32]int64 locked map[uint32]uint64 pending map[uint32]uint64 + order *core.Order // swaps, redeems, and refunds are caches of transactions. This avoids // having to query the wallet for transactions that are already confirmed. @@ -106,6 +130,11 @@ type pendingDEXOrder struct { swaps map[string]*asset.WalletTransaction redeems map[string]*asset.WalletTransaction refunds map[string]*asset.WalletTransaction + + // placementIndex/counterTradeRate are used by MultiTrade to know + // which orders to place/cancel. + placementIndex uint64 + counterTradeRate uint64 } // unifiedExchangeAdaptor implements both botCoreAdaptor and botCexAdaptor. @@ -113,8 +142,16 @@ type unifiedExchangeAdaptor struct { clientCore libxc.CEX - botID string - log dex.Logger + botID string + log dex.Logger + fiatRates atomic.Value // map[uint32]float64 + orderUpdates atomic.Value // chan *core.Order + market *MarketWithHost + baseWalletOptions map[string]string + quoteWalletOptions map[string]string + maxBuyPlacements uint32 + maxSellPlacements uint32 + rebalanceCfg *AutoRebalanceConfig subscriptionIDMtx sync.RWMutex subscriptionID *int @@ -122,6 +159,10 @@ type unifiedExchangeAdaptor struct { withdrawalNoncePrefix string withdrawalNonce atomic.Uint64 + feesMtx sync.RWMutex + buyFees *orderFees + sellFees *orderFees + balancesMtx sync.RWMutex // baseDEXBalance/baseCEXBalance are the balances the bots have before // taking into account any pending actions. These are updated whenever @@ -132,6 +173,13 @@ type unifiedExchangeAdaptor struct { pendingCEXOrders map[string]*libxc.Trade pendingWithdrawals map[string]*pendingWithdrawal pendingDeposits map[string]*pendingDeposit + + // 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 } var _ botCoreAdaptor = (*unifiedExchangeAdaptor)(nil) @@ -190,230 +238,511 @@ func (u *unifiedExchangeAdaptor) logBalanceAdjustments(dexDiffs, cexDiffs map[ui u.log.Infof(msg.String()) } -func (u *unifiedExchangeAdaptor) maxBuyQty(host string, baseID, quoteID uint32, rate uint64, options map[string]string) (uint64, error) { - baseBalance, err := u.DEXBalance(baseID) - if err != nil { - return 0, err - } - quoteBalance, err := u.DEXBalance(quoteID) - if err != nil { - return 0, err +// SufficientBalanceForDEXTrade returns whether the bot has sufficient balance +// to place a DEX trade. +func (u *unifiedExchangeAdaptor) SufficientBalanceForDEXTrade(rate, qty uint64, sell bool) (bool, error) { + fromAsset, fromFeeAsset, toAsset, toFeeAsset := orderAssets(u.market.BaseID, u.market.QuoteID, sell) + balances := map[uint32]uint64{} + for _, assetID := range []uint32{fromAsset, fromFeeAsset, toAsset, toFeeAsset} { + if _, found := balances[assetID]; !found { + bal, err := u.DEXBalance(assetID) + if err != nil { + return false, err + } + balances[assetID] = bal.Available + } } - availBaseBal, availQuoteBal := baseBalance.Available, quoteBalance.Available - mkt, err := u.clientCore.ExchangeMarket(host, baseID, quoteID) + mkt, err := u.ExchangeMarket(u.market.Host, u.market.BaseID, u.market.QuoteID) if err != nil { - return 0, err + return false, err } - fundingFees, err := u.clientCore.MaxFundingFees(quoteID, host, 1, options) + buyFees, sellFees, err := u.orderFees() if err != nil { - return 0, err + return false, err + } + fees := buyFees + if sell { + fees = sellFees } - swapFees, redeemFees, refundFees, err := u.clientCore.SingleLotFees(&core.SingleLotFeesForm{ - Host: host, - Base: baseID, - Quote: quoteID, - UseMaxFeeRate: true, - }) - if err != nil { - return 0, err + if balances[fromFeeAsset] < fees.funding { + return false, nil } + balances[fromFeeAsset] -= fees.funding - if availQuoteBal > fundingFees { - availQuoteBal -= fundingFees - } else { - availQuoteBal = 0 + fromQty := qty + if !sell { + fromQty = calc.BaseToQuote(rate, qty) + } + if balances[fromAsset] < fromQty { + return false, nil } + balances[fromAsset] -= fromQty - // Account based coins require the refund fees to be reserved as well. - if !u.isAccountLocker(quoteID) { - refundFees = 0 + numLots := qty / mkt.LotSize + if balances[fromFeeAsset] < numLots*fees.swap { + return false, nil } + balances[fromFeeAsset] -= numLots * fees.swap - lotSizeQuote := calc.BaseToQuote(rate, mkt.LotSize) - maxLots := availQuoteBal / (lotSizeQuote + swapFees + refundFees) + if u.isAccountLocker(fromAsset) { + if balances[fromFeeAsset] < numLots*fees.refund { + return false, nil + } + balances[fromFeeAsset] -= numLots * fees.refund + } - if redeemFees > 0 && u.isAccountLocker(baseID) { - maxBaseLots := availBaseBal / redeemFees - if maxLots > maxBaseLots { - maxLots = maxBaseLots + if u.isAccountLocker(toAsset) { + if balances[toFeeAsset] < numLots*fees.redemption { + return false, nil } + balances[toFeeAsset] -= numLots * fees.redemption } - return maxLots * mkt.LotSize, nil + return true, nil } -func (u *unifiedExchangeAdaptor) maxSellQty(host string, baseID, quoteID, numTrades uint32, options map[string]string) (uint64, error) { - baseBalance, err := u.DEXBalance(baseID) - if err != nil { - return 0, err - } - quoteBalance, err := u.DEXBalance(quoteID) - if err != nil { - return 0, err +// SufficientBalanceOnCEXTrade returns whether the bot has sufficient balance +// to place a CEX trade. +func (u *unifiedExchangeAdaptor) SufficientBalanceForCEXTrade(baseID, quoteID uint32, sell bool, rate, qty uint64) (bool, error) { + var fromAssetID uint32 + var fromAssetQty uint64 + if sell { + fromAssetID = u.market.BaseID + fromAssetQty = qty + } else { + fromAssetID = u.market.QuoteID + fromAssetQty = calc.BaseToQuote(rate, qty) } - availBaseBal, availQuoteBal := baseBalance.Available, quoteBalance.Available - mkt, err := u.ExchangeMarket(host, baseID, quoteID) + fromAssetBal, err := u.CEXBalance(fromAssetID) if err != nil { - return 0, err + return false, err } - fundingFees, err := u.MaxFundingFees(baseID, host, numTrades, options) - if err != nil { - return 0, err - } + return fromAssetBal.Available >= fromAssetQty, nil +} - swapFees, redeemFees, refundFees, err := u.SingleLotFees(&core.SingleLotFeesForm{ - Host: host, - Base: baseID, - Quote: quoteID, - Sell: true, - UseMaxFeeRate: true, - }) - if err != nil { - return 0, err +// dexOrderInfo is used by MultiTrade to keep track of the placement index +// and counter trade rate of an order. +type dexOrderInfo struct { + placementIndex uint64 + counterTradeRate uint64 +} + +// addPendingDexOrders adds new orders to the pendingDEXOrders map. balancesMtx +// must be locked before calling this method. idToInfo maps orders to their +// placement index and counter trade rate. +func (u *unifiedExchangeAdaptor) addPendingDexOrders(orders []*core.Order, idToInfo map[order.OrderID]dexOrderInfo) { + for _, o := range orders { + var orderID order.OrderID + copy(orderID[:], o.ID) + + fromAsset, fromFeeAsset, _, toFeeAsset := orderAssets(o.BaseID, o.QuoteID, o.Sell) + + availableDiff := map[uint32]int64{} + availableDiff[fromAsset] -= int64(o.LockedAmt) + availableDiff[fromFeeAsset] -= int64(o.ParentAssetLockedAmt + o.RefundLockedAmt) + availableDiff[toFeeAsset] -= int64(o.RedeemLockedAmt) + if o.FeesPaid != nil { + availableDiff[fromFeeAsset] -= int64(o.FeesPaid.Funding) + } + + locked := map[uint32]uint64{} + locked[fromAsset] += o.LockedAmt + locked[fromFeeAsset] += o.ParentAssetLockedAmt + o.RefundLockedAmt + locked[toFeeAsset] += o.RedeemLockedAmt + + orderInfo := idToInfo[orderID] + u.pendingDEXOrders[orderID] = &pendingDEXOrder{ + swaps: make(map[string]*asset.WalletTransaction), + redeems: make(map[string]*asset.WalletTransaction), + refunds: make(map[string]*asset.WalletTransaction), + availableDiff: availableDiff, + locked: locked, + pending: map[uint32]uint64{}, + order: o, + placementIndex: orderInfo.placementIndex, + counterTradeRate: orderInfo.counterTradeRate, + } } +} - if availBaseBal > fundingFees { - availBaseBal -= fundingFees - } else { - availBaseBal = 0 +// groupedBookedOrders returns pending dex orders grouped by the placement +// index used to create them when they were placed with MultiTrade. +func (u *unifiedExchangeAdaptor) groupedBookedOrders(sells bool) (orders map[uint64][]*pendingDEXOrder) { + orders = make(map[uint64][]*pendingDEXOrder) + + groupPendingOrder := func(pendingOrder *pendingDEXOrder) { + pendingOrder.balancesMtx.RLock() + defer pendingOrder.balancesMtx.RUnlock() + + if pendingOrder.order.Status > order.OrderStatusBooked { + return + } + + pi := pendingOrder.placementIndex + if sells == pendingOrder.order.Sell { + if orders[pi] == nil { + orders[pi] = []*pendingDEXOrder{} + } + orders[pi] = append(orders[pi], pendingOrder) + } } - // Account based coins require the refund fees to be reserved as well. - if !u.isAccountLocker(baseID) { - refundFees = 0 + u.balancesMtx.RLock() + defer u.balancesMtx.RUnlock() + + for _, pendingOrder := range u.pendingDEXOrders { + groupPendingOrder(pendingOrder) } - maxLots := availBaseBal / (mkt.LotSize + swapFees + refundFees) - if u.isAccountLocker(quoteID) && redeemFees > 0 { - maxQuoteLots := availQuoteBal / redeemFees - if maxLots > maxQuoteLots { - maxLots = maxQuoteLots + return +} + +// rateCausesSelfMatchFunc returns a function that can be called to determine +// whether a rate would cause a self match. The sell parameter indicates whether +// the returned function will support sell or buy orders. +func (u *unifiedExchangeAdaptor) rateCausesSelfMatchFunc(sell bool) func(rate uint64) bool { + var highestExistingBuy, lowestExistingSell uint64 = 0, math.MaxUint64 + + for _, groupedOrders := range u.groupedBookedOrders(!sell) { + for _, o := range groupedOrders { + if sell && o.order.Rate > highestExistingBuy { + highestExistingBuy = o.order.Rate + } + if !sell && o.order.Rate < lowestExistingSell { + lowestExistingSell = o.order.Rate + } } } - return maxLots * mkt.LotSize, nil + return func(rate uint64) bool { + if sell { + return rate <= highestExistingBuy + } + return rate >= lowestExistingSell + } } -func (c *unifiedExchangeAdaptor) sufficientBalanceForMultiSell(host string, base, quote uint32, placements []*core.QtyRate, options map[string]string) (bool, error) { - var totalQty uint64 - for _, placement := range placements { - totalQty += placement.Qty - } - maxQty, err := c.maxSellQty(host, base, quote, uint32(len(placements)), options) - if err != nil { - return false, err +// reservedForCounterTrade returns the amount of funds the CEX needs to make +// counter trades if all of the pending DEX orders are filled. +func reservedForCounterTrade(orderGroups map[uint64][]*pendingDEXOrder) uint64 { + var reserved uint64 + for _, g := range orderGroups { + for _, o := range g { + if o.order.Sell { + reserved += calc.BaseToQuote(o.counterTradeRate, o.order.Qty-o.order.Filled) + } else { + reserved += o.order.Qty - o.order.Filled + } + } } - return maxQty >= totalQty, nil + return reserved +} + +func withinTolerance(rate, target uint64, driftTolerance float64) bool { + tolerance := uint64(float64(target) * driftTolerance) + lowerBound := target - tolerance + upperBound := target + tolerance + return rate >= lowerBound && rate <= upperBound } -func (u *unifiedExchangeAdaptor) sufficientBalanceForMultiBuy(host string, baseID, quoteID uint32, placements []*core.QtyRate, options map[string]string) (bool, error) { - baseBalance, err := u.DEXBalance(baseID) +// MultiTrade places multiple orders on the DEX order book. The placements +// arguments does not represent the trades that should be placed at this time, +// but rather the amount of lots that the caller expects consistently have on +// the orderbook at various rates. It is expected that the caller will +// periodically (each epoch) call this function with the same number of +// placements in the same order, with the rates updated to reflect the current +// market conditions. +// +// When an order is placed, the index of the placement that initiated the order +// is tracked. On subsequent calls, as the rates change, the placements will be +// compared with prior trades with the same placement index. If the trades on +// the books differ from the rates in the placements by greater than +// driftTolerance, the orders will be cancelled. As orders get filled, and there +// are less than the number of lots specified in the placement on the books, +// new trades will be made. +// +// The caller can pass a rate of 0 for any placement to indicate that all orders +// that were made during previous calls to MultiTrade with the same placement index +// should be cancelled. +// +// dexReserves and cexReserves are the amount of funds that should not be used to +// place orders. These are used in case the bot is about to make a deposit or +// withdrawal, and does not want those funds to get locked up in a trade. +// +// The placements must be passed in decreasing priority order. If there is not +// enough balance to place all of the orders, the lower priority trades that +// were made in previous calls will be cancelled. +func (u *unifiedExchangeAdaptor) MultiTrade(placements []*multiTradePlacement, sell bool, driftTolerance float64, currEpoch uint64, dexReserves, cexReserves map[uint32]uint64) []*order.OrderID { + if len(placements) == 0 { + return nil + } + mkt, err := u.ExchangeMarket(u.market.Host, u.market.BaseID, u.market.QuoteID) if err != nil { - return false, err + u.log.Errorf("GroupedMultiTrade: error getting market: %v", err) + return nil } - quoteBalance, err := u.DEXBalance(quoteID) + buyFees, sellFees, err := u.orderFees() if err != nil { - return false, err + u.log.Errorf("GroupedMultiTrade: error getting order fees: %v", err) + return nil } - availBaseBal, availQuoteBal := baseBalance.Available, quoteBalance.Available - - mkt, err := u.ExchangeMarket(host, baseID, quoteID) - if err != nil { - return false, err + fees := buyFees + if sell { + fees = sellFees } + fromAsset, fromFeeAsset, toAsset, toFeeAsset := orderAssets(mkt.BaseID, mkt.QuoteID, sell) - swapFees, redeemFees, refundFees, err := u.SingleLotFees(&core.SingleLotFeesForm{ - Host: host, - Base: baseID, - Quote: quoteID, - UseMaxFeeRate: true, - }) - if err != nil { - return false, err + // First, determine the amount of balances the bot has available to place + // DEX trades taking into account dexReserves. + remainingBalances := map[uint32]uint64{} + for _, assetID := range []uint32{fromAsset, fromFeeAsset, toAsset, toFeeAsset} { + if _, found := remainingBalances[assetID]; !found { + bal, err := u.DEXBalance(assetID) + if err != nil { + u.log.Errorf("GroupedMultiTrade: error getting dex balance: %v", err) + return nil + } + availableBalance := bal.Available + if dexReserves != nil { + if dexReserves[assetID] > availableBalance { + u.log.Errorf("GroupedMultiTrade: insufficient dex balance for reserves. required: %d, have: %d", dexReserves[assetID], availableBalance) + return nil + } + availableBalance -= dexReserves[assetID] + } + remainingBalances[assetID] = availableBalance + } } - - if !u.isAccountLocker(quoteID) { - refundFees = 0 + if remainingBalances[fromFeeAsset] < fees.funding { + u.log.Debugf("GroupedMultiTrade: insufficient balance for funding fees. required: %d, have: %d", fees.funding, remainingBalances[fromFeeAsset]) + return nil } + remainingBalances[fromFeeAsset] -= fees.funding - fundingFees, err := u.MaxFundingFees(quoteID, host, uint32(len(placements)), options) - if err != nil { - return false, err - } - if availQuoteBal < fundingFees { - return false, nil + // If the placements include a counterTradeRate, the CEX balance must also + // be taken into account to determine how many trades can be placed. + accountForCEXBal := placements[0].counterTradeRate > 0 + var remainingCEXBal uint64 + if accountForCEXBal { + cexBal, err := u.CEXBalance(toAsset) + if err != nil { + u.log.Errorf("GroupedMultiTrade: error getting cex balance: %v", err) + return nil + } + remainingCEXBal = cexBal.Available + reserves := cexReserves[toAsset] + if remainingCEXBal < reserves { + u.log.Errorf("GroupedMultiTrade: insufficient CEX balance for reserves. required: %d, have: %d", cexReserves, remainingCEXBal) + return nil + } + remainingCEXBal -= reserves } - var totalLots uint64 - remainingBalance := availQuoteBal - fundingFees - for _, placement := range placements { - quoteQty := calc.BaseToQuote(placement.Rate, placement.Qty) - numLots := placement.Qty / mkt.LotSize - totalLots += numLots - req := quoteQty + (numLots * (swapFees + refundFees)) - if remainingBalance < req { - return false, nil + u.balancesMtx.RLock() + + cancels := make([]dex.Bytes, 0, len(placements)) + addCancel := func(o *core.Order) { + if currEpoch-o.Epoch < 2 { // TODO: check epoch + u.log.Debugf("GroupedMultiTrade: skipping cancel not past free cancel threshold") + return + } + cancels = append(cancels, o.ID) + } + + pendingOrders := u.groupedBookedOrders(sell) + + // requiredPlacements is a copy of placements where the lots field is + // adjusted to take into account pending orders that are already on + // the books. + requiredPlacements := make([]*multiTradePlacement, 0, len(placements)) + // ordersWithinTolerance is a list of pending orders that are within + // tolerance of their currently expected rate, in decreasing order + // by placementIndex. If the bot doesn't have enough balance to place + // an order with a higher priority (lower placementIndex) then the + // lower priority orders will be cancelled. + ordersWithinTolerance := make([]*pendingDEXOrder, 0, len(placements)) + for _, p := range placements { + pCopy := *p + requiredPlacements = append(requiredPlacements, &pCopy) + } + for _, groupedOrders := range pendingOrders { + for _, o := range groupedOrders { + if !withinTolerance(o.order.Rate, placements[o.placementIndex].rate, driftTolerance) { + addCancel(o.order) + } else { + ordersWithinTolerance = append([]*pendingDEXOrder{o}, ordersWithinTolerance...) + } + + if requiredPlacements[o.placementIndex].lots > (o.order.Qty-o.order.Filled)/mkt.LotSize { + requiredPlacements[o.placementIndex].lots -= (o.order.Qty - o.order.Filled) / mkt.LotSize + } else { + requiredPlacements[o.placementIndex].lots = 0 + } } - remainingBalance -= req } - if u.isAccountLocker(baseID) && availBaseBal < redeemFees*totalLots { - return false, nil + if accountForCEXBal { + reserved := reservedForCounterTrade(pendingOrders) + if remainingCEXBal < reserved { + u.log.Errorf("GroupedMultiTrade: insufficient CEX balance for counter trades. required: %d, have: %d", reserved, remainingCEXBal) + return nil + } + remainingCEXBal -= reserved } - return true, nil -} + rateCausesSelfMatch := u.rateCausesSelfMatchFunc(sell) -func (c *unifiedExchangeAdaptor) sufficientBalanceForMultiTrade(host string, base, quote uint32, sell bool, placements []*core.QtyRate, options map[string]string) (bool, error) { - if sell { - return c.sufficientBalanceForMultiSell(host, base, quote, placements, options) + // corePlacements will be the placements that are passed to core's + // MultiTrade function. + corePlacements := make([]*core.QtyRate, 0, len(requiredPlacements)) + orderInfos := make([]*dexOrderInfo, 0, len(requiredPlacements)) + for i, placement := range requiredPlacements { + if placement.lots == 0 { + continue + } + + if rateCausesSelfMatch(placement.rate) { + u.log.Warnf("GroupedMultiTrade: rate %d causes self match. Placements should be farther from mid-gap.", placement.rate) + continue + } + + var lotsToPlace uint64 + for l := 0; l < int(placement.lots); l++ { + qty := mkt.LotSize + if !sell { + qty = calc.BaseToQuote(placement.rate, mkt.LotSize) + } + if remainingBalances[fromAsset] < qty { + break + } + remainingBalances[fromAsset] -= qty + + if remainingBalances[fromFeeAsset] < fees.swap { + break + } + remainingBalances[fromFeeAsset] -= fees.swap + + if u.isAccountLocker(fromAsset) { + if remainingBalances[fromFeeAsset] < fees.refund { + break + } + remainingBalances[fromFeeAsset] -= fees.refund + } + + if u.isAccountLocker(toAsset) { + if remainingBalances[toFeeAsset] < fees.redemption { + break + } + remainingBalances[toFeeAsset] -= fees.redemption + } + + if accountForCEXBal { + counterTradeQty := mkt.LotSize + if sell { + counterTradeQty = calc.BaseToQuote(placement.counterTradeRate, mkt.LotSize) + } + if remainingCEXBal < counterTradeQty { + break + } + remainingCEXBal -= counterTradeQty + } + + lotsToPlace = uint64(l + 1) + } + + if lotsToPlace > 0 { + corePlacements = append(corePlacements, &core.QtyRate{ + Qty: lotsToPlace * mkt.LotSize, + Rate: placement.rate, + }) + orderInfos = append(orderInfos, &dexOrderInfo{ + placementIndex: uint64(i), + counterTradeRate: placement.counterTradeRate, + }) + } + + // If there is insufficient balance to place a higher priority order, + // cancel the lower priority orders. + if lotsToPlace < placement.lots { + for _, o := range ordersWithinTolerance { + if o.placementIndex > uint64(i) { + addCancel(o.order) + } + } + + break + } } - return c.sufficientBalanceForMultiBuy(host, base, quote, placements, options) -} -func (u *unifiedExchangeAdaptor) addPendingDexOrders(orders []*core.Order) { - u.balancesMtx.Lock() - defer u.balancesMtx.Unlock() + u.balancesMtx.RUnlock() - for _, o := range orders { - var orderID order.OrderID - copy(orderID[:], o.ID) + for _, cancel := range cancels { + if err := u.Cancel(cancel); err != nil { + u.log.Errorf("GroupedMultiTrade: error canceling order %s: %v", cancel, err) + } + } - fromAsset, fromFeeAsset, _, toFeeAsset := orderAssets(o) + if len(corePlacements) > 0 { + var walletOptions map[string]string + if sell { + walletOptions = u.baseWalletOptions + } else { + walletOptions = u.quoteWalletOptions + } - availableDiff := map[uint32]int64{} - availableDiff[fromAsset] -= int64(o.LockedAmt) - availableDiff[fromFeeAsset] -= int64(o.ParentAssetLockedAmt + o.RefundLockedAmt) - availableDiff[toFeeAsset] -= int64(o.RedeemLockedAmt) - if o.FeesPaid != nil { - availableDiff[fromFeeAsset] -= int64(o.FeesPaid.Funding) + fromBalance, err := u.DEXBalance(fromAsset) + if err != nil { + u.log.Errorf("GroupedMultiTrade: error getting dex balance: %v", err) + return nil } - locked := map[uint32]uint64{} - locked[fromAsset] += o.LockedAmt - locked[fromFeeAsset] += o.ParentAssetLockedAmt + o.RefundLockedAmt - locked[toFeeAsset] += o.RedeemLockedAmt + multiTradeForm := &core.MultiTradeForm{ + Host: u.market.Host, + Base: u.market.BaseID, + Quote: u.market.QuoteID, + Sell: sell, + Placements: corePlacements, + Options: walletOptions, + MaxLock: fromBalance.Available, + } - u.pendingDEXOrders[orderID] = &pendingDEXOrder{ - swaps: make(map[string]*asset.WalletTransaction), - redeems: make(map[string]*asset.WalletTransaction), - refunds: make(map[string]*asset.WalletTransaction), - availableDiff: availableDiff, - locked: locked, - pending: map[uint32]uint64{}, + u.balancesMtx.Lock() + defer u.balancesMtx.Unlock() + + orders, err := u.clientCore.MultiTrade([]byte{}, multiTradeForm) + if err != nil { + u.log.Errorf("GroupedMultiTrade: error placing orders: %v", err) + return nil + } + + // The return value contains a nil order ID if no order was made for the placement + // at that index. + toReturn := make([]*order.OrderID, len(placements)) + idToInfo := make(map[order.OrderID]dexOrderInfo) + for i, o := range orders { + info := orderInfos[i] + var orderID order.OrderID + copy(orderID[:], o.ID) + toReturn[info.placementIndex] = &orderID + idToInfo[orderID] = *info } + + u.addPendingDexOrders(orders, idToInfo) + + return toReturn } + + return nil } -// MultiTrade is used to place multiple standing limit orders on the same -// side of the same DEX market simultaneously. -func (u *unifiedExchangeAdaptor) MultiTrade(pw []byte, form *core.MultiTradeForm) ([]*core.Order, error) { - enough, err := u.sufficientBalanceForMultiTrade(form.Host, form.Base, form.Quote, form.Sell, form.Placements, form.Options) +// DEXTrade places a single order on the DEX order book. +func (u *unifiedExchangeAdaptor) DEXTrade(rate, qty uint64, sell bool) (*core.Order, error) { + enough, err := u.SufficientBalanceForDEXTrade(rate, qty, sell) if err != nil { return nil, err } @@ -421,83 +750,53 @@ func (u *unifiedExchangeAdaptor) MultiTrade(pw []byte, form *core.MultiTradeForm return nil, fmt.Errorf("insufficient balance") } - fromAsset := form.Quote - if form.Sell { - fromAsset = form.Base + fromAsset := u.market.QuoteID + if sell { + fromAsset = u.market.BaseID } fromBalance, err := u.DEXBalance(fromAsset) if err != nil { return nil, err } - form.MaxLock = fromBalance.Available - - orders, err := u.clientCore.MultiTrade(pw, form) - if err != nil { - return nil, err - } - u.addPendingDexOrders(orders) - - return orders, nil -} - -// MayBuy returns the maximum quantity of the base asset that the bot can -// buy for rate using its balance of the quote asset. -func (u *unifiedExchangeAdaptor) MaxBuy(host string, base, quote uint32, rate uint64) (*core.MaxOrderEstimate, error) { - maxQty, err := u.maxBuyQty(host, base, quote, rate, nil) - if err != nil { - return nil, err - } - if maxQty == 0 { - return nil, fmt.Errorf("insufficient balance") + var walletOptions map[string]string + if sell { + walletOptions = u.baseWalletOptions + } else { + walletOptions = u.quoteWalletOptions } - orderEstimate, err := u.clientCore.PreOrder(&core.TradeForm{ - Host: host, - IsLimit: true, - Base: base, - Quote: quote, - Qty: maxQty, - Rate: rate, - // TODO: handle options. need new option for split if remaining balance < certain amount. - }) - if err != nil { - return nil, err + multiTradeForm := &core.MultiTradeForm{ + Host: u.market.Host, + Base: u.market.BaseID, + Quote: u.market.QuoteID, + Sell: sell, + Placements: []*core.QtyRate{ + { + Qty: qty, + Rate: rate, + }, + }, + Options: walletOptions, + MaxLock: fromBalance.Available, } - return &core.MaxOrderEstimate{ - Swap: orderEstimate.Swap.Estimate, - Redeem: orderEstimate.Redeem.Estimate, - }, nil -} + u.balancesMtx.Lock() + defer u.balancesMtx.Unlock() -// MaxSell returned the maximum quantity of the base asset that the bot can -// sell. -func (u *unifiedExchangeAdaptor) MaxSell(host string, base, quote uint32) (*core.MaxOrderEstimate, error) { - qty, err := u.maxSellQty(host, base, quote, 1, nil) + // MultiTrade is used instead of Trade because Trade does not support + // maxLock. + orders, err := u.clientCore.MultiTrade([]byte{}, multiTradeForm) if err != nil { return nil, err } - if qty == 0 { - return nil, fmt.Errorf("insufficient balance") - } - orderEstimate, err := u.clientCore.PreOrder(&core.TradeForm{ - Host: host, - IsLimit: true, - Sell: true, - Base: base, - Quote: quote, - Qty: qty, - }) - if err != nil { - return nil, err - } + u.addPendingDexOrders(orders, map[order.OrderID]dexOrderInfo{}) - return &core.MaxOrderEstimate{ - Swap: orderEstimate.Swap.Estimate, - Redeem: orderEstimate.Redeem.Estimate, - }, nil + if len(orders) == 0 { + return nil, fmt.Errorf("no orders placed") + } + return orders[0], nil } // DEXBalance returns the bot's balance for a specific asset on the DEX. @@ -682,6 +981,12 @@ func (u *unifiedExchangeAdaptor) updatePendingDeposit(txID string, deposit *pend delete(u.pendingDeposits, txID) + if deposit.assetID == u.market.BaseID { + u.pendingBaseRebalance.Store(false) + } else { + u.pendingQuoteRebalance.Store(false) + } + dexDiffs := map[uint32]int64{} cexDiffs := map[uint32]int64{} dexDiffs[deposit.assetID] -= int64(deposit.amtSent) @@ -722,7 +1027,7 @@ func (u *unifiedExchangeAdaptor) confirmWalletTransaction(ctx context.Context, a // the bot's wallet balance and allocated to the bot's CEX balance. After both // the fees of the deposit transaction are confirmed by the wallet and the // CEX confirms the amount they received, the onConfirm callback is called. -func (u *unifiedExchangeAdaptor) Deposit(ctx context.Context, assetID uint32, amount uint64, onConfirm func()) error { +func (u *unifiedExchangeAdaptor) Deposit(ctx context.Context, assetID uint32, amount uint64) error { balance, err := u.DEXBalance(assetID) if err != nil { return err @@ -741,6 +1046,12 @@ func (u *unifiedExchangeAdaptor) Deposit(ctx context.Context, assetID uint32, am return err } + if assetID == u.market.BaseID { + u.pendingBaseRebalance.Store(true) + } else { + u.pendingQuoteRebalance.Store(true) + } + getAmtAndFee := func() (uint64, uint64, bool) { tx, err := u.clientCore.WalletTransaction(assetID, coin.TxID()) if err != nil { @@ -772,12 +1083,6 @@ func (u *unifiedExchangeAdaptor) Deposit(ctx context.Context, assetID uint32, am deposit.mtx.Unlock() u.updatePendingDeposit(coin.TxID(), deposit) - - deposit.mtx.RLock() - defer deposit.mtx.RUnlock() - if deposit.cexConfirmed { - go onConfirm() - } } go u.confirmWalletTransaction(ctx, assetID, coin.TxID(), confirmedTx) @@ -798,12 +1103,6 @@ func (u *unifiedExchangeAdaptor) Deposit(ctx context.Context, assetID uint32, am deposit.mtx.Unlock() u.updatePendingDeposit(coin.TxID(), deposit) - - deposit.mtx.RLock() - defer deposit.mtx.RUnlock() - if deposit.feeConfirmed { - go onConfirm() - } } u.CEX.ConfirmDeposit(ctx, coin.TxID(), cexConfirmedDeposit) @@ -824,6 +1123,12 @@ func (u *unifiedExchangeAdaptor) pendingWithdrawalComplete(id string, amtReceive return } + if withdrawal.assetID == u.market.BaseID { + u.pendingBaseRebalance.Store(false) + } else { + u.pendingQuoteRebalance.Store(false) + } + if u.baseCexBalances[withdrawal.assetID] < withdrawal.amtWithdrawn { u.log.Errorf("%s balance on cex %d < withdrawn amt %d", dex.BipIDSymbol(withdrawal.assetID), u.baseCexBalances[withdrawal.assetID], withdrawal.amtWithdrawn) @@ -842,7 +1147,7 @@ func (u *unifiedExchangeAdaptor) pendingWithdrawalComplete(id string, amtReceive // Withdraw withdraws funds from the CEX. After withdrawing, the CEX is queried // for the transaction ID. After the transaction ID is available, the wallet is // queried for the amount received. -func (u *unifiedExchangeAdaptor) Withdraw(ctx context.Context, assetID uint32, amount uint64, onConfirm func()) error { +func (u *unifiedExchangeAdaptor) Withdraw(ctx context.Context, assetID uint32, amount uint64) error { symbol := dex.BipIDSymbol(assetID) balance, err := u.CEXBalance(assetID) @@ -875,7 +1180,6 @@ func (u *unifiedExchangeAdaptor) Withdraw(ctx context.Context, assetID uint32, a u.log.Errorf("Error getting wallet transaction: %v", err) } else if tx.Confirmed { u.pendingWithdrawalComplete(withdrawalID, tx.Amount) - onConfirm() return } @@ -886,21 +1190,268 @@ func (u *unifiedExchangeAdaptor) Withdraw(ctx context.Context, assetID uint32, a } } + u.balancesMtx.Lock() + defer u.balancesMtx.Unlock() + err = u.CEX.Withdraw(ctx, assetID, amount, addr, confirmWithdrawal) if err != nil { return err } - u.balancesMtx.Lock() + if assetID == u.market.BaseID { + u.pendingBaseRebalance.Store(true) + } else { + u.pendingQuoteRebalance.Store(true) + } + u.pendingWithdrawals[withdrawalID] = &pendingWithdrawal{ assetID: assetID, amtWithdrawn: amount, } - u.balancesMtx.Unlock() return nil } +// FreeUpFunds cancels active orders to free up the specified amount of funds +// for a rebalance between the dex and the cex. If the cex parameter is true, +// it means we are freeing up funds for withdrawal. DEX orders that require a +// counter-trade on the CEX are cancelled. The orders are cancelled in reverse +// order of priority (determined by the order in which they were passed into +// MultiTrade). +func (u *unifiedExchangeAdaptor) FreeUpFunds(assetID uint32, cex bool, amt uint64, currEpoch uint64) { + var base bool + if assetID == u.market.BaseID { + base = true + } else if assetID != u.market.QuoteID { + u.log.Errorf("Asset %d is not the base or quote asset of the market", assetID) + return + } + + if cex { + bal, err := u.CEXBalance(assetID) + if err != nil { + u.log.Errorf("Error getting %s balance on cex: %v", dex.BipIDSymbol(assetID), err) + return + } + backingBalance := u.cexBalanceBackingDexOrders(assetID) + if bal.Available < backingBalance { + u.log.Errorf("CEX balance %d < backing balance %d", bal.Available, backingBalance) + return + } + + freeBalance := bal.Available - backingBalance + if freeBalance >= amt { + return + } + amt -= freeBalance + } else { + bal, err := u.DEXBalance(assetID) + if err != nil { + u.log.Errorf("Error getting %s balance: %v", dex.BipIDSymbol(assetID), err) + return + } + if bal.Available >= amt { + return + } + amt -= bal.Available + } + + u.balancesMtx.RLock() + defer u.balancesMtx.RUnlock() + + sells := base != cex + orders := u.groupedBookedOrders(sells) + + highToLowIndexes := make([]uint64, 0, len(orders)) + for i := range orders { + highToLowIndexes = append(highToLowIndexes, i) + } + sort.Slice(highToLowIndexes, func(i, j int) bool { + return highToLowIndexes[i] > highToLowIndexes[j] + }) + + amtFreedByCancellingOrder := func(o *pendingDEXOrder) uint64 { + o.balancesMtx.RLock() + defer o.balancesMtx.RUnlock() + + if cex { + if o.order.Sell { + return calc.BaseToQuote(o.counterTradeRate, o.order.Qty-o.order.Filled) + } + return o.order.Qty - o.order.Filled + } + + return o.locked[assetID] + } + + var totalFreedAmt uint64 + for _, index := range highToLowIndexes { + ordersForPlacement := orders[index] + for _, o := range ordersForPlacement { + // If the order is too recent, just wait for the next epoch to + // cancel. We still count this order towards the freedAmt in + // order to not cancel a higher priority trade. + if currEpoch-o.order.Epoch >= 2 { + err := u.Cancel(o.order.ID) + if err != nil { + u.log.Errorf("Error cancelling order: %v", err) + continue + } + } + + totalFreedAmt += amtFreedByCancellingOrder(o) + if totalFreedAmt >= amt { + return + } + } + } + + u.log.Warnf("Could not free up enough funds for %s %s rebalance. Freed %d, needed %d", + dex.BipIDSymbol(assetID), dex.BipIDSymbol(u.market.QuoteID), amt, amt+u.rebalanceCfg.MinQuoteTransfer) +} + +func (u *unifiedExchangeAdaptor) cexBalanceBackingDexOrders(assetID uint32) uint64 { + var base bool + if assetID == u.market.BaseID { + base = true + } + + u.balancesMtx.RLock() + defer u.balancesMtx.RUnlock() + + var locked uint64 + for _, pendingOrder := range u.pendingDEXOrders { + if base == pendingOrder.order.Sell || pendingOrder.counterTradeRate == 0 { + // sell orders require a buy counter-trade on the CEX, which makes + // use of the quote asset on the CEX. + continue + } + + remaining := pendingOrder.order.Qty - pendingOrder.order.Filled + if pendingOrder.order.Sell { + locked += calc.BaseToQuote(pendingOrder.counterTradeRate, remaining) + } else { + locked += remaining + } + } + + return locked +} + +func (u *unifiedExchangeAdaptor) rebalanceAsset(ctx context.Context, assetID uint32, minAmount, minTransferAmount uint64) (toSend int64, dexReserves, cexReserves uint64) { + dexBalance, err := u.DEXBalance(assetID) + if err != nil { + u.log.Errorf("Error getting %s balance: %v", dex.BipIDSymbol(assetID), err) + return + } + totalDexBalance := dexBalance.Available + dexBalance.Locked + + cexBalance, err := u.CEXBalance(assetID) + if err != nil { + u.log.Errorf("Error getting %s balance on cex: %v", dex.BipIDSymbol(assetID), err) + return + } + + // Don't take into account locked funds on CEX, because we don't do + // rebalancing while there are active orders on the CEX. + if (totalDexBalance+cexBalance.Available)/2 < minAmount { + u.log.Warnf("Cannot rebalance %s because balance is too low on both DEX and CEX. Min amount: %v, CEX balance: %v, DEX Balance: %v", + dex.BipIDSymbol(assetID), minAmount, cexBalance.Available, totalDexBalance) + return + } + + var requireDeposit bool + if cexBalance.Available < minAmount { + requireDeposit = true + } else if totalDexBalance >= minAmount { + // No need for withdrawal or deposit. + return + } + + if requireDeposit { + amt := (totalDexBalance+cexBalance.Available)/2 - cexBalance.Available + if amt < minTransferAmount { + u.log.Warnf("Amount required to rebalance %s (%d) is less than the min transfer amount %v", + dex.BipIDSymbol(assetID), amt, minTransferAmount) + return + } + + // If we need to cancel some orders to send the required amount to + // the CEX, cancel some orders, and then try again on the next + // epoch. + if amt > dexBalance.Available { + dexReserves = amt + return + } + + toSend = int64(amt) + } else { + amt := (dexBalance.Available+cexBalance.Available)/2 - dexBalance.Available + if amt < minTransferAmount { + u.log.Warnf("Amount required to rebalance %s (%d) is less than the min transfer amount %v", + dex.BipIDSymbol(assetID), amt, minTransferAmount) + return + } + + cexBalanceBackingDexOrders := u.cexBalanceBackingDexOrders(assetID) + if cexBalance.Available < cexBalanceBackingDexOrders { + u.log.Errorf("cex reported balance %d is less than amount required to back dex orders %d", + cexBalance.Available, cexBalanceBackingDexOrders) + // this is a bug, how to recover? + return + } + + if amt > cexBalance.Available-cexBalanceBackingDexOrders { + cexReserves = amt + return + } + + toSend = -int64(amt) + } + + return +} + +// PrepareRebalance returns the amount that needs to be either deposited to or +// withdrawn from the CEX to rebalance the specified asset. If the amount is +// positive, it is the amount that needs to be deposited to the CEX. If the +// amount is negative, it is the amount that needs to be withdrawn from the +// CEX. If the amount is zero, no rebalancing is required. +// +// The dexReserves and cexReserves return values are the amount of funds that +// need to be freed from pending orders before a deposit or withdrawal can be +// made. This value should be passed to FreeUpFunds to cancel the pending +// orders that are obstructing the transfer, and also should be passed to any +// calls to MultiTrade to make sure the funds needed for rebalancing are not +// used to place orders. +func (u *unifiedExchangeAdaptor) PrepareRebalance(ctx context.Context, assetID uint32) (rebalance int64, dexReserves, cexReserves uint64) { + if u.rebalanceCfg == nil { + return + } + + var minAmount uint64 + var minTransferAmount uint64 + if assetID == u.market.BaseID { + if u.pendingBaseRebalance.Load() { + return + } + minAmount = u.rebalanceCfg.MinBaseAmt + minTransferAmount = u.rebalanceCfg.MinBaseTransfer + } else { + if assetID != u.market.QuoteID { + u.log.Errorf("assetID %d is not the base or quote asset ID of the market", assetID) + return + } + if u.pendingQuoteRebalance.Load() { + return + } + minAmount = u.rebalanceCfg.MinQuoteAmt + minTransferAmount = u.rebalanceCfg.MinQuoteTransfer + } + + return u.rebalanceAsset(ctx, assetID, minAmount, minTransferAmount) +} + // handleCEXTradeUpdate handles a trade update from the CEX. If the trade is in // the pending map, it will be updated. If the trade is complete, the base balances // will be updated. @@ -991,48 +1542,44 @@ func (w *unifiedExchangeAdaptor) SubscribeTradeUpdates() (<-chan *libxc.Trade, f // Trade executes a trade on the CEX. The trade will be executed using the // bot's CEX balance. -func (u *unifiedExchangeAdaptor) Trade(ctx context.Context, baseID, quoteID uint32, sell bool, rate, qty uint64) (*libxc.Trade, error) { - var fromAssetID uint32 - var fromAssetQty uint64 - if sell { - fromAssetID = baseID - fromAssetQty = qty - } else { - fromAssetID = quoteID - fromAssetQty = calc.BaseToQuote(rate, qty) - } - - fromAssetBal, err := u.CEXBalance(fromAssetID) +func (u *unifiedExchangeAdaptor) CEXTrade(ctx context.Context, baseID, quoteID uint32, sell bool, rate, qty uint64) (*libxc.Trade, error) { + sufficient, err := u.SufficientBalanceForCEXTrade(baseID, quoteID, sell, rate, qty) if err != nil { return nil, err } - if fromAssetBal.Available < fromAssetQty { - return nil, fmt.Errorf("asset bal < required for trade (%d < %d)", fromAssetBal.Available, fromAssetQty) + if !sufficient { + return nil, fmt.Errorf("insufficient balance") } u.subscriptionIDMtx.RLock() subscriptionID := u.subscriptionID u.subscriptionIDMtx.RUnlock() if subscriptionID == nil { - return nil, fmt.Errorf("Trade called before SubscribeTradeUpdates") + return nil, fmt.Errorf("trade called before SubscribeTradeUpdates") } + u.balancesMtx.Lock() + defer u.balancesMtx.Unlock() + trade, err := u.CEX.Trade(ctx, baseID, quoteID, sell, rate, qty, *subscriptionID) if err != nil { return nil, err } - u.balancesMtx.Lock() - defer u.balancesMtx.Unlock() - if trade.Complete { + diffs := make(map[uint32]int64) if trade.Sell { u.baseCexBalances[trade.BaseID] -= trade.BaseFilled u.baseCexBalances[trade.QuoteID] += trade.QuoteFilled + diffs[trade.BaseID] = -int64(trade.BaseFilled) + diffs[trade.QuoteID] = int64(trade.QuoteFilled) } else { u.baseCexBalances[trade.BaseID] += trade.BaseFilled u.baseCexBalances[trade.QuoteID] -= trade.QuoteFilled + diffs[trade.BaseID] = int64(trade.BaseFilled) + diffs[trade.QuoteID] = -int64(trade.QuoteFilled) } + u.logBalanceAdjustments(nil, diffs, fmt.Sprintf("CEX trade %s completed.", trade.ID)) } else { u.pendingCEXOrders[trade.ID] = trade } @@ -1040,6 +1587,153 @@ func (u *unifiedExchangeAdaptor) Trade(ctx context.Context, baseID, quoteID uint return trade, nil } +func (u *unifiedExchangeAdaptor) fiatRate(assetID uint32) float64 { + rates := u.fiatRates.Load() + if rates == nil { + return 0 + } + + return rates.(map[uint32]float64)[assetID] +} + +// ExchangeRateFromFiatSources returns market's exchange rate using fiat sources. +func (u *unifiedExchangeAdaptor) ExchangeRateFromFiatSources() uint64 { + atomicCFactor, err := u.atomicConversionRateFromFiat(u.market.BaseID, u.market.QuoteID) + if err != nil { + u.log.Errorf("Error genrating atomic conversion rate: %v", err) + return 0 + } + return uint64(math.Round(atomicCFactor * calc.RateEncodingFactor)) +} + +// atomicConversionRateFromFiat generates a conversion rate suitable for +// converting from atomic units of one asset to atomic units of another. +// This is the same as a message-rate, but without the RateEncodingFactor, +// hence a float. +func (u *unifiedExchangeAdaptor) atomicConversionRateFromFiat(fromID, toID uint32) (float64, error) { + fromRate := u.fiatRate(fromID) + toRate := u.fiatRate(toID) + if fromRate == 0 || toRate == 0 { + return 0, fmt.Errorf("missing fiat rate. rate for %d = %f, rate for %d = %f", fromID, fromRate, toID, toRate) + } + + fromUI, err := asset.UnitInfo(fromID) + if err != nil { + return 0, fmt.Errorf("exchangeRates from asset %d not found", fromID) + } + toUI, err := asset.UnitInfo(toID) + if err != nil { + return 0, fmt.Errorf("exchangeRates to asset %d not found", toID) + } + + // v_to_atomic = v_from_atomic / from_conv_factor * convConversionRate / to_conv_factor + return 1 / float64(fromUI.Conventional.ConversionFactor) * fromRate / toRate * float64(toUI.Conventional.ConversionFactor), nil +} + +// OrderFees returns the fees for a buy and sell order. The order fees are for +// placing orders on the market specified by the exchangeAdaptorCfg used to +// create the unifiedExchangeAdaptor. +func (u *unifiedExchangeAdaptor) orderFees() (buyFees, sellFees *orderFees, err error) { + u.feesMtx.RLock() + defer u.feesMtx.RUnlock() + + if u.buyFees == nil || u.sellFees == nil { + return nil, nil, fmt.Errorf("order fees not available") + } + + return u.buyFees, u.sellFees, nil +} + +// OrderFeesInUnits returns the swap and redemption fees for either a buy or +// sell order in units of either the base or quote asset. If either the base +// or quote asset is a token, the fees are converted using fiat rates. +// Otherwise, the rate parameter is used for the conversion. +func (u *unifiedExchangeAdaptor) OrderFeesInUnits(sell, base bool, rate uint64) (uint64, error) { + buyFees, sellFees, err := u.orderFees() + if err != nil { + return 0, fmt.Errorf("error getting order fees: %v", err) + } + baseFees, quoteFees := buyFees.redemption, buyFees.swap + if sell { + baseFees, quoteFees = sellFees.swap, sellFees.redemption + } + toID := u.market.QuoteID + if base { + toID = u.market.BaseID + } + + convertViaFiat := func(fees uint64, fromID uint32) (uint64, error) { + atomicCFactor, err := u.atomicConversionRateFromFiat(fromID, toID) + if err != nil { + return 0, err + } + return uint64(math.Round(float64(fees) * atomicCFactor)), nil + } + + var baseFeesInUnits, quoteFeesInUnits uint64 + + baseToken := asset.TokenInfo(u.market.BaseID) + if baseToken != nil { + baseFeesInUnits, err = convertViaFiat(baseFees, baseToken.ParentID) + if err != nil { + return 0, err + } + } else { + if base { + baseFeesInUnits = baseFees + } else { + baseFeesInUnits = calc.BaseToQuote(rate, baseFees) + } + } + + quoteToken := asset.TokenInfo(u.market.QuoteID) + if quoteToken != nil { + quoteFeesInUnits, err = convertViaFiat(quoteFees, quoteToken.ParentID) + if err != nil { + return 0, err + } + } else { + if base { + quoteFeesInUnits = calc.QuoteToBase(rate, quoteFees) + } else { + quoteFeesInUnits = quoteFees + } + } + + return baseFeesInUnits + quoteFeesInUnits, nil +} + +// CancelAllOrders cancels all booked orders. True is returned no orders +// needed to be cancelled. +func (u *unifiedExchangeAdaptor) CancelAllOrders() bool { + u.balancesMtx.RLock() + defer u.balancesMtx.RUnlock() + + noCancels := true + + for _, pendingOrder := range u.pendingDEXOrders { + pendingOrder.balancesMtx.RLock() + if pendingOrder.order.Status <= order.OrderStatusBooked { + err := u.clientCore.Cancel(pendingOrder.order.ID) + if err != nil { + u.log.Errorf("Error canceling order %s: %v", pendingOrder.order.ID, err) + } + noCancels = false + } + pendingOrder.balancesMtx.RUnlock() + } + + return noCancels +} + +// SubscribeOrderUpdates returns a channel that sends updates for orders placed +// on the DEX. This function should be called only once. +func (u *unifiedExchangeAdaptor) SubscribeOrderUpdates() <-chan *core.Order { + orderUpdates := make(chan *core.Order, 128) + u.orderUpdates.Store(orderUpdates) + return orderUpdates +} + // isAccountLocker returns if the asset's wallet is an asset.AccountLocker. func (u *unifiedExchangeAdaptor) isAccountLocker(assetID uint32) bool { walletState := u.clientCore.WalletState(assetID) @@ -1073,13 +1767,13 @@ func (u *unifiedExchangeAdaptor) isWithdrawer(assetID uint32) bool { return walletState.Traits.IsWithdrawer() } -func orderAssets(o *core.Order) (fromAsset, fromFeeAsset, toAsset, toFeeAsset uint32) { - if o.Sell { - fromAsset = o.BaseID - toAsset = o.QuoteID +func orderAssets(baseID, quoteID uint32, sell bool) (fromAsset, fromFeeAsset, toAsset, toFeeAsset uint32) { + if sell { + fromAsset = baseID + toAsset = quoteID } else { - fromAsset = o.QuoteID - toAsset = o.BaseID + fromAsset = quoteID + toAsset = baseID } if token := asset.TokenInfo(fromAsset); token != nil { fromFeeAsset = token.ParentID @@ -1144,7 +1838,7 @@ func (u *unifiedExchangeAdaptor) updatePendingDEXOrder(o *core.Order) { return } - fromAsset, fromFeeAsset, toAsset, toFeeAsset := orderAssets(o) + fromAsset, fromFeeAsset, toAsset, toFeeAsset := orderAssets(o.BaseID, o.QuoteID, o.Sell) pendingOrder.txsMtx.Lock() @@ -1237,8 +1931,14 @@ func (u *unifiedExchangeAdaptor) updatePendingDEXOrder(o *core.Order) { pendingOrder.availableDiff = availableDiff pendingOrder.locked = locked pendingOrder.pending = pending + pendingOrder.order = o pendingOrder.balancesMtx.Unlock() + orderUpdates := u.orderUpdates.Load() + if orderUpdates != nil { + orderUpdates.(chan *core.Order) <- o + } + // If complete, remove the order from the pending list, and update the // bot's balance. if dexOrderComplete(o) { @@ -1279,10 +1979,75 @@ func (u *unifiedExchangeAdaptor) handleDEXNotification(n core.Notification) { return } u.updatePendingDEXOrder(o) + case *core.FiatRatesNote: + u.fiatRates.Store(note.FiatRates) + } +} + +// updateFeeRates updates the cached fee rates for placing orders on the market +// specified by the exchangeAdaptorCfg used to create the unifiedExchangeAdaptor. +func (u *unifiedExchangeAdaptor) updateFeeRates() error { + buySwapFees, buyRedeemFees, buyRefundFees, err := u.clientCore.SingleLotFees(&core.SingleLotFeesForm{ + Host: u.market.Host, + Base: u.market.BaseID, + Quote: u.market.QuoteID, + UseMaxFeeRate: true, + UseSafeTxSize: true, + }) + if err != nil { + return fmt.Errorf("failed to get buy single lot fees: %v", err) + } + + sellSwapFees, sellRedeemFees, sellRefundFees, err := u.clientCore.SingleLotFees(&core.SingleLotFeesForm{ + Host: u.market.Host, + Base: u.market.BaseID, + Quote: u.market.QuoteID, + UseMaxFeeRate: true, + UseSafeTxSize: true, + Sell: true, + }) + if err != nil { + return fmt.Errorf("failed to get sell single lot fees: %v", err) + } + + buyFundingFees, err := u.clientCore.MaxFundingFees(u.market.QuoteID, u.market.Host, u.maxBuyPlacements, u.baseWalletOptions) + if err != nil { + return fmt.Errorf("failed to get buy funding fees: %v", err) + } + + sellFundingFees, err := u.clientCore.MaxFundingFees(u.market.BaseID, u.market.Host, u.maxSellPlacements, u.quoteWalletOptions) + if err != nil { + return fmt.Errorf("failed to get sell funding fees: %v", err) + } + + u.feesMtx.Lock() + defer u.feesMtx.Unlock() + + u.buyFees = &orderFees{ + swap: buySwapFees, + redemption: buyRedeemFees, + refund: buyRefundFees, + funding: buyFundingFees, + } + u.sellFees = &orderFees{ + swap: sellSwapFees, + redemption: sellRedeemFees, + refund: sellRefundFees, + funding: sellFundingFees, } + + return nil } func (u *unifiedExchangeAdaptor) run(ctx context.Context) { + u.fiatRates.Store(u.clientCore.FiatConversionRates()) + + err := u.updateFeeRates() + if err != nil { + u.log.Errorf("Error updating fee rates: %v", err) + } + + // Listen for core notifications go func() { feed := u.clientCore.NotificationFeed() defer feed.ReturnFeed() @@ -1296,20 +2061,60 @@ func (u *unifiedExchangeAdaptor) run(ctx context.Context) { } } }() + + go func() { + refreshTime := time.Minute * 10 + for { + select { + case <-time.NewTimer(refreshTime).C: + err := u.updateFeeRates() + if err != nil { + u.log.Error(err) + refreshTime = time.Minute + } else { + refreshTime = time.Minute * 10 + } + case <-ctx.Done(): + return + } + } + }() +} + +type exchangeAdaptorCfg struct { + botID string + market *MarketWithHost + baseDexBalances map[uint32]uint64 + baseCexBalances map[uint32]uint64 + core clientCore + cex libxc.CEX + maxBuyPlacements uint32 + maxSellPlacements uint32 + baseWalletOptions map[string]string + quoteWalletOptions map[string]string + rebalanceCfg *AutoRebalanceConfig + log dex.Logger } // unifiedExchangeAdaptorForBot returns a unifiedExchangeAdaptor for the specified bot. -func unifiedExchangeAdaptorForBot(botID string, baseDexBalances, baseCexBalances map[uint32]uint64, core clientCore, cex libxc.CEX, log dex.Logger) *unifiedExchangeAdaptor { +func unifiedExchangeAdaptorForBot(cfg *exchangeAdaptorCfg) *unifiedExchangeAdaptor { return &unifiedExchangeAdaptor{ - clientCore: core, - CEX: cex, - botID: botID, - log: log, - baseDexBalances: baseDexBalances, - baseCexBalances: baseCexBalances, + clientCore: cfg.core, + CEX: cfg.cex, + botID: cfg.botID, + log: cfg.log, + maxBuyPlacements: cfg.maxBuyPlacements, + maxSellPlacements: cfg.maxSellPlacements, + baseWalletOptions: cfg.baseWalletOptions, + quoteWalletOptions: cfg.quoteWalletOptions, + rebalanceCfg: cfg.rebalanceCfg, + + baseDexBalances: cfg.baseDexBalances, + baseCexBalances: cfg.baseCexBalances, pendingDEXOrders: make(map[order.OrderID]*pendingDEXOrder), pendingCEXOrders: make(map[string]*libxc.Trade), pendingDeposits: make(map[string]*pendingDeposit), pendingWithdrawals: make(map[string]*pendingWithdrawal), + market: cfg.market, } } diff --git a/client/mm/exchange_adaptor_test.go b/client/mm/exchange_adaptor_test.go index d1bb57c845..a54390aed1 100644 --- a/client/mm/exchange_adaptor_test.go +++ b/client/mm/exchange_adaptor_test.go @@ -1,10 +1,12 @@ package mm import ( + "bytes" "context" "encoding/hex" "fmt" "reflect" + "sort" "testing" "time" @@ -15,492 +17,1400 @@ import ( "decred.org/dcrdex/dex/calc" "decred.org/dcrdex/dex/encode" "decred.org/dcrdex/dex/order" + "github.com/davecgh/go-spew/spew" ) -func TestExchangeAdaptorMaxSell(t *testing.T) { - tCore := newTCore() - tCore.isAccountLocker[60] = true - dcrBtcID := fmt.Sprintf("%s-%d-%d", "host1", 42, 0) - dcrEthID := fmt.Sprintf("%s-%d-%d", "host1", 42, 60) - - // Whatever is returned from PreOrder is returned from this function. - // What we need to test is what is passed to PreOrder. - orderEstimate := &core.OrderEstimate{ - Swap: &asset.PreSwap{ - Estimate: &asset.SwapEstimate{ - Lots: 5, - Value: 5e8, - MaxFees: 1600, - RealisticWorstCase: 12010, - RealisticBestCase: 6008, +func TestSufficientBalanceForDEXTrade(t *testing.T) { + lotSize := uint64(1e8) + sellFees := &orderFees{ + swap: 1e5, + redemption: 2e5, + refund: 3e5, + } + buyFees := &orderFees{ + swap: 5e5, + redemption: 6e5, + refund: 7e5, + } + + fundingFees := uint64(8e5) + + type test struct { + name string + baseID, quoteID uint32 + balances map[uint32]uint64 + isAccountLocker map[uint32]bool + sell bool + rate, qty uint64 + } + + b2q := calc.BaseToQuote + + tests := []*test{ + { + name: "sell, non account locker", + baseID: 42, + quoteID: 0, + sell: true, + rate: 1e7, + qty: 3 * lotSize, + balances: map[uint32]uint64{ + 42: 3*lotSize + 3*sellFees.swap + fundingFees, + 0: 0, }, }, - Redeem: &asset.PreRedeem{ - Estimate: &asset.RedeemEstimate{ - RealisticBestCase: 2800, - RealisticWorstCase: 6500, + { + name: "buy, non account locker", + baseID: 42, + quoteID: 0, + rate: 2e7, + qty: 2 * lotSize, + sell: false, + balances: map[uint32]uint64{ + 42: 0, + 0: b2q(2e7, 2*lotSize) + 2*buyFees.swap + fundingFees, }, }, + { + name: "sell, account locker/token", + baseID: 966001, + quoteID: 60, + sell: true, + rate: 2e7, + qty: 3 * lotSize, + isAccountLocker: map[uint32]bool{ + 966001: true, + 966: true, + 60: true, + }, + balances: map[uint32]uint64{ + 966001: 3 * lotSize, + 966: 3*sellFees.swap + 3*sellFees.refund + fundingFees, + 60: 3 * sellFees.redemption, + }, + }, + { + name: "buy, account locker/token", + baseID: 966001, + quoteID: 60, + sell: false, + rate: 2e7, + qty: 3 * lotSize, + isAccountLocker: map[uint32]bool{ + 966001: true, + 966: true, + 60: true, + }, + balances: map[uint32]uint64{ + 966: 3 * buyFees.redemption, + 60: b2q(2e7, 3*lotSize) + 3*buyFees.swap + 3*buyFees.refund + fundingFees, + }, + }, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + tCore := newTCore() + tCore.singleLotSellFees = sellFees + tCore.singleLotBuyFees = buyFees + tCore.maxFundingFees = fundingFees + + tCore.market = &core.Market{ + BaseID: test.baseID, + QuoteID: test.quoteID, + LotSize: lotSize, + } + mkt := &MarketWithHost{ + BaseID: test.baseID, + QuoteID: test.quoteID, + } + + tCore.isAccountLocker = test.isAccountLocker + + checkBalanceSufficient := func(expSufficient bool) { + t.Helper() + adaptor := unifiedExchangeAdaptorForBot(&exchangeAdaptorCfg{ + core: tCore, + baseDexBalances: test.balances, + market: mkt, + log: tLogger, + }) + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + adaptor.run(ctx) + sufficient, err := adaptor.SufficientBalanceForDEXTrade(test.rate, test.qty, test.sell) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if sufficient != expSufficient { + t.Fatalf("expected sufficient=%v, got %v", expSufficient, sufficient) + } + } + + checkBalanceSufficient(true) + + for assetID, bal := range test.balances { + if bal == 0 { + continue + } + test.balances[assetID]-- + checkBalanceSufficient(false) + test.balances[assetID]++ + } + }) + } +} + +func TestSufficientBalanceForCEXTrade(t *testing.T) { + const baseID uint32 = 42 + const quoteID uint32 = 0 + + type test struct { + name string + cexBalances map[uint32]uint64 + sell bool + rate, qty uint64 } - tCore.orderEstimate = orderEstimate - - expectedResult := &core.MaxOrderEstimate{ - Swap: &asset.SwapEstimate{ - Lots: 5, - Value: 5e8, - MaxFees: 1600, - RealisticWorstCase: 12010, - RealisticBestCase: 6008, + + tests := []*test{ + { + name: "sell", + sell: true, + rate: 5e7, + qty: 1e8, + cexBalances: map[uint32]uint64{ + baseID: 1e8, + }, }, - Redeem: &asset.RedeemEstimate{ - RealisticBestCase: 2800, - RealisticWorstCase: 6500, + { + name: "buy", + sell: false, + rate: 5e7, + qty: 1e8, + cexBalances: map[uint32]uint64{ + quoteID: calc.BaseToQuote(5e7, 1e8), + }, }, } - tests := []struct { - name string - assetBalances map[uint32]uint64 - market *core.Market - swapFees uint64 - redeemFees uint64 - refundFees uint64 - - expectPreOrderParam *core.TradeForm - wantErr bool - }{ + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + checkBalanceSufficient := func(expSufficient bool) { + tCore := newTCore() + adaptor := unifiedExchangeAdaptorForBot(&exchangeAdaptorCfg{ + core: tCore, + baseCexBalances: test.cexBalances, + market: &MarketWithHost{ + BaseID: baseID, + QuoteID: quoteID, + }, + log: tLogger, + }) + sufficient, err := adaptor.SufficientBalanceForCEXTrade(baseID, quoteID, test.sell, test.rate, test.qty) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if sufficient != expSufficient { + t.Fatalf("expected sufficient=%v, got %v", expSufficient, sufficient) + } + } + + checkBalanceSufficient(true) + + for assetID := range test.cexBalances { + test.cexBalances[assetID]-- + checkBalanceSufficient(false) + test.cexBalances[assetID]++ + } + }) + } +} + +func TestPrepareRebalance(t *testing.T) { + baseID := uint32(42) + quoteID := uint32(0) + cfg := &AutoRebalanceConfig{ + MinBaseAmt: 120e8, + MinBaseTransfer: 50e8, + MinQuoteAmt: 0.5e8, + MinQuoteTransfer: 0.1e8, + } + orderIDs := make([]order.OrderID, 5) + for i := range orderIDs { + var id order.OrderID + copy(id[:], encode.RandomBytes(order.OrderIDSize)) + orderIDs[i] = id + } + + type test struct { + name string + assetID uint32 + dexBalances map[uint32]uint64 + cexBalances map[uint32]uint64 + baseRebalancePending bool + quoteRebalancePending bool + + pendingDEXOrders map[order.OrderID]*pendingDEXOrder + expectedRebalance int64 + expectedDEXReserves uint64 + expectedCEXReserves uint64 + } + + tests := []*test{ { - name: "ok", - assetBalances: map[uint32]uint64{ - 0: 5e6, - 42: 5e6, + name: "no pending orders, no rebalance required", + assetID: 42, + dexBalances: map[uint32]uint64{ + baseID: 120e8, + quoteID: 2e8, }, - market: &core.Market{ - LotSize: 1e6, - BaseID: 42, - QuoteID: 0, + cexBalances: map[uint32]uint64{ + baseID: 120e8, + quoteID: 0, }, - expectPreOrderParam: &core.TradeForm{ - Host: "host1", - IsLimit: true, - Base: 42, - Quote: 0, - Sell: true, - Qty: 4 * 1e6, - }, - swapFees: 1000, - redeemFees: 1000, }, { - name: "1 lot", - assetBalances: map[uint32]uint64{ - 42: 1e6 + 1000, - 0: 1000, + name: "no pending orders, base deposit required", + assetID: 42, + dexBalances: map[uint32]uint64{ + baseID: 170e8, + quoteID: 2e8, }, - market: &core.Market{ - LotSize: 1e6, - BaseID: 42, - QuoteID: 0, + cexBalances: map[uint32]uint64{ + baseID: 70e8, + quoteID: 0, }, - expectPreOrderParam: &core.TradeForm{ - Host: "host1", - IsLimit: true, - Base: 42, - Quote: 0, - Sell: true, - Qty: 1e6, - }, - swapFees: 1000, - redeemFees: 1000, + expectedRebalance: 50e8, }, { - name: "not enough for 1 swap", - assetBalances: map[uint32]uint64{ - 0: 1e6 + 999, - 42: 1000, + name: "no pending orders, quote deposit required", + assetID: 0, + dexBalances: map[uint32]uint64{ + baseID: 170e8, + quoteID: 2e8, }, - market: &core.Market{ - LotSize: 1e6, - BaseID: 42, - QuoteID: 0, + cexBalances: map[uint32]uint64{ + baseID: 70e8, + quoteID: 0, }, - swapFees: 1000, - redeemFees: 1000, - wantErr: true, + expectedRebalance: 1e8, }, { - name: "not enough for 1 lot of redeem fees", - assetBalances: map[uint32]uint64{ - 42: 1e6 + 1000, - 60: 999, + name: "no pending orders, base withdrawal required", + assetID: 42, + dexBalances: map[uint32]uint64{ + baseID: 70e8, + quoteID: 0, }, - market: &core.Market{ - LotSize: 1e6, - BaseID: 42, - QuoteID: 60, + cexBalances: map[uint32]uint64{ + baseID: 170e8, + quoteID: 2e8, }, - swapFees: 1000, - redeemFees: 1000, - wantErr: true, + expectedRebalance: -50e8, }, { - name: "redeem fees don't matter if not account locker", - assetBalances: map[uint32]uint64{ - 42: 1e6 + 1000, - 0: 999, + name: "no pending orders, quote withdrawal required", + assetID: 0, + dexBalances: map[uint32]uint64{ + baseID: 70e8, + quoteID: 0, }, - market: &core.Market{ - LotSize: 1e6, - BaseID: 42, - QuoteID: 0, + cexBalances: map[uint32]uint64{ + baseID: 170e8, + quoteID: 2e8, }, - swapFees: 1000, - redeemFees: 1000, - expectPreOrderParam: &core.TradeForm{ - Host: "host1", - IsLimit: true, - Base: 42, - Quote: 0, - Sell: true, - Qty: 1e6, + expectedRebalance: -1e8, + }, + { + name: "no pending orders, base deposit required, already pending", + assetID: 42, + dexBalances: map[uint32]uint64{ + baseID: 170e8, + quoteID: 2e8, + }, + cexBalances: map[uint32]uint64{ + baseID: 70e8, + quoteID: 0, }, + baseRebalancePending: true, }, { - name: "2 lots with refund fees, not account locker", - assetBalances: map[uint32]uint64{ - 42: 2e6 + 2000, - 0: 1000, + name: "no pending orders, quote withdrawal required, already pending", + assetID: 0, + dexBalances: map[uint32]uint64{ + baseID: 70e8, + quoteID: 0, }, - market: &core.Market{ - LotSize: 1e6, - BaseID: 42, - QuoteID: 0, + cexBalances: map[uint32]uint64{ + baseID: 170e8, + quoteID: 2e8, }, - expectPreOrderParam: &core.TradeForm{ - Host: "host1", - IsLimit: true, - Base: 42, - Quote: 0, - Sell: true, - Qty: 2e6, - }, - swapFees: 1000, - redeemFees: 1000, - refundFees: 1000, + quoteRebalancePending: true, }, { - name: "1 lot with refund fees, account locker", - assetBalances: map[uint32]uint64{ - 60: 1000, - 42: 2e6 + 2000, + name: "no pending orders, deposit < min base transfer", + assetID: 42, + dexBalances: map[uint32]uint64{ + baseID: 170e8, + quoteID: 2e8, }, - market: &core.Market{ - LotSize: 1e6, - BaseID: 42, - QuoteID: 60, + cexBalances: map[uint32]uint64{ + baseID: 71e8, + quoteID: 0, }, - expectPreOrderParam: &core.TradeForm{ - Host: "host1", - IsLimit: true, - Base: 42, - Quote: 60, - Sell: true, - Qty: 1e6, - }, - swapFees: 1000, - redeemFees: 1000, - refundFees: 1000, + }, + { + name: "base deposit required, pending orders", + assetID: 42, + dexBalances: map[uint32]uint64{ + baseID: 40e8, + quoteID: 2e8, + }, + cexBalances: map[uint32]uint64{ + baseID: 70e8, + quoteID: 0, + }, + pendingDEXOrders: map[order.OrderID]*pendingDEXOrder{ + orderIDs[0]: { + locked: map[uint32]uint64{ + baseID: 130e8, + }, + order: &core.Order{ + Qty: 130e8, + Rate: 5e7, + Sell: true, + }, + counterTradeRate: 6e7, + }, + }, + expectedRebalance: 0, + expectedDEXReserves: 50e8, + }, + { + name: "base withdrawal required, pending buy order", + assetID: 42, + dexBalances: map[uint32]uint64{ + baseID: 70e8, + quoteID: 0, + }, + cexBalances: map[uint32]uint64{ + baseID: 170e8, + quoteID: 2e8, + }, + pendingDEXOrders: map[order.OrderID]*pendingDEXOrder{ + orderIDs[0]: { + locked: map[uint32]uint64{ + quoteID: 130e8, + }, + order: &core.Order{ + Qty: 150e8, + Filled: 20e8, + Rate: 5e7, + Sell: false, + }, + counterTradeRate: 5e7, // sell with 20e8 remaining + }, + }, + expectedRebalance: 0, + expectedCEXReserves: 50e8, + }, + { + name: "quote withdrawal required, pending sell order", + assetID: 0, + dexBalances: map[uint32]uint64{ + baseID: 70e8, + quoteID: 0.25e8, + }, + cexBalances: map[uint32]uint64{ + baseID: 170e8, + quoteID: 0.75e8, + }, + pendingDEXOrders: map[order.OrderID]*pendingDEXOrder{ + orderIDs[0]: { + locked: map[uint32]uint64{ + baseID: 100e8, + }, + order: &core.Order{ + Qty: 140e8, + Filled: 20e8, + Rate: 6e5, + Sell: true, + }, + counterTradeRate: 5e5, // 0.6e8 required to counter-trade 120e8 @ 5e5 + }, + }, + expectedRebalance: 0, + expectedCEXReserves: 0.25e8, }, } for _, test := range tests { - tCore.setAssetBalances(test.assetBalances) - tCore.market = test.market - tCore.sellSwapFees = test.swapFees - tCore.sellRedeemFees = test.redeemFees - tCore.sellRefundFees = test.refundFees - tCore.isAccountLocker[60] = true - - botID := dcrBtcID - if test.market.QuoteID == 60 { - botID = dcrEthID - } + t.Run(test.name, func(t *testing.T) { + tCore := newTCore() + adaptor := unifiedExchangeAdaptorForBot(&exchangeAdaptorCfg{ + core: tCore, + baseDexBalances: test.dexBalances, + baseCexBalances: test.cexBalances, + rebalanceCfg: cfg, + market: &MarketWithHost{ + Host: "dex.com", + BaseID: baseID, + QuoteID: quoteID, + }, + log: tLogger, + }) + adaptor.pendingBaseRebalance.Store(test.baseRebalancePending) + adaptor.pendingQuoteRebalance.Store(test.quoteRebalancePending) + adaptor.pendingDEXOrders = test.pendingDEXOrders + + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + rebalance, dexReserves, cexReserves := adaptor.PrepareRebalance(ctx, test.assetID) + if rebalance != test.expectedRebalance { + t.Fatalf("expected rebalance=%d, got %d", test.expectedRebalance, rebalance) + } + if dexReserves != test.expectedDEXReserves { + t.Fatalf("expected dexReserves=%d, got %d", test.expectedDEXReserves, dexReserves) + } + if cexReserves != test.expectedCEXReserves { + t.Fatalf("expected cexReserves=%d, got %d", test.expectedCEXReserves, cexReserves) + } + }) + } +} - ctx, cancel := context.WithCancel(context.Background()) - defer cancel() +func TestFreeUpFunds(t *testing.T) { + var currEpoch uint64 = 100 + baseID := uint32(42) + quoteID := uint32(0) + orderIDs := make([]order.OrderID, 5) + for i := range orderIDs { + var id order.OrderID + copy(id[:], encode.RandomBytes(order.OrderIDSize)) + orderIDs[i] = id + } - adaptor := unifiedExchangeAdaptorForBot(botID, test.assetBalances, nil, tCore, nil, tLogger) - adaptor.run(ctx) - res, err := adaptor.MaxSell("host1", test.market.BaseID, test.market.QuoteID) - if test.wantErr { - if err == nil { - t.Fatalf("%s: expected error but did not get", test.name) + type test struct { + name string + dexBalances map[uint32]uint64 + cexBalances map[uint32]uint64 + assetID uint32 + cex bool + amt uint64 + pendingDEXOrders map[order.OrderID]*pendingDEXOrder + + expectedCancels []*order.OrderID + } + + tests := []*test{ + { + name: "base, dex", + dexBalances: map[uint32]uint64{ + baseID: 10e8, + }, + assetID: baseID, + amt: 50e8, + pendingDEXOrders: map[order.OrderID]*pendingDEXOrder{ + orderIDs[0]: { + locked: map[uint32]uint64{ + baseID: 20e8, + }, + order: &core.Order{ + ID: orderIDs[0][:], + Sell: true, + }, + placementIndex: 1, + }, + orderIDs[1]: { + locked: map[uint32]uint64{ + baseID: 20e8, + }, + order: &core.Order{ + ID: orderIDs[1][:], + Sell: true, + }, + placementIndex: 0, + }, + orderIDs[2]: { + locked: map[uint32]uint64{ + baseID: 20e8, + }, + order: &core.Order{ + ID: orderIDs[2][:], + Sell: true, + }, + placementIndex: 2, + }, + }, + expectedCancels: []*order.OrderID{ + &orderIDs[2], // placementIndex 2 + &orderIDs[0], // placementIndex 1 + }, + }, + { + name: "base, cex", + cexBalances: map[uint32]uint64{ + baseID: 70e8, + }, + assetID: baseID, + cex: true, + amt: 50e8, + pendingDEXOrders: map[order.OrderID]*pendingDEXOrder{ + orderIDs[0]: { + order: &core.Order{ + Qty: 20e8, + ID: orderIDs[0][:], + Sell: false, + }, + placementIndex: 1, + counterTradeRate: 5e5, + }, + orderIDs[1]: { + order: &core.Order{ + Qty: 20e8, + ID: orderIDs[1][:], + Sell: false, + }, + placementIndex: 0, + counterTradeRate: 5e5, + }, + orderIDs[2]: { + order: &core.Order{ + Qty: 20e8, + ID: orderIDs[2][:], + Sell: false, + }, + placementIndex: 2, + counterTradeRate: 5e5, + }, + }, + expectedCancels: []*order.OrderID{ + &orderIDs[2], // placementIndex 2 + &orderIDs[0], // placementIndex 1 + }, + }, + { + name: "quote, cex", + cexBalances: map[uint32]uint64{ + quoteID: 0.6e8, + }, + assetID: quoteID, + cex: true, + amt: 0.5e8, + pendingDEXOrders: map[order.OrderID]*pendingDEXOrder{ + orderIDs[0]: { + order: &core.Order{ + Qty: 20e8, + ID: orderIDs[0][:], + Sell: true, + }, + placementIndex: 1, + counterTradeRate: 5e5, // 0.1e8 required to counter-trade 20e8 @ 5e5 + }, + orderIDs[1]: { + order: &core.Order{ + Qty: 20e8, + ID: orderIDs[1][:], + Sell: true, + }, + placementIndex: 0, + counterTradeRate: 5e5, + }, + orderIDs[2]: { + order: &core.Order{ + Qty: 20e8, + ID: orderIDs[2][:], + Sell: true, + }, + placementIndex: 2, + counterTradeRate: 5e5, + }, + }, + expectedCancels: []*order.OrderID{ + &orderIDs[2], // placementIndex 2 + &orderIDs[0], // placementIndex 1 + }, + }, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + tCore := newTCore() + adaptor := unifiedExchangeAdaptorForBot(&exchangeAdaptorCfg{ + core: tCore, + baseDexBalances: test.dexBalances, + baseCexBalances: test.cexBalances, + market: &MarketWithHost{ + Host: "dex.com", + BaseID: baseID, + QuoteID: quoteID, + }, + log: tLogger, + }) + adaptor.pendingDEXOrders = test.pendingDEXOrders + adaptor.FreeUpFunds(test.assetID, test.cex, test.amt, currEpoch) + + if len(tCore.cancelsPlaced) != len(test.expectedCancels) { + t.Fatalf("%s: expected %d cancels, got %d", test.name, len(test.expectedCancels), len(tCore.cancelsPlaced)) } - continue + + for i, cancel := range tCore.cancelsPlaced { + if !bytes.Equal(cancel, test.expectedCancels[i][:]) { + t.Fatalf("%s: expected cancel %d to be %v, got %v", test.name, i, test.expectedCancels[i], cancel) + } + } + }) + } +} + +func TestMultiTrade(t *testing.T) { + const lotSize uint64 = 50e8 + const rateStep uint64 = 1e3 + const currEpoch = 100 + const driftTolerance = 0.001 + sellFees := &orderFees{ + swap: 1e5, + redemption: 2e5, + refund: 3e5, + funding: 4e5, + } + buyFees := &orderFees{ + swap: 5e5, + redemption: 6e5, + refund: 7e5, + funding: 8e5, + } + orderIDs := make([]order.OrderID, 10) + for i := range orderIDs { + var id order.OrderID + copy(id[:], encode.RandomBytes(order.OrderIDSize)) + orderIDs[i] = id + } + + driftToleranceEdge := func(rate uint64, within bool) uint64 { + edge := rate + uint64(float64(rate)*driftTolerance) + if within { + return edge - rateStep } - if err != nil { - t.Fatalf("%s: unexpected error: %v", test.name, err) + return edge + rateStep + } + + sellPlacements := []*multiTradePlacement{ + {lots: 1, rate: 1e7, counterTradeRate: 0.9e7}, + {lots: 2, rate: 2e7, counterTradeRate: 1.9e7}, + {lots: 3, rate: 3e7, counterTradeRate: 2.9e7}, + {lots: 2, rate: 4e7, counterTradeRate: 3.9e7}, + } + + buyPlacements := []*multiTradePlacement{ + {lots: 1, rate: 4e7, counterTradeRate: 4.1e7}, + {lots: 2, rate: 3e7, counterTradeRate: 3.1e7}, + {lots: 3, rate: 2e7, counterTradeRate: 2.1e7}, + {lots: 2, rate: 1e7, counterTradeRate: 1.1e7}, + } + + // cancelLastPlacement is the same as placements, but with the rate + // and lots of the last order set to zero, which should cause pending + // orders at that placementIndex to be cancelled. + cancelLastPlacement := func(sell bool) []*multiTradePlacement { + placements := make([]*multiTradePlacement, len(sellPlacements)) + if sell { + copy(placements, sellPlacements) + } else { + copy(placements, buyPlacements) } + placements[len(placements)-1] = &multiTradePlacement{} + return placements - if !reflect.DeepEqual(tCore.preOrderParam, test.expectPreOrderParam) { - t.Fatalf("%s: expected pre order param %+v != actual %+v", test.name, test.expectPreOrderParam, tCore.preOrderParam) + } + + pendingOrders := func(sell bool) map[order.OrderID]*pendingDEXOrder { + var placements []*multiTradePlacement + if sell { + placements = sellPlacements + } else { + placements = buyPlacements } - if !reflect.DeepEqual(res, expectedResult) { - t.Fatalf("%s: expected max sell result %+v != actual %+v", test.name, expectedResult, res) + return map[order.OrderID]*pendingDEXOrder{ + orderIDs[0]: { // Should cancel, but cannot due to epoch > currEpoch - 2 + order: &core.Order{ + Qty: 1 * lotSize, + Sell: sell, + ID: orderIDs[0][:], + Rate: driftToleranceEdge(placements[0].rate, true), + Epoch: currEpoch - 1, + }, + placementIndex: 0, + counterTradeRate: placements[0].counterTradeRate, + }, + orderIDs[1]: { + order: &core.Order{ // Within tolerance, don't cancel + Qty: 2 * lotSize, + Filled: lotSize, + Sell: sell, + ID: orderIDs[1][:], + Rate: driftToleranceEdge(placements[1].rate, true), + Epoch: currEpoch - 2, + }, + placementIndex: 1, + counterTradeRate: placements[1].counterTradeRate, + }, + orderIDs[2]: { + order: &core.Order{ // Cancel + Qty: lotSize, + Sell: sell, + ID: orderIDs[2][:], + Rate: driftToleranceEdge(placements[2].rate, false), + Epoch: currEpoch - 2, + }, + placementIndex: 2, + counterTradeRate: placements[2].counterTradeRate, + }, + orderIDs[3]: { + order: &core.Order{ // Within tolerance, don't cancel + Qty: lotSize, + Sell: sell, + ID: orderIDs[3][:], + Rate: driftToleranceEdge(placements[3].rate, true), + Epoch: currEpoch - 2, + }, + placementIndex: 3, + counterTradeRate: placements[3].counterTradeRate, + }, } } -} -func TestExchangeAdaptorMaxBuy(t *testing.T) { - tCore := newTCore() + // pendingWithSelfMatch returns the same pending orders as pendingOrders, + // but with an additional order on the other side of the market that + // would cause a self-match. + pendingOrdersSelfMatch := func(sell bool) map[order.OrderID]*pendingDEXOrder { + orders := pendingOrders(sell) + var rate uint64 + if sell { + rate = driftToleranceEdge(2e7, true) // 2e7 is the rate of the lowest sell placement + } else { + rate = 3e7 // 3e7 is the rate of the highest buy placement + } + orders[orderIDs[4]] = &pendingDEXOrder{ + order: &core.Order{ // Within tolerance, don't cancel + Qty: lotSize, + Sell: !sell, + ID: orderIDs[4][:], + Rate: rate, + Epoch: currEpoch - 2, + }, + placementIndex: 0, + } + return orders + } - tCore.isAccountLocker[60] = true - dcrBtcID := fmt.Sprintf("%s-%d-%d", "host1", 42, 0) - ethBtcID := fmt.Sprintf("%s-%d-%d", "host1", 60, 0) + b2q := calc.BaseToQuote - // Whatever is returned from PreOrder is returned from this function. - // What we need to test is what is passed to PreOrder. - orderEstimate := &core.OrderEstimate{ - Swap: &asset.PreSwap{ - Estimate: &asset.SwapEstimate{ - Lots: 5, - Value: 5e8, - MaxFees: 1600, - RealisticWorstCase: 12010, - RealisticBestCase: 6008, - }, - }, - Redeem: &asset.PreRedeem{ - Estimate: &asset.RedeemEstimate{ - RealisticBestCase: 2800, - RealisticWorstCase: 6500, - }, - }, - } - tCore.orderEstimate = orderEstimate - - expectedResult := &core.MaxOrderEstimate{ - Swap: &asset.SwapEstimate{ - Lots: 5, - Value: 5e8, - MaxFees: 1600, - RealisticWorstCase: 12010, - RealisticBestCase: 6008, - }, - Redeem: &asset.RedeemEstimate{ - RealisticBestCase: 2800, - RealisticWorstCase: 6500, - }, + /* + * The dexBalance and cexBalances fields of this test are set so that they + * are at an edge. If any non-zero balance is decreased by 1, the behavior + * of the function should change. Each of the "WithDecrement" fields are + * the expected result if any of the non-zero balances are decreased by 1. + */ + type test struct { + name string + baseID uint32 + quoteID uint32 + + sellDexBalances map[uint32]uint64 + sellCexBalances map[uint32]uint64 + sellPlacements []*multiTradePlacement + sellPendingOrders map[order.OrderID]*pendingDEXOrder + sellDexReserves map[uint32]uint64 + sellCexReserves map[uint32]uint64 + + buyCexBalances map[uint32]uint64 + buyDexBalances map[uint32]uint64 + buyPlacements []*multiTradePlacement + buyPendingOrders map[order.OrderID]*pendingDEXOrder + buyDexReserves map[uint32]uint64 + buyCexReserves map[uint32]uint64 + + isAccountLocker map[uint32]bool + multiTradeResult []*core.Order + multiTradeResultWithDecrement []*core.Order + + expectedOrderIDs []*order.OrderID + expectedOrderIDsWithDecrement []*order.OrderID + + expectedSellPlacements []*core.QtyRate + expectedSellPlacementsWithDecrement []*core.QtyRate + + expectedBuyPlacements []*core.QtyRate + expectedBuyPlacementsWithDecrement []*core.QtyRate + + expectedCancels []dex.Bytes + expectedCancelsWithDecrement []dex.Bytes } - tests := []struct { - name string - dexBalances map[uint32]uint64 - market *core.Market - rate uint64 - swapFees uint64 - redeemFees uint64 - refundFees uint64 - - expectPreOrderParam *core.TradeForm - wantErr bool - }{ + tests := []*test{ { - name: "ok", - rate: 5e7, - dexBalances: map[uint32]uint64{ - 0: 5e6, - 42: 5e6, - }, - market: &core.Market{ - LotSize: 1e6, - BaseID: 42, - QuoteID: 0, + name: "non account locker", + baseID: 42, + quoteID: 0, + + // ---- Sell ---- + sellDexBalances: map[uint32]uint64{ + 42: 4*lotSize + 4*sellFees.swap + sellFees.funding, + 0: 0, + }, + sellCexBalances: map[uint32]uint64{ + 42: 0, + 0: b2q(sellPlacements[0].counterTradeRate, lotSize) + + b2q(sellPlacements[1].counterTradeRate, 2*lotSize) + + b2q(sellPlacements[2].counterTradeRate, 3*lotSize) + + b2q(sellPlacements[3].counterTradeRate, 2*lotSize), + }, + sellPlacements: sellPlacements, + sellPendingOrders: pendingOrders(true), + expectedSellPlacements: []*core.QtyRate{ + {Qty: lotSize, Rate: sellPlacements[1].rate}, + {Qty: 2 * lotSize, Rate: sellPlacements[2].rate}, + {Qty: lotSize, Rate: sellPlacements[3].rate}, + }, + expectedSellPlacementsWithDecrement: []*core.QtyRate{ + {Qty: lotSize, Rate: sellPlacements[1].rate}, + {Qty: 2 * lotSize, Rate: sellPlacements[2].rate}, + }, + + // ---- Buy ---- + buyDexBalances: map[uint32]uint64{ + 42: 0, + 0: b2q(buyPlacements[1].rate, lotSize) + + b2q(buyPlacements[2].rate, 2*lotSize) + + b2q(buyPlacements[3].rate, lotSize) + + 4*buyFees.swap + buyFees.funding, + }, + buyCexBalances: map[uint32]uint64{ + 42: 8 * lotSize, + 0: 0, + }, + buyPlacements: buyPlacements, + buyPendingOrders: pendingOrders(false), + expectedBuyPlacements: []*core.QtyRate{ + {Qty: lotSize, Rate: buyPlacements[1].rate}, + {Qty: 2 * lotSize, Rate: buyPlacements[2].rate}, + {Qty: lotSize, Rate: buyPlacements[3].rate}, + }, + expectedBuyPlacementsWithDecrement: []*core.QtyRate{ + {Qty: lotSize, Rate: buyPlacements[1].rate}, + {Qty: 2 * lotSize, Rate: buyPlacements[2].rate}, + }, + + expectedCancels: []dex.Bytes{orderIDs[2][:]}, + expectedCancelsWithDecrement: []dex.Bytes{orderIDs[2][:]}, + multiTradeResult: []*core.Order{ + {ID: orderIDs[4][:]}, + {ID: orderIDs[5][:]}, + {ID: orderIDs[6][:]}, + }, + multiTradeResultWithDecrement: []*core.Order{ + {ID: orderIDs[4][:]}, + {ID: orderIDs[5][:]}, + }, + expectedOrderIDs: []*order.OrderID{ + nil, &orderIDs[4], &orderIDs[5], &orderIDs[6], + }, + expectedOrderIDsWithDecrement: []*order.OrderID{ + nil, &orderIDs[4], &orderIDs[5], nil, }, - expectPreOrderParam: &core.TradeForm{ - Host: "host1", - IsLimit: true, - Base: 42, - Quote: 0, - Sell: false, - Rate: 5e7, - Qty: 9 * 1e6, - }, - swapFees: 1000, - redeemFees: 1000, }, { - name: "1 lot", - rate: 5e7, - dexBalances: map[uint32]uint64{ - 42: 1000, - 0: calc.BaseToQuote(5e7, 1e6) + 1000, + name: "non account locker, self-match", + baseID: 42, + quoteID: 0, + + // ---- Sell ---- + sellDexBalances: map[uint32]uint64{ + 42: 3*lotSize + 3*sellFees.swap + sellFees.funding, + 0: 0, }, - market: &core.Market{ - LotSize: 1e6, - BaseID: 42, - QuoteID: 0, + sellCexBalances: map[uint32]uint64{ + 42: 0, + 0: b2q(sellPlacements[0].counterTradeRate, lotSize) + + b2q(sellPlacements[1].counterTradeRate, lotSize) + + b2q(sellPlacements[2].counterTradeRate, 3*lotSize) + + b2q(sellPlacements[3].counterTradeRate, 2*lotSize), }, - expectPreOrderParam: &core.TradeForm{ - Host: "host1", - IsLimit: true, - Base: 42, - Quote: 0, - Sell: false, - Qty: 1e6, - Rate: 5e7, - }, - swapFees: 1000, - redeemFees: 1000, - }, - { - name: "not enough for 1 swap", - rate: 5e7, - dexBalances: map[uint32]uint64{ - 0: 1000, - 42: calc.BaseToQuote(5e7, 1e6) + 999, + sellPlacements: sellPlacements, + sellPendingOrders: pendingOrdersSelfMatch(true), + expectedSellPlacements: []*core.QtyRate{ + {Qty: 2 * lotSize, Rate: sellPlacements[2].rate}, + {Qty: lotSize, Rate: sellPlacements[3].rate}, }, - market: &core.Market{ - LotSize: 1e6, - BaseID: 42, - QuoteID: 0, + expectedSellPlacementsWithDecrement: []*core.QtyRate{ + {Qty: 2 * lotSize, Rate: sellPlacements[2].rate}, + }, + + // ---- Buy ---- + buyDexBalances: map[uint32]uint64{ + 42: 0, + 0: b2q(buyPlacements[2].rate, 2*lotSize) + + b2q(buyPlacements[3].rate, lotSize) + + 3*buyFees.swap + buyFees.funding, + }, + buyCexBalances: map[uint32]uint64{ + 42: 7 * lotSize, + 0: 0, + }, + buyPlacements: buyPlacements, + buyPendingOrders: pendingOrdersSelfMatch(false), + expectedBuyPlacements: []*core.QtyRate{ + {Qty: 2 * lotSize, Rate: buyPlacements[2].rate}, + {Qty: lotSize, Rate: buyPlacements[3].rate}, + }, + expectedBuyPlacementsWithDecrement: []*core.QtyRate{ + {Qty: 2 * lotSize, Rate: buyPlacements[2].rate}, + }, + + expectedCancels: []dex.Bytes{orderIDs[2][:]}, + expectedCancelsWithDecrement: []dex.Bytes{orderIDs[2][:]}, + multiTradeResult: []*core.Order{ + {ID: orderIDs[5][:]}, + {ID: orderIDs[6][:]}, + }, + multiTradeResultWithDecrement: []*core.Order{ + {ID: orderIDs[5][:]}, + }, + expectedOrderIDs: []*order.OrderID{ + nil, nil, &orderIDs[5], &orderIDs[6], + }, + expectedOrderIDsWithDecrement: []*order.OrderID{ + nil, nil, &orderIDs[5], nil, }, - swapFees: 1000, - redeemFees: 1000, - wantErr: true, }, { - name: "not enough for 1 lot of redeem fees", - rate: 5e7, - dexBalances: map[uint32]uint64{ - 0: calc.BaseToQuote(5e7, 1e6) + 1000, - 60: 999, - }, - market: &core.Market{ - LotSize: 1e6, - BaseID: 60, - QuoteID: 0, + name: "non account locker, cancel last placement", + baseID: 42, + quoteID: 0, + // ---- Sell ---- + sellDexBalances: map[uint32]uint64{ + 42: 3*lotSize + 3*sellFees.swap + sellFees.funding, + 0: 0, + }, + sellCexBalances: map[uint32]uint64{ + 42: 0, + 0: b2q(sellPlacements[0].counterTradeRate, lotSize) + + b2q(sellPlacements[1].counterTradeRate, 2*lotSize) + + b2q(sellPlacements[2].counterTradeRate, 3*lotSize) + + b2q(sellPlacements[3].counterTradeRate, lotSize), + }, + sellPlacements: cancelLastPlacement(true), + sellPendingOrders: pendingOrders(true), + expectedSellPlacements: []*core.QtyRate{ + {Qty: lotSize, Rate: sellPlacements[1].rate}, + {Qty: 2 * lotSize, Rate: sellPlacements[2].rate}, + }, + expectedSellPlacementsWithDecrement: []*core.QtyRate{ + {Qty: lotSize, Rate: sellPlacements[1].rate}, + {Qty: lotSize, Rate: sellPlacements[2].rate}, + }, + + // ---- Buy ---- + buyDexBalances: map[uint32]uint64{ + 42: 0, + 0: b2q(buyPlacements[1].rate, lotSize) + + b2q(buyPlacements[2].rate, 2*lotSize) + + 3*buyFees.swap + buyFees.funding, + }, + buyCexBalances: map[uint32]uint64{ + 42: 7 * lotSize, + 0: 0, + }, + buyPlacements: cancelLastPlacement(false), + buyPendingOrders: pendingOrders(false), + expectedBuyPlacements: []*core.QtyRate{ + {Qty: lotSize, Rate: buyPlacements[1].rate}, + {Qty: 2 * lotSize, Rate: buyPlacements[2].rate}, + }, + expectedBuyPlacementsWithDecrement: []*core.QtyRate{ + {Qty: lotSize, Rate: buyPlacements[1].rate}, + {Qty: lotSize, Rate: buyPlacements[2].rate}, + }, + + expectedCancels: []dex.Bytes{orderIDs[3][:], orderIDs[2][:]}, + expectedCancelsWithDecrement: []dex.Bytes{orderIDs[3][:], orderIDs[2][:]}, + multiTradeResult: []*core.Order{ + {ID: orderIDs[4][:]}, + {ID: orderIDs[5][:]}, + }, + multiTradeResultWithDecrement: []*core.Order{ + {ID: orderIDs[4][:]}, + {ID: orderIDs[5][:]}, + }, + expectedOrderIDs: []*order.OrderID{ + nil, &orderIDs[4], &orderIDs[5], nil, + }, + expectedOrderIDsWithDecrement: []*order.OrderID{ + nil, &orderIDs[4], &orderIDs[5], nil, }, - swapFees: 1000, - redeemFees: 1000, - wantErr: true, }, { - name: "only account locker affected by redeem fees", - rate: 5e7, - dexBalances: map[uint32]uint64{ - 0: calc.BaseToQuote(5e7, 1e6) + 1000, - 42: 999, + name: "non account locker, cex reserves", + baseID: 42, + quoteID: 0, + // ---- Sell ---- + sellDexBalances: map[uint32]uint64{ + 42: 2*lotSize + 2*sellFees.swap + sellFees.funding, + 0: 0, }, - market: &core.Market{ - LotSize: 1e6, - BaseID: 42, - QuoteID: 0, + sellCexBalances: map[uint32]uint64{ + 42: 0, + 0: b2q(sellPlacements[0].counterTradeRate, lotSize) + + b2q(sellPlacements[1].counterTradeRate, 2*lotSize) + + b2q(sellPlacements[2].counterTradeRate, 3*lotSize) + + b2q(sellPlacements[3].counterTradeRate, 2*lotSize), }, - swapFees: 1000, - redeemFees: 1000, - expectPreOrderParam: &core.TradeForm{ - Host: "host1", - IsLimit: true, - Base: 42, - Quote: 0, - Sell: false, - Qty: 1e6, - Rate: 5e7, + sellPlacements: sellPlacements, + sellPendingOrders: pendingOrders(true), + expectedSellPlacements: []*core.QtyRate{ + {Qty: lotSize, Rate: sellPlacements[1].rate}, + {Qty: lotSize, Rate: sellPlacements[2].rate}, + }, + expectedSellPlacementsWithDecrement: []*core.QtyRate{ + {Qty: lotSize, Rate: sellPlacements[1].rate}, + }, + sellCexReserves: map[uint32]uint64{ + 0: b2q(3.9e7, lotSize) + b2q(2.9e7, lotSize), + }, + + // ---- Buy ---- + buyDexBalances: map[uint32]uint64{ + 42: 0, + 0: b2q(buyPlacements[1].rate, lotSize) + + b2q(buyPlacements[2].rate, lotSize) + + 2*buyFees.swap + buyFees.funding, + }, + buyCexBalances: map[uint32]uint64{ + 42: 8 * lotSize, + 0: 0, + }, + buyPlacements: buyPlacements, + buyPendingOrders: pendingOrders(false), + expectedBuyPlacements: []*core.QtyRate{ + {Qty: lotSize, Rate: buyPlacements[1].rate}, + {Qty: lotSize, Rate: buyPlacements[2].rate}, + }, + expectedBuyPlacementsWithDecrement: []*core.QtyRate{ + {Qty: lotSize, Rate: buyPlacements[1].rate}, + }, + buyCexReserves: map[uint32]uint64{ + 42: 2 * lotSize, + }, + + expectedCancels: []dex.Bytes{orderIDs[2][:], orderIDs[3][:]}, + expectedCancelsWithDecrement: []dex.Bytes{orderIDs[2][:], orderIDs[3][:]}, + multiTradeResult: []*core.Order{ + {ID: orderIDs[3][:]}, + {ID: orderIDs[4][:]}, + }, + multiTradeResultWithDecrement: []*core.Order{ + {ID: orderIDs[3][:]}, + }, + expectedOrderIDs: []*order.OrderID{ + nil, &orderIDs[3], &orderIDs[4], nil, + }, + expectedOrderIDsWithDecrement: []*order.OrderID{ + nil, &orderIDs[3], nil, nil, }, }, { - name: "2 lots with refund fees, not account locker", - rate: 5e7, - dexBalances: map[uint32]uint64{ - 0: calc.BaseToQuote(5e7, 2e6) + 2000, - 42: 1000, + name: "non account locker, dex reserves", + baseID: 42, + quoteID: 0, + // ---- Sell ---- + sellDexBalances: map[uint32]uint64{ + 42: 4*lotSize + 2*sellFees.swap + sellFees.funding, + 0: 0, }, - market: &core.Market{ - LotSize: 1e6, - BaseID: 42, - QuoteID: 0, + sellCexBalances: map[uint32]uint64{ + 42: 0, + 0: b2q(sellPlacements[0].counterTradeRate, lotSize) + + b2q(sellPlacements[1].counterTradeRate, 2*lotSize) + + b2q(sellPlacements[2].counterTradeRate, 2*lotSize) + + b2q(sellPlacements[3].counterTradeRate, lotSize), + }, + sellPlacements: sellPlacements, + sellPendingOrders: pendingOrders(true), + expectedSellPlacements: []*core.QtyRate{ + {Qty: lotSize, Rate: sellPlacements[1].rate}, + {Qty: lotSize, Rate: sellPlacements[2].rate}, + }, + expectedSellPlacementsWithDecrement: []*core.QtyRate{ + {Qty: lotSize, Rate: sellPlacements[1].rate}, + }, + sellDexReserves: map[uint32]uint64{ + 42: 2 * lotSize, + }, + + // ---- Buy ---- + buyDexBalances: map[uint32]uint64{ + 42: 0, + 0: b2q(buyPlacements[1].rate, 2*lotSize) + + b2q(buyPlacements[2].rate, 2*lotSize) + + 2*buyFees.swap + buyFees.funding, + }, + buyCexBalances: map[uint32]uint64{ + 42: 6 * lotSize, + 0: 0, + }, + buyPlacements: buyPlacements, + buyPendingOrders: pendingOrders(false), + expectedBuyPlacements: []*core.QtyRate{ + {Qty: lotSize, Rate: buyPlacements[1].rate}, + {Qty: lotSize, Rate: buyPlacements[2].rate}, + }, + expectedBuyPlacementsWithDecrement: []*core.QtyRate{ + {Qty: lotSize, Rate: buyPlacements[1].rate}, + }, + buyDexReserves: map[uint32]uint64{ + 0: b2q(buyPlacements[1].rate, lotSize) + b2q(buyPlacements[2].rate, lotSize), + }, + + expectedCancels: []dex.Bytes{orderIDs[2][:], orderIDs[3][:]}, + expectedCancelsWithDecrement: []dex.Bytes{orderIDs[2][:], orderIDs[3][:]}, + multiTradeResult: []*core.Order{ + {ID: orderIDs[3][:]}, + {ID: orderIDs[4][:]}, + }, + multiTradeResultWithDecrement: []*core.Order{ + {ID: orderIDs[3][:]}, + }, + expectedOrderIDs: []*order.OrderID{ + nil, &orderIDs[3], &orderIDs[4], nil, + }, + expectedOrderIDsWithDecrement: []*order.OrderID{ + nil, &orderIDs[3], nil, nil, }, - expectPreOrderParam: &core.TradeForm{ - Host: "host1", - IsLimit: true, - Base: 42, - Quote: 0, - Sell: false, - Qty: 2e6, - Rate: 5e7, - }, - swapFees: 1000, - redeemFees: 1000, - refundFees: 1000, }, { - name: "1 lot with refund fees, account locker", - rate: 5e7, - dexBalances: map[uint32]uint64{ - 0: calc.BaseToQuote(5e7, 2e6) + 2000, - 60: 1000, + name: "account locker token", + baseID: 966001, + quoteID: 60, + isAccountLocker: map[uint32]bool{ + 966001: true, + 60: true, }, - market: &core.Market{ - LotSize: 1e6, - BaseID: 60, - QuoteID: 0, + + // ---- Sell ---- + sellDexBalances: map[uint32]uint64{ + 966001: 4 * lotSize, + 966: 4*(sellFees.swap+sellFees.refund) + sellFees.funding, + 60: 4 * sellFees.redemption, + }, + sellCexBalances: map[uint32]uint64{ + 96601: 0, + 60: b2q(sellPlacements[0].counterTradeRate, lotSize) + + b2q(sellPlacements[1].counterTradeRate, 2*lotSize) + + b2q(sellPlacements[2].counterTradeRate, 3*lotSize) + + b2q(sellPlacements[3].counterTradeRate, 2*lotSize), + }, + sellPlacements: sellPlacements, + sellPendingOrders: pendingOrders(true), + expectedSellPlacements: []*core.QtyRate{ + {Qty: lotSize, Rate: sellPlacements[1].rate}, + {Qty: 2 * lotSize, Rate: sellPlacements[2].rate}, + {Qty: lotSize, Rate: sellPlacements[3].rate}, + }, + expectedSellPlacementsWithDecrement: []*core.QtyRate{ + {Qty: lotSize, Rate: sellPlacements[1].rate}, + {Qty: 2 * lotSize, Rate: sellPlacements[2].rate}, + }, + + // ---- Buy ---- + buyDexBalances: map[uint32]uint64{ + 966: 4 * buyFees.redemption, + 60: b2q(buyPlacements[1].rate, lotSize) + + b2q(buyPlacements[2].rate, 2*lotSize) + + b2q(buyPlacements[3].rate, lotSize) + + 4*buyFees.swap + 4*buyFees.refund + buyFees.funding, + }, + buyCexBalances: map[uint32]uint64{ + 966001: 8 * lotSize, + 0: 0, + }, + buyPlacements: buyPlacements, + buyPendingOrders: pendingOrders(false), + expectedBuyPlacements: []*core.QtyRate{ + {Qty: lotSize, Rate: buyPlacements[1].rate}, + {Qty: 2 * lotSize, Rate: buyPlacements[2].rate}, + {Qty: lotSize, Rate: buyPlacements[3].rate}, + }, + expectedBuyPlacementsWithDecrement: []*core.QtyRate{ + {Qty: lotSize, Rate: buyPlacements[1].rate}, + {Qty: 2 * lotSize, Rate: buyPlacements[2].rate}, + }, + + expectedCancels: []dex.Bytes{orderIDs[2][:]}, + expectedCancelsWithDecrement: []dex.Bytes{orderIDs[2][:]}, + multiTradeResult: []*core.Order{ + {ID: orderIDs[3][:]}, + {ID: orderIDs[4][:]}, + {ID: orderIDs[5][:]}, + }, + multiTradeResultWithDecrement: []*core.Order{ + {ID: orderIDs[3][:]}, + {ID: orderIDs[4][:]}, + }, + expectedOrderIDs: []*order.OrderID{ + nil, &orderIDs[3], &orderIDs[4], &orderIDs[5], + }, + expectedOrderIDsWithDecrement: []*order.OrderID{ + nil, &orderIDs[3], &orderIDs[4], nil, }, - expectPreOrderParam: &core.TradeForm{ - Host: "host1", - IsLimit: true, - Base: 60, - Quote: 0, - Sell: false, - Qty: 1e6, - Rate: 5e7, - }, - swapFees: 1000, - redeemFees: 1000, - refundFees: 1000, }, } for _, test := range tests { - tCore.market = test.market - tCore.buySwapFees = test.swapFees - tCore.buyRedeemFees = test.redeemFees + t.Run(test.name, func(t *testing.T) { + testWithDecrement := func(sell, decrement, cex bool, assetID uint32) { + t.Run(fmt.Sprintf("sell=%v, decrement=%v, cex=%v, assetID=%d", sell, decrement, cex, assetID), func(t *testing.T) { + tCore := newTCore() + tCore.isAccountLocker = test.isAccountLocker + tCore.market = &core.Market{ + BaseID: test.baseID, + QuoteID: test.quoteID, + LotSize: lotSize, + } + tCore.multiTradeResult = test.multiTradeResult + if decrement { + tCore.multiTradeResult = test.multiTradeResultWithDecrement + } - botID := dcrBtcID - if test.market.BaseID != 42 { - botID = ethBtcID - } + var dexBalances, cexBalances map[uint32]uint64 + if sell { + dexBalances = test.sellDexBalances + cexBalances = test.sellCexBalances + } else { + dexBalances = test.buyDexBalances + cexBalances = test.buyCexBalances + } + adaptor := unifiedExchangeAdaptorForBot(&exchangeAdaptorCfg{ + core: tCore, + baseDexBalances: dexBalances, + baseCexBalances: cexBalances, + market: &MarketWithHost{ + Host: "dex.com", + BaseID: test.baseID, + QuoteID: test.quoteID, + }, + log: tLogger, + }) - ctx, cancel := context.WithCancel(context.Background()) - defer cancel() + var pendingOrders map[order.OrderID]*pendingDEXOrder + if sell { + pendingOrders = test.sellPendingOrders + } else { + pendingOrders = test.buyPendingOrders + } + pendingOrdersCopy := make(map[order.OrderID]*pendingDEXOrder) + for id, order := range pendingOrders { + pendingOrdersCopy[id] = order + } + adaptor.pendingDEXOrders = pendingOrdersCopy + adaptor.buyFees = buyFees + adaptor.sellFees = sellFees + + var placements []*multiTradePlacement + var dexReserves, cexReserves map[uint32]uint64 + if sell { + placements = test.sellPlacements + dexReserves = test.sellDexReserves + cexReserves = test.sellCexReserves + } else { + placements = test.buyPlacements + dexReserves = test.buyDexReserves + cexReserves = test.buyCexReserves + } + res := adaptor.MultiTrade(placements, sell, driftTolerance, currEpoch, dexReserves, cexReserves) - adaptor := unifiedExchangeAdaptorForBot(botID, test.dexBalances, nil, tCore, nil, tLogger) - adaptor.run(ctx) + expectedOrderIDs := test.expectedOrderIDs + if decrement { + expectedOrderIDs = test.expectedOrderIDsWithDecrement + } + if !reflect.DeepEqual(res, expectedOrderIDs) { + t.Fatalf("expected orderIDs %v, got %v", expectedOrderIDs, res) + } - res, err := adaptor.MaxBuy("host1", test.market.BaseID, test.market.QuoteID, test.rate) - if test.wantErr { - if err == nil { - t.Fatalf("%s: expected error but did not get", test.name) + var expectedPlacements []*core.QtyRate + if sell { + expectedPlacements = test.expectedSellPlacements + if decrement { + expectedPlacements = test.expectedSellPlacementsWithDecrement + } + } else { + expectedPlacements = test.expectedBuyPlacements + if decrement { + expectedPlacements = test.expectedBuyPlacementsWithDecrement + } + } + if len(expectedPlacements) > 0 != (len(tCore.multiTradesPlaced) > 0) { + t.Fatalf("%s: expected placements %v, got %v", test.name, len(expectedPlacements) > 0, len(tCore.multiTradesPlaced) > 0) + } + if len(expectedPlacements) > 0 { + placements := tCore.multiTradesPlaced[0].Placements + if !reflect.DeepEqual(placements, expectedPlacements) { + t.Fatal(spew.Sprintf("%s: expected placements:\n%#+v\ngot:\n%+#v", test.name, expectedPlacements, placements)) + } + } + + expectedCancels := test.expectedCancels + if decrement { + expectedCancels = test.expectedCancelsWithDecrement + } + sort.Slice(tCore.cancelsPlaced, func(i, j int) bool { + return bytes.Compare(tCore.cancelsPlaced[i], tCore.cancelsPlaced[j]) < 0 + }) + sort.Slice(expectedCancels, func(i, j int) bool { + return bytes.Compare(expectedCancels[i], expectedCancels[j]) < 0 + }) + if !reflect.DeepEqual(tCore.cancelsPlaced, expectedCancels) { + t.Fatalf("expected cancels %v, got %v", expectedCancels, tCore.cancelsPlaced) + } + }) } - continue - } - if err != nil { - t.Fatalf("%s: unexpected error: %v", test.name, err) - } - if !reflect.DeepEqual(tCore.preOrderParam, test.expectPreOrderParam) { - t.Fatalf("%s: expected pre order param %+v != actual %+v", test.name, test.expectPreOrderParam, tCore.preOrderParam) - } + for _, sell := range []bool{true, false} { + var dexBalances, cexBalances map[uint32]uint64 + if sell { + dexBalances = test.sellDexBalances + cexBalances = test.sellCexBalances + } else { + dexBalances = test.buyDexBalances + cexBalances = test.buyCexBalances + } - if !reflect.DeepEqual(res, expectedResult) { - t.Fatalf("%s: expected max buy result %+v != actual %+v", test.name, expectedResult, res) - } + testWithDecrement(sell, false, false, 0) + for assetID, bal := range dexBalances { + if bal == 0 { + continue + } + dexBalances[assetID]-- + testWithDecrement(sell, true, false, assetID) + dexBalances[assetID]++ + } + for assetID, bal := range cexBalances { + if bal == 0 { + continue + } + cexBalances[assetID]-- + testWithDecrement(sell, true, true, assetID) + cexBalances[assetID]++ + } + } + }) } } -func TestExchangeAdaptorDEXTrade(t *testing.T) { +func TestDEXTrade(t *testing.T) { host := "dex.com" + lotSize := uint64(1e6) orderIDs := make([]order.OrderID, 5) for i := range orderIDs { @@ -604,10 +1514,12 @@ func TestExchangeAdaptorDEXTrade(t *testing.T) { name string isDynamicSwapper map[uint32]bool initialBalances map[uint32]uint64 - multiTrade *core.MultiTradeForm + baseID uint32 + quoteID uint32 + sell bool + placements []*multiTradePlacement initialLockedFunds []*orderLockedFunds - wantErr bool postTradeBalances map[uint32]*botBalance updatesAndBalances []*updatesAndBalances } @@ -619,15 +1531,12 @@ func TestExchangeAdaptorDEXTrade(t *testing.T) { 42: 1e8, 0: 1e8, }, - multiTrade: &core.MultiTradeForm{ - Host: host, - Sell: true, - Base: 42, - Quote: 0, - Placements: []*core.QtyRate{ - {Qty: 5e6, Rate: 5e7}, - {Qty: 5e6, Rate: 6e7}, - }, + sell: true, + baseID: 42, + quoteID: 0, + placements: []*multiTradePlacement{ + {lots: 5, rate: 5e7}, + {lots: 5, rate: 6e7}, }, initialLockedFunds: []*orderLockedFunds{ newOrderLockedFunds(orderIDs[0], 5e6+2000, 0, 0, 0), @@ -731,15 +1640,11 @@ func TestExchangeAdaptorDEXTrade(t *testing.T) { 42: 1e8, 0: 1e8, }, - multiTrade: &core.MultiTradeForm{ - Host: host, - Sell: false, - Base: 42, - Quote: 0, - Placements: []*core.QtyRate{ - {Qty: 5e6, Rate: 5e7}, - {Qty: 5e6, Rate: 6e7}, - }, + baseID: 42, + quoteID: 0, + placements: []*multiTradePlacement{ + {lots: 5, rate: 5e7}, + {lots: 5, rate: 6e7}, }, initialLockedFunds: []*orderLockedFunds{ newOrderLockedFunds(orderIDs[0], b2q(5e7, 5e6)+2000, 0, 0, 0), @@ -849,15 +1754,12 @@ func TestExchangeAdaptorDEXTrade(t *testing.T) { 966: true, 60: true, }, - multiTrade: &core.MultiTradeForm{ - Host: host, - Sell: true, - Base: 60, - Quote: 966001, - Placements: []*core.QtyRate{ - {Qty: 5e6, Rate: 5e7}, - {Qty: 5e6, Rate: 6e7}, - }, + sell: true, + baseID: 60, + quoteID: 966001, + placements: []*multiTradePlacement{ + {lots: 5, rate: 5e7}, + {lots: 5, rate: 6e7}, }, initialLockedFunds: []*orderLockedFunds{ newOrderLockedFunds(orderIDs[0], 5e6+2000, 0, 4000, 3000), @@ -975,15 +1877,11 @@ func TestExchangeAdaptorDEXTrade(t *testing.T) { 966: true, 60: true, }, - multiTrade: &core.MultiTradeForm{ - Host: host, - Sell: false, - Base: 60, - Quote: 966001, - Placements: []*core.QtyRate{ - {Qty: 5e6, Rate: 5e7}, - {Qty: 5e6, Rate: 6e7}, - }, + baseID: 60, + quoteID: 966001, + placements: []*multiTradePlacement{ + {lots: 5, rate: 5e7}, + {lots: 5, rate: 6e7}, }, initialLockedFunds: []*orderLockedFunds{ newOrderLockedFunds(orderIDs[0], b2q(5e7, 5e6), 2000, 3000, 4000), @@ -1091,22 +1989,22 @@ func TestExchangeAdaptorDEXTrade(t *testing.T) { }, } - mkt := &core.Market{ - LotSize: 1e6, - } - runTest := func(test *test) { tCore := newTCore() - tCore.market = mkt + tCore.market = &core.Market{ + BaseID: test.baseID, + QuoteID: test.quoteID, + LotSize: lotSize, + } tCore.isDynamicSwapper = test.isDynamicSwapper multiTradeResult := make([]*core.Order, 0, len(test.initialLockedFunds)) for _, o := range test.initialLockedFunds { multiTradeResult = append(multiTradeResult, &core.Order{ - Host: test.multiTrade.Host, - BaseID: test.multiTrade.Base, - QuoteID: test.multiTrade.Quote, - Sell: test.multiTrade.Sell, + Host: host, + BaseID: test.baseID, + QuoteID: test.quoteID, + Sell: test.sell, LockedAmt: o.lockedAmt, ID: o.id[:], ParentAssetLockedAmt: o.parentAssetLockedAmt, @@ -1116,21 +2014,29 @@ func TestExchangeAdaptorDEXTrade(t *testing.T) { } tCore.multiTradeResult = multiTradeResult + // These don't effect the test, but need to be non-nil. + tCore.singleLotBuyFees = &orderFees{} + tCore.singleLotSellFees = &orderFees{} + ctx, cancel := context.WithCancel(context.Background()) defer cancel() - botID := dexMarketID(test.multiTrade.Host, test.multiTrade.Base, test.multiTrade.Quote) - adaptor := unifiedExchangeAdaptorForBot(botID, test.initialBalances, nil, tCore, nil, tLogger) + botID := dexMarketID(host, test.baseID, test.quoteID) + adaptor := unifiedExchangeAdaptorForBot(&exchangeAdaptorCfg{ + botID: botID, + core: tCore, + baseDexBalances: test.initialBalances, + log: tLogger, + market: &MarketWithHost{ + Host: host, + BaseID: test.baseID, + QuoteID: test.quoteID, + }, + }) adaptor.run(ctx) - _, err := adaptor.MultiTrade([]byte{}, test.multiTrade) - if test.wantErr { - if err == nil { - t.Fatalf("%s: expected error but did not get", test.name) - } - return - } - if err != nil { - t.Fatalf("%s: unexpected error: %v", test.name, err) + orders := adaptor.MultiTrade(test.placements, test.sell, 0.01, 100, nil, nil) + if len(orders) == 0 { + t.Fatalf("%s: multi trade did not place orders", test.name) } checkBalances := func(expected map[uint32]*botBalance, updateNum int) { @@ -1163,10 +2069,10 @@ func TestExchangeAdaptorDEXTrade(t *testing.T) { tCore.walletTxsMtx.Unlock() o := &core.Order{ - Host: test.multiTrade.Host, - BaseID: test.multiTrade.Base, - QuoteID: test.multiTrade.Quote, - Sell: test.multiTrade.Sell, + Host: host, + BaseID: test.baseID, + QuoteID: test.quoteID, + Sell: test.sell, LockedAmt: update.orderUpdate.lockedAmt, ID: update.orderUpdate.id[:], ParentAssetLockedAmt: update.orderUpdate.parentAssetLockedAmt, @@ -1214,7 +2120,7 @@ func TestExchangeAdaptorDEXTrade(t *testing.T) { } } -func TestExchangeAdaptorDeposit(t *testing.T) { +func TestDeposit(t *testing.T) { type test struct { name string isWithdrawer bool @@ -1392,7 +2298,6 @@ func TestExchangeAdaptorDeposit(t *testing.T) { } runTest := func(test *test) { - fmt.Println("running test ", test.name) tCore := newTCore() tCore.isWithdrawer[test.assetID] = test.isWithdrawer tCore.isDynamicSwapper[test.assetID] = test.isDynamicSwapper @@ -1427,10 +2332,21 @@ func TestExchangeAdaptorDeposit(t *testing.T) { defer cancel() botID := dexMarketID("host1", test.assetID, 0) - adaptor := unifiedExchangeAdaptorForBot(botID, dexBalances, cexBalances, tCore, tCEX, tLogger) + adaptor := unifiedExchangeAdaptorForBot(&exchangeAdaptorCfg{ + botID: botID, + core: tCore, + cex: tCEX, + baseDexBalances: dexBalances, + baseCexBalances: cexBalances, + log: tLogger, + market: &MarketWithHost{ + Host: "host1", + BaseID: test.assetID, + QuoteID: 0, + }, + }) adaptor.run(ctx) - - err := adaptor.Deposit(ctx, test.assetID, test.depositAmt, func() {}) + err := adaptor.Deposit(ctx, test.assetID, test.depositAmt) if err != nil { t.Fatalf("%s: unexpected error: %v", test.name, err) } @@ -1460,26 +2376,47 @@ func TestExchangeAdaptorDeposit(t *testing.T) { tCEX.confirmDeposit <- test.receivedAmt <-tCEX.confirmDepositComplete - if test.isDynamicSwapper { - time.Sleep(time.Millisecond * 100) // let the tx confirmation routine call WalletTransaction - } + checkPostConfirmBalance := func() error { + postConfirmBal, err := adaptor.DEXBalance(test.assetID) + if err != nil { + t.Fatalf("%s: unexpected error: %v", test.name, err) + } - postConfirmBal, err := adaptor.DEXBalance(test.assetID) - if err != nil { - t.Fatalf("%s: unexpected error: %v", test.name, err) - } + if *postConfirmBal != *test.postConfirmDEXBalance { + t.Fatalf("%s: unexpected post confirm dex balance. want %d, got %d", test.name, test.postConfirmDEXBalance, postConfirmBal) + } - if *postConfirmBal != *test.postConfirmDEXBalance { - t.Fatalf("%s: unexpected post confirm dex balance. want %d, got %d", test.name, test.postConfirmDEXBalance, postConfirmBal) + if test.assetID == 966001 { + postConfirmParentBal, err := adaptor.DEXBalance(966) + if err != nil { + t.Fatalf("%s: unexpected error: %v", test.name, err) + } + if postConfirmParentBal.Available != 2e6-test.confirmedTx.Fees { + t.Fatalf("%s: unexpected post confirm dex balance. want %d, got %d", test.name, postConfirmParentBal.Available, 2e6-test.confirmedTx.Fees) + } + } + return nil } - if test.assetID == 966001 { - postConfirmParentBal, err := adaptor.DEXBalance(966) + // Dynamic swappers start a goroutine to wait for a transaction to be + // confirmed in order to get the correct fees. Here we wait for the + // goroutine to finish. + if test.isDynamicSwapper { + var err error + for i := 0; i < 10; i++ { + time.Sleep(time.Millisecond * 100) + err = checkPostConfirmBalance() + if err == nil { + break + } + } if err != nil { - t.Fatalf("%s: unexpected error: %v", test.name, err) + t.Fatal(err) } - if postConfirmParentBal.Available != 2e6-test.confirmedTx.Fees { - t.Fatalf("%s: unexpected pre confirm dex balance. want %d, got %d", test.name, test.preConfirmDEXBalance, preConfirmBal.Available) + } else { + err = checkPostConfirmBalance() + if err != nil { + t.Fatal(err) } } } @@ -1489,7 +2426,7 @@ func TestExchangeAdaptorDeposit(t *testing.T) { } } -func TestExchangeAdaptorWithdraw(t *testing.T) { +func TestWithdraw(t *testing.T) { assetID := uint32(42) coinID := encode.RandomBytes(32) txID := hex.EncodeToString(coinID) @@ -1557,10 +2494,22 @@ func TestExchangeAdaptorWithdraw(t *testing.T) { defer cancel() botID := dexMarketID("host1", assetID, 0) - adaptor := unifiedExchangeAdaptorForBot(botID, dexBalances, cexBalances, tCore, tCEX, tLogger) + adaptor := unifiedExchangeAdaptorForBot(&exchangeAdaptorCfg{ + botID: botID, + core: tCore, + cex: tCEX, + baseDexBalances: dexBalances, + baseCexBalances: cexBalances, + log: tLogger, + market: &MarketWithHost{ + Host: "host1", + BaseID: assetID, + QuoteID: 0, + }, + }) adaptor.run(ctx) - err := adaptor.Withdraw(ctx, assetID, test.withdrawAmt, func() {}) + err := adaptor.Withdraw(ctx, assetID, test.withdrawAmt) if err != nil { t.Fatalf("%s: unexpected error: %v", test.name, err) } @@ -1595,7 +2544,7 @@ func TestExchangeAdaptorWithdraw(t *testing.T) { } } -func TestExchangeAdaptorTrade(t *testing.T) { +func TestCEXTrade(t *testing.T) { baseID := uint32(42) quoteID := uint32(0) tradeID := "123" @@ -1866,12 +2815,24 @@ func TestExchangeAdaptorTrade(t *testing.T) { defer cancel() botID := dexMarketID(botCfg.Host, botCfg.BaseID, botCfg.QuoteID) - adaptor := unifiedExchangeAdaptorForBot(botID, test.balances, test.balances, tCore, tCEX, tLogger) + adaptor := unifiedExchangeAdaptorForBot(&exchangeAdaptorCfg{ + botID: botID, + core: tCore, + cex: tCEX, + baseDexBalances: test.balances, + baseCexBalances: test.balances, + log: tLogger, + market: &MarketWithHost{ + Host: "host1", + BaseID: botCfg.BaseID, + QuoteID: botCfg.QuoteID, + }, + }) adaptor.run(ctx) adaptor.SubscribeTradeUpdates() - _, err := adaptor.Trade(ctx, baseID, quoteID, test.sell, test.rate, test.qty) + _, err := adaptor.CEXTrade(ctx, baseID, quoteID, test.sell, test.rate, test.qty) if test.wantErr { if err == nil { t.Fatalf("%s: expected error but did not get", test.name) @@ -1918,3 +2879,144 @@ func TestExchangeAdaptorTrade(t *testing.T) { runTest(test) } } + +func TestOrderFeesInUnits(t *testing.T) { + type test struct { + name string + buyFees *orderFees + sellFees *orderFees + rate uint64 + market *MarketWithHost + fiatRates map[uint32]float64 + + expectedSellBase uint64 + expectedSellQuote uint64 + expectedBuyBase uint64 + expectedBuyQuote uint64 + } + + tests := []*test{ + { + name: "dcr/btc", + market: &MarketWithHost{ + BaseID: 42, + QuoteID: 0, + }, + buyFees: &orderFees{ + swap: 5e5, + redemption: 1.1e4, + }, + sellFees: &orderFees{ + swap: 1.085e4, + redemption: 4e5, + }, + rate: 5e7, + expectedSellBase: 810850, + expectedBuyBase: 1011000, + expectedSellQuote: 405425, + expectedBuyQuote: 505500, + }, + { + name: "btc/usdc.eth", + market: &MarketWithHost{ + BaseID: 0, + QuoteID: 60001, + }, + buyFees: &orderFees{ + swap: 1e7, + redemption: 4e4, + }, + sellFees: &orderFees{ + swap: 5e4, + redemption: 1.1e7, + }, + fiatRates: map[uint32]float64{ + 60001: 0.99, + 60: 2300, + 0: 42999, + }, + rate: calc.MessageRateAlt(43000, 1e8, 1e6), + expectedSellBase: 108839, // 5e4 sats + (1.1e7 gwei / 1e9 * 2300 / 42999 * 1e8) = 108838.57 + expectedBuyBase: 93490, + expectedSellQuote: 47055556, + expectedBuyQuote: 40432323, + }, + { + name: "wbtc.polygon/usdc.eth", + market: &MarketWithHost{ + BaseID: 966003, + QuoteID: 60001, + }, + buyFees: &orderFees{ + swap: 1e7, + redemption: 2e8, + }, + sellFees: &orderFees{ + swap: 5e8, + redemption: 1.1e7, + }, + fiatRates: map[uint32]float64{ + 60001: 0.99, + 60: 2300, + 966003: 42500, + 966: 0.8, + }, + rate: calc.MessageRateAlt(43000, 1e8, 1e6), + expectedSellBase: 60470, + expectedBuyBase: 54494, + expectedSellQuote: 25959596, + expectedBuyQuote: 23393939, + }, + } + + runTest := func(tt *test) { + tCore := newTCore() + tCore.fiatRates = tt.fiatRates + tCore.singleLotBuyFees = tt.buyFees + tCore.singleLotSellFees = tt.sellFees + adaptor := unifiedExchangeAdaptorForBot(&exchangeAdaptorCfg{ + core: tCore, + log: tLogger, + market: tt.market, + }) + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + adaptor.run(ctx) + + sellBase, err := adaptor.OrderFeesInUnits(true, true, tt.rate) + if err != nil { + t.Fatalf("%s: unexpected error: %v", tt.name, err) + } + if sellBase != tt.expectedSellBase { + t.Fatalf("%s: unexpected sell base fee. want %d, got %d", tt.name, tt.expectedSellBase, sellBase) + } + + sellQuote, err := adaptor.OrderFeesInUnits(true, false, tt.rate) + if err != nil { + t.Fatalf("%s: unexpected error: %v", tt.name, err) + } + if sellQuote != tt.expectedSellQuote { + t.Fatalf("%s: unexpected sell quote fee. want %d, got %d", tt.name, tt.expectedSellQuote, sellQuote) + } + + buyBase, err := adaptor.OrderFeesInUnits(false, true, tt.rate) + if err != nil { + t.Fatalf("%s: unexpected error: %v", tt.name, err) + } + if buyBase != tt.expectedBuyBase { + t.Fatalf("%s: unexpected buy base fee. want %d, got %d", tt.name, tt.expectedBuyBase, buyBase) + } + + buyQuote, err := adaptor.OrderFeesInUnits(false, false, tt.rate) + if err != nil { + t.Fatalf("%s: unexpected error: %v", tt.name, err) + } + if buyQuote != tt.expectedBuyQuote { + t.Fatalf("%s: unexpected buy quote fee. want %d, got %d", tt.name, tt.expectedBuyQuote, buyQuote) + } + } + + for _, test := range tests { + runTest(test) + } +} diff --git a/client/mm/libxc/binance.go b/client/mm/libxc/binance.go index 106845cf00..08cf89285c 100644 --- a/client/mm/libxc/binance.go +++ b/client/mm/libxc/binance.go @@ -1418,8 +1418,6 @@ func (bnc *binance) handleMarketDataNote(b []byte) { slug := parts[0] // will be lower-case mktID := strings.ToUpper(slug) - bnc.log.Infof("Received book update for %q", mktID) - bnc.booksMtx.Lock() defer bnc.booksMtx.Unlock() diff --git a/client/mm/mm.go b/client/mm/mm.go index a06eace239..784be5f156 100644 --- a/client/mm/mm.go +++ b/client/mm/mm.go @@ -29,10 +29,7 @@ type clientCore interface { SupportedAssets() map[uint32]*core.SupportedAsset SingleLotFees(form *core.SingleLotFeesForm) (uint64, uint64, uint64, error) Cancel(oidB dex.Bytes) error - MaxBuy(host string, base, quote uint32, rate uint64) (*core.MaxOrderEstimate, error) - MaxSell(host string, base, quote uint32) (*core.MaxOrderEstimate, error) AssetBalance(assetID uint32) (*core.WalletBalance, error) - PreOrder(form *core.TradeForm) (*core.OrderEstimate, error) WalletState(assetID uint32) *core.WalletState MultiTrade(pw []byte, form *core.MultiTradeForm) ([]*core.Order, error) MaxFundingFees(fromAsset uint32, host string, numTrades uint32, fromSettings map[string]string) (uint64, error) @@ -66,10 +63,6 @@ type MarketWithHost struct { QuoteID uint32 `json:"quote"` } -func (m *MarketWithHost) String() string { - return fmt.Sprintf("%s-%d-%d", m.Host, m.BaseID, m.QuoteID) -} - // centralizedExchange is used to manage an exchange API connection. type centralizedExchange struct { libxc.CEX @@ -786,7 +779,6 @@ func (m *MarketMaker) Start(pw []byte, alternateConfigPath *string) (err error) } m.logInitialBotBalances(dexBaseBalances, cexBaseBalances) - fiatRates := m.core.FiatConversionRates() startedMarketMaking = true m.core.Broadcast(newMMStartStopNote(true)) @@ -806,6 +798,7 @@ func (m *MarketMaker) Start(pw []byte, alternateConfigPath *string) (err error) wg.Add(1) go func(cfg *BotConfig) { defer wg.Done() + mkt := MarketWithHost{cfg.Host, cfg.BaseID, cfg.QuoteID} m.markBotAsRunning(mkt, true) defer func() { @@ -816,20 +809,34 @@ func (m *MarketMaker) Start(pw []byte, alternateConfigPath *string) (err error) defer func() { m.core.Broadcast(newBotStartStopNote(cfg.Host, cfg.BaseID, cfg.QuoteID, false)) }() - logger := m.log.SubLogger(fmt.Sprintf("MarketMaker-%s-%d-%d", cfg.Host, cfg.BaseID, cfg.QuoteID)) + mktID := dexMarketID(cfg.Host, cfg.BaseID, cfg.QuoteID) - baseFiatRate := fiatRates[cfg.BaseID] - quoteFiatRate := fiatRates[cfg.QuoteID] - exchangeAdaptor := unifiedExchangeAdaptorForBot(mktID, dexBaseBalances[mktID], cexBaseBalances[mktID], m.core, nil, logger) + logger := m.log.SubLogger(fmt.Sprintf("MarketMaker-%s", mktID)) + exchangeAdaptor := unifiedExchangeAdaptorForBot(&exchangeAdaptorCfg{ + botID: mktID, + market: &mkt, + baseDexBalances: dexBaseBalances[mktID], + baseCexBalances: cexBaseBalances[mktID], + core: m.core, + cex: nil, + maxBuyPlacements: uint32(len(cfg.BasicMMConfig.BuyPlacements)), + maxSellPlacements: uint32(len(cfg.BasicMMConfig.SellPlacements)), + baseWalletOptions: cfg.BaseWalletOptions, + quoteWalletOptions: cfg.QuoteWalletOptions, + log: logger, + }) exchangeAdaptor.run(ctx) - RunBasicMarketMaker(m.ctx, cfg, exchangeAdaptor, oracle, baseFiatRate, quoteFiatRate, logger) + + RunBasicMarketMaker(m.ctx, cfg, exchangeAdaptor, oracle, logger) }(cfg) case cfg.SimpleArbConfig != nil: wg.Add(1) go func(cfg *BotConfig) { defer wg.Done() - logger := m.log.SubLogger(fmt.Sprintf("SimpleArbitrage-%s-%d-%d", cfg.Host, cfg.BaseID, cfg.QuoteID)) + mktID := dexMarketID(cfg.Host, cfg.BaseID, cfg.QuoteID) + logger := m.log.SubLogger(fmt.Sprintf("SimpleArbitrage-%s", mktID)) + cex, found := cexes[cfg.CEXCfg.Name] if !found { logger.Errorf("Cannot start %s bot due to CEX not starting", mktID) @@ -840,17 +847,34 @@ func (m *MarketMaker) Start(pw []byte, alternateConfigPath *string) (err error) defer func() { m.markBotAsRunning(mkt, false) }() - exchangeAdaptor := unifiedExchangeAdaptorForBot(mktID, dexBaseBalances[mktID], cexBaseBalances[mktID], m.core, cex, logger) + + exchangeAdaptor := unifiedExchangeAdaptorForBot(&exchangeAdaptorCfg{ + botID: mktID, + market: &mkt, + baseDexBalances: dexBaseBalances[mktID], + baseCexBalances: cexBaseBalances[mktID], + core: m.core, + cex: cex, + maxBuyPlacements: 1, + maxSellPlacements: 1, + baseWalletOptions: cfg.BaseWalletOptions, + quoteWalletOptions: cfg.QuoteWalletOptions, + rebalanceCfg: cfg.CEXCfg.AutoRebalance, + log: logger, + }) exchangeAdaptor.run(ctx) + RunSimpleArbBot(m.ctx, cfg, exchangeAdaptor, exchangeAdaptor, logger) }(cfg) case cfg.ArbMarketMakerConfig != nil: wg.Add(1) go func(cfg *BotConfig) { defer wg.Done() - logger := m.log.SubLogger(fmt.Sprintf("ArbMarketMaker-%s-%d-%d", cfg.Host, cfg.BaseID, cfg.QuoteID)) - cex, found := cexes[cfg.CEXCfg.Name] + mktID := dexMarketID(cfg.Host, cfg.BaseID, cfg.QuoteID) + logger := m.log.SubLogger(fmt.Sprintf("ArbMarketMaker-%s", mktID)) + + cex, found := cexes[cfg.CEXCfg.Name] if !found { logger.Errorf("Cannot start %s bot due to CEX not starting", mktID) return @@ -860,8 +884,23 @@ func (m *MarketMaker) Start(pw []byte, alternateConfigPath *string) (err error) defer func() { m.markBotAsRunning(mkt, false) }() - exchangeAdaptor := unifiedExchangeAdaptorForBot(mktID, dexBaseBalances[mktID], cexBaseBalances[mktID], m.core, cex, logger) + + exchangeAdaptor := unifiedExchangeAdaptorForBot(&exchangeAdaptorCfg{ + botID: mktID, + market: &mkt, + baseDexBalances: dexBaseBalances[mktID], + baseCexBalances: cexBaseBalances[mktID], + core: m.core, + cex: cex, + maxBuyPlacements: uint32(len(cfg.ArbMarketMakerConfig.BuyPlacements)), + maxSellPlacements: uint32(len(cfg.ArbMarketMakerConfig.SellPlacements)), + baseWalletOptions: cfg.BaseWalletOptions, + quoteWalletOptions: cfg.QuoteWalletOptions, + rebalanceCfg: cfg.CEXCfg.AutoRebalance, + log: logger, + }) exchangeAdaptor.run(ctx) + RunArbMarketMaker(m.ctx, cfg, exchangeAdaptor, exchangeAdaptor, logger) }(cfg) default: diff --git a/client/mm/mm_arb_market_maker.go b/client/mm/mm_arb_market_maker.go index 64b64c25c9..7106f9b07d 100644 --- a/client/mm/mm_arb_market_maker.go +++ b/client/mm/mm_arb_market_maker.go @@ -6,10 +6,10 @@ package mm import ( "context" "fmt" - "sort" "sync" "sync/atomic" + "decred.org/dcrdex/client/asset" "decred.org/dcrdex/client/core" "decred.org/dcrdex/client/mm/libxc" "decred.org/dcrdex/dex" @@ -78,58 +78,6 @@ type ArbMarketMakerConfig struct { Profit float64 `json:"profit"` DriftTolerance float64 `json:"driftTolerance"` NumEpochsLeaveOpen uint64 `json:"orderPersistence"` - BaseOptions map[string]string `json:"baseOptions"` - QuoteOptions map[string]string `json:"quoteOptions"` - // AutoRebalance determines how the bot will handle rebalancing of the - // assets between the dex and the cex. If nil, no rebalancing will take - // place. - AutoRebalance *AutoRebalanceConfig `json:"autoRebalance"` -} - -// autoRebalanceReserves keeps track of the amount of the balances that are -// reserved for an upcoming rebalance. These will be deducted from the -// available balance when placing new orders. -type autoRebalanceReserves struct { - baseDexReserves uint64 - baseCexReserves uint64 - quoteDexReserves uint64 - quoteCexReserves uint64 -} - -func (r *autoRebalanceReserves) get(base, cex bool) uint64 { - if base { - if cex { - return r.baseCexReserves - } - return r.baseDexReserves - } - if cex { - return r.quoteCexReserves - } - return r.quoteDexReserves -} - -func (r *autoRebalanceReserves) set(base, cex bool, amt uint64) { - if base { - if cex { - r.baseCexReserves = amt - } else { - r.baseDexReserves = amt - } - } else { - if cex { - r.quoteCexReserves = amt - } else { - r.quoteDexReserves = amt - } - } -} - -func (r *autoRebalanceReserves) zero() { - r.baseDexReserves = 0 - r.baseCexReserves = 0 - r.quoteDexReserves = 0 - r.quoteCexReserves = 0 } type arbMarketMaker struct { @@ -145,37 +93,20 @@ type arbMarketMaker struct { book dexOrderBook rebalanceRunning atomic.Bool currEpoch atomic.Uint64 + // dexReserves and cexReserves don't need a mutex because they are only + // accessed during a rebalance which is protected by rebalanceRunning. + dexReserves map[uint32]uint64 + cexReserves map[uint32]uint64 - ordMtx sync.RWMutex - ords map[order.OrderID]*core.Order - oidToPlacement map[order.OrderID]int - - matchesMtx sync.RWMutex - matchesSeen map[order.MatchID]bool + matchesMtx sync.RWMutex + matchesSeen map[order.MatchID]bool + pendingOrders map[order.OrderID]uint64 // orderID -> rate for counter trade on cex cexTradesMtx sync.RWMutex cexTrades map[string]uint64 - - feesMtx sync.RWMutex - buyFees *orderFees - sellFees *orderFees - - reserves autoRebalanceReserves - - pendingBaseRebalance atomic.Bool - pendingQuoteRebalance atomic.Bool -} - -// groupedOrders returns the buy and sell orders grouped by placement index. -func (m *arbMarketMaker) groupedOrders() (buys, sells map[int][]*groupedOrder) { - m.ordMtx.RLock() - defer m.ordMtx.RUnlock() - return groupOrders(m.ords, m.oidToPlacement, m.mkt.LotSize) } func (a *arbMarketMaker) handleCEXTradeUpdate(update *libxc.Trade) { - a.log.Debugf("CEX trade update: %+v", update) - if update.Complete { a.cexTradesMtx.Lock() delete(a.cexTrades, update.ID) @@ -184,32 +115,12 @@ func (a *arbMarketMaker) handleCEXTradeUpdate(update *libxc.Trade) { } } -// processDEXMatch checks to see if this is the first time the bot has seen -// this match. If so, it sends a trade to the CEX. -func (a *arbMarketMaker) processDEXMatch(o *core.Order, match *core.Match) { - var matchID order.MatchID - copy(matchID[:], match.MatchID) - - a.matchesMtx.Lock() - if a.matchesSeen[matchID] { - a.matchesMtx.Unlock() - return - } - a.matchesSeen[matchID] = true - a.matchesMtx.Unlock() - - var cexRate uint64 - if o.Sell { - cexRate = uint64(float64(match.Rate) / (1 + a.cfg.Profit)) - } else { - cexRate = uint64(float64(match.Rate) * (1 + a.cfg.Profit)) - } - cexRate = steppedRate(cexRate, a.mkt.RateStep) - +// tradeOnCEX executes a trade on the CEX. +func (a *arbMarketMaker) tradeOnCEX(rate, qty uint64, sell bool) { a.cexTradesMtx.Lock() defer a.cexTradesMtx.Unlock() - cexTrade, err := a.cex.Trade(a.ctx, a.baseID, a.quoteID, !o.Sell, cexRate, match.Qty) + cexTrade, err := a.cex.CEXTrade(a.ctx, a.baseID, a.quoteID, sell, rate, qty) if err != nil { a.log.Errorf("Error sending trade to CEX: %v", err) return @@ -221,99 +132,36 @@ func (a *arbMarketMaker) processDEXMatch(o *core.Order, match *core.Match) { a.cexTrades[cexTrade.ID] = a.currEpoch.Load() } -func (a *arbMarketMaker) processDEXMatchNote(note *core.MatchNote) { - var oid order.OrderID - copy(oid[:], note.OrderID) - - a.ordMtx.RLock() - o, found := a.ords[oid] - a.ordMtx.RUnlock() - if !found { - return - } +func (a *arbMarketMaker) processDEXOrderUpdate(o *core.Order) { + var orderID order.OrderID + copy(orderID[:], o.ID) - a.processDEXMatch(o, note.Match) -} - -func (a *arbMarketMaker) processDEXOrderNote(note *core.OrderNote) { - var oid order.OrderID - copy(oid[:], note.Order.ID) + a.matchesMtx.Lock() + defer a.matchesMtx.Unlock() - a.ordMtx.Lock() - o, found := a.ords[oid] + cexRate, found := a.pendingOrders[orderID] if !found { - a.ordMtx.Unlock() return } - a.ords[oid] = note.Order - a.ordMtx.Unlock() - for _, match := range note.Order.Matches { - a.processDEXMatch(o, match) - } + for _, match := range o.Matches { + var matchID order.MatchID + copy(matchID[:], match.MatchID) - if !note.Order.Status.IsActive() { - a.ordMtx.Lock() - delete(a.ords, oid) - delete(a.oidToPlacement, oid) - a.ordMtx.Unlock() + if !a.matchesSeen[matchID] { + a.matchesSeen[matchID] = true + a.tradeOnCEX(cexRate, match.Qty, !o.Sell) + } + } - a.matchesMtx.Lock() - for _, match := range note.Order.Matches { + if !o.Status.IsActive() { + delete(a.pendingOrders, orderID) + for _, match := range o.Matches { var matchID order.MatchID copy(matchID[:], match.MatchID) delete(a.matchesSeen, matchID) } - a.matchesMtx.Unlock() - } -} - -func (a *arbMarketMaker) vwap(sell bool, qty uint64) (vwap, extrema uint64, filled bool, err error) { - return a.cex.VWAP(a.baseID, a.quoteID, sell, qty) -} - -type arbMMRebalancer interface { - vwap(sell bool, qty uint64) (vwap, extrema uint64, filled bool, err error) - groupedOrders() (buys, sells map[int][]*groupedOrder) -} - -func (a *arbMarketMaker) placeMultiTrade(placements []*rateLots, sell bool) { - qtyRates := make([]*core.QtyRate, 0, len(placements)) - for _, p := range placements { - qtyRates = append(qtyRates, &core.QtyRate{ - Qty: p.lots * a.mkt.LotSize, - Rate: p.rate, - }) - } - - var options map[string]string - if sell { - options = a.cfg.BaseOptions - } else { - options = a.cfg.QuoteOptions - } - - orders, err := a.core.MultiTrade(nil, &core.MultiTradeForm{ - Host: a.host, - Sell: sell, - Base: a.baseID, - Quote: a.quoteID, - Placements: qtyRates, - Options: options, - }) - if err != nil { - a.log.Errorf("Error placing rebalancing order: %v", err) - return - } - - a.ordMtx.Lock() - for i, ord := range orders { - var oid order.OrderID - copy(oid[:], ord.ID) - a.ords[oid] = ord - a.oidToPlacement[oid] = placements[i].placementIndex } - a.ordMtx.Unlock() } // cancelExpiredCEXTrades cancels any trades on the CEX that have been open for @@ -330,442 +178,175 @@ func (a *arbMarketMaker) cancelExpiredCEXTrades() { if err != nil { a.log.Errorf("Error canceling CEX trade %s: %v", tradeID, err) } + + a.log.Infof("Cex trade %s was cancelled before it was filled", tradeID) } } } -func arbMarketMakerRebalance(newEpoch uint64, a arbMMRebalancer, c botCoreAdaptor, cex botCexAdaptor, cfg *ArbMarketMakerConfig, mkt *core.Market, buyFees, - sellFees *orderFees, reserves *autoRebalanceReserves, log dex.Logger) (cancels []dex.Bytes, buyOrders, sellOrders []*rateLots) { - - existingBuys, existingSells := a.groupedOrders() - - withinTolerance := func(rate, target uint64) bool { - driftTolerance := uint64(float64(target) * cfg.DriftTolerance) - lowerBound := target - driftTolerance - upperBound := target + driftTolerance - return rate >= lowerBound && rate <= upperBound - } - - cancels = make([]dex.Bytes, 0, 1) - addCancel := func(o *groupedOrder) { - if newEpoch-o.epoch < 2 { - log.Debugf("rebalance: skipping cancel not past free cancel threshold") - return - } - cancels = append(cancels, o.id[:]) +// dexPlacementRate calculates the rate at which an order should be placed on +// the DEX order book based on the rate of the counter trade on the CEX. The +// rate is calculated so that the difference in rates between the DEX and the +// CEX will pay for the network fees and still leave the configured profit. +func dexPlacementRate(cexRate uint64, sell bool, profitRate float64, mkt *core.Market, feesInQuoteUnits uint64) (uint64, error) { + var profitableRate uint64 + if sell { + profitableRate = uint64(float64(cexRate) * (1 + profitRate)) + } else { + profitableRate = uint64(float64(cexRate) / (1 + profitRate)) } - baseDEXBalance, err := c.DEXBalance(mkt.BaseID) + baseUnitInfo, err := asset.UnitInfo(mkt.BaseID) if err != nil { - log.Errorf("error getting base DEX balance: %v", err) - return + return 0, fmt.Errorf("error getting base unit info: %w", err) } - quoteDEXBalance, err := c.DEXBalance(mkt.QuoteID) + quoteUnitInfo, err := asset.UnitInfo(mkt.QuoteID) if err != nil { - log.Errorf("error getting quote DEX balance: %v", err) - return + return 0, fmt.Errorf("error getting quote unit info: %w", err) } - baseCEXBalance, err := cex.CEXBalance(mkt.BaseID) - if err != nil { - log.Errorf("error getting base CEX balance: %v", err) - return + feesInQuoteUnitsConv := float64(feesInQuoteUnits) / float64(quoteUnitInfo.Conventional.ConversionFactor) + lotSizeConv := float64(mkt.LotSize) / float64(baseUnitInfo.Conventional.ConversionFactor) + rateAdjustment := feesInQuoteUnitsConv / lotSizeConv + profitableRateConv := calc.ConventionalRate(profitableRate, baseUnitInfo, quoteUnitInfo) + + if sell { + adjustedRate := calc.MessageRate(profitableRateConv+rateAdjustment, baseUnitInfo, quoteUnitInfo) + return steppedRate(adjustedRate, mkt.RateStep), nil } - quoteCEXBalance, err := cex.CEXBalance(mkt.QuoteID) - if err != nil { - log.Errorf("error getting quote CEX balance: %v", err) - return + if rateAdjustment > profitableRateConv { + return 0, fmt.Errorf("rate adjustment required for fees %v > rate %v", rateAdjustment, profitableRateConv) } - processSide := func(sell bool) []*rateLots { - var cfgPlacements []*ArbMarketMakingPlacement - var existingOrders map[int][]*groupedOrder - var remainingDEXBalance, remainingCEXBalance, fundingFees uint64 - if sell { - cfgPlacements = cfg.SellPlacements - existingOrders = existingSells - remainingDEXBalance = baseDEXBalance.Available - remainingCEXBalance = quoteCEXBalance.Available - fundingFees = sellFees.funding - } else { - cfgPlacements = cfg.BuyPlacements - existingOrders = existingBuys - remainingDEXBalance = quoteDEXBalance.Available - remainingCEXBalance = baseCEXBalance.Available - fundingFees = buyFees.funding - } + adjustedRate := calc.MessageRate(profitableRateConv-rateAdjustment, baseUnitInfo, quoteUnitInfo) + return steppedRate(adjustedRate, mkt.RateStep), nil +} - cexReserves := reserves.get(!sell, true) - if cexReserves > remainingCEXBalance { - log.Debugf("rebalance: not enough CEX balance to cover reserves") - return nil - } - remainingCEXBalance -= cexReserves +// dexPlacementRate calculates the rate at which an order should be placed on +// the DEX order book based on the rate of the counter trade on the CEX. The +// logic is in the dexPlacementRate function, so that it can be separately +// tested. +func (a *arbMarketMaker) dexPlacementRate(cexRate uint64, sell bool) (uint64, error) { + feesInQuoteUnits, err := a.core.OrderFeesInUnits(sell, false, cexRate) + if err != nil { + return 0, fmt.Errorf("error getting fees in quote units: %w", err) + } - dexReserves := reserves.get(sell, false) - if dexReserves > remainingDEXBalance { - log.Debugf("rebalance: not enough DEX balance to cover reserves") - return nil - } - remainingDEXBalance -= dexReserves - - // Enough balance on the CEX needs to be maintained for counter-trades - // for each existing trade on the DEX. Here, we reduce the available - // balance on the CEX by the amount required for each order on the - // DEX books. - for _, ordersForPlacement := range existingOrders { - for _, o := range ordersForPlacement { - var requiredOnCEX uint64 - if sell { - rate := uint64(float64(o.rate) / (1 + cfg.Profit)) - requiredOnCEX = calc.BaseToQuote(rate, o.lots*mkt.LotSize) - } else { - requiredOnCEX = o.lots * mkt.LotSize - } - if requiredOnCEX <= remainingCEXBalance { - remainingCEXBalance -= requiredOnCEX - } else { - log.Warnf("rebalance: not enough CEX balance to cover existing order. cancelling.") - addCancel(o) - remainingCEXBalance = 0 - } - } - } - if remainingCEXBalance == 0 { - log.Debug("rebalance: not enough CEX balance to place new orders") - return nil - } + return dexPlacementRate(cexRate, sell, a.cfg.Profit, a.mkt, feesInQuoteUnits) +} - if remainingDEXBalance <= fundingFees { - log.Debug("rebalance: not enough DEX balance to pay funding fees") - return nil - } - remainingDEXBalance -= fundingFees - - // For each placement, we check the rate at which the counter trade can - // be made on the CEX for the cumulatively required lots * multipliers - // of the current and all previous placements. If any orders currently - // on the books are outside of the drift tolerance, they will be - // cancelled, and if there are less than the required lots on the DEX - // books, new orders will be added. - placements := make([]*rateLots, 0, len(cfgPlacements)) +func (a *arbMarketMaker) ordersToPlace() (buys, sells []*multiTradePlacement) { + orders := func(cfgPlacements []*ArbMarketMakingPlacement, sellOnDEX bool) []*multiTradePlacement { + newPlacements := make([]*multiTradePlacement, 0, len(cfgPlacements)) var cumulativeCEXDepth uint64 - for i, cfgPlacement := range cfgPlacements { - cumulativeCEXDepth += uint64(float64(cfgPlacement.Lots*mkt.LotSize) * cfgPlacement.Multiplier) - _, extrema, filled, err := a.vwap(sell, cumulativeCEXDepth) + for _, cfgPlacement := range cfgPlacements { + cumulativeCEXDepth += uint64(float64(cfgPlacement.Lots*a.mkt.LotSize) * cfgPlacement.Multiplier) + _, extrema, filled, err := a.cex.VWAP(a.mkt.BaseID, a.mkt.QuoteID, !sellOnDEX, cumulativeCEXDepth) if err != nil { - log.Errorf("Error calculating vwap: %v", err) - break - } - if !filled { - log.Infof("CEX %s side has < %d %s on the orderbook.", map[bool]string{true: "sell", false: "buy"}[sell], cumulativeCEXDepth, mkt.BaseSymbol) - break - } - - var placementRate uint64 - if sell { - placementRate = steppedRate(uint64(float64(extrema)*(1+cfg.Profit)), mkt.RateStep) - } else { - placementRate = steppedRate(uint64(float64(extrema)/(1+cfg.Profit)), mkt.RateStep) - } - - ordersForPlacement := existingOrders[i] - var existingLots uint64 - for _, o := range ordersForPlacement { - existingLots += o.lots - if !withinTolerance(o.rate, placementRate) { - addCancel(o) - } - } - - if cfgPlacement.Lots <= existingLots { + a.log.Errorf("Error calculating vwap: %v", err) + newPlacements = append(newPlacements, &multiTradePlacement{ + rate: 0, + lots: 0, + }) continue } - lotsToPlace := cfgPlacement.Lots - existingLots - - // TODO: handle redeem/refund fees for account lockers - var requiredOnDEX, requiredOnCEX uint64 - if sell { - requiredOnDEX = mkt.LotSize * lotsToPlace - requiredOnDEX += sellFees.swap * lotsToPlace - requiredOnCEX = calc.BaseToQuote(extrema, mkt.LotSize*lotsToPlace) - } else { - requiredOnDEX = calc.BaseToQuote(placementRate, lotsToPlace*mkt.LotSize) - requiredOnDEX += buyFees.swap * lotsToPlace - requiredOnCEX = mkt.LotSize * lotsToPlace - } - if requiredOnDEX > remainingDEXBalance { - log.Debugf("not enough DEX balance to place %d lots", lotsToPlace) + + if !filled { + a.log.Infof("CEX %s side has < %d %s on the orderbook.", map[bool]string{true: "sell", false: "buy"}[!sellOnDEX], cumulativeCEXDepth, a.mkt.BaseSymbol) + newPlacements = append(newPlacements, &multiTradePlacement{ + rate: 0, + lots: 0, + }) continue } - if requiredOnCEX > remainingCEXBalance { - log.Debugf("not enough CEX balance to place %d lots", lotsToPlace) + + placementRate, err := a.dexPlacementRate(extrema, sellOnDEX) + if err != nil { + a.log.Errorf("Error calculating dex placement rate: %v", err) + newPlacements = append(newPlacements, &multiTradePlacement{ + rate: 0, + lots: 0, + }) continue } - remainingDEXBalance -= requiredOnDEX - remainingCEXBalance -= requiredOnCEX - placements = append(placements, &rateLots{ - rate: placementRate, - lots: lotsToPlace, - placementIndex: i, + newPlacements = append(newPlacements, &multiTradePlacement{ + rate: placementRate, + lots: cfgPlacement.Lots, + counterTradeRate: extrema, }) } - return placements + return newPlacements } - buys := processSide(false) - sells := processSide(true) - - return cancels, buys, sells + buys = orders(a.cfg.BuyPlacements, false) + sells = orders(a.cfg.SellPlacements, true) + return } -// fundsLockedInOrders returns the total amount of the asset that is -// currently locked in a booked order on the DEX. -func (a *arbMarketMaker) fundsLockedInOrders(base bool) uint64 { - buys, sells := a.groupedOrders() - var locked uint64 - - var orders map[int][]*groupedOrder - if base { - orders = sells - } else { - orders = buys +func (a *arbMarketMaker) depositWithdrawIfNeeded() { + a.cexTradesMtx.RLock() + numCEXTrades := len(a.cexTrades) + a.cexTradesMtx.RUnlock() + if numCEXTrades > 0 { + return } - for _, ordersForPlacement := range orders { - for _, o := range ordersForPlacement { - locked += o.lockedAmt + rebalanceBase, dexReserves, cexReserves := a.cex.PrepareRebalance(a.ctx, a.baseID) + if rebalanceBase > 0 { + err := a.cex.Deposit(a.ctx, a.baseID, uint64(rebalanceBase)) + if err != nil { + a.log.Errorf("Error depositing %d %s to CEX: %v", rebalanceBase, a.mkt.BaseSymbol, err) } } - - return locked -} - -// dexToCexQty returns the amount of backing asset on the CEX that is required -// for a DEX order of the specified quantity and rate. dexSell indicates that -// we are selling on the DEX, and therefore buying on the CEX. -func (a *arbMarketMaker) dexToCexQty(qty, rate uint64, dexSell bool) uint64 { - if dexSell { - cexRate := uint64(float64(rate) * (1 + a.cfg.Profit)) - return calc.BaseToQuote(cexRate, qty) - } - return qty -} - -// cexBalanceBackingDexOrders returns the amount of the asset on the CEX that -// is required so that if all the orders on the DEX were filled, counter -// trades could be made on the CEX. -func (a *arbMarketMaker) cexBalanceBackingDexOrders(base bool) uint64 { - buys, sells := a.groupedOrders() - var orders map[int][]*groupedOrder - if base { - orders = buys - } else { - orders = sells - } - - var locked uint64 - for _, ordersForPlacement := range orders { - for _, o := range ordersForPlacement { - locked += a.dexToCexQty(o.lots*a.mkt.LotSize, o.rate, !base) + if rebalanceBase < 0 { + err := a.cex.Withdraw(a.ctx, a.baseID, uint64(-rebalanceBase)) + if err != nil { + a.log.Errorf("Error withdrawing %d %s from CEX: %v", -rebalanceBase, a.mkt.BaseSymbol, err) } } - - return locked -} - -// freeUpFunds cancels active orders to free up the specified amount of funds -// for a rebalance between the dex and the cex. The orders are cancelled in -// reverse order of priority. -func (a *arbMarketMaker) freeUpFunds(base, cex bool, amt uint64) { - buys, sells := a.groupedOrders() - var orders map[int][]*groupedOrder - if base && !cex || !base && cex { - orders = sells - } else { - orders = buys + if cexReserves > 0 { + a.cex.FreeUpFunds(a.baseID, true, cexReserves, a.currEpoch.Load()) } - - highToLowIndexes := make([]int, 0, len(orders)) - for i := range orders { - highToLowIndexes = append(highToLowIndexes, i) + if dexReserves > 0 { + a.cex.FreeUpFunds(a.baseID, false, dexReserves, a.currEpoch.Load()) } - sort.Slice(highToLowIndexes, func(i, j int) bool { - return highToLowIndexes[i] > highToLowIndexes[j] - }) - - currEpoch := a.currEpoch.Load() + a.cexReserves[a.baseID] = cexReserves + a.dexReserves[a.baseID] = dexReserves - for _, index := range highToLowIndexes { - ordersForPlacement := orders[index] - for _, o := range ordersForPlacement { - // If the order is too recent, just wait for the next epoch to - // cancel. We still count this order towards the freedAmt in - // order to not cancel a higher priority trade. - if currEpoch-o.epoch >= 2 { - err := a.core.Cancel(o.id[:]) - if err != nil { - a.log.Errorf("error cancelling order: %v", err) - continue - } - } - var freedAmt uint64 - if cex { - freedAmt = a.dexToCexQty(o.lots*a.mkt.LotSize, o.rate, !base) - } else { - freedAmt = o.lockedAmt - } - if freedAmt >= amt { - return - } - amt -= freedAmt - } - } -} - -// rebalanceAssets checks if funds on either the CEX or the DEX are below the -// minimum amount, and if so, initiates either withdrawal or deposit to bring -// them to equal. If some funds that need to be transferred are either locked -// in an order on the DEX, or backing a potential order on the CEX, some orders -// are cancelled to free up funds, and the transfer happens in the next epoch. -func (a *arbMarketMaker) rebalanceAssets() { - rebalanceAsset := func(base bool) { - var assetID uint32 - var minAmount uint64 - var minTransferAmount uint64 - if base { - assetID = a.baseID - minAmount = a.cfg.AutoRebalance.MinBaseAmt - minTransferAmount = a.cfg.AutoRebalance.MinBaseTransfer - } else { - assetID = a.quoteID - minAmount = a.cfg.AutoRebalance.MinQuoteAmt - minTransferAmount = a.cfg.AutoRebalance.MinQuoteTransfer - } - symbol := dex.BipIDSymbol(assetID) - - dexAvailableBalance, err := a.core.DEXBalance(assetID) + rebalanceQuote, dexReserves, cexReserves := a.cex.PrepareRebalance(a.ctx, a.quoteID) + if rebalanceQuote > 0 { + err := a.cex.Deposit(a.ctx, a.quoteID, uint64(rebalanceQuote)) if err != nil { - a.log.Errorf("Error getting %s balance: %v", symbol, err) - return + a.log.Errorf("Error depositing %d %s to CEX: %v", rebalanceQuote, a.mkt.QuoteSymbol, err) } - - totalDexBalance := dexAvailableBalance.Available + a.fundsLockedInOrders(base) - - cexBalance, err := a.cex.CEXBalance(assetID) + } + if rebalanceQuote < 0 { + err := a.cex.Withdraw(a.ctx, a.quoteID, uint64(-rebalanceQuote)) if err != nil { - a.log.Errorf("Error getting %s balance on cex: %v", symbol, err) - return - } - - if (totalDexBalance+cexBalance.Available)/2 < minAmount { - a.log.Warnf("Cannot rebalance %s because balance is too low on both DEX and CEX. Min amount: %v, CEX balance: %v, DEX Balance: %v", - symbol, minAmount, cexBalance.Available, totalDexBalance) - return - } - - var requireDeposit bool - if cexBalance.Available < minAmount { - requireDeposit = true - } else if totalDexBalance >= minAmount { - // No need for withdrawal or deposit. - return - } - - onConfirm := func() { - if base { - a.pendingBaseRebalance.Store(false) - } else { - a.pendingQuoteRebalance.Store(false) - } - } - - if requireDeposit { - amt := (totalDexBalance+cexBalance.Available)/2 - cexBalance.Available - if amt < minTransferAmount { - a.log.Warnf("Amount required to rebalance %s (%d) is less than the min transfer amount %v", - symbol, amt, minTransferAmount) - return - } - - // If we need to cancel some orders to send the required amount to - // the CEX, cancel some orders, and then try again on the next - // epoch. - if amt > dexAvailableBalance.Available { - a.reserves.set(base, false, amt) - a.freeUpFunds(base, false, amt-dexAvailableBalance.Available) - return - } - - 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 := (totalDexBalance+cexBalance.Available)/2 - totalDexBalance - if amt < minTransferAmount { - a.log.Warnf("Amount required to rebalance %s (%d) is less than the min transfer amount %v", - symbol, amt, minTransferAmount) - return - } - - cexBalanceBackingDexOrders := a.cexBalanceBackingDexOrders(base) - if cexBalance.Available < cexBalanceBackingDexOrders { - a.log.Errorf("cex reported balance %d is less than amount required to back dex orders %d", - cexBalance.Available, cexBalanceBackingDexOrders) - // this is a bug, how to recover? - return - } - - if amt > cexBalance.Available-cexBalanceBackingDexOrders { - a.reserves.set(base, true, amt) - a.freeUpFunds(base, true, amt-(cexBalance.Available-cexBalanceBackingDexOrders)) - return - } - - 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) + a.log.Errorf("Error withdrawing %d %s from CEX: %v", -rebalanceQuote, a.mkt.QuoteSymbol, err) } } - - if a.cfg.AutoRebalance == nil { - return + if cexReserves > 0 { + a.cex.FreeUpFunds(a.quoteID, true, cexReserves, a.currEpoch.Load()) } - - a.cexTradesMtx.Lock() - if len(a.cexTrades) > 0 { - a.cexTradesMtx.Unlock() - return + if dexReserves > 0 { + a.cex.FreeUpFunds(a.quoteID, false, dexReserves, a.currEpoch.Load()) } - a.cexTradesMtx.Unlock() - - a.reserves.zero() - if !a.pendingBaseRebalance.Load() { - rebalanceAsset(true) - } - if !a.pendingQuoteRebalance.Load() { - rebalanceAsset(false) - } + a.cexReserves[a.quoteID] = cexReserves + a.dexReserves[a.quoteID] = dexReserves } -// rebalance is called on each new epoch. It determines what orders need to be -// placed, cancelled, and what funds need to be transferred between the DEX and -// the CEX. +// rebalance is called on each new epoch. It will calculate the rates orders +// need to be placed on the DEX orderbook based on the CEX orderbook, and +// potentially update the orders on the DEX orderbook. It will also process +// and potentially needed withdrawals and deposits, and finally cancel any +// trades on the CEX that have been open for more than the number of epochs +// specified in the config. func (a *arbMarketMaker) rebalance(epoch uint64) { if !a.rebalanceRunning.CompareAndSwap(false, true) { return @@ -779,101 +360,25 @@ func (a *arbMarketMaker) rebalance(epoch uint64) { } a.currEpoch.Store(epoch) - cancels, buyOrders, sellOrders := arbMarketMakerRebalance(epoch, a, a.core, - a.cex, a.cfg, a.mkt, a.buyFees, a.sellFees, &a.reserves, a.log) + buys, sells := a.ordersToPlace() - for _, cancel := range cancels { - err := a.core.Cancel(cancel) - if err != nil { - a.log.Errorf("Error canceling order %s: %v", cancel, err) - return + buyIDs := a.core.MultiTrade(buys, false, a.cfg.DriftTolerance, currEpoch, a.dexReserves, a.cexReserves) + for i, id := range buyIDs { + if id != nil { + a.pendingOrders[*id] = buys[i].counterTradeRate } } - if len(buyOrders) > 0 { - a.placeMultiTrade(buyOrders, false) - } - if len(sellOrders) > 0 { - a.placeMultiTrade(sellOrders, true) - } - - a.cancelExpiredCEXTrades() - a.rebalanceAssets() -} - -func (a *arbMarketMaker) handleNotification(note core.Notification) { - switch n := note.(type) { - case *core.MatchNote: - a.processDEXMatchNote(n) - case *core.OrderNote: - a.processDEXOrderNote(n) - case *core.EpochNotification: - go a.rebalance(n.Epoch) - } -} -func (a *arbMarketMaker) cancelAllOrders() { - a.ordMtx.Lock() - defer a.ordMtx.Unlock() - for oid := range a.ords { - if err := a.core.Cancel(oid[:]); err != nil { - a.log.Errorf("error cancelling order: %v", err) + sellIDs := a.core.MultiTrade(sells, true, a.cfg.DriftTolerance, currEpoch, a.dexReserves, a.cexReserves) + for i, id := range sellIDs { + if id != nil { + a.pendingOrders[*id] = sells[i].counterTradeRate } } - a.ords = make(map[order.OrderID]*core.Order) - a.oidToPlacement = make(map[order.OrderID]int) -} - -func (a *arbMarketMaker) updateFeeRates() error { - buySwapFees, buyRedeemFees, buyRefundFees, err := a.core.SingleLotFees(&core.SingleLotFeesForm{ - Host: a.host, - Base: a.baseID, - Quote: a.quoteID, - UseMaxFeeRate: true, - UseSafeTxSize: true, - }) - if err != nil { - return fmt.Errorf("failed to get fees: %v", err) - } - - sellSwapFees, sellRedeemFees, sellRefundFees, err := a.core.SingleLotFees(&core.SingleLotFeesForm{ - Host: a.host, - Base: a.baseID, - Quote: a.quoteID, - UseMaxFeeRate: true, - UseSafeTxSize: true, - Sell: true, - }) - if err != nil { - return fmt.Errorf("failed to get fees: %v", err) - } - - buyFundingFees, err := a.core.MaxFundingFees(a.quoteID, a.host, uint32(len(a.cfg.BuyPlacements)), a.cfg.QuoteOptions) - if err != nil { - return fmt.Errorf("failed to get funding fees: %v", err) - } - - sellFundingFees, err := a.core.MaxFundingFees(a.baseID, a.host, uint32(len(a.cfg.SellPlacements)), a.cfg.BaseOptions) - if err != nil { - return fmt.Errorf("failed to get funding fees: %v", err) - } - a.feesMtx.Lock() - defer a.feesMtx.Unlock() + a.depositWithdrawIfNeeded() - a.buyFees = &orderFees{ - swap: buySwapFees, - redemption: buyRedeemFees, - funding: buyFundingFees, - refund: buyRefundFees, - } - a.sellFees = &orderFees{ - swap: sellSwapFees, - redemption: sellRedeemFees, - funding: sellFundingFees, - refund: sellRefundFees, - } - - return nil + a.cancelExpiredCEXTrades() } func (a *arbMarketMaker) run() { @@ -884,12 +389,6 @@ func (a *arbMarketMaker) run() { } a.book = book - err = a.updateFeeRates() - if err != nil { - a.log.Errorf("Failed to get fees: %v", err) - return - } - err = a.cex.SubscribeMarket(a.ctx, a.baseID, a.quoteID) if err != nil { a.log.Errorf("Failed to subscribe to cex market: %v", err) @@ -900,15 +399,16 @@ func (a *arbMarketMaker) run() { defer unsubscribe() wg := &sync.WaitGroup{} - wg.Add(1) go func() { defer wg.Done() for { select { - case <-bookFeed.Next(): - // Really nothing to do with the updates. We just need to keep - // the subscription live in order to get VWAP on dex orderbook. + case n := <-bookFeed.Next(): + if n.Action == core.EpochMatchSummary { + payload := n.Payload.(*core.EpochMatchSummaryPayload) + a.rebalance(payload.Epoch + 1) + } case <-a.ctx.Done(): return } @@ -928,15 +428,14 @@ func (a *arbMarketMaker) run() { } }() - noteFeed := a.core.NotificationFeed() wg.Add(1) go func() { defer wg.Done() - defer noteFeed.ReturnFeed() + orderUpdates := a.core.SubscribeOrderUpdates() for { select { - case n := <-noteFeed.C: - a.handleNotification(n) + case n := <-orderUpdates: + a.processDEXOrderUpdate(n) case <-a.ctx.Done(): return } @@ -944,7 +443,7 @@ func (a *arbMarketMaker) run() { }() wg.Wait() - a.cancelAllOrders() + a.core.CancelAllOrders() } func RunArbMarketMaker(ctx context.Context, cfg *BotConfig, c botCoreAdaptor, cex botCexAdaptor, log dex.Logger) { @@ -961,18 +460,20 @@ func RunArbMarketMaker(ctx context.Context, cfg *BotConfig, c botCoreAdaptor, ce } (&arbMarketMaker{ - ctx: ctx, - host: cfg.Host, - baseID: cfg.BaseID, - quoteID: cfg.QuoteID, - cex: cex, - core: c, - log: log, - cfg: cfg.ArbMarketMakerConfig, - mkt: mkt, - ords: make(map[order.OrderID]*core.Order), - oidToPlacement: make(map[order.OrderID]int), - matchesSeen: make(map[order.MatchID]bool), - cexTrades: make(map[string]uint64), + ctx: ctx, + host: cfg.Host, + baseID: cfg.BaseID, + quoteID: cfg.QuoteID, + cex: cex, + core: c, + log: log, + cfg: cfg.ArbMarketMakerConfig, + mkt: mkt, + matchesSeen: make(map[order.MatchID]bool), + pendingOrders: make(map[order.OrderID]uint64), + cexTrades: make(map[string]uint64), + dexReserves: make(map[uint32]uint64), + cexReserves: make(map[uint32]uint64), }).run() + } diff --git a/client/mm/mm_arb_market_maker_test.go b/client/mm/mm_arb_market_maker_test.go index 2b18abe49c..5b534df1e1 100644 --- a/client/mm/mm_arb_market_maker_test.go +++ b/client/mm/mm_arb_market_maker_test.go @@ -5,106 +5,44 @@ package mm import ( "context" - "encoding/hex" + "reflect" "testing" "decred.org/dcrdex/client/core" "decred.org/dcrdex/client/mm/libxc" - "decred.org/dcrdex/dex" "decred.org/dcrdex/dex/calc" "decred.org/dcrdex/dex/encode" "decred.org/dcrdex/dex/order" + "github.com/davecgh/go-spew/spew" ) -type tArbMMRebalancer struct { - buyVWAP map[uint64]*vwapResult - sellVWAP map[uint64]*vwapResult - groupedBuys map[int][]*groupedOrder - groupedSells map[int][]*groupedOrder -} - -var _ arbMMRebalancer = (*tArbMMRebalancer)(nil) - -func (r *tArbMMRebalancer) vwap(sell bool, qty uint64) (vwap, extrema uint64, filled bool, err error) { - if sell { - if res, found := r.sellVWAP[qty]; found { - return res.avg, res.extrema, true, nil - } - return 0, 0, false, nil - } - if res, found := r.buyVWAP[qty]; found { - return res.avg, res.extrema, true, nil - } - return 0, 0, false, nil -} - -func (r *tArbMMRebalancer) groupedOrders() (buys, sells map[int][]*groupedOrder) { - return r.groupedBuys, r.groupedSells -} - -func TestArbMarketMakerRebalance(t *testing.T) { - const rateStep uint64 = 1e3 +func TestArbMMRebalance(t *testing.T) { const lotSize uint64 = 50e8 - const newEpoch = 123_456_789 - const driftTolerance = 0.001 - const profit = 0.01 - - buyFees := &orderFees{ - swap: 1e4, - redemption: 2e4, - funding: 3e4, - } - sellFees := &orderFees{ - swap: 2e4, - redemption: 1e4, - funding: 4e4, - } + const currEpoch uint64 = 100 + const baseID = 42 + const quoteID = 0 - orderIDs := make([]order.OrderID, 5) - for i := range orderIDs { - copy(orderIDs[i][:], encode.RandomBytes(32)) - } - - mkt := &core.Market{ - RateStep: rateStep, - AtomToConv: 1, - LotSize: lotSize, - BaseID: 42, - QuoteID: 0, - BaseSymbol: "dcr", - QuoteSymbol: "btc", - } - - cfg1 := &ArbMarketMakerConfig{ - DriftTolerance: driftTolerance, - Profit: profit, - BuyPlacements: []*ArbMarketMakingPlacement{{ - Lots: 1, - Multiplier: 1.5, - }}, - SellPlacements: []*ArbMarketMakingPlacement{{ - Lots: 1, - Multiplier: 1.5, - }}, - } - - cfg2 := &ArbMarketMakerConfig{ - DriftTolerance: driftTolerance, - Profit: profit, + cfg := &ArbMarketMakerConfig{ + Profit: 0.01, + NumEpochsLeaveOpen: 5, BuyPlacements: []*ArbMarketMakingPlacement{ { - Lots: 1, - Multiplier: 2, + Lots: 2, + Multiplier: 1.5, }, { Lots: 1, - Multiplier: 1.5, + Multiplier: 2, }, }, SellPlacements: []*ArbMarketMakingPlacement{ + { + Lots: 2, + Multiplier: 2.0, + }, { Lots: 1, - Multiplier: 2, + Multiplier: 1.5, }, { Lots: 1, @@ -113,791 +51,179 @@ func TestArbMarketMakerRebalance(t *testing.T) { }, } - type test struct { - name string - - rebalancer *tArbMMRebalancer - cfg *ArbMarketMakerConfig - dexBalances map[uint32]uint64 - cexBalances map[uint32]*botBalance - reserves autoRebalanceReserves + bidsVWAP := map[uint64]*vwapResult{ + 4 * lotSize: { + avg: 5e6, + extrema: 4.5e6, + }, + 11 * lotSize / 2 /* 5.5 lots */ : { + avg: 4e6, + extrema: 3e6, + }, + // no result for 7 lots. CEX order doesn't have enough liquidity. + } - expectedCancels []dex.Bytes - expectedBuys []*rateLots - expectedSells []*rateLots + asksVWAP := map[uint64]*vwapResult{ + 3 * lotSize: { + avg: 6e6, + extrema: 6.5e6, + }, + 5 * lotSize: { + avg: 7e6, + extrema: 7.5e6, + }, } - multiplyRate := func(u uint64, m float64) uint64 { - return steppedRate(uint64(float64(u)*m), mkt.RateStep) + mkt := &core.Market{ + RateStep: 1e3, + AtomToConv: 1, + LotSize: lotSize, + BaseID: baseID, + QuoteID: quoteID, } - divideRate := func(u uint64, d float64) uint64 { - return steppedRate(uint64(float64(u)/d), mkt.RateStep) + buyFeesInQuoteUnits := uint64(2e5) + sellFeesInQuoteUnits := uint64(3e5) + + coreAdaptor := newTBotCoreAdaptor(newTCore()) + coreAdaptor.buyFeesInQuote = buyFeesInQuoteUnits + coreAdaptor.sellFeesInQuote = sellFeesInQuoteUnits + + cexAdaptor := newTBotCEXAdaptor() + cexAdaptor.asksVWAP = asksVWAP + cexAdaptor.bidsVWAP = bidsVWAP + + // In the first call, these values will be returned as reserves, + // then in the second call they should returned as the rebalance + // amount prompting a withdrawal/deposit. + const baseCexReserves uint64 = 2e5 + const quoteDexReserves uint64 = 3e5 + cexAdaptor.prepareRebalanceResults[baseID] = &prepareRebalanceResult{ + cexReserves: baseCexReserves, } - lotSizeMultiplier := func(m float64) uint64 { - return uint64(float64(mkt.LotSize) * m) + cexAdaptor.prepareRebalanceResults[quoteID] = &prepareRebalanceResult{ + dexReserves: quoteDexReserves, } - tests := []*test{ - // "no existing orders" - { - name: "no existing orders", - rebalancer: &tArbMMRebalancer{ - buyVWAP: map[uint64]*vwapResult{ - lotSizeMultiplier(1.5): { - avg: 2e6, - extrema: 1.9e6, - }, - }, - sellVWAP: map[uint64]*vwapResult{ - lotSizeMultiplier(1.5): { - avg: 2.1e6, - extrema: 2.2e6, - }, - }, - }, - cfg: cfg1, - dexBalances: map[uint32]uint64{ - 42: lotSize * 3, - 0: calc.BaseToQuote(1e6, 3*lotSize), - }, - cexBalances: map[uint32]*botBalance{ - 0: {Available: 1e19}, - 42: {Available: 1e19}, - }, - expectedBuys: []*rateLots{{ - rate: 1.881e6, - lots: 1, - }}, - expectedSells: []*rateLots{{ - rate: 2.222e6, - lots: 1, - }}, - }, - // "existing orders within drift tolerance" - { - name: "existing orders within drift tolerance", - rebalancer: &tArbMMRebalancer{ - buyVWAP: map[uint64]*vwapResult{ - lotSizeMultiplier(1.5): { - avg: 2e6, - extrema: 1.9e6, - }, - }, - sellVWAP: map[uint64]*vwapResult{ - lotSizeMultiplier(1.5): { - avg: 2.1e6, - extrema: 2.2e6, - }, - }, - groupedBuys: map[int][]*groupedOrder{ - 0: {{ - rate: 1.882e6, - lots: 1, - }}, - }, - groupedSells: map[int][]*groupedOrder{ - 0: {{ - rate: 2.223e6, - lots: 1, - }}, - }, - }, - cfg: cfg1, - dexBalances: map[uint32]uint64{ - 42: lotSize * 3, - 0: calc.BaseToQuote(1e6, 3*lotSize), - }, - cexBalances: map[uint32]*botBalance{ - 0: {Available: 1e19}, - 42: {Available: 1e19}, - }, - }, - // "existing orders outside drift tolerance" - { - name: "existing orders outside drift tolerance", - rebalancer: &tArbMMRebalancer{ - buyVWAP: map[uint64]*vwapResult{ - lotSizeMultiplier(1.5): { - avg: 2e6, - extrema: 1.9e6, - }, - }, - groupedBuys: map[int][]*groupedOrder{ - 0: {{ - id: orderIDs[0], - rate: 1.883e6, - lots: 1, - }}, - }, - groupedSells: map[int][]*groupedOrder{ - 0: {{ - id: orderIDs[1], - rate: 2.225e6, - lots: 1, - }}, - }, - sellVWAP: map[uint64]*vwapResult{ - lotSizeMultiplier(1.5): { - avg: 2.1e6, - extrema: 2.2e6, - }, - }, - }, - cfg: cfg1, - dexBalances: map[uint32]uint64{ - 42: lotSize * 3, - 0: calc.BaseToQuote(1e6, 3*lotSize), - }, - cexBalances: map[uint32]*botBalance{ - 0: {Available: 1e19}, - 42: {Available: 1e19}, - }, - expectedCancels: []dex.Bytes{ - orderIDs[0][:], - orderIDs[1][:], - }, - }, - // "don't cancel before free cancel" - { - name: "don't cancel before free cancel", - rebalancer: &tArbMMRebalancer{ - buyVWAP: map[uint64]*vwapResult{ - lotSizeMultiplier(1.5): { - avg: 2e6, - extrema: 1.9e6, - }, - }, - groupedBuys: map[int][]*groupedOrder{ - 0: {{ - id: orderIDs[0], - rate: 1.883e6, - lots: 1, - epoch: newEpoch - 1, - }}, - }, - groupedSells: map[int][]*groupedOrder{ - 0: {{ - id: orderIDs[1], - rate: 2.225e6, - lots: 1, - epoch: newEpoch - 2, - }}, - }, - sellVWAP: map[uint64]*vwapResult{ - lotSizeMultiplier(1.5): { - avg: 2.1e6, - extrema: 2.2e6, - }, - }, - }, - cfg: cfg1, - dexBalances: map[uint32]uint64{ - 42: lotSize * 3, - 0: calc.BaseToQuote(1e6, 3*lotSize), - }, - cexBalances: map[uint32]*botBalance{ - 0: {Available: 1e19}, - 42: {Available: 1e19}, - }, - expectedCancels: []dex.Bytes{ - orderIDs[1][:], - }, - }, - // "no existing orders, two orders each, dex balance edge, enough" - { - name: "no existing orders, two orders each, dex balance edge, enough", - rebalancer: &tArbMMRebalancer{ - buyVWAP: map[uint64]*vwapResult{ - lotSizeMultiplier(2): { - avg: 2e6, - extrema: 1.9e6, - }, - lotSizeMultiplier(3.5): { - avg: 1.8e6, - extrema: 1.7e6, - }, - }, - sellVWAP: map[uint64]*vwapResult{ - lotSizeMultiplier(2): { - avg: 2.1e6, - extrema: 2.2e6, - }, - lotSizeMultiplier(3.5): { - avg: 2.3e6, - extrema: 2.4e6, - }, - }, - }, - cfg: cfg2, - dexBalances: map[uint32]uint64{ - 42: 2*(lotSize+sellFees.swap) + sellFees.funding, - 0: calc.BaseToQuote(divideRate(1.9e6, 1+profit), lotSize) + calc.BaseToQuote(divideRate(1.7e6, 1+profit), lotSize) + 2*buyFees.swap + buyFees.funding, - }, - cexBalances: map[uint32]*botBalance{ - 0: {Available: 1e19}, - 42: {Available: 1e19}, - }, - expectedBuys: []*rateLots{ - { - rate: divideRate(1.9e6, 1+profit), - lots: 1, - }, - { - rate: divideRate(1.7e6, 1+profit), - lots: 1, - placementIndex: 1, - }, - }, - expectedSells: []*rateLots{ - { - rate: multiplyRate(2.2e6, 1+profit), - lots: 1, - }, - { - rate: multiplyRate(2.4e6, 1+profit), - lots: 1, - placementIndex: 1, - }, - }, - }, - // "no existing orders, two orders each, dex balance edge, not enough" - { - name: "no existing orders, two orders each, dex balance edge, not enough", - rebalancer: &tArbMMRebalancer{ - buyVWAP: map[uint64]*vwapResult{ - lotSizeMultiplier(2): { - avg: 2e6, - extrema: 1.9e6, - }, - lotSizeMultiplier(3.5): { - avg: 1.8e6, - extrema: 1.7e6, - }, - }, - sellVWAP: map[uint64]*vwapResult{ - lotSizeMultiplier(2): { - avg: 2.1e6, - extrema: 2.2e6, - }, - lotSizeMultiplier(3.5): { - avg: 2.3e6, - extrema: 2.4e6, - }, - }, - }, - cfg: cfg2, - dexBalances: map[uint32]uint64{ - 42: 2*(lotSize+sellFees.swap) + sellFees.funding - 1, - 0: calc.BaseToQuote(divideRate(1.9e6, 1+profit), lotSize) + calc.BaseToQuote(divideRate(1.7e6, 1+profit), lotSize) + 2*buyFees.swap + buyFees.funding - 1, - }, - cexBalances: map[uint32]*botBalance{ - 0: {Available: 1e19}, - 42: {Available: 1e19}, - }, - expectedBuys: []*rateLots{ - { - rate: divideRate(1.9e6, 1+profit), - lots: 1, - }, - }, - expectedSells: []*rateLots{ - { - rate: multiplyRate(2.2e6, 1+profit), - lots: 1, - }, - }, - }, - // "no existing orders, two orders each, cex balance edge, enough" + arbMM := &arbMarketMaker{ + baseID: baseID, + quoteID: quoteID, + cex: cexAdaptor, + core: coreAdaptor, + log: tLogger, + mkt: mkt, + cfg: cfg, + dexReserves: make(map[uint32]uint64), + cexReserves: make(map[uint32]uint64), + } + + arbMM.rebalance(currEpoch) + + dexRate := func(counterTradeRate uint64, sell bool) uint64 { + fees := buyFeesInQuoteUnits + if sell { + fees = sellFeesInQuoteUnits + } + rate, err := dexPlacementRate(counterTradeRate, sell, 0.01, mkt, fees) + if err != nil { + panic(err) + } + return rate + } + expBuyPlacements := []*multiTradePlacement{ { - name: "no existing orders, two orders each, cex balance edge, enough", - rebalancer: &tArbMMRebalancer{ - buyVWAP: map[uint64]*vwapResult{ - lotSizeMultiplier(2): { - avg: 2e6, - extrema: 1.9e6, - }, - lotSizeMultiplier(3.5): { - avg: 1.8e6, - extrema: 1.7e6, - }, - }, - sellVWAP: map[uint64]*vwapResult{ - lotSizeMultiplier(2): { - avg: 2.1e6, - extrema: 2.2e6, - }, - lotSizeMultiplier(3.5): { - avg: 2.3e6, - extrema: 2.4e6, - }, - }, - }, - cfg: cfg2, - dexBalances: map[uint32]uint64{ - 42: 1e19, - 0: 1e19, - }, - cexBalances: map[uint32]*botBalance{ - 0: {Available: calc.BaseToQuote(2.2e6, mkt.LotSize) + calc.BaseToQuote(2.4e6, mkt.LotSize)}, - 42: {Available: 2 * mkt.LotSize}, - }, - expectedBuys: []*rateLots{ - { - rate: divideRate(1.9e6, 1+profit), - lots: 1, - }, - { - rate: divideRate(1.7e6, 1+profit), - lots: 1, - placementIndex: 1, - }, - }, - expectedSells: []*rateLots{ - { - rate: multiplyRate(2.2e6, 1+profit), - lots: 1, - }, - { - rate: multiplyRate(2.4e6, 1+profit), - lots: 1, - placementIndex: 1, - }, - }, + lots: 2, + rate: dexRate(6.5e6, false), + counterTradeRate: 6.5e6, }, - // "no existing orders, two orders each, cex balance edge, not enough" { - name: "no existing orders, two orders each, cex balance edge, not enough", - rebalancer: &tArbMMRebalancer{ - buyVWAP: map[uint64]*vwapResult{ - lotSizeMultiplier(2): { - avg: 2e6, - extrema: 1.9e6, - }, - lotSizeMultiplier(3.5): { - avg: 1.8e6, - extrema: 1.7e6, - }, - }, - sellVWAP: map[uint64]*vwapResult{ - lotSizeMultiplier(2): { - avg: 2.1e6, - extrema: 2.2e6, - }, - lotSizeMultiplier(3.5): { - avg: 2.3e6, - extrema: 2.4e6, - }, - }, - }, - cfg: cfg2, - dexBalances: map[uint32]uint64{ - 42: 1e19, - 0: 1e19, - }, - cexBalances: map[uint32]*botBalance{ - 0: {Available: calc.BaseToQuote(2.2e6, mkt.LotSize) + calc.BaseToQuote(2.4e6, mkt.LotSize) - 1}, - 42: {Available: 2*mkt.LotSize - 1}, - }, - expectedBuys: []*rateLots{ - { - rate: divideRate(1.9e6, 1+profit), - lots: 1, - }, - }, - expectedSells: []*rateLots{ - { - rate: multiplyRate(2.2e6, 1+profit), - lots: 1, - }, - }, + lots: 1, + rate: dexRate(7.5e6, false), + counterTradeRate: 7.5e6, }, - // "one existing order, enough cex balance for second" - { - name: "one existing order, enough cex balance for second", - rebalancer: &tArbMMRebalancer{ - buyVWAP: map[uint64]*vwapResult{ - lotSizeMultiplier(2): { - avg: 2e6, - extrema: 1.9e6, - }, - lotSizeMultiplier(3.5): { - avg: 1.8e6, - extrema: 1.7e6, - }, - }, - sellVWAP: map[uint64]*vwapResult{ - lotSizeMultiplier(2): { - avg: 2.1e6, - extrema: 2.2e6, - }, - lotSizeMultiplier(3.5): { - avg: 2.3e6, - extrema: 2.4e6, - }, - }, - groupedBuys: map[int][]*groupedOrder{ - 0: {{ - rate: divideRate(1.9e6, 1+profit), - lots: 1, - }}, - }, - groupedSells: map[int][]*groupedOrder{ - 0: {{ - rate: multiplyRate(2.2e6, 1+profit), - lots: 1, - }}, - }, - }, - cfg: cfg2, - dexBalances: map[uint32]uint64{ - 42: 1e19, - 0: 1e19, - }, - cexBalances: map[uint32]*botBalance{ - 0: {Available: calc.BaseToQuote(2.2e6, mkt.LotSize) + calc.BaseToQuote(2.4e6, mkt.LotSize)}, - 42: {Available: 2 * mkt.LotSize}, - }, - expectedBuys: []*rateLots{ - { - rate: divideRate(1.7e6, 1+profit), - lots: 1, - placementIndex: 1, - }, - }, - expectedSells: []*rateLots{ - { - rate: multiplyRate(2.4e6, 1+profit), - lots: 1, - placementIndex: 1, - }, - }, - }, - // "one existing order, not enough cex balance for second" + } + + expSellPlacements := []*multiTradePlacement{ { - name: "one existing order, not enough cex balance for second", - rebalancer: &tArbMMRebalancer{ - buyVWAP: map[uint64]*vwapResult{ - lotSizeMultiplier(2): { - avg: 2e6, - extrema: 1.9e6, - }, - lotSizeMultiplier(3.5): { - avg: 1.8e6, - extrema: 1.7e6, - }, - }, - sellVWAP: map[uint64]*vwapResult{ - lotSizeMultiplier(2): { - avg: 2.1e6, - extrema: 2.2e6, - }, - lotSizeMultiplier(3.5): { - avg: 2.3e6, - extrema: 2.4e6, - }, - }, - groupedBuys: map[int][]*groupedOrder{ - 0: {{ - rate: divideRate(1.9e6, 1+profit), - lots: 1, - }}, - }, - groupedSells: map[int][]*groupedOrder{ - 0: {{ - rate: multiplyRate(2.2e6, 1+profit), - lots: 1, - }}, - }, - }, - cfg: cfg2, - dexBalances: map[uint32]uint64{ - 42: 1e19, - 0: 1e19, - }, - cexBalances: map[uint32]*botBalance{ - 0: {Available: calc.BaseToQuote(2.2e6, mkt.LotSize) + calc.BaseToQuote(2.4e6, mkt.LotSize) - 1}, - 42: {Available: 2*mkt.LotSize - 1}, - }, + lots: 2, + rate: dexRate(4.5e6, true), + counterTradeRate: 4.5e6, }, - // "no existing orders, two orders each, dex balance edge with reserves, enough" { - name: "no existing orders, two orders each, dex balance edge with reserves, enough", - rebalancer: &tArbMMRebalancer{ - buyVWAP: map[uint64]*vwapResult{ - lotSizeMultiplier(2): { - avg: 2e6, - extrema: 1.9e6, - }, - lotSizeMultiplier(3.5): { - avg: 1.8e6, - extrema: 1.7e6, - }, - }, - sellVWAP: map[uint64]*vwapResult{ - lotSizeMultiplier(2): { - avg: 2.1e6, - extrema: 2.2e6, - }, - lotSizeMultiplier(3.5): { - avg: 2.3e6, - extrema: 2.4e6, - }, - }, - }, - cfg: cfg2, - reserves: autoRebalanceReserves{ - baseDexReserves: 2 * lotSize, - }, - dexBalances: map[uint32]uint64{ - 42: 2*(lotSize+sellFees.swap) + sellFees.funding + 2*lotSize, - 0: calc.BaseToQuote(divideRate(1.9e6, 1+profit), lotSize) + calc.BaseToQuote(divideRate(1.7e6, 1+profit), lotSize) + 2*buyFees.swap + buyFees.funding, - }, - cexBalances: map[uint32]*botBalance{ - 0: {Available: 1e19}, - 42: {Available: 1e19}, - }, - expectedBuys: []*rateLots{ - { - rate: divideRate(1.9e6, 1+profit), - lots: 1, - }, - { - rate: divideRate(1.7e6, 1+profit), - lots: 1, - placementIndex: 1, - }, - }, - expectedSells: []*rateLots{ - { - rate: multiplyRate(2.2e6, 1+profit), - lots: 1, - }, - { - rate: multiplyRate(2.4e6, 1+profit), - lots: 1, - placementIndex: 1, - }, - }, + lots: 1, + rate: dexRate(3e6, true), + counterTradeRate: 3e6, }, - // "no existing orders, two orders each, dex balance edge with reserves, not enough" { - name: "no existing orders, two orders each, dex balance edge with reserves, not enough", - rebalancer: &tArbMMRebalancer{ - buyVWAP: map[uint64]*vwapResult{ - lotSizeMultiplier(2): { - avg: 2e6, - extrema: 1.9e6, - }, - lotSizeMultiplier(3.5): { - avg: 1.8e6, - extrema: 1.7e6, - }, - }, - sellVWAP: map[uint64]*vwapResult{ - lotSizeMultiplier(2): { - avg: 2.1e6, - extrema: 2.2e6, - }, - lotSizeMultiplier(3.5): { - avg: 2.3e6, - extrema: 2.4e6, - }, - }, - }, - cfg: cfg2, - reserves: autoRebalanceReserves{ - baseDexReserves: 2 * lotSize, - }, - dexBalances: map[uint32]uint64{ - 42: 2*(lotSize+sellFees.swap) + sellFees.funding + 2*lotSize - 1, - 0: calc.BaseToQuote(divideRate(1.9e6, 1+profit), lotSize) + calc.BaseToQuote(divideRate(1.7e6, 1+profit), lotSize) + 2*buyFees.swap + buyFees.funding, - }, - cexBalances: map[uint32]*botBalance{ - 0: {Available: 1e19}, - 42: {Available: 1e19}, - }, - expectedBuys: []*rateLots{ - { - rate: divideRate(1.9e6, 1+profit), - lots: 1, - }, - { - rate: divideRate(1.7e6, 1+profit), - lots: 1, - placementIndex: 1, - }, - }, - expectedSells: []*rateLots{ - { - rate: multiplyRate(2.2e6, 1+profit), - lots: 1, - }, - }, - }, - // "no existing orders, two orders each, cex balance edge with reserves, enough" - { - name: "no existing orders, two orders each, cex balance edge with reserves, enough", - rebalancer: &tArbMMRebalancer{ - buyVWAP: map[uint64]*vwapResult{ - lotSizeMultiplier(2): { - avg: 2e6, - extrema: 1.9e6, - }, - lotSizeMultiplier(3.5): { - avg: 1.8e6, - extrema: 1.7e6, - }, - }, - sellVWAP: map[uint64]*vwapResult{ - lotSizeMultiplier(2): { - avg: 2.1e6, - extrema: 2.2e6, - }, - lotSizeMultiplier(3.5): { - avg: 2.3e6, - extrema: 2.4e6, - }, - }, - }, - cfg: cfg2, - reserves: autoRebalanceReserves{ - quoteCexReserves: lotSize, - }, - dexBalances: map[uint32]uint64{ - 42: 1e19, - 0: 1e19, - }, - cexBalances: map[uint32]*botBalance{ - 0: {Available: calc.BaseToQuote(2.2e6, mkt.LotSize) + calc.BaseToQuote(2.4e6, mkt.LotSize) + lotSize}, - 42: {Available: 2 * mkt.LotSize}, - }, - expectedBuys: []*rateLots{ - { - rate: divideRate(1.9e6, 1+profit), - lots: 1, - }, - { - rate: divideRate(1.7e6, 1+profit), - lots: 1, - placementIndex: 1, - }, - }, - expectedSells: []*rateLots{ - { - rate: multiplyRate(2.2e6, 1+profit), - lots: 1, - }, - { - rate: multiplyRate(2.4e6, 1+profit), - lots: 1, - placementIndex: 1, - }, - }, - }, - // "no existing orders, two orders each, cex balance edge with reserves, enough" - { - name: "no existing orders, two orders each, cex balance edge with reserves, not enough", - rebalancer: &tArbMMRebalancer{ - buyVWAP: map[uint64]*vwapResult{ - lotSizeMultiplier(2): { - avg: 2e6, - extrema: 1.9e6, - }, - lotSizeMultiplier(3.5): { - avg: 1.8e6, - extrema: 1.7e6, - }, - }, - sellVWAP: map[uint64]*vwapResult{ - lotSizeMultiplier(2): { - avg: 2.1e6, - extrema: 2.2e6, - }, - lotSizeMultiplier(3.5): { - avg: 2.3e6, - extrema: 2.4e6, - }, - }, - }, - cfg: cfg2, - reserves: autoRebalanceReserves{ - baseCexReserves: lotSize, - }, - dexBalances: map[uint32]uint64{ - 42: 1e19, - 0: 1e19, - }, - cexBalances: map[uint32]*botBalance{ - 0: {Available: calc.BaseToQuote(2.2e6, mkt.LotSize) + calc.BaseToQuote(2.4e6, mkt.LotSize) + lotSize - 1}, - 42: {Available: 2 * mkt.LotSize}, - }, - expectedBuys: []*rateLots{ - { - rate: divideRate(1.9e6, 1+profit), - lots: 1, - }, - }, - expectedSells: []*rateLots{ - { - rate: multiplyRate(2.2e6, 1+profit), - lots: 1, - }, - { - rate: multiplyRate(2.4e6, 1+profit), - lots: 1, - placementIndex: 1, - }, - }, + lots: 0, + rate: 0, }, } + if !reflect.DeepEqual(expBuyPlacements, coreAdaptor.lastMultiTradeBuys) { + t.Fatal(spew.Sprintf("expected buy placements:\n%#+v\ngot:\n%#+v", expBuyPlacements, coreAdaptor.lastMultiTradeBuys)) + } + if !reflect.DeepEqual(expSellPlacements, coreAdaptor.lastMultiTradeSells) { + t.Fatal(spew.Sprintf("expected sell placements:\n%#+v\ngot:\n%#+v", expSellPlacements, coreAdaptor.lastMultiTradeSells)) + } + delete(arbMM.cexTrades, "1") // this normally will get deleted when an update arrives from the CEX + cexAdaptor.cancelledTrades = []string{} // reset - for _, test := range tests { - tCore := newTCore() - tCore.setAssetBalances(test.dexBalances) - cex := newTBotCEXAdaptor() - cex.balances = test.cexBalances + cexAdaptor.prepareRebalanceResults[baseID] = &prepareRebalanceResult{ + rebalance: -int64(baseCexReserves), + } + cexAdaptor.prepareRebalanceResults[quoteID] = &prepareRebalanceResult{ + rebalance: int64(quoteDexReserves), + } - cancels, buys, sells := arbMarketMakerRebalance(newEpoch, test.rebalancer, - newTBotCoreAdaptor(tCore), cex, test.cfg, mkt, buyFees, sellFees, &test.reserves, tLogger) + arbMM.rebalance(currEpoch + 1) - if len(cancels) != len(test.expectedCancels) { - t.Fatalf("%s: expected %d cancels, got %d", test.name, len(test.expectedCancels), len(cancels)) - } - for i := range cancels { - if !cancels[i].Equal(test.expectedCancels[i]) { - t.Fatalf("%s: cancel %d expected %x, got %x", test.name, i, test.expectedCancels[i], cancels[i]) - } - } + // The CEX orderbook hasn't changed, so the rates are the same. + if !reflect.DeepEqual(expBuyPlacements, coreAdaptor.lastMultiTradeBuys) { + t.Fatal(spew.Sprintf("expected buy placements:\n%#+v\ngot:\n%#+v", expBuyPlacements, coreAdaptor.lastMultiTradeBuys)) + } + if !reflect.DeepEqual(expSellPlacements, coreAdaptor.lastMultiTradeSells) { + t.Fatal(spew.Sprintf("expected sell placements:\n%#+v\ngot:\n%#+v", expSellPlacements, coreAdaptor.lastMultiTradeSells)) + } - if len(buys) != len(test.expectedBuys) { - t.Fatalf("%s: expected %d buys, got %d", test.name, len(test.expectedBuys), len(buys)) - } - for i := range buys { - if buys[i].rate != test.expectedBuys[i].rate { - t.Fatalf("%s: buy %d expected rate %d, got %d", test.name, i, test.expectedBuys[i].rate, buys[i].rate) - } - if buys[i].lots != test.expectedBuys[i].lots { - t.Fatalf("%s: buy %d expected lots %d, got %d", test.name, i, test.expectedBuys[i].lots, buys[i].lots) - } - if buys[i].placementIndex != test.expectedBuys[i].placementIndex { - t.Fatalf("%s: buy %d expected placement index %d, got %d", test.name, i, test.expectedBuys[i].placementIndex, buys[i].placementIndex) - } - } + // Make sure MultiTrade was called with the correct reserve arguments. + expectedDEXReserves := map[uint32]uint64{baseID: 0, quoteID: quoteDexReserves} + expectedCEXReserves := map[uint32]uint64{baseID: baseCexReserves, quoteID: 0} + if !reflect.DeepEqual(coreAdaptor.buysCEXReserves, expectedCEXReserves) { + t.Fatalf("expected cex reserves:\n%+v\ngot:\n%+v", expectedCEXReserves, coreAdaptor.buysCEXReserves) + } + if !reflect.DeepEqual(coreAdaptor.sellsCEXReserves, expectedCEXReserves) { + t.Fatalf("expected cex reserves:\n%+v\ngot:\n%+v", expectedCEXReserves, coreAdaptor.sellsCEXReserves) + } + if !reflect.DeepEqual(coreAdaptor.buysDEXReserves, expectedDEXReserves) { + t.Fatalf("expected dex reserves:\n%+v\ngot:\n%+v", expectedDEXReserves, coreAdaptor.buysDEXReserves) + } + if !reflect.DeepEqual(coreAdaptor.sellsDEXReserves, expectedDEXReserves) { + t.Fatalf("expected dex reserves:\n%+v\ngot:\n%+v", expectedDEXReserves, coreAdaptor.sellsDEXReserves) + } - if len(sells) != len(test.expectedSells) { - t.Fatalf("%s: expected %d sells, got %d", test.name, len(test.expectedSells), len(sells)) - } - for i := range sells { - if sells[i].rate != test.expectedSells[i].rate { - t.Fatalf("%s: sell %d expected rate %d, got %d", test.name, i, test.expectedSells[i].rate, sells[i].rate) - } - if sells[i].lots != test.expectedSells[i].lots { - t.Fatalf("%s: sell %d expected lots %d, got %d", test.name, i, test.expectedSells[i].lots, sells[i].lots) - } - if sells[i].placementIndex != test.expectedSells[i].placementIndex { - t.Fatalf("%s: sell %d expected placement index %d, got %d", test.name, i, test.expectedSells[i].placementIndex, sells[i].placementIndex) - } - } + expectedWithdrawal := &withdrawArgs{ + assetID: baseID, + amt: baseCexReserves, + } + if !reflect.DeepEqual(expectedWithdrawal, cexAdaptor.lastWithdrawArgs) { + t.Fatalf("expected withdrawal:\n%+v\ngot:\n%+v", expectedWithdrawal, cexAdaptor.lastWithdrawArgs) + } + + expectedDeposit := &withdrawArgs{ + assetID: quoteID, + amt: quoteDexReserves, + } + if !reflect.DeepEqual(expectedDeposit, cexAdaptor.lastDepositArgs) { + t.Fatalf("expected deposit:\n%+v\ngot:\n%+v", expectedDeposit, cexAdaptor.lastDepositArgs) + } + + arbMM.cexTrades = make(map[string]uint64) + arbMM.cexTrades["1"] = currEpoch + 2 - cfg.NumEpochsLeaveOpen + arbMM.cexTrades["2"] = currEpoch + 2 - cfg.NumEpochsLeaveOpen + 1 + arbMM.rebalance(currEpoch + 2) + + expectedCancels := []string{"1"} + if !reflect.DeepEqual(expectedCancels, cexAdaptor.cancelledTrades) { + t.Fatalf("expected cancels:\n%+v\ngot:\n%+v", expectedCancels, cexAdaptor.cancelledTrades) } } @@ -925,181 +251,87 @@ func TestArbMarketMakerDEXUpdates(t *testing.T) { QuoteSymbol: "btc", } - multiplyRate := func(u uint64, m float64) uint64 { - return steppedRate(uint64(float64(u)*m), mkt.RateStep) - } - divideRate := func(u uint64, d float64) uint64 { - return steppedRate(uint64(float64(u)/d), mkt.RateStep) - } - type test struct { name string - orders []*core.Order - notes []core.Notification + pendingOrders map[order.OrderID]uint64 + orderUpdates []*core.Order expectedCEXTrades []*libxc.Trade } tests := []*test{ { - name: "one buy and one sell match notifications", - orders: []*core.Order{ + name: "one buy and one sell match, repeated", + pendingOrders: map[order.OrderID]uint64{ + orderIDs[0]: 7.9e5, + orderIDs[1]: 6.1e5, + }, + orderUpdates: []*core.Order{ { ID: orderIDs[0][:], Sell: true, Qty: lotSize, Rate: 8e5, + Matches: []*core.Match{ + { + MatchID: matchIDs[0][:], + Qty: lotSize, + Rate: 8e5, + }, + }, }, { ID: orderIDs[1][:], Sell: false, Qty: lotSize, Rate: 6e5, - }, - }, - notes: []core.Notification{ - &core.MatchNote{ - OrderID: orderIDs[0][:], - Match: &core.Match{ - MatchID: matchIDs[0][:], - Qty: lotSize, - Rate: 8e5, - }, - }, - &core.MatchNote{ - OrderID: orderIDs[1][:], - Match: &core.Match{ - MatchID: matchIDs[1][:], - Qty: lotSize, - Rate: 6e5, - }, - }, - &core.OrderNote{ - Order: &core.Order{ - ID: orderIDs[0][:], - Sell: true, - Qty: lotSize, - Rate: 8e5, - Matches: []*core.Match{ - { - MatchID: matchIDs[0][:], - Qty: lotSize, - Rate: 8e5, - }, + Matches: []*core.Match{ + { + MatchID: matchIDs[1][:], + Qty: lotSize, + Rate: 6e5, }, }, }, - &core.OrderNote{ - Order: &core.Order{ - ID: orderIDs[1][:], - Sell: false, - Qty: lotSize, - Rate: 8e5, - Matches: []*core.Match{ - { - MatchID: matchIDs[1][:], - Qty: lotSize, - Rate: 6e5, - }, - }, - }, - }, - }, - expectedCEXTrades: []*libxc.Trade{ - { - BaseID: 42, - QuoteID: 0, - Qty: lotSize, - Rate: divideRate(8e5, 1+profit), - Sell: false, - }, - { - BaseID: 42, - QuoteID: 0, - Qty: lotSize, - Rate: multiplyRate(6e5, 1+profit), - Sell: true, - }, - nil, - nil, - }, - }, - { - name: "place cex trades due to order note", - orders: []*core.Order{ { ID: orderIDs[0][:], Sell: true, Qty: lotSize, Rate: 8e5, + Matches: []*core.Match{ + { + MatchID: matchIDs[0][:], + Qty: lotSize, + Rate: 8e5, + }, + }, }, { ID: orderIDs[1][:], Sell: false, Qty: lotSize, Rate: 6e5, - }, - }, - notes: []core.Notification{ - &core.OrderNote{ - Order: &core.Order{ - ID: orderIDs[0][:], - Sell: true, - Qty: lotSize, - Rate: 8e5, - Matches: []*core.Match{ - { - MatchID: matchIDs[0][:], - Qty: lotSize, - Rate: 8e5, - }, - }, - }, - }, - &core.OrderNote{ - Order: &core.Order{ - ID: orderIDs[1][:], - Sell: false, - Qty: lotSize, - Rate: 8e5, - Matches: []*core.Match{ - { - MatchID: matchIDs[1][:], - Qty: lotSize, - Rate: 6e5, - }, + Matches: []*core.Match{ + { + MatchID: matchIDs[1][:], + Qty: lotSize, + Rate: 6e5, }, }, }, - &core.MatchNote{ - OrderID: orderIDs[0][:], - Match: &core.Match{ - MatchID: matchIDs[0][:], - Qty: lotSize, - Rate: 8e5, - }, - }, - &core.MatchNote{ - OrderID: orderIDs[1][:], - Match: &core.Match{ - MatchID: matchIDs[1][:], - Qty: lotSize, - Rate: 6e5, - }, - }, }, expectedCEXTrades: []*libxc.Trade{ { BaseID: 42, QuoteID: 0, Qty: lotSize, - Rate: divideRate(8e5, 1+profit), + Rate: 7.9e5, Sell: false, }, { BaseID: 42, QuoteID: 0, Qty: lotSize, - Rate: multiplyRate(6e5, 1+profit), + Rate: 6.1e5, Sell: true, }, nil, @@ -1111,46 +343,37 @@ func TestArbMarketMakerDEXUpdates(t *testing.T) { runTest := func(test *test) { cex := newTBotCEXAdaptor() tCore := newTCore() + coreAdaptor := newTBotCoreAdaptor(tCore) + ctx, cancel := context.WithCancel(context.Background()) defer cancel() - ords := make(map[order.OrderID]*core.Order) - - for _, o := range test.orders { - var oid order.OrderID - copy(oid[:], o.ID) - ords[oid] = o - } - arbMM := &arbMarketMaker{ - cex: cex, - core: newTBotCoreAdaptor(tCore), - ctx: ctx, - ords: ords, - baseID: 42, - quoteID: 0, - oidToPlacement: make(map[order.OrderID]int), - matchesSeen: make(map[order.MatchID]bool), - cexTrades: make(map[string]uint64), - mkt: mkt, + cex: cex, + core: coreAdaptor, + ctx: ctx, + baseID: 42, + quoteID: 0, + matchesSeen: make(map[order.MatchID]bool), + cexTrades: make(map[string]uint64), + mkt: mkt, cfg: &ArbMarketMakerConfig{ Profit: profit, }, + pendingOrders: test.pendingOrders, } arbMM.currEpoch.Store(123) go arbMM.run() - dummyNote := &core.BondRefundNote{} - - for i, note := range test.notes { + for i, note := range test.orderUpdates { cex.lastTrade = nil - tCore.noteFeed <- note - tCore.noteFeed <- dummyNote + coreAdaptor.orderUpdates <- note + coreAdaptor.orderUpdates <- &core.Order{} // Dummy update should have no effect expectedCEXTrade := test.expectedCEXTrades[i] if (expectedCEXTrade == nil) != (cex.lastTrade == nil) { - t.Fatalf("%s: expected cex order %v but got %v", test.name, (expectedCEXTrade != nil), (cex.lastTrade != nil)) + t.Fatalf("%s: expected cex order after update %d %v but got %v", test.name, i, (expectedCEXTrade != nil), (cex.lastTrade != nil)) } if cex.lastTrade != nil && @@ -1165,537 +388,82 @@ func TestArbMarketMakerDEXUpdates(t *testing.T) { } } -func TestArbMarketMakerAutoRebalance(t *testing.T) { - orderIDs := make([]order.OrderID, 5) - for i := 0; i < 5; i++ { - copy(orderIDs[i][:], encode.RandomBytes(32)) - } - - matchIDs := make([]order.MatchID, 5) - for i := 0; i < 5; i++ { - copy(matchIDs[i][:], encode.RandomBytes(32)) - } - - mkt := &core.Market{ - LotSize: 4e8, - } - - baseID, quoteID := uint32(42), uint32(0) - - profitRate := float64(0.01) - +func TestDEXPlacementRate(t *testing.T) { type test struct { - name string - cfg *AutoRebalanceConfig - orders map[order.OrderID]*core.Order - oidToPlacement map[order.OrderID]int - cexBaseBalance uint64 - cexQuoteBalance uint64 - dexBaseBalance uint64 - dexQuoteBalance uint64 - activeCEXOrders bool - - expectedDeposit *withdrawArgs - expectedWithdraw *withdrawArgs - expectedCancels []dex.Bytes - expectedReserves autoRebalanceReserves - expectedBasePending bool - expectedQuotePending bool + name string + counterTradeRate uint64 + profit float64 + base uint32 + quote uint32 + fees uint64 + mkt *core.Market } - currEpoch := uint64(123) - tests := []*test{ - // "no orders, no need to rebalance" { - name: "no orders, no need to rebalance", - cfg: &AutoRebalanceConfig{ - MinBaseAmt: 1e16, - MinQuoteAmt: 1e12, + name: "dcr/btc", + counterTradeRate: 5e6, + profit: 0.03, + base: 42, + quote: 0, + fees: 4e5, + mkt: &core.Market{ + BaseID: 42, + QuoteID: 0, + LotSize: 40e8, + RateStep: 1e2, }, - orders: map[order.OrderID]*core.Order{}, - oidToPlacement: map[order.OrderID]int{}, - cexBaseBalance: 1e16, - cexQuoteBalance: 1e12, - dexBaseBalance: 1e16, - dexQuoteBalance: 1e12, }, - // "no action with active cex orders" { - name: "no action with active cex orders", - cfg: &AutoRebalanceConfig{ - MinBaseAmt: 6 * mkt.LotSize, - MinQuoteAmt: calc.BaseToQuote(5e7, 6*mkt.LotSize), + name: "btc/usdc.eth", + counterTradeRate: calc.MessageRateAlt(43000, 1e8, 1e6), + profit: 0.01, + base: 0, + quote: 60001, + fees: 5e5, + mkt: &core.Market{ + BaseID: 0, + QuoteID: 60001, + LotSize: 5e6, + RateStep: 1e4, }, - orders: map[order.OrderID]*core.Order{ - orderIDs[0]: { - ID: orderIDs[0][:], - Qty: 4 * mkt.LotSize, - Rate: 5e7, - Sell: true, - LockedAmt: 4 * mkt.LotSize, - Epoch: currEpoch - 2, - }, - orderIDs[1]: { - ID: orderIDs[1][:], - Qty: 4 * mkt.LotSize, - Rate: 6e7, - Sell: true, - LockedAmt: 4 * mkt.LotSize, - Epoch: currEpoch - 2, - }, - }, - oidToPlacement: map[order.OrderID]int{ - orderIDs[0]: 0, - }, - cexBaseBalance: 3 * mkt.LotSize, - dexBaseBalance: 5 * mkt.LotSize, - cexQuoteBalance: calc.BaseToQuote(5e7, 6*mkt.LotSize), - dexQuoteBalance: calc.BaseToQuote(5e7, 6*mkt.LotSize), - activeCEXOrders: true, }, - // "no orders, need to withdraw base" { - name: "no orders, need to withdraw base", - cfg: &AutoRebalanceConfig{ - MinBaseAmt: 1e16, - MinQuoteAmt: 1e12, - }, - orders: map[order.OrderID]*core.Order{}, - oidToPlacement: map[order.OrderID]int{}, - cexBaseBalance: 8e16, - cexQuoteBalance: 1e12, - dexBaseBalance: 9e15, - dexQuoteBalance: 1e12, - expectedWithdraw: &withdrawArgs{ - assetID: 42, - amt: (9e15+8e16)/2 - 9e15, - }, - expectedBasePending: true, - }, - // "need to deposit base, no need to cancel order" - { - name: "need to deposit base, no need to cancel order", - cfg: &AutoRebalanceConfig{ - MinBaseAmt: 6 * mkt.LotSize, - MinQuoteAmt: calc.BaseToQuote(5e7, 6*mkt.LotSize), - }, - orders: map[order.OrderID]*core.Order{ - orderIDs[0]: { - ID: orderIDs[0][:], - Qty: 4 * mkt.LotSize, - Rate: 5e7, - Sell: true, - LockedAmt: 4 * mkt.LotSize, - Epoch: currEpoch - 2, - }, - orderIDs[1]: { - ID: orderIDs[1][:], - Qty: 4 * mkt.LotSize, - Rate: 6e7, - Sell: true, - LockedAmt: 4 * mkt.LotSize, - Epoch: currEpoch - 2, - }, - }, - oidToPlacement: map[order.OrderID]int{ - orderIDs[0]: 0, - }, - cexBaseBalance: 3 * mkt.LotSize, - dexBaseBalance: 5 * mkt.LotSize, - cexQuoteBalance: calc.BaseToQuote(5e7, 6*mkt.LotSize), - dexQuoteBalance: calc.BaseToQuote(5e7, 6*mkt.LotSize), - expectedDeposit: &withdrawArgs{ - assetID: 42, - amt: 5 * mkt.LotSize, - }, - expectedBasePending: true, - }, - // "need to deposit base, need to cancel 1 order" - { - name: "need to deposit base, need to cancel 1 order", - cfg: &AutoRebalanceConfig{ - MinBaseAmt: 6 * mkt.LotSize, - MinQuoteAmt: calc.BaseToQuote(5e7, 6*mkt.LotSize), - }, - orders: map[order.OrderID]*core.Order{ - orderIDs[0]: { - ID: orderIDs[0][:], - Qty: 4 * mkt.LotSize, - Rate: 5e7, - Sell: true, - LockedAmt: 4 * mkt.LotSize, - Epoch: currEpoch - 2, - }, - orderIDs[1]: { - ID: orderIDs[1][:], - Qty: 4 * mkt.LotSize, - Rate: 6e7, - Sell: true, - LockedAmt: 4 * mkt.LotSize, - Epoch: currEpoch - 2, - }, - }, - oidToPlacement: map[order.OrderID]int{ - orderIDs[0]: 0, - orderIDs[1]: 1, - }, - cexBaseBalance: 3 * mkt.LotSize, - dexBaseBalance: 5*mkt.LotSize - 2, - cexQuoteBalance: calc.BaseToQuote(5e7, 6*mkt.LotSize), - dexQuoteBalance: calc.BaseToQuote(5e7, 6*mkt.LotSize), - expectedCancels: []dex.Bytes{ - orderIDs[1][:], - }, - expectedReserves: autoRebalanceReserves{ - baseDexReserves: (16*mkt.LotSize-2)/2 - 3*mkt.LotSize, - }, - }, - // "need to deposit base, need to cancel 2 orders" - { - name: "need to deposit base, need to cancel 2 orders", - cfg: &AutoRebalanceConfig{ - MinBaseAmt: 3 * mkt.LotSize, - MinQuoteAmt: calc.BaseToQuote(5e7, 6*mkt.LotSize), - }, - orders: map[order.OrderID]*core.Order{ - orderIDs[0]: { - ID: orderIDs[0][:], - Qty: 2 * mkt.LotSize, - Rate: 5e7, - Sell: true, - LockedAmt: 2 * mkt.LotSize, - Epoch: currEpoch - 2, - }, - orderIDs[1]: { - ID: orderIDs[1][:], - Qty: 2 * mkt.LotSize, - Rate: 5e7, - Sell: true, - LockedAmt: 2 * mkt.LotSize, - Epoch: currEpoch - 2, - }, - orderIDs[2]: { - ID: orderIDs[2][:], - Qty: 2 * mkt.LotSize, - Rate: 5e7, - Sell: true, - LockedAmt: 2 * mkt.LotSize, - Epoch: currEpoch - 2, - }, - }, - oidToPlacement: map[order.OrderID]int{ - orderIDs[0]: 0, - orderIDs[1]: 1, - orderIDs[2]: 2, - }, - cexBaseBalance: 0, - dexBaseBalance: 1000, - cexQuoteBalance: calc.BaseToQuote(5e7, 6*mkt.LotSize), - dexQuoteBalance: calc.BaseToQuote(5e7, 6*mkt.LotSize), - expectedCancels: []dex.Bytes{ - orderIDs[2][:], - orderIDs[1][:], - }, - expectedReserves: autoRebalanceReserves{ - baseDexReserves: (6*mkt.LotSize + 1000) / 2, - }, - }, - // "need to withdraw base, no need to cancel order" - { - name: "need to withdraw base, no need to cancel order", - cfg: &AutoRebalanceConfig{ - MinBaseAmt: 3 * mkt.LotSize, - MinQuoteAmt: calc.BaseToQuote(5e7, 6*mkt.LotSize), - }, - orders: map[order.OrderID]*core.Order{}, - oidToPlacement: map[order.OrderID]int{ - orderIDs[0]: 0, - orderIDs[1]: 1, - orderIDs[2]: 2, - }, - cexBaseBalance: 6 * mkt.LotSize, - dexBaseBalance: 0, - cexQuoteBalance: calc.BaseToQuote(5e7, 6*mkt.LotSize), - dexQuoteBalance: calc.BaseToQuote(5e7, 6*mkt.LotSize), - expectedWithdraw: &withdrawArgs{ - assetID: baseID, - amt: 3 * mkt.LotSize, - }, - expectedBasePending: true, - }, - // "need to withdraw base, need to cancel 1 order" - { - name: "need to withdraw base, need to cancel 1 order", - cfg: &AutoRebalanceConfig{ - MinBaseAmt: 3 * mkt.LotSize, - MinQuoteAmt: calc.BaseToQuote(5e7, 6*mkt.LotSize), - }, - orders: map[order.OrderID]*core.Order{ - orderIDs[0]: { - ID: orderIDs[0][:], - Qty: 2 * mkt.LotSize, - Rate: 5e7, - Sell: false, - LockedAmt: 2*mkt.LotSize + 1500, - Epoch: currEpoch - 2, - }, - orderIDs[1]: { - ID: orderIDs[1][:], - Qty: 2 * mkt.LotSize, - Rate: 6e7, - Sell: false, - LockedAmt: 2*mkt.LotSize + 1500, - Epoch: currEpoch - 2, - }, - }, - oidToPlacement: map[order.OrderID]int{ - orderIDs[0]: 0, - orderIDs[1]: 1, - }, - cexBaseBalance: 8*mkt.LotSize - 2, - dexBaseBalance: 0, - cexQuoteBalance: calc.BaseToQuote(5e7, 6*mkt.LotSize), - dexQuoteBalance: calc.BaseToQuote(5e7, 6*mkt.LotSize), - expectedCancels: []dex.Bytes{ - orderIDs[1][:], - }, - expectedReserves: autoRebalanceReserves{ - baseCexReserves: 4*mkt.LotSize - 1, - }, - }, - // "need to deposit quote, no need to cancel order" - { - name: "need to deposit quote, no need to cancel order", - cfg: &AutoRebalanceConfig{ - MinBaseAmt: 6 * mkt.LotSize, - MinQuoteAmt: calc.BaseToQuote(5e7, 6*mkt.LotSize), - }, - orders: map[order.OrderID]*core.Order{ - orderIDs[0]: { - ID: orderIDs[0][:], - Qty: 2 * mkt.LotSize, - Rate: 5e7, - Sell: false, - LockedAmt: calc.BaseToQuote(5e7, 2*mkt.LotSize), - Epoch: currEpoch - 2, - }, - orderIDs[1]: { - ID: orderIDs[1][:], - Qty: 2 * mkt.LotSize, - Rate: 5e7, - Sell: false, - LockedAmt: calc.BaseToQuote(5e7, 2*mkt.LotSize), - Epoch: currEpoch - 2, - }, - }, - oidToPlacement: map[order.OrderID]int{ - orderIDs[0]: 0, - }, - cexBaseBalance: 10 * mkt.LotSize, - dexBaseBalance: 10 * mkt.LotSize, - cexQuoteBalance: calc.BaseToQuote(5e7, 4*mkt.LotSize), - dexQuoteBalance: calc.BaseToQuote(5e7, 8*mkt.LotSize), - expectedDeposit: &withdrawArgs{ - assetID: 0, - amt: calc.BaseToQuote(5e7, 4*mkt.LotSize), - }, - expectedQuotePending: true, - }, - // "need to deposit quote, need to cancel 1 order" - { - name: "need to deposit quote, no need to cancel order", - cfg: &AutoRebalanceConfig{ - MinBaseAmt: 6 * mkt.LotSize, - MinQuoteAmt: calc.BaseToQuote(5e7, 6*mkt.LotSize), - }, - orders: map[order.OrderID]*core.Order{ - orderIDs[0]: { - ID: orderIDs[0][:], - Qty: 4 * mkt.LotSize, - Rate: 5e7, - Sell: false, - LockedAmt: calc.BaseToQuote(5e7, 4*mkt.LotSize), - Epoch: currEpoch - 2, - }, - orderIDs[1]: { - ID: orderIDs[1][:], - Qty: 4 * mkt.LotSize, - Rate: 5e7, - Sell: false, - LockedAmt: calc.BaseToQuote(5e7, 4*mkt.LotSize), - Epoch: currEpoch - 2, - }, - }, - oidToPlacement: map[order.OrderID]int{ - orderIDs[0]: 0, - orderIDs[1]: 1, - }, - cexBaseBalance: 10 * mkt.LotSize, - dexBaseBalance: 10 * mkt.LotSize, - cexQuoteBalance: calc.BaseToQuote(5e7, 4*mkt.LotSize), - dexQuoteBalance: calc.BaseToQuote(5e7, 2*mkt.LotSize), - expectedCancels: []dex.Bytes{ - orderIDs[1][:], - }, - expectedReserves: autoRebalanceReserves{ - quoteDexReserves: calc.BaseToQuote(5e7, 3*mkt.LotSize), - }, - }, - // "need to withdraw quote, no need to cancel order" - { - name: "need to withdraw quote, no need to cancel order", - cfg: &AutoRebalanceConfig{ - MinBaseAmt: 6 * mkt.LotSize, - MinQuoteAmt: calc.BaseToQuote(5e7, 6*mkt.LotSize), - }, - orders: map[order.OrderID]*core.Order{ - orderIDs[0]: { - ID: orderIDs[0][:], - Qty: 3 * mkt.LotSize, - Rate: 5e7, - Sell: true, - LockedAmt: 3 * mkt.LotSize, - Epoch: currEpoch - 2, - }, - orderIDs[1]: { - ID: orderIDs[1][:], - Qty: 3 * mkt.LotSize, - Rate: 5e7, - Sell: true, - LockedAmt: 3 * mkt.LotSize, - Epoch: currEpoch - 2, - }, - }, - oidToPlacement: map[order.OrderID]int{ - orderIDs[0]: 0, - orderIDs[1]: 1, - }, - cexBaseBalance: 10 * mkt.LotSize, - dexBaseBalance: 10 * mkt.LotSize, - cexQuoteBalance: calc.BaseToQuote(5e7, 4*mkt.LotSize) + calc.BaseToQuote(uint64(float64(5e7)*(1+profitRate)), 6*mkt.LotSize), - dexQuoteBalance: calc.BaseToQuote(5e7, 2*mkt.LotSize) + 12e6, - expectedWithdraw: &withdrawArgs{ - assetID: quoteID, - amt: calc.BaseToQuote(5e7, 4*mkt.LotSize), - }, - expectedQuotePending: true, - }, - // "need to withdraw quote, no need to cancel 1 order" - { - name: "need to withdraw quote, no need to cancel 1 order", - cfg: &AutoRebalanceConfig{ - MinBaseAmt: 6 * mkt.LotSize, - MinQuoteAmt: calc.BaseToQuote(5e7, 6*mkt.LotSize), - }, - orders: map[order.OrderID]*core.Order{ - orderIDs[0]: { - ID: orderIDs[0][:], - Qty: 3 * mkt.LotSize, - Rate: 5e7, - Sell: true, - LockedAmt: 3 * mkt.LotSize, - Epoch: currEpoch - 2, - }, - orderIDs[1]: { - ID: orderIDs[1][:], - Qty: 3 * mkt.LotSize, - Rate: 5e7, - Sell: true, - LockedAmt: 3 * mkt.LotSize, - Epoch: currEpoch - 2, - }, - }, - oidToPlacement: map[order.OrderID]int{ - orderIDs[0]: 0, - orderIDs[1]: 1, - }, - cexBaseBalance: 10 * mkt.LotSize, - dexBaseBalance: 10 * mkt.LotSize, - cexQuoteBalance: calc.BaseToQuote(5e7, 4*mkt.LotSize) + calc.BaseToQuote(uint64(float64(5e7)*(1+profitRate)), 6*mkt.LotSize), - dexQuoteBalance: calc.BaseToQuote(5e7, 2*mkt.LotSize) + 12e6 - 2, - expectedCancels: []dex.Bytes{ - orderIDs[1][:], - }, - expectedReserves: autoRebalanceReserves{ - quoteCexReserves: calc.BaseToQuote(5e7, 4*mkt.LotSize) + 1, + name: "wbtc.polygon/usdc.eth", + counterTradeRate: calc.MessageRateAlt(43000, 1e8, 1e6), + profit: 0.02, + base: 966003, + quote: 60001, + fees: 3e5, + mkt: &core.Market{ + BaseID: 966003, + QuoteID: 60001, + LotSize: 5e6, + RateStep: 1e4, }, }, } - runTest := func(test *test) { - cex := newTBotCEXAdaptor() - cex.balances = map[uint32]*botBalance{ - baseID: {Available: test.cexBaseBalance}, - quoteID: {Available: test.cexQuoteBalance}, - } - tCore := newTCore() - tCore.setAssetBalances(map[uint32]uint64{ - baseID: test.dexBaseBalance, - quoteID: test.dexQuoteBalance, - }) - - ctx, cancel := context.WithCancel(context.Background()) - defer cancel() - - mm := &arbMarketMaker{ - ctx: ctx, - cex: cex, - core: newTBotCoreAdaptor(tCore), - baseID: baseID, - quoteID: quoteID, - oidToPlacement: test.oidToPlacement, - ords: test.orders, - log: tLogger, - cfg: &ArbMarketMakerConfig{ - AutoRebalance: test.cfg, - Profit: profitRate, - }, - mkt: mkt, - } - - if test.activeCEXOrders { - mm.cexTrades = map[string]uint64{"abc": 1234} - } - - mm.rebalanceAssets() - - if (test.expectedDeposit == nil) != (cex.lastDepositArgs == nil) { - t.Fatalf("%s: expected deposit %v but got %v", test.name, (test.expectedDeposit != nil), (cex.lastDepositArgs != nil)) - } - if test.expectedDeposit != nil { - if *cex.lastDepositArgs != *test.expectedDeposit { - t.Fatalf("%s: expected deposit %+v but got %+v", test.name, test.expectedDeposit, cex.lastDepositArgs) - } - } - - if (test.expectedWithdraw == nil) != (cex.lastWithdrawArgs == nil) { - t.Fatalf("%s: expected withdraw %v but got %v", test.name, (test.expectedWithdraw != nil), (cex.lastWithdrawArgs != nil)) - } - if test.expectedWithdraw != nil { - if *cex.lastWithdrawArgs != *test.expectedWithdraw { - t.Fatalf("%s: expected withdraw %+v but got %+v", test.name, test.expectedWithdraw, cex.lastWithdrawArgs) - } - } - - if len(tCore.cancelsPlaced) != len(test.expectedCancels) { - t.Fatalf("%s: expected %d cancels but got %d", test.name, len(test.expectedCancels), len(tCore.cancelsPlaced)) - } - for i := range test.expectedCancels { - if !tCore.cancelsPlaced[i].Equal(test.expectedCancels[i]) { - t.Fatalf("%s: expected cancel %d %s but got %s", test.name, i, hex.EncodeToString(test.expectedCancels[i]), hex.EncodeToString(tCore.cancelsPlaced[i])) - } + runTest := func(tt *test) { + sellRate, err := dexPlacementRate(tt.counterTradeRate, true, tt.profit, tt.mkt, tt.fees) + if err != nil { + t.Fatalf("%s: unexpected error: %v", tt.name, err) } - if test.expectedReserves != mm.reserves { - t.Fatalf("%s: expected reserves %+v but got %+v", test.name, test.expectedReserves, mm.reserves) + expectedProfitableSellRate := uint64(float64(tt.counterTradeRate) * (1 + tt.profit)) + additional := calc.BaseToQuote(sellRate, tt.mkt.LotSize) - calc.BaseToQuote(expectedProfitableSellRate, tt.mkt.LotSize) + if additional > tt.fees*101/100 || additional < tt.fees*99/100 { + t.Fatalf("%s: expected additional %d but got %d", tt.name, tt.fees, additional) } - if test.expectedBasePending != mm.pendingBaseRebalance.Load() { - t.Fatalf("%s: expected pending base rebalance %v but got %v", test.name, test.expectedBasePending, mm.pendingBaseRebalance.Load()) + buyRate, err := dexPlacementRate(tt.counterTradeRate, false, tt.profit, tt.mkt, tt.fees) + if err != nil { + t.Fatalf("%s: unexpected error: %v", tt.name, err) } - if test.expectedQuotePending != mm.pendingQuoteRebalance.Load() { - t.Fatalf("%s: expected pending quote rebalance %v but got %v", test.name, test.expectedQuotePending, mm.pendingQuoteRebalance.Load()) + expectedProfitableBuyRate := uint64(float64(tt.counterTradeRate) / (1 + tt.profit)) + savings := calc.BaseToQuote(expectedProfitableBuyRate, tt.mkt.LotSize) - calc.BaseToQuote(buyRate, tt.mkt.LotSize) + if savings > tt.fees*101/100 || savings < tt.fees*99/100 { + t.Fatalf("%s: expected savings %d but got %d", tt.name, tt.fees, savings) } } diff --git a/client/mm/mm_basic.go b/client/mm/mm_basic.go index 260bb49b5e..106db21b4e 100644 --- a/client/mm/mm_basic.go +++ b/client/mm/mm_basic.go @@ -10,13 +10,11 @@ import ( "math" "sync" "sync/atomic" - "time" "decred.org/dcrdex/client/core" "decred.org/dcrdex/client/orderbook" "decred.org/dcrdex/dex" "decred.org/dcrdex/dex/calc" - "decred.org/dcrdex/dex/order" ) const ( @@ -99,12 +97,6 @@ type BasicMarketMakingConfig struct { // EmptyMarketRate can be set if there is no market data available, and is // ignored if there is market data available. EmptyMarketRate float64 `json:"emptyMarketRate"` - - // BaseOptions are the multi-order options for the base asset wallet. - BaseOptions map[string]string `json:"baseOptions"` - - // QuoteOptions are the multi-order options for the quote asset wallet. - QuoteOptions map[string]string `json:"quoteOptions"` } func needBreakEvenHalfSpread(strat GapStrategy) bool { @@ -182,97 +174,18 @@ func (c *BasicMarketMakingConfig) Validate() error { return nil } -// steppedRate rounds the rate to the nearest integer multiple of the step. -// The minimum returned value is step. -func steppedRate(r, step uint64) uint64 { - steps := math.Round(float64(r) / float64(step)) - if steps == 0 { - return step - } - return uint64(math.Round(steps * float64(step))) -} - -// orderFees are the fees used for calculating the half-spread. -type orderFees struct { - swap uint64 - redemption uint64 - refund uint64 - funding uint64 +type basicMMCalculator interface { + basisPrice() uint64 + halfSpread(uint64) (uint64, error) } -type basicMarketMaker struct { - ctx context.Context - host string - base uint32 - quote uint32 - cfg *BasicMarketMakingConfig +type basicMMCalculatorImpl struct { book dexOrderBook - log dex.Logger - core botCoreAdaptor oracle oracle + core botCoreAdaptor mkt *core.Market - // the fiat rate is the rate determined by comparing the fiat rates - // of the two assets. - fiatRateV atomic.Uint64 - rebalanceRunning atomic.Bool - - ordMtx sync.RWMutex - ords map[order.OrderID]*core.Order - oidToPlacement map[order.OrderID]int - - feesMtx sync.RWMutex - buyFees *orderFees - sellFees *orderFees -} - -// groupedOrder is a subset of an *core.Order. -type groupedOrder struct { - id order.OrderID - rate uint64 - lots uint64 - epoch uint64 - lockedAmt uint64 -} - -func groupOrders(orders map[order.OrderID]*core.Order, oidToPlacement map[order.OrderID]int, lotSize uint64) (buys, sells map[int][]*groupedOrder) { - makeGroupedOrder := func(o *core.Order) *groupedOrder { - var oid order.OrderID - copy(oid[:], o.ID) - return &groupedOrder{ - id: oid, - rate: o.Rate, - lots: (o.Qty - o.Filled) / lotSize, - epoch: o.Epoch, - lockedAmt: o.LockedAmt, - } - } - - buys, sells = make(map[int][]*groupedOrder), make(map[int][]*groupedOrder) - for _, ord := range orders { - var oid order.OrderID - copy(oid[:], ord.ID) - placementIndex := oidToPlacement[oid] - if ord.Sell { - if _, found := sells[placementIndex]; !found { - sells[placementIndex] = make([]*groupedOrder, 0, 1) - } - sells[placementIndex] = append(sells[placementIndex], makeGroupedOrder(ord)) - } else { - if _, found := buys[placementIndex]; !found { - buys[placementIndex] = make([]*groupedOrder, 0, 1) - } - buys[placementIndex] = append(buys[placementIndex], makeGroupedOrder(ord)) - } - } - - return buys, sells -} - -// groupedOrders returns the buy and sell orders grouped by placement index. -func (m *basicMarketMaker) groupedOrders() (buys, sells map[int][]*groupedOrder) { - m.ordMtx.RLock() - defer m.ordMtx.RUnlock() - return groupOrders(m.ords, m.oidToPlacement, m.mkt.LotSize) + cfg *BasicMarketMakingConfig + log dex.Logger } // basisPrice calculates the basis price for the market maker. @@ -285,207 +198,166 @@ func (m *basicMarketMaker) groupedOrders() (buys, sells map[int][]*groupedOrder) // or oracle weighting is 0, the fiat rate is used. // If there is no fiat rate available, the empty market rate in the // configuration is used. -func basisPrice(book dexOrderBook, oracle oracle, cfg *BasicMarketMakingConfig, mkt *core.Market, fiatRate uint64, log dex.Logger) uint64 { - midGap, err := book.MidGap() +func (b *basicMMCalculatorImpl) basisPrice() uint64 { + midGap, err := b.book.MidGap() if err != nil && !errors.Is(err, orderbook.ErrEmptyOrderbook) { - log.Errorf("MidGap error: %v", err) + b.log.Errorf("MidGap error: %v", err) return 0 } basisPrice := float64(midGap) // float64 message-rate units var oracleWeighting, oraclePrice float64 - if cfg.OracleWeighting != nil && *cfg.OracleWeighting > 0 { - oracleWeighting = *cfg.OracleWeighting - oraclePrice = oracle.getMarketPrice(mkt.BaseID, mkt.QuoteID) + if b.cfg.OracleWeighting != nil && *b.cfg.OracleWeighting > 0 { + oracleWeighting = *b.cfg.OracleWeighting + oraclePrice = b.oracle.getMarketPrice(b.mkt.BaseID, b.mkt.QuoteID) if oraclePrice == 0 { - log.Warnf("no oracle price available for %s bot", mkt.Name) + b.log.Warnf("no oracle price available for %s bot", b.mkt.Name) } } if oraclePrice > 0 { - msgOracleRate := float64(mkt.ConventionalRateToMsg(oraclePrice)) + msgOracleRate := float64(b.mkt.ConventionalRateToMsg(oraclePrice)) // Apply the oracle mismatch filter. if basisPrice > 0 { low, high := msgOracleRate*(1-maxOracleMismatch), msgOracleRate*(1+maxOracleMismatch) if basisPrice < low { - log.Debug("local mid-gap is below safe range. Using effective mid-gap of %d%% below the oracle rate.", maxOracleMismatch*100) + b.log.Debug("local mid-gap is below safe range. Using effective mid-gap of %d%% below the oracle rate.", maxOracleMismatch*100) basisPrice = low } else if basisPrice > high { - log.Debug("local mid-gap is above safe range. Using effective mid-gap of %d%% above the oracle rate.", maxOracleMismatch*100) + b.log.Debug("local mid-gap is above safe range. Using effective mid-gap of %d%% above the oracle rate.", maxOracleMismatch*100) basisPrice = high } } - if cfg.OracleBias != 0 { - msgOracleRate *= 1 + cfg.OracleBias + if b.cfg.OracleBias != 0 { + msgOracleRate *= 1 + b.cfg.OracleBias } if basisPrice == 0 { // no mid-gap available. Use the oracle price. basisPrice = msgOracleRate - log.Tracef("basisPrice: using basis price %.0f from oracle because no mid-gap was found in order book", basisPrice) + b.log.Tracef("basisPrice: using basis price %.0f from oracle because no mid-gap was found in order book", basisPrice) } else { basisPrice = msgOracleRate*oracleWeighting + basisPrice*(1-oracleWeighting) - log.Tracef("basisPrice: oracle-weighted basis price = %f", basisPrice) + b.log.Tracef("basisPrice: oracle-weighted basis price = %f", basisPrice) } } if basisPrice > 0 { - return steppedRate(uint64(basisPrice), mkt.RateStep) + return steppedRate(uint64(basisPrice), b.mkt.RateStep) } // TODO: add a configuration to turn off use of fiat rate? + fiatRate := b.core.ExchangeRateFromFiatSources() if fiatRate > 0 { - return steppedRate(fiatRate, mkt.RateStep) + return steppedRate(fiatRate, b.mkt.RateStep) } - if cfg.EmptyMarketRate > 0 { - emptyMsgRate := mkt.ConventionalRateToMsg(cfg.EmptyMarketRate) - return steppedRate(emptyMsgRate, mkt.RateStep) + if b.cfg.EmptyMarketRate > 0 { + emptyMsgRate := b.mkt.ConventionalRateToMsg(b.cfg.EmptyMarketRate) + return steppedRate(emptyMsgRate, b.mkt.RateStep) } return 0 } -func (m *basicMarketMaker) basisPrice() uint64 { - return basisPrice(m.book, m.oracle, m.cfg, m.mkt, m.fiatRateV.Load(), m.log) -} - -func (m *basicMarketMaker) halfSpread(basisPrice uint64) (uint64, error) { - form := &core.SingleLotFeesForm{ - Host: m.host, - Base: m.base, - Quote: m.quote, - Sell: true, - } - +// halfSpread calculates the distance from the mid-gap where if you sell a lot +// at the basis price plus half-gap, then buy a lot at the basis price minus +// half-gap, you will have one lot of the base asset plus the total fees in +// base units. Since the fees are in base units, basis price can be used to +// convert the quote fees to base units. In the case of tokens, the fees are +// converted using fiat rates. +func (b *basicMMCalculatorImpl) halfSpread(basisPrice uint64) (uint64, error) { if basisPrice == 0 { // prevent divide by zero later return 0, fmt.Errorf("basis price cannot be zero") } - baseFees, quoteFees, _, err := m.core.SingleLotFees(form) + sellFeesInBaseUnits, err := b.core.OrderFeesInUnits(true, true, basisPrice) if err != nil { - return 0, fmt.Errorf("SingleLotFees error: %v", err) + return 0, fmt.Errorf("error getting sell fees in base units: %w", err) } - form.Sell = false - newQuoteFees, newBaseFees, _, err := m.core.SingleLotFees(form) + buyFeesInBaseUnits, err := b.core.OrderFeesInUnits(false, true, basisPrice) if err != nil { - return 0, fmt.Errorf("SingleLotFees error: %v", err) + return 0, fmt.Errorf("error getting buy fees in base units: %w", err) } - baseFees += newBaseFees - quoteFees += newQuoteFees - - g := float64(calc.BaseToQuote(basisPrice, baseFees)+quoteFees) / - float64(baseFees+2*m.mkt.LotSize) * m.mkt.AtomToConv - - halfGap := uint64(math.Round(g * calc.RateEncodingFactor)) - - m.log.Tracef("halfSpread: base basis price = %d, lot size = %d, base fees = %d, quote fees = %d, half-gap = %d", - basisPrice, m.mkt.LotSize, baseFees, quoteFees, halfGap) - - return halfGap, nil -} - -func (m *basicMarketMaker) placeMultiTrade(placements []*rateLots, sell bool) { - qtyRates := make([]*core.QtyRate, 0, len(placements)) - for _, p := range placements { - qtyRates = append(qtyRates, &core.QtyRate{ - Qty: p.lots * m.mkt.LotSize, - Rate: p.rate, - }) - } - - var options map[string]string - if sell { - options = m.cfg.BaseOptions - } else { - options = m.cfg.QuoteOptions - } - - orders, err := m.core.MultiTrade(nil, &core.MultiTradeForm{ - Host: m.host, - Sell: sell, - Base: m.base, - Quote: m.quote, - Placements: qtyRates, - Options: options, - }) + sellFeesInQuoteUnits, err := b.core.OrderFeesInUnits(true, false, basisPrice) if err != nil { - m.log.Errorf("Error placing rebalancing order: %v", err) - return + return 0, fmt.Errorf("error getting sell fees in quote units: %w", err) } - m.ordMtx.Lock() - for i, ord := range orders { - var oid order.OrderID - copy(oid[:], ord.ID) - m.ords[oid] = ord - m.oidToPlacement[oid] = placements[i].placementIndex - } - m.ordMtx.Unlock() -} + buyFeesInQuoteUnits, err := b.core.OrderFeesInUnits(false, false, basisPrice) + if err != nil { + return 0, fmt.Errorf("error getting buy fees in quote units: %w", err) + } + + totalFeesInBaseUnits := sellFeesInBaseUnits + buyFeesInBaseUnits + totalFeesInQuoteUnits := sellFeesInQuoteUnits + buyFeesInQuoteUnits + + /* + * g = half-gap + * b = basis price + * l = lot size + * f = total fees in base units + * q = f * b = total fees in quote units + * + * We must choose a half-gap such that: + * (b + g) * l / (b - g) = l + f + * + * This means that when you sell a lot at the basis price plus half-gap, + * then buy a lot at the basis price minus half-gap, you will have one + * lot of the base asset plus the total fees in base units. + * + * Solving for g, you get: + * g = q / (f + 2l) + */ + g := float64(totalFeesInQuoteUnits) / + float64(totalFeesInBaseUnits+2*b.mkt.LotSize) -func (m *basicMarketMaker) processFiatRates(rates map[uint32]float64) { - var fiatRate uint64 + halfGap := uint64(math.Round(g * calc.RateEncodingFactor)) - baseRate := rates[m.base] - quoteRate := rates[m.quote] - if baseRate > 0 && quoteRate > 0 { - fiatRate = m.mkt.ConventionalRateToMsg(baseRate / quoteRate) - } + b.log.Tracef("halfSpread: base basis price = %d, lot size = %d, fees in base units = %d, fees in quote units = %d, half-gap = %d", + basisPrice, b.mkt.LotSize, totalFeesInBaseUnits, totalFeesInQuoteUnits, halfGap) - m.fiatRateV.Store(fiatRate) + return halfGap, nil } -func (m *basicMarketMaker) processTrade(o *core.Order) { - if len(o.ID) == 0 { - return - } - - var oid order.OrderID - copy(oid[:], o.ID) - - m.ordMtx.Lock() - defer m.ordMtx.Unlock() - - _, found := m.ords[oid] - if !found { - return - } - - if o.Status > order.OrderStatusBooked { - // We stop caring when the order is taken off the book. - delete(m.ords, oid) - delete(m.oidToPlacement, oid) - } else { - // Update our reference. - m.ords[oid] = o - } +type basicMarketMaker struct { + ctx context.Context + host string + base uint32 + quote uint32 + cfg *BasicMarketMakingConfig + log dex.Logger + core botCoreAdaptor + oracle oracle + mkt *core.Market + rebalanceRunning atomic.Bool + calculator basicMMCalculator } -func orderPrice(basisPrice, breakEven uint64, strategy GapStrategy, factor float64, sell bool, mkt *core.Market) uint64 { +func (m *basicMarketMaker) orderPrice(basisPrice, breakEven uint64, sell bool, gapFactor float64) uint64 { var halfSpread uint64 // Apply the base strategy. - switch strategy { + switch m.cfg.GapStrategy { case GapStrategyMultiplier: - halfSpread = uint64(math.Round(float64(breakEven) * factor)) + halfSpread = uint64(math.Round(float64(breakEven) * gapFactor)) case GapStrategyPercent, GapStrategyPercentPlus: - halfSpread = uint64(math.Round(factor * float64(basisPrice))) + halfSpread = uint64(math.Round(gapFactor * float64(basisPrice))) case GapStrategyAbsolute, GapStrategyAbsolutePlus: - halfSpread = mkt.ConventionalRateToMsg(factor) + halfSpread = m.mkt.ConventionalRateToMsg(gapFactor) } // Add the break-even to the "-plus" strategies - switch strategy { + switch m.cfg.GapStrategy { case GapStrategyAbsolutePlus, GapStrategyPercentPlus: halfSpread += breakEven } - halfSpread = steppedRate(halfSpread, mkt.RateStep) + halfSpread = steppedRate(halfSpread, m.mkt.RateStep) if sell { return basisPrice + halfSpread @@ -498,193 +370,42 @@ func orderPrice(basisPrice, breakEven uint64, strategy GapStrategy, factor float return basisPrice - halfSpread } -type rebalancer interface { - basisPrice() uint64 - halfSpread(uint64) (uint64, error) - groupedOrders() (buys, sells map[int][]*groupedOrder) -} - -type rateLots struct { - rate uint64 - lots uint64 - placementIndex int -} - -func basicMMRebalance(newEpoch uint64, m rebalancer, c botCoreAdaptor, cfg *BasicMarketMakingConfig, mkt *core.Market, buyFees, - sellFees *orderFees, log dex.Logger) (cancels []dex.Bytes, buyOrders, sellOrders []*rateLots) { - basisPrice := m.basisPrice() +func (m *basicMarketMaker) ordersToPlace() (buyOrders, sellOrders []*multiTradePlacement) { + basisPrice := m.calculator.basisPrice() if basisPrice == 0 { - log.Errorf("No basis price available and no empty-market rate set") + m.log.Errorf("No basis price available and no empty-market rate set") return } - log.Debugf("rebalance (%s): basis price = %d", mkt.Name, basisPrice) - var breakEven uint64 - if needBreakEvenHalfSpread(cfg.GapStrategy) { + if needBreakEvenHalfSpread(m.cfg.GapStrategy) { var err error - breakEven, err = m.halfSpread(basisPrice) + breakEven, err = m.calculator.halfSpread(basisPrice) if err != nil { - log.Errorf("Could not calculate break-even spread: %v", err) + m.log.Errorf("Could not calculate break-even spread: %v", err) return } } - existingBuys, existingSells := m.groupedOrders() - getExistingOrders := func(index int, sell bool) []*groupedOrder { - if sell { - return existingSells[index] - } - return existingBuys[index] - } - - // Get highest existing buy and lowest existing sell to avoid - // self-matches. - var highestExistingBuy, lowestExistingSell uint64 = 0, math.MaxUint64 - for _, placementOrders := range existingBuys { - for _, o := range placementOrders { - if o.rate > highestExistingBuy { - highestExistingBuy = o.rate - } - } - } - for _, placementOrders := range existingSells { - for _, o := range placementOrders { - if o.rate < lowestExistingSell { - lowestExistingSell = o.rate + orders := func(orderPlacements []*OrderPlacement, sell bool) []*multiTradePlacement { + placements := make([]*multiTradePlacement, 0, len(orderPlacements)) + for _, p := range orderPlacements { + rate := m.orderPrice(basisPrice, breakEven, sell, p.GapFactor) + lots := p.Lots + if rate == 0 { + lots = 0 } - } - } - rateCausesSelfMatch := func(rate uint64, sell bool) bool { - if sell { - return rate <= highestExistingBuy - } - return rate >= lowestExistingSell - } - - withinTolerance := func(rate, target uint64) bool { - driftTolerance := uint64(float64(target) * cfg.DriftTolerance) - lowerBound := target - driftTolerance - upperBound := target + driftTolerance - return rate >= lowerBound && rate <= upperBound - } - - baseBalance, err := c.DEXBalance(mkt.BaseID) - if err != nil { - log.Errorf("Error getting base balance: %v", err) - return - } - quoteBalance, err := c.DEXBalance(mkt.QuoteID) - if err != nil { - log.Errorf("Error getting quote balance: %v", err) - return - } - - cancels = make([]dex.Bytes, 0, len(cfg.SellPlacements)+len(cfg.BuyPlacements)) - addCancel := func(o *groupedOrder) { - if newEpoch-o.epoch < 2 { - log.Debugf("rebalance: skipping cancel not past free cancel threshold") - return - } - cancels = append(cancels, o.id[:]) - } - - processSide := func(sell bool) []*rateLots { - log.Debugf("rebalance: processing %s side", map[bool]string{true: "sell", false: "buy"}[sell]) - - var cfgPlacements []*OrderPlacement - if sell { - cfgPlacements = cfg.SellPlacements - } else { - cfgPlacements = cfg.BuyPlacements - } - if len(cfgPlacements) == 0 { - return nil - } - - var remainingBalance uint64 - if sell { - remainingBalance = baseBalance.Available - if remainingBalance > sellFees.funding { - remainingBalance -= sellFees.funding - } else { - return nil - } - } else { - remainingBalance = quoteBalance.Available - if remainingBalance > buyFees.funding { - remainingBalance -= buyFees.funding - } else { - return nil - } - } - - rlPlacements := make([]*rateLots, 0, len(cfgPlacements)) - - for i, p := range cfgPlacements { - placementRate := orderPrice(basisPrice, breakEven, cfg.GapStrategy, p.GapFactor, sell, mkt) - log.Debugf("placement %d rate: %d", i, placementRate) - - if placementRate == 0 { - log.Warnf("skipping %s placement %d because it would result in a zero rate", - map[bool]string{true: "sell", false: "buy"}[sell], i) - continue - } - if rateCausesSelfMatch(placementRate, sell) { - log.Warnf("skipping %s placement %d because it would cause a self-match", - map[bool]string{true: "sell", false: "buy"}[sell], i) - continue - } - - existingOrders := getExistingOrders(i, sell) - var numLotsOnBooks uint64 - for _, o := range existingOrders { - numLotsOnBooks += o.lots - if !withinTolerance(o.rate, placementRate) { - addCancel(o) - } - } - - var lotsToPlace uint64 - if p.Lots > numLotsOnBooks { - lotsToPlace = p.Lots - numLotsOnBooks - } - if lotsToPlace == 0 { - continue - } - - log.Debugf("placement %d: placing %d lots", i, lotsToPlace) - - // TODO: handle redeem/refund fees for account lockers - var requiredPerLot uint64 - if sell { - requiredPerLot = sellFees.swap + mkt.LotSize - } else { - requiredPerLot = calc.BaseToQuote(placementRate, mkt.LotSize) + buyFees.swap - } - if remainingBalance/requiredPerLot < lotsToPlace { - log.Debugf("placement %d: not enough balance to place %d lots, placing %d", i, lotsToPlace, remainingBalance/requiredPerLot) - lotsToPlace = remainingBalance / requiredPerLot - } - if lotsToPlace == 0 { - continue - } - - remainingBalance -= requiredPerLot * lotsToPlace - rlPlacements = append(rlPlacements, &rateLots{ - rate: placementRate, - lots: lotsToPlace, - placementIndex: i, + placements = append(placements, &multiTradePlacement{ + rate: rate, + lots: lots, }) } - - return rlPlacements + return placements } - buyOrders = processSide(false) - sellOrders = processSide(true) - - return cancels, buyOrders, sellOrders + buyOrders = orders(m.cfg.BuyPlacements, false) + sellOrders = orders(m.cfg.SellPlacements, true) + return buyOrders, sellOrders } func (m *basicMarketMaker) rebalance(newEpoch uint64) { @@ -694,102 +415,9 @@ func (m *basicMarketMaker) rebalance(newEpoch uint64) { defer m.rebalanceRunning.Store(false) m.log.Tracef("rebalance: epoch %d", newEpoch) - m.feesMtx.RLock() - buyFees, sellFees := m.buyFees, m.sellFees - m.feesMtx.RUnlock() - - cancels, buyOrders, sellOrders := basicMMRebalance(newEpoch, m, m.core, m.cfg, m.mkt, buyFees, sellFees, m.log) - - for _, cancel := range cancels { - err := m.core.Cancel(cancel) - if err != nil { - m.log.Errorf("Error canceling order %s: %v", cancel, err) - return - } - } - if len(buyOrders) > 0 { - m.placeMultiTrade(buyOrders, false) - } - if len(sellOrders) > 0 { - m.placeMultiTrade(sellOrders, true) - } -} - -func (m *basicMarketMaker) handleNotification(note core.Notification) { - switch n := note.(type) { - case *core.OrderNote: - ord := n.Order - if ord == nil { - return - } - m.processTrade(ord) - case *core.FiatRatesNote: - go m.processFiatRates(n.FiatRates) - } -} - -func (m *basicMarketMaker) cancelAllOrders() { - m.ordMtx.Lock() - defer m.ordMtx.Unlock() - for oid := range m.ords { - if err := m.core.Cancel(oid[:]); err != nil { - m.log.Errorf("error cancelling order: %v", err) - } - } - m.ords = make(map[order.OrderID]*core.Order) - m.oidToPlacement = make(map[order.OrderID]int) -} - -func (m *basicMarketMaker) updateFeeRates() error { - buySwapFees, buyRedeemFees, buyRefundFees, err := m.core.SingleLotFees(&core.SingleLotFeesForm{ - Host: m.host, - Base: m.base, - Quote: m.quote, - UseMaxFeeRate: true, - UseSafeTxSize: true, - }) - if err != nil { - return fmt.Errorf("failed to get fees: %v", err) - } - - sellSwapFees, sellRedeemFees, sellRefundFees, err := m.core.SingleLotFees(&core.SingleLotFeesForm{ - Host: m.host, - Base: m.base, - Quote: m.quote, - UseMaxFeeRate: true, - UseSafeTxSize: true, - Sell: true, - }) - if err != nil { - return fmt.Errorf("failed to get fees: %v", err) - } - - buyFundingFees, err := m.core.MaxFundingFees(m.quote, m.host, uint32(len(m.cfg.BuyPlacements)), m.cfg.QuoteOptions) - if err != nil { - return fmt.Errorf("failed to get funding fees: %v", err) - } - - sellFundingFees, err := m.core.MaxFundingFees(m.base, m.host, uint32(len(m.cfg.SellPlacements)), m.cfg.BaseOptions) - if err != nil { - return fmt.Errorf("failed to get funding fees: %v", err) - } - - m.feesMtx.Lock() - defer m.feesMtx.Unlock() - m.buyFees = &orderFees{ - swap: buySwapFees, - redemption: buyRedeemFees, - refund: buyRefundFees, - funding: buyFundingFees, - } - m.sellFees = &orderFees{ - swap: sellSwapFees, - redemption: sellRedeemFees, - refund: sellRefundFees, - funding: sellFundingFees, - } - - return nil + buyOrders, sellOrders := m.ordersToPlace() + m.core.MultiTrade(buyOrders, false, m.cfg.DriftTolerance, newEpoch, nil, nil) + m.core.MultiTrade(sellOrders, true, m.cfg.DriftTolerance, newEpoch, nil, nil) } func (m *basicMarketMaker) run() { @@ -798,7 +426,15 @@ func (m *basicMarketMaker) run() { m.log.Errorf("Failed to sync book: %v", err) return } - m.book = book + + m.calculator = &basicMMCalculatorImpl{ + book: book, + oracle: m.oracle, + core: m.core, + mkt: m.mkt, + cfg: m.cfg, + log: m.log, + } wg := sync.WaitGroup{} @@ -819,49 +455,13 @@ func (m *basicMarketMaker) run() { } }() - // Process core notifications - noteFeed := m.core.NotificationFeed() - wg.Add(1) - go func() { - defer wg.Done() - defer noteFeed.ReturnFeed() - for { - select { - case n := <-noteFeed.C: - m.handleNotification(n) - case <-m.ctx.Done(): - return - } - } - }() - - // Periodically update asset fee rates - wg.Add(1) - go func() { - defer wg.Done() - refreshTime := time.Minute * 10 - for { - select { - case <-time.NewTimer(refreshTime).C: - err := m.updateFeeRates() - if err != nil { - m.log.Error(err) - refreshTime = time.Minute - } else { - refreshTime = time.Minute * 10 - } - case <-m.ctx.Done(): - return - } - } - }() - wg.Wait() - m.cancelAllOrders() + + m.core.CancelAllOrders() } // RunBasicMarketMaker starts a basic market maker bot. -func RunBasicMarketMaker(ctx context.Context, cfg *BotConfig, c botCoreAdaptor, oracle oracle, baseFiatRate, quoteFiatRate float64, log dex.Logger) { +func RunBasicMarketMaker(ctx context.Context, cfg *BotConfig, c botCoreAdaptor, oracle oracle, log dex.Logger) { if cfg.BasicMMConfig == nil { // implies bug in caller log.Errorf("No market making config provided. Exiting.") @@ -870,7 +470,7 @@ func RunBasicMarketMaker(ctx context.Context, cfg *BotConfig, c botCoreAdaptor, err := cfg.BasicMMConfig.Validate() if err != nil { - log.Errorf("invalid market making config: %v", err) + log.Errorf("Invalid market making config: %v", err) return } @@ -881,27 +481,15 @@ func RunBasicMarketMaker(ctx context.Context, cfg *BotConfig, c botCoreAdaptor, } mm := &basicMarketMaker{ - ctx: ctx, - core: c, - log: log, - cfg: cfg.BasicMMConfig, - host: cfg.Host, - base: cfg.BaseID, - quote: cfg.QuoteID, - oracle: oracle, - mkt: mkt, - ords: make(map[order.OrderID]*core.Order), - oidToPlacement: make(map[order.OrderID]int), - } - - err = mm.updateFeeRates() - if err != nil { - log.Errorf("Not starting market maker: %v", err) - return - } - - if baseFiatRate > 0 && quoteFiatRate > 0 { - mm.fiatRateV.Store(mkt.ConventionalRateToMsg(baseFiatRate / quoteFiatRate)) + ctx: ctx, + core: c, + log: log, + cfg: cfg.BasicMMConfig, + host: cfg.Host, + base: cfg.BaseID, + quote: cfg.QuoteID, + oracle: oracle, + mkt: mkt, } mm.run() diff --git a/client/mm/mm_basic_test.go b/client/mm/mm_basic_test.go index e605915bee..a93e42c865 100644 --- a/client/mm/mm_basic_test.go +++ b/client/mm/mm_basic_test.go @@ -3,1103 +3,27 @@ package mm import ( - "errors" + "math" "reflect" - "sort" "testing" "decred.org/dcrdex/client/core" - "decred.org/dcrdex/dex" "decred.org/dcrdex/dex/calc" - "decred.org/dcrdex/dex/encode" - "decred.org/dcrdex/dex/order" + "github.com/davecgh/go-spew/spew" ) -var ( - dcrBipID uint32 = 42 - btcBipID uint32 = 0 -) - -type tRebalancer struct { - basis uint64 - breakEven uint64 - breakEvenErr error - sortedBuys map[int][]*groupedOrder - sortedSells map[int][]*groupedOrder +type tBasicMMCalculator struct { + bp uint64 + hs uint64 } -var _ rebalancer = (*tRebalancer)(nil) +var _ basicMMCalculator = (*tBasicMMCalculator)(nil) -func (r *tRebalancer) basisPrice() uint64 { - return r.basis +func (r *tBasicMMCalculator) basisPrice() uint64 { + return r.bp } - -func (r *tRebalancer) halfSpread(basisPrice uint64) (uint64, error) { - return r.breakEven, r.breakEvenErr -} - -func (r *tRebalancer) groupedOrders() (buys, sells map[int][]*groupedOrder) { - return r.sortedBuys, r.sortedSells -} - -func TestRebalance(t *testing.T) { - const rateStep uint64 = 1e3 - const midGap uint64 = 5e8 - const lotSize uint64 = 50e8 - const breakEven uint64 = 200 * rateStep - const newEpoch = 123_456_789 - const driftTolerance = 0.001 - buyFees := &orderFees{ - swap: 1e4, - redemption: 2e4, - funding: 3e4, - } - sellFees := &orderFees{ - swap: 2e4, - redemption: 1e4, - funding: 4e4, - } - - tCore := newTCore() - - orderIDs := make([]order.OrderID, 5) - for i := range orderIDs { - copy(orderIDs[i][:], encode.RandomBytes(32)) - } - - mkt := &core.Market{ - RateStep: rateStep, - AtomToConv: 1, - LotSize: lotSize, - BaseID: dcrBipID, - QuoteID: btcBipID, - } - - newBalancer := func(existingBuys, existingSells map[int][]*groupedOrder) *tRebalancer { - return &tRebalancer{ - basis: midGap, - breakEven: breakEven, - sortedBuys: existingBuys, - sortedSells: existingSells, - } - } - - log := dex.StdOutLogger("T", dex.LevelTrace) - - type test struct { - name string - cfg *BasicMarketMakingConfig - epoch uint64 - rebalancer *tRebalancer - - isAccountLocker map[uint32]bool - balances map[uint32]uint64 - - expectedBuys []rateLots - expectedSells []rateLots - expectedCancels []order.OrderID - } - - newgroupedOrder := func(id order.OrderID, lots, rate uint64, sell bool, freeCancel bool) *groupedOrder { - var epoch uint64 = newEpoch - if freeCancel { - epoch = newEpoch - 2 - } - return &groupedOrder{ - id: id, - epoch: epoch, - rate: rate, - lots: lots, - } - } - - driftToleranceEdge := func(rate uint64, within bool) uint64 { - edge := rate + uint64(float64(rate)*driftTolerance) - if within { - return edge - rateStep - } - return edge + rateStep - } - - requiredForOrder := func(sell bool, placements []*OrderPlacement, strategy GapStrategy) (req uint64) { - for _, placement := range placements { - if sell { - req += placement.Lots * mkt.LotSize - } else { - rate := orderPrice(midGap, breakEven, strategy, placement.GapFactor, sell, mkt) - req += calc.BaseToQuote(rate, placement.Lots*mkt.LotSize) - } - } - return - } - - tests := []*test{ - // "no existing orders, one order per side" - { - name: "no existing orders, one order per side", - cfg: &BasicMarketMakingConfig{ - GapStrategy: GapStrategyMultiplier, - SellPlacements: []*OrderPlacement{ - { - Lots: 1, - GapFactor: 3, - }, - }, - BuyPlacements: []*OrderPlacement{ - { - Lots: 1, - GapFactor: 3, - }, - }, - }, - rebalancer: newBalancer(nil, nil), - balances: map[uint32]uint64{ - dcrBipID: 1e13, - btcBipID: 1e13, - }, - expectedBuys: []rateLots{ - { - rate: midGap - (breakEven * 3), - lots: 1, - }, - }, - expectedSells: []rateLots{ - { - rate: midGap + (breakEven * 3), - lots: 1, - }, - }, - }, - // "no existing orders, no sell placements" - { - name: "no existing orders, no sell placements", - cfg: &BasicMarketMakingConfig{ - GapStrategy: GapStrategyMultiplier, - SellPlacements: []*OrderPlacement{}, - BuyPlacements: []*OrderPlacement{ - { - Lots: 1, - GapFactor: 3, - }, - }, - }, - rebalancer: newBalancer(nil, nil), - balances: map[uint32]uint64{ - dcrBipID: 1e13, - btcBipID: 1e13, - }, - - expectedBuys: []rateLots{ - { - rate: midGap - (breakEven * 3), - lots: 1, - }, - }, - expectedSells: []rateLots{}, - }, - // "no existing orders, no buy placements" - { - name: "no existing orders, no buy placements", - cfg: &BasicMarketMakingConfig{ - GapStrategy: GapStrategyMultiplier, - SellPlacements: []*OrderPlacement{ - { - Lots: 1, - GapFactor: 3, - }, - }, - BuyPlacements: []*OrderPlacement{}, - }, - rebalancer: newBalancer(nil, nil), - balances: map[uint32]uint64{ - dcrBipID: 1e13, - btcBipID: 1e13, - }, - - expectedBuys: []rateLots{}, - expectedSells: []rateLots{ - { - rate: midGap + (breakEven * 3), - lots: 1, - }, - }, - }, - // "no existing orders, multiple placements per side" - { - name: "no existing orders, multiple placements per side", - cfg: &BasicMarketMakingConfig{ - GapStrategy: GapStrategyMultiplier, - SellPlacements: []*OrderPlacement{ - { - Lots: 1, - GapFactor: 2, - }, - { - Lots: 1, - GapFactor: 3, - }, - }, - BuyPlacements: []*OrderPlacement{ - { - Lots: 1, - GapFactor: 2, - }, - { - Lots: 1, - GapFactor: 3, - }, - }, - }, - rebalancer: newBalancer(nil, nil), - balances: map[uint32]uint64{ - dcrBipID: 1e13, - btcBipID: 1e13, - }, - - expectedBuys: []rateLots{ - { - rate: midGap - (breakEven * 2), - lots: 1, - placementIndex: 0, - }, - { - rate: midGap - (breakEven * 3), - lots: 1, - placementIndex: 1, - }, - }, - expectedSells: []rateLots{ - { - rate: midGap + (breakEven * 2), - lots: 1, - placementIndex: 0, - }, - { - rate: midGap + (breakEven * 3), - lots: 1, - placementIndex: 1, - }, - }, - }, - // "test balances edge, enough for orders" - { - name: "test balances edge, enough for orders", - cfg: &BasicMarketMakingConfig{ - GapStrategy: GapStrategyMultiplier, - SellPlacements: []*OrderPlacement{ - { - Lots: 1, - GapFactor: 2, - }, - { - Lots: 1, - GapFactor: 3, - }, - }, - BuyPlacements: []*OrderPlacement{ - { - Lots: 1, - GapFactor: 2, - }, - { - Lots: 1, - GapFactor: 3, - }, - }, - }, - rebalancer: newBalancer(nil, nil), - balances: map[uint32]uint64{ - dcrBipID: requiredForOrder(true, []*OrderPlacement{ - { - Lots: 1, - GapFactor: 2, - }, - { - Lots: 1, - GapFactor: 3, - }, - }, GapStrategyMultiplier) + 2*sellFees.swap + sellFees.funding, - btcBipID: requiredForOrder(false, []*OrderPlacement{ - { - Lots: 1, - GapFactor: 2, - }, - { - Lots: 1, - GapFactor: 3, - }, - }, GapStrategyMultiplier) + 2*buyFees.swap + buyFees.funding, - }, - expectedBuys: []rateLots{ - { - rate: midGap - (breakEven * 2), - lots: 1, - placementIndex: 0, - }, - { - rate: midGap - (breakEven * 3), - lots: 1, - placementIndex: 1, - }, - }, - expectedSells: []rateLots{ - { - rate: midGap + (breakEven * 2), - lots: 1, - placementIndex: 0, - }, - { - rate: midGap + (breakEven * 3), - lots: 1, - placementIndex: 1, - }, - }, - }, - // "test balances edge, not enough for orders" - { - name: "test balances edge, not enough for orders", - cfg: &BasicMarketMakingConfig{ - GapStrategy: GapStrategyMultiplier, - SellPlacements: []*OrderPlacement{ - { - Lots: 1, - GapFactor: 2, - }, - { - Lots: 1, - GapFactor: 3, - }, - }, - BuyPlacements: []*OrderPlacement{ - { - Lots: 1, - GapFactor: 2, - }, - { - Lots: 1, - GapFactor: 3, - }, - }, - }, - rebalancer: newBalancer(nil, nil), - balances: map[uint32]uint64{ - dcrBipID: requiredForOrder(true, []*OrderPlacement{ - { - Lots: 1, - GapFactor: 2, - }, - { - Lots: 1, - GapFactor: 3, - }, - }, GapStrategyMultiplier) + 2*sellFees.swap + sellFees.funding - 1, - btcBipID: requiredForOrder(false, []*OrderPlacement{ - { - Lots: 1, - GapFactor: 2, - }, - { - Lots: 1, - GapFactor: 3, - }, - }, GapStrategyMultiplier) + 2*buyFees.swap + buyFees.funding - 1, - }, - expectedBuys: []rateLots{ - { - rate: midGap - (breakEven * 2), - lots: 1, - }, - }, - expectedSells: []rateLots{ - { - rate: midGap + (breakEven * 2), - lots: 1, - }, - }, - }, - // "test balances edge, enough for 2 lot orders" - { - name: "test balances edge, enough for 2 lot orders", - cfg: &BasicMarketMakingConfig{ - GapStrategy: GapStrategyMultiplier, - SellPlacements: []*OrderPlacement{ - { - Lots: 1, - GapFactor: 2, - }, - { - Lots: 2, - GapFactor: 3, - }, - }, - BuyPlacements: []*OrderPlacement{ - { - Lots: 1, - GapFactor: 2, - }, - { - Lots: 2, - GapFactor: 3, - }, - }, - }, - rebalancer: newBalancer(nil, nil), - balances: map[uint32]uint64{ - dcrBipID: requiredForOrder(true, []*OrderPlacement{ - { - Lots: 1, - GapFactor: 2, - }, - { - Lots: 2, - GapFactor: 3, - }, - }, GapStrategyMultiplier) + 3*sellFees.swap + sellFees.funding, - btcBipID: requiredForOrder(false, []*OrderPlacement{ - { - Lots: 1, - GapFactor: 2, - }, - { - Lots: 2, - GapFactor: 3, - }, - }, GapStrategyMultiplier) + 3*buyFees.swap + buyFees.funding, - }, - - expectedBuys: []rateLots{ - { - rate: midGap - (breakEven * 2), - lots: 1, - placementIndex: 0, - }, - { - rate: midGap - (breakEven * 3), - lots: 2, - placementIndex: 1, - }, - }, - expectedSells: []rateLots{ - { - rate: midGap + (breakEven * 2), - lots: 1, - placementIndex: 0, - }, - { - rate: midGap + (breakEven * 3), - lots: 2, - placementIndex: 1, - }, - }, - }, - // "test balances edge, not enough for 2 lot orders, place 1 lot" - { - name: "test balances edge, not enough for 2 lot orders, place 1 lot", - cfg: &BasicMarketMakingConfig{ - GapStrategy: GapStrategyMultiplier, - SellPlacements: []*OrderPlacement{ - { - Lots: 1, - GapFactor: 2, - }, - { - Lots: 2, - GapFactor: 3, - }, - }, - BuyPlacements: []*OrderPlacement{ - { - Lots: 1, - GapFactor: 2, - }, - { - Lots: 2, - GapFactor: 3, - }, - }, - }, - rebalancer: newBalancer(nil, nil), - balances: map[uint32]uint64{ - dcrBipID: requiredForOrder(true, []*OrderPlacement{ - { - Lots: 1, - GapFactor: 2, - }, - { - Lots: 2, - GapFactor: 3, - }, - }, GapStrategyMultiplier) + 3*sellFees.swap + sellFees.funding - 1, - btcBipID: requiredForOrder(false, []*OrderPlacement{ - { - Lots: 1, - GapFactor: 2, - }, - { - Lots: 2, - GapFactor: 3, - }, - }, GapStrategyMultiplier) + 3*buyFees.swap + buyFees.funding - 1, - }, - expectedBuys: []rateLots{ - { - rate: midGap - (breakEven * 2), - lots: 1, - }, - { - rate: midGap - (breakEven * 3), - lots: 1, - placementIndex: 1, - }, - }, - expectedSells: []rateLots{ - { - rate: midGap + (breakEven * 2), - lots: 1, - }, - { - rate: midGap + (breakEven * 3), - lots: 1, - placementIndex: 1, - }, - }, - }, - // "existing orders outside edge of drift tolerance" - { - name: "existing orders outside edge of drift tolerance", - cfg: &BasicMarketMakingConfig{ - GapStrategy: GapStrategyMultiplier, - SellPlacements: []*OrderPlacement{ - { - Lots: 1, - GapFactor: 2, - }, - { - Lots: 1, - GapFactor: 3, - }, - }, - BuyPlacements: []*OrderPlacement{ - { - Lots: 1, - GapFactor: 2, - }, - { - Lots: 1, - GapFactor: 3, - }, - }, - DriftTolerance: driftTolerance, - }, - rebalancer: newBalancer( - map[int][]*groupedOrder{ - 0: { - newgroupedOrder(orderIDs[0], 1, driftToleranceEdge(midGap-(breakEven*2), false), false, true), - }, - }, - map[int][]*groupedOrder{ - 1: { - newgroupedOrder(orderIDs[1], 1, driftToleranceEdge(midGap+(breakEven*3), false), true, true), - }, - }), - balances: map[uint32]uint64{ - dcrBipID: 1e13, - btcBipID: 1e13, - }, - expectedCancels: []order.OrderID{ - orderIDs[0], - orderIDs[1], - }, - expectedBuys: []rateLots{ - { - rate: midGap - (breakEven * 3), - lots: 1, - placementIndex: 1, - }, - }, - expectedSells: []rateLots{ - { - rate: midGap + (breakEven * 2), - lots: 1, - placementIndex: 0, - }, - }, - }, - // "existing orders within edge of drift tolerance" - { - name: "existing orders within edge of drift tolerance", - cfg: &BasicMarketMakingConfig{ - GapStrategy: GapStrategyMultiplier, - SellPlacements: []*OrderPlacement{ - { - Lots: 1, - GapFactor: 2, - }, - { - Lots: 1, - GapFactor: 3, - }, - }, - BuyPlacements: []*OrderPlacement{ - { - Lots: 1, - GapFactor: 2, - }, - { - Lots: 1, - GapFactor: 3, - }, - }, - DriftTolerance: driftTolerance, - }, - rebalancer: newBalancer( - map[int][]*groupedOrder{ - 0: { - newgroupedOrder(orderIDs[0], 1, driftToleranceEdge(midGap-(breakEven*2), true), false, true), - }, - }, - map[int][]*groupedOrder{ - 1: { - newgroupedOrder(orderIDs[1], 1, driftToleranceEdge(midGap+(breakEven*3), true), true, true), - }, - }), - balances: map[uint32]uint64{ - dcrBipID: 1e13, - btcBipID: 1e13, - }, - expectedBuys: []rateLots{ - { - rate: midGap - (breakEven * 3), - lots: 1, - placementIndex: 1, - }, - }, - expectedSells: []rateLots{ - { - rate: midGap + (breakEven * 2), - lots: 1, - }, - }, - }, - // "existing partially filled orders within drift tolerance" - { - name: "existing partially filled orders within drift tolerance", - cfg: &BasicMarketMakingConfig{ - GapStrategy: GapStrategyMultiplier, - BuyPlacements: []*OrderPlacement{ - { - Lots: 2, - GapFactor: 2, - }, - { - Lots: 1, - GapFactor: 3, - }, - }, - SellPlacements: []*OrderPlacement{ - { - Lots: 1, - GapFactor: 2, - }, - { - Lots: 2, - GapFactor: 3, - }, - }, - DriftTolerance: driftTolerance, - }, - rebalancer: newBalancer( - map[int][]*groupedOrder{ - 0: { - newgroupedOrder(orderIDs[0], 1, driftToleranceEdge(midGap-(breakEven*2), true), false, true), - }, - }, - map[int][]*groupedOrder{ - 1: { - newgroupedOrder(orderIDs[1], 1, driftToleranceEdge(midGap+(breakEven*3), true), true, true), - }, - }), - balances: map[uint32]uint64{ - dcrBipID: 1e13, - btcBipID: 1e13, - }, - expectedBuys: []rateLots{ - { - rate: midGap - (breakEven * 2), - lots: 1, - }, - { - rate: midGap - (breakEven * 3), - lots: 1, - placementIndex: 1, - }, - }, - expectedSells: []rateLots{ - { - rate: midGap + (breakEven * 2), - lots: 1, - }, - { - rate: midGap + (breakEven * 3), - lots: 1, - placementIndex: 1, - }, - }, - }, - // "existing partially filled orders outside drift tolerance" - { - name: "existing partially filled orders outside drift tolerance", - cfg: &BasicMarketMakingConfig{ - GapStrategy: GapStrategyMultiplier, - BuyPlacements: []*OrderPlacement{ - { - Lots: 2, - GapFactor: 2, - }, - { - Lots: 1, - GapFactor: 3, - }, - }, - SellPlacements: []*OrderPlacement{ - { - Lots: 1, - GapFactor: 2, - }, - { - Lots: 2, - GapFactor: 3, - }, - }, - DriftTolerance: driftTolerance, - }, - rebalancer: newBalancer( - map[int][]*groupedOrder{ - 0: { - newgroupedOrder(orderIDs[0], 1, driftToleranceEdge(midGap-(breakEven*2), false), false, true), - }, - }, - map[int][]*groupedOrder{ - 1: { - newgroupedOrder(orderIDs[1], 1, driftToleranceEdge(midGap+(breakEven*3), false), true, true), - }, - }), - balances: map[uint32]uint64{ - dcrBipID: 1e13, - btcBipID: 1e13, - }, - expectedBuys: []rateLots{ - { - rate: midGap - (breakEven * 2), - lots: 1, - }, - { - rate: midGap - (breakEven * 3), - lots: 1, - placementIndex: 1, - }, - }, - expectedSells: []rateLots{ - { - rate: midGap + (breakEven * 2), - lots: 1, - }, - { - rate: midGap + (breakEven * 3), - lots: 1, - placementIndex: 1, - }, - }, - expectedCancels: []order.OrderID{ - orderIDs[0], - orderIDs[1], - }, - }, - // "cannot place buy order due to self matching" - { - name: "cannot place buy order due to self matching", - cfg: &BasicMarketMakingConfig{ - GapStrategy: GapStrategyMultiplier, - SellPlacements: []*OrderPlacement{ - { - Lots: 1, - GapFactor: 2, - }, - { - Lots: 1, - GapFactor: 3, - }, - }, - BuyPlacements: []*OrderPlacement{ - { - Lots: 1, - GapFactor: 2, - }, - { - Lots: 1, - GapFactor: 3, - }, - }, - DriftTolerance: driftTolerance, - }, - rebalancer: newBalancer( - nil, - map[int][]*groupedOrder{ - 0: { - newgroupedOrder(orderIDs[1], 1, midGap-(breakEven*2)-1, true, true), - }, - }), - balances: map[uint32]uint64{ - dcrBipID: 1e13, - btcBipID: 1e13, - }, - expectedCancels: []order.OrderID{ - orderIDs[1], - }, - expectedBuys: []rateLots{ - { - rate: midGap - (breakEven * 3), - lots: 1, - placementIndex: 1, - }, - }, - expectedSells: []rateLots{ - { - rate: midGap + (breakEven * 3), - lots: 1, - placementIndex: 1, - }, - }, - }, - // "cannot place sell order due to self matching" - { - name: "cannot place sell order due to self matching", - cfg: &BasicMarketMakingConfig{ - GapStrategy: GapStrategyMultiplier, - SellPlacements: []*OrderPlacement{ - { - Lots: 1, - GapFactor: 2, - }, - { - Lots: 1, - GapFactor: 3, - }, - }, - BuyPlacements: []*OrderPlacement{ - { - Lots: 1, - GapFactor: 2, - }, - { - Lots: 1, - GapFactor: 3, - }, - }, - DriftTolerance: driftTolerance, - }, - rebalancer: newBalancer( - map[int][]*groupedOrder{ - 0: { - newgroupedOrder(orderIDs[1], 1, midGap+(breakEven*2), true, true), - }, - }, - nil), - balances: map[uint32]uint64{ - dcrBipID: 1e13, - btcBipID: 1e13, - }, - expectedCancels: []order.OrderID{ - orderIDs[1], - }, - expectedBuys: []rateLots{ - { - rate: midGap - (breakEven * 3), - lots: 1, - placementIndex: 1, - }, - }, - expectedSells: []rateLots{ - { - rate: midGap + (breakEven * 3), - lots: 1, - placementIndex: 1, - }, - }, - }, - // "cannot place buy order due to self matching, can't cancel because too soon" - { - name: "cannot place buy order due to self matching, can't cancel because too soon", - cfg: &BasicMarketMakingConfig{ - GapStrategy: GapStrategyMultiplier, - SellPlacements: []*OrderPlacement{ - { - Lots: 1, - GapFactor: 2, - }, - { - Lots: 1, - GapFactor: 3, - }, - }, - BuyPlacements: []*OrderPlacement{ - { - Lots: 1, - GapFactor: 2, - }, - { - Lots: 1, - GapFactor: 3, - }, - }, - DriftTolerance: driftTolerance, - }, - rebalancer: newBalancer( - nil, - map[int][]*groupedOrder{ - 0: { - newgroupedOrder(orderIDs[1], 1, midGap-(breakEven*2)-1, true, false), - }, - }), - balances: map[uint32]uint64{ - dcrBipID: 1e13, - btcBipID: 1e13, - }, - expectedCancels: []order.OrderID{}, - expectedBuys: []rateLots{ - { - rate: midGap - (breakEven * 3), - lots: 1, - placementIndex: 1, - }, - }, - expectedSells: []rateLots{ - { - rate: midGap + (breakEven * 3), - lots: 1, - placementIndex: 1, - }, - }, - }, - // "cannot place sell order due to self matching, can't cancel because too soon" - { - name: "cannot place sell order due to self matching, can't cancel because too soon", - cfg: &BasicMarketMakingConfig{ - GapStrategy: GapStrategyMultiplier, - SellPlacements: []*OrderPlacement{ - { - Lots: 1, - GapFactor: 2, - }, - { - Lots: 1, - GapFactor: 3, - }, - }, - BuyPlacements: []*OrderPlacement{ - { - Lots: 1, - GapFactor: 2, - }, - { - Lots: 1, - GapFactor: 3, - }, - }, - DriftTolerance: driftTolerance, - }, - rebalancer: newBalancer( - map[int][]*groupedOrder{ - 0: { - newgroupedOrder(orderIDs[1], 1, midGap+(breakEven*2), true, false), - }, - }, - nil), - balances: map[uint32]uint64{ - dcrBipID: 1e13, - btcBipID: 1e13, - }, - expectedCancels: []order.OrderID{}, - expectedBuys: []rateLots{ - { - rate: midGap - (breakEven * 3), - lots: 1, - placementIndex: 1, - }, - }, - expectedSells: []rateLots{ - { - rate: midGap + (breakEven * 3), - lots: 1, - placementIndex: 1, - }, - }, - }, - } - - for _, tt := range tests { - if tt.isAccountLocker != nil { - tCore.isAccountLocker = tt.isAccountLocker - } else { - tCore.isAccountLocker = map[uint32]bool{} - } - - tCore.setAssetBalances(tt.balances) - - epoch := tt.epoch - if epoch == 0 { - epoch = newEpoch - } - - cancels, buys, sells := basicMMRebalance(epoch, tt.rebalancer, newTBotCoreAdaptor(tCore), tt.cfg, mkt, buyFees, sellFees, log) - - if len(cancels) != len(tt.expectedCancels) { - t.Fatalf("%s: cancel count mismatch. expected %d, got %d", tt.name, len(tt.expectedCancels), len(cancels)) - } - - expectedCancels := make(map[order.OrderID]bool) - for _, id := range tt.expectedCancels { - expectedCancels[id] = true - } - for _, cancel := range cancels { - var id order.OrderID - copy(id[:], cancel) - if !expectedCancels[id] { - t.Fatalf("%s: unexpected cancel order ID %s", tt.name, id) - } - } - - if len(buys) != len(tt.expectedBuys) { - t.Fatalf("%s: buy count mismatch. expected %d, got %d", tt.name, len(tt.expectedBuys), len(buys)) - } - if len(sells) != len(tt.expectedSells) { - t.Fatalf("%s: sell count mismatch. expected %d, got %d", tt.name, len(tt.expectedSells), len(sells)) - } - - for i, buy := range buys { - if buy.rate != tt.expectedBuys[i].rate { - t.Fatalf("%s: buy rate mismatch. expected %d, got %d", tt.name, tt.expectedBuys[i].rate, buy.rate) - } - if buy.lots != tt.expectedBuys[i].lots { - t.Fatalf("%s: buy lots mismatch. expected %d, got %d", tt.name, tt.expectedBuys[i].lots, buy.lots) - } - if buy.placementIndex != tt.expectedBuys[i].placementIndex { - t.Fatalf("%s: buy placement index mismatch. expected %d, got %d", tt.name, tt.expectedBuys[i].placementIndex, buy.placementIndex) - } - } - - for i, sell := range sells { - if sell.rate != tt.expectedSells[i].rate { - t.Fatalf("%s: sell rate mismatch. expected %d, got %d", tt.name, tt.expectedSells[i].rate, sell.rate) - } - if sell.lots != tt.expectedSells[i].lots { - t.Fatalf("%s: sell lots mismatch. expected %d, got %d", tt.name, tt.expectedSells[i].lots, sell.lots) - } - if sell.placementIndex != tt.expectedSells[i].placementIndex { - t.Fatalf("%s: sell placement index mismatch. expected %d, got %d", tt.name, tt.expectedSells[i].placementIndex, sell.placementIndex) - } - } - } +func (r *tBasicMMCalculator) halfSpread(basisPrice uint64) (uint64, error) { + return r.hs, nil } func TestBasisPrice(t *testing.T) { @@ -1110,8 +34,6 @@ func TestBasisPrice(t *testing.T) { AtomToConv: 1, } - log := dex.StdOutLogger("T", dex.LevelTrace) - tests := []*struct { name string midGap uint64 @@ -1184,7 +106,21 @@ func TestBasisPrice(t *testing.T) { OracleWeighting: &tt.oracleWeight, OracleBias: tt.oracleBias, } - rate := basisPrice(ob, oracle, cfg, mkt, tt.fiatRate, log) + + tCore := newTCore() + adaptor := newTBotCoreAdaptor(tCore) + adaptor.fiatExchangeRate = tt.fiatRate + + calculator := &basicMMCalculatorImpl{ + book: ob, + oracle: oracle, + mkt: mkt, + cfg: cfg, + log: tLogger, + core: adaptor, + } + + rate := calculator.basisPrice() if rate != tt.exp { t.Fatalf("%s: %d != %d", tt.name, rate, tt.exp) } @@ -1192,183 +128,258 @@ func TestBasisPrice(t *testing.T) { } func TestBreakEvenHalfSpread(t *testing.T) { - mkt := &core.Market{ - LotSize: 20e8, // 20 DCR - BaseID: dcrBipID, - QuoteID: btcBipID, - AtomToConv: 1, - } - - tCore := newTCore() - log := dex.StdOutLogger("T", dex.LevelTrace) - tests := []*struct { - name string - basisPrice uint64 - sellSwapFees uint64 - sellRedeemFees uint64 - buySwapFees uint64 - buyRedeemFees uint64 - exp uint64 - singleLotFeesErr error - expErr bool + name string + basisPrice uint64 + mkt *core.Market + buyFeesInBaseUnits uint64 + sellFeesInBaseUnits uint64 + buyFeesInQuoteUnits uint64 + sellFeesInQuoteUnits uint64 + singleLotFeesErr error + expErr bool }{ { name: "basis price = 0 not allowed", expErr: true, + mkt: &core.Market{ + LotSize: 20e8, + BaseID: 42, + QuoteID: 0, + }, }, { - name: "swap fees error propagates", - singleLotFeesErr: errors.New("t"), - expErr: true, + name: "dcr/btc", + basisPrice: 5e7, // 0.4 BTC/DCR, quote lot = 8 BTC + mkt: &core.Market{ + LotSize: 20e8, + BaseID: 42, + QuoteID: 0, + }, + buyFeesInBaseUnits: 2.2e6, + sellFeesInBaseUnits: 2e6, + buyFeesInQuoteUnits: calc.BaseToQuote(2.2e6, 5e7), + sellFeesInQuoteUnits: calc.BaseToQuote(2e6, 5e7), }, { - name: "simple", - basisPrice: 4e7, // 0.4 BTC/DCR, quote lot = 8 BTC - buySwapFees: 200, // BTC - buyRedeemFees: 100, // DCR - sellSwapFees: 300, // DCR - sellRedeemFees: 50, // BTC - // total btc (quote) fees, Q = 250 - // total dcr (base) fees, B = 400 - // g = (pB + Q) / (B + 2L) - // g = (0.4*400 + 250) / (400 + 40e8) = 1.02e-7 // atomic units - // g = 10 // msg-rate units - exp: 10, + name: "btc/usdc.eth", + basisPrice: calc.MessageRateAlt(43000, 1e8, 1e6), + mkt: &core.Market{ + BaseID: 0, + QuoteID: 60001, + LotSize: 1e7, + }, + buyFeesInBaseUnits: 1e6, + sellFeesInBaseUnits: 2e6, + buyFeesInQuoteUnits: calc.BaseToQuote(calc.MessageRateAlt(43000, 1e8, 1e6), 1e6), + sellFeesInQuoteUnits: calc.BaseToQuote(calc.MessageRateAlt(43000, 1e8, 1e6), 2e6), }, } for _, tt := range tests { - tCore.sellSwapFees = tt.sellSwapFees - tCore.sellRedeemFees = tt.sellRedeemFees - tCore.buySwapFees = tt.buySwapFees - tCore.buyRedeemFees = tt.buyRedeemFees - tCore.singleLotFeesErr = tt.singleLotFeesErr - - basicMM := &basicMarketMaker{ - core: newTBotCoreAdaptor(tCore), - mkt: mkt, - log: log, + tCore := newTCore() + coreAdaptor := newTBotCoreAdaptor(tCore) + coreAdaptor.buyFeesInBase = tt.buyFeesInBaseUnits + coreAdaptor.sellFeesInBase = tt.sellFeesInBaseUnits + coreAdaptor.buyFeesInQuote = tt.buyFeesInQuoteUnits + coreAdaptor.sellFeesInQuote = tt.sellFeesInQuoteUnits + + calculator := &basicMMCalculatorImpl{ + core: coreAdaptor, + mkt: tt.mkt, + log: tLogger, } - halfSpread, err := basicMM.halfSpread(tt.basisPrice) + halfSpread, err := calculator.halfSpread(tt.basisPrice) if (err != nil) != tt.expErr { t.Fatalf("expErr = %t, err = %v", tt.expErr, err) } - if halfSpread != tt.exp { - t.Fatalf("%s: %d != %d", tt.name, halfSpread, tt.exp) + if tt.expErr { + continue } + + afterSell := calc.BaseToQuote(tt.basisPrice+halfSpread, tt.mkt.LotSize) + afterBuy := calc.QuoteToBase(tt.basisPrice-halfSpread, afterSell) + fees := afterBuy - tt.mkt.LotSize + expectedFees := tt.buyFeesInBaseUnits + tt.sellFeesInBaseUnits + + if expectedFees > fees*10001/10000 || expectedFees < fees*9999/10000 { + t.Fatalf("%s: expected fees %d, got %d", tt.name, expectedFees, fees) + } + } } -func TestGroupedOrders(t *testing.T) { +func TestBasicMMRebalance(t *testing.T) { + const basisPrice uint64 = 5e6 + const halfSpread uint64 = 2e5 const rateStep uint64 = 1e3 - const lotSize uint64 = 50e8 - mkt := &core.Market{ - RateStep: rateStep, - AtomToConv: 1, - LotSize: lotSize, - BaseID: dcrBipID, - QuoteID: btcBipID, - } + const atomToConv float64 = 1 - orderIDs := make([]order.OrderID, 5) - for i := range orderIDs { - copy(orderIDs[i][:], encode.RandomBytes(32)) + calculator := &tBasicMMCalculator{ + bp: basisPrice, + hs: halfSpread, } - orders := map[order.OrderID]*core.Order{ - orderIDs[0]: { - ID: orderIDs[0][:], - Sell: true, - Rate: 100e8, - Qty: 2 * lotSize, - Filled: lotSize, + type test struct { + name string + strategy GapStrategy + cfgBuyPlacements []*OrderPlacement + cfgSellPlacements []*OrderPlacement + + expBuyPlacements []*multiTradePlacement + expSellPlacements []*multiTradePlacement + } + tests := []*test{ + { + name: "multiplier", + strategy: GapStrategyMultiplier, + cfgBuyPlacements: []*OrderPlacement{ + {Lots: 1, GapFactor: 3}, + {Lots: 2, GapFactor: 2}, + {Lots: 3, GapFactor: 1}, + }, + cfgSellPlacements: []*OrderPlacement{ + {Lots: 3, GapFactor: 1}, + {Lots: 2, GapFactor: 2}, + {Lots: 1, GapFactor: 3}, + }, + expBuyPlacements: []*multiTradePlacement{ + {lots: 1, rate: steppedRate(basisPrice-3*halfSpread, rateStep)}, + {lots: 2, rate: steppedRate(basisPrice-2*halfSpread, rateStep)}, + {lots: 3, rate: steppedRate(basisPrice-1*halfSpread, rateStep)}, + }, + expSellPlacements: []*multiTradePlacement{ + {lots: 3, rate: steppedRate(basisPrice+1*halfSpread, rateStep)}, + {lots: 2, rate: steppedRate(basisPrice+2*halfSpread, rateStep)}, + {lots: 1, rate: steppedRate(basisPrice+3*halfSpread, rateStep)}, + }, }, - orderIDs[1]: { - ID: orderIDs[1][:], - Sell: false, - Rate: 200e8, - Qty: 2 * lotSize, - Filled: lotSize, + { + name: "percent", + strategy: GapStrategyPercent, + cfgBuyPlacements: []*OrderPlacement{ + {Lots: 1, GapFactor: 0.05}, + {Lots: 2, GapFactor: 0.1}, + {Lots: 3, GapFactor: 0.15}, + }, + cfgSellPlacements: []*OrderPlacement{ + {Lots: 3, GapFactor: 0.15}, + {Lots: 2, GapFactor: 0.1}, + {Lots: 1, GapFactor: 0.05}, + }, + expBuyPlacements: []*multiTradePlacement{ + {lots: 1, rate: steppedRate(basisPrice-uint64(math.Round((float64(basisPrice)*0.05))), rateStep)}, + {lots: 2, rate: steppedRate(basisPrice-uint64(math.Round((float64(basisPrice)*0.1))), rateStep)}, + {lots: 3, rate: steppedRate(basisPrice-uint64(math.Round((float64(basisPrice)*0.15))), rateStep)}, + }, + expSellPlacements: []*multiTradePlacement{ + {lots: 3, rate: steppedRate(basisPrice+uint64(math.Round((float64(basisPrice)*0.15))), rateStep)}, + {lots: 2, rate: steppedRate(basisPrice+uint64(math.Round((float64(basisPrice)*0.1))), rateStep)}, + {lots: 1, rate: steppedRate(basisPrice+uint64(math.Round((float64(basisPrice)*0.05))), rateStep)}, + }, }, - orderIDs[2]: { - ID: orderIDs[2][:], - Sell: true, - Rate: 300e8, - Qty: 2 * lotSize, + { + name: "percent-plus", + strategy: GapStrategyPercentPlus, + cfgBuyPlacements: []*OrderPlacement{ + {Lots: 1, GapFactor: 0.05}, + {Lots: 2, GapFactor: 0.1}, + {Lots: 3, GapFactor: 0.15}, + }, + cfgSellPlacements: []*OrderPlacement{ + {Lots: 3, GapFactor: 0.15}, + {Lots: 2, GapFactor: 0.1}, + {Lots: 1, GapFactor: 0.05}, + }, + expBuyPlacements: []*multiTradePlacement{ + {lots: 1, rate: steppedRate(basisPrice-halfSpread-uint64(math.Round((float64(basisPrice)*0.05))), rateStep)}, + {lots: 2, rate: steppedRate(basisPrice-halfSpread-uint64(math.Round((float64(basisPrice)*0.1))), rateStep)}, + {lots: 3, rate: steppedRate(basisPrice-halfSpread-uint64(math.Round((float64(basisPrice)*0.15))), rateStep)}, + }, + expSellPlacements: []*multiTradePlacement{ + {lots: 3, rate: steppedRate(basisPrice+halfSpread+uint64(math.Round((float64(basisPrice)*0.15))), rateStep)}, + {lots: 2, rate: steppedRate(basisPrice+halfSpread+uint64(math.Round((float64(basisPrice)*0.1))), rateStep)}, + {lots: 1, rate: steppedRate(basisPrice+halfSpread+uint64(math.Round((float64(basisPrice)*0.05))), rateStep)}, + }, }, - orderIDs[3]: { - ID: orderIDs[3][:], - Sell: false, - Rate: 400e8, - Qty: 1 * lotSize, + { + name: "absolute", + strategy: GapStrategyAbsolute, + cfgBuyPlacements: []*OrderPlacement{ + {Lots: 1, GapFactor: .01}, + {Lots: 2, GapFactor: .03}, + {Lots: 3, GapFactor: .06}, + }, + cfgSellPlacements: []*OrderPlacement{ + {Lots: 3, GapFactor: .06}, + {Lots: 2, GapFactor: .03}, + {Lots: 1, GapFactor: .01}, + }, + expBuyPlacements: []*multiTradePlacement{ + {lots: 1, rate: steppedRate(basisPrice-1e6, rateStep)}, + {lots: 2, rate: steppedRate(basisPrice-3e6, rateStep)}, + {lots: 0, rate: 0}, // 5e6 - 6e6 < 0 + }, + expSellPlacements: []*multiTradePlacement{ + {lots: 3, rate: steppedRate(basisPrice+6e6, rateStep)}, + {lots: 2, rate: steppedRate(basisPrice+3e6, rateStep)}, + {lots: 1, rate: steppedRate(basisPrice+1e6, rateStep)}, + }, }, - orderIDs[4]: { - ID: orderIDs[4][:], - Sell: false, - Rate: 402e8, - Qty: 1 * lotSize, + { + name: "absolute-plus", + strategy: GapStrategyAbsolutePlus, + cfgBuyPlacements: []*OrderPlacement{ + {Lots: 1, GapFactor: .01}, + {Lots: 2, GapFactor: .03}, + {Lots: 3, GapFactor: .06}, + }, + cfgSellPlacements: []*OrderPlacement{ + {Lots: 3, GapFactor: .06}, + {Lots: 2, GapFactor: .03}, + {Lots: 1, GapFactor: .01}, + }, + expBuyPlacements: []*multiTradePlacement{ + {lots: 1, rate: steppedRate(basisPrice-halfSpread-1e6, rateStep)}, + {lots: 2, rate: steppedRate(basisPrice-halfSpread-3e6, rateStep)}, + {lots: 0, rate: 0}, + }, + expSellPlacements: []*multiTradePlacement{ + {lots: 3, rate: steppedRate(basisPrice+halfSpread+6e6, rateStep)}, + {lots: 2, rate: steppedRate(basisPrice+halfSpread+3e6, rateStep)}, + {lots: 1, rate: steppedRate(basisPrice+halfSpread+1e6, rateStep)}, + }, }, } - ordToPlacementIndex := map[order.OrderID]int{ - orderIDs[0]: 0, - orderIDs[1]: 0, - orderIDs[2]: 1, - orderIDs[3]: 1, - orderIDs[4]: 1, - } - - mm := &basicMarketMaker{ - mkt: mkt, - ords: orders, - oidToPlacement: ordToPlacementIndex, - } - - expectedBuys := map[int][]*groupedOrder{ - 0: {{ - id: orderIDs[1], - rate: 200e8, - lots: 1, - }}, - 1: {{ - id: orderIDs[3], - rate: 400e8, - lots: 1, - }, { - id: orderIDs[4], - rate: 402e8, - lots: 1, - }}, - } - - expectedSells := map[int][]*groupedOrder{ - 0: {{ - id: orderIDs[0], - rate: 100e8, - lots: 1, - }}, - 1: {{ - id: orderIDs[2], - rate: 300e8, - lots: 2, - }}, - } - - buys, sells := mm.groupedOrders() + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + adaptor := newTBotCoreAdaptor(newTCore()) + cfg := &BasicMarketMakingConfig{ + GapStrategy: tt.strategy, + BuyPlacements: tt.cfgBuyPlacements, + SellPlacements: tt.cfgSellPlacements, + } + mm := &basicMarketMaker{ + cfg: cfg, + calculator: calculator, + core: adaptor, + log: tLogger, + mkt: &core.Market{ + RateStep: rateStep, + AtomToConv: atomToConv, + }, + } - for i, buy := range buys { - sort.Slice(buy, func(i, j int) bool { - return buy[i].rate < buy[j].rate - }) - reflect.DeepEqual(buy, expectedBuys[i]) - } + mm.rebalance(100) - for i, sell := range sells { - sort.Slice(sell, func(i, j int) bool { - return sell[i].rate < sell[j].rate + if !reflect.DeepEqual(tt.expBuyPlacements, adaptor.lastMultiTradeBuys) { + t.Fatal(spew.Sprintf("expected buy placements:\n%#+v\ngot:\n%#+v", tt.expBuyPlacements, adaptor.lastMultiTradeBuys)) + } + if !reflect.DeepEqual(tt.expSellPlacements, adaptor.lastMultiTradeSells) { + t.Fatal(spew.Sprintf("expected sell placements:\n%#+v\ngot:\n%#+v", tt.expSellPlacements, adaptor.lastMultiTradeSells)) + } }) - reflect.DeepEqual(sell, expectedSells[i]) } } diff --git a/client/mm/mm_simple_arb.go b/client/mm/mm_simple_arb.go index a28bbd05c2..eb40a189be 100644 --- a/client/mm/mm_simple_arb.go +++ b/client/mm/mm_simple_arb.go @@ -32,14 +32,6 @@ type SimpleArbConfig struct { // NumEpochsLeaveOpen is the number of epochs an arbitrage sequence will // stay open if one or both of the orders were not filled. NumEpochsLeaveOpen uint32 `json:"numEpochsLeaveOpen"` - // BaseOptions are the multi-order options for the base asset wallet. - BaseOptions map[string]string `json:"baseOptions"` - // QuoteOptions are the multi-order options for the quote asset wallet. - QuoteOptions map[string]string `json:"quoteOptions"` - // AutoRebalance determines how the bot will handle rebalancing of the - // assets between the dex and the cex. If nil, no rebalancing will take - // place. - AutoRebalance *AutoRebalanceConfig `json:"autoRebalance"` } func (c *SimpleArbConfig) Validate() error { @@ -85,257 +77,110 @@ type simpleArbMarketMaker struct { 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 - var minTransferAmount uint64 - if base { - assetID = a.baseID - minAmount = a.cfg.AutoRebalance.MinBaseAmt - minTransferAmount = a.cfg.AutoRebalance.MinBaseTransfer - } else { - assetID = a.quoteID - minAmount = a.cfg.AutoRebalance.MinQuoteAmt - minTransferAmount = a.cfg.AutoRebalance.MinQuoteTransfer - } - symbol := dex.BipIDSymbol(assetID) - - dexBalance, err := a.core.DEXBalance(assetID) - if err != nil { - a.log.Errorf("Error getting %s balance: %v", symbol, err) - return - } - - cexBalance, err := a.cex.CEXBalance(assetID) - if err != nil { - a.log.Errorf("Error getting %s balance on cex: %v", symbol, err) - return - } - - if (dexBalance.Available+cexBalance.Available)/2 < minAmount { - a.log.Warnf("Cannot rebalance %s because balance is too low on both DEX and CEX. Min amount: %v, CEX balance: %v, DEX Balance: %v", - symbol, minAmount, dexBalance.Available, cexBalance.Available) - 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 - if amt < minTransferAmount { - a.log.Warnf("Amount required to rebalance %s (%d) is less than the min transfer amount %v", - symbol, amt, minTransferAmount) - return - } - 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 - if amt < minTransferAmount { - a.log.Warnf("Amount required to rebalance %s (%d) is less than the min transfer amount %v", - symbol, amt, minTransferAmount) - return - } - 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, -// and if so, executes trades to capitalize on it. -func (a *simpleArbMarketMaker) rebalance(newEpoch uint64) { - if !a.rebalanceRunning.CompareAndSwap(false, true) { - return - } - defer a.rebalanceRunning.Store(false) - a.log.Tracef("rebalance: epoch %d", newEpoch) - - exists, sellOnDex, lotsToArb, dexRate, cexRate := a.arbExists() - if exists { - // Execution will not happen if it would cause a self-match. - a.executeArb(sellOnDex, lotsToArb, dexRate, cexRate, newEpoch) - } - - a.activeArbsMtx.Lock() - defer a.activeArbsMtx.Unlock() - - remainingArbs := make([]*arbSequence, 0, len(a.activeArbs)) - for _, arb := range a.activeArbs { - expired := newEpoch-arb.startEpoch > uint64(a.cfg.NumEpochsLeaveOpen) - oppositeDirectionArbFound := exists && sellOnDex != arb.sellOnDEX - - if expired || oppositeDirectionArbFound { - a.cancelArbSequence(arb) - } else { - remainingArbs = append(remainingArbs, arb) - } - } - - if a.cfg.AutoRebalance != nil && 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.CEXBalance(a.baseID) - if err != nil { - a.log.Errorf("failed to get cex balance for %v: %v", a.baseID, err) - return false, false, 0, 0, 0 - } - - cexQuoteBalance, err := a.cex.CEXBalance(a.quoteID) - if err != nil { - a.log.Errorf("failed to get cex balance for %v: %v", a.quoteID, err) - return false, false, 0, 0, 0 - } - sellOnDex = false - exists, lotsToArb, dexRate, cexRate = a.arbExistsOnSide(sellOnDex, cexBaseBalance.Available, cexQuoteBalance.Available) + exists, lotsToArb, dexRate, cexRate = a.arbExistsOnSide(sellOnDex) if exists { return } sellOnDex = true - exists, lotsToArb, dexRate, cexRate = a.arbExistsOnSide(sellOnDex, cexBaseBalance.Available, cexQuoteBalance.Available) + exists, lotsToArb, dexRate, cexRate = a.arbExistsOnSide(sellOnDex) return } // arbExistsOnSide checks if an arbitrage opportunity exists either when // buying or selling on the dex. -func (a *simpleArbMarketMaker) arbExistsOnSide(sellOnDEX bool, cexBaseBalance, cexQuoteBalance uint64) (exists bool, lotsToArb, dexRate, cexRate uint64) { +func (a *simpleArbMarketMaker) arbExistsOnSide(sellOnDEX bool) (exists bool, lotsToArb, dexRate, cexRate uint64) { noArb := func() (bool, uint64, uint64, uint64) { return false, 0, 0, 0 } lotSize := a.mkt.LotSize + var prevProfit uint64 - // maxLots is the max amount of lots of the base asset that can be traded - // on the exchange where the base asset is being sold. - var maxLots uint64 - if sellOnDEX { - maxOrder, err := a.core.MaxSell(a.host, a.baseID, a.quoteID) - if err != nil { - a.log.Errorf("MaxSell error: %v", err) - return noArb() - } - maxLots = maxOrder.Swap.Lots - } else { - maxLots = cexBaseBalance / lotSize - } - if maxLots == 0 { - return noArb() - } - - for numLots := uint64(1); numLots <= maxLots; numLots++ { + for numLots := uint64(1); ; numLots++ { dexAvg, dexExtrema, dexFilled, err := a.book.VWAP(numLots, a.mkt.LotSize, !sellOnDEX) if err != nil { a.log.Errorf("error calculating dex VWAP: %v", err) - return noArb() + break } if !dexFilled { break } - // If buying on dex, check that we have enough to buy at this rate. - if !sellOnDEX { - maxBuy, err := a.core.MaxBuy(a.host, a.baseID, a.quoteID, dexExtrema) - if err != nil { - a.log.Errorf("maxBuy error: %v") - return noArb() - } - if maxBuy.Swap.Lots < numLots { - break - } - } 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 + break } if !cexFilled { break } - // If buying on cex, make sure we have enough to buy at this rate - amountNeeded := calc.BaseToQuote(cexExtrema, numLots*lotSize) - if sellOnDEX && (amountNeeded > cexQuoteBalance) { + var buyRate, sellRate, buyAvg, sellAvg uint64 + if sellOnDEX { + buyRate = cexExtrema + sellRate = dexExtrema + buyAvg = cexAvg + sellAvg = dexAvg + } else { + buyRate = dexExtrema + sellRate = cexExtrema + buyAvg = dexAvg + sellAvg = cexAvg + } + if buyRate >= sellRate { break } - var priceRatio float64 - if sellOnDEX { - priceRatio = float64(dexAvg) / float64(cexAvg) - } else { - priceRatio = float64(cexAvg) / float64(dexAvg) + enough, err := a.core.SufficientBalanceForDEXTrade(dexExtrema, numLots*lotSize, sellOnDEX) + if err != nil { + a.log.Errorf("error checking sufficient balance: %v", err) + break + } + if !enough { + break } - // Even if the average price ratio is > profit trigger, we still need - // check if the current lot is profitable. - var currLotProfitable bool - if sellOnDEX { - currLotProfitable = dexExtrema > cexExtrema - } else { - currLotProfitable = cexExtrema > dexExtrema + enough, err = a.cex.SufficientBalanceForCEXTrade(a.baseID, a.quoteID, !sellOnDEX, cexExtrema, numLots*lotSize) + if err != nil { + a.log.Errorf("error checking sufficient balance: %v", err) + break + } + if !enough { + break } - if priceRatio > (1+a.cfg.ProfitTrigger) && currLotProfitable { - lotsToArb = numLots - dexRate = dexExtrema - cexRate = cexExtrema - } else { + feesInQuoteUnits, err := a.core.OrderFeesInUnits(sellOnDEX, false, dexAvg) + if err != nil { + a.log.Errorf("error calculating fees: %v", err) break } + + qty := numLots * lotSize + quoteForBuy := calc.BaseToQuote(buyAvg, qty) + quoteFromSell := calc.BaseToQuote(sellAvg, qty) + if quoteFromSell-quoteForBuy <= feesInQuoteUnits { + break + } + profitInQuote := quoteFromSell - quoteForBuy - feesInQuoteUnits + profitInBase := calc.QuoteToBase((buyRate+sellRate)/2, profitInQuote) + if profitInBase < prevProfit || float64(profitInBase)/float64(qty) < a.cfg.ProfitTrigger { + break + } + + prevProfit = profitInBase + lotsToArb = numLots + dexRate = dexExtrema + cexRate = cexExtrema } if lotsToArb > 0 { - a.log.Infof("arb opportunity - sellOnDex: %v, lotsToArb: %v, dexRate: %v, cexRate: %v", sellOnDEX, lotsToArb, dexRate, cexRate) + a.log.Infof("arb opportunity - sellOnDex: %v, lotsToArb: %v, dexRate: %v, cexRate: %v: profit: %d", sellOnDEX, lotsToArb, dexRate, cexRate, prevProfit) return true, lotsToArb, dexRate, cexRate } @@ -370,39 +215,17 @@ 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. - cexTrade, err := a.cex.Trade(a.ctx, a.baseID, a.quoteID, !sellOnDex, cexRate, lotsToArb*a.mkt.LotSize) + cexTrade, err := a.cex.CEXTrade(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 } - var options map[string]string - if sellOnDex { - options = a.cfg.BaseOptions - } else { - options = a.cfg.QuoteOptions - } - - dexOrders, err := a.core.MultiTrade(nil, &core.MultiTradeForm{ - Host: a.host, - Sell: sellOnDex, - Base: a.baseID, - Quote: a.quoteID, - Placements: []*core.QtyRate{ - { - Qty: lotsToArb * a.mkt.LotSize, - Rate: dexRate, - }, - }, - Options: options, - }) - if err != nil || len(dexOrders) != 1 { + dexOrder, err := a.core.DEXTrade(dexRate, lotsToArb*a.mkt.LotSize, sellOnDex) + if err != nil { if err != nil { a.log.Errorf("error placing dex order: %v", err) } - if len(dexOrders) != 1 { - a.log.Errorf("expected 1 dex order, got %v", len(dexOrders)) - } err := a.cex.CancelTrade(a.ctx, a.baseID, a.quoteID, cexTrade.ID) if err != nil { @@ -413,7 +236,7 @@ func (a *simpleArbMarketMaker) executeArb(sellOnDex bool, lotsToArb, dexRate, ce } a.activeArbs = append(a.activeArbs, &arbSequence{ - dexOrder: dexOrders[0], + dexOrder: dexOrder, dexRate: dexRate, cexOrderID: cexTrade.ID, cexRate: cexRate, @@ -527,14 +350,78 @@ func (a *simpleArbMarketMaker) handleDEXOrderUpdate(o *core.Order) { } } -func (m *simpleArbMarketMaker) handleNotification(note core.Notification) { - switch n := note.(type) { - case *core.OrderNote: - ord := n.Order - if ord == nil { - return +func (a *simpleArbMarketMaker) cancelAllOrders() { + a.activeArbsMtx.Lock() + defer a.activeArbsMtx.Unlock() + + for _, arb := range a.activeArbs { + a.cancelArbSequence(arb) + } +} + +// rebalance checks if there is an arbitrage opportunity between the dex and cex, +// and if so, executes trades to capitalize on it. +func (a *simpleArbMarketMaker) rebalance(newEpoch uint64) { + if !a.rebalanceRunning.CompareAndSwap(false, true) { + return + } + defer a.rebalanceRunning.Store(false) + a.log.Tracef("rebalance: epoch %d", newEpoch) + + exists, sellOnDex, lotsToArb, dexRate, cexRate := a.arbExists() + if exists { + // Execution will not happen if it would cause a self-match. + a.executeArb(sellOnDex, lotsToArb, dexRate, cexRate, newEpoch) + } + + a.activeArbsMtx.Lock() + remainingArbs := make([]*arbSequence, 0, len(a.activeArbs)) + for _, arb := range a.activeArbs { + expired := newEpoch-arb.startEpoch > uint64(a.cfg.NumEpochsLeaveOpen) + oppositeDirectionArbFound := exists && sellOnDex != arb.sellOnDEX + + if expired || oppositeDirectionArbFound { + a.cancelArbSequence(arb) + } else { + remainingArbs = append(remainingArbs, arb) + } + } + a.activeArbs = remainingArbs + attemptRebalance := len(a.activeArbs) == 0 + a.activeArbsMtx.Unlock() + + if !attemptRebalance { + return + } + + // There will be no reserves, as this will only be called when there + // are no active orders. + rebalanceBase, _, _ := a.cex.PrepareRebalance(a.ctx, a.baseID) + if rebalanceBase > 0 { + err := a.cex.Deposit(a.ctx, a.baseID, uint64(rebalanceBase)) + if err != nil { + a.log.Errorf("error depositing base asset: %v", err) + } + } + if rebalanceBase < 0 { + err := a.cex.Withdraw(a.ctx, a.baseID, uint64(-rebalanceBase)) + if err != nil { + a.log.Errorf("error withdrawing base asset: %v", err) + } + } + + rebalanceQuote, _, _ := a.cex.PrepareRebalance(a.ctx, a.quoteID) + if rebalanceQuote > 0 { + err := a.cex.Deposit(a.ctx, a.quoteID, uint64(rebalanceQuote)) + if err != nil { + a.log.Errorf("error depositing quote asset: %v", err) + } + } + if rebalanceQuote < 0 { + err := a.cex.Withdraw(a.ctx, a.quoteID, uint64(-rebalanceQuote)) + if err != nil { + a.log.Errorf("error withdrawing quote asset: %v", err) } - m.handleDEXOrderUpdate(ord) } } @@ -586,16 +473,14 @@ func (a *simpleArbMarketMaker) run() { } }() - noteFeed := a.core.NotificationFeed() - wg.Add(1) go func() { defer wg.Done() - defer noteFeed.ReturnFeed() + orderUpdates := a.core.SubscribeOrderUpdates() for { select { - case n := <-noteFeed.C: - a.handleNotification(n) + case n := <-orderUpdates: + a.handleDEXOrderUpdate(n) case <-a.ctx.Done(): return } @@ -607,15 +492,6 @@ func (a *simpleArbMarketMaker) run() { a.cancelAllOrders() } -func (a *simpleArbMarketMaker) cancelAllOrders() { - a.activeArbsMtx.Lock() - defer a.activeArbsMtx.Unlock() - - for _, arb := range a.activeArbs { - a.cancelArbSequence(arb) - } -} - func RunSimpleArbBot(ctx context.Context, cfg *BotConfig, c botCoreAdaptor, cex botCexAdaptor, log dex.Logger) { if cfg.SimpleArbConfig == nil { // implies bug in caller diff --git a/client/mm/mm_simple_arb_test.go b/client/mm/mm_simple_arb_test.go index b1a989078c..d3eabd6ac0 100644 --- a/client/mm/mm_simple_arb_test.go +++ b/client/mm/mm_simple_arb_test.go @@ -5,9 +5,9 @@ import ( "context" "errors" "fmt" + "math" "testing" - "decred.org/dcrdex/client/asset" "decred.org/dcrdex/client/core" "decred.org/dcrdex/client/mm/libxc" "decred.org/dcrdex/dex" @@ -16,12 +16,10 @@ import ( "decred.org/dcrdex/dex/order" ) -var log = dex.StdOutLogger("T", dex.LevelTrace) - func TestArbRebalance(t *testing.T) { - mkt := &core.Market{ - LotSize: uint64(40 * 1e8), - } + lotSize := uint64(40e8) + baseID := uint32(42) + quoteID := uint32(0) orderIDs := make([]order.OrderID, 5) for i := 0; i < 5; i++ { @@ -35,10 +33,25 @@ func TestArbRebalance(t *testing.T) { log := dex.StdOutLogger("T", dex.LevelTrace) - var currEpoch uint64 = 100 - var numEpochsLeaveOpen uint32 = 10 - var maxActiveArbs uint32 = 5 - var profitTrigger float64 = 0.01 + const currEpoch uint64 = 100 + const numEpochsLeaveOpen uint32 = 10 + const maxActiveArbs uint32 = 5 + const profitTrigger float64 = 0.01 + const feesInQuoteUnits uint64 = 5e5 + const rateStep = 1e5 + + edgeSellRate := func(buyRate, qty uint64, profitable bool) uint64 { + quoteToBuy := calc.BaseToQuote(buyRate, qty) + reqFromSell := quoteToBuy + feesInQuoteUnits + calc.BaseToQuote(buyRate, uint64(float64(qty)*profitTrigger)) + sellRate := calc.QuoteToBase(qty, reqFromSell) // quote * 1e8 / base = sellRate + var steps float64 + if profitable { + steps = math.Ceil(float64(sellRate) / float64(rateStep)) + } else { + steps = math.Floor(float64(sellRate) / float64(rateStep)) + } + return uint64(steps) * rateStep + } type testBooks struct { dexBidsAvg []uint64 @@ -61,11 +74,11 @@ func TestArbRebalance(t *testing.T) { dexAsksAvg: []uint64{2e6, 2.5e6}, dexAsksExtrema: []uint64{2e6, 3e6}, - cexBidsAvg: []uint64{1.9e6, 1.8e6}, - cexBidsExtrema: []uint64{1.85e6, 1.75e6}, + cexBidsAvg: []uint64{edgeSellRate(2e6, lotSize, false), 2.1e6}, + cexBidsExtrema: []uint64{2.2e6, 1.9e6}, - cexAsksAvg: []uint64{2.1e6, 2.2e6}, - cexAsksExtrema: []uint64{2.2e6, 2.3e6}, + cexAsksAvg: []uint64{2.4e6, 2.6e6}, + cexAsksExtrema: []uint64{2.5e6, 2.7e6}, } arbBuyOnDEXBooks := &testBooks{ @@ -75,7 +88,7 @@ func TestArbRebalance(t *testing.T) { dexAsksAvg: []uint64{2e6, 2.5e6}, dexAsksExtrema: []uint64{2e6, 3e6}, - cexBidsAvg: []uint64{2.3e6, 2.1e6}, + cexBidsAvg: []uint64{edgeSellRate(2e6, lotSize, true), 2.1e6}, cexBidsExtrema: []uint64{2.2e6, 1.9e6}, cexAsksAvg: []uint64{2.4e6, 2.6e6}, @@ -89,7 +102,7 @@ func TestArbRebalance(t *testing.T) { cexAsksAvg: []uint64{2e6, 2.5e6}, cexAsksExtrema: []uint64{2e6, 3e6}, - dexBidsAvg: []uint64{2.3e6, 2.1e6}, + dexBidsAvg: []uint64{edgeSellRate(2e6, lotSize, true), 2.1e6}, dexBidsExtrema: []uint64{2.2e6, 1.9e6}, dexAsksAvg: []uint64{2.4e6, 2.6e6}, @@ -117,7 +130,7 @@ func TestArbRebalance(t *testing.T) { cexAsksAvg: []uint64{2e6, 2e6, 2.5e6}, cexAsksExtrema: []uint64{2e6, 2e6, 3e6}, - dexBidsAvg: []uint64{2.3e6, 2.2e6, 2.1e6}, + dexBidsAvg: []uint64{edgeSellRate(2e6, lotSize, true), edgeSellRate(2e6, lotSize, true), 2.1e6}, dexBidsExtrema: []uint64{2.2e6, 2.2e6, 1.9e6}, dexAsksAvg: []uint64{2.4e6, 2.6e6}, @@ -140,37 +153,22 @@ 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 - dexMaxSell *core.MaxOrderEstimate - dexMaxBuy *core.MaxOrderEstimate - dexMaxSellErr error - dexMaxBuyErr error - // 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]*botBalance - dexVWAPErr error - cexVWAPErr error - cexTradeErr error - existingArbs []*arbSequence - pendingBaseRebalance bool - pendingQuoteRebalance bool - - autoRebalance *AutoRebalanceConfig + dexVWAPErr error + cexVWAPErr error + cexTradeErr error + existingArbs []*arbSequence + dexMaxBuyQty uint64 + dexMaxSellQty uint64 + cexMaxBuyQty uint64 + cexMaxSellQty uint64 expectedDexOrder *dexOrder expectedCexOrder *libxc.Trade expectedDEXCancels []dex.Bytes expectedCEXCancels []string - expectedWithdrawal *assetAmt - expectedDeposit *assetAmt } tests := []test{ @@ -178,259 +176,117 @@ func TestArbRebalance(t *testing.T) { { name: "no arb", books: noArbBooks, - dexMaxSell: &core.MaxOrderEstimate{ - Swap: &asset.SwapEstimate{ - Lots: 5, - }, - }, - dexMaxBuy: &core.MaxOrderEstimate{ - Swap: &asset.SwapEstimate{ - Lots: 5, - }, - }, - cexBalances: map[uint32]*botBalance{ - 0: {Available: 1e19}, - 42: {Available: 1e19}, - }, }, // "1 lot, buy on dex, sell on cex" { - name: "1 lot, buy on dex, sell on cex", - books: arbBuyOnDEXBooks, - dexMaxSell: &core.MaxOrderEstimate{ - Swap: &asset.SwapEstimate{ - Lots: 5, - }, - }, - dexMaxBuy: &core.MaxOrderEstimate{ - Swap: &asset.SwapEstimate{ - Lots: 5, - }, - }, - cexBalances: map[uint32]*botBalance{ - 0: {Available: 1e19}, - 42: {Available: 1e19}, - }, + name: "1 lot, buy on dex, sell on cex", + books: arbBuyOnDEXBooks, + dexMaxSellQty: 5 * lotSize, + dexMaxBuyQty: 5 * lotSize, + cexMaxSellQty: 5 * lotSize, + cexMaxBuyQty: 5 * lotSize, expectedDexOrder: &dexOrder{ - lots: 1, + qty: lotSize, rate: 2e6, sell: false, }, expectedCexOrder: &libxc.Trade{ 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" - { - name: "1 lot, buy on dex, sell on cex, but cex base balance not enough", - books: arbBuyOnDEXBooks, - dexMaxSell: &core.MaxOrderEstimate{ - Swap: &asset.SwapEstimate{ - Lots: 5, - }, - }, - dexMaxBuy: &core.MaxOrderEstimate{ - Swap: &asset.SwapEstimate{ - Lots: 5, - }, - }, - cexBalances: map[uint32]*botBalance{ - 0: {Available: 1e19}, - 42: {Available: mkt.LotSize / 2}, - }, - }, - // "2 lot, buy on dex, sell on cex, but dex quote balance only enough for 1" - { - name: "2 lot, buy on dex, sell on cex, but dex quote balance only enough for 1", - books: arb2LotsBuyOnDEXBooks, - dexMaxSell: &core.MaxOrderEstimate{ - Swap: &asset.SwapEstimate{ - Lots: 5, - }, - }, - dexMaxBuy: &core.MaxOrderEstimate{ - Swap: &asset.SwapEstimate{ - Lots: 1, - }, - }, - - cexBalances: map[uint32]*botBalance{ - 0: {Available: 1e19}, - 42: {Available: 1e19}, - }, - - expectedDexOrder: &dexOrder{ - lots: 1, - rate: 2e6, - sell: false, - }, - expectedCexOrder: &libxc.Trade{ - BaseID: 42, - QuoteID: 0, - Qty: mkt.LotSize, + Qty: lotSize, Rate: 2.2e6, Sell: true, }, }, // "1 lot, sell on dex, buy on cex" { - name: "1 lot, sell on dex, buy on cex", - books: arbSellOnDEXBooks, - dexMaxSell: &core.MaxOrderEstimate{ - Swap: &asset.SwapEstimate{ - Lots: 5, - }, - }, - dexMaxBuy: &core.MaxOrderEstimate{ - Swap: &asset.SwapEstimate{ - Lots: 5, - }, - }, - cexBalances: map[uint32]*botBalance{ - 0: {Available: 1e19}, - 42: {Available: 1e19}, - }, + name: "1 lot, sell on dex, buy on cex", + books: arbSellOnDEXBooks, + dexMaxSellQty: 5 * lotSize, + dexMaxBuyQty: 5 * lotSize, + cexMaxSellQty: 5 * lotSize, + cexMaxBuyQty: 5 * lotSize, expectedDexOrder: &dexOrder{ - lots: 1, + qty: lotSize, rate: 2.2e6, sell: true, }, expectedCexOrder: &libxc.Trade{ BaseID: 42, QuoteID: 0, - Qty: mkt.LotSize, + Qty: lotSize, Rate: 2e6, Sell: false, }, }, - // "2 lot, buy on cex, sell on dex, but cex quote balance only enough for 1" + // "1 lot, buy on dex, sell on cex, but dex base balance not enough" { - name: "2 lot, buy on cex, sell on dex, but cex quote balance only enough for 1", - books: arb2LotsSellOnDEXBooks, - dexMaxSell: &core.MaxOrderEstimate{ - Swap: &asset.SwapEstimate{ - Lots: 5, - }, - }, - dexMaxBuy: &core.MaxOrderEstimate{ - Swap: &asset.SwapEstimate{ - Lots: 5, - }, - }, - cexBalances: map[uint32]*botBalance{ - 0: {Available: calc.BaseToQuote(2e6, mkt.LotSize*3/2)}, - 42: {Available: 1e19}, - }, + name: "1 lot, buy on dex, sell on cex, but cex balance not enough", + books: arbBuyOnDEXBooks, + dexMaxSellQty: 5 * lotSize, + dexMaxBuyQty: 5 * lotSize, + cexMaxSellQty: 0, + cexMaxBuyQty: 5 * lotSize, + }, + // "2 lot, buy on dex, sell on cex, but dex quote balance only enough for 1" + { + name: "2 lot, buy on dex, sell on cex, but dex quote balance only enough for 1", + books: arb2LotsBuyOnDEXBooks, + dexMaxBuyQty: 1 * lotSize, + cexMaxSellQty: 5 * lotSize, expectedDexOrder: &dexOrder{ - lots: 1, - rate: 2.2e6, - sell: true, + qty: lotSize, + rate: 2e6, + sell: false, }, expectedCexOrder: &libxc.Trade{ BaseID: 42, QuoteID: 0, - Qty: mkt.LotSize, - Rate: 2e6, - Sell: false, + Qty: lotSize, + Rate: 2.2e6, + Sell: true, }, }, - // "1 lot, sell on dex, buy on cex" + // "2 lot, buy on cex, sell on dex, but cex quote balance only enough for 1" { - name: "1 lot, sell on dex, buy on cex", - books: arbSellOnDEXBooks, - dexMaxSell: &core.MaxOrderEstimate{ - Swap: &asset.SwapEstimate{ - Lots: 5, - }, - }, - dexMaxBuy: &core.MaxOrderEstimate{ - Swap: &asset.SwapEstimate{ - Lots: 5, - }, - }, - cexBalances: map[uint32]*botBalance{ - 0: {Available: 1e19}, - 42: {Available: 1e19}, - }, + name: "2 lot, buy on cex, sell on dex, but cex quote balance only enough for 1", + books: arb2LotsSellOnDEXBooks, + dexMaxSellQty: 5 * lotSize, + cexMaxBuyQty: lotSize, expectedDexOrder: &dexOrder{ - lots: 1, + qty: lotSize, rate: 2.2e6, sell: true, }, expectedCexOrder: &libxc.Trade{ BaseID: 42, QuoteID: 0, - Qty: mkt.LotSize, + Qty: lotSize, Rate: 2e6, Sell: false, }, }, // "2 lots arb still above profit trigger, but second not worth it on its own" { - name: "2 lots arb still above profit trigger, but second not worth it on its own", - books: arb2LotsButOneWorth, - dexMaxSell: &core.MaxOrderEstimate{ - Swap: &asset.SwapEstimate{ - Lots: 5, - }, - }, - dexMaxBuy: &core.MaxOrderEstimate{ - Swap: &asset.SwapEstimate{ - Lots: 5, - }, - }, - cexBalances: map[uint32]*botBalance{ - 0: {Available: 1e19}, - 42: {Available: 1e19}, - }, + name: "2 lots arb still above profit trigger, but second not worth it on its own", + books: arb2LotsButOneWorth, + dexMaxSellQty: 5 * lotSize, + dexMaxBuyQty: 5 * lotSize, + cexMaxSellQty: 5 * lotSize, + cexMaxBuyQty: 5 * lotSize, expectedDexOrder: &dexOrder{ - lots: 1, + qty: lotSize, rate: 2e6, sell: false, }, expectedCexOrder: &libxc.Trade{ BaseID: 42, QuoteID: 0, - Qty: mkt.LotSize, + Qty: lotSize, Rate: 2.2e6, Sell: true, }, }, - // "2 lot, buy on cex, sell on dex, but cex quote balance only enough for 1" - { - name: "2 lot, buy on cex, sell on dex, but cex quote balance only enough for 1", - books: arb2LotsSellOnDEXBooks, - dexMaxSell: &core.MaxOrderEstimate{ - Swap: &asset.SwapEstimate{ - Lots: 5, - }, - }, - dexMaxBuy: &core.MaxOrderEstimate{ - Swap: &asset.SwapEstimate{ - Lots: 5, - }, - }, - cexBalances: map[uint32]*botBalance{ - 0: {Available: calc.BaseToQuote(2e6, mkt.LotSize*3/2)}, - 42: {Available: 1e19}, - }, - expectedDexOrder: &dexOrder{ - lots: 1, - rate: 2.2e6, - sell: true, - }, - expectedCexOrder: &libxc.Trade{ - BaseID: 42, - QuoteID: 0, - Qty: mkt.LotSize, - Rate: 2e6, - Sell: false, - }, - }, // "cex no asks" { name: "cex no asks", @@ -447,21 +303,10 @@ func TestArbRebalance(t *testing.T) { cexAsksAvg: []uint64{}, cexAsksExtrema: []uint64{}, }, - dexMaxSell: &core.MaxOrderEstimate{ - Swap: &asset.SwapEstimate{ - Lots: 5, - }, - }, - dexMaxBuy: &core.MaxOrderEstimate{ - Swap: &asset.SwapEstimate{ - Lots: 5, - }, - }, - - cexBalances: map[uint32]*botBalance{ - 0: {Available: 1e19}, - 42: {Available: 1e19}, - }, + dexMaxSellQty: 5 * lotSize, + dexMaxBuyQty: 5 * lotSize, + cexMaxSellQty: 5 * lotSize, + cexMaxBuyQty: 5 * lotSize, }, // "dex no asks" { @@ -479,112 +324,15 @@ func TestArbRebalance(t *testing.T) { cexAsksAvg: []uint64{2.1e6, 2.2e6}, cexAsksExtrema: []uint64{2.2e6, 2.3e6}, }, - dexMaxSell: &core.MaxOrderEstimate{ - Swap: &asset.SwapEstimate{ - Lots: 5, - }, - }, - dexMaxBuy: &core.MaxOrderEstimate{ - Swap: &asset.SwapEstimate{ - Lots: 5, - }, - }, - - cexBalances: map[uint32]*botBalance{ - 0: {Available: 1e19}, - 42: {Available: 1e19}, - }, - }, - // "dex max sell error" - { - name: "dex max sell error", - books: arbSellOnDEXBooks, - dexMaxBuy: &core.MaxOrderEstimate{ - Swap: &asset.SwapEstimate{ - Lots: 5, - }, - }, - cexBalances: map[uint32]*botBalance{ - 0: {Available: 1e19}, - 42: {Available: 1e19}, - }, - dexMaxSellErr: errors.New(""), - }, - // "dex max buy error" - { - name: "dex max buy error", - books: arbBuyOnDEXBooks, - dexMaxSell: &core.MaxOrderEstimate{ - Swap: &asset.SwapEstimate{ - Lots: 5, - }, - }, - cexBalances: map[uint32]*botBalance{ - 0: {Available: 1e19}, - 42: {Available: 1e19}, - }, - dexMaxBuyErr: errors.New(""), - }, - // "dex vwap error" - { - name: "dex vwap error", - books: arbBuyOnDEXBooks, - dexMaxBuy: &core.MaxOrderEstimate{ - Swap: &asset.SwapEstimate{ - Lots: 5, - }, - }, - dexMaxSell: &core.MaxOrderEstimate{ - Swap: &asset.SwapEstimate{ - Lots: 5, - }, - }, - cexBalances: map[uint32]*botBalance{ - 0: {Available: 1e19}, - 42: {Available: 1e19}, - }, - dexVWAPErr: errors.New(""), - }, - // "cex vwap error" - { - name: "cex vwap error", - books: arbBuyOnDEXBooks, - dexMaxBuy: &core.MaxOrderEstimate{ - Swap: &asset.SwapEstimate{ - Lots: 5, - }, - }, - dexMaxSell: &core.MaxOrderEstimate{ - Swap: &asset.SwapEstimate{ - Lots: 5, - }, - }, - cexBalances: map[uint32]*botBalance{ - 0: {Available: 1e19}, - 42: {Available: 1e19}, - }, - cexVWAPErr: errors.New(""), + dexMaxSellQty: 5 * lotSize, + dexMaxBuyQty: 5 * lotSize, + cexMaxSellQty: 5 * lotSize, + cexMaxBuyQty: 5 * lotSize, }, // "self-match" { name: "self-match", books: arbSellOnDEXBooks, - dexMaxSell: &core.MaxOrderEstimate{ - Swap: &asset.SwapEstimate{ - Lots: 5, - }, - }, - dexMaxBuy: &core.MaxOrderEstimate{ - Swap: &asset.SwapEstimate{ - Lots: 5, - }, - }, - - cexBalances: map[uint32]*botBalance{ - 0: {Available: 1e19}, - 42: {Available: 1e19}, - }, - existingArbs: []*arbSequence{{ dexOrder: &core.Order{ ID: orderIDs[0][:], @@ -594,24 +342,22 @@ func TestArbRebalance(t *testing.T) { sellOnDEX: false, startEpoch: currEpoch - 2, }}, + dexMaxSellQty: 5 * lotSize, + dexMaxBuyQty: 5 * lotSize, + cexMaxSellQty: 5 * lotSize, + cexMaxBuyQty: 5 * lotSize, expectedCEXCancels: []string{cexTradeIDs[0]}, expectedDEXCancels: []dex.Bytes{orderIDs[0][:]}, }, // "remove expired active arbs" { - name: "remove expired active arbs", - books: noArbBooks, - dexMaxSell: &core.MaxOrderEstimate{ - Swap: &asset.SwapEstimate{ - Lots: 5, - }, - }, - dexMaxBuy: &core.MaxOrderEstimate{ - Swap: &asset.SwapEstimate{ - Lots: 5, - }, - }, + name: "remove expired active arbs", + books: noArbBooks, + dexMaxSellQty: 5 * lotSize, + dexMaxBuyQty: 5 * lotSize, + cexMaxSellQty: 5 * lotSize, + cexMaxBuyQty: 5 * lotSize, existingArbs: []*arbSequence{ { dexOrder: &core.Order{ @@ -650,29 +396,15 @@ func TestArbRebalance(t *testing.T) { }, expectedCEXCancels: []string{cexTradeIDs[1], cexTradeIDs[3]}, expectedDEXCancels: []dex.Bytes{orderIDs[1][:], orderIDs[2][:]}, - cexBalances: map[uint32]*botBalance{ - 0: {Available: 1e19}, - 42: {Available: 1e19}, - }, }, // "already max active arbs" { - name: "already max active arbs", - books: arbBuyOnDEXBooks, - dexMaxSell: &core.MaxOrderEstimate{ - Swap: &asset.SwapEstimate{ - Lots: 5, - }, - }, - dexMaxBuy: &core.MaxOrderEstimate{ - Swap: &asset.SwapEstimate{ - Lots: 5, - }, - }, - cexBalances: map[uint32]*botBalance{ - 0: {Available: 1e19}, - 42: {Available: 1e19}, - }, + name: "already max active arbs", + books: arbBuyOnDEXBooks, + dexMaxSellQty: 5 * lotSize, + dexMaxBuyQty: 5 * lotSize, + cexMaxSellQty: 5 * lotSize, + cexMaxBuyQty: 5 * lotSize, existingArbs: []*arbSequence{ { dexOrder: &core.Order{ @@ -718,208 +450,37 @@ func TestArbRebalance(t *testing.T) { }, // "cex trade error" { - name: "cex trade error", - books: arbBuyOnDEXBooks, - dexMaxSell: &core.MaxOrderEstimate{ - Swap: &asset.SwapEstimate{ - Lots: 5, - }, - }, - dexMaxBuy: &core.MaxOrderEstimate{ - Swap: &asset.SwapEstimate{ - Lots: 5, - }, - }, - cexBalances: map[uint32]*botBalance{ - 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]*botBalance{ - 42: {Available: 1e19}, - 0: {Available: 1e10}, - }, - autoRebalance: &AutoRebalanceConfig{ - MinBaseAmt: 1e16, - MinQuoteAmt: 1e12, - }, - expectedWithdrawal: &assetAmt{ - assetID: 42, - amt: 4.99995e18, - }, - expectedDeposit: &assetAmt{ - assetID: 0, - amt: 4.9999995e16, - }, - }, - // "no arb, base needs withdrawal, quote needs deposit, edge of min transfer amount" - { - name: "no arb, base needs withdrawal, quote needs deposit, edge of min transfer amount", - books: noArbBooks, - dexMaxSell: &core.MaxOrderEstimate{ - Swap: &asset.SwapEstimate{ - Lots: 5, - }, - }, - dexMaxBuy: &core.MaxOrderEstimate{ - Swap: &asset.SwapEstimate{ - Lots: 5, - }, - }, - dexBalances: map[uint32]uint64{ - 42: 9.5e15, - 0: 1.1e12, - }, - cexBalances: map[uint32]*botBalance{ - 42: {Available: 1.1e16}, - 0: {Available: 9.5e11}, - }, - autoRebalance: &AutoRebalanceConfig{ - MinBaseAmt: 1e16, - MinQuoteAmt: 1e12, - MinBaseTransfer: (1.1e16+9.5e15)/2 - 9.5e15, - MinQuoteTransfer: (1.1e12+9.5e11)/2 - 9.5e11, - }, - expectedWithdrawal: &assetAmt{ - assetID: 42, - amt: (1.1e16+9.5e15)/2 - 9.5e15, - }, - expectedDeposit: &assetAmt{ - assetID: 0, - amt: (1.1e12+9.5e11)/2 - 9.5e11, - }, - }, - // "no arb, base needs withdrawal, quote needs deposit, below min transfer amount" - { - name: "no arb, base needs withdrawal, quote needs deposit, below min transfer amount", - books: noArbBooks, - dexMaxSell: &core.MaxOrderEstimate{ - Swap: &asset.SwapEstimate{ - Lots: 5, - }, - }, - dexMaxBuy: &core.MaxOrderEstimate{ - Swap: &asset.SwapEstimate{ - Lots: 5, - }, - }, - dexBalances: map[uint32]uint64{ - 42: 9.5e15, - 0: 1.1e12, - }, - cexBalances: map[uint32]*botBalance{ - 42: {Available: 1.1e16}, - 0: {Available: 9.5e11}, - }, - autoRebalance: &AutoRebalanceConfig{ - MinBaseAmt: 1e16, - MinQuoteAmt: 1e12, - MinBaseTransfer: (1.1e16+9.5e15)/2 - 9.5e15 + 1, - MinQuoteTransfer: (1.1e12+9.5e11)/2 - 9.5e11 + 1, - }, - }, - // "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]*botBalance{ - 42: {Available: 1e14}, - 0: {Available: 1e17}, - }, - autoRebalance: &AutoRebalanceConfig{ - 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]*botBalance{ - 42: {Available: 1e14}, - 0: {Available: 1e17}, - }, - autoRebalance: &AutoRebalanceConfig{ - MinBaseAmt: 1e16, - MinQuoteAmt: 1e12, - }, - pendingBaseRebalance: true, - pendingQuoteRebalance: true, + name: "cex trade error", + books: arbBuyOnDEXBooks, + dexMaxSellQty: 5 * lotSize, + dexMaxBuyQty: 5 * lotSize, + cexMaxSellQty: 5 * lotSize, + cexMaxBuyQty: 5 * lotSize, + cexTradeErr: errors.New(""), }, } runTest := func(test *test) { cex := newTBotCEXAdaptor() cex.vwapErr = test.cexVWAPErr - cex.balances = test.cexBalances cex.tradeErr = test.cexTradeErr + cex.maxBuyQty = test.cexMaxBuyQty + cex.maxSellQty = test.cexMaxSellQty + cex.prepareRebalanceResults[baseID] = &prepareRebalanceResult{} + cex.prepareRebalanceResults[quoteID] = &prepareRebalanceResult{} tCore := newTCore() - tCore.maxBuyEstimate = test.dexMaxBuy - tCore.maxSellEstimate = test.dexMaxSell - tCore.maxSellErr = test.dexMaxSellErr - tCore.maxBuyErr = test.dexMaxBuyErr - tCore.setAssetBalances(test.dexBalances) + coreAdaptor := newTBotCoreAdaptor(tCore) + coreAdaptor.buyFeesInQuote = feesInQuoteUnits + coreAdaptor.sellFeesInQuote = feesInQuoteUnits + coreAdaptor.maxBuyQty = test.dexMaxBuyQty + coreAdaptor.maxSellQty = test.dexMaxSellQty + if test.expectedDexOrder != nil { - tCore.multiTradeResult = []*core.Order{ - { - ID: encode.RandomBytes(32), - }, + coreAdaptor.tradeResult = &core.Order{ + Qty: test.expectedDexOrder.qty, + Rate: test.expectedDexOrder.rate, + Sell: test.expectedDexOrder.sell, } } @@ -935,35 +496,35 @@ func TestArbRebalance(t *testing.T) { orderBook.asksVWAP[uint64(i+1)] = vwapResult{test.books.dexAsksAvg[i], test.books.dexAsksExtrema[i]} } for i := range test.books.cexBidsAvg { - cex.bidsVWAP[uint64(i+1)*mkt.LotSize] = vwapResult{test.books.cexBidsAvg[i], test.books.cexBidsExtrema[i]} + cex.bidsVWAP[uint64(i+1)*lotSize] = &vwapResult{test.books.cexBidsAvg[i], test.books.cexBidsExtrema[i]} } for i := range test.books.cexAsksAvg { - cex.asksVWAP[uint64(i+1)*mkt.LotSize] = vwapResult{test.books.cexAsksAvg[i], test.books.cexAsksExtrema[i]} + cex.asksVWAP[uint64(i+1)*lotSize] = &vwapResult{test.books.cexAsksAvg[i], test.books.cexAsksExtrema[i]} } ctx, cancel := context.WithCancel(context.Background()) defer cancel() arbEngine := &simpleArbMarketMaker{ - ctx: ctx, - log: log, - cex: cex, - mkt: mkt, - baseID: 42, - quoteID: 0, - core: newTBotCoreAdaptor(tCore), + ctx: ctx, + log: log, + cex: cex, + mkt: &core.Market{ + LotSize: lotSize, + BaseID: baseID, + QuoteID: quoteID, + }, + baseID: baseID, + quoteID: quoteID, + core: coreAdaptor, activeArbs: test.existingArbs, cfg: &SimpleArbConfig{ ProfitTrigger: profitTrigger, MaxActiveArbs: maxActiveArbs, NumEpochsLeaveOpen: numEpochsLeaveOpen, - AutoRebalance: test.autoRebalance, }, } - arbEngine.pendingBaseRebalance.Store(test.pendingBaseRebalance) - arbEngine.pendingQuoteRebalance.Store(test.pendingQuoteRebalance) - go arbEngine.run() dummyNote := &core.BookUpdate{} @@ -980,40 +541,18 @@ func TestArbRebalance(t *testing.T) { tCore.bookFeed.c <- dummyNote // Check dex trade - if test.expectedDexOrder == nil { - if len(tCore.buysPlaced) > 0 || len(tCore.sellsPlaced) > 0 { - t.Fatalf("%s: expected no dex order but got %d buys and %d sells", test.name, len(tCore.buysPlaced), len(tCore.sellsPlaced)) - } + if test.expectedDexOrder == nil != (coreAdaptor.lastTradePlaced == nil) { + t.Fatalf("%s: expected dex order %v but got %v", test.name, (test.expectedDexOrder != nil), (coreAdaptor.lastTradePlaced != nil)) } if test.expectedDexOrder != nil { - if test.expectedDexOrder.sell { - if len(tCore.multiTradesPlaced[0].Placements) != 1 { - t.Fatalf("%s: expected 1 sell order but got %d", test.name, len(tCore.sellsPlaced)) - } - if !tCore.multiTradesPlaced[0].Sell { - t.Fatalf("%s: expected sell order but got buy order", test.name) - } - if test.expectedDexOrder.rate != tCore.multiTradesPlaced[0].Placements[0].Rate { - t.Fatalf("%s: expected sell order rate %d but got %d", test.name, test.expectedDexOrder.rate, tCore.sellsPlaced[0].Rate) - } - if test.expectedDexOrder.lots*mkt.LotSize != tCore.multiTradesPlaced[0].Placements[0].Qty { - t.Fatalf("%s: expected sell order qty %d but got %d", test.name, test.expectedDexOrder.lots*mkt.LotSize, tCore.sellsPlaced[0].Qty) - } + if test.expectedDexOrder.rate != coreAdaptor.lastTradePlaced.rate { + t.Fatalf("%s: expected sell order rate %d but got %d", test.name, test.expectedDexOrder.rate, coreAdaptor.lastTradePlaced.rate) } - - if !test.expectedDexOrder.sell { - if len(tCore.multiTradesPlaced[0].Placements) != 1 { - t.Fatalf("%s: expected 1 buy order but got %d", test.name, len(tCore.buysPlaced)) - } - if tCore.multiTradesPlaced[0].Sell { - t.Fatalf("%s: expected buy order but got sell order", test.name) - } - if test.expectedDexOrder.rate != tCore.multiTradesPlaced[0].Placements[0].Rate { - t.Fatalf("%s: expected buy order rate %d but got %d", test.name, test.expectedDexOrder.rate, tCore.buysPlaced[0].Rate) - } - if test.expectedDexOrder.lots*mkt.LotSize != tCore.multiTradesPlaced[0].Placements[0].Qty { - t.Fatalf("%s: expected buy order qty %d but got %d", test.name, test.expectedDexOrder.lots*mkt.LotSize, tCore.buysPlaced[0].Qty) - } + if test.expectedDexOrder.qty != coreAdaptor.lastTradePlaced.qty { + t.Fatalf("%s: expected sell order qty %d but got %d", test.name, test.expectedDexOrder.qty, coreAdaptor.lastTradePlaced.qty) + } + if test.expectedDexOrder.sell != coreAdaptor.lastTradePlaced.sell { + t.Fatalf("%s: expected sell order sell %v but got %v", test.name, test.expectedDexOrder.sell, coreAdaptor.lastTradePlaced.sell) } } @@ -1045,74 +584,6 @@ 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 { @@ -1196,18 +667,18 @@ func TestArbDexTradeUpdates(t *testing.T) { runTest := func(test *test) { cex := newTBotCEXAdaptor() - tCore := newTCore() + coreAdaptor := newTBotCoreAdaptor(newTCore()) ctx, cancel := context.WithCancel(context.Background()) defer cancel() arbEngine := &simpleArbMarketMaker{ ctx: ctx, - log: log, + log: tLogger, cex: cex, baseID: 42, quoteID: 0, - core: newTBotCoreAdaptor(tCore), + core: coreAdaptor, activeArbs: test.activeArbs, cfg: &SimpleArbConfig{ ProfitTrigger: 0.01, @@ -1218,14 +689,11 @@ func TestArbDexTradeUpdates(t *testing.T) { go arbEngine.run() - tCore.noteFeed <- &core.OrderNote{ - Order: &core.Order{ - Status: test.updatedOrderStatus, - ID: test.updatedOrderID, - }, + coreAdaptor.orderUpdates <- &core.Order{ + Status: test.updatedOrderStatus, + ID: test.updatedOrderID, } - dummyNote := &core.BondRefundNote{} - tCore.noteFeed <- dummyNote + coreAdaptor.orderUpdates <- &core.Order{} if len(test.expectedActiveArbs) != len(arbEngine.activeArbs) { t.Fatalf("%s: expected %d active arbs but got %d", test.name, len(test.expectedActiveArbs), len(arbEngine.activeArbs)) @@ -1323,7 +791,7 @@ func TestCexTradeUpdates(t *testing.T) { arbEngine := &simpleArbMarketMaker{ ctx: ctx, - log: log, + log: tLogger, cex: cex, baseID: 42, quoteID: 0, diff --git a/client/mm/mm_test.go b/client/mm/mm_test.go index 3727fd4969..cbd13ddec2 100644 --- a/client/mm/mm_test.go +++ b/client/mm/mm_test.go @@ -16,132 +16,17 @@ import ( "decred.org/dcrdex/client/orderbook" "decred.org/dcrdex/dex" "decred.org/dcrdex/dex/order" -) - -var ( - tUTXOAssetA = &dex.Asset{ - ID: 42, - Symbol: "dcr", - Version: 0, // match the stubbed (*TXCWallet).Info result - SwapSize: 251, - SwapSizeBase: 85, - RedeemSize: 200, - MaxFeeRate: 10, - SwapConf: 1, - } - tUTXOAssetB = &dex.Asset{ - ID: 0, - Symbol: "btc", - Version: 0, // match the stubbed (*TXCWallet).Info result - SwapSize: 225, - SwapSizeBase: 76, - RedeemSize: 260, - MaxFeeRate: 2, - SwapConf: 1, - } - - tACCTAsset = &dex.Asset{ - ID: 60, - Symbol: "eth", - Version: 0, // match the stubbed (*TXCWallet).Info result - SwapSize: 135000, - SwapSizeBase: 135000, - RedeemSize: 68000, - MaxFeeRate: 20, - SwapConf: 1, - } - - tACCTAssetB = &dex.Asset{ - ID: 966, - Symbol: "polygon", - Version: 0, // match the stubbed (*TXCWallet).Info result - SwapSize: 135000, - SwapSizeBase: 135000, - RedeemSize: 68000, - MaxFeeRate: 20, - SwapConf: 1, - } - - tWalletInfo = &asset.WalletInfo{ - Version: 0, - SupportedVersions: []uint32{0}, - UnitInfo: dex.UnitInfo{ - Conventional: dex.Denomination{ - ConversionFactor: 1e8, - }, - }, - AvailableWallets: []*asset.WalletDefinition{{ - Type: "type", - }}, - } + _ "decred.org/dcrdex/client/asset/btc" // register btc asset + _ "decred.org/dcrdex/client/asset/dcr" // register dcr asset + _ "decred.org/dcrdex/client/asset/eth" // register eth asset + _ "decred.org/dcrdex/client/asset/polygon" // register polygon asset ) func init() { - asset.Register(tUTXOAssetA.ID, &tDriver{ - decodedCoinID: tUTXOAssetA.Symbol + "-coin", - winfo: tWalletInfo, - }) - asset.Register(tUTXOAssetB.ID, &tCreator{ - tDriver: &tDriver{ - decodedCoinID: tUTXOAssetB.Symbol + "-coin", - winfo: tWalletInfo, - }, - }) - asset.Register(tACCTAsset.ID, &tCreator{ - tDriver: &tDriver{ - decodedCoinID: tACCTAsset.Symbol + "-coin", - winfo: tWalletInfo, - }, - }) - asset.Register(tACCTAssetB.ID, &tCreator{ - tDriver: &tDriver{ - decodedCoinID: tACCTAssetB.Symbol + "-coin", - winfo: tWalletInfo, - }, - }) - asset.RegisterToken(60001, &dex.Token{ - ParentID: 60, - }, &asset.WalletDefinition{}, nil) - asset.RegisterToken(966001, &dex.Token{ - ParentID: 966, - }, nil, nil) rand.Seed(time.Now().UnixNano()) } -type tCreator struct { - *tDriver - doesntExist bool - existsErr error - createErr error -} - -func (ctr *tCreator) Exists(walletType, dataDir string, settings map[string]string, net dex.Network) (bool, error) { - return !ctr.doesntExist, ctr.existsErr -} - -func (ctr *tCreator) Create(*asset.CreateWalletParams) error { - return ctr.createErr -} - -type tDriver struct { - wallet asset.Wallet - decodedCoinID string - winfo *asset.WalletInfo -} - -func (drv *tDriver) Open(cfg *asset.WalletConfig, logger dex.Logger, net dex.Network) (asset.Wallet, error) { - return drv.wallet, nil -} - -func (drv *tDriver) DecodeCoinID(coinID []byte) (string, error) { - return drv.decodedCoinID, nil -} - -func (drv *tDriver) Info() *asset.WalletInfo { - return drv.winfo -} - type tBookFeed struct { c chan *core.BookUpdate } @@ -183,25 +68,14 @@ type tCore struct { assetBalances map[uint32]*core.WalletBalance assetBalanceErr error market *core.Market - orderEstimate *core.OrderEstimate - sellSwapFees uint64 - sellRedeemFees uint64 - sellRefundFees uint64 - buySwapFees uint64 - buyRedeemFees uint64 - buyRefundFees uint64 + singleLotSellFees *orderFees + singleLotBuyFees *orderFees singleLotFeesErr error - preOrderParam *core.TradeForm - tradeResult *core.Order multiTradeResult []*core.Order noteFeed chan core.Notification isAccountLocker map[uint32]bool isWithdrawer map[uint32]bool isDynamicSwapper map[uint32]bool - maxBuyEstimate *core.MaxOrderEstimate - maxBuyErr error - maxSellEstimate *core.MaxOrderEstimate - maxSellErr error cancelsPlaced []dex.Bytes buysPlaced []*core.TradeForm sellsPlaced []*core.TradeForm @@ -215,6 +89,7 @@ type tCore struct { orders map[order.OrderID]*core.Order walletTxsMtx sync.Mutex walletTxs map[string]*asset.WalletTransaction + fiatRates map[uint32]float64 } func newTCore() *tCore { @@ -225,8 +100,6 @@ func newTCore() *tCore { isWithdrawer: make(map[uint32]bool), isDynamicSwapper: make(map[uint32]bool), cancelsPlaced: make([]dex.Bytes, 0), - buysPlaced: make([]*core.TradeForm, 0), - sellsPlaced: make([]*core.TradeForm, 0), bookFeed: &tBookFeed{ c: make(chan *core.BookUpdate, 1), }, @@ -253,34 +126,22 @@ func (c *tCore) SingleLotFees(form *core.SingleLotFeesForm) (uint64, uint64, uin if c.singleLotFeesErr != nil { return 0, 0, 0, c.singleLotFeesErr } + if c.singleLotSellFees == nil && c.singleLotBuyFees == nil { + return 0, 0, 0, fmt.Errorf("no fees set") + } + if form.Sell { - return c.sellSwapFees, c.sellRedeemFees, c.sellRefundFees, nil + return c.singleLotSellFees.swap, c.singleLotSellFees.redemption, c.singleLotSellFees.refund, nil } - return c.buySwapFees, c.buyRedeemFees, c.buyRefundFees, nil + return c.singleLotBuyFees.swap, c.singleLotBuyFees.redemption, c.singleLotBuyFees.refund, nil } func (c *tCore) Cancel(oidB dex.Bytes) error { c.cancelsPlaced = append(c.cancelsPlaced, oidB) return nil } -func (c *tCore) MaxBuy(host string, base, quote uint32, rate uint64) (*core.MaxOrderEstimate, error) { - if c.maxBuyErr != nil { - return nil, c.maxBuyErr - } - return c.maxBuyEstimate, nil -} -func (c *tCore) MaxSell(host string, base, quote uint32) (*core.MaxOrderEstimate, error) { - if c.maxSellErr != nil { - return nil, c.maxSellErr - } - return c.maxSellEstimate, nil -} func (c *tCore) AssetBalance(assetID uint32) (*core.WalletBalance, error) { return c.assetBalances[assetID], c.assetBalanceErr } -func (c *tCore) PreOrder(form *core.TradeForm) (*core.OrderEstimate, error) { - c.preOrderParam = form - return c.orderEstimate, nil -} func (c *tCore) MultiTrade(pw []byte, forms *core.MultiTradeForm) ([]*core.Order, error) { c.multiTradesPlaced = append(c.multiTradesPlaced, forms) return c.multiTradeResult, nil @@ -328,7 +189,7 @@ func (c *tCore) Network() dex.Network { } func (c *tCore) FiatConversionRates() map[uint32]float64 { - return nil + return c.fiatRates } func (c *tCore) Broadcast(core.Notification) { @@ -368,29 +229,125 @@ func (c *tCore) setAssetBalances(balances map[uint32]uint64) { } } +type dexOrder struct { + rate uint64 + qty uint64 + sell bool +} + type tBotCoreAdaptor struct { clientCore tCore *tCore + + balances map[uint32]*botBalance + groupedBuys map[uint64][]*core.Order + groupedSells map[uint64][]*core.Order + orderUpdates chan *core.Order + buyFees *orderFees + sellFees *orderFees + fiatExchangeRate uint64 + buyFeesInBase uint64 + sellFeesInBase uint64 + buyFeesInQuote uint64 + sellFeesInQuote uint64 + lastMultiTradeSells []*multiTradePlacement + lastMultiTradeBuys []*multiTradePlacement + multiTradeResults [][]*core.Order + sellsDEXReserves map[uint32]uint64 + sellsCEXReserves map[uint32]uint64 + buysDEXReserves map[uint32]uint64 + buysCEXReserves map[uint32]uint64 + maxBuyQty uint64 + maxSellQty uint64 + lastTradePlaced *dexOrder + tradeResult *core.Order } func (c *tBotCoreAdaptor) DEXBalance(assetID uint32) (*botBalance, error) { if c.tCore.assetBalanceErr != nil { return nil, c.tCore.assetBalanceErr } - return &botBalance{ - Available: c.tCore.assetBalances[assetID].Available, - }, nil + return c.balances[assetID], nil } -func (c *tBotCoreAdaptor) MultiTrade(pw []byte, form *core.MultiTradeForm) ([]*core.Order, error) { - c.tCore.multiTradesPlaced = append(c.tCore.multiTradesPlaced, form) - return c.tCore.multiTradeResult, nil +func (c *tBotCoreAdaptor) GroupedBookedOrders() (buys, sells map[uint64][]*core.Order) { + return c.groupedBuys, c.groupedSells +} + +func (c *tBotCoreAdaptor) CancelAllOrders() bool { return false } + +func (c *tBotCoreAdaptor) ExchangeRateFromFiatSources() uint64 { + return c.fiatExchangeRate +} + +func (c *tBotCoreAdaptor) OrderFees() (buyFees, sellFees *orderFees, err error) { + return c.buyFees, c.sellFees, nil +} + +func (c *tBotCoreAdaptor) SubscribeOrderUpdates() (updates <-chan *core.Order) { + return c.orderUpdates +} + +func (c *tBotCoreAdaptor) OrderFeesInUnits(sell, base bool, rate uint64) (uint64, error) { + if sell && base { + return c.sellFeesInBase, nil + } + if sell && !base { + return c.sellFeesInQuote, nil + } + if !sell && base { + return c.buyFeesInBase, nil + } + return c.buyFeesInQuote, nil +} + +func (c *tBotCoreAdaptor) SufficientBalanceForDEXTrade(rate, qty uint64, sell bool) (bool, error) { + if sell { + return qty <= c.maxSellQty, nil + } + return qty <= c.maxBuyQty, nil +} + +func (c *tBotCoreAdaptor) MultiTrade(placements []*multiTradePlacement, sell bool, driftTolerance float64, currEpoch uint64, dexReserves, cexReserves map[uint32]uint64) []*order.OrderID { + if sell { + c.lastMultiTradeSells = placements + for assetID, reserve := range cexReserves { + c.sellsCEXReserves[assetID] = reserve + } + for assetID, reserve := range dexReserves { + c.sellsDEXReserves[assetID] = reserve + } + } else { + c.lastMultiTradeBuys = placements + for assetID, reserve := range cexReserves { + c.buysCEXReserves[assetID] = reserve + } + for assetID, reserve := range dexReserves { + c.buysDEXReserves[assetID] = reserve + } + } + return nil +} + +func (c *tBotCoreAdaptor) DEXTrade(rate, qty uint64, sell bool) (*core.Order, error) { + c.lastTradePlaced = &dexOrder{ + rate: rate, + qty: qty, + sell: sell, + } + return c.tradeResult, nil } func newTBotCoreAdaptor(c *tCore) *tBotCoreAdaptor { return &tBotCoreAdaptor{ - clientCore: c, - tCore: c, + clientCore: c, + tCore: c, + orderUpdates: make(chan *core.Order), + multiTradeResults: make([][]*core.Order, 0), + buysCEXReserves: make(map[uint32]uint64), + buysDEXReserves: make(map[uint32]uint64), + sellsCEXReserves: make(map[uint32]uint64), + sellsDEXReserves: make(map[uint32]uint64), } } @@ -1101,7 +1058,6 @@ func TestInitialBaseBalances(t *testing.T) { QuoteBalance: 50, BaseFeeAssetBalanceType: Amount, BaseFeeAssetBalance: 500, - CEXCfg: &BotCEXCfg{ Name: "Binance", BaseBalanceType: Percentage, @@ -1275,11 +1231,6 @@ type vwapResult struct { extrema uint64 } -type dexOrder struct { - lots, rate uint64 - sell bool -} - type withdrawArgs struct { address string amt uint64 @@ -1419,31 +1370,39 @@ func (c *tCEX) ConfirmDeposit(ctx context.Context, txID string, onConfirm func(b }() } +type prepareRebalanceResult struct { + rebalance int64 + cexReserves uint64 + dexReserves uint64 +} + type tBotCexAdaptor struct { - bidsVWAP map[uint64]vwapResult - asksVWAP map[uint64]vwapResult - vwapErr error - balances map[uint32]*botBalance - balanceErr error - tradeID string - tradeErr error - lastTrade *libxc.Trade - cancelledTrades []string - cancelTradeErr error - tradeUpdates chan *libxc.Trade - lastWithdrawArgs *withdrawArgs - lastDepositArgs *withdrawArgs - confirmDeposit func() - confirmWithdraw func() + bidsVWAP map[uint64]*vwapResult + asksVWAP map[uint64]*vwapResult + vwapErr error + balances map[uint32]*botBalance + balanceErr error + tradeID string + tradeErr error + lastTrade *libxc.Trade + cancelledTrades []string + cancelTradeErr error + tradeUpdates chan *libxc.Trade + lastWithdrawArgs *withdrawArgs + lastDepositArgs *withdrawArgs + prepareRebalanceResults map[uint32]*prepareRebalanceResult + maxBuyQty uint64 + maxSellQty uint64 } func newTBotCEXAdaptor() *tBotCexAdaptor { return &tBotCexAdaptor{ - bidsVWAP: make(map[uint64]vwapResult), - asksVWAP: make(map[uint64]vwapResult), - balances: make(map[uint32]*botBalance), - cancelledTrades: make([]string, 0), - tradeUpdates: make(chan *libxc.Trade), + bidsVWAP: make(map[uint64]*vwapResult), + asksVWAP: make(map[uint64]*vwapResult), + balances: make(map[uint32]*botBalance), + cancelledTrades: make([]string, 0), + tradeUpdates: make(chan *libxc.Trade), + prepareRebalanceResults: make(map[uint32]*prepareRebalanceResult), } } @@ -1467,7 +1426,7 @@ func (c *tBotCexAdaptor) SubscribeMarket(ctx context.Context, baseID, quoteID ui func (c *tBotCexAdaptor) SubscribeTradeUpdates() (updates <-chan *libxc.Trade, unsubscribe func()) { return c.tradeUpdates, func() {} } -func (c *tBotCexAdaptor) Trade(ctx context.Context, baseID, quoteID uint32, sell bool, rate, qty uint64) (*libxc.Trade, error) { +func (c *tBotCexAdaptor) CEXTrade(ctx context.Context, baseID, quoteID uint32, sell bool, rate, qty uint64) (*libxc.Trade, error) { if c.tradeErr != nil { return nil, c.tradeErr } @@ -1482,6 +1441,9 @@ func (c *tBotCexAdaptor) Trade(ctx context.Context, baseID, quoteID uint32, sell } return c.lastTrade, nil } +func (c *tBotCexAdaptor) FreeUpFunds(assetID uint32, cex bool, amt uint64, currEpoch uint64) { +} + func (c *tBotCexAdaptor) 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 @@ -1501,19 +1463,28 @@ func (c *tBotCexAdaptor) VWAP(baseID, quoteID uint32, sell bool, qty uint64) (vw } return res.avg, res.extrema, true, nil } -func (c *tBotCexAdaptor) Deposit(ctx context.Context, assetID uint32, amount uint64, onConfirm func()) error { +func (c *tBotCexAdaptor) Deposit(ctx context.Context, assetID uint32, amount uint64) error { c.lastDepositArgs = &withdrawArgs{ assetID: assetID, amt: amount, } - c.confirmDeposit = onConfirm return nil } -func (c *tBotCexAdaptor) Withdraw(ctx context.Context, assetID uint32, amount uint64, onConfirm func()) error { +func (c *tBotCexAdaptor) Withdraw(ctx context.Context, assetID uint32, amount uint64) error { c.lastWithdrawArgs = &withdrawArgs{ assetID: assetID, amt: amount, } - c.confirmWithdraw = onConfirm return nil } +func (c *tBotCexAdaptor) SufficientBalanceForCEXTrade(baseID, quoteID uint32, sell bool, rate, qty uint64) (bool, error) { + if sell { + return qty <= c.maxSellQty, nil + } + return qty <= c.maxBuyQty, nil +} + +func (c *tBotCexAdaptor) PrepareRebalance(ctx context.Context, assetID uint32) (rebalance int64, dexReserves, cexReserves uint64) { + res := c.prepareRebalanceResults[assetID] + return res.rebalance, res.dexReserves, res.cexReserves +} diff --git a/client/mm/sample-config.json b/client/mm/sample-arb-mm.json similarity index 87% rename from client/mm/sample-config.json rename to client/mm/sample-arb-mm.json index f6a8bf219d..23733efa19 100644 --- a/client/mm/sample-config.json +++ b/client/mm/sample-arb-mm.json @@ -13,7 +13,20 @@ "baseBalanceType": 0, "quoteBalanceType": 0, "baseBalance": 100, - "quoteBalance": 100 + "quoteBalance": 100, + "autoRebalance": { + "minBaseAmt": 3000000000, + "minBaseTransfer" : 1000000000, + "minQuoteAmt": 20000000, + "minQuoteTransfer": 10000000 + } + }, + "baseWalletOptions": { + "multisplit": "true" + }, + "quoteWalletOptions": { + "multisplit": "true", + "multisplitbuffer": "5" }, "arbMarketMakingConfig": { "cexName": "Binance", @@ -55,20 +68,7 @@ ], "profit": 0.01, "driftTolerance" : 0.001, - "orderPersistence": 10, - "baseOptions": { - "multisplit": "true" - }, - "quoteOptions": { - "multisplit": "true", - "multisplitbuffer": "5" - }, - "autoRebalance": { - "minBaseAmt": 3000000000, - "minBaseTransfer" : 1000000000, - "minQuoteAmt": 20000000, - "minQuoteTransfer": 10000000 - } + "orderPersistence": 10 } } ], diff --git a/client/mm/sample-arb.json b/client/mm/sample-arb.json new file mode 100644 index 0000000000..052bcfe1ef --- /dev/null +++ b/client/mm/sample-arb.json @@ -0,0 +1,45 @@ +{ + "botConfigs": [ + { + "host": "127.0.0.1:17273", + "baseAsset": 60, + "quoteAsset": 0, + "baseBalanceType": 0, + "quoteBalanceType": 0, + "baseBalance": 100, + "quoteBalance": 100, + "baseWalletOptions": { + "multisplit": "true" + }, + "quoteWalletOptions": { + "multisplit": "true", + "multisplitbuffer": "5" + }, + "cexCfg": { + "name": "Binance", + "baseBalanceType": 0, + "quoteBalanceType": 0, + "baseBalance": 100, + "quoteBalance": 100, + "autoRebalance": { + "minBaseAmt": 3000000000, + "minBaseTransfer" : 1000000000, + "minQuoteAmt": 20000000, + "minQuoteTransfer": 10000000 + } + }, + "simpleArbConfig": { + "profitTrigger": 0.01, + "maxActiveArbs" : 5, + "numEpochsLeaveOpen": 10 + } + } + ], + "cexConfigs": [ + { + "name": "Binance", + "apiKey": "", + "apiSecret": "" + } + ] +} \ No newline at end of file diff --git a/client/mm/sample-basic.json b/client/mm/sample-basic.json new file mode 100644 index 0000000000..32d1fc5a90 --- /dev/null +++ b/client/mm/sample-basic.json @@ -0,0 +1,53 @@ +{ + "botConfigs": [ + { + "host": "127.0.0.1:17273", + "baseAsset": 60, + "quoteAsset": 0, + "baseBalanceType": 0, + "quoteBalanceType": 0, + "baseBalance": 100, + "quoteBalance": 100, + "baseWalletOptions": { + "multisplit": "true" + }, + "quoteWalletOptions": { + "multisplit": "true", + "multisplitbuffer": "5" + }, + "basicMarketMakingConfig": { + "gapStrategy": "percent-plus", + "sellPlacements": [ + { + "lots": 1, + "gapFactor": 0.02 + }, + { + "lots": 1, + "gapFactor": 0.04 + }, + { + "lots": 3, + "gapFactor": 0.06 + } + ], + "buyPlacements": [ + { + "lots": 1, + "gapFactor": 0.02 + }, + { + "lots": 1, + "gapFactor": 0.04 + }, + { + "lots": 3, + "gapFactor": 0.06 + } + ], + "oracleWeighting": 1, + "emptyMarketRate": 0.005 + } + } + ] +} \ No newline at end of file diff --git a/client/mm/utils.go b/client/mm/utils.go new file mode 100644 index 0000000000..8a1ce24268 --- /dev/null +++ b/client/mm/utils.go @@ -0,0 +1,13 @@ +package mm + +import "math" + +// steppedRate rounds the rate to the nearest integer multiple of the step. +// The minimum returned value is step. +func steppedRate(r, step uint64) uint64 { + steps := math.Round(float64(r) / float64(step)) + if steps == 0 { + return step + } + return uint64(math.Round(steps * float64(step))) +} diff --git a/client/webserver/site/src/js/mmsettings.ts b/client/webserver/site/src/js/mmsettings.ts index 96cf71ae53..b1f5b854c4 100644 --- a/client/webserver/site/src/js/mmsettings.ts +++ b/client/webserver/site/src/js/mmsettings.ts @@ -483,19 +483,19 @@ export default class MarketMakerSettingsPage extends BasePage { oldCfg.buyPlacements = Array.from(buyPlacements, (p: ArbMarketMakingPlacement) => { return { lots: p.lots, gapFactor: p.multiplier } }) oldCfg.sellPlacements = Array.from(sellPlacements, (p: ArbMarketMakingPlacement) => { return { lots: p.lots, gapFactor: p.multiplier } }) oldCfg.profit = arbMMCfg.profit - rebalanceCfg = arbMMCfg.autoRebalance } else if (arbCfg) { // DRAFT TODO // maxActiveArbs oldCfg.profit = arbCfg.profitTrigger - rebalanceCfg = arbCfg.autoRebalance } if (cexCfg) { oldCfg.cexBaseBalance = cexCfg.baseBalance oldCfg.cexBaseBalanceType = cexCfg.baseBalanceType oldCfg.cexQuoteBalance = cexCfg.quoteBalance oldCfg.cexQuoteBalanceType = cexCfg.quoteBalanceType + rebalanceCfg = cexCfg.autoRebalance } + if (rebalanceCfg) { oldCfg.cexRebalance = true oldCfg.cexBaseMinBalance = rebalanceCfg.minBaseAmt @@ -1462,7 +1462,9 @@ export default class MarketMakerSettingsPage extends BasePage { baseBalance: cfg.baseBalance, quoteBalanceType: cfg.quoteBalanceType, quoteBalance: cfg.quoteBalance, - disabled: cfg.disabled + disabled: cfg.disabled, + baseWalletOptions: cfg.baseOptions, + quoteWalletOptions: cfg.quoteOptions } switch (botType) { case botTypeBasicMM: @@ -1494,35 +1496,25 @@ export default class MarketMakerSettingsPage extends BasePage { * selected a CEX or not. */ arbMMConfig (): ArbMarketMakingConfig { - const { page, updatedConfig: cfg } = this + const { updatedConfig: cfg } = this const arbCfg: ArbMarketMakingConfig = { buyPlacements: [], sellPlacements: [], profit: cfg.profit, driftTolerance: cfg.driftTolerance, - orderPersistence: cfg.orderPersistence, - baseOptions: cfg.baseOptions, - quoteOptions: cfg.quoteOptions + orderPersistence: cfg.orderPersistence } for (const p of cfg.buyPlacements) arbCfg.buyPlacements.push({ lots: p.lots, multiplier: p.gapFactor }) for (const p of cfg.sellPlacements) arbCfg.sellPlacements.push({ lots: p.lots, multiplier: p.gapFactor }) - if (page.cexRebalanceCheckbox.checked) { - arbCfg.autoRebalance = this.autoRebalanceConfig() - } return arbCfg } basicArbConfig (): SimpleArbConfig { - const { page, updatedConfig: cfg } = this + const { updatedConfig: cfg } = this const arbCfg: SimpleArbConfig = { profitTrigger: cfg.profit, maxActiveArbs: 100, // TODO - numEpochsLeaveOpen: cfg.orderPersistence, - baseOptions: cfg.baseOptions, - quoteOptions: cfg.quoteOptions - } - if (page.cexRebalanceCheckbox.checked) { - arbCfg.autoRebalance = this.autoRebalanceConfig() + numEpochsLeaveOpen: cfg.orderPersistence } return arbCfg } @@ -1541,13 +1533,17 @@ export default class MarketMakerSettingsPage extends BasePage { cexConfig (): BotCEXCfg { const { updatedConfig: cfg } = this - return { + const cexCfg : BotCEXCfg = { name: this.specs.cexName || '', baseBalanceType: BalanceType.Percentage, baseBalance: cfg.cexBaseBalance, quoteBalanceType: BalanceType.Percentage, quoteBalance: cfg.cexQuoteBalance } + if (this.page.cexRebalanceCheckbox.checked) { + cexCfg.autoRebalance = this.autoRebalanceConfig() + } + return cexCfg } /* @@ -1564,9 +1560,7 @@ export default class MarketMakerSettingsPage extends BasePage { driftTolerance: cfg.driftTolerance, oracleWeighting: cfg.useOracles ? cfg.oracleWeighting : 0, oracleBias: cfg.useOracles ? cfg.oracleBias : 0, - emptyMarketRate: cfg.useEmptyMarketRate ? cfg.emptyMarketRate : 0, - baseOptions: cfg.baseOptions, - quoteOptions: cfg.quoteOptions + emptyMarketRate: cfg.useEmptyMarketRate ? cfg.emptyMarketRate : 0 } return mmCfg } diff --git a/client/webserver/site/src/js/registry.ts b/client/webserver/site/src/js/registry.ts index 1eea588fe3..d83798f3ee 100644 --- a/client/webserver/site/src/js/registry.ts +++ b/client/webserver/site/src/js/registry.ts @@ -687,8 +687,6 @@ export interface BasicMarketMakingConfig { oracleWeighting: number oracleBias: number emptyMarketRate: number - baseOptions?: Record - quoteOptions?: Record } export interface ArbMarketMakingPlacement { @@ -702,18 +700,12 @@ export interface ArbMarketMakingConfig { profit: number driftTolerance: number orderPersistence: number - baseOptions?: Record - quoteOptions?: Record - autoRebalance?: AutoRebalanceConfig } export interface SimpleArbConfig { profitTrigger: number maxActiveArbs: number numEpochsLeaveOpen: number - baseOptions?: Record - quoteOptions?: Record - autoRebalance?: AutoRebalanceConfig } export enum BalanceType { @@ -727,6 +719,7 @@ export interface BotCEXCfg { baseBalance: number quoteBalanceType: BalanceType quoteBalance: number + autoRebalance?: AutoRebalanceConfig } export interface BotConfig { @@ -738,6 +731,8 @@ export interface BotConfig { quoteBalanceType: BalanceType quoteBalance: number cexCfg?: BotCEXCfg + baseWalletOptions?: Record + quoteWalletOptions?: Record basicMarketMakingConfig?: BasicMarketMakingConfig arbMarketMakingConfig?: ArbMarketMakingConfig simpleArbConfig?: SimpleArbConfig