From fbed22c61cc84e1e945c10266bd41bd867fccd77 Mon Sep 17 00:00:00 2001 From: John Costa Date: Mon, 19 Jan 2026 13:55:13 -0800 Subject: [PATCH 1/2] 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 --- .../portfolio/portfolio-chart.helper.spec.ts | 125 ++++++++++++++++++ .../app/portfolio/portfolio-chart.helper.ts | 42 ++++++ .../src/app/portfolio/portfolio.controller.ts | 2 + .../src/app/portfolio/portfolio.service.ts | 9 +- 4 files changed, 176 insertions(+), 2 deletions(-) create mode 100644 apps/api/src/app/portfolio/portfolio-chart.helper.spec.ts create mode 100644 apps/api/src/app/portfolio/portfolio-chart.helper.ts 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, From 041a854c65137951bcdb78970246005054fe8fb7 Mon Sep 17 00:00:00 2001 From: John Costa Date: Fri, 23 Jan 2026 10:27:01 -0800 Subject: [PATCH 2/2] test(api): add tests for performance grouped by year in ROAI calculator Add test cases that verify getChartByYear correctly groups portfolio performance chart data by year. Tests use getPerformance() output with getChartByYear to validate yearly grouping: - BTCUSD: Tests multi-year scenario (2021-2022) - GOOGL: Tests single-year scenario (2023) - NOVN: Tests buy-and-sell within single year (2022) Co-Authored-By: Claude Opus 4.5 --- .../roai/portfolio-calculator-btcusd.spec.ts | 65 +++++++++++++++++++ .../portfolio-calculator-googl-buy.spec.ts | 57 ++++++++++++++++ ...folio-calculator-novn-buy-and-sell.spec.ts | 57 ++++++++++++++++ 3 files changed, 179 insertions(+) diff --git a/apps/api/src/app/portfolio/calculator/roai/portfolio-calculator-btcusd.spec.ts b/apps/api/src/app/portfolio/calculator/roai/portfolio-calculator-btcusd.spec.ts index b8cecb350..a10a6da4e 100644 --- a/apps/api/src/app/portfolio/calculator/roai/portfolio-calculator-btcusd.spec.ts +++ b/apps/api/src/app/portfolio/calculator/roai/portfolio-calculator-btcusd.spec.ts @@ -7,6 +7,7 @@ import { import { PortfolioCalculatorFactory } from '@ghostfolio/api/app/portfolio/calculator/portfolio-calculator.factory'; import { CurrentRateService } from '@ghostfolio/api/app/portfolio/current-rate.service'; import { CurrentRateServiceMock } from '@ghostfolio/api/app/portfolio/current-rate.service.mock'; +import { getChartByYear } from '@ghostfolio/api/app/portfolio/portfolio-chart.helper'; import { RedisCacheService } from '@ghostfolio/api/app/redis-cache/redis-cache.service'; import { RedisCacheServiceMock } from '@ghostfolio/api/app/redis-cache/redis-cache.service.mock'; import { ConfigurationService } from '@ghostfolio/api/services/configuration/configuration.service'; @@ -88,6 +89,10 @@ describe('PortfolioCalculator', () => { ); }); + afterEach(() => { + jest.useRealTimers(); + }); + describe('get current positions', () => { it.only('with BTCUSD buy (in USD)', async () => { jest.useFakeTimers().setSystemTime(parseDate('2022-01-14').getTime()); @@ -258,5 +263,65 @@ describe('PortfolioCalculator', () => { { date: '2022-01-01', investment: 0 } ]); }); + + it.only('with BTCUSD buy - performance grouped by year', async () => { + jest.useFakeTimers().setSystemTime(parseDate('2022-01-14').getTime()); + + const activities: Activity[] = exportResponse.activities.map( + (activity) => ({ + ...activityDummyData, + ...activity, + date: parseDate(activity.date), + feeInAssetProfileCurrency: 4.46, + feeInBaseCurrency: 4.46, + SymbolProfile: { + ...symbolProfileDummyData, + currency: 'USD', + dataSource: activity.dataSource, + name: 'Bitcoin', + symbol: activity.symbol + }, + unitPriceInAssetProfileCurrency: 44558.42 + }) + ); + + const portfolioCalculator = portfolioCalculatorFactory.createCalculator({ + activities, + calculationType: PerformanceCalculationType.ROAI, + currency: exportResponse.user.settings.currency, + userId: userDummyData.id + }); + + await portfolioCalculator.computeSnapshot(); + + const { chart } = await portfolioCalculator.getPerformance({ + end: parseDate('2022-01-14'), + start: parseDate('2021-12-11') + }); + + const chartByYear = getChartByYear(chart); + + // Chart spans from 2021-12-11 to 2022-01-14, covering two years + expect(chartByYear).toHaveLength(2); + + // First year (2021) - should have the last data point from 2021-12-31 + expect(chartByYear[0].date).toEqual('2021-01-01'); + expect(chartByYear[0]).toMatchObject( + expect.objectContaining({ + date: '2021-01-01', + totalInvestmentValueWithCurrencyEffect: 44558.42 + }) + ); + + // Second year (2022) - should have the last data point from 2022-01-14 + expect(chartByYear[1].date).toEqual('2022-01-01'); + expect(chartByYear[1]).toMatchObject( + expect.objectContaining({ + date: '2022-01-01', + netPerformance: -1463.18, + totalInvestmentValueWithCurrencyEffect: 44558.42 + }) + ); + }); }); }); diff --git a/apps/api/src/app/portfolio/calculator/roai/portfolio-calculator-googl-buy.spec.ts b/apps/api/src/app/portfolio/calculator/roai/portfolio-calculator-googl-buy.spec.ts index e438d9c6d..46cc163a9 100644 --- a/apps/api/src/app/portfolio/calculator/roai/portfolio-calculator-googl-buy.spec.ts +++ b/apps/api/src/app/portfolio/calculator/roai/portfolio-calculator-googl-buy.spec.ts @@ -6,6 +6,7 @@ import { import { PortfolioCalculatorFactory } from '@ghostfolio/api/app/portfolio/calculator/portfolio-calculator.factory'; import { CurrentRateService } from '@ghostfolio/api/app/portfolio/current-rate.service'; import { CurrentRateServiceMock } from '@ghostfolio/api/app/portfolio/current-rate.service.mock'; +import { getChartByYear } from '@ghostfolio/api/app/portfolio/portfolio-chart.helper'; import { RedisCacheService } from '@ghostfolio/api/app/redis-cache/redis-cache.service'; import { RedisCacheServiceMock } from '@ghostfolio/api/app/redis-cache/redis-cache.service.mock'; import { ConfigurationService } from '@ghostfolio/api/services/configuration/configuration.service'; @@ -90,6 +91,10 @@ describe('PortfolioCalculator', () => { ); }); + afterEach(() => { + jest.useRealTimers(); + }); + describe('get current positions', () => { it.only('with GOOGL buy', async () => { jest.useFakeTimers().setSystemTime(parseDate('2023-07-10').getTime()); @@ -230,5 +235,57 @@ describe('PortfolioCalculator', () => { { date: '2023-01-01', investment: 82.329056 } ]); }); + + it.only('with GOOGL buy - performance grouped by year', async () => { + jest.useFakeTimers().setSystemTime(parseDate('2023-07-10').getTime()); + + const activities: Activity[] = [ + { + ...activityDummyData, + date: new Date('2023-01-03'), + feeInAssetProfileCurrency: 1, + feeInBaseCurrency: 0.9238, + quantity: 1, + SymbolProfile: { + ...symbolProfileDummyData, + currency: 'USD', + dataSource: 'YAHOO', + name: 'Alphabet Inc.', + symbol: 'GOOGL' + }, + type: 'BUY', + unitPriceInAssetProfileCurrency: 89.12 + } + ]; + + const portfolioCalculator = portfolioCalculatorFactory.createCalculator({ + activities, + calculationType: PerformanceCalculationType.ROAI, + currency: 'CHF', + userId: userDummyData.id + }); + + await portfolioCalculator.computeSnapshot(); + + const { chart } = await portfolioCalculator.getPerformance({ + end: parseDate('2023-07-10'), + start: parseDate('2023-01-03') + }); + + const chartByYear = getChartByYear(chart); + + // All data is within 2023, so should only have one year entry + expect(chartByYear).toHaveLength(1); + + // Should have the last data point of 2023 (2023-07-10) with normalized date + expect(chartByYear[0].date).toEqual('2023-01-01'); + expect(chartByYear[0]).toMatchObject( + expect.objectContaining({ + date: '2023-01-01', + netPerformance: new Big('26.33').mul(0.8854).toNumber(), + totalInvestmentValueWithCurrencyEffect: 82.329056 + }) + ); + }); }); }); diff --git a/apps/api/src/app/portfolio/calculator/roai/portfolio-calculator-novn-buy-and-sell.spec.ts b/apps/api/src/app/portfolio/calculator/roai/portfolio-calculator-novn-buy-and-sell.spec.ts index c4850db66..e13979653 100644 --- a/apps/api/src/app/portfolio/calculator/roai/portfolio-calculator-novn-buy-and-sell.spec.ts +++ b/apps/api/src/app/portfolio/calculator/roai/portfolio-calculator-novn-buy-and-sell.spec.ts @@ -7,6 +7,7 @@ import { import { PortfolioCalculatorFactory } from '@ghostfolio/api/app/portfolio/calculator/portfolio-calculator.factory'; import { CurrentRateService } from '@ghostfolio/api/app/portfolio/current-rate.service'; import { CurrentRateServiceMock } from '@ghostfolio/api/app/portfolio/current-rate.service.mock'; +import { getChartByYear } from '@ghostfolio/api/app/portfolio/portfolio-chart.helper'; import { RedisCacheService } from '@ghostfolio/api/app/redis-cache/redis-cache.service'; import { RedisCacheServiceMock } from '@ghostfolio/api/app/redis-cache/redis-cache.service.mock'; import { ConfigurationService } from '@ghostfolio/api/services/configuration/configuration.service'; @@ -91,6 +92,10 @@ describe('PortfolioCalculator', () => { ); }); + afterEach(() => { + jest.useRealTimers(); + }); + describe('get current positions', () => { it.only('with NOVN.SW buy and sell', async () => { jest.useFakeTimers().setSystemTime(parseDate('2022-04-11').getTime()); @@ -261,5 +266,57 @@ describe('PortfolioCalculator', () => { { date: '2022-01-01', investment: 0 } ]); }); + + it.only('with NOVN.SW buy and sell - performance grouped by year', async () => { + jest.useFakeTimers().setSystemTime(parseDate('2022-04-11').getTime()); + + const activities: Activity[] = exportResponse.activities.map( + (activity) => ({ + ...activityDummyData, + ...activity, + date: parseDate(activity.date), + feeInAssetProfileCurrency: activity.fee, + feeInBaseCurrency: activity.fee, + SymbolProfile: { + ...symbolProfileDummyData, + currency: activity.currency, + dataSource: activity.dataSource, + name: 'Novartis AG', + symbol: activity.symbol + }, + unitPriceInAssetProfileCurrency: activity.unitPrice + }) + ); + + const portfolioCalculator = portfolioCalculatorFactory.createCalculator({ + activities, + calculationType: PerformanceCalculationType.ROAI, + currency: exportResponse.user.settings.currency, + userId: userDummyData.id + }); + + await portfolioCalculator.computeSnapshot(); + + const { chart } = await portfolioCalculator.getPerformance({ + end: parseDate('2022-04-11'), + start: parseDate('2022-03-06') + }); + + const chartByYear = getChartByYear(chart); + + // All data is within 2022, so should only have one year entry + expect(chartByYear).toHaveLength(1); + + // Should have the last data point of 2022 (2022-04-11) with normalized date + expect(chartByYear[0].date).toEqual('2022-01-01'); + expect(chartByYear[0]).toMatchObject( + expect.objectContaining({ + date: '2022-01-01', + netPerformance: 19.86, + netPerformanceInPercentage: 0.13100263852242744, + totalInvestmentValueWithCurrencyEffect: 0 + }) + ); + }); }); });