Skip to content

Commit 58ac281

Browse files
authored
Merge pull request #218 from bhandras/coinbase-fiat-api
fiat: add support for using Coinbase to fetch hourly/daily prices
2 parents a19ce78 + 62634b8 commit 58ac281

File tree

6 files changed

+243
-1
lines changed

6 files changed

+243
-1
lines changed

fiat/coinbase_api.go

Lines changed: 161 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,161 @@
1+
package fiat
2+
3+
import (
4+
"context"
5+
"encoding/json"
6+
"fmt"
7+
"io"
8+
"net/http"
9+
"time"
10+
11+
"github.com/shopspring/decimal"
12+
)
13+
14+
const (
15+
coinbaseHistoryAPI = "https://api.exchange.coinbase.com/products/%s/candles"
16+
coinbaseDefaultPair = "BTC-USD"
17+
coinbaseCandleCap = 300 // max buckets.
18+
coinbaseGranHourSec = 3600 // 1‑hour buckets.
19+
coinbaseGranDaySec = 86400 // 1‑day buckets.
20+
coinbaseDefaultCurr = "USD"
21+
)
22+
23+
type coinbaseAPI struct {
24+
// granularity is the price granularity (must be GranularityHour or
25+
// GranularityDay for coinbase).
26+
granularity Granularity
27+
28+
// product is the Coinbase product pair (e.g. BTC-USD).
29+
product string
30+
31+
// client is the HTTP client used to make requests.
32+
client *http.Client
33+
}
34+
35+
// newCoinbaseAPI returns an implementation that satisfies fiatBackend.
36+
func newCoinbaseAPI(g Granularity) *coinbaseAPI {
37+
return &coinbaseAPI{
38+
granularity: g,
39+
product: coinbaseDefaultPair,
40+
client: &http.Client{
41+
Timeout: 10 * time.Second,
42+
},
43+
}
44+
}
45+
46+
// queryCoinbase performs one HTTP request for a single <300‑bucket window.
47+
func queryCoinbase(start, end time.Time, product string,
48+
g Granularity, cl *http.Client) ([]byte, error) {
49+
50+
url := fmt.Sprintf(coinbaseHistoryAPI, product) +
51+
fmt.Sprintf("?start=%s&end=%s&granularity=%d",
52+
start.Format(time.RFC3339),
53+
end.Format(time.RFC3339),
54+
int(g.aggregation.Seconds()))
55+
56+
// #nosec G107 – public data
57+
resp, err := cl.Get(url)
58+
if err != nil {
59+
return nil, err
60+
}
61+
defer resp.Body.Close()
62+
63+
return io.ReadAll(resp.Body)
64+
}
65+
66+
// parseCoinbaseData parses the JSON response from Coinbase's candles endpoint.
67+
//
68+
// Coinbase “product candles” endpoint
69+
//
70+
// GET https://api.exchange.coinbase.com/products/<product‑id>/candles
71+
//
72+
// Response body ─ array of fixed‑width arrays:
73+
//
74+
// [
75+
// [ time, low, high, open, close, volume ],
76+
// ...
77+
// ]
78+
//
79+
// Field meanings (per Coinbase docs [1]):
80+
// - time – UNIX epoch **seconds** marking the *start* of the bucket (UTC).
81+
// - low – lowest trade price during the bucket interval.
82+
// - high – highest trade price during the bucket interval.
83+
// - open – price of the first trade in the interval.
84+
// - close – price of the last trade in the interval.
85+
// - volume – amount of the base‑asset traded during the interval.
86+
//
87+
// Additional quirks
88+
// - Candles are returned in *reverse‑chronological* order (newest‑first).
89+
// - `granularity` must be one of 60, 300, 900, 3600, 21600, 86400 seconds.
90+
// - A single request can return at most 300 buckets; larger spans must be
91+
// paged by adjusting `start`/`end` query parameters.
92+
//
93+
// Example (1‑hour granularity, newest‑first):
94+
//
95+
// [
96+
// [1714632000, 64950.12, 65080.00, 65010.55, 65075.00, 84.213],
97+
// [1714628400, 64890.00, 65020.23, 64900.00, 64950.12, 92.441],
98+
// ...
99+
// ]
100+
//
101+
// [1] https://docs.cdp.coinbase.com/exchange/reference/exchangerestapi_getproductcandles
102+
func parseCoinbaseData(data []byte) ([]*Price, error) {
103+
var raw [][]float64
104+
if err := json.Unmarshal(data, &raw); err != nil {
105+
return nil, err
106+
}
107+
108+
prices := make([]*Price, 0, len(raw))
109+
for _, c := range raw {
110+
// Historical rate data may be incomplete. No data is published
111+
// for intervals where there are no ticks.
112+
if len(c) < 5 {
113+
continue
114+
}
115+
ts := time.Unix(int64(c[0]), 0).UTC()
116+
closePx := decimal.NewFromFloat(c[4])
117+
118+
prices = append(prices, &Price{
119+
Timestamp: ts,
120+
Price: closePx,
121+
Currency: coinbaseDefaultCurr,
122+
})
123+
}
124+
return prices, nil
125+
}
126+
127+
// rawPriceData satisfies the fiatBackend interface.
128+
func (c *coinbaseAPI) rawPriceData(ctx context.Context,
129+
startTime, endTime time.Time) ([]*Price, error) {
130+
131+
// Coinbase cap = 300 * granularity.
132+
chunk := c.granularity.aggregation * coinbaseCandleCap
133+
start := startTime.Truncate(c.granularity.aggregation)
134+
end := start.Add(chunk)
135+
if end.After(endTime) {
136+
end = endTime
137+
}
138+
139+
var all []*Price
140+
for start.Before(endTime) {
141+
query := func() ([]byte, error) {
142+
return queryCoinbase(
143+
start, end, c.product, c.granularity, c.client,
144+
)
145+
}
146+
147+
records, err := retryQuery(ctx, query, parseCoinbaseData)
148+
if err != nil {
149+
return nil, err
150+
}
151+
all = append(all, records...)
152+
153+
start = end
154+
end = start.Add(chunk)
155+
if end.After(endTime) {
156+
end = endTime
157+
}
158+
}
159+
160+
return all, nil
161+
}

