From 4a25b4afc858aa29eba9e2bb324c2d57f01491a4 Mon Sep 17 00:00:00 2001 From: Marton Date: Wed, 30 Oct 2024 14:20:02 +0100 Subject: [PATCH] mm: Epoch reporting (#2808) This diff updates the market makers to generate a report of their activities during each epoch. If the rates of each placement is unable to be determined, this is reported as a `PreOrderProblem`, otherwise an `OrderReport` is generated for the orders placed on each side of the market containing information such as lots the bot is required to place, the current number of lots standing, the number of lots booked, the balances required and used for each placement, etc. This information is all displayed on the UI. A bug is also fixed in this diff. Previously, on a call to `core.MultiTrade`, it was possible for some of the orders to be placed, and some of them to result in an error. In this case, an error was returned from `core.MultiTrade`, and the market makers would assume that none of the trades were placed. Now a `core.MultiTradeResult` is returned for each of the requested orders in the `MultiTrade` call containing either the order or an error. --- client/cmd/testbinance/main.go | 1 + client/core/bookie.go | 10 + client/core/core.go | 100 +- client/core/core_test.go | 114 +- client/core/errors.go | 23 + client/core/trade.go | 73 + client/mm/exchange_adaptor.go | 634 ++++++--- client/mm/exchange_adaptor_test.go | 1398 ++++++++++++++++--- client/mm/libxc/binance.go | 2 +- client/mm/libxc/interface.go | 1 + client/mm/mm.go | 161 ++- client/mm/mm_arb_market_maker.go | 102 +- client/mm/mm_arb_market_maker_test.go | 46 +- client/mm/mm_basic.go | 57 +- client/mm/mm_basic_test.go | 119 +- client/mm/mm_simple_arb.go | 108 +- client/mm/mm_simple_arb_test.go | 392 ++++-- client/mm/mm_test.go | 108 +- client/mm/notification.go | 38 + client/mm/utils.go | 61 +- client/rpcserver/handlers.go | 17 +- client/rpcserver/rpcserver.go | 2 +- client/rpcserver/rpcserver_test.go | 4 +- client/rpcserver/types.go | 1 + client/webserver/jsintl.go | 36 + client/webserver/locales/en-us.go | 20 + client/webserver/site/src/css/market.scss | 8 + client/webserver/site/src/css/mm.scss | 8 + client/webserver/site/src/html/forms.tmpl | 128 +- client/webserver/site/src/html/markets.tmpl | 4 + client/webserver/site/src/html/mm.tmpl | 4 + client/webserver/site/src/js/app.ts | 20 +- client/webserver/site/src/js/forms.ts | 4 +- client/webserver/site/src/js/locales.ts | 18 + client/webserver/site/src/js/markets.ts | 54 +- client/webserver/site/src/js/mm.ts | 22 +- client/webserver/site/src/js/mmutil.ts | 354 ++++- client/webserver/site/src/js/registry.ts | 76 + dex/utils/generics.go | 7 + 39 files changed, 3511 insertions(+), 824 deletions(-) diff --git a/client/cmd/testbinance/main.go b/client/cmd/testbinance/main.go index 68c19b2d67..3f4c7b896a 100644 --- a/client/cmd/testbinance/main.go +++ b/client/cmd/testbinance/main.go @@ -431,6 +431,7 @@ func (f *fakeBinance) run(ctx context.Context) { case <-ctx.Done(): return } + f.withdrawalHistoryMtx.Lock() for transferID, withdraw := range f.withdrawalHistory { if withdraw.txID.Load() != nil { 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 841caf3d32..25fd76fc06 100644 --- a/client/core/core.go +++ b/client/core/core.go @@ -5753,34 +5753,36 @@ func (c *Core) PreOrder(form *TradeForm) (*OrderEstimate, error) { }, nil } +// MultiTradeResult is returned from MultiTrade. Some orders may be placed +// successfully, while others may fail. +type MultiTradeResult struct { + Order *Order + Error error +} + // MultiTrade is used to place multiple standing limit orders on the same // side of the same market simultaneously. -func (c *Core) MultiTrade(pw []byte, form *MultiTradeForm) ([]*Order, error) { +func (c *Core) MultiTrade(pw []byte, form *MultiTradeForm) []*MultiTradeResult { + results := make([]*MultiTradeResult, len(form.Placements)) reqs, err := c.prepareMultiTradeRequests(pw, form) if err != nil { - return nil, err + for i := range results { + results[i] = &MultiTradeResult{Error: err} + } + return results } - orders := make([]*Order, 0, len(reqs)) - - for _, req := range reqs { - // return last error below if none of the orders succeeded + for i, req := range reqs { var corder *Order corder, err = c.sendTradeRequest(req) if err != nil { - c.log.Errorf("failed to send trade request: %v", err) + results[i] = &MultiTradeResult{Error: err} continue } - orders = append(orders, corder) - } - if len(orders) < len(reqs) { - c.log.Errorf("failed to send %d of %d trade requests", len(reqs)-len(orders), len(reqs)) - } - if len(orders) == 0 { - return nil, err + results[i] = &MultiTradeResult{Order: corder} } - return orders, nil + return results } // TxHistory returns all the transactions a wallet has made. If refID @@ -5915,7 +5917,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) @@ -5952,12 +5954,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 } @@ -10901,3 +10901,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 98e519caec..d470d38b54 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, @@ -11075,6 +11075,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 1c30e850dc..b82db39125 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 1821935968..e69489b8d0 100644 --- a/client/core/trade.go +++ b/client/core/trade.go @@ -3819,6 +3819,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..3688781d71 100644 --- a/client/mm/exchange_adaptor.go +++ b/client/mm/exchange_adaptor.go @@ -35,30 +35,27 @@ type BotBalance struct { Reserved uint64 `json:"reserved"` } -// multiTradePlacement represents a placement to be made on a DEX order book -// using the MultiTrade function. A non-zero counterTradeRate indicates that -// the bot intends to make a counter-trade on a CEX when matches are made on -// the DEX, and this must be taken into consideration in combination with the -// bot's balance on the CEX when deciding how many lots to place. This -// information is also used when considering deposits and withdrawals. -type multiTradePlacement struct { - lots uint64 - rate uint64 - counterTradeRate uint64 +func (b *BotBalance) copy() *BotBalance { + return &BotBalance{ + Available: b.Available, + Locked: b.Locked, + Pending: b.Pending, + Reserved: b.Reserved, + } } -// orderFees represents the fees that will be required for a single lot of a +// OrderFees represents the fees that will be required for a single lot of a // dex order. -type orderFees struct { +type OrderFees struct { *LotFeeRange - funding uint64 + Funding uint64 `json:"funding"` // bookingFeesPerLot is the amount of fee asset that needs to be reserved // for fees, per ordered lot. For all assets, this will include // LotFeeRange.Max.Swap. For non-token EVM assets (eth, matic) Max.Refund // will be added. If the asset is the parent chain of a token counter-asset, // Max.Redeem is added. This is a commonly needed sum in various validation // and optimization functions. - bookingFeesPerLot uint64 + BookingFeesPerLot uint64 `json:"bookingFeesPerLot"` } // botCoreAdaptor is an interface used by bots to access DEX related @@ -74,7 +71,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 +84,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) } @@ -433,8 +429,8 @@ type unifiedExchangeAdaptor struct { subscriptionID *int feesMtx sync.RWMutex - buyFees *orderFees - sellFees *orderFees + buyFees *OrderFees + sellFees *OrderFees startTime atomic.Int64 eventLogID atomic.Uint64 @@ -471,6 +467,11 @@ type unifiedExchangeAdaptor struct { } feeGapStats atomic.Value } + + epochReport atomic.Value // *EpochReport + + cexProblemsMtx sync.RWMutex + cexProblems *CEXProblems } var _ botCoreAdaptor = (*unifiedExchangeAdaptor)(nil) @@ -612,7 +613,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 +625,57 @@ func (u *unifiedExchangeAdaptor) SufficientBalanceForDEXTrade(rate, qty uint64, buyFees, sellFees, err := u.orderFees() if err != nil { - return false, err - } - fees, fundingFees := buyFees.Max, buyFees.funding - if sell { - fees, fundingFees = sellFees.Max, sellFees.funding + return false, nil, err } - if balances[fromFeeAsset] < fundingFees { - return false, nil + reqBals := make(map[uint32]uint64) + + // Funding Fees + fees, fundingFees := buyFees.Max, buyFees.Funding + if sell { + fees, fundingFees = sellFees.Max, sellFees.Funding } - balances[fromFeeAsset] -= fundingFees + reqBals[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 +687,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 @@ -921,7 +931,7 @@ func withinTolerance(rate, target uint64, driftTolerance float64) bool { return rate >= lowerBound && rate <= upperBound } -func (u *unifiedExchangeAdaptor) placeMultiTrade(placements []*dexOrderInfo, sell bool) ([]*core.Order, error) { +func (u *unifiedExchangeAdaptor) placeMultiTrade(placements []*dexOrderInfo, sell bool) []*core.MultiTradeResult { corePlacements := make([]*core.QtyRate, 0, len(placements)) for _, p := range placements { corePlacements = append(corePlacements, p.placement) @@ -958,16 +968,19 @@ func (u *unifiedExchangeAdaptor) placeMultiTrade(placements []*dexOrderInfo, sel u.balancesMtx.Lock() defer u.balancesMtx.Unlock() - orders, err := u.clientCore.MultiTrade([]byte{}, multiTradeForm) - if err != nil { - return nil, err - } + results := u.clientCore.MultiTrade([]byte{}, multiTradeForm) - if len(placements) < len(orders) { - return nil, fmt.Errorf("unexpected number of orders. expected at most %d, got %d", len(placements), len(orders)) + if len(placements) != len(results) { + u.log.Errorf("unexpected number of results. expected %d, got %d", len(placements), len(results)) + return results } - for i, o := range orders { + for i, res := range results { + if res.Error != nil { + continue + } + + o := res.Order var orderID order.OrderID copy(orderID[:], o.ID) @@ -1010,7 +1023,89 @@ func (u *unifiedExchangeAdaptor) placeMultiTrade(placements []*dexOrderInfo, sel newPendingDEXOrders = append(newPendingDEXOrders, u.pendingDEXOrders[orderID]) } - return orders, nil + return results +} + +// TradePlacement represents a placement to be made on a DEX order book +// using the MultiTrade function. A non-zero counterTradeRate indicates that +// the bot intends to make a counter-trade on a CEX when matches are made on +// the DEX, and this must be taken into consideration in combination with the +// bot's balance on the CEX when deciding how many lots to place. This +// information is also used when considering deposits and withdrawals. +type TradePlacement struct { + Rate uint64 `json:"rate"` + Lots uint64 `json:"lots"` + StandingLots uint64 `json:"standingLots"` + OrderedLots uint64 `json:"orderedLots"` + CounterTradeRate uint64 `json:"counterTradeRate"` + RequiredDEX map[uint32]uint64 `json:"requiredDex"` + RequiredCEX uint64 `json:"requiredCex"` + UsedDEX map[uint32]uint64 `json:"usedDex"` + UsedCEX uint64 `json:"usedCex"` + Order *core.Order `json:"order"` + Error *BotProblems `json:"error"` +} + +func (tp *TradePlacement) setError(err error) { + if err == nil { + tp.Error = nil + return + } + tp.OrderedLots = 0 + tp.UsedDEX = make(map[uint32]uint64) + tp.UsedCEX = 0 + problems := &BotProblems{} + updateBotProblemsBasedOnError(problems, err) + tp.Error = problems +} + +func (tp *TradePlacement) requiredLots() uint64 { + if tp.Lots > tp.StandingLots { + return tp.Lots - tp.StandingLots + } + return 0 +} + +// OrderReport summarizes the results of a MultiTrade operation. +type OrderReport struct { + Placements []*TradePlacement `json:"placements"` + Fees *OrderFees `json:"buyFees"` + AvailableDEXBals map[uint32]*BotBalance `json:"availableDexBals"` + RequiredDEXBals map[uint32]uint64 `json:"requiredDexBals"` + UsedDEXBals map[uint32]uint64 `json:"usedDexBals"` + RemainingDEXBals map[uint32]uint64 `json:"remainingDexBals"` + AvailableCEXBal *BotBalance `json:"availableCexBal"` + RequiredCEXBal uint64 `json:"requiredCexBal"` + UsedCEXBal uint64 `json:"usedCexBal"` + RemainingCEXBal uint64 `json:"remainingCexBal"` + Error *BotProblems `json:"error"` +} + +func (or *OrderReport) setError(err error) { + if or.Error == nil { + or.Error = &BotProblems{} + } + updateBotProblemsBasedOnError(or.Error, err) +} + +func newOrderReport(placements []*TradePlacement) *OrderReport { + for _, p := range placements { + p.StandingLots = 0 + p.OrderedLots = 0 + p.RequiredDEX = make(map[uint32]uint64) + p.UsedDEX = make(map[uint32]uint64) + p.UsedCEX = 0 + p.Order = nil + p.Error = nil + } + + return &OrderReport{ + AvailableDEXBals: make(map[uint32]*BotBalance), + RequiredDEXBals: make(map[uint32]uint64), + RemainingDEXBals: make(map[uint32]uint64), + UsedDEXBals: make(map[uint32]uint64), + Placements: placements, + } } // MultiTrade places multiple orders on the DEX order book. The placements @@ -1041,50 +1136,48 @@ func (u *unifiedExchangeAdaptor) placeMultiTrade(placements []*dexOrderInfo, sel // enough balance to place all of the orders, the lower priority trades that // were made in previous calls will be cancelled. func (u *unifiedExchangeAdaptor) multiTrade( - placements []*multiTradePlacement, + placements []*TradePlacement, sell bool, driftTolerance float64, currEpoch uint64, -) map[order.OrderID]*dexOrderInfo { +) (_ map[order.OrderID]*dexOrderInfo, or *OrderReport) { + or = newOrderReport(placements) if len(placements) == 0 { - return nil + return nil, or } + buyFees, sellFees, err := u.orderFees() if err != nil { - u.log.Errorf("multiTrade: error getting order fees: %v", err) - return nil + or.setError(err) + return nil, or } - - fromID, fromFeeID, toID, toFeeID := orderAssets(u.baseID, u.quoteID, sell) - fees, fundingFees := buyFees.Max, buyFees.funding + or.Fees = buyFees if sell { - fees, fundingFees = sellFees.Max, sellFees.funding + or.Fees = sellFees } + fromID, fromFeeID, toID, toFeeID := orderAssets(u.baseID, u.quoteID, sell) + fees, fundingFees := or.Fees.Max, or.Fees.Funding + // First, determine the amount of balances the bot has available to place // DEX trades taking into account dexReserves. - remainingBalances := map[uint32]uint64{} for _, assetID := range []uint32{fromID, fromFeeID, toID, toFeeID} { - if _, found := remainingBalances[assetID]; !found { - remainingBalances[assetID] = u.DEXBalance(assetID).Available + if _, found := or.RemainingDEXBals[assetID]; !found { + or.AvailableDEXBals[assetID] = u.DEXBalance(assetID).copy() + or.RemainingDEXBals[assetID] = or.AvailableDEXBals[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. - accountForCEXBal := placements[0].counterTradeRate > 0 - var remainingCEXBal uint64 + accountForCEXBal := placements[0].CounterTradeRate > 0 if accountForCEXBal { - remainingCEXBal = u.CEXBalance(toID).Available + or.AvailableCEXBal = u.CEXBalance(toID).copy() + or.RemainingCEXBal = or.AvailableCEXBal.Available } cancels := make([]dex.Bytes, 0, len(placements)) + addCancel := func(o *core.Order) { if currEpoch-o.Epoch < 2 { // TODO: check epoch u.log.Debugf("multiTrade: skipping cancel not past free cancel threshold") @@ -1093,44 +1186,31 @@ func (u *unifiedExchangeAdaptor) multiTrade( cancels = append(cancels, o.ID) } - pendingOrders := u.groupedBookedOrders(sell) - - // requiredPlacements is a copy of placements where the lots field is - // adjusted to take into account pending orders that are already on - // the books. - requiredPlacements := make([]*multiTradePlacement, 0, len(placements)) // keptOrders is a list of pending orders that are not being cancelled, in // decreasing order of placementIndex. If the bot doesn't have enough // balance to place an order with a higher priority (lower placementIndex) // then the lower priority orders in this list will be cancelled. keptOrders := make([]*pendingDEXOrder, 0, len(placements)) - for _, p := range placements { - pCopy := *p - requiredPlacements = append(requiredPlacements, &pCopy) - } - for _, groupedOrders := range pendingOrders { + + for _, groupedOrders := range u.groupedBookedOrders(sell) { for _, o := range groupedOrders { order := o.currentState().order - if o.placementIndex >= uint64(len(requiredPlacements)) { + if o.placementIndex >= uint64(len(or.Placements)) { // This will happen if there is a reconfig in which there are // now less placements than before. addCancel(order) continue } - mustCancel := !withinTolerance(order.Rate, placements[o.placementIndex].rate, driftTolerance) - if requiredPlacements[o.placementIndex].lots >= (order.Qty-order.Filled)/u.lotSize { - requiredPlacements[o.placementIndex].lots -= (order.Qty - order.Filled) / u.lotSize - } else { - // This will happen if there is a reconfig in which this - // placement index now requires less lots than before. + mustCancel := !withinTolerance(order.Rate, placements[o.placementIndex].Rate, driftTolerance) + or.Placements[o.placementIndex].StandingLots += (order.Qty - order.Filled) / u.lotSize + if or.Placements[o.placementIndex].StandingLots > or.Placements[o.placementIndex].Lots { mustCancel = true - requiredPlacements[o.placementIndex].lots = 0 } if mustCancel { u.log.Tracef("%s cancel with order rate = %s, placement rate = %s, drift tolerance = %.4f%%", - u.mwh, u.fmtRate(order.Rate), u.fmtRate(placements[o.placementIndex].rate), driftTolerance*100, + u.mwh, u.fmtRate(order.Rate), u.fmtRate(placements[o.placementIndex].Rate), driftTolerance*100, ) addCancel(order) } else { @@ -1168,54 +1248,78 @@ func (u *unifiedExchangeAdaptor) multiTrade( canAffordLots := func(rate, lots, counterTradeRate uint64) bool { dexReq, cexReq := fundingReq(rate, lots, counterTradeRate) for assetID, v := range dexReq { - if remainingBalances[assetID] < v { + if or.RemainingDEXBals[assetID] < v { return false } } - return remainingCEXBal >= cexReq + return or.RemainingCEXBal >= cexReq } - orderInfos := make([]*dexOrderInfo, 0, len(requiredPlacements)) + orderInfos := make([]*dexOrderInfo, 0, len(or.Placements)) + + // Calculate required balances for each placement and the total required. + placementRequired := false + for _, placement := range or.Placements { + if placement.requiredLots() == 0 { + continue + } + placementRequired = true + dexReq, cexReq := fundingReq(placement.Rate, placement.requiredLots(), placement.CounterTradeRate) + for assetID, v := range dexReq { + placement.RequiredDEX[assetID] = v + or.RequiredDEXBals[assetID] += v + } + placement.RequiredCEX = cexReq + or.RequiredCEXBal += cexReq + } + if placementRequired { + or.RequiredDEXBals[fromFeeID] += fundingFees + } - for i, placement := range requiredPlacements { - if placement.lots == 0 { + or.RemainingDEXBals[fromFeeID] = utils.SafeSub(or.RemainingDEXBals[fromFeeID], fundingFees) + for i, placement := range or.Placements { + if placement.requiredLots() == 0 { continue } - if rateCausesSelfMatch(placement.rate) { - u.log.Warnf("multiTrade: rate %d causes self match. Placements should be farther from mid-gap.", placement.rate) + if rateCausesSelfMatch(placement.Rate) { + u.log.Warnf("multiTrade: rate %d causes self match. Placements should be farther from mid-gap.", placement.Rate) + placement.Error = &BotProblems{CausesSelfMatch: true} continue } - searchN := int(placement.lots) + 1 + searchN := int(placement.requiredLots() + 1) lotsPlus1 := sort.Search(searchN, func(lotsi int) bool { - return !canAffordLots(placement.rate, uint64(lotsi), placement.counterTradeRate) + return !canAffordLots(placement.Rate, uint64(lotsi), placement.CounterTradeRate) }) var lotsToPlace uint64 if lotsPlus1 > 1 { lotsToPlace = uint64(lotsPlus1) - 1 - dexReq, cexReq := fundingReq(placement.rate, lotsToPlace, placement.counterTradeRate) - for assetID, v := range dexReq { - remainingBalances[assetID] -= v + placement.UsedDEX, placement.UsedCEX = fundingReq(placement.Rate, lotsToPlace, placement.CounterTradeRate) + placement.OrderedLots = lotsToPlace + for assetID, v := range placement.UsedDEX { + or.RemainingDEXBals[assetID] -= v + or.UsedDEXBals[assetID] += v } - remainingCEXBal -= cexReq + or.RemainingCEXBal -= placement.UsedCEX + or.UsedCEXBal += placement.UsedCEX orderInfos = append(orderInfos, &dexOrderInfo{ placementIndex: uint64(i), - counterTradeRate: placement.counterTradeRate, + counterTradeRate: placement.CounterTradeRate, placement: &core.QtyRate{ Qty: lotsToPlace * u.lotSize, - Rate: placement.rate, + Rate: placement.Rate, }, }) } // If there is insufficient balance to place a higher priority order, // cancel the lower priority orders. - if lotsToPlace < placement.lots { + if lotsToPlace < placement.requiredLots() { u.log.Tracef("multiTrade(%s,%d) out of funds for more placements. %d of %d lots for rate %s", - sellStr(sell), i, lotsToPlace, placement.lots, u.fmtRate(placement.rate)) + sellStr(sell), i, lotsToPlace, placement.requiredLots(), u.fmtRate(placement.Rate)) for _, o := range keptOrders { if o.placementIndex > uint64(i) { order := o.currentState().order @@ -1227,6 +1331,10 @@ func (u *unifiedExchangeAdaptor) multiTrade( } } + if len(orderInfos) > 0 { + or.UsedDEXBals[fromFeeID] += fundingFees + } + for _, cancel := range cancels { if err := u.Cancel(cancel); err != nil { u.log.Errorf("multiTrade: error canceling order %s: %v", cancel, err) @@ -1234,27 +1342,27 @@ 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 - } - + results := u.placeMultiTrade(orderInfos, sell) ordered := make(map[order.OrderID]*dexOrderInfo, len(placements)) - for i, o := range orders { + for i, res := range results { + if res.Error != nil { + or.Placements[orderInfos[i].placementIndex].setError(res.Error) + continue + } var orderID order.OrderID - copy(orderID[:], o.ID) + copy(orderID[:], res.Order.ID) ordered[orderID] = orderInfos[i] } - return ordered + + return ordered, or } - return nil + return nil, or } // 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 } @@ -1271,16 +1379,15 @@ func (u *unifiedExchangeAdaptor) DEXTrade(rate, qty uint64, sell bool) (*core.Or // multiTrade is used instead of Trade because Trade does not support // maxLock. - orders, err := u.placeMultiTrade(placements, sell) - if err != nil { - return nil, err - } - - if len(orders) == 0 { + results := u.placeMultiTrade(placements, sell) + if len(results) == 0 { return nil, fmt.Errorf("no orders placed") } + if results[0].Error != nil { + return nil, results[0].Error + } - return orders[0], nil + return results[0].Order, nil } type BotBalances struct { @@ -1384,7 +1491,6 @@ func (u *unifiedExchangeAdaptor) refreshAllPendingEvents(ctx context.Context) { pendingDeposit.mtx.RLock() id := pendingDeposit.tx.ID pendingDeposit.mtx.RUnlock() - u.confirmDeposit(ctx, id) } @@ -2068,10 +2174,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 +2199,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.updateCEXProblems(cexTradeProblem, u.baseID, err) if err != nil { return nil, err } @@ -2172,15 +2276,16 @@ func (u *unifiedExchangeAdaptor) atomicConversionRateFromFiat(fromID, toID uint3 // OrderFees returns the fees for a buy and sell order. The order fees are for // placing orders on the market specified by the exchangeAdaptorCfg used to // create the unifiedExchangeAdaptor. -func (u *unifiedExchangeAdaptor) orderFees() (buyFees, sellFees *orderFees, err error) { +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 +2297,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 { @@ -2239,7 +2345,7 @@ func (u *unifiedExchangeAdaptor) OrderFeesInUnits(sell, base bool, rate uint64) // threshold. If cancelCEXOrders is true, it will also cancel CEX orders. True // is returned if all orders have been cancelled. If cancelCEXOrders is false, // false will always be returned. -func (u *unifiedExchangeAdaptor) tryCancelOrders(ctx context.Context, epoch *uint64, cancelCEXOrders bool) bool { +func (u *unifiedExchangeAdaptor) tryCancelOrders(ctx context.Context, epoch *uint64, cancelCEXOrders bool) ([]dex.Bytes, bool) { u.balancesMtx.RLock() defer u.balancesMtx.RUnlock() @@ -2252,6 +2358,8 @@ func (u *unifiedExchangeAdaptor) tryCancelOrders(ctx context.Context, epoch *uin return *epoch-orderEpoch >= 2 } + cancels := make([]dex.Bytes, 0, len(u.pendingDEXOrders)) + for _, pendingOrder := range u.pendingDEXOrders { o := pendingOrder.currentState().order @@ -2269,12 +2377,14 @@ func (u *unifiedExchangeAdaptor) tryCancelOrders(ctx context.Context, epoch *uin err := u.clientCore.Cancel(o.ID) if err != nil { u.log.Errorf("Error canceling order %s: %v", o.ID, err) + } else { + cancels = append(cancels, o.ID) } } } if !cancelCEXOrders { - return false + return cancels, false } for _, pendingOrder := range u.pendingCEXOrders { @@ -2301,7 +2411,7 @@ func (u *unifiedExchangeAdaptor) tryCancelOrders(ctx context.Context, epoch *uin } } - return done + return cancels, done } func (u *unifiedExchangeAdaptor) cancelAllOrders(ctx context.Context) { @@ -2321,7 +2431,7 @@ func (u *unifiedExchangeAdaptor) cancelAllOrders(ctx context.Context) { } currentEpoch := book.CurrentEpoch() - if u.tryCancelOrders(ctx, ¤tEpoch, true) { + if _, done := u.tryCancelOrders(ctx, ¤tEpoch, true); done { return } @@ -2335,7 +2445,7 @@ func (u *unifiedExchangeAdaptor) cancelAllOrders(ctx context.Context) { case ni := <-bookFeed.Next(): switch epoch := ni.Payload.(type) { case *core.ResolvedEpoch: - if u.tryCancelOrders(ctx, &epoch.Current, true) { + if _, done := u.tryCancelOrders(ctx, &epoch.Current, true); done { return } timer.Reset(timeout) @@ -2687,21 +2797,21 @@ func (u *unifiedExchangeAdaptor) lotCosts(sellVWAP, buyVWAP uint64) (*lotCosts, } perLot.dexBase = u.lotSize if u.baseID == u.baseFeeID { - perLot.dexBase += sellFees.bookingFeesPerLot + perLot.dexBase += sellFees.BookingFeesPerLot } perLot.cexBase = u.lotSize perLot.baseRedeem = buyFees.Max.Redeem - perLot.baseFunding = sellFees.funding + perLot.baseFunding = sellFees.Funding dexQuoteLot := calc.BaseToQuote(sellVWAP, u.lotSize) cexQuoteLot := calc.BaseToQuote(buyVWAP, u.lotSize) perLot.dexQuote = dexQuoteLot if u.quoteID == u.quoteFeeID { - perLot.dexQuote += buyFees.bookingFeesPerLot + perLot.dexQuote += buyFees.BookingFeesPerLot } perLot.cexQuote = cexQuoteLot perLot.quoteRedeem = sellFees.Max.Redeem - perLot.quoteFunding = buyFees.funding + perLot.quoteFunding = buyFees.Funding return perLot, nil } @@ -2980,24 +3090,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.updateCEXProblems(cexDepositProblem, u.baseID, 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.updateCEXProblems(cexWithdrawProblem, u.baseID, 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.updateCEXProblems(cexDepositProblem, u.quoteID, 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.updateCEXProblems(cexWithdrawProblem, u.quoteID, err) + if err != nil { return false, fmt.Errorf("error withdrawing quote: %w", err) } } + return true, nil } @@ -3078,7 +3197,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 +3226,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 +3254,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{ @@ -3147,7 +3278,7 @@ func (u *unifiedExchangeAdaptor) updateFeeRates() error { u.feesMtx.Lock() defer u.feesMtx.Unlock() - u.buyFees = &orderFees{ + u.buyFees = &OrderFees{ LotFeeRange: &LotFeeRange{ Max: maxBuyFees, Estimated: &LotFees{ @@ -3156,10 +3287,11 @@ func (u *unifiedExchangeAdaptor) updateFeeRates() error { Refund: estQuoteFees.Refund, }, }, - funding: buyFundingFees, - bookingFeesPerLot: buyBookingFeesPerLot, + Funding: buyFundingFees, + BookingFeesPerLot: buyBookingFeesPerLot, } - u.sellFees = &orderFees{ + + u.sellFees = &OrderFees{ LotFeeRange: &LotFeeRange{ Max: maxSellFees, Estimated: &LotFees{ @@ -3168,11 +3300,11 @@ func (u *unifiedExchangeAdaptor) updateFeeRates() error { Refund: estBaseFees.Refund, }, }, - funding: sellFundingFees, - bookingFeesPerLot: sellBookingFeesPerLot, + Funding: sellFundingFees, + BookingFeesPerLot: sellBookingFeesPerLot, } - return nil + return u.buyFees, u.sellFees, nil } func (u *unifiedExchangeAdaptor) Connect(ctx context.Context) (*sync.WaitGroup, error) { @@ -3180,9 +3312,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 +3363,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 +3598,167 @@ func (u *unifiedExchangeAdaptor) Book() (buys, sells []*core.MiniOrder, _ error) return u.CEX.Book(u.baseID, u.quoteID) } +func (u *unifiedExchangeAdaptor) latestCEXProblems() *CEXProblems { + u.cexProblemsMtx.RLock() + defer u.cexProblemsMtx.RUnlock() + if u.cexProblems == nil { + return nil + } + return u.cexProblems.copy() +} + +func (u *unifiedExchangeAdaptor) latestEpoch() *EpochReport { + reportI := u.epochReport.Load() + if reportI == nil { + return nil + } + return reportI.(*EpochReport) +} + +func (u *unifiedExchangeAdaptor) updateEpochReport(report *EpochReport) { + u.epochReport.Store(report) + u.clientCore.Broadcast(newEpochReportNote(u.host, u.baseID, u.quoteID, report)) +} + +// tradingLimitNotReached returns true if the user has not reached their trading +// limit. If it has, it updates the epoch report with the problems. +func (u *unifiedExchangeAdaptor) tradingLimitNotReached(epochNum uint64) bool { + var tradingLimitReached bool + var err error + defer func() { + if err == nil && !tradingLimitReached { + return + } + + u.updateEpochReport(&EpochReport{ + PreOrderProblems: &BotProblems{ + UserLimitTooLow: tradingLimitReached, + UnknownError: err, + }, + EpochNum: epochNum, + }) + }() + + userParcels, parcelLimit, err := u.clientCore.TradingLimits(u.host) + if err != nil { + return false + } + + tradingLimitReached = userParcels >= parcelLimit + return !tradingLimitReached +} + +type cexProblemType uint16 + +const ( + cexTradeProblem cexProblemType = iota + cexDepositProblem + cexWithdrawProblem +) + +func (u *unifiedExchangeAdaptor) updateCEXProblems(typ cexProblemType, assetID uint32, err error) { + u.cexProblemsMtx.RLock() + existingErrNil := func() bool { + switch typ { + case cexTradeProblem: + return u.cexProblems.TradeErr == nil + case cexDepositProblem: + return u.cexProblems.DepositErr[assetID] == nil + case cexWithdrawProblem: + return u.cexProblems.WithdrawErr[assetID] == nil + default: + return true + } + } + if existingErrNil() && err == nil { + u.cexProblemsMtx.RUnlock() + return + } + u.cexProblemsMtx.RUnlock() + + u.cexProblemsMtx.Lock() + defer u.cexProblemsMtx.Unlock() + + switch typ { + case cexTradeProblem: + if err == nil { + u.cexProblems.TradeErr = nil + } else { + u.cexProblems.TradeErr = newStampedError(err) + } + case cexDepositProblem: + if err == nil { + delete(u.cexProblems.DepositErr, assetID) + } else { + u.cexProblems.DepositErr[assetID] = newStampedError(err) + } + case cexWithdrawProblem: + if err == nil { + delete(u.cexProblems.WithdrawErr, assetID) + } else { + u.cexProblems.WithdrawErr[assetID] = newStampedError(err) + } + } + + u.clientCore.Broadcast(newCexProblemsNote(u.host, u.baseID, u.quoteID, u.cexProblems)) +} + +// checkBotHealth returns true if the bot is healthy and can continue trading. +// If it is not healthy, it updates the epoch report with the problems. +func (u *unifiedExchangeAdaptor) checkBotHealth(epochNum uint64) (healthy bool) { + var err error + var baseAssetNotSynced, baseAssetNoPeers, quoteAssetNotSynced, quoteAssetNoPeers, accountSuspended bool + + defer func() { + if healthy { + return + } + problems := &BotProblems{ + NoWalletPeers: map[uint32]bool{ + u.baseID: baseAssetNoPeers, + u.quoteID: quoteAssetNoPeers, + }, + WalletNotSynced: map[uint32]bool{ + u.baseID: baseAssetNotSynced, + u.quoteID: quoteAssetNotSynced, + }, + AccountSuspended: accountSuspended, + UnknownError: err, + } + u.updateEpochReport(&EpochReport{ + PreOrderProblems: problems, + EpochNum: epochNum, + }) + }() + + 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 +3830,7 @@ func newUnifiedExchangeAdaptor(cfg *exchangeAdaptorCfg) (*unifiedExchangeAdaptor pendingWithdrawals: make(map[string]*pendingWithdrawal), mwh: cfg.mwh, inventoryMods: make(map[uint32]int64), + cexProblems: newCEXProblems(), } adaptor.fiatRates.Store(map[uint32]float64{}) diff --git a/client/mm/exchange_adaptor_test.go b/client/mm/exchange_adaptor_test.go index 23ca49952e..6936415f55 100644 --- a/client/mm/exchange_adaptor_test.go +++ b/client/mm/exchange_adaptor_test.go @@ -85,18 +85,18 @@ func (db *tEventLogDB) runEvents(startTime int64, mkt *MarketWithHost, n uint64, return nil, nil } -func tFees(swap, redeem, refund, funding uint64) *orderFees { +func tFees(swap, redeem, refund, funding uint64) *OrderFees { lotFees := &LotFees{ Swap: swap, Redeem: redeem, Refund: refund, } - return &orderFees{ + return &OrderFees{ LotFeeRange: &LotFeeRange{ Max: lotFees, Estimated: lotFees, }, - funding: funding, + Funding: funding, } } @@ -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) } @@ -541,7 +538,7 @@ func testDistribution(t *testing.T, baseID, quoteID uint32) { buyBookingFees, sellBookingFees := u.bookingFees(maxBuyFees, maxSellFees) - a.buyFees = &orderFees{ + a.buyFees = &OrderFees{ LotFeeRange: &LotFeeRange{ Max: maxBuyFees, Estimated: &LotFees{ @@ -550,10 +547,10 @@ func testDistribution(t *testing.T, baseID, quoteID uint32) { Refund: buyRefundFees, }, }, - funding: buyFundingFees, - bookingFeesPerLot: buyBookingFees, + Funding: buyFundingFees, + BookingFeesPerLot: buyBookingFees, } - a.sellFees = &orderFees{ + a.sellFees = &OrderFees{ LotFeeRange: &LotFeeRange{ Max: maxSellFees, Estimated: &LotFees{ @@ -561,8 +558,8 @@ func testDistribution(t *testing.T, baseID, quoteID uint32) { Redeem: sellRedeemFees, }, }, - funding: sellFundingFees, - bookingFeesPerLot: sellBookingFees, + Funding: sellFundingFees, + BookingFeesPerLot: sellBookingFees, } buyRate, _ := a.dexPlacementRate(buyVWAP, false) @@ -590,7 +587,7 @@ func testDistribution(t *testing.T, baseID, quoteID uint32) { cex.bidsVWAP[lotSize*sellLots] = vwapResult{avg: sellVWAP} minDexBase = sellLots*lotSize + sellFundingFees if baseID == u.baseFeeID { - minDexBase += sellLots * u.sellFees.bookingFeesPerLot + minDexBase += sellLots * a.sellFees.BookingFeesPerLot } if baseID == u.quoteFeeID { addBaseFees += buyRedeemFees * buyLots @@ -600,7 +597,7 @@ func testDistribution(t *testing.T, baseID, quoteID uint32) { minDexQuote = calc.BaseToQuote(buyRate, buyLots*lotSize) + buyFundingFees if quoteID == u.quoteFeeID { - minDexQuote += buyLots * a.buyFees.bookingFeesPerLot + minDexQuote += buyLots * a.buyFees.BookingFeesPerLot } if quoteID == u.baseFeeID { addQuoteFees += sellRedeemFees * sellLots @@ -902,38 +899,40 @@ func TestMultiTrade(t *testing.T) { return edge + rateStep } - sellPlacements := []*multiTradePlacement{ - {lots: 1, rate: 1e7, counterTradeRate: 0.9e7}, - {lots: 2, rate: 2e7, counterTradeRate: 1.9e7}, - {lots: 3, rate: 3e7, counterTradeRate: 2.9e7}, - {lots: 2, rate: 4e7, counterTradeRate: 3.9e7}, + sellPlacements := []*TradePlacement{ + {Lots: 1, Rate: 1e7, CounterTradeRate: 0.9e7}, + {Lots: 2, Rate: 2e7, CounterTradeRate: 1.9e7}, + {Lots: 3, Rate: 3e7, CounterTradeRate: 2.9e7}, + {Lots: 2, Rate: 4e7, CounterTradeRate: 3.9e7}, } + sps := sellPlacements - buyPlacements := []*multiTradePlacement{ - {lots: 1, rate: 4e7, counterTradeRate: 4.1e7}, - {lots: 2, rate: 3e7, counterTradeRate: 3.1e7}, - {lots: 3, rate: 2e7, counterTradeRate: 2.1e7}, - {lots: 2, rate: 1e7, counterTradeRate: 1.1e7}, + buyPlacements := []*TradePlacement{ + {Lots: 1, Rate: 4e7, CounterTradeRate: 4.1e7}, + {Lots: 2, Rate: 3e7, CounterTradeRate: 3.1e7}, + {Lots: 3, Rate: 2e7, CounterTradeRate: 2.1e7}, + {Lots: 2, Rate: 1e7, CounterTradeRate: 1.1e7}, } + bps := buyPlacements // cancelLastPlacement is the same as placements, but with the rate // and lots of the last order set to zero, which should cause pending // orders at that placementIndex to be cancelled. - cancelLastPlacement := func(sell bool) []*multiTradePlacement { - placements := make([]*multiTradePlacement, len(sellPlacements)) + cancelLastPlacement := func(sell bool) []*TradePlacement { + placements := make([]*TradePlacement, len(sellPlacements)) if sell { copy(placements, sellPlacements) } else { copy(placements, buyPlacements) } - placements[len(placements)-1] = &multiTradePlacement{} + placements[len(placements)-1] = &TradePlacement{} return placements } // removeLastPlacement simulates a reconfiguration is which the // last placement is removed. - removeLastPlacement := func(sell bool) []*multiTradePlacement { - placements := make([]*multiTradePlacement, len(sellPlacements)) + removeLastPlacement := func(sell bool) []*TradePlacement { + placements := make([]*TradePlacement, len(sellPlacements)) if sell { copy(placements, sellPlacements) } else { @@ -944,23 +943,23 @@ func TestMultiTrade(t *testing.T) { // reconfigToMorePlacements simulates a reconfiguration in which // the lots allocated to the placement at index 1 is reduced by 1. - reconfigToLessPlacements := func(sell bool) []*multiTradePlacement { - placements := make([]*multiTradePlacement, len(sellPlacements)) + reconfigToLessPlacements := func(sell bool) []*TradePlacement { + placements := make([]*TradePlacement, len(sellPlacements)) if sell { copy(placements, sellPlacements) } else { copy(placements, buyPlacements) } - placements[1] = &multiTradePlacement{ - lots: placements[1].lots - 1, - rate: placements[1].rate, - counterTradeRate: placements[1].counterTradeRate, + placements[1] = &TradePlacement{ + Lots: placements[1].Lots - 1, + Rate: placements[1].Rate, + CounterTradeRate: placements[1].CounterTradeRate, } return placements } pendingOrders := func(sell bool, baseID, quoteID uint32) map[order.OrderID]*pendingDEXOrder { - var placements []*multiTradePlacement + var placements []*TradePlacement if sell { placements = sellPlacements } else { @@ -974,10 +973,10 @@ func TestMultiTrade(t *testing.T) { orders := map[order.OrderID]*core.Order{ orderIDs[0]: { // Should cancel, but cannot due to epoch > currEpoch - 2 - Qty: 1 * lotSize, + Qty: lotSize, Sell: sell, ID: orderIDs[0][:], - Rate: driftToleranceEdge(placements[0].rate, true), + Rate: driftToleranceEdge(placements[0].Rate, true), Epoch: currEpoch - 1, BaseID: baseID, QuoteID: quoteID, @@ -987,7 +986,7 @@ func TestMultiTrade(t *testing.T) { Filled: lotSize, Sell: sell, ID: orderIDs[1][:], - Rate: driftToleranceEdge(placements[1].rate, true), + Rate: driftToleranceEdge(placements[1].Rate, true), Epoch: currEpoch - 2, BaseID: baseID, QuoteID: quoteID, @@ -996,7 +995,7 @@ func TestMultiTrade(t *testing.T) { Qty: lotSize, Sell: sell, ID: orderIDs[2][:], - Rate: driftToleranceEdge(placements[2].rate, false), + Rate: driftToleranceEdge(placements[2].Rate, false), Epoch: currEpoch - 2, BaseID: baseID, QuoteID: quoteID, @@ -1005,7 +1004,7 @@ func TestMultiTrade(t *testing.T) { Qty: lotSize, Sell: sell, ID: orderIDs[3][:], - Rate: driftToleranceEdge(placements[3].rate, true), + Rate: driftToleranceEdge(placements[3].Rate, true), Epoch: currEpoch - 2, BaseID: baseID, QuoteID: quoteID, @@ -1015,19 +1014,19 @@ func TestMultiTrade(t *testing.T) { toReturn := map[order.OrderID]*pendingDEXOrder{ orderIDs[0]: { // Should cancel, but cannot due to epoch > currEpoch - 2 placementIndex: 0, - counterTradeRate: placements[0].counterTradeRate, + counterTradeRate: placements[0].CounterTradeRate, }, orderIDs[1]: { placementIndex: 1, - counterTradeRate: placements[1].counterTradeRate, + counterTradeRate: placements[1].CounterTradeRate, }, orderIDs[2]: { placementIndex: 2, - counterTradeRate: placements[2].counterTradeRate, + counterTradeRate: placements[2].CounterTradeRate, }, orderIDs[3]: { placementIndex: 3, - counterTradeRate: placements[3].counterTradeRate, + counterTradeRate: placements[3].CounterTradeRate, }, } @@ -1119,26 +1118,30 @@ func TestMultiTrade(t *testing.T) { sellDexBalances map[uint32]uint64 sellCexBalances map[uint32]uint64 - sellPlacements []*multiTradePlacement + sellPlacements []*TradePlacement sellPendingOrders map[order.OrderID]*pendingDEXOrder buyCexBalances map[uint32]uint64 buyDexBalances map[uint32]uint64 - buyPlacements []*multiTradePlacement + buyPlacements []*TradePlacement buyPendingOrders map[order.OrderID]*pendingDEXOrder isAccountLocker map[uint32]bool - multiTradeResult []*core.Order - multiTradeResultWithDecrement []*core.Order + multiTradeResult []*core.MultiTradeResult + multiTradeResultWithDecrement []*core.MultiTradeResult expectedOrderIDs []order.OrderID expectedOrderIDsWithDecrement []order.OrderID - expectedSellPlacements []*core.QtyRate - expectedSellPlacementsWithDecrement []*core.QtyRate + expectedSellPlacements []*core.QtyRate + expectedSellPlacementsWithDecrement []*core.QtyRate + expectedSellOrderReport *OrderReport + expectedSellOrderReportWithDEXDecrement *OrderReport - expectedBuyPlacements []*core.QtyRate - expectedBuyPlacementsWithDecrement []*core.QtyRate + expectedBuyPlacements []*core.QtyRate + expectedBuyPlacementsWithDecrement []*core.QtyRate + expectedBuyOrderReport *OrderReport + expectedBuyOrderReportWithDEXDecrement *OrderReport expectedCancels []order.OrderID expectedCancelsWithDecrement []order.OrderID @@ -1152,35 +1155,192 @@ func TestMultiTrade(t *testing.T) { // ---- Sell ---- sellDexBalances: map[uint32]uint64{ - 42: 4*lotSize + 4*sellFees.Max.Swap + sellFees.funding, + 42: 4*lotSize + 4*sellFees.Max.Swap + sellFees.Funding, 0: 0, }, sellCexBalances: map[uint32]uint64{ 42: 0, - 0: b2q(sellPlacements[0].counterTradeRate, lotSize) + - b2q(sellPlacements[1].counterTradeRate, 2*lotSize) + - b2q(sellPlacements[2].counterTradeRate, 3*lotSize) + - b2q(sellPlacements[3].counterTradeRate, 2*lotSize), + 0: b2q(sellPlacements[0].CounterTradeRate, lotSize) + + b2q(sellPlacements[1].CounterTradeRate, 2*lotSize) + + b2q(sellPlacements[2].CounterTradeRate, 3*lotSize) + + b2q(sellPlacements[3].CounterTradeRate, 2*lotSize), }, sellPlacements: sellPlacements, sellPendingOrders: pendingOrders(true, 42, 0), expectedSellPlacements: []*core.QtyRate{ - {Qty: lotSize, Rate: sellPlacements[1].rate}, - {Qty: 2 * lotSize, Rate: sellPlacements[2].rate}, - {Qty: lotSize, Rate: sellPlacements[3].rate}, + {Qty: lotSize, Rate: sellPlacements[1].Rate}, + {Qty: 2 * lotSize, Rate: sellPlacements[2].Rate}, + {Qty: lotSize, Rate: sellPlacements[3].Rate}, + }, + expectedSellOrderReport: &OrderReport{ + Placements: []*TradePlacement{ + {Lots: 1, Rate: sps[0].Rate, CounterTradeRate: sps[0].CounterTradeRate, + StandingLots: 1, + RequiredDEX: map[uint32]uint64{}, + UsedDEX: map[uint32]uint64{}, + RequiredCEX: 0, + UsedCEX: 0, + }, + {Lots: 2, Rate: sps[1].Rate, CounterTradeRate: sps[1].CounterTradeRate, + StandingLots: 1, + RequiredDEX: map[uint32]uint64{ + 42: lotSize + sellFees.Max.Swap, + }, + UsedDEX: map[uint32]uint64{ + 42: lotSize + sellFees.Max.Swap, + }, + RequiredCEX: b2q(sellPlacements[1].CounterTradeRate, lotSize), + UsedCEX: b2q(sellPlacements[1].CounterTradeRate, lotSize), + OrderedLots: 1, + }, + {Lots: 3, Rate: sps[2].Rate, CounterTradeRate: sps[2].CounterTradeRate, + StandingLots: 1, + RequiredDEX: map[uint32]uint64{ + 42: 2 * (lotSize + sellFees.Max.Swap), + }, + UsedDEX: map[uint32]uint64{ + 42: 2 * (lotSize + sellFees.Max.Swap), + }, + RequiredCEX: b2q(sellPlacements[2].CounterTradeRate, 2*lotSize), + UsedCEX: b2q(sellPlacements[2].CounterTradeRate, 2*lotSize), + OrderedLots: 2, + }, + {Lots: 2, Rate: sps[3].Rate, CounterTradeRate: sps[3].CounterTradeRate, + StandingLots: 1, + RequiredDEX: map[uint32]uint64{ + 42: lotSize + sellFees.Max.Swap, + }, + UsedDEX: map[uint32]uint64{ + 42: lotSize + sellFees.Max.Swap, + }, + RequiredCEX: b2q(sellPlacements[3].CounterTradeRate, lotSize), + UsedCEX: b2q(sellPlacements[3].CounterTradeRate, lotSize), + OrderedLots: 1, + }, + }, + Fees: sellFees, + AvailableDEXBals: map[uint32]*BotBalance{ + 42: { + Available: 4*lotSize + 4*sellFees.Max.Swap + sellFees.Funding, + }, + 0: {}, + }, + RequiredDEXBals: map[uint32]uint64{ + 42: 4*lotSize + 4*sellFees.Max.Swap + sellFees.Funding, + }, + RemainingDEXBals: map[uint32]uint64{ + 42: 0, + 0: 0, + }, + AvailableCEXBal: &BotBalance{ + Available: b2q(sellPlacements[1].CounterTradeRate, lotSize) + + b2q(sellPlacements[2].CounterTradeRate, 2*lotSize) + + b2q(sellPlacements[3].CounterTradeRate, lotSize), + Reserved: b2q(sellPlacements[0].CounterTradeRate, lotSize) + + b2q(sellPlacements[1].CounterTradeRate, lotSize) + + b2q(sellPlacements[2].CounterTradeRate, lotSize) + + b2q(sellPlacements[3].CounterTradeRate, lotSize), + }, + RequiredCEXBal: b2q(sellPlacements[1].CounterTradeRate, lotSize) + + b2q(sellPlacements[2].CounterTradeRate, 2*lotSize) + + b2q(sellPlacements[3].CounterTradeRate, lotSize), + RemainingCEXBal: 0, + UsedDEXBals: map[uint32]uint64{ + 42: 4*lotSize + 4*sellFees.Max.Swap + sellFees.Funding, + }, + UsedCEXBal: b2q(sellPlacements[1].CounterTradeRate, lotSize) + + b2q(sellPlacements[2].CounterTradeRate, 2*lotSize) + + b2q(sellPlacements[3].CounterTradeRate, lotSize), }, expectedSellPlacementsWithDecrement: []*core.QtyRate{ - {Qty: lotSize, Rate: sellPlacements[1].rate}, - {Qty: 2 * lotSize, Rate: sellPlacements[2].rate}, + {Qty: lotSize, Rate: sellPlacements[1].Rate}, + {Qty: 2 * lotSize, Rate: sellPlacements[2].Rate}, + }, + expectedSellOrderReportWithDEXDecrement: &OrderReport{ + Placements: []*TradePlacement{ + {Lots: 1, Rate: sps[0].Rate, CounterTradeRate: sps[0].CounterTradeRate, + StandingLots: 1, + RequiredDEX: map[uint32]uint64{}, + UsedDEX: map[uint32]uint64{}, + RequiredCEX: 0, + UsedCEX: 0, + }, + {Lots: 2, Rate: sps[1].Rate, CounterTradeRate: sps[1].CounterTradeRate, + StandingLots: 1, + RequiredDEX: map[uint32]uint64{ + 42: lotSize + sellFees.Max.Swap, + }, + UsedDEX: map[uint32]uint64{ + 42: lotSize + sellFees.Max.Swap, + }, + RequiredCEX: b2q(sellPlacements[1].CounterTradeRate, lotSize), + UsedCEX: b2q(sellPlacements[1].CounterTradeRate, lotSize), + OrderedLots: 1, + }, + {Lots: 3, Rate: sps[2].Rate, CounterTradeRate: sps[2].CounterTradeRate, + StandingLots: 1, + RequiredDEX: map[uint32]uint64{ + 42: 2 * (lotSize + sellFees.Max.Swap), + }, + UsedDEX: map[uint32]uint64{ + 42: 2 * (lotSize + sellFees.Max.Swap), + }, + RequiredCEX: b2q(sellPlacements[2].CounterTradeRate, 2*lotSize), + UsedCEX: b2q(sellPlacements[2].CounterTradeRate, 2*lotSize), + OrderedLots: 2, + }, + {Lots: 2, Rate: sps[3].Rate, CounterTradeRate: sps[3].CounterTradeRate, + StandingLots: 1, + RequiredDEX: map[uint32]uint64{ + 42: lotSize + sellFees.Max.Swap, + }, + UsedDEX: map[uint32]uint64{}, + RequiredCEX: b2q(sellPlacements[3].CounterTradeRate, lotSize), + UsedCEX: 0, + OrderedLots: 0, + }, + }, + Fees: sellFees, + AvailableDEXBals: map[uint32]*BotBalance{ + 42: { + Available: 4*lotSize + 4*sellFees.Max.Swap + sellFees.Funding - 1, + }, + 0: {}, + }, + RequiredDEXBals: map[uint32]uint64{ + 42: 4*lotSize + 4*sellFees.Max.Swap + sellFees.Funding, + }, + RemainingDEXBals: map[uint32]uint64{ + 42: lotSize + sellFees.Max.Swap - 1, + 0: 0, + }, + AvailableCEXBal: &BotBalance{ + Available: b2q(sellPlacements[1].CounterTradeRate, lotSize) + + b2q(sellPlacements[2].CounterTradeRate, 2*lotSize) + + b2q(sellPlacements[3].CounterTradeRate, lotSize), + Reserved: b2q(sellPlacements[0].CounterTradeRate, lotSize) + + b2q(sellPlacements[1].CounterTradeRate, lotSize) + + b2q(sellPlacements[2].CounterTradeRate, lotSize) + + b2q(sellPlacements[3].CounterTradeRate, lotSize), + }, + RequiredCEXBal: b2q(sellPlacements[1].CounterTradeRate, lotSize) + + b2q(sellPlacements[2].CounterTradeRate, 2*lotSize) + + b2q(sellPlacements[3].CounterTradeRate, lotSize), + RemainingCEXBal: b2q(sellPlacements[3].CounterTradeRate, lotSize), + UsedDEXBals: map[uint32]uint64{ + 42: 3*lotSize + 3*sellFees.Max.Swap + sellFees.Funding, + }, + UsedCEXBal: b2q(sellPlacements[1].CounterTradeRate, lotSize) + + b2q(sellPlacements[2].CounterTradeRate, 2*lotSize), }, // ---- Buy ---- buyDexBalances: map[uint32]uint64{ 42: 0, - 0: b2q(buyPlacements[1].rate, lotSize) + - b2q(buyPlacements[2].rate, 2*lotSize) + - b2q(buyPlacements[3].rate, lotSize) + - 4*buyFees.Max.Swap + buyFees.funding, + 0: b2q(buyPlacements[1].Rate, lotSize) + + b2q(buyPlacements[2].Rate, 2*lotSize) + + b2q(buyPlacements[3].Rate, lotSize) + + 4*buyFees.Max.Swap + buyFees.Funding, }, buyCexBalances: map[uint32]uint64{ 42: 8 * lotSize, @@ -1189,29 +1349,185 @@ func TestMultiTrade(t *testing.T) { buyPlacements: buyPlacements, buyPendingOrders: pendingOrders(false, 42, 0), expectedBuyPlacements: []*core.QtyRate{ - {Qty: lotSize, Rate: buyPlacements[1].rate}, - {Qty: 2 * lotSize, Rate: buyPlacements[2].rate}, - {Qty: lotSize, Rate: buyPlacements[3].rate}, + {Qty: lotSize, Rate: buyPlacements[1].Rate}, + {Qty: 2 * lotSize, Rate: buyPlacements[2].Rate}, + {Qty: lotSize, Rate: buyPlacements[3].Rate}, }, expectedBuyPlacementsWithDecrement: []*core.QtyRate{ - {Qty: lotSize, Rate: buyPlacements[1].rate}, - {Qty: 2 * lotSize, Rate: buyPlacements[2].rate}, + {Qty: lotSize, Rate: buyPlacements[1].Rate}, + {Qty: 2 * lotSize, Rate: buyPlacements[2].Rate}, }, - expectedCancels: []order.OrderID{orderIDs[2]}, expectedCancelsWithDecrement: []order.OrderID{orderIDs[2]}, - multiTradeResult: []*core.Order{ - {ID: orderIDs[4][:]}, - {ID: orderIDs[5][:]}, - {ID: orderIDs[6][:]}, + multiTradeResult: []*core.MultiTradeResult{ + {Order: &core.Order{ID: orderIDs[4][:]}}, + {Order: &core.Order{ID: orderIDs[5][:]}}, + {Order: &core.Order{ID: orderIDs[6][:]}}, }, - multiTradeResultWithDecrement: []*core.Order{ - {ID: orderIDs[4][:]}, - {ID: orderIDs[5][:]}, + multiTradeResultWithDecrement: []*core.MultiTradeResult{ + {Order: &core.Order{ID: orderIDs[4][:]}}, + {Order: &core.Order{ID: orderIDs[5][:]}}, }, expectedOrderIDs: []order.OrderID{ orderIDs[4], orderIDs[5], orderIDs[6], }, + expectedBuyOrderReport: &OrderReport{ + Placements: []*TradePlacement{ + {Lots: 1, Rate: bps[0].Rate, CounterTradeRate: bps[0].CounterTradeRate, + StandingLots: 1, + RequiredDEX: map[uint32]uint64{}, + UsedDEX: map[uint32]uint64{}, + RequiredCEX: 0, + UsedCEX: 0, + }, + {Lots: 2, Rate: bps[1].Rate, CounterTradeRate: bps[1].CounterTradeRate, + StandingLots: 1, + RequiredDEX: map[uint32]uint64{ + 0: b2q(buyPlacements[1].Rate, lotSize) + buyFees.Max.Swap, + }, + UsedDEX: map[uint32]uint64{ + 0: b2q(buyPlacements[1].Rate, lotSize) + buyFees.Max.Swap, + }, + RequiredCEX: lotSize, + UsedCEX: lotSize, + OrderedLots: 1, + }, + {Lots: 3, Rate: bps[2].Rate, CounterTradeRate: bps[2].CounterTradeRate, + StandingLots: 1, + RequiredDEX: map[uint32]uint64{ + 0: 2 * (b2q(buyPlacements[2].Rate, lotSize) + buyFees.Max.Swap), + }, + UsedDEX: map[uint32]uint64{ + 0: 2 * (b2q(buyPlacements[2].Rate, lotSize) + buyFees.Max.Swap), + }, + RequiredCEX: 2 * lotSize, + UsedCEX: 2 * lotSize, + OrderedLots: 2, + }, + {Lots: 2, Rate: bps[3].Rate, CounterTradeRate: bps[3].CounterTradeRate, + StandingLots: 1, + RequiredDEX: map[uint32]uint64{ + 0: b2q(buyPlacements[3].Rate, lotSize) + buyFees.Max.Swap, + }, + UsedDEX: map[uint32]uint64{ + 0: b2q(buyPlacements[3].Rate, lotSize) + buyFees.Max.Swap, + }, + RequiredCEX: lotSize, + UsedCEX: lotSize, + OrderedLots: 1, + }, + }, + Fees: buyFees, + AvailableDEXBals: map[uint32]*BotBalance{ + 0: { + Available: b2q(buyPlacements[1].Rate, lotSize) + + b2q(buyPlacements[2].Rate, 2*lotSize) + + b2q(buyPlacements[3].Rate, lotSize) + + 4*buyFees.Max.Swap + buyFees.Funding, + }, + 42: {}, + }, + RequiredDEXBals: map[uint32]uint64{ + 0: b2q(buyPlacements[1].Rate, lotSize) + + b2q(buyPlacements[2].Rate, 2*lotSize) + + b2q(buyPlacements[3].Rate, lotSize) + + 4*buyFees.Max.Swap + buyFees.Funding, + }, + RemainingDEXBals: map[uint32]uint64{ + 42: 0, + 0: 0, + }, + AvailableCEXBal: &BotBalance{ + Available: 4 * lotSize, + Reserved: 4 * lotSize, + }, + RequiredCEXBal: 4 * lotSize, + RemainingCEXBal: 0, + UsedDEXBals: map[uint32]uint64{ + 0: b2q(buyPlacements[1].Rate, lotSize) + + b2q(buyPlacements[2].Rate, 2*lotSize) + + b2q(buyPlacements[3].Rate, lotSize) + + 4*buyFees.Max.Swap + buyFees.Funding, + }, + UsedCEXBal: 4 * lotSize, + }, + expectedBuyOrderReportWithDEXDecrement: &OrderReport{ + Placements: []*TradePlacement{ + {Lots: 1, Rate: bps[0].Rate, CounterTradeRate: bps[0].CounterTradeRate, + StandingLots: 1, + RequiredDEX: map[uint32]uint64{}, + UsedDEX: map[uint32]uint64{}, + RequiredCEX: 0, + UsedCEX: 0, + }, + {Lots: 2, Rate: bps[1].Rate, CounterTradeRate: bps[1].CounterTradeRate, + StandingLots: 1, + RequiredDEX: map[uint32]uint64{ + 0: b2q(buyPlacements[1].Rate, lotSize) + buyFees.Max.Swap, + }, + UsedDEX: map[uint32]uint64{ + 0: b2q(buyPlacements[1].Rate, lotSize) + buyFees.Max.Swap, + }, + RequiredCEX: lotSize, + UsedCEX: lotSize, + OrderedLots: 1, + }, + {Lots: 3, Rate: bps[2].Rate, CounterTradeRate: bps[2].CounterTradeRate, + StandingLots: 1, + RequiredDEX: map[uint32]uint64{ + 0: 2 * (b2q(buyPlacements[2].Rate, lotSize) + buyFees.Max.Swap), + }, + UsedDEX: map[uint32]uint64{ + 0: 2 * (b2q(buyPlacements[2].Rate, lotSize) + buyFees.Max.Swap), + }, + RequiredCEX: 2 * lotSize, + UsedCEX: 2 * lotSize, + OrderedLots: 2, + }, + {Lots: 2, Rate: bps[3].Rate, CounterTradeRate: bps[3].CounterTradeRate, + StandingLots: 1, + RequiredDEX: map[uint32]uint64{ + 0: b2q(buyPlacements[3].Rate, lotSize) + buyFees.Max.Swap, + }, + UsedDEX: map[uint32]uint64{}, + RequiredCEX: lotSize, + UsedCEX: 0, + OrderedLots: 0, + }, + }, + Fees: buyFees, + AvailableDEXBals: map[uint32]*BotBalance{ + 0: { + Available: b2q(buyPlacements[1].Rate, lotSize) + + b2q(buyPlacements[2].Rate, 2*lotSize) + + b2q(buyPlacements[3].Rate, lotSize) + + 4*buyFees.Max.Swap + buyFees.Funding - 1, + }, + 42: {}, + }, + RequiredDEXBals: map[uint32]uint64{ + 0: b2q(buyPlacements[1].Rate, lotSize) + + b2q(buyPlacements[2].Rate, 2*lotSize) + + b2q(buyPlacements[3].Rate, lotSize) + + 4*buyFees.Max.Swap + buyFees.Funding, + }, + RemainingDEXBals: map[uint32]uint64{ + 42: 0, + 0: b2q(buyPlacements[3].Rate, lotSize) + buyFees.Max.Swap - 1, + }, + AvailableCEXBal: &BotBalance{ + Available: 4 * lotSize, + Reserved: 4 * lotSize, + }, + RequiredCEXBal: 4 * lotSize, + RemainingCEXBal: lotSize, + UsedDEXBals: map[uint32]uint64{ + 0: b2q(buyPlacements[1].Rate, lotSize) + + b2q(buyPlacements[2].Rate, 2*lotSize) + + 3*buyFees.Max.Swap + buyFees.Funding, + }, + UsedCEXBal: 3 * lotSize, + }, expectedOrderIDsWithDecrement: []order.OrderID{ orderIDs[4], orderIDs[5], }, @@ -1223,34 +1539,177 @@ func TestMultiTrade(t *testing.T) { // ---- Sell ---- sellDexBalances: map[uint32]uint64{ - 42: 3*lotSize + 3*sellFees.Max.Swap + sellFees.funding, + 42: 3*lotSize + 3*sellFees.Max.Swap + sellFees.Funding, 0: 0, }, sellCexBalances: map[uint32]uint64{ 42: 0, - 0: b2q(sellPlacements[0].counterTradeRate, lotSize) + - b2q(sellPlacements[1].counterTradeRate, 2*lotSize) + - b2q(sellPlacements[2].counterTradeRate, 3*lotSize) + - b2q(sellPlacements[3].counterTradeRate, 2*lotSize), + 0: b2q(sellPlacements[0].CounterTradeRate, lotSize) + + b2q(sellPlacements[1].CounterTradeRate, 2*lotSize) + + b2q(sellPlacements[2].CounterTradeRate, 3*lotSize) + + b2q(sellPlacements[3].CounterTradeRate, 2*lotSize), }, sellPlacements: reconfigToLessPlacements(true), sellPendingOrders: secondPendingOrderNotFilled(true, 42, 0), expectedSellPlacements: []*core.QtyRate{ - // {Qty: lotSize, Rate: sellPlacements[1].rate}, - {Qty: 2 * lotSize, Rate: sellPlacements[2].rate}, - {Qty: lotSize, Rate: sellPlacements[3].rate}, + // {Qty: lotSize, Rate: sellPlacements[1].Rate}, + {Qty: 2 * lotSize, Rate: sellPlacements[2].Rate}, + {Qty: lotSize, Rate: sellPlacements[3].Rate}, + }, + expectedSellOrderReport: &OrderReport{ + Placements: []*TradePlacement{ + {Lots: 1, Rate: sps[0].Rate, CounterTradeRate: sps[0].CounterTradeRate, + StandingLots: 1, + RequiredDEX: map[uint32]uint64{}, + UsedDEX: map[uint32]uint64{}, + RequiredCEX: 0, + UsedCEX: 0, + }, + {Lots: 1, Rate: sps[1].Rate, CounterTradeRate: sps[1].CounterTradeRate, + StandingLots: 2, + RequiredDEX: map[uint32]uint64{}, + UsedDEX: map[uint32]uint64{}, + RequiredCEX: 0, + UsedCEX: 0, + OrderedLots: 0, + }, + {Lots: 3, Rate: sps[2].Rate, CounterTradeRate: sps[2].CounterTradeRate, + StandingLots: 1, + RequiredDEX: map[uint32]uint64{ + 42: 2 * (lotSize + sellFees.Max.Swap), + }, + UsedDEX: map[uint32]uint64{ + 42: 2 * (lotSize + sellFees.Max.Swap), + }, + RequiredCEX: b2q(sellPlacements[2].CounterTradeRate, 2*lotSize), + UsedCEX: b2q(sellPlacements[2].CounterTradeRate, 2*lotSize), + OrderedLots: 2, + }, + {Lots: 2, Rate: sps[3].Rate, CounterTradeRate: sps[3].CounterTradeRate, + StandingLots: 1, + RequiredDEX: map[uint32]uint64{ + 42: lotSize + sellFees.Max.Swap, + }, + UsedDEX: map[uint32]uint64{ + 42: lotSize + sellFees.Max.Swap, + }, + RequiredCEX: b2q(sellPlacements[3].CounterTradeRate, lotSize), + UsedCEX: b2q(sellPlacements[3].CounterTradeRate, lotSize), + OrderedLots: 1, + }, + }, + Fees: sellFees, + AvailableDEXBals: map[uint32]*BotBalance{ + 42: { + Available: 3*lotSize + 3*sellFees.Max.Swap + sellFees.Funding, + }, + 0: {}, + }, + RequiredDEXBals: map[uint32]uint64{ + 42: 3*lotSize + 3*sellFees.Max.Swap + sellFees.Funding, + }, + RemainingDEXBals: map[uint32]uint64{ + 42: 0, + 0: 0, + }, + AvailableCEXBal: &BotBalance{ + Available: b2q(sellPlacements[2].CounterTradeRate, 2*lotSize) + + b2q(sellPlacements[3].CounterTradeRate, lotSize), + Reserved: b2q(sellPlacements[0].CounterTradeRate, lotSize) + + 2*b2q(sellPlacements[1].CounterTradeRate, lotSize) + + b2q(sellPlacements[2].CounterTradeRate, lotSize) + + b2q(sellPlacements[3].CounterTradeRate, lotSize), + }, + RequiredCEXBal: b2q(sellPlacements[2].CounterTradeRate, 2*lotSize) + + b2q(sellPlacements[3].CounterTradeRate, lotSize), + RemainingCEXBal: 0, + UsedDEXBals: map[uint32]uint64{ + 42: 3*lotSize + 3*sellFees.Max.Swap + sellFees.Funding, + }, + UsedCEXBal: b2q(sellPlacements[2].CounterTradeRate, 2*lotSize) + + b2q(sellPlacements[3].CounterTradeRate, lotSize), }, expectedSellPlacementsWithDecrement: []*core.QtyRate{ - // {Qty: lotSize, Rate: sellPlacements[1].rate}, - {Qty: 2 * lotSize, Rate: sellPlacements[2].rate}, + // {Qty: lotSize, Rate: sellPlacements[1].Rate}, + {Qty: 2 * lotSize, Rate: sellPlacements[2].Rate}, + }, + expectedSellOrderReportWithDEXDecrement: &OrderReport{ + Placements: []*TradePlacement{ + {Lots: 1, Rate: sps[0].Rate, CounterTradeRate: sps[0].CounterTradeRate, + StandingLots: 1, + RequiredDEX: map[uint32]uint64{}, + UsedDEX: map[uint32]uint64{}, + RequiredCEX: 0, + UsedCEX: 0, + }, + {Lots: 1, Rate: sps[1].Rate, CounterTradeRate: sps[1].CounterTradeRate, + StandingLots: 2, + RequiredDEX: map[uint32]uint64{}, + UsedDEX: map[uint32]uint64{}, + RequiredCEX: 0, + UsedCEX: 0, + OrderedLots: 0, + }, + {Lots: 3, Rate: sps[2].Rate, CounterTradeRate: sps[2].CounterTradeRate, + StandingLots: 1, + RequiredDEX: map[uint32]uint64{ + 42: 2 * (lotSize + sellFees.Max.Swap), + }, + UsedDEX: map[uint32]uint64{ + 42: 2 * (lotSize + sellFees.Max.Swap), + }, + RequiredCEX: b2q(sellPlacements[2].CounterTradeRate, 2*lotSize), + UsedCEX: b2q(sellPlacements[2].CounterTradeRate, 2*lotSize), + OrderedLots: 2, + }, + {Lots: 2, Rate: sps[3].Rate, CounterTradeRate: sps[3].CounterTradeRate, + StandingLots: 1, + RequiredDEX: map[uint32]uint64{ + 42: lotSize + sellFees.Max.Swap, + }, + UsedDEX: map[uint32]uint64{}, + RequiredCEX: b2q(sellPlacements[3].CounterTradeRate, lotSize), + UsedCEX: 0, + OrderedLots: 0, + }, + }, + Fees: sellFees, + AvailableDEXBals: map[uint32]*BotBalance{ + 42: { + Available: 3*lotSize + 3*sellFees.Max.Swap + sellFees.Funding - 1, + }, + 0: {}, + }, + RequiredDEXBals: map[uint32]uint64{ + 42: 3*lotSize + 3*sellFees.Max.Swap + sellFees.Funding, + }, + RemainingDEXBals: map[uint32]uint64{ + 42: lotSize + sellFees.Max.Swap - 1, + 0: 0, + }, + AvailableCEXBal: &BotBalance{ + Available: b2q(sellPlacements[2].CounterTradeRate, 2*lotSize) + + b2q(sellPlacements[3].CounterTradeRate, lotSize), + Reserved: b2q(sellPlacements[0].CounterTradeRate, lotSize) + + 2*b2q(sellPlacements[1].CounterTradeRate, lotSize) + + b2q(sellPlacements[2].CounterTradeRate, lotSize) + + b2q(sellPlacements[3].CounterTradeRate, lotSize), + }, + RequiredCEXBal: b2q(sellPlacements[2].CounterTradeRate, 2*lotSize) + + b2q(sellPlacements[3].CounterTradeRate, lotSize), + RemainingCEXBal: b2q(sellPlacements[3].CounterTradeRate, lotSize), + UsedDEXBals: map[uint32]uint64{ + 42: 2*lotSize + 2*sellFees.Max.Swap + sellFees.Funding, + }, + UsedCEXBal: b2q(sellPlacements[2].CounterTradeRate, 2*lotSize), }, // ---- Buy ---- buyDexBalances: map[uint32]uint64{ 42: 0, - 0: b2q(buyPlacements[2].rate, 2*lotSize) + - b2q(buyPlacements[3].rate, lotSize) + - 3*buyFees.Max.Swap + buyFees.funding, + 0: b2q(buyPlacements[2].Rate, 2*lotSize) + + b2q(buyPlacements[3].Rate, lotSize) + + 3*buyFees.Max.Swap + buyFees.Funding, }, buyCexBalances: map[uint32]uint64{ 42: 8 * lotSize, @@ -1259,25 +1718,168 @@ func TestMultiTrade(t *testing.T) { buyPlacements: reconfigToLessPlacements(false), buyPendingOrders: secondPendingOrderNotFilled(false, 42, 0), expectedBuyPlacements: []*core.QtyRate{ - // {Qty: lotSize, Rate: buyPlacements[1].rate}, - {Qty: 2 * lotSize, Rate: buyPlacements[2].rate}, - {Qty: lotSize, Rate: buyPlacements[3].rate}, + // {Qty: lotSize, Rate: buyPlacements[1].Rate}, + {Qty: 2 * lotSize, Rate: buyPlacements[2].Rate}, + {Qty: lotSize, Rate: buyPlacements[3].Rate}, + }, + expectedBuyOrderReport: &OrderReport{ + Placements: []*TradePlacement{ + {Lots: 1, Rate: bps[0].Rate, CounterTradeRate: bps[0].CounterTradeRate, + StandingLots: 1, + RequiredDEX: map[uint32]uint64{}, + UsedDEX: map[uint32]uint64{}, + RequiredCEX: 0, + UsedCEX: 0, + }, + {Lots: 1, Rate: bps[1].Rate, CounterTradeRate: bps[1].CounterTradeRate, + StandingLots: 2, + RequiredDEX: map[uint32]uint64{}, + UsedDEX: map[uint32]uint64{}, + RequiredCEX: 0, + UsedCEX: 0, + OrderedLots: 0, + }, + {Lots: 3, Rate: bps[2].Rate, CounterTradeRate: bps[2].CounterTradeRate, + StandingLots: 1, + RequiredDEX: map[uint32]uint64{ + 0: 2 * (b2q(buyPlacements[2].Rate, lotSize) + buyFees.Max.Swap), + }, + UsedDEX: map[uint32]uint64{ + 0: 2 * (b2q(buyPlacements[2].Rate, lotSize) + buyFees.Max.Swap), + }, + RequiredCEX: 2 * lotSize, + UsedCEX: 2 * lotSize, + OrderedLots: 2, + }, + {Lots: 2, Rate: bps[3].Rate, CounterTradeRate: bps[3].CounterTradeRate, + StandingLots: 1, + RequiredDEX: map[uint32]uint64{ + 0: b2q(buyPlacements[3].Rate, lotSize) + buyFees.Max.Swap, + }, + UsedDEX: map[uint32]uint64{ + 0: b2q(buyPlacements[3].Rate, lotSize) + buyFees.Max.Swap, + }, + RequiredCEX: lotSize, + UsedCEX: lotSize, + OrderedLots: 1, + }, + }, + Fees: buyFees, + AvailableDEXBals: map[uint32]*BotBalance{ + 0: { + Available: b2q(buyPlacements[2].Rate, 2*lotSize) + + b2q(buyPlacements[3].Rate, lotSize) + + 3*buyFees.Max.Swap + buyFees.Funding, + }, + 42: {}, + }, + RequiredDEXBals: map[uint32]uint64{ + 0: b2q(buyPlacements[2].Rate, 2*lotSize) + + b2q(buyPlacements[3].Rate, lotSize) + + 3*buyFees.Max.Swap + buyFees.Funding, + }, + RemainingDEXBals: map[uint32]uint64{ + 42: 0, + 0: 0, + }, + AvailableCEXBal: &BotBalance{ + Available: 3 * lotSize, + Reserved: 5 * lotSize, + }, + RequiredCEXBal: 3 * lotSize, + RemainingCEXBal: 0, + UsedDEXBals: map[uint32]uint64{ + 0: b2q(buyPlacements[2].Rate, 2*lotSize) + + b2q(buyPlacements[3].Rate, lotSize) + + 3*buyFees.Max.Swap + buyFees.Funding, + }, + UsedCEXBal: 3 * lotSize, + }, + expectedBuyOrderReportWithDEXDecrement: &OrderReport{ + Placements: []*TradePlacement{ + {Lots: 1, Rate: bps[0].Rate, CounterTradeRate: bps[0].CounterTradeRate, + StandingLots: 1, + RequiredDEX: map[uint32]uint64{}, + UsedDEX: map[uint32]uint64{}, + RequiredCEX: 0, + UsedCEX: 0, + }, + {Lots: 1, Rate: bps[1].Rate, CounterTradeRate: bps[1].CounterTradeRate, + StandingLots: 2, + RequiredDEX: map[uint32]uint64{}, + UsedDEX: map[uint32]uint64{}, + RequiredCEX: 0, + UsedCEX: 0, + OrderedLots: 0, + }, + {Lots: 3, Rate: bps[2].Rate, CounterTradeRate: bps[2].CounterTradeRate, + StandingLots: 1, + RequiredDEX: map[uint32]uint64{ + 0: 2 * (b2q(buyPlacements[2].Rate, lotSize) + buyFees.Max.Swap), + }, + UsedDEX: map[uint32]uint64{ + 0: 2 * (b2q(buyPlacements[2].Rate, lotSize) + buyFees.Max.Swap), + }, + RequiredCEX: 2 * lotSize, + UsedCEX: 2 * lotSize, + OrderedLots: 2, + }, + {Lots: 2, Rate: bps[3].Rate, CounterTradeRate: bps[3].CounterTradeRate, + StandingLots: 1, + RequiredDEX: map[uint32]uint64{ + 0: b2q(buyPlacements[3].Rate, lotSize) + buyFees.Max.Swap, + }, + UsedDEX: map[uint32]uint64{}, + RequiredCEX: lotSize, + UsedCEX: 0, + OrderedLots: 0, + }, + }, + Fees: buyFees, + AvailableDEXBals: map[uint32]*BotBalance{ + 0: { + Available: b2q(buyPlacements[2].Rate, 2*lotSize) + + b2q(buyPlacements[3].Rate, lotSize) + + 3*buyFees.Max.Swap + buyFees.Funding - 1, + }, + 42: {}, + }, + RequiredDEXBals: map[uint32]uint64{ + 0: b2q(buyPlacements[2].Rate, 2*lotSize) + + b2q(buyPlacements[3].Rate, lotSize) + + 3*buyFees.Max.Swap + buyFees.Funding, + }, + RemainingDEXBals: map[uint32]uint64{ + 42: 0, + 0: b2q(buyPlacements[3].Rate, lotSize) + buyFees.Max.Swap - 1, + }, + AvailableCEXBal: &BotBalance{ + Available: 3 * lotSize, + Reserved: 5 * lotSize, + }, + RequiredCEXBal: 3 * lotSize, + RemainingCEXBal: lotSize, + UsedDEXBals: map[uint32]uint64{ + 0: b2q(buyPlacements[2].Rate, 2*lotSize) + + 2*buyFees.Max.Swap + buyFees.Funding, + }, + UsedCEXBal: 2 * lotSize, }, expectedBuyPlacementsWithDecrement: []*core.QtyRate{ - // {Qty: lotSize, Rate: buyPlacements[1].rate}, - {Qty: 2 * lotSize, Rate: buyPlacements[2].rate}, + // {Qty: lotSize, Rate: buyPlacements[1].Rate}, + {Qty: 2 * lotSize, Rate: buyPlacements[2].Rate}, }, expectedCancels: []order.OrderID{orderIDs[1], orderIDs[2]}, expectedCancelsWithDecrement: []order.OrderID{orderIDs[1], orderIDs[2]}, - multiTradeResult: []*core.Order{ - {ID: orderIDs[4][:]}, - {ID: orderIDs[5][:]}, + multiTradeResult: []*core.MultiTradeResult{ + {Order: &core.Order{ID: orderIDs[4][:]}}, + {Order: &core.Order{ID: orderIDs[5][:]}}, // {ID: orderIDs[6][:]}, }, - multiTradeResultWithDecrement: []*core.Order{ - {ID: orderIDs[4][:]}, - // {ID: orderIDs[5][:]}, + multiTradeResultWithDecrement: []*core.MultiTradeResult{ + {Order: &core.Order{ID: orderIDs[4][:]}}, + // {Order: &core.Order{ID: orderIDs[5][:]}}, }, expectedOrderIDs: []order.OrderID{ orderIDs[4], orderIDs[5], @@ -1293,32 +1895,109 @@ func TestMultiTrade(t *testing.T) { // ---- Sell ---- sellDexBalances: map[uint32]uint64{ - 42: 3*lotSize + 3*sellFees.Max.Swap + sellFees.funding, + 42: 3*lotSize + 3*sellFees.Max.Swap + sellFees.Funding, 0: 0, }, sellCexBalances: map[uint32]uint64{ 42: 0, - 0: b2q(sellPlacements[0].counterTradeRate, lotSize) + - b2q(sellPlacements[1].counterTradeRate, lotSize) + - b2q(sellPlacements[2].counterTradeRate, 3*lotSize) + - b2q(sellPlacements[3].counterTradeRate, 2*lotSize), + 0: b2q(sellPlacements[0].CounterTradeRate, lotSize) + + b2q(sellPlacements[1].CounterTradeRate, lotSize) + + b2q(sellPlacements[2].CounterTradeRate, 3*lotSize) + + b2q(sellPlacements[3].CounterTradeRate, 2*lotSize), }, sellPlacements: sellPlacements, sellPendingOrders: pendingOrdersSelfMatch(true, 42, 0), expectedSellPlacements: []*core.QtyRate{ - {Qty: 2 * lotSize, Rate: sellPlacements[2].rate}, - {Qty: lotSize, Rate: sellPlacements[3].rate}, + {Qty: 2 * lotSize, Rate: sellPlacements[2].Rate}, + {Qty: lotSize, Rate: sellPlacements[3].Rate}, + }, + expectedSellOrderReport: &OrderReport{ + Placements: []*TradePlacement{ + {Lots: 1, Rate: sps[0].Rate, CounterTradeRate: sps[0].CounterTradeRate, + StandingLots: 1, + RequiredDEX: map[uint32]uint64{}, + UsedDEX: map[uint32]uint64{}, + RequiredCEX: 0, + UsedCEX: 0, + }, + {Lots: 2, Rate: sps[1].Rate, CounterTradeRate: sps[1].CounterTradeRate, + StandingLots: 1, + RequiredDEX: map[uint32]uint64{ + 42: lotSize + sellFees.Max.Swap, + }, + UsedDEX: map[uint32]uint64{}, + RequiredCEX: b2q(sellPlacements[1].CounterTradeRate, lotSize), + UsedCEX: 0, + OrderedLots: 0, + Error: &BotProblems{CausesSelfMatch: true}, + }, + {Lots: 3, Rate: sps[2].Rate, CounterTradeRate: sps[2].CounterTradeRate, + StandingLots: 1, + RequiredDEX: map[uint32]uint64{ + 42: 2 * (lotSize + sellFees.Max.Swap), + }, + UsedDEX: map[uint32]uint64{ + 42: 2 * (lotSize + sellFees.Max.Swap), + }, + RequiredCEX: b2q(sellPlacements[2].CounterTradeRate, 2*lotSize), + UsedCEX: b2q(sellPlacements[2].CounterTradeRate, 2*lotSize), + OrderedLots: 2, + }, + {Lots: 2, Rate: sps[3].Rate, CounterTradeRate: sps[3].CounterTradeRate, + StandingLots: 1, + RequiredDEX: map[uint32]uint64{ + 42: lotSize + sellFees.Max.Swap, + }, + UsedDEX: map[uint32]uint64{ + 42: lotSize + sellFees.Max.Swap, + }, + RequiredCEX: b2q(sellPlacements[3].CounterTradeRate, lotSize), + UsedCEX: b2q(sellPlacements[3].CounterTradeRate, lotSize), + OrderedLots: 1, + }, + }, + Fees: sellFees, + AvailableDEXBals: map[uint32]*BotBalance{ + 42: { + Available: 3*lotSize + 3*sellFees.Max.Swap + sellFees.Funding, + }, + 0: {}, + }, + RequiredDEXBals: map[uint32]uint64{ + 42: 4*lotSize + 4*sellFees.Max.Swap + sellFees.Funding, + }, + RemainingDEXBals: map[uint32]uint64{ + 42: 0, + 0: 0, + }, + AvailableCEXBal: &BotBalance{ + Available: b2q(sellPlacements[2].CounterTradeRate, 2*lotSize) + + b2q(sellPlacements[3].CounterTradeRate, lotSize), + Reserved: b2q(sellPlacements[0].CounterTradeRate, lotSize) + + b2q(sellPlacements[1].CounterTradeRate, lotSize) + + b2q(sellPlacements[2].CounterTradeRate, lotSize) + + b2q(sellPlacements[3].CounterTradeRate, lotSize), + }, + RequiredCEXBal: b2q(sellPlacements[1].CounterTradeRate, 1*lotSize) + + b2q(sellPlacements[2].CounterTradeRate, 2*lotSize) + + b2q(sellPlacements[3].CounterTradeRate, lotSize), + RemainingCEXBal: 0, + UsedDEXBals: map[uint32]uint64{ + 42: 3*lotSize + 3*sellFees.Max.Swap + sellFees.Funding, + }, + UsedCEXBal: b2q(sellPlacements[2].CounterTradeRate, 2*lotSize) + + b2q(sellPlacements[3].CounterTradeRate, lotSize), }, expectedSellPlacementsWithDecrement: []*core.QtyRate{ - {Qty: 2 * lotSize, Rate: sellPlacements[2].rate}, + {Qty: 2 * lotSize, Rate: sellPlacements[2].Rate}, }, // ---- Buy ---- buyDexBalances: map[uint32]uint64{ 42: 0, - 0: b2q(buyPlacements[2].rate, 2*lotSize) + - b2q(buyPlacements[3].rate, lotSize) + - 3*buyFees.Max.Swap + buyFees.funding, + 0: b2q(buyPlacements[2].Rate, 2*lotSize) + + b2q(buyPlacements[3].Rate, lotSize) + + 3*buyFees.Max.Swap + buyFees.Funding, }, buyCexBalances: map[uint32]uint64{ 42: 7 * lotSize, @@ -1327,21 +2006,21 @@ func TestMultiTrade(t *testing.T) { buyPlacements: buyPlacements, buyPendingOrders: pendingOrdersSelfMatch(false, 42, 0), expectedBuyPlacements: []*core.QtyRate{ - {Qty: 2 * lotSize, Rate: buyPlacements[2].rate}, - {Qty: lotSize, Rate: buyPlacements[3].rate}, + {Qty: 2 * lotSize, Rate: buyPlacements[2].Rate}, + {Qty: lotSize, Rate: buyPlacements[3].Rate}, }, expectedBuyPlacementsWithDecrement: []*core.QtyRate{ - {Qty: 2 * lotSize, Rate: buyPlacements[2].rate}, + {Qty: 2 * lotSize, Rate: buyPlacements[2].Rate}, }, expectedCancels: []order.OrderID{orderIDs[2]}, expectedCancelsWithDecrement: []order.OrderID{orderIDs[2]}, - multiTradeResult: []*core.Order{ - {ID: orderIDs[5][:]}, - {ID: orderIDs[6][:]}, + multiTradeResult: []*core.MultiTradeResult{ + {Order: &core.Order{ID: orderIDs[5][:]}}, + {Order: &core.Order{ID: orderIDs[6][:]}}, }, - multiTradeResultWithDecrement: []*core.Order{ - {ID: orderIDs[5][:]}, + multiTradeResultWithDecrement: []*core.MultiTradeResult{ + {Order: &core.Order{ID: orderIDs[5][:]}}, }, expectedOrderIDs: []order.OrderID{ orderIDs[5], orderIDs[6], @@ -1356,33 +2035,33 @@ func TestMultiTrade(t *testing.T) { quoteID: 0, // ---- Sell ---- sellDexBalances: map[uint32]uint64{ - 42: 3*lotSize + 3*sellFees.Max.Swap + sellFees.funding, + 42: 3*lotSize + 3*sellFees.Max.Swap + sellFees.Funding, 0: 0, }, sellCexBalances: map[uint32]uint64{ 42: 0, - 0: b2q(sellPlacements[0].counterTradeRate, lotSize) + - b2q(sellPlacements[1].counterTradeRate, 2*lotSize) + - b2q(sellPlacements[2].counterTradeRate, 3*lotSize) + - b2q(sellPlacements[3].counterTradeRate, lotSize), + 0: b2q(sellPlacements[0].CounterTradeRate, lotSize) + + b2q(sellPlacements[1].CounterTradeRate, 2*lotSize) + + b2q(sellPlacements[2].CounterTradeRate, 3*lotSize) + + b2q(sellPlacements[3].CounterTradeRate, lotSize), }, sellPlacements: cancelLastPlacement(true), sellPendingOrders: pendingOrders(true, 42, 0), expectedSellPlacements: []*core.QtyRate{ - {Qty: lotSize, Rate: sellPlacements[1].rate}, - {Qty: 2 * lotSize, Rate: sellPlacements[2].rate}, + {Qty: lotSize, Rate: sellPlacements[1].Rate}, + {Qty: 2 * lotSize, Rate: sellPlacements[2].Rate}, }, expectedSellPlacementsWithDecrement: []*core.QtyRate{ - {Qty: lotSize, Rate: sellPlacements[1].rate}, - {Qty: lotSize, Rate: sellPlacements[2].rate}, + {Qty: lotSize, Rate: sellPlacements[1].Rate}, + {Qty: lotSize, Rate: sellPlacements[2].Rate}, }, // ---- Buy ---- buyDexBalances: map[uint32]uint64{ 42: 0, - 0: b2q(buyPlacements[1].rate, lotSize) + - b2q(buyPlacements[2].rate, 2*lotSize) + - 3*buyFees.Max.Swap + buyFees.funding, + 0: b2q(buyPlacements[1].Rate, lotSize) + + b2q(buyPlacements[2].Rate, 2*lotSize) + + 3*buyFees.Max.Swap + buyFees.Funding, }, buyCexBalances: map[uint32]uint64{ 42: 7 * lotSize, @@ -1391,23 +2070,23 @@ func TestMultiTrade(t *testing.T) { buyPlacements: cancelLastPlacement(false), buyPendingOrders: pendingOrders(false, 42, 0), expectedBuyPlacements: []*core.QtyRate{ - {Qty: lotSize, Rate: buyPlacements[1].rate}, - {Qty: 2 * lotSize, Rate: buyPlacements[2].rate}, + {Qty: lotSize, Rate: buyPlacements[1].Rate}, + {Qty: 2 * lotSize, Rate: buyPlacements[2].Rate}, }, expectedBuyPlacementsWithDecrement: []*core.QtyRate{ - {Qty: lotSize, Rate: buyPlacements[1].rate}, - {Qty: lotSize, Rate: buyPlacements[2].rate}, + {Qty: lotSize, Rate: buyPlacements[1].Rate}, + {Qty: lotSize, Rate: buyPlacements[2].Rate}, }, expectedCancels: []order.OrderID{orderIDs[3], orderIDs[2]}, expectedCancelsWithDecrement: []order.OrderID{orderIDs[3], orderIDs[2]}, - multiTradeResult: []*core.Order{ - {ID: orderIDs[4][:]}, - {ID: orderIDs[5][:]}, + multiTradeResult: []*core.MultiTradeResult{ + {Order: &core.Order{ID: orderIDs[4][:]}}, + {Order: &core.Order{ID: orderIDs[5][:]}}, }, - multiTradeResultWithDecrement: []*core.Order{ - {ID: orderIDs[4][:]}, - {ID: orderIDs[5][:]}, + multiTradeResultWithDecrement: []*core.MultiTradeResult{ + {Order: &core.Order{ID: orderIDs[4][:]}}, + {Order: &core.Order{ID: orderIDs[5][:]}}, }, expectedOrderIDs: []order.OrderID{ orderIDs[4], orderIDs[5], @@ -1422,33 +2101,33 @@ func TestMultiTrade(t *testing.T) { quoteID: 0, // ---- Sell ---- sellDexBalances: map[uint32]uint64{ - 42: 3*lotSize + 3*sellFees.Max.Swap + sellFees.funding, + 42: 3*lotSize + 3*sellFees.Max.Swap + sellFees.Funding, 0: 0, }, sellCexBalances: map[uint32]uint64{ 42: 0, - 0: b2q(sellPlacements[0].counterTradeRate, lotSize) + - b2q(sellPlacements[1].counterTradeRate, 2*lotSize) + - b2q(sellPlacements[2].counterTradeRate, 3*lotSize) + - b2q(sellPlacements[3].counterTradeRate, lotSize), + 0: b2q(sellPlacements[0].CounterTradeRate, lotSize) + + b2q(sellPlacements[1].CounterTradeRate, 2*lotSize) + + b2q(sellPlacements[2].CounterTradeRate, 3*lotSize) + + b2q(sellPlacements[3].CounterTradeRate, lotSize), }, sellPlacements: removeLastPlacement(true), sellPendingOrders: pendingOrders(true, 42, 0), expectedSellPlacements: []*core.QtyRate{ - {Qty: lotSize, Rate: sellPlacements[1].rate}, - {Qty: 2 * lotSize, Rate: sellPlacements[2].rate}, + {Qty: lotSize, Rate: sellPlacements[1].Rate}, + {Qty: 2 * lotSize, Rate: sellPlacements[2].Rate}, }, expectedSellPlacementsWithDecrement: []*core.QtyRate{ - {Qty: lotSize, Rate: sellPlacements[1].rate}, - {Qty: lotSize, Rate: sellPlacements[2].rate}, + {Qty: lotSize, Rate: sellPlacements[1].Rate}, + {Qty: lotSize, Rate: sellPlacements[2].Rate}, }, // ---- Buy ---- buyDexBalances: map[uint32]uint64{ 42: 0, - 0: b2q(buyPlacements[1].rate, lotSize) + - b2q(buyPlacements[2].rate, 2*lotSize) + - 3*buyFees.Max.Swap + buyFees.funding, + 0: b2q(buyPlacements[1].Rate, lotSize) + + b2q(buyPlacements[2].Rate, 2*lotSize) + + 3*buyFees.Max.Swap + buyFees.Funding, }, buyCexBalances: map[uint32]uint64{ 42: 7 * lotSize, @@ -1457,23 +2136,23 @@ func TestMultiTrade(t *testing.T) { buyPlacements: removeLastPlacement(false), buyPendingOrders: pendingOrders(false, 42, 0), expectedBuyPlacements: []*core.QtyRate{ - {Qty: lotSize, Rate: buyPlacements[1].rate}, - {Qty: 2 * lotSize, Rate: buyPlacements[2].rate}, + {Qty: lotSize, Rate: buyPlacements[1].Rate}, + {Qty: 2 * lotSize, Rate: buyPlacements[2].Rate}, }, expectedBuyPlacementsWithDecrement: []*core.QtyRate{ - {Qty: lotSize, Rate: buyPlacements[1].rate}, - {Qty: lotSize, Rate: buyPlacements[2].rate}, + {Qty: lotSize, Rate: buyPlacements[1].Rate}, + {Qty: lotSize, Rate: buyPlacements[2].Rate}, }, expectedCancels: []order.OrderID{orderIDs[3], orderIDs[2]}, expectedCancelsWithDecrement: []order.OrderID{orderIDs[3], orderIDs[2]}, - multiTradeResult: []*core.Order{ - {ID: orderIDs[4][:]}, - {ID: orderIDs[5][:]}, + multiTradeResult: []*core.MultiTradeResult{ + {Order: &core.Order{ID: orderIDs[4][:]}}, + {Order: &core.Order{ID: orderIDs[5][:]}}, }, - multiTradeResultWithDecrement: []*core.Order{ - {ID: orderIDs[4][:]}, - {ID: orderIDs[5][:]}, + multiTradeResultWithDecrement: []*core.MultiTradeResult{ + {Order: &core.Order{ID: orderIDs[4][:]}}, + {Order: &core.Order{ID: orderIDs[5][:]}}, }, expectedOrderIDs: []order.OrderID{ orderIDs[4], orderIDs[5], @@ -1494,35 +2173,234 @@ func TestMultiTrade(t *testing.T) { // ---- Sell ---- sellDexBalances: map[uint32]uint64{ 966001: 4 * lotSize, - 966: 4*(sellFees.Max.Swap+sellFees.Max.Refund) + sellFees.funding, + 966: 4*(sellFees.Max.Swap+sellFees.Max.Refund) + sellFees.Funding, 60: 4 * sellFees.Max.Redeem, }, sellCexBalances: map[uint32]uint64{ 96601: 0, - 60: b2q(sellPlacements[0].counterTradeRate, lotSize) + - b2q(sellPlacements[1].counterTradeRate, 2*lotSize) + - b2q(sellPlacements[2].counterTradeRate, 3*lotSize) + - b2q(sellPlacements[3].counterTradeRate, 2*lotSize), + 60: b2q(sellPlacements[0].CounterTradeRate, lotSize) + + b2q(sellPlacements[1].CounterTradeRate, 2*lotSize) + + b2q(sellPlacements[2].CounterTradeRate, 3*lotSize) + + b2q(sellPlacements[3].CounterTradeRate, 2*lotSize), }, sellPlacements: sellPlacements, sellPendingOrders: pendingOrders(true, 966001, 60), expectedSellPlacements: []*core.QtyRate{ - {Qty: lotSize, Rate: sellPlacements[1].rate}, - {Qty: 2 * lotSize, Rate: sellPlacements[2].rate}, - {Qty: lotSize, Rate: sellPlacements[3].rate}, + {Qty: lotSize, Rate: sellPlacements[1].Rate}, + {Qty: 2 * lotSize, Rate: sellPlacements[2].Rate}, + {Qty: lotSize, Rate: sellPlacements[3].Rate}, + }, + expectedSellOrderReport: &OrderReport{ + Placements: []*TradePlacement{ + {Lots: 1, Rate: sps[0].Rate, CounterTradeRate: sps[0].CounterTradeRate, + StandingLots: 1, + RequiredDEX: map[uint32]uint64{}, + UsedDEX: map[uint32]uint64{}, + RequiredCEX: 0, + UsedCEX: 0, + }, + {Lots: 2, Rate: sps[1].Rate, CounterTradeRate: sps[1].CounterTradeRate, + StandingLots: 1, + RequiredDEX: map[uint32]uint64{ + 966001: lotSize, + 966: sellFees.Max.Swap + sellFees.Max.Refund, + 60: sellFees.Max.Redeem, + }, + UsedDEX: map[uint32]uint64{ + 966001: lotSize, + 966: sellFees.Max.Swap + sellFees.Max.Refund, + 60: sellFees.Max.Redeem, + }, + RequiredCEX: b2q(sellPlacements[1].CounterTradeRate, lotSize), + UsedCEX: b2q(sellPlacements[1].CounterTradeRate, lotSize), + OrderedLots: 1, + }, + {Lots: 3, Rate: sps[2].Rate, CounterTradeRate: sps[2].CounterTradeRate, + StandingLots: 1, + RequiredDEX: map[uint32]uint64{ + 966001: 2 * lotSize, + 966: 2 * (sellFees.Max.Swap + sellFees.Max.Refund), + 60: 2 * sellFees.Max.Redeem, + }, + UsedDEX: map[uint32]uint64{ + 966001: 2 * lotSize, + 966: 2 * (sellFees.Max.Swap + sellFees.Max.Refund), + 60: 2 * sellFees.Max.Redeem, + }, + RequiredCEX: b2q(sellPlacements[2].CounterTradeRate, 2*lotSize), + UsedCEX: b2q(sellPlacements[2].CounterTradeRate, 2*lotSize), + OrderedLots: 2, + }, + {Lots: 2, Rate: sps[3].Rate, CounterTradeRate: sps[3].CounterTradeRate, + StandingLots: 1, + RequiredDEX: map[uint32]uint64{ + 966001: lotSize, + 966: sellFees.Max.Swap + sellFees.Max.Refund, + 60: sellFees.Max.Redeem, + }, + UsedDEX: map[uint32]uint64{ + 966001: lotSize, + 966: sellFees.Max.Swap + sellFees.Max.Refund, + 60: sellFees.Max.Redeem, + }, + RequiredCEX: b2q(sellPlacements[3].CounterTradeRate, lotSize), + UsedCEX: b2q(sellPlacements[3].CounterTradeRate, lotSize), + OrderedLots: 1, + }, + }, + Fees: sellFees, + AvailableDEXBals: map[uint32]*BotBalance{ + 966001: { + Available: 4 * lotSize, + }, + 966: { + Available: 4*(sellFees.Max.Swap+sellFees.Max.Refund) + sellFees.Funding, + }, + 60: { + Available: 4 * sellFees.Max.Redeem, + }, + }, + RequiredDEXBals: map[uint32]uint64{ + 966001: 4 * lotSize, + 966: 4*(sellFees.Max.Swap+sellFees.Max.Refund) + sellFees.Funding, + 60: 4 * sellFees.Max.Redeem, + }, + RemainingDEXBals: map[uint32]uint64{ + 966001: 0, + 966: 0, + 60: 0, + }, + AvailableCEXBal: &BotBalance{ + Available: b2q(sellPlacements[1].CounterTradeRate, lotSize) + + b2q(sellPlacements[2].CounterTradeRate, 2*lotSize) + + b2q(sellPlacements[3].CounterTradeRate, lotSize), + Reserved: b2q(sellPlacements[0].CounterTradeRate, lotSize) + + b2q(sellPlacements[1].CounterTradeRate, lotSize) + + b2q(sellPlacements[2].CounterTradeRate, lotSize) + + b2q(sellPlacements[3].CounterTradeRate, lotSize), + }, + RequiredCEXBal: b2q(sellPlacements[1].CounterTradeRate, lotSize) + + b2q(sellPlacements[2].CounterTradeRate, 2*lotSize) + + b2q(sellPlacements[3].CounterTradeRate, lotSize), + RemainingCEXBal: 0, + UsedDEXBals: map[uint32]uint64{ + 966001: 4 * lotSize, + 966: 4*(sellFees.Max.Swap+sellFees.Max.Refund) + sellFees.Funding, + 60: 4 * sellFees.Max.Redeem, + }, + UsedCEXBal: b2q(sellPlacements[1].CounterTradeRate, lotSize) + + b2q(sellPlacements[2].CounterTradeRate, 2*lotSize) + + b2q(sellPlacements[3].CounterTradeRate, lotSize), }, expectedSellPlacementsWithDecrement: []*core.QtyRate{ - {Qty: lotSize, Rate: sellPlacements[1].rate}, - {Qty: 2 * lotSize, Rate: sellPlacements[2].rate}, + {Qty: lotSize, Rate: sellPlacements[1].Rate}, + {Qty: 2 * lotSize, Rate: sellPlacements[2].Rate}, + }, + expectedSellOrderReportWithDEXDecrement: &OrderReport{ + Placements: []*TradePlacement{ + {Lots: 1, Rate: sps[0].Rate, CounterTradeRate: sps[0].CounterTradeRate, + StandingLots: 1, + RequiredDEX: map[uint32]uint64{}, + UsedDEX: map[uint32]uint64{}, + RequiredCEX: 0, + UsedCEX: 0, + }, + {Lots: 2, Rate: sps[1].Rate, CounterTradeRate: sps[1].CounterTradeRate, + StandingLots: 1, + RequiredDEX: map[uint32]uint64{ + 966001: lotSize, + 966: sellFees.Max.Swap + sellFees.Max.Refund, + 60: sellFees.Max.Redeem, + }, + UsedDEX: map[uint32]uint64{ + 966001: lotSize, + 966: sellFees.Max.Swap + sellFees.Max.Refund, + 60: sellFees.Max.Redeem, + }, + RequiredCEX: b2q(sellPlacements[1].CounterTradeRate, lotSize), + UsedCEX: b2q(sellPlacements[1].CounterTradeRate, lotSize), + OrderedLots: 1, + }, + {Lots: 3, Rate: sps[2].Rate, CounterTradeRate: sps[2].CounterTradeRate, + StandingLots: 1, + RequiredDEX: map[uint32]uint64{ + 966001: 2 * lotSize, + 966: 2 * (sellFees.Max.Swap + sellFees.Max.Refund), + 60: 2 * sellFees.Max.Redeem, + }, + UsedDEX: map[uint32]uint64{ + 966001: 2 * lotSize, + 966: 2 * (sellFees.Max.Swap + sellFees.Max.Refund), + 60: 2 * sellFees.Max.Redeem, + }, + RequiredCEX: b2q(sellPlacements[2].CounterTradeRate, 2*lotSize), + UsedCEX: b2q(sellPlacements[2].CounterTradeRate, 2*lotSize), + OrderedLots: 2, + }, + {Lots: 2, Rate: sps[3].Rate, CounterTradeRate: sps[3].CounterTradeRate, + StandingLots: 1, + RequiredDEX: map[uint32]uint64{ + 966001: lotSize, + 966: sellFees.Max.Swap + sellFees.Max.Refund, + 60: sellFees.Max.Redeem, + }, + UsedDEX: map[uint32]uint64{}, + RequiredCEX: b2q(sellPlacements[3].CounterTradeRate, lotSize), + UsedCEX: 0, + OrderedLots: 0, + }, + }, + Fees: sellFees, + AvailableDEXBals: map[uint32]*BotBalance{ + 966001: { + Available: 4*lotSize - 1, + }, + 966: { + Available: 4*(sellFees.Max.Swap+sellFees.Max.Refund) + sellFees.Funding, + }, + 60: { + Available: 4 * sellFees.Max.Redeem, + }, + }, + RequiredDEXBals: map[uint32]uint64{ + 966001: 4 * lotSize, + 966: 4*(sellFees.Max.Swap+sellFees.Max.Refund) + sellFees.Funding, + 60: 4 * sellFees.Max.Redeem, + }, + RemainingDEXBals: map[uint32]uint64{ + 966001: lotSize - 1, + 966: sellFees.Max.Swap + sellFees.Max.Refund, + 60: sellFees.Max.Redeem, + }, + AvailableCEXBal: &BotBalance{ + Available: b2q(sellPlacements[1].CounterTradeRate, lotSize) + + b2q(sellPlacements[2].CounterTradeRate, 2*lotSize) + + b2q(sellPlacements[3].CounterTradeRate, lotSize), + Reserved: b2q(sellPlacements[0].CounterTradeRate, lotSize) + + b2q(sellPlacements[1].CounterTradeRate, lotSize) + + b2q(sellPlacements[2].CounterTradeRate, lotSize) + + b2q(sellPlacements[3].CounterTradeRate, lotSize), + }, + RequiredCEXBal: b2q(sellPlacements[1].CounterTradeRate, lotSize) + + b2q(sellPlacements[2].CounterTradeRate, 2*lotSize) + + b2q(sellPlacements[3].CounterTradeRate, lotSize), + RemainingCEXBal: b2q(sellPlacements[3].CounterTradeRate, lotSize), + UsedDEXBals: map[uint32]uint64{ + 966001: 3 * lotSize, + 966: 3*(sellFees.Max.Swap+sellFees.Max.Refund) + sellFees.Funding, + 60: 3 * sellFees.Max.Redeem, + }, + UsedCEXBal: b2q(sellPlacements[1].CounterTradeRate, lotSize) + + b2q(sellPlacements[2].CounterTradeRate, 2*lotSize), }, // ---- Buy ---- buyDexBalances: map[uint32]uint64{ 966: 4 * buyFees.Max.Redeem, - 60: b2q(buyPlacements[1].rate, lotSize) + - b2q(buyPlacements[2].rate, 2*lotSize) + - b2q(buyPlacements[3].rate, lotSize) + - 4*buyFees.Max.Swap + 4*buyFees.Max.Refund + buyFees.funding, + 60: b2q(buyPlacements[1].Rate, lotSize) + + b2q(buyPlacements[2].Rate, 2*lotSize) + + b2q(buyPlacements[3].Rate, lotSize) + + 4*buyFees.Max.Swap + 4*buyFees.Max.Refund + buyFees.Funding, }, buyCexBalances: map[uint32]uint64{ 966001: 8 * lotSize, @@ -1531,25 +2409,25 @@ func TestMultiTrade(t *testing.T) { buyPlacements: buyPlacements, buyPendingOrders: pendingOrders(false, 966001, 60), expectedBuyPlacements: []*core.QtyRate{ - {Qty: lotSize, Rate: buyPlacements[1].rate}, - {Qty: 2 * lotSize, Rate: buyPlacements[2].rate}, - {Qty: lotSize, Rate: buyPlacements[3].rate}, + {Qty: lotSize, Rate: buyPlacements[1].Rate}, + {Qty: 2 * lotSize, Rate: buyPlacements[2].Rate}, + {Qty: lotSize, Rate: buyPlacements[3].Rate}, }, expectedBuyPlacementsWithDecrement: []*core.QtyRate{ - {Qty: lotSize, Rate: buyPlacements[1].rate}, - {Qty: 2 * lotSize, Rate: buyPlacements[2].rate}, + {Qty: lotSize, Rate: buyPlacements[1].Rate}, + {Qty: 2 * lotSize, Rate: buyPlacements[2].Rate}, }, expectedCancels: []order.OrderID{orderIDs[2]}, expectedCancelsWithDecrement: []order.OrderID{orderIDs[2]}, - multiTradeResult: []*core.Order{ - {ID: orderIDs[3][:]}, - {ID: orderIDs[4][:]}, - {ID: orderIDs[5][:]}, + multiTradeResult: []*core.MultiTradeResult{ + {Order: &core.Order{ID: orderIDs[3][:]}}, + {Order: &core.Order{ID: orderIDs[4][:]}}, + {Order: &core.Order{ID: orderIDs[5][:]}}, }, - multiTradeResultWithDecrement: []*core.Order{ - {ID: orderIDs[3][:]}, - {ID: orderIDs[4][:]}, + multiTradeResultWithDecrement: []*core.MultiTradeResult{ + {Order: &core.Order{ID: orderIDs[3][:]}}, + {Order: &core.Order{ID: orderIDs[4][:]}}, }, expectedOrderIDs: []order.OrderID{ orderIDs[3], orderIDs[4], orderIDs[5], @@ -1613,13 +2491,14 @@ func TestMultiTrade(t *testing.T) { adaptor.buyFees = buyFees adaptor.sellFees = sellFees - var placements []*multiTradePlacement + var placements []*TradePlacement if sell { placements = test.sellPlacements } else { placements = test.buyPlacements } - res := adaptor.multiTrade(placements, sell, driftTolerance, currEpoch) + + res, orderReport := adaptor.multiTrade(placements, sell, driftTolerance, currEpoch) expectedOrderIDs := test.expectedOrderIDs if decrement { @@ -1635,15 +2514,26 @@ func TestMultiTrade(t *testing.T) { } var expectedPlacements []*core.QtyRate + var expectedOrderReport *OrderReport if sell { expectedPlacements = test.expectedSellPlacements if decrement { expectedPlacements = test.expectedSellPlacementsWithDecrement + if !cex && ((sell && assetID == test.baseID) || (!sell && assetID == test.quoteID)) { + expectedOrderReport = test.expectedSellOrderReportWithDEXDecrement + } + } else { + expectedOrderReport = test.expectedSellOrderReport } } else { expectedPlacements = test.expectedBuyPlacements if decrement { expectedPlacements = test.expectedBuyPlacementsWithDecrement + if !cex { + expectedOrderReport = test.expectedBuyOrderReportWithDEXDecrement + } + } else { + expectedOrderReport = test.expectedBuyOrderReport } } if len(expectedPlacements) > 0 != (len(tCore.multiTradesPlaced) > 0) { @@ -1656,6 +2546,44 @@ func TestMultiTrade(t *testing.T) { } } + if expectedOrderReport != nil { + if !reflect.DeepEqual(orderReport.AvailableCEXBal, expectedOrderReport.AvailableCEXBal) { + t.Fatal(spew.Sprintf("%s: expected available cex bal:\n%#+v\ngot:\n%+v", test.name, expectedOrderReport.AvailableCEXBal, orderReport.AvailableCEXBal)) + } + if !reflect.DeepEqual(orderReport.RemainingCEXBal, expectedOrderReport.RemainingCEXBal) { + t.Fatal(spew.Sprintf("%s: expected remaining cex bal:\n%#+v\ngot:\n%+v", test.name, expectedOrderReport.RemainingCEXBal, orderReport.RemainingCEXBal)) + } + if !reflect.DeepEqual(orderReport.RequiredCEXBal, expectedOrderReport.RequiredCEXBal) { + t.Fatal(spew.Sprintf("%s: expected required cex bal:\n%#+v\ngot:\n%+v", test.name, expectedOrderReport.RequiredCEXBal, orderReport.RequiredCEXBal)) + } + if !reflect.DeepEqual(orderReport.Fees, expectedOrderReport.Fees) { + t.Fatal(spew.Sprintf("%s: expected fees:\n%#+v\ngot:\n%+v", test.name, expectedOrderReport.Fees, orderReport.Fees)) + } + if !reflect.DeepEqual(orderReport.AvailableDEXBals, expectedOrderReport.AvailableDEXBals) { + t.Fatal(spew.Sprintf("%s: expected available dex bals:\n%#+v\ngot:\n%+v", test.name, expectedOrderReport.AvailableDEXBals, orderReport.AvailableDEXBals)) + } + if !reflect.DeepEqual(orderReport.RequiredDEXBals, expectedOrderReport.RequiredDEXBals) { + t.Fatal(spew.Sprintf("%s: expected required dex bals:\n%#+v\ngot:\n%+v", test.name, expectedOrderReport.RequiredDEXBals, orderReport.RequiredDEXBals)) + } + if !reflect.DeepEqual(orderReport.RemainingDEXBals, expectedOrderReport.RemainingDEXBals) { + t.Fatal(spew.Sprintf("%s: expected remaining dex bals:\n%#+v\ngot:\n%+v", test.name, expectedOrderReport.RemainingDEXBals, orderReport.RemainingDEXBals)) + } + if len(orderReport.Placements) != len(expectedOrderReport.Placements) { + t.Fatalf("%s: expected %d placements, got %d", test.name, len(expectedOrderReport.Placements), len(orderReport.Placements)) + } + for i, placement := range orderReport.Placements { + if !reflect.DeepEqual(placement, expectedOrderReport.Placements[i]) { + t.Fatal(spew.Sprintf("%s: expected placement %d:\n%#+v\ngot:\n%+v", test.name, i, expectedOrderReport.Placements[i], placement)) + } + } + if !reflect.DeepEqual(orderReport.UsedDEXBals, expectedOrderReport.UsedDEXBals) { + t.Fatal(spew.Sprintf("%s: expected used dex bals:\n%#+v\ngot:\n%+v", test.name, expectedOrderReport.UsedDEXBals, orderReport.UsedDEXBals)) + } + if orderReport.UsedCEXBal != expectedOrderReport.UsedCEXBal { + t.Fatalf("%s: expected used cex bal: %d, got: %d", test.name, expectedOrderReport.UsedCEXBal, orderReport.UsedCEXBal) + } + } + expectedCancels := test.expectedCancels if decrement { expectedCancels = test.expectedCancelsWithDecrement @@ -1813,7 +2741,7 @@ func TestDEXTrade(t *testing.T) { baseID uint32 quoteID uint32 sell bool - placements []*multiTradePlacement + placements []*TradePlacement initialLockedFunds []*orderLockedFunds postTradeBalances map[uint32]*BotBalance @@ -1839,9 +2767,9 @@ func TestDEXTrade(t *testing.T) { sell: true, baseID: 42, quoteID: 0, - placements: []*multiTradePlacement{ - {lots: 5, rate: rate1}, - {lots: 5, rate: rate2}, + placements: []*TradePlacement{ + {Lots: 5, Rate: rate1}, + {Lots: 5, Rate: rate2}, }, initialLockedFunds: []*orderLockedFunds{ newOrderLockedFunds(orderIDs[0], basePerLot*5, 0, 0, 0), @@ -1970,9 +2898,9 @@ func TestDEXTrade(t *testing.T) { }, baseID: 42, quoteID: 0, - placements: []*multiTradePlacement{ - {lots: 5, rate: rate1}, - {lots: 5, rate: rate2}, + placements: []*TradePlacement{ + {Lots: 5, Rate: rate1}, + {Lots: 5, Rate: rate2}, }, initialLockedFunds: []*orderLockedFunds{ newOrderLockedFunds(orderIDs[0], 5*quotePerLot1, 0, 0, 0), @@ -2108,9 +3036,9 @@ func TestDEXTrade(t *testing.T) { sell: true, baseID: 60, quoteID: 966001, - placements: []*multiTradePlacement{ - {lots: 5, rate: rate1}, - {lots: 5, rate: rate2}, + placements: []*TradePlacement{ + {Lots: 5, Rate: rate1}, + {Lots: 5, Rate: rate2}, }, initialLockedFunds: []*orderLockedFunds{ newOrderLockedFunds(orderIDs[1], 5*basePerLot, 0, 5*redeemFees, 5*refundFees), @@ -2251,9 +3179,9 @@ func TestDEXTrade(t *testing.T) { }, baseID: 60, quoteID: 966001, - placements: []*multiTradePlacement{ - {lots: 5, rate: rate1}, - {lots: 5, rate: rate2}, + placements: []*TradePlacement{ + {Lots: 5, Rate: rate1}, + {Lots: 5, Rate: rate2}, }, initialLockedFunds: []*orderLockedFunds{ newOrderLockedFunds(orderIDs[0], 5*quoteLot1, 5*buyFees, 5*redeemFees, 5*refundFees), @@ -2393,20 +3321,23 @@ func TestDEXTrade(t *testing.T) { } tCore.isDynamicSwapper = test.isDynamicSwapper - multiTradeResult := make([]*core.Order, 0, len(test.initialLockedFunds)) + multiTradeResult := make([]*core.MultiTradeResult, 0, len(test.initialLockedFunds)) for i, o := range test.initialLockedFunds { - multiTradeResult = append(multiTradeResult, &core.Order{ - Host: host, - BaseID: test.baseID, - QuoteID: test.quoteID, - Sell: test.sell, - LockedAmt: o.lockedAmt, - ID: o.id[:], - ParentAssetLockedAmt: o.parentAssetLockedAmt, - RedeemLockedAmt: o.redeemLockedAmt, - RefundLockedAmt: o.refundLockedAmt, - Rate: test.placements[i].rate, - Qty: test.placements[i].lots * lotSize, + multiTradeResult = append(multiTradeResult, &core.MultiTradeResult{ + Order: &core.Order{ + Host: host, + BaseID: test.baseID, + QuoteID: test.quoteID, + Sell: test.sell, + LockedAmt: o.lockedAmt, + ID: o.id[:], + ParentAssetLockedAmt: o.parentAssetLockedAmt, + RedeemLockedAmt: o.redeemLockedAmt, + RefundLockedAmt: o.refundLockedAmt, + Rate: test.placements[i].Rate, + Qty: test.placements[i].Lots * lotSize, + }, + Error: nil, }) } tCore.multiTradeResult = multiTradeResult @@ -2436,7 +3367,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) } @@ -2472,8 +3403,8 @@ func TestDEXTrade(t *testing.T) { ID: uint64(i + 1), DEXOrderEvent: &DEXOrderEvent{ ID: o.id.String(), - Rate: trade.rate, - Qty: trade.lots * lotSize, + Rate: trade.Rate, + Qty: trade.Lots * lotSize, Sell: test.sell, Transactions: []*asset.WalletTransaction{}, }, @@ -2925,6 +3856,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 +4062,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 +4540,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) @@ -3681,8 +4621,8 @@ func TestCEXTrade(t *testing.T) { func TestOrderFeesInUnits(t *testing.T) { type test struct { name string - buyFees *orderFees - sellFees *orderFees + buyFees *OrderFees + sellFees *OrderFees rate uint64 market *MarketWithHost fiatRates map[uint32]float64 @@ -3730,7 +4670,7 @@ func TestOrderFeesInUnits(t *testing.T) { // 5e4 + 59431 = 109431 expectedSellBase: 109431, // 1e7 gwei * / 1e9 * 2300 / 0.99 * 1e6 = 23232323 microUSDC - // 23232323 * 1e8 / 43_000_000_000 = 54028 + // 23232323 * 1e8 / 43_000_000_000 = 54028 Sats // 4e4 + 54028 = 94028 expectedBuyBase: 94028, expectedSellQuote: 47055556, diff --git a/client/mm/libxc/binance.go b/client/mm/libxc/binance.go index dcd6a703b5..5f30c3690a 100644 --- a/client/mm/libxc/binance.go +++ b/client/mm/libxc/binance.go @@ -325,7 +325,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..f06ba59653 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" @@ -30,9 +31,8 @@ type clientCore interface { Cancel(oidB dex.Bytes) error AssetBalance(assetID uint32) (*core.WalletBalance, error) WalletTraits(assetID uint32) (asset.WalletTrait, error) - MultiTrade(pw []byte, form *core.MultiTradeForm) ([]*core.Order, error) + MultiTrade(pw []byte, form *core.MultiTradeForm) []*core.MultiTradeResult 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 +42,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 +104,8 @@ type bot interface { DEXBalance(assetID uint32) *BotBalance CEXBalance(assetID uint32) *BotBalance stats() *RunStats + latestEpoch() *EpochReport + latestCEXProblems() *CEXProblems updateConfig(cfg *BotConfig) error updateInventory(balanceDiffs *BotInventoryDiffs) withPause(func() error) error @@ -208,12 +213,141 @@ 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 (se *StampedError) isEqual(se2 *StampedError) bool { + if se == nil != (se2 == nil) { + return false + } + if se == nil { + return true + } + + return se.Stamp == se2.Stamp && se.Error == se2.Error +} + +func newStampedError(err error) *StampedError { + if err == nil { + return nil + } + return &StampedError{ + Stamp: time.Now().Unix(), + Error: err.Error(), + } +} + +// BotProblems contains problems that prevent orders from being placed. +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:"walletNotSynced"` + // 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 `json:"accountSuspended"` + // UserLimitTooLow is true if the user does not have the bonding amount + // necessary to place all of their orders. + UserLimitTooLow bool `json:"userLimitTooLow"` + // NoPriceSource is true if there is no oracle or fiat rate available. + NoPriceSource bool `json:"noPriceSource"` + // 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"` + // CausesSelfMatch is true if the order would cause a self match. + CausesSelfMatch bool `json:"causesSelfMatch"` + // UnknownError is set if an error occurred that was not one of the above. + UnknownError error `json:"unknownError"` +} + +// EpochReport contains a report of a bot's activity during an epoch. +type EpochReport struct { + // PreOrderProblems is set if there were problems with the bot's + // configuration or state that prevents orders from being placed. + PreOrderProblems *BotProblems `json:"preOrderProblems"` + // BuysReport is the report for the buys. + BuysReport *OrderReport `json:"buysReport"` + // SellsReport is the report for the sells. + SellsReport *OrderReport `json:"sellsReport"` + // EpochNum is the number of the epoch. + EpochNum uint64 `json:"epochNum"` +} + +func (er *EpochReport) setPreOrderProblems(err error) { + if err == nil { + er.PreOrderProblems = nil + return + } + + er.PreOrderProblems = &BotProblems{} + updateBotProblemsBasedOnError(er.PreOrderProblems, err) +} + +// CEXProblems contains a record of the last attempted CEX operations by +// a bot. +type CEXProblems struct { + // 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"` + // TradeErr is set if the last attempted CEX trade failed. + TradeErr *StampedError `json:"tradeErr"` +} + +func (c *CEXProblems) copy() *CEXProblems { + cp := &CEXProblems{ + DepositErr: make(map[uint32]*StampedError, len(c.DepositErr)), + WithdrawErr: make(map[uint32]*StampedError, len(c.WithdrawErr)), + } + for assetID, err := range c.DepositErr { + if err == nil { + continue + } + cp.DepositErr[assetID] = &StampedError{ + Stamp: err.Stamp, + Error: err.Error, + } + } + for assetID, err := range c.WithdrawErr { + if err == nil { + continue + } + cp.WithdrawErr[assetID] = &StampedError{ + Stamp: err.Stamp, + Error: err.Error, + } + } + if c.TradeErr != nil { + cp.TradeErr = &StampedError{ + Stamp: c.TradeErr.Stamp, + Error: c.TradeErr.Error, + } + } + return cp +} + +func newCEXProblems() *CEXProblems { + return &CEXProblems{ + DepositErr: make(map[uint32]*StampedError), + WithdrawErr: make(map[uint32]*StampedError), + } +} + // 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"` + LatestEpoch *EpochReport `json:"latestEpoch"` + CEXProblems *CEXProblems `json:"cexProblems"` } // Status generates a Status for the MarketMaker. This returns the status of @@ -229,13 +363,19 @@ func (m *MarketMaker) Status() *Status { mkt := MarketWithHost{botCfg.Host, botCfg.BaseID, botCfg.QuoteID} rb := runningBots[mkt] var stats *RunStats + var epochReport *EpochReport + var cexProblems *CEXProblems if rb != nil { stats = rb.stats() + epochReport = rb.latestEpoch() + cexProblems = rb.latestCEXProblems() } status.Bots = append(status.Bots, &BotStatus{ - Config: botCfg, - Running: rb != nil, - RunStats: stats, + Config: botCfg, + Running: rb != nil, + RunStats: stats, + LatestEpoch: epochReport, + CEXProblems: cexProblems, }) } for _, cex := range m.cexList() { @@ -264,9 +404,11 @@ func (m *MarketMaker) RunningBotsStatus() *Status { runningBots := m.runningBotsLookup() for _, rb := range runningBots { status.Bots = append(status.Bots, &BotStatus{ - Config: rb.botCfg(), - Running: true, - RunStats: rb.stats(), + Config: rb.botCfg(), + Running: true, + RunStats: rb.stats(), + LatestEpoch: rb.latestEpoch(), + CEXProblems: rb.latestCEXProblems(), }) } return status @@ -780,6 +922,7 @@ func (m *MarketMaker) StopBot(mkt *MarketWithHost) error { return fmt.Errorf("no bot running on market: %s", mkt) } bot.cm.Disconnect() + m.core.Broadcast(newRunStatsNote(mkt.Host, mkt.BaseID, mkt.QuoteID, nil)) return nil } diff --git a/client/mm/mm_arb_market_maker.go b/client/mm/mm_arb_market_maker.go index 8fe0a8ce42..174ad291f7 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,20 @@ 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 { - newPlacements := make([]*multiTradePlacement, 0, len(cfgPlacements)) +type arbMMPlacementReason struct { + Depth uint64 `json:"depth"` + CEXTooShallow bool `json:"cexFilled"` +} + +func (a *arbMarketMaker) ordersToPlace() (buys, sells []*TradePlacement, err error) { + orders := func(cfgPlacements []*ArbMarketMakingPlacement, sellOnDEX bool) ([]*TradePlacement, error) { + newPlacements := make([]*TradePlacement, 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 { @@ -259,35 +260,31 @@ func (a *arbMarketMaker) ordersToPlace() (buys, sells []*multiTradePlacement) { if !filled { a.log.Infof("CEX %s side has < %s on the orderbook.", sellStr(!sellOnDEX), a.fmtBase(cumulativeCEXDepth)) - newPlacements = append(newPlacements, &multiTradePlacement{ - rate: 0, - lots: 0, - }) + newPlacements = append(newPlacements, &TradePlacement{}) continue } 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{ - rate: placementRate, - lots: cfgPlacement.Lots, - counterTradeRate: extrema, + newPlacements = append(newPlacements, &TradePlacement{ + Rate: placementRate, + Lots: cfgPlacement.Lots, + CounterTradeRate: extrema, }) } - 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 } @@ -331,7 +328,7 @@ func (a *arbMarketMaker) distribution() (dist *distribution, err error) { // 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,29 +341,48 @@ func (a *arbMarketMaker) rebalance(epoch uint64) { } a.currEpoch.Store(epoch) + if !a.checkBotHealth(epoch) { + a.tryCancelOrders(a.ctx, &epoch, false) + return + } + actionTaken, err := a.tryTransfers(currEpoch) if err != nil { a.log.Errorf("Error performing transfers: %v", err) - return - } - if actionTaken { + } else if actionTaken { 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 buysReport, sellsReport *OrderReport + buyOrders, sellOrders, determinePlacementsErr := a.ordersToPlace() + if determinePlacementsErr != nil { + a.tryCancelOrders(a.ctx, &epoch, false) + } else { + var buys, sells map[order.OrderID]*dexOrderInfo + buys, buysReport = 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, sellsReport = 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 + + epochReport := &EpochReport{ + BuysReport: buysReport, + SellsReport: sellsReport, + EpochNum: epoch, } - a.matchesMtx.Unlock() + epochReport.setPreOrderProblems(determinePlacementsErr) + a.updateEpochReport(epochReport) a.cancelExpiredCEXTrades() - a.registerFeeGap() } @@ -379,7 +395,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 +429,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 +462,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..dbf232739c 100644 --- a/client/mm/mm_arb_market_maker_test.go +++ b/client/mm/mm_arb_market_maker_test.go @@ -10,6 +10,7 @@ import ( "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/order" @@ -41,15 +42,16 @@ 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{ + a.buyFees = &OrderFees{ LotFeeRange: &LotFeeRange{ Max: &LotFees{ Redeem: buyRedeemFees, @@ -57,9 +59,9 @@ func TestArbMMRebalance(t *testing.T) { }, Estimated: &LotFees{}, }, - bookingFeesPerLot: buySwapFees, + BookingFeesPerLot: buySwapFees, } - a.sellFees = &orderFees{ + a.sellFees = &OrderFees{ LotFeeRange: &LotFeeRange{ Max: &LotFees{ Redeem: sellRedeemFees, @@ -67,11 +69,10 @@ func TestArbMMRebalance(t *testing.T) { }, Estimated: &LotFees{}, }, - bookingFeesPerLot: sellSwapFees, + BookingFeesPerLot: sellSwapFees, } 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{ @@ -103,7 +104,7 @@ func TestArbMMRebalance(t *testing.T) { } minDexBase = sellLots * (lotSize + sellSwapFees) minCexBase = buyLots * lotSize - minDexQuote = calc.BaseToQuote(buyRate, buyLots*lotSize) + a.buyFees.bookingFeesPerLot*buyLots + minDexQuote = calc.BaseToQuote(buyRate, buyLots*lotSize) + a.buyFees.BookingFeesPerLot*buyLots minCexQuote = calc.BaseToQuote(sellRate, sellLots*lotSize) } @@ -123,6 +124,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 +157,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 +312,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{ @@ -431,7 +440,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 +455,17 @@ func mustParseAdaptorFromMarket(m *core.Market) *unifiedExchangeAdaptor { eventLogDB: newTEventLogDB(), pendingDeposits: make(map[string]*pendingDeposit), pendingWithdrawals: make(map[string]*pendingWithdrawal), + clientCore: tCore, + cexProblems: newCEXProblems(), } + + 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..e65fb0da11 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,9 @@ type basicMMCalculatorImpl struct { log dex.Logger } +var errNoBasisPrice = errors.New("no oracle or fiat rate available") +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 +165,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 +175,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 +192,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 @@ -318,10 +321,10 @@ func (m *basicMarketMaker) orderPrice(basisPrice, feeAdj uint64, sell bool, gapF return basisPrice - adj } -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") +func (m *basicMarketMaker) ordersToPlace() (buyOrders, sellOrders []*TradePlacement, err error) { + basisPrice, err := m.calculator.basisPrice() + if err != nil { + return nil, nil, err } feeGap, err := m.calculator.feeGapStats(basisPrice) @@ -340,8 +343,8 @@ func (m *basicMarketMaker) ordersToPlace() (buyOrders, sellOrders []*multiTradeP m.name, m.fmtRate(basisPrice), m.fmtRate(feeAdj)) } - orders := func(orderPlacements []*OrderPlacement, sell bool) []*multiTradePlacement { - placements := make([]*multiTradePlacement, 0, len(orderPlacements)) + orders := func(orderPlacements []*OrderPlacement, sell bool) []*TradePlacement { + placements := make([]*TradePlacement, 0, len(orderPlacements)) for i, p := range orderPlacements { rate := m.orderPrice(basisPrice, feeAdj, sell, p.GapFactor) @@ -354,9 +357,9 @@ func (m *basicMarketMaker) ordersToPlace() (buyOrders, sellOrders []*multiTradeP if rate == 0 { lots = 0 } - placements = append(placements, &multiTradePlacement{ - rate: rate, - lots: lots, + placements = append(placements, &TradePlacement{ + Rate: rate, + Lots: lots, }) } return placements @@ -375,15 +378,27 @@ 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(newEpoch) { m.tryCancelOrders(m.ctx, &newEpoch, false) return } - m.multiTrade(buyOrders, false, m.cfg().DriftTolerance, newEpoch) - m.multiTrade(sellOrders, true, m.cfg().DriftTolerance, newEpoch) + var buysReport, sellsReport *OrderReport + buyOrders, sellOrders, determinePlacementsErr := m.ordersToPlace() + if determinePlacementsErr != nil { + m.tryCancelOrders(m.ctx, &newEpoch, false) + } else { + _, buysReport = m.multiTrade(buyOrders, false, m.cfg().DriftTolerance, newEpoch) + _, sellsReport = m.multiTrade(sellOrders, true, m.cfg().DriftTolerance, newEpoch) + } + + epochReport := &EpochReport{ + BuysReport: buysReport, + SellsReport: sellsReport, + EpochNum: newEpoch, + } + epochReport.setPreOrderProblems(determinePlacementsErr) + m.updateEpochReport(epochReport) } 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..2ebf31f4e7 100644 --- a/client/mm/mm_basic_test.go +++ b/client/mm/mm_basic_test.go @@ -11,14 +11,16 @@ import ( ) 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 +29,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 +86,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) } @@ -192,8 +193,8 @@ func TestBasicMMRebalance(t *testing.T) { cfgBuyPlacements []*OrderPlacement cfgSellPlacements []*OrderPlacement - expBuyPlacements []*multiTradePlacement - expSellPlacements []*multiTradePlacement + expBuyPlacements []*TradePlacement + expSellPlacements []*TradePlacement } tests := []*test{ { @@ -209,15 +210,15 @@ func TestBasicMMRebalance(t *testing.T) { {Lots: 2, GapFactor: 2}, {Lots: 1, GapFactor: 3}, }, - expBuyPlacements: []*multiTradePlacement{ - {lots: 1, rate: steppedRate(basisPrice-3*halfSpread, rateStep)}, - {lots: 2, rate: steppedRate(basisPrice-2*halfSpread, rateStep)}, - {lots: 3, rate: steppedRate(basisPrice-1*halfSpread, rateStep)}, + expBuyPlacements: []*TradePlacement{ + {Lots: 1, Rate: steppedRate(basisPrice-3*halfSpread, rateStep)}, + {Lots: 2, Rate: steppedRate(basisPrice-2*halfSpread, rateStep)}, + {Lots: 3, Rate: steppedRate(basisPrice-1*halfSpread, rateStep)}, }, - expSellPlacements: []*multiTradePlacement{ - {lots: 3, rate: steppedRate(basisPrice+1*halfSpread, rateStep)}, - {lots: 2, rate: steppedRate(basisPrice+2*halfSpread, rateStep)}, - {lots: 1, rate: steppedRate(basisPrice+3*halfSpread, rateStep)}, + expSellPlacements: []*TradePlacement{ + {Lots: 3, Rate: steppedRate(basisPrice+1*halfSpread, rateStep)}, + {Lots: 2, Rate: steppedRate(basisPrice+2*halfSpread, rateStep)}, + {Lots: 1, Rate: steppedRate(basisPrice+3*halfSpread, rateStep)}, }, }, { @@ -233,15 +234,15 @@ func TestBasicMMRebalance(t *testing.T) { {Lots: 2, GapFactor: 0.1}, {Lots: 1, GapFactor: 0.05}, }, - expBuyPlacements: []*multiTradePlacement{ - {lots: 1, rate: steppedRate(basisPrice-uint64(math.Round((float64(basisPrice)*0.05))), rateStep)}, - {lots: 2, rate: steppedRate(basisPrice-uint64(math.Round((float64(basisPrice)*0.1))), rateStep)}, - {lots: 3, rate: steppedRate(basisPrice-uint64(math.Round((float64(basisPrice)*0.15))), rateStep)}, + expBuyPlacements: []*TradePlacement{ + {Lots: 1, Rate: steppedRate(basisPrice-uint64(math.Round((float64(basisPrice)*0.05))), rateStep)}, + {Lots: 2, Rate: steppedRate(basisPrice-uint64(math.Round((float64(basisPrice)*0.1))), rateStep)}, + {Lots: 3, Rate: steppedRate(basisPrice-uint64(math.Round((float64(basisPrice)*0.15))), rateStep)}, }, - expSellPlacements: []*multiTradePlacement{ - {lots: 3, rate: steppedRate(basisPrice+uint64(math.Round((float64(basisPrice)*0.15))), rateStep)}, - {lots: 2, rate: steppedRate(basisPrice+uint64(math.Round((float64(basisPrice)*0.1))), rateStep)}, - {lots: 1, rate: steppedRate(basisPrice+uint64(math.Round((float64(basisPrice)*0.05))), rateStep)}, + expSellPlacements: []*TradePlacement{ + {Lots: 3, Rate: steppedRate(basisPrice+uint64(math.Round((float64(basisPrice)*0.15))), rateStep)}, + {Lots: 2, Rate: steppedRate(basisPrice+uint64(math.Round((float64(basisPrice)*0.1))), rateStep)}, + {Lots: 1, Rate: steppedRate(basisPrice+uint64(math.Round((float64(basisPrice)*0.05))), rateStep)}, }, }, { @@ -257,15 +258,15 @@ func TestBasicMMRebalance(t *testing.T) { {Lots: 2, GapFactor: 0.1}, {Lots: 1, GapFactor: 0.05}, }, - expBuyPlacements: []*multiTradePlacement{ - {lots: 1, rate: steppedRate(basisPrice-halfSpread-uint64(math.Round((float64(basisPrice)*0.05))), rateStep)}, - {lots: 2, rate: steppedRate(basisPrice-halfSpread-uint64(math.Round((float64(basisPrice)*0.1))), rateStep)}, - {lots: 3, rate: steppedRate(basisPrice-halfSpread-uint64(math.Round((float64(basisPrice)*0.15))), rateStep)}, + expBuyPlacements: []*TradePlacement{ + {Lots: 1, Rate: steppedRate(basisPrice-halfSpread-uint64(math.Round((float64(basisPrice)*0.05))), rateStep)}, + {Lots: 2, Rate: steppedRate(basisPrice-halfSpread-uint64(math.Round((float64(basisPrice)*0.1))), rateStep)}, + {Lots: 3, Rate: steppedRate(basisPrice-halfSpread-uint64(math.Round((float64(basisPrice)*0.15))), rateStep)}, }, - expSellPlacements: []*multiTradePlacement{ - {lots: 3, rate: steppedRate(basisPrice+halfSpread+uint64(math.Round((float64(basisPrice)*0.15))), rateStep)}, - {lots: 2, rate: steppedRate(basisPrice+halfSpread+uint64(math.Round((float64(basisPrice)*0.1))), rateStep)}, - {lots: 1, rate: steppedRate(basisPrice+halfSpread+uint64(math.Round((float64(basisPrice)*0.05))), rateStep)}, + expSellPlacements: []*TradePlacement{ + {Lots: 3, Rate: steppedRate(basisPrice+halfSpread+uint64(math.Round((float64(basisPrice)*0.15))), rateStep)}, + {Lots: 2, Rate: steppedRate(basisPrice+halfSpread+uint64(math.Round((float64(basisPrice)*0.1))), rateStep)}, + {Lots: 1, Rate: steppedRate(basisPrice+halfSpread+uint64(math.Round((float64(basisPrice)*0.05))), rateStep)}, }, }, { @@ -281,14 +282,14 @@ func TestBasicMMRebalance(t *testing.T) { {Lots: 2, GapFactor: .03}, {Lots: 1, GapFactor: .01}, }, - expBuyPlacements: []*multiTradePlacement{ - {lots: 1, rate: steppedRate(basisPrice-1e6, rateStep)}, - {lots: 2, rate: steppedRate(basisPrice-3e6, rateStep)}, + expBuyPlacements: []*TradePlacement{ + {Lots: 1, Rate: steppedRate(basisPrice-1e6, rateStep)}, + {Lots: 2, Rate: steppedRate(basisPrice-3e6, rateStep)}, }, - expSellPlacements: []*multiTradePlacement{ - {lots: 3, rate: steppedRate(basisPrice+6e6, rateStep)}, - {lots: 2, rate: steppedRate(basisPrice+3e6, rateStep)}, - {lots: 1, rate: steppedRate(basisPrice+1e6, rateStep)}, + expSellPlacements: []*TradePlacement{ + {Lots: 3, Rate: steppedRate(basisPrice+6e6, rateStep)}, + {Lots: 2, Rate: steppedRate(basisPrice+3e6, rateStep)}, + {Lots: 1, Rate: steppedRate(basisPrice+1e6, rateStep)}, }, }, { @@ -304,14 +305,14 @@ func TestBasicMMRebalance(t *testing.T) { {Lots: 2, GapFactor: .03}, {Lots: 1, GapFactor: .01}, }, - expBuyPlacements: []*multiTradePlacement{ - {lots: 1, rate: steppedRate(basisPrice-halfSpread-1e6, rateStep)}, - {lots: 2, rate: steppedRate(basisPrice-halfSpread-3e6, rateStep)}, + expBuyPlacements: []*TradePlacement{ + {Lots: 1, Rate: steppedRate(basisPrice-halfSpread-1e6, rateStep)}, + {Lots: 2, Rate: steppedRate(basisPrice-halfSpread-3e6, rateStep)}, }, - expSellPlacements: []*multiTradePlacement{ - {lots: 3, rate: steppedRate(basisPrice+halfSpread+6e6, rateStep)}, - {lots: 2, rate: steppedRate(basisPrice+halfSpread+3e6, rateStep)}, - {lots: 1, rate: steppedRate(basisPrice+halfSpread+1e6, rateStep)}, + expSellPlacements: []*TradePlacement{ + {Lots: 3, Rate: steppedRate(basisPrice+halfSpread+6e6, rateStep)}, + {Lots: 2, Rate: steppedRate(basisPrice+halfSpread+3e6, rateStep)}, + {Lots: 1, Rate: steppedRate(basisPrice+halfSpread+1e6, rateStep)}, }, }, } @@ -331,12 +332,16 @@ 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}) const sellSwapFees, sellRedeemFees = 3e6, 1e6 const buySwapFees, buyRedeemFees = 2e5, 1e5 - mm.buyFees = &orderFees{ + mm.buyFees = &OrderFees{ LotFeeRange: &LotFeeRange{ Max: &LotFees{ Redeem: buyRedeemFees, @@ -344,9 +349,9 @@ func TestBasicMMRebalance(t *testing.T) { }, Estimated: &LotFees{}, }, - bookingFeesPerLot: buySwapFees, + BookingFeesPerLot: buySwapFees, } - mm.sellFees = &orderFees{ + mm.sellFees = &OrderFees{ LotFeeRange: &LotFeeRange{ Max: &LotFees{ Redeem: sellRedeemFees, @@ -354,7 +359,7 @@ func TestBasicMMRebalance(t *testing.T) { }, Estimated: &LotFees{}, }, - bookingFeesPerLot: sellSwapFees, + BookingFeesPerLot: sellSwapFees, } mm.baseDexBalances[baseID] = lotSize * 50 mm.baseCexBalances[baseID] = lotSize * 50 @@ -382,11 +387,11 @@ func TestBasicMMRebalance(t *testing.T) { buyRateLots[p.Rate] = p.Qty / lotSize } for _, expBuy := range tt.expBuyPlacements { - if lots, found := buyRateLots[expBuy.rate]; !found { - t.Fatalf("buy rate %d not found", expBuy.rate) + if lots, found := buyRateLots[expBuy.Rate]; !found { + t.Fatalf("buy rate %d not found", expBuy.Rate) } else { - if expBuy.lots != lots { - t.Fatalf("wrong lots %d for buy at rate %d", lots, expBuy.rate) + if expBuy.Lots != lots { + t.Fatalf("wrong lots %d for buy at rate %d", lots, expBuy.Rate) } } } @@ -395,11 +400,11 @@ func TestBasicMMRebalance(t *testing.T) { sellRateLots[p.Rate] = p.Qty / lotSize } for _, expSell := range tt.expSellPlacements { - if lots, found := sellRateLots[expSell.rate]; !found { - t.Fatalf("sell rate %d not found", expSell.rate) + if lots, found := sellRateLots[expSell.Rate]; !found { + t.Fatalf("sell rate %d not found", expSell.Rate) } else { - if expSell.lots != lots { - t.Fatalf("wrong lots %d for sell at rate %d", lots, expSell.rate) + if expSell.Lots != lots { + t.Fatalf("wrong lots %d for sell at rate %d", lots, expSell.Rate) } } } diff --git a/client/mm/mm_simple_arb.go b/client/mm/mm_simple_arb.go index ace464b65a..726127f134 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,24 @@ func (a *simpleArbMarketMaker) handleDEXOrderUpdate(o *core.Order) { } } +func (a *simpleArbMarketMaker) tryArb(newEpoch uint64) (exists, sellOnDEX bool) { + if !(a.checkBotHealth(newEpoch) && a.tradingLimitNotReached(newEpoch)) { + return false, false + } + + 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) + } + + 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) { @@ -366,21 +398,11 @@ func (a *simpleArbMarketMaker) rebalance(newEpoch uint64) { actionTaken, err := a.tryTransfers(newEpoch) if err != nil { a.log.Errorf("Error performing transfers: %v", err) - return - } - if actionTaken { + } else if actionTaken { 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 +527,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..43ca7410cd 100644 --- a/client/mm/mm_simple_arb_test.go +++ b/client/mm/mm_simple_arb_test.go @@ -459,140 +459,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 +694,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 +818,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 +854,154 @@ 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..722e0b4b6f 100644 --- a/client/mm/mm_test.go +++ b/client/mm/mm_test.go @@ -69,10 +69,10 @@ type tCore struct { assetBalances map[uint32]*core.WalletBalance assetBalanceErr error market *core.Market - singleLotSellFees *orderFees - singleLotBuyFees *orderFees + singleLotSellFees *OrderFees + singleLotBuyFees *OrderFees singleLotFeesErr error - multiTradeResult []*core.Order + multiTradeResult []*core.MultiTradeResult noteFeed chan core.Notification isAccountLocker map[uint32]bool isWithdrawer map[uint32]bool @@ -89,6 +89,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 +106,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), } } @@ -144,9 +149,9 @@ func (c *tCore) Cancel(oidB dex.Bytes) error { 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) { +func (c *tCore) MultiTrade(pw []byte, forms *core.MultiTradeForm) []*core.MultiTradeResult { c.multiTradesPlaced = append(c.multiTradesPlaced, forms) - return c.multiTradeResult, nil + return c.multiTradeResult } func (c *tCore) WalletTraits(assetID uint32) (asset.WalletTrait, error) { isAccountLocker := c.isAccountLocker[assetID] @@ -175,9 +180,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 +193,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 +219,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 { @@ -243,8 +270,8 @@ type tBotCoreAdaptor struct { groupedBuys map[uint64][]*core.Order groupedSells map[uint64][]*core.Order orderUpdates chan *core.Order - buyFees *orderFees - sellFees *orderFees + buyFees *OrderFees + sellFees *OrderFees fiatExchangeRate uint64 buyFeesInBase uint64 sellFeesInBase uint64 @@ -254,6 +281,7 @@ type tBotCoreAdaptor struct { maxSellQty uint64 lastTradePlaced *dexOrder tradeResult *core.Order + balanceDefs map[uint32]uint64 } func (c *tBotCoreAdaptor) DEXBalance(assetID uint32) (*BotBalance, error) { @@ -273,7 +301,7 @@ func (c *tBotCoreAdaptor) ExchangeRateFromFiatSources() uint64 { return c.fiatExchangeRate } -func (c *tBotCoreAdaptor) OrderFees() (buyFees, sellFees *orderFees, err error) { +func (c *tBotCoreAdaptor) OrderFees() (buyFees, sellFees *OrderFees, err error) { return c.buyFees, c.sellFees, nil } @@ -294,11 +322,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 +340,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 +561,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 +571,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 +620,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 } @@ -660,9 +668,11 @@ func (t *tExchangeAdaptor) timeStart() int64 { return 0 func (t *tExchangeAdaptor) Book() (buys, sells []*core.MiniOrder, _ error) { return nil, nil, nil } -func (t *tExchangeAdaptor) sendStatsUpdate() {} -func (t *tExchangeAdaptor) withPause(func() error) error { return nil } -func (t *tExchangeAdaptor) botCfg() *BotConfig { return t.cfg } +func (t *tExchangeAdaptor) sendStatsUpdate() {} +func (t *tExchangeAdaptor) withPause(func() error) error { return nil } +func (t *tExchangeAdaptor) botCfg() *BotConfig { return t.cfg } +func (t *tExchangeAdaptor) latestEpoch() *EpochReport { return &EpochReport{} } +func (t *tExchangeAdaptor) latestCEXProblems() *CEXProblems { return nil } func TestAvailableBalances(t *testing.T) { ctx, cancel := context.WithCancel(context.Background()) diff --git a/client/mm/notification.go b/client/mm/notification.go index 42df01c83e..343bc21663 100644 --- a/client/mm/notification.go +++ b/client/mm/notification.go @@ -11,6 +11,8 @@ const ( NoteTypeRunStats = "runstats" NoteTypeRunEvent = "runevent" NoteTypeCEXNotification = "cexnote" + NoteTypeEpochReport = "epochreport" + NoteTypeCEXProblems = "cexproblems" ) type runStatsNote struct { @@ -71,3 +73,39 @@ func newCexUpdateNote(cexName string, topic db.Topic, note interface{}) *cexNoti Note: note, } } + +type botProblemsNotification struct { + db.Notification + Host string `json:"host"` + BaseID uint32 `json:"baseID"` + QuoteID uint32 `json:"quoteID"` + Report *EpochReport `json:"report"` +} + +func newEpochReportNote(host string, baseID, quoteID uint32, report *EpochReport) *botProblemsNotification { + return &botProblemsNotification{ + Notification: db.NewNotification(NoteTypeEpochReport, "", "", "", db.Data), + Host: host, + BaseID: baseID, + QuoteID: quoteID, + Report: report, + } +} + +type cexProblemsNotification struct { + db.Notification + Host string `json:"host"` + BaseID uint32 `json:"baseID"` + QuoteID uint32 `json:"quoteID"` + Problems *CEXProblems `json:"problems"` +} + +func newCexProblemsNote(host string, baseID, quoteID uint32, problems *CEXProblems) *cexProblemsNotification { + return &cexProblemsNotification{ + Notification: db.NewNotification(NoteTypeCEXProblems, "", "", "", db.Data), + Host: host, + BaseID: baseID, + QuoteID: quoteID, + Problems: problems, + } +} diff --git a/client/mm/utils.go b/client/mm/utils.go index 8a1ce24268..948f7aa5a3 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,55 @@ func steppedRate(r, step uint64) uint64 { } return uint64(math.Round(steps * float64(step))) } + +// updateBotProblemsBasedOnError updates BotProblems based on an error +// encountered during market making. +func updateBotProblemsBasedOnError(problems *BotProblems, err error) { + if err == nil { + return + } + + if noPeersErr, is := err.(*core.WalletNoPeersError); is { + if problems.NoWalletPeers == nil { + problems.NoWalletPeers = make(map[uint32]bool) + } + problems.NoWalletPeers[noPeersErr.AssetID] = true + return + } + + if noSyncErr, is := err.(*core.WalletSyncError); is { + if problems.WalletNotSynced == nil { + problems.WalletNotSynced = make(map[uint32]bool) + } + problems.WalletNotSynced[noSyncErr.AssetID] = true + return + } + + if errors.Is(err, core.ErrAccountSuspended) { + problems.AccountSuspended = true + return + } + + var mErr *msgjson.Error + if errors.As(err, &mErr) && mErr.Code == msgjson.OrderQuantityTooHigh { + problems.UserLimitTooLow = true + return + } + + if errors.Is(err, errNoBasisPrice) { + problems.NoPriceSource = true + return + } + + if errors.Is(err, libxc.ErrUnsyncedOrderbook) { + problems.CEXOrderbookUnsynced = true + return + } + + if errors.Is(err, errOracleFiatMismatch) { + problems.OracleFiatMismatch = true + return + } + + problems.UnknownError = err +} diff --git a/client/rpcserver/handlers.go b/client/rpcserver/handlers.go index a57e58ee04..8417584557 100644 --- a/client/rpcserver/handlers.go +++ b/client/rpcserver/handlers.go @@ -524,13 +524,16 @@ func handleMultiTrade(s *RPCServer, params *RawParams) *msgjson.ResponsePayload return usage(multiTradeRoute, err) } defer form.appPass.Clear() - res, err := s.core.MultiTrade(form.appPass, form.srvForm) - if err != nil { - resErr := msgjson.NewError(msgjson.RPCTradeError, "unable to multi trade: %v", err) - return createResponse(multiTradeRoute, nil, resErr) - } - trades := make([]*tradeResponse, 0, len(res)) - for _, trade := range res { + results := s.core.MultiTrade(form.appPass, form.srvForm) + trades := make([]*tradeResponse, 0, len(results)) + for _, res := range results { + if res.Error != nil { + trades = append(trades, &tradeResponse{ + Error: res.Error, + }) + continue + } + trade := res.Order trades = append(trades, &tradeResponse{ OrderID: trade.ID.String(), Sig: trade.Sig.String(), diff --git a/client/rpcserver/rpcserver.go b/client/rpcserver/rpcserver.go index 78f4de75b3..d6779cb559 100644 --- a/client/rpcserver/rpcserver.go +++ b/client/rpcserver/rpcserver.go @@ -83,7 +83,7 @@ type clientCore interface { AddWalletPeer(assetID uint32, host string) error RemoveWalletPeer(assetID uint32, host string) error Notifications(int) (notes, pokes []*db.Notification, _ error) - MultiTrade(pw []byte, form *core.MultiTradeForm) ([]*core.Order, error) + MultiTrade(pw []byte, form *core.MultiTradeForm) []*core.MultiTradeResult TxHistory(assetID uint32, n int, refID *string, past bool) ([]*asset.WalletTransaction, error) WalletTransaction(assetID uint32, txID string) (*asset.WalletTransaction, error) diff --git a/client/rpcserver/rpcserver_test.go b/client/rpcserver/rpcserver_test.go index 6a5f0205cc..879b9a60a8 100644 --- a/client/rpcserver/rpcserver_test.go +++ b/client/rpcserver/rpcserver_test.go @@ -177,8 +177,8 @@ func (c *TCore) RemoveWalletPeer(assetID uint32, address string) error { func (c *TCore) Notifications(n int) (notes, pokes []*db.Notification, _ error) { return nil, nil, nil } -func (c *TCore) MultiTrade(appPass []byte, form *core.MultiTradeForm) ([]*core.Order, error) { - return nil, nil +func (c *TCore) MultiTrade(appPass []byte, form *core.MultiTradeForm) []*core.MultiTradeResult { + return nil } func (c *TCore) SetVSP(assetID uint32, addr string) error { return c.setVSPErr diff --git a/client/rpcserver/types.go b/client/rpcserver/types.go index ffc923ea7d..16991f32d4 100644 --- a/client/rpcserver/types.go +++ b/client/rpcserver/types.go @@ -61,6 +61,7 @@ type tradeResponse struct { OrderID string `json:"orderID"` Sig string `json:"sig"` Stamp uint64 `json:"stamp"` + Error error `json:"error,omitempty"` } // myOrdersResponse is used when responding to the myorders route. diff --git a/client/webserver/jsintl.go b/client/webserver/jsintl.go index 733be59e2e..2a33a92c50 100644 --- a/client/webserver/jsintl.go +++ b/client/webserver/jsintl.go @@ -198,6 +198,24 @@ const ( disableAccount = "DISABLE_ACCOUNT" accountDisabledMsg = "ACCOUNT_DISABLED_MSG" dexDisabledMsg = "DEX_DISABLED_MSG" + idWalletNotSynced = "WALLET_NOT_SYNCED" + idWalletNoPeers = "WALLET_NO_PEERS" + idDepositError = "DEPOSIT_ERROR" + idWithdrawError = "WITHDRAW_ERROR" + idDEXUnderfunded = "DEX_UNDERFUNDED" + idCEXUnderfunded = "CEX_UNDERFUNDED" + idCEXTooShallow = "CEX_TOO_SHALLOW" + idAccountSuspended = "ACCOUNT_SUSPENDED" + idUserLimitTooLow = "USER_LIMIT_TOO_LOW" + idNoPriceSource = "NO_PRICE_SOURCE" + idCEXOrderbookUnsynced = "CEX_ORDERBOOK_UNSYNCED" + idDeterminePlacementsError = "DETERMINE_PLACEMENTS_ERROR" + idPlaceBuyOrdersError = "PLACE_BUY_ORDERS_ERROR" + idPlaceSellOrdersError = "PLACE_SELL_ORDERS_ERROR" + idCEXTradeError = "CEX_TRADE_ERROR" + idOrderReportTitle = "ORDER_REPORT_TITLE" + idCEXBalances = "CEX_BALANCES" + idCausesSelfMatch = "CAUSES_SELF_MATCH" ) var enUS = map[string]*intl.Translation{ @@ -395,6 +413,24 @@ var enUS = map[string]*intl.Translation{ disableAccount: {T: "Disable Account"}, accountDisabledMsg: {T: "account disabled - re-enable to update settings"}, dexDisabledMsg: {T: "DEX server is disabled. Visit the settings page to enable and connect to this server."}, + idWalletNotSynced: {T: "{{ assetSymbol }} wallet not synced."}, + idWalletNoPeers: {T: "{{ assetSymbol }} wallet has no peers."}, + idDepositError: {T: "The last attempted deposit of {{ assetSymbol }} at {{ time }} failed with the following error: {{ error }}"}, + idWithdrawError: {T: "The last attempted withdrawal of {{ assetSymbol }} at {{ time }} failed with the following error: {{ error }}"}, + idDEXUnderfunded: {T: "The {{ assetSymbol }} wallet is underfunded by {{ amount }}"}, + idCEXUnderfunded: {T: "The {{ cexName }} {{ assetSymbol }} wallet is underfunded by {{ amount }}"}, + idCEXTooShallow: {T: "The {{ cexName }} market on the {{ side }} side is too shallow for arbitrages as specified by the configuration."}, + idAccountSuspended: {T: "Your account at {{ dexHost }} is suspended."}, + idUserLimitTooLow: {T: "Your account at {{ dexHost }} has a limit too low to place all the orders required by the configuration."}, + idNoPriceSource: {T: "No oracle or fiat rate sources are available for this market."}, + idCEXOrderbookUnsynced: {T: "The {{ cexName }} orderbook is not synced."}, + idDeterminePlacementsError: {T: "Error determining placements: {{ error }}"}, + idPlaceBuyOrdersError: {T: "Error placing buy orders: {{ error }}"}, + idPlaceSellOrdersError: {T: "Error placing sell orders: {{ error }}"}, + idCEXTradeError: {T: "The last attempted CEX trade at {{ time }} failed with the following error: {{ error }}"}, + idOrderReportTitle: {T: "{{ side }} orders report for epoch #{{ epochNum }}"}, + idCEXBalances: {T: "{{ cexName }} Balances"}, + idCausesSelfMatch: {T: "This order would cause a self-match"}, } var ptBR = map[string]*intl.Translation{ diff --git a/client/webserver/locales/en-us.go b/client/webserver/locales/en-us.go index 616dfd0451..2db4ce7c0a 100644 --- a/client/webserver/locales/en-us.go +++ b/client/webserver/locales/en-us.go @@ -658,4 +658,24 @@ var EnUS = map[string]*intl.Translation{ "Hide trading tier info": {T: "Hide trading tier info"}, "Show reputation": {T: "Show reputation"}, "Hide reputation": {T: "Hide reputation"}, + "buy_orders_success": {T: "All buy orders placed successfully"}, + "sell_orders_success": {T: "All sell orders placed successfully"}, + "buy_orders_failed": {T: "Unable to place all buy orders"}, + "sell_orders_failed": {T: "Unable to place all sell orders"}, + "Order report": {T: "Order report"}, + "Remaining": {T: "Remaining"}, + "Used": {T: "Used"}, + "Deficiency": {T: "Deficiency"}, + "Deficiency with Pending": {T: "Deficiency with Pending"}, + "Standing Lots": {T: "Standing Lots"}, + "Ordered Lots": {T: "Ordered Lots"}, + "Arb Rate": {T: "Arb Rate"}, + "Required DEX": {T: "Required DEX"}, + "Required CEX": {T: "Required CEX"}, + "Used DEX": {T: "Used DEX"}, + "Used CEX": {T: "Used CEX"}, + "Causes Self Match": {T: "Causes Self Match"}, + "Priority": {T: "Priority"}, + "Wallet Balances": {T: "Wallet Balances"}, + "Placements": {T: "Placements"}, } diff --git a/client/webserver/site/src/css/market.scss b/client/webserver/site/src/css/market.scss index 52490a8df1..6d43ac5e4e 100644 --- a/client/webserver/site/src/css/market.scss +++ b/client/webserver/site/src/css/market.scss @@ -713,3 +713,11 @@ div[data-handler=markets] { } } } + +.bot-problems-section { + background-color: #f00a; + margin-top: 2px; + margin-bottom: 2px; + padding-left: 2px; + border-radius: 5px; +} diff --git a/client/webserver/site/src/css/mm.scss b/client/webserver/site/src/css/mm.scss index 81c621b7d1..1136b1c1ed 100644 --- a/client/webserver/site/src/css/mm.scss +++ b/client/webserver/site/src/css/mm.scss @@ -52,6 +52,14 @@ div[data-handler=mm] { } } + .bot-problems-section { + background-color: #f00a; + margin-top: 2px; + margin-bottom: 2px; + padding-left: 2px; + border-radius: 5px; + } + #marketFilterIcon { position: absolute; left: 10px; diff --git a/client/webserver/site/src/html/forms.tmpl b/client/webserver/site/src/html/forms.tmpl index fbbb60b60f..7674d74c49 100644 --- a/client/webserver/site/src/html/forms.tmpl +++ b/client/webserver/site/src/html/forms.tmpl @@ -897,6 +897,21 @@
+
+ [[[buy_orders_success]]] + [[[buy_orders_failed]]] + +
+ +
+ [[[sell_orders_success]]] + [[[sell_orders_failed]]] + +
+ +
+
+
[[[Profit]]] @@ -1017,4 +1032,115 @@
-{{end}} \ No newline at end of file +{{end}} + +{{define "orderReportForm"}} +
+
+
+
+
+
[[[Wallet Balances]]]
+ + + + + + + + + + + + + + + + + + + + + + + + + + + +
[[[Asset]]][[[Available]]][[[Locked]]][[[Pending]]][[[Required]]][[[Used]]][[[Remaining]]][[[Deficiency]]][[[Deficiency with Pending]]]
+
+ +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + +
[[[Asset]]][[[Available]]][[[Locked]]][[[Pending]]][[[Required]]][[[Used]]][[[Remaining]]][[[Deficiency]]][[[Deficiency with Pending]]]
+
+ +
Placements
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
[[[Priority]]][[[Lots]]][[[Standing Lots]]][[[Ordered Lots]]][[[Rate]]][[[Arb Rate]]][[[Required DEX]]][[[Used DEX]]][[[Required CEX]]][[[Used CEX]]][[[Error]]]
+ + + + + +
+
+
+{{end}} diff --git a/client/webserver/site/src/html/markets.tmpl b/client/webserver/site/src/html/markets.tmpl index 120e6905b2..78915a9629 100644 --- a/client/webserver/site/src/html/markets.tmpl +++ b/client/webserver/site/src/html/markets.tmpl @@ -813,6 +813,10 @@
{{template "accelerateForm" .}}
+ +
+ {{template "orderReportForm"}} +
diff --git a/client/webserver/site/src/html/mm.tmpl b/client/webserver/site/src/html/mm.tmpl index 0cc0207a25..b7dac7d5d6 100644 --- a/client/webserver/site/src/html/mm.tmpl +++ b/client/webserver/site/src/html/mm.tmpl @@ -605,6 +605,10 @@
{{template "cexConfigForm"}}
+ +
+ {{template "orderReportForm"}} +
{{- /* END FORMS */ -}} {{template "bottom"}} diff --git a/client/webserver/site/src/js/app.ts b/client/webserver/site/src/js/app.ts index 9fe1fde02a..aa035b90f0 100644 --- a/client/webserver/site/src/js/app.ts +++ b/client/webserver/site/src/js/app.ts @@ -60,7 +60,9 @@ import { RunStatsNote, MMBotStatus, CEXNotification, - CEXBalanceUpdate + CEXBalanceUpdate, + EpochReportNote, + CEXProblemsNote } from './registry' import { setCoinHref } from './coinexplorers' @@ -1172,6 +1174,10 @@ export default class Application { if (bot) { bot.runStats = n.stats bot.running = Boolean(n.stats) + if (!n.stats) { + bot.latestEpoch = undefined + bot.cexProblems = undefined + } } break } @@ -1185,6 +1191,18 @@ export default class Application { } break } + case 'epochreport': { + const n = note as EpochReportNote + const bot = this.botStatus(n.host, n.baseID, n.quoteID) + if (bot) bot.latestEpoch = n.report + break + } + case 'cexproblems': { + const n = note as CEXProblemsNote + const bot = this.botStatus(n.host, n.baseID, n.quoteID) + if (bot) bot.cexProblems = n.problems + break + } } } diff --git a/client/webserver/site/src/js/forms.ts b/client/webserver/site/src/js/forms.ts index e253d9cec5..92ed0950de 100644 --- a/client/webserver/site/src/js/forms.ts +++ b/client/webserver/site/src/js/forms.ts @@ -68,7 +68,7 @@ interface FormsConfig { export class Forms { formsDiv: PageElement - currentForm: PageElement + currentForm: PageElement | undefined keyup: (e: KeyboardEvent) => void closed?: () => void @@ -81,6 +81,7 @@ export class Forms { }) Doc.bind(formsDiv, 'mousedown', (e: MouseEvent) => { + if (!this.currentForm) return if (!Doc.mouseInElement(e, this.currentForm)) { this.close() } }) @@ -108,6 +109,7 @@ export class Forms { close (): void { Doc.hide(this.formsDiv) if (this.closed) this.closed() + this.currentForm = undefined } exit () { diff --git a/client/webserver/site/src/js/locales.ts b/client/webserver/site/src/js/locales.ts index 1dd2ee7ddf..056dcdcc62 100644 --- a/client/webserver/site/src/js/locales.ts +++ b/client/webserver/site/src/js/locales.ts @@ -198,6 +198,24 @@ export const ID_ENABLE_ACCOUNT = 'ENABLE_ACCOUNT' export const ID_DISABLE_ACCOUNT = 'DISABLE_ACCOUNT' export const ID_ACCOUNT_DISABLED_MSG = 'ACCOUNT_DISABLED_MSG' export const ID_DEX_DISABLED_MSG = 'DEX_DISABLED_MSG' +export const ID_WALLET_NOT_SYNCED = 'WALLET_NOT_SYNCED' +export const ID_WALLET_NO_PEERS = 'WALLET_NO_PEERS' +export const ID_DEPOSIT_ERROR = 'DEPOSIT_ERROR' +export const ID_WITHDRAW_ERROR = 'WITHDRAW_ERROR' +export const ID_DEX_UNDERFUNDED = 'DEX_UNDERFUNDED' +export const ID_CEX_UNDERFUNDED = 'CEX_UNDERFUNDED' +export const ID_CEX_TOO_SHALLOW = 'CEX_TOO_SHALLOW' +export const ID_ACCOUNT_SUSPENDED = 'ACCOUNT_SUSPENDED' +export const ID_USER_LIMIT_TOO_LOW = 'USER_LIMIT_TOO_LOW' +export const ID_NO_PRICE_SOURCE = 'NO_PRICE_SOURCE' +export const ID_CEX_ORDERBOOK_UNSYNCED = 'CEX_ORDERBOOK_UNSYNCED' +export const ID_DETERMINE_PLACEMENTS_ERROR = 'DETERMINE_PLACEMENTS_ERROR' +export const ID_PLACE_BUY_ORDERS_ERROR = 'PLACE_BUY_ORDERS_ERROR' +export const ID_PLACE_SELL_ORDERS_ERROR = 'PLACE_SELL_ORDERS_ERROR' +export const ID_CEX_TRADE_ERROR = 'CEX_TRADE_ERROR' +export const ID_ORDER_REPORT_TITLE = 'ORDER_REPORT_TITLE' +export const ID_CEX_BALANCES = 'CEX_BALANCES' +export const ID_CAUSES_SELF_MATCH = 'CAUSES_SELF_MATCH' let locale: Locale diff --git a/client/webserver/site/src/js/markets.ts b/client/webserver/site/src/js/markets.ts index d0da9d6fbd..f254349f6a 100644 --- a/client/webserver/site/src/js/markets.ts +++ b/client/webserver/site/src/js/markets.ts @@ -19,7 +19,8 @@ import { AccelerateOrderForm, DepositAddress, TokenApprovalForm, - bind as bindForm + bind as bindForm, + Forms } from './forms' import * as OrderUtil from './orderutil' import ws from './ws' @@ -64,7 +65,9 @@ import { ApprovalStatus, OrderFilter, RunStatsNote, - RunEventNote + RunEventNote, + EpochReportNote, + CEXProblemsNote } from './registry' import { setOptionTemplates } from './opts' import { RunningMarketMakerDisplay } from './mmutil' @@ -81,8 +84,6 @@ const candleUpdateRoute = 'candle_update' const unmarketRoute = 'unmarket' const epochMatchSummaryRoute = 'epoch_match_summary' -const animationLength = 500 - const anHour = 60 * 60 * 1000 // milliseconds const maxUserOrdersShown = 10 @@ -191,7 +192,7 @@ export default class MarketsPage extends BasePage { stats: [StatsDisplay, StatsDisplay] loadingAnimations: { candles?: Wave, depth?: Wave } mmRunning: boolean | undefined - + forms: Forms constructor (main: HTMLElement, pageParams: MarketsPageParams) { super() @@ -214,6 +215,7 @@ export default class MarketsPage extends BasePage { this.recentMatchesSortDirection = -1 // store original title so we can re-append it when updating market value. this.ogTitle = document.title + this.forms = new Forms(page.forms) const depthReporters = { click: (x: number) => { this.reportDepthClick(x) }, @@ -272,7 +274,7 @@ export default class MarketsPage extends BasePage { this.depositAddrForm = new DepositAddress(page.deposit) } - this.mm = new RunningMarketMakerDisplay(page.mmRunning, 'markets') + this.mm = new RunningMarketMakerDisplay(page.mmRunning, this.forms, page.orderReportForm, 'markets') this.reputationMeter = new ReputationMeter(page.reputationMeter) @@ -366,7 +368,7 @@ export default class MarketsPage extends BasePage { // Cancel order form. bindForm(page.cancelForm, page.cancelSubmit, async () => { this.submitCancel() }) // Order detail view. - Doc.bind(page.vFeeDetails, 'click', () => this.showForm(page.vDetailPane)) + Doc.bind(page.vFeeDetails, 'click', () => this.forms.show(page.vDetailPane)) Doc.bind(page.closeDetailPane, 'click', () => this.showVerifyForm()) // // Bind active orders list's header sort events. page.recentMatchesTable.querySelectorAll('[data-ordercol]') @@ -408,7 +410,7 @@ export default class MarketsPage extends BasePage { setRecentMatchesSortColClasses() const closePopups = () => { - Doc.hide(page.forms) + this.forms.close() } // If the user clicks outside of a form, it should close the page overlay. @@ -529,6 +531,14 @@ export default class MarketsPage extends BasePage { this.resolveOrderFormVisibility() } }, + epochreport: (note: EpochReportNote) => { + if (note.baseID !== this.market.base.id || note.quoteID !== this.market.quote.id || note.host !== this.market.dex.host) return + this.mm.handleEpochReportNote(note) + }, + cexproblems: (note: CEXProblemsNote) => { + if (note.baseID !== this.market.base.id || note.quoteID !== this.market.quote.id || note.host !== this.market.dex.host) return + this.mm.handleCexProblemsNote(note) + }, runevent: (note: RunEventNote) => { if (note.baseID !== this.market.base.id || note.quoteID !== this.market.quote.id || note.host !== this.market.dex.host) return this.mm.update() @@ -832,7 +842,7 @@ export default class MarketsPage extends BasePage { async showTokenApprovalForm (isBase: boolean) { const assetID = isBase ? this.market.base.id : this.market.quote.id this.approveTokenForm.setAsset(assetID, this.market.dex.host) - this.showForm(this.page.approveTokenForm) + this.forms.show(this.page.approveTokenForm) } /* @@ -1968,20 +1978,6 @@ export default class MarketsPage extends BasePage { this.candleChart.draw() } - /* showForm shows a modal form with a little animation. */ - async showForm (form: HTMLElement) { - this.currentForm = form - const page = this.page - Doc.hide(...Array.from(page.forms.children)) - form.style.right = '10000px' - Doc.show(page.forms, form) - const shift = (page.forms.offsetWidth + form.offsetWidth) / 2 - await Doc.animate(animationLength, progress => { - form.style.right = `${(1 - progress) * shift}px` - }, 'easeOutHard') - form.style.right = '0' - } - /* * showToggleWalletStatus displays the toggleWalletStatusConfirm form to * enable a wallet. @@ -1991,7 +1987,7 @@ export default class MarketsPage extends BasePage { this.openAsset = asset Doc.hide(page.toggleWalletStatusErr, page.walletStatusDisable, page.disableWalletMsg) Doc.show(page.walletStatusEnable, page.enableWalletMsg) - this.showForm(page.toggleWalletStatusConfirm) + this.forms.show(page.toggleWalletStatusConfirm) } /* @@ -2137,7 +2133,7 @@ export default class MarketsPage extends BasePage { async showVerifyForm () { const page = this.page Doc.hide(page.vErr) - this.showForm(page.verifyForm) + this.forms.show(page.verifyForm) } /* @@ -2393,7 +2389,7 @@ export default class MarketsPage extends BasePage { page.cancelRemain.textContent = Doc.formatCoinValue(remaining, asset.unitInfo) page.cancelUnit.textContent = asset.symbol.toUpperCase() Doc.hide(page.cancelErr) - this.showForm(page.cancelForm) + this.forms.show(page.cancelForm) this.cancelData = { bttn: Doc.tmplElement(row, 'cancelBttn'), order: ord @@ -2405,7 +2401,7 @@ export default class MarketsPage extends BasePage { const loaded = app().loading(this.main) this.accelerateOrderForm.refresh(order) loaded() - this.showForm(this.page.accelerateForm) + this.forms.show(this.page.accelerateForm) } /* showCreate shows the new wallet creation form. */ @@ -2413,7 +2409,7 @@ export default class MarketsPage extends BasePage { const page = this.page this.currentCreate = asset this.newWalletForm.setAsset(asset.id) - this.showForm(page.newWalletForm) + this.forms.show(page.newWalletForm) } /* @@ -2446,7 +2442,7 @@ export default class MarketsPage extends BasePage { /* Display a deposit address. */ async showDeposit (assetID: number) { this.depositAddrForm.setAsset(assetID) - this.showForm(this.page.deposit) + this.forms.show(this.page.deposit) } showCustomProviderDialog (assetID: number) { diff --git a/client/webserver/site/src/js/mm.ts b/client/webserver/site/src/js/mm.ts index 6b61611805..31b8bdc3eb 100644 --- a/client/webserver/site/src/js/mm.ts +++ b/client/webserver/site/src/js/mm.ts @@ -8,7 +8,9 @@ import { StartConfig, OrderPlacement, AutoRebalanceConfig, - CEXNotification + CEXNotification, + EpochReportNote, + CEXProblemsNote } from './registry' import { MM, @@ -242,6 +244,14 @@ export default class MarketMakerPage extends BasePage { const bot = this.bots[hostedMarketID(note.host, note.baseID, note.quoteID)] if (bot) return bot.handleRunStats() }, + epochreport: (note: EpochReportNote) => { + const bot = this.bots[hostedMarketID(note.host, note.baseID, note.quoteID)] + if (bot) bot.handleEpochReportNote(note) + }, + cexproblems: (note: CEXProblemsNote) => { + const bot = this.bots[hostedMarketID(note.host, note.baseID, note.quoteID)] + if (bot) bot.handleCexProblemsNote(note) + }, cexnote: (note: CEXNotification) => { this.handleCEXNote(note) } // TODO bot start-stop notification }) @@ -411,7 +421,7 @@ class Bot extends BotMarket { const div = this.div = pg.page.botTmpl.cloneNode(true) as PageElement const page = this.page = Doc.parseTemplate(div) - this.runDisplay = new RunningMarketMakerDisplay(page.onBox, 'mm') + this.runDisplay = new RunningMarketMakerDisplay(page.onBox, pg.forms, pg.page.orderReportForm, 'mm') setMarketElements(div, baseID, quoteID, host) if (cexName) setCexElements(div, cexName) @@ -809,6 +819,14 @@ class Bot extends BotMarket { app().loadPage('mmsettings', { host, baseID, quoteID, cexName, botType }) } + handleEpochReportNote (note: EpochReportNote) { + this.runDisplay.handleEpochReportNote(note) + } + + handleCexProblemsNote (note: CEXProblemsNote) { + this.runDisplay.handleCexProblemsNote(note) + } + handleRunStats () { this.updateDisplay() this.updateTableRow() diff --git a/client/webserver/site/src/js/mmutil.ts b/client/webserver/site/src/js/mmutil.ts index eef602eb5f..96f07f2ac0 100644 --- a/client/webserver/site/src/js/mmutil.ts +++ b/client/webserver/site/src/js/mmutil.ts @@ -21,12 +21,22 @@ import { BotBalance, Order, LotFeeRange, - BookingFees + BookingFees, + BotProblems, + EpochReportNote, + OrderReport, + EpochReport, + TradePlacement, + SupportedAsset, + CEXProblemsNote, + CEXProblems } from './registry' import { getJSON, postJSON } from './http' import Doc, { clamp } from './doc' import * as OrderUtil from './orderutil' import { Chart, Region, Extents, Translator } from './charts' +import * as intl from './locales' +import { Forms } from './forms' export const GapStrategyMultiplier = 'multiplier' export const GapStrategyAbsolute = 'absolute' @@ -407,6 +417,7 @@ export class BotMarket { quoteLot: number quoteLotConv: number quoteLotUSD: number + rateStep: number baseFeeFiatRate: number quoteFeeFiatRate: number baseLots: number @@ -459,9 +470,10 @@ export class BotMarket { this.mktID = `${baseSymbol}_${quoteSymbol}` const { markets } = app().exchanges[host] - const { lotsize: lotSize } = markets[this.mktID] + const { lotsize: lotSize, ratestep: rateStep } = markets[this.mktID] this.lotSize = lotSize this.lotSizeConv = lotSize / bui.conventional.conversionFactor + this.rateStep = rateStep this.quoteLot = calculateQuoteLot(lotSize, baseID, quoteID) this.quoteLotConv = this.quoteLot / qui.conventional.conversionFactor @@ -507,8 +519,8 @@ export class BotMarket { const { baseID, quoteID } = this const botStatus = app().mmStatus.bots.find((s: MMBotStatus) => s.config.baseID === baseID && s.config.quoteID === quoteID) if (!botStatus) return { botCfg: {} as BotConfig, running: false, runStats: {} as RunStats } - const { config: botCfg, running, runStats } = botStatus - return { botCfg, running, runStats } + const { config: botCfg, running, runStats, latestEpoch, cexProblems } = botStatus + return { botCfg, running, runStats, latestEpoch, cexProblems } } /* @@ -760,15 +772,34 @@ export class RunningMarketMakerDisplay { mkt: BotMarket startTime: number ticker: any - - constructor (div: PageElement, page: string) { + currentForm: PageElement + forms: Forms + latestEpoch?: EpochReport + cexProblems?: CEXProblems + orderReportFormEl: PageElement + orderReportForm: Record + dexBalancesRowTmpl: PageElement + placementRowTmpl: PageElement + placementAmtRowTmpl: PageElement + displayedSide: 'buys' | 'sells' + + constructor (div: PageElement, forms: Forms, orderReportForm: PageElement, page: string) { this.div = div this.page = Doc.parseTemplate(div) + this.orderReportFormEl = orderReportForm + this.orderReportForm = Doc.idDescendants(orderReportForm) + this.dexBalancesRowTmpl = this.orderReportForm.dexBalancesRowTmpl + this.placementRowTmpl = this.orderReportForm.placementRowTmpl + this.placementAmtRowTmpl = this.orderReportForm.placementAmtRowTmpl + Doc.cleanTemplates(this.dexBalancesRowTmpl, this.placementRowTmpl, this.placementAmtRowTmpl) + this.forms = forms Doc.bind(this.page.stopBttn, 'click', () => this.stop()) Doc.bind(this.page.runLogsBttn, 'click', () => { const { mkt: { baseID, quoteID, host }, startTime } = this app().loadPage('mmlogs', { baseID, quoteID, host, startTime, returnPage: page }) }) + Doc.bind(this.page.buyOrdersBttn, 'click', () => this.showOrderReport('buys')) + Doc.bind(this.page.sellOrdersBttn, 'click', () => this.showOrderReport('sells')) } async stop () { @@ -843,6 +874,28 @@ export class RunningMarketMakerDisplay { this.update() } + handleEpochReportNote (n: EpochReportNote) { + if (!this.mkt) return + const { baseID, quoteID, host } = this.mkt + if (n.baseID !== baseID || n.quoteID !== quoteID || n.host !== host) return + if (!n.report) return + this.latestEpoch = n.report + if (this.forms.currentForm === this.orderReportFormEl) { + const orderReport = this.displayedSide === 'buys' ? n.report.buysReport : n.report.sellsReport + if (orderReport) this.updateOrderReport(orderReport, this.displayedSide, n.report.epochNum) + else this.forms.close() + } + this.update() + } + + handleCexProblemsNote (n: CEXProblemsNote) { + if (!this.mkt) return + const { baseID, quoteID, host } = this.mkt + if (n.baseID !== baseID || n.quoteID !== quoteID || n.host !== host) return + this.cexProblems = n.problems + this.update() + } + setTicker () { this.page.runTime.textContent = Doc.hmsSince(this.startTime) } @@ -855,8 +908,12 @@ export class RunningMarketMakerDisplay { } } = this // Get fresh stats - const { botCfg: { cexName, basicMarketMakingConfig: bmmCfg }, runStats } = this.mkt.status() + const { botCfg: { cexName, basicMarketMakingConfig: bmmCfg }, runStats, latestEpoch, cexProblems } = this.mkt.status() + if (latestEpoch) this.latestEpoch = latestEpoch + if (cexProblems) this.cexProblems = cexProblems + Doc.hide(page.stats, page.cexRow, page.pendingDepositBox, page.pendingWithdrawalBox) + if (!runStats) { if (this.ticker) { clearInterval(this.ticker) @@ -933,6 +990,195 @@ export class RunningMarketMakerDisplay { page.remoteGap.textContent = Doc.formatFourSigFigs(remoteGap) page.remoteGapPct.textContent = (remoteGap / basisPrice * 100 || 0).toFixed(2) } + + Doc.setVis(latestEpoch?.buysReport, page.buyOrdersReportBox) + if (latestEpoch?.buysReport) { + const allPlaced = allOrdersPlaced(latestEpoch.buysReport) + Doc.setVis(allPlaced, page.buyOrdersSuccess) + Doc.setVis(!allPlaced, page.buyOrdersFailed) + } + + Doc.setVis(latestEpoch?.sellsReport, page.sellOrdersReportBox) + if (latestEpoch?.sellsReport) { + const allPlaced = allOrdersPlaced(latestEpoch.sellsReport) + Doc.setVis(allPlaced, page.sellOrdersSuccess) + Doc.setVis(!allPlaced, page.sellOrdersFailed) + } + + const preOrderProblemMessages = botProblemMessages(latestEpoch?.preOrderProblems, this.mkt.cexName, this.mkt.host) + const cexErrorMessages = cexProblemMessages(this.cexProblems) + const allMessages = [...preOrderProblemMessages, ...cexErrorMessages] + Doc.setVis(allMessages.length > 0, page.preOrderProblemsBox) + Doc.empty(page.preOrderProblemsBox) + for (const msg of allMessages) { + const spanEl = document.createElement('span') as PageElement + spanEl.textContent = `- ${msg}` + page.preOrderProblemsBox.appendChild(spanEl) + } + } + + updateOrderReport (report: OrderReport, side: 'buys' | 'sells', epochNum: number) { + const form = this.orderReportForm + const sideTxt = side === 'buys' ? intl.prep(intl.ID_BUY) : intl.prep(intl.ID_SELL) + form.orderReportTitle.textContent = intl.prep(intl.ID_ORDER_REPORT_TITLE, { side: sideTxt, epochNum: `${epochNum}` }) + + Doc.setVis(report.error, form.orderReportError) + Doc.setVis(!report.error, form.orderReportDetails) + if (report.error) { + const problemMessages = botProblemMessages(report.error, this.mkt.cexName, this.mkt.host) + Doc.empty(form.orderReportError) + for (const msg of problemMessages) { + const spanEl = document.createElement('span') as PageElement + spanEl.textContent = `- ${msg}` + form.orderReportError.appendChild(spanEl) + } + return + } + + form.cexLogo.src = CEXDisplayInfos[this.mkt.cexName].logo + form.cexBalancesTitle.textContent = intl.prep(intl.ID_CEX_BALANCES, { cexName: CEXDisplayInfos[this.mkt.cexName].name }) + Doc.empty(form.dexBalancesBody, form.placementsBody) + const createRow = (assetID: number): [PageElement, number] => { + const row = this.dexBalancesRowTmpl.cloneNode(true) as HTMLElement + const rowTmpl = Doc.parseTemplate(row) + const asset = app().assets[assetID] + rowTmpl.asset.textContent = asset.symbol.toUpperCase() + rowTmpl.assetLogo.src = Doc.logoPath(asset.symbol) + const unitInfo = asset.unitInfo + const available = report.availableDexBals[assetID] ? report.availableDexBals[assetID].available : 0 + const required = report.requiredDexBals[assetID] ? report.requiredDexBals[assetID] : 0 + const remaining = report.remainingDexBals[assetID] ? report.remainingDexBals[assetID] : 0 + const pending = report.availableDexBals[assetID] ? report.availableDexBals[assetID].pending : 0 + const locked = report.availableDexBals[assetID] ? report.availableDexBals[assetID].locked : 0 + const used = report.usedDexBals[assetID] ? report.usedDexBals[assetID] : 0 + rowTmpl.available.textContent = Doc.formatCoinValue(available, unitInfo) + rowTmpl.locked.textContent = Doc.formatCoinValue(locked, unitInfo) + rowTmpl.required.textContent = Doc.formatCoinValue(required, unitInfo) + rowTmpl.remaining.textContent = Doc.formatCoinValue(remaining, unitInfo) + rowTmpl.pending.textContent = Doc.formatCoinValue(pending, unitInfo) + rowTmpl.used.textContent = Doc.formatCoinValue(used, unitInfo) + const deficiency = safeSub(required, available) + rowTmpl.deficiency.textContent = Doc.formatCoinValue(deficiency, unitInfo) + if (deficiency > 0) rowTmpl.deficiency.classList.add('text-danger') + const deficiencyWithPending = safeSub(deficiency, pending) + rowTmpl.deficiencyWithPending.textContent = Doc.formatCoinValue(deficiencyWithPending, unitInfo) + if (deficiencyWithPending > 0) rowTmpl.deficiencyWithPending.classList.add('text-danger') + return [row, deficiency] + } + const setDeficiencyVisibility = (deficiency: boolean, rows: HTMLElement[]) => { + Doc.setVis(deficiency, form.dexDeficiencyHeader, form.dexDeficiencyWithPendingHeader) + for (const row of rows) { + const rowTmpl = Doc.parseTemplate(row) + Doc.setVis(deficiency, rowTmpl.deficiency, rowTmpl.deficiencyWithPending) + } + } + const assetIDs = [this.mkt.baseID, this.mkt.quoteID] + if (!assetIDs.includes(this.mkt.baseFeeID)) assetIDs.push(this.mkt.baseFeeID) + if (!assetIDs.includes(this.mkt.quoteFeeID)) assetIDs.push(this.mkt.quoteFeeID) + let totalDeficiency = 0 + const rows : PageElement[] = [] + for (const assetID of assetIDs) { + const [row, deficiency] = createRow(assetID) + totalDeficiency += deficiency + form.dexBalancesBody.appendChild(row) + rows.push(row) + } + setDeficiencyVisibility(totalDeficiency > 0, rows) + + Doc.setVis(this.mkt.cexName, form.cexBalancesTable, form.counterTradeRateHeader) + let cexAsset: SupportedAsset + if (this.mkt.cexName) { + const cexAssetID = side === 'buys' ? this.mkt.baseID : this.mkt.quoteID + cexAsset = app().assets[cexAssetID] + form.cexAsset.textContent = cexAsset.symbol.toUpperCase() + form.cexAssetLogo.src = Doc.logoPath(cexAsset.symbol) + const availableCexBal = report.availableCexBal ? report.availableCexBal.available : 0 + const requiredCexBal = report.requiredCexBal ? report.requiredCexBal : 0 + const remainingCexBal = report.remainingCexBal ? report.remainingCexBal : 0 + const pendingCexBal = report.availableCexBal ? report.availableCexBal.pending : 0 + const reservedCexBal = report.availableCexBal ? report.availableCexBal.reserved : 0 + const usedCexBal = report.usedCexBal ? report.usedCexBal : 0 + const deficiencyCexBal = safeSub(requiredCexBal, availableCexBal) + const deficiencyWithPendingCexBal = safeSub(deficiencyCexBal, pendingCexBal) + form.cexAvailable.textContent = Doc.formatCoinValue(availableCexBal, cexAsset.unitInfo) + form.cexLocked.textContent = Doc.formatCoinValue(reservedCexBal, cexAsset.unitInfo) + form.cexRequired.textContent = Doc.formatCoinValue(requiredCexBal, cexAsset.unitInfo) + form.cexRemaining.textContent = Doc.formatCoinValue(remainingCexBal, cexAsset.unitInfo) + form.cexPending.textContent = Doc.formatCoinValue(pendingCexBal, cexAsset.unitInfo) + form.cexUsed.textContent = Doc.formatCoinValue(usedCexBal, cexAsset.unitInfo) + const deficient = deficiencyCexBal > 0 + Doc.setVis(deficient, form.cexDeficiencyHeader, form.cexDeficiencyWithPendingHeader, + form.cexDeficiency, form.cexDeficiencyWithPending) + if (deficient) { + form.cexDeficiency.textContent = Doc.formatCoinValue(deficiencyCexBal, cexAsset.unitInfo) + form.cexDeficiencyWithPending.textContent = Doc.formatCoinValue(deficiencyWithPendingCexBal, cexAsset.unitInfo) + } + } + + let anyErrors = false + for (const placement of report.placements) if (placement.error) { anyErrors = true; break } + Doc.setVis(anyErrors, form.errorHeader) + const createPlacementRow = (placement: TradePlacement, priority: number): PageElement => { + const row = this.placementRowTmpl.cloneNode(true) as HTMLElement + const rowTmpl = Doc.parseTemplate(row) + const baseUI = app().assets[this.mkt.baseID].unitInfo + const quoteUI = app().assets[this.mkt.quoteID].unitInfo + rowTmpl.priority.textContent = String(priority) + rowTmpl.rate.textContent = Doc.formatRateFullPrecision(placement.rate, baseUI, quoteUI, this.mkt.rateStep) + rowTmpl.lots.textContent = String(placement.lots) + rowTmpl.standingLots.textContent = String(placement.standingLots) + rowTmpl.orderedLots.textContent = String(placement.orderedLots) + if (placement.standingLots + placement.orderedLots < placement.lots) { + rowTmpl.lots.classList.add('text-danger') + rowTmpl.standingLots.classList.add('text-danger') + rowTmpl.orderedLots.classList.add('text-danger') + } + Doc.setVis(placement.counterTradeRate > 0, rowTmpl.counterTradeRate) + rowTmpl.counterTradeRate.textContent = Doc.formatRateFullPrecision(placement.counterTradeRate, baseUI, quoteUI, this.mkt.rateStep) + for (const assetID of assetIDs) { + const asset = app().assets[assetID] + const unitInfo = asset.unitInfo + const requiredAmt = placement.requiredDex[assetID] ? placement.requiredDex[assetID] : 0 + const usedAmt = placement.usedDex[assetID] ? placement.usedDex[assetID] : 0 + const requiredRow = this.placementAmtRowTmpl.cloneNode(true) as HTMLElement + const requiredRowTmpl = Doc.parseTemplate(requiredRow) + const usedRow = this.placementAmtRowTmpl.cloneNode(true) as HTMLElement + const usedRowTmpl = Doc.parseTemplate(usedRow) + requiredRowTmpl.amt.textContent = Doc.formatCoinValue(requiredAmt, unitInfo) + requiredRowTmpl.assetLogo.src = Doc.logoPath(asset.symbol) + requiredRowTmpl.assetSymbol.textContent = asset.symbol.toUpperCase() + usedRowTmpl.amt.textContent = Doc.formatCoinValue(usedAmt, unitInfo) + usedRowTmpl.assetLogo.src = Doc.logoPath(asset.symbol) + usedRowTmpl.assetSymbol.textContent = asset.symbol.toUpperCase() + rowTmpl.requiredDEX.appendChild(requiredRow) + rowTmpl.usedDEX.appendChild(usedRow) + } + Doc.setVis(this.mkt.cexName, rowTmpl.requiredCEX, rowTmpl.usedCEX) + if (this.mkt.cexName) { + const requiredAmt = Doc.formatCoinValue(placement.requiredCex, cexAsset.unitInfo) + rowTmpl.requiredCEX.textContent = `${requiredAmt} ${cexAsset.symbol.toUpperCase()}` + const usedAmt = Doc.formatCoinValue(placement.usedCex, cexAsset.unitInfo) + rowTmpl.usedCEX.textContent = `${usedAmt} ${cexAsset.symbol.toUpperCase()}` + } + Doc.setVis(anyErrors, rowTmpl.error) + if (placement.error) { + const errMessages = botProblemMessages(placement.error, this.mkt.cexName, this.mkt.host) + rowTmpl.error.textContent = errMessages.join('\n') + } + return row + } + for (let i = 0; i < report.placements.length; i++) { + form.placementsBody.appendChild(createPlacementRow(report.placements[i], i + 1)) + } + } + + showOrderReport (side: 'buys' | 'sells') { + if (!this.latestEpoch) return + const report = side === 'buys' ? this.latestEpoch.buysReport : this.latestEpoch.sellsReport + if (!report) return + this.updateOrderReport(report, side, this.latestEpoch.epochNum) + this.displayedSide = side + this.forms.show(this.orderReportFormEl) } readBook () { @@ -943,6 +1189,16 @@ export class RunningMarketMakerDisplay { } } +function allOrdersPlaced (report: OrderReport) { + if (report.error) return false + for (let i = 0; i < report.placements.length; i++) { + const placement = report.placements[i] + if (placement.orderedLots + placement.standingLots < placement.lots) return false + if (placement.error) return false + } + return true +} + function setSignedValue (v: number, vEl: PageElement, signEl: PageElement, maxDecimals?: number) { vEl.textContent = Doc.formatFourSigFigs(v, maxDecimals) signEl.classList.toggle('ico-plus', v > 0) @@ -1038,3 +1294,87 @@ export function feesAndCommit ( return { commit, fees } } + +function botProblemMessages (problems: BotProblems | undefined, cexName: string, dexHost: string): string[] { + if (!problems) return [] + const msgs: string[] = [] + + if (problems.walletNotSynced) { + for (const [assetID, notSynced] of Object.entries(problems.walletNotSynced)) { + if (notSynced) { + msgs.push(intl.prep(intl.ID_WALLET_NOT_SYNCED, { assetSymbol: app().assets[Number(assetID)].symbol.toUpperCase() })) + } + } + } + + if (problems.noWalletPeers) { + for (const [assetID, noPeers] of Object.entries(problems.noWalletPeers)) { + if (noPeers) { + msgs.push(intl.prep(intl.ID_WALLET_NO_PEERS, { assetSymbol: app().assets[Number(assetID)].symbol.toUpperCase() })) + } + } + } + + if (problems.accountSuspended) { + msgs.push(intl.prep(intl.ID_ACCOUNT_SUSPENDED, { dexHost: dexHost })) + } + + if (problems.userLimitTooLow) { + msgs.push(intl.prep(intl.ID_USER_LIMIT_TOO_LOW, { dexHost: dexHost })) + } + + if (problems.noPriceSource) { + msgs.push(intl.prep(intl.ID_NO_PRICE_SOURCE)) + } + + if (problems.cexOrderbookUnsynced) { + msgs.push(intl.prep(intl.ID_CEX_ORDERBOOK_UNSYNCED, { cexName: cexName })) + } + + if (problems.causesSelfMatch) { + msgs.push(intl.prep(intl.ID_CAUSES_SELF_MATCH)) + } + + return msgs +} + +function cexProblemMessages (problems: CEXProblems | undefined): string[] { + if (!problems) return [] + const msgs: string[] = [] + if (problems.depositErr) { + for (const [assetID, depositErr] of Object.entries(problems.depositErr)) { + msgs.push(intl.prep(intl.ID_DEPOSIT_ERROR, + { + assetSymbol: app().assets[Number(assetID)].symbol.toUpperCase(), + time: new Date(depositErr.stamp * 1000).toLocaleString(), + error: depositErr.error + })) + } + } + if (problems.withdrawErr) { + for (const [assetID, withdrawErr] of Object.entries(problems.withdrawErr)) { + msgs.push(intl.prep(intl.ID_WITHDRAW_ERROR, + { + assetSymbol: app().assets[Number(assetID)].symbol.toUpperCase(), + time: new Date(withdrawErr.stamp * 1000).toLocaleString(), + error: withdrawErr.error + })) + } + } + if (problems.tradeErr) { + msgs.push(intl.prep(intl.ID_CEX_TRADE_ERROR, + { + time: new Date(problems.tradeErr.stamp * 1000).toLocaleString(), + error: problems.tradeErr.error + })) + } + return msgs +} + +function safeSub (a: number, b: number) { + return a - b > 0 ? a - b : 0 +} + +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 98ad0c3a4c..644d65f078 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 @@ -883,6 +884,20 @@ export interface CEXBalanceUpdate { balance: ExchangeBalance } +export interface EpochReportNote extends CoreNote { + host: string + baseID: number + quoteID: number + report?: EpochReport +} + +export interface CEXProblemsNote extends CoreNote { + host: string + baseID: number + quoteID: number + problems?: CEXProblems +} + export interface FeeEstimates extends LotFeeRange { bookingFeesPerLot: number bookingFees: number @@ -940,10 +955,71 @@ export interface RunStats { feeGap: FeeGapStats } +export interface StampedError { + stamp: number + error: string +} + +export interface BotProblems { + walletNotSynced: Record + noWalletPeers: Record + accountSuspended: boolean + userLimitTooLow: boolean + noPriceSource: boolean + oracleFiatMismatch: boolean + cexOrderbookUnsynced: boolean + causesSelfMatch: boolean + unknownError: string +} + +export interface TradePlacement { + rate: number + lots: number + standingLots: number + orderedLots: number + counterTradeRate: number + requiredDex: Record + requiredCex: number + usedDex: Record + usedCex: number + causesSelfMatch: boolean + error?: BotProblems + reason: any +} + +export interface OrderReport { + placements: TradePlacement[] + fees: LotFeeRange + availableDexBals: Record + requiredDexBals: Record + remainingDexBals: Record + usedDexBals: Record + availableCexBal: BotBalance + requiredCexBal: number + remainingCexBal: number + usedCexBal: number + error?: BotProblems +} + +export interface EpochReport { + epochNum: number + preOrderProblems?: BotProblems + buysReport?: OrderReport + sellsReport?: OrderReport +} + +export interface CEXProblems { + depositErr: Record + withdrawErr: Record + tradeErr: StampedError +} + export interface MMBotStatus { config: BotConfig running: boolean runStats?: RunStats + latestEpoch?: EpochReport + cexProblems?: CEXProblems } export interface MarketMakingStatus { diff --git a/dex/utils/generics.go b/dex/utils/generics.go index 572442b603..d5c56ae4da 100644 --- a/dex/utils/generics.go +++ b/dex/utils/generics.go @@ -35,6 +35,13 @@ func MapKeys[K comparable, V any](m map[K]V) []K { return ks } +func SafeSub[I constraints.Unsigned](a I, b I) I { + if a < b { + return 0 + } + return a - b +} + func Min[I constraints.Ordered](m I, ns ...I) I { min := m for _, n := range ns {