Browse Source

Feature/merge asset profile into existing asset profile (#7654)

* Merge asset profile into existing asset profile

* Update changelog
pull/7626/head
Thomas Kaul 1 day ago
committed by GitHub
parent
commit
87cb77a8fd
No known key found for this signature in database GPG Key ID: B5690EEEBB952194
  1. 1
      CHANGELOG.md
  2. 16
      apps/api/src/app/admin/admin.controller.ts
  3. 2
      apps/api/src/app/admin/admin.module.ts
  4. 229
      apps/api/src/app/admin/admin.service.ts
  5. 2
      apps/api/src/app/endpoints/asset-profiles/asset-profiles.service.spec.ts
  6. 77
      apps/api/src/app/endpoints/asset-profiles/asset-profiles.service.ts
  7. 60
      apps/api/src/services/benchmark/benchmark.service.ts
  8. 9
      apps/api/src/services/queues/data-gathering/data-gathering.service.ts
  9. 4
      apps/client/src/app/components/admin-market-data/admin-market-data.component.ts
  10. 85
      apps/client/src/app/components/admin-market-data/asset-profile-dialog/asset-profile-dialog.component.ts
  11. 2
      libs/common/src/lib/dtos/index.ts
  12. 10
      libs/common/src/lib/dtos/merge-asset-profile.dto.ts
  13. 25
      libs/common/src/lib/helper.ts
  14. 11
      libs/ui/src/lib/services/admin.service.ts

1
CHANGELOG.md

@ -10,6 +10,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
### Added
- Added the write scopes to the access
- Added support to merge an asset profile into an existing asset profile in the asset profile dialog of the admin control panel (experimental)
### Changed

16
apps/api/src/app/admin/admin.controller.ts

@ -15,6 +15,7 @@ import {
GATHER_ASSET_PROFILE_PROCESS_JOB_OPTIONS
} from '@ghostfolio/common/config';
import {
MergeAssetProfileDto,
UpdateAssetProfileDto,
UpdatePropertyDto
} from '@ghostfolio/common/dtos';
@ -299,6 +300,21 @@ export class AdminController {
return this.adminService.deleteProfileData({ dataSource, symbol });
}
@HasPermission(permissions.accessAdminControl)
@Post('profile-data/:dataSource/:symbol/merge')
@UseGuards(AuthGuard('jwt'), HasPermissionGuard)
@UseInterceptors(TransformDataSourceInRequestInterceptor)
public async mergeAssetProfile(
@Body() targetAssetProfile: MergeAssetProfileDto,
@Param('dataSource') dataSource: DataSource,
@Param('symbol') symbol: string
): Promise<EnhancedAssetProfile> {
return this.adminService.mergeAssetProfile(
{ dataSource, symbol },
targetAssetProfile
);
}
@HasPermission(permissions.accessAdminControl)
@Patch('profile-data/:dataSource/:symbol')
@UseGuards(AuthGuard('jwt'), HasPermissionGuard)

2
apps/api/src/app/admin/admin.module.ts

@ -1,3 +1,4 @@
import { AssetProfilesModule } from '@ghostfolio/api/app/endpoints/asset-profiles/asset-profiles.module';
import { TransformDataSourceInRequestModule } from '@ghostfolio/api/interceptors/transform-data-source-in-request/transform-data-source-in-request.module';
import { BenchmarkModule } from '@ghostfolio/api/services/benchmark/benchmark.module';
import { ConfigurationModule } from '@ghostfolio/api/services/configuration/configuration.module';
@ -18,6 +19,7 @@ import { QueueModule } from './queue/queue.module';
@Module({
imports: [
AssetProfilesModule,
BenchmarkModule,
ConfigurationModule,
DataGatheringQueueModule,

229
apps/api/src/app/admin/admin.service.ts

@ -1,4 +1,6 @@
import { AssetProfilesService } from '@ghostfolio/api/app/endpoints/asset-profiles/asset-profiles.service';
import { environment } from '@ghostfolio/api/environments/environment';
import { BenchmarkService } from '@ghostfolio/api/services/benchmark/benchmark.service';
import { ConfigurationService } from '@ghostfolio/api/services/configuration/configuration.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';
@ -14,6 +16,7 @@ import {
} from '@ghostfolio/common/config';
import {
applyAssetProfileOverrides,
canMergeAssetProfile,
getAssetProfileIdentifier,
getCurrencyFromSymbol,
hasGhostfolioPrefix
@ -22,14 +25,18 @@ import {
AdminData,
AdminUserResponse,
AdminUsersResponse,
AssetProfileIdentifier
AssetProfileIdentifier,
EnhancedAssetProfile
} from '@ghostfolio/common/interfaces';
import { PropertyKey } from '@ghostfolio/common/types';
import {
BadRequestException,
ConflictException,
HttpException,
Injectable,
InternalServerErrorException,
Logger,
NotFoundException
} from '@nestjs/common';
import {
@ -46,7 +53,11 @@ import { randomUUID } from 'node:crypto';
@Injectable()
export class AdminService {
private readonly logger = new Logger(AdminService.name);
public constructor(
private readonly assetProfilesService: AssetProfilesService,
private readonly benchmarkService: BenchmarkService,
private readonly configurationService: ConfigurationService,
private readonly dataProviderService: DataProviderService,
private readonly exchangeRateDataService: ExchangeRateDataService,
@ -233,6 +244,222 @@ export class AdminService {
return { count, users };
}
/**
* Merges the source asset profile into the target asset profile. The
* activities, the watchlist entries and the market data which is missing
* there are moved to the target asset profile. The metadata of the source
* asset profile is discarded, because the target asset profile is the
* authoritative one. Then the source asset profile is deleted and the market
* data of the target asset profile is gathered again.
*/
public async mergeAssetProfile(
sourceAssetProfileIdentifier: AssetProfileIdentifier,
targetAssetProfileIdentifier: AssetProfileIdentifier
): Promise<EnhancedAssetProfile> {
if (
getAssetProfileIdentifier(sourceAssetProfileIdentifier) ===
getAssetProfileIdentifier(targetAssetProfileIdentifier)
) {
throw new BadRequestException(
'The source and the target asset profile must be different'
);
}
// The rules of the symbol apply to both asset profiles and are examined
// first. The remaining rules apply to the source asset profile only,
// because the merge deletes it
for (const assetProfileIdentifier of [
sourceAssetProfileIdentifier,
targetAssetProfileIdentifier
]) {
if (!canMergeAssetProfile(assetProfileIdentifier)) {
throw new BadRequestException(
`The asset profile ${getAssetProfileIdentifier(assetProfileIdentifier)} cannot be merged`
);
}
}
const [sourceAssetProfile, targetAssetProfile] = await Promise.all([
this.prismaService.symbolProfile.findUnique({
include: { watchedBy: { select: { id: true } } },
where: {
dataSource_symbol: {
dataSource: sourceAssetProfileIdentifier.dataSource,
symbol: sourceAssetProfileIdentifier.symbol
}
}
}),
this.prismaService.symbolProfile.findUnique({
where: {
dataSource_symbol: {
dataSource: targetAssetProfileIdentifier.dataSource,
symbol: targetAssetProfileIdentifier.symbol
}
}
})
]);
if (!sourceAssetProfile || !targetAssetProfile) {
throw new NotFoundException(
'The source or the target asset profile does not exist'
);
}
// An activity without a currency inherits the currency of its asset
// profile, hence a merge into an asset profile with another currency
// would change the value of the moved activities
if (sourceAssetProfile.currency !== targetAssetProfile.currency) {
throw new BadRequestException(
`The currency of the source asset profile (${sourceAssetProfile.currency}) does not match the currency of the target asset profile (${targetAssetProfile.currency})`
);
}
// A user asset profile must not become accessible to another user and a
// user must not become unable to be deleted, because the activities of the
// source asset profile would keep a reference to it
if (sourceAssetProfile.userId !== targetAssetProfile.userId) {
throw new BadRequestException(
'The source and the target asset profile must belong to the same user'
);
}
const [isBenchmark, splitsCount] = await Promise.all([
this.benchmarkService.isBenchmark(sourceAssetProfile.id),
this.prismaService.assetProfileSplit.count({
where: {
symbolProfileId: {
in: [sourceAssetProfile.id, targetAssetProfile.id]
}
}
})
]);
if (
!canMergeAssetProfile({
isBenchmark,
splitsCount,
symbol: sourceAssetProfileIdentifier.symbol
})
) {
throw new BadRequestException(
`The asset profile ${getAssetProfileIdentifier(sourceAssetProfileIdentifier)} cannot be merged. Remove the benchmark and the splits before the merge.`
);
}
const marketDataItems = await this.prismaService.marketData.findMany({
select: { date: true, marketPrice: true, state: true },
where: {
dataSource: sourceAssetProfileIdentifier.dataSource,
symbol: sourceAssetProfileIdentifier.symbol
}
});
const operations: Prisma.PrismaPromise<unknown>[] = [
this.prismaService.order.updateMany({
data: { symbolProfileId: targetAssetProfile.id },
where: { symbolProfileId: sourceAssetProfile.id }
}),
this.prismaService.symbolProfile.update({
data: {
watchedBy: {
connect: sourceAssetProfile.watchedBy.map(({ id }) => {
return { id };
})
}
},
where: { id: targetAssetProfile.id }
}),
this.prismaService.marketData.createMany({
data: marketDataItems.map(({ date, marketPrice, state }) => {
return {
date,
marketPrice,
state,
dataSource: targetAssetProfileIdentifier.dataSource,
symbol: targetAssetProfileIdentifier.symbol
};
}),
skipDuplicates: true
}),
// The market data has no relation to the asset profile and is therefore
// not deleted in cascade
this.prismaService.marketData.deleteMany({
where: {
dataSource: sourceAssetProfileIdentifier.dataSource,
symbol: sourceAssetProfileIdentifier.symbol
}
}),
this.prismaService.symbolProfile.delete({
where: { id: sourceAssetProfile.id }
})
];
const mergeDescription = `${getAssetProfileIdentifier(
sourceAssetProfileIdentifier
)} into ${getAssetProfileIdentifier(targetAssetProfileIdentifier)}`;
try {
await this.prismaService.$transaction(operations);
} catch (error) {
this.logger.error(
`Could not merge the asset profile ${mergeDescription}`,
error.stack
);
if (error instanceof Prisma.PrismaClientKnownRequestError) {
throw new ConflictException(
`The asset profile ${mergeDescription} could not be merged, because it has been changed in the meantime`
);
}
throw new InternalServerErrorException(
`The asset profile ${mergeDescription} could not be merged`
);
}
// The merge is committed at this point. The gathering of the market data
// only updates derived data, thus an error must not fail the request.
// Otherwise the admin would retry a merge which has already been done.
try {
// The activities now refer to the market data of the target asset
// profile, hence the portfolio snapshots are already wrong and are
// invalidated immediately. The market data is not gathered with force,
// because a forced gathering deletes the market data of the days which
// the data provider does not return, including the days which have been
// copied from the source asset profile.
await this.assetProfilesService.gatherSymbolAndEmitPortfolioChangedEvents(
{
...targetAssetProfileIdentifier,
force: false,
symbolProfileId: targetAssetProfile.id,
withImmediateInvalidation: true
}
);
} catch (error) {
this.logger.error(
`The asset profile ${mergeDescription} has been merged, but the market data could not be gathered`,
error.stack
);
}
const [mergedAssetProfile] =
await this.symbolProfileService.getSymbolProfiles([
targetAssetProfileIdentifier
]);
if (!mergedAssetProfile) {
this.logger.error(
`The asset profile ${mergeDescription} has been merged, but the merged asset profile could not be read`
);
throw new InternalServerErrorException(
`The asset profile ${mergeDescription} has been merged, but the merged asset profile could not be read. Reload the page.`
);
}
return mergedAssetProfile;
}
public async patchAssetProfileData(
{ dataSource, symbol }: AssetProfileIdentifier,
{

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

@ -66,6 +66,7 @@ describe('AssetProfilesService', () => {
});
expect(gatherSymbol).toHaveBeenCalledWith({
dataSource: data.dataSource,
force: true,
symbol: data.symbol
});
expect(result).toBe(split);
@ -166,6 +167,7 @@ describe('AssetProfilesService', () => {
]);
expect(gatherSymbol).toHaveBeenCalledWith({
dataSource: DataSource.YAHOO,
force: true,
symbol: 'AAPL'
});
});

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

@ -25,13 +25,15 @@ import {
} from '@ghostfolio/common/interfaces';
import { MarketDataPreset } from '@ghostfolio/common/types';
import { Injectable, NotFoundException } from '@nestjs/common';
import { Injectable, Logger, NotFoundException } from '@nestjs/common';
import { EventEmitter2 } from '@nestjs/event-emitter';
import { AssetClass, AssetSubClass, DataSource, Prisma } from '@prisma/client';
import { groupBy } from 'lodash';
@Injectable()
export class AssetProfilesService {
private readonly logger = new Logger(AssetProfilesService.name);
public constructor(
private readonly activitiesService: ActivitiesService,
private readonly assetProfileSplitService: AssetProfileSplitService,
@ -99,6 +101,54 @@ export class AssetProfilesService {
});
}
/**
* 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.
*
* With withImmediateInvalidation the snapshots are invalidated a second
* time, before the gathering. This is necessary if the snapshots are already
* wrong without new market data, because the invalidation after the
* gathering is held in memory and is therefore lost if the process restarts.
*/
public async gatherSymbolAndEmitPortfolioChangedEvents({
dataSource,
force = true,
symbol,
symbolProfileId,
withImmediateInvalidation = false
}: {
force?: boolean;
symbolProfileId: string;
withImmediateInvalidation?: boolean;
} & AssetProfileIdentifier) {
if (withImmediateInvalidation) {
await this.emitPortfolioChangedEvents(symbolProfileId);
}
const jobs = await this.dataGatheringService.gatherSymbol({
dataSource,
force,
symbol
});
void Promise.allSettled(
jobs.map((job) => {
return job.finished();
})
)
.then(() => {
return this.emitPortfolioChangedEvents(symbolProfileId);
})
.catch((error) => {
this.logger.error(
`Could not emit the portfolio changed events of the asset profile ${getAssetProfileIdentifier({ dataSource, symbol })}`,
error.stack
);
});
}
public async getAssetProfile({
dataSource,
symbol
@ -452,31 +502,6 @@ export class AssetProfilesService {
}
}
/**
* 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,
symbolProfileId
}: { symbolProfileId: string } & AssetProfileIdentifier) {
const jobs = await this.dataGatheringService.gatherSymbol({
dataSource,
symbol
});
void Promise.allSettled(
jobs.map((job) => {
return job.finished();
})
).then(() => {
return this.emitPortfolioChangedEvents(symbolProfileId);
});
}
private getAssetProfileDataUpdate({
countries,
holdings,

60
apps/api/src/services/benchmark/benchmark.service.ts

@ -145,7 +145,7 @@ export class BenchmarkService {
public async addBenchmark({
dataSource,
symbol
}: AssetProfileIdentifier): Promise<Partial<SymbolProfile> | undefined> {
}: AssetProfileIdentifier): Promise<Partial<SymbolProfile> | null> {
const assetProfile = await this.prismaService.symbolProfile.findFirst({
where: {
dataSource,
@ -154,23 +154,14 @@ export class BenchmarkService {
});
if (!assetProfile) {
return;
return null;
}
let benchmarks =
(await this.propertyService.getByKey<BenchmarkProperty[]>(
PROPERTY_BENCHMARKS,
{ skipCache: true }
)) ?? [];
const benchmarks = await this.getBenchmarksProperty();
benchmarks.push({ symbolProfileId: assetProfile.id });
benchmarks = uniqBy(benchmarks, 'symbolProfileId');
await this.propertyService.put({
key: PROPERTY_BENCHMARKS,
value: JSON.stringify(benchmarks)
});
await this.putBenchmarksProperty(uniqBy(benchmarks, 'symbolProfileId'));
return {
dataSource,
@ -195,20 +186,13 @@ export class BenchmarkService {
return null;
}
let benchmarks =
(await this.propertyService.getByKey<BenchmarkProperty[]>(
PROPERTY_BENCHMARKS,
{ skipCache: true }
)) ?? [];
const benchmarks = await this.getBenchmarksProperty();
benchmarks = benchmarks.filter(({ symbolProfileId }) => {
return symbolProfileId !== assetProfile.id;
});
await this.propertyService.put({
key: PROPERTY_BENCHMARKS,
value: JSON.stringify(benchmarks)
});
await this.putBenchmarksProperty(
benchmarks.filter((benchmark) => {
return benchmark.symbolProfileId !== assetProfile.id;
})
);
return {
dataSource,
@ -232,6 +216,14 @@ export class BenchmarkService {
}
}
public async isBenchmark(symbolProfileId: string): Promise<boolean> {
const benchmarks = await this.getBenchmarksProperty();
return benchmarks.some((benchmark) => {
return benchmark.symbolProfileId === symbolProfileId;
});
}
private async calculateAndCacheBenchmarks({
enableSharing = false
}): Promise<BenchmarkResponse['benchmarks']> {
@ -323,4 +315,20 @@ export class BenchmarkService {
return benchmarks;
}
private async getBenchmarksProperty(): Promise<BenchmarkProperty[]> {
return (
(await this.propertyService.getByKey<BenchmarkProperty[]>(
PROPERTY_BENCHMARKS,
{ skipCache: true }
)) ?? []
);
}
private async putBenchmarksProperty(benchmarks: BenchmarkProperty[]) {
await this.propertyService.put({
key: PROPERTY_BENCHMARKS,
value: JSON.stringify(benchmarks)
});
}
}

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

@ -272,7 +272,12 @@ export class DataGatheringService {
});
}
public async gatherSymbol({ dataSource, date, symbol }: DataGatheringItem) {
public async gatherSymbol({
dataSource,
date,
force = true,
symbol
}: DataGatheringItem) {
const dataGatheringItems = (await this.getSymbolsMax())
.filter((dataGatheringItem) => {
return (
@ -287,7 +292,7 @@ export class DataGatheringService {
return this.gatherSymbols({
dataGatheringItems,
force: true,
force,
priority: DATA_GATHERING_QUEUE_PRIORITY_HIGH
});
}

4
apps/client/src/app/components/admin-market-data/admin-market-data.component.ts

@ -450,11 +450,11 @@ export class GfAdminMarketDataComponent implements AfterViewInit, OnInit {
.afterClosed()
.pipe(takeUntilDestroyed(this.destroyRef))
.subscribe((newAssetProfileIdentifier) => {
this.reloadData();
if (newAssetProfileIdentifier) {
this.onOpenAssetProfileDialog(newAssetProfileIdentifier);
} else {
this.reloadData();
this.router.navigate(['.'], { relativeTo: this.route });
}
});

85
apps/client/src/app/components/admin-market-data/asset-profile-dialog/asset-profile-dialog.component.ts

@ -8,6 +8,7 @@ import { UpdateAssetProfileDto } from '@ghostfolio/common/dtos';
import { ConfirmationDialogType } from '@ghostfolio/common/enums';
import {
canDeleteAssetProfile,
canMergeAssetProfile,
DATE_FORMAT,
getCountryName,
getCurrencyFromSymbol,
@ -797,13 +798,12 @@ export class GfAssetProfileDialogComponent implements OnInit {
}
this.patchAssetProfileIdentifier({
getErrorMessage: (error) => {
if (error.status === StatusCodes.CONFLICT) {
// TODO: Ask if the user wants to merge the two asset profiles
return $localize`${assetProfileIdentifier.symbol} (${assetProfileIdentifier.dataSource}) is already in use.`;
}
conflictFn: this.user?.settings?.isExperimentalFeatures
? () => {
this.mergeAssetProfile(newAssetProfileIdentifier);
}
: undefined,
getErrorMessage: () => {
return $localize`An error occurred while updating to ${assetProfileIdentifier.symbol} (${assetProfileIdentifier.dataSource}).`;
},
title: $localize`Do you really want to convert this asset profile to ${newAssetProfileIdentifier.symbol} (${newAssetProfileIdentifier.dataSource})?`,
@ -907,11 +907,72 @@ export class GfAssetProfileDialogComponent implements OnInit {
return null;
}
private mergeAssetProfile({ dataSource, symbol }: AssetProfileIdentifier) {
if (
!canMergeAssetProfile({
isBenchmark: this.isBenchmark,
splitsCount: this.splits.length,
symbol: this.data.symbol
}) ||
!canMergeAssetProfile({ symbol })
) {
this.notificationService.alert({
message: $localize`This asset profile cannot be merged into ${symbol} (${dataSource}).`,
title: $localize`Error`
});
return;
}
this.notificationService.confirm({
confirmFn: () => {
this.adminService
.mergeAssetProfile(
{
dataSource: this.data.dataSource,
symbol: this.data.symbol
},
{ dataSource, symbol }
)
.pipe(
catchError(({ error }: HttpErrorResponse) => {
this.notificationService.alert({
message:
error?.message ??
$localize`An error occurred while merging this asset profile into ${symbol} (${dataSource}).`,
title: $localize`Error`
});
return EMPTY;
}),
takeUntilDestroyed(this.destroyRef)
)
.subscribe((mergedAssetProfile) => {
this.dialogRef.close({
dataSource: mergedAssetProfile.dataSource,
symbol: mergedAssetProfile.symbol
});
});
},
confirmType: ConfirmationDialogType.Warn,
message:
$localize`The data of this asset profile is moved to ${symbol} (${dataSource}) and this asset profile is deleted.` +
' ' +
$localize`This action cannot be undone.`,
title:
$localize`${symbol} (${dataSource}) is already in use.` +
' ' +
$localize`Do you really want to merge this asset profile into it?`
});
}
private patchAssetProfileIdentifier({
conflictFn,
getErrorMessage,
title,
updateAssetProfileDto
}: {
conflictFn?: () => void;
getErrorMessage: (error: HttpErrorResponse) => string;
title: string;
updateAssetProfileDto: UpdateAssetProfileDto;
@ -929,9 +990,13 @@ export class GfAssetProfileDialogComponent implements OnInit {
)
.pipe(
catchError((error: HttpErrorResponse) => {
this.snackBar.open(getErrorMessage(error), undefined, {
duration: ms('3 seconds')
});
if (error.status === StatusCodes.CONFLICT && conflictFn) {
conflictFn();
} else {
this.snackBar.open(getErrorMessage(error), undefined, {
duration: ms('3 seconds')
});
}
return EMPTY;
}),

2
libs/common/src/lib/dtos/index.ts

@ -13,6 +13,7 @@ import { CreateTagDto } from './create-tag.dto';
import { CreateWatchlistItemDto } from './create-watchlist-item.dto';
import { DeleteOwnUserDto } from './delete-own-user.dto';
import { HoldingDto } from './holding.dto';
import { MergeAssetProfileDto } from './merge-asset-profile.dto';
import { ScraperConfigurationDto } from './scraper-configuration.dto';
import { SectorDto } from './sector.dto';
import { TransferBalanceDto } from './transfer-balance.dto';
@ -45,6 +46,7 @@ export {
CreateWatchlistItemDto,
DeleteOwnUserDto,
HoldingDto,
MergeAssetProfileDto,
ScraperConfigurationDto,
SectorDto,
TransferBalanceDto,

10
libs/common/src/lib/dtos/merge-asset-profile.dto.ts

@ -0,0 +1,10 @@
import { DataSource } from '@prisma/client';
import { IsEnum, IsString } from 'class-validator';
export class MergeAssetProfileDto {
@IsEnum(DataSource)
dataSource: DataSource;
@IsString()
symbol: string;
}

25
libs/common/src/lib/helper.ts

@ -185,9 +185,8 @@ export function canDeleteAssetProfile({
activitiesCount === 0 &&
!isBenchmark &&
!isDerivedCurrency(getCurrencyFromSymbol(symbol)) &&
!isFearAndGreedIndexSymbol(symbol) &&
!isRootCurrency(getCurrencyFromSymbol(symbol)) &&
symbol !== ghostfolioFearAndGreedIndexSymbolCryptocurrencies &&
symbol !== ghostfolioFearAndGreedIndexSymbolStocks &&
watchedByCount === 0
);
}
@ -202,6 +201,21 @@ export function canDeleteUser({
return currentUserId !== userId;
}
export function canMergeAssetProfile({
isBenchmark = false,
splitsCount = 0,
symbol
}: Pick<AssetProfileItem, 'isBenchmark' | 'symbol'> & {
splitsCount?: number;
}): boolean {
return (
!isBenchmark &&
!isCurrencySymbol(symbol) &&
!isFearAndGreedIndexSymbol(symbol) &&
splitsCount === 0
);
}
export function canOpenHoldingDetail({
assetProfile
}: Pick<PortfolioPosition, 'assetProfile'>): boolean {
@ -585,6 +599,13 @@ export function isDraftActivity(activity?: { tags?: { id: string }[] }) {
);
}
export function isFearAndGreedIndexSymbol(aSymbol: string) {
return (
aSymbol === ghostfolioFearAndGreedIndexSymbolCryptocurrencies ||
aSymbol === ghostfolioFearAndGreedIndexSymbolStocks
);
}
export function isRootCurrency(aCurrency: string) {
if (aCurrency === 'USD') {
return true;

11
libs/ui/src/lib/services/admin.service.ts

@ -6,6 +6,7 @@ import {
import {
CreateAssetProfileSplitDto,
CreatePlatformDto,
MergeAssetProfileDto,
UpdateAssetProfileDto,
UpdatePlatformDto
} from '@ghostfolio/common/dtos';
@ -188,6 +189,16 @@ export class AdminService {
return this.http.get<DataProviderHistoricalResponse>(url);
}
public mergeAssetProfile(
{ dataSource, symbol }: AssetProfileIdentifier,
targetAssetProfile: MergeAssetProfileDto
) {
return this.http.post<EnhancedAssetProfile>(
`/api/v1/admin/profile-data/${dataSource}/${encodeURIComponent(symbol)}/merge`,
targetAssetProfile
);
}
public patchAssetProfile(
{ dataSource, symbol }: AssetProfileIdentifier,
{

Loading…
Cancel
Save