mirror of
https://github.com/lightninglabs/faraday.git
synced 2026-08-13 12:33:35 +02:00
Merge pull request #218 from bhandras/coinbase-fiat-api
fiat: add support for using Coinbase to fetch hourly/daily prices
This commit is contained in:
commit
58ac281ffe
6 changed files with 243 additions and 1 deletions
161
fiat/coinbase_api.go
Normal file
161
fiat/coinbase_api.go
Normal file
|
|
@ -0,0 +1,161 @@
|
|||
package fiat
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"time"
|
||||
|
||||
"github.com/shopspring/decimal"
|
||||
)
|
||||
|
||||
const (
|
||||
coinbaseHistoryAPI = "https://api.exchange.coinbase.com/products/%s/candles"
|
||||
coinbaseDefaultPair = "BTC-USD"
|
||||
coinbaseCandleCap = 300 // max buckets.
|
||||
coinbaseGranHourSec = 3600 // 1‑hour buckets.
|
||||
coinbaseGranDaySec = 86400 // 1‑day buckets.
|
||||
coinbaseDefaultCurr = "USD"
|
||||
)
|
||||
|
||||
type coinbaseAPI struct {
|
||||
// granularity is the price granularity (must be GranularityHour or
|
||||
// GranularityDay for coinbase).
|
||||
granularity Granularity
|
||||
|
||||
// product is the Coinbase product pair (e.g. BTC-USD).
|
||||
product string
|
||||
|
||||
// client is the HTTP client used to make requests.
|
||||
client *http.Client
|
||||
}
|
||||
|
||||
// newCoinbaseAPI returns an implementation that satisfies fiatBackend.
|
||||
func newCoinbaseAPI(g Granularity) *coinbaseAPI {
|
||||
return &coinbaseAPI{
|
||||
granularity: g,
|
||||
product: coinbaseDefaultPair,
|
||||
client: &http.Client{
|
||||
Timeout: 10 * time.Second,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
// queryCoinbase performs one HTTP request for a single <300‑bucket window.
|
||||
func queryCoinbase(start, end time.Time, product string,
|
||||
g Granularity, cl *http.Client) ([]byte, error) {
|
||||
|
||||
url := fmt.Sprintf(coinbaseHistoryAPI, product) +
|
||||
fmt.Sprintf("?start=%s&end=%s&granularity=%d",
|
||||
start.Format(time.RFC3339),
|
||||
end.Format(time.RFC3339),
|
||||
int(g.aggregation.Seconds()))
|
||||
|
||||
// #nosec G107 – public data
|
||||
resp, err := cl.Get(url)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
return io.ReadAll(resp.Body)
|
||||
}
|
||||
|
||||
// parseCoinbaseData parses the JSON response from Coinbase's candles endpoint.
|
||||
//
|
||||
// Coinbase “product candles” endpoint
|
||||
//
|
||||
// GET https://api.exchange.coinbase.com/products/<product‑id>/candles
|
||||
//
|
||||
// Response body ─ array of fixed‑width arrays:
|
||||
//
|
||||
// [
|
||||
// [ time, low, high, open, close, volume ],
|
||||
// ...
|
||||
// ]
|
||||
//
|
||||
// Field meanings (per Coinbase docs [1]):
|
||||
// - time – UNIX epoch **seconds** marking the *start* of the bucket (UTC).
|
||||
// - low – lowest trade price during the bucket interval.
|
||||
// - high – highest trade price during the bucket interval.
|
||||
// - open – price of the first trade in the interval.
|
||||
// - close – price of the last trade in the interval.
|
||||
// - volume – amount of the base‑asset traded during the interval.
|
||||
//
|
||||
// Additional quirks
|
||||
// - Candles are returned in *reverse‑chronological* order (newest‑first).
|
||||
// - `granularity` must be one of 60, 300, 900, 3600, 21600, 86400 seconds.
|
||||
// - A single request can return at most 300 buckets; larger spans must be
|
||||
// paged by adjusting `start`/`end` query parameters.
|
||||
//
|
||||
// Example (1‑hour granularity, newest‑first):
|
||||
//
|
||||
// [
|
||||
// [1714632000, 64950.12, 65080.00, 65010.55, 65075.00, 84.213],
|
||||
// [1714628400, 64890.00, 65020.23, 64900.00, 64950.12, 92.441],
|
||||
// ...
|
||||
// ]
|
||||
//
|
||||
// [1] https://docs.cdp.coinbase.com/exchange/reference/exchangerestapi_getproductcandles
|
||||
func parseCoinbaseData(data []byte) ([]*Price, error) {
|
||||
var raw [][]float64
|
||||
if err := json.Unmarshal(data, &raw); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
prices := make([]*Price, 0, len(raw))
|
||||
for _, c := range raw {
|
||||
// Historical rate data may be incomplete. No data is published
|
||||
// for intervals where there are no ticks.
|
||||
if len(c) < 5 {
|
||||
continue
|
||||
}
|
||||
ts := time.Unix(int64(c[0]), 0).UTC()
|
||||
closePx := decimal.NewFromFloat(c[4])
|
||||
|
||||
prices = append(prices, &Price{
|
||||
Timestamp: ts,
|
||||
Price: closePx,
|
||||
Currency: coinbaseDefaultCurr,
|
||||
})
|
||||
}
|
||||
return prices, nil
|
||||
}
|
||||
|
||||
// rawPriceData satisfies the fiatBackend interface.
|
||||
func (c *coinbaseAPI) rawPriceData(ctx context.Context,
|
||||
startTime, endTime time.Time) ([]*Price, error) {
|
||||
|
||||
// Coinbase cap = 300 * granularity.
|
||||
chunk := c.granularity.aggregation * coinbaseCandleCap
|
||||
start := startTime.Truncate(c.granularity.aggregation)
|
||||
end := start.Add(chunk)
|
||||
if end.After(endTime) {
|
||||
end = endTime
|
||||
}
|
||||
|
||||
var all []*Price
|
||||
for start.Before(endTime) {
|
||||
query := func() ([]byte, error) {
|
||||
return queryCoinbase(
|
||||
start, end, c.product, c.granularity, c.client,
|
||||
)
|
||||
}
|
||||
|
||||
records, err := retryQuery(ctx, query, parseCoinbaseData)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
all = append(all, records...)
|
||||
|
||||
start = end
|
||||
end = start.Add(chunk)
|
||||
if end.After(endTime) {
|
||||
end = endTime
|
||||
}
|
||||
}
|
||||
|
||||
return all, nil
|
||||
}
|
||||
|
|
@ -105,6 +105,16 @@ func (cfg *PriceSourceConfig) validatePriceSourceConfig() error {
|
|||
if len(cfg.PricePoints) == 0 {
|
||||
return errPricePointsRequired
|
||||
}
|
||||
|
||||
case CoinbasePriceBackend:
|
||||
if cfg.Granularity == nil ||
|
||||
(*cfg.Granularity != GranularityHour &&
|
||||
*cfg.Granularity != GranularityDay) {
|
||||
|
||||
return fmt.Errorf("%w: coinbase supports hourly or "+
|
||||
"daily granularity only",
|
||||
errGranularityUnsupported)
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
|
|
@ -162,6 +172,9 @@ const (
|
|||
|
||||
// CoinGeckoPriceBackend uses CoinGecko's API for fiat price data.
|
||||
CoinGeckoPriceBackend
|
||||
|
||||
// CoinbasePriceBackend uses Coinbase's API for fiat price data.
|
||||
CoinbasePriceBackend
|
||||
)
|
||||
|
||||
var priceBackendNames = map[PriceBackend]string{
|
||||
|
|
@ -170,6 +183,7 @@ var priceBackendNames = map[PriceBackend]string{
|
|||
CoinDeskPriceBackend: "coindesk",
|
||||
CustomPriceBackend: "custom",
|
||||
CoinGeckoPriceBackend: "coingecko",
|
||||
CoinbasePriceBackend: "coinbase",
|
||||
}
|
||||
|
||||
// String returns the string representation of a price backend.
|
||||
|
|
@ -212,6 +226,11 @@ func NewPriceSource(cfg *PriceSourceConfig) (*PriceSource, error) {
|
|||
return &PriceSource{
|
||||
impl: &coinGeckoAPI{},
|
||||
}, nil
|
||||
|
||||
case CoinbasePriceBackend:
|
||||
return &PriceSource{
|
||||
impl: newCoinbaseAPI(*cfg.Granularity),
|
||||
}, nil
|
||||
}
|
||||
|
||||
return nil, errUnknownPriceBackend
|
||||
|
|
|
|||
|
|
@ -1,10 +1,13 @@
|
|||
package fiat
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"net/http"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/jarcoal/httpmock"
|
||||
"github.com/lightningnetwork/lnd/lnwire"
|
||||
"github.com/shopspring/decimal"
|
||||
"github.com/stretchr/testify/require"
|
||||
|
|
@ -245,3 +248,57 @@ func TestValidatePriceSourceConfig(t *testing.T) {
|
|||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestCoinbaseRawPriceData tests the rawPriceData method of the Coinbase API
|
||||
// implementation.
|
||||
func TestCoinbaseRawPriceData(t *testing.T) {
|
||||
now := time.Now().UTC().Truncate(time.Hour)
|
||||
start := now.Add(-time.Hour * 4)
|
||||
|
||||
// Stub HTTP client with httpmock (same pattern as CoinCap tests).
|
||||
mock := httpmock.NewMockTransport()
|
||||
client := &http.Client{Transport: mock}
|
||||
|
||||
// JSON response for the Coinbase API.
|
||||
const numCandles = 4
|
||||
candles := make([][]float64, numCandles)
|
||||
|
||||
for i := range candles {
|
||||
timestamp := start.Add(time.Duration(i) * time.Hour).Unix()
|
||||
|
||||
// Example values; tweak as needed.
|
||||
low := 45_000 + float64(i)
|
||||
high := 55_000 + float64(i)
|
||||
open := 0.0
|
||||
close := 50_000 + float64(i)
|
||||
vol := 0.0
|
||||
|
||||
candles[i] = []float64{
|
||||
float64(timestamp), low, high, open, close, vol,
|
||||
}
|
||||
}
|
||||
|
||||
expected := make([]*Price, numCandles)
|
||||
for i := range expected {
|
||||
expected[i] = &Price{
|
||||
Timestamp: start.Add(time.Hour * time.Duration(i)),
|
||||
Price: decimal.NewFromFloat(float64(50_000 + i)),
|
||||
Currency: "USD",
|
||||
}
|
||||
}
|
||||
|
||||
// Four hourly candles (close = 50000) returned.
|
||||
mock.RegisterResponder(
|
||||
"GET", `=~https://api.exchange.coinbase.com/.*`,
|
||||
httpmock.NewJsonResponderOrPanic(200, candles),
|
||||
)
|
||||
|
||||
api := newCoinbaseAPI(GranularityHour)
|
||||
api.client = client
|
||||
|
||||
ctx := context.Background()
|
||||
out, err := api.rawPriceData(ctx, start, now)
|
||||
require.NoError(t, err)
|
||||
|
||||
require.EqualValues(t, expected, out)
|
||||
}
|
||||
|
|
|
|||
1
go.mod
1
go.mod
|
|
@ -6,6 +6,7 @@ require (
|
|||
github.com/btcsuite/btcd/chaincfg/chainhash v1.1.0
|
||||
github.com/btcsuite/btclog/v2 v2.0.1-0.20250110154127-3ae4bf1cb318
|
||||
github.com/grpc-ecosystem/grpc-gateway/v2 v2.22.0
|
||||
github.com/jarcoal/httpmock v1.4.0
|
||||
github.com/jessevdk/go-flags v1.4.0
|
||||
github.com/lightninglabs/faraday/frdrpc v1.0.0
|
||||
github.com/lightninglabs/lndclient v0.19.0-2
|
||||
|
|
|
|||
4
go.sum
4
go.sum
|
|
@ -298,6 +298,8 @@ github.com/jackc/puddle v0.0.0-20190608224051-11cab39313c9/go.mod h1:m4B5Dj62Y0f
|
|||
github.com/jackc/puddle v1.1.3/go.mod h1:m4B5Dj62Y0fbyuIc15OsIqK0+JU8nkqQjsgx7dvjSWk=
|
||||
github.com/jackc/puddle/v2 v2.2.1 h1:RhxXJtFG022u4ibrCSMSiu5aOq1i77R3OHKNJj77OAk=
|
||||
github.com/jackc/puddle/v2 v2.2.1/go.mod h1:vriiEXHvEE654aYKXXjOvZM39qJ0q+azkZFrfEOc3H4=
|
||||
github.com/jarcoal/httpmock v1.4.0 h1:BvhqnH0JAYbNudL2GMJKgOHe2CtKlzJ/5rWKyp+hc2k=
|
||||
github.com/jarcoal/httpmock v1.4.0/go.mod h1:ftW1xULwo+j0R0JJkJIIi7UKigZUXCLLanykgjwBXL0=
|
||||
github.com/jessevdk/go-flags v0.0.0-20141203071132-1679536dcc89/go.mod h1:4FA24M0QyGHXBuZZK/XkWh8h0e1EYbRYJSGM75WSRxI=
|
||||
github.com/jessevdk/go-flags v1.4.0 h1:4IU2WS7AumrZ/40jfhf4QVDMsQwqA7VEHozFRrGARJA=
|
||||
github.com/jessevdk/go-flags v1.4.0/go.mod h1:4FA24M0QyGHXBuZZK/XkWh8h0e1EYbRYJSGM75WSRxI=
|
||||
|
|
@ -408,6 +410,8 @@ github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWE
|
|||
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
|
||||
github.com/matttproud/golang_protobuf_extensions v1.0.1 h1:4hp9jkHxhMHkqkrB3Ix0jegS5sx/RkqARlsWZ6pIwiU=
|
||||
github.com/matttproud/golang_protobuf_extensions v1.0.1/go.mod h1:D8He9yQNgCq6Z5Ld7szi9bcBfOoFv/3dc6xSMkL2PC0=
|
||||
github.com/maxatome/go-testdeep v1.14.0 h1:rRlLv1+kI8eOI3OaBXZwb3O7xY3exRzdW5QyX48g9wI=
|
||||
github.com/maxatome/go-testdeep v1.14.0/go.mod h1:lPZc/HAcJMP92l7yI6TRz1aZN5URwUBUAfUNvrclaNM=
|
||||
github.com/miekg/dns v1.1.43 h1:JKfpVSCB84vrAmHzyrsxB5NAr5kLoMXZArPSw7Qlgyg=
|
||||
github.com/miekg/dns v1.1.43/go.mod h1:+evo5L0630/F6ca/Z9+GAqzhjGyn8/c+TBaOyfEl0V4=
|
||||
github.com/mitchellh/mapstructure v1.4.1 h1:CpVNEelQCZBooIPDn+AR3NpivK/TIKU8bDxdASFVQag=
|
||||
|
|
|
|||
|
|
@ -25,7 +25,7 @@ const (
|
|||
// Please update release_notes.md when updating this!
|
||||
appMajor uint = 0
|
||||
appMinor uint = 2
|
||||
appPatch uint = 14
|
||||
appPatch uint = 15
|
||||
|
||||
// appPreRelease MUST only contain characters from semanticAlphabet
|
||||
// per the semantic versioning spec.
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue