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:
András Bánki-Horváth 2025-05-02 13:31:08 +02:00 committed by GitHub
commit 58ac281ffe
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
6 changed files with 243 additions and 1 deletions

161
fiat/coinbase_api.go Normal file
View 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 // 1hour buckets.
coinbaseGranDaySec = 86400 // 1day 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 <300bucket 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/<productid>/candles
//
// Response body ─ array of fixedwidth 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 baseasset traded during the interval.
//
// Additional quirks
// - Candles are returned in *reversechronological* order (newestfirst).
// - `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 (1hour granularity, newestfirst):
//
// [
// [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
}

View file

@ -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

View file

@ -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
View file

@ -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
View file

@ -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=

View file

@ -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.