diff --git a/apps/api/src/app/admin/create-asset-profile.dto.ts b/apps/api/src/app/admin/create-asset-profile.dto.ts new file mode 100644 index 000000000..8041b0f0e --- /dev/null +++ b/apps/api/src/app/admin/create-asset-profile.dto.ts @@ -0,0 +1,92 @@ +import { IsCurrencyCode } from '@ghostfolio/api/validators/is-currency-code'; + +import { AssetClass, AssetSubClass, DataSource, Prisma } from '@prisma/client'; +import { + IsArray, + IsBoolean, + IsEnum, + IsObject, + IsOptional, + IsString, + IsUrl +} from 'class-validator'; + +export class CreateAssetProfileDto { + @IsEnum(AssetClass, { each: true }) + @IsOptional() + assetClass?: AssetClass; + + @IsEnum(AssetSubClass, { each: true }) + @IsOptional() + assetSubClass?: AssetSubClass; + + @IsOptional() + @IsString() + comment?: string; + + @IsArray() + @IsOptional() + countries?: Prisma.InputJsonArray; + + @IsCurrencyCode() + currency: string; + + @IsOptional() + @IsString() + cusip?: string; + + @IsEnum(DataSource) + dataSource: DataSource; + + @IsOptional() + @IsString() + figi?: string; + + @IsOptional() + @IsString() + figiComposite?: string; + + @IsOptional() + @IsString() + figiShareClass?: string; + + @IsArray() + @IsOptional() + holdings?: Prisma.InputJsonArray; + + @IsBoolean() + @IsOptional() + isActive?: boolean; + + @IsOptional() + @IsString() + isin?: string; + + @IsOptional() + @IsString() + name?: string; + + @IsObject() + @IsOptional() + scraperConfiguration?: Prisma.InputJsonObject; + + @IsArray() + @IsOptional() + sectors?: Prisma.InputJsonArray; + + @IsString() + symbol: string; + + @IsObject() + @IsOptional() + symbolMapping?: { + [dataProvider: string]: string; + }; + + @IsOptional() + @IsUrl({ + protocols: ['https'], + require_protocol: true + }) + url?: string; +} diff --git a/apps/api/src/app/export/export.service.ts b/apps/api/src/app/export/export.service.ts index 9c9d20682..54fd0763d 100644 --- a/apps/api/src/app/export/export.service.ts +++ b/apps/api/src/app/export/export.service.ts @@ -195,7 +195,6 @@ export class ExportService { figiComposite, figiShareClass, holdings: holdings as unknown as Prisma.JsonArray, - id, isActive, isin, marketData: marketDataByAssetProfile[id], diff --git a/apps/api/src/app/import/create-asset-profile-with-maketdata.dto.ts b/apps/api/src/app/import/create-asset-profile-with-maketdata.dto.ts new file mode 100644 index 000000000..8d9a4764e --- /dev/null +++ b/apps/api/src/app/import/create-asset-profile-with-maketdata.dto.ts @@ -0,0 +1,11 @@ +import { MarketData } from '@ghostfolio/common/interfaces/market-data.interface'; + +import { IsArray, IsOptional } from 'class-validator'; + +import { CreateAssetProfileDto } from '../admin/create-asset-profile.dto'; + +export class CreateAssetProfileWithMarketDataDto extends CreateAssetProfileDto { + @IsArray() + @IsOptional() + marketData?: MarketData[]; +} diff --git a/apps/api/src/app/import/import-data.dto.ts b/apps/api/src/app/import/import-data.dto.ts index 207c8152b..6066f17ce 100644 --- a/apps/api/src/app/import/import-data.dto.ts +++ b/apps/api/src/app/import/import-data.dto.ts @@ -4,6 +4,7 @@ import { Type } from 'class-transformer'; import { IsArray, IsOptional, ValidateNested } from 'class-validator'; import { CreateAccountWithBalancesDto } from './create-account-with-balances.dto'; +import { CreateAssetProfileWithMarketDataDto } from './create-asset-profile-with-maketdata.dto'; export class ImportDataDto { @IsOptional() @@ -16,4 +17,9 @@ export class ImportDataDto { @Type(() => CreateOrderDto) @ValidateNested({ each: true }) activities: CreateOrderDto[]; + + @IsArray() + @Type(() => CreateAssetProfileWithMarketDataDto) + @ValidateNested({ each: true }) + assetProfiles: CreateAssetProfileWithMarketDataDto[]; } diff --git a/apps/api/src/app/import/import.controller.ts b/apps/api/src/app/import/import.controller.ts index 081541fee..8c06bdc6d 100644 --- a/apps/api/src/app/import/import.controller.ts +++ b/apps/api/src/app/import/import.controller.ts @@ -73,6 +73,7 @@ export class ImportController { maxActivitiesToImport, accountsWithBalancesDto: importData.accounts ?? [], activitiesDto: importData.activities, + assetProfilesWithMarketDataDto: importData.assetProfiles ?? [], user: this.request.user }); diff --git a/apps/api/src/app/import/import.module.ts b/apps/api/src/app/import/import.module.ts index 142a939a6..fb6c29fc5 100644 --- a/apps/api/src/app/import/import.module.ts +++ b/apps/api/src/app/import/import.module.ts @@ -9,6 +9,7 @@ import { TransformDataSourceInResponseModule } from '@ghostfolio/api/interceptor import { ConfigurationModule } from '@ghostfolio/api/services/configuration/configuration.module'; import { DataProviderModule } from '@ghostfolio/api/services/data-provider/data-provider.module'; import { ExchangeRateDataModule } from '@ghostfolio/api/services/exchange-rate-data/exchange-rate-data.module'; +import { MarketDataModule } from '@ghostfolio/api/services/market-data/market-data.module'; import { PrismaModule } from '@ghostfolio/api/services/prisma/prisma.module'; import { DataGatheringModule } from '@ghostfolio/api/services/queues/data-gathering/data-gathering.module'; import { SymbolProfileModule } from '@ghostfolio/api/services/symbol-profile/symbol-profile.module'; @@ -27,6 +28,7 @@ import { ImportService } from './import.service'; DataGatheringModule, DataProviderModule, ExchangeRateDataModule, + MarketDataModule, OrderModule, PlatformModule, PortfolioModule, diff --git a/apps/api/src/app/import/import.service.ts b/apps/api/src/app/import/import.service.ts index e41dcd819..3867962a7 100644 --- a/apps/api/src/app/import/import.service.ts +++ b/apps/api/src/app/import/import.service.ts @@ -10,6 +10,7 @@ import { PlatformService } from '@ghostfolio/api/app/platform/platform.service'; import { PortfolioService } from '@ghostfolio/api/app/portfolio/portfolio.service'; import { ConfigurationService } from '@ghostfolio/api/services/configuration/configuration.service'; import { DataProviderService } from '@ghostfolio/api/services/data-provider/data-provider.service'; +import { MarketDataService } from '@ghostfolio/api/services/market-data/market-data.service'; import { DataGatheringService } from '@ghostfolio/api/services/queues/data-gathering/data-gathering.service'; import { SymbolProfileService } from '@ghostfolio/api/services/symbol-profile/symbol-profile.service'; import { DATA_GATHERING_QUEUE_PRIORITY_HIGH } from '@ghostfolio/common/config'; @@ -31,6 +32,7 @@ import { endOfToday, isAfter, isSameSecond, parseISO } from 'date-fns'; import { omit, uniqBy } from 'lodash'; import { v4 as uuidv4 } from 'uuid'; +import { CreateAssetProfileDto } from '../admin/create-asset-profile.dto'; import { ImportDataDto } from './import-data.dto'; @Injectable() @@ -40,6 +42,7 @@ export class ImportService { private readonly configurationService: ConfigurationService, private readonly dataGatheringService: DataGatheringService, private readonly dataProviderService: DataProviderService, + private readonly marketDataService: MarketDataService, private readonly orderService: OrderService, private readonly platformService: PlatformService, private readonly portfolioService: PortfolioService, @@ -148,17 +151,20 @@ export class ImportService { public async import({ accountsWithBalancesDto, activitiesDto, + assetProfilesWithMarketDataDto, isDryRun = false, maxActivitiesToImport, user }: { accountsWithBalancesDto: ImportDataDto['accounts']; activitiesDto: ImportDataDto['activities']; + assetProfilesWithMarketDataDto: ImportDataDto['assetProfiles']; isDryRun?: boolean; maxActivitiesToImport: number; user: UserWithSettings; }): Promise { const accountIdMapping: { [oldAccountId: string]: string } = {}; + const assetProfileSymbolMapping: { [oldSymbol: string]: string } = {}; const userCurrency = user.settings.settings.baseCurrency; if (!isDryRun && accountsWithBalancesDto?.length) { @@ -230,6 +236,64 @@ export class ImportService { } } + if (!isDryRun && assetProfilesWithMarketDataDto?.length) { + const existingAssetProfiles = + await this.symbolProfileService.getSymbolProfiles( + assetProfilesWithMarketDataDto.map(({ dataSource, symbol }) => { + return { dataSource, symbol }; + }) + ); + + for (const assetProfileWithMarketData of assetProfilesWithMarketDataDto) { + // Check if there is any existing asset profile + const existingAssetProfile = existingAssetProfiles.find( + (existingAssetProfile) => { + return ( + existingAssetProfile.dataSource === + assetProfileWithMarketData.dataSource && + existingAssetProfile.symbol === assetProfileWithMarketData.symbol + ); + } + ); + + // If there is no asset profile or if the asset profile belongs to a different user then create a new asset profile + if (!existingAssetProfile || existingAssetProfile.userId !== user.id) { + const assetProfile: CreateAssetProfileDto = omit( + assetProfileWithMarketData, + 'marketData' + ); + + // Asset profile belongs to a different user + if (existingAssetProfile) { + const symbol = uuidv4(); // Generate a new symbol for the asset profile + assetProfileSymbolMapping[assetProfile.symbol] = symbol; + assetProfile.symbol = symbol; + } + + // Create a new asset profile + const assetProfileObject: Prisma.SymbolProfileCreateInput = { + ...assetProfile, + user: { connect: { id: user.id } } + }; + + await this.symbolProfileService.add(assetProfileObject); + } + + // Insert or update market data + const marketDataObjects = assetProfileWithMarketData.marketData.map( + (marketData) => { + return { + ...marketData, + dataSource: assetProfileWithMarketData.dataSource, + symbol: assetProfileWithMarketData.symbol + } as Prisma.MarketDataUpdateInput; + } + ); + + await this.marketDataService.updateMany({ data: marketDataObjects }); + } + } + for (const activity of activitiesDto) { if (!activity.dataSource) { if (['FEE', 'INTEREST', 'LIABILITY'].includes(activity.type)) { @@ -240,11 +304,16 @@ export class ImportService { } } - // If a new account is created, then update the accountId in all activities if (!isDryRun) { - if (Object.keys(accountIdMapping).includes(activity.accountId)) { + // If a new account is created, then update the accountId in all activities + if (accountIdMapping[activity.accountId]) { activity.accountId = accountIdMapping[activity.accountId]; } + + // If a new asset profile is created, then update the symbol in all activities + if (assetProfileSymbolMapping[activity.symbol]) { + activity.symbol = assetProfileSymbolMapping[activity.symbol]; + } } } diff --git a/apps/client/src/app/pages/portfolio/activities/import-activities-dialog/import-activities-dialog.component.ts b/apps/client/src/app/pages/portfolio/activities/import-activities-dialog/import-activities-dialog.component.ts index b9f0986ac..99adfa2cc 100644 --- a/apps/client/src/app/pages/portfolio/activities/import-activities-dialog/import-activities-dialog.component.ts +++ b/apps/client/src/app/pages/portfolio/activities/import-activities-dialog/import-activities-dialog.component.ts @@ -1,4 +1,5 @@ -import { CreateAccountDto } from '@ghostfolio/api/app/account/create-account.dto'; +import { CreateAccountWithBalancesDto } from '@ghostfolio/api/app/import/create-account-with-balances.dto'; +import { CreateAssetProfileWithMarketDataDto } from '@ghostfolio/api/app/import/create-asset-profile-with-maketdata.dto'; import { Activity } from '@ghostfolio/api/app/order/interfaces/activities.interface'; import { GfDialogFooterModule } from '@ghostfolio/client/components/dialog-footer/dialog-footer.module'; import { GfDialogHeaderModule } from '@ghostfolio/client/components/dialog-header/dialog-header.module'; @@ -75,9 +76,10 @@ import { ImportActivitiesDialogParams } from './interfaces/interfaces'; templateUrl: 'import-activities-dialog.html' }) export class GfImportActivitiesDialog implements OnDestroy { - public accounts: CreateAccountDto[] = []; + public accounts: CreateAccountWithBalancesDto[] = []; public activities: Activity[] = []; public assetProfileForm: FormGroup; + public assetProfiles: CreateAssetProfileWithMarketDataDto[] = []; public dataSource: MatTableDataSource; public details: any[] = []; public deviceType: string; @@ -166,7 +168,8 @@ export class GfImportActivitiesDialog implements OnDestroy { await this.importActivitiesService.importSelectedActivities({ accounts: this.accounts, - activities: this.selectedActivities + activities: this.selectedActivities, + assetProfiles: this.assetProfiles }); this.snackBar.open( @@ -293,6 +296,7 @@ export class GfImportActivitiesDialog implements OnDestroy { const content = JSON.parse(fileContent); this.accounts = content.accounts; + this.assetProfiles = content.assetProfiles; if (!isArray(content.activities)) { if (isArray(content.orders)) { @@ -323,6 +327,7 @@ export class GfImportActivitiesDialog implements OnDestroy { await this.importActivitiesService.importJson({ accounts: content.accounts, activities: content.activities, + assetProfiles: content.assetProfiles, isDryRun: true }); this.activities = activities; diff --git a/apps/client/src/app/services/import-activities.service.ts b/apps/client/src/app/services/import-activities.service.ts index a4ffa7ec4..b70713758 100644 --- a/apps/client/src/app/services/import-activities.service.ts +++ b/apps/client/src/app/services/import-activities.service.ts @@ -1,4 +1,5 @@ -import { CreateAccountDto } from '@ghostfolio/api/app/account/create-account.dto'; +import { CreateAccountWithBalancesDto } from '@ghostfolio/api/app/import/create-account-with-balances.dto'; +import { CreateAssetProfileWithMarketDataDto } from '@ghostfolio/api/app/import/create-asset-profile-with-maketdata.dto'; import { CreateOrderDto } from '@ghostfolio/api/app/order/create-order.dto'; import { Activity } from '@ghostfolio/api/app/order/interfaces/activities.interface'; import { parseDate as parseDateHelper } from '@ghostfolio/common/helper'; @@ -73,20 +74,22 @@ export class ImportActivitiesService { public importJson({ accounts, activities, + assetProfiles, isDryRun = false }: { activities: CreateOrderDto[]; - accounts?: CreateAccountDto[]; + accounts?: CreateAccountWithBalancesDto[]; + assetProfiles?: CreateAssetProfileWithMarketDataDto[]; isDryRun?: boolean; }): Promise<{ activities: Activity[]; - accounts?: CreateAccountDto[]; }> { return new Promise((resolve, reject) => { this.postImport( { accounts, - activities + activities, + assetProfiles }, isDryRun ) @@ -106,13 +109,14 @@ export class ImportActivitiesService { public importSelectedActivities({ accounts, - activities + activities, + assetProfiles }: { - accounts: CreateAccountDto[]; + accounts?: CreateAccountWithBalancesDto[]; activities: Activity[]; + assetProfiles?: CreateAssetProfileWithMarketDataDto[]; }): Promise<{ activities: Activity[]; - accounts?: CreateAccountDto[]; }> { const importData: CreateOrderDto[] = []; @@ -120,7 +124,7 @@ export class ImportActivitiesService { importData.push(this.convertToCreateOrderDto(activity)); } - return this.importJson({ accounts, activities: importData }); + return this.importJson({ accounts, activities: importData, assetProfiles }); } private convertToCreateOrderDto({ @@ -383,7 +387,11 @@ export class ImportActivitiesService { } private postImport( - aImportData: { accounts: CreateAccountDto[]; activities: CreateOrderDto[] }, + aImportData: { + accounts?: CreateAccountWithBalancesDto[]; + activities: CreateOrderDto[]; + assetProfiles?: CreateAssetProfileWithMarketDataDto[]; + }, aIsDryRun = false ) { return this.http.post<{ activities: Activity[] }>( diff --git a/libs/common/src/lib/interfaces/export.interface.ts b/libs/common/src/lib/interfaces/export.interface.ts index 905e33b06..16a49b0ef 100644 --- a/libs/common/src/lib/interfaces/export.interface.ts +++ b/libs/common/src/lib/interfaces/export.interface.ts @@ -8,6 +8,7 @@ import { } from '@prisma/client'; import { AccountBalance } from './account-balance.interface'; +import { MarketData } from './market-data.interface'; export interface Export { accounts: (Omit & { @@ -23,8 +24,11 @@ export interface Export { | 'updatedAt' | 'userId' > & { dataSource: DataSource; date: string; symbol: string })[]; - assetProfiles: (Omit & { - marketData: { date: string; marketPrice: number }[]; + assetProfiles: (Omit< + SymbolProfile, + 'createdAt' | 'id' | 'updatedAt' | 'userId' + > & { + marketData: MarketData[]; })[]; meta: { date: string; diff --git a/libs/common/src/lib/interfaces/market-data.interface.ts b/libs/common/src/lib/interfaces/market-data.interface.ts new file mode 100644 index 000000000..b7a410cba --- /dev/null +++ b/libs/common/src/lib/interfaces/market-data.interface.ts @@ -0,0 +1,4 @@ +export interface MarketData { + date: string; + marketPrice: number; +}