exchanges/account: add UpdatedAt field to Balance and ProtectedBalance (#1827)

* add updatedAt for account balance

Signed-off-by: Ye Sijun <junnplus@gmail.com>

* add nil pointer test for load

Signed-off-by: Ye Sijun <junnplus@gmail.com>

* account: set default updatedAt to balance for Update

Signed-off-by: Ye Sijun <junnplus@gmail.com>

* engine: add UpdatedAt for account info

Signed-off-by: Ye Sijun <junnplus@gmail.com>

* account: force updatedAt for load balance

Signed-off-by: Ye Sijun <junnplus@gmail.com>

* minor change for test

Signed-off-by: Ye Sijun <junnplus@gmail.com>

---------

Signed-off-by: Ye Sijun <junnplus@gmail.com>
This commit is contained in:
Jun
2025-03-13 11:57:14 +09:00
committed by GitHub
parent 138419e7a8
commit 4474278053
9 changed files with 6890 additions and 8648 deletions

View File

@@ -31,6 +31,9 @@ var (
errBalanceIsNil = errors.New("balance is nil")
errNoCredentialBalances = errors.New("no balances associated with credentials")
errCredentialsAreNil = errors.New("credentials are nil")
errOutOfSequence = errors.New("out of sequence")
errUpdatedAtIsZero = errors.New("updatedAt may not be zero")
errLoadingBalance = errors.New("error loading balance")
)
// CollectBalances converts a map of sub-account balances into a slice
@@ -103,7 +106,7 @@ func GetHoldings(exch string, creds *Credentials, assetType asset.Item) (Holding
return Holdings{}, fmt.Errorf("%s %s %s %w %w", exch, creds, assetType, errNoCredentialBalances, ErrExchangeHoldingsNotFound)
}
var currencyBalances = make([]Balance, 0, len(subAccountHoldings))
currencyBalances := make([]Balance, 0, len(subAccountHoldings))
cpy := *creds
for mapKey, assetHoldings := range subAccountHoldings {
if mapKey.Asset != assetType {
@@ -117,6 +120,7 @@ func GetHoldings(exch string, creds *Credentials, assetType asset.Item) (Holding
Free: assetHoldings.free,
AvailableWithoutBorrow: assetHoldings.availableWithoutBorrow,
Borrowed: assetHoldings.borrowed,
UpdatedAt: assetHoldings.updatedAt,
})
assetHoldings.m.Unlock()
if cpy.SubAccount == "" && mapKey.SubAccount != "" {
@@ -239,6 +243,9 @@ func (s *Service) Update(incoming *Holdings, creds *Credentials) error {
}
for y := range incoming.Accounts[x].Currencies {
if incoming.Accounts[x].Currencies[y].UpdatedAt.IsZero() {
incoming.Accounts[x].Currencies[y].UpdatedAt = time.Now()
}
// Note: Sub accounts are case sensitive and an account "name" is
// different to account "naMe".
bal, ok := subAccounts[key.SubAccountCurrencyAsset{
@@ -254,7 +261,14 @@ func (s *Service) Update(incoming *Holdings, creds *Credentials) error {
Asset: incoming.Accounts[x].AssetType,
}] = bal
}
bal.load(incoming.Accounts[x].Currencies[y])
if err := bal.load(&incoming.Accounts[x].Currencies[y]); err != nil {
errs = common.AppendError(errs, fmt.Errorf("%w for account ID `%s` [%s %s]: %w",
errLoadingBalance,
incoming.Accounts[x].ID,
incoming.Accounts[x].AssetType,
incoming.Accounts[x].Currencies[y].Currency,
err))
}
}
}
@@ -268,22 +282,34 @@ func (s *Service) Update(incoming *Holdings, creds *Credentials) error {
// load checks to see if there is a change from incoming balance, if there is a
// change it will change then alert external routines.
func (b *ProtectedBalance) load(change Balance) {
func (b *ProtectedBalance) load(change *Balance) error {
if change == nil {
return fmt.Errorf("%w for '%T'", common.ErrNilPointer, change)
}
if change.UpdatedAt.IsZero() {
return errUpdatedAtIsZero
}
b.m.Lock()
defer b.m.Unlock()
if !b.updatedAt.IsZero() && !b.updatedAt.Before(change.UpdatedAt) {
return errOutOfSequence
}
if b.total == change.Total &&
b.hold == change.Hold &&
b.free == change.Free &&
b.availableWithoutBorrow == change.AvailableWithoutBorrow &&
b.borrowed == change.Borrowed {
return
b.borrowed == change.Borrowed &&
b.updatedAt.Equal(change.UpdatedAt) {
return nil
}
b.total = change.Total
b.hold = change.Hold
b.free = change.Free
b.availableWithoutBorrow = change.AvailableWithoutBorrow
b.borrowed = change.Borrowed
b.updatedAt = change.UpdatedAt
b.notice.Alert()
return nil
}
// Wait waits for a change in amounts for an asset type. This will pause

View File

@@ -8,6 +8,7 @@ import (
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"github.com/thrasher-corp/gocryptotrader/common"
"github.com/thrasher-corp/gocryptotrader/common/key"
"github.com/thrasher-corp/gocryptotrader/currency"
"github.com/thrasher-corp/gocryptotrader/dispatch"
@@ -85,7 +86,8 @@ func TestGetHoldings(t *testing.T) {
Accounts: []SubAccount{
{
ID: "1337",
}},
},
},
}, happyCredentials)
assert.ErrorIs(t, err, asset.ErrNotSupported)
@@ -106,7 +108,8 @@ func TestGetHoldings(t *testing.T) {
Hold: 20,
},
},
}},
},
},
}, happyCredentials)
assert.NoError(t, err)
@@ -124,7 +127,8 @@ func TestGetHoldings(t *testing.T) {
Hold: 20,
},
},
}},
},
},
}, happyCredentials)
assert.NoError(t, err)
@@ -292,28 +296,32 @@ func TestBalanceInternalWait(t *testing.T) {
func TestBalanceInternalLoad(t *testing.T) {
t.Parallel()
bi := &ProtectedBalance{}
bi.load(Balance{Total: 1, Hold: 2, Free: 3, AvailableWithoutBorrow: 4, Borrowed: 5})
err := bi.load(nil)
assert.ErrorIs(t, err, common.ErrNilPointer, "should error nil pointer correctly")
err = bi.load(&Balance{Total: 1, Hold: 2, Free: 3, AvailableWithoutBorrow: 4, Borrowed: 5})
assert.ErrorIs(t, err, errUpdatedAtIsZero, "should error correctly when updatedAt is not set")
now := time.Now()
err = bi.load(&Balance{UpdatedAt: now, Total: 1, Hold: 2, Free: 3, AvailableWithoutBorrow: 4, Borrowed: 5})
require.NoError(t, err)
bi.m.Lock()
if bi.total != 1 {
t.Fatal("unexpected value")
}
if bi.hold != 2 {
t.Fatal("unexpected value")
}
if bi.free != 3 {
t.Fatal("unexpected value")
}
if bi.availableWithoutBorrow != 4 {
t.Fatal("unexpected value")
}
if bi.borrowed != 5 {
t.Fatal("unexpected value")
}
assert.Equal(t, now, bi.updatedAt)
assert.Equal(t, 1.0, bi.total)
assert.Equal(t, 2.0, bi.hold)
assert.Equal(t, 3.0, bi.free)
assert.Equal(t, 4.0, bi.availableWithoutBorrow)
assert.Equal(t, 5.0, bi.borrowed)
bi.m.Unlock()
if bi.GetFree() != 3 {
t.Fatal("unexpected value")
}
assert.Equal(t, 3.0, bi.GetFree())
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")
err = bi.load(&Balance{UpdatedAt: now.Add(time.Second), Total: 2, Hold: 3, Free: 4, AvailableWithoutBorrow: 5, Borrowed: 6})
assert.NoError(t, err)
}
func TestGetFree(t *testing.T) {
@@ -408,11 +416,7 @@ func TestUpdate(t *testing.T) {
t.Fatal("account should be loaded")
}
if b.total != 100 {
t.Errorf("expecting 100 but received %f", b.total)
}
if b.hold != 20 {
t.Errorf("expecting 20 but received %f", b.hold)
}
assert.Equal(t, 100.0, b.total)
assert.Equal(t, 20.0, b.hold)
assert.NotEmpty(t, b.updatedAt)
}

View File

@@ -3,6 +3,7 @@ package account
import (
"errors"
"sync"
"time"
"github.com/gofrs/uuid"
"github.com/thrasher-corp/gocryptotrader/common/key"
@@ -59,6 +60,7 @@ type Balance struct {
Free float64
AvailableWithoutBorrow float64
Borrowed float64
UpdatedAt time.Time
}
// Change defines incoming balance change on currency holdings
@@ -78,6 +80,7 @@ type ProtectedBalance struct {
availableWithoutBorrow float64
borrowed float64
m sync.Mutex
updatedAt time.Time
// notice alerts for when the balance changes for strategy inspection and
// usage.