diff --git a/CHANGELOG.md b/CHANGELOG.md index 5c1fad4db..74dcfa882 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -18,6 +18,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Fixed +- Fixed the net worth calculation to prevent the double counting of cash positions - Fixed the filtering by asset class in the endpoint `GET api/v1/portfolio/holdings` - Fixed the case-insensitive sorting in the accounts table component - Fixed the case-insensitive sorting in the benchmark component diff --git a/apps/api/src/app/order/order.service.ts b/apps/api/src/app/order/order.service.ts index a939cb476..9a4f1e46b 100644 --- a/apps/api/src/app/order/order.service.ts +++ b/apps/api/src/app/order/order.service.ts @@ -743,47 +743,50 @@ export class OrderService { /** * Retrieves all orders required for the portfolio calculator, including both standard asset orders - * and synthetic orders representing cash activities. - * - * @param filters - Optional filters to apply to the orders. - * @param userCurrency - The base currency of the user. - * @param userId - The ID of the user. - * @returns An object containing the combined list of activities and the total count. + * and optional synthetic orders representing cash activities. */ @LogPerformance public async getOrdersForPortfolioCalculator({ filters, userCurrency, - userId + userId, + withCash = false }: { + /** Optional filters to apply to the orders. */ filters?: Filter[]; + /** The base currency of the user. */ userCurrency: string; + /** The ID of the user. */ userId: string; + /** Whether to include cash activities in the result. */ + withCash?: boolean; }) { - const nonCashOrders = await this.getOrders({ + const orders = await this.getOrders({ filters, userCurrency, userId, withExcludedAccountsAndActivities: false // TODO }); - const cashDetails = await this.accountService.getCashDetails({ - filters, - userId, - currency: userCurrency - }); + if (withCash) { + const cashDetails = await this.accountService.getCashDetails({ + filters, + userId, + currency: userCurrency + }); - const cashOrders = await this.getCashOrders({ - cashDetails, - filters, - userCurrency, - userId - }); + const cashOrders = await this.getCashOrders({ + cashDetails, + filters, + userCurrency, + userId + }); - return { - activities: [...nonCashOrders.activities, ...cashOrders.activities], - count: nonCashOrders.count + cashOrders.count - }; + orders.activities.push(...cashOrders.activities); + orders.count += cashOrders.count; + } + + return orders; } public async getStatisticsByCurrency( diff --git a/apps/api/src/app/portfolio/calculator/portfolio-calculator.ts b/apps/api/src/app/portfolio/calculator/portfolio-calculator.ts index d2b3c0625..8f6cb0efc 100644 --- a/apps/api/src/app/portfolio/calculator/portfolio-calculator.ts +++ b/apps/api/src/app/portfolio/calculator/portfolio-calculator.ts @@ -39,6 +39,7 @@ import { GroupBy } from '@ghostfolio/common/types'; import { PerformanceCalculationType } from '@ghostfolio/common/types/performance-calculation-type.type'; import { Logger } from '@nestjs/common'; +import { AssetSubClass } from '@prisma/client'; import { Big } from 'big.js'; import { plainToClass } from 'class-transformer'; import { @@ -389,27 +390,33 @@ export abstract class PortfolioCalculator { hasAnySymbolMetricsErrors = hasAnySymbolMetricsErrors || hasErrors; - valuesBySymbol[item.symbol] = { - currentValues, - currentValuesWithCurrencyEffect, - investmentValuesAccumulated, - investmentValuesAccumulatedWithCurrencyEffect, - investmentValuesWithCurrencyEffect, - netPerformanceValues, - netPerformanceValuesWithCurrencyEffect, - timeWeightedInvestmentValues, - timeWeightedInvestmentValuesWithCurrencyEffect - }; + const includeInTotalAssetValue = + item.assetSubClass !== AssetSubClass.CASH; + + if (includeInTotalAssetValue) { + valuesBySymbol[item.symbol] = { + currentValues, + currentValuesWithCurrencyEffect, + investmentValuesAccumulated, + investmentValuesAccumulatedWithCurrencyEffect, + investmentValuesWithCurrencyEffect, + netPerformanceValues, + netPerformanceValuesWithCurrencyEffect, + timeWeightedInvestmentValues, + timeWeightedInvestmentValuesWithCurrencyEffect + }; + } positions.push({ feeInBaseCurrency, + includeInTotalAssetValue, timeWeightedInvestment, timeWeightedInvestmentWithCurrencyEffect, - dividend: totalDividend, - dividendInBaseCurrency: totalDividendInBaseCurrency, averagePrice: item.averagePrice, currency: item.currency, dataSource: item.dataSource, + dividend: totalDividend, + dividendInBaseCurrency: totalDividendInBaseCurrency, fee: item.fee, firstBuyDate: item.firstBuyDate, grossPerformance: !hasErrors ? (grossPerformance ?? null) : null, diff --git a/apps/api/src/app/portfolio/calculator/roai/portfolio-calculator-cash.spec.ts b/apps/api/src/app/portfolio/calculator/roai/portfolio-calculator-cash.spec.ts index e27bb4daa..f5a4ca634 100644 --- a/apps/api/src/app/portfolio/calculator/roai/portfolio-calculator-cash.spec.ts +++ b/apps/api/src/app/portfolio/calculator/roai/portfolio-calculator-cash.spec.ts @@ -14,7 +14,7 @@ import { ExchangeRateDataServiceMock } from '@ghostfolio/api/services/exchange-r 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 { HistoricalDataItem } from '@ghostfolio/common/interfaces'; +import { TimelinePosition } from '@ghostfolio/common/models'; import { PerformanceCalculationType } from '@ghostfolio/common/types/performance-calculation-type.type'; import { DataSource } from '@prisma/client'; @@ -191,7 +191,8 @@ describe('PortfolioCalculator', () => { const { activities } = await orderService.getOrdersForPortfolioCalculator( { userCurrency: 'CHF', - userId: userDummyData.id + userId: userDummyData.id, + withCash: true } ); @@ -201,7 +202,14 @@ describe('PortfolioCalculator', () => { values: [] }); + const accountBalanceItems = + await accountBalanceService.getAccountBalanceItems({ + userCurrency: 'CHF', + userId: userDummyData.id + }); + const portfolioCalculator = portfolioCalculatorFactory.createCalculator({ + accountBalanceItems, activities, calculationType: PerformanceCalculationType.ROAI, currency: 'CHF', @@ -210,94 +218,72 @@ describe('PortfolioCalculator', () => { const portfolioSnapshot = await portfolioCalculator.computeSnapshot(); - const historicalData20231231 = portfolioSnapshot.historicalData.find( - ({ date }) => { - return date === '2023-12-31'; - } - ); - const historicalData20240101 = portfolioSnapshot.historicalData.find( - ({ date }) => { - return date === '2024-01-01'; - } - ); - const historicalData20241231 = portfolioSnapshot.historicalData.find( - ({ date }) => { - return date === '2024-12-31'; - } - ); - - /** - * Investment value with currency effect: 1000 USD * 0.85 = 850 CHF - * Total investment: 1000 USD * 0.91 = 910 CHF - * Value (current): 1000 USD * 0.91 = 910 CHF - * Value with currency effect: 1000 USD * 0.85 = 850 CHF - */ - expect(historicalData20231231).toMatchObject({ - date: '2023-12-31', - investmentValueWithCurrencyEffect: 850, - netPerformance: 0, - netPerformanceInPercentage: 0, - netPerformanceInPercentageWithCurrencyEffect: 0, - netPerformanceWithCurrencyEffect: 0, - netWorth: 850, - totalAccountBalance: 0, - totalInvestment: 910, - totalInvestmentValueWithCurrencyEffect: 850, - value: 910, - valueWithCurrencyEffect: 850 - }); - - /** - * Net performance with currency effect: (1000 * 0.86) - (1000 * 0.85) = 10 CHF - * Total investment: 1000 USD * 0.91 = 910 CHF - * Total investment value with currency effect: 1000 USD * 0.85 = 850 CHF - * Value (current): 1000 USD * 0.91 = 910 CHF - * Value with currency effect: 1000 USD * 0.86 = 860 CHF - */ - expect(historicalData20240101).toMatchObject({ - date: '2024-01-01', - investmentValueWithCurrencyEffect: 0, - netPerformance: 0, - netPerformanceInPercentage: 0, - netPerformanceInPercentageWithCurrencyEffect: 0.011764705882352941, - netPerformanceWithCurrencyEffect: 10, - netWorth: 860, - totalAccountBalance: 0, - totalInvestment: 910, - totalInvestmentValueWithCurrencyEffect: 850, - value: 910, - valueWithCurrencyEffect: 860 + const position = portfolioSnapshot.positions.find(({ symbol }) => { + return symbol === 'USD'; }); /** - * Investment value with currency effect: 1000 USD * 0.90 = 900 CHF + * Investment: 2000 USD * 0.91 = 1820 CHF + * Investment value with currency effect: (1000 USD * 0.85) + (1000 USD * 0.90) = 1750 CHF * Net performance: (1000 USD * 1.0) - (1000 USD * 1.0) = 0 CHF - * Net performance with currency effect: (1000 USD * 0.9) - (1000 USD * 0.85) = 50 CHF - * Total investment: 2000 USD * 0.91 = 1820 CHF - * Total investment value with currency effect: (1000 USD * 0.85) + (1000 USD * 0.90) = 1750 CHF - * Value (current): 2000 USD * 0.91 = 1820 CHF - * Value with currency effect: 2000 USD * 0.9 = 1800 CHF + * Total account balance: 2000 USD * 0.85 = 1700 CHF (using the exchange rate on 2024-12-31) + * Value in base currency: 2000 USD * 0.91 = 1820 CHF */ - expect(historicalData20241231).toMatchObject({ - date: '2024-12-31', - investmentValueWithCurrencyEffect: 900, - netPerformance: 0, - netPerformanceInPercentage: 0, - netPerformanceInPercentageWithCurrencyEffect: 0.058823529411764705, - netPerformanceWithCurrencyEffect: 50, - netWorth: 1800, - totalAccountBalance: 0, - totalInvestment: 1820, - totalInvestmentValueWithCurrencyEffect: 1750, - value: 1820, - valueWithCurrencyEffect: 1800 + expect(position).toMatchObject({ + averagePrice: new Big(1), + currency: 'USD', + dataSource: DataSource.YAHOO, + dividend: new Big(0), + dividendInBaseCurrency: new Big(0), + fee: new Big(0), + feeInBaseCurrency: new Big(0), + firstBuyDate: '2023-12-31', + grossPerformance: new Big(0), + grossPerformancePercentage: new Big(0), + grossPerformancePercentageWithCurrencyEffect: new Big( + '0.08211603004634809014' + ), + grossPerformanceWithCurrencyEffect: new Big(70), + includeInTotalAssetValue: false, + investment: new Big(1820), + investmentWithCurrencyEffect: new Big(1750), + marketPrice: null, + marketPriceInBaseCurrency: 0.91, + netPerformance: new Big(0), + netPerformancePercentage: new Big(0), + netPerformancePercentageWithCurrencyEffectMap: { + '1d': new Big('0.01111111111111111111'), + '1y': new Big('0.06937181021989792704'), + '5y': new Big('0.0818817546090273363'), + max: new Big('0.0818817546090273363'), + mtd: new Big('0.01111111111111111111'), + wtd: new Big('-0.05517241379310344828'), + ytd: new Big('0.01111111111111111111') + }, + netPerformanceWithCurrencyEffectMap: { + '1d': new Big(20), + '1y': new Big(60), + '5y': new Big(70), + max: new Big(70), + mtd: new Big(20), + wtd: new Big(-80), + ytd: new Big(20) + }, + quantity: new Big(2000), + symbol: 'USD', + timeWeightedInvestment: new Big('912.47956403269754768392'), + timeWeightedInvestmentWithCurrencyEffect: new Big( + '852.45231607629427792916' + ), + transactionCount: 2, + valueInBaseCurrency: new Big(1820) }); expect(portfolioSnapshot).toMatchObject({ hasErrors: false, - totalFeesWithCurrencyEffect: new Big('0'), - totalInterestWithCurrencyEffect: new Big('0'), - totalLiabilitiesWithCurrencyEffect: new Big('0') + totalFeesWithCurrencyEffect: new Big(0), + totalInterestWithCurrencyEffect: new Big(0), + totalLiabilitiesWithCurrencyEffect: new Big(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 070d7543b..2ceed015d 100644 --- a/apps/api/src/app/portfolio/calculator/roai/portfolio-calculator.ts +++ b/apps/api/src/app/portfolio/calculator/roai/portfolio-calculator.ts @@ -34,7 +34,11 @@ export class RoaiPortfolioCalculator extends PortfolioCalculator { let totalTimeWeightedInvestment = new Big(0); let totalTimeWeightedInvestmentWithCurrencyEffect = new Big(0); - for (const currentPosition of positions) { + for (const currentPosition of positions.filter( + ({ includeInTotalAssetValue }) => { + return includeInTotalAssetValue; + } + )) { if (currentPosition.feeInBaseCurrency) { totalFeesWithCurrencyEffect = totalFeesWithCurrencyEffect.plus( currentPosition.feeInBaseCurrency diff --git a/apps/api/src/services/exchange-rate-data/exchange-rate-data.service.mock.ts b/apps/api/src/services/exchange-rate-data/exchange-rate-data.service.mock.ts index 857c1b5a5..742be36b4 100644 --- a/apps/api/src/services/exchange-rate-data/exchange-rate-data.service.mock.ts +++ b/apps/api/src/services/exchange-rate-data/exchange-rate-data.service.mock.ts @@ -1,5 +1,7 @@ -export const ExchangeRateDataServiceMock = { - getExchangeRatesByCurrency: ({ targetCurrency }): Promise => { +import { ExchangeRateDataService } from './exchange-rate-data.service'; + +export const ExchangeRateDataServiceMock: Partial = { + getExchangeRatesByCurrency: ({ targetCurrency }) => { if (targetCurrency === 'CHF') { return Promise.resolve({ CHFCHF: { diff --git a/apps/api/src/services/queues/portfolio-snapshot/portfolio-snapshot.processor.ts b/apps/api/src/services/queues/portfolio-snapshot/portfolio-snapshot.processor.ts index 75a3a8631..58a0a8f8a 100644 --- a/apps/api/src/services/queues/portfolio-snapshot/portfolio-snapshot.processor.ts +++ b/apps/api/src/services/queues/portfolio-snapshot/portfolio-snapshot.processor.ts @@ -50,7 +50,8 @@ export class PortfolioSnapshotProcessor { await this.orderService.getOrdersForPortfolioCalculator({ filters: job.data.filters, userCurrency: job.data.userCurrency, - userId: job.data.userId + userId: job.data.userId, + withCash: true }); const accountBalanceItems = diff --git a/libs/common/src/lib/models/timeline-position.ts b/libs/common/src/lib/models/timeline-position.ts index f683c0951..8eae56cf7 100644 --- a/libs/common/src/lib/models/timeline-position.ts +++ b/libs/common/src/lib/models/timeline-position.ts @@ -50,6 +50,8 @@ export class TimelinePosition { @Type(() => Big) grossPerformanceWithCurrencyEffect: Big; + includeInTotalAssetValue?: boolean; + @Transform(transformToBig, { toClassOnly: true }) @Type(() => Big) investment: Big;