diff --git a/common/metrics.go b/common/metrics.go
index 800979f12a..5e07f67f01 100644
--- a/common/metrics.go
+++ b/common/metrics.go
@@ -95,6 +95,7 @@ type Metrics struct {
FiatRatesFetchedUnits *prometheus.CounterVec `metric:"fiat_rates_fetched_units_total"`
FiatRatesFetchedTokens *prometheus.CounterVec `metric:"fiat_rates_fetched_tokens_total"`
FiatRatesUnable *prometheus.CounterVec `metric:"fiat_rates_unable_total"`
+ FiatRatesMissingDayLookups prometheus.Counter `metric:"fiat_rates_missing_day_lookups"`
AlternativeFeeProviderRequests *prometheus.CounterVec `metric:"alternative_fee_provider_requests"`
EthSyncRpcErrors *prometheus.CounterVec `metric:"eth_sync_rpc_errors"`
}
diff --git a/configs/metrics.yaml b/configs/metrics.yaml
index 487ab542fb..56494cdd73 100644
--- a/configs/metrics.yaml
+++ b/configs/metrics.yaml
@@ -368,6 +368,10 @@ metrics:
type: counter_vec
help: Fiat-rate series that could not be fetched, split by phase and reason (gap_too_large, fetch_failed, provider_banned, no_base_ticker, budget_exhausted)
labels: [phase, reason]
+ fiat_rates_missing_day_lookups:
+ name: blockbook_fiat_rates_missing_day_lookups
+ type: counter
+ help: Fiat rate lookups that found no stored historical ticker for the resolved day and returned an unavailable rate
alternative_fee_provider_requests:
name: blockbook_alternative_fee_provider_requests
type: counter_vec
diff --git a/fiat/fiat_rates.go b/fiat/fiat_rates.go
index 41ae6212ac..de9df56f3a 100644
--- a/fiat/fiat_rates.go
+++ b/fiat/fiat_rates.go
@@ -283,7 +283,6 @@ func (fr *FiatRates) GetTickersForTimestamps(timestamps []int64, vsCurrency stri
hourlyTickersFrom := fr.hourlyTickersFrom
hourlyTickersTo := fr.hourlyTickersTo
dailyTickers := fr.dailyTickers
- dailyTickersFrom := fr.dailyTickersFrom
dailyTickersTo := fr.dailyTickersTo
fr.mux.RUnlock()
@@ -315,28 +314,24 @@ func (fr *FiatRates) GetTickersForTimestamps(timestamps []int64, vsCurrency stri
tickers[i] = prevTicker
continue
} else {
- var found bool
- if dailyTs < dailyTickersFrom {
- dailyTs = dailyTickersFrom
- }
- var ticker *common.CurrencyRatesTicker
- for ; dailyTs <= dailyTickersTo; dailyTs += secondsInDay {
- if ticker, found = dailyTickers[dailyTs]; found && ticker != nil {
- if common.IsSuitableTicker(ticker, vsCurrency, token) {
- tickers[i] = ticker
- prevTicker = ticker
- prevTs = t
- break
- } else {
- found = false
- }
+ if dailyTs <= dailyTickersTo {
+ // serve only a ticker stored for the resolved day, otherwise keep nil
+ // so that the API returns -1
+ if ticker, found := dailyTickers[dailyTs]; found && ticker != nil &&
+ roundTimeUnix(ticker.Timestamp, secondsInDay) == dailyTs &&
+ common.IsSuitableTicker(ticker, vsCurrency, token) {
+ tickers[i] = ticker
+ prevTicker = ticker
+ prevTs = t
+ } else if fr.metrics != nil {
+ fr.metrics.FiatRatesMissingDayLookups.Inc()
}
+ continue
}
- if !found {
- tickers[i] = currentTicker
- prevTicker = currentTicker
- prevTs = t
- }
+ // timestamps after the stored history are served with the current ticker
+ tickers[i] = currentTicker
+ prevTicker = currentTicker
+ prevTs = t
}
}
return &tickers, nil
diff --git a/fiat/fiat_rates_test.go b/fiat/fiat_rates_test.go
index f7643607b7..952c4f65ff 100644
--- a/fiat/fiat_rates_test.go
+++ b/fiat/fiat_rates_test.go
@@ -16,6 +16,7 @@ import (
"github.com/golang/glog"
"github.com/linxGnu/grocksdb"
"github.com/martinboehm/btcutil/chaincfg"
+ dto "github.com/prometheus/client_model/go"
"github.com/trezor/blockbook/bchain"
"github.com/trezor/blockbook/bchain/coins/btc"
"github.com/trezor/blockbook/common"
@@ -420,6 +421,100 @@ func TestGetTickersForTimestamps_UsesGranularityAndFallback(t *testing.T) {
}
}
+func TestGetTickersForTimestamps_GapInHistoryReturnsNil(t *testing.T) {
+ const day = int64(secondsInDay)
+ day1 := &common.CurrencyRatesTicker{Timestamp: time.Unix(day, 0).UTC(), Rates: map[string]float32{"usd": 1, "czk": 10}}
+ day2 := &common.CurrencyRatesTicker{Timestamp: time.Unix(2*day, 0).UTC(), Rates: map[string]float32{"usd": 2, "czk": 20}}
+ // czk is missing since day3, the same shape as a partially failing historical update
+ day3 := &common.CurrencyRatesTicker{Timestamp: time.Unix(3*day, 0).UTC(), Rates: map[string]float32{"usd": 3}}
+ day4 := &common.CurrencyRatesTicker{Timestamp: time.Unix(4*day, 0).UTC(), Rates: map[string]float32{"usd": 4}}
+ currentTicker := &common.CurrencyRatesTicker{Timestamp: time.Unix(5*day, 0).UTC(), Rates: map[string]float32{"usd": 5, "czk": 50}}
+ metrics, err := common.GetMetrics("fakecoin")
+ if err != nil {
+ t.Fatalf("GetMetrics failed: %v", err)
+ }
+ fr := &FiatRates{
+ Enabled: true,
+ metrics: metrics,
+ currentTicker: currentTicker,
+ dailyTickers: map[int64]*common.CurrencyRatesTicker{
+ day: day1,
+ 2 * day: day2,
+ 3 * day: day3,
+ 4 * day: day4,
+ },
+ dailyTickersFrom: day,
+ dailyTickersTo: 4 * day,
+ }
+
+ tickers, err := fr.GetTickersForTimestamps([]int64{0, 50000, 100000, 200000, 350000}, "czk", "")
+ if err != nil {
+ t.Fatalf("GetTickersForTimestamps returned error: %v", err)
+ }
+ want := []*common.CurrencyRatesTicker{
+ nil, // before the stored history, there is no rate for the requested day, the API returns -1
+ day1, // resolved to the first stored day
+ day2, // within the stored history, the ticker of the requested day is returned
+ nil, // czk missing on the requested day, nil makes the API return -1
+ currentTicker, // beyond the stored history, the current ticker is the closest available rate
+ }
+ if tickers == nil || !reflect.DeepEqual(*tickers, want) {
+ t.Fatalf("unexpected tickers: got %+v, want %+v", tickers, want)
+ }
+
+ // a currency never present in the stored history resolves to nil within the historical range
+ tickers, err = fr.GetTickersForTimestamps([]int64{100000}, "xyz", "")
+ if err != nil {
+ t.Fatalf("GetTickersForTimestamps returned error: %v", err)
+ }
+ if tickers == nil || len(*tickers) != 1 || (*tickers)[0] != nil {
+ t.Fatalf("unexpected tickers for unknown currency: got %+v, want [nil]", tickers)
+ }
+
+ // other currencies within the gap days are not affected
+ tickers, err = fr.GetTickersForTimestamps([]int64{200000}, "usd", "")
+ if err != nil {
+ t.Fatalf("GetTickersForTimestamps returned error: %v", err)
+ }
+ if tickers == nil || len(*tickers) != 1 || (*tickers)[0] != day3 {
+ t.Fatalf("unexpected tickers for usd: got %+v, want day3 ticker", tickers)
+ }
+
+ // a day missing in the DB is filled with the next day ticker by loadDailyTickers;
+ // such day must be reported as unavailable even though the filling ticker contains
+ // the requested currency
+ fr.dailyTickers = map[int64]*common.CurrencyRatesTicker{
+ day: day1,
+ 2 * day: day3, // forward-fill of the missing day2
+ 3 * day: day3,
+ 4 * day: day4,
+ }
+ tickers, err = fr.GetTickersForTimestamps([]int64{100000}, "usd", "")
+ if err != nil {
+ t.Fatalf("GetTickersForTimestamps returned error: %v", err)
+ }
+ if tickers == nil || len(*tickers) != 1 || (*tickers)[0] != nil {
+ t.Fatalf("unexpected tickers for a missing day: got %+v, want [nil]", tickers)
+ }
+ // the next day with a stored ticker is still served normally
+ tickers, err = fr.GetTickersForTimestamps([]int64{200000}, "usd", "")
+ if err != nil {
+ t.Fatalf("GetTickersForTimestamps returned error: %v", err)
+ }
+ if tickers == nil || len(*tickers) != 1 || (*tickers)[0] != day3 {
+ t.Fatalf("unexpected tickers for the day after a missing day: got %+v, want day3 ticker", tickers)
+ }
+
+ // every nil result above must be counted by the missing day lookups metric
+ var m dto.Metric
+ if err := metrics.FiatRatesMissingDayLookups.Write(&m); err != nil {
+ t.Fatalf("counter Write failed: %v", err)
+ }
+ if got := m.GetCounter().GetValue(); got != 4 {
+ t.Fatalf("unexpected missing day lookups count: got %v, want 4", got)
+ }
+}
+
func TestGetTickersForTimestamps_ConcurrentReadersAndWriters(t *testing.T) {
fr := &FiatRates{Enabled: true}
diff --git a/openapi.yaml b/openapi.yaml
index 1d8e04fe87..7cd917fa39 100644
--- a/openapi.yaml
+++ b/openapi.yaml
@@ -772,11 +772,13 @@ paths:
operationId: getFiatTicker
summary: Get current or historical fiat rates.
description: |-
- Returns currency rates for the requested currency and date. If a rate
- is unavailable for the exact timestamp, the closest available rate can
- be returned. Responses include the actual rate timestamp. Without a
- currency parameter, all available currencies can be returned. A rate of
- -1 marks an unavailable or invalid currency for that timestamp.
+ Returns currency rates for the requested currency and date. Rates are
+ resolved forward to the nearest stored rate at or after the requested
+ timestamp within the same UTC day; responses include the actual rate
+ timestamp. A rate of -1 marks an invalid currency or a day with no
+ stored rate. Timestamps newer than the stored history return the
+ current rate. Without a currency parameter, all available currencies
+ can be returned.
Load estimate: Low to medium; specific currency lookups are cheap,
while omitted currency and token lookups increase response size.
diff --git a/server/public_ethereumtype_test.go b/server/public_ethereumtype_test.go
index 3fac995e55..ec91035d9c 100644
--- a/server/public_ethereumtype_test.go
+++ b/server/public_ethereumtype_test.go
@@ -27,7 +27,7 @@ func httpTestsEthereumType(t *testing.T, ts *httptest.Server) {
status: http.StatusOK,
contentType: "text/html; charset=utf-8",
body: []string{
- `