diff --git a/CHANGELOG.md b/CHANGELOG.md index 6ac5b9d22..909420ce6 100644 --- a/CHANGELOG.md +++ b/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 diff --git a/apps/api/src/app/activities/activities.module.ts b/apps/api/src/app/activities/activities.module.ts index 34091ba5e..1508917f0 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 000000000..4e935efb3 --- /dev/null +++ b/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 + }; +} diff --git a/apps/api/src/app/activities/activities.service.ts b/apps/api/src/app/activities/activities.service.ts index 00ed2b407..c655e26a4 100644 --- a/apps/api/src/app/activities/activities.service.ts +++ b/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 { + const activitiesByUser = await this.prismaService.order.groupBy({ + by: ['userId'], + where: { + symbolProfileId + } + }); + + return activitiesByUser.map(({ userId }) => { + return userId; + }); + } + public async order( orderWhereUniqueInput: Prisma.OrderWhereUniqueInput ): Promise { diff --git a/apps/api/src/app/endpoints/asset-profiles/asset-profiles.controller.ts b/apps/api/src/app/endpoints/asset-profiles/asset-profiles.controller.ts index 356544acb..e1452c548 100644 --- a/apps/api/src/app/endpoints/asset-profiles/asset-profiles.controller.ts +++ b/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) 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 000000000..909c96e8b --- /dev/null +++ b/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((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); + }); +} diff --git a/apps/api/src/app/endpoints/asset-profiles/asset-profiles.service.ts b/apps/api/src/app/endpoints/asset-profiles/asset-profiles.service.ts index 0d50e2223..e34feb649 100644 --- a/apps/api/src/app/endpoints/asset-profiles/asset-profiles.service.ts +++ b/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, 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 3b09bfd26..e50b132d2 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'; @@ -124,6 +125,9 @@ describe('PortfolioCalculator', () => { activitiesService = new ActivitiesService( accountBalanceService, accountService, + { + getSplitsByUserId: jest.fn().mockResolvedValue([]) + } as unknown as AssetProfileSplitService, null, null, dataProviderService, 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 000000000..c71a3bda1 --- /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 { + denominator, + numerator, + createdAt: splitDate, + date: splitDate, + id: `${date}-${numerator}-${denominator}`, + 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 9b6bacd44..29b6f22b5 100644 --- a/apps/api/src/app/portfolio/portfolio.service.spec.ts +++ b/apps/api/src/app/portfolio/portfolio.service.spec.ts @@ -66,6 +66,7 @@ describe('PortfolioService', () => { accountService, null, null, + null, dataProviderService, null, exchangeRateDataService, 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 000000000..6d674255d --- /dev/null +++ b/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 + }; +} 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 000000000..dda77a5aa --- /dev/null +++ b/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() + }; +} 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 000000000..be5d7caa3 --- /dev/null +++ b/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 + }; +} 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 ec842caaf..31dd7935c 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 @@ -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 { + return this.prismaService.assetProfileSplit.findMany({ + orderBy: [ + { + date: 'asc' + } + ], + where: { + symbolProfile: { + activities: { + some: { + userId + } + } + } + } + }); + } + public async upsert({ date, denominator, diff --git a/apps/api/src/services/queues/data-gathering/data-gathering.service.ts b/apps/api/src/services/queues/data-gathering/data-gathering.service.ts index d418c4f3f..17906a333 100644 --- a/apps/api/src/services/queues/data-gathering/data-gathering.service.ts +++ b/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 { + return this.addJobsToQueue( dataGatheringItems.map(({ dataSource, date, symbol }) => { return { data: { diff --git a/apps/client/src/app/components/admin-market-data/asset-profile-dialog/asset-profile-dialog.html b/apps/client/src/app/components/admin-market-data/asset-profile-dialog/asset-profile-dialog.html index bcda048f8..b59b7e39f 100644 --- a/apps/client/src/app/components/admin-market-data/asset-profile-dialog/asset-profile-dialog.html +++ b/apps/client/src/app/components/admin-market-data/asset-profile-dialog/asset-profile-dialog.html @@ -511,9 +511,7 @@ - - @if (false && user?.settings?.isExperimentalFeatures) { + @if (user?.settings?.isExperimentalFeatures) { diff --git a/libs/common/src/lib/permissions.ts b/libs/common/src/lib/permissions.ts index 96533a9e7..6dab85dad 100644 --- a/libs/common/src/lib/permissions.ts +++ b/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, diff --git a/prisma/migrations/20260815120000_added_symbol_profile_id_index_to_order/migration.sql b/prisma/migrations/20260815120000_added_symbol_profile_id_index_to_order/migration.sql new file mode 100644 index 000000000..9f2aaf06d --- /dev/null +++ b/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"); diff --git a/prisma/schema.prisma b/prisma/schema.prisma index 14cbb72fe..edfe783da 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -195,6 +195,7 @@ model Order { @@index([accountId]) @@index([date]) + @@index([symbolProfileId]) @@index([type]) @@index([userId]) }