Browse Source

Feature/add stock splits logic (#7568)

* Add business logic and tests for stock splits

* Update changelog
pull/7603/head
David Requeno 3 days ago
committed by GitHub
parent
commit
9d2e4b732c
No known key found for this signature in database GPG Key ID: B5690EEEBB952194
  1. 7
      CHANGELOG.md
  2. 2
      apps/api/src/app/activities/activities.module.ts
  3. 337
      apps/api/src/app/activities/activities.service.spec.ts
  4. 48
      apps/api/src/app/activities/activities.service.ts
  5. 7
      apps/api/src/app/endpoints/asset-profiles/asset-profiles.controller.ts
  6. 179
      apps/api/src/app/endpoints/asset-profiles/asset-profiles.service.spec.ts
  7. 56
      apps/api/src/app/endpoints/asset-profiles/asset-profiles.service.ts
  8. 4
      apps/api/src/app/portfolio/calculator/roai/portfolio-calculator-cash.spec.ts
  9. 276
      apps/api/src/app/portfolio/calculator/roai/portfolio-calculator-stock-split.spec.ts
  10. 1
      apps/api/src/app/portfolio/portfolio.service.spec.ts
  11. 114
      apps/api/src/services/asset-profile-split/asset-profile-split.helper.spec.ts
  12. 58
      apps/api/src/services/asset-profile-split/asset-profile-split.helper.ts
  13. 161
      apps/api/src/services/asset-profile-split/asset-profile-split.service.spec.ts
  14. 27
      apps/api/src/services/asset-profile-split/asset-profile-split.service.ts
  15. 8
      apps/api/src/services/queues/data-gathering/data-gathering.service.ts
  16. 4
      apps/client/src/app/components/admin-market-data/asset-profile-dialog/asset-profile-dialog.html
  17. 5
      libs/common/src/lib/permissions.ts
  18. 2
      prisma/migrations/20260815120000_added_symbol_profile_id_index_to_order/migration.sql
  19. 1
      prisma/schema.prisma

7
CHANGELOG.md

@ -5,6 +5,13 @@ All notable changes to this project will be documented in this file.
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
## Unreleased
### Added
- Added the business logic and tests for stock splits of an asset profile (experimental)
- Added an index for `symbolProfileId` to the order database table
## 3.51.0 - 2026-08-14
### Changed

2
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,

337
apps/api/src/app/activities/activities.service.spec.ts

@ -0,0 +1,337 @@
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
};
}

48
apps/api/src/app/activities/activities.service.ts

