Files
price/pkg/source/coingecko.go
zeekay 575fc04f55 pkg: add oracle/source/store packages for price data
Adds three packages backing the price oracle:
- source/ — Coinbase, Coingecko, Kraken price-feed sources
- oracle/ — oracle interface + aggregation
- store/ — persistent price-history store
2026-05-20 16:22:16 -07:00

114 lines
3.2 KiB
Go

// Copyright (c) 2025 Lux Partners Limited
// SPDX-License-Identifier: MIT
package source
import (
"context"
"encoding/json"
"fmt"
"io"
"net/http"
"strings"
"time"
)
// coingeckoDefaultURL is the public/demo CoinGecko endpoint.
// Pro users provide a key; we still keep using the same base path because
// the demo and pro paths differ only by host header in the demo plan.
const coingeckoDefaultURL = "https://api.coingecko.com/api/v3"
// CoinGecko fetches quotes from the CoinGecko /coins/markets endpoint.
//
// CoinGecko uses canonical IDs ("bitcoin", "ethereum", ...) which match the
// IDs we accept on the read endpoints, so no symbol mapping is required.
type CoinGecko struct {
apiKey string
baseURL string
client *http.Client
enabled bool
}
// NewCoinGecko constructs a CoinGecko source. An empty apiKey falls back to
// the public demo endpoint with its (low) anonymous rate limit. Pass a
// custom baseURL ("") to use the default public host.
func NewCoinGecko(apiKey, baseURL string) *CoinGecko {
if baseURL == "" {
baseURL = coingeckoDefaultURL
}
return &CoinGecko{
apiKey: apiKey,
baseURL: baseURL,
client: &http.Client{Timeout: 30 * time.Second},
enabled: true,
}
}
// Name returns the canonical source identifier.
func (c *CoinGecko) Name() string { return "coingecko" }
// Enabled reports whether this source is wired up for the fetcher.
func (c *CoinGecko) Enabled() bool { return c != nil && c.enabled }
// Fetch returns quotes keyed by token ID. CoinGecko accepts the full set of
// requested IDs in a single batch call, so we make exactly one request.
func (c *CoinGecko) Fetch(ctx context.Context, ids []string) (map[string]Quote, error) {
if !c.Enabled() || len(ids) == 0 {
return map[string]Quote{}, nil
}
url := fmt.Sprintf(
"%s/coins/markets?vs_currency=usd&ids=%s&order=market_cap_desc&per_page=250&page=1&sparkline=false",
c.baseURL,
strings.Join(ids, ","),
)
req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil)
if err != nil {
return nil, err
}
if c.apiKey != "" {
req.Header.Set("x-cg-demo-api-key", c.apiKey)
}
req.Header.Set("Accept", "application/json")
resp, err := c.client.Do(req)
if err != nil {
return nil, err
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
body, _ := io.ReadAll(resp.Body)
return nil, fmt.Errorf("coingecko: status %d: %s", resp.StatusCode, string(body))
}
var raw []struct {
ID string `json:"id"`
Symbol string `json:"symbol"`
CurrentPrice float64 `json:"current_price"`
MarketCap float64 `json:"market_cap"`
TotalVolume float64 `json:"total_volume"`
PriceChangePercentage24h float64 `json:"price_change_percentage_24h"`
}
if err := json.NewDecoder(resp.Body).Decode(&raw); err != nil {
return nil, err
}
now := time.Now().UTC()
out := make(map[string]Quote, len(raw))
for _, r := range raw {
out[r.ID] = Quote{
ID: r.ID,
Symbol: r.Symbol,
PriceUSD: r.CurrentPrice,
Change24h: r.PriceChangePercentage24h,
Volume24h: r.TotalVolume,
MarketCap: r.MarketCap,
UpdatedAt: now,
SourceName: c.Name(),
}
}
return out, nil
}