From 16239b3426bb8876da543ccc98e07f26a411fafd Mon Sep 17 00:00:00 2001 From: martonp Date: Mon, 10 Jun 2024 12:31:23 +0800 Subject: [PATCH] mm: Add BotProblems to BotStatus --- client/core/bookie.go | 10 + client/core/core.go | 146 ++++++++++++++- client/core/errors.go | 49 +++++ client/core/types.go | 22 +++ client/mm/exchange_adaptor.go | 217 ++++++++++++++++++---- client/mm/exchange_adaptor_test.go | 4 +- client/mm/libxc/binance.go | 2 +- client/mm/libxc/interface.go | 1 + client/mm/mm.go | 116 +++++++++++- client/mm/mm_arb_market_maker.go | 105 ++++++++--- client/mm/mm_arb_market_maker_test.go | 7 +- client/mm/mm_basic.go | 123 ++++++++++--- client/mm/mm_basic_test.go | 256 +++++++++++++++++++++----- client/mm/mm_simple_arb.go | 5 +- client/mm/mm_test.go | 30 ++- client/mm/utils.go | 66 ++++++- 16 files changed, 1010 insertions(+), 149 deletions(-) diff --git a/client/core/bookie.go b/client/core/bookie.go index c655cbdb5e..515e189ad4 100644 --- a/client/core/bookie.go +++ b/client/core/bookie.go @@ -390,6 +390,16 @@ func (dc *dexConnection) bookie(marketID string) *bookie { return dc.books[marketID] } +func (dc *dexConnection) midGap(base, quote uint32) (midGap uint64, err error) { + marketID := marketName(base, quote) + booky := dc.bookie(marketID) + if booky == nil { + return 0, fmt.Errorf("no bookie found for market %s", marketID) + } + + return booky.MidGap() +} + // syncBook subscribes to the order book and returns the book and a BookFeed to // receive order book updates. The BookFeed must be Close()d when it is no // longer in use. Use stopBook to unsubscribed and clean up the feed. diff --git a/client/core/core.go b/client/core/core.go index 6e84d59436..16421aac1d 100644 --- a/client/core/core.go +++ b/client/core/core.go @@ -6308,8 +6308,7 @@ func (c *Core) TradeAsync(pw []byte, form *TradeForm) (*InFlightOrder, error) { _, err := c.sendTradeRequest(req) if err != nil { // If it's an OrderQuantityTooHigh error, send simplified notification - var mErr *msgjson.Error - if errors.As(err, &mErr) && mErr.Code == msgjson.OrderQuantityTooHigh { + if errors.Is(err, ErrOrderQtyTooHigh) { topic := TopicOrderQuantityTooHigh subject, details := c.formatDetails(topic, corder.Host) c.notify(newOrderNoteWithTempID(topic, subject, details, db.ErrorLevel, corder, tempID)) @@ -6368,7 +6367,7 @@ func (c *Core) prepareForTradeRequestPrep(pw []byte, base, quote uint32, host st return fail(err) } if dc.acct.suspended() { - return fail(newError(suspendedAcctErr, "may not trade while account is suspended")) + return fail(newError(suspendedAcctErr, "%w", ErrAccountSuspended)) } mktID := marketName(base, quote) @@ -6405,12 +6404,10 @@ func (c *Core) prepareForTradeRequestPrep(pw []byte, base, quote uint32, host st w.mtx.RLock() defer w.mtx.RUnlock() if w.peerCount < 1 { - return fmt.Errorf("%s wallet has no network peers (check your network or firewall)", - unbip(w.AssetID)) + return &WalletNoPeersError{w.AssetID} } if !w.synced { - return fmt.Errorf("%s still syncing. progress = %.2f%%", unbip(w.AssetID), - w.syncProgress*100) + return &WalletSyncError{w.AssetID, w.syncProgress} } return nil } @@ -10263,7 +10260,7 @@ func sendRequest(conn comms.WsConn, route string, request, response any, timeout } // Check the response error. - return <-errChan + return mapServerError(<-errChan) } // newPreimage creates a random order commitment. If you require a matching @@ -11473,3 +11470,136 @@ func (c *Core) TakeAction(assetID uint32, actionID string, actionB json.RawMessa } return goGetter.TakeAction(actionID, actionB) } + +// calcParcelLimit computes the users score-scaled user parcel limit. +func calcParcelLimit(tier int64, score, maxScore int32) uint32 { + // Users limit starts at 2 parcels per tier. + lowerLimit := tier * dex.PerTierBaseParcelLimit + // Limit can scale up to 3x with score. + upperLimit := lowerLimit * dex.ParcelLimitScoreMultiplier + limitRange := upperLimit - lowerLimit + var scaleFactor float64 + if score > 0 { + scaleFactor = float64(score) / float64(maxScore) + } + return uint32(lowerLimit) + uint32(math.Round(scaleFactor*float64(limitRange))) +} + +func (c *Core) TradingLimits(host string) (userParcels, parcelLimit uint32, err error) { + exchange, err := c.Exchange(host) + if err != nil { + return 0, 0, err + } + + dc, _, err := c.dex(host) + if err != nil { + return 0, 0, err + } + + likelyTaker := func(o *Order, midGap uint64) bool { + if o.Type == order.MarketOrderType || o.TimeInForce == order.ImmediateTiF { + return true + } + + if midGap == 0 { + return false + } + + if o.Sell { + return o.Rate < midGap + } + + return o.Rate > midGap + } + + baseQty := func(o *Order, midGap, lotSize uint64) uint64 { + qty := o.Qty + + if o.Type == order.MarketOrderType && !o.Sell { + if midGap == 0 { + qty = lotSize + } else { + qty = calc.QuoteToBase(midGap, qty) + } + } + return qty + } + + epochWeight := func(o *Order, midGap, lotSize uint64) uint64 { + if o.Status >= order.OrderStatusBooked { + return 0 + } + + if likelyTaker(o, midGap) { + return 2 * baseQty(o, midGap, lotSize) + } + + return baseQty(o, midGap, lotSize) + } + + bookedWeight := func(o *Order) uint64 { + if o.Status != order.OrderStatusBooked { + return 0 + } + return o.Qty - o.Filled + } + + settlingWeight := func(o *Order) (weight uint64) { + for _, match := range o.Matches { + if (match.Side == order.Maker && match.Status >= order.MakerRedeemed) || + (match.Side == order.Taker && match.Status >= order.MatchComplete) { + continue + } + weight += match.Qty + } + return + } + + isEpochOrder := func(o *Order) bool { + return o.Status < order.OrderStatusExecuted + } + + parcelLimit = calcParcelLimit(exchange.Auth.EffectiveTier, exchange.Auth.Rep.Score, int32(exchange.MaxScore)) + for _, mkt := range exchange.Markets { + if len(mkt.InFlightOrders) == 0 && len(mkt.Orders) == 0 { + continue + } + + var hasEpochOrder bool + for _, ord := range mkt.InFlightOrders { + if isEpochOrder(ord.Order) { + hasEpochOrder = true + break + } + } + + if !hasEpochOrder { + for _, ord := range mkt.Orders { + if isEpochOrder(ord) { + hasEpochOrder = true + break + } + } + } + + var midGap uint64 + if hasEpochOrder { + midGap, err = dc.midGap(mkt.BaseID, mkt.QuoteID) + if err != nil { + return 0, 0, err + } + } + + var mktWeight uint64 + for _, ord := range mkt.InFlightOrders { + mktWeight += epochWeight(ord.Order, midGap, mkt.LotSize) + bookedWeight(ord.Order) + settlingWeight(ord.Order) + } + for _, ord := range mkt.Orders { + mktWeight += epochWeight(ord, midGap, mkt.LotSize) + bookedWeight(ord) + settlingWeight(ord) + } + + userParcels += uint32(mktWeight / (uint64(mkt.ParcelSize) * mkt.LotSize)) + } + + return userParcels, parcelLimit, nil +} diff --git a/client/core/errors.go b/client/core/errors.go index f5de3a1f8a..d187e070af 100644 --- a/client/core/errors.go +++ b/client/core/errors.go @@ -6,6 +6,8 @@ package core import ( "errors" "fmt" + + "decred.org/dcrdex/dex/msgjson" ) // Error codes here are used on the frontend. @@ -106,3 +108,50 @@ func UnwrapErr(err error) error { } return UnwrapErr(InnerErr) } + +var ( + ErrOrderQtyTooHigh = errors.New("user order limit exceeded") + ErrAccountSuspended = errors.New("may not trade while account is suspended") +) + +var serverErrsMap = map[int]error{ + msgjson.OrderQuantityTooHigh: ErrOrderQtyTooHigh, +} + +// mapServerError maps an error sent in a server response to a client core +// error. +func mapServerError(err error) error { + if err == nil { + return nil + } + + var mErr *msgjson.Error + if !errors.As(err, &mErr) { + return err + } + + if mappedErr, found := serverErrsMap[mErr.Code]; found { + return mappedErr + } + + return err +} + +// WalletNoPeersError should be returned when a wallet has no network peers. +type WalletNoPeersError struct { + AssetID uint32 +} + +func (e *WalletNoPeersError) Error() string { + return fmt.Sprintf("%s wallet has no network peers (check your network or firewall)", unbip(e.AssetID)) +} + +// WalletSyncError should be returned when a wallet is still syncing. +type WalletSyncError struct { + AssetID uint32 + Progress float32 +} + +func (e *WalletSyncError) Error() string { + return fmt.Sprintf("%s still syncing. progress = %.2f%%", unbip(e.AssetID), e.Progress*100) +} diff --git a/client/core/types.go b/client/core/types.go index 63a629f671..03b5feb8ca 100644 --- a/client/core/types.go +++ b/client/core/types.go @@ -734,6 +734,28 @@ type Exchange struct { PendingFee *PendingFeeState `json:"pendingFee,omitempty"` } +func (e *Exchange) strongTier() uint64 { + weakStrength := e.Auth.WeakStrength + targetTier := e.Auth.TargetTier + effectiveTier := e.Auth.EffectiveTier + + if effectiveTier > int64(targetTier) { + diff := effectiveTier - int64(targetTier) + + if weakStrength >= diff { + return targetTier + } else { + return targetTier + uint64(diff-weakStrength) + } + } + + if effectiveTier >= 0 { + return uint64(effectiveTier) + } + + return 0 +} + // newDisplayIDFromSymbols creates a display-friendly market ID for a base/quote // symbol pair. func newDisplayIDFromSymbols(base, quote string) string { diff --git a/client/mm/exchange_adaptor.go b/client/mm/exchange_adaptor.go index 67a621ee81..48c6edac5e 100644 --- a/client/mm/exchange_adaptor.go +++ b/client/mm/exchange_adaptor.go @@ -62,12 +62,14 @@ type botCoreAdaptor interface { Cancel(oidB dex.Bytes) error DEXTrade(rate, qty uint64, sell bool) (*core.Order, error) ExchangeMarket(host string, baseID, quoteID uint32) (*core.Market, error) - MultiTrade(placements []*multiTradePlacement, sell bool, driftTolerance float64, currEpoch uint64, dexReserves, cexReserves map[uint32]uint64) []*order.OrderID + MultiTrade(placements []*multiTradePlacement, sell bool, driftTolerance float64, currEpoch uint64, dexReserves, + cexReserves map[uint32]uint64) (orders []*order.OrderID, dexDeficiencies, cexDeficiencies map[uint32]uint64, err error) ExchangeRateFromFiatSources() uint64 OrderFeesInUnits(sell, base bool, rate uint64) (uint64, error) // estimated fees, not max SubscribeOrderUpdates() (updates <-chan *core.Order) SufficientBalanceForDEXTrade(rate, qty uint64, sell bool) (bool, error) registerFeeGap(*FeeGapStats) + checkBotHealth() bool } // botCexAdaptor is an interface used by bots to access CEX related @@ -350,6 +352,9 @@ type unifiedExchangeAdaptor struct { } feeGapStats atomic.Value } + + botProblemsMtx sync.RWMutex + botProblems *BotProblems } var _ botCoreAdaptor = (*unifiedExchangeAdaptor)(nil) @@ -892,21 +897,19 @@ func (u *unifiedExchangeAdaptor) MultiTrade( currEpoch uint64, dexReserves, cexReserves map[uint32]uint64, -) []*order.OrderID { - +) (placedOrders []*order.OrderID, dexDeficiencies, cexDeficiencies map[uint32]uint64, err error) { if len(placements) == 0 { - return nil + return nil, nil, nil, nil } mkt, err := u.ExchangeMarket(u.host, u.baseID, u.quoteID) if err != nil { - u.log.Errorf("MultiTrade: error getting market: %v", err) - return nil + return nil, nil, nil, fmt.Errorf("error getting market: %w", err) } buyFees, sellFees, err := u.orderFees() if err != nil { - u.log.Errorf("MultiTrade: error getting order fees: %v", err) - return nil + return nil, nil, nil, fmt.Errorf("error getting order fees: %w", err) } + fromAsset, fromFeeAsset, toAsset, toFeeAsset := orderAssets(mkt.BaseID, mkt.QuoteID, sell) fees, fundingFees := buyFees.Max, buyFees.funding if sell { @@ -922,19 +925,13 @@ func (u *unifiedExchangeAdaptor) MultiTrade( availableBalance := bal.Available if dexReserves != nil { if dexReserves[assetID] > availableBalance { - u.log.Errorf("MultiTrade: insufficient dex balance for reserves. required: %d, have: %d", dexReserves[assetID], availableBalance) - return nil + return nil, nil, nil, fmt.Errorf("insufficient dex balance for reserves. required: %d, have: %d", dexReserves[assetID], availableBalance) } availableBalance -= dexReserves[assetID] } remainingBalances[assetID] = availableBalance } } - if remainingBalances[fromFeeAsset] < fundingFees { - u.log.Debugf("MultiTrade: insufficient balance for funding fees. required: %d, have: %d", fundingFees, remainingBalances[fromFeeAsset]) - return nil - } - remainingBalances[fromFeeAsset] -= fundingFees // If the placements include a counterTradeRate, the CEX balance must also // be taken into account to determine how many trades can be placed. @@ -946,7 +943,7 @@ func (u *unifiedExchangeAdaptor) MultiTrade( reserves := cexReserves[toAsset] if remainingCEXBal < reserves { u.log.Errorf("MultiTrade: insufficient CEX balance for reserves. required: %d, have: %d", cexReserves, remainingCEXBal) - return nil + return nil, nil, nil, fmt.Errorf("insufficient CEX balance for reserves. required: %d, have: %d", cexReserves, remainingCEXBal) } remainingCEXBal -= reserves } @@ -1044,6 +1041,44 @@ func (u *unifiedExchangeAdaptor) MultiTrade( orderInfos := make([]*dexOrderInfo, 0, len(requiredPlacements)) + dexDeficiencies, cexDeficiencies = func() (dexDeficiencies, cexDeficiencies map[uint32]uint64) { + totalDEXRequired := make(map[uint32]uint64) + var totalCEXRequired uint64 + + totalDEXRequired[fromFeeAsset] = fundingFees + + for _, placement := range requiredPlacements { + if placement.lots == 0 { + continue + } + + dexReq, cexReq := fundingReq(placement.rate, placement.lots, placement.counterTradeRate) + for assetID, v := range dexReq { + totalDEXRequired[assetID] += v + } + totalCEXRequired += cexReq + } + + dexDeficiencies = make(map[uint32]uint64) + for assetID, v := range totalDEXRequired { + if remainingBalances[assetID] < v { + dexDeficiencies[assetID] = v - remainingBalances[assetID] + } + } + + if remainingCEXBal < totalCEXRequired { + cexDeficiencies = map[uint32]uint64{toAsset: totalCEXRequired - remainingCEXBal} + } + + return + }() + + if remainingBalances[fromFeeAsset] < fundingFees { + u.log.Debugf("MultiTrade: insufficient balance for funding fees. required: %d, have: %d", fundingFees, remainingBalances[fromFeeAsset]) + return nil, dexDeficiencies, cexDeficiencies, nil + } + remainingBalances[fromFeeAsset] -= fundingFees + for i, placement := range requiredPlacements { if placement.lots == 0 { continue @@ -1100,8 +1135,7 @@ func (u *unifiedExchangeAdaptor) MultiTrade( if len(orderInfos) > 0 { orders, err := u.placeMultiTrade(orderInfos, sell) if err != nil { - u.log.Errorf("MultiTrade: error placing orders: %v", err) - return nil + return nil, dexDeficiencies, cexDeficiencies, err } orderIDs := make([]*order.OrderID, len(placements)) @@ -1111,10 +1145,10 @@ func (u *unifiedExchangeAdaptor) MultiTrade( copy(orderID[:], o.ID) orderIDs[info.placementIndex] = &orderID } - return orderIDs + return orderIDs, dexDeficiencies, cexDeficiencies, nil } - return nil + return nil, dexDeficiencies, cexDeficiencies, nil } // DEXTrade places a single order on the DEX order book. @@ -2103,13 +2137,14 @@ func (u *unifiedExchangeAdaptor) atomicConversionRateFromFiat(fromID, toID uint3 // create the unifiedExchangeAdaptor. func (u *unifiedExchangeAdaptor) orderFees() (buyFees, sellFees *orderFees, err error) { u.feesMtx.RLock() - defer u.feesMtx.RUnlock() + buyFees, sellFees = u.buyFees, u.sellFees + u.feesMtx.RUnlock() if u.buyFees == nil || u.sellFees == nil { - return nil, nil, fmt.Errorf("order fees not available") + return u.updateFeeRates() } - return u.buyFees, u.sellFees, nil + return buyFees, sellFees, nil } // OrderFeesInUnits returns the estimated swap and redemption fees for either a @@ -2121,6 +2156,7 @@ func (u *unifiedExchangeAdaptor) OrderFeesInUnits(sell, base bool, rate uint64) if err != nil { return 0, fmt.Errorf("error getting order fees: %v", err) } + buyFees, sellFees := buyFeeRange.Estimated, sellFeeRange.Estimated baseFees, quoteFees := buyFees.Redeem, buyFees.Swap if sell { @@ -2617,15 +2653,27 @@ func (u *unifiedExchangeAdaptor) handleDEXNotification(n core.Notification) { // 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 { +func (u *unifiedExchangeAdaptor) updateFeeRates() (buyFees, sellFees *orderFees, err error) { + defer func() { + if err == nil { + return + } + + u.feesMtx.Lock() + defer u.feesMtx.Unlock() + + u.buyFees = nil + u.sellFees = nil + }() + maxBaseFees, maxQuoteFees, err := marketFees(u.clientCore, u.host, u.baseID, u.quoteID, true) if err != nil { - return err + return nil, nil, err } estBaseFees, estQuoteFees, err := marketFees(u.clientCore, u.host, u.baseID, u.quoteID, false) if err != nil { - return err + return nil, nil, err } botCfg := u.botCfg() @@ -2633,12 +2681,12 @@ func (u *unifiedExchangeAdaptor) updateFeeRates() error { buyFundingFees, err := u.clientCore.MaxFundingFees(u.quoteID, u.host, maxBuyPlacements, botCfg.QuoteWalletOptions) if err != nil { - return fmt.Errorf("failed to get buy funding fees: %v", err) + return nil, nil, fmt.Errorf("failed to get buy funding fees: %v", err) } sellFundingFees, err := u.clientCore.MaxFundingFees(u.baseID, u.host, maxSellPlacements, botCfg.BaseWalletOptions) if err != nil { - return fmt.Errorf("failed to get sell funding fees: %v", err) + return nil, nil, fmt.Errorf("failed to get sell funding fees: %v", err) } u.feesMtx.Lock() @@ -2659,6 +2707,7 @@ func (u *unifiedExchangeAdaptor) updateFeeRates() error { }, funding: buyFundingFees, } + u.sellFees = &orderFees{ LotFeeRange: &LotFeeRange{ Max: &LotFees{ @@ -2675,7 +2724,7 @@ func (u *unifiedExchangeAdaptor) updateFeeRates() error { funding: sellFundingFees, } - return nil + return u.buyFees, u.sellFees, nil } func (u *unifiedExchangeAdaptor) Connect(ctx context.Context) (*sync.WaitGroup, error) { @@ -2683,9 +2732,9 @@ func (u *unifiedExchangeAdaptor) Connect(ctx context.Context) (*sync.WaitGroup, fiatRates := u.clientCore.FiatConversionRates() u.fiatRates.Store(fiatRates) - err := u.updateFeeRates() + _, _, err := u.updateFeeRates() if err != nil { - u.log.Errorf("Error updating fee rates: %v", err) + return nil, fmt.Errorf("failed to getting fee rates: %v", err) } startTime := time.Now().Unix() @@ -2735,7 +2784,7 @@ func (u *unifiedExchangeAdaptor) Connect(ctx context.Context) (*sync.WaitGroup, for { select { case <-time.NewTimer(refreshTime).C: - err := u.updateFeeRates() + _, _, err := u.updateFeeRates() if err != nil { u.log.Error(err) refreshTime = time.Minute @@ -2818,6 +2867,18 @@ func calcRunProfitLoss(initialBalances, finalBalances map[uint32]uint64, mods ma return finalBalanceUSD - initialBalanceUSD, (finalBalanceUSD / initialBalanceUSD) - 1 } +func (u *unifiedExchangeAdaptor) problems() *BotProblems { + u.botProblemsMtx.RLock() + defer u.botProblemsMtx.RUnlock() + return u.botProblems.copy() +} + +func (u *unifiedExchangeAdaptor) updateBotProblems(f func(*BotProblems)) { + u.botProblemsMtx.Lock() + defer u.botProblemsMtx.Unlock() + f(u.botProblems) +} + func (u *unifiedExchangeAdaptor) stats() *RunStats { u.balancesMtx.RLock() defer u.balancesMtx.RUnlock() @@ -2935,6 +2996,97 @@ func (u *unifiedExchangeAdaptor) Book() (buys, sells []*core.MiniOrder, _ error) return u.CEX.Book(u.baseID, u.quoteID) } +func (u *unifiedExchangeAdaptor) tradingLimitNotReached() bool { + var tradingLimitReached bool + var err error + defer u.updateBotProblems(func(problems *BotProblems) { + if err != nil { + problems.AdditionalError = err + } + problems.UserLimitTooLow = tradingLimitReached + }) + + userParcels, parcelLimit, err := u.clientCore.TradingLimits(u.host) + if err != nil { + return false + } + + tradingLimitReached = userParcels >= parcelLimit + return !tradingLimitReached +} + +func (u *unifiedExchangeAdaptor) updateDepositWithdrawProblems(base, deposit bool, err error) { + u.updateBotProblems(func(problems *BotProblems) { + assetID := u.quoteID + if base { + assetID = u.baseID + } + + if deposit { + if err == nil { + delete(problems.DepositErr, assetID) + } else { + problems.DepositErr[assetID] = newStampedError(err) + } + } else { + if err == nil { + delete(problems.WithdrawErr, assetID) + } else { + problems.WithdrawErr[assetID] = newStampedError(err) + } + } + }) +} + +func (u *unifiedExchangeAdaptor) checkBotHealth() bool { + user := u.clientCore.User() + + var err error + var baseAssetNotSynced, baseAssetNoPeers, quoteAssetNotSynced, quoteAssetNoPeers, accountSuspended bool + defer u.updateBotProblems(func(problems *BotProblems) { + problems.NoWalletPeers[u.baseID] = baseAssetNoPeers + problems.NoWalletPeers[u.quoteID] = quoteAssetNoPeers + problems.WalletNotSynced[u.baseID] = baseAssetNotSynced + problems.WalletNotSynced[u.quoteID] = quoteAssetNotSynced + problems.AccountSuspended = accountSuspended + problems.AdditionalError = err + }) + + baseAsset, found := user.Assets[u.baseID] + if !found { + err = fmt.Errorf("base asset %d not found in user assets", u.baseID) + return false + } + + baseAssetNotSynced = !baseAsset.Wallet.Synced + baseAssetNoPeers = baseAsset.Wallet.PeerCount == 0 + + quoteAsset, found := user.Assets[u.quoteID] + if !found { + err = fmt.Errorf("quote asset %d not found in user assets", u.quoteID) + return false + } + quoteAssetNotSynced = !quoteAsset.Wallet.Synced + quoteAssetNoPeers = quoteAsset.Wallet.PeerCount == 0 + + exchange, found := user.Exchanges[u.host] + if !found { + err = fmt.Errorf("exchange %s not found in user exchanges", u.host) + return false + } + accountSuspended = exchange.Auth.EffectiveTier <= 0 + + u.updateBotProblems(func(problems *BotProblems) { + problems.NoWalletPeers[u.baseID] = baseAssetNoPeers + problems.NoWalletPeers[u.quoteID] = quoteAssetNoPeers + problems.WalletNotSynced[u.baseID] = baseAssetNotSynced + problems.WalletNotSynced[u.quoteID] = quoteAssetNotSynced + problems.AccountSuspended = accountSuspended + }) + + return !(baseAssetNotSynced || baseAssetNoPeers || quoteAssetNotSynced || quoteAssetNoPeers || accountSuspended) +} + type exchangeAdaptorCfg struct { botID string mwh *MarketWithHost @@ -2995,6 +3147,7 @@ func newUnifiedExchangeAdaptor(cfg *exchangeAdaptorCfg) (*unifiedExchangeAdaptor pendingWithdrawals: make(map[string]*pendingWithdrawal), mwh: cfg.mwh, inventoryMods: make(map[uint32]int64), + botProblems: newBotProblems(), } adaptor.fiatRates.Store(map[uint32]float64{}) diff --git a/client/mm/exchange_adaptor_test.go b/client/mm/exchange_adaptor_test.go index 3a021a7ddd..574f402147 100644 --- a/client/mm/exchange_adaptor_test.go +++ b/client/mm/exchange_adaptor_test.go @@ -1767,7 +1767,7 @@ func TestMultiTrade(t *testing.T) { dexReserves = test.buyDexReserves cexReserves = test.buyCexReserves } - res := adaptor.MultiTrade(placements, sell, driftTolerance, currEpoch, dexReserves, cexReserves) + res, _, _, _ := adaptor.MultiTrade(placements, sell, driftTolerance, currEpoch, dexReserves, cexReserves) expectedOrderIDs := test.expectedOrderIDs if decrement { @@ -2573,7 +2573,7 @@ func TestDEXTrade(t *testing.T) { t.Fatalf("%s: Connect error: %v", test.name, err) } - orders := adaptor.MultiTrade(test.placements, test.sell, 0.01, 100, nil, nil) + 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) } diff --git a/client/mm/libxc/binance.go b/client/mm/libxc/binance.go index ac95ee1e3d..08b71609db 100644 --- a/client/mm/libxc/binance.go +++ b/client/mm/libxc/binance.go @@ -289,7 +289,7 @@ func (b *binanceOrderBook) vwap(bids bool, qty uint64) (vwap, extrema uint64, fi defer b.mtx.RUnlock() if !b.synced.Load() { - return 0, 0, filled, errors.New("orderbook not synced") + return 0, 0, filled, ErrUnsyncedOrderbook } vwap, extrema, filled = b.book.vwap(bids, qty) diff --git a/client/mm/libxc/interface.go b/client/mm/libxc/interface.go index 543a04b957..e998343bcf 100644 --- a/client/mm/libxc/interface.go +++ b/client/mm/libxc/interface.go @@ -73,6 +73,7 @@ type BalanceUpdate struct { var ( ErrWithdrawalPending = errors.New("withdrawal pending") + ErrUnsyncedOrderbook = errors.New("orderbook not synced") ) // CEX implements a set of functions that can be used to interact with a diff --git a/client/mm/mm.go b/client/mm/mm.go index baff9c054d..fc985c9507 100644 --- a/client/mm/mm.go +++ b/client/mm/mm.go @@ -9,6 +9,7 @@ import ( "fmt" "os" "sync" + "time" "decred.org/dcrdex/client/asset" "decred.org/dcrdex/client/core" @@ -39,6 +40,7 @@ type clientCore interface { Network() dex.Network Order(oidB dex.Bytes) (*core.Order, error) WalletTransaction(uint32, string) (*asset.WalletTransaction, error) + TradingLimits(host string) (userParcels, parcelLimit uint32, err error) } var _ clientCore = (*core.Core)(nil) @@ -206,12 +208,122 @@ type CEXStatus struct { Balances map[uint32]*libxc.ExchangeBalance `json:"balances"` } +// StampedError is an error with a timestamp. +type StampedError struct { + Stamp int64 `json:"stamp"` + Error string `json:"error"` +} + +func newStampedError(err error) *StampedError { + return &StampedError{ + Stamp: time.Now().Unix(), + Error: err.Error(), + } +} + +// BotProblems is a collection of problems that may affect the operation of a +// bot. +type BotProblems struct { + // WalletNotSynced is true if orders were unable to be placed due to a + // wallet not being synced. + WalletNotSynced map[uint32]bool `json:"walletSyncError"` + // NoWalletPeers is true if orders were unable to be placed due to a wallet + // not having any peers. + NoWalletPeers map[uint32]bool `json:"noWalletPeers"` + // AccountSuspended is true if orders were unable to be placed due to the + // account being suspended. + AccountSuspended bool + // NoOracleAvailable is true if the oracle is not available for the market + // when it is required. + NoOracleAvailable bool `json:"noOracleAvailable"` + // UserLimitTooLow is true if the user does not have the bonding amount + // necessary to place all of their orders. + UserLimitTooLow bool `json:"userLimitTooLow"` + // EmptyMarket is true if the market has no orders and no empty market rate + // is available. + EmptyMarket bool `json:"emptyMarket"` + // MidGapOutsideOracleSafeRange is true if the mid-gap is outside the oracle's + // safe range as defined by the config. + MidGapOutsideOracleSafeRange bool `json:"midGapOutsideOracleSafeRange"` + // CEXOrderbookUnsynced is true if the CEX orderbook is unsynced. + CEXOrderbookUnsynced bool `json:"cexOrderbookUnsynced"` + // DeterminePlacementsErr is true if there was an unidentified error when + // attempting to determine the rates at which to place orders. + DeterminePlacementsErr error `json:"determinePlacementsErr"` + // PlaceBuyOrdersErr is true if there was an unidentified error while + // placing buy orders. + PlaceBuyOrdersErr error `json:"placeBuyOrdersErr"` + // PlaceBuyOrdersErr is true if there was an unidentified error while + // placing sell orders. + PlaceSellOrdersErr error `json:"placeSellOrdersErr"` + // DepositErr is set if the last attempted deposit for an asset failed. + DepositErr map[uint32]*StampedError `json:"depositErr"` + // WithdrawErr is set if the last attempted withdrawal for an asset failed. + WithdrawErr map[uint32]*StampedError `json:"withdrawErr"` + // CEXTradeErr is set if the last attempted CEX trade failed. + CEXTradeErr *StampedError `json:"cexTradeErr"` + // AdditionalError is a catch-all for any other error that may have occurred. + AdditionalError error `json:"additionalError"` + // DEXBalanceDeficiencies is a map of asset IDs to the amount of the asset + // that is still needed to place all orders. + DEXBalanceDeficiencies map[uint32]uint64 `json:"dexBalanceDeficiencies"` + // CEXBalanceDeficiencies is a map of asset IDs to the amount of the asset + // that is still needed to place all orders. + CEXBalanceDeficiencies map[uint32]uint64 `json:"cexBalanceDeficiencies"` +} + +func newBotProblems() *BotProblems { + return &BotProblems{ + WalletNotSynced: make(map[uint32]bool), + NoWalletPeers: make(map[uint32]bool), + DepositErr: make(map[uint32]*StampedError), + WithdrawErr: make(map[uint32]*StampedError), + DEXBalanceDeficiencies: make(map[uint32]uint64), + CEXBalanceDeficiencies: make(map[uint32]uint64), + } +} + +func (bp *BotProblems) copy() *BotProblems { + copy := *bp + + if bp.WalletNotSynced != nil { + copy.WalletNotSynced = make(map[uint32]bool, len(bp.WalletNotSynced)) + for k, v := range bp.WalletNotSynced { + copy.WalletNotSynced[k] = v + } + } + + if bp.NoWalletPeers != nil { + copy.NoWalletPeers = make(map[uint32]bool, len(bp.NoWalletPeers)) + for k, v := range bp.NoWalletPeers { + copy.NoWalletPeers[k] = v + } + } + + if bp.DepositErr != nil { + copy.DepositErr = make(map[uint32]*StampedError, len(bp.DepositErr)) + for k, v := range bp.DepositErr { + copy.DepositErr[k] = v + } + } + + if bp.WithdrawErr != nil { + copy.WithdrawErr = make(map[uint32]*StampedError, len(bp.WithdrawErr)) + for k, v := range bp.WithdrawErr { + copy.WithdrawErr[k] = v + } + } + + return © +} + // BotStatus is state information about a configured bot. type BotStatus struct { Config *BotConfig `json:"config"` Running bool `json:"running"` // RunStats being non-nil means the bot is running. - RunStats *RunStats `json:"runStats"` + RunStats *RunStats `json:"runStats"` + Problems *BotProblems `json:"problems"` } // Status generates a Status for the MarketMaker. This returns the status of @@ -227,6 +339,7 @@ func (m *MarketMaker) Status() *Status { mkt := MarketWithHost{botCfg.Host, botCfg.BaseID, botCfg.QuoteID} rb := runningBots[mkt] var stats *RunStats + var problems *BotProblems if rb != nil { stats = rb.stats() } @@ -234,6 +347,7 @@ func (m *MarketMaker) Status() *Status { Config: botCfg, Running: rb != nil, RunStats: stats, + Problems: problems, }) } for _, cex := range m.cexList() { diff --git a/client/mm/mm_arb_market_maker.go b/client/mm/mm_arb_market_maker.go index 6dc4bfad0b..09bc7a7bc6 100644 --- a/client/mm/mm_arb_market_maker.go +++ b/client/mm/mm_arb_market_maker.go @@ -117,6 +117,16 @@ func (a *arbMarketMaker) handleCEXTradeUpdate(update *libxc.Trade) { } } +func (a *arbMarketMaker) updateCEXTradeError(err error) { + a.updateBotProblems(func(problems *BotProblems) { + if err == nil { + problems.CEXTradeErr = nil + } else { + problems.CEXTradeErr = newStampedError(err) + } + }) +} + // tradeOnCEX executes a trade on the CEX. func (a *arbMarketMaker) tradeOnCEX(rate, qty uint64, sell bool) { a.cexTradesMtx.Lock() @@ -234,20 +244,16 @@ func (a *arbMarketMaker) dexPlacementRate(cexRate uint64, sell bool) (uint64, er return dexPlacementRate(cexRate, sell, a.cfg().Profit, a.market, feesInQuoteUnits, a.log) } -func (a *arbMarketMaker) ordersToPlace() (buys, sells []*multiTradePlacement) { - orders := func(cfgPlacements []*ArbMarketMakingPlacement, sellOnDEX bool) []*multiTradePlacement { +func (a *arbMarketMaker) ordersToPlace() (buys, sells []*multiTradePlacement, err error) { + + orders := func(cfgPlacements []*ArbMarketMakingPlacement, sellOnDEX bool) ([]*multiTradePlacement, error) { newPlacements := make([]*multiTradePlacement, 0, len(cfgPlacements)) var cumulativeCEXDepth uint64 for i, cfgPlacement := range cfgPlacements { cumulativeCEXDepth += uint64(float64(cfgPlacement.Lots*a.lotSize) * cfgPlacement.Multiplier) _, extrema, filled, err := a.cex.VWAP(a.baseID, a.quoteID, !sellOnDEX, cumulativeCEXDepth) if err != nil { - a.log.Errorf("Error calculating vwap: %v", err) - newPlacements = append(newPlacements, &multiTradePlacement{ - rate: 0, - lots: 0, - }) - continue + return nil, fmt.Errorf("error getting CEX VWAP: %w", err) } if a.log.Level() == dex.LevelTrace { @@ -267,12 +273,7 @@ func (a *arbMarketMaker) ordersToPlace() (buys, sells []*multiTradePlacement) { 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 + return nil, fmt.Errorf("error calculating DEX placement rate: %w", err) } newPlacements = append(newPlacements, &multiTradePlacement{ @@ -282,11 +283,15 @@ func (a *arbMarketMaker) ordersToPlace() (buys, sells []*multiTradePlacement) { }) } - return newPlacements + return newPlacements, nil + } + + buys, err = orders(a.cfg().BuyPlacements, false) + if err != nil { + return } - buys = orders(a.cfg().BuyPlacements, false) - sells = orders(a.cfg().SellPlacements, true) + sells, err = orders(a.cfg().SellPlacements, true) return } @@ -301,12 +306,14 @@ func (a *arbMarketMaker) depositWithdrawIfNeeded() { rebalanceBase, dexReserves, cexReserves := a.cex.PrepareRebalance(a.ctx, a.baseID) if rebalanceBase > 0 { err := a.cex.Deposit(a.ctx, a.baseID, uint64(rebalanceBase)) + a.updateDepositWithdrawProblems(true, true, err) if err != nil { a.log.Errorf("Error depositing %s to CEX: %v", rebalanceBase, a.baseTicker, err) } } if rebalanceBase < 0 { err := a.cex.Withdraw(a.ctx, a.baseID, uint64(-rebalanceBase)) + a.updateDepositWithdrawProblems(true, false, err) if err != nil { a.log.Errorf("Error withdrawing %s from CEX: %v", -rebalanceBase, a.baseTicker, err) } @@ -323,12 +330,14 @@ func (a *arbMarketMaker) depositWithdrawIfNeeded() { rebalanceQuote, dexReserves, cexReserves := a.cex.PrepareRebalance(a.ctx, a.quoteID) if rebalanceQuote > 0 { err := a.cex.Deposit(a.ctx, a.quoteID, uint64(rebalanceQuote)) + a.updateDepositWithdrawProblems(false, true, err) if err != nil { a.log.Errorf("Error depositing %d %s to CEX: %v", rebalanceQuote, a.quoteTicker, err) } } if rebalanceQuote < 0 { err := a.cex.Withdraw(a.ctx, a.quoteID, uint64(-rebalanceQuote)) + a.updateDepositWithdrawProblems(false, false, err) if err != nil { a.log.Errorf("Error withdrawing %d %s from CEX: %v", -rebalanceQuote, a.quoteTicker, err) } @@ -344,6 +353,27 @@ func (a *arbMarketMaker) depositWithdrawIfNeeded() { a.dexReserves[a.quoteID] = dexReserves } +func (a *arbMarketMaker) updateBotLoopProblems(buyErr, sellErr, determinePlacementsErr error, dexDefs, cexDefs map[uint32]uint64) { + a.updateBotProblems(func(problems *BotProblems) { + clearErrors(problems) + + if !updateBotProblemsBasedOnError(problems, buyErr) { + problems.PlaceBuyOrdersErr = buyErr + } + + if !updateBotProblemsBasedOnError(problems, sellErr) { + problems.PlaceSellOrdersErr = sellErr + } + + if !updateBotProblemsBasedOnError(problems, determinePlacementsErr) { + problems.DeterminePlacementsErr = determinePlacementsErr + } + + problems.DEXBalanceDeficiencies = dexDefs + problems.CEXBalanceDeficiencies = cexDefs + }) +} + // 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 @@ -363,26 +393,56 @@ func (a *arbMarketMaker) rebalance(epoch uint64) { } a.currEpoch.Store(epoch) - buys, sells := a.ordersToPlace() + if !a.core.checkBotHealth() { + return + } - buyIDs := a.core.MultiTrade(buys, false, a.cfg().DriftTolerance, currEpoch, a.dexReserves, a.cexReserves) + var buyErr, sellErr, determinePlacementsErr error + var dexDefs, cexDefs map[uint32]uint64 + defer func() { + a.updateBotLoopProblems(buyErr, sellErr, determinePlacementsErr, dexDefs, cexDefs) + }() + + buyOrders, sellOrders, determinePlacementsErr := a.ordersToPlace() + if determinePlacementsErr != nil { + numBuyOrders := len(a.cfg().BuyPlacements) + numSellOrders := len(a.cfg().SellPlacements) + buyOrders, sellOrders = clearAllOrders(numBuyOrders, numSellOrders) + } + + buyIDs, buyDEXDefs, buyCEXDefs, buyErr := a.core.MultiTrade(buyOrders, false, a.cfg().DriftTolerance, currEpoch, a.dexReserves, a.cexReserves) for i, id := range buyIDs { if id != nil { a.matchesMtx.Lock() - a.pendingOrders[*id] = buys[i].counterTradeRate + a.pendingOrders[*id] = buyOrders[i].counterTradeRate a.matchesMtx.Unlock() } } - sellIDs := a.core.MultiTrade(sells, true, a.cfg().DriftTolerance, currEpoch, a.dexReserves, a.cexReserves) + sellIDs, sellDEXDefs, sellCEXDefs, sellErr := a.core.MultiTrade(sellOrders, true, a.cfg().DriftTolerance, currEpoch, a.dexReserves, a.cexReserves) for i, id := range sellIDs { if id != nil { a.matchesMtx.Lock() - a.pendingOrders[*id] = sells[i].counterTradeRate + a.pendingOrders[*id] = sellOrders[i].counterTradeRate a.matchesMtx.Unlock() } } + dexDefs = make(map[uint32]uint64) + cexDefs = make(map[uint32]uint64) + for assetID, def := range buyDEXDefs { + dexDefs[assetID] += def + } + for assetID, def := range sellDEXDefs { + dexDefs[assetID] += def + } + for assetID, def := range buyCEXDefs { + cexDefs[assetID] += def + } + for assetID, def := range sellCEXDefs { + cexDefs[assetID] += def + } + a.depositWithdrawIfNeeded() a.cancelExpiredCEXTrades() @@ -433,7 +493,6 @@ func (a *arbMarketMaker) registerFeeGap() { } func (a *arbMarketMaker) botLoop(ctx context.Context) (*sync.WaitGroup, error) { - book, bookFeed, err := a.core.SyncBook(a.host, a.baseID, a.quoteID) if err != nil { return nil, fmt.Errorf("failed to sync book: %v", err) diff --git a/client/mm/mm_arb_market_maker_test.go b/client/mm/mm_arb_market_maker_test.go index 8674ba2b50..0a6c67cf20 100644 --- a/client/mm/mm_arb_market_maker_test.go +++ b/client/mm/mm_arb_market_maker_test.go @@ -482,9 +482,10 @@ func mustParseMarket(m *core.Market) *market { func mustParseAdaptorFromMarket(m *core.Market) *unifiedExchangeAdaptor { return &unifiedExchangeAdaptor{ - market: mustParseMarket(m), - log: tLogger, - botLooper: botLooper(dummyLooper), + market: mustParseMarket(m), + log: tLogger, + botLooper: botLooper(dummyLooper), + botProblems: newBotProblems(), } } diff --git a/client/mm/mm_basic.go b/client/mm/mm_basic.go index df8b152f05..f93bfe1be2 100644 --- a/client/mm/mm_basic.go +++ b/client/mm/mm_basic.go @@ -175,7 +175,7 @@ func (c *BasicMarketMakingConfig) Validate() error { } type basicMMCalculator interface { - basisPrice() uint64 + basisPrice() (uint64, bool, error) halfSpread(uint64) (uint64, error) feeGapStats(uint64) (*FeeGapStats, error) } @@ -189,6 +189,9 @@ type basicMMCalculatorImpl struct { log dex.Logger } +var errNoOracleAvailable = errors.New("no oracle available") +var errNoBasisPrice = errors.New("order book empty and no empty-market rate set") + // basisPrice calculates the basis price for the market maker. // The mid-gap of the dex order book is used, and if oracles are // available, and the oracle weighting is > 0, the oracle price @@ -199,11 +202,10 @@ type basicMMCalculatorImpl struct { // 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 (b *basicMMCalculatorImpl) basisPrice() uint64 { +func (b *basicMMCalculatorImpl) basisPrice() (bp uint64, outsideOracleSafeRange bool, err error) { midGap, err := b.book.MidGap() if err != nil && !errors.Is(err, orderbook.ErrEmptyOrderbook) { - b.log.Errorf("MidGap error: %v", err) - return 0 + return 0, false, err } basisPrice := float64(midGap) // float64 message-rate units @@ -213,7 +215,7 @@ func (b *basicMMCalculatorImpl) basisPrice() uint64 { oracleWeighting = *b.cfg.OracleWeighting oraclePrice = b.oracle.getMarketPrice(b.baseID, b.quoteID) if oraclePrice == 0 { - b.log.Warnf("no oracle price available for %s bot", b.name) + return 0, false, errNoOracleAvailable } } @@ -226,9 +228,11 @@ func (b *basicMMCalculatorImpl) basisPrice() uint64 { if basisPrice < low { b.log.Debugf("local mid-gap is below safe range. Using effective mid-gap of %d%% below the oracle rate.", maxOracleMismatch*100) basisPrice = low + outsideOracleSafeRange = true } else if basisPrice > high { b.log.Debugf("local mid-gap is above safe range. Using effective mid-gap of %d%% above the oracle rate.", maxOracleMismatch*100) basisPrice = high + outsideOracleSafeRange = true } } @@ -241,26 +245,29 @@ func (b *basicMMCalculatorImpl) basisPrice() uint64 { b.log.Tracef("basisPrice: using basis price %s from oracle because no mid-gap was found in order book", b.fmtRate(uint64(msgOracleRate))) } else { basisPrice = msgOracleRate*oracleWeighting + basisPrice*(1-oracleWeighting) - b.log.Tracef("basisPrice: oracle-weighted basis price = %f", b.fmtRate(uint64(msgOracleRate))) + b.log.Tracef("basisPrice: oracle-weighted basis price = %s", b.fmtRate(uint64(msgOracleRate))) } } if basisPrice > 0 { - return steppedRate(uint64(basisPrice), b.rateStep) + bp = steppedRate(uint64(basisPrice), b.rateStep) + return } // TODO: add a configuration to turn off use of fiat rate? fiatRate := b.core.ExchangeRateFromFiatSources() if fiatRate > 0 { - return steppedRate(fiatRate, b.rateStep) + bp = steppedRate(fiatRate, b.rateStep) + return } if b.cfg.EmptyMarketRate > 0 { emptyMsgRate := b.msgRate(b.cfg.EmptyMarketRate) - return steppedRate(emptyMsgRate, b.rateStep) + bp = steppedRate(emptyMsgRate, b.rateStep) + return } - return 0 + return 0, false, errNoBasisPrice } // halfSpread calculates the distance from the mid-gap where if you sell a lot @@ -386,11 +393,10 @@ func (m *basicMarketMaker) orderPrice(basisPrice, feeAdj uint64, sell bool, gapF return basisPrice - adj } -func (m *basicMarketMaker) ordersToPlace() (buyOrders, sellOrders []*multiTradePlacement) { - basisPrice := m.calculator.basisPrice() - if basisPrice == 0 { - m.log.Errorf("No basis price available and no empty-market rate set") - return +func (m *basicMarketMaker) ordersToPlace() (buyOrders, sellOrders []*multiTradePlacement, outsideOracleSafeRange bool, err error) { + basisPrice, outsideOracleSafeRange, err := m.calculator.basisPrice() + if err != nil { + return nil, nil, false, err } var feeAdj uint64 @@ -398,8 +404,7 @@ func (m *basicMarketMaker) ordersToPlace() (buyOrders, sellOrders []*multiTradeP var err error feeGap, err := m.calculator.feeGapStats(basisPrice) if err != nil { - m.log.Errorf("Could not calculate break-even spread: %v", err) - return + return nil, nil, outsideOracleSafeRange, err } m.core.registerFeeGap(feeGap) feeAdj = feeGap.FeeGap / 2 @@ -434,6 +439,51 @@ func (m *basicMarketMaker) ordersToPlace() (buyOrders, sellOrders []*multiTradeP buyOrders = orders(m.cfg().BuyPlacements, false) sellOrders = orders(m.cfg().SellPlacements, true) + return buyOrders, sellOrders, outsideOracleSafeRange, nil +} + +// updateBotProblems updates the bot problems based on the errors encountered +// during the bot loop. +func (m *basicMarketMaker) updateBotLoopProblems(buyErr, sellErr, determinePlacementsErr error, outsideOracleSafeRange bool, dexDefs, cexDefs map[uint32]uint64) { + m.unifiedExchangeAdaptor.updateBotProblems(func(problems *BotProblems) { + clearErrors(problems) + problems.MidGapOutsideOracleSafeRange = outsideOracleSafeRange + + if !updateBotProblemsBasedOnError(problems, buyErr) { + problems.PlaceBuyOrdersErr = buyErr + } + + if !updateBotProblemsBasedOnError(problems, sellErr) { + problems.PlaceSellOrdersErr = sellErr + } + + if !updateBotProblemsBasedOnError(problems, determinePlacementsErr) { + problems.DeterminePlacementsErr = determinePlacementsErr + } + + problems.DEXBalanceDeficiencies = dexDefs + problems.CEXBalanceDeficiencies = cexDefs + }) +} + +func clearAllOrders(numBuyOrders, numSellOrders int) (buyOrders, sellOrders []*multiTradePlacement) { + buyOrders = make([]*multiTradePlacement, 0, numBuyOrders) + sellOrders = make([]*multiTradePlacement, 0, numSellOrders) + + for i := 0; i < numBuyOrders; i++ { + buyOrders = append(buyOrders, &multiTradePlacement{ + rate: 0, + lots: 0, + }) + } + + for i := 0; i < numSellOrders; i++ { + sellOrders = append(sellOrders, &multiTradePlacement{ + rate: 0, + lots: 0, + }) + } + return buyOrders, sellOrders } @@ -444,13 +494,44 @@ func (m *basicMarketMaker) rebalance(newEpoch uint64) { defer m.rebalanceRunning.Store(false) m.log.Tracef("rebalance: epoch %d", newEpoch) - 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) + if !m.core.checkBotHealth() { + return + } + + var buyErr, sellErr, determinePlacementsErr error + var outsideOracleSafeRange bool + var dexDefs, cexDefs map[uint32]uint64 + defer func() { + m.updateBotLoopProblems(buyErr, sellErr, determinePlacementsErr, outsideOracleSafeRange, dexDefs, cexDefs) + }() + + buyOrders, sellOrders, outsideOracleSafeRange, determinePlacementsErr := m.ordersToPlace() + if determinePlacementsErr != nil { + numBuyOrders := len(m.cfg().BuyPlacements) + numSellOrders := len(m.cfg().SellPlacements) + buyOrders, sellOrders = clearAllOrders(numBuyOrders, numSellOrders) + } + + _, buyDEXDefs, buyCEXDefs, buyErr := m.core.MultiTrade(buyOrders, false, m.cfg().DriftTolerance, newEpoch, nil, nil) + _, sellDEXDefs, sellCEXDefs, sellErr := m.core.MultiTrade(sellOrders, true, m.cfg().DriftTolerance, newEpoch, nil, nil) + + dexDefs = make(map[uint32]uint64) + cexDefs = make(map[uint32]uint64) + for k, v := range buyDEXDefs { + dexDefs[k] += v + } + for k, v := range sellDEXDefs { + dexDefs[k] += v + } + for k, v := range buyCEXDefs { + cexDefs[k] += v + } + for k, v := range sellCEXDefs { + cexDefs[k] += v + } } func (m *basicMarketMaker) botLoop(ctx context.Context) (*sync.WaitGroup, error) { - book, bookFeed, err := m.core.SyncBook(m.host, m.baseID, m.quoteID) if err != nil { return nil, fmt.Errorf("failed to sync book: %v", err) diff --git a/client/mm/mm_basic_test.go b/client/mm/mm_basic_test.go index 969cb1c05f..c1d5b15eb6 100644 --- a/client/mm/mm_basic_test.go +++ b/client/mm/mm_basic_test.go @@ -3,6 +3,7 @@ package mm import ( + "errors" "math" "reflect" "testing" @@ -13,14 +14,17 @@ import ( ) type tBasicMMCalculator struct { - bp uint64 + bp uint64 + bpOracleOutsideSafeRange bool + bpErr error + hs uint64 } var _ basicMMCalculator = (*tBasicMMCalculator)(nil) -func (r *tBasicMMCalculator) basisPrice() uint64 { - return r.bp +func (r *tBasicMMCalculator) basisPrice() (uint64, bool, error) { + return r.bp, r.bpOracleOutsideSafeRange, r.bpErr } func (r *tBasicMMCalculator) halfSpread(basisPrice uint64) (uint64, error) { return r.hs, nil @@ -39,40 +43,45 @@ func TestBasisPrice(t *testing.T) { } tests := []*struct { - name string - midGap uint64 - oraclePrice uint64 - oracleBias float64 - oracleWeight float64 - conversions map[uint32]float64 - fiatRate uint64 - exp uint64 + name string + midGap uint64 + oraclePrice uint64 + oracleBias float64 + oracleWeight float64 + conversions map[uint32]float64 + fiatRate uint64 + emptyMarketRate float64 + + expBP uint64 + expErr error + expOutsideOracleSafeRange bool }{ { name: "just mid-gap is enough", midGap: 123e5, - exp: 123e5, + expBP: 123e5, }, { name: "mid-gap + oracle weight", midGap: 1950, oraclePrice: 2000, oracleWeight: 0.5, - exp: 1975, + expBP: 1975, }, { - name: "adjusted mid-gap + oracle weight", - midGap: 1000, // adjusted to 1940 - oraclePrice: 2000, - oracleWeight: 0.5, - exp: 1970, + name: "adjusted mid-gap + oracle weight", + midGap: 1000, // adjusted to 1940 + oraclePrice: 2000, + oracleWeight: 0.5, + expBP: 1970, + expOutsideOracleSafeRange: true, }, { name: "no mid-gap effectively sets oracle weight to 100%", midGap: 0, oraclePrice: 2000, oracleWeight: 0.5, - exp: 2000, + expBP: 2000, }, { name: "mid-gap + oracle weight + oracle bias", @@ -80,54 +89,80 @@ func TestBasisPrice(t *testing.T) { oraclePrice: 2000, oracleBias: -0.01, // minus 20 oracleWeight: 0.75, - exp: 1972, // 0.25 * 1950 + 0.75 * (2000 - 20) = 1972 + expBP: 1972, // 0.25 * 1950 + 0.75 * (2000 - 20) = 1972 }, { - name: "no mid-gap and no oracle weight fails to produce result", + name: "oracle not available", + oracleWeight: 0.5, + oraclePrice: 0, + expErr: errNoOracleAvailable, + }, + { + name: "no mid-gap, oracle weight, fiat rate, use empty market rate", + emptyMarketRate: 1200.0, + expBP: calc.MessageRateAlt(1200, 1e8, 1e8), + }, + { + name: "no mid-gap, no oracle weight, no empty market rate", midGap: 0, oraclePrice: 0, - oracleWeight: 0.75, - exp: 0, + oracleWeight: 0, + expBP: 0, + expErr: errNoBasisPrice, }, { name: "no mid-gap and no oracle weight, but fiat rate is set", midGap: 0, oraclePrice: 0, - oracleWeight: 0.75, + oracleWeight: 0, fiatRate: 1200, - exp: 1200, + expBP: 1200, }, } for _, tt := range tests { - oracle := &tOracle{ - marketPrice: mkt.MsgRateToConventional(tt.oraclePrice), - } - ob := &tOrderBook{ - midGap: tt.midGap, - } - cfg := &BasicMarketMakingConfig{ - OracleWeighting: &tt.oracleWeight, - OracleBias: tt.oracleBias, - } + t.Run(tt.name, func(t *testing.T) { + oracle := &tOracle{ + marketPrice: mkt.MsgRateToConventional(tt.oraclePrice), + } + ob := &tOrderBook{ + midGap: tt.midGap, + } + cfg := &BasicMarketMakingConfig{ + OracleWeighting: &tt.oracleWeight, + OracleBias: tt.oracleBias, + EmptyMarketRate: tt.emptyMarketRate, + } - tCore := newTCore() - adaptor := newTBotCoreAdaptor(tCore) - adaptor.fiatExchangeRate = tt.fiatRate + tCore := newTCore() + adaptor := newTBotCoreAdaptor(tCore) + adaptor.fiatExchangeRate = tt.fiatRate - calculator := &basicMMCalculatorImpl{ - market: mustParseMarket(mkt), - book: ob, - oracle: oracle, - cfg: cfg, - log: tLogger, - core: adaptor, - } + calculator := &basicMMCalculatorImpl{ + market: mustParseMarket(mkt), + book: ob, + oracle: oracle, + cfg: cfg, + log: tLogger, + core: adaptor, + } - rate := calculator.basisPrice() - if rate != tt.exp { - t.Fatalf("%s: %d != %d", tt.name, rate, tt.exp) - } + rate, outsideSafeRange, err := calculator.basisPrice() + if err != nil { + if tt.expErr != err { + t.Fatalf("expected error %v, got %v", tt.expErr, err) + } + return + } + + if outsideSafeRange != tt.expOutsideOracleSafeRange { + t.Fatalf("expected outsideSafeRange %t, got %t", tt.expOutsideOracleSafeRange, outsideSafeRange) + } + + if rate != tt.expBP { + t.Fatalf("%s: %d != %d", tt.name, rate, tt.expBP) + } + }) } } @@ -386,3 +421,124 @@ func TestBasicMMRebalance(t *testing.T) { }) } } + +func TestBasicMMBotProblems(t *testing.T) { + const basisPrice uint64 = 5e6 + const halfSpread uint64 = 2e5 + const rateStep uint64 = 1e3 + const atomToConv float64 = 1 + + type test struct { + name string + bpOracleOutsideSafeRange bool + bpErr error + multiTradeBuyErr error + multiTradeSellErr error + + expBotProblems *BotProblems + } + + updateBotProblems := func(f func(*BotProblems)) *BotProblems { + bp := newBotProblems() + f(bp) + return bp + } + + noIDErr1 := errors.New("no ID") + noIDErr2 := errors.New("no ID") + + tests := []*test{ + { + name: "no problems", + expBotProblems: newBotProblems(), + }, + { + name: "mid gap outside oracle safe range", + bpOracleOutsideSafeRange: true, + expBotProblems: updateBotProblems(func(bp *BotProblems) { + bp.MidGapOutsideOracleSafeRange = true + }), + }, + { + name: "mid gap outside oracle safe range", + bpOracleOutsideSafeRange: true, + expBotProblems: updateBotProblems(func(bp *BotProblems) { + bp.MidGapOutsideOracleSafeRange = true + }), + }, + { + name: "wallet sync errors", + multiTradeBuyErr: &core.WalletSyncError{AssetID: 42}, + multiTradeSellErr: &core.WalletSyncError{AssetID: 60}, + expBotProblems: updateBotProblems(func(bp *BotProblems) { + bp.WalletNotSynced[42] = true + bp.WalletNotSynced[60] = true + }), + }, + { + name: "account suspended", + multiTradeBuyErr: core.ErrAccountSuspended, + multiTradeSellErr: core.ErrAccountSuspended, + expBotProblems: updateBotProblems(func(bp *BotProblems) { + bp.AccountSuspended = true + }), + }, + { + name: "buy no peers, sell qty too high", + multiTradeBuyErr: &core.WalletNoPeersError{AssetID: 42}, + multiTradeSellErr: core.ErrOrderQtyTooHigh, + expBotProblems: updateBotProblems(func(bp *BotProblems) { + bp.NoWalletPeers[42] = true + bp.UserLimitTooLow = true + }), + }, + { + name: "unidentified errors", + multiTradeBuyErr: noIDErr1, + multiTradeSellErr: noIDErr2, + expBotProblems: updateBotProblems(func(bp *BotProblems) { + bp.PlaceBuyOrdersErr = noIDErr1 + bp.PlaceSellOrdersErr = noIDErr2 + }), + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + calculator := &tBasicMMCalculator{ + bp: basisPrice, + bpOracleOutsideSafeRange: tt.bpOracleOutsideSafeRange, + bpErr: tt.bpErr, + hs: halfSpread, + } + + adaptor := newTBotCoreAdaptor(newTCore()) + adaptor.multiTradeBuyErr = tt.multiTradeBuyErr + adaptor.multiTradeSellErr = tt.multiTradeSellErr + + cfg := &BasicMarketMakingConfig{ + GapStrategy: GapStrategyPercentPlus, + BuyPlacements: []*OrderPlacement{}, + SellPlacements: []*OrderPlacement{}, + } + + unifiedExchangeAdaptor := mustParseAdaptorFromMarket(&core.Market{ + RateStep: rateStep, + AtomToConv: atomToConv, + }) + + mm := &basicMarketMaker{ + unifiedExchangeAdaptor: unifiedExchangeAdaptor, + calculator: calculator, + core: adaptor, + } + mm.cfgV.Store(cfg) + mm.rebalance(100) + + problems := unifiedExchangeAdaptor.problems() + if !reflect.DeepEqual(tt.expBotProblems, problems) { + t.Fatalf("expected bot problems %v, got %v", tt.expBotProblems, problems) + } + }) + } +} diff --git a/client/mm/mm_simple_arb.go b/client/mm/mm_simple_arb.go index c64df03fb5..5b5bbd7ff7 100644 --- a/client/mm/mm_simple_arb.go +++ b/client/mm/mm_simple_arb.go @@ -362,6 +362,10 @@ func (a *simpleArbMarketMaker) rebalance(newEpoch uint64) { defer a.rebalanceRunning.Store(false) a.log.Tracef("rebalance: epoch %d", newEpoch) + if !(a.core.checkBotHealth()) { + return + } + exists, sellOnDex, lotsToArb, dexRate, cexRate := a.arbExists() if a.log.Level() == dex.LevelTrace { a.log.Tracef("%s rebalance. exists = %t, %s on dex, lots = %d, dex rate = %s, cex rate = %s", @@ -426,7 +430,6 @@ func (a *simpleArbMarketMaker) rebalance(newEpoch uint64) { } func (a *simpleArbMarketMaker) botLoop(ctx context.Context) (*sync.WaitGroup, error) { - book, bookFeed, err := a.core.SyncBook(a.host, a.baseID, a.quoteID) if err != nil { return nil, fmt.Errorf("failed to sync book: %v", err) diff --git a/client/mm/mm_test.go b/client/mm/mm_test.go index 1f950f4f43..4647a7fbba 100644 --- a/client/mm/mm_test.go +++ b/client/mm/mm_test.go @@ -102,8 +102,10 @@ func newTCore() *tCore { bookFeed: &tBookFeed{ c: make(chan *core.BookUpdate, 1), }, - walletTxs: make(map[string]*asset.WalletTransaction), - book: &orderbook.OrderBook{}, + walletTxs: make(map[string]*asset.WalletTransaction), + book: &orderbook.OrderBook{}, + singleLotSellFees: tFees(1e5, 2e5, 3e5, 0), + singleLotBuyFees: tFees(5e5, 6e5, 7e5, 0), } } @@ -146,6 +148,7 @@ func (c *tCore) MultiTrade(pw []byte, forms *core.MultiTradeForm) ([]*core.Order c.multiTradesPlaced = append(c.multiTradesPlaced, forms) return c.multiTradeResult, nil } + func (c *tCore) WalletState(assetID uint32) *core.WalletState { isAccountLocker := c.isAccountLocker[assetID] isWithdrawer := c.isWithdrawer[assetID] @@ -191,8 +194,9 @@ func (c *tCore) Network() dex.Network { func (c *tCore) FiatConversionRates() map[uint32]float64 { return c.fiatRates } -func (c *tCore) Broadcast(core.Notification) { - +func (c *tCore) Broadcast(core.Notification) {} +func (c *tCore) TradingLimits(host string) (userParcels, parcelLimit uint32, err error) { + return 0, 0, nil } func (c *tCore) Send(pw []byte, assetID uint32, value uint64, address string, subtract bool) (asset.Coin, error) { @@ -252,6 +256,8 @@ type tBotCoreAdaptor struct { sellFeesInQuote uint64 lastMultiTradeSells []*multiTradePlacement lastMultiTradeBuys []*multiTradePlacement + multiTradeBuyErr error + multiTradeSellErr error multiTradeResults [][]*core.Order sellsDEXReserves map[uint32]uint64 sellsCEXReserves map[uint32]uint64 @@ -308,7 +314,15 @@ func (c *tBotCoreAdaptor) SufficientBalanceForDEXTrade(rate, qty uint64, sell bo return qty <= c.maxBuyQty, nil } -func (c *tBotCoreAdaptor) MultiTrade(placements []*multiTradePlacement, sell bool, driftTolerance float64, currEpoch uint64, dexReserves, cexReserves map[uint32]uint64) []*order.OrderID { +func (c *tBotCoreAdaptor) MultiTrade(placements []*multiTradePlacement, sell bool, driftTolerance float64, currEpoch uint64, + dexReserves, cexReserves map[uint32]uint64) ([]*order.OrderID, map[uint32]uint64, map[uint32]uint64, error) { + if sell && c.multiTradeSellErr != nil { + return nil, nil, nil, c.multiTradeSellErr + } + if !sell && c.multiTradeBuyErr != nil { + return nil, nil, nil, c.multiTradeBuyErr + } + if sell { c.lastMultiTradeSells = placements for assetID, reserve := range cexReserves { @@ -326,7 +340,7 @@ func (c *tBotCoreAdaptor) MultiTrade(placements []*multiTradePlacement, sell boo c.buysDEXReserves[assetID] = reserve } } - return nil + return nil, nil, nil, nil } func (c *tBotCoreAdaptor) DEXTrade(rate, qty uint64, sell bool) (*core.Order, error) { @@ -340,6 +354,10 @@ func (c *tBotCoreAdaptor) DEXTrade(rate, qty uint64, sell bool) (*core.Order, er func (u *tBotCoreAdaptor) registerFeeGap(s *FeeGapStats) {} +func (u *tBotCoreAdaptor) checkBotHealth() bool { + return true +} + func newTBotCoreAdaptor(c *tCore) *tBotCoreAdaptor { return &tBotCoreAdaptor{ clientCore: c, diff --git a/client/mm/utils.go b/client/mm/utils.go index 8a1ce24268..b3e64cc107 100644 --- a/client/mm/utils.go +++ b/client/mm/utils.go @@ -1,6 +1,12 @@ package mm -import "math" +import ( + "errors" + "math" + + "decred.org/dcrdex/client/core" + "decred.org/dcrdex/client/mm/libxc" +) // steppedRate rounds the rate to the nearest integer multiple of the step. // The minimum returned value is step. @@ -11,3 +17,61 @@ func steppedRate(r, step uint64) uint64 { } return uint64(math.Round(steps * float64(step))) } + +func clearErrors(problems *BotProblems) { + problems.UserLimitTooLow = false + problems.WalletNotSynced = map[uint32]bool{} + problems.NoWalletPeers = map[uint32]bool{} + problems.AccountSuspended = false + problems.NoOracleAvailable = false + problems.EmptyMarket = false +} + +func updateBotProblemsBasedOnError(problems *BotProblems, err error) bool { + if err == nil { + return false + } + + if noPeersErr, is := err.(*core.WalletNoPeersError); is { + if problems.NoWalletPeers == nil { + problems.NoWalletPeers = make(map[uint32]bool) + } + problems.NoWalletPeers[noPeersErr.AssetID] = true + return true + } + + if noSyncErr, is := err.(*core.WalletSyncError); is { + if problems.WalletNotSynced == nil { + problems.WalletNotSynced = make(map[uint32]bool) + } + problems.WalletNotSynced[noSyncErr.AssetID] = true + return true + } + + if errors.Is(err, core.ErrAccountSuspended) { + problems.AccountSuspended = true + return true + } + + if errors.Is(err, core.ErrOrderQtyTooHigh) { + problems.UserLimitTooLow = true + return true + } + + if errors.Is(err, errNoOracleAvailable) { + problems.NoOracleAvailable = true + return true + } + + if errors.Is(err, errNoBasisPrice) { + problems.EmptyMarket = true + return true + } + + if errors.Is(err, libxc.ErrUnsyncedOrderbook) { + problems.CEXOrderbookUnsynced = true + return true + } + + return false +}