Browse Source

Fix creation of asset profiles with symbol in wrong letter case by using original symbol

pull/7727/head
Thomas Kaul 1 day ago
parent
commit
ad0bae8805
  1. 13
      apps/api/src/app/endpoints/watchlist/watchlist.service.ts
  2. 30
      apps/api/src/app/import/import.service.ts
  3. 91
      apps/api/src/services/symbol-profile/symbol-profile.service.spec.ts
  4. 49
      apps/api/src/services/symbol-profile/symbol-profile.service.ts
  5. 36
      apps/client/src/app/components/admin-market-data/admin-market-data.component.ts
  6. 5
      libs/common/src/lib/helper.ts

13
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);
}
}

30
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
);

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

49
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 [];

36
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();

5
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,

Loading…
Cancel
Save