From 85c1bde3078f6754945e2a49776b0512a6394016 Mon Sep 17 00:00:00 2001 From: KenTandrian Date: Wed, 1 Oct 2025 23:15:09 +0700 Subject: [PATCH 01/19] feat(api): calculate net performance with currency effect for cash positions --- .../src/app/portfolio/portfolio.service.ts | 48 ++++++++++++++++++- 1 file changed, 47 insertions(+), 1 deletion(-) diff --git a/apps/api/src/app/portfolio/portfolio.service.ts b/apps/api/src/app/portfolio/portfolio.service.ts index f5b4ab1c6..d31beecb0 100644 --- a/apps/api/src/app/portfolio/portfolio.service.ts +++ b/apps/api/src/app/portfolio/portfolio.service.ts @@ -662,9 +662,26 @@ export class PortfolioService { }; } + const { endDate, startDate } = getIntervalFromDateRange( + dateRange, + portfolioCalculator.getStartDate() + ); + + // Gather historical exchange rate data for all currencies in cash positions + const exchangeRatesByCurrency = + await this.exchangeRateDataService.getExchangeRatesByCurrency({ + currencies: [ + ...new Set(cashDetails.accounts.map(({ currency }) => currency)) + ], + endDate, + startDate, + targetCurrency: userCurrency + }); + if (filters?.length === 0 || isFilteredByAccount || isFilteredByCash) { const cashPositions = this.getCashPositions({ cashDetails, + exchangeRatesByCurrency, userCurrency, value: filteredValueInBaseCurrency }); @@ -690,6 +707,7 @@ export class PortfolioService { ) { const emergencyFundCashPositions = this.getCashPositions({ cashDetails, + exchangeRatesByCurrency, userCurrency, value: filteredValueInBaseCurrency }); @@ -1625,10 +1643,14 @@ export class PortfolioService { private getCashPositions({ cashDetails, + exchangeRatesByCurrency, userCurrency, value }: { cashDetails: CashDetails; + exchangeRatesByCurrency: Awaited< + ReturnType + >; userCurrency: string; value: Big; }) { @@ -1650,24 +1672,48 @@ export class PortfolioService { continue; } + const exchangeRates = + exchangeRatesByCurrency[`${account.currency}${userCurrency}`]; + + // Calculate the performance of the cash position including currency effects + const netPerformanceWithCurrencyEffect = new Big(account.balance) + .mul(exchangeRates?.[format(new Date(), DATE_FORMAT)] ?? 1) + .minus( + new Big(account.balance).mul( + exchangeRates?.[format(account.createdAt, DATE_FORMAT)] ?? 1 + ) + ) + .toNumber(); + if (cashPositions[account.currency]) { cashPositions[account.currency].investment += convertedBalance; + cashPositions[account.currency].netPerformanceWithCurrencyEffect += + netPerformanceWithCurrencyEffect; cashPositions[account.currency].valueInBaseCurrency += convertedBalance; } else { cashPositions[account.currency] = this.getInitialCashPosition({ balance: convertedBalance, currency: account.currency }); + + cashPositions[account.currency].netPerformanceWithCurrencyEffect = + netPerformanceWithCurrencyEffect; } } for (const symbol of Object.keys(cashPositions)) { - // Calculate allocations for each currency + // Calculate allocations and net performances for each currency cashPositions[symbol].allocationInPercentage = value.gt(0) ? new Big(cashPositions[symbol].valueInBaseCurrency) .div(value) .toNumber() : 0; + cashPositions[symbol].netPerformancePercentWithCurrencyEffect = + cashPositions[symbol].investment > 0 + ? new Big(cashPositions[symbol].netPerformanceWithCurrencyEffect) + .div(cashPositions[symbol].investment) + .toNumber() + : 0; } return cashPositions; From ca812639e910f05480a93bae14692724458a63b4 Mon Sep 17 00:00:00 2001 From: KenTandrian Date: Wed, 1 Oct 2025 23:15:20 +0700 Subject: [PATCH 02/19] feat(docs): update changelog --- CHANGELOG.md | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index eca7c850c..846181a0f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## Unreleased +### Added + +- Extended holdings endpoint by performance with currency effect for cash + ### Changed - Improved the language localization for German (`de`) From 1713a0e9d531bebfc9774f1777e41b06e3777f36 Mon Sep 17 00:00:00 2001 From: KenTandrian Date: Wed, 1 Oct 2025 23:21:42 +0700 Subject: [PATCH 03/19] fix(api): update comment --- apps/api/src/app/portfolio/portfolio.service.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/api/src/app/portfolio/portfolio.service.ts b/apps/api/src/app/portfolio/portfolio.service.ts index d31beecb0..8415e24a5 100644 --- a/apps/api/src/app/portfolio/portfolio.service.ts +++ b/apps/api/src/app/portfolio/portfolio.service.ts @@ -1702,7 +1702,7 @@ export class PortfolioService { } for (const symbol of Object.keys(cashPositions)) { - // Calculate allocations and net performances for each currency + // Calculate allocations and net performance percentages for each currency cashPositions[symbol].allocationInPercentage = value.gt(0) ? new Big(cashPositions[symbol].valueInBaseCurrency) .div(value) From b452b6cb00fd363857d5f02a68b325ed8e6cc8ae Mon Sep 17 00:00:00 2001 From: KenTandrian Date: Sun, 12 Oct 2025 15:45:13 +0700 Subject: [PATCH 04/19] feat(types): create ExchangeRatesByCurrency interface --- apps/api/src/app/portfolio/portfolio.service.ts | 5 ++--- .../exchange-rate-data/exchange-rate-data.service.ts | 4 +++- .../interfaces/exchange-rate-data.interface.ts | 5 +++++ 3 files changed, 10 insertions(+), 4 deletions(-) create mode 100644 apps/api/src/services/exchange-rate-data/interfaces/exchange-rate-data.interface.ts diff --git a/apps/api/src/app/portfolio/portfolio.service.ts b/apps/api/src/app/portfolio/portfolio.service.ts index bb8ad2130..cc6d57954 100644 --- a/apps/api/src/app/portfolio/portfolio.service.ts +++ b/apps/api/src/app/portfolio/portfolio.service.ts @@ -24,6 +24,7 @@ import { RegionalMarketClusterRiskNorthAmerica } from '@ghostfolio/api/models/ru import { BenchmarkService } from '@ghostfolio/api/services/benchmark/benchmark.service'; import { DataProviderService } from '@ghostfolio/api/services/data-provider/data-provider.service'; import { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-data/exchange-rate-data.service'; +import { ExchangeRatesByCurrency } from '@ghostfolio/api/services/exchange-rate-data/interfaces/exchange-rate-data.interface'; import { I18nService } from '@ghostfolio/api/services/i18n/i18n.service'; import { ImpersonationService } from '@ghostfolio/api/services/impersonation/impersonation.service'; import { SymbolProfileService } from '@ghostfolio/api/services/symbol-profile/symbol-profile.service'; @@ -1648,9 +1649,7 @@ export class PortfolioService { value }: { cashDetails: CashDetails; - exchangeRatesByCurrency: Awaited< - ReturnType - >; + exchangeRatesByCurrency: ExchangeRatesByCurrency; userCurrency: string; value: Big; }) { diff --git a/apps/api/src/services/exchange-rate-data/exchange-rate-data.service.ts b/apps/api/src/services/exchange-rate-data/exchange-rate-data.service.ts index 433547c94..42551a3e2 100644 --- a/apps/api/src/services/exchange-rate-data/exchange-rate-data.service.ts +++ b/apps/api/src/services/exchange-rate-data/exchange-rate-data.service.ts @@ -26,6 +26,8 @@ import { import { isNumber } from 'lodash'; import ms from 'ms'; +import { ExchangeRatesByCurrency } from './interfaces/exchange-rate-data.interface'; + @Injectable() export class ExchangeRateDataService { private currencies: string[] = []; @@ -58,7 +60,7 @@ export class ExchangeRateDataService { endDate?: Date; startDate: Date; targetCurrency: string; - }) { + }): Promise { if (!startDate) { return {}; } diff --git a/apps/api/src/services/exchange-rate-data/interfaces/exchange-rate-data.interface.ts b/apps/api/src/services/exchange-rate-data/interfaces/exchange-rate-data.interface.ts new file mode 100644 index 000000000..8e0d2c0d4 --- /dev/null +++ b/apps/api/src/services/exchange-rate-data/interfaces/exchange-rate-data.interface.ts @@ -0,0 +1,5 @@ +export interface ExchangeRatesByCurrency { + [currency: string]: { + [dateString: string]: number; + }; +} From c15c3d0fbd91ed40d0a21daaa3ccbc92464e7edf Mon Sep 17 00:00:00 2001 From: KenTandrian Date: Sun, 12 Oct 2025 15:51:09 +0700 Subject: [PATCH 05/19] fix(api): calculate from startDate to endDate --- apps/api/src/app/portfolio/portfolio.service.ts | 12 ++++++++++-- 1 file changed, 10 insertions(+), 2 deletions(-) diff --git a/apps/api/src/app/portfolio/portfolio.service.ts b/apps/api/src/app/portfolio/portfolio.service.ts index cc6d57954..b8f34b60a 100644 --- a/apps/api/src/app/portfolio/portfolio.service.ts +++ b/apps/api/src/app/portfolio/portfolio.service.ts @@ -682,7 +682,9 @@ export class PortfolioService { if (filters?.length === 0 || isFilteredByAccount || isFilteredByCash) { const cashPositions = this.getCashPositions({ cashDetails, + endDate, exchangeRatesByCurrency, + startDate, userCurrency, value: filteredValueInBaseCurrency }); @@ -708,7 +710,9 @@ export class PortfolioService { ) { const emergencyFundCashPositions = this.getCashPositions({ cashDetails, + endDate, exchangeRatesByCurrency, + startDate, userCurrency, value: filteredValueInBaseCurrency }); @@ -1644,12 +1648,16 @@ export class PortfolioService { private getCashPositions({ cashDetails, + endDate, exchangeRatesByCurrency, + startDate, userCurrency, value }: { cashDetails: CashDetails; + endDate: Date; exchangeRatesByCurrency: ExchangeRatesByCurrency; + startDate: Date; userCurrency: string; value: Big; }) { @@ -1676,10 +1684,10 @@ export class PortfolioService { // Calculate the performance of the cash position including currency effects const netPerformanceWithCurrencyEffect = new Big(account.balance) - .mul(exchangeRates?.[format(new Date(), DATE_FORMAT)] ?? 1) + .mul(exchangeRates?.[format(endDate, DATE_FORMAT)] ?? 1) .minus( new Big(account.balance).mul( - exchangeRates?.[format(account.createdAt, DATE_FORMAT)] ?? 1 + exchangeRates?.[format(startDate, DATE_FORMAT)] ?? 1 ) ) .toNumber(); From 194daab9ceda8a4107cc6dac89a992971856c81a Mon Sep 17 00:00:00 2001 From: KenTandrian Date: Sun, 12 Oct 2025 16:57:10 +0700 Subject: [PATCH 06/19] fix(docs): update changelog --- CHANGELOG.md | 5 +---- 1 file changed, 1 insertion(+), 4 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 9a967989f..39b04a775 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,6 +10,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Added - Added a _Storybook_ story for the holdings table component +- Extended holdings endpoint by performance with currency effect for cash ### Changed @@ -61,10 +62,6 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## 2.206.0 - 2025-10-04 -### Added - -- Extended holdings endpoint by performance with currency effect for cash - ### Changed - Localized the number formatting in the settings dialog to customize the rule thresholds of the _X-ray_ page From fd5d4bb4455b17c0d6e6e05f693cb1153fd78246 Mon Sep 17 00:00:00 2001 From: Thomas Kaul <4159106+dtslvr@users.noreply.github.com> Date: Mon, 13 Oct 2025 20:53:44 +0200 Subject: [PATCH 07/19] Refactoring --- apps/api/src/app/portfolio/portfolio.service.ts | 13 ++++++++++--- 1 file changed, 10 insertions(+), 3 deletions(-) diff --git a/apps/api/src/app/portfolio/portfolio.service.ts b/apps/api/src/app/portfolio/portfolio.service.ts index b8f34b60a..53bfc7679 100644 --- a/apps/api/src/app/portfolio/portfolio.service.ts +++ b/apps/api/src/app/portfolio/portfolio.service.ts @@ -671,11 +671,15 @@ export class PortfolioService { // Gather historical exchange rate data for all currencies in cash positions const exchangeRatesByCurrency = await this.exchangeRateDataService.getExchangeRatesByCurrency({ - currencies: [ - ...new Set(cashDetails.accounts.map(({ currency }) => currency)) - ], endDate, startDate, + currencies: [ + ...new Set( + cashDetails.accounts.map(({ currency }) => { + return currency; + }) + ) + ], targetCurrency: userCurrency }); @@ -1694,8 +1698,10 @@ export class PortfolioService { if (cashPositions[account.currency]) { cashPositions[account.currency].investment += convertedBalance; + cashPositions[account.currency].netPerformanceWithCurrencyEffect += netPerformanceWithCurrencyEffect; + cashPositions[account.currency].valueInBaseCurrency += convertedBalance; } else { cashPositions[account.currency] = this.getInitialCashPosition({ @@ -1715,6 +1721,7 @@ export class PortfolioService { .div(value) .toNumber() : 0; + cashPositions[symbol].netPerformancePercentWithCurrencyEffect = cashPositions[symbol].investment > 0 ? new Big(cashPositions[symbol].netPerformanceWithCurrencyEffect) From 579cd00de778e110ad2ecc4370924842fb4182ba Mon Sep 17 00:00:00 2001 From: Thomas Kaul <4159106+dtslvr@users.noreply.github.com> Date: Mon, 13 Oct 2025 20:55:28 +0200 Subject: [PATCH 08/19] Update changelog --- CHANGELOG.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 39b04a775..0ed4c1fa0 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,8 +9,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Added +- Extended the holdings endpoint to include the performance with currency effect for cash - Added a _Storybook_ story for the holdings table component -- Extended holdings endpoint by performance with currency effect for cash ### Changed From c4b756ce06440e393416bc22d26bd2bf2923c749 Mon Sep 17 00:00:00 2001 From: KenTandrian Date: Sat, 25 Oct 2025 08:27:46 +0700 Subject: [PATCH 09/19] fix(docs): update changelog --- CHANGELOG.md | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 1a8cd85f1..4a20e4904 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,12 @@ 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 + +### Added + +- Extended the holdings endpoint to include the performance with currency effect for cash + ## 2.211.0-beta.0 - 2025-10-24 ### Added @@ -29,7 +35,6 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Added - Added support for data gathering by date range in the asset profile details dialog of the admin control panel -- Extended the holdings endpoint to include the performance with currency effect for cash ### Changed From c9fe50d9e75961c15d3242f230ffbbb501e5bb2e Mon Sep 17 00:00:00 2001 From: KenTandrian Date: Sun, 26 Oct 2025 13:03:17 +0700 Subject: [PATCH 10/19] feat(api): create get cash activities method --- .../src/app/portfolio/portfolio.service.ts | 86 +++++++++++++++++++ 1 file changed, 86 insertions(+) diff --git a/apps/api/src/app/portfolio/portfolio.service.ts b/apps/api/src/app/portfolio/portfolio.service.ts index e2836e643..53efc34a3 100644 --- a/apps/api/src/app/portfolio/portfolio.service.ts +++ b/apps/api/src/app/portfolio/portfolio.service.ts @@ -320,6 +320,92 @@ export class PortfolioService { }; } + public async getCashActivities({ + cashDetails, + userCurrency, + userId + }: { + cashDetails: CashDetails; + userCurrency: string; + userId: string; + }) { + const syntheticActivities: Activity[] = []; + + for (const account of cashDetails.accounts) { + const { balances } = await this.accountBalanceService.getAccountBalances({ + filters: [{ id: account.id, type: 'ACCOUNT' }], + userCurrency, + userId + }); + + let currentBalance = 0; + let currentBalanceInBaseCurrency = 0; + for (const balanceItem of balances) { + const syntheticActivityTemplate: Activity = { + userId, + accountId: account.id, + accountUserId: account.userId, + comment: account.name, + createdAt: new Date(balanceItem.date), + currency: account.currency, + date: new Date(balanceItem.date), + fee: 0, + feeInAssetProfileCurrency: 0, + feeInBaseCurrency: 0, + id: balanceItem.id, + isDraft: false, + quantity: 1, + SymbolProfile: { + activitiesCount: 0, + assetClass: 'LIQUIDITY', + assetSubClass: 'CASH', + countries: [], + createdAt: new Date(balanceItem.date), + currency: account.currency, + dataSource: 'YAHOO', + holdings: [], + id: account.currency, + isActive: true, + sectors: [], + symbol: account.currency, + updatedAt: new Date(balanceItem.date) + }, + symbolProfileId: account.currency, + type: 'BUY', + unitPrice: 1, + unitPriceInAssetProfileCurrency: 1, + updatedAt: new Date(balanceItem.date), + valueInBaseCurrency: 0, + value: 0 + }; + + if (currentBalance < balanceItem.value) { + // BUY + syntheticActivities.push({ + ...syntheticActivityTemplate, + type: 'BUY', + value: balanceItem.value - currentBalance, + valueInBaseCurrency: + balanceItem.valueInBaseCurrency - currentBalanceInBaseCurrency + }); + } else if (currentBalance > balanceItem.value) { + // SELL + syntheticActivities.push({ + ...syntheticActivityTemplate, + type: 'SELL', + value: currentBalance - balanceItem.value, + valueInBaseCurrency: + currentBalanceInBaseCurrency - balanceItem.valueInBaseCurrency + }); + } + currentBalance = balanceItem.value; + currentBalanceInBaseCurrency = balanceItem.valueInBaseCurrency; + } + } + + return syntheticActivities; + } + public async getDividends({ activities, groupBy From 2bfd92cb9cb3599b3c37a26dbac3dfc5bf73b340 Mon Sep 17 00:00:00 2001 From: KenTandrian Date: Sun, 26 Oct 2025 13:04:48 +0700 Subject: [PATCH 11/19] feat(api): add synthetic activities into calculator --- .../src/app/portfolio/portfolio.service.ts | 21 ++++++++++++------- 1 file changed, 14 insertions(+), 7 deletions(-) diff --git a/apps/api/src/app/portfolio/portfolio.service.ts b/apps/api/src/app/portfolio/portfolio.service.ts index 53efc34a3..081575001 100644 --- a/apps/api/src/app/portfolio/portfolio.service.ts +++ b/apps/api/src/app/portfolio/portfolio.service.ts @@ -575,6 +575,7 @@ export class PortfolioService { (user.settings?.settings as UserSettings)?.emergencyFund ?? 0 ); + // Activities for non-cash assets const { activities } = await this.orderService.getOrdersForPortfolioCalculator({ filters, @@ -582,23 +583,29 @@ export class PortfolioService { userId }); - const portfolioCalculator = this.calculatorFactory.createCalculator({ - activities, + // Synthetic activities for cash + const cashDetails = await this.accountService.getCashDetails({ filters, userId, - calculationType: this.getUserPerformanceCalculationType(user), currency: userCurrency }); + const cashActivities = await this.getCashActivities({ + cashDetails, + userCurrency, + userId + }); - const { createdAt, currentValueInBaseCurrency, hasErrors, positions } = - await portfolioCalculator.getSnapshot(); - - const cashDetails = await this.accountService.getCashDetails({ + const portfolioCalculator = this.calculatorFactory.createCalculator({ filters, userId, + activities: [...activities, ...cashActivities], + calculationType: this.getUserPerformanceCalculationType(user), currency: userCurrency }); + const { createdAt, currentValueInBaseCurrency, hasErrors, positions } = + await portfolioCalculator.getSnapshot(); + const holdings: PortfolioDetails['holdings'] = {}; const totalValueInBaseCurrency = currentValueInBaseCurrency.plus( From 2dd6aeca3a136b9459a15172aebab44894bb17cd Mon Sep 17 00:00:00 2001 From: KenTandrian Date: Sun, 26 Oct 2025 13:06:24 +0700 Subject: [PATCH 12/19] fix(api): revert changes on getCashPositions --- .../src/app/portfolio/portfolio.service.ts | 61 +------------------ 1 file changed, 1 insertion(+), 60 deletions(-) diff --git a/apps/api/src/app/portfolio/portfolio.service.ts b/apps/api/src/app/portfolio/portfolio.service.ts index 081575001..ac5091bfb 100644 --- a/apps/api/src/app/portfolio/portfolio.service.ts +++ b/apps/api/src/app/portfolio/portfolio.service.ts @@ -24,7 +24,6 @@ import { RegionalMarketClusterRiskNorthAmerica } from '@ghostfolio/api/models/ru import { BenchmarkService } from '@ghostfolio/api/services/benchmark/benchmark.service'; import { DataProviderService } from '@ghostfolio/api/services/data-provider/data-provider.service'; import { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-data/exchange-rate-data.service'; -import { ExchangeRatesByCurrency } from '@ghostfolio/api/services/exchange-rate-data/interfaces/exchange-rate-data.interface'; import { I18nService } from '@ghostfolio/api/services/i18n/i18n.service'; import { ImpersonationService } from '@ghostfolio/api/services/impersonation/impersonation.service'; import { SymbolProfileService } from '@ghostfolio/api/services/symbol-profile/symbol-profile.service'; @@ -756,32 +755,9 @@ export class PortfolioService { }; } - const { endDate, startDate } = getIntervalFromDateRange( - dateRange, - portfolioCalculator.getStartDate() - ); - - // Gather historical exchange rate data for all currencies in cash positions - const exchangeRatesByCurrency = - await this.exchangeRateDataService.getExchangeRatesByCurrency({ - endDate, - startDate, - currencies: [ - ...new Set( - cashDetails.accounts.map(({ currency }) => { - return currency; - }) - ) - ], - targetCurrency: userCurrency - }); - if (filters?.length === 0 || isFilteredByAccount || isFilteredByCash) { const cashPositions = this.getCashPositions({ cashDetails, - endDate, - exchangeRatesByCurrency, - startDate, userCurrency, value: filteredValueInBaseCurrency }); @@ -807,9 +783,6 @@ export class PortfolioService { ) { const emergencyFundCashPositions = this.getCashPositions({ cashDetails, - endDate, - exchangeRatesByCurrency, - startDate, userCurrency, value: filteredValueInBaseCurrency }); @@ -1745,16 +1718,10 @@ export class PortfolioService { private getCashPositions({ cashDetails, - endDate, - exchangeRatesByCurrency, - startDate, userCurrency, value }: { cashDetails: CashDetails; - endDate: Date; - exchangeRatesByCurrency: ExchangeRatesByCurrency; - startDate: Date; userCurrency: string; value: Big; }) { @@ -1776,51 +1743,25 @@ export class PortfolioService { continue; } - const exchangeRates = - exchangeRatesByCurrency[`${account.currency}${userCurrency}`]; - - // Calculate the performance of the cash position including currency effects - const netPerformanceWithCurrencyEffect = new Big(account.balance) - .mul(exchangeRates?.[format(endDate, DATE_FORMAT)] ?? 1) - .minus( - new Big(account.balance).mul( - exchangeRates?.[format(startDate, DATE_FORMAT)] ?? 1 - ) - ) - .toNumber(); - if (cashPositions[account.currency]) { cashPositions[account.currency].investment += convertedBalance; - cashPositions[account.currency].netPerformanceWithCurrencyEffect += - netPerformanceWithCurrencyEffect; - cashPositions[account.currency].valueInBaseCurrency += convertedBalance; } else { cashPositions[account.currency] = this.getInitialCashPosition({ balance: convertedBalance, currency: account.currency }); - - cashPositions[account.currency].netPerformanceWithCurrencyEffect = - netPerformanceWithCurrencyEffect; } } for (const symbol of Object.keys(cashPositions)) { - // Calculate allocations and net performance percentages for each currency + // Calculate allocations for each currency cashPositions[symbol].allocationInPercentage = value.gt(0) ? new Big(cashPositions[symbol].valueInBaseCurrency) .div(value) .toNumber() : 0; - - cashPositions[symbol].netPerformancePercentWithCurrencyEffect = - cashPositions[symbol].investment > 0 - ? new Big(cashPositions[symbol].netPerformanceWithCurrencyEffect) - .div(cashPositions[symbol].investment) - .toNumber() - : 0; } return cashPositions; From e8918b147f892a32b505ddb427f5ef23b1530bb9 Mon Sep 17 00:00:00 2001 From: KenTandrian Date: Sun, 26 Oct 2025 13:08:25 +0700 Subject: [PATCH 13/19] fix(api): revert changes on getCashPositions --- apps/api/src/app/portfolio/portfolio.service.ts | 1 - 1 file changed, 1 deletion(-) diff --git a/apps/api/src/app/portfolio/portfolio.service.ts b/apps/api/src/app/portfolio/portfolio.service.ts index ac5091bfb..eaebed09e 100644 --- a/apps/api/src/app/portfolio/portfolio.service.ts +++ b/apps/api/src/app/portfolio/portfolio.service.ts @@ -1745,7 +1745,6 @@ export class PortfolioService { if (cashPositions[account.currency]) { cashPositions[account.currency].investment += convertedBalance; - cashPositions[account.currency].valueInBaseCurrency += convertedBalance; } else { cashPositions[account.currency] = this.getInitialCashPosition({ From 31a60496ec6035c175aa2e0d765cbbac9709105d Mon Sep 17 00:00:00 2001 From: KenTandrian Date: Sat, 8 Nov 2025 21:02:07 +0700 Subject: [PATCH 14/19] feat(api): implement getDataSourceForExchangeRates --- apps/api/src/app/portfolio/portfolio.service.ts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/apps/api/src/app/portfolio/portfolio.service.ts b/apps/api/src/app/portfolio/portfolio.service.ts index eaebed09e..88584d257 100644 --- a/apps/api/src/app/portfolio/portfolio.service.ts +++ b/apps/api/src/app/portfolio/portfolio.service.ts @@ -361,7 +361,8 @@ export class PortfolioService { countries: [], createdAt: new Date(balanceItem.date), currency: account.currency, - dataSource: 'YAHOO', + dataSource: + this.dataProviderService.getDataSourceForExchangeRates(), holdings: [], id: account.currency, isActive: true, From f08563a510265631849eef7866b1d730c0eca5af Mon Sep 17 00:00:00 2001 From: KenTandrian Date: Sun, 16 Nov 2025 17:14:43 +0700 Subject: [PATCH 15/19] fix(docs): update changelog --- CHANGELOG.md | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index fb75ce24e..3063fb2e0 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,16 +5,18 @@ 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). -## 2.217.1 - 2025-11-16 +## Unreleased ### Added -- Introduced support for automatically gathering required exchange rates, exposed as an environment variable (`ENABLE_FEATURE_GATHER_NEW_EXCHANGE_RATES`) -- Added a blog post: _Black Weeks 2025_ +- Extended the holdings endpoint to include the performance with currency effect for cash + +## 2.217.1 - 2025-11-16 ### Added -- Extended the holdings endpoint to include the performance with currency effect for cash +- Introduced support for automatically gathering required exchange rates, exposed as an environment variable (`ENABLE_FEATURE_GATHER_NEW_EXCHANGE_RATES`) +- Added a blog post: _Black Weeks 2025_ ### Changed From 7ebef0baf4762cb2d544390069e5c279402c968d Mon Sep 17 00:00:00 2001 From: KenTandrian Date: Sun, 16 Nov 2025 19:23:16 +0700 Subject: [PATCH 16/19] fix(api): move getCashOrders to order service --- apps/api/src/app/order/order.service.ts | 114 ++++++++++++++++- .../src/app/portfolio/portfolio.service.ts | 119 ++---------------- 2 files changed, 126 insertions(+), 107 deletions(-) diff --git a/apps/api/src/app/order/order.service.ts b/apps/api/src/app/order/order.service.ts index 7dc6c646d..f115fb501 100644 --- a/apps/api/src/app/order/order.service.ts +++ b/apps/api/src/app/order/order.service.ts @@ -1,7 +1,10 @@ +import { AccountBalanceService } from '@ghostfolio/api/app/account-balance/account-balance.service'; import { AccountService } from '@ghostfolio/api/app/account/account.service'; +import { CashDetails } from '@ghostfolio/api/app/account/interfaces/cash-details.interface'; import { AssetProfileChangedEvent } from '@ghostfolio/api/events/asset-profile-changed.event'; import { PortfolioChangedEvent } from '@ghostfolio/api/events/portfolio-changed.event'; import { LogPerformance } from '@ghostfolio/api/interceptors/performance-logging/performance-logging.interceptor'; +import { DataProviderService } from '@ghostfolio/api/services/data-provider/data-provider.service'; import { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-data/exchange-rate-data.service'; import { PrismaService } from '@ghostfolio/api/services/prisma/prisma.service'; import { DataGatheringService } from '@ghostfolio/api/services/queues/data-gathering/data-gathering.service'; @@ -16,6 +19,7 @@ import { import { getAssetProfileIdentifier } from '@ghostfolio/common/helper'; import { ActivitiesResponse, + Activity, AssetProfileIdentifier, EnhancedSymbolProfile, Filter @@ -43,7 +47,9 @@ import { v4 as uuidv4 } from 'uuid'; export class OrderService { public constructor( private readonly accountService: AccountService, + private readonly accountBalanceService: AccountBalanceService, private readonly dataGatheringService: DataGatheringService, + private readonly dataProviderService: DataProviderService, private readonly eventEmitter: EventEmitter2, private readonly exchangeRateDataService: ExchangeRateDataService, private readonly prismaService: PrismaService, @@ -317,6 +323,96 @@ export class OrderService { return count; } + public async getCashOrders({ + cashDetails, + userCurrency, + userId + }: { + cashDetails: CashDetails; + userCurrency: string; + userId: string; + }): Promise { + const activities: Activity[] = []; + + for (const account of cashDetails.accounts) { + const { balances } = await this.accountBalanceService.getAccountBalances({ + filters: [{ id: account.id, type: 'ACCOUNT' }], + userCurrency, + userId + }); + + let currentBalance = 0; + let currentBalanceInBaseCurrency = 0; + for (const balanceItem of balances) { + const syntheticActivityTemplate: Activity = { + userId, + accountId: account.id, + accountUserId: account.userId, + comment: account.name, + createdAt: new Date(balanceItem.date), + currency: account.currency, + date: new Date(balanceItem.date), + fee: 0, + feeInAssetProfileCurrency: 0, + feeInBaseCurrency: 0, + id: balanceItem.id, + isDraft: false, + quantity: 1, + SymbolProfile: { + activitiesCount: 0, + assetClass: 'LIQUIDITY', + assetSubClass: 'CASH', + countries: [], + createdAt: new Date(balanceItem.date), + currency: account.currency, + dataSource: + this.dataProviderService.getDataSourceForExchangeRates(), + holdings: [], + id: account.currency, + isActive: true, + sectors: [], + symbol: account.currency, + updatedAt: new Date(balanceItem.date) + }, + symbolProfileId: account.currency, + type: 'BUY', + unitPrice: 1, + unitPriceInAssetProfileCurrency: 1, + updatedAt: new Date(balanceItem.date), + valueInBaseCurrency: 0, + value: 0 + }; + + if (currentBalance < balanceItem.value) { + // BUY + activities.push({ + ...syntheticActivityTemplate, + type: 'BUY', + value: balanceItem.value - currentBalance, + valueInBaseCurrency: + balanceItem.valueInBaseCurrency - currentBalanceInBaseCurrency + }); + } else if (currentBalance > balanceItem.value) { + // SELL + activities.push({ + ...syntheticActivityTemplate, + type: 'SELL', + value: currentBalance - balanceItem.value, + valueInBaseCurrency: + currentBalanceInBaseCurrency - balanceItem.valueInBaseCurrency + }); + } + currentBalance = balanceItem.value; + currentBalanceInBaseCurrency = balanceItem.valueInBaseCurrency; + } + } + + return { + activities, + count: activities.length + }; + } + public async getLatestOrder({ dataSource, symbol }: AssetProfileIdentifier) { return this.prismaService.order.findFirst({ orderBy: { @@ -620,12 +716,28 @@ export class OrderService { userCurrency: string; userId: string; }) { - return this.getOrders({ + const cashDetails = await this.accountService.getCashDetails({ + filters, + userId, + currency: userCurrency + }); + const cashOrders = await this.getCashOrders({ + cashDetails, + userCurrency, + userId + }); + + const nonCashOrders = await this.getOrders({ filters, userCurrency, userId, withExcludedAccountsAndActivities: false // TODO }); + + return { + activities: [...nonCashOrders.activities, ...cashOrders.activities], + count: nonCashOrders.count + cashOrders.count + }; } public async getStatisticsByCurrency( diff --git a/apps/api/src/app/portfolio/portfolio.service.ts b/apps/api/src/app/portfolio/portfolio.service.ts index 9744201a2..8e5c9b643 100644 --- a/apps/api/src/app/portfolio/portfolio.service.ts +++ b/apps/api/src/app/portfolio/portfolio.service.ts @@ -318,93 +318,6 @@ export class PortfolioService { }; } - public async getCashActivities({ - cashDetails, - userCurrency, - userId - }: { - cashDetails: CashDetails; - userCurrency: string; - userId: string; - }) { - const syntheticActivities: Activity[] = []; - - for (const account of cashDetails.accounts) { - const { balances } = await this.accountBalanceService.getAccountBalances({ - filters: [{ id: account.id, type: 'ACCOUNT' }], - userCurrency, - userId - }); - - let currentBalance = 0; - let currentBalanceInBaseCurrency = 0; - for (const balanceItem of balances) { - const syntheticActivityTemplate: Activity = { - userId, - accountId: account.id, - accountUserId: account.userId, - comment: account.name, - createdAt: new Date(balanceItem.date), - currency: account.currency, - date: new Date(balanceItem.date), - fee: 0, - feeInAssetProfileCurrency: 0, - feeInBaseCurrency: 0, - id: balanceItem.id, - isDraft: false, - quantity: 1, - SymbolProfile: { - activitiesCount: 0, - assetClass: 'LIQUIDITY', - assetSubClass: 'CASH', - countries: [], - createdAt: new Date(balanceItem.date), - currency: account.currency, - dataSource: - this.dataProviderService.getDataSourceForExchangeRates(), - holdings: [], - id: account.currency, - isActive: true, - sectors: [], - symbol: account.currency, - updatedAt: new Date(balanceItem.date) - }, - symbolProfileId: account.currency, - type: 'BUY', - unitPrice: 1, - unitPriceInAssetProfileCurrency: 1, - updatedAt: new Date(balanceItem.date), - valueInBaseCurrency: 0, - value: 0 - }; - - if (currentBalance < balanceItem.value) { - // BUY - syntheticActivities.push({ - ...syntheticActivityTemplate, - type: 'BUY', - value: balanceItem.value - currentBalance, - valueInBaseCurrency: - balanceItem.valueInBaseCurrency - currentBalanceInBaseCurrency - }); - } else if (currentBalance > balanceItem.value) { - // SELL - syntheticActivities.push({ - ...syntheticActivityTemplate, - type: 'SELL', - value: currentBalance - balanceItem.value, - valueInBaseCurrency: - currentBalanceInBaseCurrency - balanceItem.valueInBaseCurrency - }); - } - currentBalance = balanceItem.value; - currentBalanceInBaseCurrency = balanceItem.valueInBaseCurrency; - } - } - - return syntheticActivities; - } - public async getDividends({ activities, groupBy @@ -588,16 +501,11 @@ export class PortfolioService { userId, currency: userCurrency }); - const cashActivities = await this.getCashActivities({ - cashDetails, - userCurrency, - userId - }); const portfolioCalculator = this.calculatorFactory.createCalculator({ + activities, filters, userId, - activities: [...activities, ...cashActivities], calculationType: this.getUserPerformanceCalculationType(user), currency: userCurrency }); @@ -715,10 +623,10 @@ export class PortfolioService { allocationInPercentage: filteredValueInBaseCurrency.eq(0) ? 0 : valueInBaseCurrency.div(filteredValueInBaseCurrency).toNumber(), - assetClass: assetProfile.assetClass, - assetSubClass: assetProfile.assetSubClass, - countries: assetProfile.countries, - dataSource: assetProfile.dataSource, + assetClass: assetProfile?.assetClass, + assetSubClass: assetProfile?.assetSubClass, + countries: assetProfile?.countries, + dataSource: assetProfile?.dataSource, dateOfFirstActivity: parseDate(firstBuyDate), dividend: dividend?.toNumber() ?? 0, grossPerformance: grossPerformance?.toNumber() ?? 0, @@ -727,8 +635,8 @@ export class PortfolioService { grossPerformancePercentageWithCurrencyEffect?.toNumber() ?? 0, grossPerformanceWithCurrencyEffect: grossPerformanceWithCurrencyEffect?.toNumber() ?? 0, - holdings: assetProfile.holdings.map( - ({ allocationInPercentage, name }) => { + holdings: + assetProfile?.holdings.map(({ allocationInPercentage, name }) => { return { allocationInPercentage, name, @@ -736,10 +644,9 @@ export class PortfolioService { .mul(allocationInPercentage) .toNumber() }; - } - ), + }) ?? [], investment: investment.toNumber(), - name: assetProfile.name, + name: assetProfile?.name, netPerformance: netPerformance?.toNumber() ?? 0, netPerformancePercent: netPerformancePercentage?.toNumber() ?? 0, netPerformancePercentWithCurrencyEffect: @@ -749,8 +656,8 @@ export class PortfolioService { netPerformanceWithCurrencyEffect: netPerformanceWithCurrencyEffectMap?.[dateRange]?.toNumber() ?? 0, quantity: quantity.toNumber(), - sectors: assetProfile.sectors, - url: assetProfile.url, + sectors: assetProfile?.sectors, + url: assetProfile?.url, valueInBaseCurrency: valueInBaseCurrency.toNumber() }; } @@ -2251,7 +2158,7 @@ export class PortfolioService { accounts[account?.id || UNKNOWN_KEY] = { balance: 0, currency: account?.currency, - name: account.name, + name: account?.name, valueInBaseCurrency: currentValueOfSymbolInBaseCurrency }; } @@ -2265,7 +2172,7 @@ export class PortfolioService { platforms[account?.platformId || UNKNOWN_KEY] = { balance: 0, currency: account?.currency, - name: account.platform?.name, + name: account?.platform?.name, valueInBaseCurrency: currentValueOfSymbolInBaseCurrency }; } From dc6bc3ff404e77f9fc4ad40d2be1a0fe70d9084d Mon Sep 17 00:00:00 2001 From: KenTandrian Date: Sun, 16 Nov 2025 22:49:03 +0700 Subject: [PATCH 17/19] fix(api): remove cash positions override --- apps/api/src/app/portfolio/portfolio.service.ts | 16 ---------------- 1 file changed, 16 deletions(-) diff --git a/apps/api/src/app/portfolio/portfolio.service.ts b/apps/api/src/app/portfolio/portfolio.service.ts index 8e5c9b643..407783f3c 100644 --- a/apps/api/src/app/portfolio/portfolio.service.ts +++ b/apps/api/src/app/portfolio/portfolio.service.ts @@ -524,10 +524,6 @@ export class PortfolioService { return type === 'ACCOUNT'; }) ?? false; - const isFilteredByCash = filters?.some(({ id, type }) => { - return id === AssetClass.LIQUIDITY && type === 'ASSET_CLASS'; - }); - const isFilteredByClosedHoldings = filters?.some(({ id, type }) => { return id === 'CLOSED' && type === 'HOLDING_TYPE'; @@ -662,18 +658,6 @@ export class PortfolioService { }; } - if (filters?.length === 0 || isFilteredByAccount || isFilteredByCash) { - const cashPositions = this.getCashPositions({ - cashDetails, - userCurrency, - value: filteredValueInBaseCurrency - }); - - for (const symbol of Object.keys(cashPositions)) { - holdings[symbol] = cashPositions[symbol]; - } - } - const { accounts, platforms } = await this.getValueOfAccountsAndPlatforms({ activities, filters, From cac33e5c2fe6934a11ee1ee26c9410ab02386873 Mon Sep 17 00:00:00 2001 From: KenTandrian Date: Sun, 16 Nov 2025 23:35:06 +0700 Subject: [PATCH 18/19] fix(api): set quantity to the balance --- apps/api/src/app/order/order.service.ts | 2 ++ 1 file changed, 2 insertions(+) diff --git a/apps/api/src/app/order/order.service.ts b/apps/api/src/app/order/order.service.ts index f115fb501..d304454e1 100644 --- a/apps/api/src/app/order/order.service.ts +++ b/apps/api/src/app/order/order.service.ts @@ -387,6 +387,7 @@ export class OrderService { // BUY activities.push({ ...syntheticActivityTemplate, + quantity: balanceItem.value - currentBalance, type: 'BUY', value: balanceItem.value - currentBalance, valueInBaseCurrency: @@ -396,6 +397,7 @@ export class OrderService { // SELL activities.push({ ...syntheticActivityTemplate, + quantity: currentBalance - balanceItem.value, type: 'SELL', value: currentBalance - balanceItem.value, valueInBaseCurrency: From 3702c635dd8f4d156318d2050384a744b3caeb4f Mon Sep 17 00:00:00 2001 From: KenTandrian Date: Fri, 28 Nov 2025 01:17:19 +0700 Subject: [PATCH 19/19] fix(docs): move changelog entry for holdings endpoint performance to Unreleased section --- CHANGELOG.md | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index a068732e7..d6ea9d2e3 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## Unreleased +### Added + +- Extended the holdings endpoint to include the performance with currency effect for cash + ### Changed - Eliminated `uuid` in favor of using `randomUUID` from `node:crypto` @@ -20,7 +24,6 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Added -- Extended the holdings endpoint to include the performance with currency effect for cash - Extended the user detail dialog of the admin control panel’s users section by the authentication method ### Changed