Skip to content

Commit

Permalink
mm: Epoch reporting
Browse files Browse the repository at this point in the history
This diff updates the market makers to generate a report of their
activities during each epoch. If the rates of each placement is unable
to be determined, this is reported as a `PreOrderProblem`, otherwise
an `OrderReport` is generated for the orders placed on each side of the
market containing information such as lots the bot is required to place,
the current number of lots standing, the number of lots booked, the
balances required and used for each placement, etc. This information is
all displayed on the UI.

A bug is also fixed in this diff. Previously, on a call to
`core.MultiTrade`, it was possible for some of the orders to be placed,
and some of them to result in an error. In this case, an error was returned
from `core.MultiTrade`, and the market makers would assume that none
of the trades were placed. Now a `core.MultiTradeResult` is returned for
each of the requested orders in the `MultiTrade` call containing either
the order or an error.
  • Loading branch information
martonp committed Oct 29, 2024
1 parent 8ea6517 commit 078e230
Show file tree
Hide file tree
Showing 39 changed files with 3,511 additions and 824 deletions.
1 change: 1 addition & 0 deletions client/cmd/testbinance/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
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
100 changes: 80 additions & 20 deletions client/core/core.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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
}
Expand Down Expand Up @@ -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
}
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 @@ -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()
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 @@ -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))
Expand Down
Loading

0 comments on commit 078e230

Please sign in to comment.