Fiat rates refactor, fetch rates for tokens

This commit is contained in:
Martin Boehm 2022-06-15 09:32:48 +02:00 committed by Martin
parent db91824dc3
commit e0be8aa400
36 changed files with 1118 additions and 432 deletions

View File

@ -331,7 +331,7 @@ type BalanceHistory struct {
ReceivedSat *Amount `json:"received"` ReceivedSat *Amount `json:"received"`
SentSat *Amount `json:"sent"` SentSat *Amount `json:"sent"`
SentToSelfSat *Amount `json:"sentToSelf"` SentToSelfSat *Amount `json:"sentToSelf"`
FiatRates map[string]float64 `json:"rates,omitempty"` FiatRates map[string]float32 `json:"rates,omitempty"`
Txid string `json:"txid,omitempty"` Txid string `json:"txid,omitempty"`
} }
@ -468,3 +468,22 @@ type MempoolTxids struct {
Mempool []MempoolTxid `json:"mempool"` Mempool []MempoolTxid `json:"mempool"`
MempoolSize int `json:"mempoolSize"` MempoolSize int `json:"mempoolSize"`
} }
// FiatTicker contains formatted CurrencyRatesTicker data
type FiatTicker struct {
Timestamp int64 `json:"ts,omitempty"`
Rates map[string]float32 `json:"rates"`
Error string `json:"error,omitempty"`
}
// FiatTickers contains a formatted CurrencyRatesTicker list
type FiatTickers struct {
Tickers []FiatTicker `json:"tickers"`
}
// AvailableVsCurrencies contains formatted data about available versus currencies for exchange rates
type AvailableVsCurrencies struct {
Timestamp int64 `json:"ts,omitempty"`
Tickers []string `json:"available_currencies"`
Error string `json:"error,omitempty"`
}

View File

