Skip to content

Commit

Permalink
add market data API endpoints
Browse files Browse the repository at this point in the history
Exposes a rate-limited HTTP API. Data endpoints are accessible via HTTP
or WebSockets. The result for the WebSocket request is identical to the
REST API response.

Three type of data are available.

/spots is the spot price and booked volume of all markets.

/candles is candlestick data, available in bin sizes of 24h, 1h, and 15m.
An example URL is /candles/dcr/btc/15m.

/orderbook is already a WebSocket route, but is now also accessible by HTTP.
An example URL is /orderbook/dcr/btc

/config is another WebSocket route that is also now available over HTTP too.

The data API implements a data cache, but does not cache pre-encoded
responses for /candles or /orderbook (yet).
  • Loading branch information
buck54321 committed Oct 29, 2020
1 parent bd3d828 commit 442af83
Show file tree
Hide file tree
Showing 27 changed files with 1,452 additions and 83 deletions.
1 change: 1 addition & 0 deletions client/cmd/dexc/go.sum
Original file line number Diff line number Diff line change
Expand Up @@ -211,6 +211,7 @@ golang.org/x/sys v0.0.0-20200202164722-d101bd2416d5/go.mod h1:h1NjWce9XRLGQEsW7w
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
golang.org/x/text v0.3.2 h1:tW2bmiBqwgJj/UpqtC8EpXEZVYOwU0yG4iWbprSVAcs=
golang.org/x/text v0.3.2/go.mod h1:bEr9sfX3Q8Zfm5fL9x+3itogRgK3+ptLWKqgva+5dAk=
golang.org/x/time v0.0.0-20200630173020-3af7569d3a1e/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ=
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
golang.org/x/tools v0.0.0-20190328211700-ab21143f2384/go.mod h1:LCzVGOaR6xXOjkQ3onu1FJEFr0SW1gC7cKk1uF8kGRs=
golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
Expand Down
2 changes: 2 additions & 0 deletions client/cmd/dexcctl/go.sum
Original file line number Diff line number Diff line change
Expand Up @@ -194,6 +194,8 @@ golang.org/x/sys v0.0.0-20200202164722-d101bd2416d5/go.mod h1:h1NjWce9XRLGQEsW7w
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
golang.org/x/text v0.3.2 h1:tW2bmiBqwgJj/UpqtC8EpXEZVYOwU0yG4iWbprSVAcs=
golang.org/x/text v0.3.2/go.mod h1:bEr9sfX3Q8Zfm5fL9x+3itogRgK3+ptLWKqgva+5dAk=
golang.org/x/time v0.0.0-20200630173020-3af7569d3a1e h1:EHBhcS0mlXEAVwNyO2dLfjToGsyY4j24pTs2ScHnX7s=
golang.org/x/time v0.0.0-20200630173020-3af7569d3a1e/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ=
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
golang.org/x/tools v0.0.0-20190328211700-ab21143f2384/go.mod h1:LCzVGOaR6xXOjkQ3onu1FJEFr0SW1gC7cKk1uF8kGRs=
golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
Expand Down
88 changes: 88 additions & 0 deletions dex/msgjson/types.go
Original file line number Diff line number Diff line change
Expand Up @@ -70,6 +70,7 @@ const (
UnpaidAccountError // 51
InvalidRequestError // 52
OrderQuantityTooHigh // 53
HTTPRouteError // 54
)

// Routes are destinations for a "payload" of data. The type of data being
Expand Down Expand Up @@ -170,6 +171,12 @@ const (
// PenaltyRoute is the DEX-originating notification-type message
// informing of a broken rule and the resulting penalty.
PenaltyRoute = "penalty"
// SpotsRoute is the HTTP or WebSocket request to get the spot price and
// volume for the DEX's markets.
SpotsRoute = "spots"
// CandlesRoute is the HTTP request to get the set of candlesticks
// representing market activity history.
CandlesRoute = "candles"
)

type Bytes = dex.Bytes
Expand Down Expand Up @@ -1004,6 +1011,87 @@ type ConfigResult struct {
Assets []*Asset `json:"assets"`
Markets []*Market `json:"markets"`
Fee uint64 `json:"fee"`
BinSizes []string `json:"binSizes"` // Just apidata.BinSizes for now.
}

