From ad0bae8805fc68bedb84dee00485f3380462294d Mon Sep 17 00:00:00 2001 From: Thomas Kaul <4159106+dtslvr@users.noreply.github.com> Date: Fri, 28 Aug 2026 10:10:09 +0200 Subject: [PATCH] Fix creation of asset profiles with symbol in wrong letter case by using original symbol --- .../endpoints/watchlist/watchlist.service.ts | 13 ++- apps/api/src/app/import/import.service.ts | 30 +++++- .../symbol-profile.service.spec.ts | 91 +++++++++++++++++++ .../symbol-profile/symbol-profile.service.ts | 49 +++------- .../admin-market-data.component.ts | 36 ++++---- libs/common/src/lib/helper.ts | 5 +- 6 files changed, 161 insertions(+), 63 deletions(-) create mode 100644 apps/api/src/services/symbol-profile/symbol-profile.service.spec.ts diff --git a/apps/api/src/app/endpoints/watchlist/watchlist.service.ts b/apps/api/src/app/endpoints/watchlist/watchlist.service.ts index d6ee7f13f..f98a006ba 100644 --- a/apps/api/src/app/endpoints/watchlist/watchlist.service.ts +++ b/apps/api/src/app/endpoints/watchlist/watchlist.service.ts @@ -51,7 +51,11 @@ export class WatchlistService { ); } - symbol = assetProfile.symbol; + symbol = await this.symbolProfileService.getSymbolOfAssetProfile({ + dataSource, + symbol, + symbolOfDataProvider: assetProfile.symbol + }); symbolProfile = await this.prismaService.symbolProfile.findUnique({ where: { @@ -60,9 +64,10 @@ export class WatchlistService { }); if (!symbolProfile) { - await this.symbolProfileService.add( - assetProfile as Prisma.SymbolProfileCreateInput - ); + await this.symbolProfileService.add({ + ...assetProfile, + symbol + } as Prisma.SymbolProfileCreateInput); } } diff --git a/apps/api/src/app/import/import.service.ts b/apps/api/src/app/import/import.service.ts index 0a0a9550b..02e4f3e72 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,27 @@ 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 })]; + + if (assetProfile) { + assetProfile.symbol = + await this.symbolProfileService.getSymbolOfAssetProfile({ + dataSource, + symbol, + symbolOfDataProvider: assetProfile.symbol + }); + } + } + const activitiesExtendedWithErrors = await this.extendActivitiesWithErrors({ activitiesDto, userCurrency, @@ -847,13 +869,12 @@ export class ImportService { name, scraperConfiguration, sectors, + symbol, symbolMapping, url, updatedAt } = assetProfile; - const symbol = activity.assetProfile.symbol; - const validatedAccount = accounts.find(({ id }) => { return id === accountId; }); @@ -1059,7 +1080,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/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..757deafef --- /dev/null +++ b/apps/api/src/services/symbol-profile/symbol-profile.service.spec.ts @@ -0,0 +1,91 @@ +import { DataSource } from '@prisma/client'; + +import { SymbolProfileService } from './symbol-profile.service'; + +describe('SymbolProfileService', () => { + let prismaService: { symbolProfile: { findMany: jest.Mock } }; + let symbolProfileService: SymbolProfileService; + + beforeEach(() => { + prismaService = { + symbolProfile: { findMany: jest.fn().mockResolvedValue([]) } + }; + + symbolProfileService = new SymbolProfileService(prismaService as any); + }); + + describe('getSymbolOfAssetProfile', () => { + it('Keeps the symbol 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('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('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(); + }); + }); +}); 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 715ec0831..ac1edc9ad 100644 --- a/apps/api/src/services/symbol-profile/symbol-profile.service.ts +++ b/apps/api/src/services/symbol-profile/symbol-profile.service.ts @@ -127,12 +127,9 @@ export class SymbolProfileService { } /** - * Gets the symbol to use for an asset profile. An asset profile which is - * already in the database wins, also if its symbol has a different letter - * case. This prevents a second asset profile for the same instrument. - * Otherwise the symbol of the data provider is used, because it has the - * correct letter case. A custom asset profile (MANUAL) belongs to a user, - * thus its symbol stays unchanged. + * 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, @@ -143,29 +140,21 @@ export class SymbolProfileService { return symbol; } - const symbolProfile = await this.prismaService.symbolProfile.findUnique({ - where: { dataSource_symbol: { dataSource, symbol } } + const symbolProfiles = await this.prismaService.symbolProfile.findMany({ + orderBy: { symbol: 'asc' }, + select: { symbol: true }, + where: { + dataSource, + symbol: { equals: symbol, mode: 'insensitive' } + } }); - if (symbolProfile) { - return symbolProfile.symbol; - } + const symbolProfile = + symbolProfiles.find(({ symbol: symbolOfSymbolProfile }) => { + return symbolOfSymbolProfile === symbol; + }) ?? symbolProfiles[0]; - const symbolProfileWithOtherLetterCase = - await this.prismaService.symbolProfile.findFirst({ - orderBy: { symbol: 'asc' }, - where: { - dataSource, - symbol: { - equals: this.escapeLikePattern(symbol), - mode: 'insensitive' - } - } - }); - - return ( - symbolProfileWithOtherLetterCase?.symbol ?? symbolOfDataProvider ?? symbol - ); + return symbolProfile?.symbol ?? symbolOfDataProvider ?? symbol; } public async getSymbolProfiles( @@ -330,14 +319,6 @@ export class SymbolProfileService { }); } - /** - * Escapes the wildcard characters of a LIKE pattern, because Prisma - * translates a case-insensitive filter into an ILIKE expression. - */ - private escapeLikePattern(value: string) { - return value.replace(/[\\%_]/g, '\\$&'); - } - private getCountries(aCountries: Prisma.JsonArray = []): Country[] { if (aCountries === null) { return []; 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 edcac079d..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 @@ -69,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'; @@ -496,16 +496,11 @@ export class GfAdminMarketDataComponent implements AfterViewInit, OnInit { if (addAssetProfile && dataSource && symbol) { this.adminService .addAssetProfile({ dataSource, symbol }) - .pipe(takeUntilDestroyed(this.destroyRef)) - .subscribe({ - error: (error: HttpErrorResponse) => { - const { message } = (error.error ?? {}) as { - message?: string; - }; - + .pipe( + catchError(({ error }: HttpErrorResponse) => { this.snackBar.open( '😞 ' + - (message ?? + (error?.message ?? $localize`An error occurred while creating the asset profile ${symbol} (${dataSource}).`), undefined, { @@ -514,15 +509,18 @@ export class GfAdminMarketDataComponent implements AfterViewInit, OnInit { ); this.router.navigate(['.'], { relativeTo: this.route }); - }, - next: (assetProfile) => { - this.loadData(); - - this.onOpenAssetProfileDialog({ - dataSource, - symbol: assetProfile?.symbol ?? symbol - }); - } + + return EMPTY; + }), + takeUntilDestroyed(this.destroyRef) + ) + .subscribe((assetProfile) => { + this.loadData(); + + this.onOpenAssetProfileDialog({ + dataSource, + symbol: assetProfile?.symbol ?? symbol + }); }); } else { this.loadData(); diff --git a/libs/common/src/lib/helper.ts b/libs/common/src/lib/helper.ts index b2d4c5295..0e7cc6f93 100644 --- a/libs/common/src/lib/helper.ts +++ b/libs/common/src/lib/helper.ts @@ -607,9 +607,8 @@ export function isRootCurrency(aCurrency: string) { } /** - * Checks whether two symbols are the same, ignoring the letter case. Data - * providers can report a symbol in a different letter case than requested, for - * example "AAPL" for "aapl". + * 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,