From 2435374a113a0e7546e7eadfc92bafc4bce2da74 Mon Sep 17 00:00:00 2001 From: Andras Banki-Horvath Date: Fri, 2 May 2025 12:44:53 +0200 Subject: [PATCH] fiat: add support for using coinbase to fetch hourly/daily prices --- fiat/coinbase_api.go | 161 +++++++++++++++++++++++++++++++++++++++++++ fiat/prices.go | 19 +++++ fiat/prices_test.go | 57 +++++++++++++++ go.mod | 1 + go.sum | 4 ++ 5 files changed, 242 insertions(+) create mode 100644 fiat/coinbase_api.go diff --git a/fiat/coinbase_api.go b/fiat/coinbase_api.go new file mode 100644 index 0000000..622e6bc --- /dev/null +++ b/fiat/coinbase_api.go @@ -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//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 +} diff --git a/fiat/prices.go b/fiat/prices.go index 97e4f18..3c7eaad 100644 --- a/fiat/prices.go +++ b/fiat/prices.go @@ -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 diff --git a/fiat/prices_test.go b/fiat/prices_test.go index 12668f9..4f603e6 100644 --- a/fiat/prices_test.go +++ b/fiat/prices_test.go @@ -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) +} diff --git a/go.mod b/go.mod index 467a3ab..df97b24 100644 --- a/go.mod +++ b/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 diff --git a/go.sum b/go.sum index 3a01b7b..49e0854 100644 --- a/go.sum +++ b/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=