From 549d71c6932def0c399dde607fc09ca6107c8952 Mon Sep 17 00:00:00 2001 From: Daniel Date: Mon, 27 Jul 2026 10:01:21 +0200 Subject: [PATCH] Fix portfolio current value responses --- .../src/app/portfolio/portfolio.controller.ts | 4 +- .../app/portfolio/portfolio.service.spec.ts | 125 +++++++++++++++++- .../src/app/portfolio/portfolio.service.ts | 24 +++- .../portfolio-holdings-response.interface.ts | 1 + 4 files changed, 144 insertions(+), 10 deletions(-) diff --git a/apps/api/src/app/portfolio/portfolio.controller.ts b/apps/api/src/app/portfolio/portfolio.controller.ts index 175532cadc..cd9209dc9d 100644 --- a/apps/api/src/app/portfolio/portfolio.controller.ts +++ b/apps/api/src/app/portfolio/portfolio.controller.ts @@ -446,14 +446,12 @@ export class PortfolioController { filterByTags }); - const holdings = await this.portfolioService.getHoldings({ + return this.portfolioService.getHoldings({ dateRange, filters, impersonationId, userId: this.request.user.id }); - - return { holdings }; } @Get('investments') diff --git a/apps/api/src/app/portfolio/portfolio.service.spec.ts b/apps/api/src/app/portfolio/portfolio.service.spec.ts index c16590cceb..92d1873b71 100644 --- a/apps/api/src/app/portfolio/portfolio.service.spec.ts +++ b/apps/api/src/app/portfolio/portfolio.service.spec.ts @@ -1,3 +1,4 @@ +import { AccountBalanceService } from '@ghostfolio/api/app/account-balance/account-balance.service'; import { AccountService } from '@ghostfolio/api/app/account/account.service'; import { CashDetails } from '@ghostfolio/api/app/account/interfaces/cash-details.interface'; import { ActivitiesService } from '@ghostfolio/api/app/activities/activities.service'; @@ -20,6 +21,7 @@ import { randomUUID } from 'node:crypto'; import { PortfolioService } from './portfolio.service'; describe('PortfolioService', () => { + let accountBalanceService: AccountBalanceService; let accountService: AccountService; let activitiesService: ActivitiesService; let configurationService: ConfigurationService; @@ -50,6 +52,12 @@ describe('PortfolioService', () => { null ); + accountBalanceService = new AccountBalanceService( + null, + exchangeRateDataService, + null + ); + accountService = new AccountService( null, null, @@ -96,7 +104,7 @@ describe('PortfolioService', () => { ); portfolioService = new PortfolioService( - null, + accountBalanceService, accountService, activitiesService, null, @@ -337,6 +345,121 @@ describe('PortfolioService', () => { }); }); + describe('getHoldings', () => { + it('should return totalValueInBaseCurrency as the sum of returned holdings', async () => { + jest + .spyOn(impersonationService, 'validateImpersonationId') + .mockResolvedValue(null); + + jest.spyOn(portfolioService, 'getDetails').mockResolvedValue({ + accounts: {}, + createdAt: parseDate('2024-01-01'), + hasErrors: false, + holdings: { + AMZN: { + assetProfile: { name: 'Amazon.com, Inc.', symbol: 'AMZN' }, + valueInBaseCurrency: 5298.2 + }, + MSFT: { + assetProfile: { name: 'Microsoft Corporation', symbol: 'MSFT' }, + valueInBaseCurrency: 1700.15 + } + }, + platforms: {} + } as unknown as Awaited>); + + const response = await portfolioService.getHoldings({ + dateRange: 'max', + filters: [], + impersonationId: userDummyData.id, + userId: userDummyData.id + }); + + expect(response.holdings).toHaveLength(2); + expect(response.totalValueInBaseCurrency).toBeCloseTo(6998.35); + }); + }); + + describe('getPerformance', () => { + it('should use the snapshot currentValueInBaseCurrency for the current performance value', async () => { + jest + .spyOn(accountBalanceService, 'getAccountBalanceItems') + .mockResolvedValue([ + { + date: '2024-01-01', + value: 0 + } + ]); + + jest + .spyOn(activitiesService, 'getActivitiesForPortfolioCalculator') + .mockResolvedValue({ activities: [], count: 0 }); + + jest + .spyOn(impersonationService, 'validateImpersonationId') + .mockResolvedValue(null); + + jest.spyOn(userService, 'user').mockResolvedValue({ + accessesGet: [], + accounts: [], + activityCount: 0, + dataProviderGhostfolioDailyRequests: 0, + id: userDummyData.id, + settings: { + settings: { + baseCurrency: 'USD' + } + } + } as unknown as Awaited>); + + jest + .spyOn(portfolioCalculatorFactory, 'createCalculator') + .mockReturnValue({ + getPerformance: jest.fn().mockResolvedValue({ + chart: [ + { + date: '2024-01-01', + netPerformance: 0, + netPerformanceInPercentage: 0, + netPerformanceInPercentageWithCurrencyEffect: 0, + netPerformanceWithCurrencyEffect: 0, + netWorth: 0, + totalInvestment: 0, + totalInvestmentValueWithCurrencyEffect: 0, + valueWithCurrencyEffect: 0 + } + ] + }), + getSnapshot: jest.fn().mockResolvedValue({ + createdAt: parseDate('2024-01-01'), + currentValueInBaseCurrency: new Big(231216.02329207), + errors: [], + hasErrors: false, + historicalData: [{ date: '2024-01-01' }], + positions: [], + totalFeesWithCurrencyEffect: new Big(0), + totalInterestWithCurrencyEffect: new Big(0), + totalInvestment: new Big(0), + totalInvestmentWithCurrencyEffect: new Big(0), + totalLiabilitiesWithCurrencyEffect: new Big(0) + }) + } as unknown as ReturnType< + typeof portfolioCalculatorFactory.createCalculator + >); + + const response = await portfolioService.getPerformance({ + dateRange: 'max', + filters: [], + impersonationId: userDummyData.id, + userId: userDummyData.id + }); + + expect(response.performance.currentValueInBaseCurrency).toBe( + 231216.02329207 + ); + }); + }); + describe('getValueOfAccountsAndPlatforms', () => { const getValueOfAccountsAndPlatforms = (args: object) => { return ( diff --git a/apps/api/src/app/portfolio/portfolio.service.ts b/apps/api/src/app/portfolio/portfolio.service.ts index 6c527ed2b0..fb84c38ce6 100644 --- a/apps/api/src/app/portfolio/portfolio.service.ts +++ b/apps/api/src/app/portfolio/portfolio.service.ts @@ -54,6 +54,7 @@ import { InvestmentItem, PortfolioDetails, PortfolioHoldingResponse, + PortfolioHoldingsResponse, PortfolioInvestmentsResponse, PortfolioPerformanceResponse, PortfolioPosition, @@ -367,7 +368,7 @@ export class PortfolioService { filters?: Filter[]; impersonationId: string; userId: string; - }) { + }): Promise { userId = await this.getUserId(impersonationId, userId); const { holdings: holdingsMap } = await this.getDetails({ dateRange, @@ -396,7 +397,14 @@ export class PortfolioService { }); } - return holdings; + return { + holdings, + totalValueInBaseCurrency: getSum( + holdings.map(({ valueInBaseCurrency }) => { + return new Big(valueInBaseCurrency ?? 0); + }) + ).toNumber() + }; } public async getInvestments({ @@ -1059,7 +1067,12 @@ export class PortfolioService { currency: userCurrency }); - const { errors, hasErrors, historicalData } = + const { + currentValueInBaseCurrency, + errors, + hasErrors, + historicalData + } = await portfolioCalculator.getSnapshot(); const { endDate, startDate } = getIntervalFromDateRange({ dateRange }); @@ -1077,7 +1090,6 @@ export class PortfolioService { netWorth, totalInvestment, totalInvestmentValueWithCurrencyEffect, - valueWithCurrencyEffect } = chart?.at(-1) ?? { netPerformance: 0, netPerformanceInPercentage: 0, @@ -1085,7 +1097,7 @@ export class PortfolioService { netPerformanceWithCurrencyEffect: 0, netWorth: 0, totalInvestment: 0, - valueWithCurrencyEffect: 0 + totalInvestmentValueWithCurrencyEffect: 0 }; return { @@ -1100,7 +1112,7 @@ export class PortfolioService { totalInvestment, totalInvestmentValueWithCurrencyEffect, currentNetWorth: netWorth, - currentValueInBaseCurrency: valueWithCurrencyEffect, + currentValueInBaseCurrency: currentValueInBaseCurrency.toNumber(), netPerformancePercentage: netPerformanceInPercentage, netPerformancePercentageWithCurrencyEffect: netPerformanceInPercentageWithCurrencyEffect diff --git a/libs/common/src/lib/interfaces/responses/portfolio-holdings-response.interface.ts b/libs/common/src/lib/interfaces/responses/portfolio-holdings-response.interface.ts index d2cf38f553..c15f36d934 100644 --- a/libs/common/src/lib/interfaces/responses/portfolio-holdings-response.interface.ts +++ b/libs/common/src/lib/interfaces/responses/portfolio-holdings-response.interface.ts @@ -2,4 +2,5 @@ import { PortfolioPosition } from '@ghostfolio/common/interfaces'; export interface PortfolioHoldingsResponse { holdings: PortfolioPosition[]; + totalValueInBaseCurrency: number; }