Browse Source

Feature/add quantity to accounts tab of holding detail dialog (#7595)

* Add quantity to accounts tab of holding detail dialog

* Update changelog
pull/7596/head
Thomas Kaul 6 days ago
committed by GitHub
parent
commit
55939ea2e5
No known key found for this signature in database GPG Key ID: B5690EEEBB952194
  1. 5
      CHANGELOG.md
  2. 122
      apps/api/src/app/portfolio/portfolio.service.spec.ts
  3. 77
      apps/api/src/app/portfolio/portfolio.service.ts
  4. 1
      apps/client/src/app/components/holding-detail-dialog/holding-detail-dialog.html
  5. 1
      libs/common/src/lib/config.ts
  6. 2
      libs/common/src/lib/interfaces/portfolio-details.interface.ts
  7. 2
      libs/common/src/lib/types/account-with-value.type.ts
  8. 20
      libs/ui/src/lib/accounts-table/accounts-table.component.html
  9. 9
      libs/ui/src/lib/accounts-table/accounts-table.component.ts

5
CHANGELOG.md

@ -7,9 +7,14 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
## Unreleased
### Added
- Added the quantity to the accounts tab of the holding detail dialog (experimental)
### Fixed
- Fixed the allocation in the accounts tab of the holding detail dialog caused by floating-point rounding
- Fixed the allocation in the accounts tab of the holding detail dialog by excluding the cash balance of the account
- Fixed the account aggregations in impersonation mode to be based on the impersonated user
- Fixed the base currency of the activities in impersonation mode to be based on the impersonated user
- Fixed the base currency of the dividends in impersonation mode to be based on the impersonated user

122
apps/api/src/app/portfolio/portfolio.service.spec.ts

@ -426,7 +426,10 @@ describe('PortfolioService', () => {
return (
portfolioService as unknown as {
getValueOfAccountsAndPlatforms: (aArgs: object) => Promise<{
accounts: Record<string, { valueInBaseCurrency: number }>;
accounts: Record<
string,
{ quantity?: number; valueInBaseCurrency: number }
>;
platforms: Record<string, { valueInBaseCurrency: number }>;
}>;
}
@ -443,6 +446,10 @@ describe('PortfolioService', () => {
};
beforeEach(() => {
jest
.spyOn(accountService, 'accounts')
.mockResolvedValue([account] as unknown as AccountWithBalance[]);
jest
.spyOn(accountService, 'getAccounts')
.mockResolvedValue([account] as unknown as AccountWithBalance[]);
@ -548,5 +555,118 @@ describe('PortfolioService', () => {
expect(accounts[account.id].valueInBaseCurrency).toBe(100);
expect(platforms[account.platformId].valueInBaseCurrency).toBe(100);
});
it('should aggregate the quantity per account if the activities are filtered by a single holding', async () => {
const { accounts } = await getValueOfAccountsAndPlatforms({
activities: [
{
account,
accountId: account.id,
assetProfile: { symbol: 'AAPL' },
quantity: 0.1,
type: 'BUY'
},
{
account,
accountId: account.id,
assetProfile: { symbol: 'AAPL' },
quantity: 0.2,
type: 'BUY'
}
],
filters: [{ id: 'AAPL', type: 'SYMBOL' }],
portfolioItemsNow: {
AAPL: { marketPriceInBaseCurrency: 10 }
},
userCurrency: 'USD',
userId: userDummyData.id
});
expect(accounts[account.id].quantity).toBe(0.3);
});
it('should not expose a quantity if the activities are not filtered by a single holding', async () => {
const { accounts } = await getValueOfAccountsAndPlatforms({
activities: [
{
account,
accountId: account.id,
assetProfile: { symbol: 'AAPL' },
quantity: 1,
type: 'BUY'
}
],
filters: [],
portfolioItemsNow: {
AAPL: { marketPriceInBaseCurrency: 10 }
},
userCurrency: 'USD',
userId: userDummyData.id
});
expect(accounts[account.id].quantity).toBeUndefined();
});
it('should only consider accounts of the current user if the activities are filtered by a single account', async () => {
const accountsSpy = jest.spyOn(accountService, 'accounts');
await getValueOfAccountsAndPlatforms({
activities: [],
filters: [{ id: account.id, type: 'ACCOUNT' }],
portfolioItemsNow: {},
userCurrency: 'USD',
userId: userDummyData.id
});
expect(accountsSpy).toHaveBeenCalledWith(
expect.objectContaining({
where: { userId: userDummyData.id, id: account.id }
})
);
});
it('should exclude the cash balance if the activities are filtered by a single holding', async () => {
const { accounts, platforms } = await getValueOfAccountsAndPlatforms({
activities: [
{
account,
accountId: account.id,
assetProfile: { symbol: 'AAPL' },
quantity: 1,
type: 'BUY'
}
],
filters: [{ id: 'AAPL', type: 'SYMBOL' }],
portfolioItemsNow: {
AAPL: { marketPriceInBaseCurrency: 10 }
},
userCurrency: 'USD',
userId: userDummyData.id
});
// 1 * 10 (activity), without the balance of 100
expect(accounts[account.id].valueInBaseCurrency).toBe(10);
expect(platforms[account.platformId].valueInBaseCurrency).toBe(10);
});
it('should not accumulate rounding errors of the balances of accounts sharing a platform', async () => {
const platformId = randomUUID();
jest.spyOn(accountService, 'getAccounts').mockResolvedValue([
{ ...account, platformId, balance: 0.1, id: randomUUID() },
{ ...account, platformId, balance: 0.2, id: randomUUID() }
] as unknown as AccountWithBalance[]);
const { platforms } = await getValueOfAccountsAndPlatforms({
activities: [],
filters: [],
portfolioItemsNow: {},
userCurrency: 'USD',
userId: userDummyData.id
});
// 0.1 (balance) + 0.2 (balance)
expect(platforms[platformId].valueInBaseCurrency).toBe(0.3);
});
});
});

77
apps/api/src/app/portfolio/portfolio.service.ts

@ -249,6 +249,10 @@ export class PortfolioService {
}
}
const quantityOfHolding = filterBySymbol
? (details.accounts[account.id]?.quantity ?? 0)
: undefined;
const valueInBaseCurrency =
details.accounts[account.id]?.valueInBaseCurrency ?? 0;
@ -264,6 +268,7 @@ export class PortfolioService {
account.currency,
userCurrency
),
quantity: quantityOfHolding,
value: this.exchangeRateDataService.toCurrency(
valueInBaseCurrency,
userCurrency,
@ -2194,6 +2199,10 @@ export class PortfolioService {
const accounts: PortfolioDetails['accounts'] = {};
const platforms: PortfolioDetails['platforms'] = {};
const { SYMBOL: [filterBySymbol] = [] } = groupBy(filters, ({ type }) => {
return type;
});
let currentAccounts: (AccountWithBalance & {
Order?: Order[];
platform?: Platform;
@ -2205,7 +2214,7 @@ export class PortfolioService {
} else if (filters.length === 1 && filters[0].type === 'ACCOUNT') {
currentAccounts = await this.accountService.accounts({
include: { platform: true, tags: true },
where: { id: filters[0].id }
where: { userId, id: filters[0].id }
});
} else {
const accountIds = Array.from(
@ -2233,39 +2242,43 @@ export class PortfolioService {
// Iterate over the accounts plus a null entry to group activities without
// an account into the unknown bucket
for (const account of [...currentAccounts, null]) {
const currentAccountId = account?.id || UNKNOWN_KEY;
const currentPlatformId = account?.platformId || UNKNOWN_KEY;
const ordersByAccount = activities.filter(({ accountId }) => {
return account ? accountId === account.id : !accountId;
});
if (account) {
accounts[account.id] = {
// The cash balance is not part of a holding and would distort the value
// and thus the allocation per account and platform
const balanceInBaseCurrency = filterBySymbol
? 0
: this.exchangeRateDataService.toCurrency(
account.balance,
account.currency,
userCurrency
);
accounts[currentAccountId] = {
balance: account.balance,
currency: account.currency,
name: account.name,
valueInBaseCurrency: this.exchangeRateDataService.toCurrency(
account.balance,
account.currency,
userCurrency
)
valueInBaseCurrency: balanceInBaseCurrency
};
if (platforms[account.platformId || UNKNOWN_KEY]?.valueInBaseCurrency) {
platforms[account.platformId || UNKNOWN_KEY].valueInBaseCurrency +=
this.exchangeRateDataService.toCurrency(
account.balance,
account.currency,
userCurrency
);
if (platforms[currentPlatformId]) {
platforms[currentPlatformId].valueInBaseCurrency = new Big(
platforms[currentPlatformId].valueInBaseCurrency
)
.plus(balanceInBaseCurrency)
.toNumber();
} else {
platforms[account.platformId || UNKNOWN_KEY] = {
platforms[currentPlatformId] = {
balance: account.balance,
currency: account.currency,
name: account.platform?.name,
valueInBaseCurrency: this.exchangeRateDataService.toCurrency(
account.balance,
account.currency,
userCurrency
)
valueInBaseCurrency: balanceInBaseCurrency
};
}
}
@ -2274,23 +2287,30 @@ export class PortfolioService {
continue;
}
let quantityOfAccount = new Big(0);
let valueOfAccountInBaseCurrency = new Big(0);
for (const { assetProfile, quantity, type } of ordersByAccount) {
const currentQuantityOfSymbol = new Big(quantity).mul(getFactor(type));
quantityOfAccount = quantityOfAccount.plus(currentQuantityOfSymbol);
valueOfAccountInBaseCurrency = valueOfAccountInBaseCurrency.plus(
new Big(quantity)
.mul(getFactor(type))
.mul(
portfolioItemsNow[assetProfile.symbol]
?.marketPriceInBaseCurrency ?? 0
)
currentQuantityOfSymbol.mul(
portfolioItemsNow[assetProfile.symbol]?.marketPriceInBaseCurrency ??
0
)
);
}
const currentAccountId = account?.id || UNKNOWN_KEY;
const currentPlatformId = account?.platformId || UNKNOWN_KEY;
// The quantity is only meaningful if the activities are filtered by a
// single holding
const quantityOfHolding = filterBySymbol
? quantityOfAccount.toNumber()
: undefined;
if (accounts[currentAccountId]) {
accounts[currentAccountId].quantity = quantityOfHolding;
accounts[currentAccountId].valueInBaseCurrency = new Big(
accounts[currentAccountId].valueInBaseCurrency
)
@ -2301,6 +2321,7 @@ export class PortfolioService {
balance: 0,
currency: account?.currency,
name: account?.name,
quantity: quantityOfHolding,
valueInBaseCurrency: valueOfAccountInBaseCurrency.toNumber()
};
}

1
apps/client/src/app/components/holding-detail-dialog/holding-detail-dialog.html

@ -418,6 +418,7 @@
[showAllocationInPercentage]="user?.settings?.isExperimentalFeatures"
[showBalance]="false"
[showFooter]="false"
[showQuantity]="user?.settings?.isExperimentalFeatures"
[showValue]="false"
[showValueInBaseCurrency]="false"
/>

1
libs/common/src/lib/config.ts

@ -113,6 +113,7 @@ export const DEFAULT_REDACTED_PATHS = [
'accounts[*].comment',
'accounts[*].dividendInBaseCurrency',
'accounts[*].interestInBaseCurrency',
'accounts[*].quantity',
'accounts[*].value',
'accounts[*].valueInBaseCurrency',
'activities[*].account.comment',

2
libs/common/src/lib/interfaces/portfolio-details.interface.ts

@ -10,6 +10,8 @@ export interface PortfolioDetails {
balance: number;
currency: string;
name: string;
/** Only set if the activities are filtered by a single holding */
quantity?: number;
valueInBaseCurrency: number;
valueInPercentage?: number;
};

2
libs/common/src/lib/types/account-with-value.type.ts

@ -9,6 +9,8 @@ export type AccountWithValue = AccountWithBalance & {
dividendInBaseCurrency: number;
interestInBaseCurrency: number;
platform?: Platform;
/** Only set if the accounts are filtered by a single holding */
quantity?: number;
tags?: Tag[];
value: number;
valueInBaseCurrency: number;

20
libs/ui/src/lib/accounts-table/accounts-table.component.html

@ -131,6 +131,26 @@
</td>
</ng-container>
<ng-container matColumnDef="quantity">
<th
*matHeaderCellDef
class="justify-content-end px-1"
mat-header-cell
mat-sort-header
>
<ng-container i18n>Quantity</ng-container>
</th>
<td *matCellDef="let element" class="px-1 text-right" mat-cell>
<gf-value
class="d-inline-block justify-content-end"
[isCurrency]="true"
[locale]="locale()"
[value]="element.quantity"
/>
</td>
<td *matFooterCellDef class="px-1" mat-footer-cell></td>
</ng-container>
<ng-container matColumnDef="balance">
<th
*matHeaderCellDef

9
libs/ui/src/lib/accounts-table/accounts-table.component.ts

@ -66,6 +66,7 @@ export class GfAccountsTableComponent {
public readonly showAllocationInPercentage = input<boolean>();
public readonly showBalance = input(true);
public readonly showFooter = input(true);
public readonly showQuantity = input<boolean>();
public readonly showValue = input(true);
public readonly showValueInBaseCurrency = input(true);
public readonly totalBalanceInBaseCurrency = input<number>();
@ -103,6 +104,10 @@ export class GfAccountsTableComponent {
columns.push('activitiesCount');
}
if (this.showQuantity()) {
columns.push('quantity');
}
if (this.showBalance()) {
columns.push('balance');
}
@ -111,7 +116,9 @@ export class GfAccountsTableComponent {
columns.push('value');
}
columns.push('currency');
if (this.showBalance() || this.showValue()) {
columns.push('currency');
}
if (this.showValueInBaseCurrency()) {
columns.push('valueInBaseCurrency');

Loading…
Cancel
Save