From ed29c2ceab548741743577fbff1831be885b5eb3 Mon Sep 17 00:00:00 2001 From: Thomas Kaul <4159106+dtslvr@users.noreply.github.com> Date: Thu, 12 Feb 2026 18:35:45 +0100 Subject: [PATCH 1/5] Bugfix/fix issue in annualized performance calculation (#6310) * Fix issue in annualized performance calculation: Handle case where growthFactor is Infinity * Update changelog --- CHANGELOG.md | 4 ++++ libs/common/src/lib/calculation-helper.ts | 4 ++-- 2 files changed, 6 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index edbfa5460..696ed2dbd 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -11,6 +11,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Upgraded `twitter-api-v2` from version `1.27.0` to `1.29.0` +### Fixed + +- Fixed an issue in the annualized performance calculation + ## 2.237.0 - 2026-02-08 ### Changed diff --git a/libs/common/src/lib/calculation-helper.ts b/libs/common/src/lib/calculation-helper.ts index d67384a30..76b38f9b2 100644 --- a/libs/common/src/lib/calculation-helper.ts +++ b/libs/common/src/lib/calculation-helper.ts @@ -9,7 +9,7 @@ import { subDays, subYears } from 'date-fns'; -import { isNumber } from 'lodash'; +import { isFinite, isNumber } from 'lodash'; import { resetHours } from './helper'; import { DateRange } from './types'; @@ -28,7 +28,7 @@ export function getAnnualizedPerformancePercent({ exponent ); - if (!isNaN(growthFactor)) { + if (isFinite(growthFactor)) { return new Big(growthFactor).minus(1); } } From 4ba142682a2ff1e09f1e7d183e91f1d51b9cfe97 Mon Sep 17 00:00:00 2001 From: Thomas Kaul <4159106+dtslvr@users.noreply.github.com> Date: Thu, 12 Feb 2026 18:37:40 +0100 Subject: [PATCH 2/5] Task/upgrade ngx-skeleton-loader to version 12.0.0 (#6304) * Upgrade ngx-skeleton-loader to version 12.0.0 * Update changelog --- CHANGELOG.md | 1 + package-lock.json | 8 ++++---- package.json | 2 +- 3 files changed, 6 insertions(+), 5 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 696ed2dbd..2e6feba4a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Changed +- Upgraded `ngx-skeleton-loader` from version `11.3.0` to `12.0.0` - Upgraded `twitter-api-v2` from version `1.27.0` to `1.29.0` ### Fixed diff --git a/package-lock.json b/package-lock.json index d415758b1..4c70d88f6 100644 --- a/package-lock.json +++ b/package-lock.json @@ -75,7 +75,7 @@ "ng-extract-i18n-merge": "3.2.1", "ngx-device-detector": "11.0.0", "ngx-markdown": "21.0.1", - "ngx-skeleton-loader": "11.3.0", + "ngx-skeleton-loader": "12.0.0", "open-color": "1.9.1", "papaparse": "5.3.1", "passport": "0.7.0", @@ -26249,9 +26249,9 @@ } }, "node_modules/ngx-skeleton-loader": { - "version": "11.3.0", - "resolved": "https://registry.npmjs.org/ngx-skeleton-loader/-/ngx-skeleton-loader-11.3.0.tgz", - "integrity": "sha512-MLm5shgXGiCA1W5NEqct6glBFx2AEgEKbk8pDyY15BsZ2zTGUwa5jw4pe6nJdrCj6xcl/d9oFTinQHrO0q+3RA==", + "version": "12.0.0", + "resolved": "https://registry.npmjs.org/ngx-skeleton-loader/-/ngx-skeleton-loader-12.0.0.tgz", + "integrity": "sha512-vGEytpLElYKSLovFHCJkwgPZOdy0lPqyejxuhVFcZJg9dsp07o0/NeM4/Nnc2oCDE8T/wkXSPIbrpKzfTDbMCQ==", "license": "MIT", "dependencies": { "tslib": "^2.0.0" diff --git a/package.json b/package.json index 0f43fff08..93ff182b7 100644 --- a/package.json +++ b/package.json @@ -119,7 +119,7 @@ "ng-extract-i18n-merge": "3.2.1", "ngx-device-detector": "11.0.0", "ngx-markdown": "21.0.1", - "ngx-skeleton-loader": "11.3.0", + "ngx-skeleton-loader": "12.0.0", "open-color": "1.9.1", "papaparse": "5.3.1", "passport": "0.7.0", From e361f093987af52ed2edeac692a4855d4c610e3f Mon Sep 17 00:00:00 2001 From: Thomas Kaul <4159106+dtslvr@users.noreply.github.com> Date: Thu, 12 Feb 2026 19:14:37 +0100 Subject: [PATCH 3/5] Bugfix/expand date range to cover full day in exchange rate calculation (#6311) * Expand date range (start to end of day) * Update changelog --- CHANGELOG.md | 1 + .../src/app/portfolio/calculator/portfolio-calculator.ts | 7 ++++--- 2 files changed, 5 insertions(+), 3 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 2e6feba4a..bb32902f0 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -15,6 +15,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Fixed - Fixed an issue in the annualized performance calculation +- Fixed an issue with the exchange rate calculation by expanding the date range to cover the full day (start to end of day) ## 2.237.0 - 2026-02-08 diff --git a/apps/api/src/app/portfolio/calculator/portfolio-calculator.ts b/apps/api/src/app/portfolio/calculator/portfolio-calculator.ts index 2e58a4ef5..553cb8c90 100644 --- a/apps/api/src/app/portfolio/calculator/portfolio-calculator.ts +++ b/apps/api/src/app/portfolio/calculator/portfolio-calculator.ts @@ -53,6 +53,7 @@ import { isBefore, isWithinInterval, min, + startOfDay, startOfYear, subDays } from 'date-fns'; @@ -162,8 +163,8 @@ export abstract class PortfolioCalculator { subDays(dateOfFirstActivity, 1) ); - this.endDate = endDate; - this.startDate = startDate; + this.endDate = endOfDay(endDate); + this.startDate = startOfDay(startDate); this.computeTransactionPoints(); @@ -236,7 +237,7 @@ export abstract class PortfolioCalculator { const exchangeRatesByCurrency = await this.exchangeRateDataService.getExchangeRatesByCurrency({ currencies: Array.from(new Set(Object.values(currencies))), - endDate: endOfDay(this.endDate), + endDate: this.endDate, startDate: this.startDate, targetCurrency: this.currency }); From 373a4857acc161a5556132552f156569ec2a2baf Mon Sep 17 00:00:00 2001 From: Neeraj Bachani <124370566+neerajbachani@users.noreply.github.com> Date: Thu, 12 Feb 2026 23:53:23 +0530 Subject: [PATCH 4/5] Bugfix/reset buy tracking variables when position closes (#6298) * Reset buy tracking variables when position closes * Update changelog --------- Co-authored-by: Thomas Kaul <4159106+dtslvr@users.noreply.github.com> --- CHANGELOG.md | 1 + ...jnug-buy-and-sell-and-buy-and-sell.spec.ts | 190 ++++++++++++++++++ .../calculator/roai/portfolio-calculator.ts | 7 + .../portfolio/current-rate.service.mock.ts | 11 + .../jnug-buy-and-sell-and-buy-and-sell.json | 58 ++++++ 5 files changed, 267 insertions(+) create mode 100644 apps/api/src/app/portfolio/calculator/roai/portfolio-calculator-jnug-buy-and-sell-and-buy-and-sell.spec.ts create mode 100644 test/import/ok/jnug-buy-and-sell-and-buy-and-sell.json diff --git a/CHANGELOG.md b/CHANGELOG.md index bb32902f0..29cec0e76 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -14,6 +14,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Fixed +- Fixed a performance calculation issue by resetting tracking variables when a holding is fully closed - Fixed an issue in the annualized performance calculation - Fixed an issue with the exchange rate calculation by expanding the date range to cover the full day (start to end of day) diff --git a/apps/api/src/app/portfolio/calculator/roai/portfolio-calculator-jnug-buy-and-sell-and-buy-and-sell.spec.ts b/apps/api/src/app/portfolio/calculator/roai/portfolio-calculator-jnug-buy-and-sell-and-buy-and-sell.spec.ts new file mode 100644 index 000000000..d5b22e864 --- /dev/null +++ b/apps/api/src/app/portfolio/calculator/roai/portfolio-calculator-jnug-buy-and-sell-and-buy-and-sell.spec.ts @@ -0,0 +1,190 @@ +import { + activityDummyData, + loadExportFile, + symbolProfileDummyData, + 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, ExportResponse } from '@ghostfolio/common/interfaces'; +import { PerformanceCalculationType } from '@ghostfolio/common/types/performance-calculation-type.type'; + +import { Big } from 'big.js'; +import { join } from 'node:path'; + +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 exportResponse: ExportResponse; + + let configurationService: ConfigurationService; + let currentRateService: CurrentRateService; + let exchangeRateDataService: ExchangeRateDataService; + let portfolioCalculatorFactory: PortfolioCalculatorFactory; + let portfolioSnapshotService: PortfolioSnapshotService; + let redisCacheService: RedisCacheService; + + beforeAll(() => { + exportResponse = loadExportFile( + join( + __dirname, + '../../../../../../../test/import/ok/jnug-buy-and-sell-and-buy-and-sell.json' + ) + ); + }); + + beforeEach(() => { + configurationService = new ConfigurationService(); + + currentRateService = new CurrentRateService(null, null, null, null); + + exchangeRateDataService = new ExchangeRateDataService( + null, + null, + null, + null + ); + + portfolioSnapshotService = new PortfolioSnapshotService(null); + + redisCacheService = new RedisCacheService(null, null); + + portfolioCalculatorFactory = new PortfolioCalculatorFactory( + configurationService, + currentRateService, + exchangeRateDataService, + portfolioSnapshotService, + redisCacheService + ); + }); + + describe('get current positions', () => { + it.only('with JNUG buy and sell', async () => { + jest.useFakeTimers().setSystemTime(parseDate('2025-12-28').getTime()); + + const activities: Activity[] = exportResponse.activities.map( + (activity) => ({ + ...activityDummyData, + ...activity, + date: parseDate(activity.date), + feeInAssetProfileCurrency: activity.fee, + feeInBaseCurrency: activity.fee, + SymbolProfile: { + ...symbolProfileDummyData, + currency: activity.currency, + dataSource: activity.dataSource, + name: 'Direxion Daily Junior Gold Miners Index Bull 2X Shares', + symbol: activity.symbol + }, + unitPriceInAssetProfileCurrency: activity.unitPrice + }) + ); + + const portfolioCalculator = portfolioCalculatorFactory.createCalculator({ + activities, + calculationType: PerformanceCalculationType.ROAI, + currency: exportResponse.user.settings.currency, + userId: userDummyData.id + }); + + const portfolioSnapshot = await portfolioCalculator.computeSnapshot(); + + const investments = portfolioCalculator.getInvestments(); + + const investmentsByMonth = portfolioCalculator.getInvestmentsByGroup({ + data: portfolioSnapshot.historicalData, + groupBy: 'month' + }); + + const investmentsByYear = portfolioCalculator.getInvestmentsByGroup({ + data: portfolioSnapshot.historicalData, + groupBy: 'year' + }); + + expect(portfolioSnapshot).toMatchObject({ + currentValueInBaseCurrency: new Big('0'), + errors: [], + hasErrors: false, + positions: [ + { + activitiesCount: 4, + averagePrice: new Big('0'), + currency: 'USD', + dataSource: 'YAHOO', + dateOfFirstActivity: '2025-12-11', + dividend: new Big('0'), + dividendInBaseCurrency: new Big('0'), + fee: new Big('4'), + feeInBaseCurrency: new Big('4'), + grossPerformance: new Big('43.95'), // (1890.00 - 1885.05) + (2080.10 - 2041.10) + grossPerformanceWithCurrencyEffect: new Big('43.95'), // (1890.00 - 1885.05) + (2080.10 - 2041.10) + investment: new Big('0'), + investmentWithCurrencyEffect: new Big('0'), + netPerformance: new Big('39.95'), // (1890.00 - 1885.05) + (2080.10 - 2041.10) - 4 + netPerformanceWithCurrencyEffectMap: { + max: new Big('39.95') // (1890.00 - 1885.05) + (2080.10 - 2041.10) - 4 + }, + marketPrice: 237.8000030517578, + marketPriceInBaseCurrency: 237.8000030517578, + quantity: new Big('0'), + symbol: 'JNUG', + tags: [], + valueInBaseCurrency: new Big('0') + } + ], + totalFeesWithCurrencyEffect: new Big('4'), + totalInterestWithCurrencyEffect: new Big('0'), + totalInvestment: new Big('0'), + totalInvestmentWithCurrencyEffect: new Big('0'), + totalLiabilitiesWithCurrencyEffect: new Big('0') + }); + + expect(investments).toEqual([ + { date: '2025-12-11', investment: new Big('1885.05') }, + { date: '2025-12-18', investment: new Big('2041.1') }, + { date: '2025-12-28', investment: new Big('0') } + ]); + + expect(investmentsByMonth).toEqual([ + { date: '2025-12-01', investment: 0 } + ]); + + expect(investmentsByYear).toEqual([ + { date: '2025-01-01', investment: 0 } + ]); + }); + }); +}); 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 fe912510a..be69048df 100644 --- a/apps/api/src/app/portfolio/calculator/roai/portfolio-calculator.ts +++ b/apps/api/src/app/portfolio/calculator/roai/portfolio-calculator.ts @@ -626,6 +626,13 @@ export class RoaiPortfolioCalculator extends PortfolioCalculator { totalQuantityFromBuyTransactions ); + if (totalUnits.eq(0)) { + // Reset tracking variables when position is fully closed + totalInvestmentFromBuyTransactions = new Big(0); + totalInvestmentFromBuyTransactionsWithCurrencyEffect = new Big(0); + totalQuantityFromBuyTransactions = new Big(0); + } + if (PortfolioCalculator.ENABLE_LOGGING) { console.log( 'grossPerformanceFromSells', 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 4b4b8f00e..8e027f971 100644 --- a/apps/api/src/app/portfolio/current-rate.service.mock.ts +++ b/apps/api/src/app/portfolio/current-rate.service.mock.ts @@ -64,6 +64,17 @@ function mockGetValue(symbol: string, date: Date) { return { marketPrice: 0 }; + case 'JNUG': + if (isSameDay(parseDate('2025-12-10'), date)) { + return { marketPrice: 204.5599975585938 }; + } else if (isSameDay(parseDate('2025-12-17'), date)) { + return { marketPrice: 203.9700012207031 }; + } else if (isSameDay(parseDate('2025-12-28'), date)) { + return { marketPrice: 237.8000030517578 }; + } + + return { marketPrice: 0 }; + case 'MSFT': if (isSameDay(parseDate('2021-09-16'), date)) { return { marketPrice: 89.12 }; diff --git a/test/import/ok/jnug-buy-and-sell-and-buy-and-sell.json b/test/import/ok/jnug-buy-and-sell-and-buy-and-sell.json new file mode 100644 index 000000000..2a14d8afe --- /dev/null +++ b/test/import/ok/jnug-buy-and-sell-and-buy-and-sell.json @@ -0,0 +1,58 @@ +{ + "meta": { + "date": "2026-02-07T02:09:15.272Z", + "version": "dev" + }, + "activities": [ + { + "currency": "USD", + "dataSource": "YAHOO", + "date": "2025-12-11T05:00:00.000Z", + "fee": 1, + "id": "cea33621-9f4b-4cea-9eb7-be38264888aa", + "quantity": 9, + "symbol": "JNUG", + "type": "BUY", + "unitPrice": 209.45 + }, + { + "currency": "USD", + "dataSource": "YAHOO", + "date": "2025-12-18T05:00:00.000Z", + "fee": 1, + "id": "53be2e35-a0af-476c-9e63-9b1a437114a4", + "quantity": 9, + "symbol": "JNUG", + "type": "SELL", + "unitPrice": 210 + }, + { + "currency": "USD", + "dataSource": "YAHOO", + "date": "2025-12-18T05:00:00.000Z", + "fee": 1, + "id": "6648eeeb-8ea5-46b6-9a30-f278a9ed477b", + "quantity": 10, + "symbol": "JNUG", + "type": "BUY", + "unitPrice": 204.11 + }, + { + "currency": "USD", + "dataSource": "YAHOO", + "date": "2025-12-28T05:00:00.000Z", + "fee": 1, + "id": "861e736d-0086-496c-8f85-31328479cf63", + "quantity": 10, + "symbol": "JNUG", + "type": "SELL", + "unitPrice": 208.01 + } + ], + "user": { + "settings": { + "currency": "USD", + "performanceCalculationType": "ROAI" + } + } +} From eb368765d4df711339f629471cf868df8ace1538 Mon Sep 17 00:00:00 2001 From: Thomas Kaul <4159106+dtslvr@users.noreply.github.com> Date: Thu, 12 Feb 2026 19:26:10 +0100 Subject: [PATCH 5/5] Release 2.238.0 (#6312) --- CHANGELOG.md | 2 +- package-lock.json | 4 ++-- package.json | 2 +- 3 files changed, 4 insertions(+), 4 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 29cec0e76..90430f5c4 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,7 +5,7 @@ All notable changes to this project will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). -## Unreleased +## 2.238.0 - 2026-02-12 ### Changed diff --git a/package-lock.json b/package-lock.json index 4c70d88f6..1731f1e7a 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "ghostfolio", - "version": "2.237.0", + "version": "2.238.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "ghostfolio", - "version": "2.237.0", + "version": "2.238.0", "hasInstallScript": true, "license": "AGPL-3.0", "dependencies": { diff --git a/package.json b/package.json index 93ff182b7..4bfad50ff 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "ghostfolio", - "version": "2.237.0", + "version": "2.238.0", "homepage": "https://ghostfol.io", "license": "AGPL-3.0", "repository": "https://github.com/ghostfolio/ghostfolio",