first commit

This commit is contained in:
2026-08-25 06:37:14 +01:00
commit 5c3e72b40e
26 changed files with 6301 additions and 0 deletions
+538
View File
@@ -0,0 +1,538 @@
// 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 "<BASE>.<suffix>"
// variant (e.g. "DOT.S") folds into "<BASE>" 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
}