// Spot is a snapshot of a market at the end of a match cycle. A slice of Spot
// are sent as the response to the SpotsRoute request.
type Spot struct {
Stamp uint64 `json:"stamp"`
BaseID uint32 `json:"baseID"`
QuoteID uint32 `json:"quoteID"`
Rate uint64 `json:"rate"`
BookVolume uint64 `json:"bookVolume"`
}

// CandlesRequest is a data API request for market history.
type CandlesRequest struct {
BaseID uint32 `json:"baseID"`
QuoteID uint32 `json:"quoteID"`
BinSize string `json:"binSize"`
NumCandles int `json:"numCandles,omitempty"` // default and max defined in apidata.
}

// Candle is a statistical history of a specified period of market activity.
type Candle struct {
StartStamp uint64 `json:"startStamp"`
EndStamp uint64 `json:"endStamp"`
MatchVolume uint64 `json:"matchVolume"`
BookVolume uint64 `json:"bookVolume"`
OrderVolume uint64 `json:"orderVolume"`
HighRate uint64 `json:"highRate"`
LowRate uint64 `json:"lowRate"`
StartRate uint64 `json:"startRate"`
EndRate uint64 `json:"endRate"`
}

// WireCandles are Candles encoded as a series of integer arrays, as opposed to
// an array of candles. WireCandles encode smaller than []Candle, since the
// property names are not repeated for each candle.
type WireCandles struct {
StartStamps []uint64 `json:"startStamps"`
EndStamps []uint64 `json:"endStamps"`
MatchVolumes []uint64 `json:"matchVolumes"`
BookVolumes []uint64 `json:"bookVolumes"`
OrderVolumes []uint64 `json:"orderVolumes"`
HighRates []uint64 `json:"highRates"`
LowRates []uint64 `json:"lowRates"`
StartRates []uint64 `json:"startRates"`
EndRates []uint64 `json:"endRates"`
}

// NewWireCandles prepares a *WireCandles with slices of capacity n.
func NewWireCandles(n int) *WireCandles {
return &WireCandles{
StartStamps: make([]uint64, 0, n),
EndStamps: make([]uint64, 0, n),
MatchVolumes: make([]uint64, 0, n),
BookVolumes: make([]uint64, 0, n),
OrderVolumes: make([]uint64, 0, n),
HighRates: make([]uint64, 0, n),
LowRates: make([]uint64, 0, n),
StartRates: make([]uint64, 0, n),
EndRates: make([]uint64, 0, n),
}
}

// Candles converts the WireCandles to []*Candle.
func (wc *WireCandles) Candles() []*Candle {
candles := make([]*Candle, 0, len(wc.StartStamps))
for i := range wc.StartStamps {
candles = append(candles, &Candle{
StartStamp: wc.StartStamps[i],
EndStamp: wc.EndStamps[i],
MatchVolume: wc.MatchVolumes[i],
BookVolume: wc.BookVolumes[i],
OrderVolume: wc.OrderVolumes[i],
HighRate: wc.HighRates[i],
LowRate: wc.LowRates[i],
StartRate: wc.StartRates[i],
EndRate: wc.EndRates[i],
})
}
return candles
}

// Convert uint64 to 8 bytes.
Expand Down
13 changes: 13 additions & 0 deletions dex/order/match.go
Original file line number Diff line number Diff line change
Expand Up @@ -235,6 +235,19 @@ func (set *MatchSet) Matches() []*Match {
return matches
}

// HighLowRates gets the highest and lowest rate from all matches.
func (set *MatchSet) HighLowRates() (high uint64, low uint64) {
for _, rate := range set.Rates {
if rate > high {
high = rate
}
if rate < low || low == 0 {
low = rate
}
}
return
}

