Browse Source

Add groupBy=year to portfolio performance endpoint

pull/8094/head
Thomas Kaul 6 days ago
parent
commit
5ec765bc4a
  1. 175
      apps/api/src/app/portfolio/portfolio.service.spec.ts
  2. 49
      apps/api/src/app/portfolio/portfolio.service.ts

175
apps/api/src/app/portfolio/portfolio.service.spec.ts

@ -945,12 +945,10 @@ describe('PortfolioService', () => {
userId: userDummyData.id 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).toHaveBeenCalledTimes(4);
expect(getPerformanceOfCalculator).toHaveBeenNthCalledWith(2, { expect(getPerformanceOfCalculator).toHaveBeenNthCalledWith(2, {
end: endOfDay(parseDate('2023-12-31')), end: endOfDay(parseDate('2023-12-31')),
start: endOfDay(parseDate('2022-12-31')) start: new Date(0)
}); });
expect(getPerformanceOfCalculator).toHaveBeenNthCalledWith(3, { expect(getPerformanceOfCalculator).toHaveBeenNthCalledWith(3, {
end: endOfDay(parseDate('2024-12-31')), end: endOfDay(parseDate('2024-12-31')),
@ -960,15 +958,23 @@ describe('PortfolioService', () => {
end: endOfDay(parseDate('2025-06-15')), end: endOfDay(parseDate('2025-06-15')),
start: endOfDay(parseDate('2024-12-31')) start: endOfDay(parseDate('2024-12-31'))
}); });
// Each year stands alone and is dated on 1 January
expect(chart).toEqual([ expect(chart).toEqual([
{ date: '2023-01-01', netPerformance: 100 }, {
{ date: '2024-01-01', netPerformance: 200 }, date: '2023-01-01',
{ date: '2025-01-01', netPerformance: 300 } 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); expect(performance.netPerformance).toBe(600);
}); });
@ -993,14 +999,18 @@ describe('PortfolioService', () => {
userId: userDummyData.id 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).toHaveBeenCalledTimes(2);
expect(getPerformanceOfCalculator).toHaveBeenNthCalledWith( expect(getPerformanceOfCalculator).toHaveBeenNthCalledWith(
2, 2,
getPerformanceOfCalculator.mock.calls[0][0] 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); expect(performance.netPerformance).toBe(200);
}); });
@ -1032,8 +1042,6 @@ describe('PortfolioService', () => {
userId: userDummyData.id 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).toHaveBeenCalledTimes(3);
expect(getPerformanceOfCalculator).toHaveBeenNthCalledWith(2, { expect(getPerformanceOfCalculator).toHaveBeenNthCalledWith(2, {
end: endOfDay(parseDate('2024-12-31')), end: endOfDay(parseDate('2024-12-31')),
@ -1044,11 +1052,144 @@ describe('PortfolioService', () => {
start: endOfDay(parseDate('2024-12-31')) start: endOfDay(parseDate('2024-12-31'))
}); });
expect(chart).toEqual([ 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); 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', () => { describe('getSummary', () => {

49
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 * 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 * dividends and the investments grouped by year, dated on 1 January. Each
* the last chart item of the performance of this year, dated on 1 January. * year stands alone and is not accumulated, so its values are the same as
* Each year stands alone and is not accumulated, so its values are the same * for the date range of the year (e.g. '2024'), clipped to the requested
* 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
* date range. * 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({ private async getPerformanceByYear({
chartOfDateRange, chartOfDateRange,
@ -2085,28 +2088,40 @@ export class PortfolioService {
}): Promise<HistoricalDataItem[]> { }): Promise<HistoricalDataItem[]> {
const chart: HistoricalDataItem[] = []; 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( const years = uniq(
chartOfDateRange.slice(1).map(({ date }) => { chartOfDateRange?.slice(1).map(({ date }) => {
return date.substring(0, 4); return date.substring(0, 4);
}) })
); );
for (const year of years) { for (const [index, year] of years.entries()) {
const { endDate: endDateOfYear, startDate: startDateOfYear } = const { endDate: endDateOfYear, startDate: startDateOfYear } =
getIntervalFromDateRange({ startDate, dateRange: year }); getIntervalFromDateRange({ dateRange: year });
const end = min([endDate, endDateOfYear]);
const { chart: chartOfYear } = await portfolioCalculator.getPerformance({ const { chart: chartOfYear } = await portfolioCalculator.getPerformance({
end, end: min([endDate, endDateOfYear]),
start: startDateOfYear 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; return chart;

Loading…
Cancel
Save