Browse Source

Add groupBy=year to portfolio performance endpoint

pull/8094/head
Thomas Kaul 1 week ago
parent
commit
0480808ba9
  1. 7
      apps/api/src/app/portfolio/get-performance.dto.ts
  2. 2
      apps/api/src/app/portfolio/portfolio.controller.ts
  3. 175
      apps/api/src/app/portfolio/portfolio.service.spec.ts
  4. 72
      apps/api/src/app/portfolio/portfolio.service.ts

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

175
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';
@ -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,170 @@ 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
});
// A year runs from the end of 31 December of the previous year, like
// the date range of the year. The first year is clipped to the date
// range, and the current year ends today.
expect(getPerformanceOfCalculator).toHaveBeenCalledTimes(4);
expect(getPerformanceOfCalculator).toHaveBeenNthCalledWith(2, {
end: endOfDay(parseDate('2023-12-31')),
start: endOfDay(parseDate('2022-12-31'))
});
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'))
});
// Each year stands alone and is dated on 1 January
expect(chart).toEqual([
{ date: '2023-01-01', netPerformance: 100 },
{ date: '2024-01-01', netPerformance: 200 },
{ date: '2025-01-01', netPerformance: 300 }
]);
// The performance is the one of the whole date range
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
});
// The chart item at the start date of the date range belongs to the
// previous year and gets no chart item of its own
expect(getPerformanceOfCalculator).toHaveBeenCalledTimes(2);
expect(getPerformanceOfCalculator).toHaveBeenNthCalledWith(
2,
getPerformanceOfCalculator.mock.calls[0][0]
);
expect(chart).toEqual([{ date: '2024-01-01', netPerformance: 200 }]);
expect(performance.netPerformance).toBe(200);
});
});
describe('getSummary', () => {
const getSummary = (args: object) => {
return (

72
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,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,52 @@ export class PortfolioService {
return { markets, marketsAdvanced };
}
/**
* Returns one chart item per calendar year of the date range, like the
* dividends and the investments grouped by year. The chart item of a year is
* the last chart item of the performance of this year, 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.
*/
private async getPerformanceByYear({
chartOfDateRange,
endDate,
portfolioCalculator,
startDate
}: {
chartOfDateRange: HistoricalDataItem[];
endDate: Date;
portfolioCalculator: PortfolioCalculator;
startDate: Date;
}): Promise<HistoricalDataItem[]> {
const chart: HistoricalDataItem[] = [];
// The first chart item carries the values at the start date of the date
// range and belongs to the previous period, like 31 December for a
// calendar year. The years come from the other chart items, so the years
// before the first activity have no chart item.
const years = uniq(
chartOfDateRange.slice(1).map(({ date }) => {
return date.substring(0, 4);
})
);
for (const year of years) {
const { endDate: endDateOfYear, startDate: startDateOfYear } =
getIntervalFromDateRange({ dateRange: year, startDate });
const { chart: chartOfYear } = await portfolioCalculator.getPerformance({
end: min([endDate, endDateOfYear]),
start: startDateOfYear
});
chart.push({ ...chartOfYear.at(-1), date: `${year}-01-01` });
}
return chart;
}
private getReportStatistics(
evaluatedRules: PortfolioReportRule[]
): PortfolioReportResponse['xRay']['statistics'] {

Loading…
Cancel
Save