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

60
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, uniq, uniqBy } from 'lodash';
import { groupBy, uniqBy } from 'lodash';
import { randomUUID } from 'node:crypto';
@Injectable()
@ -918,34 +918,23 @@ 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 symbolProfileIds = uniq(
activities.activities.map(({ assetProfile }) => {
return assetProfile.id;
})
);
const splitsBySymbolProfileId =
await this.assetProfileSplitService.getSplitsBySymbolProfileIds(
symbolProfileIds
);
const [activities, splits] = await Promise.all([
this.getActivities({
filters,
userCurrency,
userId,
withExcludedAccountsAndActivities: false // TODO
}),
this.assetProfileSplitService.getSplitsByUserId({ userId })
]);
const hasSplits = [...splitsBySymbolProfileId.values()].some((splits) => {
return splits.length > 0;
});
if (splits.length > 0) {
const splitsBySymbolProfileId = groupBy(splits, 'symbolProfileId');
if (hasSplits) {
activities.activities = activities.activities.map((activity) => {
const key = activity.assetProfile.id;
return adjustActivityBySplits(
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(
orderWhereUniqueInput: Prisma.OrderWhereUniqueInput
): 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 { AssetProfileSplitService } from '@ghostfolio/api/services/asset-profile-split/asset-profile-split.service';
import { DataGatheringService } from '@ghostfolio/api/services/queues/data-gathering/data-gathering.service';
@ -12,19 +13,21 @@ describe('AssetProfilesService', () => {
let assetProfilesService: AssetProfilesService;
let deleteById: jest.Mock;
let emit: jest.Mock;
let findMany: jest.Mock;
let finished: jest.Mock;
let gatherSymbol: jest.Mock;
let getUserIdsByAssetProfile: jest.Mock;
let upsert: jest.Mock;
beforeEach(() => {
deleteById = jest.fn();
emit = jest.fn();
findMany = jest.fn().mockResolvedValue([]);
gatherSymbol = jest.fn();
finished = jest.fn().mockResolvedValue(undefined);
gatherSymbol = jest.fn().mockResolvedValue([{ finished }]);
getUserIdsByAssetProfile = jest.fn().mockResolvedValue([]);
upsert = jest.fn();
assetProfilesService = new AssetProfilesService(
null,
{ getUserIdsByAssetProfile } as unknown as ActivitiesService,
{
deleteById,
upsert
@ -32,10 +35,10 @@ describe('AssetProfilesService', () => {
null,
{ gatherSymbol } as unknown as DataGatheringService,
null,
null,
{ emit } as unknown as EventEmitter2,
null,
{ order: { findMany } } as never,
null,
null,
null
);
});
@ -69,25 +72,8 @@ describe('AssetProfilesService', () => {
});
it('invalidates portfolio snapshots for users holding the asset', async () => {
const split = {} as AssetProfileSplit;
upsert.mockResolvedValue(split);
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
);
upsert.mockResolvedValue({} as AssetProfileSplit);
getUserIdsByAssetProfile.mockResolvedValue(['user-1', 'user-2']);
await assetProfilesService.createSplit({
dataSource: DataSource.YAHOO,
@ -97,11 +83,11 @@ describe('AssetProfilesService', () => {
symbol: 'AAPL',
symbolProfileId: 'profile-id'
});
await flushPendingPromises();
expect(findMany).toHaveBeenCalledWith({
distinct: ['userId'],
select: { userId: true },
where: { symbolProfileId: 'profile-id' }
expect(getUserIdsByAssetProfile).toHaveBeenCalledWith({
dataSource: DataSource.YAHOO,
symbol: 'AAPL'
});
expect(emit.mock.calls.map(([, event]) => event.getUserId())).toEqual([
'user-1',
@ -109,6 +95,37 @@ describe('AssetProfilesService', () => {
]);
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', () => {
@ -123,13 +140,15 @@ describe('AssetProfilesService', () => {
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);
findMany.mockResolvedValue([{ userId: 'user-1' }]);
getUserIdsByAssetProfile.mockResolvedValue(['user-1']);
await expect(
assetProfilesService.deleteSplit({
@ -139,6 +158,7 @@ describe('AssetProfilesService', () => {
symbolProfileId: 'profile-id'
})
).resolves.toBeUndefined();
await flushPendingPromises();
expect(deleteById).toHaveBeenCalledWith({
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 dataGatheringService: DataGatheringService,
private readonly dataProviderService: DataProviderService,
private readonly exchangeRateDataService: ExchangeRateDataService,
private readonly eventEmitter: EventEmitter2,
private readonly exchangeRateDataService: ExchangeRateDataService,
private readonly marketDataService: MarketDataService,
private readonly prismaService: PrismaService,
private readonly symbolProfileService: SymbolProfileService
@ -65,8 +65,10 @@ export class AssetProfilesService {
symbolProfileId
});
await this.emitPortfolioChangedEvents(symbolProfileId);
await this.dataGatheringService.gatherSymbol({ dataSource, symbol });
await this.gatherSymbolAndEmitPortfolioChangedEvents({
dataSource,
symbol
});
return assetProfileSplit;
}
@ -89,23 +91,10 @@ export class AssetProfilesService {
throw new NotFoundException();
}
await this.emitPortfolioChangedEvents(symbolProfileId);
await this.dataGatheringService.gatherSymbol({ dataSource, symbol });
}
private async emitPortfolioChangedEvents(symbolProfileId: string) {
const users = await this.prismaService.order.findMany({
distinct: ['userId'],
select: { userId: true },
where: { symbolProfileId }
await this.gatherSymbolAndEmitPortfolioChangedEvents({
dataSource,
symbol
});
for (const { userId } of users) {
this.eventEmitter.emit(
PortfolioChangedEvent.getName(),
new PortfolioChangedEvent({ userId })
);
}
}
public async getAssetProfile({
@ -449,6 +438,47 @@ export class AssetProfilesService {
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({
countries,
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,
accountService,
{
getSplitsBySymbolProfileIds: jest.fn().mockResolvedValue(new Map())
getSplitsByUserId: jest.fn().mockResolvedValue([])
} as unknown as AssetProfileSplitService,
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);
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) {
// 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)) {
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,
quantity: new Big(activity.quantity).mul(splitFactor).toNumber(),
unitPrice: new Big(activity.unitPrice).div(splitFactor).toNumber(),
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
)
.div(splitFactor)
.mul(denominator)
.div(numerator)
.toNumber()
};
}

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

@ -56,38 +56,33 @@ describe('AssetProfileSplitService', () => {
});
});
describe('getSplitsBySymbolProfileIds', () => {
it('fetches and groups all splits with one ordered query', async () => {
const symbolProfileIds = ['aapl-profile', 'msft-profile'];
const aaplSplit = createSplit({
date: new Date('2020-08-31'),
symbolProfileId: symbolProfileIds[0]
});
const msftSplit = createSplit({
date: new Date('2021-09-16'),
symbolProfileId: symbolProfileIds[1]
});
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([aaplSplit, msftSplit]);
findMany.mockResolvedValue(splits);
const splits =
await assetProfileSplitService.getSplitsBySymbolProfileIds(
symbolProfileIds
);
const result = await assetProfileSplitService.getSplitsByUserId({
userId: 'user-id'
});
expect(result).toBe(splits);
expect(findMany).toHaveBeenCalledTimes(1);
expect(findMany).toHaveBeenCalledWith({
orderBy: [{ date: 'asc' }],
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 {
const splitDate = new Date(date);

44
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
* data-source/symbol identifier.
* Returns the splits of all asset profiles the given user has activities
* for, in ascending order by date
*/
public async getSplitsBySymbolProfileIds(
symbolProfileIds: string[]
): Promise<Map<string, AssetProfileSplit[]>> {
const splitsByAssetProfile = new Map<string, AssetProfileSplit[]>();
for (const symbolProfileId of symbolProfileIds) {
splitsByAssetProfile.set(symbolProfileId, []);
}
if (symbolProfileIds.length === 0) {
return splitsByAssetProfile;
}
const splits = await this.prismaService.assetProfileSplit.findMany({
orderBy: [{ date: 'asc' }],
public async getSplitsByUserId({
userId
}: {
userId: string;
}): Promise<AssetProfileSplit[]> {
return this.prismaService.assetProfileSplit.findMany({
orderBy: [
{
date: 'asc'
}
],
where: {
symbolProfileId: { in: symbolProfileIds }
symbolProfile: {
activities: {
some: {
userId
}
}
}
}
});
for (const split of splits) {
splitsByAssetProfile.get(split.symbolProfileId)?.push(split);
}
return splitsByAssetProfile;
}
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 { Inject, Injectable, Logger } from '@nestjs/common';
import { Prisma } from '@prisma/client';
import { JobOptions, Queue } from 'bull';
import { Job, JobOptions, Queue } from 'bull';
import { format, min, subDays, subMilliseconds, subYears } from 'date-fns';
import { isEmpty } from 'lodash';
import ms, { StringValue } from 'ms';
@ -285,7 +285,7 @@ export class DataGatheringService {
date: date ?? item.date
}));
await this.gatherSymbols({
return this.gatherSymbols({
dataGatheringItems,
force: true,
priority: DATA_GATHERING_QUEUE_PRIORITY_HIGH
@ -336,8 +336,8 @@ export class DataGatheringService {
dataGatheringItems: DataGatheringItem[];
force?: boolean;
priority: number;
}) {
await this.addJobsToQueue(
}): Promise<Job[]> {
return this.addJobsToQueue(
dataGatheringItems.map(({ dataSource, date, symbol }) => {
return {
data: {

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

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

Loading…
Cancel
Save