diff --git a/client/core/account.go b/client/core/account.go index 7377f3b365..aa1760872c 100644 --- a/client/core/account.go +++ b/client/core/account.go @@ -38,15 +38,10 @@ func (c *Core) AccountDisable(pw []byte, addr string) error { // Empty bookie's feeds map, close feeds' channels & stop close timers. dc.booksMtx.Lock() if b, found := dc.books[m.Name]; found { - b.mtx.Lock() - for _, f := range b.feeds { - close(f.C) - } - b.feeds = make(map[uint32]*BookFeed, 1) + b.closeFeeds() if b.closeTimer != nil { b.closeTimer.Stop() } - b.mtx.Unlock() } dc.booksMtx.Unlock() diff --git a/client/core/bookie.go b/client/core/bookie.go index 92a4e96ca6..a84c965eaf 100644 --- a/client/core/bookie.go +++ b/client/core/bookie.go @@ -13,6 +13,7 @@ import ( "decred.org/dcrdex/client/db" "decred.org/dcrdex/client/orderbook" "decred.org/dcrdex/dex" + "decred.org/dcrdex/dex/candles" "decred.org/dcrdex/dex/encode" "decred.org/dcrdex/dex/msgjson" "decred.org/dcrdex/dex/order" @@ -26,29 +27,78 @@ var ( outdatedClientErr = errors.New("outdated client") ) -// BookFeed manages a channel for receiving order book updates. The only -// exported field, C, is a channel on which to receive the updates as a series -// of *BookUpdate. It is imperative that the feeder (*BookFeed).Close() when no -// longer using the feed. -type BookFeed struct { - C chan *BookUpdate - id uint32 - close func(*BookFeed) +// BookFeed manages a channel for receiving order book updates. It is imperative +// that the feeder (BookFeed).Close() when no longer using the feed. +type BookFeed interface { + Next() <-chan *BookUpdate + Close() + Candles(dur string) error } -// NewBookFeed is a constructor for a *BookFeed. The caller must Close() the -// feed when it's no longer being used. -func NewBookFeed(close func(feed *BookFeed)) *BookFeed { - return &BookFeed{ - C: make(chan *BookUpdate, 256), - id: atomic.AddUint32(&feederID, 1), - close: close, - } +// bookFeed implements BookFeed. +type bookFeed struct { + // c is the update channel. Access to c is synchronized by the bookie's + // feedMtx. + c chan *BookUpdate + bookie *bookie + id uint32 +} + +// Next returns the channel for receiving updates. +func (f *bookFeed) Next() <-chan *BookUpdate { + return f.c } // Close the BookFeed. -func (f *BookFeed) Close() { - f.close(f) // i.e. (*bookie).closeFeed(feed *BookFeed) +func (f *bookFeed) Close() { + f.bookie.closeFeed(f.id) +} + +// Candles subscribes to the candlestick duration and sends the initial set +// of sticks over the update channel. +func (f *bookFeed) Candles(durStr string) error { + return f.bookie.candles(durStr, f.id) +} + +// candleCache adds synchronization and an on/off switch to *candles.Cache. +type candleCache struct { + *candles.Cache + candleMtx sync.RWMutex + on uint32 +} + +// copy creates a copy of the candles. +func (c *candleCache) copy() []candles.Candle { + var cands []candles.Candle + // The last candle can be modified after creation. Make a copy. + c.candleMtx.RLock() + defer c.candleMtx.RUnlock() + if len(c.Candles) > 0 { + cands = make([]candles.Candle, len(c.Candles)) + copy(cands, c.Candles) + } + return cands +} + +// init resets the candles with the supplied set. +func (c *candleCache) init(in []*msgjson.Candle) { + c.candleMtx.Lock() + defer c.candleMtx.Unlock() + c.Reset() + for _, candle := range in { + c.Add(candle) + } +} + +// addCandle adds the candle using candles.Cache.Add. +func (c *candleCache) addCandle(msgCandle *msgjson.Candle) *msgjson.Candle { + if atomic.LoadUint32(&c.on) == 0 { + return nil + } + c.candleMtx.Lock() + defer c.candleMtx.Unlock() + c.Add(msgCandle) + return c.Last() } // bookie is a BookFeed manager. bookie will maintain any number of order book @@ -58,40 +108,81 @@ func (f *BookFeed) Close() { // supplied close() callback. type bookie struct { *orderbook.OrderBook - log dex.Logger - mtx sync.Mutex - feeds map[uint32]*BookFeed - close func() // e.g. dexConnection.StopBook - closeTimer *time.Timer + dc *dexConnection + candleCaches map[string]*candleCache + log dex.Logger + + feedsMtx sync.RWMutex + feeds map[uint32]*bookFeed + + timerMtx sync.Mutex + closeTimer *time.Timer + base, quote uint32 } // newBookie is a constructor for a bookie. The caller should provide a callback // function to be called when there are no subscribers and the close timer has // expired. -func newBookie(base, quote uint32, logger dex.Logger, close func()) *bookie { +func newBookie(dc *dexConnection, base, quote uint32, binSizes []string, logger dex.Logger) *bookie { + candleCaches := make(map[string]*candleCache, len(binSizes)) + for _, durStr := range binSizes { + dur, err := time.ParseDuration(durStr) + if err != nil { + logger.Errorf("failed to ParseDuration(%q)", durStr) + continue + } + candleCaches[durStr] = &candleCache{ + Cache: candles.NewCache(candles.CacheSize, uint64(dur.Milliseconds())), + } + } + return &bookie{ - OrderBook: orderbook.NewOrderBook(logger.SubLogger("book")), - log: logger, - feeds: make(map[uint32]*BookFeed, 1), - close: close, - base: base, - quote: quote, + OrderBook: orderbook.NewOrderBook(logger.SubLogger("book")), + dc: dc, + candleCaches: candleCaches, + log: logger, + feeds: make(map[uint32]*bookFeed, 1), + base: base, + quote: quote, } } -// resets the bookie with a new OrderBook based on the provided book snapshot -// from the server. -func (b *bookie) reset(snapshot *msgjson.OrderBook) error { - b.mtx.Lock() - defer b.mtx.Unlock() - b.OrderBook = orderbook.NewOrderBook(b.log) - return b.OrderBook.Sync(snapshot) +// logEpochReport handles the epoch candle in the epoch_report message. +func (b *bookie) logEpochReport(note *msgjson.EpochReportNote) error { + err := b.LogEpochReport(note) + if err != nil { + return err + } + if note.Candle.EndStamp == 0 { + return fmt.Errorf("epoch report has zero-valued candle end stamp") + } + + for durStr, cache := range b.candleCaches { + c := cache.addCandle(¬e.Candle) + if c == nil { + continue + } + dur, _ := time.ParseDuration(durStr) + b.send(&BookUpdate{ + Action: CandleUpdateAction, + Host: b.dc.acct.host, + MarketID: marketName(b.base, b.quote), + Payload: CandleUpdate{ + Dur: durStr, + DurMilliSecs: uint64(dur.Milliseconds()), + Candle: c, + }, + }) + } + + return nil } -// feed gets a new *BookFeed and cancels the close timer. feed must be called -// with the bookie.mtx locked. -func (b *bookie) feed() *BookFeed { +// newFeed gets a new *bookFeed and cancels the close timer. feed must be called +// with the bookie.mtx locked. The feed is primed with the provided *BookUpdate. +func (b *bookie) newFeed(u *BookUpdate) *bookFeed { + b.timerMtx.Lock() if b.closeTimer != nil { // If Stop returns true, the timer did not fire. If false, the timer // already fired and the close func was called. The caller of feed() @@ -103,36 +194,99 @@ func (b *bookie) feed() *BookFeed { b.closeTimer.Stop() b.closeTimer = nil } - feed := NewBookFeed(b.CloseFeed) + b.timerMtx.Unlock() + feed := &bookFeed{ + c: make(chan *BookUpdate, 256), + bookie: b, + id: atomic.AddUint32(&feederID, 1), + } + feed.c <- u + b.feedsMtx.Lock() b.feeds[feed.id] = feed + b.feedsMtx.Unlock() return feed } -// CloseFeed is ultimately called when the BookFeed subscriber closes the feed. -// If this was the last feed for this bookie aka market, set a timer to -// unsubscribe unless another feed is requested. -func (b *bookie) CloseFeed(feed *BookFeed) { - b.mtx.Lock() - defer b.mtx.Unlock() - _, found := b.feeds[feed.id] - if !found { - return +// closeFeeds closes the bookie's book feeds and resets the feeds map. +func (b *bookie) closeFeeds() { + b.feedsMtx.Lock() + defer b.feedsMtx.Unlock() + for _, f := range b.feeds { + close(f.c) } - b.closeFeed(feed) + b.feeds = make(map[uint32]*bookFeed, 1) + } -func (b *bookie) closeFeed(feed *BookFeed) { - delete(b.feeds, feed.id) +// candles fetches the candle set from the server and activates the candle +// cache. +func (b *bookie) candles(durStr string, feedID uint32) error { + cache := b.candleCaches[durStr] + if cache == nil { + return fmt.Errorf("no candles for %s-%s %q", unbip(b.base), unbip(b.quote), durStr) + } + var err error + defer func() { + if err != nil { + return + } + b.feedsMtx.RLock() + defer b.feedsMtx.RUnlock() + f, ok := b.feeds[feedID] + if !ok { + // Feed must have been closed in another thread. + return + } + dur, _ := time.ParseDuration(durStr) + f.c <- &BookUpdate{ + Action: FreshCandlesAction, + Host: b.dc.acct.host, + MarketID: marketName(b.base, b.quote), + Payload: &CandlesPayload{ + Dur: durStr, + DurMilliSecs: uint64(dur.Milliseconds()), + Candles: cache.copy(), + }, + } + }() + if atomic.LoadUint32(&cache.on) == 1 { + return nil + } + // Subscribe to the feed. + payload := &msgjson.CandlesRequest{ + BaseID: b.base, + QuoteID: b.quote, + BinSize: durStr, + NumCandles: candles.CacheSize, + } + wireCandles := new(msgjson.WireCandles) + err = sendRequest(b.dc.WsConn, msgjson.CandlesRoute, payload, wireCandles, DefaultResponseTimeout) + if err != nil { + return err + } + cache.init(wireCandles.Candles()) + atomic.StoreUint32(&cache.on, 1) + return nil +} + +// closeFeed closes the specified feed, and if no more feeds are open, sets a +// close timer to disconnect from the market feed. +func (b *bookie) closeFeed(feedID uint32) { + b.feedsMtx.Lock() + delete(b.feeds, feedID) + numFeeds := len(b.feeds) + b.feedsMtx.Unlock() // If that was the last BookFeed, set a timer to unsubscribe w/ server. - if len(b.feeds) == 0 { + if numFeeds == 0 { + b.timerMtx.Lock() if b.closeTimer != nil { b.closeTimer.Stop() } b.closeTimer = time.AfterFunc(bookFeedTimeout, func() { - b.mtx.Lock() + b.feedsMtx.RLock() numFeeds := len(b.feeds) - b.mtx.Unlock() // cannot be locked for b.close + b.feedsMtx.RUnlock() // cannot be locked for b.close // Note that it is possible that the timer fired as b.feed() was // about to stop it before inserting a new BookFeed. If feed() got // the mutex first, there will be a feed to prevent b.close below. @@ -142,23 +296,24 @@ func (b *bookie) closeFeed(feed *BookFeed) { // Call the close func if there are no more feeds. if numFeeds == 0 { - b.close() + b.dc.stopBook(b.base, b.quote) } }) + b.timerMtx.Unlock() } } // send sends a *BookUpdate to all subscribers. func (b *bookie) send(u *BookUpdate) { - b.mtx.Lock() - defer b.mtx.Unlock() + b.feedsMtx.Lock() + defer b.feedsMtx.Unlock() for fid, feed := range b.feeds { select { - case feed.C <- u: + case feed.c <- u: default: b.log.Warnf("bookie %p: Closing book update feed %d with no receiver. "+ "The receiver should have closed the feed before going away.", b, fid) - b.closeFeed(feed) // delete it and maybe start a delayed bookie close + go b.closeFeed(feed.id) // delete it and maybe start a delayed bookie close } } } @@ -183,7 +338,10 @@ func (dc *dexConnection) bookie(marketID string) *bookie { // 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. -func (dc *dexConnection) syncBook(base, quote uint32) (*BookFeed, error) { +func (dc *dexConnection) syncBook(base, quote uint32) (BookFeed, error) { + dc.cfgMtx.RLock() + cfg := dc.cfg + dc.cfgMtx.RUnlock() dc.booksMtx.Lock() defer dc.booksMtx.Unlock() @@ -201,9 +359,7 @@ func (dc *dexConnection) syncBook(base, quote uint32) (*BookFeed, error) { return nil, err } - booky = newBookie(base, quote, dc.log.SubLogger(mktID), func() { - dc.stopBook(base, quote) - }) + booky = newBookie(dc, base, quote, cfg.BinSizes, dc.log.SubLogger(mktID)) err = booky.Sync(obRes) if err != nil { return nil, err @@ -213,11 +369,7 @@ func (dc *dexConnection) syncBook(base, quote uint32) (*BookFeed, error) { // Get the feed and the book under a single lock to make sure the first // message is the book. - booky.mtx.Lock() - defer booky.mtx.Unlock() - feed := booky.feed() - - feed.C <- &BookUpdate{ + feed := booky.newFeed(&BookUpdate{ Action: FreshBookAction, Host: dc.acct.host, MarketID: mktID, @@ -226,7 +378,7 @@ func (dc *dexConnection) syncBook(base, quote uint32) (*BookFeed, error) { Quote: quote, Book: booky.book(), }, - } + }) return feed, nil } @@ -273,9 +425,9 @@ func (dc *dexConnection) stopBook(base, quote uint32) { // Abort the unsubscribe if feeds exist for the bookie. This can happen if a // bookie's close func is called while a new BookFeed is generated elsewhere. if booky, found := dc.books[mkt]; found { - booky.mtx.Lock() + booky.feedsMtx.Lock() numFeeds := len(booky.feeds) - booky.mtx.Unlock() + booky.feedsMtx.Unlock() if numFeeds > 0 { dc.log.Warnf("Aborting booky %p unsubscribe for market %s with active feeds", booky, mkt) return @@ -316,7 +468,7 @@ func (dc *dexConnection) unsubscribe(base, quote uint32) error { // 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. -func (c *Core) SyncBook(host string, base, quote uint32) (*BookFeed, error) { +func (c *Core) SyncBook(host string, base, quote uint32) (BookFeed, error) { c.connMtx.RLock() dc, found := c.conns[host] c.connMtx.RUnlock() @@ -405,7 +557,7 @@ func handleBookOrderMsg(_ *Core, dc *dexConnection, msg *msgjson.Message) error return err } book.send(&BookUpdate{ - Action: msg.Route, + Action: BookOrderAction, Host: dc.acct.host, MarketID: note.MarketID, Payload: minifyOrder(note.OrderID, ¬e.TradeNote, 0), @@ -743,7 +895,7 @@ func handleUnbookOrderMsg(_ *Core, dc *dexConnection, msg *msgjson.Message) erro return err } book.send(&BookUpdate{ - Action: msg.Route, + Action: UnbookOrderAction, Host: dc.acct.host, MarketID: note.MarketID, Payload: &MiniOrder{Token: token(note.OrderID)}, @@ -771,7 +923,7 @@ func handleUpdateRemainingMsg(_ *Core, dc *dexConnection, msg *msgjson.Message) return err } book.send(&BookUpdate{ - Action: msg.Route, + Action: UpdateRemainingAction, Host: dc.acct.host, MarketID: note.MarketID, Payload: &RemainingUpdate{ @@ -794,7 +946,7 @@ func handleEpochReportMsg(_ *Core, dc *dexConnection, msg *msgjson.Message) erro return fmt.Errorf("no order book found with market id '%v'", note.MarketID) } - err = book.LogEpochReport(note) + err = book.logEpochReport(note) if err != nil { return fmt.Errorf("error logging epoch report: %w", err) } @@ -823,7 +975,7 @@ func handleEpochOrderMsg(c *Core, dc *dexConnection, msg *msgjson.Message) error // Send a MiniOrder for book updates. book.send(&BookUpdate{ - Action: msg.Route, + Action: EpochOrderAction, Host: dc.acct.host, MarketID: note.MarketID, Payload: minifyOrder(note.OrderID, ¬e.TradeNote, note.Epoch), diff --git a/client/core/core.go b/client/core/core.go index 21ef371107..9a9df5aeae 100644 --- a/client/core/core.go +++ b/client/core/core.go @@ -318,6 +318,7 @@ func (dc *dexConnection) exchangeInfo() *Exchange { Fee: dcrAsset, RegFees: feeAssets, PendingFee: dc.getPendingFee(), + CandleDurs: cfg.BinSizes, } } @@ -5101,7 +5102,7 @@ func (c *Core) handleReconnect(host string) { } // Create a fresh OrderBook for the bookie. - err = booky.reset(snap) + err = booky.Reset(snap) if err != nil { c.log.Errorf("handleReconnect: Failed to Sync market %q order book snapshot: %v", mkt.name, err) } diff --git a/client/core/core_test.go b/client/core/core_test.go index 2bc585c72a..dbbdff085b 100644 --- a/client/core/core_test.go +++ b/client/core/core_test.go @@ -208,6 +208,7 @@ func testDexConnection(ctx context.Context) (*dexConnection, *TWebsocket, *dexAc Fee: tFee, RegFeeConfirms: 0, DEXPubKey: acct.dexPubKey.SerializeCompressed(), + BinSizes: []string{"1h", "24h"}, }, notify: func(Notification) {}, trades: make(map[order.OrderID]*trackedTrade), @@ -1066,12 +1067,23 @@ func TestMarkets(t *testing.T) { } } -func TestDexConnectionOrderBook(t *testing.T) { +func TestBookFeed(t *testing.T) { rig := newTestRig() defer rig.shutdown() tCore := rig.core dc := rig.dc + checkAction := func(feed BookFeed, action string) { + select { + case u := <-feed.Next(): + if u.Action != action { + t.Fatalf("expected action = %s, got %s", action, u.Action) + } + default: + t.Fatalf("no %s received", action) + } + } + // Ensure handleOrderBookMsg creates an order book as expected. oid1 := ordertest.RandomOrderID() bookMsg, err := msgjson.NewResponse(1, &msgjson.OrderBook{ @@ -1150,16 +1162,8 @@ func TestDexConnectionOrderBook(t *testing.T) { } // Both channels should have a full orderbook. - select { - case <-feed1.C: - default: - t.Fatalf("no book on feed 1") - } - select { - case <-feed2.C: - default: - t.Fatalf("no book on feed 2") - } + checkAction(feed1, FreshBookAction) + checkAction(feed2, FreshBookAction) err = handleBookOrderMsg(tCore, dc, bookNote) if err != nil { @@ -1167,16 +1171,8 @@ func TestDexConnectionOrderBook(t *testing.T) { } // Both channels should have an update. - select { - case <-feed1.C: - default: - t.Fatalf("no update received on feed 1") - } - select { - case <-feed2.C: - default: - t.Fatalf("no update received on feed 2") - } + checkAction(feed1, BookOrderAction) + checkAction(feed2, BookOrderAction) // Close feed 1 feed1.Close() @@ -1201,16 +1197,12 @@ func TestDexConnectionOrderBook(t *testing.T) { // feed1 should have no update select { - case <-feed1.C: + case <-feed1.Next(): t.Fatalf("update for feed 1 after Close") default: } // feed2 should though - select { - case <-feed2.C: - default: - t.Fatalf("no update received on feed 2") - } + checkAction(feed2, BookOrderAction) // Make sure the book has been updated. book, _ = tCore.Book(tDexHost, tDCR.ID, tBTC.ID) @@ -1236,11 +1228,7 @@ func TestDexConnectionOrderBook(t *testing.T) { } // feed2 should have an update - select { - case <-feed2.C: - default: - t.Fatalf("no update received on feed 2") - } + checkAction(feed2, UpdateRemainingAction) book, _ = tCore.Book(tDexHost, tDCR.ID, tBTC.ID) firstSellQty := book.Sells[0].Qty if firstSellQty != 5 { @@ -1260,15 +1248,77 @@ func TestDexConnectionOrderBook(t *testing.T) { t.Fatalf("[handleUnbookOrderMsg]: unexpected err: %v", err) } // feed2 should have a notification. - select { - case <-feed2.C: - default: - t.Fatalf("no update received on feed 2") - } + checkAction(feed2, UnbookOrderAction) book, _ = tCore.Book(tDexHost, tDCR.ID, tBTC.ID) if len(book.Buys) != 1 { t.Fatalf("expected 1 buy after unbook_order, got %d", len(book.Buys)) } + + // Test candles + queueCandles := func() { + rig.ws.queueResponse(msgjson.CandlesRoute, func(msg *msgjson.Message, f msgFunc) error { + resp, _ := msgjson.NewResponse(msg.ID, &msgjson.WireCandles{ + StartStamps: []uint64{1, 2}, + EndStamps: []uint64{3, 4}, + MatchVolumes: []uint64{1, 2}, + QuoteVolumes: []uint64{1, 2}, + HighRates: []uint64{3, 4}, + LowRates: []uint64{1, 2}, + StartRates: []uint64{1, 2}, + EndRates: []uint64{3, 4}, + }, nil) + f(resp) + return nil + }) + } + queueCandles() + + if err := feed2.Candles("1h"); err != nil { + t.Fatalf("Candles error: %v", err) + } + + checkAction(feed2, FreshCandlesAction) + + // An epoch report should trigger two candle updates, one for each bin size. + epochReport, _ := msgjson.NewNotification(msgjson.EpochReportRoute, &msgjson.EpochReportNote{ + MarketID: tDcrBtcMktName, + Epoch: 1, + BaseFeeRate: 2, + QuoteFeeRate: 3, + Candle: msgjson.Candle{ + StartStamp: 1, + EndStamp: 2, + MatchVolume: 3, + QuoteVolume: 3, + HighRate: 4, + LowRate: 1, + StartRate: 1, + EndRate: 2, + }, + }) + + if err := handleEpochReportMsg(tCore, dc, epochReport); err != nil { + t.Fatalf("handleEpochReportMsg error: %v", err) + } + + // We'll only receive 1 candle update, since we only synced one set of + // candles so far. + checkAction(feed2, CandleUpdateAction) + + // Now subscribe to the 24h candles too. + queueCandles() + if err := feed2.Candles("24h"); err != nil { + t.Fatalf("24h Candles error: %v", err) + } + checkAction(feed2, FreshCandlesAction) + + // This time, an epoch report should trigger two updates. + if err := handleEpochReportMsg(tCore, dc, epochReport); err != nil { + t.Fatalf("handleEpochReportMsg error: %v", err) + } + checkAction(feed2, CandleUpdateAction) + checkAction(feed2, CandleUpdateAction) + } type tDriver struct { @@ -2252,7 +2302,7 @@ func TestTrade(t *testing.T) { tBtcWallet.fundingCoins = asset.Coins{btcCoin} tBtcWallet.fundRedeemScripts = []dex.Bytes{nil} - book := newBookie(tDCR.ID, tBTC.ID, tLogger, func() {}) + book := newBookie(rig.dc, tDCR.ID, tBTC.ID, nil, tLogger) rig.dc.books[tDcrBtcMktName] = book msgOrderNote := &msgjson.BookOrderNote{ @@ -4863,7 +4913,7 @@ func TestHandleEpochOrderMsg(t *testing.T) { t.Fatal("[handleEpochOrderMsg] expected a non-existent orderbook error") } - rig.dc.books[tDcrBtcMktName] = newBookie(tDCR.ID, tBTC.ID, tLogger, func() {}) + rig.dc.books[tDcrBtcMktName] = newBookie(rig.dc, tDCR.ID, tBTC.ID, nil, tLogger) err = handleEpochOrderMsg(rig.core, rig.dc, req) if err != nil { @@ -4925,7 +4975,7 @@ func TestHandleMatchProofMsg(t *testing.T) { t.Fatal("[handleMatchProofMsg] expected a non-existent orderbook error") } - rig.dc.books[tDcrBtcMktName] = newBookie(tDCR.ID, tBTC.ID, tLogger, func() {}) + rig.dc.books[tDcrBtcMktName] = newBookie(rig.dc, tDCR.ID, tBTC.ID, nil, tLogger) err = rig.dc.books[tDcrBtcMktName].Enqueue(eo) if err != nil { @@ -5054,7 +5104,7 @@ func TestSetEpoch(t *testing.T) { rig := newTestRig() defer rig.shutdown() dc := rig.dc - dc.books[tDcrBtcMktName] = newBookie(tDCR.ID, tBTC.ID, tLogger, func() {}) + dc.books[tDcrBtcMktName] = newBookie(rig.dc, tDCR.ID, tBTC.ID, nil, tLogger) mktEpoch := func() uint64 { dc.epochMtx.RLock() @@ -5256,7 +5306,7 @@ func TestHandleTradeSuspensionMsg(t *testing.T) { mkt := dc.marketConfig(tDcrBtcMktName) walletSet, _ := tCore.walletSet(dc, tDCR.ID, tBTC.ID, true) - rig.dc.books[tDcrBtcMktName] = newBookie(tDCR.ID, tBTC.ID, tLogger, func() {}) + rig.dc.books[tDcrBtcMktName] = newBookie(rig.dc, tDCR.ID, tBTC.ID, nil, tLogger) addTracker := func(coins asset.Coins) *trackedTrade { lo, dbOrder, preImg, _ := makeLimitOrder(dc, true, 0, 0) @@ -6878,7 +6928,7 @@ func TestPreOrder(t *testing.T) { var rate uint64 = 1e8 quoteConvertedLotSize := calc.BaseToQuote(rate, dcrBtcLotSize) - book := newBookie(tDCR.ID, tBTC.ID, tLogger, func() {}) + book := newBookie(rig.dc, tDCR.ID, tBTC.ID, nil, tLogger) dc.books[tDcrBtcMktName] = book sellNote := &msgjson.BookOrderNote{ diff --git a/client/core/types.go b/client/core/types.go index 5c42e87141..2a24b73fca 100644 --- a/client/core/types.go +++ b/client/core/types.go @@ -14,8 +14,10 @@ import ( "decred.org/dcrdex/client/db" "decred.org/dcrdex/dex" "decred.org/dcrdex/dex/calc" + "decred.org/dcrdex/dex/candles" "decred.org/dcrdex/dex/encode" "decred.org/dcrdex/dex/encrypt" + "decred.org/dcrdex/dex/msgjson" "decred.org/dcrdex/dex/order" "decred.org/dcrdex/server/account" "github.com/decred/dcrd/dcrec/secp256k1/v4" @@ -475,6 +477,7 @@ type Exchange struct { Fee *FeeAsset `json:"feeAsset"` // DEPRECATED. DCR. RegFees map[string]*FeeAsset `json:"regFees"` PendingFee *PendingFeeState `json:"pendingFee,omitempty"` + CandleDurs []string `json:"candleDurs"` } // newDisplayID creates a display-friendly market ID for a base/quote ID pair. @@ -519,11 +522,20 @@ type MarketOrderBook struct { Book *OrderBook `json:"book"` } +type CandleUpdate struct { + Dur string `json:"dur"` + DurMilliSecs uint64 `json:"ms"` + Candle *candles.Candle `json:"candle"` +} + const ( - FreshBookAction = "book" - BookOrderAction = "book_order" - EpochOrderAction = "epoch_order" - UnbookOrderAction = "unbook_order" + FreshBookAction = "book" + FreshCandlesAction = "candles" + BookOrderAction = "book_order" + EpochOrderAction = "epoch_order" + UnbookOrderAction = "unbook_order" + UpdateRemainingAction = "update_remaining" + CandleUpdateAction = "candle_update" ) // BookUpdate is an order book update. @@ -534,6 +546,12 @@ type BookUpdate struct { Payload interface{} `json:"payload"` } +type CandlesPayload struct { + Dur string `json:"dur"` + DurMilliSecs uint64 `json:"ms"` + Candles []msgjson.Candle `json:"candles"` +} + // dexAccount is the core type to represent the client's account information for // a DEX. type dexAccount struct { diff --git a/client/rpcserver/rpcserver_test.go b/client/rpcserver/rpcserver_test.go index c68b6c0393..9b7aebbb5a 100644 --- a/client/rpcserver/rpcserver_test.go +++ b/client/rpcserver/rpcserver_test.go @@ -99,8 +99,8 @@ func (c *TCore) GetDEXConfig(dexAddr string, certI interface{}) (*core.Exchange, func (c *TCore) Register(*core.RegisterForm) (*core.RegisterResult, error) { return c.registerResult, c.registerErr } -func (c *TCore) SyncBook(dex string, base, quote uint32) (*core.BookFeed, error) { - return core.NewBookFeed(func(*core.BookFeed) {}), c.syncErr +func (c *TCore) SyncBook(dex string, base, quote uint32) (core.BookFeed, error) { + return &tBookFeed{}, c.syncErr } func (c *TCore) Trade(appPass []byte, form *core.TradeForm) (order *core.Order, err error) { return c.order, c.tradeErr @@ -118,6 +118,16 @@ func (c *TCore) ExportSeed(pw []byte) ([]byte, error) { return c.exportSeed, c.exportSeedErr } +type tBookFeed struct{} + +func (*tBookFeed) Next() <-chan *core.BookUpdate { + return make(<-chan *core.BookUpdate) +} +func (*tBookFeed) Close() {} +func (*tBookFeed) Candles(dur string) error { + return nil +} + func newTServer(t *testing.T, start bool, user, pass string) (*RPCServer, func()) { tSrv, fn, err := newTServerWErr(t, start, user, pass) if err != nil { diff --git a/client/webserver/live_test.go b/client/webserver/live_test.go index 121d84cf10..002754aa82 100644 --- a/client/webserver/live_test.go +++ b/client/webserver/live_test.go @@ -30,6 +30,7 @@ import ( "decred.org/dcrdex/client/db" "decred.org/dcrdex/dex" "decred.org/dcrdex/dex/calc" + "decred.org/dcrdex/dex/candles" "decred.org/dcrdex/dex/encode" "decred.org/dcrdex/dex/msgjson" "decred.org/dcrdex/dex/order" @@ -279,6 +280,7 @@ var tExchanges = map[string]*core.Exchange{ Confs: 1, Amt: 1e8, }, + CandleDurs: []string{"1h", "24h"}, }, secondDEX: { Host: "thisdexwithalongname.com", @@ -294,6 +296,7 @@ var tExchanges = map[string]*core.Exchange{ Confs: 1, Amt: 1e8, }, + CandleDurs: []string{"5m", "1h", "24h"}, }, } @@ -325,17 +328,38 @@ type tWalletState struct { settings map[string]string } +type tBookFeed struct { + core *TCore + c chan *core.BookUpdate +} + +func (t *tBookFeed) Next() <-chan *core.BookUpdate { + return t.c +} + +func (t *tBookFeed) Close() {} + +func (t *tBookFeed) Candles(dur string) error { + t.core.sendCandles(dur) + return nil +} + type TCore struct { - reg *core.RegisterForm - inited bool - mtx sync.RWMutex - wallets map[uint32]*tWalletState - balances map[uint32]*core.WalletBalance - dexAddr string - marketID string - base uint32 - quote uint32 - feed *core.BookFeed + reg *core.RegisterForm + inited bool + mtx sync.RWMutex + wallets map[uint32]*tWalletState + balances map[uint32]*core.WalletBalance + dexAddr string + marketID string + base uint32 + quote uint32 + candleDur struct { + dur time.Duration + str string + } + + bookFeed *tBookFeed killFeed context.CancelFunc buys map[string]*core.MiniOrder sells map[string]*core.MiniOrder @@ -376,7 +400,7 @@ func newTCore() *TCore { func (c *TCore) trySend(u *core.BookUpdate) { select { - case c.feed.C <- u: + case c.bookFeed.c <- u: default: } } @@ -634,15 +658,19 @@ func (c *TCore) Order(dex.Bytes) (*core.Order, error) { return makeCoreOrder(), nil } -func (c *TCore) SyncBook(dexAddr string, base, quote uint32) (*core.BookFeed, error) { +func (c *TCore) SyncBook(dexAddr string, base, quote uint32) (core.BookFeed, error) { mktID, _ := dex.MarketName(base, quote) c.mtx.Lock() c.dexAddr = dexAddr c.marketID = mktID c.base = base c.quote = quote + c.candleDur.dur = 0 c.mtx.Unlock() + xc := tExchanges[dexAddr] + mkt := xc.Markets[mkid(base, quote)] + usrOrds := tExchanges[dexAddr].Markets[mktID].Orders isUserOrder := func(tkn string) bool { for _, ord := range usrOrds { @@ -653,11 +681,14 @@ func (c *TCore) SyncBook(dexAddr string, base, quote uint32) (*core.BookFeed, er return false } - if c.feed != nil { + if c.bookFeed != nil { c.killFeed() } - c.feed = core.NewBookFeed(func(*core.BookFeed) {}) + c.bookFeed = &tBookFeed{ + core: c, + c: make(chan *core.BookUpdate, 1), + } var ctx context.Context ctx, c.killFeed = context.WithCancel(tCtx) go func() { @@ -720,13 +751,34 @@ func (c *TCore) SyncBook(dexAddr string, base, quote uint32) (*core.BookFeed, er Payload: &core.MiniOrder{Token: tkn}, }) } + + // Send a candle update. + c.mtx.RLock() + dur := c.candleDur.dur + durStr := c.candleDur.str + c.mtx.RUnlock() + if dur == 0 { + continue + } + c.trySend(&core.BookUpdate{ + Action: core.CandleUpdateAction, + Host: dexAddr, + MarketID: mktID, + Payload: &core.CandleUpdate{ + Dur: durStr, + DurMilliSecs: uint64(dur.Milliseconds()), + Candle: candle(mkt, dur, time.Now()), + }, + }) + case <-ctx.Done(): break out } + } }() - c.feed.C <- &core.BookUpdate{ + c.bookFeed.c <- &core.BookUpdate{ Action: core.FreshBookAction, Host: dexAddr, MarketID: mktID, @@ -737,7 +789,85 @@ func (c *TCore) SyncBook(dexAddr string, base, quote uint32) (*core.BookFeed, er }, } - return c.feed, nil + return c.bookFeed, nil +} + +func candle(mkt *core.Market, dur time.Duration, stamp time.Time) *msgjson.Candle { + high, low, start, end, vol := candleStats(mkt.LotSize, mkt.RateStep, dur, stamp) + quoteVol := calc.BaseToQuote(end, vol) + + return &msgjson.Candle{ + StartStamp: encode.UnixMilliU(stamp.Truncate(dur)), + EndStamp: encode.UnixMilliU(stamp), + MatchVolume: vol, + QuoteVolume: quoteVol, + HighRate: high, + LowRate: low, + StartRate: start, + EndRate: end, + } +} + +func candleStats(lotSize, rateStep uint64, candleDur time.Duration, stamp time.Time) (high, low, start, end, vol uint64) { + freq := math.Pi * 2 / float64(candleDur.Milliseconds()*20) + maxVol := 1e5 * float64(lotSize) + volFactor := (math.Sin(float64(encode.UnixMilliU(stamp))*freq/2) + 1) / 2 + vol = uint64(maxVol * volFactor) + + waveFactor := (math.Sin(float64(encode.UnixMilliU(stamp))*freq) + 1) / 2 + priceVariation := 1e5 * float64(rateStep) + priceFloor := 0.5 * priceVariation + startWaveFactor := (math.Sin(float64(encode.UnixMilliU(stamp.Truncate(candleDur)))*freq) + 1) / 2 + start = uint64(startWaveFactor*priceVariation + priceFloor) + end = uint64(waveFactor*priceVariation + priceFloor) + + if start > end { + diff := (start - end) / 2 + high = start + diff + low = end - diff + } else { + diff := (end - start) / 2 + high = end + diff + low = start - diff + } + return +} + +func (c *TCore) sendCandles(durStr string) { + randomDelay() + dur, err := time.ParseDuration(durStr) + if err != nil { + panic("sendCandles ParseDuration error: " + err.Error()) + } + + c.mtx.RLock() + c.candleDur.dur = dur + c.candleDur.str = durStr + dexAddr := c.dexAddr + mktID := c.marketID + xc := tExchanges[c.dexAddr] + mkt := xc.Markets[mkid(c.base, c.quote)] + c.mtx.RUnlock() + + tNow := time.Now() + iStartTime := tNow.Add(-dur * candles.CacheSize).Truncate(dur) + candles := make([]msgjson.Candle, 0, candles.CacheSize) + + for iStartTime.Before(tNow) { + candles = append(candles, *candle(mkt, dur, iStartTime.Add(dur-1))) + iStartTime = iStartTime.Add(dur) + } + + c.bookFeed.c <- &core.BookUpdate{ + Action: core.FreshCandlesAction, + Host: dexAddr, + MarketID: mktID, + Payload: &core.CandlesPayload{ + Dur: durStr, + DurMilliSecs: uint64(dur.Milliseconds()), + Candles: candles, + }, + } } var numBuys = 80 @@ -794,7 +924,7 @@ func (c *TCore) book(dexAddr, mktID string) *core.OrderBook { } func (c *TCore) Unsync(dex string, base, quote uint32) { - if c.feed != nil { + if c.bookFeed != nil { c.killFeed() } } @@ -1190,7 +1320,7 @@ func TestServer(t *testing.T) { numSells = 10 feedPeriod = 5000 * time.Millisecond initialize := false - register := false + register := true forceDisconnectWallet = true gapWidthFactor = 0.2 randomPokes = true diff --git a/client/webserver/site/src/css/market.scss b/client/webserver/site/src/css/market.scss index ab88fca099..7cd6154881 100644 --- a/client/webserver/site/src/css/market.scss +++ b/client/webserver/site/src/css/market.scss @@ -276,12 +276,58 @@ table.balance-table button:hover { align-items: center; position: absolute; top: 0; - left: 50px; + left: 0; right: 0; + padding: 0 5px; height: 30px; font-size: 14px; color: #444; + .category-spacer { + display: inline-block; + margin: 0 7px; + position: relative; + bottom: 2px; + + &::after { + content: '|'; + } + } + + button.chart-selector { + width: 52px; + height: 20px; + padding: 1px; + margin-right: 10px; + background-color: #7772; + border: 1px solid #777; + border-radius: 5px; + } + + button.chart-selector:hover { + background-color: #7777; + border-color: #6cac78; + } + + .candle-dur-bttn { + background-color: #7772; + border: 1px solid #777; + padding: 2px 4px; + font-size: 14px; + border-radius: 3px; + line-height: 1; + margin: 0 2px; + + &:hover { + background-color: #7777; + } + + &:hover, + &.selected { + border-color: #6cac78; + } + } + .epoch-line { display: inline-block; border-top: 3px dotted #626262; @@ -292,26 +338,12 @@ table.balance-table button:hover { top: 1px; } - .category-spacer { - display: inline-block; - margin: 0 7px; - position: relative; - bottom: 2px; - - &::after { - content: '|'; - } + #candlestickBttn { + background-image: url('/img/candlestick_bttn.png'); } - button { - border-left: 1px solid #333; - height: 100%; - display: flex; - align-items: center; - justify-content: center; - background-color: transparent; - padding: 0 10px; - color: #999; + #depthBttn { + background-image: url('/img/depth_bttn.png'); } } } diff --git a/client/webserver/site/src/html/markets.tmpl b/client/webserver/site/src/html/markets.tmpl index 9140db857e..c7907fd361 100644 --- a/client/webserver/site/src/html/markets.tmpl +++ b/client/webserver/site/src/html/markets.tmpl @@ -48,20 +48,32 @@
-
[[[epoch]]] + + +
[[[epoch]]]
- - + + [[[price]]]: , [[[volume]]]: + + S: , + E: , + L: , + H: , + V: +
- sells: , - - [[[buys]]]: , +
+ sells: , + + [[[buys]]]: , +
+
diff --git a/client/webserver/site/src/img/candlestick_bttn.png b/client/webserver/site/src/img/candlestick_bttn.png new file mode 100644 index 0000000000..a434899404 Binary files /dev/null and b/client/webserver/site/src/img/candlestick_bttn.png differ diff --git a/client/webserver/site/src/img/depth_bttn.png b/client/webserver/site/src/img/depth_bttn.png new file mode 100644 index 0000000000..cac873aaff Binary files /dev/null and b/client/webserver/site/src/img/depth_bttn.png differ diff --git a/client/webserver/site/src/js/charts.js b/client/webserver/site/src/js/charts.js index e93f3ad0c8..28231b8b87 100644 --- a/client/webserver/site/src/js/charts.js +++ b/client/webserver/site/src/js/charts.js @@ -41,27 +41,17 @@ const lightTheme = { legendText: '#1b1b1b' } -// DepthChart is a javascript Canvas-based depth chart renderer. -export class DepthChart { - constructor (parent, reporters, zoom) { +// Chart is the base class for charts. +class Chart { + constructor (parent) { + this.parent = parent this.theme = State.isDark() ? darkTheme : lightTheme this.canvas = document.createElement('canvas') - this.parent = parent - this.reporters = reporters + this.visible = true + parent.appendChild(this.canvas) this.ctx = this.canvas.getContext('2d') this.ctx.textAlign = 'center' this.ctx.textBaseline = 'middle' - this.book = null - this.dataExtents = null - this.zoomLevel = zoom - this.lotSize = null - this.rateStep = null - this.lines = [] - this.markers = { - buys: [], - sells: [] - } - parent.appendChild(this.canvas) // Mouse handling this.mousePos = null bind(this.canvas, 'mousemove', e => { @@ -81,17 +71,31 @@ export class DepthChart { this.wheeled = () => { this.wheelLimiter = setTimeout(() => { this.wheelLimiter = null }, 100) } - bind(this.canvas, 'wheel', e => { this.wheel(e) }) this.boundResizer = () => { this.resize(parent.clientHeight) } bind(window, 'resize', this.boundResizer) bind(this.canvas, 'click', e => { this.click(e) }) - this.resize(parent.clientHeight) } - // The market handler will call unattach when the markets page is unloaded. - unattach () { - unbind(window, 'resize', this.boundResizer) + /* clear the canvas. */ + clear () { + this.ctx.clearRect(0, 0, this.canvas.width, this.canvas.height) + } + + /* draw calls the child class's render method. */ + draw () { + this.render() + } + + /* click is the handler for a click event on the canvas. */ + click (e) { + this.clicked(e) + } + + /* wheel is a mousewheel event handler. */ + wheel (e) { + this.zoom(e.deltaY < 0) + e.preventDefault() } /* @@ -110,23 +114,179 @@ export class DepthChart { this.plotRegion = new Region(this.ctx, plotExtents) this.xRegion = new Region(this.ctx, xLblExtents) this.yRegion = new Region(this.ctx, yLblExtents) + // After changing the visibility, this.canvas.getBoundingClientRect will + // return nonsense until a render. + window.requestAnimationFrame(() => { + this.rect = this.canvas.getBoundingClientRect() + this.resized() + }) + } + + /* zoom is called when the user scrolls the mouse wheel on the canvas. */ + zoom (bigger) { + if (this.wheelLimiter) return + this.zoomed(bigger) + } + + /* hide hides the canvas */ + hide () { + this.visible = false + Doc.hide(this.canvas) + } + + /* show shows the canvas */ + show () { + this.visible = true + Doc.show(this.canvas) + this.resize(this.parent.clientHeight) + } + + /* The market handler will call unattach when the markets page is unloaded. */ + unattach () { + unbind(window, 'resize', this.boundResizer) + } + + /* render must be implemented by the child class. */ + render () { + console.error('child class must override render method') + } + + /* applyLabelStyle applies the style used for axis tick labels. */ + applyLabelStyle () { + this.ctx.textAlign = 'center' + this.ctx.textBaseline = 'middle' + this.ctx.font = '12px \'sans\', sans-serif' + this.ctx.fillStyle = this.theme.axisLabel + } + + /* plotXLabels applies the provided labels to the x axis and draws the grid. */ + plotXLabels (labels, minX, maxX, unitLines) { + const extents = new Extents(minX, maxX, 0, 1) + this.xRegion.plot(extents, (ctx, tools) => { + this.applyLabelStyle() + const centerX = (maxX + minX) / 2 + let lastX = minX + let unitCenter = centerX + labels.lbls.forEach(lbl => { + ctx.fillText(lbl.txt, tools.x(lbl.val), tools.y(0.5)) + if (centerX >= lastX && centerX < lbl.val) { + unitCenter = (lastX + lbl.val) / 2 + } + lastX = lbl.val + }) + ctx.font = '11px \'sans\', sans-serif' + if (unitLines.length === 2) { + ctx.fillText(unitLines[0], tools.x(unitCenter), tools.y(0.63)) + ctx.fillText(unitLines[1], tools.x(unitCenter), tools.y(0.23)) + } else if (unitLines.length === 1) { + ctx.fillText(unitLines[0], tools.x(unitCenter), tools.y(0.5)) + } + }, true) + this.plotRegion.plot(extents, (ctx, tools) => { + ctx.lineWidth = 1 + ctx.strokeStyle = this.theme.gridLines + labels.lbls.forEach(lbl => { + line(ctx, tools.x(lbl.val), tools.y(0), tools.x(lbl.val), tools.y(1)) + }) + }, true) + } + + /* + * plotYLabels applies the y labels based on the provided plot region, and + * draws the grid. + */ + plotYLabels (region, labels, minY, maxY, unit) { + const extents = new Extents(0, 1, minY, maxY) + this.yRegion.plot(extents, (ctx, tools) => { + this.applyLabelStyle() + const centerY = maxY / 2 + let lastY = 0 + let unitCenter = centerY + labels.lbls.forEach(lbl => { + ctx.fillText(lbl.txt, tools.x(0.5), tools.y(lbl.val)) + if (centerY >= lastY && centerY < lbl.val) { + unitCenter = (lastY + lbl.val) / 2 + } + lastY = lbl.val + }) + ctx.fillText(unit, tools.x(0.5), tools.y(unitCenter)) + }, true) + region.plot(extents, (ctx, tools) => { + ctx.lineWidth = 1 + ctx.strokeStyle = this.theme.gridLines + labels.lbls.forEach(lbl => { + line(ctx, tools.x(0), tools.y(lbl.val), tools.x(1), tools.y(lbl.val)) + }) + }, true) + } + + /* + * doYLabels generates and applies the y-axis labels, based upon the + * provided plot region. + */ + doYLabels (region, step, unit, valFmt) { + const yLabels = makeLabels(this.ctx, region.height(), this.dataExtents.y.min, + this.dataExtents.y.max, 50, step, unit, valFmt) + + // Reassign the width of the y-label column to accommodate the widest text. + const yAxisWidth = yLabels.widest * 1.5 + this.yRegion.extents.x.max = yAxisWidth + this.yRegion.extents.y.max = region.extents.y.max + + this.plotRegion.extents.x.min = yAxisWidth + this.xRegion.extents.x.min = yAxisWidth + // Print the y labels. + this.plotYLabels(region, yLabels, this.dataExtents.y.min, this.dataExtents.y.max, unit) + return yLabels + } + + // drawFrame draws an outline around the plotRegion. + drawFrame () { + this.plotRegion.plot(new Extents(0, 1, 0, 1), (ctx, tools) => { + ctx.lineWidth = 1 + ctx.strokeStyle = this.theme.gridBorder + ctx.beginPath() + tools.dataCoords(() => { + ctx.moveTo(0, 0) + ctx.lineTo(0, 1) + ctx.lineTo(1, 1) + ctx.lineTo(1, 0) + ctx.lineTo(0, 0) + }) + ctx.stroke() + }) + } +} + +/* DepthChart is a javascript Canvas-based depth chart renderer. */ +export class DepthChart extends Chart { + constructor (parent, reporters, zoom) { + super(parent) + this.reporters = reporters + this.book = null + this.dataExtents = null + this.zoomLevel = zoom + this.lotSize = null + this.rateStep = null + this.lines = [] + this.markers = { + buys: [], + sells: [] + } + this.resize(parent.clientHeight) + } + + /* resized is called when the window or parent element are resized. */ + resized () { // The button region extents are set during drawing. this.zoomInBttn = new Region(this.ctx, new Extents(0, 0, 0, 0)) this.zoomOutBttn = new Region(this.ctx, new Extents(0, 0, 0, 0)) - this.rect = this.canvas.getBoundingClientRect() if (this.book) this.draw() } - // wheel is a mousewheel event handler. - wheel (e) { - this.zoom(e.deltaY < 0) - e.preventDefault() - } - - // zoom zooms the current view in or out. bigger=true is zoom in. - zoom (bigger) { + /* zoomed zooms the current view in or out. bigger=true is zoom in. */ + zoomed (bigger) { if (!this.zoomLevel) return - if (this.wheelLimiter) return if (!this.book.buys || !this.book.sells) return this.wheeled() // Zoom in to 66%, but out to 150% = 1 / (2/3) so that the same zoom levels @@ -137,8 +297,8 @@ export class DepthChart { this.reporters.zoom(this.zoomLevel) } - // click is the canvas 'click' event handler. - click (e) { + /* clicked is the canvas 'click' event handler. */ + clicked (e) { if (!this.dataExtents) return const x = e.clientX - this.rect.left const y = e.clientY - this.rect.y @@ -148,12 +308,7 @@ export class DepthChart { this.reporters.click(translator.unx(x)) } - // clear the canvas. - clear () { - this.ctx.clearRect(0, 0, this.canvas.width, this.canvas.height) - } - - // set sets the current data set and draws. + /* set sets the current data set and draws. */ set (book, lotSize, rateStep) { this.book = book this.lotSize = lotSize / 1e8 @@ -170,24 +325,25 @@ export class DepthChart { this.draw() } - // Draw the chart. - // 1. Calculate the data extents and translate the order book data to a - // cumulative form. - // 2. Draw axis ticks and grid, mid-gap line and value, zoom buttons, mouse - // position indicator... - // 4. Tick labels. - // 5. Data. - // 6. Epoch line legend. - // 7. Hover legend. - draw () { + /* + * render draws the chart. + * 1. Calculate the data extents and translate the order book data to a + * cumulative form. + * 2. Draw axis ticks and grid, mid-gap line and value, zoom buttons, mouse + * position indicator... + * 4. Tick labels. + * 5. Data. + * 6. Epoch line legend. + * 7. Hover legend. + */ + render () { // if connection fails it is not possible to get book. - if (!this.book) return + if (!this.book || !this.visible) return this.clear() // if (!this.book || this.book.empty()) return - this.ctx.textAlign = 'center' - this.ctx.textBaseline = 'middle' + const ctx = this.ctx const mousePos = this.mousePos const buys = this.book.buys const sells = this.book.sells @@ -278,57 +434,33 @@ export class DepthChart { const dataExtents = new Extents(low, high, 0, maxY) this.dataExtents = dataExtents - // Draw the axis tick labels. - const ctx = this.ctx - ctx.font = '12px \'sans\', sans-serif' - ctx.fillStyle = this.theme.axisLabel + this.doYLabels(this.plotRegion, this.lotSize, this.book.baseSymbol) - const yLabels = makeLabels(ctx, this.plotRegion.height(), dataExtents.y.min, - dataExtents.y.max, 50, this.lotSize, this.book.baseSymbol) - // Reassign the width of the y-label column to accommodate the widest text. - const yAxisWidth = yLabels.widest * 1.5 - this.yRegion.extents.x.max = yAxisWidth - this.plotRegion.extents.x.min = yAxisWidth - this.xRegion.extents.x.min = yAxisWidth const xLabels = makeLabels(ctx, this.plotRegion.width(), dataExtents.x.min, dataExtents.x.max, 100, this.rateStep, `${this.book.quoteSymbol}/${this.book.baseSymbol}`) + // Print the x labels + this.plotXLabels(xLabels, low, high, [`${this.quoteTicker}/`, this.baseTicker]) + // A function to be run at the end if there is legend data to display. let mouseData // Draw the grid. - ctx.lineWidth = 1 + this.drawFrame() this.plotRegion.plot(dataExtents, (ctx, tools) => { + ctx.lineWidth = 1 // first, a square around the plot area. ctx.strokeStyle = this.theme.gridBorder - const extX = dataExtents.x - const extY = dataExtents.y - ctx.beginPath() - tools.dataCoords(() => { - ctx.moveTo(extX.min, extY.min) - ctx.lineTo(extX.min, extY.max) - ctx.lineTo(extX.max, extY.max) - ctx.lineTo(extX.max, extY.min) - ctx.lineTo(extX.min, extY.min) - }) - ctx.stroke() - // for each x label, draw a vertical line - ctx.strokeStyle = this.theme.gridLines - xLabels.lbls.forEach(lbl => { - line(ctx, tools.x(lbl.val), tools.y(0), tools.x(lbl.val), tools.y(extY.max)) - }) - // horizontal lines for y labels. - yLabels.lbls.forEach(lbl => { - line(ctx, tools.x(extX.min), tools.y(lbl.val), tools.x(extX.max), tools.y(lbl.val)) - }) // draw a line to indicate mid-gap ctx.lineWidth = 2.5 ctx.strokeStyle = this.theme.gapLine - line(ctx, tools.x(midGap), tools.y(0), tools.x(midGap), tools.y(0.3 * extY.max)) + line(ctx, tools.x(midGap), tools.y(0), tools.x(midGap), tools.y(0.3 * dataExtents.y.max)) ctx.font = '30px \'demi-sans\', sans-serif' + ctx.textAlign = 'center' + ctx.textBaseline = 'middle' ctx.fillStyle = this.theme.value - const y = 0.5 * extY.max + const y = 0.5 * dataExtents.y.max ctx.fillText(formatLabelValue(midGap), tools.x(midGap), tools.y(y)) ctx.font = '12px \'sans\', sans-serif' // ctx.fillText('mid-market price', tools.x(midGap), tools.y(y) + 24) @@ -336,6 +468,8 @@ export class DepthChart { tools.x(midGap), tools.y(y) + 24) // Draw zoom buttons. + ctx.textAlign = 'center' + ctx.textBaseline = 'middle' const topCenterX = this.plotRegion.extents.midX const topCenterY = tools.y(maxY * 0.9) const zoomPct = dataExtents.xRange / midGap * 100 @@ -448,45 +582,12 @@ export class DepthChart { rate: dataX, depth: bestDepth[1], dotColor: dotColor, - yAxisWidth: yAxisWidth, hoverMarkers: hoverMarkers } }) - // Print the y labels. - this.yRegion.plot(new Extents(0, 1, 0, maxY), (ctx, tools) => { - const centerY = maxY / 2 - let lastY = 0 - let unitCenter = centerY - yLabels.lbls.forEach(lbl => { - ctx.fillText(lbl.txt, tools.x(0.5), tools.y(lbl.val)) - if (centerY >= lastY && centerY < lbl.val) { - unitCenter = (lastY + lbl.val) / 2 - } - lastY = lbl.val - }) - ctx.fillText(this.baseTicker, tools.x(0.5), tools.y(unitCenter)) - }, true) - - // Print the x labels - this.xRegion.plot(new Extents(low, high, 0, 1), (ctx, tools) => { - const centerX = (high + low) / 2 - let lastX = low - let unitCenter = centerX - xLabels.lbls.forEach(lbl => { - ctx.fillText(lbl.txt, tools.x(lbl.val), tools.y(0.5)) - if (centerX >= lastX && centerX < lbl.val) { - unitCenter = (lastX + lbl.val) / 2 - } - lastX = lbl.val - }) - ctx.font = '11px \'sans\', sans-serif' - ctx.fillText(`${this.quoteTicker}/`, tools.x(unitCenter), tools.y(0.63)) - ctx.fillText(this.baseTicker, tools.x(unitCenter), tools.y(0.23)) - }, true) - // Draw the epoch lines - this.ctx.lineWidth = 1.5 + ctx.lineWidth = 1.5 ctx.setLineDash([3, 3]) // epoch sells ctx.fillStyle = this.theme.sellFill @@ -498,7 +599,7 @@ export class DepthChart { this.drawDepth(buyEpoch) // Draw the book depth. - this.ctx.lineWidth = 2.5 + ctx.lineWidth = 2.5 ctx.setLineDash([]) // book sells ctx.fillStyle = this.theme.sellFill @@ -522,7 +623,7 @@ export class DepthChart { this.reporters.mouse(mouseData) } - // Draw a single side's depth chart data. + /* drawDepth draws a single side's depth chart data. */ drawDepth (depth) { const firstPt = depth[0] let y = firstPt[1] @@ -551,6 +652,7 @@ export class DepthChart { }) } + /* returns the mid-gap rate and gap width as a tuple. */ gap () { const [b, s] = [this.book.bestGapBuy(), this.book.bestGapSell()] if (!b) { @@ -560,17 +662,206 @@ export class DepthChart { return [(s.rate + b.rate) / 2, s.rate - b.rate] } + /* setLines stores the indicator lines to draw. */ setLines (lines) { this.lines = lines } + /* setMarkers sets the indicator markers to draw. */ setMarkers (markers) { this.markers = markers } } -// Extents holds a min and max in both the x and y directions, and provides -// getters for related data. +/* CandleChart is a candlestick data renderer. */ +export class CandleChart extends Chart { + constructor (parent, reporters) { + super(parent) + this.reporters = reporters + this.data = null + this.dataExtents = null + this.zoomLevel = 1 + this.numToShow = 100 + this.resize(parent.clientHeight) + } + + /* resized is called when the window or parent element are resized. */ + resized () { + const ext = this.plotRegion.extents + const candleExtents = new Extents(ext.x.min, ext.x.max, ext.y.min, ext.y.min + ext.yRange * 0.85) + this.candleRegion = new Region(this.ctx, candleExtents) + const volumeExtents = new Extents(ext.x.min, ext.x.max, ext.y.min + 0.85 * ext.yRange, ext.y.max) + this.volumeRegion = new Region(this.ctx, volumeExtents) + // Set a delay on the render to prevent lag. + if (this.resizeTimer) clearTimeout(this.resizeTimer) + this.resizeTimer = setTimeout(() => this.draw(), 100) + } + + clicked (e) {} + + /* zoomed zooms the current view in or out. bigger=true is zoom in. */ + zoomed (bigger) { + // bigger actually means fewer candles -> reduce zoomLevels index. + const idx = this.zoomLevels.indexOf(this.numToShow) + if (bigger) { + if (idx === 0) return + this.numToShow = this.zoomLevels[idx - 1] + } else { + if (this.zoomLevels.length <= idx + 1 || this.numToShow > this.data.candles.length) return + this.numToShow = this.zoomLevels[idx + 1] + } + this.draw() + } + + /* render draws the chart */ + render () { + const data = this.data + if (!data || !this.visible) return + const candleWidth = data.ms + const mousePos = this.mousePos + const allCandles = data.candles || [] + + const n = Math.min(this.numToShow, allCandles.length) + const candles = allCandles.slice(allCandles.length - n) + + this.clear() + + // If there are no candles. just don't draw anything. + if (n === 0) return + + // padding definition and some helper functions to parse candles. + const candleWidthPadding = 0.2 + const start = c => truncate(c.endStamp, candleWidth) + const end = c => start(c) + candleWidth + const paddedStart = c => start(c) + candleWidthPadding * candleWidth + const paddedWidth = (1 - 2 * candleWidthPadding) * candleWidth + + const first = candles[0] + const last = candles[n - 1] + + let [high, low, highVol] = [first.highRate, first.lowRate, first.matchVolume] + for (const c of candles) { + if (c.highRate > high) high = c.highRate + if (c.lowRate < low) low = c.lowRate + if (c.matchVolume > highVol) highVol = c.matchVolume + } + + // Calculate data extents and store them. They are used to apply labels. + const rateStep = this.market.ratestep + const dataExtents = new Extents(start(first), end(last), low, high) + if (low === high) { + // If there is no price movement at all in the window, show a little more + // top and bottom so things render nicely. + dataExtents.y.min -= rateStep + dataExtents.y.max += rateStep + } + this.dataExtents = dataExtents + + // Apply labels. + this.doYLabels(this.candleRegion, rateStep, this.market.quotesymbol, v => formatLabelValue(v / 1e8)) + this.candleRegion.extents.x.min = this.yRegion.extents.x.max + this.volumeRegion.extents.x.min = this.yRegion.extents.x.max + + const xLabels = makeCandleTimeLabels(candles, candleWidth, this.plotRegion.width(), 100) + + this.plotXLabels(xLabels, start(first), end(last), []) + + this.drawFrame() + + // Highlight the candle if the user mouse is over the canvas. + let mouseCandle + if (mousePos) { + this.plotRegion.plot(new Extents(dataExtents.x.min, dataExtents.x.max, 0, 1), (ctx, tools) => { + const selectedStartStamp = truncate(tools.unx(mousePos.x), candleWidth) + for (const c of candles) { + if (start(c) === selectedStartStamp) { + mouseCandle = c + ctx.fillStyle = this.theme.gridLines + ctx.fillRect(tools.x(start(c)), tools.y(0), tools.w(candleWidth), tools.h(1)) + break + } + } + }) + if (mouseCandle) { + const yExt = this.xRegion.extents.y + this.xRegion.plot(new Extents(dataExtents.x.min, dataExtents.x.max, yExt.min, yExt.max), (ctx, tools) => { + this.applyLabelStyle() + const rangeTxt = `${new Date(start(mouseCandle)).toLocaleString()} - ${new Date(end(mouseCandle)).toLocaleString()}` + const [xPad, yPad] = [25, 2] + const rangeWidth = ctx.measureText(rangeTxt).width + 2 * xPad + const rangeHeight = 16 + let centerX = tools.x((start(mouseCandle) + end(mouseCandle)) / 2) + let left = centerX - rangeWidth / 2 + const xExt = this.xRegion.extents.x + if (left < xExt.min) left = xExt.min + else if (left + rangeWidth > xExt.max) left = xExt.max - rangeWidth + centerX = left + rangeWidth / 2 + const top = yExt.min + (this.xRegion.height() - rangeHeight) / 2 + ctx.fillStyle = this.theme.legendFill + ctx.strokeStyle = this.theme.gridBorder + const rectArgs = [left - xPad, top - yPad, rangeWidth + 2 * xPad, rangeHeight + 2 * yPad] + ctx.fillRect(...rectArgs) + ctx.strokeRect(...rectArgs) + this.applyLabelStyle() + ctx.fillText(rangeTxt, centerX, this.xRegion.extents.midY, rangeWidth) + }) + } + } + + // Draw the volume bars. + const volDataExtents = new Extents(start(first), end(last), 0, highVol) + this.volumeRegion.plot(volDataExtents, (ctx, tools) => { + ctx.fillStyle = this.theme.gridBorder + for (const c of candles) { + ctx.fillRect(tools.x(paddedStart(c)), tools.y(0), tools.w(paddedWidth), tools.h(c.matchVolume)) + } + }) + + // Draw the candles. + this.candleRegion.plot(dataExtents, (ctx, tools) => { + ctx.lineWidth = 1 + for (const c of candles) { + const desc = c.startRate > c.endRate + const [x, y, w, h] = [tools.x(paddedStart(c)), tools.y(c.startRate), tools.w(paddedWidth), tools.h(c.endRate - c.startRate)] + const [high, low, cx] = [tools.y(c.highRate), tools.y(c.lowRate), w / 2 + x] + ctx.strokeStyle = desc ? this.theme.sellLine : this.theme.buyLine + ctx.fillStyle = desc ? this.theme.sellFill : this.theme.buyFill + + ctx.beginPath() + ctx.moveTo(cx, high) + ctx.lineTo(cx, low) + ctx.stroke() + + ctx.fillRect(x, y, w, h) + ctx.strokeRect(x, y, w, h) + } + }) + + // Report the mouse candle. + this.reporters.mouse(mouseCandle) + } + + /* setCandles sets the candle data and redraws the chart. */ + setCandles (data, market) { + this.data = data + if (!data.candles) return + this.market = market + let n = 25 + this.zoomLevels = [] + const maxCandles = Math.max(data.candles.length, 1000) + while (n < maxCandles) { + this.zoomLevels.push(n) + n *= 2 + } + this.numToShow = 100 + this.draw() + } +} + +/* + * Extents holds a min and max in both the x and y directions, and provides + * getters for related data. + */ class Extents { constructor (xMin, xMax, yMin, yMax) { this.setExtents(xMin, xMax, yMin, yMax) @@ -604,8 +895,10 @@ class Extents { } } -// Region applies an Extents to the canvas, providing utilities for coordinate -// transformations and restricting drawing to a specified region of the canvas. +/* + * Region applies an Extents to the canvas, providing utilities for coordinate + * transformations and restricting drawing to a specified region of the canvas. + */ class Region { constructor (context, extents) { this.context = context @@ -630,9 +923,11 @@ class Region { y < ext.y.max && y > ext.y.min) } - // A translator provides 4 function for coordinate transformations. x and y - // translate data coordinates to canvas coordinates for the specified data - // Extents. unx and uny translate canvas coordinates to data coordinates. + /* + * A translator provides 4 function for coordinate transformations. x and y + * translate data coordinates to canvas coordinates for the specified data + * Extents. unx and uny translate canvas coordinates to data coordinates. + */ translator (dataExtents) { const region = this.extents const xMin = dataExtents.x.min @@ -651,20 +946,19 @@ class Region { x: x => (x - xMin) * xFactor + screenMinX, y: y => screenMaxY - (y - yMin) * yFactor, unx: x => (x - screenMinX) / xFactor + xMin, - uny: y => yMin - (y - screenMaxY) / yFactor + uny: y => yMin - (y - screenMaxY) / yFactor, + w: w => w / xRange * screenW, + h: h => -h / yRange * screenH } } - // Clear the region. + /* clear clears the region. */ clear () { const ext = this.extents this.ctx.clearRect(ext.x.min, ext.y.min, ext.xRange, ext.yRange) } - // plot allows some drawing to be performed directly in data coordinates. - // Most actual drawing functions like ctx.stroke and ctx.fillRect should not - // be called from inside the provided drawFunc, but ctx.moveTo and ctx.LineTo - // are fine. + /* plot prepares tools for drawing using data coordinates. */ plot (dataExtents, drawFunc, skipMask) { const ctx = this.context const region = this.extents @@ -702,7 +996,10 @@ class Region { // on the canvas. ctx.transform(xFactor, 0, 0, yFactor, tx, ty) } - // Provide drawCoords as a tool to enable inline drawing. + // dataCoords allows some drawing to be performed directly in data + // coordinates. Most actual drawing functions like ctx.stroke and + // ctx.fillRect should not be called from inside dataCoords, but + // ctx.moveTo and ctx.LineTo are fine. tools.dataCoords = f => { ctx.save() setTransform() @@ -715,9 +1012,12 @@ class Region { } } -// makeLabels attempts to create the appropriate labels for the specified -// screen size, context, and label spacing. -function makeLabels (ctx, screenW, min, max, spacingGuess, step, unit) { +/* + * makeLabels attempts to create the appropriate labels for the specified + * screen size, context, and label spacing. + */ +function makeLabels (ctx, screenW, min, max, spacingGuess, step, unit, valFmt) { + valFmt = valFmt || formatLabelValue const n = screenW / spacingGuess const diff = max - min const tickGuess = diff / n @@ -732,7 +1032,7 @@ function makeLabels (ctx, screenW, min, max, spacingGuess, step, unit) { let widest = 0 while (x < max) { x = Number(x.toPrecision(sigFigs)) - const lbl = formatLabelValue(x) + const lbl = valFmt(x) widest = Math.max(widest, ctx.measureText(lbl).width) pts.push({ val: x, @@ -748,12 +1048,61 @@ function makeLabels (ctx, screenW, min, max, spacingGuess, step, unit) { } } -// The last element of an array. +const months = ['jan', 'feb', 'mar', 'apr', 'may', 'jun', 'jul', 'aug', 'sep', 'oct', 'nov', 'dec'] + +/* makeCandleTimeLabels prepares labels for candlestick data. */ +function makeCandleTimeLabels (candles, dur, screenW, spacingGuess) { + const first = candles[0] + const last = candles[candles.length - 1] + const start = truncate(first.endStamp, dur) + const end = truncate(last.endStamp, dur) + dur + const diff = end - start + const n = Math.min(candles.length, screenW / spacingGuess) + const tick = truncate(diff / n, dur) + if (tick === 0) return console.error('zero tick', dur, diff, n) // probably won't happen, but it'd suck if it did + let x = start + const zoneOffset = new Date().getTimezoneOffset() + const dayStamp = x => { + x = x - zoneOffset * 60000 + return x - (x % 86400000) + } + let lastDay = dayStamp(start) + let lastYear = 0 // new Date(start).getFullYear() + if (dayStamp(first) === dayStamp(last)) lastDay = 0 // Force at least one day stamp. + const pts = [] + let label + if (dur < 86400000) { + label = (d, x) => { + const day = dayStamp(x) + if (day !== lastDay) return `${months[d.getMonth()]}${d.getDate()} ${d.getHours()}:${String(d.getMinutes()).padStart(2, '0')}` + else return `${d.getHours()}:${String(d.getMinutes()).padStart(2, '0')}` + } + } else { + label = d => { + const year = d.getFullYear() + if (year !== lastYear) return `${months[d.getMonth()]}${d.getDate()} '${String(year).slice(2, 4)}` + else return `${months[d.getMonth()]}${d.getDate()}` + } + } + while (x <= end) { + const d = new Date(x) + pts.push({ + val: x, + txt: label(d, x) + }) + lastDay = dayStamp(x) + lastYear = d.getFullYear() + x += tick + } + return { lbls: pts } +} + +/* The last element of an array. */ function last (arr) { return arr[arr.length - 1] } -// line draws a line with the provided context. +/* line draws a line with the provided context. */ function line (ctx, x0, y0, x1, y1, skipStroke) { ctx.beginPath() ctx.moveTo(x0, y0) @@ -761,7 +1110,7 @@ function line (ctx, x0, y0, x1, y1, skipStroke) { if (!skipStroke) ctx.stroke() } -// dot draws a circle with the provided context. +/* dot draws a circle with the provided context. */ function dot (ctx, x, y, color, radius) { ctx.fillStyle = color ctx.beginPath() @@ -769,27 +1118,37 @@ function dot (ctx, x, y, color, radius) { ctx.fill() } +/* clamp returns v if min <= v <= max, else min or max. */ function clamp (v, min, max) { if (v < min) return min if (v > max) return max return v } -// labelSpecs is specifications for axis tick labels. +/* labelSpecs is specifications for axis tick labels. */ const labelSpecs = { minimumSignificantDigits: 4, maximumSignificantDigits: 5 } -// formatLabelValue formats the provided value using the labelSpecs format. +/* formatLabelValue formats the provided value using the labelSpecs format. */ function formatLabelValue (x) { return x.toLocaleString('en-us', labelSpecs) } +/* floatCompare compares two floats to within a tolerance of 1e-8. */ function floatCompare (a, b) { return withinTolerance(a, b, 1e-8) } +/* + * withinTolerance returns true if the difference between a and b are with + * the specified tolerance. + */ function withinTolerance (a, b, tolerance) { return Math.abs(a - b) < Math.abs(tolerance) } + +function truncate (v, w) { + return v - (v % w) +} diff --git a/client/webserver/site/src/js/markets.js b/client/webserver/site/src/js/markets.js index bd34d952aa..ef68c3fde6 100644 --- a/client/webserver/site/src/js/markets.js +++ b/client/webserver/site/src/js/markets.js @@ -2,7 +2,7 @@ import Doc, { WalletIcons } from './doc' import State from './state' import BasePage from './basepage' import OrderBook from './orderbook' -import { DepthChart } from './charts' +import { CandleChart, DepthChart } from './charts' import { postJSON } from './http' import { NewWalletForm, UnlockWalletForm, bind as bindForm } from './forms' import * as Order from './orderutil' @@ -17,7 +17,8 @@ const bookOrderRoute = 'book_order' const unbookOrderRoute = 'unbook_order' const updateRemainingRoute = 'update_remaining' const epochOrderRoute = 'epoch_order' -const bookUpdateRoute = 'bookupdate' +const candlesRoute = 'candles' +const candleUpdateRoute = 'candle_update' const unmarketRoute = 'unmarket' const lastMarketKey = 'selectedMarket' @@ -28,6 +29,9 @@ const animationLength = 500 const anHour = 60 * 60 * 1000 // milliseconds +const depthChart = 'depth_chart' +const candleChart = 'candle_chart' + const check = document.createElement('span') check.classList.add('ico-check') @@ -64,7 +68,10 @@ export default class MarketsPage extends BasePage { // Chart and legend 'marketChart', 'chartResizer', 'sellBookedBase', 'sellBookedQuote', 'buyBookedBase', 'buyBookedQuote', 'hoverData', 'hoverPrice', - 'hoverVolume', 'chartLegend', 'chartErrMsg', + 'hoverVolume', 'chartLegend', 'chartErrMsg', 'candlestickBttn', + 'depthBttn', 'epochLine', 'durBttnTemplate', 'durBttnBox', + 'depthHoverData', 'candleHoverData', 'candleHigh', 'candleLow', + 'candleStart', 'candleEnd', 'candleVol', 'depthSummary', // Max order section 'maxOrd', 'maxLbl', 'maxFromLots', 'maxFromAmt', 'maxFromTicker', 'maxToAmt', 'maxToTicker', 'maxAboveZero', 'maxLotBox', 'maxFromLotsLbl', @@ -98,13 +105,24 @@ export default class MarketsPage extends BasePage { this.ordersSortDirection = 1 // store original title so we can re-append it when updating market value. this.ogTitle = document.title - const reporters = { - click: p => { this.reportClick(p) }, - volume: d => { this.reportVolume(d) }, - mouse: d => { this.reportMousePosition(d) }, - zoom: z => { this.reportZoom(z) } + + const depthReporters = { + click: p => { this.reportDepthClick(p) }, + volume: d => { this.reportDepthVolume(d) }, + mouse: d => { this.reportDepthMouse(d) }, + zoom: z => { this.reportDepthZoom(z) } } - this.chart = new DepthChart(page.marketChart, reporters, State.fetch(depthZoomKey)) + this.depthChart = new DepthChart(page.marketChart, depthReporters, State.fetch(depthZoomKey)) + + const candleReporters = { + mouse: c => { this.reportMouseCandle(c) } + } + this.candleChart = new CandleChart(page.marketChart, candleReporters) + + // TODO: Store user's state and reload last known configuration. + this.candleChart.hide() + this.currentChart = depthChart + this.candleDur = '' // Set up the BalanceWidget. { @@ -124,7 +142,7 @@ export default class MarketsPage extends BasePage { } // Prepare templates for the buy and sell tables and the user's order table. - cleanTemplates(page.rowTemplate, page.liveTemplate) + cleanTemplates(page.rowTemplate, page.liveTemplate, page.durBttnTemplate) // Prepare the list of markets. this.marketList = new MarketList(page.marketList) @@ -166,7 +184,7 @@ export default class MarketsPage extends BasePage { if (!page.rateField.value) return this.depthLines.input = [{ rate: page.rateField.value, - color: this.isSell() ? this.chart.theme.sellLine : this.chart.theme.buyLine + color: this.isSell() ? this.depthChart.theme.sellLine : this.depthChart.theme.buyLine }] this.drawChartLines() }) @@ -182,6 +200,12 @@ export default class MarketsPage extends BasePage { else page.lotField.value = this.market.maxBuys[this.adjustedRate()].swap.lots this.lotChanged() }) + bind(page.depthBttn, 'click', () => { + this.depthChartSelected() + }) + bind(page.candlestickBttn, 'click', () => { + this.candleChartSelected() + }) Doc.disableMouseWheel(page.rateField, page.lotField, page.qtyField, page.mktBuyField) @@ -195,6 +219,10 @@ export default class MarketsPage extends BasePage { ws.registerRoute(updateRemainingRoute, data => { this.handleUpdateRemainingRoute(data) }) // Handle the new order for the order book on the 'epoch_order' route. ws.registerRoute(epochOrderRoute, data => { this.handleEpochOrderRoute(data) }) + // Handle the intial candlestick data on the 'candles' route. + ws.registerRoute(candleUpdateRoute, data => { this.handleCandleUpdateRoute(data) }) + // Handle the candles update on the 'candles' route. + ws.registerRoute(candlesRoute, data => { this.handleCandlesRoute(data) }) // Bind the wallet unlock form. this.unlockForm = new UnlockWalletForm(app, page.openForm, async () => { this.openFunc() }) // Create a wallet @@ -290,7 +318,7 @@ export default class MarketsPage extends BasePage { bind(page.sellRows, 'mouseleave', clearChartLines) bind(page.liveList, 'mouseleave', () => { this.activeMarkerRate = null - this.setMarkers() + this.setDepthMarkers() }) // Load the user's layout preferences. @@ -301,7 +329,8 @@ export default class MarketsPage extends BasePage { const h = r * (this.main.clientHeight - app.header.offsetHeight) page.marketChart.style.height = `${h}px` - this.chart.resize(h) + this.depthChart.resize(h) + this.candleChart.resize(h) } const chartDivRatio = State.fetch(chartRatioKey) if (chartDivRatio) { @@ -513,6 +542,17 @@ export default class MarketsPage extends BasePage { } else this.page.submitBttn.textContent = intl.prep(intl.ID_SET_BUTTON_BUY, { asset: this.market.base.symbol.toUpperCase() }) } + setCandleDurBttns () { + const { page, market } = this + Doc.empty(page.durBttnBox) + for (const dur of market.dex.candleDurs) { + const bttn = page.durBttnTemplate.cloneNode(true) + bttn.textContent = dur + Doc.bind(bttn, 'click', () => this.candleDurationSelected(dur)) + page.durBttnBox.appendChild(bttn) + } + } + /* setMarket sets the currently displayed market. */ async setMarket (host, base, quote) { const dex = app.user.exchanges[host] @@ -551,31 +591,38 @@ export default class MarketsPage extends BasePage { baseCfg: baseCfg, quoteCfg: quoteCfg, maxSell: null, - maxBuys: {} + maxBuys: {}, + candleCaches: {} } page.marketLoader.classList.remove('d-none') - ws.request('loadmarket', makeMarket(host, base, quote)) + if (!dex.candleDurs || dex.candleDurs.length === 0) this.currentChart = depthChart + if (this.currentChart === depthChart) ws.request('loadmarket', makeMarket(host, base, quote)) + else { + if (dex.candleDurs.indexOf(this.candleDur) === -1) this.candleDur = dex.candleDurs[0] + this.loadCandles() + } this.setLoaderMsgVisibility() this.setRegistrationStatusVisibility() this.resolveOrderFormVisibility() this.setOrderBttnText() + this.setCandleDurBttns() } /* - * reportClick is a callback used by the DepthChart when the user clicks + * reportDepthClick is a callback used by the DepthChart when the user clicks * on the chart area. The rate field is set to the x-value of the click. */ - reportClick (p) { + reportDepthClick (p) { this.page.rateField.value = p.toFixed(8) this.rateFieldChanged() } /* - * reportVolume accepts a volume report from the DepthChart and sets the + * reportDepthVolume accepts a volume report from the DepthChart and sets the * values in the chart legend. */ - reportVolume (d) { + reportDepthVolume (d) { const page = this.page page.sellBookedBase.textContent = Doc.formatCoinValue(d.sellBase) page.sellBookedQuote.textContent = Doc.formatCoinValue(d.sellQuote) @@ -584,10 +631,10 @@ export default class MarketsPage extends BasePage { } /* - * reportMousePosition accepts informations about the mouse position on the + * reportDepthMouse accepts informations about the mouse position on the * chart area. */ - reportMousePosition (d) { + reportDepthMouse (d) { while (this.hovers.length) this.hovers.shift().classList.remove('hover') const page = this.page if (!d) { @@ -610,19 +657,33 @@ export default class MarketsPage extends BasePage { page.hoverPrice.textContent = Doc.formatCoinValue(d.rate) page.hoverVolume.textContent = Doc.formatCoinValue(d.depth) page.hoverVolume.style.color = d.dotColor - page.chartLegend.style.left = `${d.yAxisWidth}px` Doc.show(page.hoverData) } /* - * reportZoom accepts informations about the current depth chart zoom level. + * reportDepthZoom accepts informations about the current depth chart zoom level. * This information is saved to disk so that the zoom level can be maintained * across reloads. */ - reportZoom (zoom) { + reportDepthZoom (zoom) { State.store(depthZoomKey, zoom) } + reportMouseCandle (candle) { + const page = this.page + if (!candle) { + Doc.hide(page.hoverData) + return + } + + page.candleStart.textContent = Doc.formatCoinValue(candle.startRate / 1e8) + page.candleEnd.textContent = Doc.formatCoinValue(candle.endRate / 1e8) + page.candleHigh.textContent = Doc.formatCoinValue(candle.highRate / 1e8) + page.candleLow.textContent = Doc.formatCoinValue(candle.lowRate / 1e8) + page.candleVol.textContent = Doc.formatCoinValue(candle.matchVolume / 1e8) + Doc.show(page.hoverData) + } + /* * parseOrder pulls the order information from the form fields. Data is not * validated in any way. @@ -664,7 +725,7 @@ export default class MarketsPage extends BasePage { if (adjusted && this.isLimit()) { this.depthLines.input = [{ rate: order.rate / 1e8, - color: order.sell ? this.chart.theme.sellLine : this.chart.theme.buyLine + color: order.sell ? this.depthChart.theme.sellLine : this.depthChart.theme.buyLine }] } this.drawChartLines() @@ -825,12 +886,12 @@ export default class MarketsPage extends BasePage { this.addTableOrder(order) } if (!this.book) { - this.chart.clear() + this.depthChart.clear() Doc.empty(this.page.buyRows) Doc.empty(this.page.sellRows) return } - this.chart.set(this.book, this.market.cfg.lotsize, this.market.cfg.ratestep) + this.depthChart.set(this.book, this.market.cfg.lotsize, this.market.cfg.ratestep) } /* @@ -919,7 +980,7 @@ export default class MarketsPage extends BasePage { } Doc.bind(row, 'mouseenter', e => { this.activeMarkerRate = ord.rate - this.setMarkers() + this.setDepthMarkers() }) updateUserOrderRow(row, ord) if (ord.type === Order.Limit && (ord.tif === Order.StandingTiF && ord.status < Order.StatusExecuted)) { @@ -938,11 +999,11 @@ export default class MarketsPage extends BasePage { page.liveList.appendChild(row) app.bindTooltips(row) } - this.setMarkers() + this.setDepthMarkers() } - /* setMarkers sets the depth chart markers for booked orders. */ - setMarkers () { + /* setDepthMarkers sets the depth chart markers for booked orders. */ + setDepthMarkers () { const markers = { buys: [], sells: [] @@ -963,8 +1024,8 @@ export default class MarketsPage extends BasePage { } } } - this.chart.setMarkers(markers) - if (this.book) this.chart.draw() + this.depthChart.setMarkers(markers) + if (this.book) this.depthChart.draw() } /* updateTitle update the browser title based on the midgap value and the @@ -1032,7 +1093,7 @@ export default class MarketsPage extends BasePage { if (order.rate > 0) this.book.add(order) this.addTableOrder(order) this.updateTitle() - this.chart.draw() + this.depthChart.draw() } /* handleUnbookOrderRoute is the handler for 'unbook_order' notifications. */ @@ -1043,7 +1104,7 @@ export default class MarketsPage extends BasePage { this.book.remove(order.token) this.removeTableOrder(order) this.updateTitle() - this.chart.draw() + this.depthChart.draw() } /* @@ -1056,7 +1117,7 @@ export default class MarketsPage extends BasePage { const update = data.payload this.book.updateRemaining(update.token, update.qty) this.updateTableOrder(update) - this.chart.draw() + this.depthChart.draw() } /* handleEpochOrderRoute is the handler for 'epoch_order' notifications. */ @@ -1066,7 +1127,40 @@ export default class MarketsPage extends BasePage { const order = data.payload if (order.rate > 0) this.book.add(order) // No cancels or market orders if (order.qty > 0) this.addTableOrder(order) // No cancel orders - this.chart.draw() + this.depthChart.draw() + } + + /* handleCandlesRoute is the handler for 'candles' notifications. */ + handleCandlesRoute (data) { + if (this.candlesLoading) { + clearTimeout(this.candlesLoading.timer) + this.candlesLoading.loaded() + this.candlesLoading = null + } + this.depthChart.hide() + this.candleChart.show() + if (data.host !== this.market.dex.host) return + const dur = data.payload.dur + this.market.candleCaches[dur] = data.payload + if (this.currentChart !== candleChart || this.candleDur !== dur) return + this.candleChart.setCandles(data.payload, this.market.cfg) + } + + /* handleCandleUpdateRoute is the handler for 'candle_update' notifications. */ + handleCandleUpdateRoute (data) { + if (data.host !== this.market.dex.host) return + const { dur, candle } = data.payload + const cache = this.market.candleCaches[dur] + if (!cache) return // must not have seen the 'candles' notification yet? + const candles = cache.candles + if (candles.length === 0) candles.push(candle) + else { + const last = candles[candles.length - 1] + if (last.startStamp === candle.startStamp) candles[candles.length - 1] = candle + else candles.push(candle) + } + if (this.currentChart !== candleChart || this.candleDur !== dur) return + this.candleChart.draw() } /* showForm shows a modal form with a little animation. */ @@ -1265,7 +1359,7 @@ export default class MarketsPage extends BasePage { updateUserOrderRow(metaOrder.row, order) // Only reset markers if there is a change, since the chart is redrawn. if ((oldStatus === Order.StatusEpoch && order.status === Order.StatusBooked) || - (oldStatus === Order.StatusBooked && order.status > Order.StatusBooked)) this.setMarkers() + (oldStatus === Order.StatusBooked && order.status > Order.StatusBooked)) this.setDepthMarkers() } /* @@ -1276,8 +1370,9 @@ export default class MarketsPage extends BasePage { if (note.host !== this.market.dex.host || note.marketID !== this.market.sid) return if (this.book) { this.book.setEpoch(note.epoch) - this.chart.draw() + this.depthChart.draw() } + this.clearOrderTableEpochs(note.epoch) for (const metaOrder of Object.values(this.metaOrders)) { const order = metaOrder.order @@ -1360,7 +1455,7 @@ export default class MarketsPage extends BasePage { // Hide confirmation modal only on success. Doc.hide(page.forms) this.refreshActiveOrders() - this.chart.draw() + this.depthChart.draw() } /* @@ -1459,7 +1554,7 @@ export default class MarketsPage extends BasePage { this.page.rateField.value = v this.depthLines.input = [{ rate: v, - color: order.sell ? this.chart.theme.sellLine : this.chart.theme.buyLine + color: order.sell ? this.depthChart.theme.sellLine : this.depthChart.theme.buyLine }] this.drawChartLines() this.previewQuoteAmt(true) @@ -1605,11 +1700,11 @@ export default class MarketsPage extends BasePage { const manager = new OrderTableRowManager(tr, orderBin) tr.manager = manager bind(tr, 'click', () => { - this.reportClick(tr.manager.getRate()) + this.reportDepthClick(tr.manager.getRate()) }) if (tr.manager.getRate() !== 0) { Doc.bind(tr, 'mouseenter', e => { - const chart = this.chart + const chart = this.depthChart this.depthLines.hover = [{ rate: tr.manager.getRate(), color: tr.manager.isSell() ? chart.theme.sellLine : chart.theme.buyLine @@ -1645,8 +1740,78 @@ export default class MarketsPage extends BasePage { /* drawChartLines draws the hover and input lines on the chart. */ drawChartLines () { - this.chart.setLines([...this.depthLines.hover, ...this.depthLines.input]) - this.chart.draw() + this.depthChart.setLines([...this.depthLines.hover, ...this.depthLines.input]) + this.depthChart.draw() + } + + /* + * depthChartSelected is called when the user clicks a button to show the + * depth chart. + */ + depthChartSelected () { + const page = this.page + Doc.hide(page.depthBttn, page.durBttnBox, page.candleHoverData) + Doc.show(page.candlestickBttn, page.epochLine, page.depthHoverData, page.depthSummary) + this.currentChart = depthChart + this.depthChart.show() + this.candleChart.hide() + } + + /* + * candleChartSelected is called when the user clicks a button to show the + * historical market data (candlestick) chart. + */ + candleChartSelected () { + const page = this.page + const dex = this.market.dex + this.currentChart = candleChart + Doc.hide(page.candlestickBttn, page.epochLine, page.depthHoverData, page.depthSummary) + Doc.show(page.depthBttn, page.durBttnBox, page.candleHoverData) + if (dex.candleDurs.indexOf(this.candleDur) === -1) this.candleDur = dex.candleDurs[0] + this.loadCandles() + } + + /* candleDurationSelected sets the candleDur and loads the candles. */ + candleDurationSelected (dur) { + this.candleDur = dur + this.loadCandles() + } + + /* + * loadCandles loads the candles for the current candleDur. If a cache is already + * active, the cache will be used without a loadcandles request. + */ + loadCandles () { + for (const bttn of this.page.durBttnBox.children) { + if (bttn.textContent === this.candleDur) bttn.classList.add('selected') + else bttn.classList.remove('selected') + } + const { candleCaches, cfg } = this.market + const cache = candleCaches[this.candleDur] + if (cache) { + this.depthChart.hide() + this.candleChart.show() + this.candleChart.setCandles(cache, cfg) + return + } + this.requestCandles() + } + + /* requestCandles sends the loadcandles request. */ + requestCandles () { + const loaded = app.loading(this.page.marketChart) + this.candlesLoading = { + loaded: loaded, + timer: setTimeout(() => { + if (this.candlesLoading) { + this.candlesLoading = null + loaded() + console.error('candles not received') + } + }, 10000) + } + const { dex, base, quote } = this.market + ws.request('loadcandles', { host: dex.host, base: base.id, quote: quote.id, dur: this.candleDur }) } /* @@ -1656,11 +1821,13 @@ export default class MarketsPage extends BasePage { unload () { ws.request(unmarketRoute, {}) ws.deregisterRoute(bookRoute) - ws.deregisterRoute(bookUpdateRoute) ws.deregisterRoute(epochOrderRoute) ws.deregisterRoute(bookOrderRoute) ws.deregisterRoute(unbookOrderRoute) - this.chart.unattach() + ws.deregisterRoute(candlesRoute) + ws.deregisterRoute(candleUpdateRoute) + this.depthChart.unattach() + this.candleChart.unattach() Doc.unbind(document, 'keyup', this.keyup) clearInterval(this.secondTicker) } diff --git a/client/webserver/site/src/localized_html/en-US/markets.tmpl b/client/webserver/site/src/localized_html/en-US/markets.tmpl index f732fba1bb..01d1be01ab 100644 --- a/client/webserver/site/src/localized_html/en-US/markets.tmpl +++ b/client/webserver/site/src/localized_html/en-US/markets.tmpl @@ -48,20 +48,32 @@
-
epoch + + +
epoch
- - + + price: , volume: + + S: , + E: , + L: , + H: , + V: +
- sells: , - - buys: , +
+ sells: , + + buys: , +
+
diff --git a/client/webserver/webserver_test.go b/client/webserver/webserver_test.go index 4e79507db1..300f7ad8ee 100644 --- a/client/webserver/webserver_test.go +++ b/client/webserver/webserver_test.go @@ -54,7 +54,7 @@ func (c *tCoin) Confirmations(context.Context) (uint32, error) { type TCore struct { balanceErr error - syncFeed *core.BookFeed + syncFeed core.BookFeed syncErr error regErr error loginErr error @@ -83,7 +83,7 @@ func (c *TCore) Register(r *core.RegisterForm) (*core.RegisterResult, error) { r func (c *TCore) InitializeClient(pw, seed []byte) error { return c.initErr } func (c *TCore) Login(pw []byte) (*core.LoginResult, error) { return &core.LoginResult{}, c.loginErr } func (c *TCore) IsInitialized() bool { return c.isInited } -func (c *TCore) SyncBook(dex string, base, quote uint32) (*core.BookFeed, error) { +func (c *TCore) SyncBook(dex string, base, quote uint32) (core.BookFeed, error) { return c.syncFeed, c.syncErr } func (c *TCore) Book(dex string, base, quote uint32) (*core.OrderBook, error) { diff --git a/client/websocket/websocket.go b/client/websocket/websocket.go index aa63618b4f..fe71571e33 100644 --- a/client/websocket/websocket.go +++ b/client/websocket/websocket.go @@ -31,13 +31,20 @@ var ( cidCounter int32 ) +type bookFeed struct { + core.BookFeed + loop *dex.StartStopWaiter + host string + base, quote uint32 +} + // wsClient is a persistent websocket connection to a client. type wsClient struct { *ws.WSLink cid int32 - feedLoopMtx sync.RWMutex - feedLoop *dex.StartStopWaiter + feedMtx sync.RWMutex + feed *bookFeed } func newWSClient(addr string, conn ws.Connection, hndlr func(msg *msgjson.Message) *msgjson.Error, logger dex.Logger) *wsClient { @@ -49,7 +56,7 @@ func newWSClient(addr string, conn ws.Connection, hndlr func(msg *msgjson.Messag // Core specifies the needed methods for Server to operate. Satisfied by *core.Core. type Core interface { - SyncBook(dex string, base, quote uint32) (*core.BookFeed, error) + SyncBook(dex string, base, quote uint32) (core.BookFeed, error) AckNotes([]dex.Bytes) } @@ -144,12 +151,12 @@ func (s *Server) connect(ctx context.Context, conn ws.Connection, addr string) { s.clientsMtx.Unlock() defer func() { - cl.feedLoopMtx.Lock() - if cl.feedLoop != nil { - cl.feedLoop.Stop() - cl.feedLoop.WaitForShutdown() + cl.feedMtx.Lock() + if cl.feed != nil { + cl.feed.loop.Stop() + cl.feed.loop.WaitForShutdown() } - cl.feedLoopMtx.Unlock() + cl.feedMtx.Unlock() s.clientsMtx.Lock() delete(s.clients, cl.cid) @@ -199,9 +206,10 @@ type wsHandler func(*Server, *wsClient, *msgjson.Message) *msgjson.Error // wsHandlers is the map used by the server to locate the router handler for a // request. var wsHandlers = map[string]wsHandler{ - "loadmarket": wsLoadMarket, - "unmarket": wsUnmarket, - "acknotes": wsAckNotes, + "loadmarket": wsLoadMarket, + "loadcandles": wsLoadCandles, + "unmarket": wsUnmarket, + "acknotes": wsAckNotes, } // marketLoad is sent by websocket clients to subscribe to a market and request @@ -212,18 +220,23 @@ type marketLoad struct { Quote uint32 `json:"quote"` } +type candlesLoad struct { + marketLoad + Dur string `json:"dur"` +} + // marketSyncer is used to synchronize market subscriptions. The marketSyncer // manages a map of clients who are subscribed to the market, and distributes // order book updates when received. type marketSyncer struct { log dex.Logger - feed *core.BookFeed + feed core.BookFeed cl *wsClient } // newMarketSyncer is the constructor for a marketSyncer, returned as a running // *dex.StartStopWaiter. -func newMarketSyncer(cl *wsClient, feed *core.BookFeed, log dex.Logger) *dex.StartStopWaiter { +func newMarketSyncer(cl *wsClient, feed core.BookFeed, log dex.Logger) *dex.StartStopWaiter { ssWaiter := dex.NewStartStopWaiter(&marketSyncer{ feed: feed, cl: cl, @@ -239,7 +252,7 @@ func (m *marketSyncer) Run(ctx context.Context) { out: for { select { - case update, ok := <-m.feed.C: + case update, ok := <-m.feed.Next(): if !ok { // We are skipping m.feed.Close if the feed were closed (external sig). return @@ -264,35 +277,71 @@ out: // wsLoadMarket is the handler for the 'loadmarket' websocket route. Subscribes // the client to the notification feed and sends the order book. func wsLoadMarket(s *Server, cl *wsClient, msg *msgjson.Message) *msgjson.Error { - market := new(marketLoad) - err := json.Unmarshal(msg.Payload, market) + req := new(marketLoad) + err := json.Unmarshal(msg.Payload, req) if err != nil { errMsg := fmt.Sprintf("error unmarshalling marketload payload: %v", err) s.log.Errorf(errMsg) return msgjson.NewError(msgjson.RPCInternal, errMsg) } + _, msgErr := loadMarket(s, cl, req) + return msgErr +} - name, err := dex.MarketName(market.Base, market.Quote) +func loadMarket(s *Server, cl *wsClient, req *marketLoad) (*bookFeed, *msgjson.Error) { + name, err := dex.MarketName(req.Base, req.Quote) if err != nil { errMsg := fmt.Sprintf("unknown market: %v", err) s.log.Errorf(errMsg) - return msgjson.NewError(msgjson.UnknownMarketError, errMsg) + return nil, msgjson.NewError(msgjson.UnknownMarketError, errMsg) } - feed, err := s.core.SyncBook(market.Host, market.Base, market.Quote) + feed, err := s.core.SyncBook(req.Host, req.Base, req.Quote) if err != nil { errMsg := fmt.Sprintf("error getting order feed: %v", err) s.log.Errorf(errMsg) - return msgjson.NewError(msgjson.RPCOrderBookError, errMsg) + return nil, msgjson.NewError(msgjson.RPCOrderBookError, errMsg) } - cl.feedLoopMtx.Lock() - if cl.feedLoop != nil { - cl.feedLoop.Stop() - cl.feedLoop.WaitForShutdown() + cl.feedMtx.Lock() + defer cl.feedMtx.Unlock() + if cl.feed != nil { + cl.feed.loop.Stop() + cl.feed.loop.WaitForShutdown() + } + cl.feed = &bookFeed{ + BookFeed: feed, + loop: newMarketSyncer(cl, feed, s.log.SubLogger(name)), + host: req.Host, + base: req.Base, + quote: req.Quote, + } + + return cl.feed, nil +} + +func wsLoadCandles(s *Server, cl *wsClient, msg *msgjson.Message) *msgjson.Error { + req := new(candlesLoad) + err := json.Unmarshal(msg.Payload, req) + if err != nil { + errMsg := fmt.Sprintf("error unmarshalling candlesLoad payload: %v", err) + s.log.Errorf(errMsg) + return msgjson.NewError(msgjson.RPCInternal, errMsg) + } + cl.feedMtx.RLock() + feed := cl.feed + cl.feedMtx.RUnlock() + if feed.host != req.Host || feed.base != req.Base || feed.quote != req.Quote { + var msgErr *msgjson.Error + feed, msgErr = loadMarket(s, cl, &req.marketLoad) + if msgErr != nil { + return msgErr + } + } + err = feed.Candles(req.Dur) + if err != nil { + return msgjson.NewError(msgjson.RPCInternal, err.Error()) } - cl.feedLoop = newMarketSyncer(cl, feed, s.log.SubLogger(name)) - cl.feedLoopMtx.Unlock() return nil } @@ -301,12 +350,12 @@ func wsLoadMarket(s *Server, cl *wsClient, msg *msgjson.Message) *msgjson.Error // and potentially unsubscribes from orderbook with the server if there are no // other consumers func wsUnmarket(_ *Server, cl *wsClient, _ *msgjson.Message) *msgjson.Error { - cl.feedLoopMtx.Lock() - defer cl.feedLoopMtx.Unlock() - if cl.feedLoop != nil { - cl.feedLoop.Stop() - cl.feedLoop.WaitForShutdown() - cl.feedLoop = nil + cl.feedMtx.Lock() + defer cl.feedMtx.Unlock() + if cl.feed.loop != nil { + cl.feed.loop.Stop() + cl.feed.loop.WaitForShutdown() + cl.feed = nil } return nil } diff --git a/client/websocket/websocket_test.go b/client/websocket/websocket_test.go index 7cf3db4377..5672c8d24d 100644 --- a/client/websocket/websocket_test.go +++ b/client/websocket/websocket_test.go @@ -23,14 +23,14 @@ var ( ) type TCore struct { - syncFeed *core.BookFeed + syncFeed core.BookFeed syncErr error notHas bool notRunning bool notOpen bool } -func (c *TCore) SyncBook(dex string, base, quote uint32) (*core.BookFeed, error) { +func (c *TCore) SyncBook(dex string, base, quote uint32) (core.BookFeed, error) { return c.syncFeed, c.syncErr } func (c *TCore) WalletState(assetID uint32) *core.WalletState { @@ -131,6 +131,16 @@ func newTServer() (*Server, *TCore) { return New(c, dex.StdOutLogger("TEST", dex.LevelTrace)), c } +type tBookFeed struct{} + +func (*tBookFeed) Next() <-chan *core.BookUpdate { + return make(chan *core.BookUpdate, 1) +} +func (*tBookFeed) Close() {} +func (*tBookFeed) Candles(dur string) error { + return nil +} + func TestMain(m *testing.M) { var shutdown func() tCtx, shutdown = context.WithCancel(context.Background()) @@ -157,8 +167,8 @@ func TestLoadMarket(t *testing.T) { // so manually stop the marketSyncer started by wsLoadMarket and the WSLink // before returning from this test. defer func() { - link.cl.feedLoop.Stop() - link.cl.feedLoop.WaitForShutdown() + link.cl.feed.loop.Stop() + link.cl.feed.loop.WaitForShutdown() link.cl.Disconnect() linkWg.Wait() }() @@ -175,12 +185,12 @@ func TestLoadMarket(t *testing.T) { t.Helper() // Create a new feed for every request because a Close()d feed cannot be // reused. - tCore.syncFeed = core.NewBookFeed(func(feed *core.BookFeed) {}) + tCore.syncFeed = &tBookFeed{} msgErr := srv.handleMessage(link.cl, subscription) if msgErr != nil { t.Fatalf("'loadmarket' error: %d: %s", msgErr.Code, msgErr.Message) } - if link.cl.feedLoop == nil { + if link.cl.feed.loop == nil { t.Fatalf("nil book feed waiter after 'loadmarket'") } } @@ -195,7 +205,7 @@ func TestLoadMarket(t *testing.T) { t.Fatalf("'unmarket' error: %d: %s", msgErr.Code, msgErr.Message) } - if link.cl.feedLoop != nil { + if link.cl.feed != nil { t.Fatalf("non-nil book feed waiter after 'unmarket'") } diff --git a/server/db/candles.go b/dex/candles/candles.go similarity index 65% rename from server/db/candles.go rename to dex/candles/candles.go index 1e1eb832f3..38a8665a37 100644 --- a/server/db/candles.go +++ b/dex/candles/candles.go @@ -1,7 +1,7 @@ // This code is available on the terms of the project LICENSE.md file, // also available online at https://blueoakcouncil.org/license/1.0.0. -package db +package candles import ( "time" @@ -10,25 +10,31 @@ import ( "decred.org/dcrdex/dex/msgjson" ) -// Candle is a report about the trading activity of a market over some -// specified period of time. Candles are managed with a CandleCache, which takes -// into account bin sizes and handles candle addition. -type Candle struct { - StartStamp uint64 - EndStamp uint64 - MatchVolume uint64 - QuoteVolume uint64 - HighRate uint64 - LowRate uint64 - StartRate uint64 - EndRate uint64 -} +const ( + // DefaultCandleRequest is the number of candles to return if the request + // does not specify otherwise. + DefaultCandleRequest = 50 + // CacheSize is the default cache size. Also represents the maximum number + // of candles that can be requested at once. + CacheSize = 1000 +) + +var ( + // BinSizes is the default bin sizes for candlestick data sets. Exported for + // use in the 'config' response. Internally, we will parse these to uint64 + // milliseconds. + BinSizes = []string{"24h", "1h", "5m"} +) -// CandleCache is a sized cache of candles. CandleCache provides methods for -// adding to the cache and reading cache data out. CandleCache is a typical -// slice until it reaches capacity, when it becomes a "circular array" to avoid -// re-allocations. -type CandleCache struct { +// Candle is a report about the trading activity of a market over some specified +// period of time. Candles are managed with a Cache, which takes into account +// bin sizes and handles candle addition. +type Candle = msgjson.Candle + +// Cache is a sized cache of candles. Cache provides methods for adding to the +// cache and reading cache data out. Candles is a typical slice until it reaches +// capacity, when it becomes a "circular array" to avoid re-allocations. +type Cache struct { Candles []Candle BinSize uint64 cap int @@ -36,24 +42,24 @@ type CandleCache struct { cursor int } -// NewCandleCache is a constructor for a CandleCache. -func NewCandleCache(cap int, binSize uint64) *CandleCache { - return &CandleCache{ +// NewCache is a constructor for a Cache. +func NewCache(cap int, binSize uint64) *Cache { + return &Cache{ cap: cap, BinSize: binSize, } } -// Add adds a new candle TO THE END of the CandleCache. The caller is -// responsible to ensure that candles added with Add are always newer than -// the last candle added. -func (c *CandleCache) Add(candle *Candle) { +// Add adds a new candle TO THE END of the Cache. The caller is responsible to +// ensure that candles added with Add are always newer than the last candle +// added. +func (c *Cache) Add(candle *Candle) { sz := len(c.Candles) if sz == 0 { c.Candles = append(c.Candles, *candle) return } - if c.combineCandles(c.last(), candle) { + if c.combineCandles(c.Last(), candle) { return } if sz == c.cap { // circular mode @@ -65,10 +71,15 @@ func (c *CandleCache) Add(candle *Candle) { c.cursor = sz // len(c.candles) - 1 } +func (c *Cache) Reset() { + c.cursor = 0 + c.Candles = nil +} + // WireCandles encodes up to 'count' most recent candles as -// *msgjson.WireCandles. If the CandleCache contains fewer than 'count', only -// those available will be returned, with no indication of error. -func (c *CandleCache) WireCandles(count int) *msgjson.WireCandles { +// *msgjson.WireCandles. If the Cache contains fewer than 'count', only those +// available will be returned, with no indication of error. +func (c *Cache) WireCandles(count int) *msgjson.WireCandles { n := count sz := len(c.Candles) if sz < n { @@ -96,13 +107,13 @@ func (c *CandleCache) WireCandles(count int) *msgjson.WireCandles { // from that candle is linearly interpreted between the endpoints. The caller is // responsible for making sure that dur >> binSize, otherwise the results will // be of little value. -func (c *CandleCache) Delta(since time.Time) (changePct float64, vol uint64) { +func (c *Cache) Delta(since time.Time) (changePct float64, vol uint64) { cutoff := encode.UnixMilliU(since) sz := len(c.Candles) if sz == 0 { return 0, 0 } - endRate := c.last().EndRate + endRate := c.Last().EndRate var startRate uint64 for i := 0; i < sz; i++ { candle := &c.Candles[(c.cursor+sz-i)%sz] @@ -128,13 +139,13 @@ func (c *CandleCache) Delta(since time.Time) (changePct float64, vol uint64) { } // last gets the most recent candle in the cache. -func (c *CandleCache) last() *Candle { +func (c *Cache) Last() *Candle { return &c.Candles[c.cursor] } // combineCandles attempts to add the candidate candle to the target candle // in-place, if they're in the same bin, otherwise returns false. -func (c *CandleCache) combineCandles(target, candidate *Candle) bool { +func (c *Cache) combineCandles(target, candidate *Candle) bool { if target.EndStamp/c.BinSize != candidate.EndStamp/c.BinSize { // The candidate candle cannot be added. return false diff --git a/server/db/candles_test.go b/dex/candles/candles_test.go similarity index 91% rename from server/db/candles_test.go rename to dex/candles/candles_test.go index 8b5881af78..dd27869a98 100644 --- a/server/db/candles_test.go +++ b/dex/candles/candles_test.go @@ -1,7 +1,7 @@ // This code is available on the terms of the project LICENSE.md file, // also available online at https://blueoakcouncil.org/license/1.0.0. -package db +package candles import ( "testing" @@ -10,12 +10,12 @@ import ( "decred.org/dcrdex/dex/encode" ) -func TestCandleCache(t *testing.T) { +func TestCache(t *testing.T) { // ctx, cancel := context.WithCancel(context.Background()) // defer cancel() const binSize = 10 const cacheCapacity = 5 - cache := NewCandleCache(cacheCapacity, binSize) + cache := NewCache(cacheCapacity, binSize) if cache.BinSize != binSize { t.Fatalf("wrong bin size. wanted %d, got %d", binSize, cache.BinSize) @@ -75,7 +75,7 @@ func TestCandleCache(t *testing.T) { if len(cache.Candles) != 1 { t.Fatalf("Add didn't add") } - lastCandle := cache.last() + lastCandle := cache.Last() if lastCandle == nil { t.Fatalf("failed to retrieve last candle") } @@ -90,9 +90,9 @@ func TestCandleCache(t *testing.T) { if len(cache.Candles) != 1 { t.Fatalf("Add didn't add") } - checkCandleStamps(cache.last(), 11, 15) - checkCandleVolumes(cache.last(), 400, 404, 150) - checkCandleRates(cache.last(), 100, 125, 25, 200) + checkCandleStamps(cache.Last(), 11, 15) + checkCandleVolumes(cache.Last(), 400, 404, 150) + checkCandleRates(cache.Last(), 100, 125, 25, 200) // Two candles each in a new bin. cache.Add(makeCandle(25, 27, 10, 11, 12, 13, 14, 15, 16)) @@ -100,9 +100,9 @@ func TestCandleCache(t *testing.T) { if len(cache.Candles) != 3 { t.Fatalf("New candles didn't add") } - checkCandleStamps(cache.last(), 41, 48) - checkCandleVolumes(cache.last(), 17, 18, 19) - checkCandleRates(cache.last(), 20, 21, 22, 23) + checkCandleStamps(cache.Last(), 41, 48) + checkCandleVolumes(cache.Last(), 17, 18, 19) + checkCandleRates(cache.Last(), 20, 21, 22, 23) // Candle combination is based on end stamp only. cache.Add(makeCandle(49, 51, 24, 25, 26, 27, 28, 29, 30)) @@ -118,7 +118,7 @@ func TestCandleCache(t *testing.T) { } // The cache becomes circular, so the most recent will be at the previously // oldest index, 0. - if cache.last() != &cache.Candles[0] { + if cache.Last() != &cache.Candles[0] { t.Fatalf("cache didn't wrap") } @@ -144,7 +144,7 @@ func TestDelta(t *testing.T) { aDayAgo := now - 86400*1000 var fiveMins uint64 = 5 * 60 * 1000 - c := NewCandleCache(5, fiveMins) + c := NewCache(5, fiveMins) // This one shouldn't be included. c.Add(&Candle{ MatchVolume: 100, @@ -176,7 +176,7 @@ func TestDelta(t *testing.T) { } // Get a delta that uses a partial stick. - c = NewCandleCache(5, fiveMins) + c = NewCache(5, fiveMins) c.Add(&Candle{ MatchVolume: 444, StartStamp: aDayAgo, diff --git a/dex/testing/loadbot/mantle.go b/dex/testing/loadbot/mantle.go index 8643704f32..12ecd7dacd 100644 --- a/dex/testing/loadbot/mantle.go +++ b/dex/testing/loadbot/mantle.go @@ -83,7 +83,7 @@ out: go func() { for { select { - case <-bookFeed.C: + case <-bookFeed.Next(): // If we ever enable the thebook feed, we // would pass the update to the Trader here. // For now, just keep the channel empty. diff --git a/server/apidata/apidata.go b/server/apidata/apidata.go index 7637604fe4..cb01d1c808 100644 --- a/server/apidata/apidata.go +++ b/server/apidata/apidata.go @@ -11,27 +11,14 @@ import ( "time" "decred.org/dcrdex/dex" + "decred.org/dcrdex/dex/candles" "decred.org/dcrdex/dex/encode" "decred.org/dcrdex/dex/msgjson" "decred.org/dcrdex/server/comms" - "decred.org/dcrdex/server/db" "decred.org/dcrdex/server/matcher" ) -const ( - // DefaultCandleRequest is the number of candles to return if the request - // does not specify otherwise. - DefaultCandleRequest = 50 - // CacheSize is the default cache size. Also represents the maximum number - // of candles that can be requested at once. - CacheSize = 1000 -) - var ( - // BinSizes is the default bin sizes for candlestick data sets. Exported for - // use in the 'config' response. Internally, we will parse these to uint64 - // milliseconds. - BinSizes = []string{"24h", "1h", "5m"} // Our internal millisecond representation of the bin sizes. binSizes []uint64 bin5min uint64 = 60 * 5 * 1000 @@ -41,7 +28,7 @@ var ( // DBSource is a source of persistent data. DBSource is used to prime the // caches at startup. type DBSource interface { - LoadEpochStats(base, quote uint32, caches []*db.CandleCache) error + LoadEpochStats(base, quote uint32, caches []*candles.Cache) error } // MarketSource is a source of market information. Markets are added after @@ -68,8 +55,8 @@ type DataAPI struct { spots map[string]json.RawMessage cacheMtx sync.RWMutex - marketCaches map[string]map[uint64]*db.CandleCache - cache5min *db.CandleCache + marketCaches map[string]map[uint64]*candles.Cache + cache5min *candles.Cache } // NewDataAPI is the constructor for a new DataAPI. @@ -78,7 +65,7 @@ func NewDataAPI(dbSrc DBSource) *DataAPI { db: dbSrc, epochDurations: make(map[string]uint64), spots: make(map[string]json.RawMessage), - marketCaches: make(map[string]map[uint64]*db.CandleCache), + marketCaches: make(map[string]map[uint64]*candles.Cache), } if atomic.CompareAndSwapUint32(&started, 0, 1) { @@ -97,11 +84,11 @@ func (s *DataAPI) AddMarketSource(mkt MarketSource) error { } epochDur := mkt.EpochDuration() s.epochDurations[mktName] = epochDur - binCaches := make(map[uint64]*db.CandleCache, len(binSizes)+1) + binCaches := make(map[uint64]*candles.Cache, len(binSizes)+1) s.marketCaches[mktName] = binCaches - cacheList := make([]*db.CandleCache, 0, len(binSizes)+1) + cacheList := make([]*candles.Cache, 0, len(binSizes)+1) for _, binSize := range append([]uint64{epochDur}, binSizes...) { - cache := db.NewCandleCache(CacheSize, binSize) + cache := candles.NewCache(candles.CacheSize, binSize) cacheList = append(cacheList, cache) binCaches[binSize] = cache if binSize == bin5min { @@ -142,7 +129,7 @@ func (s *DataAPI) ReportEpoch(base, quote uint32, epochIdx uint64, stats *matche startStamp := epochIdx * epochDur endStamp := startStamp + epochDur for _, cache := range mktCaches { - cache.Add(&db.Candle{ + cache.Add(&candles.Candle{ StartStamp: startStamp, EndStamp: endStamp, MatchVolume: stats.MatchVolume, @@ -190,9 +177,9 @@ func (s *DataAPI) handleCandles(thing interface{}) (interface{}, error) { } if req.NumCandles == 0 { - req.NumCandles = DefaultCandleRequest - } else if req.NumCandles > CacheSize { - return nil, fmt.Errorf("requested numCandles %d exceeds maximum request size %d", req.NumCandles, CacheSize) + req.NumCandles = candles.DefaultCandleRequest + } else if req.NumCandles > candles.CacheSize { + return nil, fmt.Errorf("requested numCandles %d exceeds maximum request size %d", req.NumCandles, candles.CacheSize) } mkt, err := dex.MarketName(req.BaseID, req.QuoteID) @@ -236,7 +223,7 @@ func (s *DataAPI) handleOrderBook(thing interface{}) (interface{}, error) { } func init() { - for _, s := range BinSizes { + for _, s := range candles.BinSizes { dur, err := time.ParseDuration(s) if err != nil { panic("error parsing bin size '" + s + "': " + err.Error()) diff --git a/server/apidata/apidata_test.go b/server/apidata/apidata_test.go index a629fcd3cb..087180077d 100644 --- a/server/apidata/apidata_test.go +++ b/server/apidata/apidata_test.go @@ -9,9 +9,9 @@ import ( "testing" "time" + "decred.org/dcrdex/dex/candles" "decred.org/dcrdex/dex/encode" "decred.org/dcrdex/dex/msgjson" - "decred.org/dcrdex/server/db" "decred.org/dcrdex/server/matcher" ) @@ -29,7 +29,7 @@ type TDBSource struct { loadEpochErr error } -func (db *TDBSource) LoadEpochStats(base, quote uint32, caches []*db.CandleCache) error { +func (db *TDBSource) LoadEpochStats(base, quote uint32, caches []*candles.Cache) error { return db.loadEpochErr } @@ -154,7 +154,7 @@ func TestReportEpoch(t *testing.T) { BaseID: 42, QuoteID: 0, BinSize: "1s", // Epoch duration, smallest candle size - NumCandles: CacheSize, + NumCandles: candles.CacheSize, }) if err != nil { t.Fatalf("handleCandles error: %v", err) diff --git a/server/db/driver/pg/epochs.go b/server/db/driver/pg/epochs.go index bf7f4be7cf..96ca3f2e2b 100644 --- a/server/db/driver/pg/epochs.go +++ b/server/db/driver/pg/epochs.go @@ -9,6 +9,7 @@ import ( "fmt" "time" + "decred.org/dcrdex/dex/candles" "decred.org/dcrdex/dex/order" "decred.org/dcrdex/server/db" "decred.org/dcrdex/server/db/driver/pg/internal" @@ -83,7 +84,7 @@ func (a *Archiver) InsertEpoch(ed *db.EpochResults) error { // LoadEpochStats reads all market epoch history from the database, updating the // provided caches along the way. -func (a *Archiver) LoadEpochStats(base, quote uint32, caches []*db.CandleCache) error { +func (a *Archiver) LoadEpochStats(base, quote uint32, caches []*candles.Cache) error { marketSchema, err := a.marketSchema(base, quote) if err != nil { return err @@ -110,7 +111,7 @@ func (a *Archiver) LoadEpochStats(base, quote uint32, caches []*db.CandleCache) if err != nil { return err } - candle := &db.Candle{ + candle := &candles.Candle{ StartStamp: uint64(endStamp - epochDur), EndStamp: uint64(endStamp), MatchVolume: uint64(matchVol), diff --git a/server/db/driver/pg/matches_online_test.go b/server/db/driver/pg/matches_online_test.go index ae80d2832e..beec483152 100644 --- a/server/db/driver/pg/matches_online_test.go +++ b/server/db/driver/pg/matches_online_test.go @@ -10,6 +10,7 @@ import ( "testing" "time" + "decred.org/dcrdex/dex/candles" "decred.org/dcrdex/dex/encode" "decred.org/dcrdex/dex/order" "decred.org/dcrdex/server/account" @@ -1254,10 +1255,10 @@ func TestEpochReport(t *testing.T) { QuoteVolume: 100, }) - epochCache := db.NewCandleCache(3, uint64(epochDur)) - dayCache := db.NewCandleCache(2, uint64(time.Hour*24/time.Millisecond)) + epochCache := candles.NewCache(3, uint64(epochDur)) + dayCache := candles.NewCache(2, uint64(time.Hour*24/time.Millisecond)) - err = archie.LoadEpochStats(42, 0, []*db.CandleCache{epochCache, dayCache}) + err = archie.LoadEpochStats(42, 0, []*candles.Cache{epochCache, dayCache}) if err != nil { t.Fatalf("error loading epoch stats: %v", err) } diff --git a/server/db/interface.go b/server/db/interface.go index 64e8468a04..57d71c833c 100644 --- a/server/db/interface.go +++ b/server/db/interface.go @@ -8,6 +8,7 @@ import ( "time" "decred.org/dcrdex/dex" + "decred.org/dcrdex/dex/candles" "decred.org/dcrdex/dex/order" "decred.org/dcrdex/server/account" ) @@ -78,7 +79,7 @@ type DEXArchivist interface { InsertEpoch(ed *EpochResults) error // LoadEpochStats reads all market epoch history from the database. - LoadEpochStats(uint32, uint32, []*CandleCache) error + LoadEpochStats(uint32, uint32, []*candles.Cache) error OrderArchiver AccountArchiver diff --git a/server/dex/dex.go b/server/dex/dex.go index a783c10831..3a91a9619f 100644 --- a/server/dex/dex.go +++ b/server/dex/dex.go @@ -13,6 +13,7 @@ import ( "time" "decred.org/dcrdex/dex" + "decred.org/dcrdex/dex/candles" "decred.org/dcrdex/dex/encode" "decred.org/dcrdex/dex/msgjson" "decred.org/dcrdex/dex/order" @@ -152,7 +153,7 @@ func newConfigResponse(cfg *DexConf, regAssets map[string]*msgjson.FeeAsset, cfg Markets: cfgMarkets, Fee: dcrAsset.Amt, // DEPRECATED - DCR only APIVersion: uint16(APIVersion), - BinSizes: apidata.BinSizes, + BinSizes: candles.BinSizes, DEXPubKey: cfg.DEXPrivKey.PubKey().SerializeCompressed(), RegFees: regAssets, } diff --git a/server/market/bookrouter.go b/server/market/bookrouter.go index 579e51f249..fd4f252fa9 100644 --- a/server/market/bookrouter.go +++ b/server/market/bookrouter.go @@ -44,7 +44,7 @@ const ( // This signal performs a couple of important roles. First, it informs the // client that the book updates are done, and the book will be static until // the end of the epoch. Second, it sends the candlestick data, so a - // subscriber can maintain a up-to-date CandleCache without repeatedly + // subscriber can maintain a up-to-date candles.Cache without repeatedly // querying the HTTP API for the data. epochReportAction // matchProofAction means the matching has been performed and will result in diff --git a/server/market/market_test.go b/server/market/market_test.go index 171d44a608..8e467b19f8 100644 --- a/server/market/market_test.go +++ b/server/market/market_test.go @@ -20,6 +20,7 @@ import ( "time" "decred.org/dcrdex/dex" + "decred.org/dcrdex/dex/candles" "decred.org/dcrdex/dex/encode" "decred.org/dcrdex/dex/msgjson" "decred.org/dcrdex/dex/order" @@ -209,8 +210,8 @@ func (ta *TArchivist) SaveRedeemAckSigB(mid db.MarketMatchID, sig []byte) error func (ta *TArchivist) SaveRedeemB(mid db.MarketMatchID, coinID []byte, timestamp int64) error { return nil } -func (ta *TArchivist) SetMatchInactive(mid db.MarketMatchID) error { return nil } -func (ta *TArchivist) LoadEpochStats(uint32, uint32, []*db.CandleCache) error { return nil } +func (ta *TArchivist) SetMatchInactive(mid db.MarketMatchID) error { return nil } +func (ta *TArchivist) LoadEpochStats(uint32, uint32, []*candles.Cache) error { return nil } type TCollector struct{}