fiat/prices.go

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -105,6 +105,16 @@ func (cfg *PriceSourceConfig) validatePriceSourceConfig() error {
105105
if len(cfg.PricePoints) == 0 {
106106
return errPricePointsRequired
107107
}
108+
109+
case CoinbasePriceBackend:
110+
if cfg.Granularity == nil ||
111+
(*cfg.Granularity != GranularityHour &&
112+
*cfg.Granularity != GranularityDay) {
113+
114+
return fmt.Errorf("%w: coinbase supports hourly or "+
115+
"daily granularity only",
116+
errGranularityUnsupported)
117+
}
108118
}
109119

110120
return nil
@@ -162,6 +172,9 @@ const (
162172

163173
// CoinGeckoPriceBackend uses CoinGecko's API for fiat price data.
164174
CoinGeckoPriceBackend
175+
176+
// CoinbasePriceBackend uses Coinbase's API for fiat price data.
177+
CoinbasePriceBackend
165178
)
166179

167180
var priceBackendNames = map[PriceBackend]string{
@@ -170,6 +183,7 @@ var priceBackendNames = map[PriceBackend]string{
170183
CoinDeskPriceBackend: "coindesk",
171184
CustomPriceBackend: "custom",
172185
CoinGeckoPriceBackend: "coingecko",
186+
CoinbasePriceBackend: "coinbase",
173187
}
174188

175189
// String returns the string representation of a price backend.
@@ -212,6 +226,11 @@ func NewPriceSource(cfg *PriceSourceConfig) (*PriceSource, error) {
212226
return &PriceSource{
213227
impl: &coinGeckoAPI{},
214228
}, nil
229+
230+
case CoinbasePriceBackend:
231+
return &PriceSource{
232+
impl: newCoinbaseAPI(*cfg.Granularity),
233+
}, nil
215234
}
216235

217236
return nil, errUnknownPriceBackend

fiat/prices_test.go

Lines changed: 57 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,13 @@
11
package fiat
22

33
import (
4+
"context"
45
"errors"
6+
"net/http"
57
"testing"
68
"time"
79

10+
"github.com/jarcoal/httpmock"
811
"github.com/lightningnetwork/lnd/lnwire"
912
"github.com/shopspring/decimal"
1013
"github.com/stretchr/testify/require"
@@ -245,3 +248,57 @@ func TestValidatePriceSourceConfig(t *testing.T) {
245248
})
246249
}
247250
}
251+
252+
// TestCoinbaseRawPriceData tests the rawPriceData method of the Coinbase API
253+
// implementation.
254+
func TestCoinbaseRawPriceData(t *testing.T) {
255+
now := time.Now().UTC().Truncate(time.Hour)
256+
start := now.Add(-time.Hour * 4)
257+
258+
// Stub HTTP client with httpmock (same pattern as CoinCap tests).
259+
mock := httpmock.NewMockTransport()
260+
client := &http.Client{Transport: mock}
261+
262+
// JSON response for the Coinbase API.
263+
const numCandles = 4
264+
candles := make([][]float64, numCandles)
265+
266+
for i := range candles {
267+
timestamp := start.Add(time.Duration(i) * time.Hour).Unix()
268+
269+
// Example values; tweak as needed.
270+
low := 45_000 + float64(i)
271+
high := 55_000 + float64(i)
272+
open := 0.0
273+
close := 50_000 + float64(i)
274+
vol := 0.0
275+
276+
candles[i] = []float64{
277+
float64(timestamp), low, high, open, close, vol,
278+
}
279+
}
280+
281+
expected := make([]*Price, numCandles)
282+
for i := range expected {
283+
expected[i] = &Price{
284+
Timestamp: start.Add(time.Hour * time.Duration(i)),
285+
Price: decimal.NewFromFloat(float64(50_000 + i)),
286+
Currency: "USD",
287+
}
288+
}
289+
290+
// Four hourly candles (close = 50000) returned.
291+
mock.RegisterResponder(
292+
"GET", `=~https://api.exchange.coinbase.com/.*`,
293+
httpmock.NewJsonResponderOrPanic(200, candles),
294+
)
295+
296+
api := newCoinbaseAPI(GranularityHour)
297+
api.client = client
298+
299+
ctx := context.Background()
300+
out, err := api.rawPriceData(ctx, start, now)
301+
require.NoError(t, err)
302+
303+
require.EqualValues(t, expected, out)
304+
}

