Browse Source

feat(api): add groupBy=year support to portfolio performance endpoint

This implements feature request #6221 by adding support for the `groupBy=year`
query parameter in the portfolio performance endpoint (GET /api/v2/portfolio/performance).

When `groupBy=year` is specified, the chart data is aggregated by year:
- Each year is represented by a single data point
- The date format is normalized to YYYY-01-01
- The last data point of each year is used as the representative value

The implementation follows existing patterns in the codebase (see getDividendsByGroup).
Extracted getChartByYear helper function with comprehensive unit tests.

Closes #6221
pull/6223/head
John Costa 2 weeks ago
parent
commit
fbed22c61c
  1. 125
      apps/api/src/app/portfolio/portfolio-chart.helper.spec.ts
  2. 42
      apps/api/src/app/portfolio/portfolio-chart.helper.ts
  3. 2
      apps/api/src/app/portfolio/portfolio.controller.ts
  4. 9
      apps/api/src/app/portfolio/portfolio.service.ts

125
apps/api/src/app/portfolio/portfolio-chart.helper.spec.ts

@ -0,0 +1,125 @@
import { HistoricalDataItem } from '@ghostfolio/common/interfaces';
import { getChartByYear } from './portfolio-chart.helper';
describe('portfolio-chart.helper', () => {
describe('getChartByYear', () => {
it('returns empty array for empty input', () => {
expect(getChartByYear([])).toEqual([]);
});
it('returns empty array for null input', () => {
expect(getChartByYear(null)).toEqual([]);
});
it('returns empty array for undefined input', () => {
expect(getChartByYear(undefined)).toEqual([]);
});
it('groups data by year with normalized dates (YYYY-01-01)', () => {
const chart: HistoricalDataItem[] = [
{ date: '2023-03-15', netPerformance: 100, netWorth: 1000 },
{ date: '2023-06-20', netPerformance: 150, netWorth: 1100 },
{ date: '2023-12-31', netPerformance: 200, netWorth: 1200 },
{ date: '2024-01-15', netPerformance: 180, netWorth: 1150 },
{ date: '2024-06-30', netPerformance: 250, netWorth: 1300 }
];
const result = getChartByYear(chart);
expect(result).toHaveLength(2);
expect(result[0]).toEqual({
date: '2023-01-01',
netPerformance: 200,
netWorth: 1200
});
expect(result[1]).toEqual({
date: '2024-01-01',
netPerformance: 250,
netWorth: 1300
});
});
it('uses last data point of each year as representative value', () => {
const chart: HistoricalDataItem[] = [
{ date: '2022-01-01', netPerformance: 10, netWorth: 100 },
{ date: '2022-02-01', netPerformance: 20, netWorth: 110 },
{ date: '2022-03-01', netPerformance: 30, netWorth: 120 }
];
const result = getChartByYear(chart);
expect(result).toHaveLength(1);
expect(result[0]).toEqual({
date: '2022-01-01',
netPerformance: 30,
netWorth: 120
});
});
it('handles single data point', () => {
const chart: HistoricalDataItem[] = [
{ date: '2025-07-15', netPerformance: 500, netWorth: 5000 }
];
const result = getChartByYear(chart);
expect(result).toHaveLength(1);
expect(result[0]).toEqual({
date: '2025-01-01',
netPerformance: 500,
netWorth: 5000
});
});
it('handles data spanning multiple years', () => {
const chart: HistoricalDataItem[] = [
{ date: '2020-06-01', netWorth: 100 },
{ date: '2021-06-01', netWorth: 200 },
{ date: '2022-06-01', netWorth: 300 },
{ date: '2023-06-01', netWorth: 400 },
{ date: '2024-06-01', netWorth: 500 }
];
const result = getChartByYear(chart);
expect(result).toHaveLength(5);
expect(result.map((r) => r.date)).toEqual([
'2020-01-01',
'2021-01-01',
'2022-01-01',
'2023-01-01',
'2024-01-01'
]);
expect(result.map((r) => r.netWorth)).toEqual([100, 200, 300, 400, 500]);
});
it('preserves all properties from the data point', () => {
const chart: HistoricalDataItem[] = [
{
date: '2023-12-31',
netPerformance: 100,
netPerformanceInPercentage: 0.1,
netPerformanceWithCurrencyEffect: 95,
netPerformanceInPercentageWithCurrencyEffect: 0.095,
netWorth: 1000,
totalInvestment: 900,
investmentValueWithCurrencyEffect: 850
}
];
const result = getChartByYear(chart);
expect(result[0]).toEqual({
date: '2023-01-01',
netPerformance: 100,
netPerformanceInPercentage: 0.1,
netPerformanceWithCurrencyEffect: 95,
netPerformanceInPercentageWithCurrencyEffect: 0.095,
netWorth: 1000,
totalInvestment: 900,
investmentValueWithCurrencyEffect: 850
});
});
});
});

42
apps/api/src/app/portfolio/portfolio-chart.helper.ts

@ -0,0 +1,42 @@
import { DATE_FORMAT, parseDate } from '@ghostfolio/common/helper';
import { HistoricalDataItem } from '@ghostfolio/common/interfaces';
import { format, isSameYear, set } from 'date-fns';
/**
* Groups chart data by year, providing one entry per year.
* For each year, the last available data point is used as the representative value.
* Dates are normalized to YYYY-01-01 format.
*/
export function getChartByYear(
chart: HistoricalDataItem[]
): HistoricalDataItem[] {
if (!chart?.length) {
return [];
}
const chartByYear: HistoricalDataItem[] = [];
let currentDate: Date | null = null;
for (const dataPoint of chart) {
const date = parseDate(dataPoint.date);
if (!isSameYear(date, currentDate)) {
// New year: Push a new entry with normalized date (YYYY-01-01)
chartByYear.push({
...dataPoint,
date: format(set(date, { date: 1, month: 0 }), DATE_FORMAT)
});
currentDate = date;
} else {
// Same year: Update the last entry with latest data (keep normalized date)
const normalizedDate = chartByYear[chartByYear.length - 1].date;
chartByYear[chartByYear.length - 1] = {
...dataPoint,
date: normalizedDate
};
}
}
return chartByYear;
}

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

@ -508,6 +508,7 @@ export class PortfolioController {
@Query('accounts') filterByAccounts?: string, @Query('accounts') filterByAccounts?: string,
@Query('assetClasses') filterByAssetClasses?: string, @Query('assetClasses') filterByAssetClasses?: string,
@Query('dataSource') filterByDataSource?: string, @Query('dataSource') filterByDataSource?: string,
@Query('groupBy') groupBy?: Extract<GroupBy, 'year'>,
@Query('range') dateRange: DateRange = 'max', @Query('range') dateRange: DateRange = 'max',
@Query('symbol') filterBySymbol?: string, @Query('symbol') filterBySymbol?: string,
@Query('tags') filterByTags?: string, @Query('tags') filterByTags?: string,
@ -526,6 +527,7 @@ export class PortfolioController {
const performanceInformation = await this.portfolioService.getPerformance({ const performanceInformation = await this.portfolioService.getPerformance({
dateRange, dateRange,
filters, filters,
groupBy,
impersonationId, impersonationId,
withExcludedAccounts, withExcludedAccounts,
userId: this.request.user.id userId: this.request.user.id

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

@ -91,6 +91,7 @@ import {
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';
import { getChartByYear } from './portfolio-chart.helper';
import { RulesService } from './rules.service'; import { RulesService } from './rules.service';
const Fuse = require('fuse.js'); const Fuse = require('fuse.js');
@ -970,11 +971,13 @@ export class PortfolioService {
public async getPerformance({ public async getPerformance({
dateRange = 'max', dateRange = 'max',
filters, filters,
groupBy,
impersonationId, impersonationId,
userId userId
}: { }: {
dateRange?: DateRange; dateRange?: DateRange;
filters?: Filter[]; filters?: Filter[];
groupBy?: Extract<GroupBy, 'year'>;
impersonationId: string; impersonationId: string;
userId: string; userId: string;
withExcludedAccounts?: boolean; withExcludedAccounts?: boolean;
@ -1027,11 +1030,13 @@ export class PortfolioService {
const { endDate, startDate } = getIntervalFromDateRange(dateRange); const { endDate, startDate } = getIntervalFromDateRange(dateRange);
const { chart } = await portfolioCalculator.getPerformance({ const { chart: rawChart } = await portfolioCalculator.getPerformance({
end: endDate, end: endDate,
start: startDate start: startDate
}); });
const chart = groupBy === 'year' ? getChartByYear(rawChart) : rawChart;
const { const {
netPerformance, netPerformance,
netPerformanceInPercentage, netPerformanceInPercentage,
@ -1040,7 +1045,7 @@ export class PortfolioService {
netWorth, netWorth,
totalInvestment, totalInvestment,
valueWithCurrencyEffect valueWithCurrencyEffect
} = chart?.at(-1) ?? { } = rawChart?.at(-1) ?? {
netPerformance: 0, netPerformance: 0,
netPerformanceInPercentage: 0, netPerformanceInPercentage: 0,
netPerformanceInPercentageWithCurrencyEffect: 0, netPerformanceInPercentageWithCurrencyEffect: 0,

Loading…
Cancel
Save