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]]]
+
+
+
-
-
+
+
[[[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
+
+
+
-
-
+
+
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{}