mirror of https://github.com/ghostfolio/ghostfolio
Browse Source
- Add batch lookup and grouping of stock splits per asset profile - Add pure activity adjustment helper using Big arithmetic - Apply split adjustments in getActivitiesForPortfolioCalculator - Preserve cash activities and existing filters unchanged - Verify calculator handoff for ROAI, MWR, TWR and ROI - Verify persistence side effects and NotFoundException behavior - Add comprehensive test coverage for split service, activity adjustment, portfolio calculator scenarios and factory handoffpull/7568/head
12 changed files with 1234 additions and 3 deletions
@ -0,0 +1,329 @@ |
|||
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 { 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 getSplitsByAssetProfiles: jest.Mock; |
|||
let accountService: { getCashDetails: jest.Mock }; |
|||
|
|||
beforeEach(() => { |
|||
getSplitsByAssetProfiles = jest.fn(); |
|||
accountService = { getCashDetails: jest.fn() }; |
|||
|
|||
activitiesService = new ActivitiesService( |
|||
null, |
|||
accountService as unknown as AccountService, |
|||
null, |
|||
null, |
|||
null, |
|||
null, |
|||
null, |
|||
null, |
|||
null, |
|||
null, |
|||
null, |
|||
{ getSplitsByAssetProfiles } as unknown as AssetProfileSplitService |
|||
); |
|||
}); |
|||
|
|||
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).not.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 |
|||
}); |
|||
getSplitsByAssetProfiles.mockResolvedValue( |
|||
new Map([ |
|||
['YAHOO-MSFT', [split]], |
|||
['MANUAL-AAPL', [split]] |
|||
]) |
|||
); |
|||
|
|||
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(['DIVIDEND', 'INTEREST', 'LIABILITY'])( |
|||
'preserves the value of %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('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 |
|||
}); |
|||
getSplitsByAssetProfiles.mockResolvedValue( |
|||
new Map([['YAHOO-AAPL', [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(getSplitsByAssetProfiles).toHaveBeenCalledWith([ |
|||
{ dataSource: DataSource.YAHOO, symbol: 'AAPL' } |
|||
]); |
|||
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: [] }); |
|||
getSplitsByAssetProfiles.mockResolvedValue( |
|||
new Map([['YAHOO-AAPL', [split]]]) |
|||
); |
|||
|
|||
const result = |
|||
await activitiesService.getActivitiesForPortfolioCalculator({ |
|||
userCurrency: 'USD', |
|||
userId: 'user-id', |
|||
withCash: true |
|||
}); |
|||
|
|||
expect(getSplitsByAssetProfiles).toHaveBeenCalledWith([ |
|||
{ dataSource: DataSource.YAHOO, symbol: 'AAPL' } |
|||
]); |
|||
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 |
|||
}); |
|||
getSplitsByAssetProfiles.mockResolvedValue( |
|||
new Map([ |
|||
[ |
|||
`${activity.assetProfile.dataSource}-${activity.assetProfile.symbol}`, |
|||
splits |
|||
] |
|||
]) |
|||
); |
|||
|
|||
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, |
|||
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: 'aapl-profile', |
|||
updatedAt: date |
|||
}; |
|||
} |
|||
@ -0,0 +1,93 @@ |
|||
import { AssetProfileSplitService } from '@ghostfolio/api/services/asset-profile-split/asset-profile-split.service'; |
|||
import { DataGatheringService } from '@ghostfolio/api/services/queues/data-gathering/data-gathering.service'; |
|||
|
|||
import { NotFoundException } from '@nestjs/common'; |
|||
import { AssetProfileSplit, DataSource } from '@prisma/client'; |
|||
|
|||
import { AssetProfilesService } from './asset-profiles.service'; |
|||
|
|||
describe('AssetProfilesService', () => { |
|||
let assetProfilesService: AssetProfilesService; |
|||
let deleteById: jest.Mock; |
|||
let gatherSymbol: jest.Mock; |
|||
let upsert: jest.Mock; |
|||
|
|||
beforeEach(() => { |
|||
deleteById = jest.fn(); |
|||
gatherSymbol = jest.fn(); |
|||
upsert = jest.fn(); |
|||
|
|||
assetProfilesService = new AssetProfilesService( |
|||
null, |
|||
{ |
|||
deleteById, |
|||
upsert |
|||
} as unknown as AssetProfileSplitService, |
|||
null, |
|||
{ gatherSymbol } as unknown as DataGatheringService, |
|||
null, |
|||
null, |
|||
null, |
|||
null, |
|||
null |
|||
); |
|||
}); |
|||
|
|||
describe('createSplit', () => { |
|||
it('upserts the split and refreshes the asset profile data', async () => { |
|||
const split = {} as AssetProfileSplit; |
|||
const data = { |
|||
dataSource: DataSource.YAHOO, |
|||
date: new Date('2024-06-15T18:30:00.000Z'), |
|||
denominator: 1, |
|||
numerator: 2, |
|||
symbol: 'AAPL', |
|||
symbolProfileId: 'profile-id' |
|||
}; |
|||
upsert.mockResolvedValue(split); |
|||
|
|||
const result = await assetProfilesService.createSplit(data); |
|||
|
|||
expect(upsert).toHaveBeenCalledWith({ |
|||
date: data.date, |
|||
denominator: data.denominator, |
|||
numerator: data.numerator, |
|||
symbolProfileId: data.symbolProfileId |
|||
}); |
|||
expect(gatherSymbol).toHaveBeenCalledWith({ |
|||
dataSource: data.dataSource, |
|||
symbol: data.symbol |
|||
}); |
|||
expect(result).toBe(split); |
|||
}); |
|||
}); |
|||
|
|||
describe('deleteSplit', () => { |
|||
it('throws NotFoundException when the scoped split does not exist', async () => { |
|||
deleteById.mockResolvedValue(false); |
|||
|
|||
await expect( |
|||
assetProfilesService.deleteSplit({ |
|||
id: 'split-id', |
|||
symbolProfileId: 'profile-id' |
|||
}) |
|||
).rejects.toBeInstanceOf(NotFoundException); |
|||
}); |
|||
|
|||
it('deletes an existing split using its profile scope', async () => { |
|||
deleteById.mockResolvedValue(true); |
|||
|
|||
await expect( |
|||
assetProfilesService.deleteSplit({ |
|||
id: 'split-id', |
|||
symbolProfileId: 'profile-id' |
|||
}) |
|||
).resolves.toBeUndefined(); |
|||
|
|||
expect(deleteById).toHaveBeenCalledWith({ |
|||
id: 'split-id', |
|||
symbolProfileId: 'profile-id' |
|||
}); |
|||
}); |
|||
}); |
|||
}); |
|||
@ -0,0 +1,118 @@ |
|||
import { |
|||
activityDummyData, |
|||
assetProfileDummyData, |
|||
userDummyData |
|||
} from '@ghostfolio/api/app/portfolio/calculator/portfolio-calculator-test-utils'; |
|||
import { CurrentRateService } from '@ghostfolio/api/app/portfolio/current-rate.service'; |
|||
import { CurrentRateServiceMock } from '@ghostfolio/api/app/portfolio/current-rate.service.mock'; |
|||
import { RedisCacheService } from '@ghostfolio/api/app/redis-cache/redis-cache.service'; |
|||
import { RedisCacheServiceMock } from '@ghostfolio/api/app/redis-cache/redis-cache.service.mock'; |
|||
import { ConfigurationService } from '@ghostfolio/api/services/configuration/configuration.service'; |
|||
import { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-data/exchange-rate-data.service'; |
|||
import { PortfolioSnapshotService } from '@ghostfolio/api/services/queues/portfolio-snapshot/portfolio-snapshot.service'; |
|||
import { PortfolioSnapshotServiceMock } from '@ghostfolio/api/services/queues/portfolio-snapshot/portfolio-snapshot.service.mock'; |
|||
import { Activity } from '@ghostfolio/common/interfaces'; |
|||
import { PerformanceCalculationType } from '@ghostfolio/common/types/performance-calculation-type.type'; |
|||
|
|||
import { Big } from 'big.js'; |
|||
|
|||
import { PortfolioCalculatorFactory } from './portfolio-calculator.factory'; |
|||
|
|||
jest.mock('@ghostfolio/api/app/portfolio/current-rate.service', () => { |
|||
return { |
|||
CurrentRateService: jest.fn().mockImplementation(() => { |
|||
return CurrentRateServiceMock; |
|||
}) |
|||
}; |
|||
}); |
|||
|
|||
jest.mock( |
|||
'@ghostfolio/api/services/queues/portfolio-snapshot/portfolio-snapshot.service', |
|||
() => { |
|||
return { |
|||
PortfolioSnapshotService: jest.fn().mockImplementation(() => { |
|||
return PortfolioSnapshotServiceMock; |
|||
}) |
|||
}; |
|||
} |
|||
); |
|||
|
|||
jest.mock('@ghostfolio/api/app/redis-cache/redis-cache.service', () => { |
|||
return { |
|||
RedisCacheService: jest.fn().mockImplementation(() => { |
|||
return RedisCacheServiceMock; |
|||
}) |
|||
}; |
|||
}); |
|||
|
|||
describe('PortfolioCalculatorFactory', () => { |
|||
let configurationService: ConfigurationService; |
|||
let currentRateService: CurrentRateService; |
|||
let exchangeRateDataService: ExchangeRateDataService; |
|||
let portfolioCalculatorFactory: PortfolioCalculatorFactory; |
|||
let portfolioSnapshotService: PortfolioSnapshotService; |
|||
let redisCacheService: RedisCacheService; |
|||
|
|||
beforeEach(() => { |
|||
PortfolioSnapshotServiceMock.reset(); |
|||
RedisCacheServiceMock.reset(); |
|||
|
|||
configurationService = new ConfigurationService(); |
|||
currentRateService = new CurrentRateService(null, null, null, null); |
|||
exchangeRateDataService = new ExchangeRateDataService( |
|||
null, |
|||
null, |
|||
null, |
|||
null |
|||
); |
|||
portfolioSnapshotService = new PortfolioSnapshotService(null, null); |
|||
redisCacheService = new RedisCacheService(null, null); |
|||
portfolioCalculatorFactory = new PortfolioCalculatorFactory( |
|||
configurationService, |
|||
currentRateService, |
|||
exchangeRateDataService, |
|||
portfolioSnapshotService, |
|||
redisCacheService |
|||
); |
|||
}); |
|||
|
|||
it.each([ |
|||
PerformanceCalculationType.ROAI, |
|||
PerformanceCalculationType.MWR, |
|||
PerformanceCalculationType.TWR, |
|||
PerformanceCalculationType.ROI |
|||
])( |
|||
'passes adjusted activities to the shared calculator base for %s', |
|||
(calculationType) => { |
|||
const activities: Activity[] = [ |
|||
{ |
|||
...activityDummyData, |
|||
assetProfile: { |
|||
...assetProfileDummyData, |
|||
currency: 'USD', |
|||
dataSource: 'YAHOO', |
|||
name: 'Apple Inc.', |
|||
symbol: 'AAPL' |
|||
}, |
|||
date: new Date('2020-01-01'), |
|||
feeInAssetProfileCurrency: 0, |
|||
feeInBaseCurrency: 0, |
|||
quantity: 20, |
|||
type: 'BUY', |
|||
unitPriceInAssetProfileCurrency: 50 |
|||
} |
|||
]; |
|||
|
|||
const calculator = portfolioCalculatorFactory.createCalculator({ |
|||
activities, |
|||
calculationType, |
|||
currency: 'USD', |
|||
userId: userDummyData.id |
|||
}); |
|||
const position = calculator.getTransactionPoints()[0].items[0]; |
|||
|
|||
expect(position.quantity).toEqual(new Big(20)); |
|||
expect(position.averagePrice).toEqual(new Big(50)); |
|||
} |
|||
); |
|||
}); |
|||
@ -0,0 +1,276 @@ |
|||
import { |
|||
activityDummyData, |
|||
assetProfileDummyData, |
|||
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 { CurrentRateServiceMock } from '@ghostfolio/api/app/portfolio/current-rate.service.mock'; |
|||
import { RedisCacheService } from '@ghostfolio/api/app/redis-cache/redis-cache.service'; |
|||
import { RedisCacheServiceMock } from '@ghostfolio/api/app/redis-cache/redis-cache.service.mock'; |
|||
import { adjustActivityBySplits } from '@ghostfolio/api/services/asset-profile-split/asset-profile-split.helper'; |
|||
import { ConfigurationService } from '@ghostfolio/api/services/configuration/configuration.service'; |
|||
import { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-data/exchange-rate-data.service'; |
|||
import { PortfolioSnapshotService } from '@ghostfolio/api/services/queues/portfolio-snapshot/portfolio-snapshot.service'; |
|||
import { PortfolioSnapshotServiceMock } from '@ghostfolio/api/services/queues/portfolio-snapshot/portfolio-snapshot.service.mock'; |
|||
import { parseDate } from '@ghostfolio/common/helper'; |
|||
import { Activity } from '@ghostfolio/common/interfaces'; |
|||
import { PerformanceCalculationType } from '@ghostfolio/common/types/performance-calculation-type.type'; |
|||
|
|||
import { AssetProfileSplit, DataSource } from '@prisma/client'; |
|||
import { Big } from 'big.js'; |
|||
|
|||
jest.mock('@ghostfolio/api/app/portfolio/current-rate.service', () => { |
|||
return { |
|||
CurrentRateService: jest.fn().mockImplementation(() => { |
|||
return CurrentRateServiceMock; |
|||
}) |
|||
}; |
|||
}); |
|||
|
|||
jest.mock( |
|||
'@ghostfolio/api/services/queues/portfolio-snapshot/portfolio-snapshot.service', |
|||
() => { |
|||
return { |
|||
PortfolioSnapshotService: jest.fn().mockImplementation(() => { |
|||
return PortfolioSnapshotServiceMock; |
|||
}) |
|||
}; |
|||
} |
|||
); |
|||
|
|||
jest.mock('@ghostfolio/api/app/redis-cache/redis-cache.service', () => { |
|||
return { |
|||
RedisCacheService: jest.fn().mockImplementation(() => { |
|||
return RedisCacheServiceMock; |
|||
}) |
|||
}; |
|||
}); |
|||
|
|||
describe('RoaiPortfolioCalculator stock splits', () => { |
|||
let configurationService: ConfigurationService; |
|||
let currentRateService: CurrentRateService; |
|||
let exchangeRateDataService: ExchangeRateDataService; |
|||
let portfolioCalculatorFactory: PortfolioCalculatorFactory; |
|||
let portfolioSnapshotService: PortfolioSnapshotService; |
|||
let redisCacheService: RedisCacheService; |
|||
|
|||
beforeEach(() => { |
|||
PortfolioSnapshotServiceMock.reset(); |
|||
RedisCacheServiceMock.reset(); |
|||
|
|||
configurationService = new ConfigurationService(); |
|||
currentRateService = new CurrentRateService(null, null, null, null); |
|||
exchangeRateDataService = new ExchangeRateDataService( |
|||
null, |
|||
null, |
|||
null, |
|||
null |
|||
); |
|||
portfolioSnapshotService = new PortfolioSnapshotService(null, null); |
|||
redisCacheService = new RedisCacheService(null, null); |
|||
portfolioCalculatorFactory = new PortfolioCalculatorFactory( |
|||
configurationService, |
|||
currentRateService, |
|||
exchangeRateDataService, |
|||
portfolioSnapshotService, |
|||
redisCacheService |
|||
); |
|||
}); |
|||
|
|||
it('doubles the position and halves the average price for a 2:1 split', () => { |
|||
const activity = adjustActivityBySplits( |
|||
createActivity({ unitPrice: 100 }), |
|||
[createSplit({ denominator: 1, numerator: 2 })] |
|||
); |
|||
|
|||
const position = getLastPosition(portfolioCalculatorFactory, [activity]); |
|||
|
|||
expect(position).toMatchObject({ |
|||
averagePrice: new Big(50), |
|||
investment: new Big(1000), |
|||
quantity: new Big(20) |
|||
}); |
|||
}); |
|||
|
|||
it('applies the inverse quantity and price changes for a reverse split', () => { |
|||
const activity = adjustActivityBySplits( |
|||
createActivity({ unitPrice: 100 }), |
|||
[createSplit({ denominator: 10, numerator: 1 })] |
|||
); |
|||
|
|||
const position = getLastPosition(portfolioCalculatorFactory, [activity]); |
|||
|
|||
expect(position).toMatchObject({ |
|||
averagePrice: new Big(1000), |
|||
investment: new Big(1000), |
|||
quantity: new Big(1) |
|||
}); |
|||
}); |
|||
|
|||
it('uses adjusted quantities when selling after a split', () => { |
|||
const buy = adjustActivityBySplits( |
|||
createActivity({ date: '2020-01-01', unitPrice: 100 }), |
|||
[createSplit({ denominator: 1, numerator: 2 })] |
|||
); |
|||
const sell = createActivity({ |
|||
date: '2021-01-01', |
|||
quantity: 5, |
|||
type: 'SELL', |
|||
unitPrice: 60 |
|||
}); |
|||
|
|||
const position = getLastPosition(portfolioCalculatorFactory, [buy, sell]); |
|||
|
|||
expect(position).toMatchObject({ |
|||
averagePrice: new Big(50), |
|||
investment: new Big(750), |
|||
quantity: new Big(15) |
|||
}); |
|||
}); |
|||
|
|||
it('applies multiple splits while preserving fractional precision', () => { |
|||
const activity = adjustActivityBySplits( |
|||
createActivity({ unitPrice: 100 }), |
|||
[ |
|||
createSplit({ denominator: 1, numerator: 2 }), |
|||
createSplit({ denominator: 3, numerator: 1, date: '2022-01-01' }) |
|||
] |
|||
); |
|||
|
|||
const position = getLastPosition(portfolioCalculatorFactory, [activity]); |
|||
|
|||
expect(position.averagePrice).toEqual(new Big(150)); |
|||
expect(position.quantity.toFixed(15)).toBe(new Big(20).div(3).toFixed(15)); |
|||
expect(position.investment.toNumber()).toBeCloseTo(1000, 12); |
|||
}); |
|||
|
|||
it('resets quantity and investment when the adjusted position is closed', () => { |
|||
const buy = adjustActivityBySplits( |
|||
createActivity({ date: '2020-01-01', unitPrice: 100 }), |
|||
[createSplit({ denominator: 1, numerator: 2 })] |
|||
); |
|||
const sell = createActivity({ |
|||
date: '2021-01-01', |
|||
quantity: 20, |
|||
type: 'SELL', |
|||
unitPrice: 60 |
|||
}); |
|||
|
|||
const position = getLastPosition(portfolioCalculatorFactory, [buy, sell]); |
|||
|
|||
expect(position.quantity).toEqual(new Big(0)); |
|||
expect(position.investment).toEqual(new Big(0)); |
|||
}); |
|||
|
|||
it('preserves existing behavior when no splits exist', () => { |
|||
const position = getLastPosition(portfolioCalculatorFactory, [ |
|||
createActivity({ unitPrice: 100 }) |
|||
]); |
|||
|
|||
expect(position).toMatchObject({ |
|||
averagePrice: new Big(100), |
|||
investment: new Big(1000), |
|||
quantity: new Big(10) |
|||
}); |
|||
}); |
|||
|
|||
it('uses provider market data without adjusting it a second time', async () => { |
|||
jest.useFakeTimers().setSystemTime(parseDate('2023-07-10').getTime()); |
|||
|
|||
const activity = adjustActivityBySplits( |
|||
createActivity({ date: '2023-07-09', unitPrice: 674.44 }), |
|||
[ |
|||
createSplit({ |
|||
date: '2023-07-10', |
|||
denominator: 1, |
|||
numerator: 2 |
|||
}) |
|||
] |
|||
); |
|||
const calculator = portfolioCalculatorFactory.createCalculator({ |
|||
activities: [activity], |
|||
calculationType: PerformanceCalculationType.ROAI, |
|||
currency: 'USD', |
|||
userId: userDummyData.id |
|||
}); |
|||
|
|||
const snapshot = await calculator.computeSnapshot(); |
|||
const [position] = snapshot.positions; |
|||
|
|||
expect(position).toMatchObject({ |
|||
investment: new Big(6744.4), |
|||
marketPrice: 331.83, |
|||
quantity: new Big(20), |
|||
valueInBaseCurrency: new Big(6636.6) |
|||
}); |
|||
}); |
|||
}); |
|||
|
|||
function getLastPosition( |
|||
portfolioCalculatorFactory: PortfolioCalculatorFactory, |
|||
activities: Activity[] |
|||
) { |
|||
const calculator = portfolioCalculatorFactory.createCalculator({ |
|||
activities, |
|||
calculationType: PerformanceCalculationType.ROAI, |
|||
currency: 'USD', |
|||
userId: userDummyData.id |
|||
}); |
|||
|
|||
return calculator.getTransactionPoints().at(-1).items[0]; |
|||
} |
|||
|
|||
function createActivity({ |
|||
date = '2020-01-01', |
|||
quantity = 10, |
|||
type = 'BUY', |
|||
unitPrice = 100 |
|||
}: { |
|||
date?: string; |
|||
quantity?: number; |
|||
type?: Activity['type']; |
|||
unitPrice?: number; |
|||
}): Activity { |
|||
return { |
|||
...activityDummyData, |
|||
assetProfile: { |
|||
...assetProfileDummyData, |
|||
currency: 'USD', |
|||
dataSource: DataSource.YAHOO, |
|||
name: 'Microsoft Inc.', |
|||
symbol: 'MSFT' |
|||
}, |
|||
date: parseDate(date), |
|||
feeInAssetProfileCurrency: 0, |
|||
feeInBaseCurrency: 0, |
|||
quantity, |
|||
type, |
|||
unitPrice, |
|||
unitPriceInAssetProfileCurrency: unitPrice, |
|||
value: quantity * unitPrice, |
|||
valueInBaseCurrency: quantity * unitPrice |
|||
} as Activity; |
|||
} |
|||
|
|||
function createSplit({ |
|||
date = '2021-01-01', |
|||
denominator, |
|||
numerator |
|||
}: { |
|||
date?: string; |
|||
denominator: number; |
|||
numerator: number; |
|||
}): AssetProfileSplit { |
|||
const splitDate = parseDate(date); |
|||
|
|||
return { |
|||
createdAt: splitDate, |
|||
date: splitDate, |
|||
denominator, |
|||
id: `${date}-${numerator}-${denominator}`, |
|||
numerator, |
|||
symbolProfileId: 'msft-profile', |
|||
updatedAt: splitDate |
|||
}; |
|||
} |
|||
@ -0,0 +1,96 @@ |
|||
import { |
|||
activityDummyData, |
|||
assetProfileDummyData |
|||
} from '@ghostfolio/api/app/portfolio/calculator/portfolio-calculator-test-utils'; |
|||
import { parseDate } from '@ghostfolio/common/helper'; |
|||
import { Activity } from '@ghostfolio/common/interfaces'; |
|||
|
|||
import { AssetProfileSplit, DataSource } from '@prisma/client'; |
|||
import { Big } from 'big.js'; |
|||
|
|||
import { adjustActivityBySplits } from './asset-profile-split.helper'; |
|||
|
|||
describe('adjustActivityBySplits', () => { |
|||
it('adjusts quantity and prices using the cumulative split factor', () => { |
|||
const activity = createActivity('2020-01-01'); |
|||
const splits = [ |
|||
createSplit('2021-01-01', 2, 1), |
|||
createSplit('2022-01-01', 1, 3) |
|||
]; |
|||
|
|||
const adjustedActivity = adjustActivityBySplits(activity, splits); |
|||
|
|||
expect(adjustedActivity).not.toBe(activity); |
|||
expect(adjustedActivity).toMatchObject({ |
|||
quantity: 20 / 3, |
|||
unitPrice: 150, |
|||
unitPriceInAssetProfileCurrency: 150, |
|||
value: 1000, |
|||
valueInBaseCurrency: 1000 |
|||
}); |
|||
expect(new Big(adjustedActivity.quantity).toFixed(15)).toBe( |
|||
new Big(20).div(3).toFixed(15) |
|||
); |
|||
expect(activity).toMatchObject({ |
|||
quantity: 10, |
|||
unitPrice: 100, |
|||
unitPriceInAssetProfileCurrency: 100 |
|||
}); |
|||
}); |
|||
|
|||
it('only adjusts activities before the split calendar date', () => { |
|||
const split = createSplit('2024-06-15T12:00:00Z', 2, 1); |
|||
const activityOnSplitDate = createActivity('2024-06-15T18:00:00Z'); |
|||
const activityAfterSplit = createActivity('2024-06-16T00:00:00Z'); |
|||
|
|||
const adjustedActivityOnSplitDate = adjustActivityBySplits( |
|||
activityOnSplitDate, |
|||
[split] |
|||
); |
|||
const adjustedActivityAfterSplit = adjustActivityBySplits( |
|||
activityAfterSplit, |
|||
[split] |
|||
); |
|||
|
|||
expect(adjustedActivityOnSplitDate).toEqual(activityOnSplitDate); |
|||
expect(adjustedActivityOnSplitDate).not.toBe(activityOnSplitDate); |
|||
expect(adjustedActivityAfterSplit).toEqual(activityAfterSplit); |
|||
expect(adjustedActivityAfterSplit).not.toBe(activityAfterSplit); |
|||
}); |
|||
}); |
|||
|
|||
function createActivity(date: string): Activity { |
|||
return { |
|||
...activityDummyData, |
|||
assetProfile: { |
|||
...assetProfileDummyData, |
|||
dataSource: DataSource.YAHOO, |
|||
symbol: 'AAPL' |
|||
}, |
|||
date: parseDate(date), |
|||
quantity: 10, |
|||
type: 'BUY', |
|||
unitPrice: 100, |
|||
unitPriceInAssetProfileCurrency: 100, |
|||
value: 1000, |
|||
valueInBaseCurrency: 1000 |
|||
} as Activity; |
|||
} |
|||
|
|||
function createSplit( |
|||
date: string, |
|||
numerator: number, |
|||
denominator: number |
|||
): AssetProfileSplit { |
|||
const splitDate = new Date(date); |
|||
|
|||
return { |
|||
createdAt: splitDate, |
|||
date: splitDate, |
|||
denominator, |
|||
id: `${date}-${numerator}-${denominator}`, |
|||
numerator, |
|||
symbolProfileId: 'aapl-profile', |
|||
updatedAt: splitDate |
|||
}; |
|||
} |
|||
@ -0,0 +1,35 @@ |
|||
import { resetHours } from '@ghostfolio/common/helper'; |
|||
import { Activity } from '@ghostfolio/common/interfaces'; |
|||
|
|||
import { AssetProfileSplit } from '@prisma/client'; |
|||
import { Big } from 'big.js'; |
|||
import { isBefore } from 'date-fns'; |
|||
|
|||
export function adjustActivityBySplits( |
|||
activity: Activity, |
|||
splits: AssetProfileSplit[] |
|||
): Activity { |
|||
const activityDate = resetHours(activity.date); |
|||
let splitFactor = new Big(1); |
|||
|
|||
for (const split of splits) { |
|||
if (isBefore(activityDate, resetHours(split.date))) { |
|||
splitFactor = splitFactor.mul(split.numerator).div(split.denominator); |
|||
} |
|||
} |
|||
|
|||
if (splitFactor.eq(1)) { |
|||
return { ...activity }; |
|||
} |
|||
|
|||
return { |
|||
...activity, |
|||
quantity: new Big(activity.quantity).mul(splitFactor).toNumber(), |
|||
unitPrice: new Big(activity.unitPrice).div(splitFactor).toNumber(), |
|||
unitPriceInAssetProfileCurrency: new Big( |
|||
activity.unitPriceInAssetProfileCurrency |
|||
) |
|||
.div(splitFactor) |
|||
.toNumber() |
|||
}; |
|||
} |
|||
@ -0,0 +1,200 @@ |
|||
import { PrismaService } from '@ghostfolio/api/services/prisma/prisma.service'; |
|||
import { getAssetProfileIdentifier } from '@ghostfolio/common/helper'; |
|||
import { AssetProfileIdentifier } from '@ghostfolio/common/interfaces'; |
|||
|
|||
import { AssetProfileSplit, DataSource } from '@prisma/client'; |
|||
|
|||
import { AssetProfileSplitService } from './asset-profile-split.service'; |
|||
|
|||
describe('AssetProfileSplitService', () => { |
|||
let assetProfileSplitService: AssetProfileSplitService; |
|||
let deleteMany: jest.Mock; |
|||
let findMany: jest.Mock; |
|||
let upsert: jest.Mock; |
|||
|
|||
beforeEach(() => { |
|||
deleteMany = jest.fn(); |
|||
findMany = jest.fn(); |
|||
upsert = jest.fn(); |
|||
|
|||
assetProfileSplitService = new AssetProfileSplitService({ |
|||
assetProfileSplit: { deleteMany, findMany, upsert } |
|||
} as unknown as PrismaService); |
|||
}); |
|||
|
|||
describe('deleteById', () => { |
|||
it('scopes deletion by split and asset profile identifiers', async () => { |
|||
deleteMany.mockResolvedValue({ count: 1 }); |
|||
|
|||
const result = await assetProfileSplitService.deleteById({ |
|||
id: 'split-id', |
|||
symbolProfileId: 'profile-id' |
|||
}); |
|||
|
|||
expect(result).toBe(true); |
|||
expect(deleteMany).toHaveBeenCalledWith({ |
|||
where: { |
|||
id: 'split-id', |
|||
symbolProfileId: 'profile-id' |
|||
} |
|||
}); |
|||
}); |
|||
|
|||
it('returns false when the split belongs to another asset profile', async () => { |
|||
deleteMany.mockResolvedValue({ count: 0 }); |
|||
|
|||
const result = await assetProfileSplitService.deleteById({ |
|||
id: 'split-id', |
|||
symbolProfileId: 'other-profile-id' |
|||
}); |
|||
|
|||
expect(result).toBe(false); |
|||
expect(deleteMany).toHaveBeenCalledWith({ |
|||
where: { |
|||
id: 'split-id', |
|||
symbolProfileId: 'other-profile-id' |
|||
} |
|||
}); |
|||
}); |
|||
}); |
|||
|
|||
describe('getSplitsByAssetProfiles', () => { |
|||
it('fetches and groups all splits with one ordered query', async () => { |
|||
const assetProfiles: AssetProfileIdentifier[] = [ |
|||
{ dataSource: DataSource.YAHOO, symbol: 'AAPL' }, |
|||
{ dataSource: DataSource.YAHOO, symbol: 'MSFT' } |
|||
]; |
|||
const aaplSplit = createSplit({ |
|||
date: new Date('2020-08-31'), |
|||
symbolProfile: assetProfiles[0] |
|||
}); |
|||
const msftSplit = createSplit({ |
|||
date: new Date('2021-09-16'), |
|||
symbolProfile: assetProfiles[1] |
|||
}); |
|||
|
|||
findMany.mockResolvedValue([ |
|||
{ ...aaplSplit, symbolProfile: assetProfiles[0] }, |
|||
{ ...msftSplit, symbolProfile: assetProfiles[1] } |
|||
]); |
|||
|
|||
const splits = |
|||
await assetProfileSplitService.getSplitsByAssetProfiles(assetProfiles); |
|||
|
|||
expect(findMany).toHaveBeenCalledTimes(1); |
|||
expect(findMany).toHaveBeenCalledWith({ |
|||
include: { |
|||
symbolProfile: { |
|||
select: { |
|||
dataSource: true, |
|||
symbol: true |
|||
} |
|||
} |
|||
}, |
|||
orderBy: [{ date: 'asc' }], |
|||
where: { |
|||
symbolProfile: { |
|||
OR: assetProfiles |
|||
} |
|||
} |
|||
}); |
|||
expect(splits).toEqual( |
|||
new Map([ |
|||
[getAssetProfileIdentifier(assetProfiles[0]), [aaplSplit]], |
|||
[getAssetProfileIdentifier(assetProfiles[1]), [msftSplit]] |
|||
]) |
|||
); |
|||
}); |
|||
}); |
|||
|
|||
describe('upsert', () => { |
|||
it('normalizes the split date before persisting it', async () => { |
|||
const date = new Date('2024-06-15T18:30:00.000Z'); |
|||
const normalizedDate = new Date('2024-06-15T00:00:00.000Z'); |
|||
|
|||
await assetProfileSplitService.upsert({ |
|||
date, |
|||
denominator: 1, |
|||
numerator: 2, |
|||
symbolProfileId: 'profile-id' |
|||
}); |
|||
|
|||
expect(upsert).toHaveBeenCalledWith({ |
|||
create: { |
|||
date: normalizedDate, |
|||
denominator: 1, |
|||
numerator: 2, |
|||
symbolProfileId: 'profile-id' |
|||
}, |
|||
update: { |
|||
denominator: 1, |
|||
numerator: 2 |
|||
}, |
|||
where: { |
|||
symbolProfileId_date: { |
|||
date: normalizedDate, |
|||
symbolProfileId: 'profile-id' |
|||
} |
|||
} |
|||
}); |
|||
}); |
|||
}); |
|||
|
|||
describe('getSplits', () => { |
|||
it('filters by asset profile and orders splits by date ascending', async () => { |
|||
const splits = [ |
|||
createStoredSplit('2020-01-01'), |
|||
createStoredSplit('2021-01-01') |
|||
]; |
|||
findMany.mockResolvedValue(splits); |
|||
|
|||
const result = await assetProfileSplitService.getSplits({ |
|||
dataSource: DataSource.YAHOO, |
|||
symbol: 'AAPL' |
|||
}); |
|||
|
|||
expect(result).toBe(splits); |
|||
expect(findMany).toHaveBeenCalledWith({ |
|||
orderBy: [{ date: 'asc' }], |
|||
where: { |
|||
symbolProfile: { |
|||
dataSource: DataSource.YAHOO, |
|||
symbol: 'AAPL' |
|||
} |
|||
} |
|||
}); |
|||
}); |
|||
}); |
|||
}); |
|||
|
|||
function createSplit({ |
|||
date, |
|||
symbolProfile |
|||
}: { |
|||
date: Date; |
|||
symbolProfile: AssetProfileIdentifier; |
|||
}): AssetProfileSplit { |
|||
return { |
|||
createdAt: date, |
|||
date, |
|||
denominator: 1, |
|||
id: `${symbolProfile.symbol}-split`, |
|||
numerator: 2, |
|||
symbolProfileId: `${symbolProfile.symbol}-profile`, |
|||
updatedAt: date |
|||
}; |
|||
} |
|||
|
|||
function createStoredSplit(date: string): AssetProfileSplit { |
|||
const splitDate = new Date(date); |
|||
|
|||
return { |
|||
createdAt: splitDate, |
|||
date: splitDate, |
|||
denominator: 1, |
|||
id: `${date}-split`, |
|||
numerator: 2, |
|||
symbolProfileId: 'aapl-profile', |
|||
updatedAt: splitDate |
|||
}; |
|||
} |
|||
Loading…
Reference in new issue