Browse Source

Bugfix/value of holdings excluded from analysis in portfolio summary (#7945)

* Fix value of holdings excluded from analysis in portfolio summary

* Update changelog
pull/7935/head^2
Thomas Kaul 2 days ago
committed by GitHub
parent
commit
363ae34bde
No known key found for this signature in database GPG Key ID: B5690EEEBB952194
  1. 4
      CHANGELOG.md
  2. 198
      apps/api/src/app/portfolio/portfolio.service.spec.ts
  3. 119
      apps/api/src/app/portfolio/portfolio.service.ts

4
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

198
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<Activity, 'quantity' | 'type' | 'unitPrice'> & {
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', () => {

119
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,7 +2094,8 @@ export class PortfolioService {
}): Promise<PortfolioSummary> {
const user = await this.userService.user({ id: userId });
const { activities } = await this.activitiesService.getActivities({
const { activities } =
await this.activitiesService.getActivitiesForPortfolioCalculator({
userCurrency,
userId,
withExcludedAccountsAndActivities: true
@ -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) ||

Loading…
Cancel
Save