From 70ec5f277f7411ad2ab6c8f1915532f905e518c8 Mon Sep 17 00:00:00 2001 From: Thomas Kaul <4159106+dtslvr@users.noreply.github.com> Date: Tue, 6 Oct 2026 21:53:49 +0200 Subject: [PATCH] Improve chart date calculation and tests --- .../calculator/mwr/portfolio-calculator.ts | 1 - .../calculator/portfolio-calculator.ts | 17 +- ...-calculator-googl-fee-and-dividend.spec.ts | 31 ++- ...-calculator-msft-buy-with-dividend.spec.ts | 19 +- .../roai/portfolio-calculator.spec.ts | 185 +++++++++++++++++- .../calculator/roai/portfolio-calculator.ts | 4 +- .../calculator/roi/portfolio-calculator.ts | 1 - .../calculator/twr/portfolio-calculator.ts | 1 - 8 files changed, 232 insertions(+), 27 deletions(-) diff --git a/apps/api/src/app/portfolio/calculator/mwr/portfolio-calculator.ts b/apps/api/src/app/portfolio/calculator/mwr/portfolio-calculator.ts index 2572c70598..f83b175128 100644 --- a/apps/api/src/app/portfolio/calculator/mwr/portfolio-calculator.ts +++ b/apps/api/src/app/portfolio/calculator/mwr/portfolio-calculator.ts @@ -24,7 +24,6 @@ export class MwrPortfolioCalculator extends PortfolioCalculator { protected getHoldingPerformance({}: { chartDates: string[]; - daysUntilNextChartDate: { [date: string]: number }; end: Date; exchangeRates: { [dateString: string]: number }; marketSymbolMap: { diff --git a/apps/api/src/app/portfolio/calculator/portfolio-calculator.ts b/apps/api/src/app/portfolio/calculator/portfolio-calculator.ts index a9db3ba429..1154f897d1 100644 --- a/apps/api/src/app/portfolio/calculator/portfolio-calculator.ts +++ b/apps/api/src/app/portfolio/calculator/portfolio-calculator.ts @@ -364,14 +364,15 @@ export abstract class PortfolioCalculator { // The chart dates are the same for each holding and each date range. Thus // calculate the number of days until the next chart date one time only. - const daysUntilNextChartDate: { [date: string]: number } = {}; + const parsedChartDates = chartDates.map((chartDate) => { + return parseDate(chartDate); + }); - for (let i = 0; i < chartDates.length - 1; i += 1) { - daysUntilNextChartDate[chartDates[i]] = differenceInDays( - parseDate(chartDates[i + 1]), - parseDate(chartDates[i]) - ); - } + const daysUntilNextChartDate = parsedChartDates + .slice(1) + .map((nextChartDate, index) => { + return differenceInDays(nextChartDate, parsedChartDates[index]); + }); const errors: ResponseError['errors'] = []; let hasAnyHoldingPerformanceErrors = false; @@ -955,7 +956,7 @@ export abstract class PortfolioCalculator { symbol }: { chartDates: string[]; - daysUntilNextChartDate: { [date: string]: number }; + daysUntilNextChartDate: number[]; end: Date; exchangeRates: { [dateString: string]: number }; marketSymbolMap: { diff --git a/apps/api/src/app/portfolio/calculator/roai/portfolio-calculator-googl-fee-and-dividend.spec.ts b/apps/api/src/app/portfolio/calculator/roai/portfolio-calculator-googl-fee-and-dividend.spec.ts index f0d689d026..bb6385d18d 100644 --- a/apps/api/src/app/portfolio/calculator/roai/portfolio-calculator-googl-fee-and-dividend.spec.ts +++ b/apps/api/src/app/portfolio/calculator/roai/portfolio-calculator-googl-fee-and-dividend.spec.ts @@ -190,9 +190,12 @@ describe('PortfolioCalculator', () => { expect(performanceByDateRange).toMatchObject({ max: { dividendInBaseCurrency: 5.62, - dividendInPercentageWithCurrencyEffect: 0.01882242615044543, + dividendInPercentageWithCurrencyEffect: expect.closeTo( + 0.01882242615044535, + 10 + ), netPerformance: 18.87, - netPerformanceInPercentage: 0.06319914260834614 + netPerformanceInPercentage: expect.closeTo(0.06319914260834586, 10) } }); }); @@ -282,9 +285,12 @@ describe('PortfolioCalculator', () => { expect(performanceByDateRange).toMatchObject({ max: { dividendInBaseCurrency: 5.62, - dividendInPercentageWithCurrencyEffect: 0.01882242615044543, + dividendInPercentageWithCurrencyEffect: expect.closeTo( + 0.01882242615044535, + 10 + ), netPerformance: 19.87, - netPerformanceInPercentage: 0.06654832875611222 + netPerformanceInPercentage: expect.closeTo(0.06654832875611194, 10) } }); }); @@ -373,9 +379,12 @@ describe('PortfolioCalculator', () => { expect(performanceByDateRange).toMatchObject({ max: { dividendInBaseCurrency: 0.62, - dividendInPercentageWithCurrencyEffect: 0.002076495411614976, + dividendInPercentageWithCurrencyEffect: expect.closeTo( + 0.002076495411614967, + 10 + ), netPerformance: 14.87, - netPerformanceInPercentage: 0.049802398017281764 + netPerformanceInPercentage: expect.closeTo(0.049802398017281556, 10) } }); }); @@ -458,11 +467,17 @@ describe('PortfolioCalculator', () => { }, max: { dividendInBaseCurrency: 1.3, - dividendInPercentageWithCurrencyEffect: 0.004353941992095918 + dividendInPercentageWithCurrencyEffect: expect.closeTo( + 0.004353941992095899, + 10 + ) }, ytd: { dividendInBaseCurrency: 0.68, - dividendInPercentageWithCurrencyEffect: 0.002002886512915671 + dividendInPercentageWithCurrencyEffect: expect.closeTo( + 0.002002886512915673, + 10 + ) } }); }); diff --git a/apps/api/src/app/portfolio/calculator/roai/portfolio-calculator-msft-buy-with-dividend.spec.ts b/apps/api/src/app/portfolio/calculator/roai/portfolio-calculator-msft-buy-with-dividend.spec.ts index 7aa5221dab..2b89ddd31b 100644 --- a/apps/api/src/app/portfolio/calculator/roai/portfolio-calculator-msft-buy-with-dividend.spec.ts +++ b/apps/api/src/app/portfolio/calculator/roai/portfolio-calculator-msft-buy-with-dividend.spec.ts @@ -177,10 +177,16 @@ describe('PortfolioCalculator', () => { max: { date: '2023-07-10', dividendInBaseCurrency: 0.62, - dividendInPercentageWithCurrencyEffect: 0.002076495411614976, + dividendInPercentageWithCurrencyEffect: expect.closeTo( + 0.002076495411614967, + 10 + ), netPerformance: 14.87, - netPerformanceInPercentage: 0.049802398017281764, - netPerformanceInPercentageWithCurrencyEffect: 0.049802398017281764, + netPerformanceInPercentage: expect.closeTo(0.049802398017281556, 10), + netPerformanceInPercentageWithCurrencyEffect: expect.closeTo( + 0.049802398017281556, + 10 + ), netPerformanceWithCurrencyEffect: 14.87, totalInvestmentValueWithCurrencyEffect: 298.58, valueWithCurrencyEffect: 331.83 @@ -190,8 +196,11 @@ describe('PortfolioCalculator', () => { dividendInBaseCurrency: 0, dividendInPercentageWithCurrencyEffect: 0, netPerformance: -7.6800000000000015, - netPerformanceInPercentage: -0.022620835910576995, - netPerformanceInPercentageWithCurrencyEffect: -0.022620835910576995, + netPerformanceInPercentage: expect.closeTo(-0.022620835910577015, 10), + netPerformanceInPercentageWithCurrencyEffect: expect.closeTo( + -0.022620835910577015, + 10 + ), netPerformanceWithCurrencyEffect: -7.6800000000000015, totalInvestmentValueWithCurrencyEffect: 298.58, valueWithCurrencyEffect: 331.83 diff --git a/apps/api/src/app/portfolio/calculator/roai/portfolio-calculator.spec.ts b/apps/api/src/app/portfolio/calculator/roai/portfolio-calculator.spec.ts index 99bee2c21d..5f8d9cdc44 100644 --- a/apps/api/src/app/portfolio/calculator/roai/portfolio-calculator.spec.ts +++ b/apps/api/src/app/portfolio/calculator/roai/portfolio-calculator.spec.ts @@ -1,3 +1,186 @@ +import { PortfolioCalculator } from '@ghostfolio/api/app/portfolio/calculator/portfolio-calculator'; +import { userDummyData } from '@ghostfolio/api/app/portfolio/calculator/portfolio-calculator-test-utils'; +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 { 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'; +import { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-data/exchange-rate-data.service'; +import { PortfolioSnapshotService } from '@ghostfolio/api/services/queues/portfolio-snapshot/portfolio-snapshot.service'; +import { PortfolioSnapshotServiceMock } from '@ghostfolio/api/services/queues/portfolio-snapshot/portfolio-snapshot.service.mock'; +import { HistoricalDataItem } from '@ghostfolio/common/interfaces'; +import { PerformanceCalculationType } from '@ghostfolio/common/types/performance-calculation-type.type'; + +jest.mock('@ghostfolio/api/app/portfolio/current-rate.service', () => { + return { + CurrentRateService: jest.fn().mockImplementation(() => { + return CurrentRateServiceMock; + }) + }; +}); + +jest.mock( + '@ghostfolio/api/services/queues/portfolio-snapshot/portfolio-snapshot.service', + () => { + return { + PortfolioSnapshotService: jest.fn().mockImplementation(() => { + return PortfolioSnapshotServiceMock; + }) + }; + } +); + +jest.mock('@ghostfolio/api/app/redis-cache/redis-cache.service', () => { + return { + RedisCacheService: jest.fn().mockImplementation(() => { + return RedisCacheServiceMock; + }) + }; +}); + +function getHistoricalDataItem({ + date, + investment, + netPerformance +}: { + date: string; + investment: number; + netPerformance: number; +}): HistoricalDataItem { + return { + date, + netPerformance, + dividendInBaseCurrency: 0, + netPerformanceWithCurrencyEffect: netPerformance, + totalInvestment: investment, + totalInvestmentValueWithCurrencyEffect: investment, + value: investment + netPerformance, + valueWithCurrencyEffect: investment + netPerformance + }; +} + describe('PortfolioCalculator', () => { - test.skip('Skip empty test', () => 1); + let configurationService: ConfigurationService; + let currentRateService: CurrentRateService; + let exchangeRateDataService: ExchangeRateDataService; + let portfolioCalculator: PortfolioCalculator; + let portfolioCalculatorFactory: PortfolioCalculatorFactory; + let portfolioSnapshotService: PortfolioSnapshotService; + let redisCacheService: RedisCacheService; + + beforeEach(() => { + PortfolioSnapshotServiceMock.reset(); + RedisCacheServiceMock.reset(); + + configurationService = new ConfigurationService(); + + currentRateService = new CurrentRateService(null, null, null); + + exchangeRateDataService = new ExchangeRateDataService( + null, + null, + null, + null + ); + + portfolioSnapshotService = new PortfolioSnapshotService(null, null); + + redisCacheService = new RedisCacheService(null, null); + + portfolioCalculatorFactory = new PortfolioCalculatorFactory( + configurationService, + currentRateService, + exchangeRateDataService, + portfolioSnapshotService, + redisCacheService + ); + + portfolioCalculator = portfolioCalculatorFactory.createCalculator({ + activities: [], + calculationType: PerformanceCalculationType.ROAI, + currency: 'USD', + userId: userDummyData.id + }); + }); + + describe('calculate performance percentages for date range', () => { + it('with a buy followed by a gap between chart dates', () => { + const performancePercentagesByDate = portfolioCalculator[ + 'calculatePerformancePercentagesForDateRange' + ]({ + historicalDataItems: [ + getHistoricalDataItem({ + date: '2017-01-01', + investment: 100, + netPerformance: 0 + }), + // Buy, followed by a gap of 21 days until the next chart date + getHistoricalDataItem({ + date: '2017-01-02', + investment: 200, + netPerformance: 0 + }), + // Sell + getHistoricalDataItem({ + date: '2017-01-23', + investment: 100, + netPerformance: 44 + }) + ] + }); + + // The investment of each chart date applies to each day until the next + // chart date. The investment of the last chart date counts for this day + // only: (1 * 100 + 21 * 200 + 1 * 100) / 23 = 4400 / 23 + expect( + performancePercentagesByDate['2017-01-23'].netPerformanceInPercentage + ).toBeCloseTo(0.23, 10); + + expect( + performancePercentagesByDate['2017-01-23'] + .netPerformanceInPercentageWithCurrencyEffect + ).toBeCloseTo(0.23, 10); + }); + + it('with an additional chart date of the same investment', () => { + const performancePercentagesByDate = portfolioCalculator[ + 'calculatePerformancePercentagesForDateRange' + ]({ + historicalDataItems: [ + getHistoricalDataItem({ + date: '2017-01-01', + investment: 100, + netPerformance: 0 + }), + getHistoricalDataItem({ + date: '2017-01-02', + investment: 200, + netPerformance: 0 + }), + // An additional chart date splits the gap but must not change the + // average investment + getHistoricalDataItem({ + date: '2017-01-12', + investment: 200, + netPerformance: 20 + }), + getHistoricalDataItem({ + date: '2017-01-23', + investment: 100, + netPerformance: 44 + }) + ] + }); + + expect( + performancePercentagesByDate['2017-01-23'].netPerformanceInPercentage + ).toBeCloseTo(0.23, 10); + + expect( + performancePercentagesByDate['2017-01-23'] + .netPerformanceInPercentageWithCurrencyEffect + ).toBeCloseTo(0.23, 10); + }); + }); }); diff --git a/apps/api/src/app/portfolio/calculator/roai/portfolio-calculator.ts b/apps/api/src/app/portfolio/calculator/roai/portfolio-calculator.ts index cd75355f8b..0115b4a57c 100644 --- a/apps/api/src/app/portfolio/calculator/roai/portfolio-calculator.ts +++ b/apps/api/src/app/portfolio/calculator/roai/portfolio-calculator.ts @@ -315,7 +315,7 @@ export class RoaiPortfolioCalculator extends PortfolioCalculator { symbol }: { chartDates: string[]; - daysUntilNextChartDate: { [date: string]: number }; + daysUntilNextChartDate: number[]; end: Date; exchangeRates: { [dateString: string]: number }; marketSymbolMap: { @@ -678,7 +678,7 @@ export class RoaiPortfolioCalculator extends PortfolioCalculator { // date of the range counts for this day only. const investmentDays = isLastChartDateOfRange ? 1 - : daysUntilNextChartDate[date]; + : daysUntilNextChartDate[i]; if ( investmentValuesAccumulatedWithCurrencyEffect[date] instanceof Big && diff --git a/apps/api/src/app/portfolio/calculator/roi/portfolio-calculator.ts b/apps/api/src/app/portfolio/calculator/roi/portfolio-calculator.ts index 9c12a4e970..862febf5db 100644 --- a/apps/api/src/app/portfolio/calculator/roi/portfolio-calculator.ts +++ b/apps/api/src/app/portfolio/calculator/roi/portfolio-calculator.ts @@ -24,7 +24,6 @@ export class RoiPortfolioCalculator extends PortfolioCalculator { protected getHoldingPerformance({}: { chartDates: string[]; - daysUntilNextChartDate: { [date: string]: number }; end: Date; exchangeRates: { [dateString: string]: number }; marketSymbolMap: { diff --git a/apps/api/src/app/portfolio/calculator/twr/portfolio-calculator.ts b/apps/api/src/app/portfolio/calculator/twr/portfolio-calculator.ts index 875b85194b..cad53c31ab 100644 --- a/apps/api/src/app/portfolio/calculator/twr/portfolio-calculator.ts +++ b/apps/api/src/app/portfolio/calculator/twr/portfolio-calculator.ts @@ -24,7 +24,6 @@ export class TwrPortfolioCalculator extends PortfolioCalculator { protected getHoldingPerformance({}: { chartDates: string[]; - daysUntilNextChartDate: { [date: string]: number }; end: Date; exchangeRates: { [dateString: string]: number }; marketSymbolMap: {