diff --git a/CHANGELOG.md b/CHANGELOG.md index dcf6a6da9..1939ff659 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -11,6 +11,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Consolidated the duplicated translations of the asset classes and asset sub classes +### Fixed + +- Fixed the creation of asset profiles with a symbol in the wrong letter case + ## 3.62.0 - 2026-08-27 ### Added diff --git a/apps/api/src/app/activities/activities.controller.ts b/apps/api/src/app/activities/activities.controller.ts index caf4103b9..738589ba2 100644 --- a/apps/api/src/app/activities/activities.controller.ts +++ b/apps/api/src/app/activities/activities.controller.ts @@ -8,10 +8,12 @@ import { TransformDataSourceInResponseInterceptor } from '@ghostfolio/api/interc import { ApiService } from '@ghostfolio/api/services/api/api.service'; import { DataProviderService } from '@ghostfolio/api/services/data-provider/data-provider.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 { getIntervalFromDateRange } from '@ghostfolio/common/calculation-helper'; import { DATA_GATHERING_QUEUE_PRIORITY_HIGH } from '@ghostfolio/common/config'; import { CreateOrderDto, UpdateOrderDto } from '@ghostfolio/common/dtos'; import { SubscriptionType } from '@ghostfolio/common/enums'; +import { getAssetProfileIdentifier } from '@ghostfolio/common/helper'; import { ActivitiesResponse, ActivityResponse @@ -32,7 +34,7 @@ import { Query, UseInterceptors } from '@nestjs/common'; -import { Order } from '@prisma/client'; +import { Order, SymbolProfile } from '@prisma/client'; import { parseISO } from 'date-fns'; import { StatusCodes, getReasonPhrase } from 'http-status-codes'; @@ -46,7 +48,8 @@ export class ActivitiesController { private readonly activitiesService: ActivitiesService, private readonly apiService: ApiService, private readonly dataProviderService: DataProviderService, - private readonly dataGatheringService: DataGatheringService + private readonly dataGatheringService: DataGatheringService, + private readonly symbolProfileService: SymbolProfileService ) {} @Delete() @@ -224,8 +227,12 @@ export class ActivitiesController { ? userSubscription : authenticatedUserSubscription; + let assetProfiles: { + [assetProfileIdentifier: string]: Partial; + }; + try { - await this.dataProviderService.validateActivities({ + assetProfiles = await this.dataProviderService.validateActivities({ subscription, activitiesDto: [ { @@ -251,6 +258,15 @@ export class ActivitiesController { const customCurrency = data.customCurrency; const dataSource = data.dataSource; + const symbol = await this.symbolProfileService.getSymbolOfAssetProfile({ + dataSource, + symbol: data.symbol, + symbolOfDataProvider: + assetProfiles[ + getAssetProfileIdentifier({ dataSource, symbol: data.symbol }) + ]?.symbol + }); + if (customCurrency) { data.currency = customCurrency; @@ -268,12 +284,12 @@ export class ActivitiesController { create: { currency, dataSource, - symbol: data.symbol + symbol }, where: { dataSource_symbol: { dataSource, - symbol: data.symbol + symbol } } } @@ -291,8 +307,8 @@ export class ActivitiesController { dataGatheringItems: [ { dataSource, - date: activity.date, - symbol: data.symbol + symbol, + date: activity.date } ], priority: DATA_GATHERING_QUEUE_PRIORITY_HIGH @@ -330,6 +346,11 @@ export class ActivitiesController { const customCurrency = data.customCurrency; const dataSource = data.dataSource; + const symbol = await this.symbolProfileService.getSymbolOfAssetProfile({ + dataSource, + symbol: data.symbol + }); + delete data.accountId; if (customCurrency) { @@ -356,13 +377,12 @@ export class ActivitiesController { connect: { dataSource_symbol: { dataSource, - symbol: data.symbol + symbol } }, update: { assetClass: data.assetClass, - assetSubClass: data.assetSubClass, - name: data.symbol + assetSubClass: data.assetSubClass } }, tags: data.tags?.map((id) => { diff --git a/apps/api/src/app/activities/activities.service.ts b/apps/api/src/app/activities/activities.service.ts index 3627de912..5b9ca13bf 100644 --- a/apps/api/src/app/activities/activities.service.ts +++ b/apps/api/src/app/activities/activities.service.ts @@ -896,7 +896,6 @@ export class ActivitiesService { data.type === 'BUY') ) { delete data.SymbolProfile.connect; - delete data.SymbolProfile.update.name; } else { delete data.SymbolProfile.update; diff --git a/apps/api/src/app/admin/admin.service.ts b/apps/api/src/app/admin/admin.service.ts index 915c2068e..a985f92ef 100644 --- a/apps/api/src/app/admin/admin.service.ts +++ b/apps/api/src/app/admin/admin.service.ts @@ -102,9 +102,17 @@ export class AdminService { ); } - return this.symbolProfileService.add( - assetProfile as Prisma.SymbolProfileCreateInput - ); + const symbolOfAssetProfile = + await this.symbolProfileService.getSymbolOfAssetProfile({ + dataSource, + symbol, + symbolOfDataProvider: assetProfile.symbol + }); + + return this.symbolProfileService.add({ + ...assetProfile, + symbol: symbolOfAssetProfile + } as Prisma.SymbolProfileCreateInput); } catch (error) { if ( error instanceof Prisma.PrismaClientKnownRequestError && diff --git a/apps/api/src/app/endpoints/watchlist/watchlist.service.ts b/apps/api/src/app/endpoints/watchlist/watchlist.service.ts index 88702da00..f98a006ba 100644 --- a/apps/api/src/app/endpoints/watchlist/watchlist.service.ts +++ b/apps/api/src/app/endpoints/watchlist/watchlist.service.ts @@ -26,10 +26,12 @@ export class WatchlistService { public async createWatchlistItem({ dataSource, - symbol, + symbol: aSymbol, userId }: { userId: string } & AssetProfileIdentifier): Promise { - const symbolProfile = await this.prismaService.symbolProfile.findUnique({ + let symbol = aSymbol; + + let symbolProfile = await this.prismaService.symbolProfile.findUnique({ where: { dataSource_symbol: { dataSource, symbol } } @@ -49,9 +51,24 @@ export class WatchlistService { ); } - await this.symbolProfileService.add( - assetProfile as Prisma.SymbolProfileCreateInput - ); + symbol = await this.symbolProfileService.getSymbolOfAssetProfile({ + dataSource, + symbol, + symbolOfDataProvider: assetProfile.symbol + }); + + symbolProfile = await this.prismaService.symbolProfile.findUnique({ + where: { + dataSource_symbol: { dataSource, symbol } + } + }); + + if (!symbolProfile) { + await this.symbolProfileService.add({ + ...assetProfile, + symbol + } as Prisma.SymbolProfileCreateInput); + } } await this.dataGatheringService.gatherSymbol({ diff --git a/apps/api/src/app/import/import.service.ts b/apps/api/src/app/import/import.service.ts index 313325f04..388a1885b 100644 --- a/apps/api/src/app/import/import.service.ts +++ b/apps/api/src/app/import/import.service.ts @@ -26,6 +26,7 @@ import { } from '@ghostfolio/common/dtos'; import { getAssetProfileIdentifier, + isSameSymbol, isValidCustomAssetProfileSymbol, parseDate } from '@ghostfolio/common/helper'; @@ -736,6 +737,36 @@ export class ImportService { subscription: user.subscription }); + const assetProfileIdentifiers = uniqBy( + activitiesDto.map(({ dataSource, symbol }) => { + return { dataSource, symbol }; + }), + getAssetProfileIdentifier + ); + + for (const { dataSource, symbol } of assetProfileIdentifiers) { + const assetProfile = + assetProfiles[getAssetProfileIdentifier({ dataSource, symbol })]; + + const assetProfileInImport = assetProfilesWithMarketDataDto?.some( + (assetProfileWithMarketData) => { + return ( + assetProfileWithMarketData.dataSource === dataSource && + assetProfileWithMarketData.symbol === symbol + ); + } + ); + + if (assetProfile && !assetProfileInImport) { + assetProfile.symbol = + await this.symbolProfileService.getSymbolOfAssetProfile({ + dataSource, + symbol, + symbolOfDataProvider: assetProfile.symbol + }); + } + } + const activitiesExtendedWithErrors = await this.extendActivitiesWithErrors({ activitiesDto, userCurrency, @@ -852,9 +883,11 @@ export class ImportService { url, updatedAt } = assetProfile; + const validatedAccount = accounts.find(({ id }) => { return id === accountId; }); + const validatedTags = tags.filter(({ id: tagId }) => { return tagIds.some((activityTagId) => { return activityTagId === tagId; @@ -1056,7 +1089,10 @@ export class ImportService { isSameSecond(activity.date, date) && activity.fee === fee && activity.quantity === quantity && - activity.assetProfile.symbol === symbol && + isSameSymbol({ + symbol1: activity.assetProfile.symbol, + symbol2: symbol + }) && activity.type === type && activity.unitPrice === unitPrice ); diff --git a/apps/api/src/services/data-provider/data-provider.service.spec.ts b/apps/api/src/services/data-provider/data-provider.service.spec.ts new file mode 100644 index 000000000..9bcb0ff13 --- /dev/null +++ b/apps/api/src/services/data-provider/data-provider.service.spec.ts @@ -0,0 +1,97 @@ +import { getAssetProfileIdentifier } from '@ghostfolio/common/helper'; + +import { DataSource } from '@prisma/client'; + +import { DataProviderService } from './data-provider.service'; + +describe('DataProviderService', () => { + let dataProviderService: DataProviderService; + let getAssetProfile: jest.Mock; + + beforeEach(() => { + getAssetProfile = jest.fn(); + + const dataProviderInterface = { + getAssetProfile, + getName: () => { + return DataSource.YAHOO; + } + }; + + dataProviderService = new DataProviderService( + null, + [dataProviderInterface] as any, + null, + null, + null, + null + ); + }); + + describe('getAssetProfiles', () => { + it('Corrects the letter case of the symbol', async () => { + getAssetProfile.mockResolvedValue({ + currency: 'USD', + dataSource: DataSource.YAHOO, + name: 'Apple Inc.', + symbol: 'AAPL' + }); + + const assetProfiles = await dataProviderService.getAssetProfiles([ + { dataSource: DataSource.YAHOO, symbol: 'aapl' } + ]); + + expect( + assetProfiles[ + getAssetProfileIdentifier({ + dataSource: DataSource.YAHOO, + symbol: 'aapl' + }) + ].symbol + ).toEqual('AAPL'); + }); + + it('Keeps the requested symbol if the data provider resolves it to a different symbol', async () => { + getAssetProfile.mockResolvedValue({ + currency: 'USD', + dataSource: DataSource.YAHOO, + name: 'Meta Platforms, Inc.', + symbol: 'META' + }); + + const assetProfiles = await dataProviderService.getAssetProfiles([ + { dataSource: DataSource.YAHOO, symbol: 'FB' } + ]); + + expect( + assetProfiles[ + getAssetProfileIdentifier({ + dataSource: DataSource.YAHOO, + symbol: 'FB' + }) + ].symbol + ).toEqual('FB'); + }); + + it('Keeps the requested symbol if the data provider reports no symbol', async () => { + getAssetProfile.mockResolvedValue({ + currency: 'USD', + dataSource: DataSource.YAHOO, + name: 'Apple Inc.' + }); + + const assetProfiles = await dataProviderService.getAssetProfiles([ + { dataSource: DataSource.YAHOO, symbol: 'aapl' } + ]); + + expect( + assetProfiles[ + getAssetProfileIdentifier({ + dataSource: DataSource.YAHOO, + symbol: 'aapl' + }) + ].symbol + ).toEqual('aapl'); + }); + }); +}); diff --git a/apps/api/src/services/data-provider/data-provider.service.ts b/apps/api/src/services/data-provider/data-provider.service.ts index 0d0924f80..3a62a480a 100644 --- a/apps/api/src/services/data-provider/data-provider.service.ts +++ b/apps/api/src/services/data-provider/data-provider.service.ts @@ -23,6 +23,7 @@ import { getStartOfUtcDate, isCurrency, isDerivedCurrency, + isSameSymbol, isValidCustomAssetProfileSymbol, isValidSearchQuery } from '@ghostfolio/common/helper'; @@ -130,8 +131,13 @@ export class DataProviderService implements OnModuleInit { }) ] = { ...assetProfile, - symbol, - name: formatAssetProfileName(assetProfile) + name: formatAssetProfileName(assetProfile), + symbol: isSameSymbol({ + symbol1: symbol, + symbol2: assetProfile.symbol + }) + ? assetProfile.symbol + : symbol }; } }) diff --git a/apps/api/src/services/queues/data-gathering/data-gathering.service.spec.ts b/apps/api/src/services/queues/data-gathering/data-gathering.service.spec.ts index 4fc80d3d6..994222ef1 100644 --- a/apps/api/src/services/queues/data-gathering/data-gathering.service.spec.ts +++ b/apps/api/src/services/queues/data-gathering/data-gathering.service.spec.ts @@ -4,36 +4,55 @@ import { } from '@ghostfolio/common/config'; import { parseDate } from '@ghostfolio/common/helper'; +import { DataSource } from '@prisma/client'; + import { DataGatheringService } from './data-gathering.service'; describe('DataGatheringService', () => { let dataGatheringQueue: { addBulk: jest.Mock; clean: jest.Mock }; let dataGatheringService: DataGatheringService; - let dataProviderService: { getHistoricalRaw: jest.Mock }; - let prismaService: { marketData: { groupBy: jest.Mock; upsert: jest.Mock } }; + + let dataProviderService: { + getAssetProfiles: jest.Mock; + getHistoricalRaw: jest.Mock; + }; + + let prismaService: { + marketData: { groupBy: jest.Mock; upsert: jest.Mock }; + symbolProfile: { upsert: jest.Mock }; + }; + + let symbolProfileService: { getSymbolProfiles: jest.Mock }; beforeEach(() => { dataGatheringQueue = { addBulk: jest.fn().mockResolvedValue([]), clean: jest.fn().mockResolvedValue([]) }; - dataProviderService = { getHistoricalRaw: jest.fn() }; + dataProviderService = { + getAssetProfiles: jest.fn().mockResolvedValue({}), + getHistoricalRaw: jest.fn() + }; prismaService = { marketData: { groupBy: jest.fn().mockResolvedValue([]), upsert: jest.fn().mockResolvedValue({}) - } + }, + symbolProfile: { upsert: jest.fn().mockResolvedValue({}) } + }; + symbolProfileService = { + getSymbolProfiles: jest.fn().mockResolvedValue([]) }; dataGatheringService = new DataGatheringService( - null, + [], dataGatheringQueue as any, dataProviderService as any, null, null, prismaService as any, null, - null + symbolProfileService as any ); }); @@ -110,6 +129,55 @@ describe('DataGatheringService', () => { }); }); + describe('gatherAssetProfiles', () => { + it('Keeps the requested symbol, so that no duplicate asset profile is created', async () => { + dataProviderService.getAssetProfiles.mockResolvedValue({ + 'YAHOO-aapl': { + currency: 'USD', + dataSource: DataSource.YAHOO, + name: 'Apple Inc.', + symbol: 'AAPL' + } + }); + + await dataGatheringService.gatherAssetProfiles([ + { dataSource: DataSource.YAHOO, symbol: 'aapl' } + ]); + + expect(prismaService.symbolProfile.upsert).toHaveBeenCalledWith( + expect.objectContaining({ + where: { + dataSource_symbol: { + dataSource: DataSource.YAHOO, + symbol: 'aapl' + } + } + }) + ); + }); + + it('Creates a new asset profile with the symbol of the data provider', async () => { + dataProviderService.getAssetProfiles.mockResolvedValue({ + 'YAHOO-aapl': { + currency: 'USD', + dataSource: DataSource.YAHOO, + name: 'Apple Inc.', + symbol: 'AAPL' + } + }); + + await dataGatheringService.gatherAssetProfiles([ + { dataSource: DataSource.YAHOO, symbol: 'aapl' } + ]); + + expect(prismaService.symbolProfile.upsert).toHaveBeenCalledWith( + expect.objectContaining({ + create: expect.objectContaining({ symbol: 'AAPL' }) + }) + ); + }); + }); + describe('gatherRecentMarketData', () => { it('queries the asset profiles with recent market data once and reuses them', async () => { const assetProfileIdentifiersWithRecentMarketData = [ diff --git a/apps/api/src/services/queues/data-gathering/data-gathering.service.ts b/apps/api/src/services/queues/data-gathering/data-gathering.service.ts index 4cbe6969a..53472fb34 100644 --- a/apps/api/src/services/queues/data-gathering/data-gathering.service.ts +++ b/apps/api/src/services/queues/data-gathering/data-gathering.service.ts @@ -95,8 +95,13 @@ export class DataGatheringService { assetProfileIdentifiers ); - for (const assetProfile of Object.values(assetProfiles)) { - const { symbol } = assetProfile; + for (const { dataSource, symbol } of assetProfileIdentifiers) { + const assetProfile = + assetProfiles[getAssetProfileIdentifier({ dataSource, symbol })]; + + if (!assetProfile) { + continue; + } const symbolProfile = symbolProfiles.find( ({ symbol: symbolProfileSymbol }) => { @@ -123,9 +128,7 @@ export class DataGatheringService { }); } catch (error) { this.logger.error( - `Failed to enhance data for ${symbol} (${ - assetProfile.dataSource - }) by ${dataEnhancer.getName()}`, + `Failed to enhance data for ${symbol} (${dataSource}) by ${dataEnhancer.getName()}`, error ); } @@ -137,7 +140,6 @@ export class DataGatheringService { countries, currency, cusip, - dataSource, figi, figiComposite, figiShareClass, @@ -164,8 +166,8 @@ export class DataGatheringService { isin, name, sectors, - symbol, - url + url, + symbol: assetProfile.symbol }, update: { assetClass, diff --git a/apps/api/src/services/symbol-profile/symbol-profile.service.spec.ts b/apps/api/src/services/symbol-profile/symbol-profile.service.spec.ts new file mode 100644 index 000000000..1d84e193c --- /dev/null +++ b/apps/api/src/services/symbol-profile/symbol-profile.service.spec.ts @@ -0,0 +1,113 @@ +import { DataSource } from '@prisma/client'; + +import { SymbolProfileService } from './symbol-profile.service'; + +describe('SymbolProfileService', () => { + let symbolProfileService: SymbolProfileService; + + let prismaService: { + symbolProfile: { findMany: jest.Mock; findUnique: jest.Mock }; + }; + + beforeEach(() => { + prismaService = { + symbolProfile: { + findMany: jest.fn().mockResolvedValue([]), + findUnique: jest.fn().mockResolvedValue(null) + } + }; + + symbolProfileService = new SymbolProfileService(prismaService as any); + }); + + describe('getSymbolOfAssetProfile', () => { + it('Keeps the symbol of the existing asset profile', async () => { + prismaService.symbolProfile.findUnique.mockResolvedValue({ + symbol: 'AAPL' + }); + + const symbol = await symbolProfileService.getSymbolOfAssetProfile({ + dataSource: DataSource.YAHOO, + symbol: 'AAPL', + symbolOfDataProvider: 'AAPL' + }); + + expect(symbol).toEqual('AAPL'); + expect(prismaService.symbolProfile.findMany).not.toHaveBeenCalled(); + }); + + it('Keeps the letter case of the existing asset profile', async () => { + prismaService.symbolProfile.findMany.mockResolvedValue([ + { symbol: 'aapl' } + ]); + + const symbol = await symbolProfileService.getSymbolOfAssetProfile({ + dataSource: DataSource.YAHOO, + symbol: 'AAPL', + symbolOfDataProvider: 'AAPL' + }); + + expect(symbol).toEqual('aapl'); + }); + + it('Prefers the asset profile with the same letter case', async () => { + prismaService.symbolProfile.findMany.mockResolvedValue([ + { symbol: 'AAPL' }, + { symbol: 'aapl' } + ]); + + const symbol = await symbolProfileService.getSymbolOfAssetProfile({ + dataSource: DataSource.YAHOO, + symbol: 'aapl', + symbolOfDataProvider: 'AAPL' + }); + + expect(symbol).toEqual('aapl'); + }); + + it('Ignores an asset profile which a wildcard of the query matched', async () => { + prismaService.symbolProfile.findMany.mockResolvedValue([ + { symbol: 'AAPL' } + ]); + + const symbol = await symbolProfileService.getSymbolOfAssetProfile({ + dataSource: DataSource.YAHOO, + symbol: 'aa_l', + symbolOfDataProvider: 'AA_L' + }); + + expect(symbol).toEqual('AA_L'); + }); + + it('Uses the symbol of the data provider if no asset profile exists', async () => { + const symbol = await symbolProfileService.getSymbolOfAssetProfile({ + dataSource: DataSource.YAHOO, + symbol: 'aapl', + symbolOfDataProvider: 'AAPL' + }); + + expect(symbol).toEqual('AAPL'); + }); + + it('Keeps the requested symbol if the data provider reports no symbol', async () => { + const symbol = await symbolProfileService.getSymbolOfAssetProfile({ + dataSource: DataSource.YAHOO, + symbol: 'aapl' + }); + + expect(symbol).toEqual('aapl'); + }); + + it('Keeps the symbol of a custom asset profile', async () => { + const symbol = await symbolProfileService.getSymbolOfAssetProfile({ + dataSource: DataSource.MANUAL, + symbol: 'GF_apple', + symbolOfDataProvider: 'GF_APPLE' + }); + + expect(symbol).toEqual('GF_apple'); + expect(prismaService.symbolProfile.findMany).not.toHaveBeenCalled(); + expect(prismaService.symbolProfile.findUnique).not.toHaveBeenCalled(); + }); + }); +}); diff --git a/apps/api/src/services/symbol-profile/symbol-profile.service.ts b/apps/api/src/services/symbol-profile/symbol-profile.service.ts index ebc8a94c7..07f482884 100644 --- a/apps/api/src/services/symbol-profile/symbol-profile.service.ts +++ b/apps/api/src/services/symbol-profile/symbol-profile.service.ts @@ -1,6 +1,9 @@ import { PrismaService } from '@ghostfolio/api/services/prisma/prisma.service'; import { UNKNOWN_KEY } from '@ghostfolio/common/config'; -import { applyAssetProfileOverrides } from '@ghostfolio/common/helper'; +import { + applyAssetProfileOverrides, + isSameSymbol +} from '@ghostfolio/common/helper'; import { AssetProfileIdentifier, EnhancedAssetProfile, @@ -126,6 +129,51 @@ export class SymbolProfileService { }); } + /** + * Gets the symbol to use for an asset profile. An existing asset profile + * wins, also if its symbol has a different letter case. Otherwise the symbol + * of the data provider is used, as it has the letter case of the instrument. + */ + public async getSymbolOfAssetProfile({ + dataSource, + symbol, + symbolOfDataProvider + }: { symbolOfDataProvider?: string } & AssetProfileIdentifier) { + if (dataSource === DataSource.MANUAL) { + return symbol; + } + + const symbolProfileWithSameSymbol = + await this.prismaService.symbolProfile.findUnique({ + select: { symbol: true }, + where: { dataSource_symbol: { dataSource, symbol } } + }); + + if (symbolProfileWithSameSymbol) { + return symbol; + } + + const symbolProfiles = ( + await this.prismaService.symbolProfile.findMany({ + orderBy: { symbol: 'asc' }, + select: { symbol: true }, + where: { + dataSource, + symbol: { equals: symbol, mode: 'insensitive' } + } + }) + ).filter(({ symbol: symbolOfSymbolProfile }) => { + return isSameSymbol({ symbol1: symbol, symbol2: symbolOfSymbolProfile }); + }); + + const symbolProfile = + symbolProfiles.find(({ symbol: symbolOfSymbolProfile }) => { + return symbolOfSymbolProfile === symbol; + }) ?? symbolProfiles[0]; + + return symbolProfile?.symbol ?? symbolOfDataProvider ?? symbol; + } + public async getSymbolProfiles( aAssetProfileIdentifiers: AssetProfileIdentifier[] ): Promise { diff --git a/apps/client/src/app/components/admin-market-data/admin-market-data.component.ts b/apps/client/src/app/components/admin-market-data/admin-market-data.component.ts index 87389f957..930b6e33f 100644 --- a/apps/client/src/app/components/admin-market-data/admin-market-data.component.ts +++ b/apps/client/src/app/components/admin-market-data/admin-market-data.component.ts @@ -22,6 +22,7 @@ import { GfValueComponent } from '@ghostfolio/ui/value'; import { SelectionModel } from '@angular/cdk/collections'; import { CommonModule } from '@angular/common'; +import { HttpErrorResponse } from '@angular/common/http'; import { AfterViewInit, ChangeDetectionStrategy, @@ -68,8 +69,8 @@ import { import ms from 'ms'; import { DeviceDetectorService } from 'ngx-device-detector'; import { NgxSkeletonLoaderModule } from 'ngx-skeleton-loader'; -import { Subject } from 'rxjs'; -import { distinctUntilChanged } from 'rxjs/operators'; +import { EMPTY, Subject } from 'rxjs'; +import { catchError, distinctUntilChanged } from 'rxjs/operators'; import { AdminMarketDataService } from './admin-market-data.service'; import { GfAssetProfileDialogComponent } from './asset-profile-dialog/asset-profile-dialog.component'; @@ -495,15 +496,37 @@ export class GfAdminMarketDataComponent implements AfterViewInit, OnInit { if (addAssetProfile && dataSource && symbol) { this.adminService .addAssetProfile({ dataSource, symbol }) - .pipe(takeUntilDestroyed(this.destroyRef)) - .subscribe(() => { + .pipe( + catchError(({ error }: HttpErrorResponse) => { + this.snackBar.open( + '😞 ' + + (error?.message ?? + $localize`An error occurred while creating the asset profile ${symbol} (${dataSource}).`), + undefined, + { + duration: ms('3 seconds') + } + ); + + this.router.navigate(['.'], { relativeTo: this.route }); + + return EMPTY; + }), + takeUntilDestroyed(this.destroyRef) + ) + .subscribe((assetProfile) => { this.loadData(); + + this.onOpenAssetProfileDialog({ + dataSource, + symbol: assetProfile?.symbol ?? symbol + }); }); } else { this.loadData(); - } - this.onOpenAssetProfileDialog({ dataSource, symbol }); + this.onOpenAssetProfileDialog({ dataSource, symbol }); + } }); }); } diff --git a/libs/common/src/lib/helper.spec.ts b/libs/common/src/lib/helper.spec.ts index 9fd4e8f38..a69c5eaf9 100644 --- a/libs/common/src/lib/helper.spec.ts +++ b/libs/common/src/lib/helper.spec.ts @@ -11,6 +11,7 @@ import { isAccountExcluded, isCurrency, isCurrencySymbol, + isSameSymbol, isSplitRatio, isValidCustomAssetProfileSymbol, isValidGranteeOfAccess, @@ -311,6 +312,38 @@ describe('Helper', () => { }); }); + describe('Is same symbol', () => { + it('Same symbol', () => { + expect(isSameSymbol({ symbol1: 'AAPL', symbol2: 'AAPL' })).toEqual(true); + }); + + it('Same symbol in a different letter case', () => { + expect(isSameSymbol({ symbol1: 'aapl', symbol2: 'AAPL' })).toEqual(true); + expect(isSameSymbol({ symbol1: 'AaPl', symbol2: 'AAPL' })).toEqual(true); + expect( + isSameSymbol({ symbol1: 'usd-coin', symbol2: 'USD-Coin' }) + ).toEqual(true); + }); + + it('Different symbol', () => { + expect(isSameSymbol({ symbol1: 'FB', symbol2: 'META' })).toEqual(false); + expect( + isSameSymbol({ symbol1: 'US0378331005', symbol2: 'AAPL' }) + ).toEqual(false); + expect(isSameSymbol({ symbol1: 'BRK.B', symbol2: 'BRK-B' })).toEqual( + false + ); + }); + + it('Missing symbol', () => { + expect(isSameSymbol({ symbol1: undefined, symbol2: 'AAPL' })).toEqual( + false + ); + expect(isSameSymbol({ symbol1: 'AAPL', symbol2: null })).toEqual(false); + expect(isSameSymbol({ symbol1: '', symbol2: '' })).toEqual(false); + }); + }); + describe('Is split ratio', () => { it('Forward split', () => { expect(isSplitRatio({ denominator: 1, numerator: 2 })).toEqual(true); diff --git a/libs/common/src/lib/helper.ts b/libs/common/src/lib/helper.ts index a51a74808..0e7cc6f93 100644 --- a/libs/common/src/lib/helper.ts +++ b/libs/common/src/lib/helper.ts @@ -606,6 +606,22 @@ export function isRootCurrency(aCurrency: string) { }); } +/** + * Checks whether two symbols are the same, ignoring the letter case. A data + * provider can report "AAPL" for a requested symbol "aapl". + */ +export function isSameSymbol({ + symbol1, + symbol2 +}: { + symbol1: string; + symbol2: string; +}) { + return ( + !!symbol1 && !!symbol2 && symbol1.toLowerCase() === symbol2.toLowerCase() + ); +} + /** * Validates the ratio of a stock split, expressed as the number of shares held * after the split (numerator) per number of shares held before (denominator), diff --git a/libs/ui/src/lib/services/admin.service.ts b/libs/ui/src/lib/services/admin.service.ts index aa048faaf..df200a521 100644 --- a/libs/ui/src/lib/services/admin.service.ts +++ b/libs/ui/src/lib/services/admin.service.ts @@ -25,7 +25,12 @@ import { GF_ENVIRONMENT } from '@ghostfolio/ui/environment'; import { HttpClient, HttpHeaders, HttpParams } from '@angular/common/http'; import { inject, Service } from '@angular/core'; -import { AssetProfileSplit, MarketData, Platform } from '@prisma/client'; +import { + AssetProfileSplit, + MarketData, + Platform, + SymbolProfile +} from '@prisma/client'; import { JobStatus } from 'bull'; import { isNumber } from 'lodash'; @@ -35,7 +40,7 @@ export class AdminService { private readonly http = inject(HttpClient); public addAssetProfile({ dataSource, symbol }: AssetProfileIdentifier) { - return this.http.post( + return this.http.post( `/api/v1/admin/profile-data/${dataSource}/${encodeURIComponent(symbol)}`, null );