mirror of
https://github.com/lightninglabs/faraday.git
synced 2026-08-19 13:18:36 +02:00
fiat: add GetPrice function to lookup fiat values for a set requests
This commit is contained in:
parent
22f9bf97a9
commit
fdc4f498e2
3 changed files with 460 additions and 0 deletions
|
|
@ -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
144
fiat/prices.go
Normal 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
237
fiat/prices_test.go
Normal 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)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue