diff --git a/apps/api/src/app/portfolio/portfolio-chart.helper.spec.ts b/apps/api/src/app/portfolio/portfolio-chart.helper.spec.ts new file mode 100644 index 000000000..d8c70d7bd --- /dev/null +++ b/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 + }); + }); + }); +}); diff --git a/apps/api/src/app/portfolio/portfolio-chart.helper.ts b/apps/api/src/app/portfolio/portfolio-chart.helper.ts new file mode 100644 index 000000000..d4ef480ab --- /dev/null +++ b/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; +} diff --git a/apps/api/src/app/portfolio/portfolio.controller.ts b/apps/api/src/app/portfolio/portfolio.controller.ts index 03796dad6..8b8ba2126 100644 --- a/apps/api/src/app/portfolio/portfolio.controller.ts +++ b/apps/api/src/app/portfolio/portfolio.controller.ts @@ -508,6 +508,7 @@ export class PortfolioController { @Query('accounts') filterByAccounts?: string, @Query('assetClasses') filterByAssetClasses?: string, @Query('dataSource') filterByDataSource?: string, + @Query('groupBy') groupBy?: Extract, @Query('range') dateRange: DateRange = 'max', @Query('symbol') filterBySymbol?: string, @Query('tags') filterByTags?: string, @@ -526,6 +527,7 @@ export class PortfolioController { const performanceInformation = await this.portfolioService.getPerformance({ dateRange, filters, + groupBy, impersonationId, withExcludedAccounts, userId: this.request.user.id diff --git a/apps/api/src/app/portfolio/portfolio.service.ts b/apps/api/src/app/portfolio/portfolio.service.ts index 20016e67f..dd3edfae1 100644 --- a/apps/api/src/app/portfolio/portfolio.service.ts +++ b/apps/api/src/app/portfolio/portfolio.service.ts @@ -91,6 +91,7 @@ import { import { PortfolioCalculator } from './calculator/portfolio-calculator'; import { PortfolioCalculatorFactory } from './calculator/portfolio-calculator.factory'; +import { getChartByYear } from './portfolio-chart.helper'; import { RulesService } from './rules.service'; const Fuse = require('fuse.js'); @@ -970,11 +971,13 @@ export class PortfolioService { public async getPerformance({ dateRange = 'max', filters, + groupBy, impersonationId, userId }: { dateRange?: DateRange; filters?: Filter[]; + groupBy?: Extract; impersonationId: string; userId: string; withExcludedAccounts?: boolean; @@ -1027,11 +1030,13 @@ export class PortfolioService { const { endDate, startDate } = getIntervalFromDateRange(dateRange); - const { chart } = await portfolioCalculator.getPerformance({ + const { chart: rawChart } = await portfolioCalculator.getPerformance({ end: endDate, start: startDate }); + const chart = groupBy === 'year' ? getChartByYear(rawChart) : rawChart; + const { netPerformance, netPerformanceInPercentage, @@ -1040,7 +1045,7 @@ export class PortfolioService { netWorth, totalInvestment, valueWithCurrencyEffect - } = chart?.at(-1) ?? { + } = rawChart?.at(-1) ?? { netPerformance: 0, netPerformanceInPercentage: 0, netPerformanceInPercentageWithCurrencyEffect: 0,