Skip to content

Commit

Permalink
mm: Add BotProblems to BotStatus
Browse files Browse the repository at this point in the history
This PR adds a BotProblems struct to BotStatus that reports issues that
bots are facing. Some problems such as wallet sync and connectivity are
checked preemptively while others are reported when errors arise during
determining what orders to place and when actually placing them. The
preemptive checks are especially important for the Arb bot which
additionally confirms that the trading limits allow for an additional
trade. Balance deficiencies are also reported in BotProblems.
  • Loading branch information
martonp committed Sep 22, 2024
1 parent 3e04950 commit 95aaade
Show file tree
Hide file tree
Showing 20 changed files with 1,687 additions and 334 deletions.
10 changes: 10 additions & 0 deletions client/core/bookie.go
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
68 changes: 63 additions & 5 deletions client/core/core.go
Original file line number Diff line number Diff line change
Expand Up @@ -5874,7 +5874,7 @@ func (c *Core) prepareForTradeRequestPrep(pw []byte, base, quote uint32, host st
return fail(err)
}
if dc.acct.suspended() {
return fail(newError(suspendedAcctErr, "may not trade while account is suspended"))
return fail(newError(suspendedAcctErr, "%w", ErrAccountSuspended))
}

mktID := marketName(base, quote)
Expand Down Expand Up @@ -5911,12 +5911,10 @@ func (c *Core) prepareForTradeRequestPrep(pw []byte, base, quote uint32, host st
w.mtx.RLock()
defer w.mtx.RUnlock()
if w.peerCount < 1 {
return fmt.Errorf("%s wallet has no network peers (check your network or firewall)",
unbip(w.AssetID))
return &WalletNoPeersError{w.AssetID}
}
if !w.syncStatus.Synced {
return fmt.Errorf("%s still syncing. progress = %.2f%%", unbip(w.AssetID),
w.syncStatus.BlockProgress()*100)
return &WalletSyncError{w.AssetID, w.syncStatus.BlockProgress()}
}
return nil
}
Expand Down Expand Up @@ -10827,3 +10825,63 @@ func (c *Core) RedeemGeocode(appPW, code []byte, msg string) (dex.Bytes, uint64,
func (c *Core) ExtensionModeConfig() *ExtensionModeConfig {
return c.extensionModeConfig
}

// calcParcelLimit computes the users score-scaled user parcel limit.
func calcParcelLimit(tier int64, score, maxScore int32) uint32 {
// Users limit starts at 2 parcels per tier.
lowerLimit := tier * dex.PerTierBaseParcelLimit
// Limit can scale up to 3x with score.
upperLimit := lowerLimit * dex.ParcelLimitScoreMultiplier
limitRange := upperLimit - lowerLimit
var scaleFactor float64
if score > 0 {
scaleFactor = float64(score) / float64(maxScore)
}
return uint32(lowerLimit) + uint32(math.Round(scaleFactor*float64(limitRange)))
}

// TradingLimits returns the number of parcels the user can trade on an
// exchange and the amount that are currently being traded.
func (c *Core) TradingLimits(host string) (userParcels, parcelLimit uint32, err error) {
dc, _, err := c.dex(host)
if err != nil {
return 0, 0, err
}

cfg := dc.config()
dc.acct.authMtx.RLock()
rep := dc.acct.rep
dc.acct.authMtx.RUnlock()

mkts := make(map[string]*msgjson.Market, len(cfg.Markets))
for _, mkt := range cfg.Markets {
mkts[mkt.Name] = mkt
}
mktTrades := make(map[string][]*trackedTrade)
for _, t := range dc.trackedTrades() {
mktTrades[t.mktID] = append(mktTrades[t.mktID], t)
}

parcelLimit = calcParcelLimit(rep.EffectiveTier(), rep.Score, int32(cfg.MaxScore))
for mktID, trades := range mktTrades {
mkt := mkts[mktID]
if mkt == nil {
c.log.Warnf("trade for unknown market %q", mktID)
continue
}

var midGap, mktWeight uint64
for _, t := range trades {
if t.isEpochOrder() && midGap == 0 {
midGap, err = dc.midGap(mkt.Base, mkt.Quote)
if err != nil && !errors.Is(err, orderbook.ErrEmptyOrderbook) {
return 0, 0, err
}
}
mktWeight += t.marketWeight(midGap, mkt.LotSize)
}
userParcels += uint32(mktWeight / (uint64(mkt.ParcelSize) * mkt.LotSize))
}

return userParcels, parcelLimit, nil
}
114 changes: 113 additions & 1 deletion client/core/core_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -11050,6 +11050,118 @@ func TestPokesCachePokes(t *testing.T) {
}
}

func TestTradingLimits(t *testing.T) {
rig := newTestRig()
defer rig.shutdown()

checkTradingLimits := func(expectedUserParcels, expectedParcelLimit uint32) {
t.Helper()

userParcels, parcelLimit, err := rig.core.TradingLimits(tDexHost)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}

if userParcels != expectedUserParcels {
t.Fatalf("expected user parcels %d, got %d", expectedUserParcels, userParcels)
}

if parcelLimit != expectedParcelLimit {
t.Fatalf("expected parcel limit %d, got %d", expectedParcelLimit, parcelLimit)
}
}

