Browse Source

Merge 41183eee57 into cc3bbeeb89

pull/7727/merge
Thomas Kaul 2 days ago
committed by GitHub
parent
commit
fe8080a86d
No known key found for this signature in database GPG Key ID: B5690EEEBB952194
  1. 4
      CHANGELOG.md
  2. 40
      apps/api/src/app/activities/activities.controller.ts
  3. 1
      apps/api/src/app/activities/activities.service.ts
  4. 14
      apps/api/src/app/admin/admin.service.ts
  5. 27
      apps/api/src/app/endpoints/watchlist/watchlist.service.ts
  6. 38
      apps/api/src/app/import/import.service.ts
  7. 97
      apps/api/src/services/data-provider/data-provider.service.spec.ts
  8. 10
      apps/api/src/services/data-provider/data-provider.service.ts
  9. 80
      apps/api/src/services/queues/data-gathering/data-gathering.service.spec.ts
  10. 18
      apps/api/src/services/queues/data-gathering/data-gathering.service.ts
  11. 113
      apps/api/src/services/symbol-profile/symbol-profile.service.spec.ts
  12. 50
      apps/api/src/services/symbol-profile/symbol-profile.service.ts
  13. 35
      apps/client/src/app/components/admin-market-data/admin-market-data.component.ts
  14. 33
      libs/common/src/lib/helper.spec.ts
  15. 16
      libs/common/src/lib/helper.ts
  16. 9
      libs/ui/src/lib/services/admin.service.ts

4
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 - 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 ## 3.62.0 - 2026-08-27
### Added ### Added

