Browse Source

Add business logic and tests for stock splits

pull/7568/head
Thomas Kaul 2 weeks ago
parent
commit
0ee87c73ec
  1. 40
      apps/api/src/app/activities/activities.service.spec.ts
  2. 52
      apps/api/src/app/activities/activities.service.ts
  3. 86
      apps/api/src/app/endpoints/asset-profiles/asset-profiles.service.spec.ts
  4. 68
      apps/api/src/app/endpoints/asset-profiles/asset-profiles.service.ts
  5. 118
      apps/api/src/app/portfolio/calculator/portfolio-calculator.factory.spec.ts
  6. 2
      apps/api/src/app/portfolio/calculator/roai/portfolio-calculator-cash.spec.ts
  7. 30
      apps/api/src/services/asset-profile-split/asset-profile-split.helper.ts
  8. 59
      apps/api/src/services/asset-profile-split/asset-profile-split.service.spec.ts
  9. 42
      apps/api/src/services/asset-profile-split/asset-profile-split.service.ts
  10. 8
      apps/api/src/services/queues/data-gathering/data-gathering.service.ts
  11. 5
      libs/common/src/lib/permissions.ts

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

@ -18,17 +18,17 @@ import { ActivitiesService } from './activities.service';
describe('ActivitiesService', () => { describe('ActivitiesService', () => {
let activitiesService: ActivitiesService; let activitiesService: ActivitiesService;
let getSplitsBySymbolProfileIds: jest.Mock; let getSplitsByUserId: jest.Mock;
let accountService: { getCashDetails: jest.Mock }; let accountService: { getCashDetails: jest.Mock };
beforeEach(() => { beforeEach(() => {
getSplitsBySymbolProfileIds = jest.fn(); getSplitsByUserId = jest.fn().mockResolvedValue([]);
accountService = { getCashDetails: jest.fn() }; accountService = { getCashDetails: jest.fn() };
activitiesService = new ActivitiesService( activitiesService = new ActivitiesService(
null, null,
accountService as unknown as AccountService, accountService as unknown as AccountService,
{ getSplitsBySymbolProfileIds } as unknown as AssetProfileSplitService, { getSplitsByUserId } as unknown as AssetProfileSplitService,
null, null,
null, null,
null, null,
@ -137,12 +137,10 @@ describe('ActivitiesService', () => {
activities: [activity], activities: [activity],
count: 1 count: 1
}); });
getSplitsBySymbolProfileIds.mockResolvedValue( getSplitsByUserId.mockResolvedValue([
new Map([ { ...split, symbolProfileId: 'YAHOO-MSFT-profile' },
['YAHOO-MSFT-profile', [split]], { ...split, symbolProfileId: 'MANUAL-AAPL-profile' }
['MANUAL-AAPL-profile', [split]] ]);
])
);
const result = const result =
await activitiesService.getActivitiesForPortfolioCalculator({ await activitiesService.getActivitiesForPortfolioCalculator({
@ -195,9 +193,7 @@ describe('ActivitiesService', () => {
activities: [activity], activities: [activity],
count: 1 count: 1
}); });
getSplitsBySymbolProfileIds.mockResolvedValue( getSplitsByUserId.mockResolvedValue([split]);
new Map([['YAHOO-AAPL-profile', [split]]])
);
const result = const result =
await activitiesService.getActivitiesForPortfolioCalculator({ await activitiesService.getActivitiesForPortfolioCalculator({
@ -212,9 +208,7 @@ describe('ActivitiesService', () => {
userId: 'user-id', userId: 'user-id',
withExcludedAccountsAndActivities: false withExcludedAccountsAndActivities: false
}); });
expect(getSplitsBySymbolProfileIds).toHaveBeenCalledWith([ expect(getSplitsByUserId).toHaveBeenCalledWith({ userId: 'user-id' });
'YAHOO-AAPL-profile'
]);
expect(result.activities[0]).toMatchObject({ expect(result.activities[0]).toMatchObject({
quantity: 20, quantity: 20,
unitPrice: 50, unitPrice: 50,
@ -243,9 +237,7 @@ describe('ActivitiesService', () => {
count: 1 count: 1
}); });
accountService.getCashDetails.mockResolvedValue({ accounts: [] }); accountService.getCashDetails.mockResolvedValue({ accounts: [] });
getSplitsBySymbolProfileIds.mockResolvedValue( getSplitsByUserId.mockResolvedValue([split]);
new Map([['YAHOO-AAPL-profile', [split]]])
);
const result = const result =
await activitiesService.getActivitiesForPortfolioCalculator({ await activitiesService.getActivitiesForPortfolioCalculator({
@ -254,9 +246,7 @@ describe('ActivitiesService', () => {
withCash: true withCash: true
}); });
expect(getSplitsBySymbolProfileIds).toHaveBeenCalledWith([ expect(getSplitsByUserId).toHaveBeenCalledWith({ userId: 'user-id' });
'YAHOO-AAPL-profile'
]);
expect(result.activities).toEqual([ expect(result.activities).toEqual([
expect.objectContaining({ expect.objectContaining({
assetProfile: expect.objectContaining({ symbol: 'AAPL' }), assetProfile: expect.objectContaining({ symbol: 'AAPL' }),
@ -274,8 +264,10 @@ describe('ActivitiesService', () => {
activities: [activity], activities: [activity],
count: 1 count: 1
}); });
getSplitsBySymbolProfileIds.mockResolvedValue( getSplitsByUserId.mockResolvedValue(
new Map([[activity.assetProfile.id, splits]]) splits.map((split) => {
return { ...split, symbolProfileId: activity.assetProfile.id };
})
); );
const result = const result =
@ -339,7 +331,7 @@ function createSplit(
denominator, denominator,
id: `${dateString}-${numerator}-${denominator}`, id: `${dateString}-${numerator}-${denominator}`,
numerator, numerator,
symbolProfileId: 'aapl-profile', symbolProfileId: 'YAHOO-AAPL-profile',
updatedAt: date updatedAt: date
}; };
} }

52
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, uniq, uniqBy } from 'lodash'; import { groupBy, uniqBy } from 'lodash';
import { randomUUID } from 'node:crypto'; import { randomUUID } from 'node:crypto';
@Injectable() @Injectable()
@ -918,34 +918,23 @@ export class ActivitiesService {
/** Whether to include cash activities in the result. */ /** Whether to include cash activities in the result. */
withCash?: boolean; withCash?: boolean;
}) { }) {
const activities = await this.getActivities({ const [activities, splits] = await Promise.all([
this.getActivities({
filters, filters,
userCurrency, userCurrency,
userId, userId,
withExcludedAccountsAndActivities: false // TODO withExcludedAccountsAndActivities: false // TODO
}); }),
this.assetProfileSplitService.getSplitsByUserId({ userId })
const symbolProfileIds = uniq( ]);
activities.activities.map(({ assetProfile }) => {
return assetProfile.id;
})
);
const splitsBySymbolProfileId =
await this.assetProfileSplitService.getSplitsBySymbolProfileIds(
symbolProfileIds
);
const hasSplits = [...splitsBySymbolProfileId.values()].some((splits) => { if (splits.length > 0) {
return splits.length > 0; const splitsBySymbolProfileId = groupBy(splits, 'symbolProfileId');
});
if (hasSplits) {
activities.activities = activities.activities.map((activity) => { activities.activities = activities.activities.map((activity) => {
const key = activity.assetProfile.id;
return adjustActivityBySplits( return adjustActivityBySplits(
activity, activity,
splitsBySymbolProfileId.get(key) ?? [] splitsBySymbolProfileId[activity.assetProfile.id] ?? []
); );
}); });
} }
@ -991,6 +980,29 @@ 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 getUserIdsByAssetProfile({
dataSource,
symbol
}: AssetProfileIdentifier): Promise<string[]> {
const activitiesByUser = await this.prismaService.order.groupBy({
by: ['userId'],
where: {
SymbolProfile: {
dataSource,
symbol
}
}
});
return activitiesByUser.map(({ userId }) => {
return userId;
});
}
public async order( public async order(
orderWhereUniqueInput: Prisma.OrderWhereUniqueInput orderWhereUniqueInput: Prisma.OrderWhereUniqueInput
): Promise<Order | null> { ): Promise<Order | null> {

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

@ -1,3 +1,4 @@
import { ActivitiesService } from '@ghostfolio/api/app/activities/activities.service';
import { PortfolioChangedEvent } from '@ghostfolio/api/events/portfolio-changed.event'; import { PortfolioChangedEvent } from '@ghostfolio/api/events/portfolio-changed.event';
import { AssetProfileSplitService } from '@ghostfolio/api/services/asset-profile-split/asset-profile-split.service'; 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 { DataGatheringService } from '@ghostfolio/api/services/queues/data-gathering/data-gathering.service';
@ -12,19 +13,21 @@ describe('AssetProfilesService', () => {
let assetProfilesService: AssetProfilesService; let assetProfilesService: AssetProfilesService;
let deleteById: jest.Mock; let deleteById: jest.Mock;
let emit: jest.Mock; let emit: jest.Mock;
let findMany: jest.Mock; let finished: jest.Mock;
let gatherSymbol: jest.Mock; let gatherSymbol: jest.Mock;
let getUserIdsByAssetProfile: jest.Mock;
let upsert: jest.Mock; let upsert: jest.Mock;
beforeEach(() => { beforeEach(() => {
deleteById = jest.fn(); deleteById = jest.fn();
emit = jest.fn(); emit = jest.fn();
findMany = jest.fn().mockResolvedValue([]); finished = jest.fn().mockResolvedValue(undefined);
gatherSymbol = jest.fn(); gatherSymbol = jest.fn().mockResolvedValue([{ finished }]);
getUserIdsByAssetProfile = jest.fn().mockResolvedValue([]);
upsert = jest.fn(); upsert = jest.fn();
assetProfilesService = new AssetProfilesService( assetProfilesService = new AssetProfilesService(
null, { getUserIdsByAssetProfile } as unknown as ActivitiesService,
{ {
deleteById, deleteById,
upsert upsert
@ -32,10 +35,10 @@ describe('AssetProfilesService', () => {
null, null,
{ gatherSymbol } as unknown as DataGatheringService, { gatherSymbol } as unknown as DataGatheringService,
null, null,
null,
{ emit } as unknown as EventEmitter2, { emit } as unknown as EventEmitter2,
null, null,
{ order: { findMany } } as never, null,
null,
null null
); );
}); });
@ -69,25 +72,8 @@ describe('AssetProfilesService', () => {
}); });
it('invalidates portfolio snapshots for users holding the asset', async () => { it('invalidates portfolio snapshots for users holding the asset', async () => {
const split = {} as AssetProfileSplit; upsert.mockResolvedValue({} as AssetProfileSplit);
upsert.mockResolvedValue(split); getUserIdsByAssetProfile.mockResolvedValue(['user-1', 'user-2']);
findMany.mockResolvedValue([{ userId: 'user-1' }, { userId: 'user-2' }]);
assetProfilesService = new AssetProfilesService(
null,
{
deleteById,
upsert
} as unknown as AssetProfileSplitService,
null,
{ gatherSymbol } as unknown as DataGatheringService,
null,
null,
{ emit } as unknown as EventEmitter2,
null,
{ order: { findMany } } as never,
null
);
await assetProfilesService.createSplit({ await assetProfilesService.createSplit({
dataSource: DataSource.YAHOO, dataSource: DataSource.YAHOO,
@ -97,11 +83,11 @@ describe('AssetProfilesService', () => {
symbol: 'AAPL', symbol: 'AAPL',
symbolProfileId: 'profile-id' symbolProfileId: 'profile-id'
}); });
await flushPendingPromises();
expect(findMany).toHaveBeenCalledWith({ expect(getUserIdsByAssetProfile).toHaveBeenCalledWith({
distinct: ['userId'], dataSource: DataSource.YAHOO,
select: { userId: true }, symbol: 'AAPL'
where: { symbolProfileId: 'profile-id' }
}); });
expect(emit.mock.calls.map(([, event]) => event.getUserId())).toEqual([ expect(emit.mock.calls.map(([, event]) => event.getUserId())).toEqual([
'user-1', 'user-1',
@ -109,6 +95,37 @@ describe('AssetProfilesService', () => {
]); ]);
expect(emit.mock.calls[0][0]).toBe(PortfolioChangedEvent.getName()); 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);
getUserIdsByAssetProfile.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', () => { describe('deleteSplit', () => {
@ -123,13 +140,15 @@ describe('AssetProfilesService', () => {
symbolProfileId: 'profile-id' symbolProfileId: 'profile-id'
}) })
).rejects.toBeInstanceOf(NotFoundException); ).rejects.toBeInstanceOf(NotFoundException);
await flushPendingPromises();
expect(gatherSymbol).not.toHaveBeenCalled();
expect(emit).not.toHaveBeenCalled(); expect(emit).not.toHaveBeenCalled();
}); });
it('deletes an existing split using its profile scope', async () => { it('deletes an existing split using its profile scope', async () => {
deleteById.mockResolvedValue(true); deleteById.mockResolvedValue(true);
findMany.mockResolvedValue([{ userId: 'user-1' }]); getUserIdsByAssetProfile.mockResolvedValue(['user-1']);
await expect( await expect(
assetProfilesService.deleteSplit({ assetProfilesService.deleteSplit({
@ -139,6 +158,7 @@ describe('AssetProfilesService', () => {
symbolProfileId: 'profile-id' symbolProfileId: 'profile-id'
}) })
).resolves.toBeUndefined(); ).resolves.toBeUndefined();
await flushPendingPromises();
expect(deleteById).toHaveBeenCalledWith({ expect(deleteById).toHaveBeenCalledWith({
id: 'split-id', id: 'split-id',
@ -154,3 +174,9 @@ describe('AssetProfilesService', () => {
}); });
}); });
}); });
function flushPendingPromises() {
return new Promise((resolve) => {
setImmediate(resolve);
});
}

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

@ -38,8 +38,8 @@ export class AssetProfilesService {
private readonly benchmarkService: BenchmarkService, private readonly benchmarkService: BenchmarkService,
private readonly dataGatheringService: DataGatheringService, private readonly dataGatheringService: DataGatheringService,
private readonly dataProviderService: DataProviderService, private readonly dataProviderService: DataProviderService,
private readonly exchangeRateDataService: ExchangeRateDataService,
private readonly eventEmitter: EventEmitter2, private readonly eventEmitter: EventEmitter2,
private readonly exchangeRateDataService: ExchangeRateDataService,
private readonly marketDataService: MarketDataService, private readonly marketDataService: MarketDataService,
private readonly prismaService: PrismaService, private readonly prismaService: PrismaService,
private readonly symbolProfileService: SymbolProfileService private readonly symbolProfileService: SymbolProfileService
@ -65,8 +65,10 @@ export class AssetProfilesService {
symbolProfileId symbolProfileId
}); });
await this.emitPortfolioChangedEvents(symbolProfileId); await this.gatherSymbolAndEmitPortfolioChangedEvents({
await this.dataGatheringService.gatherSymbol({ dataSource, symbol }); dataSource,
symbol
});
return assetProfileSplit; return assetProfileSplit;
} }
@ -89,23 +91,10 @@ export class AssetProfilesService {
throw new NotFoundException(); throw new NotFoundException();
} }
await this.emitPortfolioChangedEvents(symbolProfileId); await this.gatherSymbolAndEmitPortfolioChangedEvents({
await this.dataGatheringService.gatherSymbol({ dataSource, symbol }); dataSource,
} symbol
private async emitPortfolioChangedEvents(symbolProfileId: string) {
const users = await this.prismaService.order.findMany({
distinct: ['userId'],
select: { userId: true },
where: { symbolProfileId }
}); });
for (const { userId } of users) {
this.eventEmitter.emit(
PortfolioChangedEvent.getName(),
new PortfolioChangedEvent({ userId })
);
}
} }
public async getAssetProfile({ public async getAssetProfile({
@ -449,6 +438,47 @@ export class AssetProfilesService {
return assetProfile; return assetProfile;
} }
private async emitPortfolioChangedEvents({
dataSource,
symbol
}: AssetProfileIdentifier) {
const userIds = await this.activitiesService.getUserIdsByAssetProfile({
dataSource,
symbol
});
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
}: AssetProfileIdentifier) {
const jobs = await this.dataGatheringService.gatherSymbol({
dataSource,
symbol
});
void Promise.allSettled(
jobs.map((job) => {
return job.finished();
})
).then(() => {
return this.emitPortfolioChangedEvents({ dataSource, symbol });
});
}
private getAssetProfileDataUpdate({ private getAssetProfileDataUpdate({
countries, countries,
holdings, holdings,

118
apps/api/src/app/portfolio/calculator/portfolio-calculator.factory.spec.ts

@ -1,118 +0,0 @@
import {
activityDummyData,
assetProfileDummyData,
userDummyData
} from '@ghostfolio/api/app/portfolio/calculator/portfolio-calculator-test-utils';
import { CurrentRateService } from '@ghostfolio/api/app/portfolio/current-rate.service';
import { CurrentRateServiceMock } from '@ghostfolio/api/app/portfolio/current-rate.service.mock';
import { RedisCacheService } from '@ghostfolio/api/app/redis-cache/redis-cache.service';
import { RedisCacheServiceMock } from '@ghostfolio/api/app/redis-cache/redis-cache.service.mock';
import { ConfigurationService } from '@ghostfolio/api/services/configuration/configuration.service';
import { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-data/exchange-rate-data.service';
import { PortfolioSnapshotService } from '@ghostfolio/api/services/queues/portfolio-snapshot/portfolio-snapshot.service';
import { PortfolioSnapshotServiceMock } from '@ghostfolio/api/services/queues/portfolio-snapshot/portfolio-snapshot.service.mock';
import { Activity } from '@ghostfolio/common/interfaces';
import { PerformanceCalculationType } from '@ghostfolio/common/types/performance-calculation-type.type';
import { Big } from 'big.js';
import { PortfolioCalculatorFactory } from './portfolio-calculator.factory';
jest.mock('@ghostfolio/api/app/portfolio/current-rate.service', () => {
return {
CurrentRateService: jest.fn().mockImplementation(() => {
return CurrentRateServiceMock;
})
};
});
jest.mock(
'@ghostfolio/api/services/queues/portfolio-snapshot/portfolio-snapshot.service',
() => {
return {
PortfolioSnapshotService: jest.fn().mockImplementation(() => {
return PortfolioSnapshotServiceMock;
})
};
}
);
jest.mock('@ghostfolio/api/app/redis-cache/redis-cache.service', () => {
return {
RedisCacheService: jest.fn().mockImplementation(() => {
return RedisCacheServiceMock;
})
};
});
describe('PortfolioCalculatorFactory', () => {
let configurationService: ConfigurationService;
let currentRateService: CurrentRateService;
let exchangeRateDataService: ExchangeRateDataService;
let portfolioCalculatorFactory: PortfolioCalculatorFactory;
let portfolioSnapshotService: PortfolioSnapshotService;
let redisCacheService: RedisCacheService;
beforeEach(() => {
PortfolioSnapshotServiceMock.reset();
RedisCacheServiceMock.reset();
configurationService = new ConfigurationService();
currentRateService = new CurrentRateService(null, null, null, null);
exchangeRateDataService = new ExchangeRateDataService(
null,
null,
null,
null
);
portfolioSnapshotService = new PortfolioSnapshotService(null, null);
redisCacheService = new RedisCacheService(null, null);
portfolioCalculatorFactory = new PortfolioCalculatorFactory(
configurationService,
currentRateService,
exchangeRateDataService,
portfolioSnapshotService,
redisCacheService
);
});
it.each([
PerformanceCalculationType.ROAI,
PerformanceCalculationType.MWR,
PerformanceCalculationType.TWR,
PerformanceCalculationType.ROI
])(
'passes adjusted activities to the shared calculator base for %s',
(calculationType) => {
const activities: Activity[] = [
{
...activityDummyData,
assetProfile: {
...assetProfileDummyData,
currency: 'USD',
dataSource: 'YAHOO',
name: 'Apple Inc.',
symbol: 'AAPL'
},
date: new Date('2020-01-01'),
feeInAssetProfileCurrency: 0,
feeInBaseCurrency: 0,
quantity: 20,
type: 'BUY',
unitPriceInAssetProfileCurrency: 50
}
];
const calculator = portfolioCalculatorFactory.createCalculator({
activities,
calculationType,
currency: 'USD',
userId: userDummyData.id
});
const position = calculator.getTransactionPoints()[0].items[0];
expect(position.quantity).toEqual(new Big(20));
expect(position.averagePrice).toEqual(new Big(50));
}
);
});

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

@ -126,7 +126,7 @@ describe('PortfolioCalculator', () => {
accountBalanceService, accountBalanceService,
accountService, accountService,
{ {
getSplitsBySymbolProfileIds: jest.fn().mockResolvedValue(new Map()) getSplitsByUserId: jest.fn().mockResolvedValue([])
} as unknown as AssetProfileSplitService, } as unknown as AssetProfileSplitService,
null, null,
null, null,

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

@ -15,26 +15,44 @@ export function adjustActivityBySplits(
} }
const activityDate = resetHours(activity.date); const activityDate = resetHours(activity.date);
let splitFactor = new Big(1);
// 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) { 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)) { if (isBefore(activityDate, split.date)) {
splitFactor = splitFactor.mul(split.numerator).div(split.denominator); denominator = denominator.mul(split.denominator);
numerator = numerator.mul(split.numerator);
} }
} }
if (splitFactor.eq(1)) { if (numerator.eq(denominator)) {
return activity; return activity;
} }
return { return {
...activity, ...activity,
quantity: new Big(activity.quantity).mul(splitFactor).toNumber(), quantity: new Big(activity.quantity)
unitPrice: new Big(activity.unitPrice).div(splitFactor).toNumber(), .mul(numerator)
.div(denominator)
.toNumber(),
unitPrice: new Big(activity.unitPrice)
.mul(denominator)
.div(numerator)
.toNumber(),
unitPriceInAssetProfileCurrency: new Big( unitPriceInAssetProfileCurrency: new Big(
activity.unitPriceInAssetProfileCurrency activity.unitPriceInAssetProfileCurrency
) )
.div(splitFactor) .mul(denominator)
.div(numerator)
.toNumber() .toNumber()
}; };
} }

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

@ -56,38 +56,33 @@ describe('AssetProfileSplitService', () => {
}); });
}); });
describe('getSplitsBySymbolProfileIds', () => { describe('getSplitsByUserId', () => {
it('fetches and groups all splits with one ordered query', async () => { it('fetches the splits of the asset profiles held by the user with one ordered query', async () => {
const symbolProfileIds = ['aapl-profile', 'msft-profile']; const splits = [
const aaplSplit = createSplit({ createStoredSplit('2020-08-31'),
date: new Date('2020-08-31'), createStoredSplit('2021-09-16')
symbolProfileId: symbolProfileIds[0] ];
});
const msftSplit = createSplit({
date: new Date('2021-09-16'),
symbolProfileId: symbolProfileIds[1]
});
findMany.mockResolvedValue([aaplSplit, msftSplit]); findMany.mockResolvedValue(splits);
const splits = const result = await assetProfileSplitService.getSplitsByUserId({
await assetProfileSplitService.getSplitsBySymbolProfileIds( userId: 'user-id'
symbolProfileIds });
);
expect(result).toBe(splits);
expect(findMany).toHaveBeenCalledTimes(1); expect(findMany).toHaveBeenCalledTimes(1);
expect(findMany).toHaveBeenCalledWith({ expect(findMany).toHaveBeenCalledWith({
orderBy: [{ date: 'asc' }], orderBy: [{ date: 'asc' }],
where: { where: {
symbolProfileId: { in: symbolProfileIds } symbolProfile: {
activities: {
some: {
userId: 'user-id'
}
}
}
} }
}); });
expect(splits).toEqual(
new Map([
[symbolProfileIds[0], [aaplSplit]],
[symbolProfileIds[1], [msftSplit]]
])
);
}); });
}); });
@ -151,24 +146,6 @@ describe('AssetProfileSplitService', () => {
}); });
}); });
function createSplit({
date,
symbolProfileId
}: {
date: Date;
symbolProfileId: string;
}): AssetProfileSplit {
return {
createdAt: date,
date,
denominator: 1,
id: `${symbolProfileId}-split`,
numerator: 2,
symbolProfileId,
updatedAt: date
};
}
function createStoredSplit(date: string): AssetProfileSplit { function createStoredSplit(date: string): AssetProfileSplit {
const splitDate = new Date(date); const splitDate = new Date(date);

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

@ -53,34 +53,30 @@ export class AssetProfileSplitService {
} }
/** /**
* Returns the splits of all given asset profiles, grouped by their * Returns the splits of all asset profiles the given user has activities
* data-source/symbol identifier. * for, in ascending order by date
*/ */
public async getSplitsBySymbolProfileIds( public async getSplitsByUserId({
symbolProfileIds: string[] userId
): Promise<Map<string, AssetProfileSplit[]>> { }: {
const splitsByAssetProfile = new Map<string, AssetProfileSplit[]>(); userId: string;
}): Promise<AssetProfileSplit[]> {
for (const symbolProfileId of symbolProfileIds) { return this.prismaService.assetProfileSplit.findMany({
splitsByAssetProfile.set(symbolProfileId, []); orderBy: [
} {
date: 'asc'
if (symbolProfileIds.length === 0) {
return splitsByAssetProfile;
} }
],
const splits = await this.prismaService.assetProfileSplit.findMany({
orderBy: [{ date: 'asc' }],
where: { where: {
symbolProfileId: { in: symbolProfileIds } symbolProfile: {
activities: {
some: {
userId
} }
});
for (const split of splits) {
splitsByAssetProfile.get(split.symbolProfileId)?.push(split);
} }
}
return splitsByAssetProfile; }
});
} }
public async upsert({ public async upsert({

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

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

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

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

Loading…
Cancel
Save