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]]] |
+
+
+
+
+
+
+ |
+ |
+ |
+ |
+ |
+ |
+ |
+ |
+ |
+
+
+
+
+
+
+
+
+
+ [[[Asset]]] |
+ [[[Available]]] |
+ [[[Locked]]] |
+ [[[Pending]]] |
+ [[[Required]]] |
+ [[[Used]]] |
+ [[[Remaining]]] |
+
+
+
+
+
+
+ |
+ |
+ |
+ |
+ |
+ |
+ |
+ |
+ |
+
+
+
+
+
+
Placements
+
+
+
+ [[[Priority]]] |
+ [[[Lots]]] |
+ [[[Standing Lots]]] |
+ [[[Ordered Lots]]] |
+ [[[Rate]]] |
+
+ [[[Required DEX]]] |
+ [[[Used DEX]]] |
+ [[[Required CEX]]] |
+ [[[Used CEX]]] |
+
+
+
+
+
+ |
+ |
+ |
+ |
+ |
+ |
+
+
+
+
+
+
+ |
+ |
+ |
+ |
+ |
+
+
+
+
+
+{{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 @@
+
+
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 @@
+
+
{{- /* 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 {