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 066f33ea3..22c93badc 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()); @@ -257,5 +262,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 28b44e159..fe94f0267 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()); @@ -229,5 +234,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 02a4e80d8..4def1a8d8 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()); @@ -260,5 +265,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 + }) + ); + }); }); }); 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 a5a1d95ee..523077ee9 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 05df6a8fc..7903c15d0 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'); @@ -976,11 +977,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; @@ -1033,11 +1036,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, @@ -1046,7 +1051,7 @@ export class PortfolioService { netWorth, totalInvestment, valueWithCurrencyEffect - } = chart?.at(-1) ?? { + } = rawChart?.at(-1) ?? { netPerformance: 0, netPerformanceInPercentage: 0, netPerformanceInPercentageWithCurrencyEffect: 0,