From 99b53a7b33d6a22cdaca0d1149cd54010a6158e1 Mon Sep 17 00:00:00 2001 From: David Requeno Date: Fri, 7 Aug 2026 22:21:20 -0600 Subject: [PATCH] Feature/stock splits logic - 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 handoff --- .../src/app/activities/activities.module.ts | 2 + .../app/activities/activities.service.spec.ts | 329 ++++++++++++++++++ .../src/app/activities/activities.service.ts | 33 +- .../asset-profiles.service.spec.ts | 93 +++++ .../portfolio-calculator.factory.spec.ts | 118 +++++++ .../roai/portfolio-calculator-cash.spec.ts | 6 +- .../portfolio-calculator-stock-split.spec.ts | 276 +++++++++++++++ .../app/portfolio/portfolio.service.spec.ts | 1 + .../asset-profile-split.helper.spec.ts | 96 +++++ .../asset-profile-split.helper.ts | 35 ++ .../asset-profile-split.service.spec.ts | 200 +++++++++++ .../asset-profile-split.service.ts | 48 ++- 12 files changed, 1234 insertions(+), 3 deletions(-) create mode 100644 apps/api/src/app/activities/activities.service.spec.ts create mode 100644 apps/api/src/app/endpoints/asset-profiles/asset-profiles.service.spec.ts create mode 100644 apps/api/src/app/portfolio/calculator/portfolio-calculator.factory.spec.ts create mode 100644 apps/api/src/app/portfolio/calculator/roai/portfolio-calculator-stock-split.spec.ts create mode 100644 apps/api/src/services/asset-profile-split/asset-profile-split.helper.spec.ts create mode 100644 apps/api/src/services/asset-profile-split/asset-profile-split.helper.ts create mode 100644 apps/api/src/services/asset-profile-split/asset-profile-split.service.spec.ts diff --git a/apps/api/src/app/activities/activities.module.ts b/apps/api/src/app/activities/activities.module.ts index 34091ba5e4..1508917f04 100644 --- a/apps/api/src/app/activities/activities.module.ts +++ b/apps/api/src/app/activities/activities.module.ts @@ -6,6 +6,7 @@ import { RedactValuesInResponseModule } from '@ghostfolio/api/interceptors/redac import { TransformDataSourceInRequestModule } from '@ghostfolio/api/interceptors/transform-data-source-in-request/transform-data-source-in-request.module'; import { TransformDataSourceInResponseModule } from '@ghostfolio/api/interceptors/transform-data-source-in-response/transform-data-source-in-response.module'; import { ApiModule } from '@ghostfolio/api/services/api/api.module'; +import { AssetProfileSplitModule } from '@ghostfolio/api/services/asset-profile-split/asset-profile-split.module'; import { BenchmarkModule } from '@ghostfolio/api/services/benchmark/benchmark.module'; import { DataProviderModule } from '@ghostfolio/api/services/data-provider/data-provider.module'; import { ExchangeRateDataModule } from '@ghostfolio/api/services/exchange-rate-data/exchange-rate-data.module'; @@ -26,6 +27,7 @@ import { ActivitiesService } from './activities.service'; exports: [ActivitiesService], imports: [ ApiModule, + AssetProfileSplitModule, BenchmarkModule, CacheModule, DataGatheringQueueModule, diff --git a/apps/api/src/app/activities/activities.service.spec.ts b/apps/api/src/app/activities/activities.service.spec.ts new file mode 100644 index 0000000000..d2ae51b23f --- /dev/null +++ b/apps/api/src/app/activities/activities.service.spec.ts @@ -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 + }; +} diff --git a/apps/api/src/app/activities/activities.service.ts b/apps/api/src/app/activities/activities.service.ts index 140726aebc..cb5e190ce0 100644 --- a/apps/api/src/app/activities/activities.service.ts +++ b/apps/api/src/app/activities/activities.service.ts @@ -8,6 +8,8 @@ import { WHERE_ACCOUNT_NOT_EXCLUDED } from '@ghostfolio/api/helper/account.helper'; import { LogPerformance } from '@ghostfolio/api/interceptors/performance-logging/performance-logging.interceptor'; +import { adjustActivityBySplits } from '@ghostfolio/api/services/asset-profile-split/asset-profile-split.helper'; +import { AssetProfileSplitService } from '@ghostfolio/api/services/asset-profile-split/asset-profile-split.service'; import { BenchmarkService } from '@ghostfolio/api/services/benchmark/benchmark.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'; @@ -66,7 +68,8 @@ export class ActivitiesService { private readonly marketDataService: MarketDataService, private readonly prismaService: PrismaService, private readonly symbolProfileService: SymbolProfileService, - private readonly tagService: TagService + private readonly tagService: TagService, + private readonly assetProfileSplitService: AssetProfileSplitService ) {} public areCashActivitiesExcludedByFilters(filters: Filter[] = []) { @@ -901,6 +904,34 @@ export class ActivitiesService { withExcludedAccountsAndActivities: false // TODO }); + const assetProfiles = uniqBy( + activities.activities.map(({ assetProfile }) => { + return { + dataSource: assetProfile.dataSource, + symbol: assetProfile.symbol + }; + }), + ({ dataSource, symbol }) => { + return getAssetProfileIdentifier({ dataSource, symbol }); + } + ); + const splitsByAssetProfile = + await this.assetProfileSplitService.getSplitsByAssetProfiles( + assetProfiles + ); + + activities.activities = activities.activities.map((activity) => { + const key = getAssetProfileIdentifier({ + dataSource: activity.assetProfile.dataSource, + symbol: activity.assetProfile.symbol + }); + + return adjustActivityBySplits( + activity, + splitsByAssetProfile.get(key) ?? [] + ); + }); + if (withCash && !this.areCashActivitiesExcludedByFilters(filters)) { const cashDetails = await this.accountService.getCashDetails({ filters, diff --git a/apps/api/src/app/endpoints/asset-profiles/asset-profiles.service.spec.ts b/apps/api/src/app/endpoints/asset-profiles/asset-profiles.service.spec.ts new file mode 100644 index 0000000000..aa0241ae72 --- /dev/null +++ b/apps/api/src/app/endpoints/asset-profiles/asset-profiles.service.spec.ts @@ -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' + }); + }); + }); +}); diff --git a/apps/api/src/app/portfolio/calculator/portfolio-calculator.factory.spec.ts b/apps/api/src/app/portfolio/calculator/portfolio-calculator.factory.spec.ts new file mode 100644 index 0000000000..99f3106eb2 --- /dev/null +++ b/apps/api/src/app/portfolio/calculator/portfolio-calculator.factory.spec.ts @@ -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)); + } + ); +}); diff --git a/apps/api/src/app/portfolio/calculator/roai/portfolio-calculator-cash.spec.ts b/apps/api/src/app/portfolio/calculator/roai/portfolio-calculator-cash.spec.ts index 3b09bfd260..74de017367 100644 --- a/apps/api/src/app/portfolio/calculator/roai/portfolio-calculator-cash.spec.ts +++ b/apps/api/src/app/portfolio/calculator/roai/portfolio-calculator-cash.spec.ts @@ -11,6 +11,7 @@ import { CurrentRateService } from '@ghostfolio/api/app/portfolio/current-rate.s 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 { AssetProfileSplitService } from '@ghostfolio/api/services/asset-profile-split/asset-profile-split.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'; @@ -132,7 +133,10 @@ describe('PortfolioCalculator', () => { null, null, null, - null + null, + { + getSplitsByAssetProfiles: jest.fn().mockResolvedValue(new Map()) + } as unknown as AssetProfileSplitService ); portfolioSnapshotService = new PortfolioSnapshotService(null, null); diff --git a/apps/api/src/app/portfolio/calculator/roai/portfolio-calculator-stock-split.spec.ts b/apps/api/src/app/portfolio/calculator/roai/portfolio-calculator-stock-split.spec.ts new file mode 100644 index 0000000000..338e7ed8ac --- /dev/null +++ b/apps/api/src/app/portfolio/calculator/roai/portfolio-calculator-stock-split.spec.ts @@ -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 + }; +} diff --git a/apps/api/src/app/portfolio/portfolio.service.spec.ts b/apps/api/src/app/portfolio/portfolio.service.spec.ts index eed3a27cb7..3433d08c61 100644 --- a/apps/api/src/app/portfolio/portfolio.service.spec.ts +++ b/apps/api/src/app/portfolio/portfolio.service.spec.ts @@ -74,6 +74,7 @@ describe('PortfolioService', () => { null, null, null, + null, null ); diff --git a/apps/api/src/services/asset-profile-split/asset-profile-split.helper.spec.ts b/apps/api/src/services/asset-profile-split/asset-profile-split.helper.spec.ts new file mode 100644 index 0000000000..a979033b1d --- /dev/null +++ b/apps/api/src/services/asset-profile-split/asset-profile-split.helper.spec.ts @@ -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 + }; +} diff --git a/apps/api/src/services/asset-profile-split/asset-profile-split.helper.ts b/apps/api/src/services/asset-profile-split/asset-profile-split.helper.ts new file mode 100644 index 0000000000..994d9dc550 --- /dev/null +++ b/apps/api/src/services/asset-profile-split/asset-profile-split.helper.ts @@ -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() + }; +} diff --git a/apps/api/src/services/asset-profile-split/asset-profile-split.service.spec.ts b/apps/api/src/services/asset-profile-split/asset-profile-split.service.spec.ts new file mode 100644 index 0000000000..81e9118981 --- /dev/null +++ b/apps/api/src/services/asset-profile-split/asset-profile-split.service.spec.ts @@ -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 + }; +} diff --git a/apps/api/src/services/asset-profile-split/asset-profile-split.service.ts b/apps/api/src/services/asset-profile-split/asset-profile-split.service.ts index ec842caaf5..99a350f556 100644 --- a/apps/api/src/services/asset-profile-split/asset-profile-split.service.ts +++ b/apps/api/src/services/asset-profile-split/asset-profile-split.service.ts @@ -1,5 +1,8 @@ import { PrismaService } from '@ghostfolio/api/services/prisma/prisma.service'; -import { resetHours } from '@ghostfolio/common/helper'; +import { + getAssetProfileIdentifier, + resetHours +} from '@ghostfolio/common/helper'; import { AssetProfileIdentifier } from '@ghostfolio/common/interfaces'; import { Injectable } from '@nestjs/common'; @@ -52,6 +55,49 @@ export class AssetProfileSplitService { }); } + /** + * Returns the splits of all given asset profiles, grouped by their + * data-source/symbol identifier. + */ + public async getSplitsByAssetProfiles( + assetProfiles: AssetProfileIdentifier[] + ): Promise> { + const splitsByAssetProfile = new Map(); + + for (const assetProfile of assetProfiles) { + splitsByAssetProfile.set(getAssetProfileIdentifier(assetProfile), []); + } + + if (assetProfiles.length === 0) { + return splitsByAssetProfile; + } + + const splits = await this.prismaService.assetProfileSplit.findMany({ + include: { + symbolProfile: { + select: { + dataSource: true, + symbol: true + } + } + }, + orderBy: [{ date: 'asc' }], + where: { + symbolProfile: { + OR: assetProfiles + } + } + }); + + for (const { symbolProfile, ...split } of splits) { + splitsByAssetProfile + .get(getAssetProfileIdentifier(symbolProfile)) + ?.push(split); + } + + return splitsByAssetProfile; + } + public async upsert({ date, denominator,