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
### Fixed
- Fixed the creation of asset profiles with a symbol in the wrong letter case
## 3.62.0 - 2026-08-27
### 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 { 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';
import { SubscriptionType } from '@ghostfolio/common/enums';
import { getAssetProfileIdentifier } from '@ghostfolio/common/helper';
import {
ActivitiesResponse,
ActivityResponse
@ -32,7 +34,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';
@ -46,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()
@ -224,8 +227,12 @@ export class ActivitiesController {
? userSubscription
: authenticatedUserSubscription;
let assetProfiles: {
[assetProfileIdentifier: string]: Partial<SymbolProfile>;
};
try {
await this.dataProviderService.validateActivities({
assetProfiles = await this.dataProviderService.validateActivities({
subscription,
activitiesDto: [
{
@ -251,6 +258,15 @@ export class ActivitiesController {
const customCurrency = data.customCurrency;
const dataSource = data.dataSource;
const symbol = await this.symbolProfileService.getSymbolOfAssetProfile({
dataSource,
symbol: data.symbol,
symbolOfDataProvider:
assetProfiles[
getAssetProfileIdentifier({ dataSource, symbol: data.symbol })
]?.symbol
});
if (customCurrency) {
data.currency = customCurrency;
@ -268,12 +284,12 @@ export class ActivitiesController {
create: {
currency,
dataSource,
symbol: data.symbol
symbol
},
where: {
dataSource_symbol: {
dataSource,
symbol: data.symbol
symbol
}
}
}
@ -291,8 +307,8 @@ export class ActivitiesController {
dataGatheringItems: [
{
dataSource,
date: activity.date,
symbol: data.symbol
symbol,
date: activity.date
}
],
priority: DATA_GATHERING_QUEUE_PRIORITY_HIGH
@ -330,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) {
@ -356,13 +377,12 @@ export class ActivitiesController {
connect: {
dataSource_symbol: {
dataSource,
symbol: data.symbol
symbol
}
},
update: {
assetClass: data.assetClass,
assetSubClass: data.assetSubClass,
name: data.symbol
assetSubClass: data.assetSubClass
}
},
tags: data.tags?.map((id) => {

1
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;

14
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 &&

27
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<void> {
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,24 @@ export class WatchlistService {
);
}
await this.symbolProfileService.add(
assetProfile as Prisma.SymbolProfileCreateInput
);
symbol = await this.symbolProfileService.getSymbolOfAssetProfile({
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({

38
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,36 @@ 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 })];
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({
activitiesDto,
userCurrency,
@ -852,9 +883,11 @@ export class ImportService {
url,
updatedAt
} = assetProfile;
const validatedAccount = accounts.find(({ id }) => {
return id === accountId;
});
const validatedTags = tags.filter(({ id: tagId }) => {
return tagIds.some((activityTagId) => {
return activityTagId === tagId;
@ -1056,7 +1089,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
);

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,
isCurrency,
isDerivedCurrency,
isSameSymbol,
isValidCustomAssetProfileSymbol,
isValidSearchQuery
} from '@ghostfolio/common/helper';
@ -130,8 +131,13 @@ export class DataProviderService implements OnModuleInit {
})
] = {
...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';
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,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', () => {
it('queries the asset profiles with recent market data once and reuses them', async () => {
const assetProfileIdentifiersWithRecentMarketData = [

18
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 }) => {
@ -123,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
);
}
@ -137,7 +140,6 @@ export class DataGatheringService {
countries,
currency,
cusip,
dataSource,
figi,
figiComposite,
figiShareClass,
@ -164,8 +166,8 @@ export class DataGatheringService {
isin,
name,
sectors,
symbol,
url
url,
symbol: assetProfile.symbol
},
update: {
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 { UNKNOWN_KEY } from '@ghostfolio/common/config';
import { applyAssetProfileOverrides } from '@ghostfolio/common/helper';
import {
applyAssetProfileOverrides,
isSameSymbol
} from '@ghostfolio/common/helper';
import {
AssetProfileIdentifier,
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(
aAssetProfileIdentifiers: AssetProfileIdentifier[]
): 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 { CommonModule } from '@angular/common';
import { HttpErrorResponse } from '@angular/common/http';
import {
AfterViewInit,
ChangeDetectionStrategy,
@ -68,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';
@ -495,15 +496,37 @@ export class GfAdminMarketDataComponent implements AfterViewInit, OnInit {
if (addAssetProfile && dataSource && symbol) {
this.adminService
.addAssetProfile({ dataSource, symbol })
.pipe(takeUntilDestroyed(this.destroyRef))
.subscribe(() => {
.pipe(
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.onOpenAssetProfileDialog({
dataSource,
symbol: assetProfile?.symbol ?? symbol
});
});
} else {
this.loadData();
}
this.onOpenAssetProfileDialog({ dataSource, symbol });
this.onOpenAssetProfileDialog({ dataSource, symbol });
}
});
});
}

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

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
* 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 { 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<void>(
return this.http.post<SymbolProfile>(
`/api/v1/admin/profile-data/${dataSource}/${encodeURIComponent(symbol)}`,
null
);

Loading…
Cancel
Save