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:
parent
06d6950ad3
commit
0ec1cb86e2
|
@ -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
|
||||||
|
|
|
@ -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
|
@ -22,7 +22,6 @@ type TokenMetadata struct {
|
||||||
Symbol Symbol
|
Symbol Symbol
|
||||||
CoingeckoID string
|
CoingeckoID string
|
||||||
Decimals int64
|
Decimals int64
|
||||||
price float64
|
|
||||||
}
|
}
|
||||||
|
|
||||||
var (
|
var (
|
||||||
|
|
|
@ -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
|
|
@ -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
|
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -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
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -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
|
||||||
|
|
Loading…
Reference in New Issue