mirror of
https://github.com/d0zingcat/gocryptotrader.git
synced 2026-05-28 15:10:32 +00:00
* 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>
1391 lines
40 KiB
Go
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 }}
|
|
`
|