diff --git a/cmd/dcrdata/public/images/coinex-logo.svg b/cmd/dcrdata/public/images/coinex-logo.svg new file mode 100644 index 000000000..927f593a6 --- /dev/null +++ b/cmd/dcrdata/public/images/coinex-logo.svg @@ -0,0 +1,12 @@ + + + + + + + + + + + + diff --git a/cmd/dcrdata/public/images/kucoin-logo.svg b/cmd/dcrdata/public/images/kucoin-logo.svg new file mode 100644 index 000000000..c344b6233 --- /dev/null +++ b/cmd/dcrdata/public/images/kucoin-logo.svg @@ -0,0 +1,7 @@ + + + + + + + diff --git a/cmd/dcrdata/public/js/controllers/market_controller.js b/cmd/dcrdata/public/js/controllers/market_controller.js index 6070ea9d8..55c98f5aa 100644 --- a/cmd/dcrdata/public/js/controllers/market_controller.js +++ b/cmd/dcrdata/public/js/controllers/market_controller.js @@ -33,12 +33,15 @@ const prettyDurations = { } const exchangeLinks = { CurrencyPairDCRBTC: { - dcrdex: 'https://dex.decred.org' + dcrdex: 'https://dex.decred.org', + coinex: 'https://www.coinex.com/en/exchange/DCR-BTC' }, CurrencyPairDCRUSDT: { binance: 'https://www.binance.com/en/trade/DCR_USDT', dcrdex: 'https://dex.decred.org', - mexc: 'https://www.mexc.com/exchange/DCR_USDT' + mexc: 'https://www.mexc.com/exchange/DCR_USDT', + kucoin: 'https://www.kucoin.com/trade/DCR-USDT', + coinex: 'https://www.coinex.com/en/exchange/DCR-USDT' } } const CurrencyPairDCRUSDT = 'DCR-USDT' diff --git a/cmd/dcrdata/public/scss/icons.scss b/cmd/dcrdata/public/scss/icons.scss index eac3f27cd..bc7b53c3b 100644 --- a/cmd/dcrdata/public/scss/icons.scss +++ b/cmd/dcrdata/public/scss/icons.scss @@ -168,3 +168,11 @@ .exchange-logo.mexc { background: url("/images/mexc-logo.svg") no-repeat; } + +.exchange-logo.kucoin { + background: url("/images/kucoin-logo.svg") no-repeat; +} + +.exchange-logo.coinex { + background: url("/images/coinex-logo.svg") no-repeat; +} diff --git a/cmd/dcrdata/views/extras.tmpl b/cmd/dcrdata/views/extras.tmpl index 1e7395bbb..3c7e16f72 100644 --- a/cmd/dcrdata/views/extras.tmpl +++ b/cmd/dcrdata/views/extras.tmpl @@ -67,7 +67,7 @@ - + @@ -193,7 +193,7 @@ data-turbolinks-suppress-warning > diff --git a/exchanges/bot.go b/exchanges/bot.go index 5bcdd5cf6..f650d3ec4 100644 --- a/exchanges/bot.go +++ b/exchanges/bot.go @@ -819,9 +819,8 @@ func (bot *ExchangeBot) cachedChartVersion(chartId string) int { // processState is a helper function to process a slice of ExchangeState into a // price, and optionally a volume sum, and perform some cleanup along the way. -// If volumeAveraged is false, all exchanges are given equal weight in the avg. // If exchange is invalid, a bool false is returned as a last return value. -func (bot *ExchangeBot) processState(token, code string, states map[CurrencyPair]*ExchangeState, volumeAveraged bool) (float64, float64, bool) { +func (bot *ExchangeBot) processState(token, code string, states map[CurrencyPair]*ExchangeState) (float64, float64, bool) { oldestValid := time.Now().Add(-bot.RequestExpiry) if bot.Exchanges[token].LastUpdate().Before(oldestValid) { return 0, 0, false @@ -829,12 +828,6 @@ func (bot *ExchangeBot) processState(token, code string, states map[CurrencyPair var priceAccumulator, volSum float64 for currencyPair, state := range states { - volume := 1.0 - if volumeAveraged { - volume = state.Volume - } - volSum += volume - // Convert price to bot.Index. price := state.Price switch currencyPair { @@ -843,13 +836,13 @@ func (bot *ExchangeBot) processState(token, code string, states map[CurrencyPair case CurrencyPairDCRUSDT: price = bot.indexPrice(USDTIndex, code) * price } - if price == 0 { // missing index price for currencyPair. - return 0, 0, false + if price == 0 { + continue // missing index price for currencyPair so let's skip this one. } - priceAccumulator += volume * price + volSum += state.Volume + priceAccumulator += state.Volume * price } - if volSum == 0 { return 0, 0, true } @@ -927,7 +920,7 @@ func (bot *ExchangeBot) updateIndices(update *IndexUpdate) error { func (bot *ExchangeBot) dcrPriceAndVolume(code string) (float64, float64) { var dcrPrice, volume, nSources float64 for token, xcStates := range bot.currentState.DCRExchanges { - processedDcrPrice, processedVolume, ok := bot.processState(token, code, xcStates, true) + processedDcrPrice, processedVolume, ok := bot.processState(token, code, xcStates) if !ok { continue } diff --git a/exchanges/exchanges.go b/exchanges/exchanges.go index f90fdbf19..8e57a177d 100644 --- a/exchanges/exchanges.go +++ b/exchanges/exchanges.go @@ -31,6 +31,8 @@ const ( Binance = "binance" DexDotDecred = "dcrdex" Mexc = "mexc" + Kucoin = "kucoin" + CoinEx = "coinex" ) // A few candlestick bin sizes. @@ -163,6 +165,54 @@ var ( }, }, } + KucoinURLS = URLs{ + Markets: []CurrencyPair{CurrencyPairDCRUSDT}, + Price: map[CurrencyPair]string{ + CurrencyPairDCRUSDT: "https://api.kucoin.com/api/v1/market/stats?symbol=DCR-USDT", + }, + Depth: map[CurrencyPair]string{ + // This API will return data with partial orderbook. The full + // orderbook API requires an API key, see: + // https://www.kucoin.com/docs-new/rest/spot-trading/market-data/get-full-orderbook + CurrencyPairDCRUSDT: "https://api.kucoin.com/api/v1/market/orderbook/level2_100?symbol=DCR-USDT", + }, + Candlesticks: map[CurrencyPair]map[candlestickKey]string{ + CurrencyPairDCRUSDT: { + // For each query, the system would return at most 1500 pieces + // of data. Read more: + // https://www.kucoin.com/docs-new/rest/spot-trading/market-data/get-klines + hourKey: "https://api.kucoin.com/api/v1/market/candles?symbol=DCR-USDT&type=1hour", + dayKey: "https://api.kucoin.com/api/v1/market/candles?symbol=DCR-USDT&type=1day", + monthKey: "https://api.kucoin.com/api/v1/market/candles?symbol=DCR-USDT&type=1month", + }, + }, + } + CoinExURLs = URLs{ + Markets: []CurrencyPair{CurrencyPairDCRUSDT, CurrencyPairDCRBTC}, + Price: map[CurrencyPair]string{ + CurrencyPairDCRUSDT: "https://api.coinex.com/v2/spot/ticker?market=DCRUSDT", + CurrencyPairDCRBTC: "https://api.coinex.com/v2/spot/ticker?market=DCRBTC", + }, + Depth: map[CurrencyPair]string{ + // This API will return data with partial orderbook (max 50). + CurrencyPairDCRUSDT: "https://api.coinex.com/v2/spot/depth?market=DCRUSDT&limit=50&interval=0.00000001", + CurrencyPairDCRBTC: "https://api.coinex.com/v2/spot/depth?market=DCRBTC&limit=50&interval=0.00000001", + }, + Candlesticks: map[CurrencyPair]map[candlestickKey]string{ + // For each query, the system would return at most 1000 pieces + // of data. Default of 100 max if no limit is specified. Read + // more: + // https://docs.coinex.com/api/v2/spot/market/http/list-market-kline + CurrencyPairDCRUSDT: { + hourKey: "https://api.coinex.com/v2/spot/kline?market=DCRUSDT&limit=1000&period=1hour", + dayKey: "https://api.coinex.com/v2/spot/kline?market=DCRUSDT&limit=1000&period=1day", + }, + CurrencyPairDCRBTC: { + hourKey: "https://api.coinex.com/v2/spot/kline?market=DCRBTC&limit=1000&period=1hour", + dayKey: "https://api.coinex.com/v2/spot/kline?market=DCRBTC&limit=1000&period=1day", + }, + }, + } ) // Indices maps tokens to constructors for {BTC, USDT}-fiat exchanges. @@ -179,7 +229,9 @@ var DcrExchanges = map[string]func(*http.Client, *BotChannels) (Exchange, error) Cert: core.CertStore[dex.Mainnet]["dex.decred.org:7232"], CertHost: "dex.decred.org", }), - Mexc: NewMexc, + Mexc: NewMexc, + Kucoin: NewKucoin, + CoinEx: NewCoinEx, } // IsIndex checks whether the given token is a known {Bitcoin, USDT} index, as @@ -288,7 +340,7 @@ func (sticks Candlesticks) needsUpdate(bin candlestickKey) bool { type BaseState struct { Price float64 `json:"price"` // BaseVolume is poorly named. This is the volume in terms of (usually) BTC - // or USDT, not the base asset of any particular market. + // or USDT, not the base asset of any particular market. TODO: Rename. BaseVolume float64 `json:"base_volume,omitempty"` Volume float64 `json:"volume,omitempty"` Change float64 `json:"change,omitempty"` @@ -976,8 +1028,8 @@ type BinancePriceResponse struct { // "0.01575800", // Low // "0.01577100", // Close // "148976.11427815", // Volume -// 1640804940000, // Close Time (Mexc Only) -// "168387.3" // Quote Asset Volume (Mexc Only) +// 1640804940000, // Close Time +// "168387.3" // Quote Asset Volume // ] // // ] @@ -991,7 +1043,7 @@ func badStickElement(key string, element interface{}) Candlesticks { func (r CandlestickResponse) translate() Candlesticks { sticks := make(Candlesticks, 0, len(r)) for _, rawStick := range r { - if len(rawStick) < 6 { + if len(rawStick) < 7 { log.Error("Unable to decode candlestick response. Not enough elements.") return Candlesticks{} } @@ -1884,3 +1936,499 @@ func (mexc *MexcExchange) refresh(pair CurrencyPair, requests *requests) { Depth: depth, }) } + +// KucoinExchange is a high-volume and well-known crypto exchange. +type KucoinExchange struct { + *CommonExchange +} + +// NewKucoin constructs a *KucoinExchange. +func NewKucoin(client *http.Client, channels *BotChannels) (kucoin Exchange, err error) { + reqs := newRequests(KucoinURLS.Markets) + for mkt, price := range KucoinURLS.Price { + reqs[mkt].price, err = http.NewRequest(http.MethodGet, price, nil) + if err != nil { + return + } + } + + for mkt, depth := range KucoinURLS.Depth { + reqs[mkt].depth, err = http.NewRequest(http.MethodGet, depth, nil) + if err != nil { + return + } + } + + for mkt, candlesticks := range KucoinURLS.Candlesticks { + for dur, url := range candlesticks { + reqs[mkt].candlesticks[dur], err = http.NewRequest(http.MethodGet, url, nil) + if err != nil { + return + } + } + } + + kucoin = &KucoinExchange{ + CommonExchange: newCommonExchange(Kucoin, client, reqs, channels), + } + return +} + +// KucoinPriceResponse models the JSON price data returned from the Kucoin API. +type KucoinPriceResponse struct { + // Code string `json:"code"` + Data struct { + Time int64 `json:"time"` + Symbol string `json:"symbol"` + ChangePrice string `json:"changePrice"` + Vol string `json:"vol"` + VolValue string `json:"volValue"` + Last string `json:"last"` + + // These are unused fields, but left commented since they are part of the original schema. + // Sequence string `json:"sequence"` + // Buy string `json:"buy"` + // Sell string `json:"sell"` + // ChangeRate string `json:"changeRate"` + // High string `json:"high"` + // Low string `json:"low"` + // AveragePrice string `json:"averagePrice"` + // TakerFeeRate string `json:"takerFeeRate"` + // MakerFeeRate string `json:"makerFeeRate"` + // TakerCoefficient string `json:"takerCoefficient"` + // MakerCoefficient string `json:"makerCoefficient"` + } `json:"data"` +} + +// KucoinCandlestickResponse models candlestick data returned from the Kucoin +// API. The candlestick response Sample response is [ +// +// [ +// "1745020800", // Start time of the candle cycle +// "11.87", // Open +// "11.87", // Close +// "11.87", // High +// "11.87", // Low +// "0.15", // Volume +// "1.836289", // Volume in Quote Asset +// ] +// +// ] +type KucoinCandlestickResponse struct { + // Code string `json:"code"` + Data [][]interface{} `json:"data"` +} + +func (r KucoinCandlestickResponse) translate() Candlesticks { + sticks := make(Candlesticks, 0, len(r.Data)) + for _, rawStick := range r.Data { + if len(rawStick) < 7 { + log.Error("Unable to decode candlestick response. Not enough elements.") + return Candlesticks{} + } + unixMsStr, ok := rawStick[0].(string) + if !ok { + return badStickElement("start time", rawStick[0]) + } + + unixMsFlt, err := strconv.Atoi(unixMsStr) + if err != nil { + return badStickElement("start time", err) + } + startTime := time.Unix(int64(unixMsFlt/1e3), 0) + + openStr, ok := rawStick[1].(string) + if !ok { + return badStickElement("open", rawStick[1]) + } + open, err := strconv.ParseFloat(openStr, 64) + if err != nil { + return badStickElement("open float", err) + } + + closeStr, ok := rawStick[2].(string) + if !ok { + return badStickElement("close", rawStick[4]) + } + close, err := strconv.ParseFloat(closeStr, 64) + if err != nil { + return badStickElement("close float", err) + } + + highStr, ok := rawStick[3].(string) + if !ok { + return badStickElement("high", rawStick[2]) + } + high, err := strconv.ParseFloat(highStr, 64) + if err != nil { + return badStickElement("high float", err) + } + + lowStr, ok := rawStick[4].(string) + if !ok { + return badStickElement("low", rawStick[3]) + } + low, err := strconv.ParseFloat(lowStr, 64) + if err != nil { + return badStickElement("low float", err) + } + + volumeStr, ok := rawStick[5].(string) + if !ok { + return badStickElement("volume", rawStick[5]) + } + volume, err := strconv.ParseFloat(volumeStr, 64) + if err != nil { + return badStickElement("volume float", err) + } + + sticks = append(sticks, Candlestick{ + High: high, + Low: low, + Open: open, + Close: close, + Volume: volume, + Start: startTime, + }) + } + return sticks +} + +// KucoinDepthResponse models the response for Kucoin depth chart data. +type KucoinDepthResponse struct { + // Code string `json:"code"` + Data struct { + // These are unused fields, but left commented since they are part of the original schema. + // Time int64 `json:"time"` used + // Sequence string `json:"sequence"` used + Bids [][2]string + Asks [][2]string + } `json:"data"` +} + +// Refresh retrieves and parses API data from Kucoin. +func (kucoin *KucoinExchange) Refresh() { + kucoin.LogRequest() + for mkt, requests := range kucoin.requests { + kucoin.refresh(mkt, requests) + } +} + +func (kucoin *KucoinExchange) refresh(mkt CurrencyPair, requests *requests) { + priceResponse := new(KucoinPriceResponse) + err := kucoin.fetch(requests.price, priceResponse) + if err != nil { + kucoin.fail(fmt.Sprintf("%s: Fetch price", mkt), err) + return + } + price, err := strconv.ParseFloat(priceResponse.Data.Last, 64) + if err != nil { + kucoin.fail(fmt.Sprintf("%s: Failed to parse float from Data.Last=%s", mkt, priceResponse.Data.Last), err) + return + } + baseVolume, err := strconv.ParseFloat(priceResponse.Data.VolValue, 64) + if err != nil { + kucoin.fail(fmt.Sprintf("%s: Failed to parse float from Data.VolValue=%s", mkt, priceResponse.Data.VolValue), err) + return + } + + dcrVolume, err := strconv.ParseFloat(priceResponse.Data.Vol, 64) + if err != nil { + kucoin.fail(fmt.Sprintf("%s: Failed to parse float from Data.Vol=%s", mkt, priceResponse.Data.Vol), err) + return + } + priceChange, err := strconv.ParseFloat(priceResponse.Data.ChangePrice, 64) + if err != nil { + kucoin.fail(fmt.Sprintf("%s: Failed to parse float from Data.ChangePrice=%s", mkt, priceResponse.Data.ChangePrice), err) + return + } + + // Get the depth chart + depthResponse := new(KucoinDepthResponse) + err = kucoin.fetch(requests.depth, depthResponse) + if err != nil { + log.Errorf("Error retrieving depth chart data from Binance(%s): %v", mkt, err) + } + depth := translateDepthPoints(Kucoin, depthResponse.Data.Asks, depthResponse.Data.Bids) + + // Grab the current state to check if candlesticks need updating + state := kucoin.state(mkt) + + candlesticks := map[candlestickKey]Candlesticks{} + for bin, req := range requests.candlesticks { + oldSticks, found := state.Candlesticks[bin] + if !found || oldSticks.needsUpdate(bin) { + log.Tracef("Signalling candlestick update for %s, market %s, bin size %s", kucoin.token, mkt, bin) + response := new(KucoinCandlestickResponse) + err := kucoin.fetch(req, response) + if err != nil { + log.Errorf("Error retrieving candlestick data from kucoin for bin size %s: %v", string(bin), err) + continue + } + sticks := response.translate() + + if !found || sticks.time().After(oldSticks.time()) { + candlesticks[bin] = sticks + } + } + } + + kucoin.Update(mkt, &ExchangeState{ + BaseState: BaseState{ + Price: price, + BaseVolume: baseVolume, + Volume: dcrVolume, + Change: priceChange, + Stamp: priceResponse.Data.Time / 1000, + }, + Candlesticks: candlesticks, + Depth: depth, + }) +} + +// CoinExchange is a global and well-known crypto exchange. +type CoinExchange struct { + *CommonExchange +} + +// NewCoinEx constructs a *CoinExchange. +func NewCoinEx(client *http.Client, channels *BotChannels) (coinEx Exchange, err error) { + reqs := newRequests(CoinExURLs.Markets) + for mkt, price := range CoinExURLs.Price { + reqs[mkt].price, err = http.NewRequest(http.MethodGet, price, nil) + if err != nil { + return + } + } + + for mkt, depth := range CoinExURLs.Depth { + reqs[mkt].depth, err = http.NewRequest(http.MethodGet, depth, nil) + if err != nil { + return + } + } + + for mkt, candlesticks := range CoinExURLs.Candlesticks { + for dur, url := range candlesticks { + reqs[mkt].candlesticks[dur], err = http.NewRequest(http.MethodGet, url, nil) + if err != nil { + return + } + } + } + + coinEx = &CoinExchange{ + CommonExchange: newCommonExchange(CoinEx, client, reqs, channels), + } + return +} + +// CoinExPriceResponse models the JSON price data returned from the CoinEx API. +type CoinExPriceResponse struct { + // Code int64 `json:"code"` + Data []struct { + Close string `json:"close"` + High string `json:"high"` + Last string `json:"last"` + Low string `json:"low"` + Open string `json:"open"` + Value string `json:"value"` + Volume string `json:"volume"` + + // These are unused fields, but left commented since they are part of the + // original schema. + // Market string `json:"market"` + // Period int64 `json:"period"` + // VolumeBuy string `json:"volume_buy"` + // VolumeSell string `json:"volume_sell"` + } `json:"data"` + // Message string `json:"message"` +} + +// CoinExCandlestickResponse models candlestick data returned from the CoinEx +// API. The candlestick response has mixed-type arrays, so type-checking is +// appropriate. Sample response is: +// +// { +// "code": 0, +// "data": [ +// { +// "close": "23.6538", +// "created_at": 1658793600000, +// "high": "24.4502", +// "low": "22.652", +// "market": "DCRUSDT", +// "open": "24.1441", +// "value": "4164.677065377772", +// "volume": "175.75783949" +// }, +// } +type CoinExCandlestickResponse struct { + // Code int64 `json:"code"` + Data []struct { + Close string `json:"close"` + CreatedAt int64 `json:"created_at"` + High string `json:"high"` + Low string `json:"low"` + Open string `json:"open"` + Volume string `json:"volume"` + + // These are unused fields, but left commented since they are part of the + // original schema. + // Market string `json:"market"` + // Value string `json:"value"` + } `json:"data"` +} + +func (r CoinExCandlestickResponse) translate() Candlesticks { + sticks := make(Candlesticks, 0, len(r.Data)) + for _, rawStick := range r.Data { + startTime := time.Unix(rawStick.CreatedAt/1e3, 0) + + open, err := strconv.ParseFloat(rawStick.Open, 64) + if err != nil { + return badStickElement("open float", err) + } + + high, err := strconv.ParseFloat(rawStick.High, 64) + if err != nil { + return badStickElement("high float", err) + } + + low, err := strconv.ParseFloat(rawStick.Low, 64) + if err != nil { + return badStickElement("low float", err) + } + + close, err := strconv.ParseFloat(rawStick.Close, 64) + if err != nil { + return badStickElement("close float", err) + } + + volume, err := strconv.ParseFloat(rawStick.Volume, 64) + if err != nil { + return badStickElement("volume float", err) + } + + sticks = append(sticks, Candlestick{ + High: high, + Low: low, + Open: open, + Close: close, + Volume: volume, + Start: startTime, + }) + } + return sticks +} + +// CoinExDepthResponse models the response for CoinEx depth chart data. +type CoinExDepthResponse struct { + Code int64 + Data struct { + Depth struct { + Bids [][2]string `json:"bids"` + Asks [][2]string `json:"asks"` + } `json:"depth"` + + // These are unused fields, but left commented since they are part of the + // original schema. + // Last string `json:"last"` + // UpdatedAt int64 `json:"updated_at"` + // Checksum string `json:"checksum"` + } `json:"data"` + + // These are unused fields, but left commented since they are part of the + // original schema. + // IsFull bool `json:"is_full"` + // Market string `json:"market"` +} + +// Refresh retrieves and parses API data from CoinEx. +func (coinex *CoinExchange) Refresh() { + coinex.LogRequest() + for mkt, requests := range coinex.requests { + coinex.refresh(mkt, requests) + } +} + +func (coinex *CoinExchange) refresh(mkt CurrencyPair, requests *requests) { + priceResponseBody := new(CoinExPriceResponse) + err := coinex.fetch(requests.price, priceResponseBody) + if err != nil { + coinex.fail(fmt.Sprintf("%s: Fetch price", mkt), err) + return + } + + priceResponse := priceResponseBody.Data[0] + price, err := strconv.ParseFloat(priceResponse.Last, 64) + if err != nil { + coinex.fail(fmt.Sprintf("%s: Failed to parse float from Last=%s", mkt, priceResponse.Last), err) + return + } + baseVolume, err := strconv.ParseFloat(priceResponse.Value, 64) + if err != nil { + coinex.fail(fmt.Sprintf("%s: Failed to parse float from Value=%s", mkt, priceResponse.Value), err) + return + } + + dcrVolume, err := strconv.ParseFloat(priceResponse.Volume, 64) + if err != nil { + coinex.fail(fmt.Sprintf("%s: Failed to parse float from Volume=%s", mkt, priceResponse.Volume), err) + return + } + + open, err := strconv.ParseFloat(priceResponse.Open, 64) + if err != nil { + coinex.fail(fmt.Sprintf("%s: Failed to parse float from High=%s", mkt, priceResponse.High), err) + return + } + + close, err := strconv.ParseFloat(priceResponse.Close, 64) + if err != nil { + coinex.fail(fmt.Sprintf("%s: Failed to parse float from Close=%s", mkt, priceResponse.Close), err) + return + } + + // Get the depth chart + depthResponse := new(CoinExDepthResponse) + err = coinex.fetch(requests.depth, depthResponse) + if err != nil { + log.Errorf("Error retrieving depth chart data from CoinEx(%s): %v", mkt, err) + } + depth := translateDepthPoints(CoinEx, depthResponse.Data.Depth.Asks, depthResponse.Data.Depth.Bids) + + // Grab the current state to check if candlesticks need updating. + state := coinex.state(mkt) + + candlesticks := map[candlestickKey]Candlesticks{} + for bin, req := range requests.candlesticks { + oldSticks, found := state.Candlesticks[bin] + if !found || oldSticks.needsUpdate(bin) { + log.Tracef("Signalling candlestick update for %s, market %s, bin size %s", coinex.token, mkt, bin) + response := new(CoinExCandlestickResponse) + err := coinex.fetch(req, response) + if err != nil { + log.Errorf("Error retrieving candlestick data from CoinEx for bin size %s: %v", string(bin), err) + continue + } + sticks := response.translate() + + if !found || sticks.time().After(oldSticks.time()) { + candlesticks[bin] = sticks + } + } + } + + coinex.Update(mkt, &ExchangeState{ + BaseState: BaseState{ + Price: price, + BaseVolume: baseVolume, + Volume: dcrVolume, + Change: close - open, + Stamp: time.Now().Unix(), + }, + Candlesticks: candlesticks, + Depth: depth, + }) +}