You cannot select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
cointop/pkg/api/impl/coingecko/coingecko.go

471 lines
12 KiB
Go

package coingecko
import (
"errors"
"fmt"
"math"
"strconv"
"strings"
"sync"
"time"
apitypes "github.com/cointop-sh/cointop/pkg/api/types"
"github.com/cointop-sh/cointop/pkg/api/util"
gecko "github.com/cointop-sh/cointop/pkg/api/vendors/coingecko/v3"
"github.com/cointop-sh/cointop/pkg/api/vendors/coingecko/v3/types"
geckoTypes "github.com/cointop-sh/cointop/pkg/api/vendors/coingecko/v3/types"
)
// ErrPingFailed is the error for when pinging the API fails
var ErrPingFailed = errors.New("failed to ping")
// ErrNotFound is the error when the target is not found
var ErrNotFound = errors.New("not found")
// Config config
type Config struct {
PerPage uint
MaxPages uint
}
// Service service
type Service struct {
client *gecko.Client
maxResultsPerPage uint
maxPages uint
cacheMap sync.Map
cachedRates *types.ExchangeRatesItem
}
// NewCoinGecko new service
func NewCoinGecko(config *Config) *Service {
maxResultsPerPage := 250 // absolute max
maxResults := uint(0)
maxPages := uint(10)
perPage := uint(100)
if config.PerPage > 0 {
perPage = config.PerPage
}
if config.MaxPages > 0 {
maxPages = config.MaxPages
maxResults = perPage * maxPages
maxPages = uint(math.Ceil(math.Max(float64(maxResults)/float64(maxResultsPerPage), 1)))
}
client := gecko.NewClient(nil)
svc := &Service{
client: client,
maxResultsPerPage: uint(math.Min(float64(maxResults), float64(maxResultsPerPage))),
maxPages: maxPages,
cacheMap: sync.Map{},
}
svc.cacheCoinsIDList()
return svc
}
// Ping ping API
func (s *Service) Ping() error {
if _, err := s.client.Ping(); err != nil {
return err
}
return nil
}
// GetAllCoinData gets all coin data. Need to paginate through all pages
func (s *Service) GetAllCoinData(convert string, ch chan []apitypes.Coin) error {
go func() {
defer close(ch)
for i := 0; i < int(s.maxPages); i++ {
if i > 0 {
time.Sleep(1 * time.Second)
}
coins, err := s.getPaginatedCoinData(convert, i, []string{})
if err != nil {
return
}
ch <- coins
}
}()
return nil
}
// GetCoinData gets all data of a coin.
func (s *Service) GetCoinData(name string, convert string) (apitypes.Coin, error) {
ret := apitypes.Coin{}
ids := []string{name}
coins, err := s.getPaginatedCoinData(convert, 0, ids)
if err != nil {
return ret, err
}
if len(coins) > 0 {
ret = coins[0]
}
return ret, nil
}
// GetCoinDataBatch gets all data of specified coins.
func (s *Service) GetCoinDataBatch(names []string, convert string) ([]apitypes.Coin, error) {
return s.getPaginatedCoinData(convert, 0, names)
}
// GetCoinGraphData gets coin graph data
func (s *Service) GetCoinGraphData(convert, symbol, name string, start, end int64) (apitypes.CoinGraph, error) {
ret := apitypes.CoinGraph{}
days := strconv.Itoa(util.CalcDays(start, end))
chart, err := s.client.CoinsIDMarketChart(s.coinNameToID(name), convert, days)
if err != nil {
return ret, err
}
var marketCap [][]float64
var priceCoin [][]float64
var priceBTC [][]float64
var volumeCoin [][]float64
if chart.Prices != nil {
for _, item := range *chart.Prices {
timestamp := float64(item[0])
price := float64(item[1])
priceCoin = append(priceCoin, []float64{
timestamp,
price,
})
}
}
ret.MarketCapByAvailableSupply = marketCap
ret.PriceBTC = priceBTC
ret.Price = priceCoin
ret.Volume = volumeCoin
return ret, nil
}
// GetCachedExchangeRates returns an indefinitely cached set of exchange rates
func (s *Service) GetExchangeRates(cached bool) (*types.ExchangeRatesItem, error) {
if s.cachedRates == nil || !cached {
rates, err := s.client.ExchangeRates()
if err != nil {
return nil, err
}
s.cachedRates = rates
}
return s.cachedRates, nil
}
// GetExchangeRate gets the current excange rate between two currencies
func (s *Service) GetExchangeRate(convertFrom, convertTo string, cached bool) (float64, error) {
convertFrom = strings.ToLower(convertFrom)
convertTo = strings.ToLower(convertTo)
if convertFrom == convertTo {
return 1.0, nil
}
rates, err := s.GetExchangeRates(cached)
if err != nil {
return 0, err
}
if rates == nil {
return 0, fmt.Errorf("expected rates, received nil")
}
// Combined rate is convertFrom->BTC->convertTo
fromRate, found := (*rates)[convertFrom]
if !found {
return 0, fmt.Errorf("unsupported currency conversion: %s", convertFrom)
}
toRate, found := (*rates)[convertTo]
if !found {
return 0, fmt.Errorf("unsupported currency conversion: %s", convertTo)
}
rate := toRate.Value / fromRate.Value
return rate, nil
}
// GetGlobalMarketGraphData gets global market graph data
func (s *Service) GetGlobalMarketGraphData(convert string, start int64, end int64) (apitypes.MarketGraph, error) {
days := strconv.Itoa(util.CalcDays(start, end))
ret := apitypes.MarketGraph{}
convertTo := strings.ToLower(convert)
if convertTo == "" {
convertTo = "usd"
}
graphData, err := s.client.GlobalCharts("usd", days)
if err != nil {
return ret, err
}
// This API does not appear to support vs_currency and only returns USD, so use ExchangeRates to convert
// TODO: watch out - this is not cached, so we hit the backend every time!
rate, err := s.GetExchangeRate("usd", convertTo, true)
if err != nil {
return ret, err
}
var marketCapUSD [][]float64
var marketVolumeUSD [][]float64
if graphData.Stats != nil {
for _, item := range *graphData.Stats {
marketCapUSD = append(marketCapUSD, []float64{
float64(item[0]),
float64(item[1]) * rate,
})
}
}
ret.MarketCapByAvailableSupply = marketCapUSD
ret.VolumeUSD = marketVolumeUSD
return ret, nil
}
// GetGlobalMarketData gets global market data
func (s *Service) GetGlobalMarketData(convert string) (apitypes.GlobalMarketData, error) {
convert = strings.ToLower(convert)
ret := apitypes.GlobalMarketData{}
market, err := s.client.Global()
if err != nil {
return ret, err
}
totalMarketCap := market.TotalMarketCap[convert]
totalVolume := market.TotalVolume[convert]
btcDominance := market.MarketCapPercentage["btc"]
ret = apitypes.GlobalMarketData{
TotalMarketCapUSD: totalMarketCap,
Total24HVolumeUSD: totalVolume,
BitcoinPercentageOfMarketCap: btcDominance,
ActiveCurrencies: int(market.ActiveCryptocurrencies),
ActiveAssets: 0,
ActiveMarkets: int(market.Markets),
}
return ret, nil
}
// Price returns the current price of the coin
func (s *Service) Price(name string, convert string) (float64, error) {
ids := []string{s.coinNameToID(name)}
convert = strings.ToLower(convert)
currencies := []string{convert}
priceList, err := s.client.SimplePrice(ids, currencies)
if err != nil {
return 0, err
}
for _, item := range *priceList {
if p, ok := item[convert]; ok {
return util.FormatPrice(float64(p), convert), nil
}
}
return 0, ErrNotFound
}
// CoinLink returns the URL link for the coin
func (s *Service) CoinLink(name string) string {
ID := s.coinNameToID(name)
return fmt.Sprintf("https://www.coingecko.com/en/coins/%s", ID)
}
// SupportedCurrencies returns a list of supported currencies
func (s *Service) SupportedCurrencies() []string {
// keep these in alphabetical order
return []string{
"AED",
"ARS",
"AUD",
"BDT",
"BHD",
"BMD",
"BNB",
"BRL",
"BTC",
"CAD",
"CHF",
"CLP",
"CNY",
"CZK",
"DKK",
"EOS",
"ETH",
"EUR",
"GBP",
"HKD",
"HUF",
"IDR",
"ILS",
"INR",
"JPY",
"KRW",
"KWD",
"LKR",
"MMK",
"MXN",
"MYR",
"NOK",
"NZD",
"PHP",
"PKR",
"PLN",
"RUB",
"SAR",
"SATS",
"SEK",
"SGD",
"THB",
"TRY",
"TWD",
"UAH",
"USD",
"VEF",
"VND",
"XAG",
"XDR",
"ZAR",
}
}
// cacheCoinsIDList fetches list of all coin IDS by name and symbols and caches it in a map for fast lookups
func (s *Service) cacheCoinsIDList() error {
list, err := s.client.CoinsList()
if err != nil {
return err
}
if list == nil {
return nil
}
var firstWords [][]string
for _, item := range *list {
keys := []string{
strings.ToLower(item.Name),
strings.ToLower(item.Symbol),
util.NameToSlug(item.Name),
}
parts := strings.Split(strings.ToLower(item.Name), " ")
if len(parts) > 1 {
if parts[1] == "coin" {
keys = append(keys, parts[0])
} else {
firstWords = append(firstWords, []string{parts[0], item.ID})
}
}
for _, key := range keys {
_, exists := s.cacheMap.Load(key)
if !exists {
s.cacheMap.Store(key, item.ID)
}
}
}
for _, parts := range firstWords {
_, exists := s.cacheMap.Load(parts[0])
if !exists {
s.cacheMap.Store(parts[0], parts[1])
}
}
return nil
}
// coinNameToID attempts to get coin ID based on coin name or coin symbol
func (s *Service) coinNameToID(name string) string {
id, ok := s.cacheMap.Load(strings.ToLower(strings.TrimSpace(name)))
if ok {
return id.(string)
}
return util.NameToSlug(name)
}
// getPaginatedCoinData fetches coin data from page offset
func (s *Service) getPaginatedCoinData(convert string, offset int, names []string) ([]apitypes.Coin, error) {
var ret []apitypes.Coin
page := offset + 1 // page starts at 1
sparkline := false
pcp := geckoTypes.PriceChangePercentageObject
priceChangePercentage := []string{
pcp.PCP1h,
pcp.PCP24h,
pcp.PCP7d,
pcp.PCP30d,
pcp.PCP1y,
}
order := geckoTypes.OrderTypeObject.MarketCapDesc
convertTo := strings.ToLower(convert)
if convertTo == "" {
convertTo = "usd"
}
ids := make([]string, len(names))
for i, name := range names {
ids[i] = s.coinNameToID(name)
}
list, err := s.client.CoinsMarket(convertTo, ids, order, int(s.maxResultsPerPage), page, sparkline, priceChangePercentage)
if err != nil {
return nil, err
}
if list != nil {
// for fetching "simple prices"
currencies := make([]string, len(*list))
for i, item := range *list {
currencies[i] = item.Name
}
for _, item := range *list {
price := item.CurrentPrice
var percentChange1H float64
var percentChange24H float64
var percentChange7D float64
var percentChange30D float64
var percentChange1Y float64
if item.PriceChangePercentage1hInCurrency != nil {
percentChange1H = *item.PriceChangePercentage1hInCurrency
}
if item.PriceChangePercentage24hInCurrency != nil {
percentChange24H = *item.PriceChangePercentage24hInCurrency
}
if item.PriceChangePercentage7dInCurrency != nil {
percentChange7D = *item.PriceChangePercentage7dInCurrency
}
if item.PriceChangePercentage30dInCurrency != nil {
percentChange30D = *item.PriceChangePercentage30dInCurrency
}
if item.PriceChangePercentage1yInCurrency != nil {
percentChange1Y = *item.PriceChangePercentage1yInCurrency
}
availableSupply := item.CirculatingSupply
totalSupply := item.TotalSupply
if totalSupply == 0 {
totalSupply = availableSupply
}
ret = append(ret, apitypes.Coin{
ID: util.FormatID(item.ID),
Name: util.FormatName(item.Name),
Symbol: util.FormatSymbol(item.Symbol),
Rank: util.FormatRank(item.MarketCapRank),
AvailableSupply: util.FormatSupply(availableSupply),
TotalSupply: util.FormatSupply(totalSupply),
MarketCap: util.FormatMarketCap(item.MarketCap),
Price: util.FormatPrice(price, convert),
PercentChange1H: util.FormatPercentChange(percentChange1H),
PercentChange24H: util.FormatPercentChange(percentChange24H),
PercentChange7D: util.FormatPercentChange(percentChange7D),
PercentChange30D: util.FormatPercentChange(percentChange30D),
PercentChange1Y: util.FormatPercentChange(percentChange1Y),
Volume24H: util.FormatVolume(item.TotalVolume),
LastUpdated: util.FormatLastUpdated(item.LastUpdated),
})
}
}
return ret, nil
}