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 (
"context"
"fmt"
"math"
"math/big"
"strconv"
"time"
@ -11,6 +10,7 @@ import (
influxdb2 "github.com/influxdata/influxdb-client-go/v2"
"github.com/influxdata/influxdb-client-go/v2/api"
"github.com/influxdata/influxdb-client-go/v2/api/write"
"github.com/shopspring/decimal"
wormscanNotionalCache "github.com/wormhole-foundation/wormhole-explorer/common/client/cache/notional"
"github.com/wormhole-foundation/wormhole-explorer/common/domain"
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{
Logger: m.logger,
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)
if err != nil {
return 0, err
return decimal.NewFromInt(0), err
}
return priceData.NotionalUsd, nil
@ -190,25 +190,6 @@ func (m *Metric) volumeMeasurement(ctx context.Context, vaa *sdk.VAA) error {
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.
//
// Some VAAs will not generate a measurement, so the caller must always check
@ -239,7 +220,7 @@ type MakePointForVaaVolumeParams struct {
Vaa *sdk.VAA
// 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 *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
notionalBigInt, err := floatToBigInt(notionalUSD)
if err != nil {
return nil, fmt.Errorf("failed to convert notional to big integer: %w", err)
}
notionalBigInt := notionalUSD.
Truncate(8).
Mul(decimal.NewFromInt(1e8)).
BigInt()
// Calculate the volume, with an implicit precision of 8 decimals
var volume big.Int

View File

@ -9,6 +9,7 @@ import (
"time"
"github.com/go-redis/redis/v8"
"github.com/shopspring/decimal"
"github.com/wormhole-foundation/wormhole-explorer/common/domain"
"go.uber.org/zap"
)
@ -32,8 +33,8 @@ type NotionalLocalCacheReadable interface {
// PriceData is the notional value of assets in cache.
type PriceData struct {
NotionalUsd float64 `json:"notional_usd"`
UpdatedAt time.Time `json:"updated_at"`
NotionalUsd decimal.Decimal `json:"notional_usd"`
UpdatedAt time.Time `json:"updated_at"`
}
// 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
CoingeckoID string
Decimals int64
price float64
}
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/coinGeckoId\:/CoingeckoID\:/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

View File

@ -12,6 +12,7 @@ import (
"time"
"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/common/domain"
sdk "github.com/wormhole-foundation/wormhole/sdk/vaa"
@ -151,21 +152,15 @@ func (lp *LineParser) ParseLine(line []byte) (string, error) {
{
p := metric.MakePointForVaaVolumeParams{
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
price, err := lp.PriceCache.GetPriceByTime(
int16(vaa.EmitterChain),
tokenMetadata.CoingeckoID,
timestamp,
)
price, err := lp.PriceCache.GetPriceByTime(tokenMetadata.CoingeckoID, timestamp)
if err != nil {
return 0, err
return decimal.NewFromInt(0), err
}
// convert to float64
result, _ := price.Float64()
return result, nil
return price, 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
day = time.Date(day.Year(), day.Month(), day.Day(), 0, 0, 0, 0, time.UTC)
// generate key
key := fmt.Sprintf("%d%s%d", chainID, symbol, day.UnixMilli())
// look up the price
key := fmt.Sprintf("%s%d", coingeckoID, day.UnixMilli())
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
func (cpc *CoinPricesCache) InitCache() {
// open prices file
file := cpc.filename
f, err := os.Open(file)
@ -45,23 +47,28 @@ func (cpc *CoinPricesCache) InitCache() {
panic(err)
}
defer f.Close()
// read line by line
scanner := bufio.NewScanner(f)
for scanner.Scan() {
row := scanner.Text()
// split line by comma
row := scanner.Text()
tokens := strings.Split(row, ",")
if len(tokens) != 5 {
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])
if err != nil {
msg := fmt.Sprintf("failed to parse price err=%v line=%s", err, row)
panic(msg)
}
cpc.Prices[key] = price
}

View File

@ -7,6 +7,7 @@ import (
"net/http"
"strings"
"github.com/shopspring/decimal"
"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.
type NotionalUSD struct {
Price *float64 `json:"usd"`
Price *decimal.Decimal `json:"usd"`
}
// GetNotionalUSD returns the notional USD value for the given ids