rig.dc.acct.rep.BondedTier = 10
book := newBookie(rig.dc, tUTXOAssetA.ID, tUTXOAssetB.ID, nil, tLogger)
rig.dc.books[tDcrBtcMktName] = book
checkTradingLimits(0, 20)

oids := []order.OrderID{
{0x01}, {0x02}, {0x03}, {0x04}, {0x05},
}

// Add an epoch order, 2 lots not likely taker
ord := &order.LimitOrder{
Force: order.StandingTiF,
P: order.Prefix{ServerTime: time.Now()},
T: order.Trade{
Sell: true,
Quantity: dcrBtcLotSize * 2,
},
}
tracker := &trackedTrade{
Order: ord,
preImg: newPreimage(),
mktID: tDcrBtcMktName,
db: rig.db,
dc: rig.dc,
metaData: &db.OrderMetaData{
Status: order.OrderStatusEpoch,
},
}
rig.dc.trades[oids[0]] = tracker
checkTradingLimits(2, 20)

// Add another epoch order, 2 lots, likely taker, so 2x
ord = &order.LimitOrder{
Force: order.ImmediateTiF,
P: order.Prefix{ServerTime: time.Now()},
T: order.Trade{
Sell: true,
Quantity: dcrBtcLotSize * 2,
},
}
tracker = &trackedTrade{
Order: ord,
preImg: newPreimage(),
mktID: tDcrBtcMktName,
db: rig.db,
dc: rig.dc,
metaData: &db.OrderMetaData{
Status: order.OrderStatusEpoch,
},
}
rig.dc.trades[oids[1]] = tracker
checkTradingLimits(6, 20)

// Add partially filled booked order
ord = &order.LimitOrder{
P: order.Prefix{ServerTime: time.Now()},
T: order.Trade{
Sell: true,
Quantity: dcrBtcLotSize * 2,
FillAmt: dcrBtcLotSize,
},
}
tracker = &trackedTrade{
Order: ord,
preImg: newPreimage(),
mktID: tDcrBtcMktName,
db: rig.db,
dc: rig.dc,
metaData: &db.OrderMetaData{
Status: order.OrderStatusBooked,
},
}
rig.dc.trades[oids[2]] = tracker
checkTradingLimits(7, 20)

// Add settling match to the booked order
tracker.matches = map[order.MatchID]*matchTracker{
{0x01}: {
MetaMatch: db.MetaMatch{
UserMatch: &order.UserMatch{
Quantity: dcrBtcLotSize,
},
MetaData: &db.MatchMetaData{
Proof: db.MatchProof{},
},
},
},
}
checkTradingLimits(8, 20)
}

func TestTakeAction(t *testing.T) {
rig := newTestRig()
defer rig.shutdown()
Expand Down
23 changes: 23 additions & 0 deletions client/core/errors.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}
73 changes: 73 additions & 0 deletions client/core/trade.go
Original file line number Diff line number Diff line change
Expand Up @@ -3760,6 +3760,79 @@ func (t *trackedTrade) orderAccelerationParameters() (swapCoins, accelerationCoi
return swapCoins, accelerationCoins, dex.Bytes(t.metaData.ChangeCoin), requiredForRemainingSwaps, nil
}

func (t *trackedTrade) likelyTaker(midGap uint64) bool {
if t.Type() == order.MarketOrderType {
return true
}
lo := t.Order.(*order.LimitOrder)
if lo.Force == order.ImmediateTiF {
return true
}

if midGap == 0 {
return false
}

if lo.Sell {
return lo.Rate < midGap
}

return lo.Rate > midGap
}

func (t *trackedTrade) baseQty(midGap, lotSize uint64) uint64 {
qty := t.Trade().Quantity

if t.Type() == order.MarketOrderType && !t.Trade().Sell {
if midGap == 0 {
qty = lotSize
} else {
qty = calc.QuoteToBase(midGap, qty)
}
}

return qty
}

func (t *trackedTrade) epochWeight(midGap, lotSize uint64) uint64 {
if t.status() >= order.OrderStatusBooked {
return 0
}

if t.likelyTaker(midGap) {
return 2 * t.baseQty(midGap, lotSize)
}

return t.baseQty(midGap, lotSize)
}

func (t *trackedTrade) bookedWeight() uint64 {
if t.status() != order.OrderStatusBooked {
return 0
}

return t.Trade().Remaining()
}

func (t *trackedTrade) settlingWeight() (weight uint64) {
for _, match := range t.matches {
if (match.Side == order.Maker && match.Status >= order.MakerRedeemed) ||
(match.Side == order.Taker && match.Status >= order.MatchComplete) {
continue
}
weight += match.Quantity
}
return
}

func (t *trackedTrade) isEpochOrder() bool {
return t.status() == order.OrderStatusEpoch
}

func (t *trackedTrade) marketWeight(midGap, lotSize uint64) uint64 {
return t.epochWeight(midGap, lotSize) + t.bookedWeight() + t.settlingWeight()
}

// mapifyCoins converts the slice of coins to a map keyed by hex coin ID.
func mapifyCoins(coins asset.Coins) map[string]asset.Coin {
coinMap := make(map[string]asset.Coin, len(coins))
Expand Down
Loading

0 comments on commit 95aaade

Please sign in to comment.