@ -1342,7 +1342,8 @@ func (w *Worker) setFiatRateToBalanceHistories(histories BalanceHistories, curre
for i := range histories { for i := range histories {
bh := &histories[i] bh := &histories[i]
t := time.Unix(int64(bh.Time), 0) t := time.Unix(int64(bh.Time), 0)
ticker, err := w.db.FiatRatesFindTicker(&t) // TODO
ticker, err := w.db.FiatRatesFindTicker(&t, "", "")
if err != nil { if err != nil {
glog.Errorf("Error finding ticker by date %v. Error: %v", t, err) glog.Errorf("Error finding ticker by date %v. Error: %v", t, err)
continue continue
@ -1352,7 +1353,7 @@ func (w *Worker) setFiatRateToBalanceHistories(histories BalanceHistories, curre
if len(currencies) == 0 { if len(currencies) == 0 {
bh.FiatRates = ticker.Rates bh.FiatRates = ticker.Rates
} else { } else {
rates := make(map[string]float64) rates := make(map[string]float32)
for _, currency := range currencies { for _, currency := range currencies {
currency = strings.ToLower(currency) currency = strings.ToLower(currency)
if rate, found := ticker.Rates[currency]; found { if rate, found := ticker.Rates[currency]; found {
@ -1593,17 +1594,17 @@ func removeEmpty(stringSlice []string) []string {
} }
// getFiatRatesResult checks if CurrencyRatesTicker contains all necessary data and returns formatted result // getFiatRatesResult checks if CurrencyRatesTicker contains all necessary data and returns formatted result
func (w *Worker) getFiatRatesResult(currencies []string, ticker *db.CurrencyRatesTicker) (*db.ResultTickerAsString, error) { func (w *Worker) getFiatRatesResult(currencies []string, ticker *db.CurrencyRatesTicker) (*FiatTicker, error) {
currencies = removeEmpty(currencies) currencies = removeEmpty(currencies)
if len(currencies) == 0 { if len(currencies) == 0 {
// Return all available ticker rates // Return all available ticker rates
return &db.ResultTickerAsString{ return &FiatTicker{
Timestamp: ticker.Timestamp.UTC().Unix(), Timestamp: ticker.Timestamp.UTC().Unix(),
Rates: ticker.Rates, Rates: ticker.Rates,
}, nil }, nil
} }
// Check if currencies from the list are available in the ticker rates // Check if currencies from the list are available in the ticker rates
rates := make(map[string]float64) rates := make(map[string]float32)
for _, currency := range currencies { for _, currency := range currencies {
currency = strings.ToLower(currency) currency = strings.ToLower(currency)
if rate, found := ticker.Rates[currency]; found { if rate, found := ticker.Rates[currency]; found {
@ -1612,25 +1613,26 @@ func (w *Worker) getFiatRatesResult(currencies []string, ticker *db.CurrencyRate
rates[currency] = -1 rates[currency] = -1
} }
} }
return &db.ResultTickerAsString{ return &FiatTicker{
Timestamp: ticker.Timestamp.UTC().Unix(), Timestamp: ticker.Timestamp.UTC().Unix(),
Rates: rates, Rates: rates,
}, nil }, nil
} }
// GetFiatRatesForBlockID returns fiat rates for block height or block hash // GetFiatRatesForBlockID returns fiat rates for block height or block hash
func (w *Worker) GetFiatRatesForBlockID(bid string, currencies []string) (*db.ResultTickerAsString, error) { func (w *Worker) GetFiatRatesForBlockID(blockID string, currencies []string) (*FiatTicker, error) {
var ticker *db.CurrencyRatesTicker var ticker *db.CurrencyRatesTicker
bi, err := w.getBlockInfoFromBlockID(bid) bi, err := w.getBlockInfoFromBlockID(blockID)
if err != nil { if err != nil {
if err == bchain.ErrBlockNotFound { if err == bchain.ErrBlockNotFound {
return nil, NewAPIError(fmt.Sprintf("Block %v not found", bid), true) return nil, NewAPIError(fmt.Sprintf("Block %v not found", blockID), true)
} }
return nil, NewAPIError(fmt.Sprintf("Block %v not found, error: %v", bid, err), false) return nil, NewAPIError(fmt.Sprintf("Block %v not found, error: %v", blockID, err), false)
} }
dbi := &db.BlockInfo{Time: bi.Time} // get Unix timestamp from block dbi := &db.BlockInfo{Time: bi.Time} // get Unix timestamp from block
tm := time.Unix(dbi.Time, 0) // convert it to Time object tm := time.Unix(dbi.Time, 0) // convert it to Time object
ticker, err = w.db.FiatRatesFindTicker(&tm) // TODO
ticker, err = w.db.FiatRatesFindTicker(&tm, "", "")
if err != nil { if err != nil {
return nil, NewAPIError(fmt.Sprintf("Error finding ticker: %v", err), false) return nil, NewAPIError(fmt.Sprintf("Error finding ticker: %v", err), false)
} else if ticker == nil { } else if ticker == nil {
@ -1644,8 +1646,9 @@ func (w *Worker) GetFiatRatesForBlockID(bid string, currencies []string) (*db.Re
} }
// GetCurrentFiatRates returns last available fiat rates // GetCurrentFiatRates returns last available fiat rates
func (w *Worker) GetCurrentFiatRates(currencies []string) (*db.ResultTickerAsString, error) { func (w *Worker) GetCurrentFiatRates(currencies []string) (*FiatTicker, error) {
ticker, err := w.db.FiatRatesFindLastTicker() // TODO
ticker, err := w.db.FiatRatesFindLastTicker("", "")
if err != nil { if err != nil {
return nil, NewAPIError(fmt.Sprintf("Error finding ticker: %v", err), false) return nil, NewAPIError(fmt.Sprintf("Error finding ticker: %v", err), false)
} else if ticker == nil { } else if ticker == nil {
@ -1660,8 +1663,8 @@ func (w *Worker) GetCurrentFiatRates(currencies []string) (*db.ResultTickerAsStr
// makeErrorRates returns a map of currrencies, with each value equal to -1 // makeErrorRates returns a map of currrencies, with each value equal to -1
// used when there was an error finding ticker // used when there was an error finding ticker
func makeErrorRates(currencies []string) map[string]float64 { func makeErrorRates(currencies []string) map[string]float32 {
rates := make(map[string]float64) rates := make(map[string]float32)
for _, currency := range currencies { for _, currency := range currencies {
rates[strings.ToLower(currency)] = -1 rates[strings.ToLower(currency)] = -1
} }
@ -1669,28 +1672,29 @@ func makeErrorRates(currencies []string) map[string]float64 {
} }
// GetFiatRatesForTimestamps returns fiat rates for each of the provided dates // GetFiatRatesForTimestamps returns fiat rates for each of the provided dates
func (w *Worker) GetFiatRatesForTimestamps(timestamps []int64, currencies []string) (*db.ResultTickersAsString, error) { func (w *Worker) GetFiatRatesForTimestamps(timestamps []int64, currencies []string) (*FiatTickers, error) {
if len(timestamps) == 0 { if len(timestamps) == 0 {
return nil, NewAPIError("No timestamps provided", true) return nil, NewAPIError("No timestamps provided", true)
} }
currencies = removeEmpty(currencies) currencies = removeEmpty(currencies)
ret := &db.ResultTickersAsString{} ret := &FiatTickers{}
for _, timestamp := range timestamps { for _, timestamp := range timestamps {
date := time.Unix(timestamp, 0) date := time.Unix(timestamp, 0)
date = date.UTC() date = date.UTC()
ticker, err := w.db.FiatRatesFindTicker(&date) // TODO
ticker, err := w.db.FiatRatesFindTicker(&date, "", "")
if err != nil { if err != nil {
glog.Errorf("Error finding ticker for date %v. Error: %v", date, err) glog.Errorf("Error finding ticker for date %v. Error: %v", date, err)
ret.Tickers = append(ret.Tickers, db.ResultTickerAsString{Timestamp: date.Unix(), Rates: makeErrorRates(currencies)}) ret.Tickers = append(ret.Tickers, FiatTicker{Timestamp: date.Unix(), Rates: makeErrorRates(currencies)})
continue continue
} else if ticker == nil { } else if ticker == nil {
ret.Tickers = append(ret.Tickers, db.ResultTickerAsString{Timestamp: date.Unix(), Rates: makeErrorRates(currencies)}) ret.Tickers = append(ret.Tickers, FiatTicker{Timestamp: date.Unix(), Rates: makeErrorRates(currencies)})
continue continue
} }
result, err := w.getFiatRatesResult(currencies, ticker) result, err := w.getFiatRatesResult(currencies, ticker)
if err != nil { if err != nil {
ret.Tickers = append(ret.Tickers, db.ResultTickerAsString{Timestamp: date.Unix(), Rates: makeErrorRates(currencies)}) ret.Tickers = append(ret.Tickers, FiatTicker{Timestamp: date.Unix(), Rates: makeErrorRates(currencies)})
continue continue
} }
ret.Tickers = append(ret.Tickers, *result) ret.Tickers = append(ret.Tickers, *result)
@ -1698,12 +1702,13 @@ func (w *Worker) GetFiatRatesForTimestamps(timestamps []int64, currencies []stri
return ret, nil return ret, nil
} }
// GetFiatRatesTickersList returns the list of available fiatRates tickers // GetAvailableVsCurrencies returns the list of available versus currencies for exchange rates
func (w *Worker) GetFiatRatesTickersList(timestamp int64) (*db.ResultTickerListAsString, error) { func (w *Worker) GetAvailableVsCurrencies(timestamp int64) (*AvailableVsCurrencies, error) {
date := time.Unix(timestamp, 0) date := time.Unix(timestamp, 0)
date = date.UTC() date = date.UTC()
ticker, err := w.db.FiatRatesFindTicker(&date) // TODO
ticker, err := w.db.FiatRatesFindTicker(&date, "", "")
if err != nil { if err != nil {
return nil, NewAPIError(fmt.Sprintf("Error finding ticker: %v", err), false) return nil, NewAPIError(fmt.Sprintf("Error finding ticker: %v", err), false)
} else if ticker == nil { } else if ticker == nil {
@ -1716,7 +1721,7 @@ func (w *Worker) GetFiatRatesTickersList(timestamp int64) (*db.ResultTickerListA
} }
sort.Strings(keys) // sort to get deterministic results sort.Strings(keys) // sort to get deterministic results
return &db.ResultTickerListAsString{ return &AvailableVsCurrencies{
Timestamp: ticker.Timestamp.Unix(), Timestamp: ticker.Timestamp.Unix(),
Tickers: keys, Tickers: keys,
}, nil }, nil

View File

@ -688,9 +688,9 @@ func initDownloaders(db *db.RocksDB, chain bchain.BlockChain, configfile string)
} }
if config.FiatRates == "" || config.FiatRatesParams == "" { if config.FiatRates == "" || config.FiatRatesParams == "" {
glog.Infof("FiatRates config (%v) is empty, not downloading fiat rates.", configfile) glog.Infof("FiatRates config (%v) is empty, not downloading fiat rates", configfile)
} else { } else {
fiatRates, err := fiat.NewFiatRatesDownloader(db, config.FiatRates, config.FiatRatesParams, nil, onNewFiatRatesTicker) fiatRates, err := fiat.NewFiatRatesDownloader(db, config.FiatRates, config.FiatRatesParams, onNewFiatRatesTicker)
if err != nil { if err != nil {
glog.Errorf("NewFiatRatesDownloader Init error: %v", err) glog.Errorf("NewFiatRatesDownloader Init error: %v", err)
} else { } else {

View File

@ -53,6 +53,8 @@
"mempoolTxTimeoutHours": 12, "mempoolTxTimeoutHours": 12,
"processInternalTransactions": true, "processInternalTransactions": true,
"queryBackendOnMempoolResync": false, "queryBackendOnMempoolResync": false,
"fiat_rates": "coingecko",
"fiat_rates_params": "{\"url\": \"https://api.coingecko.com/api/v3\", \"coin\": \"ethereum\",\"platformIdentifier\": \"ethereum\",\"platformVsCurrency\": \"eth\",\"periodSeconds\": 900}",
"fourByteSignatures": "https://www.4byte.directory/api/v1/signatures/" "fourByteSignatures": "https://www.4byte.directory/api/v1/signatures/"
} }
} }

View File

@ -1,9 +1,13 @@
package db package db
import ( import (
"encoding/json" "encoding/binary"
"math"
"sync"
"time" "time"
vlq "github.com/bsm/go-vlq"
"github.com/flier/gorocksdb"
"github.com/golang/glog" "github.com/golang/glog"
"github.com/juju/errors" "github.com/juju/errors"
) )
@ -11,29 +15,83 @@ import (
// FiatRatesTimeFormat is a format string for storing FiatRates timestamps in rocksdb // FiatRatesTimeFormat is a format string for storing FiatRates timestamps in rocksdb
const FiatRatesTimeFormat = "20060102150405" // YYYYMMDDhhmmss const FiatRatesTimeFormat = "20060102150405" // YYYYMMDDhhmmss
var tickersMux sync.Mutex
var lastTickerInDB *CurrencyRatesTicker
var currentTicker *CurrencyRatesTicker
// CurrencyRatesTicker contains coin ticker data fetched from API // CurrencyRatesTicker contains coin ticker data fetched from API
type CurrencyRatesTicker struct { type CurrencyRatesTicker struct {
Timestamp *time.Time // return as unix timestamp in API Timestamp time.Time // return as unix timestamp in API
Rates map[string]float64 Rates map[string]float32 // rates of the base currency against a list of vs currencies
TokenRates map[string]float32 // rates of the tokens (identified by the address of the contract) against the base currency
} }
// ResultTickerAsString contains formatted CurrencyRatesTicker data func packTimestamp(t *time.Time) []byte {
type ResultTickerAsString struct { return []byte(t.UTC().Format(FiatRatesTimeFormat))
Timestamp int64 `json:"ts,omitempty"`
Rates map[string]float64 `json:"rates"`
Error string `json:"error,omitempty"`
} }
// ResultTickersAsString contains a formatted CurrencyRatesTicker list func packFloat32(buf []byte, n float32) int {
type ResultTickersAsString struct { binary.BigEndian.PutUint32(buf, math.Float32bits(n))
Tickers []ResultTickerAsString `json:"tickers"` return 4
} }
// ResultTickerListAsString contains formatted data about available currency tickers func unpackFloat32(buf []byte) (float32, int) {
type ResultTickerListAsString struct { return math.Float32frombits(binary.BigEndian.Uint32(buf)), 4
Timestamp int64 `json:"ts,omitempty"` }
Tickers []string `json:"available_currencies"`
Error string `json:"error,omitempty"` func packCurrencyRatesTicker(ticker *CurrencyRatesTicker) []byte {
buf := make([]byte, 0, 32)
varBuf := make([]byte, vlq.MaxLen64)
l := packVaruint(uint(len(ticker.Rates)), varBuf)
buf = append(buf, varBuf[:l]...)
for c, v := range ticker.Rates {
buf = append(buf, packString(c)...)
l = packFloat32(varBuf, v)
buf = append(buf, varBuf[:l]...)
}
l = packVaruint(uint(len(ticker.TokenRates)), varBuf)
buf = append(buf, varBuf[:l]...)
for c, v := range ticker.TokenRates {
buf = append(buf, packString(c)...)
l = packFloat32(varBuf, v)
buf = append(buf, varBuf[:l]...)
}
return buf
}
func unpackCurrencyRatesTicker(buf []byte) (*CurrencyRatesTicker, error) {
var (
ticker CurrencyRatesTicker
s string
l int
len uint
v float32
)
len, l = unpackVaruint(buf)
buf = buf[l:]
if len > 0 {
ticker.Rates = make(map[string]float32, len)
for i := 0; i < int(len); i++ {
s, l = unpackString(buf)
buf = buf[l:]
v, l = unpackFloat32(buf)
buf = buf[l:]
ticker.Rates[s] = v
}
}
len, l = unpackVaruint(buf)
buf = buf[l:]
if len > 0 {
ticker.TokenRates = make(map[string]float32, len)
for i := 0; i < int(len); i++ {
s, l = unpackString(buf)
buf = buf[l:]
v, l = unpackFloat32(buf)
buf = buf[l:]
ticker.TokenRates[s] = v
}
}
return &ticker, nil
} }
// FiatRatesConvertDate checks if the date is in correct format and returns the Time object. // FiatRatesConvertDate checks if the date is in correct format and returns the Time object.
@ -51,85 +109,131 @@ func FiatRatesConvertDate(date string) (*time.Time, error) {
} }
// FiatRatesStoreTicker stores ticker data at the specified time // FiatRatesStoreTicker stores ticker data at the specified time
func (d *RocksDB) FiatRatesStoreTicker(ticker *CurrencyRatesTicker) error { func (d *RocksDB) FiatRatesStoreTicker(wb *gorocksdb.WriteBatch, ticker *CurrencyRatesTicker) error {
if len(ticker.Rates) == 0 { if len(ticker.Rates) == 0 {
return errors.New("Error storing ticker: empty rates") return errors.New("Error storing ticker: empty rates")
} else if ticker.Timestamp == nil {
return errors.New("Error storing ticker: empty timestamp")
}
ratesMarshalled, err := json.Marshal(ticker.Rates)
if err != nil {
glog.Error("Error marshalling ticker rates: ", err)
return err
}
timeFormatted := ticker.Timestamp.UTC().Format(FiatRatesTimeFormat)
err = d.db.PutCF(d.wo, d.cfh[cfFiatRates], []byte(timeFormatted), ratesMarshalled)
if err != nil {
glog.Error("Error storing ticker: ", err)
return err
} }
wb.PutCF(d.cfh[cfFiatRates], packTimestamp(&ticker.Timestamp), packCurrencyRatesTicker(ticker))
return nil return nil
} }
// FiatRatesFindTicker gets FiatRates data closest to the specified timestamp func getTickerFromIterator(it *gorocksdb.Iterator, vsCurrency string, token string) (*CurrencyRatesTicker, error) {
func (d *RocksDB) FiatRatesFindTicker(tickerTime *time.Time) (*CurrencyRatesTicker, error) { timeObj, err := time.Parse(FiatRatesTimeFormat, string(it.Key().Data()))
ticker := &CurrencyRatesTicker{} if err != nil {
return nil, err
}
ticker, err := unpackCurrencyRatesTicker(it.Value().Data())
if err != nil {
return nil, err
}
if vsCurrency != "" {
if ticker.Rates == nil {
return nil, nil
}
if _, found := ticker.Rates[vsCurrency]; !found {
return nil, nil
}
}
if token != "" {
if ticker.TokenRates == nil {
return nil, nil
}
if _, found := ticker.TokenRates[token]; !found {
return nil, nil
}
}
ticker.Timestamp = timeObj.UTC()
return ticker, nil
}
// FiatRatesGetTicker gets FiatRates ticker at the specified timestamp if it exist
func (d *RocksDB) FiatRatesGetTicker(tickerTime *time.Time) (*CurrencyRatesTicker, error) {
tickerTimeFormatted := tickerTime.UTC().Format(FiatRatesTimeFormat)
val, err := d.db.GetCF(d.ro, d.cfh[cfFiatRates], []byte(tickerTimeFormatted))
if err != nil {
return nil, err
}
defer val.Free()
data := val.Data()
if len(data) == 0 {
return nil, nil
}
ticker, err := unpackCurrencyRatesTicker(data)
if err != nil {
return nil, err
}
ticker.Timestamp = tickerTime.UTC()
return ticker, nil
}
// FiatRatesFindTicker gets FiatRates data closest to the specified timestamp, of the base currency, vsCurrency or the token if specified
func (d *RocksDB) FiatRatesFindTicker(tickerTime *time.Time, vsCurrency string, token string) (*CurrencyRatesTicker, error) {
tickersMux.Lock()
if currentTicker != nil && lastTickerInDB != nil {
if tickerTime.After(lastTickerInDB.Timestamp) {
f := true
if token != "" && currentTicker.TokenRates != nil {
_, f = currentTicker.TokenRates[token]
}
if f {
tickersMux.Unlock()
return currentTicker, nil
}
}
}
tickersMux.Unlock()
tickerTimeFormatted := tickerTime.UTC().Format(FiatRatesTimeFormat) tickerTimeFormatted := tickerTime.UTC().Format(FiatRatesTimeFormat)
it := d.db.NewIteratorCF(d.ro, d.cfh[cfFiatRates]) it := d.db.NewIteratorCF(d.ro, d.cfh[cfFiatRates])
defer it.Close() defer it.Close()
for it.Seek([]byte(tickerTimeFormatted)); it.Valid(); it.Next() { for it.Seek([]byte(tickerTimeFormatted)); it.Valid(); it.Next() {
timeObj, err := time.Parse(FiatRatesTimeFormat, string(it.Key().Data())) ticker, err := getTickerFromIterator(it, vsCurrency, token)
if err != nil { if err != nil {
glog.Error("FiatRatesFindTicker time parse error: ", err) glog.Error("FiatRatesFindTicker error: ", err)
return nil, err return nil, err
} }
timeObj = timeObj.UTC() if ticker != nil {
ticker.Timestamp = &timeObj return ticker, nil
err = json.Unmarshal(it.Value().Data(), &ticker.Rates)
if err != nil {
glog.Error("FiatRatesFindTicker error unpacking rates: ", err)
return nil, err
} }
break
} }
if err := it.Err(); err != nil { return nil, nil
glog.Error("FiatRatesFindTicker Iterator error: ", err)
return nil, err
}
if !it.Valid() {
return nil, nil // ticker not found
}
return ticker, nil
} }
// FiatRatesFindLastTicker gets the last FiatRates record // FiatRatesFindLastTicker gets the last FiatRates record, of the base currency, vsCurrency or the token if specified
func (d *RocksDB) FiatRatesFindLastTicker() (*CurrencyRatesTicker, error) { func (d *RocksDB) FiatRatesFindLastTicker(vsCurrency string, token string) (*CurrencyRatesTicker, error) {
ticker := &CurrencyRatesTicker{}
it := d.db.NewIteratorCF(d.ro, d.cfh[cfFiatRates]) it := d.db.NewIteratorCF(d.ro, d.cfh[cfFiatRates])
defer it.Close() defer it.Close()
for it.SeekToLast(); it.Valid(); it.Next() { for it.SeekToLast(); it.Valid(); it.Prev() {
timeObj, err := time.Parse(FiatRatesTimeFormat, string(it.Key().Data())) ticker, err := getTickerFromIterator(it, vsCurrency, token)
if err != nil { if err != nil {
glog.Error("FiatRatesFindTicker time parse error: ", err) glog.Error("FiatRatesFindLastTicker error: ", err)
return nil, err return nil, err
} }
timeObj = timeObj.UTC() if ticker != nil {
ticker.Timestamp = &timeObj // if without filter, store the ticker for later use
err = json.Unmarshal(it.Value().Data(), &ticker.Rates) if vsCurrency == "" && token == "" {
if err != nil { tickersMux.Lock()
glog.Error("FiatRatesFindTicker error unpacking rates: ", err) lastTickerInDB = ticker
return nil, err tickersMux.Unlock()
}
return ticker, nil
} }
break
} }
if err := it.Err(); err != nil { return nil, nil
glog.Error("FiatRatesFindLastTicker Iterator error: ", err) }
return ticker, err
} // FiatRatesGetCurrentTicker return current ticker
if !it.Valid() { func (d *RocksDB) FiatRatesGetCurrentTicker(tickerTime *time.Time, token string) (*CurrencyRatesTicker, error) {
return nil, nil // ticker not found tickersMux.Lock()
} defer tickersMux.Unlock()
return ticker, nil return currentTicker, nil
}
// FiatRatesCurrentTicker return current ticker
func (d *RocksDB) FiatRatesSetCurrentTicker(t *CurrencyRatesTicker) {
tickersMux.Lock()
defer tickersMux.Unlock()
currentTicker = t
} }

View File

@ -3,8 +3,11 @@
package db package db
import ( import (
"reflect"
"testing" "testing"
"time" "time"
"github.com/flier/gorocksdb"
) )
func TestRocksTickers(t *testing.T) { func TestRocksTickers(t *testing.T) {
@ -30,34 +33,63 @@ func TestRocksTickers(t *testing.T) {
} }
// Test storing & finding tickers // Test storing & finding tickers
key, _ := time.Parse(FiatRatesTimeFormat, "20190627000000") pastKey, _ := time.Parse(FiatRatesTimeFormat, "20190627000000")
futureKey, _ := time.Parse(FiatRatesTimeFormat, "20190630000000") futureKey, _ := time.Parse(FiatRatesTimeFormat, "20190630000000")
ts1, _ := time.Parse(FiatRatesTimeFormat, "20190628000000") ts1, _ := time.Parse(FiatRatesTimeFormat, "20190628000000")
ticker1 := &CurrencyRatesTicker{ ticker1 := &CurrencyRatesTicker{
Timestamp: &ts1, Timestamp: ts1,
Rates: map[string]float64{ Rates: map[string]float32{
"usd": 20000, "usd": 20000,
"eur": 18000,
},
TokenRates: map[string]float32{
"0x6B175474E89094C44Da98b954EedeAC495271d0F": 17.2,
}, },
} }
ts2, _ := time.Parse(FiatRatesTimeFormat, "20190629000000") ts2, _ := time.Parse(FiatRatesTimeFormat, "20190629000000")
ticker2 := &CurrencyRatesTicker{ ticker2 := &CurrencyRatesTicker{
Timestamp: &ts2, Timestamp: ts2,
Rates: map[string]float64{ Rates: map[string]float32{
"usd": 30000, "usd": 30000,
}, },
TokenRates: map[string]float32{
"0x82dF128257A7d7556262E1AB7F1f639d9775B85E": 13.1,
"0x6B175474E89094C44Da98b954EedeAC495271d0F": 17.5,
},
} }
err := d.FiatRatesStoreTicker(ticker1)
wb := gorocksdb.NewWriteBatch()
defer wb.Destroy()
err := d.FiatRatesStoreTicker(wb, ticker1)
if err != nil { if err != nil {
t.Errorf("Error storing ticker! %v", err) t.Errorf("Error storing ticker! %v", err)
} }
d.FiatRatesStoreTicker(ticker2) err = d.FiatRatesStoreTicker(wb, ticker2)
if err != nil {
t.Errorf("Error storing ticker! %v", err)
}
err = d.WriteBatch(wb)
if err != nil { if err != nil {
t.Errorf("Error storing ticker! %v", err) t.Errorf("Error storing ticker! %v", err)
} }
ticker, err := d.FiatRatesFindTicker(&key) // should find the closest key (ticker1) // test FiatRatesGetTicker with ticker that should be in DB
t1, err := d.FiatRatesGetTicker(&ts1)
if err != nil || t1 == nil {
t.Fatalf("FiatRatesGetTicker t1 %v", err)
}
if !reflect.DeepEqual(t1, ticker1) {
t.Fatalf("FiatRatesGetTicker(t1) = %v, want %v", *t1, *ticker1)
}
// test FiatRatesGetTicker with ticker that is not in DB
t2, err := d.FiatRatesGetTicker(&pastKey)
if err != nil || t2 != nil {
t.Fatalf("FiatRatesGetTicker t2 %v, %v", err, t2)
}
ticker, err := d.FiatRatesFindTicker(&pastKey, "", "") // should find the closest key (ticker1)
if err != nil { if err != nil {
t.Errorf("TestRocksTickers err: %+v", err) t.Errorf("TestRocksTickers err: %+v", err)
} else if ticker == nil { } else if ticker == nil {
@ -66,7 +98,7 @@ func TestRocksTickers(t *testing.T) {
t.Errorf("Incorrect ticker found. Expected: %v, found: %+v", ticker1.Timestamp, ticker.Timestamp) t.Errorf("Incorrect ticker found. Expected: %v, found: %+v", ticker1.Timestamp, ticker.Timestamp)
} }
ticker, err = d.FiatRatesFindLastTicker() // should find the last key (ticker2) ticker, err = d.FiatRatesFindLastTicker("", "") // should find the last key (ticker2)
if err != nil { if err != nil {
t.Errorf("TestRocksTickers err: %+v", err) t.Errorf("TestRocksTickers err: %+v", err)
} else if ticker == nil { } else if ticker == nil {
@ -75,10 +107,104 @@ func TestRocksTickers(t *testing.T) {
t.Errorf("Incorrect ticker found. Expected: %v, found: %+v", ticker1.Timestamp, ticker.Timestamp) t.Errorf("Incorrect ticker found. Expected: %v, found: %+v", ticker1.Timestamp, ticker.Timestamp)
} }
ticker, err = d.FiatRatesFindTicker(&futureKey) // should not find anything ticker, err = d.FiatRatesFindTicker(&futureKey, "", "") // should not find anything
if err != nil { if err != nil {
t.Errorf("TestRocksTickers err: %+v", err) t.Errorf("TestRocksTickers err: %+v", err)
} else if ticker != nil { } else if ticker != nil {
t.Errorf("Ticker found, but the timestamp is older than the last ticker entry.") t.Errorf("Ticker found, but the timestamp is older than the last ticker entry.")
} }
ticker, err = d.FiatRatesFindTicker(&pastKey, "", "0x6B175474E89094C44Da98b954EedeAC495271d0F") // should find the closest key (ticker1)
if err != nil {
t.Errorf("TestRocksTickers err: %+v", err)
} else if ticker == nil {
t.Errorf("Ticker not found")
} else if ticker.Timestamp.Format(FiatRatesTimeFormat) != ticker1.Timestamp.Format(FiatRatesTimeFormat) {
t.Errorf("Incorrect ticker found. Expected: %v, found: %+v", ticker1.Timestamp, ticker.Timestamp)
}
ticker, err = d.FiatRatesFindTicker(&pastKey, "", "0x82dF128257A7d7556262E1AB7F1f639d9775B85E") // should find the last key (ticker2)
if err != nil {
t.Errorf("TestRocksTickers err: %+v", err)
} else if ticker == nil {
t.Errorf("Ticker not found")
} else if ticker.Timestamp.Format(FiatRatesTimeFormat) != ticker2.Timestamp.Format(FiatRatesTimeFormat) {
t.Errorf("Incorrect ticker found. Expected: %v, found: %+v", ticker2.Timestamp, ticker.Timestamp)
}
ticker, err = d.FiatRatesFindLastTicker("eur", "") // should find the closest key (ticker1)
if err != nil {
t.Errorf("TestRocksTickers err: %+v", err)
} else if ticker == nil {
t.Errorf("Ticker not found")
} else if ticker.Timestamp.Format(FiatRatesTimeFormat) != ticker1.Timestamp.Format(FiatRatesTimeFormat) {
t.Errorf("Incorrect ticker found. Expected: %v, found: %+v", ticker1.Timestamp, ticker.Timestamp)
}
ticker, err = d.FiatRatesFindLastTicker("usd", "") // should find the last key (ticker2)
if err != nil {
t.Errorf("TestRocksTickers err: %+v", err)
} else if ticker == nil {
t.Errorf("Ticker not found")
} else if ticker.Timestamp.Format(FiatRatesTimeFormat) != ticker2.Timestamp.Format(FiatRatesTimeFormat) {
t.Errorf("Incorrect ticker found. Expected: %v, found: %+v", ticker2.Timestamp, ticker.Timestamp)
}
ticker, err = d.FiatRatesFindLastTicker("aud", "") // should not find any key
if err != nil {
t.Errorf("TestRocksTickers err: %+v", err)
} else if ticker != nil {
t.Errorf("Ticker %v found unexpectedly for aud vsCurrency", ticker)
}
}
func Test_packUnpackCurrencyRatesTicker(t *testing.T) {
type args struct {
}
tests := []struct {
name string
data CurrencyRatesTicker
}{
{
name: "empty",
data: CurrencyRatesTicker{},
},
{
name: "rates",
data: CurrencyRatesTicker{
Rates: map[string]float32{
"usd": 2129.2341123,
"eur": 1332.51234,
},
},
},
{
name: "rates&tokenrates",
data: CurrencyRatesTicker{
Rates: map[string]float32{
"usd": 322129.987654321,
"eur": 291332.12345678,
},
TokenRates: map[string]float32{
"0x82dF128257A7d7556262E1AB7F1f639d9775B85E": 0.4092341123,
"0x6B175474E89094C44Da98b954EedeAC495271d0F": 12.32323232323232,
"0xdAC17F958D2ee523a2206206994597C13D831ec7": 1332421341235.51234,
},
},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
packed := packCurrencyRatesTicker(&tt.data)
got, err := unpackCurrencyRatesTicker(packed)
if err != nil {
t.Errorf("unpackCurrencyRatesTicker() error = %v", err)
return
}
if !reflect.DeepEqual(got, &tt.data) {
t.Errorf("unpackCurrencyRatesTicker() = %v, want %v", *got, tt.data)
}
})
}
} }

View File

@ -2,12 +2,15 @@ package fiat
import ( import (
"encoding/json" "encoding/json"
"errors" "fmt"
"io/ioutil" "io/ioutil"
"net/http" "net/http"
"net/url"
"strconv" "strconv"
"strings"
"time" "time"
"github.com/flier/gorocksdb"
"github.com/golang/glog" "github.com/golang/glog"
"github.com/trezor/blockbook/db" "github.com/trezor/blockbook/db"
) )
@ -16,121 +19,405 @@ import (
type Coingecko struct { type Coingecko struct {
url string url string
coin string coin string
platformIdentifier string
platformVsCurrency string
httpTimeoutSeconds time.Duration httpTimeoutSeconds time.Duration
throttlingDelay time.Duration
timeFormat string timeFormat string
httpClient *http.Client
db *db.RocksDB
updatingTokens bool
}
// simpleSupportedVSCurrencies https://api.coingecko.com/api/v3/simple/supported_vs_currencies
type simpleSupportedVSCurrencies []string
type coinsListItem struct {
ID string `json:"id"`
Symbol string `json:"symbol"`
Name string `json:"name"`
Platforms map[string]string `json:"platforms"`
}
// coinList https://api.coingecko.com/api/v3/coins/list
type coinList []coinsListItem
type marketPoint [2]float32
type marketChartPrices struct {
Prices []marketPoint `json:"prices"`
} }
// NewCoinGeckoDownloader creates a coingecko structure that implements the RatesDownloaderInterface // NewCoinGeckoDownloader creates a coingecko structure that implements the RatesDownloaderInterface
func NewCoinGeckoDownloader(url string, coin string, timeFormat string) RatesDownloaderInterface { func NewCoinGeckoDownloader(db *db.RocksDB, url string, coin string, platformIdentifier string, platformVsCurrency string, timeFormat string, throttlingDelayMs int) RatesDownloaderInterface {
httpTimeoutSeconds := 15 * time.Second
return &Coingecko{ return &Coingecko{
url: url, url: url,
coin: coin, coin: coin,
httpTimeoutSeconds: 15 * time.Second, platformIdentifier: platformIdentifier,
platformVsCurrency: platformVsCurrency,
httpTimeoutSeconds: httpTimeoutSeconds,
timeFormat: timeFormat, timeFormat: timeFormat,
httpClient: &http.Client{
Timeout: httpTimeoutSeconds,
},
db: db,
throttlingDelay: time.Duration(throttlingDelayMs) * time.Millisecond,
} }
} }
// makeRequest retrieves the response from Coingecko API at the specified date. // doReq HTTP client
// If timestamp is nil, it fetches the latest market data available. func doReq(req *http.Request, client *http.Client) ([]byte, error) {
func (cg *Coingecko) makeRequest(timestamp *time.Time) ([]byte, error) {
requestURL := cg.url + "/coins/" + cg.coin
if timestamp != nil {
requestURL += "/history"
}
req, err := http.NewRequest("GET", requestURL, nil)
if err != nil {
glog.Errorf("Error creating a new request for %v: %v", requestURL, err)
return nil, err
}
req.Close = true
req.Header.Set("Content-Type", "application/json")
// Add query parameters
q := req.URL.Query()
// Add a unix timestamp to query parameters to get uncached responses
currentTimestamp := strconv.FormatInt(time.Now().UTC().UnixNano(), 10)
q.Add("current_timestamp", currentTimestamp)
if timestamp == nil {
q.Add("market_data", "true")
q.Add("localization", "false")
q.Add("tickers", "false")
q.Add("community_data", "false")
q.Add("developer_data", "false")
} else {
timestampFormatted := timestamp.Format(cg.timeFormat)
q.Add("date", timestampFormatted)
}
req.URL.RawQuery = q.Encode()
client := &http.Client{
Timeout: cg.httpTimeoutSeconds,
}
resp, err := client.Do(req) resp, err := client.Do(req)
if err != nil { if err != nil {
return nil, err return nil, err
} }
defer resp.Body.Close() defer resp.Body.Close()
if resp.StatusCode != http.StatusOK { body, err := ioutil.ReadAll(resp.Body)
return nil, errors.New("Invalid response status: " + string(resp.Status))
}
bodyBytes, err := ioutil.ReadAll(resp.Body)
if err != nil { if err != nil {
return nil, err return nil, err
} }
return bodyBytes, nil if resp.StatusCode != 200 {
return nil, fmt.Errorf("%s", body)
}
return body, nil
} }
// GetData gets fiat rates from API at the specified date and returns a CurrencyRatesTicker // makeReq HTTP request helper - will retry the call after 1 minute on error
// If timestamp is nil, it will download the current fiat rates. func (cg *Coingecko) makeReq(url string) ([]byte, error) {
func (cg *Coingecko) getTicker(timestamp *time.Time) (*db.CurrencyRatesTicker, error) { for {
dataTimestamp := timestamp // glog.Infof("Coingecko makeReq %v", url)
if timestamp == nil { req, err := http.NewRequest("GET", url, nil)
timeNow := time.Now() if err != nil {
dataTimestamp = &timeNow return nil, err
}
req.Header.Set("Content-Type", "application/json")
resp, err := doReq(req, cg.httpClient)
if err == nil {
return resp, err
}
if err.Error() != "error code: 1015" {
glog.Errorf("Coingecko makeReq %v error %v", url, err)
return nil, err
}
// if there is a throttling error, wait 70 seconds and retry
glog.Errorf("Coingecko makeReq %v error %v, will retry in 70 seconds", url, err)
time.Sleep(70 * time.Second)
} }
dataTimestampUTC := dataTimestamp.UTC()
ticker := &db.CurrencyRatesTicker{Timestamp: &dataTimestampUTC}
bodyBytes, err := cg.makeRequest(timestamp)
if err != nil {
return nil, err
}
type FiatRatesResponse struct {
MarketData struct {
Prices map[string]float64 `json:"current_price"`
} `json:"market_data"`
}
var data FiatRatesResponse
err = json.Unmarshal(bodyBytes, &data)
if err != nil {
glog.Errorf("Error parsing FiatRates response: %v", err)
return nil, err
}
ticker.Rates = data.MarketData.Prices
return ticker, nil
} }
// MarketDataExists checks if there's data available for the specific timestamp. // SimpleSupportedVSCurrencies /simple/supported_vs_currencies
func (cg *Coingecko) marketDataExists(timestamp *time.Time) (bool, error) { func (cg *Coingecko) simpleSupportedVSCurrencies() (simpleSupportedVSCurrencies, error) {
resp, err := cg.makeRequest(timestamp) url := cg.url + "/simple/supported_vs_currencies"
resp, err := cg.makeReq(url)
if err != nil { if err != nil {
glog.Error("Error getting market data: ", err) return nil, err
return false, err
} }
type FiatRatesResponse struct { var data simpleSupportedVSCurrencies
MarketData struct {
Prices map[string]interface{} `json:"current_price"`
} `json:"market_data"`
}
var data FiatRatesResponse
err = json.Unmarshal(resp, &data) err = json.Unmarshal(resp, &data)
if err != nil { if err != nil {
glog.Errorf("Error parsing Coingecko response: %v", err) return nil, err
}
return data, nil
}
// SimplePrice /simple/price Multiple ID and Currency (ids, vs_currencies)
func (cg *Coingecko) simplePrice(ids []string, vsCurrencies []string) (*map[string]map[string]float32, error) {
params := url.Values{}
idsParam := strings.Join(ids, ",")
vsCurrenciesParam := strings.Join(vsCurrencies, ",")
params.Add("ids", idsParam)
params.Add("vs_currencies", vsCurrenciesParam)
url := fmt.Sprintf("%s/simple/price?%s", cg.url, params.Encode())
resp, err := cg.makeReq(url)
if err != nil {
return nil, err
}
t := make(map[string]map[string]float32)
err = json.Unmarshal(resp, &t)
if err != nil {
return nil, err
}
return &t, nil
}
// CoinsList /coins/list
func (cg *Coingecko) coinsList() (coinList, error) {
params := url.Values{}
platform := "false"
if cg.platformIdentifier != "" {
platform = "true"
}
params.Add("include_platform", platform)
url := fmt.Sprintf("%s/coins/list?%s", cg.url, params.Encode())
resp, err := cg.makeReq(url)
if err != nil {
return nil, err
}
var data coinList
err = json.Unmarshal(resp, &data)
if err != nil {
return nil, err
}
return data, nil
}
// coinMarketChart /coins/{id}/market_chart?vs_currency={usd, eur, jpy, etc.}&days={1,14,30,max}
func (cg *Coingecko) coinMarketChart(id string, vs_currency string, days string) (*marketChartPrices, error) {
if len(id) == 0 || len(vs_currency) == 0 || len(days) == 0 {
return nil, fmt.Errorf("id, vs_currency, and days is required")
}
params := url.Values{}
params.Add("interval", "daily")
params.Add("vs_currency", vs_currency)
params.Add("days", days)
url := fmt.Sprintf("%s/coins/%s/market_chart?%s", cg.url, id, params.Encode())
resp, err := cg.makeReq(url)
if err != nil {
return nil, err
}
m := marketChartPrices{}
err = json.Unmarshal(resp, &m)
if err != nil {
return &m, err
}
return &m, nil
}
var vsCurrencies []string
var platformIds []string
var platformIdsToTokens map[string]string
func (cg *Coingecko) platformIds() error {
if cg.platformIdentifier == "" {
return nil
}
cl, err := cg.coinsList()
if err != nil {
return err
}
idsMap := make(map[string]string, 64)
ids := make([]string, 0, 64)
for i := range cl {
id, found := cl[i].Platforms[cg.platformIdentifier]
if found && id != "" {
idsMap[cl[i].ID] = id
ids = append(ids, cl[i].ID)
}
}
platformIds = ids
platformIdsToTokens = idsMap
return nil
}
func (cg *Coingecko) CurrentTickers() (*db.CurrencyRatesTicker, error) {
var newTickers = db.CurrencyRatesTicker{}
if vsCurrencies == nil {
vs, err := cg.simpleSupportedVSCurrencies()
if err != nil {
return nil, err
}
vsCurrencies = vs
}
prices, err := cg.simplePrice([]string{cg.coin}, vsCurrencies)
if err != nil || prices == nil {
return nil, err
}
newTickers.Rates = make(map[string]float32, len((*prices)[cg.coin]))
for t, v := range (*prices)[cg.coin] {
newTickers.Rates[t] = v
}
if cg.platformIdentifier != "" && cg.platformVsCurrency != "" {
if platformIdsToTokens == nil {
err = cg.platformIds()
if err != nil {
return nil, err
}
}
newTickers.TokenRates = make(map[string]float32)
const platformIdsGroup = 200
for from := 0; from < len(platformIds); from += platformIdsGroup {
to := from + platformIdsGroup
if to > len(platformIds) {
to = len(platformIds)
}
tokenPrices, err := cg.simplePrice(platformIds[from:to], []string{cg.platformVsCurrency})
if err != nil || tokenPrices == nil {
return nil, err
}
for id, v := range *tokenPrices {
t, found := platformIdsToTokens[id]
if found {
newTickers.TokenRates[t] = v[cg.platformVsCurrency]
}
}
}
}
newTickers.Timestamp = time.Now().UTC()
return &newTickers, nil
}
func (cg *Coingecko) getHistoricalTicker(tickersToUpdate map[uint]*db.CurrencyRatesTicker, coinId string, vsCurrency string, token string) (bool, error) {
lastTicker, err := cg.db.FiatRatesFindLastTicker(vsCurrency, token)
if err != nil {
return false, err return false, err
} }
return len(data.MarketData.Prices) != 0, nil var days string
if lastTicker == nil {
days = "max"
} else {
diff := time.Since(lastTicker.Timestamp)
d := int(diff / (24 * 3600 * 1000000000))
if d == 0 { // nothing to do, the last ticker exist
return false, nil
}
days = strconv.Itoa(d)
}
mc, err := cg.coinMarketChart(coinId, vsCurrency, days)
if err != nil {
return false, err
}
warningLogged := false
for _, p := range mc.Prices {
var timestamp uint
if p[0] > 100000000000 {
// convert timestamp from milliseconds to seconds
timestamp = uint(p[0] / 1000)
} else {
timestamp = uint(p[0])
}
rate := p[1]
if timestamp%(24*3600) == 0 && timestamp != 0 && rate != 0 { // process only tickers for the whole day with non 0 value
var found bool
var ticker *db.CurrencyRatesTicker
if ticker, found = tickersToUpdate[timestamp]; !found {
u := time.Unix(int64(timestamp), 0).UTC()
ticker, err = cg.db.FiatRatesGetTicker(&u)
if err != nil {
return false, err
}
if ticker == nil {
if token != "" { // if the base currency is not found in DB, do not create ticker for the token
if !warningLogged {
glog.Warningf("No base currency ticker for date %v for token %s", u, token)
warningLogged = true
}
continue
}
ticker = &db.CurrencyRatesTicker{
Timestamp: u,
Rates: make(map[string]float32),
}
}
tickersToUpdate[timestamp] = ticker
}
if token == "" {
ticker.Rates[vsCurrency] = rate
} else {
if ticker.TokenRates == nil {
ticker.TokenRates = make(map[string]float32)
}
ticker.TokenRates[token] = rate
}
}
}
return true, nil
}
func (cg *Coingecko) storeTickers(tickersToUpdate map[uint]*db.CurrencyRatesTicker) error {
if len(tickersToUpdate) > 0 {
wb := gorocksdb.NewWriteBatch()
defer wb.Destroy()
for _, v := range tickersToUpdate {
if err := cg.db.FiatRatesStoreTicker(wb, v); err != nil {
return err
}
}
if err := cg.db.WriteBatch(wb); err != nil {
return err
}
}
return nil
}
// UpdateHistoricalTickers gets historical tickers for the main crypto currency
func (cg *Coingecko) UpdateHistoricalTickers() error {
tickersToUpdate := make(map[uint]*db.CurrencyRatesTicker)
// reload vs_currencies
vs, err := cg.simpleSupportedVSCurrencies()
if err != nil {
return err
}
vsCurrencies = vs
for _, currency := range vsCurrencies {
// get historical rates for each currency
var err error
var req bool
if req, err = cg.getHistoricalTicker(tickersToUpdate, cg.coin, currency, ""); err != nil {
// report error and continue, Coingecko may return error like "Could not find coin with the given id"
// the rates will be updated next run
glog.Errorf("getHistoricalTicker %s-%s %v", cg.coin, currency, err)
}
if req {
time.Sleep(cg.throttlingDelay)
}
}
return cg.storeTickers(tickersToUpdate)
}
// UpdateHistoricalTokenTickers gets historical tickers for the tokens
func (cg *Coingecko) UpdateHistoricalTokenTickers() error {
if cg.updatingTokens {
return nil
}
cg.updatingTokens = true
defer func() { cg.updatingTokens = false }()
tickersToUpdate := make(map[uint]*db.CurrencyRatesTicker)
if cg.platformIdentifier != "" && cg.platformVsCurrency != "" {
// reload platform ids
if err := cg.platformIds(); err != nil {
return err
}
glog.Infof("Coingecko returned %d %s tokens ", len(platformIds), cg.coin)
count := 0
// get token historical rates
for tokenId, token := range platformIdsToTokens {
var err error
var req bool
if req, err = cg.getHistoricalTicker(tickersToUpdate, tokenId, cg.platformVsCurrency, token); err != nil {
// report error and continue, Coingecko may return error like "Could not find coin with the given id"
// the rates will be updated next run
glog.Errorf("getHistoricalTicker %s-%s %v", tokenId, cg.platformVsCurrency, err)
}
count++
if count%100 == 0 {
err := cg.storeTickers(tickersToUpdate)
if err != nil {
return err
}
tickersToUpdate = make(map[uint]*db.CurrencyRatesTicker)
glog.Infof("Coingecko updated %d of %d token tickers", count, len(platformIds))
}
if req {
// long delay next request to avoid throttling
time.Sleep(cg.throttlingDelay * 20)
}
}
}
return cg.storeTickers(tickersToUpdate)
} }

View File

@ -4,7 +4,7 @@ import (
"encoding/json" "encoding/json"
"errors" "errors"
"fmt" "fmt"
"reflect" "math/rand"
"time" "time"
"github.com/golang/glog" "github.com/golang/glog"
@ -16,28 +16,29 @@ type OnNewFiatRatesTicker func(ticker *db.CurrencyRatesTicker)
// RatesDownloaderInterface provides method signatures for specific fiat rates downloaders // RatesDownloaderInterface provides method signatures for specific fiat rates downloaders
type RatesDownloaderInterface interface { type RatesDownloaderInterface interface {
getTicker(timestamp *time.Time) (*db.CurrencyRatesTicker, error) CurrentTickers() (*db.CurrencyRatesTicker, error)
marketDataExists(timestamp *time.Time) (bool, error) UpdateHistoricalTickers() error
UpdateHistoricalTokenTickers() error
} }
// RatesDownloader stores FiatRates API parameters // RatesDownloader stores FiatRates API parameters
type RatesDownloader struct { type RatesDownloader struct {
periodSeconds time.Duration periodSeconds int64
db *db.RocksDB db *db.RocksDB
startTime *time.Time // a starting timestamp for tests to be deterministic (time.Now() for production)
timeFormat string timeFormat string
callbackOnNewTicker OnNewFiatRatesTicker callbackOnNewTicker OnNewFiatRatesTicker
downloader RatesDownloaderInterface downloader RatesDownloaderInterface
} }
// NewFiatRatesDownloader initializes the downloader for FiatRates API. // NewFiatRatesDownloader initializes the downloader for FiatRates API.
// If the startTime is nil, the downloader will start from the beginning. func NewFiatRatesDownloader(db *db.RocksDB, apiType string, params string, callback OnNewFiatRatesTicker) (*RatesDownloader, error) {
func NewFiatRatesDownloader(db *db.RocksDB, apiType string, params string, startTime *time.Time, callback OnNewFiatRatesTicker) (*RatesDownloader, error) {
var rd = &RatesDownloader{} var rd = &RatesDownloader{}
type fiatRatesParams struct { type fiatRatesParams struct {
URL string `json:"url"` URL string `json:"url"`
Coin string `json:"coin"` Coin string `json:"coin"`
PeriodSeconds int `json:"periodSeconds"` PlatformIdentifier string `json:"platformIdentifier"`
PlatformVsCurrency string `json:"platformVsCurrency"`
PeriodSeconds int64 `json:"periodSeconds"`
} }
rdParams := &fiatRatesParams{} rdParams := &fiatRatesParams{}
err := json.Unmarshal([]byte(params), &rdParams) err := json.Unmarshal([]byte(params), &rdParams)
@ -47,168 +48,62 @@ func NewFiatRatesDownloader(db *db.RocksDB, apiType string, params string, start
if rdParams.URL == "" || rdParams.PeriodSeconds == 0 { if rdParams.URL == "" || rdParams.PeriodSeconds == 0 {
return nil, errors.New("Missing parameters") return nil, errors.New("Missing parameters")
} }
rd.timeFormat = "02-01-2006" // Layout string for FiatRates date formatting (DD-MM-YYYY) rd.timeFormat = "02-01-2006" // Layout string for FiatRates date formatting (DD-MM-YYYY)
rd.periodSeconds = time.Duration(rdParams.PeriodSeconds) * time.Second // Time period for syncing the latest market data rd.periodSeconds = rdParams.PeriodSeconds // Time period for syncing the latest market data
if rd.periodSeconds < 60 { // minimum is one minute
rd.periodSeconds = 60
}
rd.db = db rd.db = db
rd.callbackOnNewTicker = callback rd.callbackOnNewTicker = callback
if startTime == nil {
timeNow := time.Now().UTC()
rd.startTime = &timeNow
} else {
rd.startTime = startTime // If startTime is nil, time.Now() will be used
}
if apiType == "coingecko" { if apiType == "coingecko" {
rd.downloader = NewCoinGeckoDownloader(rdParams.URL, rdParams.Coin, rd.timeFormat) throttlingDelayMs := 50
if callback == nil {
// a small hack - in tests the callback is not used, therefore there is no delay slowing the test
throttlingDelayMs = 0
}
rd.downloader = NewCoinGeckoDownloader(db, rdParams.URL, rdParams.Coin, rdParams.PlatformIdentifier, rdParams.PlatformVsCurrency, rd.timeFormat, throttlingDelayMs)
} else { } else {
return nil, fmt.Errorf("NewFiatRatesDownloader: incorrect API type %q", apiType) return nil, fmt.Errorf("NewFiatRatesDownloader: incorrect API type %q", apiType)
} }
return rd, nil return rd, nil
} }
// Run starts the FiatRates downloader. If there are tickers available, it continues from the last record. // Run periodically downloads current (every 15 minutes) and historical (once a day) tickers
// If there are no tickers, it finds the earliest market data available on API and downloads historical data.
// When historical data is downloaded, it continues to fetch the latest ticker prices.
func (rd *RatesDownloader) Run() error { func (rd *RatesDownloader) Run() error {
var timestamp *time.Time var lastHistoricalTickers time.Time
// Check if there are any tickers stored in database
glog.Infof("Finding last available ticker...")
ticker, err := rd.db.FiatRatesFindLastTicker()
if err != nil {
glog.Errorf("RatesDownloader FindTicker error: %v", err)
return err
}
if ticker == nil {
// If no tickers found, start downloading from the beginning
glog.Infof("No tickers found! Looking up the earliest market data available on API and downloading from there.")
timestamp, err = rd.findEarliestMarketData()
if err != nil {
glog.Errorf("Error looking up earliest market data: %v", err)
return err
}
} else {
// If found, continue downloading data from the next day of the last available record
glog.Infof("Last available ticker: %v", ticker.Timestamp)
timestamp = ticker.Timestamp
}
err = rd.syncHistorical(timestamp)
if err != nil {
glog.Errorf("RatesDownloader syncHistorical error: %v", err)
return err
}
if err := rd.syncLatest(); err != nil {
glog.Errorf("RatesDownloader syncLatest error: %v", err)
return err
}
return nil
}
// FindEarliestMarketData uses binary search to find the oldest market data available on API.
func (rd *RatesDownloader) findEarliestMarketData() (*time.Time, error) {
minDateString := "03-01-2009"
minDate, err := time.Parse(rd.timeFormat, minDateString)
if err != nil {
glog.Error("Error parsing date: ", err)
return nil, err
}
maxDate := rd.startTime.Add(time.Duration(-24) * time.Hour) // today's historical tickers may not be ready yet, so set to yesterday
currentDate := maxDate
for { for {
var dataExists bool = false tickers, err := rd.downloader.CurrentTickers()
for { if err != nil && tickers != nil {
dataExists, err = rd.downloader.marketDataExists(&currentDate) glog.Error("FiatRatesDownloader: CurrentTickers error ", err)
if err != nil {
glog.Errorf("Error checking if market data exists for date %v. Error: %v. Retrying in %v seconds.", currentDate, err, rd.periodSeconds)
timer := time.NewTimer(rd.periodSeconds)
<-timer.C
}
break
}
dateDiff := currentDate.Sub(minDate)
if dataExists {
if dateDiff < time.Hour*24 {
maxDate := time.Date(maxDate.Year(), maxDate.Month(), maxDate.Day(), 0, 0, 0, 0, maxDate.Location()) // truncate time to day
return &maxDate, nil
}
maxDate = currentDate
currentDate = currentDate.Add(-1 * dateDiff / 2)
} else { } else {
minDate = currentDate rd.db.FiatRatesSetCurrentTicker(tickers)
currentDate = currentDate.Add(maxDate.Sub(currentDate) / 2) glog.Info("FiatRatesDownloader: CurrentTickers updated")
} }
if time.Now().UTC().YearDay() != lastHistoricalTickers.YearDay() || time.Now().UTC().Year() != lastHistoricalTickers.Year() {
err = rd.downloader.UpdateHistoricalTickers()
if err != nil {
glog.Error("FiatRatesDownloader: UpdateHistoricalTickers error ", err)
} else {
lastHistoricalTickers = time.Now().UTC()
glog.Info("FiatRatesDownloader: UpdateHistoricalTickers finished")
}
// UpdateHistoricalTokenTickers in a goroutine, it can take quite some time as there may be many tokens
go func() {
err := rd.downloader.UpdateHistoricalTokenTickers()
if err != nil {
glog.Error("FiatRatesDownloader: UpdateHistoricalTokenTickers error ", err)
} else {
lastHistoricalTickers = time.Now().UTC()
glog.Info("FiatRatesDownloader: UpdateHistoricalTokenTickers finished")
}
}()
}
// next run on the
now := time.Now().Unix()
next := now + rd.periodSeconds
next -= next % rd.periodSeconds
next += int64(rand.Intn(12))
time.Sleep(time.Duration(next-now) * time.Second)
} }
} }
// syncLatest downloads the latest FiatRates data every rd.PeriodSeconds
func (rd *RatesDownloader) syncLatest() error {
timer := time.NewTimer(rd.periodSeconds)
var lastTickerRates map[string]float64
sameTickerCounter := 0
for {
ticker, err := rd.downloader.getTicker(nil)
if err != nil {
// Do not exit on GET error, log it, wait and try again
glog.Errorf("syncLatest GetData error: %v", err)
<-timer.C
timer.Reset(rd.periodSeconds)
continue
}
if sameTickerCounter < 5 && reflect.DeepEqual(ticker.Rates, lastTickerRates) {
// If rates are the same as previous, do not store them
glog.Infof("syncLatest: ticker rates for %v are the same as previous, skipping...", ticker.Timestamp)
<-timer.C
timer.Reset(rd.periodSeconds)
sameTickerCounter++
continue
}
lastTickerRates = ticker.Rates
sameTickerCounter = 0
glog.Infof("syncLatest: storing ticker for %v", ticker.Timestamp)
err = rd.db.FiatRatesStoreTicker(ticker)
if err != nil {
// If there's an error storing ticker (like missing rates), log it, wait and try again
glog.Errorf("syncLatest StoreTicker error: %v", err)
} else if rd.callbackOnNewTicker != nil {
rd.callbackOnNewTicker(ticker)
}
<-timer.C
timer.Reset(rd.periodSeconds)
}
}
// syncHistorical downloads all the historical data since the specified timestamp till today,
// then continues to download the latest rates
func (rd *RatesDownloader) syncHistorical(timestamp *time.Time) error {
period := time.Duration(1) * time.Second
timer := time.NewTimer(period)
for {
if rd.startTime.Sub(*timestamp) < time.Duration(time.Hour*24) {
break
}
ticker, err := rd.downloader.getTicker(timestamp)
if err != nil {
// Do not exit on GET error, log it, wait and try again
glog.Errorf("syncHistorical GetData error: %v", err)
<-timer.C
timer.Reset(rd.periodSeconds)
continue
}
glog.Infof("syncHistorical: storing ticker for %v", ticker.Timestamp)
err = rd.db.FiatRatesStoreTicker(ticker)
if err != nil {
// If there's an error storing ticker (like missing rates), log it and continue to the next day
glog.Errorf("syncHistorical error storing ticker for %v: %v", timestamp, err)
}
*timestamp = timestamp.Add(time.Hour * 24) // go to the next day
<-timer.C
timer.Reset(period)
}
return nil
}

View File

@ -9,6 +9,7 @@ import (
"net/http" "net/http"
"net/http/httptest" "net/http/httptest"
"os" "os"
"reflect"
"testing" "testing"
"time" "time"
@ -66,13 +67,9 @@ func bitcoinTestnetParser() *btc.BitcoinParser {
} }
// getFiatRatesMockData reads a stub JSON response from a file and returns its content as string // getFiatRatesMockData reads a stub JSON response from a file and returns its content as string
func getFiatRatesMockData(dateParam string) (string, error) { func getFiatRatesMockData(name string) (string, error) {
var filename string var filename string
if dateParam == "current" { filename = "fiat/mock_data/" + name + ".json"
filename = "fiat/mock_data/current.json"
} else {
filename = "fiat/mock_data/" + dateParam + ".json"
}
mockFile, err := os.Open(filename) mockFile, err := os.Open(filename)
if err != nil { if err != nil {
glog.Errorf("Cannot open file %v", filename) glog.Errorf("Cannot open file %v", filename)
@ -98,27 +95,43 @@ func TestFiatRates(t *testing.T) {
if r.URL.Path == "/ping" { if r.URL.Path == "/ping" {
w.WriteHeader(200) w.WriteHeader(200)
} else if r.URL.Path == "/coins/bitcoin/history" { } else if r.URL.Path == "/coins/list" {
date := r.URL.Query()["date"][0] mockData, err = getFiatRatesMockData("coinlist")
mockData, err = getFiatRatesMockData(date) // get stub rates by date } else if r.URL.Path == "/simple/supported_vs_currencies" {
} else if r.URL.Path == "/coins/bitcoin" { mockData, err = getFiatRatesMockData("vs_currencies")
mockData, err = getFiatRatesMockData("current") // get "latest" stub rates } else if r.URL.Path == "/simple/price" {
if r.URL.Query().Get("ids") == "ethereum" {
mockData, err = getFiatRatesMockData("simpleprice_base")
} else {
mockData, err = getFiatRatesMockData("simpleprice_tokens")
}
} else if r.URL.Path == "/coins/ethereum/market_chart" {
vsCurrency := r.URL.Query().Get("vs_currency")
if vsCurrency == "usd" {
days := r.URL.Query().Get("days")
if days == "max" {
mockData, err = getFiatRatesMockData("market_chart_eth_usd_max")
} else {
mockData, err = getFiatRatesMockData("market_chart_eth_usd_1")
}
} else {
mockData, err = getFiatRatesMockData("market_chart_eth_other")
}
} else if r.URL.Path == "/coins/vendit/market_chart" || r.URL.Path == "/coins/ethereum-cash-token/market_chart" {
mockData, err = getFiatRatesMockData("market_chart_token_other")
} else { } else {
t.Errorf("Unknown URL path: %v", r.URL.Path) t.Fatalf("Unknown URL path: %v", r.URL.Path)
} }
if err != nil { if err != nil {
t.Errorf("Error loading stub data: %v", err) t.Fatalf("Error loading stub data: %v", err)
} }
fmt.Fprintln(w, mockData) fmt.Fprintln(w, mockData)
})) }))
defer mockServer.Close() defer mockServer.Close()
// real CoinGecko API
//configJSON := `{"fiat_rates": "coingecko", "fiat_rates_params": "{\"url\": \"https://api.coingecko.com/api/v3\", \"coin\": \"bitcoin\", \"periodSeconds\": 60}"}`
// mocked CoinGecko API // mocked CoinGecko API
configJSON := `{"fiat_rates": "coingecko", "fiat_rates_params": "{\"url\": \"` + mockServer.URL + `\", \"coin\": \"bitcoin\", \"periodSeconds\": 60}"}` configJSON := `{"fiat_rates": "coingecko", "fiat_rates_params": "{\"url\": \"` + mockServer.URL + `\", \"coin\": \"ethereum\",\"platformIdentifier\":\"ethereum\",\"platformVsCurrency\": \"eth\",\"periodSeconds\": 60}"}`
type fiatRatesConfig struct { type fiatRatesConfig struct {
FiatRates string `json:"fiat_rates"` FiatRates string `json:"fiat_rates"`
@ -128,49 +141,157 @@ func TestFiatRates(t *testing.T) {
var config fiatRatesConfig var config fiatRatesConfig
err := json.Unmarshal([]byte(configJSON), &config) err := json.Unmarshal([]byte(configJSON), &config)
if err != nil { if err != nil {
t.Errorf("Error parsing config: %v", err) t.Fatalf("Error parsing config: %v", err)
} }
if config.FiatRates == "" || config.FiatRatesParams == "" { if config.FiatRates == "" || config.FiatRatesParams == "" {
t.Errorf("Error parsing FiatRates config - empty parameter") t.Fatalf("Error parsing FiatRates config - empty parameter")
return return
} }
testStartTime := time.Date(2019, 11, 22, 16, 0, 0, 0, time.UTC) fiatRates, err := NewFiatRatesDownloader(d, config.FiatRates, config.FiatRatesParams, nil)
fiatRates, err := NewFiatRatesDownloader(d, config.FiatRates, config.FiatRatesParams, &testStartTime, nil)
if err != nil { if err != nil {
t.Errorf("FiatRates init error: %v\n", err) t.Fatalf("FiatRates init error: %v", err)
} }
if config.FiatRates == "coingecko" { if config.FiatRates == "coingecko" {
timestamp, err := fiatRates.findEarliestMarketData()
// get current tickers
currentTickers, err := fiatRates.downloader.CurrentTickers()
if err != nil { if err != nil {
t.Errorf("Error looking up earliest market data: %v", err) t.Fatalf("Error in CurrentTickers: %v", err)
return return
} }
earliestTimestamp, _ := time.Parse(db.FiatRatesTimeFormat, "20130429000000") if currentTickers == nil {
if *timestamp != earliestTimestamp { t.Fatalf("CurrentTickers returned nil value")
t.Errorf("Incorrect earliest available timestamp found. Wanted: %v, got: %v", earliestTimestamp, timestamp)
return return
} }
// After verifying that findEarliestMarketData works correctly, wantCurrentTickers := db.CurrencyRatesTicker{
// set the earliest available timestamp to 2 days ago for easier testing Rates: map[string]float32{
*timestamp = fiatRates.startTime.Add(time.Duration(-24*2) * time.Hour) "aed": 8447.1,
"ars": 268901,
"aud": 3314.36,
"btc": 0.07531005,
"eth": 1,
"eur": 2182.99,
"ltc": 29.097696,
"usd": 2299.72,
},
TokenRates: map[string]float32{
"0x5e9997684d061269564f94e5d11ba6ce6fa9528c": 5.58195e-07,
"0x906710835d1ae85275eb770f06873340ca54274b": 1.39852e-10,
},
Timestamp: currentTickers.Timestamp,
}
if !reflect.DeepEqual(currentTickers, &wantCurrentTickers) {
t.Fatalf("CurrentTickers() = %v, want %v", *currentTickers, wantCurrentTickers)
}
err = fiatRates.syncHistorical(timestamp) ticker, err := fiatRates.db.FiatRatesFindLastTicker("usd", "")
if err != nil { if err != nil {
t.Errorf("RatesDownloader syncHistorical error: %v", err) t.Fatalf("FiatRatesFindLastTicker failed with error: %v", err)
return
} }
ticker, err := fiatRates.downloader.getTicker(fiatRates.startTime) if ticker != nil {
if err != nil { t.Fatalf("FiatRatesFindLastTicker found unexpected data")
// Do not exit on GET error, log it, wait and try again
glog.Errorf("Sync GetData error: %v", err)
return
} }
err = fiatRates.db.FiatRatesStoreTicker(ticker)
// update historical tickers for the first time
err = fiatRates.downloader.UpdateHistoricalTickers()
if err != nil { if err != nil {
glog.Errorf("Sync StoreTicker error %v", err) t.Fatalf("UpdateHistoricalTickers 1st pass failed with error: %v", err)
return }
err = fiatRates.downloader.UpdateHistoricalTokenTickers()
if err != nil {
t.Fatalf("UpdateHistoricalTokenTickers 1st pass failed with error: %v", err)
}
ticker, err = fiatRates.db.FiatRatesFindLastTicker("usd", "")
if err != nil || ticker == nil {
t.Fatalf("FiatRatesFindLastTicker failed with error: %v", err)
}
wantTicker := db.CurrencyRatesTicker{
Rates: map[string]float32{
"aed": 241272.48,
"ars": 241272.48,
"aud": 241272.48,
"btc": 241272.48,
"eth": 241272.48,
"eur": 241272.48,
"ltc": 241272.48,
"usd": 1794.5397,
},
TokenRates: map[string]float32{
"0x5e9997684d061269564f94e5d11ba6ce6fa9528c": 4.161734e+07,
"0x906710835d1ae85275eb770f06873340ca54274b": 4.161734e+07,
},
Timestamp: time.Unix(1654732800, 0).UTC(),
}
if !reflect.DeepEqual(ticker, &wantTicker) {
t.Fatalf("UpdateHistoricalTickers(usd) 1st pass = %v, want %v", *ticker, wantTicker)
}
ticker, err = fiatRates.db.FiatRatesFindLastTicker("eur", "")
if err != nil || ticker == nil {
t.Fatalf("FiatRatesFindLastTicker failed with error: %v", err)
}
wantTicker = db.CurrencyRatesTicker{
Rates: map[string]float32{
"aed": 240402.97,
"ars": 240402.97,
"aud": 240402.97,
"btc": 240402.97,
"eth": 240402.97,
"eur": 240402.97,
"ltc": 240402.97,
},
TokenRates: map[string]float32{
"0x5e9997684d061269564f94e5d11ba6ce6fa9528c": 4.1464476e+07,
"0x906710835d1ae85275eb770f06873340ca54274b": 4.1464476e+07,
},
Timestamp: time.Unix(1654819200, 0).UTC(),
}
if !reflect.DeepEqual(ticker, &wantTicker) {
t.Fatalf("UpdateHistoricalTickers(eur) 1st pass = %v, want %v", *ticker, wantTicker)
}
// update historical tickers for the second time
err = fiatRates.downloader.UpdateHistoricalTickers()
if err != nil {
t.Fatalf("UpdateHistoricalTickers 2nd pass failed with error: %v", err)
}
err = fiatRates.downloader.UpdateHistoricalTokenTickers()
if err != nil {
t.Fatalf("UpdateHistoricalTokenTickers 2nd pass failed with error: %v", err)
}
ticker, err = fiatRates.db.FiatRatesFindLastTicker("usd", "")
if err != nil || ticker == nil {
t.Fatalf("FiatRatesFindLastTicker failed with error: %v", err)
}
wantTicker = db.CurrencyRatesTicker{
Rates: map[string]float32{
"aed": 240402.97,
"ars": 240402.97,
"aud": 240402.97,
"btc": 240402.97,
"eth": 240402.97,
"eur": 240402.97,
"ltc": 240402.97,
"usd": 1788.4183,
},
TokenRates: map[string]float32{
"0x5e9997684d061269564f94e5d11ba6ce6fa9528c": 4.1464476e+07,
"0x906710835d1ae85275eb770f06873340ca54274b": 4.1464476e+07,
},
Timestamp: time.Unix(1654819200, 0).UTC(),
}
if !reflect.DeepEqual(ticker, &wantTicker) {
t.Fatalf("UpdateHistoricalTickers(usd) 2nd pass = %v, want %v", *ticker, wantTicker)
}
ticker, err = fiatRates.db.FiatRatesFindLastTicker("eur", "")
if err != nil || ticker == nil {
t.Fatalf("FiatRatesFindLastTicker failed with error: %v", err)
}
if !reflect.DeepEqual(ticker, &wantTicker) {
t.Fatalf("UpdateHistoricalTickers(eur) 2nd pass = %v, want %v", *ticker, wantTicker)
} }
} }
} }

View File

@ -1 +0,0 @@
{"id":"bitcoin","symbol":"btc","name":"Bitcoin","localization":{"en":"Bitcoin","de":"Bitcoin","es":"Bitcoin","fr":"Bitcoin","it":"Bitcoin","pl":"Bitcoin","ro":"Bitcoin","hu":"Bitcoin","nl":"Bitcoin","pt":"Bitcoin","sv":"Bitcoin","vi":"Bitcoin","tr":"Bitcoin","ru":"биткоина","ja":"ビットコイン","zh":"比特币","zh-tw":"比特幣","ko":"비트코인","ar":"بيتكوين","th":"บิตคอยน์","id":"Bitcoin"},"image":{"thumb":"https://assets.coingecko.com/coins/images/1/thumb/bitcoin.png?1547033579","small":"https://assets.coingecko.com/coins/images/1/small/bitcoin.png?1547033579"}}

View File

@ -1 +0,0 @@
{"id":"bitcoin","symbol":"btc","name":"Bitcoin","localization":{"en":"Bitcoin","de":"Bitcoin","es":"Bitcoin","fr":"Bitcoin","it":"Bitcoin","pl":"Bitcoin","ro":"Bitcoin","hu":"Bitcoin","nl":"Bitcoin","pt":"Bitcoin","sv":"Bitcoin","vi":"Bitcoin","tr":"Bitcoin","ru":"биткоина","ja":"ビットコイン","zh":"比特币","zh-tw":"比特幣","ko":"비트코인","ar":"بيتكوين","th":"บิตคอยน์","id":"Bitcoin"},"image":{"thumb":"https://assets.coingecko.com/coins/images/1/thumb/bitcoin.png?1547033579","small":"https://assets.coingecko.com/coins/images/1/small/bitcoin.png?1547033579"},"market_data":{"current_price":{"aud":112.481,"brl":232.8687,"btc":1.0,"cad":117.617,"chf":108.7145,"cny":718.7368,"dkk":661.3731,"eur":88.6291,"gbp":74.9767,"hkd":903.2559,"idr":1130568.3956,"inr":6274.6092,"jpy":11364.3607,"krw":128625.969,"mxn":1412.9046,"myr":353.6681,"nzd":136.2101,"php":4792.6186,"pln":368.8928,"rub":3623.3519,"sek":758.5144,"sgd":143.534,"twd":3433.0342,"usd":117.0,"xag":4.9088,"xau":0.0808,"xdr":76.8864,"zar":1049.0856},"market_cap":{"aud":1248780934.15,"brl":2585343237.705,"btc":11102150.0,"cad":1305801576.55,"chf":1206964686.175,"cny":7979523764.12,"dkk":7342663362.165,"eur":983973562.5649999,"gbp":832402569.9049999,"hkd":10028082490.185,"idr":12551739913210.54,"inr":69661652529.78,"jpy":126168837145.505,"krw":1428024801733.35,"mxn":15686278804.89,"myr":3926476296.415,"nzd":1512224961.715,"php":53208370589.99,"pln":4095503199.52,"rub":40226996296.585,"sek":8421140645.96,"sgd":1593535998.1,"twd":38114060643.53,"usd":1298951550.0,"xag":54498233.92,"xau":897053.72,"xdr":853604345.7599999,"zar":11647105694.04},"total_volume":{"aud":0.0,"brl":0.0,"btc":0.0,"cad":0.0,"chf":0.0,"cny":0.0,"dkk":0.0,"eur":0.0,"gbp":0.0,"hkd":0.0,"idr":0.0,"inr":0.0,"jpy":0.0,"krw":0.0,"mxn":0.0,"myr":0.0,"nzd":0.0,"php":0.0,"pln":0.0,"rub":0.0,"sek":0.0,"sgd":0.0,"twd":0.0,"usd":0.0,"xag":0.0,"xau":0.0,"xdr":0.0,"zar":0.0}},"community_data":{"facebook_likes":null,"twitter_followers":null,"reddit_average_posts_48h":0.0,"reddit_average_comments_48h":0.0,"reddit_subscribers":null,"reddit_accounts_active_48h":null},"developer_data":{"forks":null,"stars":null,"subscribers":null,"total_issues":null,"closed_issues":null,"pull_requests_merged":null,"pull_request_contributors":null,"code_additions_deletions_4_weeks":{"additions":null,"deletions":null},"commit_count_4_weeks":null},"public_interest_stats":{"alexa_rank":null,"bing_matches":null}}

View File

@ -1 +0,0 @@
{"id":"bitcoin","symbol":"btc","name":"Bitcoin","localization":{"en":"Bitcoin","de":"Bitcoin","es":"Bitcoin","fr":"Bitcoin","it":"Bitcoin","pl":"Bitcoin","ro":"Bitcoin","hu":"Bitcoin","nl":"Bitcoin","pt":"Bitcoin","sv":"Bitcoin","vi":"Bitcoin","tr":"Bitcoin","ru":"биткоина","ja":"ビットコイン","zh":"比特币","zh-tw":"比特幣","ko":"비트코인","ar":"بيتكوين","th":"บิตคอยน์","id":"Bitcoin"},"image":{"thumb":"https://assets.coingecko.com/coins/images/1/thumb/bitcoin.png?1547033579","small":"https://assets.coingecko.com/coins/images/1/small/bitcoin.png?1547033579"}}

View File

@ -1 +0,0 @@
{"id":"bitcoin","symbol":"btc","name":"Bitcoin","localization":{"en":"Bitcoin","de":"Bitcoin","es":"Bitcoin","fr":"Bitcoin","it":"Bitcoin","pl":"Bitcoin","ro":"Bitcoin","hu":"Bitcoin","nl":"Bitcoin","pt":"Bitcoin","sv":"Bitcoin","vi":"Bitcoin","tr":"Bitcoin","ru":"биткоина","ja":"ビットコイン","zh":"比特币","zh-tw":"比特幣","ko":"비트코인","ar":"بيتكوين","th":"บิตคอยน์","id":"Bitcoin"},"image":{"thumb":"https://assets.coingecko.com/coins/images/1/thumb/bitcoin.png?1547033579","small":"https://assets.coingecko.com/coins/images/1/small/bitcoin.png?1547033579"},"market_data":{"current_price":{"aud":112.4208,"brl":233.1081,"btc":1.0,"cad":117.0104,"chf":108.4737,"cny":715.1351,"dkk":659.3659,"eur":88.4403,"gbp":74.4895,"hkd":899.8427,"idr":1128375.8759,"inr":6235.1253,"jpy":11470.4956,"krw":127344.157,"mxn":1401.3929,"myr":352.0832,"nzd":135.8774,"php":4740.2255,"pln":366.461,"rub":3602.9548,"sek":754.5925,"sgd":143.0844,"twd":3426.0044,"usd":116.79,"xag":4.9089,"xau":0.0807,"xdr":76.8136,"zar":1033.9804},"market_cap":{"aud":1249804517.76,"brl":2591509369.32,"btc":11117200.0,"cad":1300828018.88,"chf":1205923817.64,"cny":7950299933.72,"dkk":7330302583.48,"eur":983208503.1599998,"gbp":828114669.4000001,"hkd":10003731264.44,"idr":12544380287555.48,"inr":69317134985.16,"jpy":127519793684.32,"krw":1415710462200.4,"mxn":15579565147.88,"myr":3914179351.04,"nzd":1510576231.28,"php":52698034928.6,"pln":4074020229.2,"rub":40054769102.56,"sek":8388955741.0,"sgd":1590697891.68,"twd":38087576115.68,"usd":1298377788.0,"xag":54573223.08,"xau":897158.0399999999,"xdr":853952153.9199998,"zar":11494966902.88},"total_volume":{"aud":0.0,"brl":0.0,"btc":0.0,"cad":0.0,"chf":0.0,"cny":0.0,"dkk":0.0,"eur":0.0,"gbp":0.0,"hkd":0.0,"idr":0.0,"inr":0.0,"jpy":0.0,"krw":0.0,"mxn":0.0,"myr":0.0,"nzd":0.0,"php":0.0,"pln":0.0,"rub":0.0,"sek":0.0,"sgd":0.0,"twd":0.0,"usd":0.0,"xag":0.0,"xau":0.0,"xdr":0.0,"zar":0.0}},"community_data":{"facebook_likes":null,"twitter_followers":null,"reddit_average_posts_48h":0.0,"reddit_average_comments_48h":0.0,"reddit_subscribers":null,"reddit_accounts_active_48h":null},"developer_data":{"forks":null,"stars":null,"subscribers":null,"total_issues":null,"closed_issues":null,"pull_requests_merged":null,"pull_request_contributors":null,"code_additions_deletions_4_weeks":{"additions":null,"deletions":null},"commit_count_4_weeks":null},"public_interest_stats":{"alexa_rank":null,"bing_matches":null}}

View File

@ -1 +0,0 @@
{"id":"bitcoin","symbol":"btc","name":"Bitcoin","localization":{"en":"Bitcoin","de":"Bitcoin","es":"Bitcoin","fr":"Bitcoin","it":"Bitcoin","pl":"Bitcoin","ro":"Bitcoin","hu":"Bitcoin","nl":"Bitcoin","pt":"Bitcoin","sv":"Bitcoin","vi":"Bitcoin","tr":"Bitcoin","ru":"биткоина","ja":"ビットコイン","zh":"比特币","zh-tw":"比特幣","ko":"비트코인","ar":"بيتكوين","th":"บิตคอยน์","id":"Bitcoin"},"image":{"thumb":"https://assets.coingecko.com/coins/images/1/thumb/bitcoin.png?1547033579","small":"https://assets.coingecko.com/coins/images/1/small/bitcoin.png?1547033579"},"market_data":{"current_price":{"aud":126.3133,"brl":259.5964,"btc":1.0,"cad":126.0278,"chf":115.6713,"cny":748.4143,"dkk":695.3988,"eur":93.2043,"gbp":79.6307,"hkd":946.0816,"idr":1196029.7068,"inr":6892.8316,"jpy":12203.5904,"krw":137012.0733,"mxn":1551.7041,"myr":377.299,"nzd":152.0791,"php":5112.2715,"pln":396.1512,"rub":3891.7674,"sek":800.3999,"sgd":152.8465,"twd":3646.9125,"uah":987.805115156,"usd":121.309,"xag":5.3382,"xau":0.0868,"xdr":81.033,"zar":1200.5467},"market_cap":{"aud":1420386743.3035636,"brl":2919148539.142982,"btc":11244950.003709536,"cad":1417176310.0775046,"chf":1300717985.3640869,"cny":8415881385.561269,"dkk":7819724738.639607,"eur":1048077693.6307446,"gbp":895443240.2603929,"hkd":10638640291.429523,"idr":13449294255917.375,"inr":77509546725.9892,"jpy":137228763913.74965,"krw":1540693914163.0862,"mxn":17448835025.0511,"myr":4242708391.449604,"nzd":1710121876.1091428,"php":57487237422.88915,"pln":4454700437.909536,"rub":43762729839.06665,"sek":9000456858.474112,"sgd":1718751250.7419894,"twd":41009348730.40335,"uah":11107819133.33776,"usd":1364113640.0,"xag":60027792.10980224,"xau":976061.6603219877,"xdr":911212033.6505947,"zar":13500087618.61847},"total_volume":{"aud":0.0,"brl":0.0,"btc":0.0,"cad":0.0,"chf":0.0,"cny":0.0,"dkk":0.0,"eur":0.0,"gbp":0.0,"hkd":0.0,"idr":0.0,"inr":0.0,"jpy":0.0,"krw":0.0,"mxn":0.0,"myr":0.0,"nzd":0.0,"php":0.0,"pln":0.0,"rub":0.0,"sek":0.0,"sgd":0.0,"twd":0.0,"uah":0.0,"usd":0.0,"xag":0.0,"xau":0.0,"xdr":0.0,"zar":0.0}},"community_data":{"facebook_likes":null,"twitter_followers":null,"reddit_average_posts_48h":0.0,"reddit_average_comments_48h":0.0,"reddit_subscribers":null,"reddit_accounts_active_48h":null},"developer_data":{"forks":null,"stars":null,"subscribers":null,"total_issues":null,"closed_issues":null,"pull_requests_merged":null,"pull_request_contributors":null,"code_additions_deletions_4_weeks":{"additions":null,"deletions":null},"commit_count_4_weeks":null},"public_interest_stats":{"alexa_rank":null,"bing_matches":null}}

View File

@ -1 +0,0 @@
{"id":"bitcoin","symbol":"btc","name":"Bitcoin","localization":{"en":"Bitcoin","de":"Bitcoin","es":"Bitcoin","fr":"Bitcoin","it":"Bitcoin","pl":"Bitcoin","ro":"Bitcoin","hu":"Bitcoin","nl":"Bitcoin","pt":"Bitcoin","sv":"Bitcoin","vi":"Bitcoin","tr":"Bitcoin","ru":"биткоина","ja":"ビットコイン","zh":"比特币","zh-tw":"比特幣","ko":"비트코인","ar":"بيتكوين","th":"บิตคอยน์","id":"Bitcoin"},"image":{"thumb":"https://assets.coingecko.com/coins/images/1/thumb/bitcoin.png?1547033579","small":"https://assets.coingecko.com/coins/images/1/small/bitcoin.png?1547033579"},"market_data":{"current_price":{"aud":130.8239,"bdt":9961.3816372,"bhd":48.39643563999999,"bmd":128.38,"brl":272.2555,"btc":1.0,"cad":127.0763,"chf":111.3766,"cny":754.702,"dkk":677.221,"eur":90.7575,"gbp":76.6108,"hkd":955.6959,"idr":1401976.6268,"inr":7601.0098,"jpy":11938.215,"krw":132238.2292,"ltc":59.84440454817475,"mmk":124564.42058759999,"mxn":1619.1347,"myr":393.0957,"nzd":148.4503,"php":5315.0149,"pln":381.3587,"rub":3973.0086,"sek":791.16,"sgd":153.7814,"twd":3623.6843,"uah":1051.00353918,"usd":128.38,"vef":807.6801751200001,"xag":5.5156,"xau":0.0932,"xdr":80.1381,"zar":1233.5253},"market_cap":{"aud":1544578916.545,"bdt":117609550368.68365,"bhd":571394937.205442,"bmd":1515724889.0,"brl":3214398173.525,"btc":11806550.0,"cad":1500332689.765,"chf":1314973396.73,"cny":8910426898.1,"dkk":7995643597.55,"eur":1071532961.6249999,"gbp":904509240.74,"hkd":11283471428.145,"idr":16552507143145.54,"inr":89741702254.19,"jpy":140949132308.25,"krw":1561277264961.26,"ltc":706555954.5182526,"mmk":1470676059888.5288,"mxn":19116394792.285,"myr":4641104036.835,"nzd":1752685889.465,"php":62751989167.595,"pln":4502530559.485,"rub":46907524686.33,"sek":9340870098.0,"sgd":1815627788.17,"twd":42783209872.165,"uah":12408725835.50563,"usd":1515724889.0,"vef":9535916371.563036,"xag":65120207.18,"xau":1100370.4600000002,"xdr":946154484.5549998,"zar":14563678130.715},"total_volume":{"aud":0.0,"bdt":0.0,"bhd":0.0,"bmd":0.0,"brl":0.0,"btc":0.0,"cad":0.0,"chf":0.0,"cny":0.0,"dkk":0.0,"eur":0.0,"gbp":0.0,"hkd":0.0,"idr":0.0,"inr":0.0,"jpy":0.0,"krw":0.0,"ltc":0.0,"mmk":0.0,"mxn":0.0,"myr":0.0,"nzd":0.0,"php":0.0,"pln":0.0,"rub":0.0,"sek":0.0,"sgd":0.0,"twd":0.0,"uah":0.0,"usd":0.0,"vef":0.0,"xag":0.0,"xau":0.0,"xdr":0.0,"zar":0.0}},"community_data":{"facebook_likes":null,"twitter_followers":null,"reddit_average_posts_48h":0.0,"reddit_average_comments_48h":0.0,"reddit_subscribers":null,"reddit_accounts_active_48h":null},"developer_data":{"forks":null,"stars":null,"subscribers":null,"total_issues":null,"closed_issues":null,"pull_requests_merged":null,"pull_request_contributors":null,"code_additions_deletions_4_weeks":{"additions":null,"deletions":null},"commit_count_4_weeks":null},"public_interest_stats":{"alexa_rank":null,"bing_matches":null}}

View File

@ -1 +0,0 @@
{"id":"bitcoin","symbol":"btc","name":"Bitcoin","localization":{"en":"Bitcoin","de":"Bitcoin","es":"Bitcoin","fr":"Bitcoin","it":"Bitcoin","pl":"Bitcoin","ro":"Bitcoin","hu":"Bitcoin","nl":"Bitcoin","pt":"Bitcoin","sv":"Bitcoin","vi":"Bitcoin","tr":"Bitcoin","ru":"биткоина","ja":"ビットコイン","zh":"比特币","zh-tw":"比特幣","ko":"비트코인","ar":"بيتكوين","th":"บิตคอยน์","id":"Bitcoin"},"image":{"thumb":"https://assets.coingecko.com/coins/images/1/thumb/bitcoin.png?1547033579","small":"https://assets.coingecko.com/coins/images/1/small/bitcoin.png?1547033579"},"market_data":{"current_price":{"aud":635.4009,"bdt":46187.0412867627,"bhd":224.2182020654,"bmd":594.6833,"brl":1330.3026,"btc":1.0,"cad":645.6162,"chf":537.5144,"cny":3818.09,"dkk":3291.0056,"eur":439.2353,"gbp":350.5075,"hkd":4609.773,"idr":7056654.8237,"inr":35623.7845,"jpy":60664.7779,"krw":605273.662,"ltc":58.68544600938967,"mmk":576316.9820261401,"mxn":7774.4393,"myr":1921.5065,"nzd":689.3958,"php":26182.8705,"pln":1816.6144,"rub":20449.5057,"sek":3957.6568,"sgd":743.7241,"twd":17936.3326,"uah":6989.384186896,"usd":594.6833,"vef":3749.9319498579002,"vnd":12616057.538675,"xag":30.3811,"xau":0.4678,"xdr":388.0442,"zar":6383.9883},"market_cap":{"aud":8191238932.305,"bdt":595417933396.2369,"bhd":2890497741.0160007,"bmd":7666330027.785,"brl":17149529452.77,"btc":12891450.0,"cad":8322928961.49,"chf":6929340011.88,"cny":49220716330.5,"dkk":42425834142.12,"eur":5662379908.185,"gbp":4518549910.875,"hkd":59426658140.85,"idr":90970512826987.36,"inr":459242236692.525,"jpy":782056951058.955,"krw":7802855149989.9,"ltc":756540492.9577465,"mmk":7429561557940.883,"mxn":100223795513.985,"myr":24771004969.425,"nzd":8887311485.91,"php":337535165907.225,"pln":23418793706.88,"rub":263623780256.265,"sek":51019934754.36,"sgd":9587682048.945,"twd":231225334896.27,"uah":90103296776.16043,"usd":7666330027.785,"vef":48342060234.99562,"vnd":162639274956951.8,"xag":391656431.595,"xau":6030620.31,"xdr":5002452402.09,"zar":82298865970.035},"total_volume":{"aud":40549103.56573137,"bdt":2947498375.4849124,"bhd":14308835.723827252,"bmd":37950646.151919045,"brl":84895343.87055749,"btc":63816.5661486022,"cad":41201008.933909185,"chf":34302323.26342622,"cny":243657393.04631656,"dkk":210020676.56782028,"eur":28030488.577251133,"gbp":22368185.059331186,"hkd":294179883.5845404,"idr":450331479344.50385,"inr":2273387600.0077996,"jpy":3871417811.7456107,"krw":38626486689.029686,"ltc":3745103.647218439,"mmk":36778570806.03395,"mxn":496138019.85674256,"myr":122623946.66221909,"nzd":43994872.673268534,"php":1670900887.223535,"pln":115930093.0241033,"rub":1305017233.2102678,"sek":252564066.9706653,"sgd":47461918.22395964,"twd":1144635155.8312302,"uah":446038498.30104274,"usd":37950646.151919045,"vef":239307780.33086348,"vnd":805113470451.4246,"xag":1938817.4778172984,"xau":29853.38964431611,"xdr":24763648.357881423,"zar":407404211.6388525}},"community_data":{"facebook_likes":22450,"twitter_followers":54747,"reddit_average_posts_48h":2.449,"reddit_average_comments_48h":266.163,"reddit_subscribers":122886,"reddit_accounts_active_48h":"957.0"},"developer_data":{"forks":3894,"stars":5469,"subscribers":757,"total_issues":4332,"closed_issues":3943,"pull_requests_merged":1950,"pull_request_contributors":201,"code_additions_deletions_4_weeks":{"additions":null,"deletions":null},"commit_count_4_weeks":null},"public_interest_stats":{"alexa_rank":null,"bing_matches":null}}

View File

@ -1 +0,0 @@
{"id":"bitcoin","symbol":"btc","name":"Bitcoin","localization":{"en":"Bitcoin","de":"Bitcoin","es":"Bitcoin","fr":"Bitcoin","it":"Bitcoin","pl":"Bitcoin","ro":"Bitcoin","hu":"Bitcoin","nl":"Bitcoin","pt":"Bitcoin","sv":"Bitcoin","vi":"Bitcoin","tr":"Bitcoin","ru":"биткоина","ja":"ビットコイン","zh":"比特币","zh-tw":"比特幣","ko":"비트코인","ar":"بيتكوين","th":"บิตคอยน์","id":"Bitcoin"},"image":{"thumb":"https://assets.coingecko.com/coins/images/1/thumb/bitcoin.png?1547033579","small":"https://assets.coingecko.com/coins/images/1/small/bitcoin.png?1547033579"}}

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View File

@ -1 +0,0 @@
{"id":"bitcoin","symbol":"btc","name":"Bitcoin","localization":{"en":"Bitcoin","de":"Bitcoin","es":"Bitcoin","fr":"Bitcoin","it":"Bitcoin","pl":"Bitcoin","ro":"Bitcoin","hu":"Bitcoin","nl":"Bitcoin","pt":"Bitcoin","sv":"Bitcoin","vi":"Bitcoin","tr":"Bitcoin","ru":"биткоина","ja":"ビットコイン","zh":"比特币","zh-tw":"比特幣","ko":"비트코인","ar":"بيتكوين","th":"บิตคอยน์","id":"Bitcoin"},"image":{"thumb":"https://assets.coingecko.com/coins/images/1/thumb/bitcoin.png?1547033579","small":"https://assets.coingecko.com/coins/images/1/small/bitcoin.png?1547033579"}}

View File

@ -1 +0,0 @@
{"id":"bitcoin","symbol":"btc","name":"Bitcoin","localization":{"en":"Bitcoin","de":"Bitcoin","es":"Bitcoin","fr":"Bitcoin","it":"Bitcoin","pl":"Bitcoin","ro":"Bitcoin","hu":"Bitcoin","nl":"Bitcoin","pt":"Bitcoin","sv":"Bitcoin","vi":"Bitcoin","tr":"Bitcoin","ru":"биткоина","ja":"ビットコイン","zh":"比特币","zh-tw":"比特幣","ko":"비트코인","ar":"بيتكوين","th":"บิตคอยน์","id":"Bitcoin"},"image":{"thumb":"https://assets.coingecko.com/coins/images/1/thumb/bitcoin.png?1547033579","small":"https://assets.coingecko.com/coins/images/1/small/bitcoin.png?1547033579"}}

View File

@ -1 +0,0 @@
{"id":"bitcoin","symbol":"btc","name":"Bitcoin","localization":{"en":"Bitcoin","de":"Bitcoin","es":"Bitcoin","fr":"Bitcoin","it":"Bitcoin","pl":"Bitcoin","ro":"Bitcoin","hu":"Bitcoin","nl":"Bitcoin","pt":"Bitcoin","sv":"Bitcoin","vi":"Bitcoin","tr":"Bitcoin","ru":"биткоина","ja":"ビットコイン","zh":"比特币","zh-tw":"比特幣","ko":"비트코인","ar":"بيتكوين","th":"บิตคอยน์","id":"Bitcoin"},"image":{"thumb":"https://assets.coingecko.com/coins/images/1/thumb/bitcoin.png?1547033579","small":"https://assets.coingecko.com/coins/images/1/small/bitcoin.png?1547033579"},"market_data":{"current_price":{"aud":130.7952,"brl":268.8555,"btc":1.0,"cad":136.8008,"chf":126.7471,"cny":830.3415,"dkk":769.4261,"eur":103.1862,"gbp":86.889,"hkd":1043.747,"idr":1306348.9692,"inr":7304.2353,"jpy":13203.1967,"krw":149390.4586,"mxn":1633.6086,"myr":407.992,"nzd":158.5211,"php":5543.837,"pln":429.2283,"rub":4203.4233,"sek":884.1254,"sgd":166.2931,"usd":135.3,"xag":5.716,"xau":0.0938,"zar":1223.2239},"market_cap":{"aud":1450558006.5599997,"brl":2981688151.6499996,"btc":11090299.999999998,"cad":1517161912.2399998,"chf":1405663363.1299999,"cny":9208736337.449999,"dkk":8533166276.829999,"eur":1144365913.86,"gbp":963625076.6999998,"hkd":11575467354.099998,"idr":14487801973118.756,"inr":81006160747.59,"jpy":146427412362.00998,"krw":1656785003011.5798,"mxn":18117209456.579998,"myr":4524753677.599999,"nzd":1758046555.3299997,"php":61482815481.09999,"pln":4760270615.489999,"rub":46617225423.99,"sek":9805215923.619999,"sgd":1844240366.9299998,"usd":1500517590,"xag":63392154.79999999,"xau":1040270.1399999998,"zar":13565920018.169998},"total_volume":{"aud":0.0,"brl":0.0,"btc":0.0,"cad":0.0,"chf":0.0,"cny":0.0,"dkk":0.0,"eur":0.0,"gbp":0.0,"hkd":0.0,"idr":0.0,"inr":0.0,"jpy":0.0,"krw":0.0,"mxn":0.0,"myr":0.0,"nzd":0.0,"php":0.0,"pln":0.0,"rub":0.0,"sek":0.0,"sgd":0.0,"usd":0,"xag":0.0,"xau":0.0,"zar":0.0}},"community_data":{"facebook_likes":null,"twitter_followers":null,"reddit_average_posts_48h":0.0,"reddit_average_comments_48h":0.0,"reddit_subscribers":null,"reddit_accounts_active_48h":null},"developer_data":{"forks":null,"stars":null,"subscribers":null,"total_issues":null,"closed_issues":null,"pull_requests_merged":null,"pull_request_contributors":null,"code_additions_deletions_4_weeks":{"additions":null,"deletions":null},"commit_count_4_weeks":null},"public_interest_stats":{"alexa_rank":null,"bing_matches":null}}

View File

@ -1 +0,0 @@
{"id":"bitcoin","symbol":"btc","name":"Bitcoin","localization":{"en":"Bitcoin","de":"Bitcoin","es":"Bitcoin","fr":"Bitcoin","it":"Bitcoin","pl":"Bitcoin","ro":"Bitcoin","hu":"Bitcoin","nl":"Bitcoin","pt":"Bitcoin","sv":"Bitcoin","vi":"Bitcoin","tr":"Bitcoin","ru":"биткоина","ja":"ビットコイン","zh":"比特币","zh-tw":"比特幣","ko":"비트코인","ar":"بيتكوين","th":"บิตคอยน์","id":"Bitcoin"},"image":{"thumb":"https://assets.coingecko.com/coins/images/1/thumb/bitcoin.png?1547033579","small":"https://assets.coingecko.com/coins/images/1/small/bitcoin.png?1547033579"},"market_data":{"current_price":{"aud":140.0534,"brl":287.7259,"btc":1.0,"cad":146.3907,"chf":135.5619,"cny":889.0842,"dkk":822.6608,"eur":110.3745,"gbp":93.0697,"hkd":1117.9148,"idr":1394387.3129,"inr":7822.3338,"jpy":14108.4087,"krw":159839.6429,"mxn":1748.706,"myr":436.5932,"nzd":169.7084,"php":5937.7695,"pln":459.2644,"rub":4501.5503,"sek":944.2334,"sgd":178.0707,"twd":4262.3287,"usd":141.96,"xag":6.1223,"xau":0.1005,"xdr":95.6015,"zar":1309.9167},"market_cap":{"aud":1553878467.66,"brl":3192290087.91,"btc":11094900.0,"cad":1624190177.43,"chf":1504045724.31,"cny":9864300290.58,"dkk":9127339309.92,"eur":1224594040.05,"gbp":1032599014.53,"hkd":12403152914.52,"idr":15470587797894.21,"inr":86788011277.62,"jpy":156531383685.63,"krw":1773404854011.21,"mxn":19401718199.4,"myr":4843957894.68,"nzd":1882897727.16,"php":65878958825.55,"pln":5095492591.56,"rub":49944250423.47,"sek":10476175149.66,"sgd":1975676609.43,"twd":47290110693.63,"usd":1575032004.0,"xag":67926306.27,"xau":1115037.45,"xdr":1060689082.35,"zar":14533394794.83},"total_volume":{"aud":0.0,"brl":0.0,"btc":0.0,"cad":0.0,"chf":0.0,"cny":0.0,"dkk":0.0,"eur":0.0,"gbp":0.0,"hkd":0.0,"idr":0.0,"inr":0.0,"jpy":0.0,"krw":0.0,"mxn":0.0,"myr":0.0,"nzd":0.0,"php":0.0,"pln":0.0,"rub":0.0,"sek":0.0,"sgd":0.0,"twd":0.0,"usd":0.0,"xag":0.0,"xau":0.0,"xdr":0.0,"zar":0.0}},"community_data":{"facebook_likes":null,"twitter_followers":null,"reddit_average_posts_48h":0.0,"reddit_average_comments_48h":0.0,"reddit_subscribers":null,"reddit_accounts_active_48h":null},"developer_data":{"forks":null,"stars":null,"subscribers":null,"total_issues":null,"closed_issues":null,"pull_requests_merged":null,"pull_request_contributors":null,"code_additions_deletions_4_weeks":{"additions":null,"deletions":null},"commit_count_4_weeks":null},"public_interest_stats":{"alexa_rank":null,"bing_matches":null}}

View File

@ -0,0 +1,30 @@
[
{ "id": "01coin", "symbol": "zoc", "name": "01coin", "platforms": {} },
{
"id": "0-5x-long-algorand-token",
"symbol": "algohalf",
"name": "0.5X Long Algorand Token",
"platforms": { "ethereum": "" }
},
{ "id": "ethereum", "symbol": "eth", "name": "Ethereum", "platforms": {} },
{
"id": "ethereum-cash-token",
"symbol": "ecash",
"name": "Ethereum Cash Token",
"platforms": { "ethereum": "0x906710835d1ae85275eb770f06873340ca54274b" }
},
{
"id": "santa-shiba",
"symbol": "santashib",
"name": "Santa Shiba",
"platforms": {
"binance-smart-chain": "0x74c609b16512869b1873f5a9d7999deee386e740"
}
},
{
"id": "vendit",
"symbol": "vndt",
"name": "Vendit",
"platforms": { "ethereum": "0x5e9997684d061269564f94e5d11ba6ce6fa9528c" }
}
]

File diff suppressed because one or more lines are too long

View File

@ -0,0 +1,23 @@
{
"prices": [
[1654560000000, 245991.30610738738],
[1654646400000, 241439.61063702923],
[1654732800000, 241272.47868584536],
[1654819200000, 240402.9616407818],
[1654874261000, 232687.7973743471]
],
"market_caps": [
[1654560000000, 29783749062026.934],
[1654646400000, 29309140822797.383],
[1654732800000, 29218371977967.83],
[1654819200000, 29135342816603.11],
[1654874261000, 28322159926577.836]
],
"total_volumes": [
[1654560000000, 2198234703995.5186],
[1654646400000, 3139844528072.9595],
[1654732800000, 2381462737920.105],
[1654819200000, 1407275835572.8992],
[1654874261000, 1875231811513.972]
]
}

View File

@ -0,0 +1,14 @@
{
"prices": [
[1654819200000, 1788.4182866616045],
[1654871975000, 1741.4106052586249]
],
"market_caps": [
[1654819200000, 216720355679.05618],
[1654871975000, 210920939953.81134]
],
"total_volumes": [
[1654819200000, 10469080004.414614],
[1654871975000, 13875498345.972267]
]
}

View File

@ -0,0 +1,17 @@
{
"prices": [
[1654560000000, 1860.1813068416047],
[1654646400000, 1818.3877119829308],
[1654732800000, 1794.539625671828]
],
"market_caps": [
[1654560000000, 225224111085.68793],
[1654646400000, 220727955347.00992],
[1654732800000, 217320792647.69748]
],
"total_volumes": [
[1654560000000, 16623006597.793545],
[1654646400000, 23647547692.445885],
[1654732800000, 17712874976.607395]
]
}

View File

@ -0,0 +1,23 @@
{
"prices": [
[1654560000000, 43129640.779293984],
[1654646400000, 42170403.75197084],
[1654732800000, 41617340.4960857],
[1654819200000, 41464477.97624925],
[1654893557000, 39012012.89610346]
],
"market_caps": [
[1654560000000, 5221982916522588],
[1654646400000, 5118923172979404],
[1654732800000, 5039907336185186],
[1654819200000, 5024661446418917],
[1654893557000, 4722632860950729]
],
"total_volumes": [
[1654560000000, 385416357318398.5],
[1654646400000, 548412545554966],
[1654732800000, 410780981662688],
[1654819200000, 242725619902352.5],
[1654893557000, 395315245827820.75]
]
}

View File

@ -0,0 +1,12 @@
{
"ethereum": {
"btc": 0.07531005,
"eth": 1.0,
"ltc": 29.097696,
"usd": 2299.72,
"eur": 2182.99,
"aed": 8447.1,
"ars": 268901,
"aud": 3314.36
}
}

View File

@ -0,0 +1,8 @@
{
"ethereum-cash-token": {
"eth": 1.39852e-10
},
"vendit": {
"eth": 5.58195e-07
}
}

View File

@ -0,0 +1,10 @@
[
"btc",
"eth",
"ltc",
"usd",
"eur",
"aed",
"ars",
"aud"
]

View File

@ -193,7 +193,7 @@ func (s *PublicServer) ConnectFullPublicInterface() {
serveMux.HandleFunc(path+"api/v2/balancehistory/", s.jsonHandler(s.apiBalanceHistory, apiDefault)) serveMux.HandleFunc(path+"api/v2/balancehistory/", s.jsonHandler(s.apiBalanceHistory, apiDefault))
serveMux.HandleFunc(path+"api/v2/tickers/", s.jsonHandler(s.apiTickers, apiV2)) serveMux.HandleFunc(path+"api/v2/tickers/", s.jsonHandler(s.apiTickers, apiV2))
serveMux.HandleFunc(path+"api/v2/multi-tickers/", s.jsonHandler(s.apiMultiTickers, apiV2)) serveMux.HandleFunc(path+"api/v2/multi-tickers/", s.jsonHandler(s.apiMultiTickers, apiV2))
serveMux.HandleFunc(path+"api/v2/tickers-list/", s.jsonHandler(s.apiTickersList, apiV2)) serveMux.HandleFunc(path+"api/v2/tickers-list/", s.jsonHandler(s.apiAvailableVsCurrencies, apiV2))
// socket.io interface // socket.io interface
serveMux.Handle(path+"socket.io/", s.socketio.GetHandler()) serveMux.Handle(path+"socket.io/", s.socketio.GetHandler())
// websocket interface // websocket interface
@ -1197,21 +1197,21 @@ func (s *PublicServer) apiSendTx(r *http.Request, apiVersion int) (interface{},
return nil, api.NewAPIError("Missing tx blob", true) return nil, api.NewAPIError("Missing tx blob", true)
} }
// apiTickersList returns a list of available FiatRates currencies // apiAvailableVsCurrencies returns a list of available versus currencies
func (s *PublicServer) apiTickersList(r *http.Request, apiVersion int) (interface{}, error) { func (s *PublicServer) apiAvailableVsCurrencies(r *http.Request, apiVersion int) (interface{}, error) {
s.metrics.ExplorerViews.With(common.Labels{"action": "api-tickers-list"}).Inc() s.metrics.ExplorerViews.With(common.Labels{"action": "api-tickers-list"}).Inc()
timestampString := strings.ToLower(r.URL.Query().Get("timestamp")) timestampString := strings.ToLower(r.URL.Query().Get("timestamp"))
timestamp, err := strconv.ParseInt(timestampString, 10, 64) timestamp, err := strconv.ParseInt(timestampString, 10, 64)
if err != nil { if err != nil {
return nil, api.NewAPIError("Parameter \"timestamp\" is not a valid Unix timestamp.", true) return nil, api.NewAPIError("Parameter \"timestamp\" is not a valid Unix timestamp.", true)
} }
result, err := s.api.GetFiatRatesTickersList(timestamp) result, err := s.api.GetAvailableVsCurrencies(timestamp)
return result, err return result, err
} }
// apiTickers returns FiatRates ticker prices for the specified block or timestamp. // apiTickers returns FiatRates ticker prices for the specified block or timestamp.
func (s *PublicServer) apiTickers(r *http.Request, apiVersion int) (interface{}, error) { func (s *PublicServer) apiTickers(r *http.Request, apiVersion int) (interface{}, error) {
var result *db.ResultTickerAsString var result *api.FiatTicker
var err error var err error
currency := strings.ToLower(r.URL.Query().Get("currency")) currency := strings.ToLower(r.URL.Query().Get("currency"))
@ -1251,7 +1251,7 @@ func (s *PublicServer) apiTickers(r *http.Request, apiVersion int) (interface{},
// apiMultiTickers returns FiatRates ticker prices for the specified comma separated list of timestamps. // apiMultiTickers returns FiatRates ticker prices for the specified comma separated list of timestamps.
func (s *PublicServer) apiMultiTickers(r *http.Request, apiVersion int) (interface{}, error) { func (s *PublicServer) apiMultiTickers(r *http.Request, apiVersion int) (interface{}, error) {
var result []db.ResultTickerAsString var result []api.FiatTicker
var err error var err error
currency := strings.ToLower(r.URL.Query().Get("currency")) currency := strings.ToLower(r.URL.Query().Get("currency"))

View File

@ -14,6 +14,7 @@ import (
"testing" "testing"
"time" "time"
"github.com/flier/gorocksdb"
"github.com/golang/glog" "github.com/golang/glog"
"github.com/gorilla/websocket" "github.com/gorilla/websocket"
"github.com/martinboehm/btcutil/chaincfg" "github.com/martinboehm/btcutil/chaincfg"
@ -161,51 +162,56 @@ func newPostRequest(u string, body string) *http.Request {
return r return r
} }
func insertFiatRate(date string, rates map[string]float64, d *db.RocksDB) error { func insertFiatRate(date string, rates map[string]float32, d *db.RocksDB) error {
convertedDate, err := db.FiatRatesConvertDate(date) convertedDate, err := db.FiatRatesConvertDate(date)
if err != nil { if err != nil {
return err return err
} }
ticker := &db.CurrencyRatesTicker{ ticker := &db.CurrencyRatesTicker{
Timestamp: convertedDate, Timestamp: *convertedDate,
Rates: rates, Rates: rates,
} }
return d.FiatRatesStoreTicker(ticker) wb := gorocksdb.NewWriteBatch()
defer wb.Destroy()
if err := d.FiatRatesStoreTicker(wb, ticker); err != nil {
return err
}
return d.WriteBatch(wb)
} }
// initTestFiatRates initializes test data for /api/v2/tickers endpoint // initTestFiatRates initializes test data for /api/v2/tickers endpoint
func initTestFiatRates(d *db.RocksDB) error { func initTestFiatRates(d *db.RocksDB) error {
if err := insertFiatRate("20180320020000", map[string]float64{ if err := insertFiatRate("20180320020000", map[string]float32{
"usd": 2000.0, "usd": 2000.0,
"eur": 1300.0, "eur": 1300.0,
}, d); err != nil { }, d); err != nil {
return err return err
} }
if err := insertFiatRate("20180320030000", map[string]float64{ if err := insertFiatRate("20180320030000", map[string]float32{
"usd": 2001.0, "usd": 2001.0,
"eur": 1301.0, "eur": 1301.0,
}, d); err != nil { }, d); err != nil {
return err return err
} }
if err := insertFiatRate("20180320040000", map[string]float64{ if err := insertFiatRate("20180320040000", map[string]float32{
"usd": 2002.0, "usd": 2002.0,
"eur": 1302.0, "eur": 1302.0,
}, d); err != nil { }, d); err != nil {
return err return err
} }
if err := insertFiatRate("20180321055521", map[string]float64{ if err := insertFiatRate("20180321055521", map[string]float32{
"usd": 2003.0, "usd": 2003.0,
"eur": 1303.0, "eur": 1303.0,
}, d); err != nil { }, d); err != nil {
return err return err
} }
if err := insertFiatRate("20191121140000", map[string]float64{ if err := insertFiatRate("20191121140000", map[string]float32{
"usd": 7814.5, "usd": 7814.5,
"eur": 7100.0, "eur": 7100.0,
}, d); err != nil { }, d); err != nil {
return err return err
} }
return insertFiatRate("20191121143015", map[string]float64{ return insertFiatRate("20191121143015", map[string]float32{
"usd": 7914.5, "usd": 7914.5,
"eur": 7134.1, "eur": 7134.1,
}, d) }, d)

View File

@ -421,7 +421,7 @@ var requestHandlers = map[string]func(*WebsocketServer, *websocketChannel, *webs
}{} }{}
err = json.Unmarshal(req.Params, &r) err = json.Unmarshal(req.Params, &r)
if err == nil { if err == nil {
rv, err = s.getFiatRatesTickersList(r.Timestamp) rv, err = s.getAvailableVsCurrencies(r.Timestamp)
} }
return return
}, },
@ -960,7 +960,7 @@ func (s *WebsocketServer) OnNewTx(tx *bchain.MempoolTx) {
} }
} }
func (s *WebsocketServer) broadcastTicker(currency string, rates map[string]float64) { func (s *WebsocketServer) broadcastTicker(currency string, rates map[string]float32) {
as, ok := s.fiatRatesSubscriptions[currency] as, ok := s.fiatRatesSubscriptions[currency]
if ok && len(as) > 0 { if ok && len(as) > 0 {
data := struct { data := struct {
@ -983,7 +983,7 @@ func (s *WebsocketServer) OnNewFiatRatesTicker(ticker *db.CurrencyRatesTicker) {
s.fiatRatesSubscriptionsLock.Lock() s.fiatRatesSubscriptionsLock.Lock()
defer s.fiatRatesSubscriptionsLock.Unlock() defer s.fiatRatesSubscriptionsLock.Unlock()
for currency, rate := range ticker.Rates { for currency, rate := range ticker.Rates {
s.broadcastTicker(currency, map[string]float64{currency: rate}) s.broadcastTicker(currency, map[string]float32{currency: rate})
} }
s.broadcastTicker(allFiatRates, ticker.Rates) s.broadcastTicker(allFiatRates, ticker.Rates)
} }
@ -998,7 +998,7 @@ func (s *WebsocketServer) getFiatRatesForTimestamps(timestamps []int64, currenci
return ret, err return ret, err
} }
func (s *WebsocketServer) getFiatRatesTickersList(timestamp int64) (interface{}, error) { func (s *WebsocketServer) getAvailableVsCurrencies(timestamp int64) (interface{}, error) {
ret, err := s.api.GetFiatRatesTickersList(timestamp) ret, err := s.api.GetAvailableVsCurrencies(timestamp)
return ret, err return ret, err
} }