From beb8f9de78950f373375193bb090ee9587d70881 Mon Sep 17 00:00:00 2001 From: Thomas Kaul <4159106+dtslvr@users.noreply.github.com> Date: Sat, 3 Oct 2026 09:13:08 +0200 Subject: [PATCH] Bugfix/carry forward of market prices between chart dates (#8009) * Carry forward of market prices between chart dates * Update changelog --- CHANGELOG.md | 4 + .../calculator/portfolio-calculator.ts | 33 ++-- ...h-market-price-between-chart-dates.spec.ts | 170 ++++++++++++++++++ .../portfolio/current-rate.service.mock.ts | 14 ++ 4 files changed, 211 insertions(+), 10 deletions(-) create mode 100644 apps/api/src/app/portfolio/calculator/roai/portfolio-calculator-manual-buy-with-market-price-between-chart-dates.spec.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index ff764bf4a8..0d3af3346e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -13,6 +13,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Upgraded `ai` from version `7.0.37` to `7.0.114` - Upgraded `dotenv` from version `17.4.2` to `18.0.3` +### Fixed + +- Fixed the portfolio calculation for holdings with historical market prices between the chart dates + ## 3.77.0 - 2026-10-02 ### Added diff --git a/apps/api/src/app/portfolio/calculator/portfolio-calculator.ts b/apps/api/src/app/portfolio/calculator/portfolio-calculator.ts index e03b530258..eb1e6f9fac 100644 --- a/apps/api/src/app/portfolio/calculator/portfolio-calculator.ts +++ b/apps/api/src/app/portfolio/calculator/portfolio-calculator.ts @@ -791,6 +791,16 @@ export abstract class PortfolioCalculator { activitiesByDate[activity.date].push(activity); } + // Carry forward the market prices of all dates, not only of the chart + // dates, as sparse market data (e.g. MANUAL) can be between two chart dates + const marketPriceDates = Object.keys(marketSymbolMap) + .filter((date) => { + return !!marketSymbolMap[date][assetProfileIdentifier]; + }) + .sort(); + + let indexOfMarketPriceDate = 0; + for (const dateString of chartDates) { if (dateString < startDateString) { continue; @@ -798,6 +808,18 @@ export abstract class PortfolioCalculator { break; } + while ( + indexOfMarketPriceDate < marketPriceDates.length && + marketPriceDates[indexOfMarketPriceDate] <= dateString + ) { + lastMarketPrice = + marketSymbolMap[marketPriceDates[indexOfMarketPriceDate]][ + assetProfileIdentifier + ]; + + indexOfMarketPriceDate += 1; + } + const activitiesOfDate = activitiesByDate[dateString]; if (!lastMarketPrice && activitiesOfDate?.length > 0) { @@ -808,13 +830,8 @@ export abstract class PortfolioCalculator { } } - const marketPrice = marketSymbolMap[dateString]?.[assetProfileIdentifier]; - const unitPrice = - marketPrice ?? - lastMarketPrice ?? - lastActivityUnitPrice ?? - unitPriceAtEndDate; + lastMarketPrice ?? lastActivityUnitPrice ?? unitPriceAtEndDate; if (activitiesOfDate?.length > 0) { for (const activity of activitiesOfDate) { @@ -832,10 +849,6 @@ export abstract class PortfolioCalculator { unitPriceFromMarketData: unitPrice }); } - - if (marketPrice) { - lastMarketPrice = marketPrice; - } } // Sort the activities so that the start and end placeholder activities diff --git a/apps/api/src/app/portfolio/calculator/roai/portfolio-calculator-manual-buy-with-market-price-between-chart-dates.spec.ts b/apps/api/src/app/portfolio/calculator/roai/portfolio-calculator-manual-buy-with-market-price-between-chart-dates.spec.ts new file mode 100644 index 0000000000..989409e77b --- /dev/null +++ b/apps/api/src/app/portfolio/calculator/roai/portfolio-calculator-manual-buy-with-market-price-between-chart-dates.spec.ts @@ -0,0 +1,170 @@ +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; + }) + }; +}); + +describe('PortfolioCalculator', () => { + let configurationService: ConfigurationService; + let currentRateService: CurrentRateService; + let exchangeRateDataService: ExchangeRateDataService; + 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 + ); + }); + + describe('get current positions', () => { + it('with MANUAL buy and market price between two chart dates', async () => { + jest.useFakeTimers().setSystemTime(parseDate('2024-01-31').getTime()); + + const activities: Activity[] = [ + { + ...activityDummyData, + assetProfile: { + ...assetProfileDummyData, + currency: 'USD', + dataSource: 'MANUAL', + name: 'Private Investment', + symbol: '6c0c5cee-0208-4975-b473-03baf2518497' + }, + date: parseDate('2021-01-04'), + feeInAssetProfileCurrency: 0, + feeInBaseCurrency: 0, + quantity: 10, + type: 'BUY', + unitPriceInAssetProfileCurrency: 50 + } + ]; + + const portfolioCalculator = portfolioCalculatorFactory.createCalculator({ + activities, + calculationType: PerformanceCalculationType.ROAI, + currency: 'USD', + userId: userDummyData.id + }); + + const portfolioSnapshot = await portfolioCalculator.computeSnapshot(); + + const historicalDataByDate = Object.fromEntries( + portfolioSnapshot.historicalData.map((historicalDataItem) => { + return [historicalDataItem.date, historicalDataItem]; + }) + ); + + /** + * The only historical market prices are on 2023-06-14 and 2023-06-15, + * which are not chart dates (every third day from 2021-01-03) + */ + expect(historicalDataByDate['2023-06-14']).toBeUndefined(); + expect(historicalDataByDate['2023-06-15']).toBeUndefined(); + + /** + * The chart dates before the market prices use the unit price of the + * activity: 50 + */ + expect(historicalDataByDate['2023-06-13']).toMatchObject({ + netPerformance: 0, // 10 * (50 - 50) = 0 + totalInvestment: 500, + value: 500 // 10 * 50 = 500 + }); + + /** + * The chart dates after the market prices carry the latest one forward + * (100), not the first one (80) + */ + expect(historicalDataByDate['2023-06-16']).toMatchObject({ + netPerformance: 500, // 10 * (100 - 50) = 500 + totalInvestment: 500, + value: 1000 // 10 * 100 = 1000 + }); + + expect(historicalDataByDate['2024-01-30']).toMatchObject({ + netPerformance: 500, // 10 * (100 - 50) = 500 + totalInvestment: 500, + value: 1000 // 10 * 100 = 1000 + }); + + /** + * The market price is unchanged since 2023-06-15, hence there is no + * performance today + */ + expect(portfolioSnapshot.positions[0]).toMatchObject({ + netPerformancePercentageWithCurrencyEffectMap: { + '1d': new Big(0) + }, + netPerformanceWithCurrencyEffectMap: { + '1d': new Big(0), + max: new Big(500) // 10 * (100 - 50) = 500 + } + }); + }); + }); +}); 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 7fcacad610..18739ddab5 100644 --- a/apps/api/src/app/portfolio/current-rate.service.mock.ts +++ b/apps/api/src/app/portfolio/current-rate.service.mock.ts @@ -30,6 +30,20 @@ function mockGetValue({ return { marketPrice: 0 }; + case '6c0c5cee-0208-4975-b473-03baf2518497': + // Sparse market data with the quote of today, which falls back to the + // latest market price + if (isSameDay(parseDate('2023-06-14'), date)) { + return { marketPrice: 80 }; + } else if ( + isSameDay(parseDate('2023-06-15'), date) || + isSameDay(parseDate('2024-01-31'), date) + ) { + return { marketPrice: 100 }; + } + + return { marketPrice: 0 }; + case 'BALN.SW': if (isSameDay(parseDate('2021-11-12'), date)) { return { marketPrice: 146 };