Use decimals instead of floats in price cache (#362)

## Description

Previously, the notional cache was using the `float64` type to manipulate price data. Since floating point types can't represent price data accurately, this commit changes the codebase to use a lossless representation (i.e.: `decimal.Decimal`).
This commit is contained in:
agodnic 2023-05-31 16:24:40 -03:00 committed by GitHub
parent 06d6950ad3
commit 0ec1cb86e2
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
8 changed files with 826 additions and 841 deletions

View File

@ -3,7 +3,6 @@ package metric
import ( import (
"context" "context"
"fmt" "fmt"
"math"
"math/big" "math/big"
"strconv" "strconv"
"time" "time"
@ -11,6 +10,7 @@ import (
influxdb2 "github.com/influxdata/influxdb-client-go/v2" influxdb2 "github.com/influxdata/influxdb-client-go/v2"
"github.com/influxdata/influxdb-client-go/v2/api" "github.com/influxdata/influxdb-client-go/v2/api"
"github.com/influxdata/influxdb-client-go/v2/api/write" "github.com/influxdata/influxdb-client-go/v2/api/write"
"github.com/shopspring/decimal"
wormscanNotionalCache "github.com/wormhole-foundation/wormhole-explorer/common/client/cache/notional" wormscanNotionalCache "github.com/wormhole-foundation/wormhole-explorer/common/client/cache/notional"
"github.com/wormhole-foundation/wormhole-explorer/common/domain" "github.com/wormhole-foundation/wormhole-explorer/common/domain"
sdk "github.com/wormhole-foundation/wormhole/sdk/vaa" sdk "github.com/wormhole-foundation/wormhole/sdk/vaa"
@ -156,11 +156,11 @@ func (m *Metric) volumeMeasurement(ctx context.Context, vaa *sdk.VAA) error {
p := MakePointForVaaVolumeParams{ p := MakePointForVaaVolumeParams{
Logger: m.logger, Logger: m.logger,
Vaa: vaa, Vaa: vaa,
TokenPriceFunc: func(symbol domain.Symbol, timestamp time.Time) (float64, error) { TokenPriceFunc: func(symbol domain.Symbol, timestamp time.Time) (decimal.Decimal, error) {
priceData, err := m.notionalCache.Get(symbol) priceData, err := m.notionalCache.Get(symbol)
if err != nil { if err != nil {
return 0, err return decimal.NewFromInt(0), err
} }
return priceData.NotionalUsd, nil return priceData.NotionalUsd, nil
@ -190,25 +190,6 @@ func (m *Metric) volumeMeasurement(ctx context.Context, vaa *sdk.VAA) error {
return nil return nil
} }
// toInt converts a float64 into a big.Int with 8 decimals of implicit precision.
//
// If we ever upgrade the notional cache to store prices as big integers,
// this gnarly function won't be needed anymore.
func floatToBigInt(f float64) (*big.Int, error) {
integral, frac := math.Modf(f)
strIntegral := strconv.FormatFloat(integral, 'f', 0, 64)
strFrac := fmt.Sprintf("%.8f", frac)[2:]
i, err := strconv.ParseInt(strIntegral+strFrac, 10, 64)
if err != nil {
return nil, err
}
return big.NewInt(i), nil
}
// MakePointForVaaCount generates a data point for the VAA count measurement. // MakePointForVaaCount generates a data point for the VAA count measurement.
// //
// Some VAAs will not generate a measurement, so the caller must always check // Some VAAs will not generate a measurement, so the caller must always check
@ -239,7 +220,7 @@ type MakePointForVaaVolumeParams struct {
Vaa *sdk.VAA Vaa *sdk.VAA
// TokenPriceFunc returns the price of the given token at the specified timestamp. // TokenPriceFunc returns the price of the given token at the specified timestamp.
TokenPriceFunc func(symbol domain.Symbol, timestamp time.Time) (float64, error) TokenPriceFunc func(symbol domain.Symbol, timestamp time.Time) (decimal.Decimal, error)
// Logger is an optional parameter, in case the caller wants additional visibility. // Logger is an optional parameter, in case the caller wants additional visibility.
Logger *zap.Logger Logger *zap.Logger
@ -321,10 +302,10 @@ func MakePointForVaaVolume(params *MakePointForVaaVolumeParams) (*write.Point, e
} }
// Convert the notional value to an integer with an implicit precision of 8 decimals // Convert the notional value to an integer with an implicit precision of 8 decimals
notionalBigInt, err := floatToBigInt(notionalUSD) notionalBigInt := notionalUSD.
if err != nil { Truncate(8).
return nil, fmt.Errorf("failed to convert notional to big integer: %w", err) Mul(decimal.NewFromInt(1e8)).
} BigInt()
// Calculate the volume, with an implicit precision of 8 decimals // Calculate the volume, with an implicit precision of 8 decimals
var volume big.Int var volume big.Int

View File

@ -9,6 +9,7 @@ import (
"time" "time"
"github.com/go-redis/redis/v8" "github.com/go-redis/redis/v8"
"github.com/shopspring/decimal"
"github.com/wormhole-foundation/wormhole-explorer/common/domain" "github.com/wormhole-foundation/wormhole-explorer/common/domain"
"go.uber.org/zap" "go.uber.org/zap"
) )
@ -32,8 +33,8 @@ type NotionalLocalCacheReadable interface {
// PriceData is the notional value of assets in cache. // PriceData is the notional value of assets in cache.
type PriceData struct { type PriceData struct {
NotionalUsd float64 `json:"notional_usd"` NotionalUsd decimal.Decimal `json:"notional_usd"`
UpdatedAt time.Time `json:"updated_at"` UpdatedAt time.Time `json:"updated_at"`
} }
// MarshalBinary implements the encoding.BinaryMarshaler interface. // MarshalBinary implements the encoding.BinaryMarshaler interface.

File diff suppressed because it is too large Load Diff

View File

@ -22,7 +22,6 @@ type TokenMetadata struct {
Symbol Symbol Symbol Symbol
CoingeckoID string CoingeckoID string
Decimals int64 Decimals int64
price float64
} }
var ( var (

View File

@ -9,4 +9,5 @@ sed -i 's/addr\:/TokenAddress\:/g' generated_mainnet_tokens.go
sed -i 's/symbol\:/Symbol\:/g' generated_mainnet_tokens.go sed -i 's/symbol\:/Symbol\:/g' generated_mainnet_tokens.go
sed -i 's/coinGeckoId\:/CoingeckoID\:/g' generated_mainnet_tokens.go sed -i 's/coinGeckoId\:/CoingeckoID\:/g' generated_mainnet_tokens.go
sed -i 's/decimals\:/Decimals\:/g' generated_mainnet_tokens.go sed -i 's/decimals\:/Decimals\:/g' generated_mainnet_tokens.go
sed -i 's/\, price\:.*}/}/g' generated_mainnet_tokens.go
sed -i 's/TokenMetadata{TokenChain/{TokenChain/g' generated_mainnet_tokens.go sed -i 's/TokenMetadata{TokenChain/{TokenChain/g' generated_mainnet_tokens.go

View File

@ -12,6 +12,7 @@ import (
"time" "time"
"github.com/influxdata/influxdb-client-go/v2/api/write" "github.com/influxdata/influxdb-client-go/v2/api/write"
"github.com/shopspring/decimal"
"github.com/wormhole-foundation/wormhole-explorer/analytic/metric" "github.com/wormhole-foundation/wormhole-explorer/analytic/metric"
"github.com/wormhole-foundation/wormhole-explorer/common/domain" "github.com/wormhole-foundation/wormhole-explorer/common/domain"
sdk "github.com/wormhole-foundation/wormhole/sdk/vaa" sdk "github.com/wormhole-foundation/wormhole/sdk/vaa"
@ -151,21 +152,15 @@ func (lp *LineParser) ParseLine(line []byte) (string, error) {
{ {
p := metric.MakePointForVaaVolumeParams{ p := metric.MakePointForVaaVolumeParams{
Vaa: vaa, Vaa: vaa,
TokenPriceFunc: func(_ domain.Symbol, timestamp time.Time) (float64, error) { TokenPriceFunc: func(_ domain.Symbol, timestamp time.Time) (decimal.Decimal, error) {
// fetch the historic price from cache // fetch the historic price from cache
price, err := lp.PriceCache.GetPriceByTime( price, err := lp.PriceCache.GetPriceByTime(tokenMetadata.CoingeckoID, timestamp)
int16(vaa.EmitterChain),
tokenMetadata.CoingeckoID,
timestamp,
)
if err != nil { if err != nil {
return 0, err return decimal.NewFromInt(0), err
} }
// convert to float64 return price, nil
result, _ := price.Float64()
return result, nil
}, },
} }

View File

@ -22,22 +22,24 @@ func NewCoinPricesCache(priceFile string) *CoinPricesCache {
} }
} }
func (c *CoinPricesCache) GetPriceByTime(chainID int16, symbol string, day time.Time) (*decimal.Decimal, error) { func (c *CoinPricesCache) GetPriceByTime(coingeckoID string, day time.Time) (decimal.Decimal, error) {
// remove hours and minutes // remove hours and minutes,
// times are in UTC // times are in UTC
day = time.Date(day.Year(), day.Month(), day.Day(), 0, 0, 0, 0, time.UTC) day = time.Date(day.Year(), day.Month(), day.Day(), 0, 0, 0, 0, time.UTC)
// generate key // look up the price
key := fmt.Sprintf("%d%s%d", chainID, symbol, day.UnixMilli()) key := fmt.Sprintf("%s%d", coingeckoID, day.UnixMilli())
if price, ok := c.Prices[key]; ok { if price, ok := c.Prices[key]; ok {
return &price, nil return price, nil
} }
return nil, fmt.Errorf("price not found for %s", key)
return decimal.NewFromInt(0), fmt.Errorf("price not found for %s", key)
} }
// load the csv file with prices into a map // load the csv file with prices into a map
func (cpc *CoinPricesCache) InitCache() { func (cpc *CoinPricesCache) InitCache() {
// open prices file // open prices file
file := cpc.filename file := cpc.filename
f, err := os.Open(file) f, err := os.Open(file)
@ -45,23 +47,28 @@ func (cpc *CoinPricesCache) InitCache() {
panic(err) panic(err)
} }
defer f.Close() defer f.Close()
// read line by line // read line by line
scanner := bufio.NewScanner(f) scanner := bufio.NewScanner(f)
for scanner.Scan() { for scanner.Scan() {
row := scanner.Text()
// split line by comma // split line by comma
row := scanner.Text()
tokens := strings.Split(row, ",") tokens := strings.Split(row, ",")
if len(tokens) != 5 { if len(tokens) != 5 {
panic(fmt.Errorf("invalid line: %s", row)) panic(fmt.Errorf("invalid line: %s", row))
} }
// build map key: chainid+coingecko_id+timestamp
key := fmt.Sprintf("%s%s%s", tokens[0], tokens[1], tokens[3])
// build map key: coingecko_id+timestamp
key := fmt.Sprintf("%s%s", tokens[1], tokens[3])
// parse price
price, err := decimal.NewFromString(tokens[4]) price, err := decimal.NewFromString(tokens[4])
if err != nil { if err != nil {
msg := fmt.Sprintf("failed to parse price err=%v line=%s", err, row) msg := fmt.Sprintf("failed to parse price err=%v line=%s", err, row)
panic(msg) panic(msg)
} }
cpc.Prices[key] = price cpc.Prices[key] = price
} }

View File

@ -7,6 +7,7 @@ import (
"net/http" "net/http"
"strings" "strings"
"github.com/shopspring/decimal"
"github.com/wormhole-foundation/wormhole-explorer/common/domain" "github.com/wormhole-foundation/wormhole-explorer/common/domain"
) )
@ -26,7 +27,7 @@ func NewCoingeckoAPI(url string) *CoingeckoAPI {
// NotionalUSD is the response from the coingecko API. // NotionalUSD is the response from the coingecko API.
type NotionalUSD struct { type NotionalUSD struct {
Price *float64 `json:"usd"` Price *decimal.Decimal `json:"usd"`
} }
// GetNotionalUSD returns the notional USD value for the given ids // GetNotionalUSD returns the notional USD value for the given ids