mirror of https://github.com/ghostfolio/ghostfolio
173 changed files with 10581 additions and 9725 deletions
@ -0,0 +1,87 @@ |
|||||
|
import { HasPermission } from '@ghostfolio/api/decorators/has-permission.decorator'; |
||||
|
import { HasPermissionGuard } from '@ghostfolio/api/guards/has-permission.guard'; |
||||
|
import { ApiService } from '@ghostfolio/api/services/api/api.service'; |
||||
|
import { UpdateAssetProfileDataDto } from '@ghostfolio/common/dtos'; |
||||
|
import { |
||||
|
AssetProfilesResponse, |
||||
|
EnhancedSymbolProfile |
||||
|
} from '@ghostfolio/common/interfaces'; |
||||
|
import { permissions } from '@ghostfolio/common/permissions'; |
||||
|
import { MarketDataPreset, RequestWithUser } from '@ghostfolio/common/types'; |
||||
|
|
||||
|
import { |
||||
|
Body, |
||||
|
Controller, |
||||
|
Get, |
||||
|
HttpException, |
||||
|
Inject, |
||||
|
Param, |
||||
|
Patch, |
||||
|
Query, |
||||
|
UseGuards |
||||
|
} from '@nestjs/common'; |
||||
|
import { REQUEST } from '@nestjs/core'; |
||||
|
import { AuthGuard } from '@nestjs/passport'; |
||||
|
import { DataSource, Prisma } from '@prisma/client'; |
||||
|
import { StatusCodes, getReasonPhrase } from 'http-status-codes'; |
||||
|
|
||||
|
import { AssetProfilesService } from './asset-profiles.service'; |
||||
|
|
||||
|
@Controller('asset-profiles') |
||||
|
export class AssetProfilesController { |
||||
|
public constructor( |
||||
|
private readonly apiService: ApiService, |
||||
|
private readonly assetProfilesService: AssetProfilesService, |
||||
|
@Inject(REQUEST) private readonly request: RequestWithUser |
||||
|
) {} |
||||
|
|
||||
|
@Get() |
||||
|
@HasPermission(permissions.accessAdminControl) |
||||
|
@UseGuards(AuthGuard('jwt'), HasPermissionGuard) |
||||
|
public async getAssetProfiles( |
||||
|
@Query('assetSubClasses') filterByAssetSubClasses?: string, |
||||
|
@Query('dataSource') filterByDataSource?: string, |
||||
|
@Query('presetId') presetId?: MarketDataPreset, |
||||
|
@Query('query') filterBySearchQuery?: string, |
||||
|
@Query('skip') skip?: number, |
||||
|
@Query('sortColumn') sortColumn?: string, |
||||
|
@Query('sortDirection') sortDirection?: Prisma.SortOrder, |
||||
|
@Query('take') take?: number |
||||
|
): Promise<AssetProfilesResponse> { |
||||
|
const filters = this.apiService.buildFiltersFromQueryParams({ |
||||
|
filterByAssetSubClasses, |
||||
|
filterByDataSource, |
||||
|
filterBySearchQuery |
||||
|
}); |
||||
|
|
||||
|
return this.assetProfilesService.getAssetProfiles({ |
||||
|
filters, |
||||
|
presetId, |
||||
|
sortColumn, |
||||
|
sortDirection, |
||||
|
skip: isNaN(skip) ? undefined : skip, |
||||
|
take: isNaN(take) ? undefined : take |
||||
|
}); |
||||
|
} |
||||
|
|
||||
|
@HasPermission(permissions.accessAdminControl) |
||||
|
@Patch(':dataSource/:symbol') |
||||
|
@UseGuards(AuthGuard('jwt'), HasPermissionGuard) |
||||
|
public async updateAssetProfileData( |
||||
|
@Body() assetProfileData: UpdateAssetProfileDataDto, |
||||
|
@Param('dataSource') dataSource: DataSource, |
||||
|
@Param('symbol') symbol: string |
||||
|
): Promise<EnhancedSymbolProfile> { |
||||
|
if (!this.request.user.settings.settings.isExperimentalFeatures) { |
||||
|
throw new HttpException( |
||||
|
getReasonPhrase(StatusCodes.NOT_FOUND), |
||||
|
StatusCodes.NOT_FOUND |
||||
|
); |
||||
|
} |
||||
|
|
||||
|
return this.assetProfilesService.updateAssetProfileData( |
||||
|
{ dataSource, symbol }, |
||||
|
assetProfileData |
||||
|
); |
||||
|
} |
||||
|
} |
||||
@ -0,0 +1,25 @@ |
|||||
|
import { ActivitiesModule } from '@ghostfolio/api/app/activities/activities.module'; |
||||
|
import { ApiModule } from '@ghostfolio/api/services/api/api.module'; |
||||
|
import { BenchmarkModule } from '@ghostfolio/api/services/benchmark/benchmark.module'; |
||||
|
import { ExchangeRateDataModule } from '@ghostfolio/api/services/exchange-rate-data/exchange-rate-data.module'; |
||||
|
import { PrismaModule } from '@ghostfolio/api/services/prisma/prisma.module'; |
||||
|
import { SymbolProfileModule } from '@ghostfolio/api/services/symbol-profile/symbol-profile.module'; |
||||
|
|
||||
|
import { Module } from '@nestjs/common'; |
||||
|
|
||||
|
import { AssetProfilesController } from './asset-profiles.controller'; |
||||
|
import { AssetProfilesService } from './asset-profiles.service'; |
||||
|
|
||||
|
@Module({ |
||||
|
controllers: [AssetProfilesController], |
||||
|
imports: [ |
||||
|
ActivitiesModule, |
||||
|
ApiModule, |
||||
|
BenchmarkModule, |
||||
|
ExchangeRateDataModule, |
||||
|
PrismaModule, |
||||
|
SymbolProfileModule |
||||
|
], |
||||
|
providers: [AssetProfilesService] |
||||
|
}) |
||||
|
export class AssetProfilesModule {} |
||||
@ -0,0 +1,482 @@ |
|||||
|
import { ActivitiesService } from '@ghostfolio/api/app/activities/activities.service'; |
||||
|
import { BenchmarkService } from '@ghostfolio/api/services/benchmark/benchmark.service'; |
||||
|
import { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-data/exchange-rate-data.service'; |
||||
|
import { PrismaService } from '@ghostfolio/api/services/prisma/prisma.service'; |
||||
|
import { SymbolProfileService } from '@ghostfolio/api/services/symbol-profile/symbol-profile.service'; |
||||
|
import { UpdateAssetProfileDataDto } from '@ghostfolio/common/dtos'; |
||||
|
import { |
||||
|
applyAssetProfileOverrides, |
||||
|
getAssetProfileIdentifier, |
||||
|
getCurrencyFromSymbol, |
||||
|
isCurrency |
||||
|
} from '@ghostfolio/common/helper'; |
||||
|
import { |
||||
|
AssetProfileItem, |
||||
|
AssetProfileIdentifier, |
||||
|
AssetProfilesResponse, |
||||
|
EnhancedSymbolProfile, |
||||
|
Filter |
||||
|
} from '@ghostfolio/common/interfaces'; |
||||
|
import { MarketDataPreset } from '@ghostfolio/common/types'; |
||||
|
|
||||
|
import { Injectable, NotFoundException } from '@nestjs/common'; |
||||
|
import { AssetClass, AssetSubClass, DataSource, Prisma } from '@prisma/client'; |
||||
|
import { groupBy } from 'lodash'; |
||||
|
|
||||
|
@Injectable() |
||||
|
export class AssetProfilesService { |
||||
|
public constructor( |
||||
|
private readonly activitiesService: ActivitiesService, |
||||
|
private readonly benchmarkService: BenchmarkService, |
||||
|
private readonly exchangeRateDataService: ExchangeRateDataService, |
||||
|
private readonly prismaService: PrismaService, |
||||
|
private readonly symbolProfileService: SymbolProfileService |
||||
|
) {} |
||||
|
|
||||
|
public async getAssetProfiles({ |
||||
|
filters = [], |
||||
|
presetId, |
||||
|
sortColumn, |
||||
|
sortDirection = 'asc', |
||||
|
skip, |
||||
|
take = Number.MAX_SAFE_INTEGER |
||||
|
}: { |
||||
|
filters?: Filter[]; |
||||
|
presetId?: MarketDataPreset; |
||||
|
skip?: number; |
||||
|
sortColumn?: string; |
||||
|
sortDirection?: Prisma.SortOrder; |
||||
|
take?: number; |
||||
|
}): Promise<AssetProfilesResponse> { |
||||
|
let orderBy: Prisma.Enumerable<Prisma.SymbolProfileOrderByWithRelationInput> = |
||||
|
[{ symbol: 'asc' }]; |
||||
|
const where: Prisma.SymbolProfileWhereInput = {}; |
||||
|
|
||||
|
if (presetId === 'BENCHMARKS') { |
||||
|
const benchmarkAssetProfiles = |
||||
|
await this.benchmarkService.getBenchmarkAssetProfiles(); |
||||
|
|
||||
|
where.id = { |
||||
|
in: benchmarkAssetProfiles.map(({ id }) => { |
||||
|
return id; |
||||
|
}) |
||||
|
}; |
||||
|
} else if (presetId === 'CURRENCIES') { |
||||
|
return this.getAssetProfilesForCurrencies(); |
||||
|
} else if ( |
||||
|
presetId === 'ETF_WITHOUT_COUNTRIES' || |
||||
|
presetId === 'ETF_WITHOUT_SECTORS' |
||||
|
) { |
||||
|
filters = [{ id: 'ETF', type: 'ASSET_SUB_CLASS' }]; |
||||
|
} else if (presetId === 'NO_ACTIVITIES') { |
||||
|
where.activities = { |
||||
|
none: {} |
||||
|
}; |
||||
|
} |
||||
|
|
||||
|
const searchQuery = filters.find(({ type }) => { |
||||
|
return type === 'SEARCH_QUERY'; |
||||
|
})?.id; |
||||
|
|
||||
|
const { |
||||
|
ASSET_SUB_CLASS: filtersByAssetSubClass, |
||||
|
DATA_SOURCE: filtersByDataSource |
||||
|
} = groupBy(filters, ({ type }) => { |
||||
|
return type; |
||||
|
}); |
||||
|
|
||||
|
const marketDataItems = await this.prismaService.marketData.groupBy({ |
||||
|
_count: true, |
||||
|
by: ['dataSource', 'symbol'] |
||||
|
}); |
||||
|
|
||||
|
if (filtersByAssetSubClass) { |
||||
|
where.assetSubClass = AssetSubClass[filtersByAssetSubClass[0].id]; |
||||
|
} |
||||
|
|
||||
|
if (filtersByDataSource) { |
||||
|
where.dataSource = DataSource[filtersByDataSource[0].id]; |
||||
|
} |
||||
|
|
||||
|
if (searchQuery) { |
||||
|
where.OR = [ |
||||
|
{ id: { mode: 'insensitive', startsWith: searchQuery } }, |
||||
|
{ isin: { mode: 'insensitive', startsWith: searchQuery } }, |
||||
|
{ name: { mode: 'insensitive', startsWith: searchQuery } }, |
||||
|
{ symbol: { mode: 'insensitive', startsWith: searchQuery } } |
||||
|
]; |
||||
|
} |
||||
|
|
||||
|
if (sortColumn) { |
||||
|
orderBy = [{ [sortColumn]: sortDirection }]; |
||||
|
|
||||
|
if (sortColumn === 'activitiesCount') { |
||||
|
orderBy = [ |
||||
|
{ |
||||
|
activities: { |
||||
|
_count: sortDirection |
||||
|
} |
||||
|
} |
||||
|
]; |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
const extendedPrismaClient = this.getExtendedPrismaClient(); |
||||
|
|
||||
|
const symbolProfileResult = await Promise.all([ |
||||
|
extendedPrismaClient.symbolProfile.findMany({ |
||||
|
skip, |
||||
|
take, |
||||
|
where, |
||||
|
orderBy: [...orderBy, { id: sortDirection }], |
||||
|
select: { |
||||
|
_count: { |
||||
|
select: { |
||||
|
activities: true, |
||||
|
watchedBy: true |
||||
|
} |
||||
|
}, |
||||
|
activities: { |
||||
|
orderBy: [{ date: 'asc' }], |
||||
|
select: { date: true }, |
||||
|
take: 1 |
||||
|
}, |
||||
|
assetClass: true, |
||||
|
assetSubClass: true, |
||||
|
comment: true, |
||||
|
countries: true, |
||||
|
currency: true, |
||||
|
dataSource: true, |
||||
|
id: true, |
||||
|
isin: true, |
||||
|
isActive: true, |
||||
|
isUsedByUsersWithSubscription: true, |
||||
|
name: true, |
||||
|
scraperConfiguration: true, |
||||
|
sectors: true, |
||||
|
symbol: true, |
||||
|
SymbolProfileOverrides: true |
||||
|
} |
||||
|
}), |
||||
|
this.prismaService.symbolProfile.count({ where }) |
||||
|
]); |
||||
|
const symbolProfiles = symbolProfileResult[0]; |
||||
|
let count = symbolProfileResult[1]; |
||||
|
|
||||
|
const lastMarketPrices = await this.prismaService.marketData.findMany({ |
||||
|
distinct: ['dataSource', 'symbol'], |
||||
|
orderBy: { date: 'desc' }, |
||||
|
select: { |
||||
|
dataSource: true, |
||||
|
marketPrice: true, |
||||
|
symbol: true |
||||
|
}, |
||||
|
where: { |
||||
|
dataSource: { |
||||
|
in: symbolProfiles.map(({ dataSource }) => { |
||||
|
return dataSource; |
||||
|
}) |
||||
|
}, |
||||
|
symbol: { |
||||
|
in: symbolProfiles.map(({ symbol }) => { |
||||
|
return symbol; |
||||
|
}) |
||||
|
} |
||||
|
} |
||||
|
}); |
||||
|
|
||||
|
const lastMarketPriceMap = new Map<string, number>(); |
||||
|
|
||||
|
for (const { dataSource, marketPrice, symbol } of lastMarketPrices) { |
||||
|
lastMarketPriceMap.set( |
||||
|
getAssetProfileIdentifier({ dataSource, symbol }), |
||||
|
marketPrice |
||||
|
); |
||||
|
} |
||||
|
|
||||
|
let assetProfiles: AssetProfileItem[] = await Promise.all( |
||||
|
symbolProfiles.map(async (assetProfile) => { |
||||
|
const { |
||||
|
_count, |
||||
|
activities, |
||||
|
comment, |
||||
|
currency, |
||||
|
dataSource, |
||||
|
id, |
||||
|
isin, |
||||
|
isActive, |
||||
|
isUsedByUsersWithSubscription, |
||||
|
symbol |
||||
|
} = assetProfile; |
||||
|
|
||||
|
const { assetClass, assetSubClass, countries, name, sectors } = |
||||
|
applyAssetProfileOverrides( |
||||
|
assetProfile, |
||||
|
assetProfile.SymbolProfileOverrides |
||||
|
); |
||||
|
|
||||
|
const countriesCount = countries ? Object.keys(countries).length : 0; |
||||
|
|
||||
|
const lastMarketPrice = lastMarketPriceMap.get( |
||||
|
getAssetProfileIdentifier({ dataSource, symbol }) |
||||
|
); |
||||
|
|
||||
|
const marketDataItemCount = |
||||
|
marketDataItems.find((marketDataItem) => { |
||||
|
return ( |
||||
|
marketDataItem.dataSource === dataSource && |
||||
|
marketDataItem.symbol === symbol |
||||
|
); |
||||
|
})?._count ?? 0; |
||||
|
|
||||
|
const sectorsCount = sectors ? Object.keys(sectors).length : 0; |
||||
|
|
||||
|
return { |
||||
|
assetClass, |
||||
|
assetSubClass, |
||||
|
comment, |
||||
|
countriesCount, |
||||
|
currency, |
||||
|
dataSource, |
||||
|
id, |
||||
|
isActive, |
||||
|
isin, |
||||
|
lastMarketPrice, |
||||
|
marketDataItemCount, |
||||
|
name, |
||||
|
sectorsCount, |
||||
|
symbol, |
||||
|
activitiesCount: _count.activities, |
||||
|
date: activities?.[0]?.date, |
||||
|
isUsedByUsersWithSubscription: await isUsedByUsersWithSubscription, |
||||
|
watchedByCount: _count.watchedBy |
||||
|
}; |
||||
|
}) |
||||
|
); |
||||
|
|
||||
|
if (presetId) { |
||||
|
if (presetId === 'ETF_WITHOUT_COUNTRIES') { |
||||
|
assetProfiles = assetProfiles.filter(({ countriesCount }) => { |
||||
|
return countriesCount === 0; |
||||
|
}); |
||||
|
} else if (presetId === 'ETF_WITHOUT_SECTORS') { |
||||
|
assetProfiles = assetProfiles.filter(({ sectorsCount }) => { |
||||
|
return sectorsCount === 0; |
||||
|
}); |
||||
|
} |
||||
|
|
||||
|
count = assetProfiles.length; |
||||
|
} |
||||
|
|
||||
|
return { |
||||
|
assetProfiles, |
||||
|
count |
||||
|
}; |
||||
|
} |
||||
|
|
||||
|
public async updateAssetProfileData( |
||||
|
{ dataSource, symbol }: AssetProfileIdentifier, |
||||
|
assetProfileData: UpdateAssetProfileDataDto |
||||
|
): Promise<EnhancedSymbolProfile> { |
||||
|
const notFoundMessage = `Could not find the asset profile for ${symbol} (${dataSource})`; |
||||
|
|
||||
|
const data = this.getAssetProfileDataUpdate(assetProfileData); |
||||
|
|
||||
|
if (Object.keys(data).length > 0) { |
||||
|
try { |
||||
|
await this.symbolProfileService.updateSymbolProfile( |
||||
|
{ |
||||
|
dataSource, |
||||
|
symbol |
||||
|
}, |
||||
|
this.symbolProfileService.getAssetProfileUpdateInput( |
||||
|
{ dataSource, symbol }, |
||||
|
data |
||||
|
) |
||||
|
); |
||||
|
} catch (error) { |
||||
|
if ( |
||||
|
error instanceof Prisma.PrismaClientKnownRequestError && |
||||
|
error.code === 'P2025' |
||||
|
) { |
||||
|
throw new NotFoundException(notFoundMessage); |
||||
|
} |
||||
|
|
||||
|
throw error; |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
const [assetProfile] = await this.symbolProfileService.getSymbolProfiles([ |
||||
|
{ |
||||
|
dataSource, |
||||
|
symbol |
||||
|
} |
||||
|
]); |
||||
|
|
||||
|
if (!assetProfile) { |
||||
|
throw new NotFoundException(notFoundMessage); |
||||
|
} |
||||
|
|
||||
|
return assetProfile; |
||||
|
} |
||||
|
|
||||
|
private getAssetProfileDataUpdate({ |
||||
|
countries, |
||||
|
holdings, |
||||
|
sectors |
||||
|
}: UpdateAssetProfileDataDto): Pick< |
||||
|
Prisma.SymbolProfileUpdateInput, |
||||
|
'countries' | 'holdings' | 'sectors' |
||||
|
> { |
||||
|
const data: Pick< |
||||
|
Prisma.SymbolProfileUpdateInput, |
||||
|
'countries' | 'holdings' | 'sectors' |
||||
|
> = {}; |
||||
|
|
||||
|
if (countries !== undefined) { |
||||
|
data.countries = countries as Prisma.JsonArray; |
||||
|
} |
||||
|
|
||||
|
if (holdings !== undefined) { |
||||
|
data.holdings = holdings as Prisma.JsonArray; |
||||
|
} |
||||
|
|
||||
|
if (sectors !== undefined) { |
||||
|
data.sectors = sectors as Prisma.JsonArray; |
||||
|
} |
||||
|
|
||||
|
return data; |
||||
|
} |
||||
|
|
||||
|
private async getAssetProfilesForCurrencies(): Promise<AssetProfilesResponse> { |
||||
|
const currencyPairs = this.exchangeRateDataService.getCurrencyPairs(); |
||||
|
|
||||
|
const [lastMarketPrices, marketDataItems] = await Promise.all([ |
||||
|
this.prismaService.marketData.findMany({ |
||||
|
distinct: ['dataSource', 'symbol'], |
||||
|
orderBy: { date: 'desc' }, |
||||
|
select: { |
||||
|
dataSource: true, |
||||
|
marketPrice: true, |
||||
|
symbol: true |
||||
|
}, |
||||
|
where: { |
||||
|
dataSource: { |
||||
|
in: currencyPairs.map(({ dataSource }) => { |
||||
|
return dataSource; |
||||
|
}) |
||||
|
}, |
||||
|
symbol: { |
||||
|
in: currencyPairs.map(({ symbol }) => { |
||||
|
return symbol; |
||||
|
}) |
||||
|
} |
||||
|
} |
||||
|
}), |
||||
|
this.prismaService.marketData.groupBy({ |
||||
|
_count: true, |
||||
|
by: ['dataSource', 'symbol'] |
||||
|
}) |
||||
|
]); |
||||
|
|
||||
|
const lastMarketPriceMap = new Map<string, number>(); |
||||
|
|
||||
|
for (const { dataSource, marketPrice, symbol } of lastMarketPrices) { |
||||
|
lastMarketPriceMap.set( |
||||
|
getAssetProfileIdentifier({ dataSource, symbol }), |
||||
|
marketPrice |
||||
|
); |
||||
|
} |
||||
|
|
||||
|
const assetProfilePromises: Promise<AssetProfileItem>[] = currencyPairs.map( |
||||
|
async ({ dataSource, symbol }) => { |
||||
|
let activitiesCount: EnhancedSymbolProfile['activitiesCount'] = 0; |
||||
|
let currency: EnhancedSymbolProfile['currency'] = '-'; |
||||
|
let dateOfFirstActivity: EnhancedSymbolProfile['dateOfFirstActivity']; |
||||
|
|
||||
|
if (isCurrency(getCurrencyFromSymbol(symbol))) { |
||||
|
currency = getCurrencyFromSymbol(symbol); |
||||
|
({ activitiesCount, dateOfFirstActivity } = |
||||
|
await this.activitiesService.getStatisticsByCurrency(currency)); |
||||
|
} |
||||
|
|
||||
|
const lastMarketPrice = lastMarketPriceMap.get( |
||||
|
getAssetProfileIdentifier({ dataSource, symbol }) |
||||
|
); |
||||
|
|
||||
|
const marketDataItemCount = |
||||
|
marketDataItems.find((marketDataItem) => { |
||||
|
return ( |
||||
|
marketDataItem.dataSource === dataSource && |
||||
|
marketDataItem.symbol === symbol |
||||
|
); |
||||
|
})?._count ?? 0; |
||||
|
|
||||
|
return { |
||||
|
activitiesCount, |
||||
|
currency, |
||||
|
dataSource, |
||||
|
lastMarketPrice, |
||||
|
marketDataItemCount, |
||||
|
symbol, |
||||
|
assetClass: AssetClass.LIQUIDITY, |
||||
|
assetSubClass: AssetSubClass.CASH, |
||||
|
countriesCount: 0, |
||||
|
date: dateOfFirstActivity, |
||||
|
id: undefined, |
||||
|
isActive: true, |
||||
|
name: symbol, |
||||
|
sectorsCount: 0, |
||||
|
watchedByCount: 0 |
||||
|
}; |
||||
|
} |
||||
|
); |
||||
|
|
||||
|
const assetProfiles = await Promise.all(assetProfilePromises); |
||||
|
return { assetProfiles, count: assetProfiles.length }; |
||||
|
} |
||||
|
|
||||
|
private getExtendedPrismaClient() { |
||||
|
const symbolProfileExtension = Prisma.defineExtension((client) => { |
||||
|
return client.$extends({ |
||||
|
result: { |
||||
|
symbolProfile: { |
||||
|
isUsedByUsersWithSubscription: { |
||||
|
compute: async ({ id }) => { |
||||
|
const { _count } = |
||||
|
await this.prismaService.symbolProfile.findUnique({ |
||||
|
select: { |
||||
|
_count: { |
||||
|
select: { |
||||
|
activities: { |
||||
|
where: { |
||||
|
user: { |
||||
|
subscriptions: { |
||||
|
some: { |
||||
|
expiresAt: { |
||||
|
gt: new Date() |
||||
|
} |
||||
|
} |
||||
|
} |
||||
|
} |
||||
|
} |
||||
|
} |
||||
|
} |
||||
|
} |
||||
|
}, |
||||
|
where: { |
||||
|
id |
||||
|
} |
||||
|
}); |
||||
|
|
||||
|
return _count.activities > 0; |
||||
|
} |
||||
|
} |
||||
|
} |
||||
|
} |
||||
|
}); |
||||
|
}); |
||||
|
|
||||
|
return this.prismaService.$extends(symbolProfileExtension); |
||||
|
} |
||||
|
} |
||||
@ -0,0 +1,17 @@ |
|||||
|
import { countries } from 'countries-list'; |
||||
|
|
||||
|
export function getCountryCodeByName({ |
||||
|
aliases = {}, |
||||
|
name |
||||
|
}: { |
||||
|
aliases?: Record<string, string>; |
||||
|
name: string; |
||||
|
}): string { |
||||
|
for (const [code, country] of Object.entries(countries)) { |
||||
|
if (country.name === name || country.name === aliases[name]) { |
||||
|
return code; |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
return undefined; |
||||
|
} |
||||
@ -0,0 +1,28 @@ |
|||||
|
import { SECTORS } from '@ghostfolio/common/config'; |
||||
|
import { SectorName } from '@ghostfolio/common/types'; |
||||
|
|
||||
|
import { Logger } from '@nestjs/common'; |
||||
|
|
||||
|
export function getSectorName({ |
||||
|
aliases = {}, |
||||
|
name |
||||
|
}: { |
||||
|
aliases?: Record<string, SectorName>; |
||||
|
name: string; |
||||
|
}): SectorName { |
||||
|
if (aliases[name]) { |
||||
|
return aliases[name]; |
||||
|
} |
||||
|
|
||||
|
if ((SECTORS as readonly string[]).includes(name)) { |
||||
|
return name as SectorName; |
||||
|
} |
||||
|
|
||||
|
if (name) { |
||||
|
const logger = new Logger('getSectorName'); |
||||
|
|
||||
|
logger.warn(`Could not map the sector "${name}" to the ontology`); |
||||
|
} |
||||
|
|
||||
|
return 'Other'; |
||||
|
} |
||||
@ -1,132 +1,142 @@ |
|||||
<div mat-dialog-title>{{ data.categoryName }} › {{ data.rule.name }}</div> |
<div mat-dialog-title>{{ data.categoryName }} › {{ data.rule.name }}</div> |
||||
|
|
||||
<div class="py-3" mat-dialog-content> |
<form |
||||
@if ( |
class="d-flex flex-column h-100" |
||||
data.rule.configuration.thresholdMin && data.rule.configuration.thresholdMax |
[formGroup]="settingsForm" |
||||
) { |
(ngSubmit)="onSubmit()" |
||||
<div class="w-100"> |
> |
||||
<h6 class="d-flex mb-0"> |
<div class="py-3" mat-dialog-content> |
||||
<ng-container i18n>Threshold range</ng-container>: |
@if ( |
||||
<gf-value |
data.rule.configuration.thresholdMin && |
||||
class="ml-1" |
data.rule.configuration.thresholdMax |
||||
[isPercent]="data.rule.configuration.threshold.unit === '%'" |
) { |
||||
[locale]="data.locale" |
<div class="w-100"> |
||||
[precision]="2" |
<h6 class="d-flex mb-0"> |
||||
[value]="data.settings.thresholdMin" |
<ng-container i18n>Threshold range</ng-container>: |
||||
/> |
<gf-value |
||||
<span class="mx-1">-</span> |
class="ml-1" |
||||
<gf-value |
[isPercent]="data.rule.configuration.threshold.unit === '%'" |
||||
[isPercent]="data.rule.configuration.threshold.unit === '%'" |
[locale]="data.locale" |
||||
[locale]="data.locale" |
[precision]="2" |
||||
[precision]="2" |
[value]="settingsForm.get('thresholdMin').value" |
||||
[value]="data.settings.thresholdMax" |
/> |
||||
/> |
<span class="mx-1">-</span> |
||||
</h6> |
<gf-value |
||||
<div class="align-items-center d-flex w-100"> |
[isPercent]="data.rule.configuration.threshold.unit === '%'" |
||||
<gf-value |
[locale]="data.locale" |
||||
[isPercent]="data.rule.configuration.threshold.unit === '%'" |
[precision]="2" |
||||
[locale]="data.locale" |
[value]="settingsForm.get('thresholdMax').value" |
||||
[precision]="2" |
/> |
||||
[value]="data.rule.configuration.threshold.min" |
</h6> |
||||
/> |
<div class="align-items-center d-flex w-100"> |
||||
<mat-slider |
<gf-value |
||||
class="flex-grow-1" |
[isPercent]="data.rule.configuration.threshold.unit === '%'" |
||||
[max]="data.rule.configuration.threshold.max" |
[locale]="data.locale" |
||||
[min]="data.rule.configuration.threshold.min" |
[precision]="2" |
||||
[step]="data.rule.configuration.threshold.step" |
[value]="data.rule.configuration.threshold.min" |
||||
> |
/> |
||||
<input matSliderStartThumb [(ngModel)]="data.settings.thresholdMin" /> |
<mat-slider |
||||
<input matSliderEndThumb [(ngModel)]="data.settings.thresholdMax" /> |
class="flex-grow-1" |
||||
</mat-slider> |
[max]="data.rule.configuration.threshold.max" |
||||
<gf-value |
[min]="data.rule.configuration.threshold.min" |
||||
[isPercent]="data.rule.configuration.threshold.unit === '%'" |
[step]="data.rule.configuration.threshold.step" |
||||
[locale]="data.locale" |
> |
||||
[precision]="2" |
<input formControlName="thresholdMin" matSliderStartThumb /> |
||||
[value]="data.rule.configuration.threshold.max" |
<input formControlName="thresholdMax" matSliderEndThumb /> |
||||
/> |
</mat-slider> |
||||
|
<gf-value |
||||
|
[isPercent]="data.rule.configuration.threshold.unit === '%'" |
||||
|
[locale]="data.locale" |
||||
|
[precision]="2" |
||||
|
[value]="data.rule.configuration.threshold.max" |
||||
|
/> |
||||
|
</div> |
||||
</div> |
</div> |
||||
</div> |
} @else { |
||||
} @else { |
<div class="w-100" [class.d-none]="!data.rule.configuration.thresholdMin"> |
||||
<div class="w-100" [class.d-none]="!data.rule.configuration.thresholdMin"> |
<h6 class="d-flex mb-0"> |
||||
<h6 class="d-flex mb-0"> |
<ng-container i18n>Threshold Min</ng-container>: |
||||
<ng-container i18n>Threshold Min</ng-container>: |
<gf-value |
||||
<gf-value |
class="ml-1" |
||||
class="ml-1" |
[isPercent]="data.rule.configuration.threshold.unit === '%'" |
||||
[isPercent]="data.rule.configuration.threshold.unit === '%'" |
[locale]="data.locale" |
||||
[locale]="data.locale" |
[precision]="2" |
||||
[precision]="2" |
[value]="settingsForm.get('thresholdMin').value" |
||||
[value]="data.settings.thresholdMin" |
/> |
||||
/> |
</h6> |
||||
</h6> |
<div class="align-items-center d-flex w-100"> |
||||
<div class="align-items-center d-flex w-100"> |
<gf-value |
||||
<gf-value |
[isPercent]="data.rule.configuration.threshold.unit === '%'" |
||||
[isPercent]="data.rule.configuration.threshold.unit === '%'" |
[locale]="data.locale" |
||||
[locale]="data.locale" |
[precision]="2" |
||||
[precision]="2" |
[value]="data.rule.configuration.threshold.min" |
||||
[value]="data.rule.configuration.threshold.min" |
/> |
||||
/> |
<mat-slider |
||||
<mat-slider |
class="flex-grow-1" |
||||
class="flex-grow-1" |
name="thresholdMin" |
||||
name="thresholdMin" |
[max]="data.rule.configuration.threshold.max" |
||||
[max]="data.rule.configuration.threshold.max" |
[min]="data.rule.configuration.threshold.min" |
||||
[min]="data.rule.configuration.threshold.min" |
[step]="data.rule.configuration.threshold.step" |
||||
[step]="data.rule.configuration.threshold.step" |
> |
||||
> |
<input formControlName="thresholdMin" matSliderThumb /> |
||||
<input matSliderThumb [(ngModel)]="data.settings.thresholdMin" /> |
</mat-slider> |
||||
</mat-slider> |
<gf-value |
||||
<gf-value |
[isPercent]="data.rule.configuration.threshold.unit === '%'" |
||||
[isPercent]="data.rule.configuration.threshold.unit === '%'" |
[locale]="data.locale" |
||||
[locale]="data.locale" |
[precision]="2" |
||||
[precision]="2" |
[value]="data.rule.configuration.threshold.max" |
||||
[value]="data.rule.configuration.threshold.max" |
/> |
||||
/> |
</div> |
||||
</div> |
</div> |
||||
</div> |
<div class="w-100" [class.d-none]="!data.rule.configuration.thresholdMax"> |
||||
<div class="w-100" [class.d-none]="!data.rule.configuration.thresholdMax"> |
<h6 class="d-flex mb-0"> |
||||
<h6 class="d-flex mb-0"> |
<ng-container i18n>Threshold Max</ng-container>: |
||||
<ng-container i18n>Threshold Max</ng-container>: |
<gf-value |
||||
<gf-value |
class="ml-1" |
||||
class="ml-1" |
[isPercent]="data.rule.configuration.threshold.unit === '%'" |
||||
[isPercent]="data.rule.configuration.threshold.unit === '%'" |
[locale]="data.locale" |
||||
[locale]="data.locale" |
[precision]="2" |
||||
[precision]="2" |
[value]="settingsForm.get('thresholdMax').value" |
||||
[value]="data.settings.thresholdMax" |
/> |
||||
/> |
</h6> |
||||
</h6> |
<div class="align-items-center d-flex w-100"> |
||||
<div class="align-items-center d-flex w-100"> |
<gf-value |
||||
<gf-value |
[isPercent]="data.rule.configuration.threshold.unit === '%'" |
||||
[isPercent]="data.rule.configuration.threshold.unit === '%'" |
[locale]="data.locale" |
||||
[locale]="data.locale" |
[precision]="2" |
||||
[precision]="2" |
[value]="data.rule.configuration.threshold.min" |
||||
[value]="data.rule.configuration.threshold.min" |
/> |
||||
/> |
<mat-slider |
||||
<mat-slider |
class="flex-grow-1" |
||||
class="flex-grow-1" |
name="thresholdMax" |
||||
name="thresholdMax" |
[max]="data.rule.configuration.threshold.max" |
||||
[max]="data.rule.configuration.threshold.max" |
[min]="data.rule.configuration.threshold.min" |
||||
[min]="data.rule.configuration.threshold.min" |
[step]="data.rule.configuration.threshold.step" |
||||
[step]="data.rule.configuration.threshold.step" |
> |
||||
> |
<input formControlName="thresholdMax" matSliderThumb /> |
||||
<input matSliderThumb [(ngModel)]="data.settings.thresholdMax" /> |
</mat-slider> |
||||
</mat-slider> |
<gf-value |
||||
<gf-value |
[isPercent]="data.rule.configuration.threshold.unit === '%'" |
||||
[isPercent]="data.rule.configuration.threshold.unit === '%'" |
[locale]="data.locale" |
||||
[locale]="data.locale" |
[precision]="2" |
||||
[precision]="2" |
[value]="data.rule.configuration.threshold.max" |
||||
[value]="data.rule.configuration.threshold.max" |
/> |
||||
/> |
</div> |
||||
</div> |
</div> |
||||
</div> |
} |
||||
} |
</div> |
||||
</div> |
|
||||
|
|
||||
<div align="end" mat-dialog-actions> |
<div align="end" mat-dialog-actions> |
||||
<button i18n mat-button (click)="dialogRef.close()">Close</button> |
<button mat-button type="button" (click)="dialogRef.close()"> |
||||
<button |
<ng-container i18n>Close</ng-container> |
||||
color="primary" |
</button> |
||||
mat-flat-button |
<button |
||||
(click)="dialogRef.close(data.settings)" |
color="primary" |
||||
> |
mat-flat-button |
||||
<ng-container i18n>Save</ng-container> |
type="submit" |
||||
</button> |
[disabled]="!settingsForm.dirty" |
||||
</div> |
> |
||||
|
<ng-container i18n>Save</ng-container> |
||||
|
</button> |
||||
|
</div> |
||||
|
</form> |
||||
|
|||||
Some files were not shown because too many files changed in this diff
Loading…
Reference in new issue