From a79a114da13da7144318ccf9e554058d1c0865c8 Mon Sep 17 00:00:00 2001 From: David Requeno Date: Mon, 10 Aug 2026 21:57:52 -0600 Subject: [PATCH] Optimize stock split lookup by symbol profile ID --- .../app/activities/activities.service.spec.ts | 38 +++++++-------- .../src/app/activities/activities.service.ts | 29 ++++-------- .../roai/portfolio-calculator-cash.spec.ts | 2 +- .../asset-profile-split.service.spec.ts | 46 ++++++------------- .../asset-profile-split.service.ts | 33 ++++--------- 5 files changed, 52 insertions(+), 96 deletions(-) diff --git a/apps/api/src/app/activities/activities.service.spec.ts b/apps/api/src/app/activities/activities.service.spec.ts index a8268019d..16a774da4 100644 --- a/apps/api/src/app/activities/activities.service.spec.ts +++ b/apps/api/src/app/activities/activities.service.spec.ts @@ -14,11 +14,11 @@ import { ActivitiesService } from './activities.service'; describe('ActivitiesService', () => { let activitiesService: ActivitiesService; - let getSplitsByAssetProfiles: jest.Mock; + let getSplitsBySymbolProfileIds: jest.Mock; let accountService: { getCashDetails: jest.Mock }; beforeEach(() => { - getSplitsByAssetProfiles = jest.fn(); + getSplitsBySymbolProfileIds = jest.fn(); accountService = { getCashDetails: jest.fn() }; activitiesService = new ActivitiesService( @@ -33,7 +33,7 @@ describe('ActivitiesService', () => { null, null, null, - { getSplitsByAssetProfiles } as unknown as AssetProfileSplitService + { getSplitsBySymbolProfileIds } as unknown as AssetProfileSplitService ); }); @@ -133,10 +133,10 @@ describe('ActivitiesService', () => { activities: [activity], count: 1 }); - getSplitsByAssetProfiles.mockResolvedValue( + getSplitsBySymbolProfileIds.mockResolvedValue( new Map([ - ['YAHOO-MSFT', [split]], - ['MANUAL-AAPL', [split]] + ['YAHOO-MSFT-profile', [split]], + ['MANUAL-AAPL-profile', [split]] ]) ); @@ -175,8 +175,8 @@ describe('ActivitiesService', () => { activities: [activity], count: 1 }); - getSplitsByAssetProfiles.mockResolvedValue( - new Map([['YAHOO-AAPL', [split]]]) + getSplitsBySymbolProfileIds.mockResolvedValue( + new Map([['YAHOO-AAPL-profile', [split]]]) ); const result = @@ -192,8 +192,8 @@ describe('ActivitiesService', () => { userId: 'user-id', withExcludedAccountsAndActivities: false }); - expect(getSplitsByAssetProfiles).toHaveBeenCalledWith([ - { dataSource: DataSource.YAHOO, symbol: 'AAPL' } + expect(getSplitsBySymbolProfileIds).toHaveBeenCalledWith([ + 'YAHOO-AAPL-profile' ]); expect(result.activities[0]).toMatchObject({ quantity: 20, @@ -223,8 +223,8 @@ describe('ActivitiesService', () => { count: 1 }); accountService.getCashDetails.mockResolvedValue({ accounts: [] }); - getSplitsByAssetProfiles.mockResolvedValue( - new Map([['YAHOO-AAPL', [split]]]) + getSplitsBySymbolProfileIds.mockResolvedValue( + new Map([['YAHOO-AAPL-profile', [split]]]) ); const result = @@ -234,8 +234,8 @@ describe('ActivitiesService', () => { withCash: true }); - expect(getSplitsByAssetProfiles).toHaveBeenCalledWith([ - { dataSource: DataSource.YAHOO, symbol: 'AAPL' } + expect(getSplitsBySymbolProfileIds).toHaveBeenCalledWith([ + 'YAHOO-AAPL-profile' ]); expect(result.activities).toEqual([ expect.objectContaining({ @@ -254,13 +254,8 @@ describe('ActivitiesService', () => { activities: [activity], count: 1 }); - getSplitsByAssetProfiles.mockResolvedValue( - new Map([ - [ - `${activity.assetProfile.dataSource}-${activity.assetProfile.symbol}`, - splits - ] - ]) + getSplitsBySymbolProfileIds.mockResolvedValue( + new Map([[activity.assetProfile.id, splits]]) ); const result = @@ -298,6 +293,7 @@ function createActivity({ assetSubClass, currency, dataSource, + id: `${dataSource}-${symbol}-profile`, symbol }, date: parseDate(date), diff --git a/apps/api/src/app/activities/activities.service.ts b/apps/api/src/app/activities/activities.service.ts index 4ee96e7ea..f17a2994d 100644 --- a/apps/api/src/app/activities/activities.service.ts +++ b/apps/api/src/app/activities/activities.service.ts @@ -60,7 +60,7 @@ import { } from '@prisma/client'; import { Big } from 'big.js'; import { endOfToday } from 'date-fns'; -import { groupBy, uniqBy } from 'lodash'; +import { groupBy, uniq, uniqBy } from 'lodash'; import { randomUUID } from 'node:crypto'; @Injectable() @@ -932,36 +932,27 @@ export class ActivitiesService { withExcludedAccountsAndActivities: false // TODO }); - const assetProfiles = uniqBy( + const symbolProfileIds = uniq( activities.activities.map(({ assetProfile }) => { - return { - dataSource: assetProfile.dataSource, - symbol: assetProfile.symbol - }; - }), - ({ dataSource, symbol }) => { - return getAssetProfileIdentifier({ dataSource, symbol }); - } + return assetProfile.id; + }) ); - const splitsByAssetProfile = - await this.assetProfileSplitService.getSplitsByAssetProfiles( - assetProfiles + const splitsBySymbolProfileId = + await this.assetProfileSplitService.getSplitsBySymbolProfileIds( + symbolProfileIds ); - const hasSplits = [...splitsByAssetProfile.values()].some((splits) => { + const hasSplits = [...splitsBySymbolProfileId.values()].some((splits) => { return splits.length > 0; }); if (hasSplits) { activities.activities = activities.activities.map((activity) => { - const key = getAssetProfileIdentifier({ - dataSource: activity.assetProfile.dataSource, - symbol: activity.assetProfile.symbol - }); + const key = activity.assetProfile.id; return adjustActivityBySplits( activity, - splitsByAssetProfile.get(key) ?? [] + splitsBySymbolProfileId.get(key) ?? [] ); }); } 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 74de01736..082857307 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 @@ -135,7 +135,7 @@ describe('PortfolioCalculator', () => { null, null, { - getSplitsByAssetProfiles: jest.fn().mockResolvedValue(new Map()) + getSplitsBySymbolProfileIds: jest.fn().mockResolvedValue(new Map()) } as unknown as AssetProfileSplitService ); 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 index 81e911898..dd520fa18 100644 --- 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 @@ -1,6 +1,4 @@ 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'; @@ -58,50 +56,36 @@ describe('AssetProfileSplitService', () => { }); }); - describe('getSplitsByAssetProfiles', () => { + describe('getSplitsBySymbolProfileIds', () => { 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 symbolProfileIds = ['aapl-profile', 'msft-profile']; const aaplSplit = createSplit({ date: new Date('2020-08-31'), - symbolProfile: assetProfiles[0] + symbolProfileId: symbolProfileIds[0] }); const msftSplit = createSplit({ date: new Date('2021-09-16'), - symbolProfile: assetProfiles[1] + symbolProfileId: symbolProfileIds[1] }); - findMany.mockResolvedValue([ - { ...aaplSplit, symbolProfile: assetProfiles[0] }, - { ...msftSplit, symbolProfile: assetProfiles[1] } - ]); + findMany.mockResolvedValue([aaplSplit, msftSplit]); const splits = - await assetProfileSplitService.getSplitsByAssetProfiles(assetProfiles); + await assetProfileSplitService.getSplitsBySymbolProfileIds( + symbolProfileIds + ); expect(findMany).toHaveBeenCalledTimes(1); expect(findMany).toHaveBeenCalledWith({ - include: { - symbolProfile: { - select: { - dataSource: true, - symbol: true - } - } - }, orderBy: [{ date: 'asc' }], where: { - symbolProfile: { - OR: assetProfiles - } + symbolProfileId: { in: symbolProfileIds } } }); expect(splits).toEqual( new Map([ - [getAssetProfileIdentifier(assetProfiles[0]), [aaplSplit]], - [getAssetProfileIdentifier(assetProfiles[1]), [msftSplit]] + [symbolProfileIds[0], [aaplSplit]], + [symbolProfileIds[1], [msftSplit]] ]) ); }); @@ -169,18 +153,18 @@ describe('AssetProfileSplitService', () => { function createSplit({ date, - symbolProfile + symbolProfileId }: { date: Date; - symbolProfile: AssetProfileIdentifier; + symbolProfileId: string; }): AssetProfileSplit { return { createdAt: date, date, denominator: 1, - id: `${symbolProfile.symbol}-split`, + id: `${symbolProfileId}-split`, numerator: 2, - symbolProfileId: `${symbolProfile.symbol}-profile`, + symbolProfileId, updatedAt: date }; } 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 99a350f55..c4012dc38 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,8 +1,5 @@ import { PrismaService } from '@ghostfolio/api/services/prisma/prisma.service'; -import { - getAssetProfileIdentifier, - resetHours -} from '@ghostfolio/common/helper'; +import { resetHours } from '@ghostfolio/common/helper'; import { AssetProfileIdentifier } from '@ghostfolio/common/interfaces'; import { Injectable } from '@nestjs/common'; @@ -59,40 +56,28 @@ export class AssetProfileSplitService { * Returns the splits of all given asset profiles, grouped by their * data-source/symbol identifier. */ - public async getSplitsByAssetProfiles( - assetProfiles: AssetProfileIdentifier[] + public async getSplitsBySymbolProfileIds( + symbolProfileIds: string[] ): Promise> { const splitsByAssetProfile = new Map(); - for (const assetProfile of assetProfiles) { - splitsByAssetProfile.set(getAssetProfileIdentifier(assetProfile), []); + for (const symbolProfileId of symbolProfileIds) { + splitsByAssetProfile.set(symbolProfileId, []); } - if (assetProfiles.length === 0) { + if (symbolProfileIds.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 - } + symbolProfileId: { in: symbolProfileIds } } }); - for (const { symbolProfile, ...split } of splits) { - splitsByAssetProfile - .get(getAssetProfileIdentifier(symbolProfile)) - ?.push(split); + for (const split of splits) { + splitsByAssetProfile.get(split.symbolProfileId)?.push(split); } return splitsByAssetProfile;