Files
gocryptotrader/exchanges/gemini/gemini_websocket.go
Samuael A. 3f534a15f1 cmd/exchange_template, exchanges: Update templates and propogate to exchanges (#1777)
* Added TimeInForce type and updated related files

* Linter issue fix and minor coinbasepro type update

* Bitrex consts update

* added unit test and minor changes in bittrex

* Unit tests update

* Fix minor linter issues

* Update TestStringToTimeInForce unit test

* Exchange test template change

* A different approach

* fix conflict with gateio timeInForce

* minor exchange template update

* Minor fix to test_files template

* Update order tests

* Complete updating the order unit tests

* Updating exchange wrapper and test template files

* update kucoin and deribit wrapper to match the time in force change

* minor comment update

* fix time-in-force related test errors

* linter issue fix

* ADD_NEW_EXCHANGE documentation update

* time in force constants, functions and unit tests update

* shift tif policies to TimeInForce

* Update time-in-force, related functions, and unit tests

* fix linter issue and time-in-force processing

* added a good till crossing tif value

* order type fix and fix related tim-in-force entries

* update time-in-force unmarshaling and unit test

* consistency guideline added

* fix time-in-force error in gateio

* linter issue fix

* update based on review comments

* add unit test and fix missing issues

* minor fix and added benchmark unit test

* change GTT to GTC for limit

* fix linter issue

* added time-in-force value to place order param

* fix minor issues based on review comment and move tif code to separate files

* update on exchanges linked to time-in-force

* resolve missing review comments

* minor linter issues fix

* added time-in-force handler and update timeInForce parametered endpoint

* minor fixes based on review

* nits fix

* update based on review

* linter fix

* rm getTimeInForce func and minor change to time-in-force

* minor change

* update based on review comments

* wrappers and time-in-force calling approach

* minor change

* update gateio string to timeInForce conversion and unit test

* update exchange template

* update wrapper template file

* policy comments, and template files update

* rename all exchange types name to Exchange

* update on template files and template generation

* templates and generation code and other updates

* linter issue fix

* added subscriptions and websocket templates

* update ADD_NEW_EXCHANGE.md with recent binance functions and implementations

* rename template files and update unit tests

* minor template and unit test fix

* rename templates and fix on unit tests

* update on template files and documentation

* removed unnecessary tag fix and update templates

* fix Add_NEW_EXCHANGE.md doc file

* formatting, comments, and error checks update on template files

* rename exchange receivers to e and ex for consistency

* rename unit test exchange receiver and minor updates

* linter issues fix

* fix deribit issue and minor style update

* fix test issues caused by receiver change

* raname local variables exchange declaration variables

* update templates comments

* update templates and related comments

* renamed ex to e

* update template comments

* toggle WS to false to improve coverage

* template comments update

* added test coverage to Ws enabled and minor changes

---------

Co-authored-by: Samuel Reid <43227667+cranktakular@users.noreply.github.com>
2025-07-17 10:46:36 +10:00

596 lines
16 KiB
Go

// Package gemini exchange documentation can be found at
// https://docs.sandbox.gemini.com
package gemini
import (
"context"
"encoding/base64"
"encoding/hex"
"errors"
"fmt"
"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"
exchange "github.com/thrasher-corp/gocryptotrader/exchanges"
"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/subscription"
"github.com/thrasher-corp/gocryptotrader/exchanges/trade"
"github.com/thrasher-corp/gocryptotrader/log"
)
const (
geminiWebsocketEndpoint = "wss://api.gemini.com"
geminiWebsocketSandboxEndpoint = "wss://api.sandbox.gemini.com/v1/"
geminiWsMarketData = "marketdata"
geminiWsOrderEvents = "order/events"
)
const (
marketDataLevel2 = "l2"
candlesChannel = "candles"
)
var defaultSubscriptions = subscription.List{
{Enabled: true, Asset: asset.Spot, Channel: subscription.CandlesChannel, Interval: kline.OneDay},
{Enabled: true, Asset: asset.Spot, Channel: subscription.OrderbookChannel},
// Authenticated connection is directly to the orders URI, so this is implicit
// {Enabled: true, Channel: subscription.MyOrdersChannel, Authenticated: true},
}
var subscriptionNames = map[string]string{
subscription.CandlesChannel: candlesChannel,
subscription.OrderbookChannel: marketDataLevel2,
}
// Instantiates a communications channel between websocket connections
var comms = make(chan websocket.Response)
// WsConnect initiates a websocket connection
func (e *Exchange) WsConnect() error {
ctx := context.TODO()
if !e.Websocket.IsEnabled() || !e.IsEnabled() {
return websocket.ErrWebsocketNotEnabled
}
var dialer gws.Dialer
err := e.Websocket.Conn.Dial(ctx, &dialer, http.Header{})
if err != nil {
return err
}
e.Websocket.Wg.Add(2)
go e.wsReadData()
go e.wsFunnelConnectionData(e.Websocket.Conn)
if e.Websocket.CanUseAuthenticatedEndpoints() {
err := e.WsAuth(ctx, &dialer)
if err != nil {
log.Errorf(log.ExchangeSys, "%v - websocket authentication failed: %v\n", e.Name, err)
e.Websocket.SetCanUseAuthenticatedEndpoints(false)
}
}
return nil
}
// generateSubscriptions returns a list of subscriptions from the configured subscriptions feature
func (e *Exchange) generateSubscriptions() (subscription.List, error) {
return e.Features.Subscriptions.ExpandTemplates(e)
}
// GetSubscriptionTemplate returns a subscription channel template
func (e *Exchange) GetSubscriptionTemplate(_ *subscription.Subscription) (*template.Template, error) {
return template.New("master.tmpl").Funcs(template.FuncMap{
"channelName": channelName,
"interval": channelInterval,
}).Parse(subTplText)
}
// Subscribe sends a websocket message to receive data from the channel
func (e *Exchange) Subscribe(subs subscription.List) error {
ctx := context.TODO()
return e.manageSubs(ctx, subs, wsSubscribeOp)
}
// Unsubscribe sends a websocket message to stop receiving data from the channel
func (e *Exchange) Unsubscribe(subs subscription.List) error {
ctx := context.TODO()
return e.manageSubs(ctx, subs, wsUnsubscribeOp)
}
func (e *Exchange) manageSubs(ctx context.Context, subs subscription.List, op wsSubOp) error {
req := wsSubscribeRequest{
Type: op,
Subscriptions: make([]wsSubscriptions, 0, len(subs)),
}
for _, s := range subs {
req.Subscriptions = append(req.Subscriptions, wsSubscriptions{
Name: s.QualifiedChannel,
Symbols: s.Pairs.Strings(),
})
}
if err := e.Websocket.Conn.SendJSONMessage(ctx, request.Unset, req); err != nil {
return err
}
if op == wsUnsubscribeOp {
return e.Websocket.RemoveSubscriptions(e.Websocket.Conn, subs...)
}
return e.Websocket.AddSuccessfulSubscriptions(e.Websocket.Conn, subs...)
}
// WsAuth will connect to Gemini's secure endpoint
func (e *Exchange) WsAuth(ctx context.Context, dialer *gws.Dialer) error {
if !e.IsWebsocketAuthenticationSupported() {
return fmt.Errorf("%v AuthenticatedWebsocketAPISupport not enabled", e.Name)
}
creds, err := e.GetCredentials(ctx)
if err != nil {
return err
}
payload := WsRequestPayload{
Request: "/v1/" + geminiWsOrderEvents,
Nonce: time.Now().UnixNano(),
}
payloadJSON, err := json.Marshal(payload)
if err != nil {
return fmt.Errorf("%v sendAuthenticatedHTTPRequest: Unable to JSON request", e.Name)
}
wsEndpoint, err := e.API.Endpoints.GetURL(exchange.WebsocketSpot)
if err != nil {
return err
}
endpoint := wsEndpoint + geminiWsOrderEvents
payloadB64 := base64.StdEncoding.EncodeToString(payloadJSON)
hmac, err := crypto.GetHMAC(crypto.HashSHA512_384, []byte(payloadB64), []byte(creds.Secret))
if err != nil {
return err
}
headers := http.Header{}
headers.Add("Content-Length", "0")
headers.Add("Content-Type", "text/plain")
headers.Add("X-GEMINI-PAYLOAD", payloadB64)
headers.Add("X-GEMINI-APIKEY", creds.Key)
headers.Add("X-GEMINI-SIGNATURE", hex.EncodeToString(hmac))
headers.Add("Cache-Control", "no-cache")
err = e.Websocket.AuthConn.Dial(ctx, dialer, headers)
if err != nil {
return fmt.Errorf("%v Websocket connection %v error. Error %v", e.Name, endpoint, err)
}
e.Websocket.Wg.Add(1)
go e.wsFunnelConnectionData(e.Websocket.AuthConn)
return nil
}
// wsFunnelConnectionData receives data from multiple connections and passes it to wsReadData
func (e *Exchange) wsFunnelConnectionData(ws websocket.Connection) {
defer e.Websocket.Wg.Done()
for {
resp := ws.ReadMessage()
if resp.Raw == nil {
return
}
comms <- websocket.Response{Raw: resp.Raw}
}
}
// wsReadData receives and passes on websocket messages for processing
func (e *Exchange) wsReadData() {
defer e.Websocket.Wg.Done()
for {
select {
case <-e.Websocket.ShutdownC:
select {
case resp := <-comms:
err := e.wsHandleData(resp.Raw)
if err != nil {
select {
case e.Websocket.DataHandler <- err:
default:
log.Errorf(log.WebsocketMgr,
"%s websocket handle data error: %v",
e.Name,
err)
}
}
default:
}
return
case resp := <-comms:
err := e.wsHandleData(resp.Raw)
if err != nil {
e.Websocket.DataHandler <- err
}
}
}
}
func (e *Exchange) wsHandleData(respRaw []byte) error {
// only order details are sent in arrays
if strings.HasPrefix(string(respRaw), "[") {
var result []WsOrderResponse
err := json.Unmarshal(respRaw, &result)
if err != nil {
return err
}
for i := range result {
oSide, err := order.StringToOrderSide(result[i].Side)
if err != nil {
e.Websocket.DataHandler <- order.ClassificationError{
Exchange: e.Name,
OrderID: result[i].OrderID,
Err: err,
}
}
var oType order.Type
oType, err = stringToOrderType(result[i].OrderType)
if err != nil {
e.Websocket.DataHandler <- order.ClassificationError{
Exchange: e.Name,
OrderID: result[i].OrderID,
Err: err,
}
}
var oStatus order.Status
oStatus, err = stringToOrderStatus(result[i].Type)
if err != nil {
e.Websocket.DataHandler <- order.ClassificationError{
Exchange: e.Name,
OrderID: result[i].OrderID,
Err: err,
}
}
enabledPairs, err := e.GetAvailablePairs(asset.Spot)
if err != nil {
return err
}
format, err := e.GetPairFormat(asset.Spot, true)
if err != nil {
return err
}
pair, err := currency.NewPairFromFormattedPairs(result[i].Symbol, enabledPairs, format)
if err != nil {
return err
}
e.Websocket.DataHandler <- &order.Detail{
HiddenOrder: result[i].IsHidden,
Price: result[i].Price,
Amount: result[i].OriginalAmount,
ExecutedAmount: result[i].ExecutedAmount,
RemainingAmount: result[i].RemainingAmount,
Exchange: e.Name,
OrderID: result[i].OrderID,
Type: oType,
Side: oSide,
Status: oStatus,
AssetType: asset.Spot,
Date: result[i].TimestampMS.Time(),
Pair: pair,
}
}
return nil
}
var result map[string]any
err := json.Unmarshal(respRaw, &result)
if err != nil {
return fmt.Errorf("%v Error: %v, Raw: %v", e.Name, err, string(respRaw))
}
if _, ok := result["type"]; ok {
switch result["type"] {
case "l2_updates":
var l2MarketData *wsL2MarketData
err := json.Unmarshal(respRaw, &l2MarketData)
if err != nil {
return err
}
return e.wsProcessUpdate(l2MarketData)
case "trade":
if !e.IsSaveTradeDataEnabled() {
return nil
}
var result wsTrade
err := json.Unmarshal(respRaw, &result)
if err != nil {
return err
}
tSide, err := order.StringToOrderSide(result.Side)
if err != nil {
e.Websocket.DataHandler <- order.ClassificationError{
Exchange: e.Name,
Err: err,
}
}
enabledPairs, err := e.GetEnabledPairs(asset.Spot)
if err != nil {
return err
}
format, err := e.GetPairFormat(asset.Spot, true)
if err != nil {
return err
}
pair, err := currency.NewPairFromFormattedPairs(result.Symbol, enabledPairs, format)
if err != nil {
return err
}
tradeEvent := trade.Data{
Timestamp: result.Timestamp.Time(),
CurrencyPair: pair,
AssetType: asset.Spot,
Exchange: e.Name,
Price: result.Price,
Amount: result.Quantity,
Side: tSide,
TID: strconv.FormatInt(result.EventID, 10),
}
return trade.AddTradesToBuffer(tradeEvent)
case "subscription_ack":
var result WsSubscriptionAcknowledgementResponse
err := json.Unmarshal(respRaw, &result)
if err != nil {
return err
}
e.Websocket.DataHandler <- result
case "initial":
var result WsSubscriptionAcknowledgementResponse
err := json.Unmarshal(respRaw, &result)
if err != nil {
return err
}
e.Websocket.DataHandler <- result
case "heartbeat":
return nil
case "candles_1m_updates",
"candles_5m_updates",
"candles_15m_updates",
"candles_30m_updates",
"candles_1h_updates",
"candles_6h_updates",
"candles_1d_updates":
var candle wsCandleResponse
err := json.Unmarshal(respRaw, &candle)
if err != nil {
return err
}
enabledPairs, err := e.GetEnabledPairs(asset.Spot)
if err != nil {
return err
}
format, err := e.GetPairFormat(asset.Spot, true)
if err != nil {
return err
}
pair, err := currency.NewPairFromFormattedPairs(candle.Symbol, enabledPairs, format)
if err != nil {
return err
}
for i := range candle.Changes {
if len(candle.Changes[i]) != 6 {
continue
}
interval, ok := result["type"].(string)
if !ok {
return errors.New("unable to type assert interval")
}
e.Websocket.DataHandler <- websocket.KlineData{
Timestamp: time.UnixMilli(int64(candle.Changes[i][0])),
Pair: pair,
AssetType: asset.Spot,
Exchange: e.Name,
Interval: interval,
OpenPrice: candle.Changes[i][1],
HighPrice: candle.Changes[i][2],
LowPrice: candle.Changes[i][3],
ClosePrice: candle.Changes[i][4],
Volume: candle.Changes[i][5],
}
}
default:
e.Websocket.DataHandler <- websocket.UnhandledMessageWarning{Message: e.Name + websocket.UnhandledMessage + string(respRaw)}
return nil
}
} else if r, ok := result["result"].(string); ok {
switch r {
case "error":
if reason, ok := result["reason"].(string); ok {
if msg, ok := result["message"].(string); ok {
reason += " - " + msg
}
return errors.New(reason)
}
return fmt.Errorf("%v Unhandled websocket error %s", e.Name, respRaw)
default:
e.Websocket.DataHandler <- websocket.UnhandledMessageWarning{Message: e.Name + websocket.UnhandledMessage + string(respRaw)}
return nil
}
}
return nil
}
func stringToOrderStatus(status string) (order.Status, error) {
switch status {
case "accepted":
return order.New, nil
case "booked":
return order.Active, nil
case "fill":
return order.Filled, nil
case "cancelled":
return order.Cancelled, nil
case "cancel_rejected":
return order.Rejected, nil
case "closed":
return order.Filled, nil
default:
return order.UnknownStatus, errors.New(status + " not recognised as order status")
}
}
func stringToOrderType(oType string) (order.Type, error) {
switch oType {
case "exchange limit", "auction-only limit", "indication-of-interest limit":
return order.Limit, nil
case "market buy", "market sell", "block_trade":
// block trades are conducted off order-book, so their type is market,
// but would be considered a hidden trade
return order.Market, nil
default:
return order.UnknownType, errors.New(oType + " not recognised as order type")
}
}
func (e *Exchange) wsProcessUpdate(result *wsL2MarketData) error {
isInitial := len(result.Changes) > 0 && len(result.Trades) > 0
enabledPairs, err := e.GetEnabledPairs(asset.Spot)
if err != nil {
return err
}
format, err := e.GetPairFormat(asset.Spot, true)
if err != nil {
return err
}
pair, err := currency.NewPairFromFormattedPairs(result.Symbol, enabledPairs, format)
if err != nil {
return err
}
bids := make([]orderbook.Level, 0, len(result.Changes))
asks := make([]orderbook.Level, 0, len(result.Changes))
for x := range result.Changes {
price, err := strconv.ParseFloat(result.Changes[x][1], 64)
if err != nil {
return err
}
amount, err := strconv.ParseFloat(result.Changes[x][2], 64)
if err != nil {
return err
}
obItem := orderbook.Level{
Amount: amount,
Price: price,
}
if result.Changes[x][0] == "buy" {
bids = append(bids, obItem)
continue
}
asks = append(asks, obItem)
}
if isInitial {
var newOrderBook orderbook.Book
newOrderBook.Asks = asks
newOrderBook.Bids = bids
newOrderBook.Asset = asset.Spot
newOrderBook.Pair = pair
newOrderBook.Exchange = e.Name
newOrderBook.ValidateOrderbook = e.ValidateOrderbook
newOrderBook.LastUpdated = time.Now() // No time is sent
err := e.Websocket.Orderbook.LoadSnapshot(&newOrderBook)
if err != nil {
return err
}
} else {
if len(asks) == 0 && len(bids) == 0 {
return nil
}
err := e.Websocket.Orderbook.Update(&orderbook.Update{
Asks: asks,
Bids: bids,
Pair: pair,
Asset: asset.Spot,
UpdateTime: time.Now(), // No time is sent
})
if err != nil {
return err
}
}
if len(result.AuctionEvents) > 0 {
e.Websocket.DataHandler <- result.AuctionEvents
}
if !e.IsSaveTradeDataEnabled() {
return nil
}
trades := make([]trade.Data, len(result.Trades))
for x := range result.Trades {
tSide, err := order.StringToOrderSide(result.Trades[x].Side)
if err != nil {
e.Websocket.DataHandler <- order.ClassificationError{
Exchange: e.Name,
Err: err,
}
}
trades[x] = trade.Data{
Timestamp: result.Trades[x].Timestamp.Time(),
CurrencyPair: pair,
AssetType: asset.Spot,
Exchange: e.Name,
Price: result.Trades[x].Price,
Amount: result.Trades[x].Quantity,
Side: tSide,
TID: strconv.FormatInt(result.Trades[x].EventID, 10),
}
}
return trade.AddTradesToBuffer(trades...)
}
func channelName(s *subscription.Subscription) string {
if n, ok := subscriptionNames[s.Channel]; ok {
return n
}
panic(fmt.Errorf("%w: %s", subscription.ErrNotSupported, s.Channel))
}
func channelInterval(i kline.Interval) string {
switch i {
case kline.OneMin, kline.FiveMin, kline.FifteenMin, kline.ThirtyMin, kline.OneHour, kline.SixHour:
return i.Short()
case kline.OneDay:
return "1d"
}
panic(fmt.Errorf("%w: %s", kline.ErrUnsupportedInterval, i.Short()))
}
const subTplText = `
{{ range $asset, $pairs := $.AssetPairs }}
{{- channelName $.S -}}
{{- with $i := $.S.Interval -}} _ {{- interval $i }}{{ end -}}
{{- $.AssetSeparator }}
{{- end }}
`