@ -14,6 +14,8 @@ import {
WHERE_ACTIVITY_NOT_DRAFT
} from '@ghostfolio/api/helper/activity.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,6 +68,7 @@ export class ActivitiesService {
public constructor(
private readonly accountBalanceService: AccountBalanceService,
private readonly accountService: AccountService,
private readonly assetProfileSplitService: AssetProfileSplitService,
private readonly benchmarkService: BenchmarkService,
private readonly dataGatheringService: DataGatheringService,
private readonly dataProviderService: DataProviderService,
@ -915,12 +918,26 @@ export class ActivitiesService {
/** Whether to include cash activities in the result. */
withCash?: boolean;
}) {
const activities = await this.getActivities({
filters,
userCurrency,
userId,
withExcludedAccountsAndActivities: false // TODO
});
const [activities, splits] = await Promise.all([
this.getActivities({
filters,
userCurrency,
userId,
withExcludedAccountsAndActivities: false // TODO
}),
this.assetProfileSplitService.getSplitsByUserId({ userId })
]);
if (splits.length > 0) {
const splitsBySymbolProfileId = groupBy(splits, 'symbolProfileId');
activities.activities = activities.activities.map((activity) => {
return adjustActivityBySplits(
activity,
splitsBySymbolProfileId[activity.assetProfile.id] ?? []
);
});
}
if (withCash && !this.areCashActivitiesExcludedByFilters(filters)) {
const cashDetails = await this.accountService.getCashDetails({
@ -963,6 +980,25 @@ export class ActivitiesService {
};
}
/**
* Returns the id of every user who has an activity for the given asset
* profile, including draft activities and activities of excluded accounts
*/
public async getUserIdsBySymbolProfileId(
symbolProfileId: string
): Promise<string[]> {
const activitiesByUser = await this.prismaService.order.groupBy({
by: ['userId'],
where: {
symbolProfileId
}
});
return activitiesByUser.map(({ userId }) => {
return userId;
});
}
public async order(
orderWhereUniqueInput: Prisma.OrderWhereUniqueInput
): Promise<Order | null> {

7
apps/api/src/app/endpoints/asset-profiles/asset-profiles.controller.ts

@ -169,7 +169,12 @@ export class AssetProfilesController {
permissions.deleteAssetProfileSplitOfOwnAssetProfile
});
return this.assetProfilesService.deleteSplit({ id, symbolProfileId });
return this.assetProfilesService.deleteSplit({
dataSource,
id,
symbol,
symbolProfileId
});
}
@HasPermission(permissions.accessAdminControl)

179
apps/api/src/app/endpoints/asset-profiles/asset-profiles.service.spec.ts

@ -0,0 +1,179 @@
import { ActivitiesService } from '@ghostfolio/api/app/activities/activities.service';
import { PortfolioChangedEvent } from '@ghostfolio/api/events/portfolio-changed.event';
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 { EventEmitter2 } from '@nestjs/event-emitter';
import { AssetProfileSplit, DataSource } from '@prisma/client';
import { AssetProfilesService } from './asset-profiles.service';
describe('AssetProfilesService', () => {
let assetProfilesService: AssetProfilesService;
let deleteById: jest.Mock;
let emit: jest.Mock;
let finished: jest.Mock;
let gatherSymbol: jest.Mock;
let getUserIdsBySymbolProfileId: jest.Mock;
let upsert: jest.Mock;
beforeEach(() => {
deleteById = jest.fn();
emit = jest.fn();
finished = jest.fn().mockResolvedValue(undefined);
gatherSymbol = jest.fn().mockResolvedValue([{ finished }]);
getUserIdsBySymbolProfileId = jest.fn().mockResolvedValue([]);
upsert = jest.fn();
assetProfilesService = new AssetProfilesService(
{ getUserIdsBySymbolProfileId } as unknown as ActivitiesService,
{
deleteById,
upsert
} as unknown as AssetProfileSplitService,
null,
{ gatherSymbol } as unknown as DataGatheringService,
null,
{ emit } as unknown as EventEmitter2,
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);
});
it('invalidates portfolio snapshots for users holding the asset', async () => {
upsert.mockResolvedValue({} as AssetProfileSplit);
getUserIdsBySymbolProfileId.mockResolvedValue(['user-1', 'user-2']);
await assetProfilesService.createSplit({
dataSource: DataSource.YAHOO,
date: new Date('2024-06-15T18:30:00.000Z'),
denominator: 1,
numerator: 2,
symbol: 'AAPL',
symbolProfileId: 'profile-id'
});
await flushPendingPromises();
expect(getUserIdsBySymbolProfileId).toHaveBeenCalledWith('profile-id');
expect(emit.mock.calls.map(([, event]) => event.getUserId())).toEqual([
'user-1',
'user-2'
]);
expect(emit.mock.calls[0][0]).toBe(PortfolioChangedEvent.getName());
});
it('emits the events only once the market data has been gathered', async () => {
let completeJob: () => void;
finished.mockReturnValue(
new Promise<void>((resolve) => {
completeJob = resolve;
})
);
upsert.mockResolvedValue({} as AssetProfileSplit);
getUserIdsBySymbolProfileId.mockResolvedValue(['user-1']);
await assetProfilesService.createSplit({
dataSource: DataSource.YAHOO,
date: new Date('2024-06-15T18:30:00.000Z'),
denominator: 1,
numerator: 2,
symbol: 'AAPL',
symbolProfileId: 'profile-id'
});
await flushPendingPromises();
expect(emit).not.toHaveBeenCalled();
completeJob();
await flushPendingPromises();
expect(emit.mock.calls.map(([, event]) => event.getUserId())).toEqual([
'user-1'
]);
});
});
describe('deleteSplit', () => {
it('throws NotFoundException when the scoped split does not exist', async () => {
deleteById.mockResolvedValue(false);
await expect(
assetProfilesService.deleteSplit({
dataSource: DataSource.YAHOO,
id: 'split-id',
symbol: 'AAPL',
symbolProfileId: 'profile-id'
})
).rejects.toBeInstanceOf(NotFoundException);
await flushPendingPromises();
expect(gatherSymbol).not.toHaveBeenCalled();
expect(emit).not.toHaveBeenCalled();
});
it('deletes an existing split using its profile scope', async () => {
deleteById.mockResolvedValue(true);
getUserIdsBySymbolProfileId.mockResolvedValue(['user-1']);
await expect(
assetProfilesService.deleteSplit({
dataSource: DataSource.YAHOO,
id: 'split-id',
symbol: 'AAPL',
symbolProfileId: 'profile-id'
})
).resolves.toBeUndefined();
await flushPendingPromises();
expect(deleteById).toHaveBeenCalledWith({
id: 'split-id',
symbolProfileId: 'profile-id'
});
expect(emit.mock.calls.map(([, event]) => event.getUserId())).toEqual([
'user-1'
]);
expect(gatherSymbol).toHaveBeenCalledWith({
dataSource: DataSource.YAHOO,
symbol: 'AAPL'
});
});
});
});
function flushPendingPromises() {
return new Promise((resolve) => {
setImmediate(resolve);
});
}

