Feature/optimize import validation by reducing to unique asset profiles (#2198)

* Optimize activities validation

* Optimize data gathering in import

* Update changelog
This commit is contained in:
Thomas Kaul 2023-08-01 09:10:13 +02:00 committed by GitHub
parent 4973d0261d
commit 286e41eb21
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
9 changed files with 153 additions and 62 deletions

View File

@ -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/), 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). and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
## Unreleased
### Changed
- Optimized the validation in the activities import by reducing the list to unique asset profiles
- Optimized the data gathering in the activities import
## 1.295.0 - 2023-07-30 ## 1.295.0 - 2023-07-30
### Added ### Added

View File

@ -7,6 +7,7 @@ import {
GATHER_ASSET_PROFILE_PROCESS, GATHER_ASSET_PROFILE_PROCESS,
GATHER_ASSET_PROFILE_PROCESS_OPTIONS GATHER_ASSET_PROFILE_PROCESS_OPTIONS
} from '@ghostfolio/common/config'; } from '@ghostfolio/common/config';
import { getAssetProfileIdentifier } from '@ghostfolio/common/helper';
import { import {
AdminData, AdminData,
AdminMarketData, AdminMarketData,
@ -116,7 +117,7 @@ export class AdminController {
name: GATHER_ASSET_PROFILE_PROCESS, name: GATHER_ASSET_PROFILE_PROCESS,
opts: { opts: {
...GATHER_ASSET_PROFILE_PROCESS_OPTIONS, ...GATHER_ASSET_PROFILE_PROCESS_OPTIONS,
jobId: `${dataSource}-${symbol}` jobId: getAssetProfileIdentifier({ dataSource, symbol })
} }
}; };
}) })
@ -152,7 +153,7 @@ export class AdminController {
name: GATHER_ASSET_PROFILE_PROCESS, name: GATHER_ASSET_PROFILE_PROCESS,
opts: { opts: {
...GATHER_ASSET_PROFILE_PROCESS_OPTIONS, ...GATHER_ASSET_PROFILE_PROCESS_OPTIONS,
jobId: `${dataSource}-${symbol}` jobId: getAssetProfileIdentifier({ dataSource, symbol })
} }
}; };
}) })
@ -185,7 +186,7 @@ export class AdminController {
name: GATHER_ASSET_PROFILE_PROCESS, name: GATHER_ASSET_PROFILE_PROCESS,
opts: { opts: {
...GATHER_ASSET_PROFILE_PROCESS_OPTIONS, ...GATHER_ASSET_PROFILE_PROCESS_OPTIONS,
jobId: `${dataSource}-${symbol}` jobId: getAssetProfileIdentifier({ dataSource, symbol })
} }
}); });
} }

View File

