From 2ab3532744d6e4e111b214dd2ce78f49134c1954 Mon Sep 17 00:00:00 2001 From: Thomas Kaul <4159106+dtslvr@users.noreply.github.com> Date: Sat, 10 Oct 2026 17:26:11 +0200 Subject: [PATCH] Fix value of accounts filtered by asset class --- .../src/app/endpoints/mcp/mcp.controller.ts | 2 +- .../app/portfolio/portfolio.service.spec.ts | 83 ++++++++++++++++++- .../src/app/portfolio/portfolio.service.ts | 31 ++++--- 3 files changed, 104 insertions(+), 12 deletions(-) diff --git a/apps/api/src/app/endpoints/mcp/mcp.controller.ts b/apps/api/src/app/endpoints/mcp/mcp.controller.ts index c6ec55d2d5..66e1ff7098 100644 --- a/apps/api/src/app/endpoints/mcp/mcp.controller.ts +++ b/apps/api/src/app/endpoints/mcp/mcp.controller.ts @@ -38,7 +38,7 @@ export class GhostfolioMcpController { ', ' )}. If the access reads the monetary values, these columns are given in addition: ${PortfolioTableService.getAccountsTableValueColumnNames().join( ', ' - )}. The allocation in percentage is relative to the accounts of the result, hence the parameters change it. The parameters change the value in base currency as well. With the holding parameter, it is the value of the holding in the account without the cash balance. Without the holding parameter, it includes the full cash balance of the account, also with the assetClasses parameter. The balance is always the full cash balance of the account in the currency of the account.`, + )}. The allocation in percentage is relative to the accounts of the result, hence the parameters change it. The parameters change the value in base currency as well. With the holding parameter or with asset classes which do not include LIQUIDITY, it is the value of the matching holdings in the account without the cash balance. Otherwise, it includes the full cash balance of the account. The balance is always the full cash balance of the account in the currency of the account.`, name: 'get-accounts', parameters: GET_ACCOUNTS_PARAMETERS }) diff --git a/apps/api/src/app/portfolio/portfolio.service.spec.ts b/apps/api/src/app/portfolio/portfolio.service.spec.ts index c408f51934..ac5dfebd49 100644 --- a/apps/api/src/app/portfolio/portfolio.service.spec.ts +++ b/apps/api/src/app/portfolio/portfolio.service.spec.ts @@ -1454,7 +1454,11 @@ describe('PortfolioService', () => { getValueOfAccountsAndPlatforms: (aArgs: object) => Promise<{ accounts: Record< string, - { quantity?: number; valueInBaseCurrency: number } + { + balance: number; + quantity?: number; + valueInBaseCurrency: number; + } >; platforms: Record; }>; @@ -1675,6 +1679,83 @@ describe('PortfolioService', () => { expect(platforms[account.platformId].valueInBaseCurrency).toBe(10); }); + it('should exclude the cash balance if the activities are filtered by an asset class other than liquidity', async () => { + const { accounts, platforms } = await getValueOfAccountsAndPlatforms({ + activities: [ + { + account, + accountId: account.id, + assetProfile: { dataSource: DataSource.YAHOO, symbol: 'AAPL' }, + quantity: 1, + type: 'BUY' + } + ], + filters: [{ id: AssetClass.EQUITY, type: 'ASSET_CLASS' }], + portfolioItemsNow: { + 'YAHOO-AAPL': { marketPriceInBaseCurrency: 10 } + }, + userCurrency: 'USD', + userId: userDummyData.id + }); + + expect(accounts[account.id].balance).toBe(100); + + // 1 * 10 (activity), without the balance of 100 + expect(accounts[account.id].valueInBaseCurrency).toBe(10); + expect(platforms[account.platformId].valueInBaseCurrency).toBe(10); + }); + + it('should include the cash balance if the activities are filtered by asset classes including liquidity', async () => { + const { accounts, platforms } = await getValueOfAccountsAndPlatforms({ + activities: [ + { + account, + accountId: account.id, + assetProfile: { dataSource: DataSource.YAHOO, symbol: 'AAPL' }, + quantity: 1, + type: 'BUY' + } + ], + filters: [ + { id: AssetClass.EQUITY, type: 'ASSET_CLASS' }, + { id: AssetClass.LIQUIDITY, type: 'ASSET_CLASS' } + ], + portfolioItemsNow: { + 'YAHOO-AAPL': { marketPriceInBaseCurrency: 10 } + }, + userCurrency: 'USD', + userId: userDummyData.id + }); + + // 100 (balance) + 1 * 10 (activity) + expect(accounts[account.id].valueInBaseCurrency).toBe(110); + expect(platforms[account.platformId].valueInBaseCurrency).toBe(110); + }); + + it('should include the cash balance if the activities are filtered by a tag', async () => { + const { accounts, platforms } = await getValueOfAccountsAndPlatforms({ + activities: [ + { + account, + accountId: account.id, + assetProfile: { dataSource: DataSource.YAHOO, symbol: 'AAPL' }, + quantity: 1, + type: 'BUY' + } + ], + filters: [{ id: randomUUID(), type: 'TAG' }], + portfolioItemsNow: { + 'YAHOO-AAPL': { marketPriceInBaseCurrency: 10 } + }, + userCurrency: 'USD', + userId: userDummyData.id + }); + + // 100 (balance) + 1 * 10 (activity) + expect(accounts[account.id].valueInBaseCurrency).toBe(110); + expect(platforms[account.platformId].valueInBaseCurrency).toBe(110); + }); + it('should not accumulate rounding errors of the balances of accounts sharing a platform', async () => { const platformId = randomUUID(); diff --git a/apps/api/src/app/portfolio/portfolio.service.ts b/apps/api/src/app/portfolio/portfolio.service.ts index ab2c0de2d4..48f0886481 100644 --- a/apps/api/src/app/portfolio/portfolio.service.ts +++ b/apps/api/src/app/portfolio/portfolio.service.ts @@ -2434,10 +2434,19 @@ export class PortfolioService { const accounts: PortfolioDetails['accounts'] = {}; const platforms: PortfolioDetails['platforms'] = {}; - const { SYMBOL: [filterBySymbol] = [] } = groupBy(filters, ({ type }) => { + const { + ASSET_CLASS: filtersByAssetClass = [], + SYMBOL: [filterBySymbol] = [] + } = groupBy(filters, ({ type }) => { return type; }); + const isFilteredByAssetClassOtherThanLiquidity = + filtersByAssetClass.length > 0 && + !filtersByAssetClass.some(({ id }) => { + return id === AssetClass.LIQUIDITY; + }); + let currentAccounts: (AccountWithBalance & { Order?: Order[]; platform?: Platform; @@ -2485,15 +2494,17 @@ export class PortfolioService { }); if (account) { - // 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 - ); + // The cash balance is not part of a holding or of an asset class other + // than liquidity and would distort the value and thus the allocation + // per account and platform + const balanceInBaseCurrency = + filterBySymbol || isFilteredByAssetClassOtherThanLiquidity + ? 0 + : this.exchangeRateDataService.toCurrency( + account.balance, + account.currency, + userCurrency + ); accounts[currentAccountId] = { balance: account.balance,