Browse Source

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

* Add groupBy=year
pull/8100/head
Thomas Kaul 15 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/),
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

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 { 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<GroupBy, 'year'>;
@IsBoolean()
@Transform(({ value }: TransformFnParams) => {
return value === 'true';

2
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

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 { 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<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', () => {
const getSummary = (args: object) => {
return (

78
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<GroupBy, 'year'>;
userId: string;
withExcludedAccounts?: boolean;
}): Promise<PortfolioPerformanceResponse> {
@ -1252,7 +1255,8 @@ export class PortfolioService {
const { endDate, startDate } = getIntervalFromDateRange({ dateRange });
const { chart } = await portfolioCalculator.getPerformance({
const { chart: chartOfDateRange } =
await portfolioCalculator.getPerformance({
end: endDate,
start: startDate
});
@ -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<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(
evaluatedRules: PortfolioReportRule[]
): PortfolioReportResponse['xRay']['statistics'] {

Loading…
Cancel
Save