From 363ae34bded8883a912644b755108c6ebd1c957c Mon Sep 17 00:00:00 2001 From: Thomas Kaul <4159106+dtslvr@users.noreply.github.com> Date: Sat, 26 Sep 2026 09:41:41 +0200 Subject: [PATCH] Bugfix/value of holdings excluded from analysis in portfolio summary (#7945) * Fix value of holdings excluded from analysis in portfolio summary * Update changelog --- CHANGELOG.md | 4 + .../app/portfolio/portfolio.service.spec.ts | 198 +++++++++++++++++- .../src/app/portfolio/portfolio.service.ts | 127 +++++++++-- 3 files changed, 307 insertions(+), 22 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index c0b7e512a0..d69d280038 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -14,6 +14,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Upgraded `bull-board` from version `9.9.0` to `9.10.1` - Upgraded `zod` from version `4.5.4` to `4.6.5` +### Fixed + +- Fixed the value of the holdings excluded from analysis in the portfolio summary + ## 3.72.0 - 2026-09-20 ### Added diff --git a/apps/api/src/app/portfolio/portfolio.service.spec.ts b/apps/api/src/app/portfolio/portfolio.service.spec.ts index 59addfb44e..223a8964c1 100644 --- a/apps/api/src/app/portfolio/portfolio.service.spec.ts +++ b/apps/api/src/app/portfolio/portfolio.service.spec.ts @@ -773,6 +773,27 @@ describe('PortfolioService', () => { ).getSummary(args); }; + function createExcludedActivity({ + currency = 'CHF', + quantity, + symbol, + type, + unitPrice + }: Pick & { + currency?: string; + symbol: string; + }) { + return { + currency, + quantity, + type, + unitPrice, + account: { tags: [{ id: TAG_ID_EXCLUDE_FROM_ANALYSIS }] }, + assetProfile: { currency, symbol, dataSource: DataSource.YAHOO }, + tags: [] + } as unknown as Activity; + } + function createPortfolioCalculator() { return { getDividendInBaseCurrency: jest.fn().mockResolvedValue(new Big(0)), @@ -791,9 +812,13 @@ describe('PortfolioService', () => { beforeEach(() => { jest - .spyOn(activitiesService, 'getActivities') + .spyOn(activitiesService, 'getActivitiesForPortfolioCalculator') .mockResolvedValue({ activities: [], count: 0 }); + jest + .spyOn(exchangeRateDataService, 'toCurrency') + .mockImplementation((aValue) => aValue); + jest.spyOn(portfolioService, 'getPerformance').mockResolvedValue({ performance: { currentValueInBaseCurrency: 3000, @@ -837,6 +862,177 @@ describe('PortfolioService', () => { expect(summary.totalAssetsInBaseCurrency).toBe(3000); expect(summary.totalValueInBaseCurrency).toBe(3000); }); + + it('should value the open holdings of excluded accounts at the current market price', async () => { + jest.spyOn(accountService, 'getCashDetails').mockResolvedValue({ + accounts: [], + balanceInBaseCurrency: 1500 + }); + + jest + .spyOn(activitiesService, 'getActivitiesForPortfolioCalculator') + .mockResolvedValue({ + activities: [ + createExcludedActivity({ + quantity: 10, + symbol: 'AAPL', + type: 'BUY', + unitPrice: 100 + }), + createExcludedActivity({ + quantity: 4, + symbol: 'AAPL', + type: 'SELL', + unitPrice: 150 + }), + createExcludedActivity({ + quantity: 5, + symbol: 'MSFT', + type: 'BUY', + unitPrice: 100 + }), + createExcludedActivity({ + quantity: 5, + symbol: 'MSFT', + type: 'SELL', + unitPrice: 120 + }) + ], + count: 4 + }); + + const getQuotes = jest + .spyOn(dataProviderService, 'getQuotes') + .mockResolvedValue({ + 'YAHOO-AAPL': { + currency: 'CHF', + dataSource: DataSource.YAHOO, + marketPrice: 200, + marketState: 'open' + } + }); + + const summary = await getSummary({ + balanceInBaseCurrency: 1000, + emergencyFundHoldingsValueInBaseCurrency: 0, + filteredValueInBaseCurrency: new Big(3000), + portfolioCalculator: createPortfolioCalculator(), + userCurrency: 'CHF', + userId: userDummyData.id + }); + + // The closed holding (MSFT) does not need a quote + expect(getQuotes).toHaveBeenCalledWith( + expect.objectContaining({ + items: [{ dataSource: DataSource.YAHOO, symbol: 'AAPL' }] + }) + ); + + // 500 (balance of excluded accounts) + 6 * 200 (AAPL) + 0 (MSFT) + expect(summary.excludedAccountsAndActivities).toBe(1700); + expect(summary.totalValueInBaseCurrency).toBe(4700); + }); + + it('should convert the market value of an excluded holding from the currency of the asset profile to the user currency', async () => { + jest.spyOn(accountService, 'getCashDetails').mockResolvedValue({ + accounts: [], + balanceInBaseCurrency: 1000 + }); + + jest + .spyOn(activitiesService, 'getActivitiesForPortfolioCalculator') + .mockResolvedValue({ + activities: [ + createExcludedActivity({ + currency: 'USD', + quantity: 10, + symbol: 'AAPL', + type: 'BUY', + unitPrice: 100 + }), + createExcludedActivity({ + currency: 'USD', + quantity: 4, + symbol: 'AAPL', + type: 'SELL', + unitPrice: 150 + }) + ], + count: 2 + }); + + jest.spyOn(dataProviderService, 'getQuotes').mockResolvedValue({ + 'YAHOO-AAPL': { + currency: 'USD', + dataSource: DataSource.YAHOO, + marketPrice: 200, + marketState: 'open' + } + }); + + jest + .spyOn(exchangeRateDataService, 'toCurrency') + .mockImplementation((aValue, aFromCurrency, aToCurrency) => { + return aFromCurrency === 'USD' && aToCurrency === 'CHF' + ? aValue * 0.8 + : aValue; + }); + + const summary = await getSummary({ + balanceInBaseCurrency: 1000, + emergencyFundHoldingsValueInBaseCurrency: 0, + filteredValueInBaseCurrency: new Big(3000), + portfolioCalculator: createPortfolioCalculator(), + userCurrency: 'CHF', + userId: userDummyData.id + }); + + // 6 * 200 USD (AAPL) * 0.8 (USDCHF) + expect(summary.excludedAccountsAndActivities).toBe(960); + expect(summary.totalValueInBaseCurrency).toBe(3960); + }); + + it('should fall back to the unit price of the latest activity of an excluded holding without a market price', async () => { + jest.spyOn(accountService, 'getCashDetails').mockResolvedValue({ + accounts: [], + balanceInBaseCurrency: 1000 + }); + + jest + .spyOn(activitiesService, 'getActivitiesForPortfolioCalculator') + .mockResolvedValue({ + activities: [ + createExcludedActivity({ + quantity: 10, + symbol: 'AAPL', + type: 'BUY', + unitPrice: 100 + }), + createExcludedActivity({ + quantity: 4, + symbol: 'AAPL', + type: 'SELL', + unitPrice: 150 + }) + ], + count: 2 + }); + + jest.spyOn(dataProviderService, 'getQuotes').mockResolvedValue({}); + + const summary = await getSummary({ + balanceInBaseCurrency: 1000, + emergencyFundHoldingsValueInBaseCurrency: 0, + filteredValueInBaseCurrency: new Big(3000), + portfolioCalculator: createPortfolioCalculator(), + userCurrency: 'CHF', + userId: userDummyData.id + }); + + // 6 * 150 (unit price of the latest activity) + expect(summary.excludedAccountsAndActivities).toBe(900); + expect(summary.totalValueInBaseCurrency).toBe(3900); + }); }); describe('getValueOfAccountsAndPlatforms', () => { diff --git a/apps/api/src/app/portfolio/portfolio.service.ts b/apps/api/src/app/portfolio/portfolio.service.ts index 1476db4ae0..90de5ddc38 100644 --- a/apps/api/src/app/portfolio/portfolio.service.ts +++ b/apps/api/src/app/portfolio/portfolio.service.ts @@ -39,6 +39,7 @@ import { TAG_ID_EXCLUDE_FROM_ANALYSIS, UNKNOWN_KEY } from '@ghostfolio/common/config'; +import { SubscriptionType } from '@ghostfolio/common/enums'; import { DATE_FORMAT, getAssetProfileIdentifier, @@ -2093,11 +2094,12 @@ export class PortfolioService { }): Promise { const user = await this.userService.user({ id: userId }); - const { activities } = await this.activitiesService.getActivities({ - userCurrency, - userId, - withExcludedAccountsAndActivities: true - }); + const { activities } = + await this.activitiesService.getActivitiesForPortfolioCalculator({ + userCurrency, + userId, + withExcludedAccountsAndActivities: true + }); const excludedActivities: Activity[] = []; const nonExcludedActivities: Activity[] = []; @@ -2164,31 +2166,28 @@ export class PortfolioService { .plus(emergencyFundHoldingsValueInBaseCurrency) .toNumber(); - const totalOfExcludedActivities = this.getSumOfActivityType({ - userCurrency, - activities: excludedActivities, - activityType: 'BUY' - }).minus( - this.getSumOfActivityType({ - userCurrency, - activities: excludedActivities, - activityType: 'SELL' - }) - ); - - const cashDetailsWithExcludedAccounts = - await this.accountService.getCashDetails({ + const [ + cashDetailsWithExcludedAccounts, + valueOfExcludedActivitiesInBaseCurrency + ] = await Promise.all([ + this.accountService.getCashDetails({ userId, currency: userCurrency, withExcludedAccounts: true - }); + }), + this.getValueOfExcludedActivitiesInBaseCurrency({ + userCurrency, + activities: excludedActivities, + subscriptionType: user.subscription?.type + }) + ]); const excludedBalanceInBaseCurrency = new Big( cashDetailsWithExcludedAccounts.balanceInBaseCurrency ).minus(balanceInBaseCurrency); const excludedAccountsAndActivities = excludedBalanceInBaseCurrency - .plus(totalOfExcludedActivities) + .plus(valueOfExcludedActivitiesInBaseCurrency) .toNumber(); // Exclude emergency fund from the financial independence calculation @@ -2491,6 +2490,92 @@ export class PortfolioService { return { accounts, platforms }; } + private async getValueOfExcludedActivitiesInBaseCurrency({ + activities, + subscriptionType, + userCurrency + }: { + activities: Activity[]; + subscriptionType?: SubscriptionType; + userCurrency: string; + }) { + const holdings: { + [assetProfileIdentifier: string]: { + latestActivity: Activity; + quantity: Big; + }; + } = {}; + + // The activities are sorted by date in ascending order + for (const activity of activities) { + const factor = getFactor(activity.type); + + if (factor === 0 || isDraftActivity(activity)) { + continue; + } + + const assetProfileIdentifier = getAssetProfileIdentifier( + activity.assetProfile + ); + + const quantity = holdings[assetProfileIdentifier]?.quantity ?? new Big(0); + + holdings[assetProfileIdentifier] = { + latestActivity: activity, + quantity: quantity.plus(new Big(activity.quantity).mul(factor)) + }; + } + + const openHoldings = Object.values(holdings).filter(({ quantity }) => { + return !quantity.eq(0); + }); + + const quotes = + openHoldings.length > 0 + ? await this.dataProviderService.getQuotes({ + subscriptionType, + items: openHoldings.map( + ({ + latestActivity: { + assetProfile: { dataSource, symbol } + } + }) => { + return { dataSource, symbol }; + } + ) + }) + : {}; + + return getSum( + openHoldings.map(({ latestActivity, quantity }) => { + const { assetProfile, currency, unitPrice } = latestActivity; + + const marketPrice = + quotes[getAssetProfileIdentifier(assetProfile)]?.marketPrice; + + if (!marketPrice) { + // Fall back to the unit price of the latest activity without a + // market price + return new Big( + this.exchangeRateDataService.toCurrency( + quantity.mul(unitPrice).toNumber(), + currency ?? assetProfile.currency, + userCurrency + ) + ); + } + + return new Big( + this.exchangeRateDataService.toCurrency( + quantity.mul(marketPrice).toNumber(), + assetProfile.currency, + userCurrency + ) + ); + }) + ); + } + private isExcludedFromAnalysis(activity: Activity) { return ( isAccountExcluded(activity.account) ||