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 }