40
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 { ApiService } from '@ghostfolio/api/services/api/api.service';
import { DataProviderService } from '@ghostfolio/api/services/data-provider/data-provider.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 { 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 { getIntervalFromDateRange } from '@ghostfolio/common/calculation-helper';
import { DATA_GATHERING_QUEUE_PRIORITY_HIGH } from '@ghostfolio/common/config'; import { DATA_GATHERING_QUEUE_PRIORITY_HIGH } from '@ghostfolio/common/config';
import { CreateOrderDto, UpdateOrderDto } from '@ghostfolio/common/dtos'; import { CreateOrderDto, UpdateOrderDto } from '@ghostfolio/common/dtos';
import { SubscriptionType } from '@ghostfolio/common/enums'; import { SubscriptionType } from '@ghostfolio/common/enums';
import { getAssetProfileIdentifier } from '@ghostfolio/common/helper';
import { import {
ActivitiesResponse, ActivitiesResponse,
ActivityResponse ActivityResponse
@ -32,7 +34,7 @@ import {
Query, Query,
UseInterceptors UseInterceptors
} from '@nestjs/common'; } from '@nestjs/common';
import { Order } from '@prisma/client'; import { Order, SymbolProfile } from '@prisma/client';
import { parseISO } from 'date-fns'; import { parseISO } from 'date-fns';
import { StatusCodes, getReasonPhrase } from 'http-status-codes'; import { StatusCodes, getReasonPhrase } from 'http-status-codes';
@ -46,7 +48,8 @@ export class ActivitiesController {
private readonly activitiesService: ActivitiesService, private readonly activitiesService: ActivitiesService,
private readonly apiService: ApiService, private readonly apiService: ApiService,
private readonly dataProviderService: DataProviderService, private readonly dataProviderService: DataProviderService,
private readonly dataGatheringService: DataGatheringService private readonly dataGatheringService: DataGatheringService,
private readonly symbolProfileService: SymbolProfileService
) {} ) {}
@Delete() @Delete()
@ -224,8 +227,12 @@ export class ActivitiesController {
? userSubscription ? userSubscription
: authenticatedUserSubscription; : authenticatedUserSubscription;
let assetProfiles: {
[assetProfileIdentifier: string]: Partial<SymbolProfile>;
};
try { try {
await this.dataProviderService.validateActivities({ assetProfiles = await this.dataProviderService.validateActivities({
subscription, subscription,
activitiesDto: [ activitiesDto: [
{ {
@ -251,6 +258,15 @@ export class ActivitiesController {
const customCurrency = data.customCurrency; const customCurrency = data.customCurrency;
const dataSource = data.dataSource; const dataSource = data.dataSource;
const symbol = await this.symbolProfileService.getSymbolOfAssetProfile({
dataSource,
symbol: data.symbol,
symbolOfDataProvider:
assetProfiles[
getAssetProfileIdentifier({ dataSource, symbol: data.symbol })
]?.symbol
});
if (customCurrency) { if (customCurrency) {
data.currency = customCurrency; data.currency = customCurrency;
@ -268,12 +284,12 @@ export class ActivitiesController {
create: { create: {
currency, currency,
dataSource, dataSource,
symbol: data.symbol symbol
}, },
where: { where: {
dataSource_symbol: { dataSource_symbol: {
dataSource, dataSource,
symbol: data.symbol symbol
} }
} }
} }
@ -291,8 +307,8 @@ export class ActivitiesController {
dataGatheringItems: [ dataGatheringItems: [
{ {
dataSource, dataSource,
date: activity.date, symbol,
symbol: data.symbol date: activity.date
} }
], ],
priority: DATA_GATHERING_QUEUE_PRIORITY_HIGH priority: DATA_GATHERING_QUEUE_PRIORITY_HIGH
@ -330,6 +346,11 @@ export class ActivitiesController {
const customCurrency = data.customCurrency; const customCurrency = data.customCurrency;
const dataSource = data.dataSource; const dataSource = data.dataSource;
const symbol = await this.symbolProfileService.getSymbolOfAssetProfile({
dataSource,
symbol: data.symbol
});
delete data.accountId; delete data.accountId;
if (customCurrency) { if (customCurrency) {
@ -356,13 +377,12 @@ export class ActivitiesController {
connect: { connect: {
dataSource_symbol: { dataSource_symbol: {
dataSource, dataSource,
symbol: data.symbol symbol
} }
}, },
update: { update: {
assetClass: data.assetClass, assetClass: data.assetClass,
assetSubClass: data.assetSubClass, assetSubClass: data.assetSubClass
name: data.symbol
} }
}, },
tags: data.tags?.map((id) => { tags: data.tags?.map((id) => {

1
apps/api/src/app/activities/activities.service.ts

@ -896,7 +896,6 @@ export class ActivitiesService {
data.type === 'BUY') data.type === 'BUY')
) { ) {
delete data.SymbolProfile.connect; delete data.SymbolProfile.connect;
delete data.SymbolProfile.update.name;
} else { } else {
delete data.SymbolProfile.update; delete data.SymbolProfile.update;

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

@ -102,9 +102,17 @@ export class AdminService {
); );
} }
return this.symbolProfileService.add( const symbolOfAssetProfile =
assetProfile as Prisma.SymbolProfileCreateInput await this.symbolProfileService.getSymbolOfAssetProfile({
); dataSource,
symbol,
symbolOfDataProvider: assetProfile.symbol
});
return this.symbolProfileService.add({
...assetProfile,
symbol: symbolOfAssetProfile
} as Prisma.SymbolProfileCreateInput);
} catch (error) { } catch (error) {
if ( if (
error instanceof Prisma.PrismaClientKnownRequestError && error instanceof Prisma.PrismaClientKnownRequestError &&

27
apps/api/src/app/endpoints/watchlist/watchlist.service.ts

@ -26,10 +26,12 @@ export class WatchlistService {
public async createWatchlistItem({ public async createWatchlistItem({
dataSource, dataSource,
symbol, symbol: aSymbol,
userId userId
}: { userId: string } & AssetProfileIdentifier): Promise<void> { }: { userId: string } & AssetProfileIdentifier): Promise<void> {
const symbolProfile = await this.prismaService.symbolProfile.findUnique({ let symbol = aSymbol;
let symbolProfile = await this.prismaService.symbolProfile.findUnique({
where: { where: {
dataSource_symbol: { dataSource, symbol } dataSource_symbol: { dataSource, symbol }
} }
@ -49,9 +51,24 @@ export class WatchlistService {
); );
} }
await this.symbolProfileService.add( symbol = await this.symbolProfileService.getSymbolOfAssetProfile({
assetProfile as Prisma.SymbolProfileCreateInput 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({ await this.dataGatheringService.gatherSymbol({

38
apps/api/src/app/import/import.service.ts

@ -26,6 +26,7 @@ import {
} from '@ghostfolio/common/dtos'; } from '@ghostfolio/common/dtos';
import { import {
getAssetProfileIdentifier, getAssetProfileIdentifier,
isSameSymbol,
isValidCustomAssetProfileSymbol, isValidCustomAssetProfileSymbol,
parseDate parseDate
} from '@ghostfolio/common/helper'; } from '@ghostfolio/common/helper';
@ -736,6 +737,36 @@ export class ImportService {
subscription: user.subscription 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({ const activitiesExtendedWithErrors = await this.extendActivitiesWithErrors({
activitiesDto, activitiesDto,
userCurrency, userCurrency,
@ -852,9 +883,11 @@ export class ImportService {
url, url,
updatedAt updatedAt
} = assetProfile; } = assetProfile;
const validatedAccount = accounts.find(({ id }) => { const validatedAccount = accounts.find(({ id }) => {
return id === accountId; return id === accountId;
}); });
const validatedTags = tags.filter(({ id: tagId }) => { const validatedTags = tags.filter(({ id: tagId }) => {
return tagIds.some((activityTagId) => { return tagIds.some((activityTagId) => {
return activityTagId === tagId; return activityTagId === tagId;
@ -1056,7 +1089,10 @@ export class ImportService {
isSameSecond(activity.date, date) && isSameSecond(activity.date, date) &&
activity.fee === fee && activity.fee === fee &&
activity.quantity === quantity && activity.quantity === quantity &&
activity.assetProfile.symbol === symbol && isSameSymbol({
symbol1: activity.assetProfile.symbol,
symbol2: symbol
}) &&
activity.type === type && activity.type === type &&
activity.unitPrice === unitPrice activity.unitPrice === unitPrice
); );

97
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');
});
});
});

10
apps/api/src/services/data-provider/data-provider.service.ts

@ -23,6 +23,7 @@ import {
getStartOfUtcDate, getStartOfUtcDate,
isCurrency, isCurrency,
isDerivedCurrency, isDerivedCurrency,
isSameSymbol,
isValidCustomAssetProfileSymbol, isValidCustomAssetProfileSymbol,
isValidSearchQuery isValidSearchQuery
} from '@ghostfolio/common/helper'; } from '@ghostfolio/common/helper';
@ -130,8 +131,13 @@ export class DataProviderService implements OnModuleInit {
}) })
] = { ] = {
...assetProfile, ...assetProfile,
symbol, name: formatAssetProfileName(assetProfile),
name: formatAssetProfileName(assetProfile) symbol: isSameSymbol({
symbol1: symbol,
symbol2: assetProfile.symbol
})
? assetProfile.symbol
: symbol
}; };
} }
}) })

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

@ -4,36 +4,55 @@ import {
} from '@ghostfolio/common/config'; } from '@ghostfolio/common/config';
import { parseDate } from '@ghostfolio/common/helper'; import { parseDate } from '@ghostfolio/common/helper';
import { DataSource } from '@prisma/client';
import { DataGatheringService } from './data-gathering.service'; import { DataGatheringService } from './data-gathering.service';
describe('DataGatheringService', () => { describe('DataGatheringService', () => {
let dataGatheringQueue: { addBulk: jest.Mock; clean: jest.Mock }; let dataGatheringQueue: { addBulk: jest.Mock; clean: jest.Mock };
let dataGatheringService: DataGatheringService; 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(() => { beforeEach(() => {
dataGatheringQueue = { dataGatheringQueue = {
addBulk: jest.fn().mockResolvedValue([]), addBulk: jest.fn().mockResolvedValue([]),
clean: jest.fn().mockResolvedValue([]) clean: jest.fn().mockResolvedValue([])
}; };
dataProviderService = { getHistoricalRaw: jest.fn() }; dataProviderService = {
getAssetProfiles: jest.fn().mockResolvedValue({}),
getHistoricalRaw: jest.fn()
};
prismaService = { prismaService = {
marketData: { marketData: {
groupBy: jest.fn().mockResolvedValue([]), groupBy: jest.fn().mockResolvedValue([]),
upsert: jest.fn().mockResolvedValue({}) upsert: jest.fn().mockResolvedValue({})
} },
symbolProfile: { upsert: jest.fn().mockResolvedValue({}) }
};
symbolProfileService = {
getSymbolProfiles: jest.fn().mockResolvedValue([])
}; };
dataGatheringService = new DataGatheringService( dataGatheringService = new DataGatheringService(
null, [],
dataGatheringQueue as any, dataGatheringQueue as any,
dataProviderService as any, dataProviderService as any,
null, null,
null, null,
prismaService as any, prismaService as any,
null, 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', () => { describe('gatherRecentMarketData', () => {
it('queries the asset profiles with recent market data once and reuses them', async () => { it('queries the asset profiles with recent market data once and reuses them', async () => {
const assetProfileIdentifiersWithRecentMarketData = [ const assetProfileIdentifiersWithRecentMarketData = [

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

@ -95,8 +95,13 @@ export class DataGatheringService {
assetProfileIdentifiers assetProfileIdentifiers
); );
for (const assetProfile of Object.values(assetProfiles)) { for (const { dataSource, symbol } of assetProfileIdentifiers) {
const { symbol } = assetProfile; const assetProfile =
assetProfiles[getAssetProfileIdentifier({ dataSource, symbol })];
if (!assetProfile) {
continue;
}
const symbolProfile = symbolProfiles.find( const symbolProfile = symbolProfiles.find(
({ symbol: symbolProfileSymbol }) => { ({ symbol: symbolProfileSymbol }) => {
@ -123,9 +128,7 @@ export class DataGatheringService {
}); });
} catch (error) { } catch (error) {
this.logger.error( this.logger.error(
`Failed to enhance data for ${symbol} (${ `Failed to enhance data for ${symbol} (${dataSource}) by ${dataEnhancer.getName()}`,
assetProfile.dataSource
}) by ${dataEnhancer.getName()}`,
error error
); );
} }
@ -137,7 +140,6 @@ export class DataGatheringService {
countries, countries,
currency, currency,
cusip, cusip,
dataSource,
figi, figi,
figiComposite, figiComposite,
figiShareClass, figiShareClass,
@ -164,8 +166,8 @@ export class DataGatheringService {
isin, isin,
name, name,
sectors, sectors,
symbol, url,
url symbol: assetProfile.symbol
}, },
update: { update: {
assetClass, assetClass,

113
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();
});
});
});

50
apps/api/src/services/symbol-profile/symbol-profile.service.ts

@ -1,6 +1,9 @@
import { PrismaService } from '@ghostfolio/api/services/prisma/prisma.service'; import { PrismaService } from '@ghostfolio/api/services/prisma/prisma.service';
import { UNKNOWN_KEY } from '@ghostfolio/common/config'; import { UNKNOWN_KEY } from '@ghostfolio/common/config';
import { applyAssetProfileOverrides } from '@ghostfolio/common/helper'; import {
applyAssetProfileOverrides,
isSameSymbol
} from '@ghostfolio/common/helper';
import { import {
AssetProfileIdentifier, AssetProfileIdentifier,
EnhancedAssetProfile, 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( public async getSymbolProfiles(
aAssetProfileIdentifiers: AssetProfileIdentifier[] aAssetProfileIdentifiers: AssetProfileIdentifier[]
): Promise<EnhancedAssetProfile[]> { ): Promise<EnhancedAssetProfile[]> {

35
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 { SelectionModel } from '@angular/cdk/collections';
import { CommonModule } from '@angular/common'; import { CommonModule } from '@angular/common';
import { HttpErrorResponse } from '@angular/common/http';
import { import {
AfterViewInit, AfterViewInit,
ChangeDetectionStrategy, ChangeDetectionStrategy,
@ -68,8 +69,8 @@ import {
import ms from 'ms'; import ms from 'ms';
import { DeviceDetectorService } from 'ngx-device-detector'; import { DeviceDetectorService } from 'ngx-device-detector';
import { NgxSkeletonLoaderModule } from 'ngx-skeleton-loader'; import { NgxSkeletonLoaderModule } from 'ngx-skeleton-loader';
import { Subject } from 'rxjs'; import { EMPTY, Subject } from 'rxjs';
import { distinctUntilChanged } from 'rxjs/operators'; import { catchError, distinctUntilChanged } from 'rxjs/operators';
import { AdminMarketDataService } from './admin-market-data.service'; import { AdminMarketDataService } from './admin-market-data.service';
import { GfAssetProfileDialogComponent } from './asset-profile-dialog/asset-profile-dialog.component'; import { GfAssetProfileDialogComponent } from './asset-profile-dialog/asset-profile-dialog.component';
@ -495,15 +496,37 @@ export class GfAdminMarketDataComponent implements AfterViewInit, OnInit {
if (addAssetProfile && dataSource && symbol) { if (addAssetProfile && dataSource && symbol) {
this.adminService this.adminService
.addAssetProfile({ dataSource, symbol }) .addAssetProfile({ dataSource, symbol })
.pipe(takeUntilDestroyed(this.destroyRef)) .pipe(
.subscribe(() => { 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.loadData();
this.onOpenAssetProfileDialog({
dataSource,
symbol: assetProfile?.symbol ?? symbol
});
}); });
} else { } else {
this.loadData(); this.loadData();
}
this.onOpenAssetProfileDialog({ dataSource, symbol }); this.onOpenAssetProfileDialog({ dataSource, symbol });
}
}); });
}); });
} }

33
libs/common/src/lib/helper.spec.ts

@ -11,6 +11,7 @@ import {
isAccountExcluded, isAccountExcluded,
isCurrency, isCurrency,
isCurrencySymbol, isCurrencySymbol,
isSameSymbol,
isSplitRatio, isSplitRatio,
isValidCustomAssetProfileSymbol, isValidCustomAssetProfileSymbol,
isValidGranteeOfAccess, 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', () => { describe('Is split ratio', () => {
it('Forward split', () => { it('Forward split', () => {
expect(isSplitRatio({ denominator: 1, numerator: 2 })).toEqual(true); expect(isSplitRatio({ denominator: 1, numerator: 2 })).toEqual(true);

16
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 * 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), * after the split (numerator) per number of shares held before (denominator),

9
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 { HttpClient, HttpHeaders, HttpParams } from '@angular/common/http';
import { inject, Service } from '@angular/core'; 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 { JobStatus } from 'bull';
import { isNumber } from 'lodash'; import { isNumber } from 'lodash';
@ -35,7 +40,7 @@ export class AdminService {
private readonly http = inject(HttpClient); private readonly http = inject(HttpClient);
public addAssetProfile({ dataSource, symbol }: AssetProfileIdentifier) { public addAssetProfile({ dataSource, symbol }: AssetProfileIdentifier) {
return this.http.post<void>( return this.http.post<SymbolProfile>(
`/api/v1/admin/profile-data/${dataSource}/${encodeURIComponent(symbol)}`, `/api/v1/admin/profile-data/${dataSource}/${encodeURIComponent(symbol)}`,
null null
); );

Loading…
Cancel
Save