mirror of
https://github.com/luxfi/price.git
synced 2026-10-30 15:21:24 +00:00
Lux Partners Limited is not the entity behind this work. Every place the old name stood — copyright lines, site footers, brand config, legal and compliance text — now names the company that actually holds it. lux.fund is the exception and is not renamed to match: that site belongs to Lux Assets Management LLC, a different company, and it says so. Two kinds of line were left for a person rather than rewritten. A dated news item recording that Lux Partners Limited was established in the Isle of Man is a historical fact, and renaming it would falsify the record. And a sentence asserting that the company is domiciled in the Isle of Man is a claim about a jurisdiction, not a name — an Inc or an LLC is not an Isle of Man company, so swapping the name there would assert something untrue. Co-authored-by: Hanzo Dev <[email protected]>
172 lines
4.7 KiB
Go
172 lines
4.7 KiB
Go
// Copyright (c) 2025 Lux Industries Inc
|
|
// SPDX-License-Identifier: MIT
|
|
|
|
package source
|
|
|
|
import (
|
|
"context"
|
|
"encoding/json"
|
|
"fmt"
|
|
"io"
|
|
"net/http"
|
|
"strconv"
|
|
"strings"
|
|
"time"
|
|
)
|
|
|
|
// krakenDefaultURL is the public Kraken REST endpoint.
|
|
const krakenDefaultURL = "https://api.kraken.com/0/public"
|
|
|
|
// Kraken fetches ticker data from /Ticker?pair=... in a single request.
|
|
//
|
|
// Kraken pair names are bespoke (XBTUSD, XETHZUSD, ADAUSD, ...). The Source
|
|
// holds an ID->pair map seeded at construction time so the translation
|
|
// layer is explicit and testable. Pairs not present in the map are skipped
|
|
// silently — the source omits them rather than erroring.
|
|
type Kraken struct {
|
|
baseURL string
|
|
client *http.Client
|
|
idToPair map[string]string // canonical ID -> kraken pair code
|
|
enabled bool
|
|
}
|
|
|
|
// NewKraken constructs a Kraken source. Pass a custom baseURL ("") to use
|
|
// the public host. Pass a nil idToPair to use the default mapping.
|
|
func NewKraken(baseURL string, idToPair map[string]string) *Kraken {
|
|
if baseURL == "" {
|
|
baseURL = krakenDefaultURL
|
|
}
|
|
if idToPair == nil {
|
|
idToPair = DefaultKrakenPairs()
|
|
}
|
|
return &Kraken{
|
|
baseURL: baseURL,
|
|
client: &http.Client{Timeout: 15 * time.Second},
|
|
idToPair: idToPair,
|
|
enabled: true,
|
|
}
|
|
}
|
|
|
|
// Name returns the canonical source identifier.
|
|
func (k *Kraken) Name() string { return "kraken" }
|
|
|
|
// Enabled reports whether this source is wired up for the fetcher.
|
|
func (k *Kraken) Enabled() bool { return k != nil && k.enabled }
|
|
|
|
// Fetch returns quotes keyed by canonical token ID. Kraken returns multiple
|
|
// pairs in a single response, so we issue exactly one HTTP request.
|
|
func (k *Kraken) Fetch(ctx context.Context, ids []string) (map[string]Quote, error) {
|
|
if !k.Enabled() || len(ids) == 0 {
|
|
return map[string]Quote{}, nil
|
|
}
|
|
|
|
// Build the pair query. Track pair->id so we can demux the response.
|
|
var pairs []string
|
|
pairToID := make(map[string]string)
|
|
for _, id := range ids {
|
|
pair, ok := k.idToPair[strings.ToLower(id)]
|
|
if !ok {
|
|
continue
|
|
}
|
|
pairs = append(pairs, pair)
|
|
pairToID[pair] = id
|
|
}
|
|
if len(pairs) == 0 {
|
|
return map[string]Quote{}, nil
|
|
}
|
|
|
|
url := fmt.Sprintf("%s/Ticker?pair=%s", k.baseURL, strings.Join(pairs, ","))
|
|
req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
req.Header.Set("Accept", "application/json")
|
|
|
|
resp, err := k.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("kraken: status %d: %s", resp.StatusCode, string(body))
|
|
}
|
|
|
|
// Kraken response: {"error":[],"result":{"<PAIR>":{"c":["price","lot"], "v":["...","24h"], "p":["...","24h"], "o":"open"}}}
|
|
var payload struct {
|
|
Error []string `json:"error"`
|
|
Result map[string]json.RawMessage `json:"result"`
|
|
}
|
|
if err := json.NewDecoder(resp.Body).Decode(&payload); err != nil {
|
|
return nil, err
|
|
}
|
|
if len(payload.Error) > 0 {
|
|
return nil, fmt.Errorf("kraken: %s", strings.Join(payload.Error, ","))
|
|
}
|
|
|
|
now := time.Now().UTC()
|
|
out := make(map[string]Quote, len(payload.Result))
|
|
|
|
for pair, raw := range payload.Result {
|
|
var t struct {
|
|
C []string `json:"c"` // last trade closed: [price, lot]
|
|
V []string `json:"v"` // volume: [today, 24h]
|
|
P []string `json:"p"` // VWAP: [today, 24h]
|
|
O string `json:"o"` // today open
|
|
}
|
|
if err := json.Unmarshal(raw, &t); err != nil {
|
|
continue
|
|
}
|
|
|
|
// Kraken sometimes returns a different pair key than the request
|
|
// (XBTUSD vs XXBTZUSD). Match by suffix as a fallback.
|
|
id, ok := pairToID[pair]
|
|
if !ok {
|
|
for reqPair, candidateID := range pairToID {
|
|
if strings.HasSuffix(pair, strings.TrimPrefix(reqPair, "X")) {
|
|
id = candidateID
|
|
ok = true
|
|
break
|
|
}
|
|
}
|
|
if !ok {
|
|
continue
|
|
}
|
|
}
|
|
|
|
price, err := parseKrakenFloat(t.C, 0)
|
|
if err != nil || price == 0 {
|
|
continue
|
|
}
|
|
volume, _ := parseKrakenFloat(t.V, 1)
|
|
vwap, _ := parseKrakenFloat(t.P, 1)
|
|
open, _ := strconv.ParseFloat(t.O, 64)
|
|
|
|
var change24h float64
|
|
if open > 0 {
|
|
change24h = ((price - open) / open) * 100
|
|
}
|
|
_ = vwap // reserved; kept for symmetry with other sources
|
|
|
|
out[id] = Quote{
|
|
ID: id,
|
|
PriceUSD: price,
|
|
Change24h: change24h,
|
|
Volume24h: volume * price, // Kraken volume is base units; convert to USD
|
|
UpdatedAt: now,
|
|
SourceName: k.Name(),
|
|
}
|
|
}
|
|
return out, nil
|
|
}
|
|
|
|
// parseKrakenFloat safely extracts the Nth element of a string array as a
|
|
// float. Kraken returns these arrays uniformly across endpoints.
|
|
func parseKrakenFloat(arr []string, idx int) (float64, error) {
|
|
if idx >= len(arr) {
|
|
return 0, fmt.Errorf("index out of range")
|
|
}
|
|
return strconv.ParseFloat(arr[idx], 64)
|
|
}
|