// Package kraken is a minimal client for the parts of the Kraken REST API // this app needs: public ticker/OHLC price data, and private balance/trade // history for auto-importing purchase lots. No SDK — plain net/http plus // stdlib crypto for request signing, per Kraken's documented spec. package kraken import ( "crypto/hmac" "crypto/sha256" "crypto/sha512" "encoding/base64" "encoding/json" "fmt" "io" "net/http" "net/url" "sort" "strconv" "strings" "time" ) const baseURL = "https://api.kraken.com" // fiatCurrencies are Kraken's supported cash currencies. Balance/Ledger // entries for these represent cash sitting in the account, not a crypto // position — callers exclude them from portfolio tracking. var fiatCurrencies = map[string]bool{ "USD": true, "EUR": true, "GBP": true, "JPY": true, "CHF": true, "CAD": true, "AUD": true, } // IsFiat reports whether altname (as returned by AssetAltName) is one of // Kraken's cash currencies rather than a crypto asset. func IsFiat(altname string) bool { return fiatCurrencies[altname] } type Client struct { apiKey string apiSecret string // base64-encoded, as issued by Kraken http *http.Client pairs map[string]pairInfo // keyed by Kraken's pair name, e.g. XXBTZUSD assets map[string]string // asset code -> altname, e.g. XXBT -> XBT } type pairInfo struct { AltName string `json:"altname"` Base string `json:"base"` Quote string `json:"quote"` } func New(apiKey, apiSecret string) *Client { return &Client{ apiKey: apiKey, apiSecret: apiSecret, http: &http.Client{Timeout: 15 * time.Second}, } } type envelope struct { Error []string `json:"error"` Result json.RawMessage `json:"result"` } func (c *Client) doPublic(path string, params url.Values) (json.RawMessage, error) { u := baseURL + path if params != nil { u += "?" + params.Encode() } resp, err := c.http.Get(u) if err != nil { return nil, err } defer resp.Body.Close() return decodeEnvelope(resp.Body) } func (c *Client) doPrivate(path string, params url.Values) (json.RawMessage, error) { if c.apiKey == "" || c.apiSecret == "" { return nil, fmt.Errorf("kraken: no API credentials configured") } if params == nil { params = url.Values{} } nonce := strconv.FormatInt(time.Now().UnixNano()/int64(time.Millisecond), 10) params.Set("nonce", nonce) body := params.Encode() sign, err := c.sign(path, nonce, body) if err != nil { return nil, err } req, err := http.NewRequest(http.MethodPost, baseURL+path, strings.NewReader(body)) if err != nil { return nil, err } req.Header.Set("Content-Type", "application/x-www-form-urlencoded") req.Header.Set("API-Key", c.apiKey) req.Header.Set("API-Sign", sign) resp, err := c.http.Do(req) if err != nil { return nil, err } defer resp.Body.Close() return decodeEnvelope(resp.Body) } func decodeEnvelope(r io.Reader) (json.RawMessage, error) { var env envelope if err := json.NewDecoder(r).Decode(&env); err != nil { return nil, err } if len(env.Error) > 0 { return nil, fmt.Errorf("kraken: %s", strings.Join(env.Error, "; ")) } return env.Result, nil } // sign implements Kraken's documented API-Sign algorithm: // HMAC-SHA512(path + SHA256(nonce + postdata), base64-decoded secret). func (c *Client) sign(path, nonce, postData string) (string, error) { secret, err := base64.StdEncoding.DecodeString(c.apiSecret) if err != nil { return "", fmt.Errorf("kraken: invalid api secret: %w", err) } shaSum := sha256.Sum256([]byte(nonce + postData)) mac := hmac.New(sha512.New, secret) mac.Write([]byte(path)) mac.Write(shaSum[:]) return base64.StdEncoding.EncodeToString(mac.Sum(nil)), nil } // --- asset/pair metadata (loaded lazily, cached for process lifetime) --- func (c *Client) loadPairs() error { if c.pairs != nil { return nil } raw, err := c.doPublic("/0/public/AssetPairs", nil) if err != nil { return err } var m map[string]pairInfo if err := json.Unmarshal(raw, &m); err != nil { return err } c.pairs = m return nil } func (c *Client) loadAssets() error { if c.assets != nil { return nil } raw, err := c.doPublic("/0/public/Assets", nil) if err != nil { return err } var m map[string]struct { AltName string `json:"altname"` } if err := json.Unmarshal(raw, &m); err != nil { return err } out := map[string]string{} for code, a := range m { out[code] = a.AltName } c.assets = out return nil } // AssetAltName resolves a raw Kraken asset code (e.g. "XXBT") to its // human-friendly ticker (e.g. "XBT"). Falls back to the raw code. func (c *Client) AssetAltName(code string) string { if err := c.loadAssets(); err != nil { return code } if alt, ok := c.assets[code]; ok { return alt } return code } // PairAssets resolves a Kraken pair name (as returned by TradesHistory or // stored on a purchase, e.g. "XXBTZUSD") to its base/quote altnames (e.g. // "XBT", "USD"). Falls back to the raw pair name if it can't be resolved. func (c *Client) PairAssets(pair string) (base, quote string) { if err := c.loadPairs(); err != nil { return pair, "" } if info, ok := c.pairs[pair]; ok { return c.AssetAltName(info.Base), c.AssetAltName(info.Quote) } return pair, "" } // FindPairFor returns a Kraken pair (and its quote altname) trading // baseAsset against a common fiat — useful for pricing a currency that // wasn't acquired through a recorded buy/sell (e.g. a staking reward or // airdrop that only shows up in the account balance). Prefers USD, then // EUR, then GBP, then whatever pair is first found. func (c *Client) FindPairFor(baseAsset string) (pair, quote string) { if err := c.loadPairs(); err != nil { return "", "" } preferred := []string{"USD", "EUR", "GBP"} var fallbackPair, fallbackQuote string for name, info := range c.pairs { if c.AssetAltName(info.Base) != baseAsset { continue } q := c.AssetAltName(info.Quote) for _, p := range preferred { if q == p { return name, q } } if fallbackPair == "" { fallbackPair, fallbackQuote = name, q } } return fallbackPair, fallbackQuote } // wsSymbolOverrides covers the handful of assets where Kraken's REST // altname (used everywhere else in this app) differs from the ticker used // on the WebSocket v2 API — confirmed live: XBT/USD is rejected, BTC/USD // isn't; XDG/USD is rejected, DOGE/USD isn't. Everything else matches. var wsSymbolOverrides = map[string]string{ "XBT": "BTC", "XDG": "DOGE", } // WSSymbol returns the "BASE/QUOTE" symbol Kraken's public WebSocket v2 // ticker channel expects for a given altname pair. func WSSymbol(currency, quote string) string { if alt, ok := wsSymbolOverrides[currency]; ok { currency = alt } return currency + "/" + quote } // WithCredentials returns a copy of c authenticated with the given API // key/secret, sharing the same cached pair/asset metadata (maps are // reference types, so the copy's cache stays warm). func (c *Client) WithCredentials(apiKey, apiSecret string) *Client { cp := *c cp.apiKey = apiKey cp.apiSecret = apiSecret return &cp } // --- public: ticker --- // Ticker returns the last traded price for one pair given as its altname // (e.g. "XBTUSD"). Kraken keys the response by its internal pair name, not // the altname requested, so we just take the single entry back. func (c *Client) Ticker(altPair string) (float64, error) { raw, err := c.doPublic("/0/public/Ticker", url.Values{"pair": {altPair}}) if err != nil { return 0, err } var m map[string]struct { C []string `json:"c"` // last trade closed [price, lot volume] } if err := json.Unmarshal(raw, &m); err != nil { return 0, err } for _, v := range m { if len(v.C) > 0 { return strconv.ParseFloat(v.C[0], 64) } } return 0, fmt.Errorf("kraken: no ticker data for %s", altPair) } // FXRate returns how many units of `to` one unit of `from` is worth, using // Kraken's fiat-cross tickers. Tries the direct pair, then the inverse, then // triangulates through USD if neither exists directly. func (c *Client) FXRate(from, to string) (float64, error) { if from == "" || to == "" || from == to { return 1, nil } if r, err := c.Ticker(from + to); err == nil && r > 0 { return r, nil } if r, err := c.Ticker(to + from); err == nil && r > 0 { return 1 / r, nil } if from != "USD" && to != "USD" { r1, err1 := c.FXRate(from, "USD") r2, err2 := c.FXRate("USD", to) if err1 == nil && err2 == nil { return r1 * r2, nil } } return 0, fmt.Errorf("kraken: no fx rate for %s->%s", from, to) } // --- public: OHLC --- type Candle struct { Time time.Time Open, High, Low, Close float64 } // OHLC returns candles for altPair at the given interval (minutes), only // including data since the given time. func (c *Client) OHLC(altPair string, intervalMinutes int, since time.Time) ([]Candle, error) { params := url.Values{ "pair": {altPair}, "interval": {strconv.Itoa(intervalMinutes)}, "since": {strconv.FormatInt(since.Unix(), 10)}, } raw, err := c.doPublic("/0/public/OHLC", params) if err != nil { return nil, err } var m map[string]json.RawMessage if err := json.Unmarshal(raw, &m); err != nil { return nil, err } var candles []Candle for key, v := range m { if key == "last" { continue } var rows [][]interface{} if err := json.Unmarshal(v, &rows); err != nil { continue } for _, row := range rows { if len(row) < 5 { continue } t, _ := toFloat(row[0]) o, _ := toFloat(row[1]) h, _ := toFloat(row[2]) l, _ := toFloat(row[3]) cl, _ := toFloat(row[4]) candles = append(candles, Candle{Time: time.Unix(int64(t), 0), Open: o, High: h, Low: l, Close: cl}) } } sort.Slice(candles, func(i, j int) bool { return candles[i].Time.Before(candles[j].Time) }) return candles, nil } func toFloat(v interface{}) (float64, error) { switch x := v.(type) { case float64: return x, nil case string: return strconv.ParseFloat(x, 64) default: return 0, fmt.Errorf("unexpected type %T", v) } } // --- private: balance --- type Balance struct { Total float64 // liquid + staked combined Staked float64 // portion of Total that's staked/bonded } // NormalizeStakedAsset strips Kraken's staking-variant suffix so a staked // balance merges into its liquid currency instead of appearing as a // separate, untracked asset — e.g. "ETH2.S" (staked ETH) and the legacy // "ETH2" bonding token both fold into "ETH". Any other "." // variant (e.g. "DOT.S") folds into "" the same way. func NormalizeStakedAsset(altname string) (base string, staked bool) { base = altname if i := strings.Index(altname, "."); i != -1 { base = altname[:i] staked = true } if base == "ETH2" { base = "ETH" staked = true } return base, staked } // Balance returns current holdings keyed by asset altname (e.g. "XBT"), // with staked/bonded variants merged into their liquid currency. func (c *Client) Balance() (map[string]Balance, error) { raw, err := c.doPrivate("/0/private/Balance", nil) if err != nil { return nil, err } var m map[string]string if err := json.Unmarshal(raw, &m); err != nil { return nil, err } out := map[string]Balance{} for code, amtStr := range m { amt, err := strconv.ParseFloat(amtStr, 64) if err != nil { continue } base, staked := NormalizeStakedAsset(c.AssetAltName(code)) b := out[base] b.Total += amt if staked { b.Staked += amt } out[base] = b } return out, nil } // --- private: trades history --- type Trade struct { ID string Pair string // Kraken pair name, e.g. XXBTZUSD Currency string // base asset altname, e.g. XBT Quote string // quote asset altname, e.g. USD Type string // "buy" or "sell" Price float64 Cost float64 Fee float64 Vol float64 Time time.Time } // TradesHistory returns every closed trade on the account, paginating // through Kraken's 50-per-page limit. func (c *Client) TradesHistory() ([]Trade, error) { var out []Trade offset := 0 for { params := url.Values{"ofs": {strconv.Itoa(offset)}} raw, err := c.doPrivate("/0/private/TradesHistory", params) if err != nil { return nil, err } var page struct { Trades map[string]json.RawMessage `json:"trades"` Count int `json:"count"` } if err := json.Unmarshal(raw, &page); err != nil { return nil, err } for id, rawTrade := range page.Trades { var m map[string]interface{} if err := json.Unmarshal(rawTrade, &m); err != nil { continue } t := Trade{ID: id} t.Pair, _ = m["pair"].(string) t.Type, _ = m["type"].(string) if s, ok := m["price"].(string); ok { t.Price, _ = strconv.ParseFloat(s, 64) } if s, ok := m["cost"].(string); ok { t.Cost, _ = strconv.ParseFloat(s, 64) } if s, ok := m["fee"].(string); ok { t.Fee, _ = strconv.ParseFloat(s, 64) } if s, ok := m["vol"].(string); ok { t.Vol, _ = strconv.ParseFloat(s, 64) } if f, ok := m["time"].(float64); ok { t.Time = time.Unix(int64(f), 0) } t.Currency, t.Quote = c.PairAssets(t.Pair) out = append(out, t) } offset += len(page.Trades) if len(page.Trades) == 0 || offset >= page.Count { break } } return out, nil } // --- private: ledger (deposits/withdrawals) --- type LedgerRow struct { ID string Type string // "deposit" | "withdrawal" | (trade, staking, etc — caller filters) Currency string // asset altname, e.g. "XBT" Amount float64 Fee float64 Time time.Time } // Ledgers returns every ledger entry on the account (deposits, withdrawals, // trades, staking, ...), paginating through Kraken's 50-per-page limit. // Callers filter Type for what they need. func (c *Client) Ledgers() ([]LedgerRow, error) { var out []LedgerRow offset := 0 for { params := url.Values{"ofs": {strconv.Itoa(offset)}} raw, err := c.doPrivate("/0/private/Ledgers", params) if err != nil { return nil, err } var page struct { Ledger map[string]json.RawMessage `json:"ledger"` Count int `json:"count"` } if err := json.Unmarshal(raw, &page); err != nil { return nil, err } for id, rawEntry := range page.Ledger { var m map[string]interface{} if err := json.Unmarshal(rawEntry, &m); err != nil { continue } e := LedgerRow{ID: id} e.Type, _ = m["type"].(string) asset, _ := m["asset"].(string) e.Currency, _ = NormalizeStakedAsset(c.AssetAltName(asset)) if s, ok := m["amount"].(string); ok { e.Amount, _ = strconv.ParseFloat(s, 64) } if s, ok := m["fee"].(string); ok { e.Fee, _ = strconv.ParseFloat(s, 64) } if f, ok := m["time"].(float64); ok { e.Time = time.Unix(int64(f), 0) } out = append(out, e) } offset += len(page.Ledger) if len(page.Ledger) == 0 || offset >= page.Count { break } } return out, nil }