diff --git a/apps/api/src/app/portfolio/calculator/portfolio-calculator.ts b/apps/api/src/app/portfolio/calculator/portfolio-calculator.ts index ccf4005ff..2082c3c2b 100644 --- a/apps/api/src/app/portfolio/calculator/portfolio-calculator.ts +++ b/apps/api/src/app/portfolio/calculator/portfolio-calculator.ts @@ -39,7 +39,6 @@ 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 { @@ -319,6 +318,7 @@ export abstract class PortfolioCalculator { totalCurrentValueWithCurrencyEffect: Big; totalInvestmentValue: Big; totalInvestmentValueWithCurrencyEffect: Big; + totalLiabilitiesWithCurrencyEffect: Big; totalNetPerformanceValue: Big; totalNetPerformanceValueWithCurrencyEffect: Big; totalTimeWeightedInvestmentValue: Big; @@ -389,9 +389,6 @@ export abstract class PortfolioCalculator { hasAnySymbolMetricsErrors = hasAnySymbolMetricsErrors || hasErrors; - const includeInTotalAssetValue = - item.assetSubClass !== AssetSubClass.CASH; - valuesBySymbol[item.symbol] = { currentValues, currentValuesWithCurrencyEffect, @@ -405,7 +402,6 @@ export abstract class PortfolioCalculator { }; positions.push({ - includeInTotalAssetValue, timeWeightedInvestment, timeWeightedInvestmentWithCurrencyEffect, activitiesCount: item.activitiesCount, @@ -479,8 +475,28 @@ export abstract class PortfolioCalculator { {} as { [date: string]: Big } ); + const liabilityItemsMap = this.activities.reduce( + (map, { assetProfile, date, quantity, type, unitPrice }) => { + if (type === 'LIABILITY') { + const exchangeRate = + exchangeRatesByCurrency[ + `${assetProfile.currency}${this.currency}` + ]?.[date] ?? 1; + + map[date] = (map[date] ?? new Big(0)).plus( + quantity.mul(unitPrice).mul(exchangeRate) + ); + } + + return map; + }, + {} as { [date: string]: Big } + ); + const accountBalanceMap: { [date: string]: Big } = {}; + const liabilitiesMap: { [date: string]: Big } = {}; + let accumulatedLiabilities = new Big(0); let lastKnownBalance = new Big(0); for (const dateString of chartDates) { @@ -492,6 +508,14 @@ export abstract class PortfolioCalculator { // Add the most recent balance to the accountBalanceMap accountBalanceMap[dateString] = lastKnownBalance; + if (liabilityItemsMap[dateString] !== undefined) { + accumulatedLiabilities = accumulatedLiabilities.plus( + liabilityItemsMap[dateString] + ); + } + + liabilitiesMap[dateString] = accumulatedLiabilities; + for (const symbol of Object.keys(valuesBySymbol)) { const symbolValues = valuesBySymbol[symbol]; @@ -550,6 +574,7 @@ export abstract class PortfolioCalculator { accumulatedValuesByDate[dateString] ?.totalInvestmentValueWithCurrencyEffect ?? new Big(0) ).add(investmentValueAccumulatedWithCurrencyEffect), + totalLiabilitiesWithCurrencyEffect: liabilitiesMap[dateString], totalNetPerformanceValue: ( accumulatedValuesByDate[dateString]?.totalNetPerformanceValue ?? new Big(0) @@ -580,6 +605,7 @@ export abstract class PortfolioCalculator { totalCurrentValueWithCurrencyEffect, totalInvestmentValue, totalInvestmentValueWithCurrencyEffect, + totalLiabilitiesWithCurrencyEffect, totalNetPerformanceValue, totalNetPerformanceValueWithCurrencyEffect, totalTimeWeightedInvestmentValue, @@ -608,7 +634,9 @@ export abstract class PortfolioCalculator { netPerformance: totalNetPerformanceValue.toNumber(), netPerformanceWithCurrencyEffect: totalNetPerformanceValueWithCurrencyEffect.toNumber(), - netWorth: totalCurrentValueWithCurrencyEffect.toNumber(), + netWorth: totalCurrentValueWithCurrencyEffect + .minus(totalLiabilitiesWithCurrencyEffect) + .toNumber(), totalAccountBalance: totalAccountBalanceWithCurrencyEffect.toNumber(), totalInvestment: totalInvestmentValue.toNumber(), totalInvestmentValueWithCurrencyEffect: @@ -770,11 +798,6 @@ export abstract class PortfolioCalculator { ? 0 : netPerformanceWithCurrencyEffectSinceStartDate / timeWeightedInvestmentValue - // TODO: Add net worth - // netWorth: totalCurrentValueWithCurrencyEffect - // .plus(totalAccountBalanceWithCurrencyEffect) - // .toNumber() - // netWorth: 0 }); } } 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 1c2eb69d1..559fb0ed1 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 @@ -137,7 +137,7 @@ describe('PortfolioCalculator', () => { }); describe('Cash Performance', () => { - it('should calculate performance for cash assets in CHF default currency', async () => { + const computePortfolioSnapshot = async () => { jest.useFakeTimers().setSystemTime(parseDate('2025-01-01').getTime()); const accountId = randomUUID(); @@ -217,7 +217,11 @@ describe('PortfolioCalculator', () => { userId: userDummyData.id }); - const portfolioSnapshot = await portfolioCalculator.computeSnapshot(); + return portfolioCalculator.computeSnapshot(); + }; + + it('should calculate performance for cash assets in CHF default currency', async () => { + const portfolioSnapshot = await computePortfolioSnapshot(); const position = portfolioSnapshot.positions.find(({ symbol }) => { return symbol === 'USD'; @@ -246,7 +250,6 @@ describe('PortfolioCalculator', () => { '0.08211603004634809014' ), grossPerformanceWithCurrencyEffect: new Big(70), - includeInTotalAssetValue: false, investment: new Big(1820), investmentWithCurrencyEffect: new Big(1750), marketPrice: 1, @@ -281,115 +284,40 @@ describe('PortfolioCalculator', () => { }); expect(portfolioSnapshot).toMatchObject({ + currentValueInBaseCurrency: new Big(1820), hasErrors: false, totalFeesWithCurrencyEffect: new Big(0), totalInterestWithCurrencyEffect: new Big(0), + totalInvestment: new Big(1820), totalLiabilitiesWithCurrencyEffect: new Big(0) }); }); it('should include cash in the net worth and performance without counting it twice', async () => { - jest.useFakeTimers().setSystemTime(parseDate('2025-01-01').getTime()); - - const accountId = randomUUID(); - - jest - .spyOn(accountBalanceService, 'getAccountBalances') - .mockResolvedValue({ - balances: [ - { - accountId, - date: parseDate('2023-12-31'), - id: randomUUID(), - value: 1000, - valueInBaseCurrency: 850 - }, - { - accountId, - date: parseDate('2024-12-31'), - id: randomUUID(), - value: 2000, - valueInBaseCurrency: 1800 - } - ] - }); - - jest.spyOn(accountService, 'getCashDetails').mockResolvedValue({ - accounts: [ - { - balance: 2000, - comment: null, - createdAt: parseDate('2023-12-31'), - currency: 'USD', - id: accountId, - isExcluded: false, - name: 'USD', - platformId: null, - updatedAt: parseDate('2023-12-31'), - userId: userDummyData.id - } - ], - balanceInBaseCurrency: 1820 - }); - - jest - .spyOn(dataProviderService, 'getDataSourceForExchangeRates') - .mockReturnValue(DataSource.YAHOO); - - jest.spyOn(activitiesService, 'getActivities').mockResolvedValue({ - activities: [], - count: 0 - }); - - const { activities } = - await activitiesService.getActivitiesForPortfolioCalculator({ - userCurrency: 'CHF', - userId: userDummyData.id, - withCash: true - }); - - jest.spyOn(currentRateService, 'getValues').mockResolvedValue({ - dataProviderInfos: [], - errors: [], - values: [] - }); - - const accountBalanceItems = - await accountBalanceService.getAccountBalanceItems({ - userCurrency: 'CHF', - userId: userDummyData.id - }); - - const portfolioCalculator = portfolioCalculatorFactory.createCalculator({ - accountBalanceItems, - activities, - calculationType: PerformanceCalculationType.ROAI, - currency: 'CHF', - userId: userDummyData.id - }); - - const portfolioSnapshot = await portfolioCalculator.computeSnapshot(); + const portfolioSnapshot = await computePortfolioSnapshot(); const lastDataItem = portfolioSnapshot.historicalData.at(-1); - // The cash position is now reflected in the portfolio value (it used to - // be excluded, which left the value at 0 for a cash-only portfolio). - expect(lastDataItem.valueWithCurrencyEffect).toBeGreaterThan(0); - - // The account balance is still tracked ... - expect(lastDataItem.totalAccountBalance).toBeGreaterThan(0); - - // ... but it is no longer added on top of the cash position, so the net - // worth equals the portfolio value and cash is not counted twice. - expect(lastDataItem.netWorth).toBeCloseTo( - lastDataItem.valueWithCurrencyEffect - ); - - // Cash now contributes to the performance (here through the currency - // effect), so it is no longer flat at 0 %. - expect( - lastDataItem.netPerformanceInPercentageWithCurrencyEffect - ).not.toBe(0); + /** + * Value with currency effect: 2000 USD * 0.91 = 1820 CHF + * Net worth: 1820 CHF - 0 CHF (liabilities) = 1820 CHF + * Total account balance: 1800 CHF (balance in base currency on 2024-12-31) + * Net performance with currency effect: 70 CHF / 852.45 CHF ≈ 8.21 % + */ + expect(lastDataItem).toEqual({ + date: '2025-01-01', + investmentValueWithCurrencyEffect: 0, + netPerformance: 0, + netPerformanceInPercentage: 0, + netPerformanceInPercentageWithCurrencyEffect: 0.08211603004634808, + netPerformanceWithCurrencyEffect: 70, + netWorth: 1820, + totalAccountBalance: 1800, + totalInvestment: 1820, + totalInvestmentValueWithCurrencyEffect: 1750, + value: 1820, + valueWithCurrencyEffect: 1820 + }); }); }); }); diff --git a/apps/api/src/app/portfolio/calculator/roai/portfolio-calculator-liability.spec.ts b/apps/api/src/app/portfolio/calculator/roai/portfolio-calculator-liability.spec.ts index 94654af61..51a4c0368 100644 --- a/apps/api/src/app/portfolio/calculator/roai/portfolio-calculator-liability.spec.ts +++ b/apps/api/src/app/portfolio/calculator/roai/portfolio-calculator-liability.spec.ts @@ -113,6 +113,14 @@ describe('PortfolioCalculator', () => { expect(portfolioSnapshot.totalLiabilitiesWithCurrencyEffect).toEqual( new Big(3000) ); + + /** + * Net worth: 0 USD (current value) - 3000 USD (liabilities) = -3000 USD + */ + expect(portfolioSnapshot.historicalData.at(-1)).toMatchObject({ + netWorth: -3000, + valueWithCurrencyEffect: 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 f79e5bc79..daacdc60d 100644 --- a/apps/api/src/app/portfolio/calculator/roai/portfolio-calculator.ts +++ b/apps/api/src/app/portfolio/calculator/roai/portfolio-calculator.ts @@ -40,11 +40,7 @@ export class RoaiPortfolioCalculator extends PortfolioCalculator { let totalTimeWeightedInvestment = new Big(0); let totalTimeWeightedInvestmentWithCurrencyEffect = new Big(0); - for (const currentPosition of positions.filter( - ({ includeInTotalAssetValue }) => { - return includeInTotalAssetValue; - } - )) { + for (const currentPosition of positions) { if (currentPosition.feeInBaseCurrency) { totalFeesWithCurrencyEffect = totalFeesWithCurrencyEffect.plus( currentPosition.feeInBaseCurrency diff --git a/apps/api/src/app/portfolio/portfolio.service.ts b/apps/api/src/app/portfolio/portfolio.service.ts index 6617b8f9b..108e30618 100644 --- a/apps/api/src/app/portfolio/portfolio.service.ts +++ b/apps/api/src/app/portfolio/portfolio.service.ts @@ -541,12 +541,6 @@ export class PortfolioService { let filteredValueInBaseCurrency = currentValueInBaseCurrency; - if (!this.activitiesService.areCashActivitiesExcludedByFilters(filters)) { - filteredValueInBaseCurrency = filteredValueInBaseCurrency.plus( - cashDetails.balanceInBaseCurrency - ); - } - const assetProfileIdentifiers = positions.map(({ dataSource, symbol }) => { return { dataSource, @@ -1969,8 +1963,7 @@ export class PortfolioService { .plus(totalOfExcludedActivities) .toNumber(); - const netWorth = new Big(balanceInBaseCurrency) - .plus(currentValueInBaseCurrency) + const netWorth = new Big(currentValueInBaseCurrency) .plus(excludedAccountsAndActivities) .minus(liabilities) .toNumber(); diff --git a/libs/common/src/lib/models/timeline-position.ts b/libs/common/src/lib/models/timeline-position.ts index 13f9001d5..b16db4988 100644 --- a/libs/common/src/lib/models/timeline-position.ts +++ b/libs/common/src/lib/models/timeline-position.ts @@ -51,8 +51,6 @@ export class TimelinePosition { @Type(() => Big) grossPerformanceWithCurrencyEffect: Big; - includeInTotalAssetValue?: boolean; - @Transform(transformToBig, { toClassOnly: true }) @Type(() => Big) investment: Big;