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 { ActivitiesService } from '@ghostfolio/api/app/activities/activities.service'; import { PortfolioCalculator } from '@ghostfolio/api/app/portfolio/calculator/portfolio-calculator'; import { 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 { UserService } from '@ghostfolio/api/app/user/user.service'; import { ConfigurationService } from '@ghostfolio/api/services/configuration/configuration.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 { SymbolProfileService } from '@ghostfolio/api/services/symbol-profile/symbol-profile.service'; import { TAG_ID_EMERGENCY_FUND, TAG_ID_EXCLUDE_FROM_ANALYSIS, UNKNOWN_KEY } from '@ghostfolio/common/config'; import { parseDate, resetHours } from '@ghostfolio/common/helper'; import { Activity, AssetProfileIdentifier, Filter, PortfolioSummary } from '@ghostfolio/common/interfaces'; import { AccountWithBalance } from '@ghostfolio/common/types'; import { AssetClass, DataSource, Prisma } from '@prisma/client'; import { Big } from 'big.js'; import { endOfDay } from 'date-fns'; import { randomUUID } from 'node:crypto'; import { PortfolioService } from './portfolio.service'; describe('PortfolioService', () => { let accountBalanceService: AccountBalanceService; let accountService: AccountService; let activitiesService: ActivitiesService; let configurationService: ConfigurationService; let currentRateService: CurrentRateService; let dataProviderService: DataProviderService; let exchangeRateDataService: ExchangeRateDataService; let portfolioCalculatorFactory: PortfolioCalculatorFactory; let portfolioService: PortfolioService; let symbolProfileService: SymbolProfileService; let userService: UserService; beforeEach(() => { configurationService = new ConfigurationService(); dataProviderService = new DataProviderService( configurationService, null, null, null, null, null ); exchangeRateDataService = new ExchangeRateDataService( null, null, null, null ); accountBalanceService = new AccountBalanceService( null, exchangeRateDataService, null ); accountService = new AccountService( null, null, exchangeRateDataService, null, null ); activitiesService = new ActivitiesService( null, accountService, null, null, null, dataProviderService, null, exchangeRateDataService, null, null, null, null ); currentRateService = new CurrentRateService( activitiesService, dataProviderService, null ); portfolioCalculatorFactory = new PortfolioCalculatorFactory( configurationService, null, exchangeRateDataService, null, null ); symbolProfileService = new SymbolProfileService(null); userService = new UserService( null, null, null, null, null, null, null, null, null ); portfolioService = new PortfolioService( accountBalanceService, accountService, activitiesService, null, portfolioCalculatorFactory, currentRateService, dataProviderService, exchangeRateDataService, null, null, null, symbolProfileService, userService ); }); describe('getAccounts', () => { const tagId = 'd6bf8b4a-8ef9-4b3f-9e2c-0a1b2c3d4e5f'; const whereTagsOfActivity = { some: { OR: [{ id: tagId }] } }; const whereActivityOfTag = { OR: [ { account: { tags: { some: { OR: [{ tagId }] } } } }, { tags: whereTagsOfActivity } ] }; const whereActivityOfAssetClass = { SymbolProfile: { OR: [ { AND: [ { OR: [{ assetClass: AssetClass.EQUITY }] }, { OR: [ { assetProfileOverrides: { assetClass: null } }, { assetProfileOverrides: { is: null } } ] } ] }, { assetProfileOverrides: { OR: [{ assetClass: AssetClass.EQUITY }] } } ] } }; const getAccountsQuery = async (filters?: Filter[]) => { const accountsSpy = jest .spyOn(accountService, 'accounts') .mockResolvedValue([]); jest.spyOn(portfolioService, 'getDetails').mockResolvedValue({ accounts: {} } as unknown as Awaited>); jest.spyOn(userService, 'user').mockResolvedValue(null); await portfolioService.getAccounts({ filters, userId: userDummyData.id }); const { include, where } = accountsSpy.mock.calls[0][0]; return { where, whereOfActivities: ( include.activities as { where?: Prisma.OrderWhereInput } ).where }; }; it('should not restrict the accounts and their activities without a filter', async () => { const { where, whereOfActivities } = await getAccountsQuery(); expect(where).toEqual({ userId: userDummyData.id }); expect(whereOfActivities).toBeUndefined(); }); it('should match an account with the tag filter by its own tags or by the tags of its activities', async () => { const { where, whereOfActivities } = await getAccountsQuery([ { id: tagId, type: 'TAG' } ]); expect(where.AND).toEqual([ { OR: [ { activities: { some: { tags: whereTagsOfActivity } } }, { tags: { some: { OR: [{ tagId }] } } } ] } ]); expect(whereOfActivities).toEqual({ AND: [whereActivityOfTag] }); }); it('should restrict the accounts and their activities to the asset class filter', async () => { const { where, whereOfActivities } = await getAccountsQuery([ { id: AssetClass.EQUITY, type: 'ASSET_CLASS' } ]); expect(where.AND).toEqual([ { activities: { some: whereActivityOfAssetClass } } ]); expect(whereOfActivities).toEqual({ AND: [whereActivityOfAssetClass] }); }); it('should restrict the accounts and their activities to the holding filter', async () => { const whereActivityOfHolding = { SymbolProfile: { AND: [{ dataSource: DataSource.YAHOO }, { symbol: 'AAPL' }] } }; const { where, whereOfActivities } = await getAccountsQuery([ { id: DataSource.YAHOO, type: 'DATA_SOURCE' }, { id: 'AAPL', type: 'SYMBOL' } ]); expect(where.AND).toEqual([ { activities: { some: whereActivityOfHolding } } ]); expect(whereOfActivities).toEqual({ AND: [whereActivityOfHolding] }); }); it('should combine the filters of different types with a logical and', async () => { const { where, whereOfActivities } = await getAccountsQuery([ { id: AssetClass.EQUITY, type: 'ASSET_CLASS' }, { id: tagId, type: 'TAG' } ]); expect(where.AND).toEqual([ { activities: { some: whereActivityOfAssetClass } }, { OR: [ { activities: { some: { tags: whereTagsOfActivity } } }, { tags: { some: { OR: [{ tagId }] } } } ] } ]); expect(whereOfActivities).toEqual({ AND: [whereActivityOfAssetClass, whereActivityOfTag] }); }); it('should calculate the dividend and the interest of an account as quantity times unit price', async () => { jest.spyOn(accountService, 'accounts').mockResolvedValue([ { activities: [ { currency: 'USD', date: new Date('2024-01-02'), quantity: 2, SymbolProfile: { currency: 'USD' }, tags: [], type: 'DIVIDEND', unitPrice: 10 }, { currency: 'USD', date: new Date('2024-01-03'), quantity: 3, SymbolProfile: { currency: 'USD' }, tags: [], type: 'INTEREST', unitPrice: 5 } ], balance: 0, currency: 'USD', id: 'account-id', name: 'Account' } ] as unknown as Awaited>); jest.spyOn(portfolioService, 'getDetails').mockResolvedValue({ accounts: {} } as unknown as Awaited>); jest.spyOn(userService, 'user').mockResolvedValue({ settings: { settings: { baseCurrency: 'USD' } } } as unknown as Awaited>); jest .spyOn(exchangeRateDataService, 'toCurrencyAtDate') .mockImplementation(async (value) => { return value; }); jest .spyOn(exchangeRateDataService, 'toCurrency') .mockImplementation((value) => { return value; }); const [account] = await portfolioService.getAccounts({ userId: userDummyData.id }); expect(account).toMatchObject({ dividendInBaseCurrency: 20, interestInBaseCurrency: 15 }); }); }); describe('getAggregatedMarkets', () => { const getAggregatedMarkets = (holdings: object[]) => { return ( portfolioService as unknown as { getAggregatedMarkets: (aHoldings: object[]) => { markets: Record< string, { valueInBaseCurrency: number; valueInPercentage: number } >; marketsAdvanced: Record; }; } ).getAggregatedMarkets(holdings); }; it('should distribute holdings with countries to their market and route holdings without countries (e.g. commodities, cryptocurrencies) to the unknown bucket', () => { const holdings = [ { // Gold (GC=F) assetProfile: { countries: [] }, markets: { developedMarkets: 0, emergingMarkets: 0, otherMarkets: 0 }, marketsAdvanced: { asiaPacific: 0, emergingMarkets: 0, europe: 0, japan: 0, northAmerica: 0, otherMarkets: 0 }, valueInBaseCurrency: 500 }, { // MSFT assetProfile: { countries: [{ code: 'US', weight: 1 }] }, markets: { developedMarkets: 1, emergingMarkets: 0, otherMarkets: 0 }, marketsAdvanced: { asiaPacific: 0, emergingMarkets: 0, europe: 0, japan: 0, northAmerica: 1, otherMarkets: 0 }, valueInBaseCurrency: 1000 } ]; const { markets, marketsAdvanced } = getAggregatedMarkets(holdings); expect(markets.developedMarkets.valueInBaseCurrency).toBe(1000); expect(markets[UNKNOWN_KEY].valueInBaseCurrency).toBe(500); expect(markets.developedMarkets.valueInPercentage).toBeCloseTo( 1000 / 1500 ); expect(markets[UNKNOWN_KEY].valueInPercentage).toBeCloseTo(500 / 1500); expect(marketsAdvanced.northAmerica.valueInBaseCurrency).toBe(1000); expect(marketsAdvanced[UNKNOWN_KEY].valueInBaseCurrency).toBe(500); }); }); describe('getCashSymbolProfiles', () => { it('should use the exchange-rate data source so the symbol-profile join in getDetails matches the calculator positions', () => { jest .spyOn(dataProviderService, 'getDataSourceForExchangeRates') .mockReturnValue(DataSource.YAHOO); const cashDetails: CashDetails = { accounts: [ { balance: 2000, comment: null, createdAt: parseDate('2024-01-01'), currency: 'USD', id: randomUUID(), name: 'USD', platformId: null, updatedAt: parseDate('2024-01-01'), userId: userDummyData.id } ], balanceInBaseCurrency: 1820 }; const assetProfiles = ( portfolioService as unknown as { getCashSymbolProfiles: ( aCashDetails: CashDetails ) => AssetProfileIdentifier[]; } ).getCashSymbolProfiles(cashDetails); expect(assetProfiles).toHaveLength(1); expect(assetProfiles[0].dataSource).toBe(DataSource.YAHOO); expect(assetProfiles[0].symbol).toBe('USD'); }); }); describe('getDetails', () => { const setUpCashOnlyPortfolio = ({ baseCurrency = 'CHF', emergencyFund, quantity = 2000 }: { baseCurrency?: string; emergencyFund?: number; quantity?: number; } = {}) => { const cashAccount: AccountWithBalance = { balance: 2000, comment: null, createdAt: parseDate('2024-01-01'), currency: 'USD', id: randomUUID(), name: 'USD', platformId: null, updatedAt: parseDate('2024-01-01'), userId: userDummyData.id }; jest.spyOn(accountService, 'getCashDetails').mockResolvedValue({ accounts: [cashAccount], balanceInBaseCurrency: 1820 }); jest .spyOn(activitiesService, 'getActivitiesForPortfolioCalculator') .mockResolvedValue({ activities: [], count: 0 }); jest .spyOn(dataProviderService, 'getDataSourceForExchangeRates') .mockReturnValue(DataSource.YAHOO); jest .spyOn(symbolProfileService, 'getSymbolProfiles') .mockResolvedValue([]); jest.spyOn(userService, 'user').mockResolvedValue({ accounts: [], activitiesCount: 0, dataProviderGhostfolioDailyRequests: 0, id: userDummyData.id, settings: { settings: { baseCurrency, emergencyFund } } } as unknown as Awaited>); const usdPosition = { activitiesCount: 1, averageInvestment: new Big(0), averageInvestmentWithCurrencyEffect: new Big(0), averagePrice: new Big(1), currency: 'USD', dataSource: DataSource.YAHOO, dateOfFirstActivity: '2024-01-01', dividend: new Big(0), dividendInBaseCurrency: new Big(0), fee: new Big(0), feeInBaseCurrency: new Big(0), grossPerformance: new Big(0), grossPerformancePercentage: new Big(0), grossPerformancePercentageWithCurrencyEffect: new Big(0), grossPerformanceWithCurrencyEffect: new Big(0), investment: new Big(1820), investmentWithCurrencyEffect: new Big(1820), marketPrice: 1, marketPriceInBaseCurrency: 0.91, netPerformance: new Big(0), netPerformancePercentage: new Big(0), netPerformancePercentageWithCurrencyEffectMap: {}, netPerformanceWithCurrencyEffectMap: {}, quantity: new Big(quantity), symbol: 'USD', tags: [], valueInBaseCurrency: new Big(1820) }; jest .spyOn(portfolioCalculatorFactory, 'createCalculator') .mockReturnValue({ getSnapshot: jest.fn().mockResolvedValue({ activitiesCount: 1, createdAt: parseDate('2024-01-01'), currentValueInBaseCurrency: new Big(1820), errors: [], hasErrors: false, historicalData: [], positions: [usdPosition], totalFeesWithCurrencyEffect: new Big(0), totalInterestWithCurrencyEffect: new Big(0), totalInvestment: new Big(1820), totalInvestmentWithCurrencyEffect: new Big(1820), totalLiabilitiesWithCurrencyEffect: new Big(0) }) } as unknown as ReturnType< typeof portfolioCalculatorFactory.createCalculator >); jest .spyOn( portfolioService as unknown as { getValueOfAccountsAndPlatforms: () => Promise<{ accounts: object; platforms: object; }>; }, 'getValueOfAccountsAndPlatforms' ) .mockResolvedValue({ accounts: {}, platforms: {} }); }; it('should return cash holdings when the calculator emits cash positions with the exchange-rate data source', async () => { setUpCashOnlyPortfolio(); const { holdings } = await portfolioService.getDetails({ filters: [], userId: userDummyData.id }); expect(holdings).toEqual([ expect.objectContaining({ assetProfile: expect.objectContaining({ dataSource: DataSource.YAHOO, symbol: 'USD' }) }) ]); }); it('should replace the existing cash holding instead of adding a second one when filtering by the emergency fund tag', async () => { setUpCashOnlyPortfolio({ baseCurrency: 'USD', emergencyFund: 1000 }); const { holdings } = await portfolioService.getDetails({ filters: [{ id: TAG_ID_EMERGENCY_FUND, type: 'TAG' }], userId: userDummyData.id }); expect(holdings).toHaveLength(1); expect(holdings[0].assetProfile.symbol).toBe('USD'); expect(holdings[0].valueInBaseCurrency).toBe(1000); }); it('should include closed holdings when all holdings are requested', async () => { setUpCashOnlyPortfolio({ quantity: 0 }); const { holdings } = await portfolioService.getDetails({ filters: [], includeAllHoldings: true, userId: userDummyData.id }); expect(holdings).toHaveLength(1); expect(holdings[0].quantity).toBe(0); }); it.each([ { holdingType: 'ACTIVE', quantity: 2000 }, { holdingType: 'CLOSED', quantity: 0 } ])( 'should return $holdingType holdings when the holding type is specified', async ({ holdingType, quantity }) => { setUpCashOnlyPortfolio({ quantity }); const { holdings } = await portfolioService.getDetails({ filters: [{ id: holdingType, type: 'HOLDING_TYPE' }], userId: userDummyData.id }); expect(holdings).toHaveLength(1); expect(holdings[0].quantity).toBe(quantity); } ); it('should remove the holding type only from the snapshot filters', async () => { setUpCashOnlyPortfolio({ quantity: 0 }); await portfolioService.getDetails({ filters: [ { id: AssetClass.EQUITY, type: 'ASSET_CLASS' }, { id: 'CLOSED', type: 'HOLDING_TYPE' } ], userId: userDummyData.id }); expect(portfolioCalculatorFactory.createCalculator).toHaveBeenCalledWith( expect.objectContaining({ filters: [{ id: AssetClass.EQUITY, type: 'ASSET_CLASS' }] }) ); expect( activitiesService.getActivitiesForPortfolioCalculator ).toHaveBeenCalledWith({ filters: [{ id: AssetClass.EQUITY, type: 'ASSET_CLASS' }], userCurrency: 'CHF', userId: userDummyData.id }); }); }); describe('getHoldings', () => { const activeHolding = { assetProfile: { isin: 'US0378331005', name: 'Apple', symbol: 'AAPL' }, quantity: 1 }; const closedHolding = { assetProfile: { isin: 'US5949181045', name: 'Microsoft', symbol: 'MSFT' }, quantity: 0 }; beforeEach(() => { jest.spyOn(portfolioService, 'getDetails').mockResolvedValue({ holdings: [activeHolding, closedHolding] } as unknown as Awaited>); }); it('should request all holdings when the holding type is not specified', async () => { const holdings = await portfolioService.getHoldings({ dateRange: 'max', userId: userDummyData.id }); expect(holdings).toEqual([activeHolding, closedHolding]); expect(portfolioService.getDetails).toHaveBeenCalledWith({ dateRange: 'max', filters: undefined, includeAllHoldings: true, userId: userDummyData.id }); }); it('should find a closed holding when the holding type is not specified', async () => { const holdings = await portfolioService.getHoldings({ dateRange: 'max', filters: [{ id: 'Microsoft', type: 'SEARCH_QUERY' }], userId: userDummyData.id }); expect(holdings).toEqual([closedHolding]); }); it.each(['ACTIVE', 'CLOSED'])( 'should not request all holdings when the holding type is %s', async (holdingType) => { await portfolioService.getHoldings({ dateRange: 'max', filters: [{ id: holdingType, type: 'HOLDING_TYPE' }], userId: userDummyData.id }); expect(portfolioService.getDetails).toHaveBeenCalledWith({ dateRange: 'max', filters: [{ id: holdingType, type: 'HOLDING_TYPE' }], includeAllHoldings: false, userId: userDummyData.id }); } ); }); describe('getHolding', () => { const dataSource = DataSource.YAHOO; const symbol = 'AAPL'; const includedActivity = { assetProfile: { dataSource, symbol }, tags: [] } as Activity; beforeEach(() => { jest.spyOn(userService, 'user').mockResolvedValue({ id: userDummyData.id, settings: { settings: { baseCurrency: 'USD' } } } as unknown as Awaited>); jest .spyOn(symbolProfileService, 'getSymbolProfiles') .mockResolvedValue([]); jest .spyOn(portfolioCalculatorFactory, 'createCalculator') .mockReturnValue({ getHoldingBalancesByDate: jest.fn().mockReturnValue([]), getSnapshot: jest.fn().mockResolvedValue({ positions: [] }) } as unknown as PortfolioCalculator); }); it('keeps the cached path when the holding has included and excluded activities', async () => { const excludedActivity = { ...includedActivity, tags: [{ id: TAG_ID_EXCLUDE_FROM_ANALYSIS }] } as Activity; const getActivities = jest .spyOn(activitiesService, 'getActivitiesForPortfolioCalculator') .mockResolvedValueOnce({ activities: [includedActivity], count: 1 }) .mockResolvedValue({ activities: [includedActivity, excludedActivity], count: 2 }); await portfolioService.getHolding({ dataSource, symbol, userId: userDummyData.id, withExcludedActivities: true }); expect(getActivities).toHaveBeenCalledTimes(1); expect(getActivities).toHaveBeenCalledWith({ userCurrency: 'USD', userId: userDummyData.id }); expect(portfolioCalculatorFactory.createCalculator).toHaveBeenCalledWith( expect.objectContaining({ activities: [includedActivity], filters: undefined, usePortfolioSnapshotCache: true }) ); }); it.each([ { account: { tags: [{ id: TAG_ID_EXCLUDE_FROM_ANALYSIS }] }, name: 'account', tags: [] }, { account: { tags: [] }, name: 'activity tag', tags: [{ id: TAG_ID_EXCLUDE_FROM_ANALYSIS }] } ])( 'uses the direct path for a holding excluded by its $name', async ({ account, tags }) => { const excludedActivity = { ...includedActivity, account, tags } as Activity; const getActivities = jest .spyOn(activitiesService, 'getActivitiesForPortfolioCalculator') .mockResolvedValueOnce({ activities: [], count: 0 }) .mockResolvedValueOnce({ activities: [excludedActivity], count: 1 }); await portfolioService.getHolding({ dataSource, symbol, userId: userDummyData.id, withExcludedActivities: true }); expect(getActivities).toHaveBeenCalledTimes(2); expect(getActivities).toHaveBeenLastCalledWith({ filters: [ { id: dataSource, type: 'DATA_SOURCE' }, { id: symbol, type: 'SYMBOL' } ], userCurrency: 'USD', userId: userDummyData.id, withExcludedAccountsAndActivities: true }); expect( portfolioCalculatorFactory.createCalculator ).toHaveBeenCalledWith( expect.objectContaining({ activities: [excludedActivity], filters: [ { id: dataSource, type: 'DATA_SOURCE' }, { id: symbol, type: 'SYMBOL' } ], usePortfolioSnapshotCache: false }) ); } ); it('does not load excluded activities by default', async () => { const getActivities = jest .spyOn(activitiesService, 'getActivitiesForPortfolioCalculator') .mockResolvedValue({ activities: [], count: 0 }); const holding = await portfolioService.getHolding({ dataSource, symbol, userId: userDummyData.id }); expect(holding).toBeUndefined(); expect(getActivities).toHaveBeenCalledTimes(1); expect( portfolioCalculatorFactory.createCalculator ).not.toHaveBeenCalled(); }); }); describe('getPerformance', () => { let getPerformanceOfCalculator: jest.Mock; beforeEach(() => { jest.useFakeTimers().setSystemTime(parseDate('2025-06-15').getTime()); getPerformanceOfCalculator = jest.fn(); jest .spyOn(accountBalanceService, 'getAccountBalanceItems') .mockResolvedValue([]); jest .spyOn(activitiesService, 'getActivitiesForPortfolioCalculator') .mockResolvedValue({ activities: [{} as Activity], count: 1 }); jest .spyOn(portfolioCalculatorFactory, 'createCalculator') .mockReturnValue({ getPerformance: getPerformanceOfCalculator, getSnapshot: jest.fn().mockResolvedValue({ errors: [], hasErrors: false, historicalData: [{ date: '2023-03-01' }] }) } as unknown as PortfolioCalculator); jest.spyOn(userService, 'user').mockResolvedValue({ id: userDummyData.id, settings: { settings: { baseCurrency: 'CHF' } } } as unknown as Awaited>); }); afterEach(() => { jest.useRealTimers(); }); it('should return the chart of the date range without a group', async () => { getPerformanceOfCalculator.mockResolvedValueOnce({ chart: [ { date: '2023-03-01', netPerformance: 0 }, { date: '2025-06-15', netPerformance: 600 } ] }); const { chart, dateOfFirstActivity, performance } = await portfolioService.getPerformance({ dateRange: 'max', userId: userDummyData.id }); expect(getPerformanceOfCalculator).toHaveBeenCalledTimes(1); expect(getPerformanceOfCalculator).toHaveBeenCalledWith({ end: endOfDay(parseDate('2025-06-15')), start: new Date(0) }); expect(chart).toEqual([ { date: '2023-03-01', netPerformance: 0 }, { date: '2025-06-15', netPerformance: 600 } ]); expect(dateOfFirstActivity).toEqual(parseDate('2023-03-01')); expect(performance.netPerformance).toBe(600); }); it('should return one chart item per calendar year since the first activity when grouped by year', async () => { getPerformanceOfCalculator .mockResolvedValueOnce({ chart: [ { date: '2023-03-01', netPerformance: 0 }, { date: '2023-12-31', netPerformance: 100 }, { date: '2024-12-31', netPerformance: 300 }, { date: '2025-06-15', netPerformance: 600 } ] }) .mockResolvedValueOnce({ chart: [ { date: '2023-03-01', netPerformance: 0 }, { date: '2023-12-31', netPerformance: 100 } ] }) .mockResolvedValueOnce({ chart: [ { date: '2023-12-31', netPerformance: 0 }, { date: '2024-12-31', netPerformance: 200 } ] }) .mockResolvedValueOnce({ chart: [ { date: '2024-12-31', netPerformance: 0 }, { date: '2025-06-15', netPerformance: 300 } ] }); const { chart, performance } = await portfolioService.getPerformance({ dateRange: 'max', groupBy: 'year', userId: userDummyData.id }); expect(getPerformanceOfCalculator).toHaveBeenCalledTimes(4); expect(getPerformanceOfCalculator).toHaveBeenNthCalledWith(2, { end: endOfDay(parseDate('2023-12-31')), start: new Date(0) }); expect(getPerformanceOfCalculator).toHaveBeenNthCalledWith(3, { end: endOfDay(parseDate('2024-12-31')), start: endOfDay(parseDate('2023-12-31')) }); expect(getPerformanceOfCalculator).toHaveBeenNthCalledWith(4, { end: endOfDay(parseDate('2025-06-15')), start: endOfDay(parseDate('2024-12-31')) }); expect(chart).toEqual([ { date: '2023-01-01', investmentValueWithCurrencyEffect: 0, netPerformance: 100 }, { date: '2024-01-01', investmentValueWithCurrencyEffect: 0, netPerformance: 200 }, { date: '2025-01-01', investmentValueWithCurrencyEffect: 0, netPerformance: 300 } ]); expect(performance.netPerformance).toBe(600); }); it('should return the same chart item for a calendar year as for its date range when grouped by year', async () => { getPerformanceOfCalculator .mockResolvedValueOnce({ chart: [ { date: '2023-12-31', netPerformance: 0 }, { date: '2024-12-31', netPerformance: 200 } ] }) .mockResolvedValueOnce({ chart: [ { date: '2023-12-31', netPerformance: 0 }, { date: '2024-12-31', netPerformance: 200 } ] }); const { chart, performance } = await portfolioService.getPerformance({ dateRange: '2024', groupBy: 'year', userId: userDummyData.id }); expect(getPerformanceOfCalculator).toHaveBeenCalledTimes(2); expect(getPerformanceOfCalculator).toHaveBeenNthCalledWith( 2, getPerformanceOfCalculator.mock.calls[0][0] ); expect(chart).toEqual([ { date: '2024-01-01', investmentValueWithCurrencyEffect: 0, netPerformance: 200 } ]); expect(performance.netPerformance).toBe(200); }); it('should clip the first calendar year to the date range when grouped by year', async () => { getPerformanceOfCalculator .mockResolvedValueOnce({ chart: [ { date: '2024-06-15', netPerformance: 0 }, { date: '2024-12-31', netPerformance: 100 }, { date: '2025-06-15', netPerformance: 300 } ] }) .mockResolvedValueOnce({ chart: [ { date: '2024-06-15', netPerformance: 0 }, { date: '2024-12-31', netPerformance: 100 } ] }) .mockResolvedValueOnce({ chart: [ { date: '2024-12-31', netPerformance: 0 }, { date: '2025-06-15', netPerformance: 200 } ] }); const { chart, performance } = await portfolioService.getPerformance({ dateRange: '1y', groupBy: 'year', userId: userDummyData.id }); expect(getPerformanceOfCalculator).toHaveBeenCalledTimes(3); expect(getPerformanceOfCalculator).toHaveBeenNthCalledWith(2, { end: endOfDay(parseDate('2024-12-31')), start: resetHours(parseDate('2024-06-15')) }); expect(getPerformanceOfCalculator).toHaveBeenNthCalledWith(3, { end: endOfDay(parseDate('2025-06-15')), start: endOfDay(parseDate('2024-12-31')) }); expect(chart).toEqual([ { date: '2024-01-01', investmentValueWithCurrencyEffect: 0, netPerformance: 100 }, { date: '2025-01-01', investmentValueWithCurrencyEffect: 0, netPerformance: 200 } ]); expect(performance.netPerformance).toBe(300); }); it('should start the first calendar year at the start date of the date range on 1 January when grouped by year', async () => { jest.setSystemTime(parseDate('2026-01-01').getTime()); getPerformanceOfCalculator .mockResolvedValueOnce({ chart: [ { date: '2025-01-01', netPerformance: 0 }, { date: '2025-12-31', netPerformance: 100 }, { date: '2026-01-01', netPerformance: 150 } ] }) .mockResolvedValueOnce({ chart: [ { date: '2025-01-01', netPerformance: 0 }, { date: '2025-12-31', netPerformance: 100 } ] }) .mockResolvedValueOnce({ chart: [ { date: '2025-12-31', netPerformance: 0 }, { date: '2026-01-01', netPerformance: 50 } ] }); const { chart } = await portfolioService.getPerformance({ dateRange: '1y', groupBy: 'year', userId: userDummyData.id }); expect(getPerformanceOfCalculator).toHaveBeenCalledTimes(3); expect(getPerformanceOfCalculator).toHaveBeenNthCalledWith(1, { end: endOfDay(parseDate('2026-01-01')), start: resetHours(parseDate('2025-01-01')) }); expect(getPerformanceOfCalculator).toHaveBeenNthCalledWith(2, { end: endOfDay(parseDate('2025-12-31')), start: resetHours(parseDate('2025-01-01')) }); expect(getPerformanceOfCalculator).toHaveBeenNthCalledWith(3, { end: endOfDay(parseDate('2026-01-01')), start: endOfDay(parseDate('2025-12-31')) }); expect(chart).toEqual([ { date: '2025-01-01', investmentValueWithCurrencyEffect: 0, netPerformance: 100 }, { date: '2026-01-01', investmentValueWithCurrencyEffect: 0, netPerformance: 50 } ]); }); it('should add up the investment of each calendar year when grouped by year', async () => { getPerformanceOfCalculator .mockResolvedValueOnce({ chart: [ { date: '2023-12-31', investmentValueWithCurrencyEffect: 0 }, { date: '2024-03-01', investmentValueWithCurrencyEffect: 1000 }, { date: '2024-12-31', investmentValueWithCurrencyEffect: 300 }, { date: '2025-06-15', investmentValueWithCurrencyEffect: 200 } ] }) .mockResolvedValueOnce({ chart: [ { date: '2023-12-31', investmentValueWithCurrencyEffect: 0 }, { date: '2024-03-01', investmentValueWithCurrencyEffect: 1000 }, { date: '2024-12-31', investmentValueWithCurrencyEffect: 300 } ] }) .mockResolvedValueOnce({ chart: [ { date: '2024-12-31', investmentValueWithCurrencyEffect: 300 }, { date: '2025-06-15', investmentValueWithCurrencyEffect: 200 } ] }); const { chart } = await portfolioService.getPerformance({ dateRange: 'max', groupBy: 'year', userId: userDummyData.id }); expect(chart).toEqual([ { date: '2024-01-01', investmentValueWithCurrencyEffect: 1300 }, { date: '2025-01-01', investmentValueWithCurrencyEffect: 200 } ]); }); }); describe('getSummary', () => { const getSummary = (args: object) => { return ( portfolioService as unknown as { getSummary: (aArgs: object) => Promise; } ).getSummary(args); }; function createExcludedActivity({ currency = 'CHF', quantity, symbol, type, unitPrice }: Pick & { currency?: string; symbol: string; }) { return { currency, quantity, type, unitPrice, account: { tags: [{ id: TAG_ID_EXCLUDE_FROM_ANALYSIS }] }, assetProfile: { currency, symbol, dataSource: DataSource.YAHOO }, tags: [] } as unknown as Activity; } function createPortfolioCalculator() { return { getFeesInBaseCurrency: jest.fn().mockResolvedValue(new Big(0)), getInterestInBaseCurrency: jest.fn().mockResolvedValue(new Big(0)), getLiabilitiesInBaseCurrency: jest.fn().mockResolvedValue(new Big(0)), getSnapshot: jest.fn().mockResolvedValue({ currentValueInBaseCurrency: new Big(3000), totalCashInBaseCurrency: new Big(1000), totalInvestment: new Big(2000), totalInvestmentWithCurrencyEffect: new Big(2000) }), getStartDate: jest.fn().mockReturnValue(parseDate('2024-01-01')) } as unknown as PortfolioCalculator; } beforeEach(() => { jest .spyOn(activitiesService, 'getActivitiesForPortfolioCalculator') .mockResolvedValue({ activities: [], count: 0 }); jest .spyOn(exchangeRateDataService, 'toCurrency') .mockImplementation((aValue) => aValue); jest.spyOn(portfolioService, 'getPerformance').mockResolvedValue({ performance: { currentValueInBaseCurrency: 3000, dividendInBaseCurrency: 50, netPerformance: 500, netPerformancePercentage: 0.2, netPerformancePercentageWithCurrencyEffect: 0.2, netPerformanceWithCurrencyEffect: 500 } } as Awaited>); jest.spyOn(userService, 'user').mockResolvedValue({ id: userDummyData.id, settings: { settings: { baseCurrency: 'CHF' } } } as unknown as Awaited>); }); it('should derive the cash and net worth from the account balance when there are no excluded accounts, no emergency fund and no liabilities', async () => { jest.spyOn(accountService, 'getCashDetails').mockResolvedValue({ accounts: [], balanceInBaseCurrency: 1000 }); const portfolioCalculator = createPortfolioCalculator(); const summary = await getSummary({ portfolioCalculator, balanceInBaseCurrency: 1000, emergencyFundHoldingsValueInBaseCurrency: 0, filteredValueInBaseCurrency: new Big(3000), userCurrency: 'CHF', userId: userDummyData.id }); expect(summary.cash).toBe(1000); expect(summary.dividendInBaseCurrency).toBe(50); expect(summary.emergencyFund.total).toBe(0); expect(summary.excludedAccountsAndActivities).toBe(0); expect(summary.totalAssetsInBaseCurrency).toBe(3000); expect(summary.totalValueInBaseCurrency).toBe(3000); }); it('should value the open holdings of excluded accounts at the current market price', async () => { jest.spyOn(accountService, 'getCashDetails').mockResolvedValue({ accounts: [], balanceInBaseCurrency: 1500 }); jest .spyOn(activitiesService, 'getActivitiesForPortfolioCalculator') .mockResolvedValue({ activities: [ createExcludedActivity({ quantity: 10, symbol: 'AAPL', type: 'BUY', unitPrice: 100 }), createExcludedActivity({ quantity: 4, symbol: 'AAPL', type: 'SELL', unitPrice: 150 }), createExcludedActivity({ quantity: 5, symbol: 'MSFT', type: 'BUY', unitPrice: 100 }), createExcludedActivity({ quantity: 5, symbol: 'MSFT', type: 'SELL', unitPrice: 120 }) ], count: 4 }); const getValues = jest .spyOn(currentRateService, 'getValues') .mockResolvedValue({ dataProviderInfos: [], errors: [], values: [ { dataSource: DataSource.YAHOO, date: new Date(), marketPrice: 200, symbol: 'AAPL' } ] }); const summary = await getSummary({ balanceInBaseCurrency: 1000, emergencyFundHoldingsValueInBaseCurrency: 0, filteredValueInBaseCurrency: new Big(3000), portfolioCalculator: createPortfolioCalculator(), userCurrency: 'CHF', userId: userDummyData.id }); // The closed holding (MSFT) does not need a market price expect(getValues).toHaveBeenCalledWith( expect.objectContaining({ assetProfileIdentifiersWithQuotes: [ { dataSource: DataSource.YAHOO, symbol: 'AAPL' } ], dataGatheringItems: [{ dataSource: DataSource.YAHOO, symbol: 'AAPL' }] }) ); // 500 (balance of excluded accounts) + 6 * 200 (AAPL) + 0 (MSFT) expect(summary.excludedAccountsAndActivities).toBe(1700); expect(summary.totalValueInBaseCurrency).toBe(4700); }); it('should convert the market value of an excluded holding from the currency of the asset profile to the user currency', async () => { jest.spyOn(accountService, 'getCashDetails').mockResolvedValue({ accounts: [], balanceInBaseCurrency: 1000 }); jest .spyOn(activitiesService, 'getActivitiesForPortfolioCalculator') .mockResolvedValue({ activities: [ createExcludedActivity({ currency: 'USD', quantity: 10, symbol: 'AAPL', type: 'BUY', unitPrice: 100 }), createExcludedActivity({ currency: 'USD', quantity: 4, symbol: 'AAPL', type: 'SELL', unitPrice: 150 }) ], count: 2 }); jest.spyOn(currentRateService, 'getValues').mockResolvedValue({ dataProviderInfos: [], errors: [], values: [ { dataSource: DataSource.YAHOO, date: new Date(), marketPrice: 200, symbol: 'AAPL' } ] }); jest .spyOn(exchangeRateDataService, 'toCurrency') .mockImplementation((aValue, aFromCurrency, aToCurrency) => { return aFromCurrency === 'USD' && aToCurrency === 'CHF' ? aValue * 0.8 : aValue; }); const summary = await getSummary({ balanceInBaseCurrency: 1000, emergencyFundHoldingsValueInBaseCurrency: 0, filteredValueInBaseCurrency: new Big(3000), portfolioCalculator: createPortfolioCalculator(), userCurrency: 'CHF', userId: userDummyData.id }); // 6 * 200 USD (AAPL) * 0.8 (USDCHF) expect(summary.excludedAccountsAndActivities).toBe(960); expect(summary.totalValueInBaseCurrency).toBe(3960); }); it('should fall back to the unit price of the latest activity of an excluded holding without a market price', async () => { jest.spyOn(accountService, 'getCashDetails').mockResolvedValue({ accounts: [], balanceInBaseCurrency: 1000 }); jest .spyOn(activitiesService, 'getActivitiesForPortfolioCalculator') .mockResolvedValue({ activities: [ createExcludedActivity({ quantity: 10, symbol: 'AAPL', type: 'BUY', unitPrice: 100 }), createExcludedActivity({ quantity: 4, symbol: 'AAPL', type: 'SELL', unitPrice: 150 }) ], count: 2 }); jest.spyOn(currentRateService, 'getValues').mockResolvedValue({ dataProviderInfos: [], errors: [], values: [] }); const summary = await getSummary({ balanceInBaseCurrency: 1000, emergencyFundHoldingsValueInBaseCurrency: 0, filteredValueInBaseCurrency: new Big(3000), portfolioCalculator: createPortfolioCalculator(), userCurrency: 'CHF', userId: userDummyData.id }); // 6 * 150 (unit price of the latest activity) expect(summary.excludedAccountsAndActivities).toBe(900); expect(summary.totalValueInBaseCurrency).toBe(3900); }); }); describe('getValueOfAccountsAndPlatforms', () => { const getValueOfAccountsAndPlatforms = (args: object) => { return ( portfolioService as unknown as { getValueOfAccountsAndPlatforms: (aArgs: object) => Promise<{ accounts: Record< string, { quantity?: number; valueInBaseCurrency: number } >; platforms: Record; }>; } ).getValueOfAccountsAndPlatforms(args); }; const account = { balance: 100, currency: 'USD', id: randomUUID(), name: 'Account 1', platform: { name: 'Platform 1' }, platformId: randomUUID() }; beforeEach(() => { jest .spyOn(accountService, 'accounts') .mockResolvedValue([account] as unknown as AccountWithBalance[]); jest .spyOn(accountService, 'getAccounts') .mockResolvedValue([account] as unknown as AccountWithBalance[]); jest .spyOn(exchangeRateDataService, 'toCurrency') .mockImplementation((aValue) => aValue); }); it('should group activities without an account into the unknown bucket of accounts and platforms', async () => { const { accounts, platforms } = await getValueOfAccountsAndPlatforms({ activities: [ { account, accountId: account.id, assetProfile: { dataSource: DataSource.YAHOO, symbol: 'AAPL' }, quantity: 1, type: 'BUY' }, { account: null, accountId: null, assetProfile: { dataSource: DataSource.YAHOO, symbol: 'BABA' }, quantity: 2, type: 'BUY' } ], filters: [], portfolioItemsNow: { 'YAHOO-AAPL': { marketPriceInBaseCurrency: 10 }, 'YAHOO-BABA': { marketPriceInBaseCurrency: 20 } }, userCurrency: 'USD', userId: userDummyData.id }); // 100 (balance) + 1 * 10 (activity) expect(accounts[account.id].valueInBaseCurrency).toBe(110); expect(platforms[account.platformId].valueInBaseCurrency).toBe(110); // 2 * 20 (activity without an account) expect(accounts[UNKNOWN_KEY].valueInBaseCurrency).toBe(40); expect(platforms[UNKNOWN_KEY].valueInBaseCurrency).toBe(40); }); it('should not create an unknown bucket when every activity has an account', async () => { const { accounts, platforms } = await getValueOfAccountsAndPlatforms({ activities: [ { account, accountId: account.id, assetProfile: { dataSource: DataSource.YAHOO, symbol: 'AAPL' }, quantity: 1, type: 'BUY' } ], filters: [], portfolioItemsNow: { 'YAHOO-AAPL': { marketPriceInBaseCurrency: 10 } }, userCurrency: 'USD', userId: userDummyData.id }); expect(accounts[UNKNOWN_KEY]).toBeUndefined(); expect(platforms[UNKNOWN_KEY]).toBeUndefined(); }); it('should not accumulate rounding errors of activities cancelling each other out', async () => { const { accounts, platforms } = await getValueOfAccountsAndPlatforms({ activities: [ { account, accountId: account.id, assetProfile: { dataSource: DataSource.YAHOO, symbol: 'AAPL' }, quantity: 0.1, type: 'BUY' }, { account, accountId: account.id, assetProfile: { dataSource: DataSource.YAHOO, symbol: 'AAPL' }, quantity: 0.2, type: 'BUY' }, { account, accountId: account.id, assetProfile: { dataSource: DataSource.YAHOO, symbol: 'AAPL' }, quantity: 0.3, type: 'SELL' } ], filters: [], portfolioItemsNow: { 'YAHOO-AAPL': { marketPriceInBaseCurrency: 1234.5678 } }, userCurrency: 'USD', userId: userDummyData.id }); // 100 (balance) + 0 (activities) expect(accounts[account.id].valueInBaseCurrency).toBe(100); expect(platforms[account.platformId].valueInBaseCurrency).toBe(100); }); it('should aggregate the quantity per account if the activities are filtered by a single holding', async () => { const { accounts } = await getValueOfAccountsAndPlatforms({ activities: [ { account, accountId: account.id, assetProfile: { dataSource: DataSource.YAHOO, symbol: 'AAPL' }, quantity: 0.1, type: 'BUY' }, { account, accountId: account.id, assetProfile: { dataSource: DataSource.YAHOO, symbol: 'AAPL' }, quantity: 0.2, type: 'BUY' } ], filters: [{ id: 'AAPL', type: 'SYMBOL' }], portfolioItemsNow: { 'YAHOO-AAPL': { marketPriceInBaseCurrency: 10 } }, userCurrency: 'USD', userId: userDummyData.id }); expect(accounts[account.id].quantity).toBe(0.3); }); it('should not expose a quantity if the activities are not filtered by a single holding', async () => { const { accounts } = await getValueOfAccountsAndPlatforms({ activities: [ { account, accountId: account.id, assetProfile: { dataSource: DataSource.YAHOO, symbol: 'AAPL' }, quantity: 1, type: 'BUY' } ], filters: [], portfolioItemsNow: { 'YAHOO-AAPL': { marketPriceInBaseCurrency: 10 } }, userCurrency: 'USD', userId: userDummyData.id }); expect(accounts[account.id].quantity).toBeUndefined(); }); it('should only consider accounts of the current user if the activities are filtered by a single account', async () => { const accountsSpy = jest.spyOn(accountService, 'accounts'); await getValueOfAccountsAndPlatforms({ activities: [], filters: [{ id: account.id, type: 'ACCOUNT' }], portfolioItemsNow: {}, userCurrency: 'USD', userId: userDummyData.id }); expect(accountsSpy).toHaveBeenCalledWith( expect.objectContaining({ where: { userId: userDummyData.id, id: account.id } }) ); }); it('should exclude the cash balance if the activities are filtered by a single holding', async () => { const { accounts, platforms } = await getValueOfAccountsAndPlatforms({ activities: [ { account, accountId: account.id, assetProfile: { dataSource: DataSource.YAHOO, symbol: 'AAPL' }, quantity: 1, type: 'BUY' } ], filters: [{ id: 'AAPL', type: 'SYMBOL' }], portfolioItemsNow: { 'YAHOO-AAPL': { marketPriceInBaseCurrency: 10 } }, userCurrency: 'USD', userId: userDummyData.id }); // 1 * 10 (activity), without the balance of 100 expect(accounts[account.id].valueInBaseCurrency).toBe(10); expect(platforms[account.platformId].valueInBaseCurrency).toBe(10); }); it('should not accumulate rounding errors of the balances of accounts sharing a platform', async () => { const platformId = randomUUID(); jest.spyOn(accountService, 'getAccounts').mockResolvedValue([ { ...account, platformId, balance: 0.1, id: randomUUID() }, { ...account, platformId, balance: 0.2, id: randomUUID() } ] as unknown as AccountWithBalance[]); const { platforms } = await getValueOfAccountsAndPlatforms({ activities: [], filters: [], portfolioItemsNow: {}, userCurrency: 'USD', userId: userDummyData.id }); // 0.1 (balance) + 0.2 (balance) expect(platforms[platformId].valueInBaseCurrency).toBe(0.3); }); }); });