From 2f07e54c8961ed3b5017dc933068261d2a4cf3e5 Mon Sep 17 00:00:00 2001 From: Pedro Moreira Date: Wed, 14 Oct 2026 22:26:17 +0100 Subject: [PATCH] Implement holding and overall performance of ROI portfolio calculator Extract getDateRangeBoundaries and getNetPerformanceOfDateRange into the base calculator and use them in both the ROAI and the ROI per-date-range loops to avoid duplicated date-range logic. ROAI behaviour is unchanged. --- .../calculator/portfolio-calculator.ts | 57 +++- .../calculator/roai/portfolio-calculator.ts | 37 +-- ...olio-calculator-reference-examples.spec.ts | 243 ++++++++++++++++ .../calculator/roi/portfolio-calculator.ts | 266 +++++++++++++++++- .../portfolio/current-rate.service.mock.ts | 36 +++ 5 files changed, 608 insertions(+), 31 deletions(-) create mode 100644 apps/api/src/app/portfolio/calculator/roi/portfolio-calculator-reference-examples.spec.ts diff --git a/apps/api/src/app/portfolio/calculator/portfolio-calculator.ts b/apps/api/src/app/portfolio/calculator/portfolio-calculator.ts index 1a754f20ad..23a43231cc 100644 --- a/apps/api/src/app/portfolio/calculator/portfolio-calculator.ts +++ b/apps/api/src/app/portfolio/calculator/portfolio-calculator.ts @@ -221,7 +221,8 @@ export abstract class PortfolioCalculator { } protected abstract calculateOverallPerformance( - positions: PortfolioCalculatorHolding[] + positions: PortfolioCalculatorHolding[], + accumulatedValuesByDate: { [date: string]: AccumulatedValues } ): PortfolioSnapshot; protected abstract calculatePerformancePercentages({ @@ -753,7 +754,10 @@ export abstract class PortfolioCalculator { }; }); - const overall = this.calculateOverallPerformance(positions); + const overall = this.calculateOverallPerformance( + positions, + accumulatedValuesByDate + ); return { ...overall, @@ -926,6 +930,28 @@ export abstract class PortfolioCalculator { return this.dataProviderInfos; } + /** + * Returns the start and the end date string of a date range, where the + * start date is not before the start date of the calculation + */ + protected getDateRangeBoundaries({ + dateRange, + start + }: { + dateRange: DateRange; + start: Date; + }) { + const { endDate, startDate } = getIntervalFromDateRange({ dateRange }); + + return { + rangeEndDateString: format(endDate, DATE_FORMAT), + rangeStartDateString: format( + isBefore(startDate, start) ? start : startDate, + DATE_FORMAT + ) + }; + } + /** * Returns the date ranges of the performance of a holding: the fixed ranges * and each past calendar year between the start and the end date @@ -1617,6 +1643,33 @@ export abstract class PortfolioCalculator { return this.snapshot.totalLiabilitiesWithCurrencyEffect; } + /** + * Returns the net performance with currency effect within a date range + */ + protected getNetPerformanceOfDateRange({ + dateRange, + netPerformanceValuesWithCurrencyEffect, + rangeEndDateString, + rangeStartDateString + }: { + dateRange: DateRange; + netPerformanceValuesWithCurrencyEffect: { [date: string]: Big }; + rangeEndDateString: string; + rangeStartDateString: string; + }) { + return ( + netPerformanceValuesWithCurrencyEffect[rangeEndDateString]?.minus( + // If the date range is 'max', take 0 as a start value. Otherwise, + // the value of the end of the day of the start date is taken which + // differs from the buying price. + dateRange === 'max' + ? new Big(0) + : (netPerformanceValuesWithCurrencyEffect[rangeStartDateString] ?? + new Big(0)) + ) ?? new Big(0) + ); + } + public async getPerformance({ end, start }: { end: Date; start: Date }) { await this.snapshotPromise; 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 93c90bee2d..474036b927 100644 --- a/apps/api/src/app/portfolio/calculator/roai/portfolio-calculator.ts +++ b/apps/api/src/app/portfolio/calculator/roai/portfolio-calculator.ts @@ -5,11 +5,8 @@ import { HoldingPerformance } from '@ghostfolio/api/app/portfolio/interfaces/hol import { PortfolioCalculatorHolding } from '@ghostfolio/api/app/portfolio/interfaces/portfolio-calculator-holding.interface'; import { WeightedInvestment } from '@ghostfolio/api/app/portfolio/interfaces/weighted-investment.interface'; import { PerformancePercentages } from '@ghostfolio/api/app/portfolio/types/performance-percentages.type'; -import { - getAnnualizedPerformancePercent, - getIntervalFromDateRange -} from '@ghostfolio/common/calculation-helper'; -import { DATE_FORMAT, parseDate } from '@ghostfolio/common/helper'; +import { getAnnualizedPerformancePercent } from '@ghostfolio/common/calculation-helper'; +import { parseDate } from '@ghostfolio/common/helper'; import { AssetProfileIdentifier, HistoricalDataItem @@ -19,7 +16,7 @@ import { DateRange } from '@ghostfolio/common/types'; import { PerformanceCalculationType } from '@ghostfolio/common/types/performance-calculation-type.type'; import { Big } from 'big.js'; -import { differenceInDays, format, isBefore } from 'date-fns'; +import { differenceInDays } from 'date-fns'; import { sortedIndex } from 'lodash-es'; export class RoaiPortfolioCalculator extends PortfolioCalculator { @@ -294,6 +291,7 @@ export class RoaiPortfolioCalculator extends PortfolioCalculator { symbol }); + // `!` does not narrow the union under `strictNullChecks: false` if (holdingValuationOfPeriod.hasMarketPrices === false) { return holdingValuationOfPeriod.holdingPerformance; } @@ -514,16 +512,8 @@ export class RoaiPortfolioCalculator extends PortfolioCalculator { } = {}; for (const dateRange of this.getDateRanges({ end, start })) { - const dateInterval = getIntervalFromDateRange({ dateRange }); - const endDate = dateInterval.endDate; - let startDate = dateInterval.startDate; - - if (isBefore(startDate, start)) { - startDate = start; - } - - const rangeEndDateString = format(endDate, DATE_FORMAT); - const rangeStartDateString = format(startDate, DATE_FORMAT); + const { rangeEndDateString, rangeStartDateString } = + this.getDateRangeBoundaries({ dateRange, start }); const currentValuesAtDateRangeStartWithCurrencyEffect = currentValuesWithCurrencyEffect[rangeStartDateString] ?? new Big(0); @@ -568,15 +558,12 @@ export class RoaiPortfolioCalculator extends PortfolioCalculator { : new Big(0); netPerformanceWithCurrencyEffectMap[dateRange] = - netPerformanceValuesWithCurrencyEffect[rangeEndDateString]?.minus( - // If the date range is 'max', take 0 as a start value. Otherwise, - // the value of the end of the day of the start date is taken which - // differs from the buying price. - dateRange === 'max' - ? new Big(0) - : (netPerformanceValuesWithCurrencyEffect[rangeStartDateString] ?? - new Big(0)) - ) ?? new Big(0); + this.getNetPerformanceOfDateRange({ + dateRange, + netPerformanceValuesWithCurrencyEffect, + rangeEndDateString, + rangeStartDateString + }); netPerformancePercentageWithCurrencyEffectMap[dateRange] = average.gt(0) ? netPerformanceWithCurrencyEffectMap[dateRange].div(average) diff --git a/apps/api/src/app/portfolio/calculator/roi/portfolio-calculator-reference-examples.spec.ts b/apps/api/src/app/portfolio/calculator/roi/portfolio-calculator-reference-examples.spec.ts new file mode 100644 index 0000000000..085c88ce1c --- /dev/null +++ b/apps/api/src/app/portfolio/calculator/roi/portfolio-calculator-reference-examples.spec.ts @@ -0,0 +1,243 @@ +import { + activityDummyData, + assetProfileDummyData, + 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 { parseDate } from '@ghostfolio/common/helper'; +import { Activity } from '@ghostfolio/common/interfaces'; +import { PerformanceCalculationType } from '@ghostfolio/common/types/performance-calculation-type.type'; + +import { Big } from 'big.js'; + +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 getActivity({ + date, + quantity, + symbol, + type, + unitPrice +}: { + date: string; + quantity: number; + symbol: string; + type: Activity['type']; + unitPrice: number; +}): Activity { + return { + ...activityDummyData, + quantity, + type, + assetProfile: { + ...assetProfileDummyData, + symbol, + currency: 'CHF', + dataSource: 'YAHOO', + name: symbol + }, + date: parseDate(date), + feeInAssetProfileCurrency: 0, + feeInBaseCurrency: 0, + unitPriceInAssetProfileCurrency: unitPrice + }; +} + +describe('RoiPortfolioCalculator', () => { + let portfolioCalculatorFactory: PortfolioCalculatorFactory; + + beforeEach(() => { + PortfolioSnapshotServiceMock.reset(); + RedisCacheServiceMock.reset(); + + portfolioCalculatorFactory = new PortfolioCalculatorFactory( + new ConfigurationService(), + new CurrentRateService(null, null, null), + new ExchangeRateDataService(null, null, null, null), + new PortfolioSnapshotService(null, null), + new RedisCacheService(null, null) + ); + }); + + afterEach(() => { + jest.useRealTimers(); + }); + + describe('get current positions', () => { + it('with a small buy followed by a large buy', async () => { + jest.useFakeTimers().setSystemTime(parseDate('2024-12-31').getTime()); + + const portfolioCalculator = portfolioCalculatorFactory.createCalculator({ + activities: [ + getActivity({ + date: '2024-01-01', + quantity: 100, + symbol: 'ROI-DCA', + type: 'BUY', + unitPrice: 10 + }), + getActivity({ + date: '2024-10-27', + quantity: 600, + symbol: 'ROI-DCA', + type: 'BUY', + unitPrice: 15 + }) + ], + calculationType: PerformanceCalculationType.ROI, + currency: 'CHF', + userId: userDummyData.id + }); + + const portfolioSnapshot = await portfolioCalculator.computeSnapshot(); + + // Gain: 700 * 15 - (1000 + 9000) = 500, total buy value: 10000 + expect(portfolioSnapshot.positions).toMatchObject([ + { + netPerformance: new Big('500'), + netPerformancePercentage: new Big('500').div('10000'), + netPerformancePercentageWithCurrencyEffectMap: { + max: new Big('500').div('10000') + }, + netPerformanceWithCurrencyEffectMap: { max: new Big('500') } + } + ]); + + expect(portfolioSnapshot.historicalData.at(-1)).toMatchObject({ + date: '2024-12-31', + netPerformance: 500, + netPerformanceInPercentage: 0.05, + netPerformanceInPercentageWithCurrencyEffect: 0.05, + totalBuyValue: 10000, + totalBuyValueWithCurrencyEffect: 10000, + totalInvestment: 10000, + value: 10500 + }); + }); + + it('with a buy followed by a partial sell', async () => { + jest.useFakeTimers().setSystemTime(parseDate('2024-12-31').getTime()); + + const portfolioCalculator = portfolioCalculatorFactory.createCalculator({ + activities: [ + getActivity({ + date: '2024-01-01', + quantity: 100, + symbol: 'ROI-PARTIAL-SELL', + type: 'BUY', + unitPrice: 10 + }), + getActivity({ + date: '2024-06-01', + quantity: 50, + symbol: 'ROI-PARTIAL-SELL', + type: 'SELL', + unitPrice: 16 + }) + ], + calculationType: PerformanceCalculationType.ROI, + currency: 'CHF', + userId: userDummyData.id + }); + + const portfolioSnapshot = await portfolioCalculator.computeSnapshot(); + + // Gain: 50 * (16 - 10) + 50 * (18 - 10) = 700, total buy value: 1000 + expect(portfolioSnapshot.positions).toMatchObject([ + { + netPerformance: new Big('700'), + netPerformancePercentage: new Big('700').div('1000'), + netPerformancePercentageWithCurrencyEffectMap: { + max: new Big('700').div('1000') + } + } + ]); + + expect(portfolioSnapshot.historicalData.at(-1)).toMatchObject({ + netPerformanceInPercentage: 0.7, + totalBuyValue: 1000 + }); + }); + + it('with a buy, a full sell and a buy again', async () => { + jest.useFakeTimers().setSystemTime(parseDate('2024-12-31').getTime()); + + const portfolioCalculator = portfolioCalculatorFactory.createCalculator({ + activities: [ + getActivity({ + date: '2024-01-01', + quantity: 100, + symbol: 'ROI-REBUY', + type: 'BUY', + unitPrice: 10 + }), + getActivity({ + date: '2024-06-01', + quantity: 100, + symbol: 'ROI-REBUY', + type: 'SELL', + unitPrice: 10 + }), + getActivity({ + date: '2024-06-02', + quantity: 100, + symbol: 'ROI-REBUY', + type: 'BUY', + unitPrice: 10 + }) + ], + calculationType: PerformanceCalculationType.ROI, + currency: 'CHF', + userId: userDummyData.id + }); + + const portfolioSnapshot = await portfolioCalculator.computeSnapshot(); + + // Gain: 100 * (11 - 10) = 100, the money is invested twice: 2000 + expect(portfolioSnapshot.positions).toMatchObject([ + { + netPerformance: new Big('100'), + netPerformancePercentage: new Big('100').div('2000') + } + ]); + + expect(portfolioSnapshot.historicalData.at(-1)).toMatchObject({ + netPerformanceInPercentage: 0.05, + totalBuyValue: 2000 + }); + }); + }); +}); 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 40b0ac4a4f..4848b8032b 100644 --- a/apps/api/src/app/portfolio/calculator/roi/portfolio-calculator.ts +++ b/apps/api/src/app/portfolio/calculator/roi/portfolio-calculator.ts @@ -1,19 +1,89 @@ import { PortfolioCalculator } from '@ghostfolio/api/app/portfolio/calculator/portfolio-calculator'; import { AccumulatedValues } from '@ghostfolio/api/app/portfolio/interfaces/accumulated-values.interface'; import { HoldingPerformance } from '@ghostfolio/api/app/portfolio/interfaces/holding-performance.interface'; +import { PortfolioCalculatorHolding } from '@ghostfolio/api/app/portfolio/interfaces/portfolio-calculator-holding.interface'; import { PerformancePercentages } from '@ghostfolio/api/app/portfolio/types/performance-percentages.type'; +import { getAnnualizedPerformancePercent } from '@ghostfolio/common/calculation-helper'; +import { DATE_FORMAT } from '@ghostfolio/common/helper'; import { AssetProfileIdentifier, HistoricalDataItem } from '@ghostfolio/common/interfaces'; import { PortfolioSnapshot } from '@ghostfolio/common/models'; +import { DateRange } from '@ghostfolio/common/types'; import { PerformanceCalculationType } from '@ghostfolio/common/types/performance-calculation-type.type'; import { Big } from 'big.js'; +import { differenceInDays, format } from 'date-fns'; export class RoiPortfolioCalculator extends PortfolioCalculator { - protected calculateOverallPerformance(): PortfolioSnapshot { - throw new Error('Method not implemented.'); + protected calculateOverallPerformance( + positions: PortfolioCalculatorHolding[], + accumulatedValuesByDate: { [date: string]: AccumulatedValues } + ): PortfolioSnapshot { + const { + currentValueInBaseCurrency, + hasErrors, + totalFeesWithCurrencyEffect, + totalInvestment, + totalInvestmentWithCurrencyEffect + } = this.getTotalsOfPositions(positions); + + const dateOfFirstActivity = this.getStartDate(); + + const daysInMarket = dateOfFirstActivity + ? differenceInDays(new Date(), dateOfFirstActivity) + : 0; + + // The values of the last chart date contain the dividends and the buys of + // the holdings with market prices only. Thus, the dividend yield is not + // distorted by holdings without a total buy value. + const lastAccumulatedValues = Object.values(accumulatedValuesByDate).at(-1); + + const totalBuyValue = lastAccumulatedValues?.totalBuyValue ?? new Big(0); + + const totalBuyValueWithCurrencyEffect = + lastAccumulatedValues?.totalBuyValueWithCurrencyEffect ?? new Big(0); + + const totalDividendValueWithCurrencyEffect = + lastAccumulatedValues?.totalDividendValueWithCurrencyEffect ?? new Big(0); + + const dividendYieldPercent = getAnnualizedPerformancePercent({ + daysInMarket, + netPerformancePercentage: totalBuyValue.eq(0) + ? new Big(0) + : totalDividendValueWithCurrencyEffect.div(totalBuyValue) + }); + + const dividendYieldPercentWithCurrencyEffect = + getAnnualizedPerformancePercent({ + daysInMarket, + netPerformancePercentage: totalBuyValueWithCurrencyEffect.eq(0) + ? new Big(0) + : totalDividendValueWithCurrencyEffect.div( + totalBuyValueWithCurrencyEffect + ) + }); + + return { + currentValueInBaseCurrency, + dividendYieldPercent, + dividendYieldPercentWithCurrencyEffect, + hasErrors, + positions, + totalFeesWithCurrencyEffect, + totalInvestment, + totalInvestmentWithCurrencyEffect, + activitiesCount: this.activities.filter(({ type }) => { + return ['BUY', 'SELL'].includes(type); + }).length, + createdAt: new Date(), + errors: [], + historicalData: [], + totalCashInBaseCurrency: new Big(0), + totalInterestWithCurrencyEffect: new Big(0), + totalLiabilitiesWithCurrencyEffect: new Big(0) + }; } protected calculatePerformancePercentages({ @@ -122,8 +192,17 @@ export class RoiPortfolioCalculator extends PortfolioCalculator { }; } - protected getHoldingPerformance({}: { + protected getHoldingPerformance({ + chartDates, + dataSource, + end, + exchangeRates, + marketSymbolMap, + start, + symbol + }: { chartDates: string[]; + daysUntilNextChartDate: number[]; end: Date; exchangeRates: { [dateString: string]: number }; marketSymbolMap: { @@ -131,7 +210,186 @@ export class RoiPortfolioCalculator extends PortfolioCalculator { }; start: Date; } & AssetProfileIdentifier): HoldingPerformance { - throw new Error('Method not implemented.'); + const holdingValuationOfPeriod = this.getHoldingValuationOfPeriod({ + chartDates, + dataSource, + end, + exchangeRates, + marketSymbolMap, + start, + symbol + }); + + // `!` does not narrow the union under `strictNullChecks: false` + if (holdingValuationOfPeriod.hasMarketPrices === false) { + return holdingValuationOfPeriod.holdingPerformance; + } + + const { + dateOfFirstActivity, + holdingValuation: { + buyValuesAccumulated, + buyValuesAccumulatedWithCurrencyEffect, + currentValues, + currentValuesWithCurrencyEffect, + dividendValuesWithCurrencyEffect, + initialValue, + investmentValuesAccumulated, + investmentValuesAccumulatedWithCurrencyEffect, + investmentValuesWithCurrencyEffect, + items, + netPerformanceValues, + netPerformanceValuesWithCurrencyEffect + }, + totalDividend, + totalDividendInBaseCurrency, + totalInterestInBaseCurrency, + totalLiabilitiesInBaseCurrency, + unitPriceAtEndDate + } = holdingValuationOfPeriod; + + const endItem = items.find(({ itemType }) => { + return itemType === 'end'; + }); + + const startItem = items.find(({ itemType }) => { + return itemType === 'start'; + }); + + const endDateString = format(end, DATE_FORMAT); + + // The invested capital of the period is the value at the start date plus + // the value of each buy. A sell does not reduce it. + const investedCapital = startItem.value.plus( + buyValuesAccumulated[endDateString] ?? new Big(0) + ); + + const investedCapitalWithCurrencyEffect = + startItem.valueWithCurrencyEffect.plus( + buyValuesAccumulatedWithCurrencyEffect[endDateString] ?? new Big(0) + ); + + const totalGrossPerformance = endItem.grossPerformance.minus( + startItem.grossPerformance + ); + + const totalGrossPerformanceWithCurrencyEffect = + endItem.grossPerformanceWithCurrencyEffect.minus( + startItem.grossPerformanceWithCurrencyEffect + ); + + const totalNetPerformance = totalGrossPerformance.minus( + endItem.fees.minus(startItem.fees) + ); + + const grossPerformancePercentage = investedCapital.gt(0) + ? totalGrossPerformance.div(investedCapital) + : new Big(0); + + const grossPerformancePercentageWithCurrencyEffect = + investedCapitalWithCurrencyEffect.gt(0) + ? totalGrossPerformanceWithCurrencyEffect.div( + investedCapitalWithCurrencyEffect + ) + : new Big(0); + + const netPerformancePercentage = investedCapital.gt(0) + ? totalNetPerformance.div(investedCapital) + : new Big(0); + + const daysInMarket = differenceInDays(new Date(), dateOfFirstActivity); + + const dividendYieldPercent = getAnnualizedPerformancePercent({ + daysInMarket, + netPerformancePercentage: investedCapital.eq(0) + ? new Big(0) + : totalDividendInBaseCurrency.div(investedCapital) + }); + + const dividendYieldPercentWithCurrencyEffect = + getAnnualizedPerformancePercent({ + daysInMarket, + netPerformancePercentage: investedCapitalWithCurrencyEffect.eq(0) + ? new Big(0) + : totalDividendInBaseCurrency.div(investedCapitalWithCurrencyEffect) + }); + + const netPerformancePercentageWithCurrencyEffectMap: { + [key: DateRange]: Big; + } = {}; + + const netPerformanceWithCurrencyEffectMap: { + [key: DateRange]: Big; + } = {}; + + for (const dateRange of this.getDateRanges({ end, start })) { + const { rangeEndDateString, rangeStartDateString } = + this.getDateRangeBoundaries({ dateRange, start }); + + netPerformanceWithCurrencyEffectMap[dateRange] = + this.getNetPerformanceOfDateRange({ + dateRange, + netPerformanceValuesWithCurrencyEffect, + rangeEndDateString, + rangeStartDateString + }); + + const investedCapitalOfDateRange = ( + currentValuesWithCurrencyEffect[rangeStartDateString] ?? new Big(0) + ) + .plus( + buyValuesAccumulatedWithCurrencyEffect[rangeEndDateString] ?? + new Big(0) + ) + .minus( + buyValuesAccumulatedWithCurrencyEffect[rangeStartDateString] ?? + new Big(0) + ); + + netPerformancePercentageWithCurrencyEffectMap[dateRange] = + investedCapitalOfDateRange.gt(0) + ? netPerformanceWithCurrencyEffectMap[dateRange].div( + investedCapitalOfDateRange + ) + : new Big(0); + } + + return { + buyValuesAccumulated, + buyValuesAccumulatedWithCurrencyEffect, + currentValues, + currentValuesWithCurrencyEffect, + dividendValuesWithCurrencyEffect, + dividendYieldPercent, + dividendYieldPercentWithCurrencyEffect, + grossPerformancePercentage, + grossPerformancePercentageWithCurrencyEffect, + investmentValuesAccumulated, + investmentValuesAccumulatedWithCurrencyEffect, + investmentValuesWithCurrencyEffect, + netPerformancePercentage, + netPerformancePercentageWithCurrencyEffectMap, + netPerformanceValues, + netPerformanceValuesWithCurrencyEffect, + netPerformanceWithCurrencyEffectMap, + totalDividend, + totalDividendInBaseCurrency, + totalInterestInBaseCurrency, + totalLiabilitiesInBaseCurrency, + // The ROI does not weight the investment by time + averageInvestment: new Big(0), + averageInvestmentValues: {}, + averageInvestmentValuesWithCurrencyEffect: {}, + averageInvestmentWithCurrencyEffect: new Big(0), + grossPerformance: totalGrossPerformance, + grossPerformanceWithCurrencyEffect: + totalGrossPerformanceWithCurrencyEffect, + hasErrors: + endItem.quantity.gt(0) && (!initialValue || !unitPriceAtEndDate), + netPerformance: totalNetPerformance, + totalInvestment: endItem.investment, + totalInvestmentWithCurrencyEffect: endItem.investmentWithCurrencyEffect + }; } protected getPerformanceCalculationType() { diff --git a/apps/api/src/app/portfolio/current-rate.service.mock.ts b/apps/api/src/app/portfolio/current-rate.service.mock.ts index 18739ddab5..868bade3f8 100644 --- a/apps/api/src/app/portfolio/current-rate.service.mock.ts +++ b/apps/api/src/app/portfolio/current-rate.service.mock.ts @@ -123,6 +123,42 @@ function mockGetValue({ return { marketPrice: 0 }; + case 'ROI-DCA': + if (isSameDay(parseDate('2024-01-01'), date)) { + return { marketPrice: 10 }; + } else if ( + isSameDay(parseDate('2024-10-27'), date) || + isSameDay(parseDate('2024-12-31'), date) + ) { + return { marketPrice: 15 }; + } + + return { marketPrice: 0 }; + + case 'ROI-PARTIAL-SELL': + if (isSameDay(parseDate('2024-01-01'), date)) { + return { marketPrice: 10 }; + } else if (isSameDay(parseDate('2024-06-01'), date)) { + return { marketPrice: 16 }; + } else if (isSameDay(parseDate('2024-12-31'), date)) { + return { marketPrice: 18 }; + } + + return { marketPrice: 0 }; + + case 'ROI-REBUY': + if ( + isSameDay(parseDate('2024-01-01'), date) || + isSameDay(parseDate('2024-06-01'), date) || + isSameDay(parseDate('2024-06-02'), date) + ) { + return { marketPrice: 10 }; + } else if (isSameDay(parseDate('2024-12-31'), date)) { + return { marketPrice: 11 }; + } + + return { marketPrice: 0 }; + default: return { marketPrice: 0 }; }