Browse Source

feat(api): add groupBy=year support to portfolio performance endpoint

Add a groupBy=year query parameter to GET /api/v2/portfolio/performance
that returns one chart entry per year with independently computed
performance metrics.

- Add getPerformanceByYear helper that iterates year intervals using
  eachYearOfInterval and calls portfolioCalculator.getPerformance() for
  each, giving every year its own baseline (netPerformance starts at 0)
- Wire groupBy parameter through controller and service
- Add dedicated unit tests for getPerformanceByYear (interval clamping,
  date normalization, multi-year ranges, empty chart handling)
- Extend existing ROAI calculator tests (btcusd, googl-buy,
  novn-buy-and-sell) with per-year performance assertions

Closes #6221
pull/6297/head
John Costa 2 days ago
parent
commit
f7bf50d850
  1. 51
      apps/api/src/app/portfolio/calculator/roai/portfolio-calculator-btcusd.spec.ts
  2. 25
      apps/api/src/app/portfolio/calculator/roai/portfolio-calculator-googl-buy.spec.ts
  3. 24
      apps/api/src/app/portfolio/calculator/roai/portfolio-calculator-novn-buy-and-sell.spec.ts
  4. 170
      apps/api/src/app/portfolio/portfolio-chart.helper.spec.ts
  5. 36
      apps/api/src/app/portfolio/portfolio-chart.helper.ts
  6. 2
      apps/api/src/app/portfolio/portfolio.controller.ts
  7. 16
      apps/api/src/app/portfolio/portfolio.service.ts

51
apps/api/src/app/portfolio/calculator/roai/portfolio-calculator-btcusd.spec.ts

@ -88,6 +88,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());
@ -119,6 +123,13 @@ describe('PortfolioCalculator', () => {
const portfolioSnapshot = await portfolioCalculator.computeSnapshot();
// Make getPerformance() usable: computeSnapshot() returns the snapshot
// but doesn't store it on the instance (only initialize() does that
// via cache). The snapshotPromise is stuck because the mock uses
// timers/promises setTimeout which is frozen by jest.useFakeTimers().
(portfolioCalculator as any).snapshot = portfolioSnapshot;
(portfolioCalculator as any).snapshotPromise = Promise.resolve();
const historicalDataDates = portfolioSnapshot.historicalData.map(
({ date }) => {
return date;
@ -256,6 +267,46 @@ describe('PortfolioCalculator', () => {
{ date: '2021-01-01', investment: 44558.42 },
{ date: '2022-01-01', investment: 0 }
]);
// Performance grouped by year: call getPerformance() per year interval
// Data spans 2021-12-11 to 2022-01-14, covering two years
// Year 2021: 2021-12-11 to 2021-12-31
const { chart: chart2021 } = await portfolioCalculator.getPerformance({
end: parseDate('2021-12-31'),
start: parseDate('2021-12-11')
});
expect(chart2021.length).toBeGreaterThan(0);
expect(chart2021.at(-1)).toMatchObject(
expect.objectContaining({
totalInvestmentValueWithCurrencyEffect: 44558.42
})
);
// Year 2022: 2022-01-01 to 2022-01-14
const { chart: chart2022 } = await portfolioCalculator.getPerformance({
end: parseDate('2022-01-14'),
start: parseDate('2022-01-01')
});
expect(chart2022.length).toBeGreaterThan(0);
// Performance is relative to start of this interval (2022-01-01), not 2021.
// netPerformance is independently baselined for 2022, not cumulative from 2021.
// Exact values depend on mock price interpolation between sparse data points;
// we verify structure and sign rather than specific numbers.
expect(chart2022.at(-1)).toMatchObject(
expect.objectContaining({
netPerformance: expect.any(Number),
netPerformanceInPercentage: expect.any(Number),
totalInvestmentValueWithCurrencyEffect: 44558.42
})
);
// Verify 2022 has an independent baseline: netPerformance starts at 0 for
// the first entry, proving it's not cumulative from the 2021 interval.
expect(chart2022[0].netPerformance).toEqual(0);
});
});
});

