diff --git a/client/cmd/testbinance/main.go b/client/cmd/testbinance/main.go index d5cc39e79a..1459f2f198 100644 --- a/client/cmd/testbinance/main.go +++ b/client/cmd/testbinance/main.go @@ -433,6 +433,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 85cfae4040..b3884390ea 100644 --- a/client/core/core.go +++ b/client/core/core.go @@ -5749,34 +5749,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 @@ -5911,7 +5913,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) @@ -5948,12 +5950,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 } @@ -10897,3 +10897,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 {