From a6124891e76b31c07ea10e5a8b3175ce2aaa0628 Mon Sep 17 00:00:00 2001 From: Thomas Kaul <4159106+dtslvr@users.noreply.github.com> Date: Wed, 26 Aug 2026 15:48:22 +0200 Subject: [PATCH 01/11] Fix creation of asset profiles with symbol in wrong letter case by using original symbol --- .../app/activities/activities.controller.ts | 22 ++++++++++++++----- .../endpoints/watchlist/watchlist.service.ts | 22 ++++++++++++++----- .../data-provider/data-provider.service.ts | 4 ++-- .../data-gathering/data-gathering.service.ts | 10 ++++++--- 4 files changed, 42 insertions(+), 16 deletions(-) diff --git a/apps/api/src/app/activities/activities.controller.ts b/apps/api/src/app/activities/activities.controller.ts index caf4103b9..3ea0f8a99 100644 --- a/apps/api/src/app/activities/activities.controller.ts +++ b/apps/api/src/app/activities/activities.controller.ts @@ -12,6 +12,7 @@ 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 +33,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'; @@ -224,8 +225,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 +256,11 @@ export class ActivitiesController { const customCurrency = data.customCurrency; const dataSource = data.dataSource; + const symbol = + assetProfiles[ + getAssetProfileIdentifier({ dataSource, symbol: data.symbol }) + ]?.symbol ?? data.symbol; + if (customCurrency) { data.currency = customCurrency; @@ -268,12 +278,12 @@ export class ActivitiesController { create: { currency, dataSource, - symbol: data.symbol + symbol }, where: { dataSource_symbol: { dataSource, - symbol: data.symbol + symbol } } } @@ -291,8 +301,8 @@ export class ActivitiesController { dataGatheringItems: [ { dataSource, - date: activity.date, - symbol: data.symbol + symbol, + date: activity.date } ], priority: DATA_GATHERING_QUEUE_PRIORITY_HIGH diff --git a/apps/api/src/app/endpoints/watchlist/watchlist.service.ts b/apps/api/src/app/endpoints/watchlist/watchlist.service.ts index 88702da00..d6ee7f13f 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,19 @@ export class WatchlistService { ); } - await this.symbolProfileService.add( - assetProfile as Prisma.SymbolProfileCreateInput - ); + symbol = assetProfile.symbol; + + symbolProfile = await this.prismaService.symbolProfile.findUnique({ + where: { + dataSource_symbol: { dataSource, symbol } + } + }); + + if (!symbolProfile) { + await this.symbolProfileService.add( + assetProfile as Prisma.SymbolProfileCreateInput + ); + } } await this.dataGatheringService.gatherSymbol({ 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..15ec0b150 100644 --- a/apps/api/src/services/data-provider/data-provider.service.ts +++ b/apps/api/src/services/data-provider/data-provider.service.ts @@ -130,8 +130,8 @@ export class DataProviderService implements OnModuleInit { }) ] = { ...assetProfile, - symbol, - name: formatAssetProfileName(assetProfile) + name: formatAssetProfileName(assetProfile), + symbol: assetProfile.symbol ?? symbol }; } }) 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..691d3a0e1 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 }) => { @@ -137,7 +142,6 @@ export class DataGatheringService { countries, currency, cusip, - dataSource, figi, figiComposite, figiShareClass, From 5398f306836f2f4c2f1c3c974d9a4fa435de5a04 Mon Sep 17 00:00:00 2001 From: Thomas Kaul <4159106+dtslvr@users.noreply.github.com> Date: Wed, 26 Aug 2026 15:49:20 +0200 Subject: [PATCH 02/11] Update changelog --- CHANGELOG.md | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 803c8b967..2a1b77f03 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -27,6 +27,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Fixed the date of the historical market data gathering endpoint for a specific date for instances running in a time zone other than UTC - Fixed the validation of the date in the historical market data gathering endpoint for a specific date +### Fixed + +- Fixed the creation of asset profiles with a symbol in the wrong letter case by using the original symbol of the data provider + ## 3.61.0 - 2026-08-25 ### Changed From 125292a5f22e34f689800a823432b12a015c91c1 Mon Sep 17 00:00:00 2001 From: Thomas Kaul <4159106+dtslvr@users.noreply.github.com> Date: Wed, 26 Aug 2026 16:49:11 +0200 Subject: [PATCH 03/11] Fix creation of asset profiles with symbol in wrong letter case by using original symbol --- .../app/activities/activities.controller.ts | 2 +- apps/api/src/app/import/import.service.ts | 5 +- .../data-provider.service.spec.ts | 97 +++++++++++++++++++ .../data-provider/data-provider.service.ts | 8 +- .../data-gathering.service.spec.ts | 59 +++++++++-- .../data-gathering/data-gathering.service.ts | 4 +- .../admin-market-data.component.ts | 11 ++- libs/common/src/lib/helper.spec.ts | 33 +++++++ libs/common/src/lib/helper.ts | 17 ++++ libs/ui/src/lib/services/admin.service.ts | 9 +- 10 files changed, 228 insertions(+), 17 deletions(-) create mode 100644 apps/api/src/services/data-provider/data-provider.service.spec.ts diff --git a/apps/api/src/app/activities/activities.controller.ts b/apps/api/src/app/activities/activities.controller.ts index 3ea0f8a99..5d0d932ee 100644 --- a/apps/api/src/app/activities/activities.controller.ts +++ b/apps/api/src/app/activities/activities.controller.ts @@ -227,7 +227,7 @@ export class ActivitiesController { let assetProfiles: { [assetProfileIdentifier: string]: Partial; - }; + } = {}; try { assetProfiles = await this.dataProviderService.validateActivities({ diff --git a/apps/api/src/app/import/import.service.ts b/apps/api/src/app/import/import.service.ts index 313325f04..0a0a9550b 100644 --- a/apps/api/src/app/import/import.service.ts +++ b/apps/api/src/app/import/import.service.ts @@ -847,14 +847,17 @@ export class ImportService { name, scraperConfiguration, sectors, - symbol, symbolMapping, url, updatedAt } = assetProfile; + + const symbol = activity.assetProfile.symbol; + const validatedAccount = accounts.find(({ id }) => { return id === accountId; }); + const validatedTags = tags.filter(({ id: tagId }) => { return tagIds.some((activityTagId) => { return activityTagId === tagId; 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 15ec0b150..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'; @@ -131,7 +132,12 @@ export class DataProviderService implements OnModuleInit { ] = { ...assetProfile, name: formatAssetProfileName(assetProfile), - symbol: assetProfile.symbol ?? symbol + 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..270acbcff 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,34 @@ 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' + } + } + }) + ); + }); + }); + 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 691d3a0e1..fc7efbdec 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 @@ -128,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 ); } 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..9c3c0c791 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 @@ -496,14 +496,19 @@ export class GfAdminMarketDataComponent implements AfterViewInit, OnInit { this.adminService .addAssetProfile({ dataSource, symbol }) .pipe(takeUntilDestroyed(this.destroyRef)) - .subscribe(() => { + .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..b2d4c5295 100644 --- a/libs/common/src/lib/helper.ts +++ b/libs/common/src/lib/helper.ts @@ -606,6 +606,23 @@ 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". + */ +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 ); From e64e4a4ba3cab0a8071f0f6adedbe09ca3af5c5b Mon Sep 17 00:00:00 2001 From: Thomas Kaul <4159106+dtslvr@users.noreply.github.com> Date: Wed, 26 Aug 2026 16:50:03 +0200 Subject: [PATCH 04/11] Update changelog --- CHANGELOG.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 2a1b77f03..ea4cb7ef4 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -29,7 +29,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Fixed -- Fixed the creation of asset profiles with a symbol in the wrong letter case by using the original symbol of the data provider +- Fixed the creation of asset profiles with a symbol in the wrong letter case by using the symbol of the _Yahoo Finance_ service ## 3.61.0 - 2026-08-25 From 7b262aeeaacea6217dce2f90b19a60269f7ba107 Mon Sep 17 00:00:00 2001 From: Thomas Kaul <4159106+dtslvr@users.noreply.github.com> Date: Wed, 26 Aug 2026 17:34:03 +0200 Subject: [PATCH 05/11] Fix creation of asset profiles with symbol in wrong letter case by using original symbol --- .../app/activities/activities.controller.ts | 23 +++++++++++----- .../symbol-profile/symbol-profile.service.ts | 27 +++++++++++++++++++ .../admin-market-data.component.ts | 19 ++++++++----- 3 files changed, 56 insertions(+), 13 deletions(-) diff --git a/apps/api/src/app/activities/activities.controller.ts b/apps/api/src/app/activities/activities.controller.ts index 5d0d932ee..7b647ba73 100644 --- a/apps/api/src/app/activities/activities.controller.ts +++ b/apps/api/src/app/activities/activities.controller.ts @@ -8,6 +8,7 @@ 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'; @@ -47,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() @@ -256,10 +258,14 @@ export class ActivitiesController { const customCurrency = data.customCurrency; const dataSource = data.dataSource; - const symbol = - assetProfiles[ - getAssetProfileIdentifier({ dataSource, symbol: data.symbol }) - ]?.symbol ?? data.symbol; + const symbol = await this.symbolProfileService.getSymbolOfAssetProfile({ + dataSource, + symbol: data.symbol, + symbolOfDataProvider: + assetProfiles[ + getAssetProfileIdentifier({ dataSource, symbol: data.symbol }) + ]?.symbol + }); if (customCurrency) { data.currency = customCurrency; @@ -340,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) { @@ -366,7 +377,7 @@ export class ActivitiesController { connect: { dataSource_symbol: { dataSource, - symbol: data.symbol + symbol } }, update: { 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..970802b5a 100644 --- a/apps/api/src/services/symbol-profile/symbol-profile.service.ts +++ b/apps/api/src/services/symbol-profile/symbol-profile.service.ts @@ -126,6 +126,33 @@ 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. + */ + public async getSymbolOfAssetProfile({ + dataSource, + symbol, + symbolOfDataProvider + }: { symbolOfDataProvider?: string } & AssetProfileIdentifier) { + if (dataSource === DataSource.MANUAL) { + return symbol; + } + + const symbolProfile = await this.prismaService.symbolProfile.findFirst({ + where: { + dataSource, + symbol: { equals: symbol, mode: 'insensitive' } + } + }); + + 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 9c3c0c791..ef70590ba 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 @@ -496,13 +496,18 @@ export class GfAdminMarketDataComponent implements AfterViewInit, OnInit { this.adminService .addAssetProfile({ dataSource, symbol }) .pipe(takeUntilDestroyed(this.destroyRef)) - .subscribe((assetProfile) => { - this.loadData(); - - this.onOpenAssetProfileDialog({ - dataSource, - symbol: assetProfile?.symbol ?? symbol - }); + .subscribe({ + error: () => { + this.router.navigate(['.'], { relativeTo: this.route }); + }, + next: (assetProfile) => { + this.loadData(); + + this.onOpenAssetProfileDialog({ + dataSource, + symbol: assetProfile?.symbol ?? symbol + }); + } }); } else { this.loadData(); From 6dcd9f84f7d0bd39fdf6472d6afb4d9d317ca227 Mon Sep 17 00:00:00 2001 From: Thomas Kaul <4159106+dtslvr@users.noreply.github.com> Date: Wed, 26 Aug 2026 17:34:13 +0200 Subject: [PATCH 06/11] Update changelog --- CHANGELOG.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index ea4cb7ef4..617a43288 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -29,7 +29,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Fixed -- Fixed the creation of asset profiles with a symbol in the wrong letter case by using the symbol of the _Yahoo Finance_ service +- Fixed the creation of asset profiles with a symbol in the wrong letter case ## 3.61.0 - 2026-08-25 From 5db792417afefe7ac4a2d9e7553a94a7e6a80561 Mon Sep 17 00:00:00 2001 From: Thomas Kaul <4159106+dtslvr@users.noreply.github.com> Date: Fri, 28 Aug 2026 09:23:09 +0200 Subject: [PATCH 07/11] Fix creation of asset profiles with symbol in wrong letter case by using original symbol --- .../app/activities/activities.controller.ts | 5 ++- .../src/app/activities/activities.service.ts | 1 - .../data-gathering.service.spec.ts | 21 +++++++++++ .../data-gathering/data-gathering.service.ts | 4 +-- .../symbol-profile/symbol-profile.service.ts | 35 +++++++++++++++---- .../admin-market-data.component.ts | 17 ++++++++- 6 files changed, 70 insertions(+), 13 deletions(-) diff --git a/apps/api/src/app/activities/activities.controller.ts b/apps/api/src/app/activities/activities.controller.ts index 7b647ba73..738589ba2 100644 --- a/apps/api/src/app/activities/activities.controller.ts +++ b/apps/api/src/app/activities/activities.controller.ts @@ -229,7 +229,7 @@ export class ActivitiesController { let assetProfiles: { [assetProfileIdentifier: string]: Partial; - } = {}; + }; try { assetProfiles = await this.dataProviderService.validateActivities({ @@ -382,8 +382,7 @@ export class ActivitiesController { }, 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/services/queues/data-gathering/data-gathering.service.spec.ts b/apps/api/src/services/queues/data-gathering/data-gathering.service.spec.ts index 270acbcff..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 @@ -155,6 +155,27 @@ describe('DataGatheringService', () => { }) ); }); + + 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', () => { 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 fc7efbdec..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 @@ -166,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.ts b/apps/api/src/services/symbol-profile/symbol-profile.service.ts index 970802b5a..715ec0831 100644 --- a/apps/api/src/services/symbol-profile/symbol-profile.service.ts +++ b/apps/api/src/services/symbol-profile/symbol-profile.service.ts @@ -143,14 +143,29 @@ export class SymbolProfileService { return symbol; } - const symbolProfile = await this.prismaService.symbolProfile.findFirst({ - where: { - dataSource, - symbol: { equals: symbol, mode: 'insensitive' } - } + const symbolProfile = await this.prismaService.symbolProfile.findUnique({ + where: { dataSource_symbol: { dataSource, symbol } } }); - return symbolProfile?.symbol ?? symbolOfDataProvider ?? symbol; + if (symbolProfile) { + return symbolProfile.symbol; + } + + const symbolProfileWithOtherLetterCase = + await this.prismaService.symbolProfile.findFirst({ + orderBy: { symbol: 'asc' }, + where: { + dataSource, + symbol: { + equals: this.escapeLikePattern(symbol), + mode: 'insensitive' + } + } + }); + + return ( + symbolProfileWithOtherLetterCase?.symbol ?? symbolOfDataProvider ?? symbol + ); } public async getSymbolProfiles( @@ -315,6 +330,14 @@ 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 ef70590ba..edcac079d 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, @@ -497,7 +498,21 @@ export class GfAdminMarketDataComponent implements AfterViewInit, OnInit { .addAssetProfile({ dataSource, symbol }) .pipe(takeUntilDestroyed(this.destroyRef)) .subscribe({ - error: () => { + error: (error: HttpErrorResponse) => { + const { message } = (error.error ?? {}) as { + message?: string; + }; + + this.snackBar.open( + '😞 ' + + (message ?? + $localize`An error occurred while creating the asset profile ${symbol} (${dataSource}).`), + undefined, + { + duration: ms('3 seconds') + } + ); + this.router.navigate(['.'], { relativeTo: this.route }); }, next: (assetProfile) => { From d49d572badd2196fdaecff8013399dc005a48cbb Mon Sep 17 00:00:00 2001 From: Thomas Kaul <4159106+dtslvr@users.noreply.github.com> Date: Fri, 28 Aug 2026 09:26:13 +0200 Subject: [PATCH 08/11] Update changelog --- CHANGELOG.md | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 617a43288..eece07381 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,12 @@ 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/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +## Unreleased + +### Fixed + +- Fixed the creation of asset profiles with a symbol in the wrong letter case + ## 3.62.0 - 2026-08-27 ### Added @@ -27,10 +33,6 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Fixed the date of the historical market data gathering endpoint for a specific date for instances running in a time zone other than UTC - Fixed the validation of the date in the historical market data gathering endpoint for a specific date -### Fixed - -- Fixed the creation of asset profiles with a symbol in the wrong letter case - ## 3.61.0 - 2026-08-25 ### Changed 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 09/11] 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, From 35ca9cf6002b460577a3181c6e1e24b9867e7254 Mon Sep 17 00:00:00 2001 From: Thomas Kaul <4159106+dtslvr@users.noreply.github.com> Date: Fri, 28 Aug 2026 10:36:58 +0200 Subject: [PATCH 10/11] Fix creation of asset profiles with symbol in wrong letter case by using original symbol --- apps/api/src/app/admin/admin.service.ts | 14 ++++++++--- apps/api/src/app/import/import.service.ts | 11 ++++++++- .../symbol-profile.service.spec.ts | 14 +++++++++++ .../symbol-profile/symbol-profile.service.ts | 23 ++++++++++++------- 4 files changed, 50 insertions(+), 12 deletions(-) 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/import/import.service.ts b/apps/api/src/app/import/import.service.ts index 02e4f3e72..388a1885b 100644 --- a/apps/api/src/app/import/import.service.ts +++ b/apps/api/src/app/import/import.service.ts @@ -748,7 +748,16 @@ export class ImportService { const assetProfile = assetProfiles[getAssetProfileIdentifier({ dataSource, symbol })]; - if (assetProfile) { + const assetProfileInImport = assetProfilesWithMarketDataDto?.some( + (assetProfileWithMarketData) => { + return ( + assetProfileWithMarketData.dataSource === dataSource && + assetProfileWithMarketData.symbol === symbol + ); + } + ); + + if (assetProfile && !assetProfileInImport) { assetProfile.symbol = await this.symbolProfileService.getSymbolOfAssetProfile({ dataSource, 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 index 757deafef..6e61583db 100644 --- a/apps/api/src/services/symbol-profile/symbol-profile.service.spec.ts +++ b/apps/api/src/services/symbol-profile/symbol-profile.service.spec.ts @@ -58,6 +58,20 @@ describe('SymbolProfileService', () => { 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, 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 ac1edc9ad..1afbce69a 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, @@ -140,13 +143,17 @@ export class SymbolProfileService { return symbol; } - const symbolProfiles = await this.prismaService.symbolProfile.findMany({ - orderBy: { symbol: 'asc' }, - select: { symbol: true }, - where: { - dataSource, - symbol: { equals: symbol, mode: 'insensitive' } - } + 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 = From aed50bebcc31d488ca521c7bcf9ae748ab9e182f Mon Sep 17 00:00:00 2001 From: Thomas Kaul <4159106+dtslvr@users.noreply.github.com> Date: Fri, 28 Aug 2026 10:51:24 +0200 Subject: [PATCH 11/11] Fix creation of asset profiles with symbol in wrong letter case by using original symbol --- .../symbol-profile.service.spec.ts | 18 +++++++++++++----- .../symbol-profile/symbol-profile.service.ts | 10 ++++++++++ 2 files changed, 23 insertions(+), 5 deletions(-) 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 index 6e61583db..1d84e193c 100644 --- a/apps/api/src/services/symbol-profile/symbol-profile.service.spec.ts +++ b/apps/api/src/services/symbol-profile/symbol-profile.service.spec.ts @@ -3,12 +3,18 @@ import { DataSource } from '@prisma/client'; import { SymbolProfileService } from './symbol-profile.service'; describe('SymbolProfileService', () => { - let prismaService: { symbolProfile: { findMany: jest.Mock } }; let symbolProfileService: SymbolProfileService; + let prismaService: { + symbolProfile: { findMany: jest.Mock; findUnique: jest.Mock }; + }; + beforeEach(() => { prismaService = { - symbolProfile: { findMany: jest.fn().mockResolvedValue([]) } + symbolProfile: { + findMany: jest.fn().mockResolvedValue([]), + findUnique: jest.fn().mockResolvedValue(null) + } }; symbolProfileService = new SymbolProfileService(prismaService as any); @@ -16,9 +22,9 @@ describe('SymbolProfileService', () => { describe('getSymbolOfAssetProfile', () => { it('Keeps the symbol of the existing asset profile', async () => { - prismaService.symbolProfile.findMany.mockResolvedValue([ - { symbol: 'AAPL' } - ]); + prismaService.symbolProfile.findUnique.mockResolvedValue({ + symbol: 'AAPL' + }); const symbol = await symbolProfileService.getSymbolOfAssetProfile({ dataSource: DataSource.YAHOO, @@ -27,6 +33,7 @@ describe('SymbolProfileService', () => { }); expect(symbol).toEqual('AAPL'); + expect(prismaService.symbolProfile.findMany).not.toHaveBeenCalled(); }); it('Keeps the letter case of the existing asset profile', async () => { @@ -100,6 +107,7 @@ describe('SymbolProfileService', () => { 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 1afbce69a..07f482884 100644 --- a/apps/api/src/services/symbol-profile/symbol-profile.service.ts +++ b/apps/api/src/services/symbol-profile/symbol-profile.service.ts @@ -143,6 +143,16 @@ export class SymbolProfileService { 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' },