mirror of https://github.com/ghostfolio/ghostfolio
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
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
|
|
};
|
|
}
|
|
|