From 7e0d39ac6e608e66f13454e53e1b04095a01e740 Mon Sep 17 00:00:00 2001 From: Thomas Kaul <4159106+dtslvr@users.noreply.github.com> Date: Sat, 10 Oct 2026 10:20:03 +0200 Subject: [PATCH] Feature/group by year in portfolio performance endpoint (#8094) * Add groupBy=year --- CHANGELOG.md | 6 + .../src/app/portfolio/get-performance.dto.ts | 7 +- .../src/app/portfolio/portfolio.controller.ts | 2 + .../app/portfolio/portfolio.service.spec.ts | 331 +++++++++++++++++- .../src/app/portfolio/portfolio.service.ts | 84 ++++- 5 files changed, 421 insertions(+), 9 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index d1e70a4b8d..04fb7bb3d6 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,12 @@ All notable changes to this project will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +## Unreleased + +### Added + +- Extended the `GET api/v2/portfolio/performance` endpoint by the `groupBy` query parameter (`year`) + ## 3.82.0 - 2026-10-09 ### Added diff --git a/apps/api/src/app/portfolio/get-performance.dto.ts b/apps/api/src/app/portfolio/get-performance.dto.ts index 5992c2a092..fa8537dbd4 100644 --- a/apps/api/src/app/portfolio/get-performance.dto.ts +++ b/apps/api/src/app/portfolio/get-performance.dto.ts @@ -1,9 +1,14 @@ import { DateRangeFilterDto } from '@ghostfolio/api/dtos/date-range-filter.dto'; +import { GroupBy } from '@ghostfolio/common/types'; import { Transform, TransformFnParams } from 'class-transformer'; -import { IsBoolean } from 'class-validator'; +import { IsBoolean, IsIn, IsOptional } from 'class-validator'; export class GetPerformanceDto extends DateRangeFilterDto { + @IsIn(['year'] as GroupBy[]) + @IsOptional() + groupBy?: Extract; + @IsBoolean() @Transform(({ value }: TransformFnParams) => { return value === 'true'; diff --git a/apps/api/src/app/portfolio/portfolio.controller.ts b/apps/api/src/app/portfolio/portfolio.controller.ts index 588bb6df17..109b5cca59 100644 --- a/apps/api/src/app/portfolio/portfolio.controller.ts +++ b/apps/api/src/app/portfolio/portfolio.controller.ts @@ -495,6 +495,7 @@ export class PortfolioController { accounts, assetClasses, dataSource, + groupBy, range, symbol, tags, @@ -511,6 +512,7 @@ export class PortfolioController { const performanceInformation = await this.portfolioService.getPerformance({ filters, + groupBy, userId, withExcludedAccounts, dateRange: range diff --git a/apps/api/src/app/portfolio/portfolio.service.spec.ts b/apps/api/src/app/portfolio/portfolio.service.spec.ts index c1dcbf8f53..f562966b06 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'; @@ -15,7 +16,7 @@ import { TAG_ID_EXCLUDE_FROM_ANALYSIS, UNKNOWN_KEY } from '@ghostfolio/common/config'; -import { parseDate } from '@ghostfolio/common/helper'; +import { parseDate, resetHours } from '@ghostfolio/common/helper'; import { Activity, AssetProfileIdentifier, @@ -26,11 +27,13 @@ import { AccountWithBalance } from '@ghostfolio/common/types'; import { AssetClass, DataSource, Prisma } from '@prisma/client'; import { Big } from 'big.js'; +import { endOfDay } from 'date-fns'; import { randomUUID } from 'node:crypto'; import { PortfolioService } from './portfolio.service'; describe('PortfolioService', () => { + let accountBalanceService: AccountBalanceService; let accountService: AccountService; let activitiesService: ActivitiesService; let configurationService: ConfigurationService; @@ -61,6 +64,12 @@ describe('PortfolioService', () => { null ); + accountBalanceService = new AccountBalanceService( + null, + exchangeRateDataService, + null + ); + accountService = new AccountService( null, null, @@ -113,7 +122,7 @@ describe('PortfolioService', () => { ); portfolioService = new PortfolioService( - null, + accountBalanceService, accountService, activitiesService, null, @@ -833,6 +842,324 @@ describe('PortfolioService', () => { }); }); + describe('getPerformance', () => { + let getPerformanceOfCalculator: jest.Mock; + + beforeEach(() => { + jest.useFakeTimers().setSystemTime(parseDate('2025-06-15').getTime()); + + getPerformanceOfCalculator = jest.fn(); + + jest + .spyOn(accountBalanceService, 'getAccountBalanceItems') + .mockResolvedValue([]); + + jest + .spyOn(activitiesService, 'getActivitiesForPortfolioCalculator') + .mockResolvedValue({ activities: [{} as Activity], count: 1 }); + + jest + .spyOn(portfolioCalculatorFactory, 'createCalculator') + .mockReturnValue({ + getPerformance: getPerformanceOfCalculator, + getSnapshot: jest.fn().mockResolvedValue({ + errors: [], + hasErrors: false, + historicalData: [{ date: '2023-03-01' }] + }) + } as unknown as PortfolioCalculator); + + jest.spyOn(userService, 'user').mockResolvedValue({ + id: userDummyData.id, + settings: { + settings: { + baseCurrency: 'CHF' + } + } + } as unknown as Awaited>); + }); + + afterEach(() => { + jest.useRealTimers(); + }); + + it('should return the chart of the date range without a group', async () => { + getPerformanceOfCalculator.mockResolvedValueOnce({ + chart: [ + { date: '2023-03-01', netPerformance: 0 }, + { date: '2025-06-15', netPerformance: 600 } + ] + }); + + const { chart, dateOfFirstActivity, performance } = + await portfolioService.getPerformance({ + dateRange: 'max', + userId: userDummyData.id + }); + + expect(getPerformanceOfCalculator).toHaveBeenCalledTimes(1); + expect(getPerformanceOfCalculator).toHaveBeenCalledWith({ + end: endOfDay(parseDate('2025-06-15')), + start: new Date(0) + }); + expect(chart).toEqual([ + { date: '2023-03-01', netPerformance: 0 }, + { date: '2025-06-15', netPerformance: 600 } + ]); + expect(dateOfFirstActivity).toEqual(parseDate('2023-03-01')); + expect(performance.netPerformance).toBe(600); + }); + + it('should return one chart item per calendar year since the first activity when grouped by year', async () => { + getPerformanceOfCalculator + .mockResolvedValueOnce({ + chart: [ + { date: '2023-03-01', netPerformance: 0 }, + { date: '2023-12-31', netPerformance: 100 }, + { date: '2024-12-31', netPerformance: 300 }, + { date: '2025-06-15', netPerformance: 600 } + ] + }) + .mockResolvedValueOnce({ + chart: [ + { date: '2023-03-01', netPerformance: 0 }, + { date: '2023-12-31', netPerformance: 100 } + ] + }) + .mockResolvedValueOnce({ + chart: [ + { date: '2023-12-31', netPerformance: 0 }, + { date: '2024-12-31', netPerformance: 200 } + ] + }) + .mockResolvedValueOnce({ + chart: [ + { date: '2024-12-31', netPerformance: 0 }, + { date: '2025-06-15', netPerformance: 300 } + ] + }); + + const { chart, performance } = await portfolioService.getPerformance({ + dateRange: 'max', + groupBy: 'year', + userId: userDummyData.id + }); + + expect(getPerformanceOfCalculator).toHaveBeenCalledTimes(4); + expect(getPerformanceOfCalculator).toHaveBeenNthCalledWith(2, { + end: endOfDay(parseDate('2023-12-31')), + start: new Date(0) + }); + expect(getPerformanceOfCalculator).toHaveBeenNthCalledWith(3, { + end: endOfDay(parseDate('2024-12-31')), + start: endOfDay(parseDate('2023-12-31')) + }); + expect(getPerformanceOfCalculator).toHaveBeenNthCalledWith(4, { + end: endOfDay(parseDate('2025-06-15')), + start: endOfDay(parseDate('2024-12-31')) + }); + expect(chart).toEqual([ + { + date: '2023-01-01', + investmentValueWithCurrencyEffect: 0, + netPerformance: 100 + }, + { + date: '2024-01-01', + investmentValueWithCurrencyEffect: 0, + netPerformance: 200 + }, + { + date: '2025-01-01', + investmentValueWithCurrencyEffect: 0, + netPerformance: 300 + } + ]); + expect(performance.netPerformance).toBe(600); + }); + + it('should return the same chart item for a calendar year as for its date range when grouped by year', async () => { + getPerformanceOfCalculator + .mockResolvedValueOnce({ + chart: [ + { date: '2023-12-31', netPerformance: 0 }, + { date: '2024-12-31', netPerformance: 200 } + ] + }) + .mockResolvedValueOnce({ + chart: [ + { date: '2023-12-31', netPerformance: 0 }, + { date: '2024-12-31', netPerformance: 200 } + ] + }); + + const { chart, performance } = await portfolioService.getPerformance({ + dateRange: '2024', + groupBy: 'year', + userId: userDummyData.id + }); + + expect(getPerformanceOfCalculator).toHaveBeenCalledTimes(2); + expect(getPerformanceOfCalculator).toHaveBeenNthCalledWith( + 2, + getPerformanceOfCalculator.mock.calls[0][0] + ); + expect(chart).toEqual([ + { + date: '2024-01-01', + investmentValueWithCurrencyEffect: 0, + netPerformance: 200 + } + ]); + expect(performance.netPerformance).toBe(200); + }); + + it('should clip the first calendar year to the date range when grouped by year', async () => { + getPerformanceOfCalculator + .mockResolvedValueOnce({ + chart: [ + { date: '2024-06-15', netPerformance: 0 }, + { date: '2024-12-31', netPerformance: 100 }, + { date: '2025-06-15', netPerformance: 300 } + ] + }) + .mockResolvedValueOnce({ + chart: [ + { date: '2024-06-15', netPerformance: 0 }, + { date: '2024-12-31', netPerformance: 100 } + ] + }) + .mockResolvedValueOnce({ + chart: [ + { date: '2024-12-31', netPerformance: 0 }, + { date: '2025-06-15', netPerformance: 200 } + ] + }); + + const { chart, performance } = await portfolioService.getPerformance({ + dateRange: '1y', + groupBy: 'year', + userId: userDummyData.id + }); + + expect(getPerformanceOfCalculator).toHaveBeenCalledTimes(3); + expect(getPerformanceOfCalculator).toHaveBeenNthCalledWith(2, { + end: endOfDay(parseDate('2024-12-31')), + start: resetHours(parseDate('2024-06-15')) + }); + expect(getPerformanceOfCalculator).toHaveBeenNthCalledWith(3, { + end: endOfDay(parseDate('2025-06-15')), + start: endOfDay(parseDate('2024-12-31')) + }); + expect(chart).toEqual([ + { + date: '2024-01-01', + investmentValueWithCurrencyEffect: 0, + netPerformance: 100 + }, + { + date: '2025-01-01', + investmentValueWithCurrencyEffect: 0, + netPerformance: 200 + } + ]); + expect(performance.netPerformance).toBe(300); + }); + + it('should start the first calendar year at the start date of the date range on 1 January when grouped by year', async () => { + jest.setSystemTime(parseDate('2026-01-01').getTime()); + + getPerformanceOfCalculator + .mockResolvedValueOnce({ + chart: [ + { date: '2025-01-01', netPerformance: 0 }, + { date: '2025-12-31', netPerformance: 100 }, + { date: '2026-01-01', netPerformance: 150 } + ] + }) + .mockResolvedValueOnce({ + chart: [ + { date: '2025-01-01', netPerformance: 0 }, + { date: '2025-12-31', netPerformance: 100 } + ] + }) + .mockResolvedValueOnce({ + chart: [ + { date: '2025-12-31', netPerformance: 0 }, + { date: '2026-01-01', netPerformance: 50 } + ] + }); + + const { chart } = await portfolioService.getPerformance({ + dateRange: '1y', + groupBy: 'year', + userId: userDummyData.id + }); + + expect(getPerformanceOfCalculator).toHaveBeenCalledTimes(3); + expect(getPerformanceOfCalculator).toHaveBeenNthCalledWith(1, { + end: endOfDay(parseDate('2026-01-01')), + start: resetHours(parseDate('2025-01-01')) + }); + expect(getPerformanceOfCalculator).toHaveBeenNthCalledWith(2, { + end: endOfDay(parseDate('2025-12-31')), + start: resetHours(parseDate('2025-01-01')) + }); + expect(getPerformanceOfCalculator).toHaveBeenNthCalledWith(3, { + end: endOfDay(parseDate('2026-01-01')), + start: endOfDay(parseDate('2025-12-31')) + }); + expect(chart).toEqual([ + { + date: '2025-01-01', + investmentValueWithCurrencyEffect: 0, + netPerformance: 100 + }, + { + date: '2026-01-01', + investmentValueWithCurrencyEffect: 0, + netPerformance: 50 + } + ]); + }); + + it('should add up the investment of each calendar year when grouped by year', async () => { + getPerformanceOfCalculator + .mockResolvedValueOnce({ + chart: [ + { date: '2023-12-31', investmentValueWithCurrencyEffect: 0 }, + { date: '2024-03-01', investmentValueWithCurrencyEffect: 1000 }, + { date: '2024-12-31', investmentValueWithCurrencyEffect: 300 }, + { date: '2025-06-15', investmentValueWithCurrencyEffect: 200 } + ] + }) + .mockResolvedValueOnce({ + chart: [ + { date: '2023-12-31', investmentValueWithCurrencyEffect: 0 }, + { date: '2024-03-01', investmentValueWithCurrencyEffect: 1000 }, + { date: '2024-12-31', investmentValueWithCurrencyEffect: 300 } + ] + }) + .mockResolvedValueOnce({ + chart: [ + { date: '2024-12-31', investmentValueWithCurrencyEffect: 300 }, + { date: '2025-06-15', investmentValueWithCurrencyEffect: 200 } + ] + }); + + const { chart } = await portfolioService.getPerformance({ + dateRange: 'max', + groupBy: 'year', + userId: userDummyData.id + }); + + expect(chart).toEqual([ + { date: '2024-01-01', investmentValueWithCurrencyEffect: 1300 }, + { date: '2025-01-01', investmentValueWithCurrencyEffect: 200 } + ]); + }); + }); + describe('getSummary', () => { const getSummary = (args: object) => { return ( diff --git a/apps/api/src/app/portfolio/portfolio.service.ts b/apps/api/src/app/portfolio/portfolio.service.ts index 00de213d08..8aea5ef0db 100644 --- a/apps/api/src/app/portfolio/portfolio.service.ts +++ b/apps/api/src/app/portfolio/portfolio.service.ts @@ -100,11 +100,12 @@ import { isBefore, isSameMonth, isSameYear, + min, parseISO, set, startOfDay } from 'date-fns'; -import { groupBy } from 'lodash-es'; +import { groupBy, uniq } from 'lodash-es'; import { PortfolioCalculator } from './calculator/portfolio-calculator'; import { PortfolioCalculatorFactory } from './calculator/portfolio-calculator.factory'; @@ -1194,10 +1195,12 @@ export class PortfolioService { public async getPerformance({ dateRange = DEFAULT_DATE_RANGE, filters, + groupBy, userId }: { dateRange?: DateRange; filters?: Filter[]; + groupBy?: Extract; userId: string; withExcludedAccounts?: boolean; }): Promise { @@ -1252,10 +1255,11 @@ export class PortfolioService { const { endDate, startDate } = getIntervalFromDateRange({ dateRange }); - const { chart } = await portfolioCalculator.getPerformance({ - end: endDate, - start: startDate - }); + const { chart: chartOfDateRange } = + await portfolioCalculator.getPerformance({ + end: endDate, + start: startDate + }); const { dividendInBaseCurrency, @@ -1268,7 +1272,7 @@ export class PortfolioService { totalInvestment, totalInvestmentValueWithCurrencyEffect, valueWithCurrencyEffect - } = chart?.at(-1) ?? { + } = chartOfDateRange?.at(-1) ?? { dividendInBaseCurrency: 0, dividendInPercentageWithCurrencyEffect: 0, netPerformance: 0, @@ -1280,6 +1284,16 @@ export class PortfolioService { valueWithCurrencyEffect: 0 }; + const chart = + groupBy === 'year' + ? await this.getPerformanceByYear({ + chartOfDateRange, + endDate, + portfolioCalculator, + startDate + }) + : chartOfDateRange; + return { chart, errors, @@ -2050,6 +2064,64 @@ export class PortfolioService { return { markets, marketsAdvanced }; } + /** + * Returns one chart item per calendar year of the date range, dated on + * 1 January. Each year stands alone and is not accumulated, so its values + * are the same as for the date range of the year (e.g. '2024'), clipped to + * the requested date range. The investment is the sum of the year, the other + * values are the ones at the end of the year. The first chart item of the + * date range carries the values at its start date and belongs to the + * previous period, like 31 December for a calendar year, so the years before + * the first activity have no chart item. + */ + private async getPerformanceByYear({ + chartOfDateRange, + endDate, + portfolioCalculator, + startDate + }: { + chartOfDateRange: HistoricalDataItem[]; + endDate: Date; + portfolioCalculator: PortfolioCalculator; + startDate: Date; + }): Promise { + const chart: HistoricalDataItem[] = []; + + const years = uniq( + chartOfDateRange.slice(1).map(({ date }) => { + return date.substring(0, 4); + }) + ); + + for (const [index, year] of years.entries()) { + const { endDate: endDateOfYear, startDate: startDateOfYear } = + getIntervalFromDateRange({ dateRange: year }); + + const { chart: chartOfYear } = await portfolioCalculator.getPerformance({ + end: min([endDate, endDateOfYear]), + start: index === 0 ? startDate : startDateOfYear + }); + + let investmentValueWithCurrencyEffect = new Big(0); + + for (const historicalDataItem of chartOfYear.slice(1)) { + investmentValueWithCurrencyEffect = + investmentValueWithCurrencyEffect.plus( + historicalDataItem.investmentValueWithCurrencyEffect ?? 0 + ); + } + + chart.push({ + ...chartOfYear.at(-1), + date: `${year}-01-01`, + investmentValueWithCurrencyEffect: + investmentValueWithCurrencyEffect.toNumber() + }); + } + + return chart; + } + private getReportStatistics( evaluatedRules: PortfolioReportRule[] ): PortfolioReportResponse['xRay']['statistics'] {