From 95aaade3dab1f34b71f9403c3a7ace2052d23c92 Mon Sep 17 00:00:00 2001 From: martonp Date: Sun, 22 Sep 2024 15:51:28 +0200 Subject: [PATCH] mm: Add BotProblems to BotStatus This PR adds a BotProblems struct to BotStatus that reports issues that bots are facing. Some problems such as wallet sync and connectivity are checked preemptively while others are reported when errors arise during determining what orders to place and when actually placing them. The preemptive checks are especially important for the Arb bot which additionally confirms that the trading limits allow for an additional trade. Balance deficiencies are also reported in BotProblems. --- client/core/bookie.go | 10 + client/core/core.go | 68 +++- client/core/core_test.go | 114 ++++++- client/core/errors.go | 23 ++ client/core/trade.go | 73 +++++ client/mm/exchange_adaptor.go | 316 ++++++++++++++---- client/mm/exchange_adaptor_test.go | 21 +- client/mm/libxc/binance.go | 2 +- client/mm/libxc/interface.go | 1 + client/mm/mm.go | 106 +++++- client/mm/mm_arb_market_maker.go | 118 +++++-- client/mm/mm_arb_market_maker_test.go | 247 +++++++++++++- client/mm/mm_basic.go | 73 ++++- client/mm/mm_basic_test.go | 160 ++++++++- client/mm/mm_simple_arb.go | 119 ++++--- client/mm/mm_simple_arb_test.go | 392 ++++++++++++++++------- client/mm/mm_test.go | 93 +++--- client/mm/utils.go | 80 ++++- client/webserver/site/src/js/mmutil.ts | 4 + client/webserver/site/src/js/registry.ts | 1 + 20 files changed, 1687 insertions(+), 334 deletions(-) diff --git a/client/core/bookie.go b/client/core/bookie.go index 5814e0c267..397bbed935 100644 --- a/client/core/bookie.go +++ b/client/core/bookie.go @@ -391,6 +391,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 a6871a813c..641ae886a4 100644 --- a/client/core/core.go +++ b/client/core/core.go @@ -5874,7 +5874,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) @@ -5911,12 +5911,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.syncStatus.Synced { - return fmt.Errorf("%s still syncing. progress = %.2f%%", unbip(w.AssetID), - w.syncStatus.BlockProgress()*100) + return &WalletSyncError{w.AssetID, w.syncStatus.BlockProgress()} } return nil } @@ -10827,3 +10825,63 @@ func (c *Core) RedeemGeocode(appPW, code []byte, msg string) (dex.Bytes, uint64, func (c *Core) ExtensionModeConfig() *ExtensionModeConfig { return c.extensionModeConfig } + +// 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))) +} + +// TradingLimits returns the number of parcels the user can trade on an +// exchange and the amount that are currently being traded. +func (c *Core) TradingLimits(host string) (userParcels, parcelLimit uint32, err error) { + dc, _, err := c.dex(host) + if err != nil { + return 0, 0, err + } + + cfg := dc.config() + dc.acct.authMtx.RLock() + rep := dc.acct.rep + dc.acct.authMtx.RUnlock() + + mkts := make(map[string]*msgjson.Market, len(cfg.Markets)) + for _, mkt := range cfg.Markets { + mkts[mkt.Name] = mkt + } + mktTrades := make(map[string][]*trackedTrade) + for _, t := range dc.trackedTrades() { + mktTrades[t.mktID] = append(mktTrades[t.mktID], t) + } + + parcelLimit = calcParcelLimit(rep.EffectiveTier(), rep.Score, int32(cfg.MaxScore)) + for mktID, trades := range mktTrades { + mkt := mkts[mktID] + if mkt == nil { + c.log.Warnf("trade for unknown market %q", mktID) + continue + } + + var midGap, mktWeight uint64 + for _, t := range trades { + if t.isEpochOrder() && midGap == 0 { + midGap, err = dc.midGap(mkt.Base, mkt.Quote) + if err != nil && !errors.Is(err, orderbook.ErrEmptyOrderbook) { + return 0, 0, err + } + } + mktWeight += t.marketWeight(midGap, mkt.LotSize) + } + userParcels += uint32(mktWeight / (uint64(mkt.ParcelSize) * mkt.LotSize)) + } + + return userParcels, parcelLimit, nil +} diff --git a/client/core/core_test.go b/client/core/core_test.go index 8f6fa9c5b0..f8878888ca 100644 --- a/client/core/core_test.go +++ b/client/core/core_test.go @@ -239,7 +239,7 @@ func testDexConnection(ctx context.Context, crypter *tCrypter) (*dexConnection, Base: tUTXOAssetA.ID, Quote: tUTXOAssetB.ID, LotSize: dcrBtcLotSize, - ParcelSize: 100, + ParcelSize: 1, RateStep: dcrBtcRateStep, EpochLen: 60000, MarketBuyBuffer: 1.1, @@ -11050,6 +11050,118 @@ func TestPokesCachePokes(t *testing.T) { } } +func TestTradingLimits(t *testing.T) { + rig := newTestRig() + defer rig.shutdown() + + checkTradingLimits := func(expectedUserParcels, expectedParcelLimit uint32) { + t.Helper() + + userParcels, parcelLimit, err := rig.core.TradingLimits(tDexHost) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + + if userParcels != expectedUserParcels { + t.Fatalf("expected user parcels %d, got %d", expectedUserParcels, userParcels) + } + + if parcelLimit != expectedParcelLimit { + t.Fatalf("expected parcel limit %d, got %d", expectedParcelLimit, parcelLimit) + } + } + + rig.dc.acct.rep.BondedTier = 10 + book := newBookie(rig.dc, tUTXOAssetA.ID, tUTXOAssetB.ID, nil, tLogger) + rig.dc.books[tDcrBtcMktName] = book + checkTradingLimits(0, 20) + + oids := []order.OrderID{ + {0x01}, {0x02}, {0x03}, {0x04}, {0x05}, + } + + // Add an epoch order, 2 lots not likely taker + ord := &order.LimitOrder{ + Force: order.StandingTiF, + P: order.Prefix{ServerTime: time.Now()}, + T: order.Trade{ + Sell: true, + Quantity: dcrBtcLotSize * 2, + }, + } + tracker := &trackedTrade{ + Order: ord, + preImg: newPreimage(), + mktID: tDcrBtcMktName, + db: rig.db, + dc: rig.dc, + metaData: &db.OrderMetaData{ + Status: order.OrderStatusEpoch, + }, + } + rig.dc.trades[oids[0]] = tracker + checkTradingLimits(2, 20) + + // Add another epoch order, 2 lots, likely taker, so 2x + ord = &order.LimitOrder{ + Force: order.ImmediateTiF, + P: order.Prefix{ServerTime: time.Now()}, + T: order.Trade{ + Sell: true, + Quantity: dcrBtcLotSize * 2, + }, + } + tracker = &trackedTrade{ + Order: ord, + preImg: newPreimage(), + mktID: tDcrBtcMktName, + db: rig.db, + dc: rig.dc, + metaData: &db.OrderMetaData{ + Status: order.OrderStatusEpoch, + }, + } + rig.dc.trades[oids[1]] = tracker + checkTradingLimits(6, 20) + + // Add partially filled booked order + ord = &order.LimitOrder{ + P: order.Prefix{ServerTime: time.Now()}, + T: order.Trade{ + Sell: true, + Quantity: dcrBtcLotSize * 2, + FillAmt: dcrBtcLotSize, + }, + } + tracker = &trackedTrade{ + Order: ord, + preImg: newPreimage(), + mktID: tDcrBtcMktName, + db: rig.db, + dc: rig.dc, + metaData: &db.OrderMetaData{ + Status: order.OrderStatusBooked, + }, + } + rig.dc.trades[oids[2]] = tracker + checkTradingLimits(7, 20) + + // Add settling match to the booked order + tracker.matches = map[order.MatchID]*matchTracker{ + {0x01}: { + MetaMatch: db.MetaMatch{ + UserMatch: &order.UserMatch{ + Quantity: dcrBtcLotSize, + }, + MetaData: &db.MatchMetaData{ + Proof: db.MatchProof{}, + }, + }, + }, + } + checkTradingLimits(8, 20) +} + func TestTakeAction(t *testing.T) { rig := newTestRig() defer rig.shutdown() diff --git a/client/core/errors.go b/client/core/errors.go index f5de3a1f8a..3a60fe3718 100644 --- a/client/core/errors.go +++ b/client/core/errors.go @@ -106,3 +106,26 @@ func UnwrapErr(err error) error { } return UnwrapErr(InnerErr) } + +var ( + ErrAccountSuspended = errors.New("may not trade while account is suspended") +) + +// 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/trade.go b/client/core/trade.go index ef1fc0ccb9..2964f2fb22 100644 --- a/client/core/trade.go +++ b/client/core/trade.go @@ -3760,6 +3760,79 @@ func (t *trackedTrade) orderAccelerationParameters() (swapCoins, accelerationCoi return swapCoins, accelerationCoins, dex.Bytes(t.metaData.ChangeCoin), requiredForRemainingSwaps, nil } +func (t *trackedTrade) likelyTaker(midGap uint64) bool { + if t.Type() == order.MarketOrderType { + return true + } + lo := t.Order.(*order.LimitOrder) + if lo.Force == order.ImmediateTiF { + return true + } + + if midGap == 0 { + return false + } + + if lo.Sell { + return lo.Rate < midGap + } + + return lo.Rate > midGap +} + +func (t *trackedTrade) baseQty(midGap, lotSize uint64) uint64 { + qty := t.Trade().Quantity + + if t.Type() == order.MarketOrderType && !t.Trade().Sell { + if midGap == 0 { + qty = lotSize + } else { + qty = calc.QuoteToBase(midGap, qty) + } + } + + return qty +} + +func (t *trackedTrade) epochWeight(midGap, lotSize uint64) uint64 { + if t.status() >= order.OrderStatusBooked { + return 0 + } + + if t.likelyTaker(midGap) { + return 2 * t.baseQty(midGap, lotSize) + } + + return t.baseQty(midGap, lotSize) +} + +func (t *trackedTrade) bookedWeight() uint64 { + if t.status() != order.OrderStatusBooked { + return 0 + } + + return t.Trade().Remaining() +} + +func (t *trackedTrade) settlingWeight() (weight uint64) { + for _, match := range t.matches { + if (match.Side == order.Maker && match.Status >= order.MakerRedeemed) || + (match.Side == order.Taker && match.Status >= order.MatchComplete) { + continue + } + weight += match.Quantity + } + return +} + +func (t *trackedTrade) isEpochOrder() bool { + return t.status() == order.OrderStatusEpoch +} + +func (t *trackedTrade) marketWeight(midGap, lotSize uint64) uint64 { + return t.epochWeight(midGap, lotSize) + t.bookedWeight() + t.settlingWeight() +} + // mapifyCoins converts the slice of coins to a map keyed by hex coin ID. func mapifyCoins(coins asset.Coins) map[string]asset.Coin { coinMap := make(map[string]asset.Coin, len(coins)) diff --git a/client/mm/exchange_adaptor.go b/client/mm/exchange_adaptor.go index c73776cdaf..cb8f4930ef 100644 --- a/client/mm/exchange_adaptor.go +++ b/client/mm/exchange_adaptor.go @@ -74,7 +74,7 @@ type botCoreAdaptor interface { 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) + SufficientBalanceForDEXTrade(rate, qty uint64, sell bool) (bool, map[uint32]uint64, error) } // botCexAdaptor is an interface used by bots to access CEX related @@ -87,8 +87,7 @@ type botCexAdaptor interface { SubscribeMarket(ctx context.Context, baseID, quoteID uint32) error SubscribeTradeUpdates() <-chan *libxc.Trade 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) + SufficientBalanceForCEXTrade(baseID, quoteID uint32, sell bool, rate, qty uint64) (bool, map[uint32]uint64) MidGap(baseID, quoteID uint32) uint64 Book() (buys, sells []*core.MiniOrder, _ error) } @@ -471,6 +470,9 @@ type unifiedExchangeAdaptor struct { } feeGapStats atomic.Value } + + botProblemsMtx sync.RWMutex + botProblems *BotProblems } var _ botCoreAdaptor = (*unifiedExchangeAdaptor)(nil) @@ -612,7 +614,7 @@ func (u *unifiedExchangeAdaptor) logBalanceAdjustments(dexDiffs, cexDiffs map[ui // SufficientBalanceForDEXTrade returns whether the bot has sufficient balance // to place a DEX trade. -func (u *unifiedExchangeAdaptor) SufficientBalanceForDEXTrade(rate, qty uint64, sell bool) (bool, error) { +func (u *unifiedExchangeAdaptor) SufficientBalanceForDEXTrade(rate, qty uint64, sell bool) (bool, map[uint32]uint64, error) { fromAsset, fromFeeAsset, toAsset, toFeeAsset := orderAssets(u.baseID, u.quoteID, sell) balances := map[uint32]uint64{} for _, assetID := range []uint32{fromAsset, fromFeeAsset, toAsset, toFeeAsset} { @@ -624,53 +626,57 @@ func (u *unifiedExchangeAdaptor) SufficientBalanceForDEXTrade(rate, qty uint64, buyFees, sellFees, err := u.orderFees() if err != nil { - return false, err + return false, nil, err } + + reqBals := make(map[uint32]uint64) + + // Funding Fees fees, fundingFees := buyFees.Max, buyFees.funding if sell { fees, fundingFees = sellFees.Max, sellFees.funding } + reqBals[fromFeeAsset] += fundingFees - if balances[fromFeeAsset] < fundingFees { - return false, nil - } - balances[fromFeeAsset] -= fundingFees - + // Trade Qty fromQty := qty if !sell { fromQty = calc.BaseToQuote(rate, qty) } - if balances[fromAsset] < fromQty { - return false, nil - } - balances[fromAsset] -= fromQty + reqBals[fromAsset] += fromQty + // Swap Fees numLots := qty / u.lotSize - if balances[fromFeeAsset] < numLots*fees.Swap { - return false, nil - } - balances[fromFeeAsset] -= numLots * fees.Swap + reqBals[fromFeeAsset] += numLots * fees.Swap + // Refund Fees if u.isAccountLocker(fromAsset) { - if balances[fromFeeAsset] < numLots*fees.Refund { - return false, nil - } - balances[fromFeeAsset] -= numLots * fees.Refund + reqBals[fromFeeAsset] += numLots * fees.Refund } + // Redeem Fees if u.isAccountLocker(toAsset) { - if balances[toFeeAsset] < numLots*fees.Redeem { - return false, nil + reqBals[toFeeAsset] += numLots * fees.Redeem + } + + sufficient := true + deficiencies := make(map[uint32]uint64) + + for assetID, reqBal := range reqBals { + if bal, found := balances[assetID]; found && bal >= reqBal { + continue + } else { + deficiencies[assetID] = reqBal - bal + sufficient = false } - balances[toFeeAsset] -= numLots * fees.Redeem } - return true, nil + return sufficient, deficiencies, nil } // 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) { +func (u *unifiedExchangeAdaptor) SufficientBalanceForCEXTrade(baseID, quoteID uint32, sell bool, rate, qty uint64) (bool, map[uint32]uint64) { var fromAssetID uint32 var fromAssetQty uint64 if sell { @@ -682,7 +688,12 @@ func (u *unifiedExchangeAdaptor) SufficientBalanceForCEXTrade(baseID, quoteID ui } fromAssetBal := u.CEXBalance(fromAssetID) - return fromAssetBal.Available >= fromAssetQty, nil + + if fromAssetBal.Available < fromAssetQty { + return false, map[uint32]uint64{fromAssetID: fromAssetQty - fromAssetBal.Available} + } + + return true, nil } // dexOrderInfo is used by MultiTrade to keep track of the placement index @@ -1045,14 +1056,13 @@ func (u *unifiedExchangeAdaptor) multiTrade( sell bool, driftTolerance float64, currEpoch uint64, -) map[order.OrderID]*dexOrderInfo { +) (placedOrders map[order.OrderID]*dexOrderInfo, dexDeficiencies, cexDeficiencies map[uint32]uint64, err error) { if len(placements) == 0 { - return nil + return nil, nil, nil, nil } buyFees, sellFees, err := u.orderFees() if err != nil { - u.log.Errorf("multiTrade: error getting order fees: %v", err) - return nil + return nil, nil, nil, err } fromID, fromFeeID, toID, toFeeID := orderAssets(u.baseID, u.quoteID, sell) @@ -1069,12 +1079,6 @@ func (u *unifiedExchangeAdaptor) multiTrade( remainingBalances[assetID] = u.DEXBalance(assetID).Available } } - if remainingBalances[fromFeeID] < fundingFees { - u.log.Debugf("multiTrade: insufficient balance for funding fees. required: %d, have: %d", - fundingFees, remainingBalances[fromFeeID]) - return nil - } - remainingBalances[fromFeeID] -= fundingFees // If the placements include a counterTradeRate, the CEX balance must also // be taken into account to determine how many trades can be placed. @@ -1177,6 +1181,35 @@ func (u *unifiedExchangeAdaptor) multiTrade( orderInfos := make([]*dexOrderInfo, 0, len(requiredPlacements)) + totalDEXRequired := make(map[uint32]uint64) + var totalCEXRequired uint64 + totalDEXRequired[fromID] = 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] + } + } + cexDeficiencies = make(map[uint32]uint64) + if remainingCEXBal < totalCEXRequired { + cexDeficiencies[toID] = totalCEXRequired - remainingCEXBal + } + + if remainingBalances[fromFeeID] < fundingFees { + return nil, dexDeficiencies, cexDeficiencies, nil + } + remainingBalances[fromFeeID] -= fundingFees + for i, placement := range requiredPlacements { if placement.lots == 0 { continue @@ -1236,8 +1269,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, nil, nil, err } ordered := make(map[order.OrderID]*dexOrderInfo, len(placements)) @@ -1246,15 +1278,15 @@ func (u *unifiedExchangeAdaptor) multiTrade( copy(orderID[:], o.ID) ordered[orderID] = orderInfos[i] } - return ordered + return ordered, dexDeficiencies, cexDeficiencies, nil } - return nil + return nil, dexDeficiencies, cexDeficiencies, nil } // 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) + enough, _, err := u.SufficientBalanceForDEXTrade(rate, qty, sell) if err != nil { return nil, err } @@ -2068,10 +2100,7 @@ func (w *unifiedExchangeAdaptor) SubscribeTradeUpdates() <-chan *libxc.Trade { // Trade executes a trade on the CEX. The trade will be executed using the // bot's CEX balance. 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 - } + sufficient, _ := u.SufficientBalanceForCEXTrade(baseID, quoteID, sell, rate, qty) if !sufficient { return nil, fmt.Errorf("insufficient balance") } @@ -2096,7 +2125,8 @@ func (u *unifiedExchangeAdaptor) CEXTrade(ctx context.Context, baseID, quoteID u u.balancesMtx.Lock() defer u.balancesMtx.Unlock() - trade, err = u.CEX.Trade(ctx, baseID, quoteID, sell, rate, qty, *subscriptionID) + trade, err := u.CEX.Trade(ctx, baseID, quoteID, sell, rate, qty, *subscriptionID) + u.updateCEXTradeError(err) if err != nil { return nil, err } @@ -2174,13 +2204,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 @@ -2192,6 +2223,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 { @@ -2980,24 +3012,33 @@ func (u *unifiedExchangeAdaptor) transfer(dist *distribution, currEpoch uint64) } if baseInv.toDeposit > 0 { - if err := u.deposit(u.ctx, u.baseID, baseInv.toDeposit); err != nil { + err := u.deposit(u.ctx, u.baseID, baseInv.toDeposit) + u.updateDepositWithdrawProblems(true, true, err) + if err != nil { return false, fmt.Errorf("error depositing base: %w", err) } } else if baseInv.toWithdraw > 0 { - if err := u.withdraw(u.ctx, u.baseID, baseInv.toWithdraw); err != nil { + err := u.withdraw(u.ctx, u.baseID, baseInv.toWithdraw) + u.updateDepositWithdrawProblems(true, false, err) + if err != nil { return false, fmt.Errorf("error withdrawing base: %w", err) } } if quoteInv.toDeposit > 0 { - if err := u.deposit(u.ctx, u.quoteID, quoteInv.toDeposit); err != nil { + err := u.deposit(u.ctx, u.quoteID, quoteInv.toDeposit) + u.updateDepositWithdrawProblems(false, true, err) + if err != nil { return false, fmt.Errorf("error depositing quote: %w", err) } } else if quoteInv.toWithdraw > 0 { - if err := u.withdraw(u.ctx, u.quoteID, quoteInv.toWithdraw); err != nil { + err := u.withdraw(u.ctx, u.quoteID, quoteInv.toWithdraw) + u.updateDepositWithdrawProblems(false, false, err) + if err != nil { return false, fmt.Errorf("error withdrawing quote: %w", err) } } + return true, nil } @@ -3078,7 +3119,7 @@ func (u *unifiedExchangeAdaptor) cexCounterRates(cexBuyLots, cexSellLots uint64) return } if !filled { - err = errors.New("cex book to empty to get a counter-rate estimate") + err = errors.New("cex book too empty to get a counter-rate estimate") } return } @@ -3107,15 +3148,27 @@ func (u *unifiedExchangeAdaptor) bookingFees(buyFees, sellFees *LotFees) (buyBoo // 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 + } + + // In case of an error, clear the cached fees to avoid using stale data. + 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() @@ -3123,12 +3176,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) } maxBuyFees := &LotFees{ @@ -3159,6 +3212,7 @@ func (u *unifiedExchangeAdaptor) updateFeeRates() error { funding: buyFundingFees, bookingFeesPerLot: buyBookingFeesPerLot, } + u.sellFees = &orderFees{ LotFeeRange: &LotFeeRange{ Max: maxSellFees, @@ -3172,7 +3226,7 @@ func (u *unifiedExchangeAdaptor) updateFeeRates() error { bookingFeesPerLot: sellBookingFeesPerLot, } - return nil + return u.buyFees, u.sellFees, nil } func (u *unifiedExchangeAdaptor) Connect(ctx context.Context) (*sync.WaitGroup, error) { @@ -3180,9 +3234,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() @@ -3231,7 +3285,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 @@ -3466,6 +3520,137 @@ func (u *unifiedExchangeAdaptor) Book() (buys, sells []*core.MiniOrder, _ error) return u.CEX.Book(u.baseID, u.quoteID) } +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) +} + +// tradingLimitNotReached returns true if the user has not reached their trading +// limit. +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 +} + +// updateDepositWithdrawProblems updates BotProblems with the result of the +// last deposit or withdrawal attempt for a specific asset. +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) + } + } + }) +} + +// updateCEXTradeError updates BotProblems with the result of the last CEX trade +// attempt. +func (u *unifiedExchangeAdaptor) updateCEXTradeError(err error) { + u.updateBotProblems(func(problems *BotProblems) { + if err == nil { + problems.CEXTradeErr = nil + } else { + problems.CEXTradeErr = newStampedError(err) + } + }) +} + +// checkBotHealth returns true if the bot is healthy and can continue trading. +func (u *unifiedExchangeAdaptor) checkBotHealth() bool { + var err error + var baseAssetNotSynced, baseAssetNoPeers, quoteAssetNotSynced, quoteAssetNoPeers, accountSuspended bool + defer u.updateBotProblems(func(problems *BotProblems) { + if baseAssetNoPeers { + problems.NoWalletPeers[u.baseID] = true + } else { + delete(problems.NoWalletPeers, u.baseID) + } + + if quoteAssetNoPeers { + problems.NoWalletPeers[u.quoteID] = true + } else { + delete(problems.NoWalletPeers, u.quoteID) + } + + if baseAssetNotSynced { + problems.WalletNotSynced[u.baseID] = true + } else { + delete(problems.WalletNotSynced, u.baseID) + } + + if quoteAssetNotSynced { + problems.WalletNotSynced[u.quoteID] = true + } else { + delete(problems.WalletNotSynced, u.quoteID) + } + + problems.AccountSuspended = accountSuspended + problems.AdditionalError = err + }) + + baseWallet := u.clientCore.WalletState(u.baseID) + if baseWallet == nil { + err = fmt.Errorf("base asset %d wallet not found", u.baseID) + return false + } + + baseAssetNotSynced = !baseWallet.Synced + baseAssetNoPeers = baseWallet.PeerCount == 0 + + quoteWallet := u.clientCore.WalletState(u.quoteID) + if quoteWallet == nil { + err = fmt.Errorf("quote asset %d wallet not found", u.quoteID) + return false + } + + quoteAssetNotSynced = !quoteWallet.Synced + quoteAssetNoPeers = quoteWallet.PeerCount == 0 + + exchange, err := u.clientCore.Exchange(u.host) + if err != nil { + err = fmt.Errorf("error getting exchange: %w", err) + return false + } + accountSuspended = exchange.Auth.EffectiveTier <= 0 + + return !(baseAssetNotSynced || baseAssetNoPeers || quoteAssetNotSynced || quoteAssetNoPeers || accountSuspended) +} + type exchangeAdaptorCfg struct { botID string mwh *MarketWithHost @@ -3537,6 +3722,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 23ca49952e..60a584a08d 100644 --- a/client/mm/exchange_adaptor_test.go +++ b/client/mm/exchange_adaptor_test.go @@ -213,7 +213,7 @@ func TestSufficientBalanceForDEXTrade(t *testing.T) { if err != nil { t.Fatalf("Connect error: %v", err) } - sufficient, err := adaptor.SufficientBalanceForDEXTrade(test.rate, test.qty, test.sell) + sufficient, _, err := adaptor.SufficientBalanceForDEXTrade(test.rate, test.qty, test.sell) if err != nil { t.Fatalf("unexpected error: %v", err) } @@ -280,10 +280,7 @@ func TestSufficientBalanceForCEXTrade(t *testing.T) { QuoteID: quoteID, }, }) - sufficient, err := adaptor.SufficientBalanceForCEXTrade(baseID, quoteID, test.sell, test.rate, test.qty) - if err != nil { - t.Fatalf("unexpected error: %v", err) - } + sufficient, _ := adaptor.SufficientBalanceForCEXTrade(baseID, quoteID, test.sell, test.rate, test.qty) if sufficient != expSufficient { t.Fatalf("expected sufficient=%v, got %v", expSufficient, sufficient) } @@ -1619,7 +1616,8 @@ func TestMultiTrade(t *testing.T) { } else { placements = test.buyPlacements } - res := adaptor.multiTrade(placements, sell, driftTolerance, currEpoch) + + res, _, _, _ := adaptor.multiTrade(placements, sell, driftTolerance, currEpoch) expectedOrderIDs := test.expectedOrderIDs if decrement { @@ -2436,7 +2434,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) + orders, _, _, _ := adaptor.multiTrade(test.placements, test.sell, 0.01, 100) if len(orders) == 0 { t.Fatalf("%s: multi trade did not place orders", test.name) } @@ -2925,6 +2923,10 @@ func TestDeposit(t *testing.T) { }, eventLogDB: eventLogDB, }) + + tCore.singleLotBuyFees = tFees(0, 0, 0, 0) + tCore.singleLotSellFees = tFees(0, 0, 0, 0) + _, err := adaptor.Connect(ctx) if err != nil { t.Fatalf("%s: Connect error: %v", test.name, err) @@ -3127,6 +3129,9 @@ func TestWithdraw(t *testing.T) { }, eventLogDB: eventLogDB, }) + tCore.singleLotBuyFees = tFees(0, 0, 0, 0) + tCore.singleLotSellFees = tFees(0, 0, 0, 0) + _, err := adaptor.Connect(ctx) if err != nil { t.Fatalf("%s: Connect error: %v", test.name, err) @@ -3602,6 +3607,8 @@ func TestCEXTrade(t *testing.T) { }, eventLogDB: eventLogDB, }) + tCore.singleLotBuyFees = tFees(0, 0, 0, 0) + tCore.singleLotSellFees = tFees(0, 0, 0, 0) _, err := adaptor.Connect(ctx) if err != nil { t.Fatalf("%s: Connect error: %v", test.name, err) diff --git a/client/mm/libxc/binance.go b/client/mm/libxc/binance.go index 3e8fcc7967..6abcf90e4a 100644 --- a/client/mm/libxc/binance.go +++ b/client/mm/libxc/binance.go @@ -313,7 +313,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 b6c1e25f66..cc645cf69a 100644 --- a/client/mm/libxc/interface.go +++ b/client/mm/libxc/interface.go @@ -78,6 +78,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 0b8a6a9e75..5959e85e28 100644 --- a/client/mm/mm.go +++ b/client/mm/mm.go @@ -11,6 +11,7 @@ import ( "fmt" "os" "sync" + "time" "decred.org/dcrdex/client/asset" "decred.org/dcrdex/client/core" @@ -18,6 +19,7 @@ import ( "decred.org/dcrdex/client/orderbook" "decred.org/dcrdex/dex" "decred.org/dcrdex/dex/order" + "decred.org/dcrdex/dex/utils" ) // clientCore is satisfied by core.Core. @@ -32,7 +34,6 @@ type clientCore interface { WalletTraits(assetID uint32) (asset.WalletTrait, error) MultiTrade(pw []byte, form *core.MultiTradeForm) ([]*core.Order, error) MaxFundingFees(fromAsset uint32, host string, numTrades uint32, fromSettings map[string]string) (uint64, error) - User() *core.User Login(pw []byte) error OpenWallet(assetID uint32, appPW []byte) error Broadcast(core.Notification) @@ -42,6 +43,9 @@ 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) + WalletState(assetID uint32) *core.WalletState + Exchange(host string) (*core.Exchange, error) } var _ clientCore = (*core.Core)(nil) @@ -101,6 +105,7 @@ type bot interface { DEXBalance(assetID uint32) *BotBalance CEXBalance(assetID uint32) *BotBalance stats() *RunStats + problems() *BotProblems updateConfig(cfg *BotConfig) error updateInventory(balanceDiffs *BotInventoryDiffs) withPause(func() error) error @@ -208,12 +213,106 @@ 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"` + // OracleFiatMismatch is true if the mid-gap is outside the oracle's + // safe range as defined by the config. + OracleFiatMismatch bool `json:"oracleFiatMismatch"` + // CEXOrderbookUnsynced is true if the CEX orderbook is unsynced. + CEXOrderbookUnsynced bool `json:"cexOrderbookUnsynced"` + // DeterminePlacementsErr is non-nil if there was an unidentified error + // when attempting to determine the rates at which to place orders. + DeterminePlacementsErr error `json:"determinePlacementsErr"` + // PlaceBuyOrdersErr is non-nil if there was an unidentified error while + // placing buy orders. + PlaceBuyOrdersErr error `json:"placeBuyOrdersErr"` + // PlaceBuyOrdersErr is non-nil 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"` + // CEXTooShallow is a map from "sell" or "buy" to whether the CEX orderbook + // is too shallow determine the rate at which to place orders. This is only + // relevant for the Arb-MM bot. + CEXTooShallow map[string]bool `json:"cexTooShallow"` +} + +func newBotProblems() *BotProblems { + // *** Each map added here must be copied in the copy method *** + 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), + CEXTooShallow: make(map[string]bool), + } +} + +func (bp *BotProblems) copy() *BotProblems { + copy := *bp + copy.WalletNotSynced = utils.CopyMap(bp.WalletNotSynced) + copy.NoWalletPeers = utils.CopyMap(bp.NoWalletPeers) + copy.DepositErr = utils.CopyMap(bp.DepositErr) + copy.WithdrawErr = utils.CopyMap(bp.WithdrawErr) + copy.DEXBalanceDeficiencies = utils.CopyMap(bp.DEXBalanceDeficiencies) + copy.CEXBalanceDeficiencies = utils.CopyMap(bp.CEXBalanceDeficiencies) + copy.CEXTooShallow = utils.CopyMap(bp.CEXTooShallow) + 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 @@ -229,13 +328,16 @@ 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() + problems = rb.problems() } status.Bots = append(status.Bots, &BotStatus{ 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 8fe0a8ce42..828ab078ba 100644 --- a/client/mm/mm_arb_market_maker.go +++ b/client/mm/mm_arb_market_maker.go @@ -13,6 +13,7 @@ import ( "decred.org/dcrdex/client/core" "decred.org/dcrdex/client/mm/libxc" + "decred.org/dcrdex/client/orderbook" "decred.org/dcrdex/dex" "decred.org/dcrdex/dex/calc" "decred.org/dcrdex/dex/order" @@ -235,20 +236,18 @@ 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, cexTooShallow map[string]bool, err error) { + cexTooShallow = make(map[string]bool) + + 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 { @@ -258,6 +257,7 @@ func (a *arbMarketMaker) ordersToPlace() (buys, sells []*multiTradePlacement) { } if !filled { + cexTooShallow[sellStr(!sellOnDEX)] = true a.log.Infof("CEX %s side has < %s on the orderbook.", sellStr(!sellOnDEX), a.fmtBase(cumulativeCEXDepth)) newPlacements = append(newPlacements, &multiTradePlacement{ rate: 0, @@ -268,12 +268,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{ @@ -283,11 +278,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 } @@ -325,13 +324,36 @@ func (a *arbMarketMaker) distribution() (dist *distribution, err error) { return dist, nil } +func (a *arbMarketMaker) updateBotLoopProblems(buyErr, sellErr, determinePlacementsErr error, cexTooShallow map[string]bool, + dexDefs, cexDefs map[uint32]uint64) { + a.updateBotProblems(func(problems *BotProblems) { + clearBotProblemErrors(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 + problems.CEXTooShallow = cexTooShallow + }) +} + // 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) { +func (a *arbMarketMaker) rebalance(epoch uint64, book *orderbook.OrderBook) { if !a.rebalanceRunning.CompareAndSwap(false, true) { return } @@ -344,6 +366,10 @@ func (a *arbMarketMaker) rebalance(epoch uint64) { } a.currEpoch.Store(epoch) + if !a.checkBotHealth() { + return + } + actionTaken, err := a.tryTransfers(currEpoch) if err != nil { a.log.Errorf("Error performing transfers: %v", err) @@ -353,17 +379,49 @@ func (a *arbMarketMaker) rebalance(epoch uint64) { return } - buys, sells := a.ordersToPlace() - buyInfos := a.multiTrade(buys, false, a.cfg().DriftTolerance, currEpoch) - sellInfos := a.multiTrade(sells, true, a.cfg().DriftTolerance, currEpoch) - a.matchesMtx.Lock() - for oid, info := range buyInfos { - a.pendingOrders[oid] = info.counterTradeRate + var buyErr, sellErr, determinePlacementsErr error + var dexDefs, cexDefs map[uint32]uint64 + var cexTooShallow map[string]bool + defer func() { + a.updateBotLoopProblems(buyErr, sellErr, determinePlacementsErr, cexTooShallow, dexDefs, cexDefs) + }() + + buyOrders, sellOrders, cexTooShallow, determinePlacementsErr := a.ordersToPlace() + if determinePlacementsErr != nil { + a.tryCancelOrders(a.ctx, &epoch, false) + return + } + + buys, buyDEXDefs, buyCEXDefs, buyErr := a.multiTrade(buyOrders, false, a.cfg().DriftTolerance, currEpoch) + for id, ord := range buys { + a.matchesMtx.Lock() + a.pendingOrders[id] = ord.counterTradeRate + a.matchesMtx.Unlock() + } + + sells, sellDEXDefs, sellCEXDefs, sellErr := a.multiTrade(sellOrders, true, a.cfg().DriftTolerance, currEpoch) + for id, ord := range sells { + a.matchesMtx.Lock() + a.pendingOrders[id] = ord.counterTradeRate + a.matchesMtx.Unlock() } - for oid, info := range sellInfos { - a.pendingOrders[oid] = info.counterTradeRate + + 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 } - a.matchesMtx.Unlock() + for assetID, def := range sellCEXDefs { + cexDefs[assetID] += def + } + + a.updateBotLoopProblems(buyErr, sellErr, determinePlacementsErr, cexTooShallow, dexDefs, cexDefs) a.cancelExpiredCEXTrades() @@ -379,7 +437,7 @@ func (a *arbMarketMaker) tryTransfers(currEpoch uint64) (actionTaken bool, err e return a.transfer(dist, currEpoch) } -func feeGap(core botCoreAdaptor, cex botCexAdaptor, baseID, quoteID uint32, lotSize uint64) (*FeeGapStats, error) { +func feeGap(core botCoreAdaptor, cex libxc.CEX, baseID, quoteID uint32, lotSize uint64) (*FeeGapStats, error) { s := &FeeGapStats{ BasisPrice: cex.MidGap(baseID, quoteID), } @@ -413,7 +471,7 @@ func feeGap(core botCoreAdaptor, cex botCexAdaptor, baseID, quoteID uint32, lotS } func (a *arbMarketMaker) registerFeeGap() { - feeGap, err := feeGap(a.core, a.cex, a.baseID, a.quoteID, a.lotSize) + feeGap, err := feeGap(a.core, a.CEX, a.baseID, a.quoteID, a.lotSize) if err != nil { a.log.Warnf("error getting fee-gap stats: %v", err) return @@ -446,7 +504,7 @@ func (a *arbMarketMaker) botLoop(ctx context.Context) (*sync.WaitGroup, error) { case ni := <-bookFeed.Next(): switch epoch := ni.Payload.(type) { case *core.ResolvedEpoch: - a.rebalance(epoch.Current) + a.rebalance(epoch.Current, book) } case <-ctx.Done(): return diff --git a/client/mm/mm_arb_market_maker_test.go b/client/mm/mm_arb_market_maker_test.go index ab63639780..fe1888156d 100644 --- a/client/mm/mm_arb_market_maker_test.go +++ b/client/mm/mm_arb_market_maker_test.go @@ -5,13 +5,17 @@ package mm import ( "context" + "errors" + "reflect" "sync" "testing" "decred.org/dcrdex/client/core" "decred.org/dcrdex/client/mm/libxc" + "decred.org/dcrdex/client/orderbook" "decred.org/dcrdex/dex/calc" "decred.org/dcrdex/dex/encode" + "decred.org/dcrdex/dex/msgjson" "decred.org/dcrdex/dex/order" ) @@ -41,12 +45,13 @@ func TestArbMMRebalance(t *testing.T) { u.CEX = cex u.botCfgV.Store(&BotConfig{}) c := newTCore() + c.setWalletsAndExchange(mkt) u.clientCore = c - u.autoRebalanceCfg = &AutoRebalanceConfig{} u.fiatRates.Store(map[uint32]float64{baseID: 1, quoteID: 1}) a := &arbMarketMaker{ unifiedExchangeAdaptor: u, cex: newTBotCEXAdaptor(), + core: newTBotCoreAdaptor(c), pendingOrders: make(map[order.OrderID]uint64), } a.buyFees = &orderFees{ @@ -71,7 +76,6 @@ func TestArbMMRebalance(t *testing.T) { } var buyLots, sellLots, minDexBase, minCexBase /* totalBase, */, minDexQuote, minCexQuote /*, totalQuote */ uint64 - // var perLot *lotCosts setLots := func(buy, sell uint64) { buyLots, sellLots = buy, sell a.placementLotsV.Store(&placementLots{ @@ -123,6 +127,12 @@ func TestArbMMRebalance(t *testing.T) { } checkPlacements := func(ps ...*expectedPlacement) { + t.Helper() + + if len(ps) != len(c.multiTradesPlaced) { + t.Fatalf("expected %d placements, got %d", len(ps), len(c.multiTradesPlaced)) + } + var n int for _, ord := range c.multiTradesPlaced { for _, pl := range ord.Placements { @@ -150,28 +160,29 @@ func TestArbMMRebalance(t *testing.T) { setBals(baseID, minDexBase, minCexBase) setBals(quoteID, minDexQuote, minCexQuote) - a.rebalance(epoch()) + a.rebalance(epoch(), &orderbook.OrderBook{}) checkPlacements(ep(false, buyRate, 1), ep(true, sellRate, 1)) // base balance too low setBals(baseID, minDexBase-1, minCexBase) - a.rebalance(epoch()) + a.rebalance(epoch(), &orderbook.OrderBook{}) checkPlacements(ep(false, buyRate, 1)) // quote balance too low setBals(baseID, minDexBase, minCexBase) setBals(quoteID, minDexQuote-1, minCexQuote) - a.rebalance(epoch()) + a.rebalance(epoch(), &orderbook.OrderBook{}) checkPlacements(ep(true, sellRate, 1)) // cex quote balance too low. Can't place sell. setBals(quoteID, minDexQuote, minCexQuote-1) - a.rebalance(epoch()) + a.rebalance(epoch(), &orderbook.OrderBook{}) checkPlacements(ep(false, buyRate, 1)) // cex base balance too low. Can't place buy. setBals(baseID, minDexBase, minCexBase-1) setBals(quoteID, minDexQuote, minCexQuote) + a.rebalance(epoch(), &orderbook.OrderBook{}) checkPlacements(ep(true, sellRate, 1)) } @@ -304,6 +315,7 @@ func TestArbMarketMakerDEXUpdates(t *testing.T) { cexTrades: make(map[string]uint64), pendingOrders: test.pendingOrders, } + arbMM.CEX = newTCEX() arbMM.ctx = ctx arbMM.setBotLoop(arbMM.botLoop) arbMM.cfgV.Store(&ArbMarketMakerConfig{ @@ -422,6 +434,214 @@ func TestDEXPlacementRate(t *testing.T) { } } +func TestArbMMBotProblems(t *testing.T) { + const baseID, quoteID = 42, 0 + const lotSize uint64 = 5e9 + const sellSwapFees, sellRedeemFees = 3e6, 1e6 + const buySwapFees, buyRedeemFees = 2e5, 1e5 + const buyRate, sellRate = 1e7, 1.1e7 + + type test struct { + name string + multiTradeBuyErr error + multiTradeSellErr error + cexTooShallow bool + noBalance bool + + expBotProblems *BotProblems + } + + noIDErr1 := errors.New("no ID") + noIDErr2 := errors.New("no ID") + + updateBotProblems := func(f func(*BotProblems)) *BotProblems { + bp := newBotProblems() + f(bp) + return bp + } + + tests := []*test{ + { + name: "no problems", + expBotProblems: newBotProblems(), + }, + { + name: "wallet sync errors", + multiTradeBuyErr: &core.WalletSyncError{AssetID: baseID}, + multiTradeSellErr: &core.WalletSyncError{AssetID: quoteID}, + expBotProblems: updateBotProblems(func(bp *BotProblems) { + bp.WalletNotSynced[baseID] = true + bp.WalletNotSynced[quoteID] = 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: baseID}, + multiTradeSellErr: &msgjson.Error{Code: msgjson.OrderQuantityTooHigh}, + expBotProblems: updateBotProblems(func(bp *BotProblems) { + bp.NoWalletPeers[baseID] = true + bp.UserLimitTooLow = true + }), + }, + { + name: "unidentified errors", + multiTradeBuyErr: noIDErr1, + multiTradeSellErr: noIDErr2, + expBotProblems: updateBotProblems(func(bp *BotProblems) { + bp.PlaceBuyOrdersErr = noIDErr1 + bp.PlaceSellOrdersErr = noIDErr2 + }), + }, + { + name: "CEX too shallow", + cexTooShallow: true, + expBotProblems: updateBotProblems(func(bp *BotProblems) { + bp.CEXTooShallow = map[string]bool{ + "buy": true, + "sell": true, + } + }), + }, + { + name: "no balance", + noBalance: true, + expBotProblems: updateBotProblems(func(bp *BotProblems) { + bp.DEXBalanceDeficiencies = map[uint32]uint64{ + baseID: lotSize + sellSwapFees, + quoteID: calc.BaseToQuote(sellRate, lotSize) + buySwapFees, + } + bp.CEXBalanceDeficiencies = map[uint32]uint64{ + baseID: lotSize, + quoteID: calc.BaseToQuote(buyRate, lotSize), + } + }), + }, + } + + runTest := func(tt *test) { + t.Run(tt.name, func(t *testing.T) { + cex := newTCEX() + mkt := &core.Market{ + RateStep: 1e3, + AtomToConv: 1, + LotSize: lotSize, + BaseID: baseID, + QuoteID: quoteID, + } + u := mustParseAdaptorFromMarket(mkt) + u.CEX = cex + u.botCfgV.Store(&BotConfig{}) + c := newTCore() + c.setWalletsAndExchange(mkt) + u.clientCore = c + u.autoRebalanceCfg = &AutoRebalanceConfig{} + u.fiatRates.Store(map[uint32]float64{baseID: 1, quoteID: 1}) + a := &arbMarketMaker{ + unifiedExchangeAdaptor: u, + core: newTBotCoreAdaptor(c), + cex: newTBotCEXAdaptor(), + pendingOrders: make(map[order.OrderID]uint64), + } + + cfg := &ArbMarketMakerConfig{ + Profit: 0, + BuyPlacements: []*ArbMarketMakingPlacement{ + { + Lots: 1, + Multiplier: 1, + }, + }, + SellPlacements: []*ArbMarketMakingPlacement{ + { + Lots: 1, + Multiplier: 1, + }, + }, + } + + var lots uint64 = 1 + if tt.cexTooShallow { + lots++ + cfg.BuyPlacements = append(cfg.BuyPlacements, &ArbMarketMakingPlacement{ + Lots: 1, + Multiplier: 1, + }) + cfg.SellPlacements = append(cfg.SellPlacements, &ArbMarketMakingPlacement{ + Lots: 1, + Multiplier: 1, + }) + } + + a.cfgV.Store(cfg) + a.placementLotsV.Store(&placementLots{ + baseLots: lots, + quoteLots: lots, + }) + + cex.asksVWAP[lotSize] = vwapResult{ + avg: buyRate, + extrema: buyRate, + } + cex.bidsVWAP[lotSize] = vwapResult{ + avg: sellRate, + extrema: sellRate, + } + + if !tt.noBalance { + setBals := func(assetID uint32, dexBal, cexBal uint64) { + a.baseDexBalances[assetID] = int64(dexBal) + a.baseCexBalances[assetID] = int64(cexBal) + } + setBals(baseID, 1e10, 1e10) + setBals(quoteID, 1e10, 1e10) + } + + a.unifiedExchangeAdaptor.clientCore.(*tCore).multiTradeBuyErr = tt.multiTradeBuyErr + a.unifiedExchangeAdaptor.clientCore.(*tCore).multiTradeSellErr = tt.multiTradeSellErr + + a.buyFees = &orderFees{ + LotFeeRange: &LotFeeRange{ + Max: &LotFees{ + Redeem: buyRedeemFees, + Swap: buySwapFees, + }, + Estimated: &LotFees{}, + }, + bookingFeesPerLot: buySwapFees, + } + a.sellFees = &orderFees{ + LotFeeRange: &LotFeeRange{ + Max: &LotFees{ + Redeem: sellRedeemFees, + Swap: sellSwapFees, + }, + Estimated: &LotFees{}, + }, + bookingFeesPerLot: sellSwapFees, + } + + a.rebalance(1, &orderbook.OrderBook{}) + + problems := a.problems() + if !reflect.DeepEqual(tt.expBotProblems, problems) { + t.Fatalf("expected bot problems %v, got %v", tt.expBotProblems, problems) + } + }) + } + + for _, test := range tests { + runTest(test) + } +} + func mustParseMarket(m *core.Market) *market { mkt, err := parseMarket("host.com", m) if err != nil { @@ -431,7 +651,10 @@ func mustParseMarket(m *core.Market) *market { } func mustParseAdaptorFromMarket(m *core.Market) *unifiedExchangeAdaptor { - return &unifiedExchangeAdaptor{ + tCore := newTCore() + tCore.setWalletsAndExchange(m) + + u := &unifiedExchangeAdaptor{ ctx: context.Background(), market: mustParseMarket(m), log: tLogger, @@ -443,7 +666,17 @@ func mustParseAdaptorFromMarket(m *core.Market) *unifiedExchangeAdaptor { eventLogDB: newTEventLogDB(), pendingDeposits: make(map[string]*pendingDeposit), pendingWithdrawals: make(map[string]*pendingWithdrawal), + botProblems: newBotProblems(), + clientCore: tCore, } + + u.botCfgV.Store(&BotConfig{ + Host: u.host, + BaseID: u.baseID, + QuoteID: u.quoteID, + }) + + return u } func mustParseAdaptor(cfg *exchangeAdaptorCfg) *unifiedExchangeAdaptor { diff --git a/client/mm/mm_basic.go b/client/mm/mm_basic.go index ae20c815da..2a940b4d5c 100644 --- a/client/mm/mm_basic.go +++ b/client/mm/mm_basic.go @@ -139,7 +139,7 @@ func (c *BasicMarketMakingConfig) Validate() error { } type basicMMCalculator interface { - basisPrice() uint64 + basisPrice() (bp uint64, err error) halfSpread(uint64) (uint64, error) feeGapStats(uint64) (*FeeGapStats, error) } @@ -152,6 +152,10 @@ 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") +var errOracleFiatMismatch = errors.New("oracle rate and fiat rate mismatch") + // 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 @@ -162,7 +166,7 @@ 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() (uint64, error) { oracleRate := b.msgRate(b.oracle.getMarketPrice(b.baseID, b.quoteID)) b.log.Tracef("oracle rate = %s", b.fmtRate(oracleRate)) @@ -172,15 +176,15 @@ func (b *basicMMCalculatorImpl) basisPrice() uint64 { "No fiat-based rate estimate(s) available for sanity check for %s", b.market.name, ) if oracleRate == 0 { // steppedRate(0, x) => x, so we have to handle this. - return 0 + return 0, errNoBasisPrice } - return steppedRate(oracleRate, b.rateStep) + return steppedRate(oracleRate, b.rateStep), nil } if oracleRate == 0 { b.log.Meter("basisPrice_nooracle_"+b.market.name, time.Hour).Infof( "No oracle rate available. Using fiat-derived basis rate = %s for %s", b.fmtRate(rateFromFiat), b.market.name, ) - return steppedRate(rateFromFiat, b.rateStep) + return steppedRate(rateFromFiat, b.rateStep), nil } mismatch := math.Abs((float64(oracleRate) - float64(rateFromFiat)) / float64(oracleRate)) const maxOracleFiatMismatch = 0.05 @@ -189,10 +193,10 @@ func (b *basicMMCalculatorImpl) basisPrice() uint64 { "Oracle rate sanity check failed for %s. oracle rate = %s, rate from fiat = %s", b.market.name, b.market.fmtRate(oracleRate), b.market.fmtRate(rateFromFiat), ) - return 0 + return 0, errOracleFiatMismatch } - return steppedRate(oracleRate, b.rateStep) + return steppedRate(oracleRate, b.rateStep), nil } // halfSpread calculates the distance from the mid-gap where if you sell a lot @@ -319,9 +323,9 @@ func (m *basicMarketMaker) orderPrice(basisPrice, feeAdj uint64, sell bool, gapF } func (m *basicMarketMaker) ordersToPlace() (buyOrders, sellOrders []*multiTradePlacement, err error) { - basisPrice := m.calculator.basisPrice() - if basisPrice == 0 { - return nil, nil, fmt.Errorf("no basis price available") + basisPrice, err := m.calculator.basisPrice() + if err != nil { + return nil, nil, err } feeGap, err := m.calculator.feeGapStats(basisPrice) @@ -367,6 +371,28 @@ func (m *basicMarketMaker) ordersToPlace() (buyOrders, sellOrders []*multiTradeP return buyOrders, sellOrders, nil } +// updateBotProblems updates the bot problems based on the errors encountered +// during the bot loop. +func (m *basicMarketMaker) updateBotLoopProblems(buyErr, sellErr, determinePlacementsErr error, dexDefs map[uint32]uint64) { + m.unifiedExchangeAdaptor.updateBotProblems(func(problems *BotProblems) { + clearBotProblemErrors(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 + }) +} + func (m *basicMarketMaker) rebalance(newEpoch uint64) { if !m.rebalanceRunning.CompareAndSwap(false, true) { return @@ -375,15 +401,32 @@ func (m *basicMarketMaker) rebalance(newEpoch uint64) { m.log.Tracef("rebalance: epoch %d", newEpoch) - buyOrders, sellOrders, err := m.ordersToPlace() - if err != nil { - m.log.Errorf("error calculating orders to place: %v. cancelling all orders", err) + if !m.checkBotHealth() { + return + } + + var buyErr, sellErr, determinePlacementsErr error + var dexDefs map[uint32]uint64 + defer func() { + m.updateBotLoopProblems(buyErr, sellErr, determinePlacementsErr, dexDefs) + }() + + buyOrders, sellOrders, determinePlacementsErr := m.ordersToPlace() + if determinePlacementsErr != nil { m.tryCancelOrders(m.ctx, &newEpoch, false) return } - m.multiTrade(buyOrders, false, m.cfg().DriftTolerance, newEpoch) - m.multiTrade(sellOrders, true, m.cfg().DriftTolerance, newEpoch) + _, buyDEXDefs, _, buyErr := m.multiTrade(buyOrders, false, m.cfg().DriftTolerance, newEpoch) + _, sellDEXDefs, _, sellErr := m.multiTrade(sellOrders, true, m.cfg().DriftTolerance, newEpoch) + + dexDefs = make(map[uint32]uint64) + for k, v := range buyDEXDefs { + dexDefs[k] += v + } + for k, v := range sellDEXDefs { + dexDefs[k] += v + } } func (m *basicMarketMaker) botLoop(ctx context.Context) (*sync.WaitGroup, error) { diff --git a/client/mm/mm_basic_test.go b/client/mm/mm_basic_test.go index 624df55aaa..6e5ec3a431 100644 --- a/client/mm/mm_basic_test.go +++ b/client/mm/mm_basic_test.go @@ -3,22 +3,27 @@ package mm import ( + "errors" "math" + "reflect" "testing" "decred.org/dcrdex/client/core" "decred.org/dcrdex/dex/calc" + "decred.org/dcrdex/dex/msgjson" ) type tBasicMMCalculator struct { - bp uint64 + bp uint64 + bpErr error + hs uint64 } var _ basicMMCalculator = (*tBasicMMCalculator)(nil) -func (r *tBasicMMCalculator) basisPrice() uint64 { - return r.bp +func (r *tBasicMMCalculator) basisPrice() (uint64, error) { + return r.bp, r.bpErr } func (r *tBasicMMCalculator) halfSpread(basisPrice uint64) (uint64, error) { return r.hs, nil @@ -27,7 +32,6 @@ func (r *tBasicMMCalculator) halfSpread(basisPrice uint64) (uint64, error) { func (r *tBasicMMCalculator) feeGapStats(basisPrice uint64) (*FeeGapStats, error) { return &FeeGapStats{FeeGap: r.hs * 2}, nil } - func TestBasisPrice(t *testing.T) { mkt := &core.Market{ RateStep: 1, @@ -85,7 +89,7 @@ func TestBasisPrice(t *testing.T) { core: adaptor, } - rate := calculator.basisPrice() + rate, _ := calculator.basisPrice() if rate != tt.exp { t.Fatalf("%s: %d != %d", tt.name, rate, tt.exp) } @@ -331,6 +335,10 @@ func TestBasicMMRebalance(t *testing.T) { calculator: calculator, } tcore := newTCore() + tcore.setWalletsAndExchange(&core.Market{ + BaseID: baseID, + QuoteID: quoteID, + }) mm.clientCore = tcore mm.botCfgV.Store(&BotConfig{}) mm.fiatRates.Store(map[uint32]float64{baseID: 1, quoteID: 1}) @@ -406,3 +414,145 @@ 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 + const baseID, quoteID = uint32(42), uint32(0) + const lotSize = uint64(1e8) + + type test struct { + name string + bpErr error + multiTradeBuyErr error + multiTradeSellErr error + noBalance bool + + expBotProblems *BotProblems + } + + updateBotProblems := func(f func(*BotProblems)) *BotProblems { + bp := newBotProblems() + f(bp) + return bp + } + + noIDErr1 := errors.New("no ID") + noIDErr2 := errors.New("no ID") + + var swapFees, redeemFees, refundFees uint64 = 1e5, 2e5, 3e5 + + tests := []*test{ + { + name: "no problems", + expBotProblems: newBotProblems(), + }, + { + name: "mid gap outside oracle safe range", + bpErr: errOracleFiatMismatch, + expBotProblems: updateBotProblems(func(bp *BotProblems) { + bp.OracleFiatMismatch = true + }), + }, + { + name: "wallet sync errors", + multiTradeBuyErr: &core.WalletSyncError{AssetID: baseID}, + multiTradeSellErr: &core.WalletSyncError{AssetID: quoteID}, + expBotProblems: updateBotProblems(func(bp *BotProblems) { + bp.WalletNotSynced[baseID] = true + bp.WalletNotSynced[quoteID] = 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: baseID}, + multiTradeSellErr: &msgjson.Error{Code: msgjson.OrderQuantityTooHigh}, + expBotProblems: updateBotProblems(func(bp *BotProblems) { + bp.NoWalletPeers[baseID] = true + bp.UserLimitTooLow = true + }), + }, + { + name: "unidentified errors", + multiTradeBuyErr: noIDErr1, + multiTradeSellErr: noIDErr2, + expBotProblems: updateBotProblems(func(bp *BotProblems) { + bp.PlaceBuyOrdersErr = noIDErr1 + bp.PlaceSellOrdersErr = noIDErr2 + }), + }, + { + name: "no balance", + noBalance: true, + expBotProblems: updateBotProblems(func(bp *BotProblems) { + bp.DEXBalanceDeficiencies = map[uint32]uint64{ + baseID: lotSize + swapFees, + quoteID: calc.BaseToQuote(basisPrice-basisPrice/100, lotSize) + swapFees, + } + }), + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + calculator := &tBasicMMCalculator{ + bp: basisPrice, + bpErr: tt.bpErr, + hs: halfSpread, + } + + adaptor := newTBotCoreAdaptor(newTCore()) + mm := &basicMarketMaker{ + unifiedExchangeAdaptor: mustParseAdaptorFromMarket(&core.Market{ + RateStep: rateStep, + AtomToConv: atomToConv, + BaseID: baseID, + QuoteID: quoteID, + LotSize: lotSize, + }), + calculator: calculator, + core: adaptor, + } + + mm.buyFees = tFees(swapFees, redeemFees, refundFees, 0) + mm.sellFees = tFees(swapFees, redeemFees, refundFees, 0) + + mm.unifiedExchangeAdaptor.clientCore.(*tCore).multiTradeBuyErr = tt.multiTradeBuyErr + mm.unifiedExchangeAdaptor.clientCore.(*tCore).multiTradeSellErr = tt.multiTradeSellErr + + if !tt.noBalance { + mm.baseDexBalances[baseID] = int64(lotSize * 50) + mm.baseDexBalances[quoteID] = int64(calc.BaseToQuote(basisPrice, lotSize*50)) + } + + mm.cfgV.Store(&BasicMarketMakingConfig{ + GapStrategy: GapStrategyPercent, + SellPlacements: []*OrderPlacement{ + {Lots: 1, GapFactor: 0.01}, + }, + BuyPlacements: []*OrderPlacement{ + {Lots: 1, GapFactor: 0.01}, + }, + }) + + mm.unifiedExchangeAdaptor.fiatRates.Store(map[uint32]float64{baseID: 1, quoteID: 1}) + + mm.rebalance(100) + + problems := mm.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 ace464b65a..cc37b934f5 100644 --- a/client/mm/mm_simple_arb.go +++ b/client/mm/mm_simple_arb.go @@ -82,42 +82,55 @@ func (a *simpleArbMarketMaker) cfg() *SimpleArbConfig { } // arbExists checks if an arbitrage opportunity exists. -func (a *simpleArbMarketMaker) arbExists() (exists, sellOnDex bool, lotsToArb, dexRate, cexRate uint64) { +func (a *simpleArbMarketMaker) arbExists() (exists, sellOnDex bool, lotsToArb, dexRate, cexRate uint64, dexDefs, cexDefs map[uint32]uint64, err error) { sellOnDex = false - exists, lotsToArb, dexRate, cexRate = a.arbExistsOnSide(sellOnDex) - if exists { + exists, lotsToArb, dexRate, cexRate, buyDexDefs, buyCexDefs, err := a.arbExistsOnSide(sellOnDex) + if err != nil || exists { return } sellOnDex = true - exists, lotsToArb, dexRate, cexRate = a.arbExistsOnSide(sellOnDex) + exists, lotsToArb, dexRate, cexRate, sellDexDefs, sellCexDefs, err := a.arbExistsOnSide(sellOnDex) + if err != nil || exists { + return + } + + dexDefs = make(map[uint32]uint64) + cexDefs = make(map[uint32]uint64) + for assetID, qty := range buyDexDefs { + dexDefs[assetID] += qty + } + for assetID, qty := range sellDexDefs { + dexDefs[assetID] += qty + } + for assetID, qty := range buyCexDefs { + cexDefs[assetID] += qty + } + for assetID, qty := range sellCexDefs { + cexDefs[assetID] += qty + } + return } // arbExistsOnSide checks if an arbitrage opportunity exists either when // buying or selling on the dex. -func (a *simpleArbMarketMaker) arbExistsOnSide(sellOnDEX bool) (exists bool, lotsToArb, dexRate, cexRate uint64) { - noArb := func() (bool, uint64, uint64, uint64) { - return false, 0, 0, 0 - } - +func (a *simpleArbMarketMaker) arbExistsOnSide(sellOnDEX bool) (exists bool, lotsToArb, dexRate, cexRate uint64, dexDefs, cexDefs map[uint32]uint64, err error) { lotSize := a.lotSize var prevProfit uint64 for numLots := uint64(1); ; numLots++ { dexAvg, dexExtrema, dexFilled, err := a.book.VWAP(numLots, a.lotSize, !sellOnDEX) if err != nil { - a.log.Errorf("error calculating dex VWAP: %v", err) - break + return false, 0, 0, 0, nil, nil, fmt.Errorf("error calculating dex VWAP: %w", err) } if !dexFilled { break } - cexAvg, cexExtrema, cexFilled, err := a.cex.VWAP(a.baseID, a.quoteID, sellOnDEX, numLots*lotSize) + cexAvg, cexExtrema, cexFilled, err := a.CEX.VWAP(a.baseID, a.quoteID, sellOnDEX, numLots*lotSize) if err != nil { - a.log.Errorf("error calculating cex VWAP: %v", err) - break + return false, 0, 0, 0, nil, nil, fmt.Errorf("error calculating cex VWAP: %w", err) } if !cexFilled { break @@ -135,32 +148,33 @@ func (a *simpleArbMarketMaker) arbExistsOnSide(sellOnDEX bool) (exists bool, lot buyAvg = dexAvg sellAvg = cexAvg } - if buyRate >= sellRate { + + // For 1 lots, check balances in order to add insufficient balances to BotProblems + if buyRate >= sellRate && numLots > 1 { break } - enough, err := a.core.SufficientBalanceForDEXTrade(dexExtrema, numLots*lotSize, sellOnDEX) + dexSufficient, dexDefs, err := a.core.SufficientBalanceForDEXTrade(dexExtrema, numLots*lotSize, sellOnDEX) if err != nil { - a.log.Errorf("error checking sufficient balance: %v", err) - break - } - if !enough { - break + return false, 0, 0, 0, nil, nil, fmt.Errorf("error checking dex balance: %w", err) } - 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 + cexSufficient, cexDefs := a.cex.SufficientBalanceForCEXTrade(a.baseID, a.quoteID, !sellOnDEX, cexExtrema, numLots*lotSize) + if !dexSufficient || !cexSufficient { + if numLots == 1 { + return false, 0, 0, 0, dexDefs, cexDefs, nil + } else { + break + } } - if !enough { + + if buyRate >= sellRate /* && numLots == 1 */ { break } feesInQuoteUnits, err := a.core.OrderFeesInUnits(sellOnDEX, false, dexAvg) if err != nil { - a.log.Errorf("error calculating fees: %v", err) - break + return false, 0, 0, 0, nil, nil, fmt.Errorf("error getting fees: %w", err) } qty := numLots * lotSize @@ -184,10 +198,10 @@ func (a *simpleArbMarketMaker) arbExistsOnSide(sellOnDEX bool) (exists bool, lot if lotsToArb > 0 { a.log.Infof("arb opportunity - sellOnDex: %t, lotsToArb: %d, dexRate: %s, cexRate: %s: profit: %s", sellOnDEX, lotsToArb, a.fmtRate(dexRate), a.fmtRate(cexRate), a.fmtBase(prevProfit)) - return true, lotsToArb, dexRate, cexRate + return true, lotsToArb, dexRate, cexRate, nil, nil, nil } - return noArb() + return false, 0, 0, 0, nil, nil, nil } // executeArb will execute an arbitrage sequence by placing orders on the dex @@ -354,6 +368,39 @@ func (a *simpleArbMarketMaker) handleDEXOrderUpdate(o *core.Order) { } } +func (a *simpleArbMarketMaker) updateBotLoopProblems(determinePlacementsErr error, dexDefs, cexDefs map[uint32]uint64) { + a.updateBotProblems(func(problems *BotProblems) { + clearBotProblemErrors(problems) + + if !updateBotProblemsBasedOnError(problems, determinePlacementsErr) { + problems.DeterminePlacementsErr = determinePlacementsErr + } + + problems.DEXBalanceDeficiencies = dexDefs + problems.CEXBalanceDeficiencies = cexDefs + }) +} + +func (a *simpleArbMarketMaker) tryArb(newEpoch uint64) (exists, sellOnDEX bool) { + if !(a.checkBotHealth() && a.tradingLimitNotReached()) { + return false, false + } + + exists, sellOnDex, lotsToArb, dexRate, cexRate, dexDefs, cexDefs, determinePlacementsErr := 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", + a.name, exists, sellStr(sellOnDex), lotsToArb, a.fmtRate(dexRate), a.fmtRate(cexRate)) + } + if exists { + // Execution will not happen if it would cause a self-match. + a.executeArb(sellOnDex, lotsToArb, dexRate, cexRate, newEpoch) + } + + a.updateBotLoopProblems(determinePlacementsErr, dexDefs, cexDefs) + + return exists, sellOnDex +} + // 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) { @@ -372,15 +419,7 @@ func (a *simpleArbMarketMaker) rebalance(newEpoch uint64) { 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", - a.name, exists, sellStr(sellOnDex), lotsToArb, a.fmtRate(dexRate), a.fmtRate(cexRate)) - } - if exists { - // Execution will not happen if it would cause a self-match. - a.executeArb(sellOnDex, lotsToArb, dexRate, cexRate, newEpoch) - } + exists, sellOnDex := a.tryArb(newEpoch) a.activeArbsMtx.Lock() remainingArbs := make([]*arbSequence, 0, len(a.activeArbs)) @@ -505,7 +544,7 @@ func (a *simpleArbMarketMaker) botLoop(ctx context.Context) (*sync.WaitGroup, er } func (a *simpleArbMarketMaker) registerFeeGap() { - feeGap, err := feeGap(a.core, a.cex, a.baseID, a.quoteID, a.lotSize) + feeGap, err := feeGap(a.core, a.CEX, a.baseID, a.quoteID, a.lotSize) if err != nil { a.log.Warnf("error getting fee-gap stats: %v", err) return diff --git a/client/mm/mm_simple_arb_test.go b/client/mm/mm_simple_arb_test.go index 8ac88b3d8c..7b12aa789c 100644 --- a/client/mm/mm_simple_arb_test.go +++ b/client/mm/mm_simple_arb_test.go @@ -6,6 +6,7 @@ import ( "errors" "fmt" "math" + "reflect" "testing" "decred.org/dcrdex/client/core" @@ -459,140 +460,144 @@ func TestArbRebalance(t *testing.T) { } runTest := func(test *test) { - cex := newTBotCEXAdaptor() - cex.vwapErr = test.cexVWAPErr - cex.tradeErr = test.cexTradeErr - cex.maxBuyQty = test.cexMaxBuyQty - cex.maxSellQty = test.cexMaxSellQty - - tCore := newTCore() - coreAdaptor := newTBotCoreAdaptor(tCore) - coreAdaptor.buyFeesInQuote = feesInQuoteUnits - coreAdaptor.sellFeesInQuote = feesInQuoteUnits - coreAdaptor.maxBuyQty = test.dexMaxBuyQty - coreAdaptor.maxSellQty = test.dexMaxSellQty - - if test.expectedDexOrder != nil { - coreAdaptor.tradeResult = &core.Order{ - Qty: test.expectedDexOrder.qty, - Rate: test.expectedDexOrder.rate, - Sell: test.expectedDexOrder.sell, + t.Run(test.name, func(t *testing.T) { + cex := newTBotCEXAdaptor() + tcex := newTCEX() + tcex.vwapErr = test.cexVWAPErr + cex.tradeErr = test.cexTradeErr + cex.maxBuyQty = test.cexMaxBuyQty + cex.maxSellQty = test.cexMaxSellQty + + tc := newTCore() + coreAdaptor := newTBotCoreAdaptor(tc) + coreAdaptor.buyFeesInQuote = feesInQuoteUnits + coreAdaptor.sellFeesInQuote = feesInQuoteUnits + coreAdaptor.maxBuyQty = test.dexMaxBuyQty + coreAdaptor.maxSellQty = test.dexMaxSellQty + + if test.expectedDexOrder != nil { + coreAdaptor.tradeResult = &core.Order{ + Qty: test.expectedDexOrder.qty, + Rate: test.expectedDexOrder.rate, + Sell: test.expectedDexOrder.sell, + } } - } - orderBook := &tOrderBook{ - bidsVWAP: make(map[uint64]vwapResult), - asksVWAP: make(map[uint64]vwapResult), - vwapErr: test.dexVWAPErr, - } - for i := range test.books.dexBidsAvg { - orderBook.bidsVWAP[uint64(i+1)] = vwapResult{test.books.dexBidsAvg[i], test.books.dexBidsExtrema[i]} - } - for i := range test.books.dexAsksAvg { - 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)*lotSize] = &vwapResult{test.books.cexBidsAvg[i], test.books.cexBidsExtrema[i]} - } - for i := range test.books.cexAsksAvg { - cex.asksVWAP[uint64(i+1)*lotSize] = &vwapResult{test.books.cexAsksAvg[i], test.books.cexAsksExtrema[i]} - } + orderBook := &tOrderBook{ + bidsVWAP: make(map[uint64]vwapResult), + asksVWAP: make(map[uint64]vwapResult), + vwapErr: test.dexVWAPErr, + } + for i := range test.books.dexBidsAvg { + orderBook.bidsVWAP[uint64(i+1)] = vwapResult{test.books.dexBidsAvg[i], test.books.dexBidsExtrema[i]} + } + for i := range test.books.dexAsksAvg { + orderBook.asksVWAP[uint64(i+1)] = vwapResult{test.books.dexAsksAvg[i], test.books.dexAsksExtrema[i]} + } + for i := range test.books.cexBidsAvg { + tcex.bidsVWAP[uint64(i+1)*lotSize] = vwapResult{test.books.cexBidsAvg[i], test.books.cexBidsExtrema[i]} + } + for i := range test.books.cexAsksAvg { + tcex.asksVWAP[uint64(i+1)*lotSize] = vwapResult{test.books.cexAsksAvg[i], test.books.cexAsksExtrema[i]} + } - a := &simpleArbMarketMaker{ - unifiedExchangeAdaptor: mustParseAdaptorFromMarket(&core.Market{ + u := mustParseAdaptorFromMarket(&core.Market{ LotSize: lotSize, BaseID: baseID, QuoteID: quoteID, RateStep: 1e2, - }), - cex: cex, - core: coreAdaptor, - activeArbs: test.existingArbs, - } - const sellSwapFees, sellRedeemFees = 3e5, 1e5 - const buySwapFees, buyRedeemFees = 2e4, 1e4 - const buyRate, sellRate = 1e7, 1.1e7 - tcex := newTCEX() - a.CEX = tcex - tcex.asksVWAP[lotSize] = vwapResult{avg: buyRate} - tcex.bidsVWAP[lotSize] = vwapResult{avg: sellRate} - a.buyFees = &orderFees{ - LotFeeRange: &LotFeeRange{ - Max: &LotFees{ - Redeem: buyRedeemFees, - }, - Estimated: &LotFees{ - Swap: buySwapFees, - Redeem: buyRedeemFees, - }, - }, - bookingFeesPerLot: buySwapFees, - } - a.sellFees = &orderFees{ - LotFeeRange: &LotFeeRange{ - Max: &LotFees{ - Redeem: sellRedeemFees, + }) + u.clientCore.(*tCore).userParcels = 0 + u.clientCore.(*tCore).parcelLimit = 1 + + a := &simpleArbMarketMaker{ + unifiedExchangeAdaptor: u, + cex: cex, + core: coreAdaptor, + activeArbs: test.existingArbs, + } + const sellSwapFees, sellRedeemFees = 3e5, 1e5 + const buySwapFees, buyRedeemFees = 2e4, 1e4 + const buyRate, sellRate = 1e7, 1.1e7 + a.CEX = tcex + a.buyFees = &orderFees{ + LotFeeRange: &LotFeeRange{ + Max: &LotFees{ + Redeem: buyRedeemFees, + }, + Estimated: &LotFees{ + Swap: buySwapFees, + Redeem: buyRedeemFees, + }, }, - Estimated: &LotFees{ - Swap: sellSwapFees, - Redeem: sellRedeemFees, + bookingFeesPerLot: buySwapFees, + } + a.sellFees = &orderFees{ + LotFeeRange: &LotFeeRange{ + Max: &LotFees{ + Redeem: sellRedeemFees, + }, + Estimated: &LotFees{ + Swap: sellSwapFees, + Redeem: sellRedeemFees, + }, }, - }, - bookingFeesPerLot: sellSwapFees, - } - // arbEngine.setBotLoop(arbEngine.botLoop) - a.cfgV.Store(&SimpleArbConfig{ - ProfitTrigger: profitTrigger, - MaxActiveArbs: maxActiveArbs, - NumEpochsLeaveOpen: numEpochsLeaveOpen, - }) - a.book = orderBook - a.rebalance(currEpoch) - - // Check dex trade - 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.rate != coreAdaptor.lastTradePlaced.rate { - t.Fatalf("%s: expected sell order rate %d but got %d", test.name, test.expectedDexOrder.rate, coreAdaptor.lastTradePlaced.rate) + bookingFeesPerLot: sellSwapFees, } - 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) + // arbEngine.setBotLoop(arbEngine.botLoop) + a.cfgV.Store(&SimpleArbConfig{ + ProfitTrigger: profitTrigger, + MaxActiveArbs: maxActiveArbs, + NumEpochsLeaveOpen: numEpochsLeaveOpen, + }) + a.book = orderBook + a.rebalance(currEpoch) + + // Check dex trade + 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.sell != coreAdaptor.lastTradePlaced.sell { - t.Fatalf("%s: expected sell order sell %v but got %v", test.name, test.expectedDexOrder.sell, coreAdaptor.lastTradePlaced.sell) + if test.expectedDexOrder != nil { + 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.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) + } } - } - // Check cex trade - if (test.expectedCexOrder == nil) != (cex.lastTrade == nil) { - t.Fatalf("%s: expected cex order %v but got %v", test.name, (test.expectedCexOrder != nil), (cex.lastTrade != nil)) - } - if cex.lastTrade != nil && - *cex.lastTrade != *test.expectedCexOrder { - t.Fatalf("%s: cex order %+v != expected %+v", test.name, cex.lastTrade, test.expectedCexOrder) - } + // Check cex trade + if (test.expectedCexOrder == nil) != (cex.lastTrade == nil) { + t.Fatalf("%s: expected cex order %v but got %v", test.name, (test.expectedCexOrder != nil), (cex.lastTrade != nil)) + } + if cex.lastTrade != nil && + *cex.lastTrade != *test.expectedCexOrder { + t.Fatalf("%s: cex order %+v != expected %+v", test.name, cex.lastTrade, test.expectedCexOrder) + } - // Check dex cancels - if len(test.expectedDEXCancels) != len(tCore.cancelsPlaced) { - t.Fatalf("%s: expected %d cancels but got %d", test.name, len(test.expectedDEXCancels), len(tCore.cancelsPlaced)) - } - for i := range test.expectedDEXCancels { - if !bytes.Equal(test.expectedDEXCancels[i], tCore.cancelsPlaced[i][:]) { - t.Fatalf("%s: expected cancel %x but got %x", test.name, test.expectedDEXCancels[i], tCore.cancelsPlaced[i]) + // Check dex cancels + if len(test.expectedDEXCancels) != len(tc.cancelsPlaced) { + t.Fatalf("%s: expected %d cancels but got %d", test.name, len(test.expectedDEXCancels), len(tc.cancelsPlaced)) + } + for i := range test.expectedDEXCancels { + if !bytes.Equal(test.expectedDEXCancels[i], tc.cancelsPlaced[i][:]) { + t.Fatalf("%s: expected cancel %x but got %x", test.name, test.expectedDEXCancels[i], tc.cancelsPlaced[i]) + } } - } - // Check cex cancels - if len(test.expectedCEXCancels) != len(cex.cancelledTrades) { - t.Fatalf("%s: expected %d cex cancels but got %d", test.name, len(test.expectedCEXCancels), len(cex.cancelledTrades)) - } - for i := range test.expectedCEXCancels { - if test.expectedCEXCancels[i] != cex.cancelledTrades[i] { - t.Fatalf("%s: expected cex cancel %s but got %s", test.name, test.expectedCEXCancels[i], cex.cancelledTrades[i]) + // Check cex cancels + if len(test.expectedCEXCancels) != len(cex.cancelledTrades) { + t.Fatalf("%s: expected %d cex cancels but got %d", test.name, len(test.expectedCEXCancels), len(cex.cancelledTrades)) } - } + for i := range test.expectedCEXCancels { + if test.expectedCEXCancels[i] != cex.cancelledTrades[i] { + t.Fatalf("%s: expected cex cancel %s but got %s", test.name, test.expectedCEXCancels[i], cex.cancelledTrades[i]) + } + } + }) } for _, test := range tests { @@ -690,6 +695,8 @@ func TestArbDexTradeUpdates(t *testing.T) { core: coreAdaptor, activeArbs: test.activeArbs, } + arbEngine.clientCore = newTCore() + arbEngine.CEX = newTCEX() arbEngine.ctx = ctx arbEngine.setBotLoop(arbEngine.botLoop) arbEngine.cfgV.Store(&SimpleArbConfig{ @@ -812,6 +819,7 @@ func TestCexTradeUpdates(t *testing.T) { activeArbs: test.activeArbs, } arbEngine.ctx = ctx + arbEngine.CEX = newTCEX() arbEngine.setBotLoop(arbEngine.botLoop) arbEngine.cfgV.Store(&SimpleArbConfig{ ProfitTrigger: 0.01, @@ -847,3 +855,153 @@ func TestCexTradeUpdates(t *testing.T) { runTest(test) } } + +func TestArbBotProblems(t *testing.T) { + const baseID, quoteID = 42, 0 + const lotSize uint64 = 5e9 + const sellSwapFees, sellRedeemFees = 3e6, 1e6 + const buySwapFees, buyRedeemFees = 2e5, 1e5 + const buyRate, sellRate = 1e7, 1.1e7 + + type test struct { + name string + userLimitTooLow bool + dexBalanceDefs map[uint32]uint64 + cexBalanceDefs map[uint32]uint64 + + expBotProblems *BotProblems + } + + updateBotProblems := func(f func(*BotProblems)) *BotProblems { + bp := newBotProblems() + f(bp) + return bp + } + + tests := []*test{ + { + name: "no problems", + expBotProblems: newBotProblems(), + }, + { + name: "user limit too low", + userLimitTooLow: true, + expBotProblems: updateBotProblems(func(bp *BotProblems) { + bp.UserLimitTooLow = true + }), + }, + { + name: "balance deficiencies", + dexBalanceDefs: map[uint32]uint64{ + baseID: lotSize + sellSwapFees, + quoteID: calc.BaseToQuote(buyRate, lotSize) + buySwapFees, + }, + cexBalanceDefs: map[uint32]uint64{ + baseID: lotSize, + quoteID: calc.BaseToQuote(sellRate, lotSize), + }, + expBotProblems: updateBotProblems(func(bp *BotProblems) { + // All these values are multiplied by 2 because the same deficiencies + // are returned for buys and sells, and they are summed. + bp.DEXBalanceDeficiencies = map[uint32]uint64{ + baseID: (lotSize + sellSwapFees) * 2, + quoteID: (calc.BaseToQuote(buyRate, lotSize) + buySwapFees) * 2, + } + bp.CEXBalanceDeficiencies = map[uint32]uint64{ + baseID: lotSize * 2, + quoteID: calc.BaseToQuote(sellRate, lotSize) * 2, + } + }), + }, + } + + runTest := func(tt *test) { + t.Run(tt.name, func(t *testing.T) { + cex := newTCEX() + mkt := &core.Market{ + RateStep: 1e3, + AtomToConv: 1, + LotSize: lotSize, + BaseID: baseID, + QuoteID: quoteID, + } + u := mustParseAdaptorFromMarket(mkt) + u.CEX = cex + u.botCfgV.Store(&BotConfig{}) + c := newTCore() + if !tt.userLimitTooLow { + u.clientCore.(*tCore).userParcels = 0 + u.clientCore.(*tCore).parcelLimit = 1 + } + u.fiatRates.Store(map[uint32]float64{baseID: 1, quoteID: 1}) + cexAdaptor := newTBotCEXAdaptor() + coreAdaptor := newTBotCoreAdaptor(c) + a := &simpleArbMarketMaker{ + unifiedExchangeAdaptor: u, + cex: cexAdaptor, + core: coreAdaptor, + } + + coreAdaptor.balanceDefs = tt.dexBalanceDefs + cexAdaptor.balanceDefs = tt.cexBalanceDefs + + a.cfgV.Store(&SimpleArbConfig{}) + + cex.asksVWAP[lotSize] = vwapResult{ + avg: buyRate, + extrema: buyRate, + } + cex.bidsVWAP[lotSize] = vwapResult{ + avg: sellRate, + extrema: sellRate, + } + + a.book = &tOrderBook{ + bidsVWAP: map[uint64]vwapResult{ + 1: { + avg: buyRate, + extrema: buyRate, + }, + }, + asksVWAP: map[uint64]vwapResult{ + 1: { + avg: sellRate, + extrema: sellRate, + }, + }, + } + + a.buyFees = &orderFees{ + LotFeeRange: &LotFeeRange{ + Max: &LotFees{ + Redeem: buyRedeemFees, + Swap: buySwapFees, + }, + Estimated: &LotFees{}, + }, + bookingFeesPerLot: buySwapFees, + } + a.sellFees = &orderFees{ + LotFeeRange: &LotFeeRange{ + Max: &LotFees{ + Redeem: sellRedeemFees, + Swap: sellSwapFees, + }, + Estimated: &LotFees{}, + }, + bookingFeesPerLot: sellSwapFees, + } + + a.rebalance(1) + + problems := a.problems() + if !reflect.DeepEqual(tt.expBotProblems, problems) { + t.Fatalf("expected bot problems %v, got %v", tt.expBotProblems, problems) + } + }) + } + + for _, test := range tests { + runTest(test) + } +} diff --git a/client/mm/mm_test.go b/client/mm/mm_test.go index 956db9679c..c2be000952 100644 --- a/client/mm/mm_test.go +++ b/client/mm/mm_test.go @@ -73,6 +73,8 @@ type tCore struct { singleLotBuyFees *orderFees singleLotFeesErr error multiTradeResult []*core.Order + multiTradeBuyErr error + multiTradeSellErr error noteFeed chan core.Notification isAccountLocker map[uint32]bool isWithdrawer map[uint32]bool @@ -89,6 +91,10 @@ type tCore struct { walletTxsMtx sync.Mutex walletTxs map[string]*asset.WalletTransaction fiatRates map[uint32]float64 + userParcels uint32 + parcelLimit uint32 + exchange *core.Exchange + walletStates map[uint32]*core.WalletState } func newTCore() *tCore { @@ -102,8 +108,9 @@ 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{}, + walletStates: make(map[uint32]*core.WalletState), } } @@ -145,6 +152,12 @@ func (c *tCore) AssetBalance(assetID uint32) (*core.WalletBalance, error) { return c.assetBalances[assetID], c.assetBalanceErr } func (c *tCore) MultiTrade(pw []byte, forms *core.MultiTradeForm) ([]*core.Order, error) { + if forms.Sell && c.multiTradeSellErr != nil { + return nil, c.multiTradeSellErr + } + if !forms.Sell && c.multiTradeBuyErr != nil { + return nil, c.multiTradeBuyErr + } c.multiTradesPlaced = append(c.multiTradesPlaced, forms) return c.multiTradeResult, nil } @@ -175,9 +188,6 @@ func (c *tCore) Login(pw []byte) error { func (c *tCore) OpenWallet(assetID uint32, pw []byte) error { return nil } -func (c *tCore) User() *core.User { - return nil -} func (c *tCore) WalletTransaction(assetID uint32, txID string) (*asset.WalletTransaction, error) { c.walletTxsMtx.Lock() defer c.walletTxsMtx.Unlock() @@ -191,8 +201,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 c.userParcels, c.parcelLimit, nil } func (c *tCore) Send(pw []byte, assetID uint32, value uint64, address string, subtract bool) (asset.Coin, error) { @@ -216,6 +227,30 @@ func (c *tCore) Order(id dex.Bytes) (*core.Order, error) { return nil, fmt.Errorf("order %s not found", id) } +func (c *tCore) Exchange(host string) (*core.Exchange, error) { + return c.exchange, nil +} + +func (c *tCore) WalletState(assetID uint32) *core.WalletState { + return c.walletStates[assetID] +} + +func (c *tCore) setWalletsAndExchange(m *core.Market) { + c.walletStates[m.BaseID] = &core.WalletState{ + PeerCount: 1, + Synced: true, + } + c.walletStates[m.QuoteID] = &core.WalletState{ + PeerCount: 1, + Synced: true, + } + c.exchange = &core.Exchange{ + Auth: core.ExchangeAuth{ + EffectiveTier: 2, + }, + } +} + func (c *tCore) setAssetBalances(balances map[uint32]uint64) { c.assetBalances = make(map[uint32]*core.WalletBalance) for assetID, bal := range balances { @@ -254,6 +289,7 @@ type tBotCoreAdaptor struct { maxSellQty uint64 lastTradePlaced *dexOrder tradeResult *core.Order + balanceDefs map[uint32]uint64 } func (c *tBotCoreAdaptor) DEXBalance(assetID uint32) (*BotBalance, error) { @@ -294,11 +330,11 @@ func (c *tBotCoreAdaptor) OrderFeesInUnits(sell, base bool, rate uint64) (uint64 return c.buyFeesInQuote, nil } -func (c *tBotCoreAdaptor) SufficientBalanceForDEXTrade(rate, qty uint64, sell bool) (bool, error) { +func (c *tBotCoreAdaptor) SufficientBalanceForDEXTrade(rate, qty uint64, sell bool) (bool, map[uint32]uint64, error) { if sell { - return qty <= c.maxSellQty, nil + return qty <= c.maxSellQty, c.balanceDefs, nil } - return qty <= c.maxBuyQty, nil + return qty <= c.maxBuyQty, c.balanceDefs, nil } func (c *tBotCoreAdaptor) DEXTrade(rate, qty uint64, sell bool) (*core.Order, error) { @@ -312,6 +348,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, @@ -529,9 +569,6 @@ type prepareRebalanceResult struct { } type tBotCexAdaptor struct { - bidsVWAP map[uint64]*vwapResult - asksVWAP map[uint64]*vwapResult - vwapErr error balances map[uint32]*BotBalance balanceErr error tradeID string @@ -542,12 +579,11 @@ type tBotCexAdaptor struct { tradeUpdates chan *libxc.Trade maxBuyQty uint64 maxSellQty uint64 + balanceDefs map[uint32]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), @@ -592,32 +628,12 @@ func (c *tBotCexAdaptor) CEXTrade(ctx context.Context, baseID, quoteID uint32, s 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 - } - - if sell { - res, found := c.asksVWAP[qty] - if !found { - return 0, 0, false, nil - } - return res.avg, res.extrema, true, nil - } - - res, found := c.bidsVWAP[qty] - if !found { - return 0, 0, false, nil - } - return res.avg, res.extrema, true, nil - -} func (c *tBotCexAdaptor) MidGap(baseID, quoteID uint32) uint64 { return 0 } -func (c *tBotCexAdaptor) SufficientBalanceForCEXTrade(baseID, quoteID uint32, sell bool, rate, qty uint64) (bool, error) { +func (c *tBotCexAdaptor) SufficientBalanceForCEXTrade(baseID, quoteID uint32, sell bool, rate, qty uint64) (bool, map[uint32]uint64) { if sell { - return qty <= c.maxSellQty, nil + return qty <= c.maxSellQty, c.balanceDefs } - return qty <= c.maxBuyQty, nil + return qty <= c.maxBuyQty, c.balanceDefs } func (c *tBotCexAdaptor) Book() (_, _ []*core.MiniOrder, _ error) { return nil, nil, nil } @@ -663,6 +679,7 @@ func (t *tExchangeAdaptor) Book() (buys, sells []*core.MiniOrder, _ error) { func (t *tExchangeAdaptor) sendStatsUpdate() {} func (t *tExchangeAdaptor) withPause(func() error) error { return nil } func (t *tExchangeAdaptor) botCfg() *BotConfig { return t.cfg } +func (t *tExchangeAdaptor) problems() *BotProblems { return nil } func TestAvailableBalances(t *testing.T) { ctx, cancel := context.WithCancel(context.Background()) diff --git a/client/mm/utils.go b/client/mm/utils.go index 8a1ce24268..772f836807 100644 --- a/client/mm/utils.go +++ b/client/mm/utils.go @@ -1,6 +1,13 @@ package mm -import "math" +import ( + "errors" + "math" + + "decred.org/dcrdex/client/core" + "decred.org/dcrdex/client/mm/libxc" + "decred.org/dcrdex/dex/msgjson" +) // steppedRate rounds the rate to the nearest integer multiple of the step. // The minimum returned value is step. @@ -11,3 +18,74 @@ func steppedRate(r, step uint64) uint64 { } return uint64(math.Round(steps * float64(step))) } + +// clearBotProblemErrors clears all the problems that may be set in +// updateBotProblemsBasedOnError. +func clearBotProblemErrors(problems *BotProblems) { + problems.UserLimitTooLow = false + problems.WalletNotSynced = map[uint32]bool{} + problems.NoWalletPeers = map[uint32]bool{} + problems.AccountSuspended = false + problems.NoOracleAvailable = false + problems.EmptyMarket = false + problems.CEXOrderbookUnsynced = false + problems.OracleFiatMismatch = false +} + +// updateBotProblemsBasedOnError updates BotProblems based on an error +// encountered during market making. True is returned if the error maps +// to a known problem. +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 + } + + var mErr *msgjson.Error + if errors.As(err, &mErr) && mErr.Code == msgjson.OrderQuantityTooHigh { + 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 + } + + if errors.Is(err, errOracleFiatMismatch) { + problems.OracleFiatMismatch = true + return true + } + + return false +} diff --git a/client/webserver/site/src/js/mmutil.ts b/client/webserver/site/src/js/mmutil.ts index 08ce0856b7..ecc5484b49 100644 --- a/client/webserver/site/src/js/mmutil.ts +++ b/client/webserver/site/src/js/mmutil.ts @@ -1035,3 +1035,7 @@ export function feesAndCommit ( return { commit, fees } } + +window.mmstatus = function () : Promise { + return MM.status() +} diff --git a/client/webserver/site/src/js/registry.ts b/client/webserver/site/src/js/registry.ts index 1974d98c6e..7897230ce7 100644 --- a/client/webserver/site/src/js/registry.ts +++ b/client/webserver/site/src/js/registry.ts @@ -4,6 +4,7 @@ declare global { enableLogger: (loggerID: string, enable: boolean) => void recordLogger: (loggerID: string, enable: boolean) => void dumpLogger: (loggerID: string) => void + mmstatus: () => Promise testFormatFourSigFigs: () => void testFormatRateFullPrecision: () => void user: () => User