Files
gocryptotrader/exchanges/kraken/kraken_websocket.go
Ryan O'Hara-Reid ac731ce283 websocket/gateio: Support multi connection management and integrate with GateIO (#1580)
* gateio: Add multi asset websocket support WIP.

* meow

* Add tests and shenanigans

* integrate flushing and for enabling/disabling pairs from rpc shenanigans

* some changes

* linter: fixes strikes again.

* Change name ConnectionAssociation -> ConnectionCandidate for better clarity on purpose. Change connections map to point to candidate to track subscriptions for future dynamic connections holder and drop struct ConnectionDetails.

* Add subscription tests (state functional)

* glorious:nits + proxy handling

* Spelling

* linter: fixerino

* instead of nil, dont do nil.

* clean up nils

* cya nils

* don't need to set URL or check if its running

* stop ping handler routine leak

* * Fix bug where reader routine on error that is not a disconnection error but websocket frame error or anything really makes the reader routine return and then connection never cycles and the buffer gets filled.
* Handle reconnection via an errors.Is check which is simpler and in that scope allow for quick disconnect reconnect without waiting for connection cycle.
* Dial now uses code from DialContext but just calls context.Background()
* Don't allow reader to return on parse binary response error. Just output error and return a non nil response

* Allow rollback on connect on any error across all connections

* fix shadow jutsu

* glorious/gk: nitters - adds in ws mock server

* linter: fix

* fix deadlock on connection as the previous channel had no reader and would hang connection reader for eternity.

* gk: nits

* Leak issue and edge case

* gk: nits

* gk: drain brain

* glorious: nits

* Update exchanges/stream/websocket.go

Co-authored-by: Scott <gloriousCode@users.noreply.github.com>

* glorious: nits

* add tests

* linter: fix

* After merge

* Add error connection info

* Fix edge case where it does not reconnect made by an already closed connection

* stream coverage

* glorious: nits

* glorious: nits removed asset error handling in stream package

* linter: fix

* rm block

* Add basic readme

* fix asset enabled flush cycle for multi connection

* spella: fix

* linter: fix

* Add glorious suggestions, fix some race thing

* reinstate name before any routine gets spawned

* stop on error in mock tests

* glorious: nits

* glorious: nits found in CI build

* Add test for drain, bumped wait times as there seems to be something happening on macos CI builds, used context.WithTimeout because its instant.

* mutex across shutdown and connect for protection

* lint: fix

* test time withoffset, reinstate stop

* fix whoops

* const trafficCheckInterval; rm testmain

* y

* fix lint

* bump time check window

* stream: fix intermittant test failures while testing routines and remove code that is not needed.

* spells

* cant do what I did

* protect race due to routine.

* update testURL

* use mock websocket connection instead of test URL's

* linter: fix

* remove url because its throwing errors on CI builds

* connections drop all the time, don't need to worry about not being able to echo back ws data as it can be easily reviewed _test file side.

* remove another superfluous url thats not really set up for this

* spawn overwatch routine when there is no errors, inline checker instead of waiting for a time period, add sleep inline with echo handler as this is really quick and wanted to ensure that latency is handing correctly

* linter: fixerino uperino

* glorious: panix

* linter: things

* whoops

* defer lock and use functions that don't require locking in SetProxyAddress

* lint: fix

* thrasher: nits

---------

Co-authored-by: shazbert <ryan.oharareid@thrasher.io>
Co-authored-by: Scott <gloriousCode@users.noreply.github.com>
2024-10-10 15:09:52 +11:00

1391 lines
40 KiB
Go

