You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
 
 
 
 
 

1698 lines
53 KiB

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<ReturnType<typeof portfolioService.getDetails>>);
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<ReturnType<typeof accountService.accounts>>);
jest.spyOn(portfolioService, 'getDetails').mockResolvedValue({
accounts: {}
} as unknown as Awaited<ReturnType<typeof portfolioService.getDetails>>);
jest.spyOn(userService, 'user').mockResolvedValue({
settings: { settings: { baseCurrency: 'USD' } }
} as unknown as Awaited<ReturnType<typeof userService.user>>);
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<string, { valueInBaseCurrency: number }>;
};
}
).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<ReturnType<typeof userService.user>>);
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<ReturnType<typeof portfolioService.getDetails>>);
});
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<ReturnType<typeof userService.user>>);
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<ReturnType<typeof userService.user>>);
});
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<PortfolioSummary>;
}
).getSummary(args);
};
function createExcludedActivity({
currency = 'CHF',
quantity,
symbol,
type,
unitPrice
}: Pick<Activity, 'quantity' | 'type' | 'unitPrice'> & {
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<ReturnType<typeof portfolioService.getPerformance>>);
jest.spyOn(userService, 'user').mockResolvedValue({
id: userDummyData.id,
settings: {
settings: {
baseCurrency: 'CHF'
}
}
} as unknown as Awaited<ReturnType<typeof userService.user>>);
});
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<string, { valueInBaseCurrency: number }>;
}>;
}
).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);
});
});
});