|
|
|
@ -1,7 +1,11 @@ |
|
|
|
import { AccountBalanceService } from '@ghostfolio/api/app/account-balance/account-balance.service'; |
|
|
|
import { AccountService } from '@ghostfolio/api/app/account/account.service'; |
|
|
|
import { ActivitiesService } from '@ghostfolio/api/app/activities/activities.service'; |
|
|
|
import { userDummyData } from '@ghostfolio/api/app/portfolio/calculator/portfolio-calculator-test-utils'; |
|
|
|
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'; |
|
|
|
@ -19,6 +23,7 @@ import { PerformanceCalculationType } from '@ghostfolio/common/types/performance |
|
|
|
|
|
|
|
import { DataSource } from '@prisma/client'; |
|
|
|
import { Big } from 'big.js'; |
|
|
|
import { eachDayOfInterval } from 'date-fns'; |
|
|
|
import { randomUUID } from 'node:crypto'; |
|
|
|
|
|
|
|
jest.mock('@ghostfolio/api/app/portfolio/current-rate.service', () => { |
|
|
|
@ -314,5 +319,287 @@ describe('PortfolioCalculator', () => { |
|
|
|
valueWithCurrencyEffect: 1820 |
|
|
|
}); |
|
|
|
}); |
|
|
|
|
|
|
|
it('should exclude cash in the base currency from the performance calculation', 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: 1000 |
|
|
|
}, |
|
|
|
{ |
|
|
|
accountId, |
|
|
|
date: parseDate('2024-12-31'), |
|
|
|
id: randomUUID(), |
|
|
|
value: 2000, |
|
|
|
valueInBaseCurrency: 2000 |
|
|
|
} |
|
|
|
] |
|
|
|
}); |
|
|
|
|
|
|
|
jest.spyOn(accountService, 'getCashDetails').mockResolvedValue({ |
|
|
|
accounts: [ |
|
|
|
{ |
|
|
|
balance: 2000, |
|
|
|
comment: null, |
|
|
|
createdAt: parseDate('2023-12-31'), |
|
|
|
currency: 'CHF', |
|
|
|
id: accountId, |
|
|
|
isExcluded: false, |
|
|
|
name: 'CHF', |
|
|
|
platformId: null, |
|
|
|
updatedAt: parseDate('2023-12-31'), |
|
|
|
userId: userDummyData.id |
|
|
|
} |
|
|
|
], |
|
|
|
balanceInBaseCurrency: 2000 |
|
|
|
}); |
|
|
|
|
|
|
|
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 position = portfolioSnapshot.positions.find(({ symbol }) => { |
|
|
|
return symbol === 'CHF'; |
|
|
|
}); |
|
|
|
|
|
|
|
/** |
|
|
|
* The holding itself keeps its investment and value so that it remains |
|
|
|
* visible in the holdings table |
|
|
|
*/ |
|
|
|
expect(position).toMatchObject<Partial<TimelinePosition>>({ |
|
|
|
currency: 'CHF', |
|
|
|
grossPerformance: new Big(0), |
|
|
|
grossPerformanceWithCurrencyEffect: new Big(0), |
|
|
|
investment: new Big(2000), |
|
|
|
investmentWithCurrencyEffect: new Big(2000), |
|
|
|
netPerformance: new Big(0), |
|
|
|
quantity: new Big(2000), |
|
|
|
symbol: 'CHF', |
|
|
|
valueInBaseCurrency: new Big(2000) |
|
|
|
}); |
|
|
|
|
|
|
|
/** |
|
|
|
* Total investment: 0 CHF (cash in the base currency cannot generate a |
|
|
|
* currency effect and would only dilute the performance) |
|
|
|
* Current value in base currency: 2000 CHF (the cash still counts |
|
|
|
* towards the net worth) |
|
|
|
*/ |
|
|
|
expect(portfolioSnapshot).toMatchObject({ |
|
|
|
currentValueInBaseCurrency: new Big(2000), |
|
|
|
hasErrors: false, |
|
|
|
totalCashInBaseCurrency: new Big(2000), |
|
|
|
totalFeesWithCurrencyEffect: new Big(0), |
|
|
|
totalInterestWithCurrencyEffect: new Big(0), |
|
|
|
totalInvestment: new Big(0), |
|
|
|
totalLiabilitiesWithCurrencyEffect: new Big(0) |
|
|
|
}); |
|
|
|
|
|
|
|
/** |
|
|
|
* Value: 0 CHF (the cash is excluded from the performance calculation |
|
|
|
* and therefore from the value it is measured against) |
|
|
|
* Net worth: 2000 CHF (the cash still counts towards the net worth) |
|
|
|
*/ |
|
|
|
expect(portfolioSnapshot.historicalData.at(-1)).toEqual({ |
|
|
|
date: '2025-01-01', |
|
|
|
investmentValueWithCurrencyEffect: 0, |
|
|
|
netPerformance: 0, |
|
|
|
netPerformanceInPercentage: 0, |
|
|
|
netPerformanceInPercentageWithCurrencyEffect: 0, |
|
|
|
netPerformanceWithCurrencyEffect: 0, |
|
|
|
netWorth: 2000, |
|
|
|
totalCashInBaseCurrency: 2000, |
|
|
|
totalInvestment: 0, |
|
|
|
totalInvestmentValueWithCurrencyEffect: 0, |
|
|
|
value: 0, |
|
|
|
valueWithCurrencyEffect: 0 |
|
|
|
}); |
|
|
|
}); |
|
|
|
|
|
|
|
it('should add cash in the base currency to the net worth of a portfolio with holdings', 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: 2000, |
|
|
|
valueInBaseCurrency: 2000 |
|
|
|
} |
|
|
|
] |
|
|
|
}); |
|
|
|
|
|
|
|
jest.spyOn(accountService, 'getCashDetails').mockResolvedValue({ |
|
|
|
accounts: [ |
|
|
|
{ |
|
|
|
balance: 2000, |
|
|
|
comment: null, |
|
|
|
createdAt: parseDate('2023-12-31'), |
|
|
|
currency: 'CHF', |
|
|
|
id: accountId, |
|
|
|
isExcluded: false, |
|
|
|
name: 'CHF', |
|
|
|
platformId: null, |
|
|
|
updatedAt: parseDate('2023-12-31'), |
|
|
|
userId: userDummyData.id |
|
|
|
} |
|
|
|
], |
|
|
|
balanceInBaseCurrency: 2000 |
|
|
|
}); |
|
|
|
|
|
|
|
jest |
|
|
|
.spyOn(dataProviderService, 'getDataSourceForExchangeRates') |
|
|
|
.mockReturnValue(DataSource.YAHOO); |
|
|
|
|
|
|
|
jest.spyOn(activitiesService, 'getActivities').mockResolvedValue({ |
|
|
|
activities: [ |
|
|
|
{ |
|
|
|
...activityDummyData, |
|
|
|
assetProfile: { |
|
|
|
...assetProfileDummyData, |
|
|
|
currency: 'CHF', |
|
|
|
dataSource: 'YAHOO', |
|
|
|
name: 'Novartis AG', |
|
|
|
symbol: 'NOVN.SW' |
|
|
|
}, |
|
|
|
date: parseDate('2023-12-31'), |
|
|
|
feeInAssetProfileCurrency: 0, |
|
|
|
feeInBaseCurrency: 0, |
|
|
|
quantity: 2, |
|
|
|
type: 'BUY', |
|
|
|
unitPriceInAssetProfileCurrency: 100 |
|
|
|
} |
|
|
|
], |
|
|
|
count: 1 |
|
|
|
}); |
|
|
|
|
|
|
|
const { activities } = |
|
|
|
await activitiesService.getActivitiesForPortfolioCalculator({ |
|
|
|
userCurrency: 'CHF', |
|
|
|
userId: userDummyData.id, |
|
|
|
withCash: true |
|
|
|
}); |
|
|
|
|
|
|
|
// The cash symbol has no market data, the holding is quoted at a
|
|
|
|
// constant price so that it does not generate any performance on its own
|
|
|
|
jest |
|
|
|
.spyOn(currentRateService, 'getValues') |
|
|
|
.mockImplementation(({ dataGatheringItems, dateQuery }) => { |
|
|
|
const values = []; |
|
|
|
|
|
|
|
for (const date of eachDayOfInterval({ |
|
|
|
end: dateQuery.lt, |
|
|
|
start: dateQuery.gte |
|
|
|
})) { |
|
|
|
for (const { dataSource, symbol } of dataGatheringItems) { |
|
|
|
if (symbol === 'NOVN.SW') { |
|
|
|
values.push({ date, dataSource, marketPrice: 100, symbol }); |
|
|
|
} |
|
|
|
} |
|
|
|
} |
|
|
|
|
|
|
|
return Promise.resolve({ |
|
|
|
values, |
|
|
|
dataProviderInfos: [], |
|
|
|
errors: [] |
|
|
|
}); |
|
|
|
}); |
|
|
|
|
|
|
|
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(); |
|
|
|
|
|
|
|
/** |
|
|
|
* Total assets: 2000 CHF cash + 2 * 100 CHF holding = 2200 CHF |
|
|
|
* Total investment: 200 CHF (only the holding, the cash is excluded) |
|
|
|
*/ |
|
|
|
expect(portfolioSnapshot).toMatchObject({ |
|
|
|
currentValueInBaseCurrency: new Big(2200), |
|
|
|
hasErrors: false, |
|
|
|
totalCashInBaseCurrency: new Big(2000), |
|
|
|
totalInvestment: new Big(200) |
|
|
|
}); |
|
|
|
|
|
|
|
/** |
|
|
|
* Value: 200 CHF (the holding only, the cash is excluded from the |
|
|
|
* performance calculation) |
|
|
|
* Net worth: 2200 CHF (the value plus the cash, counted exactly once) |
|
|
|
*/ |
|
|
|
expect(portfolioSnapshot.historicalData.at(-1)).toEqual({ |
|
|
|
date: '2025-01-01', |
|
|
|
investmentValueWithCurrencyEffect: 0, |
|
|
|
netPerformance: 0, |
|
|
|
netPerformanceInPercentage: 0, |
|
|
|
netPerformanceInPercentageWithCurrencyEffect: 0, |
|
|
|
netPerformanceWithCurrencyEffect: 0, |
|
|
|
netWorth: 2200, |
|
|
|
totalCashInBaseCurrency: 2000, |
|
|
|
totalInvestment: 200, |
|
|
|
totalInvestmentValueWithCurrencyEffect: 200, |
|
|
|
value: 200, |
|
|
|
valueWithCurrencyEffect: 200 |
|
|
|
}); |
|
|
|
}); |
|
|
|
}); |
|
|
|
}); |
|
|
|
|