go.mod

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ require (
66
github.com/btcsuite/btcd/chaincfg/chainhash v1.1.0
77
github.com/btcsuite/btclog/v2 v2.0.1-0.20250110154127-3ae4bf1cb318
88
github.com/grpc-ecosystem/grpc-gateway/v2 v2.22.0
9+
github.com/jarcoal/httpmock v1.4.0
910
github.com/jessevdk/go-flags v1.4.0
1011
github.com/lightninglabs/faraday/frdrpc v1.0.0
1112
github.com/lightninglabs/lndclient v0.19.0-2

go.sum

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -298,6 +298,8 @@ github.com/jackc/puddle v0.0.0-20190608224051-11cab39313c9/go.mod h1:m4B5Dj62Y0f
298298
github.com/jackc/puddle v1.1.3/go.mod h1:m4B5Dj62Y0fbyuIc15OsIqK0+JU8nkqQjsgx7dvjSWk=
299299
github.com/jackc/puddle/v2 v2.2.1 h1:RhxXJtFG022u4ibrCSMSiu5aOq1i77R3OHKNJj77OAk=
300300
github.com/jackc/puddle/v2 v2.2.1/go.mod h1:vriiEXHvEE654aYKXXjOvZM39qJ0q+azkZFrfEOc3H4=
301+
github.com/jarcoal/httpmock v1.4.0 h1:BvhqnH0JAYbNudL2GMJKgOHe2CtKlzJ/5rWKyp+hc2k=
302+
github.com/jarcoal/httpmock v1.4.0/go.mod h1:ftW1xULwo+j0R0JJkJIIi7UKigZUXCLLanykgjwBXL0=
301303
github.com/jessevdk/go-flags v0.0.0-20141203071132-1679536dcc89/go.mod h1:4FA24M0QyGHXBuZZK/XkWh8h0e1EYbRYJSGM75WSRxI=
302304
github.com/jessevdk/go-flags v1.4.0 h1:4IU2WS7AumrZ/40jfhf4QVDMsQwqA7VEHozFRrGARJA=
303305
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
408410
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
409411
github.com/matttproud/golang_protobuf_extensions v1.0.1 h1:4hp9jkHxhMHkqkrB3Ix0jegS5sx/RkqARlsWZ6pIwiU=
410412
github.com/matttproud/golang_protobuf_extensions v1.0.1/go.mod h1:D8He9yQNgCq6Z5Ld7szi9bcBfOoFv/3dc6xSMkL2PC0=
413+
github.com/maxatome/go-testdeep v1.14.0 h1:rRlLv1+kI8eOI3OaBXZwb3O7xY3exRzdW5QyX48g9wI=
414+
github.com/maxatome/go-testdeep v1.14.0/go.mod h1:lPZc/HAcJMP92l7yI6TRz1aZN5URwUBUAfUNvrclaNM=
411415
github.com/miekg/dns v1.1.43 h1:JKfpVSCB84vrAmHzyrsxB5NAr5kLoMXZArPSw7Qlgyg=
412416
github.com/miekg/dns v1.1.43/go.mod h1:+evo5L0630/F6ca/Z9+GAqzhjGyn8/c+TBaOyfEl0V4=
413417
github.com/mitchellh/mapstructure v1.4.1 h1:CpVNEelQCZBooIPDn+AR3NpivK/TIKU8bDxdASFVQag=

version.go

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -25,7 +25,7 @@ const (
2525
// Please update release_notes.md when updating this!
2626
appMajor uint = 0
2727
appMinor uint = 2
28-
appPatch uint = 14
28+
appPatch uint = 15
2929

3030
// appPreRelease MUST only contain characters from semanticAlphabet
3131
// per the semantic versioning spec.

0 commit comments

Comments
 (0)