From deeeda4246d2261ad42cbbabae5ef4bf2d423715 Mon Sep 17 00:00:00 2001 From: buck54321 Date: Mon, 18 May 2020 12:18:18 -0500 Subject: [PATCH 1/2] show more order status details in orders table Shows additional details in the user orders table. New columns include order type, percent settled, and order status. Additionally, the age column is refreshed every second. --- client/core/bookie.go | 26 ++- client/core/core.go | 130 ++++++++++--- client/core/core_test.go | 200 +++++++++++++------- client/core/notification.go | 8 +- client/core/trade.go | 122 +++++++----- client/core/types.go | 24 ++- client/webserver/live_test.go | 122 ++++++++---- client/webserver/site/src/html/markets.tmpl | 6 + client/webserver/site/src/js/markets.js | 139 ++++++++++++-- 9 files changed, 563 insertions(+), 214 deletions(-) diff --git a/client/core/bookie.go b/client/core/bookie.go index d9991f6336..203f43a3ea 100644 --- a/client/core/bookie.go +++ b/client/core/bookie.go @@ -291,8 +291,10 @@ func handleBookOrderMsg(_ *Core, dc *dexConnection, msg *msgjson.Message) error return err } book.send(&BookUpdate{ - Action: msg.Route, - Payload: minifyOrder(note.OrderID, ¬e.TradeNote, 0), + Action: msg.Route, + DEX: dc.acct.url, + MarketID: note.MarketID, + Payload: minifyOrder(note.OrderID, ¬e.TradeNote, 0), }) return nil } @@ -319,8 +321,10 @@ func handleUnbookOrderMsg(_ *Core, dc *dexConnection, msg *msgjson.Message) erro return err } book.send(&BookUpdate{ - Action: msg.Route, - Payload: &MiniOrder{Token: token(note.OrderID)}, + Action: msg.Route, + DEX: dc.acct.url, + MarketID: note.MarketID, + Payload: &MiniOrder{Token: token(note.OrderID)}, }) return nil @@ -348,7 +352,9 @@ func handleUpdateRemainingMsg(_ *Core, dc *dexConnection, msg *msgjson.Message) return err } book.send(&BookUpdate{ - Action: msg.Route, + Action: msg.Route, + DEX: dc.acct.url, + MarketID: note.MarketID, Payload: &RemainingUpdate{ Token: token(note.OrderID), Qty: float64(note.Remaining) / conversionFactor, @@ -366,7 +372,9 @@ func handleEpochOrderMsg(c *Core, dc *dexConnection, msg *msgjson.Message) error return fmt.Errorf("epoch order note unmarshal error: %v", err) } - c.setEpoch(note.Epoch) + if dc.setEpoch(note.MarketID, note.Epoch) { + c.refreshUser() + } dc.booksMtx.RLock() defer dc.booksMtx.RUnlock() @@ -383,8 +391,10 @@ func handleEpochOrderMsg(c *Core, dc *dexConnection, msg *msgjson.Message) error } // Send a mini-order for book updates. book.send(&BookUpdate{ - Action: msg.Route, - Payload: minifyOrder(note.OrderID, ¬e.TradeNote, note.Epoch), + Action: msg.Route, + DEX: dc.acct.url, + MarketID: note.MarketID, + Payload: minifyOrder(note.OrderID, ¬e.TradeNote, note.Epoch), }) return nil diff --git a/client/core/core.go b/client/core/core.go index e7875bb89d..c4d6b93465 100644 --- a/client/core/core.go +++ b/client/core/core.go @@ -60,6 +60,9 @@ type dexConnection struct { tradeMtx sync.RWMutex trades map[order.OrderID]*trackedTrade + + epochMtx sync.RWMutex + epoch map[string]uint64 } // refreshMarkets rebuilds, saves, and returns the market map. The map itself @@ -264,17 +267,71 @@ func (dc *dexConnection) compareServerMatches(matches map[order.OrderID]*serverM } // tickAsset checks open matches related to a specific asset for needed action. -func (dc *dexConnection) tickAsset(assetID uint32) { +func (dc *dexConnection) tickAsset(assetID uint32) (numUpdated int) { dc.tradeMtx.RLock() defer dc.tradeMtx.RUnlock() for _, trade := range dc.trades { if trade.Base() == assetID || trade.Quote() == assetID { - err := trade.tick() + n, err := trade.tick() if err != nil { log.Errorf("%s tick error: %v", dc.acct.url, err) } + numUpdated += n } } + return +} + +// market gets the *Market from the marketMap, or nil if mktID is unknown. +func (dc *dexConnection) market(mktID string) *Market { + dc.marketMtx.RLock() + defer dc.marketMtx.RUnlock() + return dc.marketMap[mktID] +} + +// setEpoch sets the epoch. If the passed epoch is greater than the highest +// previously passed epoch, send an epoch notification to all subscribers. +func (dc *dexConnection) setEpoch(mktID string, epochIdx uint64) bool { + dc.epochMtx.Lock() + defer dc.epochMtx.Unlock() + if epochIdx > dc.epoch[mktID] { + dc.epoch[mktID] = epochIdx + dc.notify(newEpochNotification(dc.acct.url, mktID, epochIdx)) + var updateCount int + dc.tradeMtx.Lock() + for _, trade := range dc.trades { + if trade.processEpoch(epochIdx) { + updateCount++ + } + } + dc.tradeMtx.Unlock() + if updateCount > 0 { + dc.refreshMarkets() + return true + } + } + return false +} + +// marketEpochDuration gets the market's epoch duration. If the market is not +// known, an error is logged and 0 is returned. +func (dc *dexConnection) marketEpochDuration(mktID string) uint64 { + mkt := dc.market(mktID) + if mkt == nil { + log.Errorf("marketEpoch called for unknown market %s", mktID) + return 0 + } + return mkt.EpochLen +} + +// marketEpoch gets the best known epoch for the specified market and time +// stamp. If the market is not known, 0 is returned. +func (dc *dexConnection) marketEpoch(mktID string, stamp time.Time) uint64 { + epochLen := dc.marketEpochDuration(mktID) + if epochLen == 0 { + return 0 + } + return encode.UnixMilliU(stamp) / epochLen } // blockWaiter is a message waiting to be stamped, signed, and sent once a @@ -323,9 +380,6 @@ type Core struct { noteMtx sync.RWMutex noteChans []chan Notification - - epochMtx sync.RWMutex - epoch uint64 } // New is the constructor for a new Core. @@ -1175,6 +1229,12 @@ func (c *Core) Trade(pw []byte, form *TradeForm) (*Order, error) { return nil, fmt.Errorf("unknown DEX %s", form.DEX) } + mktID := marketName(form.Base, form.Quote) + mkt := dc.market(mktID) + if mkt == nil { + return nil, fmt.Errorf("order placed for unknown market") + } + rate, qty := form.Rate, form.Qty if form.IsLimit && rate == 0 { return nil, fmt.Errorf("zero-rate order not allowed") @@ -1326,7 +1386,7 @@ func (c *Core) Trade(pw []byte, form *TradeForm) (*Order, error) { } // Prepare and store the tracker and get the core.Order to return. - tracker := newTrackedTrade(dbOrder, preImg, dc, c.db, c.latencyQ, wallets, coins, c.notify) + tracker := newTrackedTrade(dbOrder, preImg, dc, mkt, c.db, c.latencyQ, wallets, coins, c.notify) corder, _ := tracker.coreOrder() dc.tradeMtx.Lock() dc.trades[tracker.ID()] = tracker @@ -1742,6 +1802,12 @@ func (c *Core) dbTrackers(dc *dexConnection) (map[order.OrderID]*trackedTrade, e trackers := make(map[order.OrderID]*trackedTrade, len(dbOrders)) for _, dbOrder := range dbOrders { ord, md := dbOrder.Order, dbOrder.MetaData + mktID := marketName(ord.Base(), ord.Quote()) + mkt := dc.market(mktID) + if mkt == nil { + log.Errorf("active %s order retrieved for unknown market %s", ord.ID(), mktID) + continue + } var preImg order.Preimage copy(preImg[:], md.Proof.Preimage) if co, ok := ord.(*order.CancelOrder); ok { @@ -1750,7 +1816,7 @@ func (c *Core) dbTrackers(dc *dexConnection) (map[order.OrderID]*trackedTrade, e } else { var preImg order.Preimage copy(preImg[:], dbOrder.MetaData.Proof.Preimage) - trackers[dbOrder.Order.ID()] = newTrackedTrade(dbOrder, preImg, dc, c.db, c.latencyQ, nil, nil, c.notify) + trackers[dbOrder.Order.ID()] = newTrackedTrade(dbOrder, preImg, dc, mkt, c.db, c.latencyQ, nil, nil, c.notify) } } for oid := range cancels { @@ -1991,6 +2057,7 @@ func (c *Core) connectDEX(acctInfo *db.AccountInfo) (*dexConnection, error) { } marketMap := make(map[string]*Market) + epochMap := make(map[string]uint64) for _, mkt := range dexCfg.Markets { base, quote := assets[mkt.Base], assets[mkt.Quote] market := &Market{ @@ -2004,6 +2071,7 @@ func (c *Core) connectDEX(acctInfo *db.AccountInfo) (*dexConnection, error) { MarketBuyBuffer: mkt.MarketBuyBuffer, } marketMap[mkt.Name] = market + epochMap[mkt.Name] = 0 } // Create the dexConnection and listen for incoming messages. @@ -2017,6 +2085,7 @@ func (c *Core) connectDEX(acctInfo *db.AccountInfo) (*dexConnection, error) { marketMap: marketMap, trades: make(map[order.OrderID]*trackedTrade), notify: c.notify, + epoch: epochMap, } dc.refreshMarkets() @@ -2027,17 +2096,6 @@ func (c *Core) connectDEX(acctInfo *db.AccountInfo) (*dexConnection, error) { return dc, nil } -// If the passed epoch is greater than the highest previously passed epoch, -// send an epoch notification to all subscribers. -func (c *Core) setEpoch(epochIdx uint64) { - c.epochMtx.Lock() - defer c.epochMtx.Unlock() - if epochIdx > c.epoch { - c.epoch = epochIdx - c.notify(newEpochNotification(epochIdx)) - } -} - // handleReconnect is called when a WsConn indicates that a lost connection has // been re-established. func (c *Core) handleReconnect(uri string) { @@ -2053,7 +2111,9 @@ func handleMatchProofMsg(c *Core, dc *dexConnection, msg *msgjson.Message) error } // Expire the epoch - c.setEpoch(note.Epoch + 1) + if dc.setEpoch(note.MarketID, note.Epoch+1) { + c.refreshUser() + } dc.booksMtx.RLock() defer dc.booksMtx.RUnlock() @@ -2164,14 +2224,20 @@ out: log.Error(err) } case <-ticker.C: + var numUpdated int dc.tradeMtx.Lock() for _, trade := range dc.trades { - err := trade.tick() + n, err := trade.tick() if err != nil { log.Error(err) } + numUpdated += n } dc.tradeMtx.Unlock() + if numUpdated > 0 { + dc.refreshMarkets() + c.refreshUser() + } case <-c.ctx.Done(): break out } @@ -2260,7 +2326,12 @@ func handleAuditRoute(c *Core, dc *dexConnection, msg *msgjson.Message) error { if err != nil { return err } - return tracker.tick() + numUpdated, err := tracker.tick() + if numUpdated > 0 { + dc.refreshMarkets() + c.refreshUser() + } + return err } // handleRedemptionRoute handles the DEX-originating redemption request, which @@ -2281,7 +2352,12 @@ func handleRedemptionRoute(c *Core, dc *dexConnection, msg *msgjson.Message) err if err != nil { return err } - return tracker.tick() + numUpdated, err := tracker.tick() + if numUpdated > 0 { + dc.refreshMarkets() + c.refreshUser() + } + return err } // removeWaiter removes a blockWaiter from the map. @@ -2319,10 +2395,18 @@ func (c *Core) tipChange(assetID uint32, nodeErr error) { } c.waiterMtx.Unlock() c.connMtx.RLock() + var numUpdated int for _, dc := range c.conns { - dc.tickAsset(assetID) + n := dc.tickAsset(assetID) + if n > 0 { + dc.refreshMarkets() + } + numUpdated += n } c.connMtx.RUnlock() + if numUpdated > 0 { + c.refreshUser() + } } // convertAssetInfo converts from a *msgjson.Asset to the nearly identical diff --git a/client/core/core_test.go b/client/core/core_test.go index 86e531743f..8288dd5209 100644 --- a/client/core/core_test.go +++ b/client/core/core_test.go @@ -181,6 +181,7 @@ func testDexConnection() (*dexConnection, *TWebsocket, *dexAccount) { notify: func(Notification) {}, marketMap: map[string]*Market{tDcrBtcMktName: mkt}, trades: make(map[order.OrderID]*trackedTrade), + epoch: map[string]uint64{tDcrBtcMktName: 0}, }, conn, acct } @@ -672,14 +673,13 @@ func TestMarkets(t *testing.T) { func TestDexConnectionOrderBook(t *testing.T) { rig := newTestRig() tCore := rig.core - mid := marketName(tDCR.ID, tBTC.ID) dc := rig.dc // Ensure handleOrderBookMsg creates an order book as expected. oid1 := ordertest.RandomOrderID() bookMsg, err := msgjson.NewResponse(1, &msgjson.OrderBook{ Seq: 1, - MarketID: mid, + MarketID: tDcrBtcMktName, Orders: []*msgjson.BookOrderNote{ { TradeNote: msgjson.TradeNote{ @@ -689,7 +689,7 @@ func TestDexConnectionOrderBook(t *testing.T) { }, OrderNote: msgjson.OrderNote{ Seq: 1, - MarketID: mid, + MarketID: tDcrBtcMktName, OrderID: oid1[:], }, }, @@ -708,7 +708,7 @@ func TestDexConnectionOrderBook(t *testing.T) { }, OrderNote: msgjson.OrderNote{ Seq: 2, - MarketID: mid, + MarketID: tDcrBtcMktName, OrderID: oid2[:], }, }) @@ -781,7 +781,7 @@ func TestDexConnectionOrderBook(t *testing.T) { }, OrderNote: msgjson.OrderNote{ Seq: 3, - MarketID: mid, + MarketID: tDcrBtcMktName, OrderID: oid3[:], }, }) @@ -816,7 +816,7 @@ func TestDexConnectionOrderBook(t *testing.T) { bookNote, _ = msgjson.NewNotification(msgjson.BookOrderRoute, &msgjson.UpdateRemainingNote{ OrderNote: msgjson.OrderNote{ Seq: 4, - MarketID: mid, + MarketID: tDcrBtcMktName, OrderID: oid3[:], }, Remaining: 5 * 1e8, @@ -842,7 +842,7 @@ func TestDexConnectionOrderBook(t *testing.T) { // order book as expected. unbookNote, _ := msgjson.NewNotification(msgjson.UnbookOrderRoute, &msgjson.UnbookOrderNote{ Seq: 5, - MarketID: mid, + MarketID: tDcrBtcMktName, OrderID: oid1[:], }) @@ -1695,29 +1695,10 @@ func TestTrade(t *testing.T) { func TestCancel(t *testing.T) { rig := newTestRig() dc := rig.dc - preImg := newPreimage() - lo := &order.LimitOrder{ - P: order.Prefix{ - OrderType: order.LimitOrderType, - BaseAsset: tDCR.ID, - QuoteAsset: tBTC.ID, - ClientTime: time.Now(), - ServerTime: time.Now(), - Commit: preImg.Commit(), - }, - } - dbOrder := &db.MetaOrder{ - MetaData: &db.OrderMetaData{ - Status: order.OrderStatusEpoch, - DEX: dc.acct.url, - Proof: db.OrderProof{ - Preimage: preImg[:], - }, - }, - Order: lo, - } + lo, dbOrder, preImg, _ := makeLimitOrder(dc, true, 0, 0) oid := lo.ID() - tracker := newTrackedTrade(dbOrder, preImg, dc, rig.db, rig.queue, nil, nil, rig.core.notify) + mkt := dc.market(tDcrBtcMktName) + tracker := newTrackedTrade(dbOrder, preImg, dc, mkt, rig.db, rig.queue, nil, nil, rig.core.notify) dc.trades[oid] = tracker handleCancel := func(msg *msgjson.Message, f msgFunc) error { @@ -1783,9 +1764,10 @@ func TestHandlePreimageRequest(t *testing.T) { req, _ := msgjson.NewRequest(rig.dc.NextID(), msgjson.PreimageRoute, payload) tracker := &trackedTrade{ - Order: ord, - preImg: preImg, - dc: rig.dc, + Order: ord, + preImg: preImg, + dc: rig.dc, + metaData: &db.OrderMetaData{}, } rig.dc.trades[oid] = tracker err := handlePreimageRequest(rig.core, rig.dc, req) @@ -1872,42 +1854,15 @@ func TestTradeTracking(t *testing.T) { cancelledQty := tDCR.LotSize qty := 2*matchSize + cancelledQty rate := tBTC.RateStep * 10 - preImgL := newPreimage() - addr := ordertest.RandomAddress() - lo := &order.LimitOrder{ - P: order.Prefix{ - AccountID: dc.acct.ID(), - BaseAsset: tDCR.ID, - QuoteAsset: tBTC.ID, - OrderType: order.LimitOrderType, - ClientTime: time.Now(), - ServerTime: time.Now().Add(time.Millisecond), - Commit: preImgL.Commit(), - }, - T: order.Trade{ - Sell: true, - Quantity: qty, - Address: addr, - }, - Rate: tBTC.RateStep, - } - dbOrder := &db.MetaOrder{ - MetaData: &db.OrderMetaData{ - Status: order.OrderStatusEpoch, - DEX: dc.acct.url, - Proof: db.OrderProof{ - Preimage: preImgL[:], - }, - }, - Order: lo, - } + lo, dbOrder, preImgL, addr := makeLimitOrder(dc, true, qty, tBTC.RateStep) loid := lo.ID() mid := ordertest.RandomMatchID() walletSet, err := tCore.walletSet(dc, tDCR.ID, tBTC.ID, true) if err != nil { t.Fatalf("walletSet error: %v", err) } - tracker := newTrackedTrade(dbOrder, preImgL, dc, rig.db, rig.queue, walletSet, nil, rig.core.notify) + mkt := dc.market(tDcrBtcMktName) + tracker := newTrackedTrade(dbOrder, preImgL, dc, mkt, rig.db, rig.queue, walletSet, nil, rig.core.notify) rig.dc.trades[tracker.ID()] = tracker var match *matchTracker checkStatus := func(tag string, wantStatus order.MatchStatus) { @@ -2408,10 +2363,11 @@ func TestReadConnectMatches(t *testing.T) { } oid := lo.ID() dbOrder := &db.MetaOrder{ - // MetaData: &db.OrderMetaData{}, - Order: lo, + MetaData: &db.OrderMetaData{}, + Order: lo, } - tracker := newTrackedTrade(dbOrder, preImg, dc, rig.db, rig.queue, nil, nil, notify) + mkt := dc.market(tDcrBtcMktName) + tracker := newTrackedTrade(dbOrder, preImg, dc, mkt, rig.db, rig.queue, nil, nil, notify) metaMatch := db.MetaMatch{ MetaData: &db.MatchMetaData{}, Match: &order.UserMatch{}, @@ -2576,11 +2532,10 @@ func TestHandleEpochOrderMsg(t *testing.T) { rig := newTestRig() ord := &order.LimitOrder{P: order.Prefix{ServerTime: time.Now()}} oid := ord.ID() - mid := hex.EncodeToString(encode.RandomBytes(order.OrderIDSize)) payload := &msgjson.EpochOrderNote{ BookOrderNote: msgjson.BookOrderNote{ OrderNote: msgjson.OrderNote{ - MarketID: mid, + MarketID: tDcrBtcMktName, OrderID: oid.Bytes(), }, TradeNote: msgjson.TradeNote{ @@ -2601,7 +2556,7 @@ func TestHandleEpochOrderMsg(t *testing.T) { t.Fatal("[handleEpochOrderMsg] expected a non-existent orderbook error") } - rig.dc.books[mid] = newBookie(func() {}) + rig.dc.books[tDcrBtcMktName] = newBookie(func() {}) err = handleEpochOrderMsg(rig.core, rig.dc, req) if err != nil { @@ -2627,7 +2582,6 @@ func makeMatchProof(preimages []order.Preimage, commitments []order.Commitment) func TestHandleMatchProofMsg(t *testing.T) { rig := newTestRig() - mid := hex.EncodeToString(encode.RandomBytes(order.OrderIDSize)) pimg := newPreimage() cmt := pimg.Commit() @@ -2637,7 +2591,7 @@ func TestHandleMatchProofMsg(t *testing.T) { } payload := &msgjson.MatchProofNote{ - MarketID: mid, + MarketID: tDcrBtcMktName, Epoch: 1, Preimages: []dex.Bytes{pimg[:]}, CSum: csum[:], @@ -2647,7 +2601,7 @@ func TestHandleMatchProofMsg(t *testing.T) { eo := &msgjson.EpochOrderNote{ BookOrderNote: msgjson.BookOrderNote{ OrderNote: msgjson.OrderNote{ - MarketID: mid, + MarketID: tDcrBtcMktName, OrderID: encode.RandomBytes(order.OrderIDSize), }, }, @@ -2664,9 +2618,9 @@ func TestHandleMatchProofMsg(t *testing.T) { t.Fatal("[handleMatchProofMsg] expected a non-existent orderbook error") } - rig.dc.books[mid] = newBookie(func() {}) + rig.dc.books[tDcrBtcMktName] = newBookie(func() {}) - err = rig.dc.books[mid].Enqueue(eo) + err = rig.dc.books[tDcrBtcMktName].Enqueue(eo) if err != nil { t.Fatalf("[Enqueue] unexpected error: %v", err) } @@ -2722,3 +2676,103 @@ func TestLogout(t *testing.T) { tDcrWallet.lockErr = tErr ensureErr("lock wallet") } + +func TestSetEpoch(t *testing.T) { + rig := newTestRig() + dc := rig.dc + rig.dc.books[tDcrBtcMktName] = newBookie(func() {}) + lo, dbOrder, preImg, _ := makeLimitOrder(dc, true, 0, 0) + mkt := dc.market(tDcrBtcMktName) + tracker := newTrackedTrade(dbOrder, preImg, dc, mkt, rig.db, rig.queue, nil, nil, rig.core.notify) + dc.trades[lo.ID()] = tracker + metaData := tracker.metaData + + payload := &msgjson.MatchProofNote{ + MarketID: tDcrBtcMktName, + Epoch: uint64(tracker.Time()) / tracker.epochLen, + } + nextReq := func() *msgjson.Message { + payload.Epoch++ + req, _ := msgjson.NewRequest(rig.dc.NextID(), msgjson.MatchProofRoute, payload) + return req + } + + ensureStatus := func(tag string, status order.OrderStatus) { + err := handleMatchProofMsg(rig.core, rig.dc, nextReq()) + if err != nil { + t.Fatalf("error setting epoch for %s: %v", tag, err) + } + if metaData.Status != status { + t.Fatalf("wrong status for %s. expected %s, got %s", tag, status, metaData.Status) + } + corder, _ := tracker.coreOrderInternal() + if corder.Status != status { + t.Fatalf("wrong core order status for %s. expected %s, got %s", tag, status, corder.Status) + } + } + + // Immediate limit order + ensureStatus("immediate limit order", order.OrderStatusExecuted) + + // Standing limit order + metaData.Status = order.OrderStatusEpoch + lo.Force = order.StandingTiF + ensureStatus("standing limit order", order.OrderStatusBooked) + + // Market order + mo := &order.MarketOrder{ + P: lo.P, + T: *lo.T.Copy(), + } + mo.P.OrderType = order.LimitOrderType + dbOrder = &db.MetaOrder{ + MetaData: &db.OrderMetaData{ + Status: order.OrderStatusEpoch, + }, + Order: mo, + } + tracker = newTrackedTrade(dbOrder, preImg, dc, mkt, rig.db, rig.queue, nil, nil, rig.core.notify) + metaData = tracker.metaData + dc.trades[mo.ID()] = tracker + ensureStatus("market order", order.OrderStatusExecuted) + + // Same epoch shouldn't result in change. + payload.Epoch-- + metaData.Status = order.OrderStatusEpoch + ensureStatus("market order unchanged", order.OrderStatusEpoch) + +} + +func makeLimitOrder(dc *dexConnection, sell bool, qty, rate uint64) (*order.LimitOrder, *db.MetaOrder, order.Preimage, string) { + preImg := newPreimage() + addr := ordertest.RandomAddress() + lo := &order.LimitOrder{ + P: order.Prefix{ + AccountID: dc.acct.ID(), + BaseAsset: tDCR.ID, + QuoteAsset: tBTC.ID, + OrderType: order.LimitOrderType, + ClientTime: time.Now(), + ServerTime: time.Now().Add(time.Millisecond), + Commit: preImg.Commit(), + }, + T: order.Trade{ + Sell: true, + Quantity: qty, + Address: addr, + }, + Rate: tBTC.RateStep, + Force: order.ImmediateTiF, + } + dbOrder := &db.MetaOrder{ + MetaData: &db.OrderMetaData{ + Status: order.OrderStatusEpoch, + DEX: dc.acct.url, + Proof: db.OrderProof{ + Preimage: preImg[:], + }, + }, + Order: lo, + } + return lo, dbOrder, preImg, addr +} diff --git a/client/core/notification.go b/client/core/notification.go index 49d3173af4..27e0b63dd0 100644 --- a/client/core/notification.go +++ b/client/core/notification.go @@ -154,11 +154,15 @@ func (on *OrderNote) String() string { // EpochNotification is a data notification that a new epoch has begun. type EpochNotification struct { db.Notification - Epoch uint64 `json:"epoch"` + DEX string `json:"dex"` + MarketID string `json:"marketID"` + Epoch uint64 `json:"epoch"` } -func newEpochNotification(epochIdx uint64) *EpochNotification { +func newEpochNotification(dexAddr, mktID string, epochIdx uint64) *EpochNotification { return &EpochNotification{ + DEX: dexAddr, + MarketID: mktID, Notification: db.NewNotification("epoch", "", "", db.Data), Epoch: epochIdx, } diff --git a/client/core/trade.go b/client/core/trade.go index 90e07d031a..3ad739a594 100644 --- a/client/core/trade.go +++ b/client/core/trade.go @@ -88,12 +88,12 @@ type trackedTrade struct { matchMtx sync.RWMutex matches map[order.MatchID]*matchTracker notify func(Notification) + epochLen uint64 } // newTrackedTrade is a constructor for a trackedTrade. -func newTrackedTrade(dbOrder *db.MetaOrder, preImg order.Preimage, dc *dexConnection, +func newTrackedTrade(dbOrder *db.MetaOrder, preImg order.Preimage, dc *dexConnection, mkt *Market, db db.DB, latencyQ *wait.TickerQueue, wallets *walletSet, coins asset.Coins, notify func(Notification)) *trackedTrade { - ord := dbOrder.Order return &trackedTrade{ Order: ord, @@ -107,6 +107,7 @@ func newTrackedTrade(dbOrder *db.MetaOrder, preImg order.Preimage, dc *dexConnec coins: mapifyCoins(coins), matches: make(map[order.MatchID]*matchTracker), notify: notify, + epochLen: mkt.EpochLen, } } @@ -135,33 +136,38 @@ func (t *trackedTrade) coreOrder() (*Order, *Order) { // should be called with both the mtx and the matchMtx locked. func (t *trackedTrade) coreOrderInternal() (*Order, *Order) { prefix, trade := t.Prefix(), t.Trade() + cancelling := t.cancel != nil + canceled := cancelling && t.cancel.matches.maker != nil + + orderEpoch := t.dc.marketEpoch(t.mktID, prefix.ServerTime) var tif order.TimeInForce if lo, ok := t.Order.(*order.LimitOrder); ok { tif = lo.Force } - cancelling := t.cancel != nil corder := &Order{ - DEX: t.dc.acct.url, - MarketID: t.mktID, - Type: prefix.OrderType, - ID: t.ID().String(), - Stamp: encode.UnixMilliU(prefix.ServerTime), - Rate: t.rate(), - Qty: trade.Quantity, - Sell: trade.Sell, - Filled: trade.Filled(), - Cancelling: cancelling, - Canceled: cancelling && t.cancel.matches.maker != nil, - + DEX: t.dc.acct.url, + MarketID: t.mktID, + Type: prefix.OrderType, + ID: t.ID().String(), + Stamp: encode.UnixMilliU(prefix.ServerTime), + Status: t.metaData.Status, + Epoch: orderEpoch, + Rate: t.rate(), + Qty: trade.Quantity, + Sell: trade.Sell, + Filled: trade.Filled(), + Cancelling: cancelling, + Canceled: canceled, TimeInForce: tif, } for _, match := range t.matches { dbMatch := match.Match corder.Matches = append(corder.Matches, &Match{ MatchID: match.id.String(), - Step: dbMatch.Status, + Status: dbMatch.Status, Rate: dbMatch.Rate, Qty: dbMatch.Quantity, + Side: dbMatch.Side, }) } var cancelOrder *Order @@ -171,6 +177,7 @@ func (t *trackedTrade) coreOrderInternal() (*Order, *Order) { MarketID: t.mktID, Type: order.CancelOrderType, Stamp: encode.UnixMilliU(t.cancel.ServerTime), + Epoch: orderEpoch, TargetID: t.cancel.TargetOrderID.String(), } } @@ -327,27 +334,6 @@ func (t *trackedTrade) negotiate(msgMatches []*msgjson.Match) error { // The filled amount includes all of the trackedTrade's matches, so the // filled amount must be set, not just increased. trade.SetFill(filled) - corder, cancelOrder := t.coreOrderInternal() - t.matchMtx.RUnlock() - // Send notifications. Call coreOrderInternal with the matchMtx locked. - if includesCancellation { - details := fmt.Sprintf("%s order on %s-%s at %s has been canceled (%s)", - strings.Title(sellString(trade.Sell)), unbip(t.Base()), unbip(t.Quote()), t.dc.acct.url, t.token()) - t.notify(newOrderNote("Order canceled", details, db.Success, corder)) - // Also send out a data notification with the cancel order information. - t.notify(newOrderNote("cancel", "", db.Data, cancelOrder)) - // Set the order status for both orders. - t.metaData.Status = order.OrderStatusCanceled - t.db.UpdateOrderStatus(t.cancel.ID(), order.OrderStatusExecuted) - } - if includesTrades { - fillRatio := float64(trade.Filled()) / float64(trade.Quantity) - details := fmt.Sprintf("%s order on %s-%s %.1f%% filled (%s)", - strings.Title(sellString(trade.Sell)), unbip(t.Base()), unbip(t.Quote()), fillRatio*100, t.token()) - log.Debugf("trade order %v matched with %d orders", t.ID(), len(msgMatches)) - t.notify(newOrderNote("Matches made", details, db.Poke, corder)) - } - // Set the order as executed depending on type and fill. if t.metaData.Status != order.OrderStatusCanceled { switch t.Prefix().Type() { @@ -361,13 +347,34 @@ func (t *trackedTrade) negotiate(msgMatches []*msgjson.Match) error { t.metaData.Status = order.OrderStatusExecuted } } + corder, cancelOrder := t.coreOrderInternal() + t.matchMtx.RUnlock() err := t.db.UpdateOrder(t.metaOrder()) if err != nil { return fmt.Errorf("Failed to update order in db") } - return t.tick() + // Send notifications. + if includesCancellation { + details := fmt.Sprintf("%s order on %s-%s at %s has been canceled (%s)", + strings.Title(sellString(trade.Sell)), unbip(t.Base()), unbip(t.Quote()), t.dc.acct.url, t.token()) + t.notify(newOrderNote("Order canceled", details, db.Success, corder)) + // Also send out a data notification with the cancel order information. + t.notify(newOrderNote("cancel", "", db.Data, cancelOrder)) + // Set the order status for both orders. + t.metaData.Status = order.OrderStatusCanceled + t.db.UpdateOrderStatus(t.cancel.ID(), order.OrderStatusExecuted) + } + if includesTrades { + fillRatio := float64(trade.Filled()) / float64(trade.Quantity) + details := fmt.Sprintf("%s order on %s-%s %.1f%% filled (%s)", + strings.Title(sellString(trade.Sell)), unbip(t.Base()), unbip(t.Quote()), fillRatio*100, t.token()) + log.Debugf("trade order %v matched with %d orders", t.ID(), len(msgMatches)) + t.notify(newOrderNote("Matches made", details, db.Poke, corder)) + } + _, err = t.tick() + return err } func (t *trackedTrade) metaOrder() *db.MetaOrder { @@ -466,9 +473,10 @@ func (t *trackedTrade) isRedeemable(match *matchTracker) bool { } // tick will check for and perform any match actions necessary. -func (t *trackedTrade) tick() error { +func (t *trackedTrade) tick() (int, error) { var swaps []*matchTracker var redeems []*matchTracker + errs := newErrorSet(t.dc.acct.url + " tick: ") t.matchMtx.Lock() defer t.matchMtx.Unlock() @@ -495,11 +503,10 @@ func (t *trackedTrade) tick() error { // notifications before swapMatches. corder, _ := t.coreOrderInternal() if err != nil { - log.Errorf("swapMatches: %v", err) + errs.add("swapMatches: %v", err) details := fmt.Sprintf("Error encountered sending a swap output(s) worth %.8f %s on order %s", float64(qty)/conversionFactor, unbip(fromID), t.token()) t.notify(newOrderNote("Swap error", details, db.ErrorLevel, corder)) - return err } else { details := fmt.Sprintf("Sent swaps worth %.8f %s on order %s", float64(qty)/conversionFactor, unbip(fromID), t.token()) @@ -516,18 +523,17 @@ func (t *trackedTrade) tick() error { err := t.redeemMatches(redeems) corder, _ := t.coreOrderInternal() if err != nil { - log.Errorf("redeemMatches: %v", err) + errs.add("redeemMatches: %v", err) details := fmt.Sprintf("Error encountered sending redemptions worth %.8f %s on order %s", float64(qty)/conversionFactor, unbip(toAsset), t.token()) t.notify(newOrderNote("Redemption error", details, db.ErrorLevel, corder)) - return err } else { details := fmt.Sprintf("Redeemed %.8f %s on order %s", float64(qty)/conversionFactor, unbip(toAsset), t.token()) t.notify(newOrderNote("Match complete", details, db.Poke, corder)) } } - return nil + return len(swaps) + len(redeems), errs.ifany() } // swapMatches will send a transaction with swap outputs for the specified @@ -900,6 +906,34 @@ func (t *trackedTrade) processRedemption(msgID uint64, redemption *msgjson.Redem return nil } +// processEpoch processes the new epoch index. If an existing trade has an epoch +// index < the new index and has a status of OrderStatusEpoch, the order will +// be assumed to have matched without error, and the status set to executed or +// booked, depending on the order type. +// +// DRAFT NOTE: As of now, if the client is not subscribed to the order book when +// the order actually matches, the status might not be set correctly. This is a +// consequence of having no notifications for orders which either are 1) +// unmatched (market order against empty book, or immediate limit order with +// restrictive rate), or 2) are booked without matching. This also creates the +// odd case that a fully-matched standing limit order may be set to +// OrderStatusBooked before a subsequent match notification might change it +// to OrderStatusExecuted. +func (t *trackedTrade) processEpoch(epochIdx uint64) bool { + t.mtx.Lock() + defer t.mtx.Unlock() + tradeEpoch := uint64(t.Time()) / t.epochLen + if t.metaData.Status != order.OrderStatusEpoch || epochIdx <= tradeEpoch { + return false + } + t.metaData.Status = order.OrderStatusExecuted + if lo, ok := t.Order.(*order.LimitOrder); ok && lo.Force == order.StandingTiF { + t.metaData.Status = order.OrderStatusBooked + } + t.db.UpdateOrderStatus(t.ID(), t.metaData.Status) + return true +} + // 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/core/types.go b/client/core/types.go index 9aa0d1e984..8588fd3da1 100644 --- a/client/core/types.go +++ b/client/core/types.go @@ -111,9 +111,10 @@ type RegisterForm struct { // Match represents a match on an order. An order may have many matches. type Match struct { MatchID string `json:"matchID"` - Step order.MatchStatus `json:"status"` + Status order.MatchStatus `json:"status"` Rate uint64 `json:"rate"` Qty uint64 `json:"qty"` + Side order.MatchSide `json:"side"` } // Order is core's general type for an order. An order may be a market, limit, @@ -124,6 +125,8 @@ type Order struct { Type order.OrderType `json:"type"` ID string `json:"id"` Stamp uint64 `json:"stamp"` + Status order.OrderStatus `json:"status"` + Epoch uint64 `json:"epoch"` Qty uint64 `json:"qty"` Sell bool `json:"sell"` Filled uint64 `json:"filled"` @@ -199,11 +202,12 @@ func newDisplayIDFromSymbols(base, quote string) string { // MiniOrder is minimal information about an order in a market's order book. type MiniOrder struct { - Qty float64 `json:"qty"` - Rate float64 `json:"rate"` - Epoch uint64 `json:"epoch"` - Sell bool `json:"sell"` - Token string `json:"token"` + Qty float64 `json:"qty"` + Rate float64 `json:"rate"` + Epoch uint64 `json:"epoch"` + Sell bool `json:"sell"` + Token string `json:"token"` + MarketID string `json:"marketID"` } // RemainingUpdate is an update to the quantity for an order on the order book. @@ -228,8 +232,10 @@ const ( // BookUpdate is an order book update. type BookUpdate struct { - Action string `json:"action"` - Payload interface{} `json:"payload"` + Action string `json:"action"` + DEX string `json:"dex"` + MarketID string `json:"marketID"` + Payload interface{} `json:"payload"` } // dexAccount is the core type to represent the client's account information for @@ -417,7 +423,7 @@ type TradeForm struct { TifNow bool `json:"tifnow"` } -// mktID is a string ID constructed from the asset IDs. +// marketName is a string ID constructed from the asset IDs. func marketName(b, q uint32) string { mkt, _ := dex.MarketName(b, q) return mkt diff --git a/client/webserver/live_test.go b/client/webserver/live_test.go index 8f18afeade..3f8ab699ee 100644 --- a/client/webserver/live_test.go +++ b/client/webserver/live_test.go @@ -51,33 +51,61 @@ func randomMagnitude(low, high int) float64 { return mantissa * math.Pow10(exponent) } +func userOrders() (ords []*core.Order) { + orderCount := rand.Intn(5) + for i := 0; i < orderCount; i++ { + qty := uint64(randomMagnitude(7, 11)) + filled := uint64(rand.Float64() * float64(qty)) + orderType := order.OrderType(rand.Intn(2) + 1) + status := order.OrderStatusEpoch + epoch := encode.UnixMilliU(time.Now()) / uint64(epochDuration.Milliseconds()) + isLimit := orderType == order.LimitOrderType + if rand.Float32() > 0.5 { + epoch -= 1 + if isLimit { + status = order.OrderStatusBooked + } else { + status = order.OrderStatusExecuted + } + } + var tif order.TimeInForce + if isLimit && rand.Float32() > 0.66 { + tif = order.StandingTiF + } + ords = append(ords, &core.Order{ + ID: ordertest.RandomOrderID().String(), + Type: orderType, + Stamp: encode.UnixMilliU(time.Now()) - uint64(rand.Float64()*600_000), + Status: status, + Epoch: epoch, + Rate: uint64(randomMagnitude(-2, 4)), + Qty: qty, + Sell: rand.Intn(2) > 0, + Filled: filled, + Matches: []*core.Match{ + { + Qty: uint64(rand.Float64() * float64(filled)), + Status: order.MatchComplete, + }, + }, + TimeInForce: tif, + }) + } + return +} + func mkMrkt(base, quote string) *core.Market { baseID, _ := dex.BipSymbolID(base) quoteID, _ := dex.BipSymbolID(quote) - market := &core.Market{ + return &core.Market{ Name: fmt.Sprintf("%s-%s", base, quote), BaseID: baseID, BaseSymbol: base, QuoteID: quoteID, QuoteSymbol: quote, MarketBuyBuffer: rand.Float64() + 1, + EpochLen: uint64(epochDuration.Milliseconds()), } - orderCount := rand.Intn(5) - qty := uint64(randomMagnitude(7, 11)) - for i := 0; i < orderCount; i++ { - market.Orders = append(market.Orders, &core.Order{ - ID: ordertest.RandomOrderID().String(), - Type: order.OrderType(rand.Intn(2) + 1), - Stamp: encode.UnixMilliU(time.Now()) - uint64(rand.Float64()*86_400_000), - Rate: uint64(randomMagnitude(-2, 4)), - Qty: qty, - Sell: rand.Intn(2) > 0, - Filled: uint64(rand.Float64() * float64(qty)), - Matches: nil, - }) - } - - return market } func mkSupportedAsset(symbol string, state *tWalletState, bal uint64) *core.SupportedAsset { @@ -218,19 +246,21 @@ type tWalletState struct { } type TCore struct { - reg *core.RegisterForm - inited bool - mtx sync.RWMutex - wallets map[uint32]*tWalletState - balances map[uint32]uint64 - midGap float64 - maxQty float64 - feed *core.BookFeed - killFeed context.CancelFunc - buys map[string]*core.MiniOrder - sells map[string]*core.MiniOrder - noteFeed chan core.Notification - orderMtx sync.Mutex + reg *core.RegisterForm + inited bool + mtx sync.RWMutex + wallets map[uint32]*tWalletState + balances map[uint32]uint64 + dexAddr string + marketID string + midGap float64 + maxQty float64 + feed *core.BookFeed + killFeed context.CancelFunc + buys map[string]*core.MiniOrder + sells map[string]*core.MiniOrder + noteFeed chan core.Notification + orderMtx sync.Mutex epochOrders []*core.BookUpdate } @@ -275,9 +305,14 @@ func (c *TCore) Register(r *core.RegisterForm) error { func (c *TCore) Login([]byte) ([]*db.Notification, error) { return nil, nil } func (c *TCore) Logout() error { return nil } -func (c *TCore) Sync(dex string, base, quote uint32) (*core.OrderBook, *core.BookFeed, error) { +func (c *TCore) Sync(dexAddr string, base, quote uint32) (*core.OrderBook, *core.BookFeed, error) { c.midGap = randomMagnitude(-2, 4) c.maxQty = randomMagnitude(-2, 4) + mktID, _ := dex.MarketName(base, quote) + c.mtx.Lock() + c.dexAddr = dexAddr + c.marketID = mktID + c.mtx.Unlock() if c.feed != nil { c.killFeed() @@ -297,7 +332,6 @@ func (c *TCore) Sync(dex string, base, quote uint32) (*core.OrderBook, *core.Boo switch { case r < 0.80: // Book order - sell := rand.Float32() < 0.5 ord := randomOrder(sell, c.maxQty, c.midGap, 0.05*c.midGap, true) c.orderMtx.Lock() @@ -307,7 +341,9 @@ func (c *TCore) Sync(dex string, base, quote uint32) (*core.OrderBook, *core.Boo } side[ord.Token] = ord epochOrder := &core.BookUpdate{ - Action: msgjson.EpochOrderRoute, + Action: msgjson.EpochOrderRoute, + DEX: c.dexAddr, + MarketID: mktID, Payload: ord, } c.trySend(epochOrder) @@ -333,7 +369,9 @@ func (c *TCore) Sync(dex string, base, quote uint32) (*core.OrderBook, *core.Boo c.orderMtx.Unlock() c.trySend(&core.BookUpdate{ - Action: msgjson.UnbookOrderRoute, + Action: msgjson.UnbookOrderRoute, + DEX: c.dexAddr, + MarketID: mktID, Payload: &core.MiniOrder{Token: tkn}, }) } @@ -527,11 +565,15 @@ func (c *TCore) Wallets() []*core.WalletState { } func (c *TCore) User() *core.User { - // unregistered user should not have exchanges exchanges := map[string]*core.Exchange{} if c.reg != nil { exchanges = tExchanges } + for _, xc := range tExchanges { + for _, mkt := range xc.Markets { + mkt.Orders = userOrders() + } + } user := &core.User{ Exchanges: exchanges, Initialized: c.inited, @@ -596,7 +638,13 @@ out: select { case <-epochTick: epochTick = time.NewTimer(epochDuration - time.Since(time.Now().Truncate(epochDuration))).C + c.mtx.RLock() + dexAddr := c.dexAddr + mktID := c.marketID + c.mtx.RUnlock() c.noteFeed <- &core.EpochNotification{ + DEX: dexAddr, + MarketID: mktID, Notification: db.NewNotification("epoch", "", "", db.Data), Epoch: getEpoch(), } @@ -604,11 +652,11 @@ out: // Send limit orders as newly booked. for _, o := range c.epochOrders { miniOrder := o.Payload.(*core.MiniOrder) - if (miniOrder.Rate > 0) { + if miniOrder.Rate > 0 { miniOrder.Epoch = 0 o.Action = msgjson.BookOrderRoute c.trySend(o) - if (miniOrder.Sell) { + if miniOrder.Sell { c.sells[miniOrder.Token] = miniOrder } else { c.buys[miniOrder.Token] = miniOrder diff --git a/client/webserver/site/src/html/markets.tmpl b/client/webserver/site/src/html/markets.tmpl index de3f92a9d3..84139d82e4 100644 --- a/client/webserver/site/src/html/markets.tmpl +++ b/client/webserver/site/src/html/markets.tmpl @@ -201,21 +201,27 @@ + + + + + + diff --git a/client/webserver/site/src/js/markets.js b/client/webserver/site/src/js/markets.js index e632a90012..47ebed7334 100644 --- a/client/webserver/site/src/js/markets.js +++ b/client/webserver/site/src/js/markets.js @@ -11,7 +11,7 @@ var app const bind = Doc.bind const LIMIT = 1 -// const MARKET = 2 +const MARKET = 2 // const CANCEL = 3 const bookRoute = 'book' @@ -22,6 +22,25 @@ const epochOrderRoute = 'epoch_order' const bookUpdateRoute = 'bookupdate' const unmarketRoute = 'unmarket' +/* The match statuses are a mirror of dex/order.MatchStatus. */ +// const newlyMatched = 0 +// const makerSwapCast = 1 +// const takerSwapCast = 2 +const makerRedeemed = 3 +// const matchComplete = 4 + +/* The time-in-force specifiers are a mirror of dex/order.TimeInForce. */ +const immediateTiF = 0 +// const standingTiF = 1 + +/* The order statuses are a mirror of dex/order.OrderStatus. */ +const statusUnknown = 0 +const statusEpoch = 1 +const statusBooked = 2 +const statusExecuted = 3 +const statusCanceled = 4 +const statusRevoked = 5 + const animationLength = 500 const check = document.createElement('span') @@ -178,6 +197,13 @@ export default class MarketsPage extends BasePage { // it was found. const firstEntry = mktFound ? lastMarket : this.markets[0] this.setMarket(firstEntry.dex, firstEntry.base, firstEntry.quote) + + // Start a ticker to update time-since values. + this.secondTicker = setInterval(() => { + this.page.liveList.querySelectorAll('[data-col=age]').forEach(td => { + td.textContent = Doc.timeSince(td.parentNode.order.stamp) + }) + }, 1000) } /* isSell is true if the user has selected sell in the order options. */ @@ -416,17 +442,11 @@ export default class MarketsPage extends BasePage { Doc.empty(page.liveList) for (const order of orders) { const row = page.liveTemplate.cloneNode(true) + row.order = order orderRows[order.id] = row - const set = (col, s) => { row.querySelector(`[data-col=${col}]`).textContent = s } - set('side', order.sell ? 'sell' : 'buy') - set('age', Doc.timeSince(order.stamp)) - set('rate', Doc.formatCoinValue(order.rate / 1e8)) - set('qty', Doc.formatCoinValue(order.qty / 1e8)) - set('filled', `${(order.filled / order.qty * 100).toFixed(1)}%`) + updateUserOrderRow(row, order) if (order.type === LIMIT) { - if (order.cancelling) { - set('cancel', order.canceled ? 'canceled' : 'cancelling') - } else if (order.filled !== order.qty) { + if (!order.cancelling && order.filled !== order.qty) { const icon = row.querySelector('[data-col=cancel] > span') Doc.show(icon) bind(icon, 'click', e => { @@ -476,6 +496,7 @@ export default class MarketsPage extends BasePage { /* handleBookOrderRoute is the handler for 'book_order' notifications. */ handleBookOrderRoute (data) { + if (data.dex !== this.market.dex.url || data.marketID !== this.market.sid) return const order = data.payload if (order.rate > 0) this.book.add(order) this.addTableOrder(order) @@ -484,6 +505,7 @@ export default class MarketsPage extends BasePage { /* handleUnbookOrderRoute is the handler for 'unbook_order' notifications. */ handleUnbookOrderRoute (data) { + if (data.dex !== this.market.dex.url || data.marketID !== this.market.sid) return const order = data.payload this.book.remove(order.token) this.removeTableOrder(order) @@ -495,6 +517,7 @@ export default class MarketsPage extends BasePage { * notifications. */ handleUpdateRemainingRoute (data) { + if (data.dex !== this.market.dex.url || data.marketID !== this.market.sid) return const update = data.payload this.book.updateRemaining(update.token, update.qty) this.updateTableOrder(update) @@ -503,6 +526,7 @@ export default class MarketsPage extends BasePage { /* handleEpochOrderRoute is the handler for 'epoch_order' notifications. */ handleEpochOrderRoute (data) { + if (data.dex !== this.market.dex.url || data.marketID !== this.market.sid) return const order = data.payload if (order.rate > 0) this.book.add(order) this.addTableOrder(order) @@ -579,8 +603,7 @@ export default class MarketsPage extends BasePage { const page = this.page const remaining = order.qty - order.filled page.cancelRemain.textContent = Doc.formatCoinValue(remaining / 1e8) - const isMarketBuy = !order.isLimit && !order.sell - const symbol = isMarketBuy ? this.market.quote.symbol : this.market.base.symbol + const symbol = isMarketBuy(order) ? this.market.quote.symbol : this.market.base.symbol page.cancelUnit.textContent = symbol.toUpperCase() this.showForm(page.cancelForm) bind(page.cancelSubmit, 'click', async () => { @@ -646,11 +669,10 @@ export default class MarketsPage extends BasePage { } else { const row = this.orderRows[order.id] if (!row) return - const td = row.querySelector('[data-col=filled]') - td.textContent = `${(order.filled / order.qty * 100).toFixed(1)}%` + updateUserOrderRow(row, order) if (order.filled === order.qty) { // Remove the cancellation button. - row.querySelector('[data-col=cancel]').textContent = '' + updateDataCol(row, 'cancel', '') } } } @@ -659,12 +681,28 @@ export default class MarketsPage extends BasePage { * handleEpochNote handles notifications signalling the start of a new epoch. */ handleEpochNote (note) { + if (note.dex !== this.market.dex.url || note.marketID !== this.market.sid) return if (this.book) { this.book.setEpoch(note.epoch) this.chart.draw() } this.clearOrderTableEpochs(note.epoch) - this.chart.draw() + for (const tr of Array.from(this.page.liveList.children)) { + const order = tr.order + const alreadyMatched = note.epoch > order.epoch + const statusTD = tr.querySelector('[data-col=status]') + switch (true) { + case order.type === LIMIT && order.status === statusEpoch && alreadyMatched: + statusTD.textContent = order.tif === immediateTiF ? 'executed' : 'booked' + order.status = order.tif === immediateTiF ? statusExecuted : statusBooked + break + case order.type === MARKET && order.status === statusEpoch: + // Tehcnically don't know if this should be 'executed' or 'settling'. + statusTD.textContent = 'executed' + order.status = statusExecuted + break + } + } } /* @@ -690,12 +728,11 @@ export default class MarketsPage extends BasePage { // ordering. Grab updated info. const baseWallet = app.walletMap[market.base.id] const quoteWallet = app.walletMap[market.quote.id] + await app.fetchUser() if (!baseWallet.open || !quoteWallet.open) { - await app.fetchUser() this.updateWallet(market.base.id) this.updateWallet(market.quote.id) } - app.orders(order.dex, order.base, order.quote).push(res.order) this.refreshActiveOrders() } @@ -929,6 +966,7 @@ export default class MarketsPage extends BasePage { ws.deregisterRoute(bookOrderRoute) ws.deregisterRoute(unbookOrderRoute) this.chart.unattach() + clearInterval(this.secondTicker) } } @@ -954,3 +992,68 @@ function swapBttns (before, now) { before.classList.remove('selected') now.classList.add('selected') } + +/* sumSettled sums the quantities of the matches that have completed. */ +function sumSettled (order) { + if (!order.matches) return 0 + const qty = isMarketBuy(order) ? m => m.qty * m.rate * 1e-8 : m => m.qty + return order.matches.reduce((settled, match) => { + // >= makerRedeemed is used because the maker never actually goes to + // matchComplete (once at makerRedeemed, nothing left to do), and the taker + // never goes to makerRedeemed, since at that point, they just complete the + // swap. + return (match.status >= makerRedeemed) ? settled + qty(match) : settled + }, 0) +} + +/* + * hasLiveMatches returns true if the order has matches that have not completed + * settlement yet. + */ +function hasLiveMatches (order) { + if (!order.matches) return false + for (const match of order.matches) { + if (match.status < makerRedeemed) return true + } + return false +} + +/* statusString converts the order status to a string */ +function statusString (order) { + const isLive = hasLiveMatches(order) + switch (order.status) { + case statusUnknown: return 'unknown' + case statusEpoch: return 'epoch' + case statusBooked: return order.cancelling ? 'cancelling' : 'booked' + case statusExecuted: return isLive ? 'settling' : 'excecuted' + case statusCanceled: return isLive ? 'canceled/settling' : 'canceled' + case statusRevoked: return isLive ? 'revoked/settling' : 'revoked' + } +} + +/* + * updateDataCol sets the value of data-col=[col] . + */ +function updateDataCol (tr, col, s) { + tr.querySelector(`[data-col=${col}]`).textContent = s +} + +/* + * updateUserOrderRow sets the td values of the user's order table row element. + */ +function updateUserOrderRow (tr, order) { + updateDataCol(tr, 'type', order.type === LIMIT ? 'limit' : 'market') + updateDataCol(tr, 'side', order.sell ? 'sell' : 'buy') + updateDataCol(tr, 'age', Doc.timeSince(order.stamp)) + updateDataCol(tr, 'rate', Doc.formatCoinValue(order.rate / 1e8)) + updateDataCol(tr, 'qty', Doc.formatCoinValue(order.qty / 1e8)) + updateDataCol(tr, 'filled', `${(order.filled / order.qty * 100).toFixed(1)}%`) + updateDataCol(tr, 'settled', `${(sumSettled(order) / order.qty * 100).toFixed(1)}%`) + updateDataCol(tr, 'status', statusString(order)) +} + +/* isMarketBuy will return true if the order is a market buy order. */ +function isMarketBuy (order) { + return order.type === MARKET && !order.sell +} From 80d58c3fedfef76ceff3c9ba5be926024825ba6e Mon Sep 17 00:00:00 2001 From: buck54321 Date: Wed, 20 May 2020 13:34:16 -0500 Subject: [PATCH 2/2] review fixes --- client/core/core.go | 8 ++++---- client/core/core_test.go | 10 +++++----- client/core/trade.go | 13 ++----------- client/webserver/site/src/js/markets.js | 2 +- 4 files changed, 12 insertions(+), 21 deletions(-) diff --git a/client/core/core.go b/client/core/core.go index c4d6b93465..35462fedb1 100644 --- a/client/core/core.go +++ b/client/core/core.go @@ -324,8 +324,8 @@ func (dc *dexConnection) marketEpochDuration(mktID string) uint64 { return mkt.EpochLen } -// marketEpoch gets the best known epoch for the specified market and time -// stamp. If the market is not known, 0 is returned. +// marketEpoch gets the epoch index for the specified market and time stamp. If +// the market is not known, 0 is returned. func (dc *dexConnection) marketEpoch(mktID string, stamp time.Time) uint64 { epochLen := dc.marketEpochDuration(mktID) if epochLen == 0 { @@ -1386,7 +1386,7 @@ func (c *Core) Trade(pw []byte, form *TradeForm) (*Order, error) { } // Prepare and store the tracker and get the core.Order to return. - tracker := newTrackedTrade(dbOrder, preImg, dc, mkt, c.db, c.latencyQ, wallets, coins, c.notify) + tracker := newTrackedTrade(dbOrder, preImg, dc, mkt.EpochLen, c.db, c.latencyQ, wallets, coins, c.notify) corder, _ := tracker.coreOrder() dc.tradeMtx.Lock() dc.trades[tracker.ID()] = tracker @@ -1816,7 +1816,7 @@ func (c *Core) dbTrackers(dc *dexConnection) (map[order.OrderID]*trackedTrade, e } else { var preImg order.Preimage copy(preImg[:], dbOrder.MetaData.Proof.Preimage) - trackers[dbOrder.Order.ID()] = newTrackedTrade(dbOrder, preImg, dc, mkt, c.db, c.latencyQ, nil, nil, c.notify) + trackers[dbOrder.Order.ID()] = newTrackedTrade(dbOrder, preImg, dc, mkt.EpochLen, c.db, c.latencyQ, nil, nil, c.notify) } } for oid := range cancels { diff --git a/client/core/core_test.go b/client/core/core_test.go index 8288dd5209..e08851a0df 100644 --- a/client/core/core_test.go +++ b/client/core/core_test.go @@ -1698,7 +1698,7 @@ func TestCancel(t *testing.T) { lo, dbOrder, preImg, _ := makeLimitOrder(dc, true, 0, 0) oid := lo.ID() mkt := dc.market(tDcrBtcMktName) - tracker := newTrackedTrade(dbOrder, preImg, dc, mkt, rig.db, rig.queue, nil, nil, rig.core.notify) + tracker := newTrackedTrade(dbOrder, preImg, dc, mkt.EpochLen, rig.db, rig.queue, nil, nil, rig.core.notify) dc.trades[oid] = tracker handleCancel := func(msg *msgjson.Message, f msgFunc) error { @@ -1862,7 +1862,7 @@ func TestTradeTracking(t *testing.T) { t.Fatalf("walletSet error: %v", err) } mkt := dc.market(tDcrBtcMktName) - tracker := newTrackedTrade(dbOrder, preImgL, dc, mkt, rig.db, rig.queue, walletSet, nil, rig.core.notify) + tracker := newTrackedTrade(dbOrder, preImgL, dc, mkt.EpochLen, rig.db, rig.queue, walletSet, nil, rig.core.notify) rig.dc.trades[tracker.ID()] = tracker var match *matchTracker checkStatus := func(tag string, wantStatus order.MatchStatus) { @@ -2367,7 +2367,7 @@ func TestReadConnectMatches(t *testing.T) { Order: lo, } mkt := dc.market(tDcrBtcMktName) - tracker := newTrackedTrade(dbOrder, preImg, dc, mkt, rig.db, rig.queue, nil, nil, notify) + tracker := newTrackedTrade(dbOrder, preImg, dc, mkt.EpochLen, rig.db, rig.queue, nil, nil, notify) metaMatch := db.MetaMatch{ MetaData: &db.MatchMetaData{}, Match: &order.UserMatch{}, @@ -2683,7 +2683,7 @@ func TestSetEpoch(t *testing.T) { rig.dc.books[tDcrBtcMktName] = newBookie(func() {}) lo, dbOrder, preImg, _ := makeLimitOrder(dc, true, 0, 0) mkt := dc.market(tDcrBtcMktName) - tracker := newTrackedTrade(dbOrder, preImg, dc, mkt, rig.db, rig.queue, nil, nil, rig.core.notify) + tracker := newTrackedTrade(dbOrder, preImg, dc, mkt.EpochLen, rig.db, rig.queue, nil, nil, rig.core.notify) dc.trades[lo.ID()] = tracker metaData := tracker.metaData @@ -2731,7 +2731,7 @@ func TestSetEpoch(t *testing.T) { }, Order: mo, } - tracker = newTrackedTrade(dbOrder, preImg, dc, mkt, rig.db, rig.queue, nil, nil, rig.core.notify) + tracker = newTrackedTrade(dbOrder, preImg, dc, mkt.EpochLen, rig.db, rig.queue, nil, nil, rig.core.notify) metaData = tracker.metaData dc.trades[mo.ID()] = tracker ensureStatus("market order", order.OrderStatusExecuted) diff --git a/client/core/trade.go b/client/core/trade.go index 3ad739a594..1c620c9484 100644 --- a/client/core/trade.go +++ b/client/core/trade.go @@ -92,7 +92,7 @@ type trackedTrade struct { } // newTrackedTrade is a constructor for a trackedTrade. -func newTrackedTrade(dbOrder *db.MetaOrder, preImg order.Preimage, dc *dexConnection, mkt *Market, +func newTrackedTrade(dbOrder *db.MetaOrder, preImg order.Preimage, dc *dexConnection, epochLen uint64, db db.DB, latencyQ *wait.TickerQueue, wallets *walletSet, coins asset.Coins, notify func(Notification)) *trackedTrade { ord := dbOrder.Order return &trackedTrade{ @@ -107,7 +107,7 @@ func newTrackedTrade(dbOrder *db.MetaOrder, preImg order.Preimage, dc *dexConnec coins: mapifyCoins(coins), matches: make(map[order.MatchID]*matchTracker), notify: notify, - epochLen: mkt.EpochLen, + epochLen: epochLen, } } @@ -910,15 +910,6 @@ func (t *trackedTrade) processRedemption(msgID uint64, redemption *msgjson.Redem // index < the new index and has a status of OrderStatusEpoch, the order will // be assumed to have matched without error, and the status set to executed or // booked, depending on the order type. -// -// DRAFT NOTE: As of now, if the client is not subscribed to the order book when -// the order actually matches, the status might not be set correctly. This is a -// consequence of having no notifications for orders which either are 1) -// unmatched (market order against empty book, or immediate limit order with -// restrictive rate), or 2) are booked without matching. This also creates the -// odd case that a fully-matched standing limit order may be set to -// OrderStatusBooked before a subsequent match notification might change it -// to OrderStatusExecuted. func (t *trackedTrade) processEpoch(epochIdx uint64) bool { t.mtx.Lock() defer t.mtx.Unlock() diff --git a/client/webserver/site/src/js/markets.js b/client/webserver/site/src/js/markets.js index 47ebed7334..ea4a9b08f9 100644 --- a/client/webserver/site/src/js/markets.js +++ b/client/webserver/site/src/js/markets.js @@ -1040,7 +1040,7 @@ function updateDataCol (tr, col, s) { } /* - * updateUserOrderRow sets the td values of the user's order table row element. + * updateUserOrderRow sets the td contents of the user's order table row. */ function updateUserOrderRow (tr, order) { updateDataCol(tr, 'type', order.type === LIMIT ? 'limit' : 'market')
Type Side Age Rate Quantity FilledSettledStatus
element that is a child + * of the specified