56
apps/api/src/app/endpoints/asset-profiles/asset-profiles.service.ts

@ -1,4 +1,5 @@
import { ActivitiesService } from '@ghostfolio/api/app/activities/activities.service';
import { PortfolioChangedEvent } from '@ghostfolio/api/events/portfolio-changed.event';
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';
@ -25,6 +26,7 @@ import {
import { MarketDataPreset } from '@ghostfolio/common/types';
import { Injectable, NotFoundException } from '@nestjs/common';
import { EventEmitter2 } from '@nestjs/event-emitter';
import { AssetClass, AssetSubClass, DataSource, Prisma } from '@prisma/client';
import { groupBy } from 'lodash';
@ -36,6 +38,7 @@ export class AssetProfilesService {
private readonly benchmarkService: BenchmarkService,
private readonly dataGatheringService: DataGatheringService,
private readonly dataProviderService: DataProviderService,
private readonly eventEmitter: EventEmitter2,
private readonly exchangeRateDataService: ExchangeRateDataService,
private readonly marketDataService: MarketDataService,
private readonly prismaService: PrismaService,
@ -62,18 +65,24 @@ export class AssetProfilesService {
symbolProfileId
});
await this.dataGatheringService.gatherSymbol({ dataSource, symbol });
await this.gatherSymbolAndEmitPortfolioChangedEvents({
dataSource,
symbol,
symbolProfileId
});
return assetProfileSplit;
}
public async deleteSplit({
dataSource,
id,
symbol,
symbolProfileId
}: {
id: string;
symbolProfileId: string;
}) {
} & AssetProfileIdentifier) {
const isDeleted = await this.assetProfileSplitService.deleteById({
id,
symbolProfileId
@ -82,6 +91,12 @@ export class AssetProfilesService {
if (!isDeleted) {
throw new NotFoundException();
}
await this.gatherSymbolAndEmitPortfolioChangedEvents({
dataSource,
symbol,
symbolProfileId
});
}
public async getAssetProfile({
@ -425,6 +440,43 @@ export class AssetProfilesService {
return assetProfile;
}
private async emitPortfolioChangedEvents(symbolProfileId: string) {
const userIds =
await this.activitiesService.getUserIdsBySymbolProfileId(symbolProfileId);
for (const userId of userIds) {
this.eventEmitter.emit(
PortfolioChangedEvent.getName(),
new PortfolioChangedEvent({ userId })
);
}
}
/**
* Gathers the market data of the given asset profile and invalidates the
* portfolio snapshots of the affected users as soon as it is available.
* Emitting the events earlier would recompute the snapshots from
* split-adjusted quantities and not yet split-adjusted market prices.
*/
private async gatherSymbolAndEmitPortfolioChangedEvents({
dataSource,
symbol,
symbolProfileId
}: { symbolProfileId: string } & AssetProfileIdentifier) {
const jobs = await this.dataGatheringService.gatherSymbol({
dataSource,
symbol
});
void Promise.allSettled(
jobs.map((job) => {
return job.finished();
})
).then(() => {
return this.emitPortfolioChangedEvents(symbolProfileId);
});
}
private getAssetProfileDataUpdate({
countries,
holdings,

4
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';
@ -124,6 +125,9 @@ describe('PortfolioCalculator', () => {
activitiesService = new ActivitiesService(
accountBalanceService,
accountService,
{
getSplitsByUserId: jest.fn().mockResolvedValue([])
} as unknown as AssetProfileSplitService,
null,
null,
dataProviderService,

276
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 {
denominator,
numerator,
createdAt: splitDate,
date: splitDate,
id: `${date}-${numerator}-${denominator}`,
symbolProfileId: 'msft-profile',
updatedAt: splitDate
};
}

1
apps/api/src/app/portfolio/portfolio.service.spec.ts

@ -66,6 +66,7 @@ describe('PortfolioService', () => {
accountService,
null,
null,
null,
dataProviderService,
null,
exchangeRateDataService,

114
apps/api/src/services/asset-profile-split/asset-profile-split.helper.spec.ts

@ -0,0 +1,114 @@
import {
activityDummyData,
assetProfileDummyData
} from '@ghostfolio/api/app/portfolio/calculator/portfolio-calculator-test-utils';
import * as commonHelper from '@ghostfolio/common/helper';
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-15T00: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).toBe(activityOnSplitDate);
expect(adjustedActivityAfterSplit).toEqual(activityAfterSplit);
expect(adjustedActivityAfterSplit).toBe(activityAfterSplit);
});
it('compares stored UTC split dates without normalizing them locally', () => {
const resetHoursSpy = jest
.spyOn(commonHelper, 'resetHours')
.mockReturnValue(new Date('2024-06-14T00:00:00Z'));
try {
const activity = createActivity('2024-06-14T12:00:00Z');
const split = createSplit('2024-06-15T00:00:00Z', 2, 1);
const adjustedActivity = adjustActivityBySplits(activity, [split]);
expect(adjustedActivity.quantity).toBe(20);
} finally {
resetHoursSpy.mockRestore();
}
});
});
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 {
denominator,
numerator,
createdAt: splitDate,
date: splitDate,
id: `${date}-${numerator}-${denominator}`,
symbolProfileId: 'aapl-profile',
updatedAt: splitDate
};
}

58
apps/api/src/services/asset-profile-split/asset-profile-split.helper.ts

@ -0,0 +1,58 @@
import { INVESTMENT_ACTIVITY_TYPES } from '@ghostfolio/common/config';
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 {
if (!INVESTMENT_ACTIVITY_TYPES.includes(activity.type)) {
return activity;
}
const activityDate = resetHours(activity.date);
// Accumulate both parts of the ratio and divide only once, so that the
// cumulative split factor of consecutive splits stays exact
let denominator = new Big(1);
let numerator = new Big(1);
for (const split of splits) {
// Skip malformed splits to not break the portfolio calculation of every
// user holding this asset profile
if (split.denominator <= 0 || split.numerator <= 0) {
continue;
}
if (isBefore(activityDate, split.date)) {
denominator = denominator.mul(split.denominator);
numerator = numerator.mul(split.numerator);
}
}
if (numerator.eq(denominator)) {
return activity;
}
return {
...activity,
quantity: new Big(activity.quantity)
.mul(numerator)
.div(denominator)
.toNumber(),
unitPrice: new Big(activity.unitPrice)
.mul(denominator)
.div(numerator)
.toNumber(),
unitPriceInAssetProfileCurrency: new Big(
activity.unitPriceInAssetProfileCurrency
)
.mul(denominator)
.div(numerator)
.toNumber()
};
}

161
apps/api/src/services/asset-profile-split/asset-profile-split.service.spec.ts

@ -0,0 +1,161 @@
import { PrismaService } from '@ghostfolio/api/services/prisma/prisma.service';
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('getSplitsByUserId', () => {
it('fetches the splits of the asset profiles held by the user with one ordered query', async () => {
const splits = [
createStoredSplit('2020-08-31'),
createStoredSplit('2021-09-16')
];
findMany.mockResolvedValue(splits);
const result = await assetProfileSplitService.getSplitsByUserId({
userId: 'user-id'
});
expect(result).toBe(splits);
expect(findMany).toHaveBeenCalledTimes(1);
expect(findMany).toHaveBeenCalledWith({
orderBy: [{ date: 'asc' }],
where: {
symbolProfile: {
activities: {
some: {
userId: 'user-id'
}
}
}
}
});
});
});
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 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
};
}

27
apps/api/src/services/asset-profile-split/asset-profile-split.service.ts

@ -52,6 +52,33 @@ export class AssetProfileSplitService {
});
}
/**
* Returns the splits of all asset profiles the given user has activities
* for, in ascending order by date
*/
public async getSplitsByUserId({
userId
}: {
userId: string;
}): Promise<AssetProfileSplit[]> {
return this.prismaService.assetProfileSplit.findMany({
orderBy: [
{
date: 'asc'
}
],
where: {
symbolProfile: {
activities: {
some: {
userId
}
}
}
}
});
}
public async upsert({
date,
denominator,

8
apps/api/src/services/queues/data-gathering/data-gathering.service.ts

@ -29,7 +29,7 @@ import {
import { InjectQueue } from '@nestjs/bull';
import { Inject, Injectable, Logger } from '@nestjs/common';
import { Prisma } from '@prisma/client';
import { JobOptions, Queue } from 'bull';
import { Job, JobOptions, Queue } from 'bull';
import { format, min, subDays, subMilliseconds, subYears } from 'date-fns';
import { isEmpty } from 'lodash';
import ms, { StringValue } from 'ms';
@ -285,7 +285,7 @@ export class DataGatheringService {
date: date ?? item.date
}));
await this.gatherSymbols({
return this.gatherSymbols({
dataGatheringItems,
force: true,
priority: DATA_GATHERING_QUEUE_PRIORITY_HIGH
@ -336,8 +336,8 @@ export class DataGatheringService {
dataGatheringItems: DataGatheringItem[];
force?: boolean;
priority: number;
}) {
await this.addJobsToQueue(
}): Promise<Job[]> {
return this.addJobsToQueue(
dataGatheringItems.map(({ dataSource, date, symbol }) => {
return {
data: {

4
apps/client/src/app/components/admin-market-data/asset-profile-dialog/asset-profile-dialog.html

@ -511,9 +511,7 @@
</div>
</div>
</mat-tab>
<!-- Splits are not applied to the portfolio calculation yet, hence the
tab to manage them is disabled -->
@if (false && user?.settings?.isExperimentalFeatures) {
@if (user?.settings?.isExperimentalFeatures) {
<mat-tab>
<ng-template mat-tab-label>
<ion-icon name="git-compare-outline" />

5
libs/common/src/lib/permissions.ts

@ -138,7 +138,9 @@ export function getPermissions(aRole: Role): string[] {
permissions.createAccount,
permissions.createAccountBalance,
permissions.createActivity,
permissions.createAssetProfileSplitOfOwnAssetProfile,
// TODO: Grant createAssetProfileSplitOfOwnAssetProfile and
// deleteAssetProfileSplitOfOwnAssetProfile once the stock splits
// feature is no longer experimental
permissions.createMarketDataOfOwnAssetProfile,
permissions.createOwnTag,
permissions.createWatchlistItem,
@ -146,7 +148,6 @@ export function getPermissions(aRole: Role): string[] {
permissions.deleteAccount,
permissions.deleteAccountBalance,
permissions.deleteActivity,
permissions.deleteAssetProfileSplitOfOwnAssetProfile,
permissions.deleteAuthDevice,
permissions.deleteWatchlistItem,
permissions.readAiPrompt,

2
prisma/migrations/20260815120000_added_symbol_profile_id_index_to_order/migration.sql

@ -0,0 +1,2 @@
-- CreateIndex
CREATE INDEX "Order_symbolProfileId_idx" ON "Order"("symbolProfileId");

1
prisma/schema.prisma

@ -195,6 +195,7 @@ model Order {
@@index([accountId])
@@index([date])
@@index([symbolProfileId])
@@index([type])
@@index([userId])
}

Loading…
Cancel
Save