fiat: add GetPrice function to lookup fiat values for a set requests

This commit is contained in:
carla 2020-04-28 20:59:07 +02:00
parent 22f9bf97a9
commit fdc4f498e2
No known key found for this signature in database
GPG key ID: 4CA7FE54A6213C91
3 changed files with 460 additions and 0 deletions

View file

@ -2,8 +2,13 @@ package fiat
import (
"context"
"encoding/json"
"errors"
"fmt"
"io/ioutil"
"net/http"
"sort"
"strconv"
"time"
"github.com/lightninglabs/faraday/utils"
@ -13,6 +18,9 @@ const (
// maxQueries is the total number of queries we allow a call to coincap
// api to be split up.
maxQueries = 5
// coinCapHistoryAPI is the endpoint we hit for historical price data.
coinCapHistoryAPI = "https://api.coincap.io/v2/assets/bitcoin/history"
)
var (
@ -100,6 +108,77 @@ type coinCapAPI struct {
convert func([]byte) ([]*usdPrice, error)
}
// newCoinCapAPI returns a coin cap api struct which can be used to query
// historical prices.
func newCoinCapAPI(granularity Granularity) *coinCapAPI {
return &coinCapAPI{
granularity: granularity,
query: queryCoinCap,
convert: parseCoinCapData,
}
}
// queryCoinCap returns a function which will httpQuery coincap for historical
// prices.
func queryCoinCap(start, end time.Time, granularity Granularity) ([]byte,
error) {
// The coincap api requires milliseconds.
startMs := start.Unix() * 1000
endMs := end.Unix() * 1000
url := fmt.Sprintf("%v?interval=%v&start=%v&end=%v",
coinCapHistoryAPI, granularity, startMs,
endMs)
log.Debugf("coincap url: %v", url)
// Query the http endpoint with the url provided
// #nosec G107
response, err := http.Get(url)
if err != nil {
return nil, err
}
defer response.Body.Close()
return ioutil.ReadAll(response.Body)
}
type coinCapResponse struct {
Data []*coinCapDataPoint `json:"data"`
}
type coinCapDataPoint struct {
Price string `json:"priceUsd"`
Timestamp int64 `json:"time"`
}
// parseCoinCapData parses http response data to usc price structs, using
// intermediary structs to get around parsing.
func parseCoinCapData(data []byte) ([]*usdPrice, error) {
var priceEntries coinCapResponse
if err := json.Unmarshal(data, &priceEntries); err != nil {
return nil, err
}
var usdRecords = make([]*usdPrice, len(priceEntries.Data))
// Convert each entry from the api to a usable record with a converted
// time and parsed price.
for i, entry := range priceEntries.Data {
floatPrice, err := strconv.ParseFloat(entry.Price, 64)
if err != nil {
return nil, err
}
usdRecords[i] = &usdPrice{
timestamp: time.Unix(0, entry.Timestamp),
price: floatPrice,
}
}
return usdRecords, nil
}
// GetPrices retrieves price information from coincap's api. If necessary, this
// call splits up the request for data into multiple requests. This is required
// because the more granular we want our price data to be, the smaller the

144
fiat/prices.go Normal file
View file

@ -0,0 +1,144 @@
package fiat
import (
"context"
"errors"
"time"
"github.com/lightningnetwork/lnd/lnwire"
)
var (
errNoPrices = errors.New("no price data provided")
errDuplicateLabel = errors.New("duplicate label in request set")
)
// PriceRequest describes a request for price information.
type PriceRequest struct {
// Identifier uniquely identifies the request.
Identifier string
// Value is the amount of BTC in msat.
Value lnwire.MilliSatoshi
// Timestamp is the time at which the price should be obtained.
Timestamp time.Time
}
// GetPrices gets a set of prices for a set of timestamped requests.
func GetPrices(ctx context.Context, requests []*PriceRequest,
granularity Granularity) (map[string]float64, error) {
if len(requests) == 0 {
return nil, nil
}
log.Debugf("getting prices for: %v requests", len(requests))
// Make sure that every label that in the request set is unique.
uniqueLabels := make(map[string]bool, len(requests))
for _, request := range requests {
_, ok := uniqueLabels[request.Identifier]
if ok {
return nil, errDuplicateLabel
}
uniqueLabels[request.Identifier] = true
}
// Get the minimum and maximum timestamps for our set of requests
// so that we can efficiently query for price data.
start, end := getQueryableDuration(requests)
// Get a set of historical price data points.
coinCapBackend := newCoinCapAPI(granularity)
priceData, err := coinCapBackend.GetPrices(ctx, start, end)
if err != nil {
return nil, err
}
// Prices will map transaction identifiers to their USD prices.
var prices = make(map[string]float64, len(requests))
for _, request := range requests {
price, err := getPrice(priceData, request)
if err != nil {
return nil, err
}
prices[request.Identifier] = price
}
return prices, nil
}
// getQueryableDuration gets the smallest and largest timestamp from a set of
// requests so that we can query for an appropriate set of price data.
func getQueryableDuration(requests []*PriceRequest) (time.Time, time.Time) {
var start, end time.Time
// Iterate through our min and max times and get the time range over
// which we need to get price information.
for _, req := range requests {
if start.IsZero() || start.After(req.Timestamp) {
start = req.Timestamp
}
if end.IsZero() || end.Before(req.Timestamp) {
end = req.Timestamp
}
}
return start, end
}
// msatToUSD converts a msat amount to usd. Note that this function coverts
// values to Bitcoin values, then gets the fiat price for that BTC value. If
// an amount < 1000 msat is given, a zero amount will be returned.
func msatToUSD(price float64, amt lnwire.MilliSatoshi) float64 {
btcBal := amt.ToBTC()
return price * btcBal
}
// getPrice gets the price for a timestamped request from a set of price data.
// This function expects the price data to be sorted with ascending timestamps.
// If request lies between two price points, we simply aggregate the two prices.
func getPrice(prices []*usdPrice, request *PriceRequest) (float64, error) {
var lastPrice float64
if len(prices) == 0 {
return 0, errNoPrices
}
for _, price := range prices {
// Check the optimistic case where the price timestamp matches
// our timestamp exactly.
if price.timestamp.Equal(request.Timestamp) {
return msatToUSD(price.price, request.Value), nil
}
// Once we reach a price point that is before our request's
// timestamp, the request's timestamp lies somewhere between
// the current price data point and the previous on.
if request.Timestamp.Before(price.timestamp) {
// If the last price is 0, the request is after the
// very first price data point. We do not aggregate in
// this case.
if lastPrice == 0 {
return msatToUSD(price.price, request.Value),
nil
}
// Otherwise, aggregate the price over the current data
// point and the next one.
price := (lastPrice + price.price) / 2
return msatToUSD(price, request.Value), nil
}
lastPrice = price.price
}
// If we have fallen through to this point, the price's timestamp falls
// after our last price data point's timestamp. In this case, we just
// return the price quoted on that price.
return msatToUSD(lastPrice, request.Value), nil
}

237
fiat/prices_test.go Normal file
View file

@ -0,0 +1,237 @@
package fiat
import (
"testing"
"time"
"github.com/lightningnetwork/lnd/lnwire"
)
// TestGetPrice tests getting price from a set of price data.
func TestGetPrice(t *testing.T) {
now := time.Now()
oneHourAgo := now.Add(time.Hour * -1)
twoHoursAgo := now.Add(time.Hour * -2)
tests := []struct {
name string
prices []*usdPrice
request *PriceRequest
expectedErr error
expectedPrice float64
}{
{
name: "no prices",
prices: nil,
request: &PriceRequest{
Value: 1,
Timestamp: oneHourAgo,
},
expectedErr: errNoPrices,
},
{
name: "timestamp before range",
prices: []*usdPrice{
{
timestamp: now,
price: 10000,
},
},
request: &PriceRequest{
Value: 1,
Timestamp: oneHourAgo,
},
expectedErr: nil,
expectedPrice: msatToUSD(10000, 1),
},
{
name: "timestamp equals data point timestamp",
prices: []*usdPrice{
{
timestamp: oneHourAgo,
price: 10000,
},
{
timestamp: now,
price: 10000,
},
},
request: &PriceRequest{
Value: 2,
Timestamp: now,
},
expectedErr: nil,
expectedPrice: msatToUSD(10000, 2),
},
{
name: "timestamp after range",
prices: []*usdPrice{
{
timestamp: twoHoursAgo,
price: 20000,
},
{
timestamp: oneHourAgo,
price: 10000,
},
},
request: &PriceRequest{
Value: 3,
Timestamp: now,
},
expectedErr: nil,
expectedPrice: msatToUSD(10000, 3),
},
{
name: "timestamp between prices, aggregated",
prices: []*usdPrice{
{
timestamp: twoHoursAgo,
price: 20000,
},
{
timestamp: now,
price: 10000,
},
},
request: &PriceRequest{
Value: 3,
Timestamp: oneHourAgo,
},
expectedErr: nil,
expectedPrice: msatToUSD((20000+10000)/2, 3),
},
}
for _, test := range tests {
test := test
t.Run(test.name, func(t *testing.T) {
price, err := getPrice(test.prices, test.request)
if err != test.expectedErr {
t.Fatalf("expected: %v, got: %v",
test.expectedErr, err)
}
if price != test.expectedPrice {
t.Fatalf("expected: %v, got: %v",
test.expectedPrice, price)
}
})
}
}
// TestGetQueryableDuration tests getting min/max from a set of timestamps.
func TestGetQueryableDuration(t *testing.T) {
now := time.Now()
yesterday := now.Add(time.Hour * -24)
tests := []struct {
name string
requests []*PriceRequest
expectStart time.Time
expectEnd time.Time
}{
{
name: "single ts",
requests: []*PriceRequest{
{
Timestamp: now,
},
},
expectStart: now,
expectEnd: now,
},
{
name: "different ts",
requests: []*PriceRequest{
{
Timestamp: now,
},
{
Timestamp: yesterday,
},
},
expectStart: yesterday,
expectEnd: now,
},
{
name: "duplicate ts",
requests: []*PriceRequest{
{
Timestamp: now,
},
{
Timestamp: now,
},
{
Timestamp: yesterday,
},
},
expectStart: yesterday,
expectEnd: now,
},
}
for _, test := range tests {
test := test
t.Run(test.name, func(t *testing.T) {
t.Parallel()
start, end := getQueryableDuration(test.requests)
if !start.Equal(test.expectStart) {
t.Fatalf("expected: %v, got: %v",
test.expectStart, start)
}
if !end.Equal(test.expectEnd) {
t.Fatalf("expected: %v, got: %v",
test.expectEnd, end)
}
})
}
}
// TestMSatToUsd tests conversion of msat to usd. This
func TestMSatToUsd(t *testing.T) {
tests := []struct {
name string
amount lnwire.MilliSatoshi
price float64
expectedFiat float64
}{
{
name: "1 sat not rounded down",
amount: 1000,
price: 10000,
expectedFiat: 0.0001,
},
{
name: "1 msat rounded down",
amount: 1,
price: 10000,
expectedFiat: 0,
},
{
name: "1 btc + 1 msat rounded down",
amount: 100000000001,
price: 10000,
expectedFiat: 10000,
},
}
for _, test := range tests {
test := test
t.Run(test.name, func(t *testing.T) {
t.Parallel()
amt := msatToUSD(test.price, test.amount)
if amt != test.expectedFiat {
t.Fatalf("expected: %v, got: %v",
test.expectedFiat, amt)
}
})
}
}