github.com/cryptohub-digital/blockbook-fork@v0.0.0-20230713133354-673c927af7f1/fiat/fiat_rates.go (about)

     1  package fiat
     2  
     3  import (
     4  	"encoding/json"
     5  	"errors"
     6  	"fmt"
     7  	"math/rand"
     8  	"strings"
     9  	"sync"
    10  	"time"
    11  
    12  	"github.com/cryptohub-digital/blockbook-fork/common"
    13  	"github.com/cryptohub-digital/blockbook-fork/db"
    14  	"github.com/golang/glog"
    15  )
    16  
    17  const currentTickersKey = "CurrentTickers"
    18  const hourlyTickersKey = "HourlyTickers"
    19  const fiveMinutesTickersKey = "FiveMinutesTickers"
    20  
    21  const highGranularityVsCurrency = "usd"
    22  
    23  const secondsInDay = 24 * 60 * 60
    24  const secondsInHour = 60 * 60
    25  const secondsInFiveMinutes = 5 * 60
    26  
    27  // OnNewFiatRatesTicker is used to send notification about a new FiatRates ticker
    28  type OnNewFiatRatesTicker func(ticker *common.CurrencyRatesTicker)
    29  
    30  // RatesDownloaderInterface provides method signatures for a specific fiat rates downloader
    31  type RatesDownloaderInterface interface {
    32  	CurrentTickers() (*common.CurrencyRatesTicker, error)
    33  	HourlyTickers() (*[]common.CurrencyRatesTicker, error)
    34  	FiveMinutesTickers() (*[]common.CurrencyRatesTicker, error)
    35  	UpdateHistoricalTickers() error
    36  	UpdateHistoricalTokenTickers() error
    37  }
    38  
    39  // FiatRates is used to fetch and refresh fiat rates
    40  type FiatRates struct {
    41  	Enabled                bool
    42  	periodSeconds          int64
    43  	db                     *db.RocksDB
    44  	timeFormat             string
    45  	callbackOnNewTicker    OnNewFiatRatesTicker
    46  	downloader             RatesDownloaderInterface
    47  	downloadTokens         bool
    48  	provider               string
    49  	allowedVsCurrencies    string
    50  	mux                    sync.RWMutex
    51  	currentTicker          *common.CurrencyRatesTicker
    52  	hourlyTickers          map[int64]*common.CurrencyRatesTicker
    53  	hourlyTickersFrom      int64
    54  	hourlyTickersTo        int64
    55  	fiveMinutesTickers     map[int64]*common.CurrencyRatesTicker
    56  	fiveMinutesTickersFrom int64
    57  	fiveMinutesTickersTo   int64
    58  	dailyTickers           map[int64]*common.CurrencyRatesTicker
    59  	dailyTickersFrom       int64
    60  	dailyTickersTo         int64
    61  }
    62  
    63  // NewFiatRates initializes the FiatRates handler
    64  func NewFiatRates(db *db.RocksDB, configFileContent []byte, metrics *common.Metrics, callback OnNewFiatRatesTicker) (*FiatRates, error) {
    65  	var config struct {
    66  		FiatRates             string `json:"fiat_rates"`
    67  		FiatRatesParams       string `json:"fiat_rates_params"`
    68  		FiatRatesVsCurrencies string `json:"fiat_rates_vs_currencies"`
    69  	}
    70  	err := json.Unmarshal(configFileContent, &config)
    71  	if err != nil {
    72  		return nil, fmt.Errorf("error parsing config file, %v", err)
    73  	}
    74  
    75  	var fr = &FiatRates{
    76  		provider:            config.FiatRates,
    77  		allowedVsCurrencies: config.FiatRatesVsCurrencies,
    78  	}
    79  
    80  	if config.FiatRates == "" || config.FiatRatesParams == "" {
    81  		glog.Infof("FiatRates config is empty, not downloading fiat rates")
    82  		fr.Enabled = false
    83  		return fr, nil
    84  	}
    85  
    86  	type fiatRatesParams struct {
    87  		URL                string `json:"url"`
    88  		Coin               string `json:"coin"`
    89  		PlatformIdentifier string `json:"platformIdentifier"`
    90  		PlatformVsCurrency string `json:"platformVsCurrency"`
    91  		PeriodSeconds      int64  `json:"periodSeconds"`
    92  	}
    93  	rdParams := &fiatRatesParams{}
    94  	err = json.Unmarshal([]byte(config.FiatRatesParams), &rdParams)
    95  	if err != nil {
    96  		return nil, err
    97  	}
    98  	if rdParams.PeriodSeconds == 0 {
    99  		return nil, errors.New("missing parameters")
   100  	}
   101  	fr.timeFormat = "02-01-2006"              // Layout string for FiatRates date formatting (DD-MM-YYYY)
   102  	fr.periodSeconds = rdParams.PeriodSeconds // Time period for syncing the latest market data
   103  	if fr.periodSeconds < 60 {                // minimum is one minute
   104  		fr.periodSeconds = 60
   105  	}
   106  	fr.db = db
   107  	fr.callbackOnNewTicker = callback
   108  	fr.downloadTokens = rdParams.PlatformIdentifier != "" && rdParams.PlatformVsCurrency != ""
   109  	if fr.downloadTokens {
   110  		common.TickerRecalculateTokenRate = strings.ToLower(db.GetInternalState().CoinShortcut) != rdParams.PlatformVsCurrency
   111  		common.TickerTokenVsCurrency = rdParams.PlatformVsCurrency
   112  	}
   113  	is := fr.db.GetInternalState()
   114  	if fr.provider == "coingecko" {
   115  		throttle := true
   116  		if callback == nil {
   117  			// a small hack - in tests the callback is not used, therefore there is no delay slowing down the test
   118  			throttle = false
   119  		}
   120  		fr.downloader = NewCoinGeckoDownloader(db, rdParams.URL, rdParams.Coin, rdParams.PlatformIdentifier, rdParams.PlatformVsCurrency, fr.allowedVsCurrencies, fr.timeFormat, metrics, throttle)
   121  		if is != nil {
   122  			is.HasFiatRates = true
   123  			is.HasTokenFiatRates = fr.downloadTokens
   124  			fr.Enabled = true
   125  
   126  			if err := fr.loadDailyTickers(); err != nil {
   127  				return nil, err
   128  			}
   129  
   130  			currentTickers, err := db.FiatRatesGetSpecialTickers(currentTickersKey)
   131  			if err != nil {
   132  				glog.Error("FiatRatesDownloader: get CurrentTickers from DB error ", err)
   133  			}
   134  			if currentTickers != nil && len(*currentTickers) > 0 {
   135  				fr.currentTicker = &(*currentTickers)[0]
   136  			}
   137  
   138  			hourlyTickers, err := db.FiatRatesGetSpecialTickers(hourlyTickersKey)
   139  			if err != nil {
   140  				glog.Error("FiatRatesDownloader: get HourlyTickers from DB error ", err)
   141  			}
   142  			fr.hourlyTickers, fr.hourlyTickersFrom, fr.hourlyTickersTo = fr.tickersToMap(hourlyTickers, secondsInHour)
   143  
   144  			fiveMinutesTickers, err := db.FiatRatesGetSpecialTickers(fiveMinutesTickersKey)
   145  			if err != nil {
   146  				glog.Error("FiatRatesDownloader: get FiveMinutesTickers from DB error ", err)
   147  			}
   148  			fr.fiveMinutesTickers, fr.fiveMinutesTickersFrom, fr.fiveMinutesTickersTo = fr.tickersToMap(fiveMinutesTickers, secondsInFiveMinutes)
   149  
   150  		}
   151  	} else {
   152  		return nil, fmt.Errorf("unknown provider %q", fr.provider)
   153  	}
   154  	fr.logTickersInfo()
   155  	return fr, nil
   156  }
   157  
   158  // GetCurrentTicker returns current ticker
   159  func (fr *FiatRates) GetCurrentTicker(vsCurrency string, token string) *common.CurrencyRatesTicker {
   160  	fr.mux.RLock()
   161  	currentTicker := fr.currentTicker
   162  	fr.mux.RUnlock()
   163  	if currentTicker != nil && common.IsSuitableTicker(currentTicker, vsCurrency, token) {
   164  		return currentTicker
   165  	}
   166  	return nil
   167  }
   168  
   169  // getTokenTickersForTimestamps returns tickers for slice of timestamps, that contain requested vsCurrency and token
   170  func (fr *FiatRates) getTokenTickersForTimestamps(timestamps []int64, vsCurrency string, token string) (*[]*common.CurrencyRatesTicker, error) {
   171  	currentTicker := fr.GetCurrentTicker("", token)
   172  	tickers := make([]*common.CurrencyRatesTicker, len(timestamps))
   173  	var prevTicker *common.CurrencyRatesTicker
   174  	var prevTs int64
   175  	var err error
   176  	for i, t := range timestamps {
   177  		// check if the token is available in the current ticker - if not, return nil ticker instead of wasting time in costly DB searches
   178  		if currentTicker != nil {
   179  			var ticker *common.CurrencyRatesTicker
   180  			date := time.Unix(t, 0)
   181  			// if previously found ticker is newer than this one (token tickers may not be in DB for every day), skip search in DB
   182  			if prevTicker != nil && t >= prevTs && !date.After(prevTicker.Timestamp) {
   183  				ticker = prevTicker
   184  				prevTs = t
   185  			} else {
   186  				ticker, err = fr.db.FiatRatesFindTicker(&date, vsCurrency, token)
   187  				if err != nil {
   188  					return nil, err
   189  				}
   190  				prevTicker = ticker
   191  				prevTs = t
   192  			}
   193  			// if ticker not found in DB, use current ticker
   194  			if ticker == nil {
   195  				tickers[i] = currentTicker
   196  				prevTicker = currentTicker
   197  				prevTs = t
   198  			} else {
   199  				tickers[i] = ticker
   200  			}
   201  		}
   202  	}
   203  	return &tickers, nil
   204  }
   205  
   206  // GetTickersForTimestamps returns tickers for slice of timestamps, that contain requested vsCurrency and token
   207  func (fr *FiatRates) GetTickersForTimestamps(timestamps []int64, vsCurrency string, token string) (*[]*common.CurrencyRatesTicker, error) {
   208  	if !fr.Enabled {
   209  		return nil, nil
   210  	}
   211  	// token rates are not in memory, them load from DB
   212  	if token != "" {
   213  		return fr.getTokenTickersForTimestamps(timestamps, vsCurrency, token)
   214  	}
   215  	fr.mux.RLock()
   216  	defer fr.mux.RUnlock()
   217  	tickers := make([]*common.CurrencyRatesTicker, len(timestamps))
   218  	var prevTicker *common.CurrencyRatesTicker
   219  	var prevTs int64
   220  	for i, t := range timestamps {
   221  		dailyTs := ceilUnix(t, secondsInDay)
   222  		// use higher granularity only for non daily timestamps
   223  		if t != dailyTs {
   224  			if t >= fr.fiveMinutesTickersFrom && t <= fr.fiveMinutesTickersTo {
   225  				if ticker, found := fr.fiveMinutesTickers[ceilUnix(t, secondsInFiveMinutes)]; found && ticker != nil {
   226  					if common.IsSuitableTicker(ticker, vsCurrency, token) {
   227  						tickers[i] = ticker
   228  						continue
   229  					}
   230  				}
   231  			}
   232  			if t >= fr.hourlyTickersFrom && t <= fr.hourlyTickersTo {
   233  				if ticker, found := fr.hourlyTickers[ceilUnix(t, secondsInHour)]; found && ticker != nil {
   234  					if common.IsSuitableTicker(ticker, vsCurrency, token) {
   235  						tickers[i] = ticker
   236  						continue
   237  					}
   238  				}
   239  			}
   240  		}
   241  		if prevTicker != nil && t >= prevTs && t <= prevTicker.Timestamp.Unix() {
   242  			tickers[i] = prevTicker
   243  			continue
   244  		} else {
   245  			var found bool
   246  			if dailyTs < fr.dailyTickersFrom {
   247  				dailyTs = fr.dailyTickersFrom
   248  			}
   249  			var ticker *common.CurrencyRatesTicker
   250  			for ; dailyTs <= fr.dailyTickersTo; dailyTs += secondsInDay {
   251  				if ticker, found = fr.dailyTickers[dailyTs]; found && ticker != nil {
   252  					if common.IsSuitableTicker(ticker, vsCurrency, token) {
   253  						tickers[i] = ticker
   254  						prevTicker = ticker
   255  						prevTs = t
   256  						break
   257  					} else {
   258  						found = false
   259  					}
   260  				}
   261  			}
   262  			if !found {
   263  				tickers[i] = fr.currentTicker
   264  				prevTicker = fr.currentTicker
   265  				prevTs = t
   266  			}
   267  		}
   268  	}
   269  	return &tickers, nil
   270  }
   271  func (fr *FiatRates) logTickersInfo() {
   272  	glog.Infof("fiat rates %s handler, %d (%s - %s) daily tickers, %d (%s - %s) hourly tickers, %d (%s - %s) 5 minute tickers", fr.provider,
   273  		len(fr.dailyTickers), time.Unix(fr.dailyTickersFrom, 0).Format("2006-01-02"), time.Unix(fr.dailyTickersTo, 0).Format("2006-01-02"),
   274  		len(fr.hourlyTickers), time.Unix(fr.hourlyTickersFrom, 0).Format("2006-01-02 15:04"), time.Unix(fr.hourlyTickersTo, 0).Format("2006-01-02 15:04"),
   275  		len(fr.fiveMinutesTickers), time.Unix(fr.fiveMinutesTickersFrom, 0).Format("2006-01-02 15:04"), time.Unix(fr.fiveMinutesTickersTo, 0).Format("2006-01-02 15:04"))
   276  }
   277  
   278  func roundTimeUnix(t time.Time, granularity int64) int64 {
   279  	return roundUnix(t.UTC().Unix(), granularity)
   280  }
   281  
   282  func roundUnix(t int64, granularity int64) int64 {
   283  	unix := t + (granularity >> 1)
   284  	return unix - unix%granularity
   285  }
   286  
   287  func ceilUnix(t int64, granularity int64) int64 {
   288  	unix := t + (granularity - 1)
   289  	return unix - unix%granularity
   290  }
   291  
   292  // loadDailyTickers loads daily tickers to cache
   293  func (fr *FiatRates) loadDailyTickers() error {
   294  	fr.mux.Lock()
   295  	defer fr.mux.Unlock()
   296  	fr.dailyTickers = make(map[int64]*common.CurrencyRatesTicker)
   297  	err := fr.db.FiatRatesGetAllTickers(func(ticker *common.CurrencyRatesTicker) error {
   298  		normalizedTime := roundTimeUnix(ticker.Timestamp, secondsInDay)
   299  		if normalizedTime == fr.dailyTickersFrom {
   300  			// there are multiple tickers on the first day, use only the first one
   301  			return nil
   302  		}
   303  		// remove token rates from cache to save memory (tickers with token rates are hundreds of kb big)
   304  		ticker.TokenRates = nil
   305  		if len(fr.dailyTickers) > 0 {
   306  			// check that there is a ticker for every day, if missing, set it from current value if missing
   307  			prevTime := normalizedTime
   308  			for {
   309  				prevTime -= secondsInDay
   310  				if _, found := fr.dailyTickers[prevTime]; found {
   311  					break
   312  				}
   313  				fr.dailyTickers[prevTime] = ticker
   314  			}
   315  		} else {
   316  			fr.dailyTickersFrom = normalizedTime
   317  		}
   318  		fr.dailyTickers[normalizedTime] = ticker
   319  		fr.dailyTickersTo = normalizedTime
   320  		return nil
   321  	})
   322  	return err
   323  }
   324  
   325  // setCurrentTicker sets current ticker
   326  func (fr *FiatRates) setCurrentTicker(t *common.CurrencyRatesTicker) {
   327  	fr.mux.Lock()
   328  	defer fr.mux.Unlock()
   329  	fr.currentTicker = t
   330  	fr.db.FiatRatesStoreSpecialTickers(currentTickersKey, &[]common.CurrencyRatesTicker{*t})
   331  }
   332  
   333  func (fr *FiatRates) tickersToMap(tickers *[]common.CurrencyRatesTicker, granularitySeconds int64) (map[int64]*common.CurrencyRatesTicker, int64, int64) {
   334  	if tickers == nil || len(*tickers) == 0 {
   335  		return make(map[int64]*common.CurrencyRatesTicker), 0, 0
   336  	}
   337  	m := make(map[int64]*common.CurrencyRatesTicker, len(*tickers))
   338  	from := int64(0)
   339  	to := int64(0)
   340  	for i := range *tickers {
   341  		ticker := (*tickers)[i]
   342  		normalizedTime := roundTimeUnix(ticker.Timestamp, granularitySeconds)
   343  		dailyTime := roundTimeUnix(ticker.Timestamp, secondsInDay)
   344  		dailyTicker, found := fr.dailyTickers[dailyTime]
   345  		if !found {
   346  			// if not found in historical tickers, use current ticker
   347  			dailyTicker = fr.currentTicker
   348  		}
   349  		if dailyTicker != nil {
   350  			// high granularity tickers are loaded only in one currency, add other currencies based on daily rate between fiat currencies
   351  			vsRate, foundVs := ticker.Rates[highGranularityVsCurrency]
   352  			dailyVsRate, foundDaily := dailyTicker.Rates[highGranularityVsCurrency]
   353  			if foundDaily && dailyVsRate != 0 && foundVs && vsRate != 0 {
   354  				for currency, rate := range dailyTicker.Rates {
   355  					if currency != highGranularityVsCurrency {
   356  						ticker.Rates[currency] = vsRate * rate / dailyVsRate
   357  					}
   358  				}
   359  			}
   360  		}
   361  		if len(m) > 0 {
   362  			if normalizedTime == from {
   363  				// there are multiple normalized tickers for the first entry, skip
   364  				continue
   365  			}
   366  			// check that there is a ticker for each period, set it from current value if missing
   367  			prevTime := normalizedTime
   368  			for {
   369  				prevTime -= granularitySeconds
   370  				if _, found := m[prevTime]; found {
   371  					break
   372  				}
   373  				m[prevTime] = &ticker
   374  			}
   375  		} else {
   376  			from = normalizedTime
   377  		}
   378  		m[normalizedTime] = &ticker
   379  		to = normalizedTime
   380  	}
   381  	return m, from, to
   382  }
   383  
   384  // setCurrentTicker sets hourly tickers
   385  func (fr *FiatRates) setHourlyTickers(t *[]common.CurrencyRatesTicker) {
   386  	fr.db.FiatRatesStoreSpecialTickers(hourlyTickersKey, t)
   387  	fr.mux.Lock()
   388  	defer fr.mux.Unlock()
   389  	fr.hourlyTickers, fr.hourlyTickersFrom, fr.hourlyTickersTo = fr.tickersToMap(t, secondsInHour)
   390  }
   391  
   392  // setCurrentTicker sets hourly tickers
   393  func (fr *FiatRates) setFiveMinutesTickers(t *[]common.CurrencyRatesTicker) {
   394  	fr.db.FiatRatesStoreSpecialTickers(fiveMinutesTickersKey, t)
   395  	fr.mux.Lock()
   396  	defer fr.mux.Unlock()
   397  	fr.fiveMinutesTickers, fr.fiveMinutesTickersFrom, fr.fiveMinutesTickersTo = fr.tickersToMap(t, secondsInFiveMinutes)
   398  }
   399  
   400  // RunDownloader periodically downloads current (every 15 minutes) and historical (once a day) tickers
   401  func (fr *FiatRates) RunDownloader() error {
   402  	glog.Infof("Starting %v FiatRates downloader...", fr.provider)
   403  	var lastHistoricalTickers time.Time
   404  	is := fr.db.GetInternalState()
   405  	tickerFromIs := fr.GetCurrentTicker("", "")
   406  	firstRun := true
   407  	for {
   408  		unix := time.Now().Unix()
   409  		next := unix + fr.periodSeconds
   410  		next -= next % fr.periodSeconds
   411  		// skip waiting for the period for the first run if there are no tickerFromIs or they are too old
   412  		if !firstRun || (tickerFromIs != nil && next-tickerFromIs.Timestamp.Unix() < fr.periodSeconds) {
   413  			// wait for the next run with a slight random value to avoid too many request at the same time
   414  			next += int64(rand.Intn(12))
   415  			time.Sleep(time.Duration(next-unix) * time.Second)
   416  		}
   417  		firstRun = false
   418  		currentTicker, err := fr.downloader.CurrentTickers()
   419  		if err != nil || currentTicker == nil {
   420  			glog.Error("FiatRatesDownloader: CurrentTickers error ", err)
   421  		} else {
   422  			fr.setCurrentTicker(currentTicker)
   423  			glog.Info("FiatRatesDownloader: CurrentTickers updated")
   424  			if fr.callbackOnNewTicker != nil {
   425  				fr.callbackOnNewTicker(currentTicker)
   426  			}
   427  		}
   428  		hourlyTickers, err := fr.downloader.HourlyTickers()
   429  		if err != nil || hourlyTickers == nil {
   430  			glog.Error("FiatRatesDownloader: HourlyTickers error ", err)
   431  		} else {
   432  			fr.setHourlyTickers(hourlyTickers)
   433  			glog.Info("FiatRatesDownloader: HourlyTickers updated")
   434  		}
   435  		fiveMinutesTickers, err := fr.downloader.FiveMinutesTickers()
   436  		if err != nil || fiveMinutesTickers == nil {
   437  			glog.Error("FiatRatesDownloader: FiveMinutesTickers error ", err)
   438  		} else {
   439  			fr.setFiveMinutesTickers(fiveMinutesTickers)
   440  			glog.Info("FiatRatesDownloader: FiveMinutesTickers updated")
   441  		}
   442  		now := time.Now().UTC()
   443  		// once a day, 1 hour after UTC midnight (to let the provider prepare historical rates) update historical tickers
   444  		if (now.YearDay() != lastHistoricalTickers.YearDay() || now.Year() != lastHistoricalTickers.Year()) && now.Hour() > 0 {
   445  			err = fr.downloader.UpdateHistoricalTickers()
   446  			if err != nil {
   447  				glog.Error("FiatRatesDownloader: UpdateHistoricalTickers error ", err)
   448  			} else {
   449  				lastHistoricalTickers = time.Now().UTC()
   450  				if err = fr.loadDailyTickers(); err != nil {
   451  					glog.Error("FiatRatesDownloader: loadDailyTickers error ", err)
   452  				} else {
   453  					ticker, found := fr.dailyTickers[fr.dailyTickersTo]
   454  					if !found || ticker == nil {
   455  						glog.Error("FiatRatesDownloader: dailyTickers not loaded")
   456  					} else {
   457  						glog.Infof("FiatRatesDownloader: UpdateHistoricalTickers finished, last ticker from %v", ticker.Timestamp)
   458  						fr.logTickersInfo()
   459  						if is != nil {
   460  							is.HistoricalFiatRatesTime = ticker.Timestamp
   461  						}
   462  					}
   463  				}
   464  				if fr.downloadTokens {
   465  					// UpdateHistoricalTokenTickers in a goroutine, it can take quite some time as there are many tokens
   466  					go func() {
   467  						err := fr.downloader.UpdateHistoricalTokenTickers()
   468  						if err != nil {
   469  							glog.Error("FiatRatesDownloader: UpdateHistoricalTokenTickers error ", err)
   470  						} else {
   471  							glog.Info("FiatRatesDownloader: UpdateHistoricalTokenTickers finished")
   472  							if is != nil {
   473  								is.HistoricalTokenFiatRatesTime = time.Now().UTC()
   474  							}
   475  						}
   476  					}()
   477  				}
   478  			}
   479  		}
   480  	}
   481  }