accounts: Move to instance methods, fix races and isolate tests (#1923)

* Bybit: Fix race in TestUpdateAccountInfo and  TestWSHandleData

* DriveBy rename TestWSHandleData
* This doesn't address running with -race=2+ due to the singleton

* Accounts: Add account.GetService()

* exchange: Assertify TestSetupDefaults

* Exchanges: Add account.Service override for testing

* Exchanges: Remove duplicate IsWebsocketEnabled test from TestSetupDefaults

* Dispatch: Replace nil checks with NilGuard

* Engine: Remove deprecated printAccountHoldingsChangeSummary

* Dispatcher: Add EnsureRunning method

* Accounts: Move singleton accounts service to exchange Accounts

* Move singleton accounts service to exchange Accounts

This maintains the concept of a global store, whilst allowing exchanges
to override it when needed, particularly for testing.

APIServer:

* Remove getAllActiveAccounts from apiserver

Deprecated apiserver only thing using this, so remove it instead of
updating it

* Update comment for UpdateAccountBalances everywhere

* Docs: Add punctuation to function comments

* Bybit: Coverage for wsProcessWalletPushData Save
This commit is contained in:
Gareth Kirwan
2025-10-28 09:52:45 +07:00
committed by GitHub
parent bda9bbec66
commit 73e200e4e7
140 changed files with 3515 additions and 4025 deletions

View File

@@ -629,7 +629,7 @@ func (e *Exchange) GetSpotOrders(ctx context.Context, currencyPair currency.Pair
}
// CancelAllOpenOrdersSpecifiedCurrencyPair cancel all open orders in specified currency pair
func (e *Exchange) CancelAllOpenOrdersSpecifiedCurrencyPair(ctx context.Context, currencyPair currency.Pair, side order.Side, account asset.Item) ([]SpotOrder, error) {
func (e *Exchange) CancelAllOpenOrdersSpecifiedCurrencyPair(ctx context.Context, currencyPair currency.Pair, side order.Side, a asset.Item) ([]SpotOrder, error) {
if currencyPair.IsEmpty() {
return nil, currency.ErrCurrencyPairEmpty
}
@@ -638,8 +638,8 @@ func (e *Exchange) CancelAllOpenOrdersSpecifiedCurrencyPair(ctx context.Context,
if side == order.Buy || side == order.Sell {
params.Set("side", strings.ToLower(side.Title()))
}
if account == asset.Spot || account == asset.Margin || account == asset.CrossMargin {
params.Set("account", account.String())
if a == asset.Spot || a == asset.Margin || a == asset.CrossMargin {
params.Set("account", a.String())
}
var response []SpotOrder
return response, e.SendAuthenticatedHTTPRequest(ctx, exchange.RestSpot, spotCancelAllOpenOrdersEPL, http.MethodDelete, gateioSpotOrders, params, nil, &response)
@@ -663,7 +663,7 @@ func (e *Exchange) CancelBatchOrdersWithIDList(ctx context.Context, args []Cance
}
// GetSpotOrder retrieves a single spot order using the order id and currency pair information.
func (e *Exchange) GetSpotOrder(ctx context.Context, orderID string, currencyPair currency.Pair, account asset.Item) (*SpotOrder, error) {
func (e *Exchange) GetSpotOrder(ctx context.Context, orderID string, currencyPair currency.Pair, a asset.Item) (*SpotOrder, error) {
if orderID == "" {
return nil, errInvalidOrderID
}
@@ -672,7 +672,7 @@ func (e *Exchange) GetSpotOrder(ctx context.Context, orderID string, currencyPai
}
params := url.Values{}
params.Set("currency_pair", currencyPair.String())
if accountType := account.String(); accountType != "" {
if accountType := a.String(); accountType != "" {
params.Set("account", accountType)
}
var response *SpotOrder
@@ -817,7 +817,7 @@ func (e *Exchange) CreatePriceTriggeredOrder(ctx context.Context, arg *PriceTrig
}
// GetPriceTriggeredOrderList retrieves price orders created with an order detail and trigger price information.
func (e *Exchange) GetPriceTriggeredOrderList(ctx context.Context, status string, market currency.Pair, account asset.Item, offset, limit uint64) ([]SpotPriceTriggeredOrder, error) {
func (e *Exchange) GetPriceTriggeredOrderList(ctx context.Context, status string, market currency.Pair, a asset.Item, offset, limit uint64) ([]SpotPriceTriggeredOrder, error) {
if status != statusOpen && status != statusFinished {
return nil, fmt.Errorf("%w status %s", errInvalidOrderStatus, status)
}
@@ -826,8 +826,8 @@ func (e *Exchange) GetPriceTriggeredOrderList(ctx context.Context, status string
if market.IsPopulated() {
params.Set("market", market.String())
}
if account == asset.CrossMargin {
params.Set("account", account.String())
if a == asset.CrossMargin {
params.Set("account", a.String())
}
if limit > 0 {
params.Set("limit", strconv.FormatUint(limit, 10))
@@ -840,18 +840,18 @@ func (e *Exchange) GetPriceTriggeredOrderList(ctx context.Context, status string
}
// CancelMultipleSpotOpenOrders deletes price triggered orders.
func (e *Exchange) CancelMultipleSpotOpenOrders(ctx context.Context, currencyPair currency.Pair, account asset.Item) ([]SpotPriceTriggeredOrder, error) {
func (e *Exchange) CancelMultipleSpotOpenOrders(ctx context.Context, currencyPair currency.Pair, a asset.Item) ([]SpotPriceTriggeredOrder, error) {
params := url.Values{}
if currencyPair.IsPopulated() {
params.Set("market", currencyPair.String())
}
switch account {
switch a {
case asset.Empty:
return nil, asset.ErrNotSupported
case asset.Spot:
params.Set("account", "normal")
default:
params.Set("account", account.String())
params.Set("account", a.String())
}
var response []SpotPriceTriggeredOrder
return response, e.SendAuthenticatedHTTPRequest(ctx, exchange.RestSpot, spotCancelTriggerOrdersEPL, http.MethodDelete, gateioSpotPriceOrders, params, nil, &response)

View File

@@ -20,8 +20,8 @@ import (
"github.com/thrasher-corp/gocryptotrader/core"
"github.com/thrasher-corp/gocryptotrader/currency"
"github.com/thrasher-corp/gocryptotrader/encoding/json"
"github.com/thrasher-corp/gocryptotrader/exchange/accounts"
"github.com/thrasher-corp/gocryptotrader/exchange/websocket"
"github.com/thrasher-corp/gocryptotrader/exchanges/account"
"github.com/thrasher-corp/gocryptotrader/exchanges/asset"
"github.com/thrasher-corp/gocryptotrader/exchanges/fundingrate"
"github.com/thrasher-corp/gocryptotrader/exchanges/futures"
@@ -66,12 +66,35 @@ func TestUpdateTradablePairs(t *testing.T) {
testexch.UpdatePairsOnce(t, e)
}
func TestGetAccountInfo(t *testing.T) {
func TestCancelAllExchangeOrders(t *testing.T) {
t.Parallel()
sharedtestvalues.SkipTestIfCredentialsUnset(t, e, canManipulateRealOrders)
_, err := e.CancelAllOrders(t.Context(), nil)
require.ErrorIs(t, err, order.ErrCancelOrderIsNil)
r := &order.Cancel{
OrderID: "1",
AccountID: "1",
}
for _, a := range e.GetAssetTypes(false) {
r.AssetType = a
r.Pair = currency.EMPTYPAIR
_, err = e.CancelAllOrders(t.Context(), r)
assert.ErrorIs(t, err, currency.ErrCurrencyPairEmpty)
r.Pair = getPair(t, a)
_, err = e.CancelAllOrders(t.Context(), r)
require.NoError(t, err)
}
}
func TestGetAccountBalances(t *testing.T) {
t.Parallel()
sharedtestvalues.SkipTestIfCredentialsUnset(t, e)
for _, a := range e.GetAssetTypes(false) {
_, err := e.UpdateAccountInfo(t.Context(), a)
assert.NoErrorf(t, err, "UpdateAccountInfo should not error for asset %s", a)
_, err := e.UpdateAccountBalances(t.Context(), a)
assert.NoErrorf(t, err, "UpdateAccountBalances should not error for asset %s", a)
}
}
@@ -2028,7 +2051,7 @@ const wsBalancesPushDataJSON = `{"time": 1605248616, "channel": "spot.balances",
func TestBalancesPushData(t *testing.T) {
t.Parallel()
ctx := account.DeployCredentialsToContext(t.Context(), &account.Credentials{Key: "test", Secret: "test"})
ctx := accounts.DeployCredentialsToContext(t.Context(), &accounts.Credentials{Key: "test", Secret: "test"})
if err := e.WsHandleSpotData(ctx, nil, []byte(wsBalancesPushDataJSON)); err != nil {
t.Errorf("%s websocket balances push data error: %v", e.Name, err)
}
@@ -2047,7 +2070,7 @@ const wsCrossMarginBalancePushDataJSON = `{"time": 1605248616,"channel": "spot.c
func TestCrossMarginBalancePushData(t *testing.T) {
t.Parallel()
ctx := account.DeployCredentialsToContext(t.Context(), &account.Credentials{Key: "test", Secret: "test"})
ctx := accounts.DeployCredentialsToContext(t.Context(), &accounts.Credentials{Key: "test", Secret: "test"})
if err := e.WsHandleSpotData(ctx, nil, []byte(wsCrossMarginBalancePushDataJSON)); err != nil {
t.Errorf("%s websocket cross margin balance push data error: %v", e.Name, err)
}
@@ -2069,7 +2092,7 @@ func TestFuturesDataHandler(t *testing.T) {
require.NoError(t, testexch.Setup(e), "Test instance Setup must not error")
testexch.FixtureToDataHandler(t, "testdata/wsFutures.json", func(ctx context.Context, m []byte) error {
if strings.Contains(string(m), "futures.balances") {
ctx = account.DeployCredentialsToContext(ctx, &account.Credentials{Key: "test", Secret: "test"})
ctx = accounts.DeployCredentialsToContext(ctx, &accounts.Credentials{Key: "test", Secret: "test"})
}
return e.WsHandleFuturesData(ctx, nil, m, asset.CoinMarginedFutures)
})
@@ -2236,7 +2259,7 @@ const optionsBalancePushDataJSON = `{ "channel": "options.balances", "event": "u
func TestOptionsBalancePushData(t *testing.T) {
t.Parallel()
ctx := account.DeployCredentialsToContext(t.Context(), &account.Credentials{Key: "test", Secret: "test"})
ctx := accounts.DeployCredentialsToContext(t.Context(), &accounts.Credentials{Key: "test", Secret: "test"})
if err := e.WsHandleOptionsData(ctx, nil, []byte(optionsBalancePushDataJSON)); err != nil {
t.Errorf("%s websocket options balance push data error: %v", e.Name, err)
}

View File

@@ -949,18 +949,18 @@ type OptionsUnderlyingTicker struct {
IndexPrice types.Number `json:"index_price"`
}
// OptionAccount represents option account.
// OptionAccount represents an option account.
type OptionAccount struct {
User int64 `json:"user"`
Currency string `json:"currency"`
ShortEnabled bool `json:"short_enabled"`
Total types.Number `json:"total"`
UnrealisedPnl string `json:"unrealised_pnl"`
InitMargin string `json:"init_margin"`
MaintMargin string `json:"maint_margin"`
OrderMargin string `json:"order_margin"`
Available types.Number `json:"available"`
Point string `json:"point"`
User int64 `json:"user"`
Currency currency.Code `json:"currency"`
ShortEnabled bool `json:"short_enabled"`
Total types.Number `json:"total"`
UnrealisedPnl string `json:"unrealised_pnl"`
InitMargin string `json:"init_margin"`
MaintMargin string `json:"maint_margin"`
OrderMargin string `json:"order_margin"`
Available types.Number `json:"available"`
Point string `json:"point"`
}
// AccountBook represents account changing history item
@@ -1218,11 +1218,11 @@ type MarginAccountItem struct {
// AccountBalanceInformation represents currency account balance information.
type AccountBalanceInformation struct {
Available types.Number `json:"available"`
Borrowed types.Number `json:"borrowed"`
Interest types.Number `json:"interest"`
Currency string `json:"currency"`
LockedAmount types.Number `json:"locked"`
Available types.Number `json:"available"`
Borrowed types.Number `json:"borrowed"`
Interest types.Number `json:"interest"`
Currency currency.Code `json:"currency"`
LockedAmount types.Number `json:"locked"`
}
// MarginAccountBalanceChangeInfo represents margin account balance
@@ -1371,9 +1371,9 @@ type SpotTradingFeeRate struct {
// SpotAccount represents spot account
type SpotAccount struct {
Currency string `json:"currency"`
Available types.Number `json:"available"`
Locked types.Number `json:"locked"`
Currency currency.Code `json:"currency"`
Available types.Number `json:"available"`
Locked types.Number `json:"locked"`
}
// CreateOrderRequest represents a single order creation param.
@@ -1710,26 +1710,26 @@ type InitFlashSwapOrderPreviewResponse struct {
// FuturesAccount represents futures account detail
type FuturesAccount struct {
User int64 `json:"user"`
Currency string `json:"currency"`
Total types.Number `json:"total"` // total = position_margin + order_margin + available
UnrealisedPnl types.Number `json:"unrealised_pnl"`
PositionMargin types.Number `json:"position_margin"`
OrderMargin types.Number `json:"order_margin"` // Order margin of unfinished orders
Available types.Number `json:"available"` // The available balance for transferring or trading
Point types.Number `json:"point"`
Bonus string `json:"bonus"`
EnabledCredit bool `json:"enable_credit"`
InDualMode bool `json:"in_dual_mode"` // Whether dual mode is enabled
UpdateTime types.Time `json:"update_time"`
UpdateID int64 `json:"update_id"`
PositionInitialMargine types.Number `json:"position_initial_margin"` // applicable to the portfolio margin account model
MaintenanceMargin types.Number `json:"maintenance_margin"`
MarginMode int64 `json:"margin_mode"` // Margin mode: 1-cross margin, 2-isolated margin, 3-portfolio margin
EnabledEvolvedClassic bool `json:"enable_evolved_classic"`
CrossInitialMargin types.Number `json:"cross_initial_margin"`
CrossUnrealisedPnl types.Number `json:"cross_unrealised_pnl"`
IsolatedPositionMargin types.Number `json:"isolated_position_margin"`
User int64 `json:"user"`
Currency currency.Code `json:"currency"`
Total types.Number `json:"total"` // total = position_margin + order_margin + available
UnrealisedPnl types.Number `json:"unrealised_pnl"`
PositionMargin types.Number `json:"position_margin"`
OrderMargin types.Number `json:"order_margin"` // Order margin of unfinished orders
Available types.Number `json:"available"` // The available balance for transferring or trading
Point types.Number `json:"point"`
Bonus string `json:"bonus"`
EnabledCredit bool `json:"enable_credit"`
InDualMode bool `json:"in_dual_mode"` // Whether dual mode is enabled
UpdateTime types.Time `json:"update_time"`
UpdateID int64 `json:"update_id"`
PositionInitialMargine types.Number `json:"position_initial_margin"` // applicable to the portfolio margin account model
MaintenanceMargin types.Number `json:"maintenance_margin"`
MarginMode int64 `json:"margin_mode"` // Margin mode: 1-cross margin, 2-isolated margin, 3-portfolio margin
EnabledEvolvedClassic bool `json:"enable_evolved_classic"`
CrossInitialMargin types.Number `json:"cross_initial_margin"`
CrossUnrealisedPnl types.Number `json:"cross_unrealised_pnl"`
IsolatedPositionMargin types.Number `json:"isolated_position_margin"`
History struct {
DepositAndWithdrawal string `json:"dnw"` // total amount of deposit and withdraw
ProfitAndLoss types.Number `json:"pnl"` // total amount of trading profit and loss
@@ -2159,15 +2159,15 @@ type WsSpotBalance struct {
// WsMarginBalance represents margin account balance push data
type WsMarginBalance struct {
Timestamp types.Time `json:"timestamp_ms"`
User string `json:"user"`
CurrencyPair string `json:"currency_pair"`
Currency string `json:"currency"`
Change types.Number `json:"change"`
Available types.Number `json:"available"`
Freeze types.Number `json:"freeze"`
Borrowed string `json:"borrowed"`
Interest string `json:"interest"`
Timestamp types.Time `json:"timestamp_ms"`
User string `json:"user"`
CurrencyPair string `json:"currency_pair"`
Currency currency.Code `json:"currency"`
Change types.Number `json:"change"`
Available types.Number `json:"available"`
Freeze types.Number `json:"freeze"`
Borrowed string `json:"borrowed"`
Interest string `json:"interest"`
}
// WsFundingBalance represents funding balance push data.
@@ -2182,12 +2182,12 @@ type WsFundingBalance struct {
// WsCrossMarginBalance represents a cross margin balance detail
type WsCrossMarginBalance struct {
Timestamp types.Time `json:"timestamp_ms"`
User string `json:"user"`
Currency string `json:"currency"`
Change string `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 string `json:"change"`
Total types.Number `json:"total"`
Available types.Number `json:"available"`
}
// WsCrossMarginLoan represents a cross margin loan push data

View File

@@ -21,8 +21,8 @@ import (
"github.com/thrasher-corp/gocryptotrader/common/key"
"github.com/thrasher-corp/gocryptotrader/currency"
"github.com/thrasher-corp/gocryptotrader/encoding/json"
"github.com/thrasher-corp/gocryptotrader/exchange/accounts"
"github.com/thrasher-corp/gocryptotrader/exchange/websocket"
"github.com/thrasher-corp/gocryptotrader/exchanges/account"
"github.com/thrasher-corp/gocryptotrader/exchanges/asset"
"github.com/thrasher-corp/gocryptotrader/exchanges/fill"
"github.com/thrasher-corp/gocryptotrader/exchanges/kline"
@@ -489,65 +489,55 @@ func (e *Exchange) processUserPersonalTrades(data []byte) error {
}
func (e *Exchange) processSpotBalances(ctx context.Context, data []byte) error {
var resp []WsSpotBalance
var resp []*WsSpotBalance
if err := json.Unmarshal(data, &resp); err != nil {
return err
}
creds, err := e.GetCredentials(ctx)
if err != nil {
subAccts := accounts.SubAccounts{}
for _, bal := range resp {
a := accounts.NewSubAccount(asset.Spot, bal.User)
a.Balances.Set(bal.Currency, accounts.Balance{
Total: bal.Total.Float64(),
Free: bal.Available.Float64(),
Hold: bal.Freeze.Float64(),
AvailableWithoutBorrow: bal.Available.Float64(),
UpdatedAt: bal.Timestamp.Time(),
})
subAccts = subAccts.Merge(a)
}
if err := e.Accounts.Save(ctx, subAccts, false); err != nil {
return err
}
changes := make([]account.Change, len(resp))
for i := range resp {
changes[i] = account.Change{
Account: resp[i].User,
AssetType: asset.Spot,
Balance: &account.Balance{
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(),
},
}
}
e.Websocket.DataHandler <- changes
return account.ProcessChange(e.Name, changes, creds)
e.Websocket.DataHandler <- subAccts
return nil
}
func (e *Exchange) processMarginBalances(ctx context.Context, data []byte) error {
resp := struct {
Time types.Time `json:"time"`
Channel string `json:"channel"`
Event string `json:"event"`
Result []WsMarginBalance `json:"result"`
Time types.Time `json:"time"`
Channel string `json:"channel"`
Event string `json:"event"`
Result []*WsMarginBalance `json:"result"`
}{}
err := json.Unmarshal(data, &resp)
if err != nil {
if err := json.Unmarshal(data, &resp); err != nil {
return err
}
creds, err := e.GetCredentials(ctx)
if err != nil {
subAccts := accounts.SubAccounts{}
for _, bal := range resp.Result {
a := accounts.NewSubAccount(asset.Margin, bal.User)
a.Balances.Set(bal.Currency, accounts.Balance{
Total: bal.Available.Float64() + bal.Freeze.Float64(),
Free: bal.Available.Float64(),
Hold: bal.Freeze.Float64(),
UpdatedAt: bal.Timestamp.Time(),
})
subAccts = subAccts.Merge(a)
}
if err := e.Accounts.Save(ctx, subAccts, false); err != nil {
return err
}
changes := make([]account.Change, len(resp.Result))
for x := range resp.Result {
changes[x] = account.Change{
AssetType: asset.Margin,
Balance: &account.Balance{
Currency: currency.NewCode(resp.Result[x].Currency),
Total: resp.Result[x].Available.Float64() + resp.Result[x].Freeze.Float64(),
Free: resp.Result[x].Available.Float64(),
Hold: resp.Result[x].Freeze.Float64(),
UpdatedAt: resp.Result[x].Timestamp.Time(),
},
}
}
e.Websocket.DataHandler <- changes
return account.ProcessChange(e.Name, changes, creds)
e.Websocket.DataHandler <- subAccts
return nil
}
func (e *Exchange) processFundingBalances(data []byte) error {
@@ -567,34 +557,30 @@ func (e *Exchange) processFundingBalances(data []byte) error {
func (e *Exchange) processCrossMarginBalance(ctx context.Context, data []byte) error {
resp := struct {
Time types.Time `json:"time"`
Channel string `json:"channel"`
Event string `json:"event"`
Result []WsCrossMarginBalance `json:"result"`
Time types.Time `json:"time"`
Channel string `json:"channel"`
Event string `json:"event"`
Result []*WsCrossMarginBalance `json:"result"`
}{}
err := json.Unmarshal(data, &resp)
if err != nil {
return err
}
creds, err := e.GetCredentials(ctx)
if err != nil {
subAccts := accounts.SubAccounts{}
for _, bal := range resp.Result {
a := accounts.NewSubAccount(asset.CrossMargin, bal.User)
a.Balances.Set(bal.Currency, accounts.Balance{
Total: bal.Total.Float64(),
Free: bal.Available.Float64(),
UpdatedAt: bal.Timestamp.Time(),
})
subAccts = subAccts.Merge(a)
}
if err := e.Accounts.Save(ctx, subAccts, false); err != nil {
return err
}
changes := make([]account.Change, len(resp.Result))
for x := range resp.Result {
changes[x] = account.Change{
Account: resp.Result[x].User,
AssetType: asset.Margin,
Balance: &account.Balance{
Currency: currency.NewCode(resp.Result[x].Currency),
Total: resp.Result[x].Total.Float64(),
Free: resp.Result[x].Available.Float64(),
UpdatedAt: resp.Result[x].Timestamp.Time(),
},
}
}
e.Websocket.DataHandler <- changes
return account.ProcessChange(e.Name, changes, creds)
e.Websocket.DataHandler <- subAccts
return nil
}
func (e *Exchange) processCrossMarginLoans(data []byte) error {

View File

@@ -9,8 +9,8 @@ import (
gws "github.com/gorilla/websocket"
"github.com/thrasher-corp/gocryptotrader/currency"
"github.com/thrasher-corp/gocryptotrader/exchange/accounts"
"github.com/thrasher-corp/gocryptotrader/exchange/websocket"
"github.com/thrasher-corp/gocryptotrader/exchanges/account"
"github.com/thrasher-corp/gocryptotrader/exchanges/asset"
"github.com/thrasher-corp/gocryptotrader/exchanges/kline"
"github.com/thrasher-corp/gocryptotrader/exchanges/subscription"
@@ -115,7 +115,7 @@ func (e *Exchange) generateDeliveryFuturesPayload(ctx context.Context, event str
if len(channelsToSubscribe) == 0 {
return nil, errors.New("cannot generate payload, no channels supplied")
}
var creds *account.Credentials
var creds *accounts.Credentials
var err error
if e.Websocket.CanUseAuthenticatedEndpoints() {
creds, err = e.GetCredentials(ctx)

View File

@@ -11,8 +11,8 @@ import (
gws "github.com/gorilla/websocket"
"github.com/thrasher-corp/gocryptotrader/currency"
"github.com/thrasher-corp/gocryptotrader/encoding/json"
"github.com/thrasher-corp/gocryptotrader/exchange/accounts"
"github.com/thrasher-corp/gocryptotrader/exchange/websocket"
"github.com/thrasher-corp/gocryptotrader/exchanges/account"
"github.com/thrasher-corp/gocryptotrader/exchanges/asset"
"github.com/thrasher-corp/gocryptotrader/exchanges/fill"
"github.com/thrasher-corp/gocryptotrader/exchanges/kline"
@@ -198,7 +198,7 @@ func (e *Exchange) generateFuturesPayload(ctx context.Context, event string, cha
if len(channelsToSubscribe) == 0 {
return nil, errors.New("cannot generate payload, no channels supplied")
}
var creds *account.Credentials
var creds *accounts.Credentials
var err error
if e.Websocket.CanUseAuthenticatedEndpoints() {
creds, err = e.GetCredentials(ctx)
@@ -632,36 +632,31 @@ func (e *Exchange) processPositionCloseData(data []byte) error {
}
func (e *Exchange) processBalancePushData(ctx context.Context, data []byte, assetType asset.Item) error {
var resp []WsBalance
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))
for x, bal := range resp {
subAccts := accounts.SubAccounts{}
for _, 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: c,
Total: bal.Balance,
Free: bal.Balance,
AvailableWithoutBorrow: bal.Balance,
UpdatedAt: bal.Time.Time(),
},
}
a := accounts.NewSubAccount(assetType, bal.User)
a.Balances.Set(c, accounts.Balance{
Total: bal.Balance,
Free: bal.Balance,
AvailableWithoutBorrow: bal.Balance,
UpdatedAt: bal.Time.Time(),
})
subAccts = subAccts.Merge(a)
}
e.Websocket.DataHandler <- changes
return account.ProcessChange(e.Name, changes, creds)
err := e.Accounts.Save(ctx, subAccts, false)
if err == nil {
e.Websocket.DataHandler <- subAccts
}
return err
}
func (e *Exchange) processFuturesReduceRiskLimitNotification(data []byte) error {

View File

@@ -12,8 +12,8 @@ import (
gws "github.com/gorilla/websocket"
"github.com/thrasher-corp/gocryptotrader/currency"
"github.com/thrasher-corp/gocryptotrader/encoding/json"
"github.com/thrasher-corp/gocryptotrader/exchange/accounts"
"github.com/thrasher-corp/gocryptotrader/exchange/websocket"
"github.com/thrasher-corp/gocryptotrader/exchanges/account"
"github.com/thrasher-corp/gocryptotrader/exchanges/asset"
"github.com/thrasher-corp/gocryptotrader/exchanges/fill"
"github.com/thrasher-corp/gocryptotrader/exchanges/kline"
@@ -218,7 +218,7 @@ func (e *Exchange) generateOptionsPayload(ctx context.Context, event string, cha
continue
}
params = append([]string{strconv.FormatInt(userID, 10)}, params...)
var creds *account.Credentials
var creds *accounts.Credentials
creds, err = e.GetCredentials(ctx)
if err != nil {
return nil, err

View File

@@ -2,6 +2,9 @@ package gateio
import (
"context"
"maps"
"slices"
"strconv"
"testing"
"time"
@@ -9,8 +12,8 @@ import (
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"github.com/thrasher-corp/gocryptotrader/currency"
"github.com/thrasher-corp/gocryptotrader/exchange/accounts"
exchange "github.com/thrasher-corp/gocryptotrader/exchanges"
"github.com/thrasher-corp/gocryptotrader/exchanges/account"
"github.com/thrasher-corp/gocryptotrader/exchanges/asset"
)
@@ -42,17 +45,17 @@ type websocketBalancesTest struct {
input []byte
err error
deployCreds bool
expected []account.Change
expected accounts.SubAccounts
}
func TestProcessSpotBalances(t *testing.T) {
func TestProcessSpotBalances(t *testing.T) { //nolint:tparallel // Sequential tests, do not use t.Parallel(); Some timestamps are deliberately identical from trading activity
t.Parallel()
e := new(Exchange) //nolint:govet // Intentional shadow
e.SetDefaults()
e.Name = "ProcessSpotBalancesTest"
e.Accounts = accounts.MustNewAccounts(e)
// Sequential tests, do not use t.Run(); Some timestamps are deliberately identical from trading activity
for _, tc := range []websocketBalancesTest{
for i, tc := range []websocketBalancesTest{
{
input: []byte(`[{"timestamp":"1755718222"}]`),
err: exchange.ErrCredentialsAreEmpty,
@@ -60,17 +63,19 @@ func TestProcessSpotBalances(t *testing.T) {
{
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{
expected: accounts.SubAccounts{
{
Account: "12870774",
ID: "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),
Balances: accounts.CurrencyBalances{
currency.USDT: accounts.Balance{
Currency: currency.USDT,
Total: 3087.01142272991036062136,
Free: 3081.68642272991036062136,
Hold: 5.325,
AvailableWithoutBorrow: 3081.68642272991036062136,
UpdatedAt: time.UnixMilli(1755718222394),
},
},
},
},
@@ -78,44 +83,51 @@ func TestProcessSpotBalances(t *testing.T) {
{
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{
expected: accounts.SubAccounts{
{
Account: "12870774",
ID: "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),
Balances: accounts.CurrencyBalances{
currency.USDT: accounts.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)
t.Run(strconv.Itoa(i), func(t *testing.T) {
// Sequential tests, do not use t.Parallel(); Some timestamps are deliberately identical from trading activity
ctx := t.Context()
if tc.deployCreds {
ctx = accounts.DeployCredentialsToContext(ctx, &accounts.Credentials{Key: "test", Secret: "test"})
}
err := e.processSpotBalances(ctx, tc.input)
if tc.err != nil {
require.ErrorIs(t, err, tc.err)
} else {
require.NoError(t, err, "processSpotBalances must not error")
checkAccountChange(ctx, t, e, &tc)
}
})
}
}
func TestProcessBalancePushData(t *testing.T) {
func TestProcessBalancePushData(t *testing.T) { //nolint:tparallel // Sequential tests, do not use t.Parallel(); Some timestamps are deliberately identical from trading activity
t.Parallel()
e := new(Exchange) //nolint:govet // Intentional shadow
e.SetDefaults()
e.Name = "ProcessFuturesBalancesTest"
e.Accounts = accounts.MustNewAccounts(e)
// Sequential tests, do not use t.Run(); Some timestamps are deliberately identical from trading activity
for _, tc := range []websocketBalancesTest{
usdtLower := currency.USDT.Lower()
for i, tc := range []websocketBalancesTest{
{
input: []byte(`[{"timestamp":"1755718222"}]`),
err: exchange.ErrCredentialsAreEmpty,
@@ -123,16 +135,18 @@ func TestProcessBalancePushData(t *testing.T) {
{
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{
expected: accounts.SubAccounts{
{
Account: "12870774",
ID: "12870774",
AssetType: asset.USDTMarginedFutures,
Balance: &account.Balance{
Currency: currency.USDT,
Total: 2214.191673190433,
Free: 2214.191673190433,
AvailableWithoutBorrow: 2214.191673190433,
UpdatedAt: time.UnixMilli(1755738515671),
Balances: accounts.CurrencyBalances{
usdtLower: accounts.Balance{
Currency: usdtLower,
Total: 2214.191673190433,
Free: 2214.191673190433,
AvailableWithoutBorrow: 2214.191673190433,
UpdatedAt: time.UnixMilli(1755738515671),
},
},
},
},
@@ -140,33 +154,37 @@ func TestProcessBalancePushData(t *testing.T) {
{
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{
expected: accounts.SubAccounts{
{
Account: "12870774",
ID: "12870774",
AssetType: asset.USDTMarginedFutures,
Balance: &account.Balance{
Currency: currency.USDT,
Total: 2214.189114310433,
Free: 2214.189114310433,
AvailableWithoutBorrow: 2214.189114310433,
UpdatedAt: time.UnixMilli(1755738516430),
Balances: accounts.CurrencyBalances{
usdtLower: accounts.Balance{
Currency: usdtLower,
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)
t.Run(strconv.Itoa(i), func(t *testing.T) {
// Sequential tests, do not use t.Parallel(); Some timestamps are deliberately identical from trading activity
ctx := t.Context()
if tc.deployCreds {
ctx = accounts.DeployCredentialsToContext(ctx, &accounts.Credentials{Key: "test", Secret: "test"})
}
err := e.processBalancePushData(ctx, tc.input, asset.USDTMarginedFutures)
if tc.err != nil {
require.ErrorIs(t, err, tc.err)
} else {
require.NoError(t, err, "processBalancePushData must not error")
checkAccountChange(ctx, t, e, &tc)
}
})
}
}
@@ -175,25 +193,19 @@ func checkAccountChange(ctx context.Context, t *testing.T, exch *Exchange, tc *w
require.Len(t, exch.Websocket.DataHandler, 1)
payload := <-exch.Websocket.DataHandler
received, ok := payload.([]account.Change)
received, ok := payload.(accounts.SubAccounts)
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")
require.Equal(t, tc.expected, received)
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)
creds, err := exch.GetCredentials(ctx)
require.NoError(t, err, "GetCredentials must not error")
for _, change := range received {
bal := slices.Collect(maps.Values(change.Balances))[0]
stored, err := exch.Accounts.GetBalance(change.ID, creds, change.AssetType, bal.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")
assert.Equal(t, bal.Free, stored.Free, "free balance should equal with accounts stored value")
}
}

View File

@@ -16,10 +16,10 @@ import (
"github.com/thrasher-corp/gocryptotrader/common/key"
"github.com/thrasher-corp/gocryptotrader/config"
"github.com/thrasher-corp/gocryptotrader/currency"
"github.com/thrasher-corp/gocryptotrader/exchange/accounts"
"github.com/thrasher-corp/gocryptotrader/exchange/order/limits"
"github.com/thrasher-corp/gocryptotrader/exchange/websocket"
exchange "github.com/thrasher-corp/gocryptotrader/exchanges"
"github.com/thrasher-corp/gocryptotrader/exchanges/account"
"github.com/thrasher-corp/gocryptotrader/exchanges/asset"
"github.com/thrasher-corp/gocryptotrader/exchanges/deposit"
"github.com/thrasher-corp/gocryptotrader/exchanges/fundingrate"
@@ -675,56 +675,43 @@ func (e *Exchange) UpdateOrderbookWithLimit(ctx context.Context, p currency.Pair
return orderbook.Get(e.Name, p, a)
}
// UpdateAccountInfo retrieves balances for all enabled currencies for the
func (e *Exchange) UpdateAccountInfo(ctx context.Context, a asset.Item) (account.Holdings, error) {
info := account.Holdings{
Exchange: e.Name,
Accounts: []account.SubAccount{{
AssetType: a,
}},
}
// UpdateAccountBalances retrieves currency balances
func (e *Exchange) UpdateAccountBalances(ctx context.Context, a asset.Item) (accounts.SubAccounts, error) {
subAccts := accounts.SubAccounts{accounts.NewSubAccount(a, "")}
switch a {
case asset.Spot:
balances, err := e.GetSpotAccounts(ctx, currency.EMPTYCODE)
if err != nil {
return info, err
return nil, err
}
currencies := make([]account.Balance, len(balances))
for i := range balances {
currencies[i] = account.Balance{
Currency: currency.NewCode(balances[i].Currency),
Total: balances[i].Available.Float64() + balances[i].Locked.Float64(),
Hold: balances[i].Locked.Float64(),
Free: balances[i].Available.Float64(),
}
subAccts[0].Balances.Set(balances[i].Currency, accounts.Balance{
Total: balances[i].Available.Float64() + balances[i].Locked.Float64(),
Hold: balances[i].Locked.Float64(),
Free: balances[i].Available.Float64(),
})
}
info.Accounts[0].Currencies = currencies
case asset.Margin, asset.CrossMargin:
balances, err := e.GetMarginAccountList(ctx, currency.EMPTYPAIR)
if err != nil {
return info, err
return nil, err
}
currencies := make([]account.Balance, 0, 2*len(balances))
for i := range balances {
currencies = append(currencies,
account.Balance{
Currency: currency.NewCode(balances[i].Base.Currency),
Total: balances[i].Base.Available.Float64() + balances[i].Base.LockedAmount.Float64(),
Hold: balances[i].Base.LockedAmount.Float64(),
Free: balances[i].Base.Available.Float64(),
},
account.Balance{
Currency: currency.NewCode(balances[i].Quote.Currency),
Total: balances[i].Quote.Available.Float64() + balances[i].Quote.LockedAmount.Float64(),
Hold: balances[i].Quote.LockedAmount.Float64(),
Free: balances[i].Quote.Available.Float64(),
})
subAccts[0].Balances.Set(balances[i].Base.Currency, accounts.Balance{
Total: balances[i].Base.Available.Float64() + balances[i].Base.LockedAmount.Float64(),
Hold: balances[i].Base.LockedAmount.Float64(),
Free: balances[i].Base.Available.Float64(),
})
subAccts[0].Balances.Set(balances[i].Quote.Currency, accounts.Balance{
Total: balances[i].Quote.Available.Float64() + balances[i].Quote.LockedAmount.Float64(),
Hold: balances[i].Quote.LockedAmount.Float64(),
Free: balances[i].Quote.Available.Float64(),
})
}
info.Accounts[0].Currencies = currencies
case asset.CoinMarginedFutures, asset.USDTMarginedFutures, asset.DeliveryFutures:
settle, err := getSettlementCurrency(currency.EMPTYPAIR, a)
if err != nil {
return info, err
return nil, err
}
var acc *FuturesAccount
if a == asset.DeliveryFutures {
@@ -733,33 +720,27 @@ func (e *Exchange) UpdateAccountInfo(ctx context.Context, a asset.Item) (account
acc, err = e.QueryFuturesAccount(ctx, settle)
}
if err != nil {
return info, err
return nil, err
}
info.Accounts[0].Currencies = []account.Balance{{
Currency: currency.NewCode(acc.Currency),
Total: acc.Total.Float64(),
Hold: acc.Total.Float64() - acc.Available.Float64(),
Free: acc.Available.Float64(),
}}
subAccts[0].Balances.Set(acc.Currency, accounts.Balance{
Total: acc.Total.Float64(),
Hold: acc.Total.Float64() - acc.Available.Float64(),
Free: acc.Available.Float64(),
})
case asset.Options:
balance, err := e.GetOptionAccounts(ctx)
if err != nil {
return info, err
return nil, err
}
info.Accounts[0].Currencies = []account.Balance{{
Currency: currency.NewCode(balance.Currency),
Total: balance.Total.Float64(),
Hold: balance.Total.Float64() - balance.Available.Float64(),
Free: balance.Available.Float64(),
}}
subAccts[0].Balances.Set(balance.Currency, accounts.Balance{
Total: balance.Total.Float64(),
Hold: balance.Total.Float64() - balance.Available.Float64(),
Free: balance.Available.Float64(),
})
default:
return info, fmt.Errorf("%w asset type: %v", asset.ErrNotSupported, a)
return nil, fmt.Errorf("%w asset type: %q", asset.ErrNotSupported, a)
}
creds, err := e.GetCredentials(ctx)
if err == nil {
err = account.Process(&info, creds)
}
return info, err
return subAccts, e.Accounts.Save(ctx, subAccts, true)
}
// GetAccountFundingHistory returns funding history, deposits and
@@ -1756,10 +1737,9 @@ func (e *Exchange) GetAvailableTransferChains(ctx context.Context, cryptocurrenc
return availableChains, nil
}
// ValidateAPICredentials validates current credentials used for wrapper
// functionality
// ValidateAPICredentials validates current credentials used for wrapper functionality
func (e *Exchange) ValidateAPICredentials(ctx context.Context, assetType asset.Item) error {
_, err := e.UpdateAccountInfo(ctx, assetType)
_, err := e.UpdateAccountBalances(ctx, assetType)
return e.CheckTransientError(err)
}