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 64882061f..d66e081e9 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 @@ -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; @@ -255,6 +266,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); }); }); }); 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 9b48a1324..3c70bae5e 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 @@ -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({ @@ -227,6 +235,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 + }) + ); }); }); }); 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 adbb5c3ff..f9118cba2 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 @@ -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({ @@ -258,6 +266,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 + }) + ); }); }); }); 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..f2d3b6020 --- /dev/null +++ b/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' + }); + }); +}); 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..4a30da97c --- /dev/null +++ b/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 { + 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; +} diff --git a/apps/api/src/app/portfolio/portfolio.controller.ts b/apps/api/src/app/portfolio/portfolio.controller.ts index b8aefe0ac..371a18eb0 100644 --- a/apps/api/src/app/portfolio/portfolio.controller.ts +++ b/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, @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 diff --git a/apps/api/src/app/portfolio/portfolio.service.ts b/apps/api/src/app/portfolio/portfolio.service.ts index 7be375473..a877a50ec 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 { getPerformanceByYear } from './portfolio-chart.helper'; import { RulesService } from './rules.service'; const Fuse = require('fuse.js'); @@ -969,11 +970,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; @@ -1026,11 +1029,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, @@ -1039,7 +1051,7 @@ export class PortfolioService { netWorth, totalInvestment, valueWithCurrencyEffect - } = chart?.at(-1) ?? { + } = fullChart?.at(-1) ?? { netPerformance: 0, netPerformanceInPercentage: 0, netPerformanceInPercentageWithCurrencyEffect: 0,