package kraken
import (
"bytes"
"context"
"encoding/json"
"errors"
"fmt"
"hash/crc32"
"net/http"
"strconv"
"strings"
"text/template"
"time"
"github.com/buger/jsonparser"
"github.com/gorilla/websocket"
"github.com/thrasher-corp/gocryptotrader/common"
"github.com/thrasher-corp/gocryptotrader/common/convert"
"github.com/thrasher-corp/gocryptotrader/currency"
"github.com/thrasher-corp/gocryptotrader/exchanges/asset"
"github.com/thrasher-corp/gocryptotrader/exchanges/kline"
"github.com/thrasher-corp/gocryptotrader/exchanges/order"
"github.com/thrasher-corp/gocryptotrader/exchanges/orderbook"
"github.com/thrasher-corp/gocryptotrader/exchanges/request"
"github.com/thrasher-corp/gocryptotrader/exchanges/stream"
"github.com/thrasher-corp/gocryptotrader/exchanges/subscription"
"github.com/thrasher-corp/gocryptotrader/exchanges/ticker"
"github.com/thrasher-corp/gocryptotrader/exchanges/trade"
"github.com/thrasher-corp/gocryptotrader/log"
)
// List of all websocket channels to subscribe to
const (
krakenWSURL = "wss://ws.kraken.com"
krakenAuthWSURL = "wss://ws-auth.kraken.com"
krakenWSSandboxURL = "wss://sandbox.kraken.com"
krakenWSSupportedVersion = "1.4.0"
// Websocket Channels
krakenWsHeartbeat = "heartbeat"
krakenWsSystemStatus = "systemStatus"
krakenWsSubscribe = "subscribe"
krakenWsUnsubscribe = "unsubscribe"
krakenWsSubscribed = "subscribed"
krakenWsUnsubscribed = "unsubscribed"
krakenWsSubscriptionStatus = "subscriptionStatus"
krakenWsTicker = "ticker"
krakenWsOHLC = "ohlc"
krakenWsTrade = "trade"
krakenWsSpread = "spread"
krakenWsOrderbook = "book"
krakenWsOwnTrades = "ownTrades"
krakenWsOpenOrders = "openOrders"
krakenWsAddOrder = "addOrder"
krakenWsCancelOrder = "cancelOrder"
krakenWsCancelAll = "cancelAll"
krakenWsAddOrderStatus = "addOrderStatus"
krakenWsCancelOrderStatus = "cancelOrderStatus"
krakenWsCancelAllOrderStatus = "cancelAllStatus"
krakenWsPingDelay = time.Second * 27
)
var channelNames = map[string]string{
subscription.TickerChannel: krakenWsTicker,
subscription.OrderbookChannel: krakenWsOrderbook,
subscription.CandlesChannel: krakenWsOHLC,
subscription.AllTradesChannel: krakenWsTrade,
subscription.MyTradesChannel: krakenWsOwnTrades,
subscription.MyOrdersChannel: krakenWsOpenOrders,
}
var reverseChannelNames = map[string]string{}
func init() {
for k, v := range channelNames {
reverseChannelNames[v] = k
}
}
var (
authToken string
errParsingWSField = errors.New("error parsing WS field")
errCancellingOrder = errors.New("error cancelling order")
errSubPairMissing = errors.New("pair missing from subscription response")
errInvalidChecksum = errors.New("invalid checksum")
)
var defaultSubscriptions = subscription.List{
{Enabled: true, Asset: asset.Spot, Channel: subscription.TickerChannel},
{Enabled: true, Asset: asset.Spot, Channel: subscription.AllTradesChannel},
{Enabled: true, Asset: asset.Spot, Channel: subscription.CandlesChannel, Interval: kline.OneMin},
{Enabled: true, Asset: asset.Spot, Channel: subscription.OrderbookChannel, Levels: 1000},
{Enabled: true, Channel: subscription.MyOrdersChannel, Authenticated: true},
{Enabled: true, Channel: subscription.MyTradesChannel, Authenticated: true},
}
// WsConnect initiates a websocket connection
func (k *Kraken) WsConnect() error {
if !k.Websocket.IsEnabled() || !k.IsEnabled() {
return stream.ErrWebsocketNotEnabled
}
var dialer websocket.Dialer
err := k.Websocket.Conn.Dial(&dialer, http.Header{})
if err != nil {
return err
}
comms := make(chan stream.Response)
k.Websocket.Wg.Add(2)
go k.wsReadData(comms)
go k.wsFunnelConnectionData(k.Websocket.Conn, comms)
if k.IsWebsocketAuthenticationSupported() {
authToken, err = k.GetWebsocketToken(context.TODO())
if err != nil {
k.Websocket.SetCanUseAuthenticatedEndpoints(false)
log.Errorf(log.ExchangeSys,
"%v - authentication failed: %v\n",
k.Name,
err)
} else {
err = k.Websocket.AuthConn.Dial(&dialer, http.Header{})
if err != nil {
k.Websocket.SetCanUseAuthenticatedEndpoints(false)
log.Errorf(log.ExchangeSys,
"%v - failed to connect to authenticated endpoint: %v\n",
k.Name,
err)
} else {
k.Websocket.SetCanUseAuthenticatedEndpoints(true)
k.Websocket.Wg.Add(1)
go k.wsFunnelConnectionData(k.Websocket.AuthConn, comms)
k.startWsPingHandler(k.Websocket.AuthConn)
}
}
}
k.startWsPingHandler(k.Websocket.Conn)
return nil
}
// wsFunnelConnectionData funnels both auth and public ws data into one manageable place
func (k *Kraken) wsFunnelConnectionData(ws stream.Connection, comms chan stream.Response) {
defer k.Websocket.Wg.Done()
for {
resp := ws.ReadMessage()
if resp.Raw == nil {
return
}
comms <- resp
}
}
// wsReadData receives and passes on websocket messages for processing
func (k *Kraken) wsReadData(comms chan stream.Response) {
defer k.Websocket.Wg.Done()
for {
select {
case <-k.Websocket.ShutdownC:
select {
case resp := <-comms:
err := k.wsHandleData(resp.Raw)
if err != nil {
select {
case k.Websocket.DataHandler <- err:
default:
log.Errorf(log.WebsocketMgr, "%s websocket handle data error: %v", k.Name, err)
}
}
default:
}
return
case resp := <-comms:
err := k.wsHandleData(resp.Raw)
if err != nil {
k.Websocket.DataHandler <- err
}
}
}
}
func (k *Kraken) wsHandleData(respRaw []byte) error {
if strings.HasPrefix(string(respRaw), "[") {
var msg []any
if err := json.Unmarshal(respRaw, &msg); err != nil {
return err
}
if len(msg) < 3 {
return fmt.Errorf("data array too short: %s", respRaw)
}
// For all types of channel second to last field is the channel Name
c, ok := msg[len(msg)-2].(string)
if !ok {
return common.GetTypeAssertError("string", msg[len(msg)-2], "channelName")
}
pair := currency.EMPTYPAIR
if maybePair, ok2 := msg[len(msg)-1].(string); ok2 {
var err error
if pair, err = currency.NewPairFromString(maybePair); err != nil {
return err
}
}
return k.wsReadDataResponse(c, pair, msg)
}
event, err := jsonparser.GetString(respRaw, "event")
if err != nil {
return fmt.Errorf("%w parsing: %s", err, respRaw)
}
if event == krakenWsSubscriptionStatus { // Must happen before IncomingWithData to avoid race
k.wsProcessSubStatus(respRaw)
}
reqID, err := jsonparser.GetInt(respRaw, "reqid")
if err == nil && reqID != 0 && k.Websocket.Match.IncomingWithData(reqID, respRaw) {
return nil
}
if event == "" {
return nil
}
switch event {
case stream.Pong, krakenWsHeartbeat:
return nil
case krakenWsCancelOrderStatus, krakenWsCancelAllOrderStatus, krakenWsAddOrderStatus, krakenWsSubscriptionStatus:
// All of these should have found a listener already
return fmt.Errorf("%w: %s %v", stream.ErrNoMessageListener, event, reqID)
case krakenWsSystemStatus:
return k.wsProcessSystemStatus(respRaw)
default:
k.Websocket.DataHandler <- stream.UnhandledMessageWarning{
Message: fmt.Sprintf("%s: %s", stream.UnhandledMessage, respRaw),
}
}
return nil
}
// startWsPingHandler sets up a websocket ping handler to maintain a connection
func (k *Kraken) startWsPingHandler(conn stream.Connection) {
conn.SetupPingHandler(request.Unset, stream.PingHandler{
Message: []byte(`{"event":"ping"}`),
Delay: krakenWsPingDelay,
MessageType: websocket.TextMessage,
})
}
// wsReadDataResponse classifies the WS response and sends to appropriate handler
func (k *Kraken) wsReadDataResponse(c string, pair currency.Pair, response []any) error {
switch c {
case krakenWsTicker:
return k.wsProcessTickers(response, pair)
case krakenWsSpread:
return k.wsProcessSpread(response, pair)
case krakenWsTrade:
return k.wsProcessTrades(response, pair)
case krakenWsOwnTrades:
return k.wsProcessOwnTrades(response[0])
case krakenWsOpenOrders:
return k.wsProcessOpenOrders(response[0])
}
channelType := strings.TrimRight(c, "-0123456789")
switch channelType {
case krakenWsOHLC:
return k.wsProcessCandle(c, response, pair)
case krakenWsOrderbook:
return k.wsProcessOrderBook(c, response, pair)
default:
return fmt.Errorf("received unidentified data for subscription %s: %+v", c, response)
}
}
func (k *Kraken) wsProcessSystemStatus(respRaw []byte) error {
var systemStatus wsSystemStatus
err := json.Unmarshal(respRaw, &systemStatus)
if err != nil {
return fmt.Errorf("%s parsing system status: %s", err, respRaw)
}
if systemStatus.Status != "online" {
k.Websocket.DataHandler <- fmt.Errorf("system status not online: %v", systemStatus.Status)
}
if systemStatus.Version > krakenWSSupportedVersion {
log.Warnf(log.ExchangeSys, "%v New version of Websocket API released. Was %v Now %v", k.Name, krakenWSSupportedVersion, systemStatus.Version)
}
return nil
}
func (k *Kraken) wsProcessOwnTrades(ownOrders interface{}) error {
if data, ok := ownOrders.([]interface{}); ok {
for i := range data {
trades, err := json.Marshal(data[i])
if err != nil {
return err
}
var result map[string]*WsOwnTrade
err = json.Unmarshal(trades, &result)
if err != nil {
return err
}
for key, val := range result {
oSide, err := order.StringToOrderSide(val.Type)
if err != nil {
k.Websocket.DataHandler <- order.ClassificationError{
Exchange: k.Name,
OrderID: key,
Err: err,
}
}
oType, err := order.StringToOrderType(val.OrderType)
if err != nil {
k.Websocket.DataHandler <- order.ClassificationError{
Exchange: k.Name,
OrderID: key,
Err: err,
}
}
trade := order.TradeHistory{
Price: val.Price,
Amount: val.Vol,
Fee: val.Fee,
Exchange: k.Name,
TID: key,
Type: oType,
Side: oSide,
Timestamp: convert.TimeFromUnixTimestampDecimal(val.Time),
}
k.Websocket.DataHandler <- &order.Detail{
Exchange: k.Name,
OrderID: val.OrderTransactionID,
Trades: []order.TradeHistory{trade},
}
}
}
return nil
}
return errors.New(k.Name + " - Invalid own trades data")
}
func (k *Kraken) wsProcessOpenOrders(ownOrders interface{}) error {
if data, ok := ownOrders.([]interface{}); ok {
for i := range data {
orders, err := json.Marshal(data[i])
if err != nil {
return err
}
var result map[string]*WsOpenOrder
err = json.Unmarshal(orders, &result)
if err != nil {
return err
}
for key, val := range result {
d := &order.Detail{
Exchange: k.Name,
OrderID: key,
AverageExecutedPrice: val.AveragePrice,
Amount: val.Volume,
LimitPriceUpper: val.LimitPrice,
ExecutedAmount: val.ExecutedVolume,
Fee: val.Fee,
Date: convert.TimeFromUnixTimestampDecimal(val.OpenTime).Truncate(time.Microsecond),
LastUpdated: convert.TimeFromUnixTimestampDecimal(val.LastUpdated).Truncate(time.Microsecond),
}
if val.Status != "" {
if s, err := order.StringToOrderStatus(val.Status); err != nil {
k.Websocket.DataHandler <- order.ClassificationError{
Exchange: k.Name,
OrderID: key,
Err: err,
}
} else {
d.Status = s
}
}
if val.Description.Pair != "" {
if strings.Contains(val.Description.Order, "sell") {
d.Side = order.Sell
} else {
if oSide, err := order.StringToOrderSide(val.Description.Type); err != nil {
k.Websocket.DataHandler <- order.ClassificationError{
Exchange: k.Name,
OrderID: key,
Err: err,
}
} else {
d.Side = oSide
}
}
if oType, err := order.StringToOrderType(val.Description.OrderType); err != nil {
k.Websocket.DataHandler <- order.ClassificationError{
Exchange: k.Name,
OrderID: key,
Err: err,
}
} else {
d.Type = oType
}
if p, err := currency.NewPairFromString(val.Description.Pair); err != nil {
k.Websocket.DataHandler <- order.ClassificationError{
Exchange: k.Name,
OrderID: key,
Err: err,
}
} else {
d.Pair = p
if d.AssetType, err = k.GetPairAssetType(p); err != nil {
k.Websocket.DataHandler <- order.ClassificationError{
Exchange: k.Name,
OrderID: key,
Err: err,
}
}
}
}
if val.Description.Price > 0 {
d.Leverage = val.Description.Leverage
d.Price = val.Description.Price
}
if val.Volume > 0 {
// Note: We don't seem to ever get both there values
d.RemainingAmount = val.Volume - val.ExecutedVolume
}
k.Websocket.DataHandler <- d
}
}
return nil
}
return errors.New("invalid own trades data")
}
// wsProcessTickers converts ticker data and sends it to the datahandler
func (k *Kraken) wsProcessTickers(response []any, pair currency.Pair) error {
t, ok := response[1].(map[string]any)
if !ok {
return errors.New("received invalid ticker data")
}
data := map[string]float64{}
for _, b := range []byte("abcvlho") { // p and t skipped
key := string(b)
a, ok := t[key].([]any)
if !ok {
return fmt.Errorf("received invalid ticker data: %w", common.GetTypeAssertError("[]any", t[key], "ticker."+key))
}
var s string
if s, ok = a[0].(string); !ok {
return fmt.Errorf("received invalid ticker data: %w", common.GetTypeAssertError("string", a[0], "ticker."+key+"[0]"))
}
f, err := strconv.ParseFloat(s, 64)
if err != nil {
return fmt.Errorf("received invalid ticker data: %w", err)
}
data[key] = f
}
k.Websocket.DataHandler <- &ticker.Price{
ExchangeName: k.Name,
Ask: data["a"],
Bid: data["b"],
Close: data["c"],
Volume: data["v"],
Low: data["l"],
High: data["h"],
Open: data["o"],
AssetType: asset.Spot,
Pair: pair,
}
return nil
}
// wsProcessSpread converts spread/orderbook data and sends it to the datahandler
func (k *Kraken) wsProcessSpread(response []any, pair currency.Pair) error {
data, ok := response[1].([]any)
if !ok {
return errors.New("received invalid spread data")
}
if len(data) < 5 {
return errors.New("unexpected wsProcessSpread data length")
}
bestBid, ok := data[0].(string)
if !ok {
return errors.New("wsProcessSpread: unable to type assert bestBid")
}
bestAsk, ok := data[1].(string)
if !ok {
return errors.New("wsProcessSpread: unable to type assert bestAsk")
}
timeData, err := strconv.ParseFloat(data[2].(string), 64)
if err != nil {
return fmt.Errorf("wsProcessSpread: unable to parse timeData: %w", err)
}
bidVolume, ok := data[3].(string)
if !ok {
return errors.New("wsProcessSpread: unable to type assert bidVolume")
}
askVolume, ok := data[4].(string)
if !ok {
return errors.New("wsProcessSpread: unable to type assert askVolume")
}
if k.Verbose {
log.Debugf(log.ExchangeSys,
"%v Spread data for '%v' received. Best bid: '%v' Best ask: '%v' Time: '%v', Bid volume '%v', Ask volume '%v'",
k.Name,
pair,
bestBid,
bestAsk,
convert.TimeFromUnixTimestampDecimal(timeData),
bidVolume,
askVolume)
}
return nil
}
// wsProcessTrades converts trade data and sends it to the datahandler
func (k *Kraken) wsProcessTrades(response []any, pair currency.Pair) error {
data, ok := response[1].([]any)
if !ok {
return errors.New("received invalid trade data")
}
if !k.IsSaveTradeDataEnabled() {
return nil
}
trades := make([]trade.Data, len(data))
for i := range data {
t, ok := data[i].([]interface{})
if !ok {
return errors.New("unidentified trade data received")
}
timeData, err := strconv.ParseFloat(t[2].(string), 64)
if err != nil {
return err
}
price, err := strconv.ParseFloat(t[0].(string), 64)
if err != nil {
return err
}
amount, err := strconv.ParseFloat(t[1].(string), 64)
if err != nil {
return err
}
var tSide = order.Buy
s, ok := t[3].(string)
if !ok {
return common.GetTypeAssertError("string", t[3], "side")
}
if s == "s" {
tSide = order.Sell
}
trades[i] = trade.Data{
AssetType: asset.Spot,
CurrencyPair: pair,
Exchange: k.Name,
Price: price,
Amount: amount,
Timestamp: convert.TimeFromUnixTimestampDecimal(timeData),
Side: tSide,
}
}
return trade.AddTradesToBuffer(k.Name, trades...)
}
// wsProcessOrderBook handles both partial and full orderbook updates
func (k *Kraken) wsProcessOrderBook(c string, response []any, pair currency.Pair) error {
key := &subscription.Subscription{
Channel: c,
Asset: asset.Spot,
Pairs: currency.Pairs{pair},
}
if err := fqChannelNameSub(key); err != nil {
return err
}
s := k.Websocket.GetSubscription(key)
if s == nil {
return fmt.Errorf("%w: %s %s %s", subscription.ErrNotFound, asset.Spot, c, pair)
}
if s.State() == subscription.UnsubscribingState {
// We only care if it's currently unsubscribing
return nil
}
ob, ok := response[1].(map[string]any)
if !ok {
return errors.New("received invalid orderbook data")
}
if len(response) == 5 {
ob2, ok2 := response[2].(map[string]any)
if !ok2 {
return errors.New("received invalid orderbook data")
}
// Squish both maps together to process
for k, v := range ob2 {
if _, ok := ob[k]; ok {
return errors.New("cannot merge maps, conflict is present")
}
ob[k] = v
}
}
// NOTE: Updates are a priority so check if it's an update first as we don't
// need multiple map lookups to check for snapshot.
askData, asksExist := ob["a"].([]interface{})
bidData, bidsExist := ob["b"].([]interface{})
if asksExist || bidsExist {
checksum, ok := ob["c"].(string)
if !ok {
return errors.New("could not process orderbook update checksum not found")
}
err := k.wsProcessOrderBookUpdate(pair, askData, bidData, checksum)
if errors.Is(err, errInvalidChecksum) {
log.Debugf(log.Global, "%s Resubscribing to invalid %s orderbook", k.Name, pair)
go func() {
if e2 := k.Websocket.ResubscribeToChannel(k.Websocket.Conn, s); e2 != nil && !errors.Is(e2, subscription.ErrInStateAlready) {
log.Errorf(log.ExchangeSys, "%s resubscription failure for %v: %v", k.Name, pair, e2)
}
}()
}
return err
}
askSnapshot, askSnapshotExists := ob["as"].([]interface{})
bidSnapshot, bidSnapshotExists := ob["bs"].([]interface{})
if !askSnapshotExists && !bidSnapshotExists {
return fmt.Errorf("%w for %v %v", errNoWebsocketOrderbookData, pair, asset.Spot)
}
return k.wsProcessOrderBookPartial(pair, askSnapshot, bidSnapshot, key.Levels)
}
// wsProcessOrderBookPartial creates a new orderbook entry for a given currency pair
func (k *Kraken) wsProcessOrderBookPartial(pair currency.Pair, askData, bidData []any, levels int) error {
base := orderbook.Base{
Pair: pair,
Asset: asset.Spot,
VerifyOrderbook: k.CanVerifyOrderbook,
Bids: make(orderbook.Tranches, len(bidData)),
Asks: make(orderbook.Tranches, len(askData)),
MaxDepth: levels,
ChecksumStringRequired: true,
}
// Kraken ob data is timestamped per price, GCT orderbook data is
// timestamped per entry using the highest last update time, we can attempt
// to respect both within a reasonable degree
var highestLastUpdate time.Time
for i := range askData {
asks, ok := askData[i].([]interface{})
if !ok {
return common.GetTypeAssertError("[]interface{}", askData[i], "asks")
}
if len(asks) < 3 {
return errors.New("unexpected asks length")
}
priceStr, ok := asks[0].(string)
if !ok {
return common.GetTypeAssertError("string", asks[0], "price")
}
price, err := strconv.ParseFloat(priceStr, 64)
if err != nil {
return err
}
amountStr, ok := asks[1].(string)
if !ok {
return common.GetTypeAssertError("string", asks[1], "amount")
}
amount, err := strconv.ParseFloat(amountStr, 64)
if err != nil {
return err
}
tdStr, ok := asks[2].(string)
if !ok {
return common.GetTypeAssertError("string", asks[2], "time")
}
timeData, err := strconv.ParseFloat(tdStr, 64)
if err != nil {
return err
}
base.Asks[i] = orderbook.Tranche{
Amount: amount,
StrAmount: amountStr,
Price: price,
StrPrice: priceStr,
}
askUpdatedTime := convert.TimeFromUnixTimestampDecimal(timeData)
if highestLastUpdate.Before(askUpdatedTime) {
highestLastUpdate = askUpdatedTime
}
}
for i := range bidData {
bids, ok := bidData[i].([]interface{})
if !ok {
return common.GetTypeAssertError("[]interface{}", bidData[i], "bids")
}
if len(bids) < 3 {
return errors.New("unexpected bids length")
}
priceStr, ok := bids[0].(string)
if !ok {
return common.GetTypeAssertError("string", bids[0], "price")
}
price, err := strconv.ParseFloat(priceStr, 64)
if err != nil {
return err
}
amountStr, ok := bids[1].(string)
if !ok {
return common.GetTypeAssertError("string", bids[1], "amount")
}
amount, err := strconv.ParseFloat(amountStr, 64)
if err != nil {
return err
}
tdStr, ok := bids[2].(string)
if !ok {
return common.GetTypeAssertError("string", bids[2], "time")
}
timeData, err := strconv.ParseFloat(tdStr, 64)
if err != nil {
return err
}
base.Bids[i] = orderbook.Tranche{
Amount: amount,
StrAmount: amountStr,
Price: price,
StrPrice: priceStr,
}
bidUpdateTime := convert.TimeFromUnixTimestampDecimal(timeData)
if highestLastUpdate.Before(bidUpdateTime) {
highestLastUpdate = bidUpdateTime
}
}
base.LastUpdated = highestLastUpdate
base.Exchange = k.Name
return k.Websocket.Orderbook.LoadSnapshot(&base)
}
// wsProcessOrderBookUpdate updates an orderbook entry for a given currency pair
func (k *Kraken) wsProcessOrderBookUpdate(pair currency.Pair, askData, bidData []any, checksum string) error {
update := orderbook.Update{
Asset: asset.Spot,
Pair: pair,
Bids: make([]orderbook.Tranche, len(bidData)),
Asks: make([]orderbook.Tranche, len(askData)),
}
// Calculating checksum requires incoming decimal place checks for both
// price and amount as there is no set standard between currency pairs. This
// is calculated per update as opposed to snapshot because changes to
// decimal amounts could occur at any time.
var highestLastUpdate time.Time
// Ask data is not always sent
for i := range askData {
asks, ok := askData[i].([]interface{})
if !ok {
return errors.New("asks type assertion failure")
}
priceStr, ok := asks[0].(string)
if !ok {
return errors.New("price type assertion failure")
}
price, err := strconv.ParseFloat(priceStr, 64)
if err != nil {
return err
}
amountStr, ok := asks[1].(string)
if !ok {
return errors.New("amount type assertion failure")
}
amount, err := strconv.ParseFloat(amountStr, 64)
if err != nil {
return err
}
timeStr, ok := asks[2].(string)
if !ok {
return errors.New("time type assertion failure")
}
timeData, err := strconv.ParseFloat(timeStr, 64)
if err != nil {
return err
}
update.Asks[i] = orderbook.Tranche{
Amount: amount,
StrAmount: amountStr,
Price: price,
StrPrice: priceStr,
}
askUpdatedTime := convert.TimeFromUnixTimestampDecimal(timeData)
if highestLastUpdate.Before(askUpdatedTime) {
highestLastUpdate = askUpdatedTime
}
}
// Bid data is not always sent
for i := range bidData {
bids, ok := bidData[i].([]interface{})
if !ok {
return common.GetTypeAssertError("[]interface{}", bidData[i], "bids")
}
priceStr, ok := bids[0].(string)
if !ok {
return errors.New("price type assertion failure")
}
price, err := strconv.ParseFloat(priceStr, 64)
if err != nil {
return err
}
amountStr, ok := bids[1].(string)
if !ok {
return errors.New("amount type assertion failure")
}
amount, err := strconv.ParseFloat(amountStr, 64)
if err != nil {
return err
}
timeStr, ok := bids[2].(string)
if !ok {
return errors.New("time type assertion failure")
}
timeData, err := strconv.ParseFloat(timeStr, 64)
if err != nil {
return err
}
update.Bids[i] = orderbook.Tranche{
Amount: amount,
StrAmount: amountStr,
Price: price,
StrPrice: priceStr,
}
bidUpdatedTime := convert.TimeFromUnixTimestampDecimal(timeData)
if highestLastUpdate.Before(bidUpdatedTime) {
highestLastUpdate = bidUpdatedTime
}
}
update.UpdateTime = highestLastUpdate
err := k.Websocket.Orderbook.Update(&update)
if err != nil {
return err
}
book, err := k.Websocket.Orderbook.GetOrderbook(pair, asset.Spot)
if err != nil {
return fmt.Errorf("cannot calculate websocket checksum: book not found for %s %s %w", pair, asset.Spot, err)
}
token, err := strconv.ParseInt(checksum, 10, 64)
if err != nil {
return err
}
return validateCRC32(book, uint32(token))
}
func validateCRC32(b *orderbook.Base, token uint32) error {
if b == nil {
return common.ErrNilPointer
}
var checkStr strings.Builder
for i := 0; i < 10 && i < len(b.Asks); i++ {
_, err := checkStr.WriteString(trim(b.Asks[i].StrPrice + trim(b.Asks[i].StrAmount)))
if err != nil {
return err
}
}
for i := 0; i < 10 && i < len(b.Bids); i++ {
_, err := checkStr.WriteString(trim(b.Bids[i].StrPrice) + trim(b.Bids[i].StrAmount))
if err != nil {
return err
}
}
if check := crc32.ChecksumIEEE([]byte(checkStr.String())); check != token {
return fmt.Errorf("%s %s %w %d, expected %d", b.Pair, b.Asset, errInvalidChecksum, check, token)
}
return nil
}
// trim removes '.' and prefixed '0' from subsequent string
func trim(s string) string {
s = strings.Replace(s, ".", "", 1)
s = strings.TrimLeft(s, "0")
return s
}
// wsProcessCandle converts candle data and sends it to the data handler
func (k *Kraken) wsProcessCandle(c string, resp []any, pair currency.Pair) error {
// 8 string quoted floats followed by 1 integer for trade count
dataRaw, ok := resp[1].([]any)
if !ok || len(dataRaw) != 9 {
return errors.New("received invalid candle data")
}
data := make([]float64, 8)
for i := range 8 {
s, ok := dataRaw[i].(string)
if !ok {
return fmt.Errorf("received invalid candle data: %w", common.GetTypeAssertError("string", dataRaw[i], "candle-data"))
}
f, err := strconv.ParseFloat(s, 64)
if err != nil {
return fmt.Errorf("received invalid candle data: %w", err)
}
data[i] = f
}
// Faster than getting it through the subscription
parts := strings.Split(c, "-")
if len(parts) != 2 {
return errBadChannelSuffix
}
interval := parts[1]
k.Websocket.DataHandler <- stream.KlineData{
AssetType: asset.Spot,
Pair: pair,
Timestamp: time.Now(),
Exchange: k.Name,
StartTime: convert.TimeFromUnixTimestampDecimal(data[0]),
CloseTime: convert.TimeFromUnixTimestampDecimal(data[1]),
OpenPrice: data[2],
HighPrice: data[3],
LowPrice: data[4],
ClosePrice: data[5],
Volume: data[7],
Interval: interval,
}
return nil
}
// GetSubscriptionTemplate returns a subscription channel template
func (k *Kraken) GetSubscriptionTemplate(_ *subscription.Subscription) (*template.Template, error) {
return template.New("master.tmpl").Funcs(template.FuncMap{"channelName": channelName}).Parse(subTplText)
}
func (k *Kraken) generateSubscriptions() (subscription.List, error) {
return k.Features.Subscriptions.ExpandTemplates(k)
}
// Subscribe adds a channel subscription to the websocket
func (k *Kraken) Subscribe(in subscription.List) error {
in, errs := in.ExpandTemplates(k)
// Collect valid new subs and add to websocket in Subscribing state
subs := subscription.List{}
for _, s := range in {
if s.State() != subscription.ResubscribingState {
if err := k.Websocket.AddSubscriptions(k.Websocket.Conn, s); err != nil {
errs = common.AppendError(errs, fmt.Errorf("%w; Channel: %s Pairs: %s", err, s.Channel, s.Pairs.Join()))
continue
}
}
subs = append(subs, s)
}
// Merge subs by grouping pairs for request; We make a single request to subscribe to N+ pairs, but get N+ responses back
groupedSubs := subs.GroupPairs()
errs = common.AppendError(errs,
k.ParallelChanOp(groupedSubs, func(s subscription.List) error { return k.manageSubs(krakenWsSubscribe, s) }, 1),
)
for _, s := range subs {
if s.State() != subscription.SubscribedState {
_ = s.SetState(subscription.InactiveState)
if err := k.Websocket.RemoveSubscriptions(k.Websocket.Conn, s); err != nil {
errs = common.AppendError(errs, fmt.Errorf("error removing failed subscription: %w; Channel: %s Pairs: %s", err, s.Channel, s.Pairs.Join()))
}
}
}
return errs
}
// Unsubscribe removes a channel subscriptions from the websocket
func (k *Kraken) Unsubscribe(keys subscription.List) error {
var errs error
// Make sure we have the concrete subscriptions, since we will change the state
subs := make(subscription.List, 0, len(keys))
for _, key := range keys {
if s := k.Websocket.GetSubscription(key); s == nil {
errs = common.AppendError(errs, fmt.Errorf("%w; Channel: %s Pairs: %s", subscription.ErrNotFound, key.Channel, key.Pairs.Join()))
} else {
if s.State() != subscription.ResubscribingState {
if err := s.SetState(subscription.UnsubscribingState); err != nil {
errs = common.AppendError(errs, fmt.Errorf("%w; Channel: %s Pairs: %s", err, s.Channel, s.Pairs.Join()))
continue
}
}
subs = append(subs, s)
}
}
subs = subs.GroupPairs()
return common.AppendError(errs,
k.ParallelChanOp(subs, func(s subscription.List) error { return k.manageSubs(krakenWsUnsubscribe, s) }, 1),
)
}
// manageSubs handles both websocket channel subscribe and unsubscribe
func (k *Kraken) manageSubs(op string, subs subscription.List) error {
if len(subs) != 1 {
return subscription.ErrBatchingNotSupported
}
s := subs[0]
if err := enforceStandardChannelNames(s); err != nil {
return err
}
reqFmt := currency.PairFormat{Uppercase: true, Delimiter: "/"}
r := &WebsocketSubRequest{
Event: op,
RequestID: k.Websocket.Conn.GenerateMessageID(false),
Subscription: WebsocketSubscriptionData{
Name: s.QualifiedChannel,
Depth: s.Levels,
},
Pairs: s.Pairs.Format(reqFmt).Strings(),
}
if s.Interval != 0 {
// TODO: Can Interval type be a kraken specific type with a MarshalText so we don't have to duplicate this
r.Subscription.Interval = int(time.Duration(s.Interval).Minutes())
}
conn := k.Websocket.Conn
if s.Authenticated {
r.Subscription.Token = authToken
conn = k.Websocket.AuthConn
}
resps, err := conn.SendMessageReturnResponses(context.TODO(), request.Unset, r.RequestID, r, len(s.Pairs))
// Ignore an overall timeout, because we'll track individual subscriptions in handleSubResps
err = common.ExcludeError(err, stream.ErrSignatureTimeout)
if err != nil {
return fmt.Errorf("%w; Channel: %s Pair: %s", err, s.Channel, s.Pairs)
}
return k.handleSubResps(s, resps, op)
}
// handleSubResps takes a collection of subscription responses from Kraken
// We submit a subscription for N+ pairs, and we get N+ individual responses
// Returns an error collection of unique errors and its pairs
func (k *Kraken) handleSubResps(s *subscription.Subscription, resps [][]byte, op string) error {
reqFmt := currency.PairFormat{Uppercase: true, Delimiter: "/"}
errMap := map[string]error{}
pairErrs := map[currency.Pair]error{}
for _, p := range s.Pairs {
pairErrs[p.Format(reqFmt)] = errSubPairMissing
}
subPairs := currency.Pairs{}
for _, resp := range resps {
pName, err := jsonparser.GetUnsafeString(resp, "pair")
if err != nil {
return fmt.Errorf("%w parsing WS pair from message: %s", err, resp)
}
pair, err := currency.NewPairDelimiter(pName, "/")
if err != nil {
return fmt.Errorf("%w parsing WS pair; Channel: %s Pair: %s", err, s.Channel, pName)
}
if err := k.getSubRespErr(resp, op); err != nil {
// Remove the pair name from the error so we can group errors
errStr := strings.TrimSpace(strings.TrimSuffix(err.Error(), pName))
if _, ok := errMap[errStr]; !ok {
errMap[errStr] = errors.New(errStr)
}
pairErrs[pair] = errMap[errStr]
} else {
delete(pairErrs, pair)
if k.Verbose && op == krakenWsSubscribe {
subPairs = subPairs.Add(pair)
}
}
}
// 2) Reverse the collection and report a list of pairs with each unique error, and re-add the missing and error pairs for unsubscribe
errPairs := map[error]currency.Pairs{}
for pair, err := range pairErrs {
errPairs[err] = errPairs[err].Add(pair)
}
var errs error
for err, pairs := range errPairs {
errs = common.AppendError(errs, fmt.Errorf("%w; Channel: %s Pairs: %s", err, s.Channel, pairs.Join()))
}
if k.Verbose && len(subPairs) > 0 {
log.Debugf(log.ExchangeSys, "%s Subscribed to Channel: %s Pairs: %s", k.Name, s.Channel, subPairs.Join())
}
return errs
}
// getSubErrResp calls getRespErr and if there's no error from that ensures the status matches the sub operation
func (k *Kraken) getSubRespErr(resp []byte, op string) error {
if err := k.getRespErr(resp); err != nil {
return err
}
exp := op + "d" // subscribed or unsubscribed
if status, err := jsonparser.GetUnsafeString(resp, "status"); err != nil {
return fmt.Errorf("error parsing WS status: %w from message: %s", err, resp)
} else if status != exp {
return fmt.Errorf("wrong WS status: %s; expected: %s from message %s", exp, op, resp)
}
return nil
}
// getRespErr takes a json response string and looks for an error event type
// If found it returns the errorMessage
// It might log parsing errors about the nature of the error
// If the error message is not defined it will return a wrapped errUnknownError
func (k *Kraken) getRespErr(resp []byte) error {
event, err := jsonparser.GetUnsafeString(resp, "event")
switch {
case err != nil:
return fmt.Errorf("error parsing WS event: %w from message: %s", err, resp)
case event != "error":
status, _ := jsonparser.GetUnsafeString(resp, "status") // Error is really irrelevant here
if status != "error" {
return nil
}
}
var msg string
if msg, err = jsonparser.GetString(resp, "errorMessage"); err != nil {
log.Errorf(log.ExchangeSys, "%s error parsing WS errorMessage: %s from message: %s", k.Name, err, resp)
return fmt.Errorf("%w: error message did not contain errorMessage: %s", common.ErrUnknownError, resp)
}
return errors.New(msg)
}
// wsProcessSubStatus handles creating or removing Subscriptions as soon as we receive a message
// It's job is to ensure that subscription state is kept correct sequentially between WS messages
// If this responsibility was moved to Subscribe then we would have a race due to the channel connecting IncomingWithData
func (k *Kraken) wsProcessSubStatus(resp []byte) {
pName, err := jsonparser.GetUnsafeString(resp, "pair")
if err != nil {
return
}
pair, err := currency.NewPairFromString(pName)
if err != nil {
return
}
c, err := jsonparser.GetUnsafeString(resp, "channelName")
if err != nil {
return
}
if err = k.getRespErr(resp); err != nil {
return
}
status, err := jsonparser.GetUnsafeString(resp, "status")
if err != nil {
return
}
key := &subscription.Subscription{
// We don't use asset because it's either Empty or Spot, but not both
Channel: c,
Pairs: currency.Pairs{pair},
}
if err = fqChannelNameSub(key); err != nil {
return
}
s := k.Websocket.GetSubscription(&subscription.IgnoringAssetKey{Subscription: key})
if s == nil {
log.Errorf(log.ExchangeSys, "%s %s Channel: %s Pairs: %s", k.Name, subscription.ErrNotFound, key.Channel, key.Pairs.Join())
return
}
if status == krakenWsSubscribed {
err = s.SetState(subscription.SubscribedState)
} else if s.State() != subscription.ResubscribingState { // Do not remove a resubscribing sub which just unsubbed
err = k.Websocket.RemoveSubscriptions(k.Websocket.Conn, s)
if e2 := s.SetState(subscription.UnsubscribedState); e2 != nil {
err = common.AppendError(err, e2)
}
}
if err != nil {
log.Errorf(log.ExchangeSys, "%s %s Channel: %s Pairs: %s", k.Name, err, s.Channel, s.Pairs.Join())
}
}
// channelName converts a global channel name to kraken bespoke names
func channelName(s *subscription.Subscription) string {
if n, ok := channelNames[s.Channel]; ok {
return n
}
return s.Channel
}
func enforceStandardChannelNames(s *subscription.Subscription) error {
name := strings.Split(s.Channel, "-") // Protect against attempted usage of book-N as a channel name
if n, ok := reverseChannelNames[name[0]]; ok && n != s.Channel {
return fmt.Errorf("%w: %s => subscription.%s%sChannel", subscription.ErrPrivateChannelName, s.Channel, bytes.ToUpper([]byte{n[0]}), n[1:])
}
return nil
}
// fqChannelNameSub converts an fully qualified channel name into standard name and subscription params
// e.g. book-5 => subscription.OrderbookChannel with Levels: 5
func fqChannelNameSub(s *subscription.Subscription) error {
parts := strings.Split(s.Channel, "-")
name := parts[0]
if stdName, ok := reverseChannelNames[name]; ok {
name = stdName
}
if name == subscription.OrderbookChannel || name == subscription.CandlesChannel {
if len(parts) != 2 {
return errBadChannelSuffix
}
i, err := strconv.Atoi(parts[1])
if err != nil {
return errBadChannelSuffix
}
switch name {
case subscription.OrderbookChannel:
s.Levels = i
case subscription.CandlesChannel:
s.Interval = kline.Interval(time.Minute * time.Duration(i))
}
}
s.Channel = name
return nil
}
// wsAddOrder creates an order, returned order ID if success
func (k *Kraken) wsAddOrder(req *WsAddOrderRequest) (string, error) {
if req == nil {
return "", common.ErrNilPointer
}
req.RequestID = k.Websocket.AuthConn.GenerateMessageID(false)
req.Event = krakenWsAddOrder
req.Token = authToken
jsonResp, err := k.Websocket.AuthConn.SendMessageReturnResponse(context.TODO(), request.Unset, req.RequestID, req)
if err != nil {
return "", err
}
var resp WsAddOrderResponse
err = json.Unmarshal(jsonResp, &resp)
if err != nil {
return "", err
}
if resp.Status == "error" {
return "", errors.New("AddOrder error: " + resp.ErrorMessage)
}
k.Websocket.DataHandler <- &order.Detail{
Exchange: k.Name,
OrderID: resp.TransactionID,
Status: order.New,
}
return resp.TransactionID, nil
}
// wsCancelOrders cancels open orders concurrently
// It does not use the multiple txId facility of the cancelOrder API because the errors are not specific
func (k *Kraken) wsCancelOrders(orderIDs []string) error {
errs := common.CollectErrors(len(orderIDs))
for _, id := range orderIDs {
go func() {
defer errs.Wg.Done()
errs.C <- k.wsCancelOrder(id)
}()
}
return errs.Collect()
}
// wsCancelOrder cancels an open order
func (k *Kraken) wsCancelOrder(orderID string) error {
id := k.Websocket.AuthConn.GenerateMessageID(false)
req := WsCancelOrderRequest{
Event: krakenWsCancelOrder,
Token: authToken,
TransactionIDs: []string{orderID},
RequestID: id,
}
resp, err := k.Websocket.AuthConn.SendMessageReturnResponse(context.TODO(), request.Unset, id, req)
if err != nil {
return fmt.Errorf("%w %s: %w", errCancellingOrder, orderID, err)
}
status, err := jsonparser.GetUnsafeString(resp, "status")
if err != nil {
return fmt.Errorf("%w 'status': %w from message: %s", errParsingWSField, err, resp)
} else if status == "ok" {
return nil
}
err = common.ErrUnknownError
if msg, pErr := jsonparser.GetUnsafeString(resp, "errorMessage"); pErr == nil && msg != "" {
err = errors.New(msg)
}
return fmt.Errorf("%w %s: %w", errCancellingOrder, orderID, err)
}
// wsCancelAllOrders cancels all opened orders
// Returns number (count param) of affected orders or 0 if no open orders found
func (k *Kraken) wsCancelAllOrders() (*WsCancelOrderResponse, error) {
id := k.Websocket.AuthConn.GenerateMessageID(false)
req := WsCancelOrderRequest{
Event: krakenWsCancelAll,
Token: authToken,
RequestID: id,
}
jsonResp, err := k.Websocket.AuthConn.SendMessageReturnResponse(context.TODO(), request.Unset, id, req)
if err != nil {
return &WsCancelOrderResponse{}, err
}
var resp WsCancelOrderResponse
err = json.Unmarshal(jsonResp, &resp)
if err != nil {
return &WsCancelOrderResponse{}, err
}
if resp.ErrorMessage != "" {
return &WsCancelOrderResponse{}, errors.New(resp.ErrorMessage)
}
return &resp, nil
}
/*
One sub per-pair. We don't use one sub with many pairs because:
- Kraken will fan out in responses anyay
- resubscribe is messy when our subs don't match their respsonses
- FlushChannels and GetChannelDiff would incorrectly resub existing subs if we don't generate the same as we've stored
*/
const subTplText = `
{{- if $.S.Asset -}}
{{ range $asset, $pairs := $.AssetPairs }}
{{- range $p := $pairs -}}
{{- channelName $.S }}
{{- $.PairSeparator }}
{{- end -}}
{{ $.AssetSeparator }}
{{- end -}}
{{- else -}}
{{- channelName $.S }}
{{- end }}
`