first commit
This commit is contained in:
@@ -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
|
||||
}
|
||||
Reference in New Issue
Block a user