mirror of
https://github.com/d0zingcat/gocryptotrader.git
synced 2026-05-23 15:10:15 +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 * stream match update * update tests * linter: fix * glorious: nits + handle context cancellations * 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. * glorious: whooops * gk: nits * Leak issue and edge case * Websocket: Add SendMessageReturnResponses * whooooooopsie * gk: nitssssss * Update exchanges/stream/stream_match.go Co-authored-by: Gareth Kirwan <gbjkirwan@gmail.com> * Update exchanges/stream/stream_match_test.go Co-authored-by: Gareth Kirwan <gbjkirwan@gmail.com> * linter: appease the linter gods * gk: nits * gk: drain brain * started * more changes before merge match pr * gateio: still building out * gateio: finish spot * fix up tests in gateio * Add tests for stream package * rm unused field * glorious: nits * rn files, specifically set function names to asset and offload routing to websocket type. * linter: fix * Add futures websocket request support * gateio: integrate with IBOTExchange (cherry pick my nose) * linter: fix * glorious: nits * add counter and update gateio * fix collision issue * 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 * upgrade to upstream merge * 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 * Set correct price * 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 * fix ID bug, why I do this, I don't know. * glorious: panix * linter: things * whoops * dont need to make consecutive Unix() calls * websocket: fix potential panic on error and no responses and adding waitForResponses * rm json parser and handle in json package instead * in favour of json package unmarshalling * linter: fix * linter: fix again * * change field name OutboundRequestSignature to WrapperDefinedConnectionSignature for agnostic inbound and outbound connections. * change method name GetOutboundConnection to GetConnection for agnostic inbound and outbound connections. * drop outbound field map for improved performance just using a range and field check (less complex as well) * change field name connections to connectionToWrapper for better clarity * spells and magic and wands * glorious: nits * comparable check for signature * mv err var * glorious: nits and stuff * attempt to fix race * glorious: nits * gk: nits; engine log cleanup * gk: nits; OCD * gk: nits; move function change file names * gk: nits; 🚀 * gk: nits; convert variadic function and message inspection to interface and include a specific function for that handling so as to not need nil on every call * gk: nits; continued * gk: engine nits; rm loaded exchange * gk: nits; drop WebsocketLoginResponse * stream: Add match method EnsureMatchWithData * gk: nits; rn Inspect to IsFinal * gk: nits; rn to MessageFilter * linter: fix * gateio: update rate limit definitions (cherry-pick) * Add test and missing * Shared REST rate limit definitions with Websocket service, set lookup item to nil for systems that do not require rate limiting; add glorious nit * integrate rate limits for websocket trading spot * conform to match upstream changes * standardise names to upstream style * fix wrapper standards test when sending a auth request through a websocket connection * whoops * Update exchanges/gateio/gateio_types.go Co-authored-by: Scott <gloriousCode@users.noreply.github.com> * glorious: nits * linter: fix * linter: overload * whoops * spelling fixes on recent merge * glorious: nits * linter: fix? * glorious: nits * gk: assert errors touched * gk: unexport derive functions * gk: nitssssssss * fix test * gk: nitters v1 * gk: http status * gk/nits: Add getAssetFromFuturesPair * gk: nits single response when submitting * gk: new pair with delimiter in tests * gk: param update slice to slice of pointers * gk: add asset type in params, includes t.Context() for tests * linter: fix * linter: fix * fix merge whoopsie * glorious: nits * gk: nit * shift over to websocket package error * internal/exchange/websocket -> exchange/websocket * PEAK OCD! * appease the OCD gods * thrasher: nits --------- Co-authored-by: shazbert <ryan.oharareid@thrasher.io> Co-authored-by: Gareth Kirwan <gbjkirwan@gmail.com> Co-authored-by: Scott <gloriousCode@users.noreply.github.com>
443 lines
12 KiB
Go
443 lines
12 KiB
Go
package btse
|
|
|
|
import (
|
|
"context"
|
|
"errors"
|
|
"net/http"
|
|
"strconv"
|
|
"strings"
|
|
"text/template"
|
|
"time"
|
|
|
|
gws "github.com/gorilla/websocket"
|
|
"github.com/thrasher-corp/gocryptotrader/common/crypto"
|
|
"github.com/thrasher-corp/gocryptotrader/currency"
|
|
"github.com/thrasher-corp/gocryptotrader/encoding/json"
|
|
"github.com/thrasher-corp/gocryptotrader/exchange/websocket"
|
|
"github.com/thrasher-corp/gocryptotrader/exchanges/asset"
|
|
"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/subscription"
|
|
"github.com/thrasher-corp/gocryptotrader/exchanges/trade"
|
|
"github.com/thrasher-corp/gocryptotrader/log"
|
|
)
|
|
|
|
const (
|
|
btseWebsocket = "wss://ws.btse.com/ws/spot"
|
|
btseWebsocketTimer = time.Second * 57
|
|
)
|
|
|
|
var subscriptionNames = map[string]string{
|
|
subscription.MyTradesChannel: "notificationApi",
|
|
subscription.AllTradesChannel: "tradeHistoryApi",
|
|
}
|
|
|
|
var defaultSubscriptions = subscription.List{
|
|
{Enabled: true, Asset: asset.Spot, Channel: subscription.AllTradesChannel},
|
|
{Enabled: true, Channel: subscription.MyTradesChannel, Authenticated: true},
|
|
}
|
|
|
|
// WsConnect connects the websocket client
|
|
func (b *BTSE) WsConnect() error {
|
|
if !b.Websocket.IsEnabled() || !b.IsEnabled() {
|
|
return websocket.ErrWebsocketNotEnabled
|
|
}
|
|
var dialer gws.Dialer
|
|
err := b.Websocket.Conn.Dial(&dialer, http.Header{})
|
|
if err != nil {
|
|
return err
|
|
}
|
|
b.Websocket.Conn.SetupPingHandler(request.Unset, websocket.PingHandler{
|
|
MessageType: gws.PingMessage,
|
|
Delay: btseWebsocketTimer,
|
|
})
|
|
|
|
b.Websocket.Wg.Add(1)
|
|
go b.wsReadData()
|
|
|
|
if b.IsWebsocketAuthenticationSupported() {
|
|
err = b.WsAuthenticate(context.TODO())
|
|
if err != nil {
|
|
b.Websocket.DataHandler <- err
|
|
b.Websocket.SetCanUseAuthenticatedEndpoints(false)
|
|
}
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
// WsAuthenticate Send an authentication message to receive auth data
|
|
func (b *BTSE) WsAuthenticate(ctx context.Context) error {
|
|
creds, err := b.GetCredentials(ctx)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
nonce := strconv.FormatInt(time.Now().UnixMilli(), 10)
|
|
path := "/ws/spot" + nonce
|
|
|
|
hmac, err := crypto.GetHMAC(crypto.HashSHA512_384,
|
|
[]byte((path)),
|
|
[]byte(creds.Secret),
|
|
)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
sign := crypto.HexEncodeToString(hmac)
|
|
req := wsSub{
|
|
Operation: "authKeyExpires",
|
|
Arguments: []string{creds.Key, nonce, sign},
|
|
}
|
|
return b.Websocket.Conn.SendJSONMessage(ctx, request.Unset, req)
|
|
}
|
|
|
|
func stringToOrderStatus(status string) (order.Status, error) {
|
|
switch status {
|
|
case "ORDER_INSERTED", "TRIGGER_INSERTED":
|
|
return order.New, nil
|
|
case "ORDER_CANCELLED":
|
|
return order.Cancelled, nil
|
|
case "ORDER_FULL_TRANSACTED":
|
|
return order.Filled, nil
|
|
case "ORDER_PARTIALLY_TRANSACTED":
|
|
return order.PartiallyFilled, nil
|
|
case "TRIGGER_ACTIVATED":
|
|
return order.Active, nil
|
|
case "INSUFFICIENT_BALANCE":
|
|
return order.InsufficientBalance, nil
|
|
case "MARKET_UNAVAILABLE":
|
|
return order.MarketUnavailable, nil
|
|
default:
|
|
return order.UnknownStatus, errors.New(status + " not recognised as order status")
|
|
}
|
|
}
|
|
|
|
// wsReadData receives and passes on websocket messages for processing
|
|
func (b *BTSE) wsReadData() {
|
|
defer b.Websocket.Wg.Done()
|
|
|
|
for {
|
|
resp := b.Websocket.Conn.ReadMessage()
|
|
if resp.Raw == nil {
|
|
return
|
|
}
|
|
err := b.wsHandleData(resp.Raw)
|
|
if err != nil {
|
|
b.Websocket.DataHandler <- err
|
|
}
|
|
}
|
|
}
|
|
|
|
func (b *BTSE) wsHandleData(respRaw []byte) error {
|
|
type Result map[string]any
|
|
var result Result
|
|
err := json.Unmarshal(respRaw, &result)
|
|
if err != nil {
|
|
if strings.Contains(string(respRaw), "connect success") {
|
|
return nil
|
|
}
|
|
return err
|
|
}
|
|
if result == nil {
|
|
return nil
|
|
}
|
|
|
|
if result["event"] != nil {
|
|
event, ok := result["event"].(string)
|
|
if !ok {
|
|
return errors.New(b.Name + websocket.UnhandledMessage + string(respRaw))
|
|
}
|
|
switch event {
|
|
case "subscribe":
|
|
var subscribe WsSubscriptionAcknowledgement
|
|
err = json.Unmarshal(respRaw, &subscribe)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if b.Verbose {
|
|
log.Infof(log.WebsocketMgr, "%v subscribed to %v", b.Name, strings.Join(subscribe.Channel, ", "))
|
|
}
|
|
case "login":
|
|
var login WsLoginAcknowledgement
|
|
err = json.Unmarshal(respRaw, &login)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
b.Websocket.SetCanUseAuthenticatedEndpoints(login.Success)
|
|
if b.Verbose {
|
|
log.Infof(log.WebsocketMgr, "%v websocket authenticated: %v", b.Name, login.Success)
|
|
}
|
|
default:
|
|
return errors.New(b.Name + websocket.UnhandledMessage + string(respRaw))
|
|
}
|
|
return nil
|
|
}
|
|
|
|
topic, ok := result["topic"].(string)
|
|
if !ok {
|
|
return errors.New(b.Name + websocket.UnhandledMessage + string(respRaw))
|
|
}
|
|
switch {
|
|
case topic == "notificationApi":
|
|
var notification wsNotification
|
|
err = json.Unmarshal(respRaw, ¬ification)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
for i := range notification.Data {
|
|
var oType order.Type
|
|
var oSide order.Side
|
|
var oStatus order.Status
|
|
oType, err = order.StringToOrderType(notification.Data[i].Type)
|
|
if err != nil {
|
|
b.Websocket.DataHandler <- order.ClassificationError{
|
|
Exchange: b.Name,
|
|
OrderID: notification.Data[i].OrderID,
|
|
Err: err,
|
|
}
|
|
}
|
|
oSide, err = order.StringToOrderSide(notification.Data[i].OrderMode)
|
|
if err != nil {
|
|
b.Websocket.DataHandler <- order.ClassificationError{
|
|
Exchange: b.Name,
|
|
OrderID: notification.Data[i].OrderID,
|
|
Err: err,
|
|
}
|
|
}
|
|
oStatus, err = stringToOrderStatus(notification.Data[i].Status)
|
|
if err != nil {
|
|
b.Websocket.DataHandler <- order.ClassificationError{
|
|
Exchange: b.Name,
|
|
OrderID: notification.Data[i].OrderID,
|
|
Err: err,
|
|
}
|
|
}
|
|
|
|
var p currency.Pair
|
|
p, err = currency.NewPairFromString(notification.Data[i].Symbol)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
var a asset.Item
|
|
a, err = b.GetPairAssetType(p)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
b.Websocket.DataHandler <- &order.Detail{
|
|
Price: notification.Data[i].Price,
|
|
Amount: notification.Data[i].Size,
|
|
TriggerPrice: notification.Data[i].TriggerPrice,
|
|
Exchange: b.Name,
|
|
OrderID: notification.Data[i].OrderID,
|
|
Type: oType,
|
|
Side: oSide,
|
|
Status: oStatus,
|
|
AssetType: a,
|
|
Date: time.UnixMilli(notification.Data[i].Timestamp),
|
|
Pair: p,
|
|
}
|
|
}
|
|
case strings.Contains(topic, "tradeHistoryApi"):
|
|
saveTradeData := b.IsSaveTradeDataEnabled()
|
|
tradeFeed := b.IsTradeFeedEnabled()
|
|
if !saveTradeData && !tradeFeed {
|
|
return nil
|
|
}
|
|
|
|
var tradeHistory wsTradeHistory
|
|
err = json.Unmarshal(respRaw, &tradeHistory)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
var trades []trade.Data
|
|
for x := range tradeHistory.Data {
|
|
var p currency.Pair
|
|
p, err = currency.NewPairFromString(tradeHistory.Data[x].Symbol)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
var a asset.Item
|
|
a, err = b.GetPairAssetType(p)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
trades = append(trades, trade.Data{
|
|
Timestamp: tradeHistory.Data[x].Timestamp.Time().UTC(),
|
|
CurrencyPair: p,
|
|
AssetType: a,
|
|
Exchange: b.Name,
|
|
Price: tradeHistory.Data[x].Price,
|
|
Amount: tradeHistory.Data[x].Size,
|
|
Side: tradeHistory.Data[x].Side,
|
|
TID: strconv.FormatInt(tradeHistory.Data[x].TID, 10),
|
|
})
|
|
}
|
|
if tradeFeed {
|
|
for i := range trades {
|
|
b.Websocket.DataHandler <- trades[i]
|
|
}
|
|
}
|
|
if saveTradeData {
|
|
return trade.AddTradesToBuffer(trades...)
|
|
}
|
|
case strings.Contains(topic, "orderBookL2Api"): // TODO: Fix orderbook updates.
|
|
var t wsOrderBook
|
|
err = json.Unmarshal(respRaw, &t)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
newOB := orderbook.Base{
|
|
Bids: make(orderbook.Tranches, 0, len(t.Data.BuyQuote)),
|
|
Asks: make(orderbook.Tranches, 0, len(t.Data.SellQuote)),
|
|
}
|
|
var price, amount float64
|
|
for i := range t.Data.SellQuote {
|
|
p := strings.ReplaceAll(t.Data.SellQuote[i].Price, ",", "")
|
|
price, err = strconv.ParseFloat(p, 64)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
a := strings.ReplaceAll(t.Data.SellQuote[i].Size, ",", "")
|
|
amount, err = strconv.ParseFloat(a, 64)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if b.orderbookFilter(price, amount) {
|
|
continue
|
|
}
|
|
newOB.Asks = append(newOB.Asks, orderbook.Tranche{
|
|
Price: price,
|
|
Amount: amount,
|
|
})
|
|
}
|
|
for j := range t.Data.BuyQuote {
|
|
p := strings.ReplaceAll(t.Data.BuyQuote[j].Price, ",", "")
|
|
price, err = strconv.ParseFloat(p, 64)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
a := strings.ReplaceAll(t.Data.BuyQuote[j].Size, ",", "")
|
|
amount, err = strconv.ParseFloat(a, 64)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if b.orderbookFilter(price, amount) {
|
|
continue
|
|
}
|
|
newOB.Bids = append(newOB.Bids, orderbook.Tranche{
|
|
Price: price,
|
|
Amount: amount,
|
|
})
|
|
}
|
|
p, err := currency.NewPairFromString(t.Topic[strings.Index(t.Topic, ":")+1 : strings.Index(t.Topic, currency.UnderscoreDelimiter)])
|
|
if err != nil {
|
|
return err
|
|
}
|
|
var a asset.Item
|
|
a, err = b.GetPairAssetType(p)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
newOB.Pair = p
|
|
newOB.Asset = a
|
|
newOB.Exchange = b.Name
|
|
newOB.Asks.Reverse() // Reverse asks for correct alignment
|
|
newOB.VerifyOrderbook = b.CanVerifyOrderbook
|
|
newOB.LastUpdated = time.Now() // NOTE: Temp to fix test.
|
|
err = b.Websocket.Orderbook.LoadSnapshot(&newOB)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
default:
|
|
return errors.New(b.Name + websocket.UnhandledMessage + string(respRaw))
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
// orderbookFilter is needed on book levels from this exchange as their data
|
|
// is incorrect
|
|
func (b *BTSE) orderbookFilter(price, amount float64) bool {
|
|
// Amount filtering occurs when the amount exceeds the decimal returned.
|
|
// e.g. {"price":"1.37","size":"0.00"} currency: SFI-ETH
|
|
// Opted to not round up to 0.01 as this might skew calculations
|
|
// more than removing from the books completely.
|
|
|
|
// Price filtering occurs when we are deep in the bid book and there are
|
|
// prices that are less than 4 decimal places
|
|
// e.g. {"price":"0.0000","size":"14219"} currency: TRX-PAX
|
|
// We cannot load a zero price and this will ruin calculations
|
|
return price == 0 || amount == 0
|
|
}
|
|
|
|
// generateSubscriptions returns a list of subscriptions from the configured subscriptions feature
|
|
func (b *BTSE) generateSubscriptions() (subscription.List, error) {
|
|
return b.Features.Subscriptions.ExpandTemplates(b)
|
|
}
|
|
|
|
// GetSubscriptionTemplate returns a subscription channel template
|
|
func (b *BTSE) GetSubscriptionTemplate(_ *subscription.Subscription) (*template.Template, error) {
|
|
return template.New("master.tmpl").Funcs(template.FuncMap{
|
|
"channelName": channelName,
|
|
"isSymbolChannel": isSymbolChannel,
|
|
}).Parse(subTplText)
|
|
}
|
|
|
|
// Subscribe sends a websocket message to receive data from a list of channels
|
|
func (b *BTSE) Subscribe(subs subscription.List) error {
|
|
req := wsSub{Operation: "subscribe"}
|
|
for _, s := range subs {
|
|
req.Arguments = append(req.Arguments, s.QualifiedChannel)
|
|
}
|
|
err := b.Websocket.Conn.SendJSONMessage(context.TODO(), request.Unset, req)
|
|
if err == nil {
|
|
err = b.Websocket.AddSuccessfulSubscriptions(b.Websocket.Conn, subs...)
|
|
}
|
|
return err
|
|
}
|
|
|
|
// Unsubscribe sends a websocket message to stop receiving data from a list of channels
|
|
func (b *BTSE) Unsubscribe(subs subscription.List) error {
|
|
req := wsSub{Operation: "unsubscribe"}
|
|
for _, s := range subs {
|
|
req.Arguments = append(req.Arguments, s.QualifiedChannel)
|
|
}
|
|
err := b.Websocket.Conn.SendJSONMessage(context.TODO(), request.Unset, req)
|
|
if err == nil {
|
|
err = b.Websocket.RemoveSubscriptions(b.Websocket.Conn, subs...)
|
|
}
|
|
return err
|
|
}
|
|
|
|
// channelName returns the correct channel name for the asset
|
|
func channelName(s *subscription.Subscription) string {
|
|
if name, ok := subscriptionNames[s.Channel]; ok {
|
|
return name
|
|
}
|
|
panic("Channel not supported: " + s.Channel)
|
|
}
|
|
|
|
// isSymbolChannel returns if the channel expects receive a symbol
|
|
func isSymbolChannel(s *subscription.Subscription) bool {
|
|
return s.Channel != subscription.MyTradesChannel
|
|
}
|
|
|
|
const subTplText = `
|
|
{{- with $name := channelName $.S }}
|
|
{{ range $asset, $pairs := $.AssetPairs }}
|
|
{{- if isSymbolChannel $.S }}
|
|
{{- range $p := $pairs -}}
|
|
{{- $name -}} : {{- $p -}}
|
|
{{- $.PairSeparator }}
|
|
{{- end }}
|
|
{{- else -}}
|
|
{{ $name }}
|
|
{{- end }}
|
|
{{- $.AssetSeparator }}
|
|
{{- end }}
|
|
{{- end }}
|
|
`
|