diff --git a/CHANGELOG.md b/CHANGELOG.md index 22ec996da..704b83470 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Added +- Added support for grouping portfolio performance by year - Introduced a DTO for the query parameters of the asset profiles endpoint - Introduced a DTO for the query parameters of the symbol lookup endpoints diff --git a/apps/api/src/app/portfolio/get-performance.dto.ts b/apps/api/src/app/portfolio/get-performance.dto.ts index 5992c2a09..0db72fceb 100644 --- a/apps/api/src/app/portfolio/get-performance.dto.ts +++ b/apps/api/src/app/portfolio/get-performance.dto.ts @@ -1,9 +1,13 @@ import { DateRangeFilterDto } from '@ghostfolio/api/dtos/date-range-filter.dto'; 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']) + @IsOptional() + groupBy?: 'year'; + @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 431a8da20..af956dd1b 100644 --- a/apps/api/src/app/portfolio/portfolio.controller.ts +++ b/apps/api/src/app/portfolio/portfolio.controller.ts @@ -494,6 +494,7 @@ export class PortfolioController { accounts, assetClasses, dataSource, + groupBy, range, symbol, tags, @@ -510,6 +511,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 227262c40..155848c25 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'; @@ -25,6 +26,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; @@ -54,6 +56,12 @@ describe('PortfolioService', () => { null ); + accountBalanceService = new AccountBalanceService( + null, + exchangeRateDataService, + null + ); + accountService = new AccountService( null, null, @@ -100,7 +108,7 @@ describe('PortfolioService', () => { ); portfolioService = new PortfolioService( - null, + accountBalanceService, accountService, activitiesService, null, @@ -490,6 +498,127 @@ describe('PortfolioService', () => { }); }); + describe('getPerformance', () => { + const getPerformance = jest.fn(); + const portfolioCalculator = { + getPerformance, + getSnapshot: jest.fn() + } as unknown as PortfolioCalculator; + + beforeEach(() => { + jest + .spyOn(accountBalanceService, 'getAccountBalanceItems') + .mockResolvedValue([]); + getPerformance.mockReset(); + + jest.spyOn(userService, 'user').mockResolvedValue({ + id: userDummyData.id, + settings: { + settings: { + baseCurrency: 'USD' + } + } + } as unknown as Awaited>); + + jest + .spyOn(activitiesService, 'getActivitiesForPortfolioCalculator') + .mockResolvedValue({ activities: [{}], count: 1 } as never); + jest + .spyOn(portfolioCalculatorFactory, 'createCalculator') + .mockReturnValue(portfolioCalculator); + portfolioCalculator.getSnapshot = jest.fn().mockResolvedValue({ + errors: [], + hasErrors: false, + historicalData: [{ date: '2024-01-01' }] + }); + }); + + afterEach(() => { + jest.useRealTimers(); + }); + + it('returns one chart entry per interval year, including years without chart data, when grouping by year', async () => { + jest.useFakeTimers().setSystemTime(parseDate('2025-06-15')); + + let performanceCallCount = 0; + + getPerformance.mockImplementation( + ({ end }: { end: Date; start: Date }) => { + performanceCallCount++; + + if (performanceCallCount === 1) { + return Promise.resolve({ + chart: [ + { + date: '2025-06-15', + netPerformance: 10000, + netPerformanceInPercentage: 1, + netPerformanceInPercentageWithCurrencyEffect: 1.1, + netPerformanceWithCurrencyEffect: 11000, + netWorth: 30000, + totalInvestment: 20000, + totalInvestmentValueWithCurrencyEffect: 21000, + valueWithCurrencyEffect: 31000 + } + ] + }); + } + + const year = end.getFullYear(); + + if (year === 2022) { + return Promise.resolve({ chart: [] }); + } + + return Promise.resolve({ + chart: [ + { date: `${year}-01-01`, netPerformance: year - 1 }, + { + date: `${year}-12-31`, + netPerformance: year, + netWorth: year * 10 + } + ] + }); + } + ); + + const result = await portfolioService.getPerformance({ + dateRange: '5y', + groupBy: 'year', + userId: userDummyData.id + }); + + expect(result.chart).toEqual( + [2020, 2021, 2022, 2023, 2024, 2025].map((year) => + year === 2022 + ? { date: `${year}-01-01` } + : { + date: `${year}-01-01`, + netPerformance: year, + netWorth: year * 10 + } + ) + ); + expect(result.performance).toEqual({ + currentNetWorth: 30000, + currentValueInBaseCurrency: 31000, + netPerformance: 10000, + netPerformancePercentage: 1, + netPerformancePercentageWithCurrencyEffect: 1.1, + netPerformanceWithCurrencyEffect: 11000, + totalInvestment: 20000, + totalInvestmentValueWithCurrencyEffect: 21000 + }); + expect(getPerformance).toHaveBeenCalledTimes(7); + expect( + getPerformance.mock.calls.slice(1).map(([{ end }]) => { + return end.getFullYear(); + }) + ).toEqual([2020, 2021, 2022, 2023, 2024, 2025]); + }); + }); + 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 9f81754f4..d405473a8 100644 --- a/apps/api/src/app/portfolio/portfolio.service.ts +++ b/apps/api/src/app/portfolio/portfolio.service.ts @@ -92,11 +92,15 @@ import { import { Big } from 'big.js'; import { differenceInDays, + eachYearOfInterval, + endOfYear, format, isAfter, isBefore, isSameMonth, isSameYear, + max, + min, parseISO, set } from 'date-fns'; @@ -1135,10 +1139,12 @@ export class PortfolioService { public async getPerformance({ dateRange = DEFAULT_DATE_RANGE, filters, + groupBy, userId }: { dateRange?: DateRange; filters?: Filter[]; + groupBy?: 'year'; userId: string; withExcludedAccounts?: boolean; }): Promise { @@ -1190,11 +1196,40 @@ export class PortfolioService { const { endDate, startDate } = getIntervalFromDateRange({ dateRange }); - const { chart } = await portfolioCalculator.getPerformance({ + const { chart: fullChart } = await portfolioCalculator.getPerformance({ end: endDate, start: startDate }); + let chart = fullChart; + + if (groupBy === 'year') { + chart = []; + + for (const year of eachYearOfInterval({ + end: endDate, + start: startDate + })) { + const intervalEnd = min([endDate, endOfYear(year)]); + const intervalStart = max([startDate, year]); + + if (!isBefore(intervalStart, intervalEnd)) { + continue; + } + + const { chart: chartForYear } = + await portfolioCalculator.getPerformance({ + end: intervalEnd, + start: intervalStart + }); + + chart.push({ + ...(chartForYear.at(-1) ?? {}), + date: format(year, DATE_FORMAT) + }); + } + } + const { netPerformance, netPerformanceInPercentage, @@ -1204,7 +1239,7 @@ export class PortfolioService { totalInvestment, totalInvestmentValueWithCurrencyEffect, valueWithCurrencyEffect - } = chart?.at(-1) ?? { + } = fullChart?.at(-1) ?? { netPerformance: 0, netPerformanceInPercentage: 0, netPerformanceInPercentageWithCurrencyEffect: 0,