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.
 
 
 
 
 

337 lines
9.8 KiB

import { AccountService } from '@ghostfolio/api/app/account/account.service';
import {
activityDummyData,
assetProfileDummyData
} from '@ghostfolio/api/app/portfolio/calculator/portfolio-calculator-test-utils';
import { AssetProfileSplitService } from '@ghostfolio/api/services/asset-profile-split/asset-profile-split.service';
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 { AssetProfileSplit, DataSource } from '@prisma/client';
import { Big } from 'big.js';
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('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('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];
}
});
});
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
};
}