diff --git a/apps/api/src/app/portfolio/portfolio.service.spec.ts b/apps/api/src/app/portfolio/portfolio.service.spec.ts index c6bf33a11b..e4c0049cec 100644 --- a/apps/api/src/app/portfolio/portfolio.service.spec.ts +++ b/apps/api/src/app/portfolio/portfolio.service.spec.ts @@ -945,12 +945,10 @@ describe('PortfolioService', () => { userId: userDummyData.id }); - // A year runs from the end of 31 December of the previous year, like - // the date range of the year, 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')) + start: new Date(0) }); expect(getPerformanceOfCalculator).toHaveBeenNthCalledWith(3, { end: endOfDay(parseDate('2024-12-31')), @@ -960,15 +958,23 @@ describe('PortfolioService', () => { 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 } + { + date: '2023-01-01', + investmentValueWithCurrencyEffect: 0, + netPerformance: 100 + }, + { + date: '2024-01-01', + investmentValueWithCurrencyEffect: 0, + netPerformance: 200 + }, + { + date: '2025-01-01', + investmentValueWithCurrencyEffect: 0, + netPerformance: 300 + } ]); - - // The performance is the one of the whole date range expect(performance.netPerformance).toBe(600); }); @@ -993,14 +999,18 @@ describe('PortfolioService', () => { 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(chart).toEqual([ + { + date: '2024-01-01', + investmentValueWithCurrencyEffect: 0, + netPerformance: 200 + } + ]); expect(performance.netPerformance).toBe(200); }); @@ -1032,8 +1042,6 @@ describe('PortfolioService', () => { userId: userDummyData.id }); - // The first year starts at the start date of the date range, not at the - // end of 31 December of the previous year expect(getPerformanceOfCalculator).toHaveBeenCalledTimes(3); expect(getPerformanceOfCalculator).toHaveBeenNthCalledWith(2, { end: endOfDay(parseDate('2024-12-31')), @@ -1044,11 +1052,144 @@ describe('PortfolioService', () => { start: endOfDay(parseDate('2024-12-31')) }); expect(chart).toEqual([ - { date: '2024-01-01', netPerformance: 100 }, - { date: '2025-01-01', netPerformance: 200 } + { + date: '2024-01-01', + investmentValueWithCurrencyEffect: 0, + netPerformance: 100 + }, + { + date: '2025-01-01', + investmentValueWithCurrencyEffect: 0, + netPerformance: 200 + } ]); expect(performance.netPerformance).toBe(300); }); + + it('should start the first calendar year at the start date of the date range on 1 January when grouped by year', async () => { + jest.setSystemTime(parseDate('2026-01-01').getTime()); + + getPerformanceOfCalculator + .mockResolvedValueOnce({ + chart: [ + { date: '2025-01-01', netPerformance: 0 }, + { date: '2025-12-31', netPerformance: 100 }, + { date: '2026-01-01', netPerformance: 150 } + ] + }) + .mockResolvedValueOnce({ + chart: [ + { date: '2025-01-01', netPerformance: 0 }, + { date: '2025-12-31', netPerformance: 100 } + ] + }) + .mockResolvedValueOnce({ + chart: [ + { date: '2025-12-31', netPerformance: 0 }, + { date: '2026-01-01', netPerformance: 50 } + ] + }); + + const { chart } = await portfolioService.getPerformance({ + dateRange: '1y', + groupBy: 'year', + userId: userDummyData.id + }); + + expect(getPerformanceOfCalculator).toHaveBeenCalledTimes(3); + expect(getPerformanceOfCalculator).toHaveBeenNthCalledWith(1, { + end: endOfDay(parseDate('2026-01-01')), + start: resetHours(parseDate('2025-01-01')) + }); + expect(getPerformanceOfCalculator).toHaveBeenNthCalledWith(2, { + end: endOfDay(parseDate('2025-12-31')), + start: resetHours(parseDate('2025-01-01')) + }); + expect(getPerformanceOfCalculator).toHaveBeenNthCalledWith(3, { + end: endOfDay(parseDate('2026-01-01')), + start: endOfDay(parseDate('2025-12-31')) + }); + expect(chart).toEqual([ + { + date: '2025-01-01', + investmentValueWithCurrencyEffect: 0, + netPerformance: 100 + }, + { + date: '2026-01-01', + investmentValueWithCurrencyEffect: 0, + netPerformance: 50 + } + ]); + }); + + it('should add up the investment of each calendar year when grouped by year', async () => { + getPerformanceOfCalculator + .mockResolvedValueOnce({ + chart: [ + { date: '2023-12-31', investmentValueWithCurrencyEffect: 0 }, + { date: '2024-03-01', investmentValueWithCurrencyEffect: 1000 }, + { date: '2024-12-31', investmentValueWithCurrencyEffect: 300 }, + { date: '2025-06-15', investmentValueWithCurrencyEffect: 200 } + ] + }) + .mockResolvedValueOnce({ + chart: [ + { date: '2023-12-31', investmentValueWithCurrencyEffect: 0 }, + { date: '2024-03-01', investmentValueWithCurrencyEffect: 1000 }, + { date: '2024-12-31', investmentValueWithCurrencyEffect: 300 } + ] + }) + .mockResolvedValueOnce({ + chart: [ + { date: '2024-12-31', investmentValueWithCurrencyEffect: 300 }, + { date: '2025-06-15', investmentValueWithCurrencyEffect: 200 } + ] + }); + + const { chart } = await portfolioService.getPerformance({ + dateRange: 'max', + groupBy: 'year', + userId: userDummyData.id + }); + + expect(chart).toEqual([ + { date: '2024-01-01', investmentValueWithCurrencyEffect: 1300 }, + { date: '2025-01-01', investmentValueWithCurrencyEffect: 200 } + ]); + }); + + it('should skip a calendar year without a chart when grouped by year', async () => { + getPerformanceOfCalculator + .mockResolvedValueOnce({ + chart: [ + { date: '2023-12-31', netPerformance: 0 }, + { date: '2024-12-31', netPerformance: 200 }, + { date: '2025-06-15', netPerformance: 300 } + ] + }) + .mockResolvedValueOnce({ chart: [] }) + .mockResolvedValueOnce({ + chart: [ + { date: '2024-12-31', netPerformance: 0 }, + { date: '2025-06-15', netPerformance: 100 } + ] + }); + + const { chart } = await portfolioService.getPerformance({ + dateRange: 'max', + groupBy: 'year', + userId: userDummyData.id + }); + + expect(chart).toEqual([ + { + date: '2025-01-01', + investmentValueWithCurrencyEffect: 0, + netPerformance: 100 + } + ]); + }); }); describe('getSummary', () => { diff --git a/apps/api/src/app/portfolio/portfolio.service.ts b/apps/api/src/app/portfolio/portfolio.service.ts index 63f47df5b0..5f31cabfca 100644 --- a/apps/api/src/app/portfolio/portfolio.service.ts +++ b/apps/api/src/app/portfolio/portfolio.service.ts @@ -2066,11 +2066,14 @@ export class PortfolioService { /** * 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. + * dividends and the investments grouped by 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. The investment is the sum of the year, the other values are + * the ones at the end of the year. The first chart item of the date range + * carries the values at its start date and belongs to the previous period, + * like 31 December for a calendar year, so the years before the first + * activity have no chart item. */ private async getPerformanceByYear({ chartOfDateRange, @@ -2085,28 +2088,40 @@ export class PortfolioService { }): Promise { 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 }) => { + chartOfDateRange?.slice(1).map(({ date }) => { return date.substring(0, 4); }) ); - for (const year of years) { + for (const [index, year] of years.entries()) { const { endDate: endDateOfYear, startDate: startDateOfYear } = - getIntervalFromDateRange({ startDate, dateRange: year }); - - const end = min([endDate, endDateOfYear]); + getIntervalFromDateRange({ dateRange: year }); const { chart: chartOfYear } = await portfolioCalculator.getPerformance({ - end, - start: startDateOfYear + end: min([endDate, endDateOfYear]), + start: index === 0 ? startDate : startDateOfYear }); - chart.push({ ...chartOfYear.at(-1), date: `${year}-01-01` }); + if (!chartOfYear?.length) { + continue; + } + + let investmentValueWithCurrencyEffect = new Big(0); + + for (const historicalDataItem of chartOfYear.slice(1)) { + investmentValueWithCurrencyEffect = + investmentValueWithCurrencyEffect.plus( + historicalDataItem.investmentValueWithCurrencyEffect ?? 0 + ); + } + + chart.push({ + ...chartOfYear.at(-1), + date: `${year}-01-01`, + investmentValueWithCurrencyEffect: + investmentValueWithCurrencyEffect.toNumber() + }); } return chart;