Browse Source

Feature/group by year in portfolio performance endpoint (#8094)

* Add groupBy=year
pull/8100/head
Thomas Kaul 12 hours ago
committed by GitHub
parent
commit
7e0d39ac6e
No known key found for this signature in database GPG Key ID: B5690EEEBB952194
  1. 6
      CHANGELOG.md
  2. 7
      apps/api/src/app/portfolio/get-performance.dto.ts
  3. 2
      apps/api/src/app/portfolio/portfolio.controller.ts
  4. 331
      apps/api/src/app/portfolio/portfolio.service.spec.ts
  5. 78
      apps/api/src/app/portfolio/portfolio.service.ts

6
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/), 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). 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 ## 3.82.0 - 2026-10-09
### Added ### Added

7
apps/api/src/app/portfolio/get-performance.dto.ts

@ -1,9 +1,14 @@
import { DateRangeFilterDto } from '@ghostfolio/api/dtos/date-range-filter.dto'; import { DateRangeFilterDto } from '@ghostfolio/api/dtos/date-range-filter.dto';
import { GroupBy } from '@ghostfolio/common/types';
import { Transform, TransformFnParams } from 'class-transformer'; import { Transform, TransformFnParams } from 'class-transformer';
import { IsBoolean } from 'class-validator'; import { IsBoolean, IsIn, IsOptional } from 'class-validator';
export class GetPerformanceDto extends DateRangeFilterDto { export class GetPerformanceDto extends DateRangeFilterDto {
@IsIn(['year'] as GroupBy[])
@IsOptional()
groupBy?: Extract<GroupBy, 'year'>;
@IsBoolean() @IsBoolean()
@Transform(({ value }: TransformFnParams) => { @Transform(({ value }: TransformFnParams) => {
return value === 'true'; return value === 'true';

2
apps/api/src/app/portfolio/portfolio.controller.ts

@ -495,6 +495,7 @@ export class PortfolioController {
accounts, accounts,
assetClasses, assetClasses,
dataSource, dataSource,
groupBy,
range, range,
symbol, symbol,
tags, tags,
@ -511,6 +512,7 @@ export class PortfolioController {
const performanceInformation = await this.portfolioService.getPerformance({ const performanceInformation = await this.portfolioService.getPerformance({
filters, filters,
groupBy,
userId, userId,
withExcludedAccounts, withExcludedAccounts,
dateRange: range dateRange: range

331
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 { AccountService } from '@ghostfolio/api/app/account/account.service';
import { CashDetails } from '@ghostfolio/api/app/account/interfaces/cash-details.interface'; import { CashDetails } from '@ghostfolio/api/app/account/interfaces/cash-details.interface';
import { ActivitiesService } from '@ghostfolio/api/app/activities/activities.service'; import { ActivitiesService } from '@ghostfolio/api/app/activities/activities.service';
@ -15,7 +16,7 @@ import {
TAG_ID_EXCLUDE_FROM_ANALYSIS, TAG_ID_EXCLUDE_FROM_ANALYSIS,
UNKNOWN_KEY UNKNOWN_KEY
} from '@ghostfolio/common/config'; } from '@ghostfolio/common/config';
import { parseDate } from '@ghostfolio/common/helper'; import { parseDate, resetHours } from '@ghostfolio/common/helper';
import { import {
Activity, Activity,
AssetProfileIdentifier, AssetProfileIdentifier,
@ -26,11 +27,13 @@ import { AccountWithBalance } from '@ghostfolio/common/types';
import { AssetClass, DataSource, Prisma } from '@prisma/client'; import { AssetClass, DataSource, Prisma } from '@prisma/client';
import { Big } from 'big.js'; import { Big } from 'big.js';
import { endOfDay } from 'date-fns';
import { randomUUID } from 'node:crypto'; import { randomUUID } from 'node:crypto';
import { PortfolioService } from './portfolio.service'; import { PortfolioService } from './portfolio.service';
describe('PortfolioService', () => { describe('PortfolioService', () => {
let accountBalanceService: AccountBalanceService;
let accountService: AccountService; let accountService: AccountService;
let activitiesService: ActivitiesService; let activitiesService: ActivitiesService;
let configurationService: ConfigurationService; let configurationService: ConfigurationService;
@ -61,6 +64,12 @@ describe('PortfolioService', () => {
null null
); );
accountBalanceService = new AccountBalanceService(
null,
exchangeRateDataService,
null
);
accountService = new AccountService( accountService = new AccountService(
null, null,
null, null,
@ -113,7 +122,7 @@ describe('PortfolioService', () => {
); );
portfolioService = new PortfolioService( portfolioService = new PortfolioService(
null, accountBalanceService,
accountService, accountService,
activitiesService, activitiesService,
null, 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<ReturnType<typeof userService.user>>);
});
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', () => { describe('getSummary', () => {
const getSummary = (args: object) => { const getSummary = (args: object) => {
return ( return (

78
apps/api/src/app/portfolio/portfolio.service.ts

@ -100,11 +100,12 @@ import {
isBefore, isBefore,
isSameMonth, isSameMonth,
isSameYear, isSameYear,
min,
parseISO, parseISO,
set, set,
startOfDay startOfDay
} from 'date-fns'; } from 'date-fns';
import { groupBy } from 'lodash-es'; import { groupBy, uniq } from 'lodash-es';
import { PortfolioCalculator } from './calculator/portfolio-calculator'; import { PortfolioCalculator } from './calculator/portfolio-calculator';
import { PortfolioCalculatorFactory } from './calculator/portfolio-calculator.factory'; import { PortfolioCalculatorFactory } from './calculator/portfolio-calculator.factory';
@ -1194,10 +1195,12 @@ export class PortfolioService {
public async getPerformance({ public async getPerformance({
dateRange = DEFAULT_DATE_RANGE, dateRange = DEFAULT_DATE_RANGE,
filters, filters,
groupBy,
userId userId
}: { }: {
dateRange?: DateRange; dateRange?: DateRange;
filters?: Filter[]; filters?: Filter[];
groupBy?: Extract<GroupBy, 'year'>;
userId: string; userId: string;
withExcludedAccounts?: boolean; withExcludedAccounts?: boolean;
}): Promise<PortfolioPerformanceResponse> { }): Promise<PortfolioPerformanceResponse> {
@ -1252,7 +1255,8 @@ export class PortfolioService {
const { endDate, startDate } = getIntervalFromDateRange({ dateRange }); const { endDate, startDate } = getIntervalFromDateRange({ dateRange });
const { chart } = await portfolioCalculator.getPerformance({ const { chart: chartOfDateRange } =
await portfolioCalculator.getPerformance({
end: endDate, end: endDate,
start: startDate start: startDate
}); });
@ -1268,7 +1272,7 @@ export class PortfolioService {
totalInvestment, totalInvestment,
totalInvestmentValueWithCurrencyEffect, totalInvestmentValueWithCurrencyEffect,
valueWithCurrencyEffect valueWithCurrencyEffect
} = chart?.at(-1) ?? { } = chartOfDateRange?.at(-1) ?? {
dividendInBaseCurrency: 0, dividendInBaseCurrency: 0,
dividendInPercentageWithCurrencyEffect: 0, dividendInPercentageWithCurrencyEffect: 0,
netPerformance: 0, netPerformance: 0,
@ -1280,6 +1284,16 @@ export class PortfolioService {
valueWithCurrencyEffect: 0 valueWithCurrencyEffect: 0
}; };
const chart =
groupBy === 'year'
? await this.getPerformanceByYear({
chartOfDateRange,
endDate,
portfolioCalculator,
startDate
})
: chartOfDateRange;
return { return {
chart, chart,
errors, errors,
@ -2050,6 +2064,64 @@ export class PortfolioService {
return { markets, marketsAdvanced }; 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<HistoricalDataItem[]> {
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( private getReportStatistics(
evaluatedRules: PortfolioReportRule[] evaluatedRules: PortfolioReportRule[]
): PortfolioReportResponse['xRay']['statistics'] { ): PortfolioReportResponse['xRay']['statistics'] {

Loading…
Cancel
Save