Browse Source

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

pull/7727/head
Thomas Kaul 2 days 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. 43
      apps/api/src/services/symbol-profile/symbol-profile.service.ts
  5. 24
      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({ symbolProfile = await this.prismaService.symbolProfile.findUnique({
where: { where: {
@ -60,9 +64,10 @@ export class WatchlistService {
}); });
if (!symbolProfile) { if (!symbolProfile) {
await this.symbolProfileService.add( await this.symbolProfileService.add({
assetProfile as Prisma.SymbolProfileCreateInput ...assetProfile,
); symbol
} as Prisma.SymbolProfileCreateInput);
} }
} }

30
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,27 @@ 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 })];
if (assetProfile) {
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,
@ -847,13 +869,12 @@ export class ImportService {
name, name,
scraperConfiguration, scraperConfiguration,
sectors, sectors,
symbol,
symbolMapping, symbolMapping,
url, url,
updatedAt updatedAt
} = assetProfile; } = assetProfile;
const symbol = activity.assetProfile.symbol;
const validatedAccount = accounts.find(({ id }) => { const validatedAccount = accounts.find(({ id }) => {
return id === accountId; return id === accountId;
}); });
@ -1059,7 +1080,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
); );

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

43
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 * Gets the symbol to use for an asset profile. An existing asset profile
* already in the database wins, also if its symbol has a different letter * wins, also if its symbol has a different letter case. Otherwise the symbol
* case. This prevents a second asset profile for the same instrument. * of the data provider is used, as it has the letter case of the 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({ public async getSymbolOfAssetProfile({
dataSource, dataSource,
@ -143,29 +140,21 @@ export class SymbolProfileService {
return symbol; return symbol;
} }
const symbolProfile = await this.prismaService.symbolProfile.findUnique({ const symbolProfiles = await this.prismaService.symbolProfile.findMany({
where: { dataSource_symbol: { dataSource, symbol } }
});
if (symbolProfile) {
return symbolProfile.symbol;
}
const symbolProfileWithOtherLetterCase =
await this.prismaService.symbolProfile.findFirst({
orderBy: { symbol: 'asc' }, orderBy: { symbol: 'asc' },
select: { symbol: true },
where: { where: {
dataSource, dataSource,
symbol: { symbol: { equals: symbol, mode: 'insensitive' }
equals: this.escapeLikePattern(symbol),
mode: 'insensitive'
}
} }
}); });
return ( const symbolProfile =
symbolProfileWithOtherLetterCase?.symbol ?? symbolOfDataProvider ?? symbol symbolProfiles.find(({ symbol: symbolOfSymbolProfile }) => {
); return symbolOfSymbolProfile === symbol;
}) ?? symbolProfiles[0];
return symbolProfile?.symbol ?? symbolOfDataProvider ?? symbol;
} }
public async getSymbolProfiles( 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[] { private getCountries(aCountries: Prisma.JsonArray = []): Country[] {
if (aCountries === null) { if (aCountries === null) {
return []; return [];

24
apps/client/src/app/components/admin-market-data/admin-market-data.component.ts

@ -69,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';
@ -496,16 +496,11 @@ 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) => {
error: (error: HttpErrorResponse) => {
const { message } = (error.error ?? {}) as {
message?: string;
};
this.snackBar.open( this.snackBar.open(
'😞 ' + '😞 ' +
(message ?? (error?.message ??
$localize`An error occurred while creating the asset profile ${symbol} (${dataSource}).`), $localize`An error occurred while creating the asset profile ${symbol} (${dataSource}).`),
undefined, undefined,
{ {
@ -514,15 +509,18 @@ export class GfAdminMarketDataComponent implements AfterViewInit, OnInit {
); );
this.router.navigate(['.'], { relativeTo: this.route }); this.router.navigate(['.'], { relativeTo: this.route });
},
next: (assetProfile) => { return EMPTY;
}),
takeUntilDestroyed(this.destroyRef)
)
.subscribe((assetProfile) => {
this.loadData(); this.loadData();
this.onOpenAssetProfileDialog({ this.onOpenAssetProfileDialog({
dataSource, dataSource,
symbol: assetProfile?.symbol ?? symbol symbol: assetProfile?.symbol ?? symbol
}); });
}
}); });
} else { } else {
this.loadData(); 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 * Checks whether two symbols are the same, ignoring the letter case. A data
* providers can report a symbol in a different letter case than requested, for * provider can report "AAPL" for a requested symbol "aapl".
* example "AAPL" for "aapl".
*/ */
export function isSameSymbol({ export function isSameSymbol({
symbol1, symbol1,

Loading…
Cancel
Save