25
apps/api/src/app/portfolio/calculator/roai/portfolio-calculator-googl-buy.spec.ts

@ -90,6 +90,10 @@ describe('PortfolioCalculator', () => {
);
});
afterEach(() => {
jest.useRealTimers();
});
describe('get current positions', () => {
it.only('with GOOGL buy', async () => {
jest.useFakeTimers().setSystemTime(parseDate('2023-07-10').getTime());
@ -122,6 +126,10 @@ describe('PortfolioCalculator', () => {
const portfolioSnapshot = await portfolioCalculator.computeSnapshot();
// Make getPerformance() usable (see btcusd test for full explanation)
(portfolioCalculator as any).snapshot = portfolioSnapshot;
(portfolioCalculator as any).snapshotPromise = Promise.resolve();
const investments = portfolioCalculator.getInvestments();
const investmentsByMonth = portfolioCalculator.getInvestmentsByGroup({
@ -228,6 +236,23 @@ describe('PortfolioCalculator', () => {
expect(investmentsByYear).toEqual([
{ date: '2023-01-01', investment: 82.329056 }
]);
// Performance grouped by year: single year (2023-01-03 to 2023-07-10)
const { chart: chart2023 } = await portfolioCalculator.getPerformance({
end: parseDate('2023-07-10'),
start: parseDate('2023-01-03')
});
expect(chart2023.length).toBeGreaterThan(0);
// Verify independent baseline: netPerformance starts at 0
expect(chart2023[0].netPerformance).toEqual(0);
expect(chart2023.at(-1)).toMatchObject(
expect.objectContaining({
totalInvestmentValueWithCurrencyEffect: 82.329056
})
);
});
});
});

24
apps/api/src/app/portfolio/calculator/roai/portfolio-calculator-novn-buy-and-sell.spec.ts

@ -91,6 +91,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());
@ -122,6 +126,10 @@ describe('PortfolioCalculator', () => {
const portfolioSnapshot = await portfolioCalculator.computeSnapshot();
// Make getPerformance() usable (see btcusd test for full explanation)
(portfolioCalculator as any).snapshot = portfolioSnapshot;
(portfolioCalculator as any).snapshotPromise = Promise.resolve();
const investments = portfolioCalculator.getInvestments();
const investmentsByMonth = portfolioCalculator.getInvestmentsByGroup({
@ -259,6 +267,22 @@ describe('PortfolioCalculator', () => {
expect(investmentsByYear).toEqual([
{ date: '2022-01-01', investment: 0 }
]);
// Performance grouped by year: single year (2022-03-06 to 2022-04-11)
const { chart: chart2022 } = await portfolioCalculator.getPerformance({
end: parseDate('2022-04-11'),
start: parseDate('2022-03-06')
});
expect(chart2022.length).toBeGreaterThan(0);
expect(chart2022.at(-1)).toMatchObject(
expect.objectContaining({
netPerformance: 19.86,
netPerformanceInPercentage: 0.13100263852242744,
totalInvestmentValueWithCurrencyEffect: 0
})
);
});
});
});

170
apps/api/src/app/portfolio/portfolio-chart.helper.spec.ts

@ -0,0 +1,170 @@
import { PortfolioCalculator } from '@ghostfolio/api/app/portfolio/calculator/portfolio-calculator';
import { parseDate } from '@ghostfolio/common/helper';
import { HistoricalDataItem } from '@ghostfolio/common/interfaces';
import { getPerformanceByYear } from './portfolio-chart.helper';
describe('getPerformanceByYear', () => {
let mockGetPerformance: jest.Mock;
let portfolioCalculator: PortfolioCalculator;
beforeEach(() => {
mockGetPerformance = jest.fn();
portfolioCalculator = {
getPerformance: mockGetPerformance
} as unknown as PortfolioCalculator;
});
it('calls getPerformance once per year with correct intervals', async () => {
// Range spans two years: 2021-06-15 to 2022-03-10
mockGetPerformance.mockResolvedValue({
chart: [{ date: '2021-12-31', netPerformance: 100 }]
});
await getPerformanceByYear({
end: parseDate('2022-03-10'),
portfolioCalculator,
start: parseDate('2021-06-15')
});
expect(mockGetPerformance).toHaveBeenCalledTimes(2);
// First year: clamped start to actual start date, end to Dec 31
const firstCall = mockGetPerformance.mock.calls[0][0];
expect(firstCall.start).toEqual(parseDate('2021-06-15'));
expect(firstCall.end.getFullYear()).toEqual(2021);
expect(firstCall.end.getMonth()).toEqual(11); // December
expect(firstCall.end.getDate()).toEqual(31);
// Second year: starts Jan 1, clamped end to actual end date
const secondCall = mockGetPerformance.mock.calls[1][0];
expect(secondCall.start).toEqual(parseDate('2022-01-01'));
expect(secondCall.end).toEqual(parseDate('2022-03-10'));
});
it('normalizes output dates to YYYY-01-01', async () => {
mockGetPerformance.mockResolvedValue({
chart: [{ date: '2023-07-15', netPerformance: 50, netWorth: 1000 }]
});
const result = await getPerformanceByYear({
end: parseDate('2023-12-31'),
portfolioCalculator,
start: parseDate('2023-03-01')
});
expect(result).toHaveLength(1);
expect(result[0].date).toEqual('2023-01-01');
});
it('uses last chart entry for each year', async () => {
mockGetPerformance.mockResolvedValue({
chart: [
{ date: '2024-01-15', netPerformance: 10 },
{ date: '2024-06-15', netPerformance: 50 },
{ date: '2024-12-31', netPerformance: 100 }
]
});
const result = await getPerformanceByYear({
end: parseDate('2024-12-31'),
portfolioCalculator,
start: parseDate('2024-01-01')
});
expect(result).toHaveLength(1);
expect(result[0].netPerformance).toEqual(100);
});
it('handles single-year range', async () => {
mockGetPerformance.mockResolvedValue({
chart: [{ date: '2023-05-01', netPerformance: 200 }]
});
const result = await getPerformanceByYear({
end: parseDate('2023-09-30'),
portfolioCalculator,
start: parseDate('2023-05-01')
});
expect(mockGetPerformance).toHaveBeenCalledTimes(1);
expect(result).toHaveLength(1);
expect(result[0]).toEqual({ date: '2023-01-01', netPerformance: 200 });
});
it('handles three-year range', async () => {
mockGetPerformance
.mockResolvedValueOnce({
chart: [{ date: '2021-12-31', netPerformance: 100 }]
})
.mockResolvedValueOnce({
chart: [{ date: '2022-12-31', netPerformance: 200 }]
})
.mockResolvedValueOnce({
chart: [{ date: '2023-06-30', netPerformance: 150 }]
});
const result = await getPerformanceByYear({
end: parseDate('2023-06-30'),
portfolioCalculator,
start: parseDate('2021-03-01')
});
expect(mockGetPerformance).toHaveBeenCalledTimes(3);
expect(result).toHaveLength(3);
expect(result.map((r) => r.date)).toEqual([
'2021-01-01',
'2022-01-01',
'2023-01-01'
]);
expect(result.map((r) => r.netPerformance)).toEqual([100, 200, 150]);
});
it('skips years where getPerformance returns empty chart', async () => {
mockGetPerformance
.mockResolvedValueOnce({
chart: [{ date: '2021-12-31', netPerformance: 100 }]
})
.mockResolvedValueOnce({ chart: [] })
.mockResolvedValueOnce({
chart: [{ date: '2023-06-30', netPerformance: 300 }]
});
const result = await getPerformanceByYear({
end: parseDate('2023-06-30'),
portfolioCalculator,
start: parseDate('2021-03-01')
});
expect(mockGetPerformance).toHaveBeenCalledTimes(3);
expect(result).toHaveLength(2);
expect(result[0].date).toEqual('2021-01-01');
expect(result[1].date).toEqual('2023-01-01');
});
it('preserves all properties from the last chart entry', async () => {
const chartEntry: HistoricalDataItem = {
date: '2023-12-31',
netPerformance: 100,
netPerformanceInPercentage: 0.1,
netPerformanceWithCurrencyEffect: 95,
netPerformanceInPercentageWithCurrencyEffect: 0.095,
netWorth: 1000,
totalInvestment: 900,
totalInvestmentValueWithCurrencyEffect: 850
};
mockGetPerformance.mockResolvedValue({ chart: [chartEntry] });
const result = await getPerformanceByYear({
end: parseDate('2023-12-31'),
portfolioCalculator,
start: parseDate('2023-01-01')
});
expect(result[0]).toEqual({
...chartEntry,
date: '2023-01-01'
});
});
});

36
apps/api/src/app/portfolio/portfolio-chart.helper.ts

@ -0,0 +1,36 @@
import { PortfolioCalculator } from '@ghostfolio/api/app/portfolio/calculator/portfolio-calculator';
import { DATE_FORMAT } from '@ghostfolio/common/helper';
import { HistoricalDataItem } from '@ghostfolio/common/interfaces';
import { eachYearOfInterval, endOfYear, format, max, min } from 'date-fns';
export async function getPerformanceByYear({
end,
portfolioCalculator,
start
}: {
end: Date;
portfolioCalculator: PortfolioCalculator;
start: Date;
}): Promise<HistoricalDataItem[]> {
const chartByYear: HistoricalDataItem[] = [];
for (const yearDate of eachYearOfInterval({ start, end })) {
const intervalStart = max([yearDate, start]);
const intervalEnd = min([endOfYear(yearDate), end]);
const { chart } = await portfolioCalculator.getPerformance({
end: intervalEnd,
start: intervalStart
});
if (chart.length > 0) {
chartByYear.push({
...chart.at(-1),
date: format(yearDate, DATE_FORMAT)
});
}
}
return chartByYear;
}

2
apps/api/src/app/portfolio/portfolio.controller.ts

@ -510,6 +510,7 @@ export class PortfolioController {
@Query('accounts') filterByAccounts?: string,
@Query('assetClasses') filterByAssetClasses?: string,
@Query('dataSource') filterByDataSource?: string,
@Query('groupBy') groupBy?: Extract<GroupBy, 'year'>,
@Query('range') dateRange: DateRange = 'max',
@Query('symbol') filterBySymbol?: string,
@Query('tags') filterByTags?: string,
@ -528,6 +529,7 @@ export class PortfolioController {
const performanceInformation = await this.portfolioService.getPerformance({
dateRange,
filters,
groupBy,
impersonationId,
withExcludedAccounts,
userId: this.request.user.id

16
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 { getPerformanceByYear } 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<GroupBy, 'year'>;
impersonationId: string;
userId: string;
withExcludedAccounts?: boolean;
@ -1033,11 +1036,20 @@ export class PortfolioService {
const { endDate, startDate } = getIntervalFromDateRange(dateRange);
const { chart } = await portfolioCalculator.getPerformance({
const { chart: fullChart } = await portfolioCalculator.getPerformance({
end: endDate,
start: startDate
});
const chart =
groupBy === 'year'
? await getPerformanceByYear({
end: endDate,
portfolioCalculator,
start: startDate
})
: fullChart;
const {
netPerformance,
netPerformanceInPercentage,
@ -1046,7 +1058,7 @@ export class PortfolioService {
netWorth,
totalInvestment,
valueWithCurrencyEffect
} = chart?.at(-1) ?? {
} = fullChart?.at(-1) ?? {
netPerformance: 0,
netPerformanceInPercentage: 0,
netPerformanceInPercentageWithCurrencyEffect: 0,

Loading…
Cancel
Save