Browse Source

Optimize stock split lookup by symbol profile ID

pull/7568/head
David Requeno 3 weeks ago
parent
commit
a79a114da1
  1. 38
      apps/api/src/app/activities/activities.service.spec.ts
  2. 29
      apps/api/src/app/activities/activities.service.ts
  3. 2
      apps/api/src/app/portfolio/calculator/roai/portfolio-calculator-cash.spec.ts
  4. 46
      apps/api/src/services/asset-profile-split/asset-profile-split.service.spec.ts
  5. 33
      apps/api/src/services/asset-profile-split/asset-profile-split.service.ts

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

@ -14,11 +14,11 @@ import { ActivitiesService } from './activities.service';
describe('ActivitiesService', () => { describe('ActivitiesService', () => {
let activitiesService: ActivitiesService; let activitiesService: ActivitiesService;
let getSplitsByAssetProfiles: jest.Mock; let getSplitsBySymbolProfileIds: jest.Mock;
let accountService: { getCashDetails: jest.Mock }; let accountService: { getCashDetails: jest.Mock };
beforeEach(() => { beforeEach(() => {
getSplitsByAssetProfiles = jest.fn(); getSplitsBySymbolProfileIds = jest.fn();
accountService = { getCashDetails: jest.fn() }; accountService = { getCashDetails: jest.fn() };
activitiesService = new ActivitiesService( activitiesService = new ActivitiesService(
@ -33,7 +33,7 @@ describe('ActivitiesService', () => {
null, null,
null, null,
null, null,
{ getSplitsByAssetProfiles } as unknown as AssetProfileSplitService { getSplitsBySymbolProfileIds } as unknown as AssetProfileSplitService
); );
}); });
@ -133,10 +133,10 @@ describe('ActivitiesService', () => {
activities: [activity], activities: [activity],
count: 1 count: 1
}); });
getSplitsByAssetProfiles.mockResolvedValue( getSplitsBySymbolProfileIds.mockResolvedValue(
new Map([ new Map([
['YAHOO-MSFT', [split]], ['YAHOO-MSFT-profile', [split]],
['MANUAL-AAPL', [split]] ['MANUAL-AAPL-profile', [split]]
]) ])
); );
@ -175,8 +175,8 @@ describe('ActivitiesService', () => {
activities: [activity], activities: [activity],
count: 1 count: 1
}); });
getSplitsByAssetProfiles.mockResolvedValue( getSplitsBySymbolProfileIds.mockResolvedValue(
new Map([['YAHOO-AAPL', [split]]]) new Map([['YAHOO-AAPL-profile', [split]]])
); );
const result = const result =
@ -192,8 +192,8 @@ describe('ActivitiesService', () => {
userId: 'user-id', userId: 'user-id',
withExcludedAccountsAndActivities: false withExcludedAccountsAndActivities: false
}); });
expect(getSplitsByAssetProfiles).toHaveBeenCalledWith([ expect(getSplitsBySymbolProfileIds).toHaveBeenCalledWith([
{ dataSource: DataSource.YAHOO, symbol: 'AAPL' } 'YAHOO-AAPL-profile'
]); ]);
expect(result.activities[0]).toMatchObject({ expect(result.activities[0]).toMatchObject({
quantity: 20, quantity: 20,
@ -223,8 +223,8 @@ describe('ActivitiesService', () => {
count: 1 count: 1
}); });
accountService.getCashDetails.mockResolvedValue({ accounts: [] }); accountService.getCashDetails.mockResolvedValue({ accounts: [] });
getSplitsByAssetProfiles.mockResolvedValue( getSplitsBySymbolProfileIds.mockResolvedValue(
new Map([['YAHOO-AAPL', [split]]]) new Map([['YAHOO-AAPL-profile', [split]]])
); );
const result = const result =
@ -234,8 +234,8 @@ describe('ActivitiesService', () => {
withCash: true withCash: true
}); });
expect(getSplitsByAssetProfiles).toHaveBeenCalledWith([ expect(getSplitsBySymbolProfileIds).toHaveBeenCalledWith([
{ dataSource: DataSource.YAHOO, symbol: 'AAPL' } 'YAHOO-AAPL-profile'
]); ]);
expect(result.activities).toEqual([ expect(result.activities).toEqual([
expect.objectContaining({ expect.objectContaining({
@ -254,13 +254,8 @@ describe('ActivitiesService', () => {
activities: [activity], activities: [activity],
count: 1 count: 1
}); });
getSplitsByAssetProfiles.mockResolvedValue( getSplitsBySymbolProfileIds.mockResolvedValue(
new Map([ new Map([[activity.assetProfile.id, splits]])
[
`${activity.assetProfile.dataSource}-${activity.assetProfile.symbol}`,
splits
]
])
); );
const result = const result =
@ -298,6 +293,7 @@ function createActivity({
assetSubClass, assetSubClass,
currency, currency,
dataSource, dataSource,
id: `${dataSource}-${symbol}-profile`,
symbol symbol
}, },
date: parseDate(date), date: parseDate(date),

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

@ -60,7 +60,7 @@ import {
} from '@prisma/client'; } from '@prisma/client';
import { Big } from 'big.js'; import { Big } from 'big.js';
import { endOfToday } from 'date-fns'; import { endOfToday } from 'date-fns';
import { groupBy, uniqBy } from 'lodash'; import { groupBy, uniq, uniqBy } from 'lodash';
import { randomUUID } from 'node:crypto'; import { randomUUID } from 'node:crypto';
@Injectable() @Injectable()
@ -932,36 +932,27 @@ export class ActivitiesService {
withExcludedAccountsAndActivities: false // TODO withExcludedAccountsAndActivities: false // TODO
}); });
const assetProfiles = uniqBy( const symbolProfileIds = uniq(
activities.activities.map(({ assetProfile }) => { activities.activities.map(({ assetProfile }) => {
return { return assetProfile.id;
dataSource: assetProfile.dataSource, })
symbol: assetProfile.symbol
};
}),
({ dataSource, symbol }) => {
return getAssetProfileIdentifier({ dataSource, symbol });
}
); );
const splitsByAssetProfile = const splitsBySymbolProfileId =
await this.assetProfileSplitService.getSplitsByAssetProfiles( await this.assetProfileSplitService.getSplitsBySymbolProfileIds(
assetProfiles symbolProfileIds
); );
const hasSplits = [...splitsByAssetProfile.values()].some((splits) => { const hasSplits = [...splitsBySymbolProfileId.values()].some((splits) => {
return splits.length > 0; return splits.length > 0;
}); });
if (hasSplits) { if (hasSplits) {
activities.activities = activities.activities.map((activity) => { activities.activities = activities.activities.map((activity) => {
const key = getAssetProfileIdentifier({ const key = activity.assetProfile.id;
dataSource: activity.assetProfile.dataSource,
symbol: activity.assetProfile.symbol
});
return adjustActivityBySplits( return adjustActivityBySplits(
activity, activity,
splitsByAssetProfile.get(key) ?? [] splitsBySymbolProfileId.get(key) ?? []
); );
}); });
} }

2
apps/api/src/app/portfolio/calculator/roai/portfolio-calculator-cash.spec.ts

@ -135,7 +135,7 @@ describe('PortfolioCalculator', () => {
null, null,
null, null,
{ {
getSplitsByAssetProfiles: jest.fn().mockResolvedValue(new Map()) getSplitsBySymbolProfileIds: jest.fn().mockResolvedValue(new Map())
} as unknown as AssetProfileSplitService } as unknown as AssetProfileSplitService
); );

46
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 { PrismaService } from '@ghostfolio/api/services/prisma/prisma.service';
import { getAssetProfileIdentifier } from '@ghostfolio/common/helper';
import { AssetProfileIdentifier } from '@ghostfolio/common/interfaces';
import { AssetProfileSplit, DataSource } from '@prisma/client'; import { 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 () => { it('fetches and groups all splits with one ordered query', async () => {
const assetProfiles: AssetProfileIdentifier[] = [ const symbolProfileIds = ['aapl-profile', 'msft-profile'];
{ dataSource: DataSource.YAHOO, symbol: 'AAPL' },
{ dataSource: DataSource.YAHOO, symbol: 'MSFT' }
];
const aaplSplit = createSplit({ const aaplSplit = createSplit({
date: new Date('2020-08-31'), date: new Date('2020-08-31'),
symbolProfile: assetProfiles[0] symbolProfileId: symbolProfileIds[0]
}); });
const msftSplit = createSplit({ const msftSplit = createSplit({
date: new Date('2021-09-16'), date: new Date('2021-09-16'),
symbolProfile: assetProfiles[1] symbolProfileId: symbolProfileIds[1]
}); });
findMany.mockResolvedValue([ findMany.mockResolvedValue([aaplSplit, msftSplit]);
{ ...aaplSplit, symbolProfile: assetProfiles[0] },
{ ...msftSplit, symbolProfile: assetProfiles[1] }
]);
const splits = const splits =
await assetProfileSplitService.getSplitsByAssetProfiles(assetProfiles); await assetProfileSplitService.getSplitsBySymbolProfileIds(
symbolProfileIds
);
expect(findMany).toHaveBeenCalledTimes(1); expect(findMany).toHaveBeenCalledTimes(1);
expect(findMany).toHaveBeenCalledWith({ expect(findMany).toHaveBeenCalledWith({
include: {
symbolProfile: {
select: {
dataSource: true,
symbol: true
}
}
},
orderBy: [{ date: 'asc' }], orderBy: [{ date: 'asc' }],
where: { where: {
symbolProfile: { symbolProfileId: { in: symbolProfileIds }
OR: assetProfiles
}
} }
}); });
expect(splits).toEqual( expect(splits).toEqual(
new Map([ new Map([
[getAssetProfileIdentifier(assetProfiles[0]), [aaplSplit]], [symbolProfileIds[0], [aaplSplit]],
[getAssetProfileIdentifier(assetProfiles[1]), [msftSplit]] [symbolProfileIds[1], [msftSplit]]
]) ])
); );
}); });
@ -169,18 +153,18 @@ describe('AssetProfileSplitService', () => {
function createSplit({ function createSplit({
date, date,
symbolProfile symbolProfileId
}: { }: {
date: Date; date: Date;
symbolProfile: AssetProfileIdentifier; symbolProfileId: string;
}): AssetProfileSplit { }): AssetProfileSplit {
return { return {
createdAt: date, createdAt: date,
date, date,
denominator: 1, denominator: 1,
id: `${symbolProfile.symbol}-split`, id: `${symbolProfileId}-split`,
numerator: 2, numerator: 2,
symbolProfileId: `${symbolProfile.symbol}-profile`, symbolProfileId,
updatedAt: date updatedAt: date
}; };
} }

33
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 { PrismaService } from '@ghostfolio/api/services/prisma/prisma.service';
import { import { resetHours } from '@ghostfolio/common/helper';
getAssetProfileIdentifier,
resetHours
} from '@ghostfolio/common/helper';
import { AssetProfileIdentifier } from '@ghostfolio/common/interfaces'; import { AssetProfileIdentifier } from '@ghostfolio/common/interfaces';
import { Injectable } from '@nestjs/common'; import { Injectable } from '@nestjs/common';
@ -59,40 +56,28 @@ export class AssetProfileSplitService {
* Returns the splits of all given asset profiles, grouped by their * Returns the splits of all given asset profiles, grouped by their
* data-source/symbol identifier. * data-source/symbol identifier.
*/ */
public async getSplitsByAssetProfiles( public async getSplitsBySymbolProfileIds(
assetProfiles: AssetProfileIdentifier[] symbolProfileIds: string[]
): Promise<Map<string, AssetProfileSplit[]>> { ): Promise<Map<string, AssetProfileSplit[]>> {
const splitsByAssetProfile = new Map<string, AssetProfileSplit[]>(); const splitsByAssetProfile = new Map<string, AssetProfileSplit[]>();
for (const assetProfile of assetProfiles) { for (const symbolProfileId of symbolProfileIds) {
splitsByAssetProfile.set(getAssetProfileIdentifier(assetProfile), []); splitsByAssetProfile.set(symbolProfileId, []);
} }
if (assetProfiles.length === 0) { if (symbolProfileIds.length === 0) {
return splitsByAssetProfile; return splitsByAssetProfile;
} }
const splits = await this.prismaService.assetProfileSplit.findMany({ const splits = await this.prismaService.assetProfileSplit.findMany({
include: {
symbolProfile: {
select: {
dataSource: true,
symbol: true
}
}
},
orderBy: [{ date: 'asc' }], orderBy: [{ date: 'asc' }],
where: { where: {
symbolProfile: { symbolProfileId: { in: symbolProfileIds }
OR: assetProfiles
}
} }
}); });
for (const { symbolProfile, ...split } of splits) { for (const split of splits) {
splitsByAssetProfile splitsByAssetProfile.get(split.symbolProfileId)?.push(split);
.get(getAssetProfileIdentifier(symbolProfile))
?.push(split);
} }
return splitsByAssetProfile; return splitsByAssetProfile;

Loading…
Cancel
Save