@ -8,10 +8,14 @@ import {
import { OrderService } from '@ghostfolio/api/app/order/order.service'; import { OrderService } from '@ghostfolio/api/app/order/order.service';
import { PlatformService } from '@ghostfolio/api/app/platform/platform.service'; import { PlatformService } from '@ghostfolio/api/app/platform/platform.service';
import { PortfolioService } from '@ghostfolio/api/app/portfolio/portfolio.service'; import { PortfolioService } from '@ghostfolio/api/app/portfolio/portfolio.service';
import { DataGatheringService } from '@ghostfolio/api/services/data-gathering/data-gathering.service';
import { DataProviderService } from '@ghostfolio/api/services/data-provider/data-provider.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'; import { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-data/exchange-rate-data.service';
import { SymbolProfileService } from '@ghostfolio/api/services/symbol-profile/symbol-profile.service'; import { SymbolProfileService } from '@ghostfolio/api/services/symbol-profile/symbol-profile.service';
import { parseDate } from '@ghostfolio/common/helper'; import {
getAssetProfileIdentifier,
parseDate
} from '@ghostfolio/common/helper';
import { UniqueAsset } from '@ghostfolio/common/interfaces'; import { UniqueAsset } from '@ghostfolio/common/interfaces';
import { import {
AccountWithPlatform, AccountWithPlatform,
@ -21,12 +25,14 @@ import { Injectable } from '@nestjs/common';
import { DataSource, Prisma, SymbolProfile } from '@prisma/client'; import { DataSource, Prisma, SymbolProfile } from '@prisma/client';
import Big from 'big.js'; import Big from 'big.js';
import { endOfToday, isAfter, isSameDay, parseISO } from 'date-fns'; import { endOfToday, isAfter, isSameDay, parseISO } from 'date-fns';
import { uniqBy } from 'lodash';
import { v4 as uuidv4 } from 'uuid'; import { v4 as uuidv4 } from 'uuid';
@Injectable() @Injectable()
export class ImportService { export class ImportService {
public constructor( public constructor(
private readonly accountService: AccountService, private readonly accountService: AccountService,
private readonly dataGatheringService: DataGatheringService,
private readonly dataProviderService: DataProviderService, private readonly dataProviderService: DataProviderService,
private readonly exchangeRateDataService: ExchangeRateDataService, private readonly exchangeRateDataService: ExchangeRateDataService,
private readonly orderService: OrderService, private readonly orderService: OrderService,
@ -220,8 +226,7 @@ export class ImportService {
const assetProfiles = await this.validateActivities({ const assetProfiles = await this.validateActivities({
activitiesDto, activitiesDto,
maxActivitiesToImport, maxActivitiesToImport
userId
}); });
const activitiesExtendedWithErrors = await this.extendActivitiesWithErrors({ const activitiesExtendedWithErrors = await this.extendActivitiesWithErrors({
@ -250,10 +255,37 @@ export class ImportService {
error, error,
fee, fee,
quantity, quantity,
SymbolProfile: assetProfile, SymbolProfile,
type, type,
unitPrice unitPrice
} of activitiesExtendedWithErrors) { } of activitiesExtendedWithErrors) {
const assetProfile = assetProfiles[
getAssetProfileIdentifier({
dataSource: SymbolProfile.dataSource,
symbol: SymbolProfile.symbol
})
] ?? {
currency: SymbolProfile.currency,
dataSource: SymbolProfile.dataSource,
symbol: SymbolProfile.symbol
};
const {
assetClass,
assetSubClass,
countries,
createdAt,
currency,
dataSource,
id,
isin,
name,
scraperConfiguration,
sectors,
symbol,
symbolMapping,
url,
updatedAt
} = assetProfile;
const validatedAccount = accounts.find(({ id }) => { const validatedAccount = accounts.find(({ id }) => {
return id === accountId; return id === accountId;
}); });
@ -279,23 +311,22 @@ export class ImportService {
id: uuidv4(), id: uuidv4(),
isDraft: isAfter(date, endOfToday()), isDraft: isAfter(date, endOfToday()),
SymbolProfile: { SymbolProfile: {
assetClass: assetProfile.assetClass, assetClass,
assetSubClass: assetProfile.assetSubClass, assetSubClass,
comment: assetProfile.comment, countries,
countries: assetProfile.countries, createdAt,
createdAt: assetProfile.createdAt, currency,
currency: assetProfile.currency, dataSource,
dataSource: assetProfile.dataSource, id,
id: assetProfile.id, isin,
isin: assetProfile.isin, name,
name: assetProfile.name, scraperConfiguration,
scraperConfiguration: assetProfile.scraperConfiguration, sectors,
sectors: assetProfile.sectors, symbol,
symbol: assetProfile.currency, symbolMapping,
symbolMapping: assetProfile.symbolMapping, updatedAt,
updatedAt: assetProfile.updatedAt, url,
url: assetProfile.url, comment: assetProfile.comment
...assetProfiles[assetProfile.symbol]
}, },
Account: validatedAccount, Account: validatedAccount,
symbolProfileId: undefined, symbolProfileId: undefined,
@ -318,14 +349,14 @@ export class ImportService {
SymbolProfile: { SymbolProfile: {
connectOrCreate: { connectOrCreate: {
create: { create: {
currency: assetProfile.currency, currency,
dataSource: assetProfile.dataSource, dataSource,
symbol: assetProfile.symbol symbol
}, },
where: { where: {
dataSource_symbol: { dataSource_symbol: {
dataSource: assetProfile.dataSource, dataSource,
symbol: assetProfile.symbol symbol
} }
} }
} }
@ -337,24 +368,49 @@ export class ImportService {
const value = new Big(quantity).mul(unitPrice).toNumber(); const value = new Big(quantity).mul(unitPrice).toNumber();
//@ts-ignore
activities.push({ activities.push({
...order, ...order,
error, error,
value, value,
feeInBaseCurrency: this.exchangeRateDataService.toCurrency( feeInBaseCurrency: this.exchangeRateDataService.toCurrency(
fee, fee,
assetProfile.currency, currency,
userCurrency userCurrency
), ),
//@ts-ignore
SymbolProfile: assetProfile,
valueInBaseCurrency: this.exchangeRateDataService.toCurrency( valueInBaseCurrency: this.exchangeRateDataService.toCurrency(
value, value,
assetProfile.currency, currency,
userCurrency userCurrency
) )
}); });
} }
activities.sort((activity1, activity2) => {
return Number(activity1.date) - Number(activity2.date);
});
if (!isDryRun) {
// Gather symbol data in the background, if not dry run
const uniqueActivities = uniqBy(activities, ({ SymbolProfile }) => {
return getAssetProfileIdentifier({
dataSource: SymbolProfile.dataSource,
symbol: SymbolProfile.symbol
});
});
this.dataGatheringService.gatherSymbols(
uniqueActivities.map(({ date, SymbolProfile }) => {
return {
date,
dataSource: SymbolProfile.dataSource,
symbol: SymbolProfile.symbol
};
})
);
}
return activities; return activities;
} }
@ -446,25 +502,30 @@ export class ImportService {
private async validateActivities({ private async validateActivities({
activitiesDto, activitiesDto,
maxActivitiesToImport, maxActivitiesToImport
userId
}: { }: {
activitiesDto: Partial<CreateOrderDto>[]; activitiesDto: Partial<CreateOrderDto>[];
maxActivitiesToImport: number; maxActivitiesToImport: number;
userId: string;
}) { }) {
if (activitiesDto?.length > maxActivitiesToImport) { if (activitiesDto?.length > maxActivitiesToImport) {
throw new Error(`Too many activities (${maxActivitiesToImport} at most)`); throw new Error(`Too many activities (${maxActivitiesToImport} at most)`);
} }
const assetProfiles: { const assetProfiles: {
[symbol: string]: Partial<SymbolProfile>; [assetProfileIdentifier: string]: Partial<SymbolProfile>;
} = {}; } = {};
const uniqueActivitiesDto = uniqBy(
activitiesDto,
({ dataSource, symbol }) => {
return getAssetProfileIdentifier({ dataSource, symbol });
}
);
for (const [ for (const [
index, index,
{ currency, dataSource, symbol } { currency, dataSource, symbol }
] of activitiesDto.entries()) { ] of uniqueActivitiesDto.entries()) {
if (dataSource !== 'MANUAL') { if (dataSource !== 'MANUAL') {
const assetProfile = ( const assetProfile = (
await this.dataProviderService.getAssetProfiles([ await this.dataProviderService.getAssetProfiles([
@ -484,7 +545,8 @@ export class ImportService {
); );
} }
assetProfiles[symbol] = assetProfile; assetProfiles[getAssetProfileIdentifier({ dataSource, symbol })] =
assetProfile;
} }
} }

View File

@ -2,6 +2,7 @@ import { RedactValuesInResponseInterceptor } from '@ghostfolio/api/interceptors/
import { TransformDataSourceInRequestInterceptor } from '@ghostfolio/api/interceptors/transform-data-source-in-request.interceptor'; import { TransformDataSourceInRequestInterceptor } from '@ghostfolio/api/interceptors/transform-data-source-in-request.interceptor';
import { TransformDataSourceInResponseInterceptor } from '@ghostfolio/api/interceptors/transform-data-source-in-response.interceptor'; import { TransformDataSourceInResponseInterceptor } from '@ghostfolio/api/interceptors/transform-data-source-in-response.interceptor';
import { ApiService } from '@ghostfolio/api/services/api/api.service'; import { ApiService } from '@ghostfolio/api/services/api/api.service';
import { DataGatheringService } from '@ghostfolio/api/services/data-gathering/data-gathering.service';
import { ImpersonationService } from '@ghostfolio/api/services/impersonation/impersonation.service'; import { ImpersonationService } from '@ghostfolio/api/services/impersonation/impersonation.service';
import { HEADER_KEY_IMPERSONATION } from '@ghostfolio/common/config'; import { HEADER_KEY_IMPERSONATION } from '@ghostfolio/common/config';
import { hasPermission, permissions } from '@ghostfolio/common/permissions'; import { hasPermission, permissions } from '@ghostfolio/common/permissions';
@ -36,6 +37,7 @@ import { UpdateOrderDto } from './update-order.dto';
export class OrderController { export class OrderController {
public constructor( public constructor(
private readonly apiService: ApiService, private readonly apiService: ApiService,
private readonly dataGatheringService: DataGatheringService,
private readonly impersonationService: ImpersonationService, private readonly impersonationService: ImpersonationService,
private readonly orderService: OrderService, private readonly orderService: OrderService,
@Inject(REQUEST) private readonly request: RequestWithUser @Inject(REQUEST) private readonly request: RequestWithUser
@ -123,7 +125,7 @@ export class OrderController {
); );
} }
return this.orderService.createOrder({ const order = await this.orderService.createOrder({
...data, ...data,
date: parseISO(data.date), date: parseISO(data.date),
SymbolProfile: { SymbolProfile: {
@ -144,6 +146,19 @@ export class OrderController {
User: { connect: { id: this.request.user.id } }, User: { connect: { id: this.request.user.id } },
userId: this.request.user.id userId: this.request.user.id
}); });
if (!order.isDraft) {
// Gather symbol data in the background, if not draft
this.dataGatheringService.gatherSymbols([
{
dataSource: data.dataSource,
date: order.date,
symbol: data.symbol
}
]);
}
return order;
} }
@Put(':id') @Put(':id')

View File

@ -7,6 +7,7 @@ import {
GATHER_ASSET_PROFILE_PROCESS, GATHER_ASSET_PROFILE_PROCESS,
GATHER_ASSET_PROFILE_PROCESS_OPTIONS GATHER_ASSET_PROFILE_PROCESS_OPTIONS
} from '@ghostfolio/common/config'; } from '@ghostfolio/common/config';
import { getAssetProfileIdentifier } from '@ghostfolio/common/helper';
import { Filter } from '@ghostfolio/common/interfaces'; import { Filter } from '@ghostfolio/common/interfaces';
import { OrderWithAccount } from '@ghostfolio/common/types'; import { OrderWithAccount } from '@ghostfolio/common/types';
import { Injectable } from '@nestjs/common'; import { Injectable } from '@nestjs/common';
@ -117,7 +118,7 @@ export class OrderService {
}; };
} }
await this.dataGatheringService.addJobToQueue({ this.dataGatheringService.addJobToQueue({
data: { data: {
dataSource: data.SymbolProfile.connectOrCreate.create.dataSource, dataSource: data.SymbolProfile.connectOrCreate.create.dataSource,
symbol: data.SymbolProfile.connectOrCreate.create.symbol symbol: data.SymbolProfile.connectOrCreate.create.symbol
@ -125,26 +126,13 @@ export class OrderService {
name: GATHER_ASSET_PROFILE_PROCESS, name: GATHER_ASSET_PROFILE_PROCESS,
opts: { opts: {
...GATHER_ASSET_PROFILE_PROCESS_OPTIONS, ...GATHER_ASSET_PROFILE_PROCESS_OPTIONS,
jobId: `${data.SymbolProfile.connectOrCreate.create.dataSource}-${data.SymbolProfile.connectOrCreate.create.symbol}` jobId: getAssetProfileIdentifier({
dataSource: data.SymbolProfile.connectOrCreate.create.dataSource,
symbol: data.SymbolProfile.connectOrCreate.create.symbol
})
} }
}); });
const isDraft =
data.type === 'LIABILITY'
? false
: isAfter(data.date as Date, endOfToday());
if (!isDraft) {
// Gather symbol data of order in the background, if not draft
this.dataGatheringService.gatherSymbols([
{
dataSource: data.SymbolProfile.connectOrCreate.create.dataSource,
date: <Date>data.date,
symbol: data.SymbolProfile.connectOrCreate.create.symbol
}
]);
}
delete data.accountId; delete data.accountId;
delete data.assetClass; delete data.assetClass;
delete data.assetSubClass; delete data.assetSubClass;
@ -162,6 +150,11 @@ export class OrderService {
const orderData: Prisma.OrderCreateInput = data; const orderData: Prisma.OrderCreateInput = data;
const isDraft =
data.type === 'LIABILITY'
? false
: isAfter(data.date as Date, endOfToday());
const order = await this.prismaService.order.create({ const order = await this.prismaService.order.create({
data: { data: {
...orderData, ...orderData,

View File

@ -1,4 +1,5 @@
import { ConfigurationService } from '@ghostfolio/api/services/configuration/configuration.service'; import { ConfigurationService } from '@ghostfolio/api/services/configuration/configuration.service';
import { getAssetProfileIdentifier } from '@ghostfolio/common/helper';
import { UniqueAsset } from '@ghostfolio/common/interfaces'; import { UniqueAsset } from '@ghostfolio/common/interfaces';
import { CACHE_MANAGER, Inject, Injectable, Logger } from '@nestjs/common'; import { CACHE_MANAGER, Inject, Injectable, Logger } from '@nestjs/common';
@ -22,7 +23,7 @@ export class RedisCacheService {
} }
public getQuoteKey({ dataSource, symbol }: UniqueAsset) { public getQuoteKey({ dataSource, symbol }: UniqueAsset) {
return `quote-${dataSource}-${symbol}`; return `quote-${getAssetProfileIdentifier({ dataSource, symbol })}`;
} }
public async remove(key: string) { public async remove(key: string) {

View File

@ -2,6 +2,7 @@ import {
GATHER_ASSET_PROFILE_PROCESS, GATHER_ASSET_PROFILE_PROCESS,
GATHER_ASSET_PROFILE_PROCESS_OPTIONS GATHER_ASSET_PROFILE_PROCESS_OPTIONS
} from '@ghostfolio/common/config'; } from '@ghostfolio/common/config';
import { getAssetProfileIdentifier } from '@ghostfolio/common/helper';
import { Injectable } from '@nestjs/common'; import { Injectable } from '@nestjs/common';
import { Cron, CronExpression } from '@nestjs/schedule'; import { Cron, CronExpression } from '@nestjs/schedule';
@ -48,7 +49,7 @@ export class CronService {
name: GATHER_ASSET_PROFILE_PROCESS, name: GATHER_ASSET_PROFILE_PROCESS,
opts: { opts: {
...GATHER_ASSET_PROFILE_PROCESS_OPTIONS, ...GATHER_ASSET_PROFILE_PROCESS_OPTIONS,
jobId: `${dataSource}-${symbol}` jobId: getAssetProfileIdentifier({ dataSource, symbol })
} }
}; };
}) })

View File

@ -10,7 +10,11 @@ import {
GATHER_HISTORICAL_MARKET_DATA_PROCESS, GATHER_HISTORICAL_MARKET_DATA_PROCESS,
GATHER_HISTORICAL_MARKET_DATA_PROCESS_OPTIONS GATHER_HISTORICAL_MARKET_DATA_PROCESS_OPTIONS
} from '@ghostfolio/common/config'; } from '@ghostfolio/common/config';
import { DATE_FORMAT, resetHours } from '@ghostfolio/common/helper'; import {
DATE_FORMAT,
getAssetProfileIdentifier,
resetHours
} from '@ghostfolio/common/helper';
import { UniqueAsset } from '@ghostfolio/common/interfaces'; import { UniqueAsset } from '@ghostfolio/common/interfaces';
import { InjectQueue } from '@nestjs/bull'; import { InjectQueue } from '@nestjs/bull';
import { Inject, Injectable, Logger } from '@nestjs/common'; import { Inject, Injectable, Logger } from '@nestjs/common';
@ -221,7 +225,10 @@ export class DataGatheringService {
name: GATHER_HISTORICAL_MARKET_DATA_PROCESS, name: GATHER_HISTORICAL_MARKET_DATA_PROCESS,
opts: { opts: {
...GATHER_HISTORICAL_MARKET_DATA_PROCESS_OPTIONS, ...GATHER_HISTORICAL_MARKET_DATA_PROCESS_OPTIONS,
jobId: `${dataSource}-${symbol}-${format(date, DATE_FORMAT)}` jobId: `${getAssetProfileIdentifier({
dataSource,
symbol
})}-${format(date, DATE_FORMAT)}`
} }
}; };
}) })

View File

@ -5,7 +5,7 @@ import { getDate, getMonth, getYear, parse, subDays } from 'date-fns';
import { de, es, fr, it, nl, pt } from 'date-fns/locale'; import { de, es, fr, it, nl, pt } from 'date-fns/locale';
import { ghostfolioScraperApiSymbolPrefix, locale } from './config'; import { ghostfolioScraperApiSymbolPrefix, locale } from './config';
import { Benchmark } from './interfaces'; import { Benchmark, UniqueAsset } from './interfaces';
import { ColorScheme } from './types'; import { ColorScheme } from './types';
const NUMERIC_REGEXP = /[-]{0,1}[\d]*[.,]{0,1}[\d]+/g; const NUMERIC_REGEXP = /[-]{0,1}[\d]*[.,]{0,1}[\d]+/g;
@ -64,6 +64,10 @@ export function extractNumberFromString(aString: string): number {
} }
} }
export function getAssetProfileIdentifier({ dataSource, symbol }: UniqueAsset) {
return `${dataSource}-${symbol}`;
}
export function getBackgroundColor(aColorScheme: ColorScheme) { export function getBackgroundColor(aColorScheme: ColorScheme) {
return getCssVariable( return getCssVariable(
aColorScheme === 'DARK' || aColorScheme === 'DARK' ||