diff --git a/exchanges/account/account.go b/exchanges/account/account.go index a405cf7e..29cd890c 100644 --- a/exchanges/account/account.go +++ b/exchanges/account/account.go @@ -396,7 +396,7 @@ func (b *ProtectedBalance) load(change *Balance) error { } b.m.Lock() defer b.m.Unlock() - if !b.updatedAt.IsZero() && !b.updatedAt.Before(change.UpdatedAt) { + if !b.updatedAt.IsZero() && b.updatedAt.After(change.UpdatedAt) { return errOutOfSequence } if b.total == change.Total && diff --git a/exchanges/account/account_test.go b/exchanges/account/account_test.go index de7a82db..c42d3fd0 100644 --- a/exchanges/account/account_test.go +++ b/exchanges/account/account_test.go @@ -290,8 +290,11 @@ func TestBalanceInternalLoad(t *testing.T) { assert.Equal(t, 3.0, bi.GetFree()) + err = bi.load(&Balance{UpdatedAt: now.Add(-time.Second), Total: 2, Hold: 3, Free: 4, AvailableWithoutBorrow: 5, Borrowed: 6}) + assert.ErrorIs(t, err, errOutOfSequence, "should error correctly with old update trying to store") + err = bi.load(&Balance{UpdatedAt: now, Total: 2, Hold: 3, Free: 4, AvailableWithoutBorrow: 5, Borrowed: 6}) - assert.ErrorIs(t, err, errOutOfSequence, "should error correctly with same UpdatedAt") + assert.NoError(t, err, "should not error when timestamps are the same") err = bi.load(&Balance{UpdatedAt: now.Add(time.Second), Total: 2, Hold: 3, Free: 4, AvailableWithoutBorrow: 5, Borrowed: 6}) assert.NoError(t, err) diff --git a/exchanges/gateio/gateio_types.go b/exchanges/gateio/gateio_types.go index ac2d94d7..5e240753 100644 --- a/exchanges/gateio/gateio_types.go +++ b/exchanges/gateio/gateio_types.go @@ -2119,12 +2119,15 @@ type WsUserPersonalTrade struct { // WsSpotBalance represents a spot balance. type WsSpotBalance struct { - Timestamp types.Time `json:"timestamp_ms"` - User string `json:"user"` - Currency string `json:"currency"` - Change types.Number `json:"change"` - Total types.Number `json:"total"` - Available types.Number `json:"available"` + Timestamp types.Time `json:"timestamp_ms"` + User string `json:"user"` + Currency currency.Code `json:"currency"` + Change types.Number `json:"change"` + Total types.Number `json:"total"` + Available types.Number `json:"available"` + Freeze types.Number `json:"freeze"` + FreezeChange types.Number `json:"freeze_change"` + ChangeType string `json:"change_type"` // e.g. "order-create", "order-match" } // WsMarginBalance represents margin account balance push data @@ -2329,12 +2332,13 @@ type WsPositionClose struct { // WsBalance represents a options and futures balance push data type WsBalance struct { - Balance float64 `json:"balance"` - Change float64 `json:"change"` - Text string `json:"text"` - Time types.Time `json:"time_ms"` - Type string `json:"type"` - User string `json:"user"` + Balance float64 `json:"balance"` + Change float64 `json:"change"` + Currency currency.Code `json:"currency"` + Text string `json:"text"` + Time types.Time `json:"time_ms"` + Type string `json:"type"` + User string `json:"user"` } // WsFuturesReduceRiskLimitNotification represents a futures reduced risk limit push data diff --git a/exchanges/gateio/gateio_websocket.go b/exchanges/gateio/gateio_websocket.go index 115491da..f5ed4a33 100644 --- a/exchanges/gateio/gateio_websocket.go +++ b/exchanges/gateio/gateio_websocket.go @@ -196,7 +196,7 @@ func (e *Exchange) WsHandleSpotData(ctx context.Context, conn websocket.Connecti case spotUserTradesChannel: return e.processUserPersonalTrades(respRaw) case spotBalancesChannel: - return e.processSpotBalances(ctx, respRaw) + return e.processSpotBalances(ctx, push.Result) case marginBalancesChannel: return e.processMarginBalances(ctx, respRaw) case spotFundingBalanceChannel: @@ -510,31 +510,28 @@ func (e *Exchange) processUserPersonalTrades(data []byte) error { } func (e *Exchange) processSpotBalances(ctx context.Context, data []byte) error { - resp := struct { - Time types.Time `json:"time"` - Channel string `json:"channel"` - Event string `json:"event"` - Result []WsSpotBalance `json:"result"` - }{} - err := json.Unmarshal(data, &resp) - if err != nil { + var resp []WsSpotBalance + if err := json.Unmarshal(data, &resp); err != nil { return err } + creds, err := e.GetCredentials(ctx) if err != nil { return err } - changes := make([]account.Change, len(resp.Result)) - for i := range resp.Result { + + changes := make([]account.Change, len(resp)) + for i := range resp { changes[i] = account.Change{ - Account: resp.Result[i].User, + Account: resp[i].User, AssetType: asset.Spot, Balance: &account.Balance{ - Currency: currency.NewCode(resp.Result[i].Currency), - Total: resp.Result[i].Total.Float64(), - Free: resp.Result[i].Available.Float64(), - Hold: resp.Result[i].Total.Float64() - resp.Result[i].Available.Float64(), - UpdatedAt: resp.Result[i].Timestamp.Time(), + Currency: resp[i].Currency, + Total: resp[i].Total.Float64(), + Free: resp[i].Available.Float64(), + Hold: resp[i].Freeze.Float64(), + AvailableWithoutBorrow: resp[i].Available.Float64(), + UpdatedAt: resp[i].Timestamp.Time(), }, } } diff --git a/exchanges/gateio/gateio_websocket_futures.go b/exchanges/gateio/gateio_websocket_futures.go index 57d2f80c..1dd6eceb 100644 --- a/exchanges/gateio/gateio_websocket_futures.go +++ b/exchanges/gateio/gateio_websocket_futures.go @@ -177,7 +177,7 @@ func (e *Exchange) WsHandleFuturesData(ctx context.Context, conn websocket.Conne case futuresAutoPositionCloseChannel: return e.processPositionCloseData(respRaw) case futuresBalancesChannel: - return e.processBalancePushData(ctx, respRaw, a) + return e.processBalancePushData(ctx, push.Result, a) case futuresReduceRiskLimitsChannel: return e.processFuturesReduceRiskLimitNotification(respRaw) case futuresPositionsChannel: @@ -632,34 +632,31 @@ func (e *Exchange) processPositionCloseData(data []byte) error { } func (e *Exchange) processBalancePushData(ctx context.Context, data []byte, assetType asset.Item) error { - resp := struct { - Time types.Time `json:"time"` - Channel string `json:"channel"` - Event string `json:"event"` - Result []WsBalance `json:"result"` - }{} - err := json.Unmarshal(data, &resp) - if err != nil { + var resp []WsBalance + if err := json.Unmarshal(data, &resp); err != nil { return err } + creds, err := e.GetCredentials(ctx) if err != nil { return err } - changes := make([]account.Change, len(resp.Result)) - for x, bal := range resp.Result { - info := strings.Split(bal.Text, currency.UnderscoreDelimiter) - if len(info) != 2 { - return errors.New("malformed text") + + changes := make([]account.Change, len(resp)) + for x, bal := range resp { + c := bal.Currency + if assetType == asset.Options && c.IsEmpty() { + c = currency.USDT // Settlement currency is USDT } changes[x] = account.Change{ AssetType: assetType, Account: bal.User, Balance: &account.Balance{ - Currency: currency.NewCode(info[0]), - Total: bal.Balance, - Free: bal.Balance, - UpdatedAt: bal.Time.Time(), + Currency: c, + Total: bal.Balance, + Free: bal.Balance, + AvailableWithoutBorrow: bal.Balance, + UpdatedAt: bal.Time.Time(), }, } } diff --git a/exchanges/gateio/gateio_websocket_option.go b/exchanges/gateio/gateio_websocket_option.go index 8b5d191b..0d4e0ed9 100644 --- a/exchanges/gateio/gateio_websocket_option.go +++ b/exchanges/gateio/gateio_websocket_option.go @@ -330,7 +330,7 @@ func (e *Exchange) WsHandleOptionsData(ctx context.Context, conn websocket.Conne case optionsPositionCloseChannel: return e.processPositionCloseData(respRaw) case optionsBalancesChannel: - return e.processBalancePushData(ctx, respRaw, asset.Options) + return e.processBalancePushData(ctx, push.Result, asset.Options) case optionsPositionsChannel: return e.processOptionsPositionPushData(respRaw) case "options.pong": diff --git a/exchanges/gateio/gateio_websocket_test.go b/exchanges/gateio/gateio_websocket_test.go index c9404b3b..e2fbca17 100644 --- a/exchanges/gateio/gateio_websocket_test.go +++ b/exchanges/gateio/gateio_websocket_test.go @@ -1,11 +1,17 @@ package gateio import ( + "context" "testing" "time" gws "github.com/gorilla/websocket" + "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" + "github.com/thrasher-corp/gocryptotrader/currency" + exchange "github.com/thrasher-corp/gocryptotrader/exchanges" + "github.com/thrasher-corp/gocryptotrader/exchanges/account" + "github.com/thrasher-corp/gocryptotrader/exchanges/asset" ) func TestGetWSPingHandler(t *testing.T) { @@ -31,3 +37,163 @@ func TestGetWSPingHandler(t *testing.T) { require.Contains(t, string(got.Message), tc.channel) } } + +type websocketBalancesTest struct { + input []byte + err error + deployCreds bool + expected []account.Change +} + +func TestProcessSpotBalances(t *testing.T) { + t.Parallel() + e := new(Exchange) //nolint:govet // Intentional shadow + e.SetDefaults() + e.Name = "ProcessSpotBalancesTest" + + // Sequential tests, do not use t.Run(); Some timestamps are deliberately identical from trading activity + for _, tc := range []websocketBalancesTest{ + { + input: []byte(`[{"timestamp":"1755718222"}]`), + err: exchange.ErrCredentialsAreEmpty, + }, + { + deployCreds: true, + input: []byte(`[{"timestamp":"1755718222","timestamp_ms":"1755718222394","user":"12870774","currency":"USDT","change":"0","total":"3087.01142272991036062136","available":"3081.68642272991036062136","freeze":"5.325","freeze_change":"5.32500000000000000000","change_type":"order-create"}]`), + expected: []account.Change{ + { + Account: "12870774", + AssetType: asset.Spot, + Balance: &account.Balance{ + Currency: currency.USDT, + Total: 3087.01142272991036062136, + Free: 3081.68642272991036062136, + Hold: 5.325, + AvailableWithoutBorrow: 3081.68642272991036062136, + UpdatedAt: time.UnixMilli(1755718222394), + }, + }, + }, + }, + { + deployCreds: true, + input: []byte(`[{"timestamp":"1755718222","timestamp_ms":"1755718222394","user":"12870774","currency":"USDT","change":"-3.99375000000000000000","total":"3083.01767272991036062136","available":"3081.68642272991036062136","freeze":"1.33125","freeze_change":"-3.99375000000000000000","change_type":"order-match"}]`), + expected: []account.Change{ + { + Account: "12870774", + AssetType: asset.Spot, + Balance: &account.Balance{ + Currency: currency.USDT, + Total: 3083.01767272991036062136, + Free: 3081.68642272991036062136, + Hold: 1.33125, + AvailableWithoutBorrow: 3081.68642272991036062136, + UpdatedAt: time.UnixMilli(1755718222394), + }, + }, + }, + }, + } { + ctx := t.Context() + if tc.deployCreds { + ctx = account.DeployCredentialsToContext(ctx, &account.Credentials{Key: "test", Secret: "test"}) + } + err := e.processSpotBalances(ctx, tc.input) + if tc.err != nil { + require.ErrorIs(t, err, tc.err) + continue + } + require.NoError(t, err, "processSpotBalances must not error") + checkAccountChange(ctx, t, e, &tc) + } +} + +func TestProcessBalancePushData(t *testing.T) { + t.Parallel() + e := new(Exchange) //nolint:govet // Intentional shadow + e.SetDefaults() + e.Name = "ProcessFuturesBalancesTest" + + // Sequential tests, do not use t.Run(); Some timestamps are deliberately identical from trading activity + for _, tc := range []websocketBalancesTest{ + { + input: []byte(`[{"timestamp":"1755718222"}]`), + err: exchange.ErrCredentialsAreEmpty, + }, + { + deployCreds: true, + input: []byte(`[{"balance":2214.191673190433,"change":-0.0025776,"currency":"usdt","text":"TCOM_USDT:263179103241933596","time":1755738515,"time_ms":1755738515671,"type":"fee","user":"12870774"}]`), + expected: []account.Change{ + { + Account: "12870774", + AssetType: asset.USDTMarginedFutures, + Balance: &account.Balance{ + Currency: currency.USDT, + Total: 2214.191673190433, + Free: 2214.191673190433, + AvailableWithoutBorrow: 2214.191673190433, + UpdatedAt: time.UnixMilli(1755738515671), + }, + }, + }, + }, + { + deployCreds: true, + input: []byte(`[{"balance":2214.189114310433,"change":-0.00255888,"currency":"usdt","text":"TCOM_USDT:263179103241933644","time":1755738516,"time_ms":1755738516430,"type":"fee","user":"12870774"}]`), + expected: []account.Change{ + { + Account: "12870774", + AssetType: asset.USDTMarginedFutures, + Balance: &account.Balance{ + Currency: currency.USDT, + Total: 2214.189114310433, + Free: 2214.189114310433, + AvailableWithoutBorrow: 2214.189114310433, + UpdatedAt: time.UnixMilli(1755738516430), + }, + }, + }, + }, + } { + ctx := t.Context() + if tc.deployCreds { + ctx = account.DeployCredentialsToContext(ctx, &account.Credentials{Key: "test", Secret: "test"}) + } + err := e.processBalancePushData(ctx, tc.input, asset.USDTMarginedFutures) + if tc.err != nil { + require.ErrorIs(t, err, tc.err) + continue + } + require.NoError(t, err, "processBalancePushData must not error") + require.Len(t, e.Websocket.DataHandler, 1) + checkAccountChange(ctx, t, e, &tc) + } +} + +func checkAccountChange(ctx context.Context, t *testing.T, exch *Exchange, tc *websocketBalancesTest) { + t.Helper() + + require.Len(t, exch.Websocket.DataHandler, 1) + payload := <-exch.Websocket.DataHandler + received, ok := payload.([]account.Change) + require.Truef(t, ok, "Expected account changes, got %T", payload) + + require.Lenf(t, received, len(tc.expected), "Expected %d changes, got %d", len(tc.expected), len(received)) + for i, change := range received { + assert.Equal(t, tc.expected[i].Account, change.Account, "account should equal") + assert.Equal(t, tc.expected[i].AssetType, change.AssetType, "asset type should equal") + assert.True(t, tc.expected[i].Balance.Currency.Equal(change.Balance.Currency), "currency should equal") + assert.Equal(t, tc.expected[i].Balance.Total, change.Balance.Total, "total should equal") + assert.Equal(t, tc.expected[i].Balance.Hold, change.Balance.Hold, "hold should equal") + assert.Equal(t, tc.expected[i].Balance.Free, change.Balance.Free, "free should equal") + assert.Equal(t, tc.expected[i].Balance.AvailableWithoutBorrow, change.Balance.AvailableWithoutBorrow, "available without borrow should equal") + assert.Equal(t, tc.expected[i].Balance.Borrowed, change.Balance.Borrowed, "borrowed should equal") + assert.Equal(t, tc.expected[i].Balance.UpdatedAt, change.Balance.UpdatedAt, "updated at should equal") + + creds, err := exch.GetCredentials(ctx) + require.NoError(t, err, "GetCredentials must not error") + stored, err := account.GetBalance(exch.Name, tc.expected[i].Account, creds, tc.expected[i].AssetType, tc.expected[i].Balance.Currency) + require.NoError(t, err, "GetBalance must not error") + assert.Equal(t, tc.expected[i].Balance.Free, stored.GetFree(), "free balance should equal with accounts stored value") + } +}