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.
 
 
 
 
 

710 lines
20 KiB

import { AccountService } from '@ghostfolio/api/app/account/account.service';
import {
activityDummyData,
assetProfileDummyData
} from '@ghostfolio/api/app/portfolio/calculator/portfolio-calculator-test-utils';
import { WHERE_ACTIVITY_NOT_DRAFT } from '@ghostfolio/api/helper/activity.helper';
import { AssetProfileSplitService } from '@ghostfolio/api/services/asset-profile-split/asset-profile-split.service';
import { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-data/exchange-rate-data.service';
import { PrismaService } from '@ghostfolio/api/services/prisma/prisma.service';
import { SymbolProfileService } from '@ghostfolio/api/services/symbol-profile/symbol-profile.service';
import { TagService } from '@ghostfolio/api/services/tag/tag.service';
import { getIntervalFromDateRange } from '@ghostfolio/common/calculation-helper';
import {
INVESTMENT_ACTIVITY_TYPES,
NON_INVESTMENT_ACTIVITY_TYPES
} from '@ghostfolio/common/config';
import { parseDate } from '@ghostfolio/common/helper';
import { Activity, Filter } from '@ghostfolio/common/interfaces';
import { EventEmitter2 } from '@nestjs/event-emitter';
import {
AssetClass,
AssetProfileSplit,
AssetSubClass,
DataSource,
Prisma,
Type as ActivityType
} from '@prisma/client';
import { Big } from 'big.js';
import { isUUID } from 'class-validator';
import { endOfDay } from 'date-fns';
import { ActivitiesService } from './activities.service';
describe('ActivitiesService', () => {
let activitiesService: ActivitiesService;
let getSplitsByUserId: jest.Mock;
let accountService: { getCashDetails: jest.Mock };
beforeEach(() => {
getSplitsByUserId = jest.fn().mockResolvedValue([]);
accountService = { getCashDetails: jest.fn() };
activitiesService = new ActivitiesService(
null,
accountService as unknown as AccountService,
{ getSplitsByUserId } as unknown as AssetProfileSplitService,
null,
null,
null,
null,
null,
null,
null,
null,
null
);
});
describe('createActivity', () => {
it('creates a custom asset profile with a UUID if no asset profile exists for a symbol with the prefix', async () => {
const assetProfile = await getCreatedAssetProfile({ symbol: 'GF_COPX' });
expect(isUUID(assetProfile.symbol)).toBe(true);
expect(assetProfile.name).toBe('GF_COPX');
});
it('connects to the existing asset profile of a symbol with the prefix', async () => {
const assetProfile = await getCreatedAssetProfile({
existingAssetProfile: { id: 'asset-profile-id' },
symbol: 'GF_COPX'
});
expect(assetProfile.symbol).toBe('GF_COPX');
});
it('creates a custom asset profile with the requested UUID if no asset profile exists', async () => {
const symbol = '1ad7d4a2-6b2d-4e0f-9b1f-2c0f8d3e5a7b';
const assetProfile = await getCreatedAssetProfile({ symbol });
expect(assetProfile.symbol).toBe(symbol);
});
async function getCreatedAssetProfile({
existingAssetProfile,
symbol
}: {
existingAssetProfile?: { id: string };
symbol: string;
}) {
const create = jest.fn(({ data }: Prisma.OrderCreateArgs) => {
return {
...data,
SymbolProfile: data.SymbolProfile.connectOrCreate.create
};
});
const service = new ActivitiesService(
null,
null,
null,
null,
null,
null,
{ emit: jest.fn() } as unknown as EventEmitter2,
null,
null,
{
order: { create },
symbolProfile: {
findUnique: jest
.fn()
.mockResolvedValue(existingAssetProfile ?? null)
}
} as unknown as PrismaService,
null,
{ validateTagIds: jest.fn() } as unknown as TagService
);
await service.createActivity({
currency: 'USD',
date: parseDate('2024-01-01'),
fee: 0,
quantity: 1,
SymbolProfile: {
connectOrCreate: {
create: { symbol, currency: 'USD', dataSource: DataSource.MANUAL },
where: {
dataSource_symbol: { symbol, dataSource: DataSource.MANUAL }
}
}
},
type: 'BUY',
unitPrice: 100,
user: { connect: { id: 'user-id' } },
userId: 'user-id'
});
const { create: assetProfile, where } =
create.mock.calls[0][0].data.SymbolProfile.connectOrCreate;
expect(where.dataSource_symbol).toEqual({
dataSource: DataSource.MANUAL,
symbol: assetProfile.symbol
});
return assetProfile;
}
});
describe('getActivities', () => {
it('returns the activities with the asset profile but without the relation to the symbol profile', async () => {
const assetProfile = {
...assetProfileDummyData,
currency: 'USD',
dataSource: DataSource.YAHOO,
name: 'Apple Inc.',
symbol: 'AAPL'
};
const findMany = jest.fn().mockResolvedValue([
{
...activityDummyData,
account: null,
currency: 'USD',
date: parseDate('2021-01-01'),
fee: 1,
id: 'activity-id',
quantity: 10,
SymbolProfile: {
currency: 'USD',
dataSource: DataSource.YAHOO,
symbol: 'AAPL'
},
tags: [],
type: 'BUY',
unitPrice: 100
}
]);
const service = new ActivitiesService(
null,
null,
null,
null,
null,
null,
null,
{
toCurrencyAtDate: jest.fn().mockResolvedValue(0)
} as unknown as ExchangeRateDataService,
null,
{
order: {
findMany,
count: jest.fn().mockResolvedValue(1)
}
} as unknown as PrismaService,
{
getSymbolProfiles: jest
.fn()
.mockResolvedValue([
{ ...assetProfile, dataSource: DataSource.MANUAL },
assetProfile
])
} as unknown as SymbolProfileService,
null
);
const { activities } = await service.getActivities({
userCurrency: 'USD',
userId: 'user-id'
});
expect(findMany).toHaveBeenCalledWith(
expect.objectContaining({
include: expect.objectContaining({
SymbolProfile: {
select: { currency: true, dataSource: true, symbol: true }
}
})
})
);
expect(activities).toHaveLength(1);
expect(activities[0]).not.toHaveProperty('SymbolProfile');
expect(activities[0].assetProfile).toBe(assetProfile);
});
});
describe('getActivitiesForPortfolioCalculator', () => {
it('leaves an activity unchanged when no splits exist', async () => {
const activity = createActivity({ symbol: 'AAPL' });
const result = await getAdjustedActivity(activity, []);
expect(result).toEqual(activity);
expect(result).toBe(activity);
});
it.each([
{ denominator: 1, expectedPrice: 50, expectedQuantity: 20, numerator: 2 },
{
denominator: 10,
expectedPrice: 1000,
expectedQuantity: 1,
numerator: 1
}
])(
'applies a $numerator:$denominator split to quantity and price',
async ({ denominator, expectedPrice, expectedQuantity, numerator }) => {
const result = await getAdjustedActivity(
createActivity({ symbol: 'AAPL' }),
[createSplit('2021-01-01', numerator, denominator)]
);
expect(result.quantity).toBe(expectedQuantity);
expect(result.unitPrice).toBe(expectedPrice);
expect(result.unitPriceInAssetProfileCurrency).toBe(expectedPrice);
}
);
it('adjusts only activities before the split calendar date', async () => {
const split = createSplit('2021-01-01', 2, 1);
const activityBeforeSplit = createActivity({
date: '2020-12-31T23:00:00.000Z',
symbol: 'AAPL'
});
const activityOnSplitDate = createActivity({
date: '2021-01-01T23:00:00.000Z',
symbol: 'AAPL'
});
const activityAfterSplit = createActivity({
date: '2021-01-02T00:00:00.000Z',
symbol: 'AAPL'
});
expect(
(await getAdjustedActivity(activityBeforeSplit, [split])).quantity
).toBe(20);
expect(
(await getAdjustedActivity(activityOnSplitDate, [split])).quantity
).toBe(10);
expect(
(await getAdjustedActivity(activityAfterSplit, [split])).quantity
).toBe(10);
});
it('applies multiple splits cumulatively with exact ratio arithmetic', async () => {
const result = await getAdjustedActivity(
createActivity({ symbol: 'AAPL' }),
[createSplit('2021-01-01', 2, 1), createSplit('2022-01-01', 1, 3)]
);
expect(new Big(result.quantity).toFixed(15)).toBe(
new Big(20).div(3).toFixed(15)
);
expect(result.unitPrice).toBe(150);
});
it('preserves fees and total activity value', async () => {
const activity = createActivity({ symbol: 'AAPL' });
activity.feeInAssetProfileCurrency = 12;
activity.feeInBaseCurrency = 15;
const result = await getAdjustedActivity(activity, [
createSplit('2021-01-01', 2, 1)
]);
expect(result).toMatchObject({
feeInAssetProfileCurrency: 12,
feeInBaseCurrency: 15,
value: 1000,
valueInBaseCurrency: 1000
});
expect(result.quantity * result.unitPrice).toBe(1000);
});
it('does not apply splits from another symbol or data source', async () => {
const activity = createActivity({ symbol: 'AAPL' });
const split = createSplit('2021-01-01', 2, 1);
jest.spyOn(activitiesService, 'getActivities').mockResolvedValue({
activities: [activity],
count: 1
});
getSplitsByUserId.mockResolvedValue([
{ ...split, symbolProfileId: 'YAHOO-MSFT-profile' },
{ ...split, symbolProfileId: 'MANUAL-AAPL-profile' }
]);
const result =
await activitiesService.getActivitiesForPortfolioCalculator({
userCurrency: 'USD',
userId: 'user-id'
});
expect(result.activities[0].quantity).toBe(10);
expect(result.activities[0].unitPrice).toBe(100);
});
it.each(INVESTMENT_ACTIVITY_TYPES)(
'adjusts %s activities',
async (type) => {
const activity = createActivity({ symbol: 'AAPL' });
activity.type = type as Activity['type'];
const result = await getAdjustedActivity(activity, [
createSplit('2021-01-01', 2, 1)
]);
expect(result.quantity).toBe(20);
expect(result.unitPrice).toBe(50);
expect(result.quantity * result.unitPrice).toBe(1000);
}
);
it.each(NON_INVESTMENT_ACTIVITY_TYPES)(
'leaves %s activities unchanged',
async (type) => {
const activity = createActivity({ symbol: 'AAPL' });
activity.type = type as Activity['type'];
const result = await getAdjustedActivity(activity, [
createSplit('2021-01-01', 2, 1)
]);
expect(result).toBe(activity);
expect(result.quantity).toBe(10);
expect(result.unitPrice).toBe(100);
}
);
it('loads and applies splits to standard activities while preserving filters', async () => {
const activity = createActivity({ symbol: 'AAPL' });
const filters = [{ id: 'AAPL', type: 'SYMBOL' }] as Filter[];
const split = createSplit();
jest.spyOn(activitiesService, 'getActivities').mockResolvedValue({
activities: [activity],
count: 1
});
getSplitsByUserId.mockResolvedValue([split]);
const result =
await activitiesService.getActivitiesForPortfolioCalculator({
filters,
userCurrency: 'USD',
userId: 'user-id'
});
expect(activitiesService.getActivities).toHaveBeenCalledWith({
filters,
userCurrency: 'USD',
userId: 'user-id',
withExcludedAccountsAndActivities: false
});
expect(getSplitsByUserId).toHaveBeenCalledWith({ userId: 'user-id' });
expect(result.activities[0]).toMatchObject({
quantity: 20,
unitPrice: 50,
unitPriceInAssetProfileCurrency: 50
});
});
it('includes excluded accounts and activities when requested', async () => {
jest.spyOn(activitiesService, 'getActivities').mockResolvedValue({
activities: [],
count: 0
});
await activitiesService.getActivitiesForPortfolioCalculator({
userCurrency: 'USD',
userId: 'user-id',
withExcludedAccountsAndActivities: true
});
expect(activitiesService.getActivities).toHaveBeenCalledWith({
filters: undefined,
userCurrency: 'USD',
userId: 'user-id',
withExcludedAccountsAndActivities: true
});
});
it('does not adjust synthetic cash activities', async () => {
const activity = createActivity({ symbol: 'AAPL' });
const cashActivity = createActivity({
assetSubClass: 'CASH',
currency: 'USD',
dataSource: DataSource.YAHOO,
quantity: 100,
symbol: 'USD',
unitPrice: 1
});
const split = createSplit();
jest.spyOn(activitiesService, 'getActivities').mockResolvedValue({
activities: [activity],
count: 1
});
jest.spyOn(activitiesService, 'getCashActivities').mockResolvedValue({
activities: [cashActivity],
count: 1
});
accountService.getCashDetails.mockResolvedValue({ accounts: [] });
getSplitsByUserId.mockResolvedValue([split]);
const result =
await activitiesService.getActivitiesForPortfolioCalculator({
userCurrency: 'USD',
userId: 'user-id',
withCash: true
});
expect(getSplitsByUserId).toHaveBeenCalledWith({ userId: 'user-id' });
expect(result.activities).toEqual([
expect.objectContaining({
assetProfile: expect.objectContaining({ symbol: 'AAPL' }),
quantity: 20
}),
cashActivity
]);
});
async function getAdjustedActivity(
activity: Activity,
splits: AssetProfileSplit[]
) {
jest.spyOn(activitiesService, 'getActivities').mockResolvedValue({
activities: [activity],
count: 1
});
getSplitsByUserId.mockResolvedValue(
splits.map((split) => {
return { ...split, symbolProfileId: activity.assetProfile.id };
})
);
const result =
await activitiesService.getActivitiesForPortfolioCalculator({
userCurrency: 'USD',
userId: 'user-id'
});
return result.activities[0];
}
});
describe('getLatestActivity', () => {
it('filters by type and unit price and excludes draft activities', async () => {
const findFirst = jest.fn().mockResolvedValue(null);
const service = new ActivitiesService(
null,
null,
null,
null,
null,
null,
null,
null,
null,
{ order: { findFirst } } as unknown as PrismaService,
null,
null
);
await service.getLatestActivity({
dataSource: DataSource.YAHOO,
symbol: 'AAPL',
types: [ActivityType.BUY, ActivityType.SELL]
});
expect(findFirst).toHaveBeenCalledWith({
orderBy: [{ date: 'desc' }, { createdAt: 'desc' }],
where: {
...WHERE_ACTIVITY_NOT_DRAFT,
SymbolProfile: { dataSource: DataSource.YAHOO, symbol: 'AAPL' },
type: { in: [ActivityType.BUY, ActivityType.SELL] },
unitPrice: { gt: 0 }
}
});
});
});
describe('getWhereClause', () => {
afterEach(() => {
jest.useRealTimers();
});
it('excludes the activities of the start date of a date range', () => {
jest.useFakeTimers().setSystemTime(new Date(2024, 5, 15));
const { endDate, startDate } = getIntervalFromDateRange({
dateRange: 'ytd'
});
const { AND } = activitiesService['getWhereClause']({
endDate,
startDate,
includeDrafts: false,
userId: 'user-id',
withExcludedAccountsAndActivities: false
});
// The same limits as for the calendar year 2024
expect(AND).toEqual(
expect.arrayContaining([
{ date: { gt: endOfDay(new Date(2023, 11, 31)) } },
{ date: { lte: endOfDay(new Date(2024, 5, 15)) } }
])
);
});
});
describe('updateActivity', () => {
it('updates the custom asset profile of the user', async () => {
const data = await getUpdatedActivityData({ userId: 'user-id' });
expect(data.SymbolProfile).toEqual({
update: {
data: {
assetClass: AssetClass.COMMODITY,
assetSubClass: AssetSubClass.PRECIOUS_METAL
},
where: { userId: 'user-id' }
}
});
});
it.each([
{ owner: 'the admin', userId: null },
{ owner: 'another user', userId: 'other-user-id' }
])('does not update the asset profile of $owner', async ({ userId }) => {
const data = await getUpdatedActivityData({ userId });
expect(data).not.toHaveProperty('SymbolProfile');
});
it('does not update the asset profile of a data provider', async () => {
const data = await getUpdatedActivityData({
dataSource: DataSource.YAHOO,
symbol: 'AAPL',
type: ActivityType.FEE,
userId: null
});
expect(data).not.toHaveProperty('SymbolProfile');
});
async function getUpdatedActivityData({
dataSource = DataSource.MANUAL,
symbol = 'GF_GOLD',
type = ActivityType.BUY,
userId
}: {
dataSource?: DataSource;
symbol?: string;
type?: ActivityType;
userId: string | null;
}) {
const update = jest.fn().mockResolvedValue({ userId: 'user-id' });
const service = new ActivitiesService(
null,
null,
null,
null,
null,
null,
{ emit: jest.fn() } as unknown as EventEmitter2,
null,
null,
{
order: {
update,
findUnique: jest
.fn()
.mockResolvedValue({ SymbolProfile: { userId } })
}
} as unknown as PrismaService,
null,
{ validateTagIds: jest.fn() } as unknown as TagService
);
await service.updateActivity({
data: {
type,
assetClass: AssetClass.COMMODITY,
assetSubClass: AssetSubClass.PRECIOUS_METAL,
date: parseDate('2024-01-01'),
SymbolProfile: {
connect: {
dataSource_symbol: {
dataSource,
symbol
}
},
update: {
assetClass: AssetClass.COMMODITY,
assetSubClass: AssetSubClass.PRECIOUS_METAL,
name: symbol
}
}
},
originalDate: parseDate('2024-01-01'),
userId: 'user-id',
where: { id: 'activity-id' }
});
return (update.mock.calls[0] as [Prisma.OrderUpdateArgs])[0].data;
}
});
});
function createActivity({
assetSubClass,
currency,
dataSource = DataSource.YAHOO,
date = '2020-01-01',
quantity = 10,
symbol,
unitPrice = 100
}: {
assetSubClass?: string;
currency?: string;
dataSource?: DataSource;
date?: string;
quantity?: number;
symbol: string;
unitPrice?: number;
}): Activity {
return {
...activityDummyData,
assetProfile: {
...assetProfileDummyData,
assetSubClass,
currency,
dataSource,
id: `${dataSource}-${symbol}-profile`,
symbol
},
date: parseDate(date),
quantity,
type: 'BUY',
unitPrice,
unitPriceInAssetProfileCurrency: unitPrice,
value: quantity * unitPrice,
valueInBaseCurrency: quantity * unitPrice
} as Activity;
}
function createSplit(
dateString = '2021-01-01',
numerator = 2,
denominator = 1
): AssetProfileSplit {
const date = parseDate(dateString);
return {
createdAt: date,
date,
denominator,
id: `${dateString}-${numerator}-${denominator}`,
numerator,
symbolProfileId: 'YAHOO-AAPL-profile',
updatedAt: date
};
}