func appendUint64Bytes(b []byte, i uint64) []byte {
iBytes := make([]byte, 8)
binary.BigEndian.PutUint64(iBytes, i)
Expand Down
1 change: 1 addition & 0 deletions go.mod
Original file line number Diff line number Diff line change
Expand Up @@ -34,5 +34,6 @@ require (
go.etcd.io/bbolt v1.3.5
golang.org/x/crypto v0.0.0-20200510223506-06a226fb4e37
golang.org/x/sync v0.0.0-20190423024810-112230192c58
golang.org/x/time v0.0.0-20200630173020-3af7569d3a1e
gopkg.in/ini.v1 v1.55.0
)
2 changes: 2 additions & 0 deletions go.sum
Original file line number Diff line number Diff line change
Expand Up @@ -195,6 +195,8 @@ golang.org/x/sys v0.0.0-20200202164722-d101bd2416d5/go.mod h1:h1NjWce9XRLGQEsW7w
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
golang.org/x/text v0.3.2 h1:tW2bmiBqwgJj/UpqtC8EpXEZVYOwU0yG4iWbprSVAcs=
golang.org/x/text v0.3.2/go.mod h1:bEr9sfX3Q8Zfm5fL9x+3itogRgK3+ptLWKqgva+5dAk=
golang.org/x/time v0.0.0-20200630173020-3af7569d3a1e h1:EHBhcS0mlXEAVwNyO2dLfjToGsyY4j24pTs2ScHnX7s=
golang.org/x/time v0.0.0-20200630173020-3af7569d3a1e/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ=
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
golang.org/x/tools v0.0.0-20190328211700-ab21143f2384/go.mod h1:LCzVGOaR6xXOjkQ3onu1FJEFr0SW1gC7cKk1uF8kGRs=
golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
Expand Down
237 changes: 237 additions & 0 deletions server/apidata/apidata.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,237 @@
// 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 apidata

import (
"context"
"encoding/json"
"fmt"
"sync"
"sync/atomic"
"time"

"decred.org/dcrdex/dex"
"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", "15m"}
// Our internal millisecond representation of the bin sizes.
binSizes []uint64
started uint32
)

// 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
}

// MarketSource is a source of market information. Markets are added after
// construction but before use using the AddMarketSource method.
type MarketSource interface {
EpochDuration() uint64
Base() uint32
Quote() uint32
}

// BookSource is a source of order book information. The BookSource is added
// after construction but before use.
type BookSource interface {
Book(mktName string) (*msgjson.OrderBook, error)
}

// DataAPI implement a data API backend. API data is
type DataAPI struct {
db DBSource
epochDurations map[string]uint64
bookSource BookSource

spotsMtx sync.RWMutex
spots map[string]json.RawMessage

cacheMtx sync.RWMutex
marketCaches map[string]map[uint64]*db.CandleCache
}

// NewDataAPI is the constructor for a new DataAPI.
func NewDataAPI(ctx context.Context, dbSrc DBSource) *DataAPI {
s := &DataAPI{
db: dbSrc,
epochDurations: make(map[string]uint64),
spots: make(map[string]json.RawMessage),
marketCaches: make(map[string]map[uint64]*db.CandleCache),
}

if atomic.CompareAndSwapUint32(&started, 0, 1) {
comms.RegisterHTTP(msgjson.SpotsRoute, s.handleSpots)
comms.RegisterHTTP(msgjson.CandlesRoute, s.handleCandles)
comms.RegisterHTTP(msgjson.OrderBookRoute, s.handleOrderBook)
}
return s
}

// AddMarketSource should be called before any markets are running.
func (s *DataAPI) AddMarketSource(mkt MarketSource) error {
mktName, err := dex.MarketName(mkt.Base(), mkt.Quote())
if err != nil {
return err
}
epochDur := mkt.EpochDuration()
s.epochDurations[mktName] = epochDur
binCaches := make(map[uint64]*db.CandleCache, len(binSizes)+1)
s.marketCaches[mktName] = binCaches
cacheList := make([]*db.CandleCache, 0, len(binSizes)+1)
for _, binSize := range append([]uint64{epochDur}, binSizes...) {
cache := db.NewCandleCache(CacheSize, binSize)
cacheList = append(cacheList, cache)
binCaches[binSize] = cache
}
err = s.db.LoadEpochStats(mkt.Base(), mkt.Quote(), cacheList)
if err != nil {
return err
}
return nil
}

