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

@@ -28,7 +28,6 @@ import (
"github.com/thrasher-corp/gocryptotrader/currency"
"github.com/thrasher-corp/gocryptotrader/dispatch"
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/binance"
"github.com/thrasher-corp/gocryptotrader/exchanges/binanceus"
@@ -539,45 +538,6 @@ func GetRelatableCurrencies(p currency.Pair, incOrig, incUSDT bool) currency.Pai
return pairs
}
// GetCollatedExchangeAccountInfoByCoin collates individual exchange account
// information and turns it into a map string of exchange.AccountCurrencyInfo
func GetCollatedExchangeAccountInfoByCoin(accounts []account.Holdings) map[currency.Code]account.Balance {
result := make(map[currency.Code]account.Balance)
for x := range accounts {
for y := range accounts[x].Accounts {
for z := range accounts[x].Accounts[y].Currencies {
currencyName := accounts[x].Accounts[y].Currencies[z].Currency
total := accounts[x].Accounts[y].Currencies[z].Total
onHold := accounts[x].Accounts[y].Currencies[z].Hold
avail := accounts[x].Accounts[y].Currencies[z].AvailableWithoutBorrow
free := accounts[x].Accounts[y].Currencies[z].Free
borrowed := accounts[x].Accounts[y].Currencies[z].Borrowed
info, ok := result[currencyName]
if !ok {
accountInfo := account.Balance{
Currency: currencyName,
Total: total,
Hold: onHold,
Free: free,
AvailableWithoutBorrow: avail,
Borrowed: borrowed,
}
result[currencyName] = accountInfo
} else {
info.Hold += onHold
info.Total += total
info.Free += free
info.AvailableWithoutBorrow += avail
info.Borrowed += borrowed
result[currencyName] = info
}
}
}
}
return result
}
// GetExchangeHighestPriceByCurrencyPair returns the exchange with the highest
// price for a given currency pair and asset type
func GetExchangeHighestPriceByCurrencyPair(p currency.Pair, a asset.Item) (string, error) {

View File

@@ -29,7 +29,6 @@ import (
"github.com/thrasher-corp/gocryptotrader/database"
"github.com/thrasher-corp/gocryptotrader/dispatch"
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/protocol"
@@ -707,67 +706,6 @@ func TestGetExchangeNamesByCurrency(t *testing.T) {
}
}
func TestGetCollatedExchangeAccountInfoByCoin(t *testing.T) {
t.Parallel()
CreateTestBot(t)
var exchangeInfo []account.Holdings
var bitfinexHoldings account.Holdings
bitfinexHoldings.Exchange = "Bitfinex"
bitfinexHoldings.Accounts = append(bitfinexHoldings.Accounts,
account.SubAccount{
Currencies: []account.Balance{
{
Currency: currency.BTC,
Total: 100,
Hold: 0,
},
},
})
exchangeInfo = append(exchangeInfo, bitfinexHoldings)
var bitstampHoldings account.Holdings
bitstampHoldings.Exchange = testExchange
bitstampHoldings.Accounts = append(bitstampHoldings.Accounts,
account.SubAccount{
Currencies: []account.Balance{
{
Currency: currency.LTC,
Total: 100,
Hold: 0,
},
{
Currency: currency.BTC,
Total: 100,
Hold: 0,
},
},
})
exchangeInfo = append(exchangeInfo, bitstampHoldings)
result := GetCollatedExchangeAccountInfoByCoin(exchangeInfo)
if len(result) == 0 {
t.Fatal("Unexpected result")
}
amount, ok := result[currency.BTC]
if !ok {
t.Fatal("Expected currency was not found in result map")
}
if amount.Total != 200 {
t.Fatal("Unexpected result")
}
_, ok = result[currency.ETH]
if ok {
t.Fatal("Unexpected result")
}
}
func TestGetExchangeHighestPriceByCurrencyPair(t *testing.T) {
t.Parallel()
CreateTestBot(t)

View File

@@ -7,9 +7,9 @@ import (
"sync/atomic"
"time"
"github.com/thrasher-corp/gocryptotrader/common"
"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"
"github.com/thrasher-corp/gocryptotrader/log"
"github.com/thrasher-corp/gocryptotrader/portfolio"
@@ -124,152 +124,97 @@ func (m *portfolioManager) processPortfolio() {
}
m.m.Lock()
defer m.m.Unlock()
exchanges, err := m.exchangeManager.GetExchanges()
if err != nil {
log.Errorf(log.PortfolioMgr, "Portfolio manager cannot get exchanges: %v", err)
if err := m.updateExchangeBalances(); err != nil {
log.Errorf(log.PortfolioMgr, "Portfolio updateExchangeBalances error: %v", err)
}
allExchangesHoldings := m.getExchangeAccountInfo(exchanges)
m.seedExchangeAccountInfo(allExchangesHoldings)
data := m.base.GetPortfolioAddressesGroupedByCoin()
for key, value := range data {
if err := m.base.UpdatePortfolio(context.TODO(), value, key); err != nil {
log.Errorf(log.PortfolioMgr, "Portfolio manager: UpdatePortfolio error: %s for currency %s\n", err, key)
log.Errorf(log.PortfolioMgr, "Portfolio manager: UpdatePortfolio error: %s for currency %s", err, key)
continue
}
log.Debugf(log.PortfolioMgr, "Portfolio manager: Successfully updated address balance for %s address(es) %s\n", key, value)
log.Debugf(log.PortfolioMgr, "Portfolio manager: Successfully updated address balance for %s address(es) %s", key, value)
}
atomic.CompareAndSwapInt32(&m.processing, 1, 0)
}
// seedExchangeAccountInfo seeds account info
func (m *portfolioManager) seedExchangeAccountInfo(accounts []account.Holdings) {
if len(accounts) == 0 {
return
// updateExchangeBalances calls UpdateAccountBalance on each exchange, and transfers the account balances into portfolio
func (m *portfolioManager) updateExchangeBalances() error {
if err := common.NilGuard(m); err != nil {
return err
}
for x := range accounts {
var currencies []account.Balance
for y := range accounts[x].Accounts {
next:
for z := range accounts[x].Accounts[y].Currencies {
for i := range currencies {
if !accounts[x].Accounts[y].Currencies[z].Currency.Equal(currencies[i].Currency) {
continue
}
currencies[i].Hold += accounts[x].Accounts[y].Currencies[z].Hold
currencies[i].Total += accounts[x].Accounts[y].Currencies[z].Total
currencies[i].AvailableWithoutBorrow += accounts[x].Accounts[y].Currencies[z].AvailableWithoutBorrow
currencies[i].Free += accounts[x].Accounts[y].Currencies[z].Free
currencies[i].Borrowed += accounts[x].Accounts[y].Currencies[z].Borrowed
continue next
}
currencies = append(currencies, account.Balance{
Currency: accounts[x].Accounts[y].Currencies[z].Currency,
Total: accounts[x].Accounts[y].Currencies[z].Total,
Hold: accounts[x].Accounts[y].Currencies[z].Hold,
Free: accounts[x].Accounts[y].Currencies[z].Free,
AvailableWithoutBorrow: accounts[x].Accounts[y].Currencies[z].AvailableWithoutBorrow,
Borrowed: accounts[x].Accounts[y].Currencies[z].Borrowed,
})
exchanges, errs := m.exchangeManager.GetExchanges()
if errs != nil {
return fmt.Errorf("portfolio manager cannot get exchanges: %w", errs)
}
for _, e := range exchanges {
if !e.IsEnabled() {
continue
}
if !e.IsRESTAuthenticationSupported() {
if m.base.Verbose {
log.Debugf(log.PortfolioMgr, "Portfolio skipping %s due to disabled authenticated API support", e.GetName())
}
continue
}
assetTypes := asset.Items{asset.Spot}
if e.HasAssetTypeAccountSegregation() {
assetTypes = e.GetAssetTypes(true)
}
for j := range currencies {
if !m.base.ExchangeAddressCoinExists(accounts[x].Exchange, currencies[j].Currency) {
if currencies[j].Total <= 0 {
continue
}
log.Debugf(log.PortfolioMgr, "Portfolio: Adding new exchange address: %s, %s, %f, %s\n",
accounts[x].Exchange,
currencies[j].Currency,
currencies[j].Total,
portfolio.ExchangeAddress)
m.base.Addresses = append(m.base.Addresses, portfolio.Address{
Address: accounts[x].Exchange,
CoinType: currencies[j].Currency,
Balance: currencies[j].Total,
Description: portfolio.ExchangeAddress,
})
continue
}
if currencies[j].Total <= 0 {
log.Debugf(log.PortfolioMgr, "Portfolio: Removing %s %s entry.\n",
accounts[x].Exchange,
currencies[j].Currency)
m.base.RemoveExchangeAddress(accounts[x].Exchange, currencies[j].Currency)
continue
}
balance, ok := m.base.GetAddressBalance(accounts[x].Exchange,
portfolio.ExchangeAddress,
currencies[j].Currency)
if !ok {
continue
}
if balance != currencies[j].Total {
log.Debugf(log.PortfolioMgr, "Portfolio: Updating %s %s entry with balance %f.\n",
accounts[x].Exchange,
currencies[j].Currency,
currencies[j].Total)
m.base.UpdateExchangeAddressBalance(accounts[x].Exchange,
currencies[j].Currency,
currencies[j].Total)
for _, a := range assetTypes {
if _, err := e.UpdateAccountBalances(context.TODO(), a); err != nil {
errs = common.AppendError(errs, fmt.Errorf("error updating %s %s account balances: %w", e.GetName(), a, err))
}
}
if err := m.updateExchangeAddressBalances(e); err != nil {
errs = common.AppendError(errs, fmt.Errorf("error updating %s account balances: %w", e.GetName(), err))
}
}
return errs
}
// getExchangeAccountInfo returns all the current enabled exchanges
func (m *portfolioManager) getExchangeAccountInfo(exchanges []exchange.IBotExchange) []account.Holdings {
response := make([]account.Holdings, 0, len(exchanges))
for x := range exchanges {
if !exchanges[x].IsEnabled() {
continue
}
if !exchanges[x].IsRESTAuthenticationSupported() {
if m.base.Verbose {
log.Debugf(log.PortfolioMgr,
"skipping %s due to disabled authenticated API support.\n",
exchanges[x].GetName())
}
continue
}
assetTypes := asset.Items{asset.Spot}
if exchanges[x].HasAssetTypeAccountSegregation() {
// Get enabled exchange asset types to sync account information.
// TODO: Update with further api key asset segration e.g. Kraken has
// individual keys associated with different asset types.
assetTypes = exchanges[x].GetAssetTypes(true)
}
exchangeHoldings := account.Holdings{
Exchange: exchanges[x].GetName(),
Accounts: make([]account.SubAccount, 0, len(assetTypes)),
}
for y := range assetTypes {
// Update account info to process account updates in memory on
// every fetch.
accountHoldings, err := exchanges[x].UpdateAccountInfo(context.TODO(), assetTypes[y])
if err != nil {
log.Errorf(log.PortfolioMgr,
"Error encountered retrieving exchange account info for %s. Error %s\n",
exchanges[x].GetName(),
err)
// updateExchangeAddressBalances fetches and collates all account balances with their deposit addresses
func (m *portfolioManager) updateExchangeAddressBalances(e exchange.IBotExchange) error {
if err := common.NilGuard(m, e); err != nil {
return err
}
currs, err := e.GetBase().Accounts.CurrencyBalances(nil, asset.All)
if err != nil {
return err
}
eName := e.GetName()
for c, b := range currs {
if !m.base.ExchangeAddressCoinExists(e.GetName(), c) {
if b.Total <= 0 {
continue
}
exchangeHoldings.Accounts = append(exchangeHoldings.Accounts, accountHoldings.Accounts...)
log.Debugf(log.PortfolioMgr, "Portfolio: Adding new exchange address: %s, %s, %f, %s", eName, c, b.Total, portfolio.ExchangeAddress)
m.base.Addresses = append(m.base.Addresses, portfolio.Address{
Address: eName,
CoinType: c,
Balance: b.Total,
Description: portfolio.ExchangeAddress,
})
continue
}
if len(exchangeHoldings.Accounts) > 0 {
response = append(response, exchangeHoldings)
if b.Total <= 0 {
log.Debugf(log.PortfolioMgr, "Portfolio: Removing %s %s entry", eName, c)
m.base.RemoveExchangeAddress(eName, c)
continue
}
if balance, ok := m.base.GetAddressBalance(eName, portfolio.ExchangeAddress, c); ok && balance != b.Total {
log.Debugf(log.PortfolioMgr, "Portfolio: Updating %s %s entry with balance %f", eName, c, b.Total)
m.base.UpdateExchangeAddressBalance(eName, c, b.Total)
}
}
return response
return nil
}
// AddAddress adds a new portfolio address for the portfolio manager to track

View File

@@ -1,11 +1,18 @@
package engine
import (
"context"
"errors"
"sync"
"testing"
"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/asset"
"github.com/thrasher-corp/gocryptotrader/portfolio"
)
func TestSetupPortfolioManager(t *testing.T) {
@@ -94,3 +101,98 @@ func TestProcessPortfolio(t *testing.T) {
m.processPortfolio()
}
func TestUpdateExchangeBalances(t *testing.T) {
t.Parallel()
assert.ErrorContains(t, (*portfolioManager)(nil).updateExchangeBalances(), "nil pointer: *engine.portfolioManager")
assert.ErrorIs(t, new(portfolioManager).updateExchangeBalances(), ErrNilSubsystem)
m, err := setupPortfolioManager(NewExchangeManager(), 0, &portfolio.Base{Verbose: true})
require.NoError(t, err, "setupPortfolioManager must not error")
assert.NoError(t, m.updateExchangeBalances(), "updateExchangeBalances should not error with an empty exchange list")
e := &mockExchange{err: errors.New("Mock UpdateBalanceError")}
m.exchangeManager.exchanges = map[string]exchange.IBotExchange{"mock": e}
assert.NoError(t, m.updateExchangeBalances(), "updateExchangeBalances should not error on disabled exchanges")
e.enabled = true
assert.NoError(t, m.updateExchangeBalances(), "updateExchangeBalances should skip exchange without auth support")
e.authSupported = true
assert.ErrorIs(t, m.updateExchangeBalances(), e.err, "error should contain the UpdateAccountBalances error message")
}
func TestUpdateExchangeAddressBalances(t *testing.T) {
t.Parallel()
assert.ErrorContains(t, (*portfolioManager)(nil).updateExchangeAddressBalances(nil), "nil pointer: *engine.portfolioManager")
assert.ErrorContains(t, new(portfolioManager).updateExchangeAddressBalances(nil), "nil pointer: <nil>")
e := &mockExchange{enabled: false, err: errors.New("Mock UpdateBalanceError")}
m, err := setupPortfolioManager(NewExchangeManager(), 0, nil)
require.NoError(t, err, "setupPortfolioManager must not error")
assert.ErrorContains(t, m.updateExchangeAddressBalances(e), "nil pointer: *accounts.Accounts", "updateExchangeAddressBalances should propagate CurrencyBalances errors")
a := accounts.MustNewAccounts(e)
e.accounts = a
subAcct := accounts.NewSubAccount(asset.Spot, "")
subAcct.Balances.Set(currency.BTC, accounts.Balance{Total: 1.5})
subAcct.Balances.Set(currency.ETH, accounts.Balance{Total: 0})
require.NoError(t, a.Save(t.Context(), accounts.SubAccounts{subAcct}, false), "accounts.Save must not error")
require.NoError(t, m.updateExchangeAddressBalances(e))
require.Len(t, m.base.Addresses, 1, "must have one address for the positive balance")
assert.Equal(t, 1.5, m.base.Addresses[0].Balance, "balance should match on a new address")
subAcct.Balances.Set(currency.BTC, accounts.Balance{Total: 2})
require.NoError(t, a.Save(t.Context(), accounts.SubAccounts{subAcct}, true), "accounts.Save must not error")
require.NoError(t, m.updateExchangeAddressBalances(e))
require.Len(t, m.base.Addresses, 1, "must have one address for the positive balance")
assert.Equal(t, 2.0, m.base.Addresses[0].Balance, "balance should match after update existing address")
subAcct.Balances.Set(currency.BTC, accounts.Balance{Total: 0})
require.NoError(t, a.Save(t.Context(), accounts.SubAccounts{subAcct}, true), "accounts.Save must not error")
require.NoError(t, m.updateExchangeAddressBalances(e))
assert.Empty(t, m.base.Addresses, "should have removed address with no balance")
}
// mockExchange is a minimal mock for testing
type mockExchange struct {
exchange.IBotExchange
enabled bool
authSupported bool
err error
accounts *accounts.Accounts
}
func (m *mockExchange) GetName() string {
return "mocky"
}
func (m *mockExchange) IsEnabled() bool {
return m.enabled
}
func (m *mockExchange) IsRESTAuthenticationSupported() bool {
return m.authSupported
}
func (m *mockExchange) HasAssetTypeAccountSegregation() bool {
return true
}
func (m *mockExchange) GetAssetTypes(bool) asset.Items {
return asset.Items{asset.Spot, asset.Futures}
}
func (m *mockExchange) UpdateAccountBalances(context.Context, asset.Item) (accounts.SubAccounts, error) {
return nil, m.err
}
func (m *mockExchange) GetBase() *exchange.Base {
return &exchange.Base{Name: "mocky", Accounts: m.accounts}
}
func (m *mockExchange) GetCredentials(context.Context) (*accounts.Credentials, error) {
return &accounts.Credentials{Key: m.GetName()}, nil
}

View File

@@ -32,8 +32,8 @@ import (
"github.com/thrasher-corp/gocryptotrader/database/repository/audit"
exchangeDB "github.com/thrasher-corp/gocryptotrader/database/repository/exchange"
"github.com/thrasher-corp/gocryptotrader/encoding/json"
"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"
"github.com/thrasher-corp/gocryptotrader/exchanges/collateral"
"github.com/thrasher-corp/gocryptotrader/exchanges/fundingrate"
@@ -73,7 +73,6 @@ var (
errCurrencyPairInvalid = errors.New("currency provided is not found in the available pairs list")
errNoTrades = errors.New("no trades returned from supplied params")
errNilRequestData = errors.New("nil request data received, cannot continue")
errNoAccountInformation = errors.New("account information does not exist")
errShutdownNotAllowed = errors.New("shutting down this bot instance is not allowed via gRPC, please enable by command line flag --grpcshutdown or config.json field grpcAllowBotShutdown")
errGRPCShutdownSignalIsNil = errors.New("cannot shutdown, gRPC shutdown channel is nil")
errInvalidStrategy = errors.New("invalid strategy")
@@ -114,7 +113,7 @@ func (s *RPCServer) authenticateClient(ctx context.Context) (context.Context, er
password != s.Config.RemoteControl.Password {
return ctx, errors.New("username/password mismatch")
}
ctx, err = account.ParseCredentialsMetadata(ctx, md)
ctx, err = accounts.ParseCredentialsMetadata(ctx, md)
if err != nil {
return ctx, err
}
@@ -556,87 +555,80 @@ func (s *RPCServer) GetOrderbooks(_ context.Context, _ *gctrpc.GetOrderbooksRequ
return &gctrpc.GetOrderbooksResponse{Orderbooks: obResponse}, nil
}
// GetAccountInfo returns an account balance for a specific exchange
func (s *RPCServer) GetAccountInfo(ctx context.Context, r *gctrpc.GetAccountInfoRequest) (*gctrpc.GetAccountInfoResponse, error) {
// GetAccountBalances returns an account balance for a specific exchange.
func (s *RPCServer) GetAccountBalances(ctx context.Context, r *gctrpc.GetAccountBalancesRequest) (*gctrpc.GetAccountBalancesResponse, error) {
assetType, err := asset.New(r.AssetType)
if err != nil {
return nil, err
}
exch, err := s.GetExchangeByName(r.Exchange)
e, err := s.GetExchangeByName(r.Exchange)
if err != nil {
return nil, err
}
err = checkParams(r.Exchange, exch, assetType, currency.EMPTYPAIR)
if err := checkParams(r.Exchange, e, assetType, currency.EMPTYPAIR); err != nil {
return nil, err
}
resp, err := e.GetCachedSubAccounts(ctx, assetType)
if err != nil {
return nil, err
}
resp, err := exch.GetCachedAccountInfo(ctx, assetType)
if err != nil {
return nil, err
}
return createAccountInfoRequest(resp)
return accountBalanceResp(r.Exchange, resp), nil
}
// UpdateAccountInfo forces an update of the account info
func (s *RPCServer) UpdateAccountInfo(ctx context.Context, r *gctrpc.GetAccountInfoRequest) (*gctrpc.GetAccountInfoResponse, error) {
// UpdateAccountBalances forces an update of the account balances.
func (s *RPCServer) UpdateAccountBalances(ctx context.Context, r *gctrpc.GetAccountBalancesRequest) (*gctrpc.GetAccountBalancesResponse, error) {
assetType, err := asset.New(r.AssetType)
if err != nil {
return nil, err
}
exch, err := s.GetExchangeByName(r.Exchange)
e, err := s.GetExchangeByName(r.Exchange)
if err != nil {
return nil, err
}
err = checkParams(r.Exchange, exch, assetType, currency.EMPTYPAIR)
if err := checkParams(r.Exchange, e, assetType, currency.EMPTYPAIR); err != nil {
return nil, err
}
resp, err := e.UpdateAccountBalances(ctx, assetType)
if err != nil {
return nil, err
}
resp, err := exch.UpdateAccountInfo(ctx, assetType)
if err != nil {
return nil, err
}
return createAccountInfoRequest(resp)
return accountBalanceResp(r.Exchange, resp), nil
}
func createAccountInfoRequest(h account.Holdings) (*gctrpc.GetAccountInfoResponse, error) {
accounts := make([]*gctrpc.Account, len(h.Accounts))
for x := range h.Accounts {
var a gctrpc.Account
a.Id = h.Accounts[x].Credentials.String()
for _, y := range h.Accounts[x].Currencies {
if y.Total == 0 &&
y.Hold == 0 &&
y.Free == 0 &&
y.AvailableWithoutBorrow == 0 &&
y.Borrowed == 0 {
continue
}
a.Currencies = append(a.Currencies, &gctrpc.AccountCurrencyInfo{
Currency: y.Currency.String(),
TotalValue: y.Total,
Hold: y.Hold,
Free: y.Free,
FreeWithoutBorrow: y.AvailableWithoutBorrow,
Borrowed: y.Borrowed,
UpdatedAt: timestamppb.New(y.UpdatedAt),
func accountBalanceResp(eName string, s accounts.SubAccounts) *gctrpc.GetAccountBalancesResponse {
subAccts := make([]*gctrpc.Account, len(s))
for i, sa := range s {
subAccts[i] = &gctrpc.Account{
Id: sa.ID,
}
for curr, bal := range sa.Balances {
subAccts[i].Currencies = append(subAccts[i].Currencies, &gctrpc.AccountCurrencyInfo{
Currency: curr.String(),
TotalValue: bal.Total,
Hold: bal.Hold,
Free: bal.Free,
FreeWithoutBorrow: bal.AvailableWithoutBorrow,
Borrowed: bal.Borrowed,
UpdatedAt: timestamppb.New(bal.UpdatedAt),
})
}
accounts[x] = &a
}
return &gctrpc.GetAccountInfoResponse{Exchange: h.Exchange, Accounts: accounts}, nil
return &gctrpc.GetAccountBalancesResponse{
Exchange: eName,
Accounts: subAccts,
}
}
// GetAccountInfoStream streams an account balance for a specific exchange
func (s *RPCServer) GetAccountInfoStream(r *gctrpc.GetAccountInfoRequest, stream gctrpc.GoCryptoTraderService_GetAccountInfoStreamServer) error {
// GetAccountBalancesStream streams an account balance for a specific exchange
func (s *RPCServer) GetAccountBalancesStream(r *gctrpc.GetAccountBalancesRequest, stream gctrpc.GoCryptoTraderService_GetAccountBalancesStreamServer) error {
assetType, err := asset.New(r.AssetType)
if err != nil {
return err
@@ -652,7 +644,7 @@ func (s *RPCServer) GetAccountInfoStream(r *gctrpc.GetAccountInfoRequest, stream
return err
}
pipe, err := account.SubscribeToExchangeAccount(r.Exchange)
pipe, err := exch.SubscribeAccountBalances()
if err != nil {
return err
}
@@ -677,32 +669,12 @@ func (s *RPCServer) GetAccountInfoStream(r *gctrpc.GetAccountInfoRequest, stream
case <-init:
}
holdings, err := exch.GetCachedAccountInfo(stream.Context(), assetType)
subAccts, err := exch.GetCachedSubAccounts(stream.Context(), assetType)
if err != nil {
return err
}
accounts := make([]*gctrpc.Account, len(holdings.Accounts))
for x := range holdings.Accounts {
subAccounts := make([]*gctrpc.AccountCurrencyInfo, len(holdings.Accounts[x].Currencies))
for y := range holdings.Accounts[x].Currencies {
subAccounts[y] = &gctrpc.AccountCurrencyInfo{
Currency: holdings.Accounts[x].Currencies[y].Currency.String(),
TotalValue: holdings.Accounts[x].Currencies[y].Total,
Hold: holdings.Accounts[x].Currencies[y].Hold,
UpdatedAt: timestamppb.New(holdings.Accounts[x].Currencies[y].UpdatedAt),
}
}
accounts[x] = &gctrpc.Account{
Id: holdings.Accounts[x].ID,
Currencies: subAccounts,
}
}
if err := stream.Send(&gctrpc.GetAccountInfoResponse{
Exchange: holdings.Exchange,
Accounts: accounts,
}); err != nil {
if err := stream.Send(accountBalanceResp(r.Exchange, subAccts)); err != nil {
return err
}
}
@@ -4756,8 +4728,7 @@ func (s *RPCServer) GetCollateral(ctx context.Context, r *gctrpc.GetCollateralRe
if err != nil {
return nil, err
}
feat := exch.GetSupportedFeatures()
if !feat.FuturesCapabilities.Collateral {
if f := exch.GetSupportedFeatures(); !f.FuturesCapabilities.Collateral {
return nil, fmt.Errorf("%w Get Collateral for exchange %v", common.ErrFunctionNotSupported, exch.GetName())
}
@@ -4766,42 +4737,16 @@ func (s *RPCServer) GetCollateral(ctx context.Context, r *gctrpc.GetCollateralRe
return nil, err
}
err = checkParams(r.Exchange, exch, a, currency.EMPTYPAIR)
if err != nil {
if err := checkParams(r.Exchange, exch, a, currency.EMPTYPAIR); err != nil {
return nil, err
}
if !a.IsFutures() {
return nil, fmt.Errorf("%s %w", a, futures.ErrNotFuturesAsset)
}
ai, err := exch.GetCachedAccountInfo(ctx, a)
currBalances, err := exch.GetCachedCurrencyBalances(ctx, a)
if err != nil {
return nil, err
}
creds, err := exch.GetCredentials(ctx)
if err != nil {
return nil, err
}
subAccounts := make([]string, len(ai.Accounts))
var acc *account.SubAccount
for i := range ai.Accounts {
subAccounts[i] = ai.Accounts[i].ID
if ai.Accounts[i].ID == "main" && creds.SubAccount == "" {
acc = &ai.Accounts[i]
break
}
if strings.EqualFold(creds.SubAccount, ai.Accounts[i].ID) {
acc = &ai.Accounts[i]
break
}
}
if acc == nil {
return nil, fmt.Errorf("%w for %s %s and stored credentials - available subaccounts: %s",
errNoAccountInformation,
exch.GetName(),
creds.SubAccount,
strings.Join(subAccounts, ","))
}
var spotPairs currency.Pairs
if r.CalculateOffline {
spotPairs, err = exch.GetAvailablePairs(asset.Spot)
@@ -4810,24 +4755,22 @@ func (s *RPCServer) GetCollateral(ctx context.Context, r *gctrpc.GetCollateralRe
}
}
calculators := make([]futures.CollateralCalculator, 0, len(acc.Currencies))
for i := range acc.Currencies {
total := decimal.NewFromFloat(acc.Currencies[i].Total)
free := decimal.NewFromFloat(acc.Currencies[i].AvailableWithoutBorrow)
calculators := make([]futures.CollateralCalculator, 0, len(currBalances))
for curr, balance := range currBalances {
total := decimal.NewFromFloat(balance.Total)
free := decimal.NewFromFloat(balance.AvailableWithoutBorrow)
cal := futures.CollateralCalculator{
CalculateOffline: r.CalculateOffline,
CollateralCurrency: acc.Currencies[i].Currency,
CollateralCurrency: curr,
Asset: a,
FreeCollateral: free,
LockedCollateral: total.Sub(free),
}
if r.CalculateOffline &&
!acc.Currencies[i].Currency.Equal(currency.USD) {
if r.CalculateOffline && !curr.Equal(currency.USD) {
var tick *ticker.Price
tickerCurr := currency.NewPair(acc.Currencies[i].Currency, currency.USD)
tickerCurr := currency.NewPair(curr, currency.USD)
if !spotPairs.Contains(tickerCurr, true) {
// cannot price currency to calculate collateral
continue
continue // cannot price currency to calculate collateral
}
tick, err = exch.GetCachedTicker(tickerCurr, asset.Spot)
if err != nil {
@@ -4855,7 +4798,6 @@ func (s *RPCServer) GetCollateral(ctx context.Context, r *gctrpc.GetCollateralRe
collateralDisplayCurrency := " " + c.CollateralCurrency.String()
result := &gctrpc.GetCollateralResponse{
SubAccount: creds.SubAccount,
CollateralCurrency: c.CollateralCurrency.String(),
AvailableCollateral: c.AvailableCollateral.String() + collateralDisplayCurrency,
UsedCollateral: c.UsedCollateral.String() + collateralDisplayCurrency,

View File

@@ -34,8 +34,8 @@ import (
dbexchange "github.com/thrasher-corp/gocryptotrader/database/repository/exchange"
sqltrade "github.com/thrasher-corp/gocryptotrader/database/repository/trade"
"github.com/thrasher-corp/gocryptotrader/encoding/json"
"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"
"github.com/thrasher-corp/gocryptotrader/exchanges/binance"
"github.com/thrasher-corp/gocryptotrader/exchanges/collateral"
@@ -316,28 +316,33 @@ func (f fExchange) GetCachedTicker(p currency.Pair, a asset.Item) (*ticker.Price
}, nil
}
// GetCachedAccountInfo overrides testExchange's fetch account info function
// to do the bare minimum required with no API calls or credentials required
func (f fExchange) GetCachedAccountInfo(_ context.Context, a asset.Item) (account.Holdings, error) {
return account.Holdings{
Exchange: f.GetName(),
Accounts: []account.SubAccount{
{
ID: "1337",
AssetType: a,
Currencies: []account.Balance{
{
Currency: currency.USD,
Total: 1337,
},
{
Currency: currency.BTC,
Total: 13337,
},
},
},
// GetCachedSubAccounts overrides testExchange's fetch account info function to do the bare minimum required with no API calls or credentials required
// Only returns balances for creds with a SubAccount populated
func (f fExchange) GetCachedSubAccounts(ctx context.Context, a asset.Item) (accounts.SubAccounts, error) {
creds, err := f.GetCredentials(ctx)
if err != nil {
return nil, err
}
if creds.SubAccount == "" {
return nil, fmt.Errorf("%w for %s credentials %s asset %s", accounts.ErrNoBalances, f.GetName(), creds, a)
}
return accounts.SubAccounts{{
ID: creds.SubAccount,
Balances: accounts.CurrencyBalances{
currency.USD: {Currency: currency.USD, Total: 1337},
currency.BTC: {Currency: currency.BTC, Total: 13337},
},
}, nil
}}, nil
}
// GetCachedCurrencyBalances overrides testExchange's fetch account info function to do the bare minimum required with no API calls or credentials required
// Only returns balances for creds with a SubAccount populated
func (f fExchange) GetCachedCurrencyBalances(ctx context.Context, a asset.Item) (accounts.CurrencyBalances, error) {
subAccts, err := f.GetCachedSubAccounts(ctx, a)
if err != nil {
return nil, err
}
return subAccts[0].Balances, nil
}
// CalculateTotalCollateral overrides testExchange's CalculateTotalCollateral function
@@ -386,22 +391,13 @@ func (f fExchange) CalculateTotalCollateral(context.Context, *futures.TotalColla
}, nil
}
// UpdateAccountInfo overrides testExchange's update account info function
// UpdateAccountBalances overrides testExchange's update account info function
// to do the bare minimum required with no API calls or credentials required
func (f fExchange) UpdateAccountInfo(_ context.Context, a asset.Item) (account.Holdings, error) {
func (f fExchange) UpdateAccountBalances(_ context.Context, a asset.Item) (accounts.SubAccounts, error) {
if a == asset.Futures {
return account.Holdings{}, asset.ErrNotSupported
return accounts.SubAccounts{}, asset.ErrNotSupported
}
return account.Holdings{
Exchange: f.GetName(),
Accounts: []account.SubAccount{
{
ID: "1337",
AssetType: a,
Currencies: nil,
},
},
}, nil
return accounts.SubAccounts{accounts.NewSubAccount(a, "1337")}, nil
}
// GetCurrencyStateSnapshot overrides interface function
@@ -1216,63 +1212,48 @@ func TestGetHistoricTrades(t *testing.T) {
}
}
func TestGetAccountInfo(t *testing.T) {
func TestGetAccountBalances(t *testing.T) {
t.Parallel()
em := NewExchangeManager()
exch, err := em.NewExchangeByName(testExchange)
if err != nil {
t.Fatal(err)
}
require.NoError(t, err)
b := exch.GetBase()
b.Name = fakeExchangeName
b.Enabled = true
b.CurrencyPairs.Pairs = make(map[asset.Item]*currency.PairStore)
b.CurrencyPairs.Pairs[asset.Spot] = &currency.PairStore{
AssetEnabled: true,
}
fakeExchange := fExchange{
IBotExchange: exch,
}
b.CurrencyPairs.Pairs[asset.Spot] = &currency.PairStore{AssetEnabled: true}
fakeExchange := fExchange{IBotExchange: exch}
err = em.Add(fakeExchange)
require.NoError(t, err)
ctx := accounts.DeployCredentialsToContext(t.Context(), &accounts.Credentials{Key: "fakerino", Secret: "supafake", SubAccount: "42"})
s := RPCServer{Engine: &Engine{ExchangeManager: em}}
_, err = s.GetAccountInfo(t.Context(), &gctrpc.GetAccountInfoRequest{Exchange: fakeExchangeName, AssetType: asset.Spot.String()})
_, err = s.GetAccountBalances(ctx, &gctrpc.GetAccountBalancesRequest{Exchange: fakeExchangeName, AssetType: asset.Spot.String()})
assert.NoError(t, err)
}
func TestUpdateAccountInfo(t *testing.T) {
func TestUpdateAccountBalances(t *testing.T) {
t.Parallel()
em := NewExchangeManager()
exch, err := em.NewExchangeByName(testExchange)
if err != nil {
t.Fatal(err)
}
require.NoError(t, err)
b := exch.GetBase()
b.Name = fakeExchangeName
b.Enabled = true
b.CurrencyPairs.Pairs = make(map[asset.Item]*currency.PairStore)
b.CurrencyPairs.Pairs[asset.Spot] = &currency.PairStore{
AssetEnabled: true,
}
fakeExchange := fExchange{
IBotExchange: exch,
}
b.CurrencyPairs.Pairs[asset.Spot] = &currency.PairStore{AssetEnabled: true}
fakeExchange := fExchange{IBotExchange: exch}
err = em.Add(fakeExchange)
require.NoError(t, err)
s := RPCServer{Engine: &Engine{ExchangeManager: em}}
_, err = s.GetAccountInfo(t.Context(), &gctrpc.GetAccountInfoRequest{Exchange: fakeExchangeName, AssetType: asset.Spot.String()})
ctx := accounts.DeployCredentialsToContext(t.Context(), &accounts.Credentials{Key: "fakerino", Secret: "supafake", SubAccount: "42"})
_, err = s.GetAccountBalances(ctx, &gctrpc.GetAccountBalancesRequest{Exchange: fakeExchangeName, AssetType: asset.Spot.String()})
assert.NoError(t, err)
_, err = s.UpdateAccountInfo(t.Context(), &gctrpc.GetAccountInfoRequest{Exchange: fakeExchangeName, AssetType: asset.Futures.String()})
_, err = s.UpdateAccountBalances(ctx, &gctrpc.GetAccountBalancesRequest{Exchange: fakeExchangeName, AssetType: asset.Futures.String()})
assert.ErrorIs(t, err, currency.ErrAssetNotFound)
_, err = s.UpdateAccountInfo(t.Context(), &gctrpc.GetAccountInfoRequest{
Exchange: fakeExchangeName,
AssetType: asset.Spot.String(),
})
_, err = s.UpdateAccountBalances(ctx, &gctrpc.GetAccountBalancesRequest{Exchange: fakeExchangeName, AssetType: asset.Spot.String()})
assert.NoError(t, err)
}
@@ -2196,6 +2177,8 @@ func TestGetCollateral(t *testing.T) {
b := exch.GetBase()
b.Name = fakeExchangeName
b.Enabled = true
b.Accounts, err = accounts.GetStore().GetExchangeAccounts(b)
require.NoError(t, err, "GetExchangeAccounts must not error")
cp, err := currency.NewPairFromString("btc-usd")
require.NoError(t, err)
@@ -2235,17 +2218,15 @@ func TestGetCollateral(t *testing.T) {
})
require.ErrorIs(t, err, exchange.ErrCredentialsAreEmpty)
ctx := account.DeployCredentialsToContext(t.Context(),
&account.Credentials{Key: "fakerino", Secret: "supafake"})
ctx := accounts.DeployCredentialsToContext(t.Context(), &accounts.Credentials{Key: "fakerino", Secret: "supafake"})
_, err = s.GetCollateral(ctx, &gctrpc.GetCollateralRequest{
Exchange: fakeExchangeName,
Asset: asset.Futures.String(),
})
require.ErrorIs(t, err, errNoAccountInformation)
require.ErrorIs(t, err, accounts.ErrNoBalances)
ctx = account.DeployCredentialsToContext(t.Context(),
&account.Credentials{Key: "fakerino", Secret: "supafake", SubAccount: "1337"})
ctx = accounts.DeployCredentialsToContext(t.Context(), &accounts.Credentials{Key: "fakerino", Secret: "supafake", SubAccount: "1337"})
r, err := s.GetCollateral(ctx, &gctrpc.GetCollateralRequest{
Exchange: fakeExchangeName,

View File

@@ -7,8 +7,8 @@ import (
"github.com/thrasher-corp/gocryptotrader/common"
"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/fill"
"github.com/thrasher-corp/gocryptotrader/exchanges/order"
"github.com/thrasher-corp/gocryptotrader/exchanges/orderbook"
@@ -329,15 +329,9 @@ func (m *WebsocketRoutineManager) websocketDataHandler(exchName string, data any
return fmt.Errorf("%w %s", d.Err, d.Error())
case websocket.UnhandledMessageWarning:
log.Warnf(log.WebsocketMgr, "%s unhandled message - %s", exchName, d.Message)
case account.Change:
case []accounts.Change, accounts.Change:
if m.verbose {
m.printAccountHoldingsChangeSummary(exchName, d)
}
case []account.Change:
if m.verbose {
for x := range d {
m.printAccountHoldingsChangeSummary(exchName, d[x])
}
log.Debugf(log.WebsocketMgr, "%s %+v", exchName, d)
}
case []trade.Data, trade.Data:
if m.verbose {
@@ -349,10 +343,7 @@ func (m *WebsocketRoutineManager) websocketDataHandler(exchName string, data any
}
default:
if m.verbose {
log.Warnf(log.WebsocketMgr,
"%s websocket Unknown type: %+v",
exchName,
d)
log.Warnf(log.WebsocketMgr, "%s websocket Unknown type: %+v", exchName, d)
}
}
return nil
@@ -396,21 +387,6 @@ func (m *WebsocketRoutineManager) printOrderSummary(o *order.Detail, isUpdate bo
o.RemainingAmount)
}
// printAccountHoldingsChangeSummary this function will be deprecated when a
// account holdings update is done.
func (m *WebsocketRoutineManager) printAccountHoldingsChangeSummary(exch string, o account.Change) {
if m == nil || atomic.LoadInt32(&m.state) == stoppedState || o.Balance == nil {
return
}
log.Debugf(log.WebsocketMgr,
"Account Holdings Balance Changed: %s %s %s has changed balance by %f for account: %s",
exch,
o.AssetType,
o.Balance.Currency,
o.Balance.Total,
o.Account)
}
// registerWebsocketDataHandler registers an externally (GCT Library) defined
// dedicated filter specific data types for internal & external strategy use.
// InterceptorOnly as true will purge all other registered handlers