diff --git a/client/mm/exchange_adaptor.go b/client/mm/exchange_adaptor.go index 3688781d71..a41d4a05eb 100644 --- a/client/mm/exchange_adaptor.go +++ b/client/mm/exchange_adaptor.go @@ -71,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, map[uint32]uint64, error) + SufficientBalanceForDEXTrade(rate, qty uint64, sell bool) (bool, error) } // botCexAdaptor is an interface used by bots to access CEX related @@ -84,7 +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, map[uint32]uint64) + SufficientBalanceForCEXTrade(baseID, quoteID uint32, sell bool, rate, qty uint64) bool MidGap(baseID, quoteID uint32) uint64 Book() (buys, sells []*core.MiniOrder, _ error) } @@ -613,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, map[uint32]uint64, error) { +func (u *unifiedExchangeAdaptor) SufficientBalanceForDEXTrade(rate, qty uint64, sell bool) (bool, error) { fromAsset, fromFeeAsset, toAsset, toFeeAsset := orderAssets(u.baseID, u.quoteID, sell) balances := map[uint32]uint64{} for _, assetID := range []uint32{fromAsset, fromFeeAsset, toAsset, toFeeAsset} { @@ -625,57 +625,53 @@ func (u *unifiedExchangeAdaptor) SufficientBalanceForDEXTrade(rate, qty uint64, buyFees, sellFees, err := u.orderFees() if err != nil { - return false, nil, err + return false, err } - - reqBals := make(map[uint32]uint64) - - // Funding Fees fees, fundingFees := buyFees.Max, buyFees.Funding if sell { fees, fundingFees = sellFees.Max, sellFees.Funding } - reqBals[fromFeeAsset] += fundingFees - // Trade Qty + if balances[fromFeeAsset] < fundingFees { + return false, nil + } + balances[fromFeeAsset] -= fundingFees + fromQty := qty if !sell { fromQty = calc.BaseToQuote(rate, qty) } - reqBals[fromAsset] += fromQty + if balances[fromAsset] < fromQty { + return false, nil + } + balances[fromAsset] -= fromQty - // Swap Fees numLots := qty / u.lotSize - reqBals[fromFeeAsset] += numLots * fees.Swap + if balances[fromFeeAsset] < numLots*fees.Swap { + return false, nil + } + balances[fromFeeAsset] -= numLots * fees.Swap - // Refund Fees if u.isAccountLocker(fromAsset) { - reqBals[fromFeeAsset] += numLots * fees.Refund + if balances[fromFeeAsset] < numLots*fees.Refund { + return false, nil + } + balances[fromFeeAsset] -= numLots * fees.Refund } - // Redeem Fees if u.isAccountLocker(toAsset) { - 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 + if balances[toFeeAsset] < numLots*fees.Redeem { + return false, nil } + balances[toFeeAsset] -= numLots * fees.Redeem } - return sufficient, deficiencies, nil + return true, 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, map[uint32]uint64) { +func (u *unifiedExchangeAdaptor) SufficientBalanceForCEXTrade(baseID, quoteID uint32, sell bool, rate, qty uint64) bool { var fromAssetID uint32 var fromAssetQty uint64 if sell { @@ -687,12 +683,7 @@ func (u *unifiedExchangeAdaptor) SufficientBalanceForCEXTrade(baseID, quoteID ui } fromAssetBal := u.CEXBalance(fromAssetID) - - if fromAssetBal.Available < fromAssetQty { - return false, map[uint32]uint64{fromAssetID: fromAssetQty - fromAssetBal.Available} - } - - return true, nil + return fromAssetBal.Available >= fromAssetQty } // dexOrderInfo is used by MultiTrade to keep track of the placement index @@ -1042,10 +1033,11 @@ type TradePlacement struct { RequiredCEX uint64 `json:"requiredCex"` UsedDEX map[uint32]uint64 `json:"usedDex"` UsedCEX uint64 `json:"usedCex"` - Order *core.Order `json:"order"` Error *BotProblems `json:"error"` } +// setError sets the error field of the TradePlacement and updates the fields +// that indicate that the trade was placed to 0. func (tp *TradePlacement) setError(err error) { if err == nil { tp.Error = nil @@ -1082,6 +1074,10 @@ type OrderReport struct { } func (or *OrderReport) setError(err error) { + if err == nil { + or.Error = nil + return + } if or.Error == nil { or.Error = &BotProblems{} } @@ -1089,14 +1085,15 @@ func (or *OrderReport) setError(err error) { } 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 + cpPlacements := make([]*TradePlacement, len(placements)) + for i, p := range placements { + cpPlacements[i] = &TradePlacement{ + Rate: p.Rate, + Lots: p.Lots, + CounterTradeRate: p.CounterTradeRate, + RequiredDEX: make(map[uint32]uint64), + UsedDEX: make(map[uint32]uint64), + } } return &OrderReport{ @@ -1104,7 +1101,7 @@ func newOrderReport(placements []*TradePlacement) *OrderReport { RequiredDEXBals: make(map[uint32]uint64), RemainingDEXBals: make(map[uint32]uint64), UsedDEXBals: make(map[uint32]uint64), - Placements: placements, + Placements: cpPlacements, } } @@ -1362,7 +1359,7 @@ func (u *unifiedExchangeAdaptor) multiTrade( // 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 } @@ -2174,8 +2171,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, _ := u.SufficientBalanceForCEXTrade(baseID, quoteID, sell, rate, qty) - if !sufficient { + if !u.SufficientBalanceForCEXTrade(baseID, quoteID, sell, rate, qty) { return nil, fmt.Errorf("insufficient balance") } @@ -2345,7 +2341,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) ([]dex.Bytes, bool) { +func (u *unifiedExchangeAdaptor) tryCancelOrders(ctx context.Context, epoch *uint64, cancelCEXOrders bool) bool { u.balancesMtx.RLock() defer u.balancesMtx.RUnlock() @@ -2384,7 +2380,7 @@ func (u *unifiedExchangeAdaptor) tryCancelOrders(ctx context.Context, epoch *uin } if !cancelCEXOrders { - return cancels, false + return false } for _, pendingOrder := range u.pendingCEXOrders { @@ -2411,7 +2407,7 @@ func (u *unifiedExchangeAdaptor) tryCancelOrders(ctx context.Context, epoch *uin } } - return cancels, done + return done } func (u *unifiedExchangeAdaptor) cancelAllOrders(ctx context.Context) { @@ -2431,7 +2427,7 @@ func (u *unifiedExchangeAdaptor) cancelAllOrders(ctx context.Context) { } currentEpoch := book.CurrentEpoch() - if _, done := u.tryCancelOrders(ctx, ¤tEpoch, true); done { + if u.tryCancelOrders(ctx, ¤tEpoch, true) { return } @@ -2445,7 +2441,7 @@ func (u *unifiedExchangeAdaptor) cancelAllOrders(ctx context.Context) { case ni := <-bookFeed.Next(): switch epoch := ni.Payload.(type) { case *core.ResolvedEpoch: - if _, done := u.tryCancelOrders(ctx, &epoch.Current, true); done { + if u.tryCancelOrders(ctx, &epoch.Current, true) { return } timer.Reset(timeout) @@ -3116,7 +3112,6 @@ func (u *unifiedExchangeAdaptor) transfer(dist *distribution, currEpoch uint64) return false, fmt.Errorf("error withdrawing quote: %w", err) } } - return true, nil } @@ -3171,6 +3166,12 @@ func (u *unifiedExchangeAdaptor) inventory(assetID uint32, dexLot, cexLot uint64 // of lots, a 1-lot estimate will be attempted too. func (u *unifiedExchangeAdaptor) cexCounterRates(cexBuyLots, cexSellLots uint64) (dexBuyRate, dexSellRate uint64, err error) { tryLots := func(b, s uint64) (uint64, uint64, bool, error) { + if b == 0 { + b = 1 + } + if s == 0 { + s = 1 + } buyRate, _, filled, err := u.CEX.VWAP(u.baseID, u.quoteID, true, u.lotSize*s) if err != nil { return 0, 0, false, fmt.Errorf("error calculating dex buy price for quote conversion: %w", err) @@ -3656,51 +3657,44 @@ const ( cexWithdrawProblem ) -func (u *unifiedExchangeAdaptor) updateCEXProblems(typ cexProblemType, assetID uint32, err error) { - u.cexProblemsMtx.RLock() - existingErrNil := func() bool { +// updateCEXProblemState updates the state of a cex problem. It returns true +// if the problem state was updated. It is always updated if the error is +// non-nil. +func (u *unifiedExchangeAdaptor) updateCEXProblemState(typ cexProblemType, assetID uint32, err error) bool { + if err != nil { switch typ { case cexTradeProblem: - return u.cexProblems.TradeErr == nil + u.cexProblems.TradeErr = newStampedError(err) case cexDepositProblem: - return u.cexProblems.DepositErr[assetID] == nil + u.cexProblems.DepositErr[assetID] = newStampedError(err) case cexWithdrawProblem: - return u.cexProblems.WithdrawErr[assetID] == nil - default: - return true + u.cexProblems.WithdrawErr[assetID] = newStampedError(err) } + return true } - if existingErrNil() && err == nil { - u.cexProblemsMtx.RUnlock() - return - } - u.cexProblemsMtx.RUnlock() - - u.cexProblemsMtx.Lock() - defer u.cexProblemsMtx.Unlock() + var updated bool switch typ { case cexTradeProblem: - if err == nil { - u.cexProblems.TradeErr = nil - } else { - u.cexProblems.TradeErr = newStampedError(err) - } + updated = u.cexProblems.TradeErr != nil + u.cexProblems.TradeErr = nil case cexDepositProblem: - if err == nil { - delete(u.cexProblems.DepositErr, assetID) - } else { - u.cexProblems.DepositErr[assetID] = newStampedError(err) - } + updated = u.cexProblems.DepositErr[assetID] != nil + delete(u.cexProblems.DepositErr, assetID) case cexWithdrawProblem: - if err == nil { - delete(u.cexProblems.WithdrawErr, assetID) - } else { - u.cexProblems.WithdrawErr[assetID] = newStampedError(err) - } + updated = u.cexProblems.WithdrawErr[assetID] != nil + delete(u.cexProblems.WithdrawErr, assetID) } + return updated +} - u.clientCore.Broadcast(newCexProblemsNote(u.host, u.baseID, u.quoteID, u.cexProblems)) +func (u *unifiedExchangeAdaptor) updateCEXProblems(typ cexProblemType, assetID uint32, err error) { + u.cexProblemsMtx.Lock() + defer u.cexProblemsMtx.Unlock() + + if u.updateCEXProblemState(typ, assetID, 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. diff --git a/client/mm/exchange_adaptor_test.go b/client/mm/exchange_adaptor_test.go index 6936415f55..c568c4541c 100644 --- a/client/mm/exchange_adaptor_test.go +++ b/client/mm/exchange_adaptor_test.go @@ -18,6 +18,7 @@ import ( "decred.org/dcrdex/dex" "decred.org/dcrdex/dex/calc" "decred.org/dcrdex/dex/encode" + "decred.org/dcrdex/dex/msgjson" "decred.org/dcrdex/dex/order" "decred.org/dcrdex/dex/utils" "github.com/davecgh/go-spew/spew" @@ -213,7 +214,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,7 +281,7 @@ func TestSufficientBalanceForCEXTrade(t *testing.T) { QuoteID: quoteID, }, }) - sufficient, _ := adaptor.SufficientBalanceForCEXTrade(baseID, quoteID, test.sell, test.rate, test.qty) + sufficient := adaptor.SufficientBalanceForCEXTrade(baseID, quoteID, test.sell, test.rate, test.qty) if sufficient != expSufficient { t.Fatalf("expected sufficient=%v, got %v", expSufficient, sufficient) } @@ -1532,6 +1533,157 @@ func TestMultiTrade(t *testing.T) { orderIDs[4], orderIDs[5], }, }, + { + name: "not enough bonding for last placement", + baseID: 42, + quoteID: 0, + + // ---- Sell ---- + sellDexBalances: map[uint32]uint64{ + 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), + }, + 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}, + }, + 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{}, + RequiredCEX: b2q(sellPlacements[3].CounterTradeRate, lotSize), + UsedCEX: 0, + OrderedLots: 0, + Error: &BotProblems{ + UserLimitTooLow: true, + }, + }, + }, + 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}, + }, + + // ---- 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, + }, + buyCexBalances: map[uint32]uint64{ + 42: 8 * lotSize, + 0: 0, + }, + 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}, + }, + expectedBuyPlacementsWithDecrement: []*core.QtyRate{ + {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.MultiTradeResult{ + {Order: &core.Order{ID: orderIDs[4][:]}}, + {Order: &core.Order{ID: orderIDs[5][:]}}, + {Error: &msgjson.Error{Code: msgjson.OrderQuantityTooHigh}}, + }, + multiTradeResultWithDecrement: []*core.MultiTradeResult{ + {Order: &core.Order{ID: orderIDs[4][:]}}, + {Order: &core.Order{ID: orderIDs[5][:]}}, + }, + expectedOrderIDs: []order.OrderID{ + orderIDs[4], orderIDs[5], + }, + expectedOrderIDsWithDecrement: []order.OrderID{ + orderIDs[4], orderIDs[5], + }, + }, { name: "non account locker, reconfig to less placements", baseID: 42, diff --git a/client/mm/libxc/orderbook.go b/client/mm/libxc/orderbook.go index 78eced2cf3..7da5d3bb37 100644 --- a/client/mm/libxc/orderbook.go +++ b/client/mm/libxc/orderbook.go @@ -115,6 +115,10 @@ func (ob *orderbook) clear() { } func (ob *orderbook) vwap(bids bool, qty uint64) (vwap, extrema uint64, filled bool) { + if qty == 0 { // avoid division by zero + return 0, 0, false + } + ob.mtx.RLock() defer ob.mtx.RUnlock() diff --git a/client/mm/mm_simple_arb.go b/client/mm/mm_simple_arb.go index 726127f134..b32ce3282f 100644 --- a/client/mm/mm_simple_arb.go +++ b/client/mm/mm_simple_arb.go @@ -82,57 +82,39 @@ func (a *simpleArbMarketMaker) cfg() *SimpleArbConfig { } // arbExists checks if an arbitrage opportunity exists. -func (a *simpleArbMarketMaker) arbExists() (exists, sellOnDex bool, lotsToArb, dexRate, cexRate uint64, dexDefs, cexDefs map[uint32]uint64, err error) { +func (a *simpleArbMarketMaker) arbExists() (exists, sellOnDex bool, lotsToArb, dexRate, cexRate uint64, err error) { sellOnDex = false - exists, lotsToArb, dexRate, cexRate, buyDexDefs, buyCexDefs, err := a.arbExistsOnSide(sellOnDex) + exists, lotsToArb, dexRate, cexRate, err = a.arbExistsOnSide(sellOnDex) if err != nil || exists { return } sellOnDex = true - exists, lotsToArb, dexRate, cexRate, sellDexDefs, sellCexDefs, err := a.arbExistsOnSide(sellOnDex) + exists, lotsToArb, dexRate, cexRate, 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, dexDefs, cexDefs map[uint32]uint64, err error) { +func (a *simpleArbMarketMaker) arbExistsOnSide(sellOnDEX bool) (exists bool, lotsToArb, dexRate, cexRate 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 { - return false, 0, 0, 0, nil, nil, fmt.Errorf("error calculating dex VWAP: %w", err) - } - if !dexFilled { - break + return false, 0, 0, 0, fmt.Errorf("error calculating dex VWAP: %w", err) } - cexAvg, cexExtrema, cexFilled, err := a.CEX.VWAP(a.baseID, a.quoteID, sellOnDEX, numLots*lotSize) if err != nil { - return false, 0, 0, 0, nil, nil, fmt.Errorf("error calculating cex VWAP: %w", err) + return false, 0, 0, 0, fmt.Errorf("error calculating cex VWAP: %w", err) } - if !cexFilled { + + if !dexFilled || !cexFilled { break } @@ -154,15 +136,15 @@ func (a *simpleArbMarketMaker) arbExistsOnSide(sellOnDEX bool) (exists bool, lot break } - dexSufficient, dexDefs, err := a.core.SufficientBalanceForDEXTrade(dexExtrema, numLots*lotSize, sellOnDEX) + dexSufficient, err := a.core.SufficientBalanceForDEXTrade(dexExtrema, numLots*lotSize, sellOnDEX) if err != nil { - return false, 0, 0, 0, nil, nil, fmt.Errorf("error checking dex balance: %w", err) + return false, 0, 0, 0, fmt.Errorf("error checking dex balance: %w", err) } - cexSufficient, cexDefs := a.cex.SufficientBalanceForCEXTrade(a.baseID, a.quoteID, !sellOnDEX, cexExtrema, numLots*lotSize) + cexSufficient := 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 + return false, 0, 0, 0, nil } else { break } @@ -174,7 +156,7 @@ func (a *simpleArbMarketMaker) arbExistsOnSide(sellOnDEX bool) (exists bool, lot feesInQuoteUnits, err := a.core.OrderFeesInUnits(sellOnDEX, false, dexAvg) if err != nil { - return false, 0, 0, 0, nil, nil, fmt.Errorf("error getting fees: %w", err) + return false, 0, 0, 0, fmt.Errorf("error getting fees: %w", err) } qty := numLots * lotSize @@ -198,10 +180,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, nil, nil, nil + return true, lotsToArb, dexRate, cexRate, nil } - return false, 0, 0, 0, nil, nil, nil + return false, 0, 0, 0, nil } // executeArb will execute an arbitrage sequence by placing orders on the dex @@ -368,12 +350,15 @@ func (a *simpleArbMarketMaker) handleDEXOrderUpdate(o *core.Order) { } } -func (a *simpleArbMarketMaker) tryArb(newEpoch uint64) (exists, sellOnDEX bool) { +func (a *simpleArbMarketMaker) tryArb(newEpoch uint64) (exists, sellOnDEX bool, err error) { if !(a.checkBotHealth(newEpoch) && a.tradingLimitNotReached(newEpoch)) { - return false, false + return false, false, nil } - exists, sellOnDex, lotsToArb, dexRate, cexRate, _, _, _ := a.arbExists() + exists, sellOnDex, lotsToArb, dexRate, cexRate, err := a.arbExists() + if err != nil { + return false, false, err + } 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)) @@ -383,7 +368,7 @@ func (a *simpleArbMarketMaker) tryArb(newEpoch uint64) (exists, sellOnDEX bool) a.executeArb(sellOnDex, lotsToArb, dexRate, cexRate, newEpoch) } - return exists, sellOnDex + return exists, sellOnDex, nil } // rebalance checks if there is an arbitrage opportunity between the dex and cex, @@ -402,7 +387,16 @@ func (a *simpleArbMarketMaker) rebalance(newEpoch uint64) { return } - exists, sellOnDex := a.tryArb(newEpoch) + epochReport := &EpochReport{EpochNum: newEpoch} + + exists, sellOnDex, err := a.tryArb(newEpoch) + if err != nil { + epochReport.setPreOrderProblems(err) + a.unifiedExchangeAdaptor.updateEpochReport(epochReport) + return + } + + a.unifiedExchangeAdaptor.updateEpochReport(epochReport) a.activeArbsMtx.Lock() remainingArbs := make([]*arbSequence, 0, len(a.activeArbs)) diff --git a/client/mm/mm_test.go b/client/mm/mm_test.go index 722e0b4b6f..9b4b078cd5 100644 --- a/client/mm/mm_test.go +++ b/client/mm/mm_test.go @@ -281,7 +281,6 @@ type tBotCoreAdaptor struct { maxSellQty uint64 lastTradePlaced *dexOrder tradeResult *core.Order - balanceDefs map[uint32]uint64 } func (c *tBotCoreAdaptor) DEXBalance(assetID uint32) (*BotBalance, error) { @@ -322,11 +321,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, map[uint32]uint64, error) { +func (c *tBotCoreAdaptor) SufficientBalanceForDEXTrade(rate, qty uint64, sell bool) (bool, error) { if sell { - return qty <= c.maxSellQty, c.balanceDefs, nil + return qty <= c.maxSellQty, nil } - return qty <= c.maxBuyQty, c.balanceDefs, nil + return qty <= c.maxBuyQty, nil } func (c *tBotCoreAdaptor) DEXTrade(rate, qty uint64, sell bool) (*core.Order, error) { @@ -571,7 +570,6 @@ type tBotCexAdaptor struct { tradeUpdates chan *libxc.Trade maxBuyQty uint64 maxSellQty uint64 - balanceDefs map[uint32]uint64 } func newTBotCEXAdaptor() *tBotCexAdaptor { @@ -621,11 +619,11 @@ func (c *tBotCexAdaptor) FreeUpFunds(assetID uint32, cex bool, amt uint64, currE } func (c *tBotCexAdaptor) MidGap(baseID, quoteID uint32) uint64 { return 0 } -func (c *tBotCexAdaptor) SufficientBalanceForCEXTrade(baseID, quoteID uint32, sell bool, rate, qty uint64) (bool, map[uint32]uint64) { +func (c *tBotCexAdaptor) SufficientBalanceForCEXTrade(baseID, quoteID uint32, sell bool, rate, qty uint64) bool { if sell { - return qty <= c.maxSellQty, c.balanceDefs + return qty <= c.maxSellQty } - return qty <= c.maxBuyQty, c.balanceDefs + return qty <= c.maxBuyQty } func (c *tBotCexAdaptor) Book() (_, _ []*core.MiniOrder, _ error) { return nil, nil, nil } diff --git a/client/webserver/site/src/html/forms.tmpl b/client/webserver/site/src/html/forms.tmpl index d5d4344b59..38e3fc9352 100644 --- a/client/webserver/site/src/html/forms.tmpl +++ b/client/webserver/site/src/html/forms.tmpl @@ -1069,10 +1069,11 @@ -
-
- +
+
+
+
@@ -1095,13 +1096,14 @@ - + - - -
[[[Asset]]]
-
+ + + + +
Placements
@@ -1114,8 +1116,8 @@ - - + + diff --git a/client/webserver/site/src/js/forms.ts b/client/webserver/site/src/js/forms.ts index 92ed0950de..631f207d96 100644 --- a/client/webserver/site/src/js/forms.ts +++ b/client/webserver/site/src/js/forms.ts @@ -69,6 +69,7 @@ interface FormsConfig { export class Forms { formsDiv: PageElement currentForm: PageElement | undefined + currentFormID: string | undefined keyup: (e: KeyboardEvent) => void closed?: () => void @@ -94,8 +95,9 @@ export class Forms { } /* showForm shows a modal form with a little animation. */ - async show (form: HTMLElement): Promise { + async show (form: HTMLElement, id?: string): Promise { this.currentForm = form + this.currentFormID = id Doc.hide(...Array.from(this.formsDiv.children)) form.style.right = '10000px' Doc.show(this.formsDiv, form) @@ -108,8 +110,9 @@ export class Forms { close (): void { Doc.hide(this.formsDiv) - if (this.closed) this.closed() this.currentForm = undefined + this.currentFormID = undefined + if (this.closed) this.closed() } exit () { diff --git a/client/webserver/site/src/js/markets.ts b/client/webserver/site/src/js/markets.ts index 8121cd0831..0c55b22a68 100644 --- a/client/webserver/site/src/js/markets.ts +++ b/client/webserver/site/src/js/markets.ts @@ -70,7 +70,7 @@ import { CEXProblemsNote } from './registry' import { setOptionTemplates } from './opts' -import { RunningMarketMakerDisplay } from './mmutil' +import { RunningMarketMakerDisplay, RunningMMDisplayElements } from './mmutil' const bind = Doc.bind @@ -274,7 +274,14 @@ export default class MarketsPage extends BasePage { this.depositAddrForm = new DepositAddress(page.deposit) } - this.mm = new RunningMarketMakerDisplay(page.mmRunning, this.forms, page.orderReportForm, 'markets') + const runningMMDisplayElements: RunningMMDisplayElements = { + orderReportForm: page.orderReportForm, + dexBalancesRowTmpl: page.dexBalancesRowTmpl, + placementRowTmpl: page.placementRowTmpl, + placementAmtRowTmpl: page.placementAmtRowTmpl + } + Doc.cleanTemplates(page.dexBalancesRowTmpl, page.placementRowTmpl, page.placementAmtRowTmpl) + this.mm = new RunningMarketMakerDisplay(page.mmRunning, this.forms, runningMMDisplayElements, 'markets') this.reputationMeter = new ReputationMeter(page.reputationMeter) @@ -283,6 +290,7 @@ export default class MarketsPage extends BasePage { // Prepare templates for the buy and sell tables and the user's order table. setOptionTemplates(page) + Doc.cleanTemplates( page.orderRowTmpl, page.durBttnTemplate, page.booleanOptTmpl, page.rangeOptTmpl, page.orderOptTmpl, page.userOrderTmpl, page.recentMatchesTemplate diff --git a/client/webserver/site/src/js/mm.ts b/client/webserver/site/src/js/mm.ts index 31b8bdc3eb..a5b048239b 100644 --- a/client/webserver/site/src/js/mm.ts +++ b/client/webserver/site/src/js/mm.ts @@ -24,7 +24,8 @@ import { PlacementsChart, BotMarket, hostedMarketID, - RunningMarketMakerDisplay + RunningMarketMakerDisplay, + RunningMMDisplayElements } from './mmutil' import Doc, { MiniSlider } from './doc' import BasePage from './basepage' @@ -32,7 +33,6 @@ import * as OrderUtil from './orderutil' import { Forms, CEXConfigurationForm } from './forms' import * as intl from './locales' import { StatusBooked } from './orderutil' - const mediumBreakpoint = 768 interface FundingSlider { @@ -184,6 +184,7 @@ export default class MarketMakerPage extends BasePage { sortedBots: Bot[] cexes: Record twoColumn: boolean + runningMMDisplayElements: RunningMMDisplayElements constructor (main: HTMLElement) { super() @@ -198,6 +199,13 @@ export default class MarketMakerPage extends BasePage { this.forms = new Forms(page.forms) this.cexConfigForm = new CEXConfigurationForm(page.cexConfigForm, (cexName: string) => this.cexConfigured(cexName)) + this.runningMMDisplayElements = { + orderReportForm: page.orderReportForm, + dexBalancesRowTmpl: page.dexBalancesRowTmpl, + placementRowTmpl: page.placementRowTmpl, + placementAmtRowTmpl: page.placementAmtRowTmpl + } + Doc.cleanTemplates(page.dexBalancesRowTmpl, page.placementRowTmpl, page.placementAmtRowTmpl) Doc.bind(page.newBot, 'click', () => { this.newBot() }) Doc.bind(page.archivedLogsBtn, 'click', () => { app().loadPage('mmarchives') }) @@ -306,7 +314,7 @@ export default class MarketMakerPage extends BasePage { const [baseSymbol, quoteSymbol] = [app().assets[baseID].symbol, app().assets[quoteID].symbol] const mktID = `${baseSymbol}_${quoteSymbol}` if (!app().exchanges[host]?.markets[mktID]) return - const bot = new Bot(this, botStatus, startupBalanceCache) + const bot = new Bot(this, this.runningMMDisplayElements, botStatus, startupBalanceCache) page.botRows.appendChild(bot.row.tr) sortedBots.push(bot) bots[bot.id] = bot @@ -411,7 +419,7 @@ class Bot extends BotMarket { row: BotRow runDisplay: RunningMarketMakerDisplay - constructor (pg: MarketMakerPage, status: MMBotStatus, startupBalanceCache?: Record>) { + constructor (pg: MarketMakerPage, runningMMElements: RunningMMDisplayElements, status: MMBotStatus, startupBalanceCache?: Record>) { super(status.config) startupBalanceCache = startupBalanceCache ?? {} this.pg = pg @@ -421,7 +429,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, pg.forms, pg.page.orderReportForm, 'mm') + this.runDisplay = new RunningMarketMakerDisplay(page.onBox, pg.forms, runningMMElements, 'mm') setMarketElements(div, baseID, quoteID, host) if (cexName) setCexElements(div, cexName) diff --git a/client/webserver/site/src/js/mmutil.ts b/client/webserver/site/src/js/mmutil.ts index 485b97e3f2..e6c16fef3c 100644 --- a/client/webserver/site/src/js/mmutil.ts +++ b/client/webserver/site/src/js/mmutil.ts @@ -766,6 +766,13 @@ export class BotMarket { } } +export type RunningMMDisplayElements = { + orderReportForm: PageElement + dexBalancesRowTmpl: PageElement + placementRowTmpl: PageElement + placementAmtRowTmpl: PageElement +} + export class RunningMarketMakerDisplay { div: PageElement page: Record @@ -778,19 +785,19 @@ export class RunningMarketMakerDisplay { cexProblems?: CEXProblems orderReportFormEl: PageElement orderReportForm: Record + displayedOrderReportFormSide: 'buys' | 'sells' dexBalancesRowTmpl: PageElement placementRowTmpl: PageElement placementAmtRowTmpl: PageElement - displayedSide: 'buys' | 'sells' - constructor (div: PageElement, forms: Forms, orderReportForm: PageElement, page: string) { + constructor (div: PageElement, forms: Forms, elements: RunningMMDisplayElements, 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 + this.orderReportFormEl = elements.orderReportForm + this.orderReportForm = Doc.idDescendants(elements.orderReportForm) + this.dexBalancesRowTmpl = elements.dexBalancesRowTmpl + this.placementRowTmpl = elements.placementRowTmpl + this.placementAmtRowTmpl = elements.placementAmtRowTmpl Doc.cleanTemplates(this.dexBalancesRowTmpl, this.placementRowTmpl, this.placementAmtRowTmpl) this.forms = forms Doc.bind(this.page.stopBttn, 'click', () => this.stop()) @@ -880,9 +887,9 @@ export class RunningMarketMakerDisplay { 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) + if (this.forms.currentForm === this.orderReportFormEl && this.forms.currentFormID === this.mkt.id) { + const orderReport = this.displayedOrderReportFormSide === 'buys' ? n.report.buysReport : n.report.sellsReport + if (orderReport) this.updateOrderReport(orderReport, this.displayedOrderReportFormSide, n.report.epochNum) else this.forms.close() } this.update() @@ -909,8 +916,8 @@ export class RunningMarketMakerDisplay { } = this // Get fresh stats const { botCfg: { cexName, basicMarketMakingConfig: bmmCfg }, runStats, latestEpoch, cexProblems } = this.mkt.status() - if (latestEpoch) this.latestEpoch = latestEpoch - if (cexProblems) this.cexProblems = cexProblems + this.latestEpoch = latestEpoch + this.cexProblems = cexProblems Doc.hide(page.stats, page.cexRow, page.pendingDepositBox, page.pendingWithdrawalBox) @@ -1035,8 +1042,6 @@ export class RunningMarketMakerDisplay { 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 @@ -1085,9 +1090,16 @@ export class RunningMarketMakerDisplay { } setDeficiencyVisibility(totalDeficiency > 0, rows) - Doc.setVis(this.mkt.cexName, form.cexBalancesTable, form.counterTradeRateHeader) + Doc.setVis(this.mkt.cexName, form.cexSection, form.counterTradeRateHeader, form.requiredCEXHeader, form.usedCEXHeader) let cexAsset: SupportedAsset if (this.mkt.cexName) { + const cexDisplayInfo = CEXDisplayInfos[this.mkt.cexName] + if (cexDisplayInfo) { + form.cexLogo.src = cexDisplayInfo.logo + form.cexBalancesTitle.textContent = intl.prep(intl.ID_CEX_BALANCES, { cexName: cexDisplayInfo.name }) + } else { + console.error(`CEXDisplayInfo not found for ${this.mkt.cexName}`) + } const cexAssetID = side === 'buys' ? this.mkt.baseID : this.mkt.quoteID cexAsset = app().assets[cexAssetID] form.cexAsset.textContent = cexAsset.symbol.toUpperCase() @@ -1112,6 +1124,8 @@ export class RunningMarketMakerDisplay { if (deficient) { form.cexDeficiency.textContent = Doc.formatCoinValue(deficiencyCexBal, cexAsset.unitInfo) form.cexDeficiencyWithPending.textContent = Doc.formatCoinValue(deficiencyWithPendingCexBal, cexAsset.unitInfo) + if (deficiencyWithPendingCexBal > 0) form.cexDeficiencyWithPending.classList.add('text-warning') + else form.cexDeficiencyWithPending.classList.remove('text-warning') } } @@ -1177,8 +1191,8 @@ export class RunningMarketMakerDisplay { 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) + this.displayedOrderReportFormSide = side + this.forms.show(this.orderReportFormEl, this.mkt.id) } readBook () { diff --git a/client/webserver/site/src/js/registry.ts b/client/webserver/site/src/js/registry.ts index 644d65f078..d4832abee4 100644 --- a/client/webserver/site/src/js/registry.ts +++ b/client/webserver/site/src/js/registry.ts @@ -982,9 +982,7 @@ export interface TradePlacement { requiredCex: number usedDex: Record usedCex: number - causesSelfMatch: boolean error?: BotProblems - reason: any } export interface OrderReport {
[[[Arb Rate]]] [[[Required DEX]]] [[[Used DEX]]][[[Required CEX]]][[[Used CEX]]][[[Required CEX]]][[[Used CEX]]] [[[Error]]]