// SetBookSource should be called before the first call to handleBook.
func (s *DataAPI) SetBookSource(bs BookSource) {
s.bookSource = bs
}

// ReportEpoch should be called by every Market after every match cycle to
// report their epoch stats.
func (s *DataAPI) ReportEpoch(base, quote uint32, epochIdx uint64, stats *matcher.MatchCycleStats) error {
mktName, err := dex.MarketName(base, quote)
if err != nil {
return err
}

// Add the candlestick.
s.cacheMtx.Lock()
mktCaches := s.marketCaches[mktName]
if mktCaches == nil {
s.cacheMtx.Unlock()
return fmt.Errorf("unkown market %q", mktName)
}
epochDur := s.epochDurations[mktName]
startStamp := epochIdx * epochDur
endStamp := startStamp + epochDur
for _, cache := range mktCaches {
cache.Add(&db.Candle{
StartStamp: startStamp,
EndStamp: endStamp,
MatchVolume: stats.MatchVolume,
BookVolume: stats.BookVolume,
OrderVolume: stats.OrderVolume,
HighRate: stats.HighRate,
LowRate: stats.LowRate,
StartRate: stats.StartRate,
EndRate: stats.EndRate,
})
}
s.cacheMtx.Unlock()

// Encode the spot price.
s.spotsMtx.Lock()
s.spots[mktName], err = json.Marshal(msgjson.Spot{
Stamp: encode.UnixMilliU(time.Now()),
BaseID: base,
QuoteID: quote,
Rate: stats.EndRate,
BookVolume: stats.BookVolume,
})
s.spotsMtx.Unlock()
return err
}

// handleSpots implements comms.HTTPHandler for the /spots endpoint.
func (s *DataAPI) handleSpots(interface{}) (interface{}, error) {
s.spotsMtx.RLock()
defer s.spotsMtx.RUnlock()
spots := make([]json.RawMessage, 0, len(s.spots))
for _, spot := range s.spots {
spots = append(spots, spot)
}
return spots, nil
}

// handleCandles implements comms.HTTPHandler for the /candles endpoints.
func (s *DataAPI) handleCandles(thing interface{}) (interface{}, error) {
req, ok := thing.(*msgjson.CandlesRequest)
if !ok {
return nil, fmt.Errorf("candles request unparseable")
}

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)
}

mkt, err := dex.MarketName(req.BaseID, req.QuoteID)
if err != nil {
return nil, fmt.Errorf("error parsing market for %d - %d", req.BaseID, req.QuoteID)
}

binSizeDuration, err := time.ParseDuration(req.BinSize)
if err != nil {
return nil, fmt.Errorf("error parsing binSize")
}
binSize := uint64(binSizeDuration / time.Millisecond)

s.cacheMtx.RLock()
defer s.cacheMtx.RUnlock()
marketCaches := s.marketCaches[mkt]
if marketCaches == nil {
return nil, fmt.Errorf("market %s not known", mkt)
}

cache := marketCaches[binSize]
if cache == nil {
return nil, fmt.Errorf("no data available for binSize %s", req.BinSize)
}

return cache.WireCandles(req.NumCandles), nil
}

// handleOrderBook implements comms.HTTPHandler for the /orderbook endpoints.
func (s *DataAPI) handleOrderBook(thing interface{}) (interface{}, error) {
req, ok := thing.(*msgjson.OrderBookSubscription)
if !ok {
return nil, fmt.Errorf("orderbook request unparseable")
}

mkt, err := dex.MarketName(req.Base, req.Quote)
if err != nil {
return nil, fmt.Errorf("can't parse requested market")
}
return s.bookSource.Book(mkt)
}

func init() {
for _, s := range BinSizes {
dur, err := time.ParseDuration(s)
if err != nil {
panic("error parsing bin size '" + s + "': " + err.Error())
}
binSizes = append(binSizes, uint64(dur/time.Millisecond))
}
}
Loading

0 comments on commit 442af83

Please sign in to comment.