|
|
|
@ -1,6 +1,5 @@ |
|
|
|
import { ActivitiesService } from '@ghostfolio/api/app/activities/activities.service'; |
|
|
|
import { environment } from '@ghostfolio/api/environments/environment'; |
|
|
|
import { BenchmarkService } from '@ghostfolio/api/services/benchmark/benchmark.service'; |
|
|
|
import { ConfigurationService } from '@ghostfolio/api/services/configuration/configuration.service'; |
|
|
|
import { DataProviderService } from '@ghostfolio/api/services/data-provider/data-provider.service'; |
|
|
|
import { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-data/exchange-rate-data.service'; |
|
|
|
@ -13,24 +12,15 @@ import { |
|
|
|
PROPERTY_IS_READ_ONLY_MODE, |
|
|
|
PROPERTY_IS_USER_SIGNUP_ENABLED |
|
|
|
} from '@ghostfolio/common/config'; |
|
|
|
import { |
|
|
|
applyAssetProfileOverrides, |
|
|
|
getAssetProfileIdentifier, |
|
|
|
getCurrencyFromSymbol, |
|
|
|
isCurrency |
|
|
|
} from '@ghostfolio/common/helper'; |
|
|
|
import { getCurrencyFromSymbol, isCurrency } from '@ghostfolio/common/helper'; |
|
|
|
import { |
|
|
|
AdminData, |
|
|
|
AdminMarketData, |
|
|
|
AdminMarketDataDetails, |
|
|
|
AdminMarketDataItem, |
|
|
|
AdminUserResponse, |
|
|
|
AdminUsersResponse, |
|
|
|
AssetProfileIdentifier, |
|
|
|
EnhancedSymbolProfile, |
|
|
|
Filter |
|
|
|
EnhancedSymbolProfile |
|
|
|
} from '@ghostfolio/common/interfaces'; |
|
|
|
import { MarketDataPreset } from '@ghostfolio/common/types'; |
|
|
|
|
|
|
|
import { |
|
|
|
BadRequestException, |
|
|
|
@ -48,13 +38,11 @@ import { |
|
|
|
} from '@prisma/client'; |
|
|
|
import { differenceInDays } from 'date-fns'; |
|
|
|
import { StatusCodes, getReasonPhrase } from 'http-status-codes'; |
|
|
|
import { groupBy } from 'lodash'; |
|
|
|
|
|
|
|
@Injectable() |
|
|
|
export class AdminService { |
|
|
|
public constructor( |
|
|
|
private readonly activitiesService: ActivitiesService, |
|
|
|
private readonly benchmarkService: BenchmarkService, |
|
|
|
private readonly configurationService: ConfigurationService, |
|
|
|
private readonly dataProviderService: DataProviderService, |
|
|
|
private readonly exchangeRateDataService: ExchangeRateDataService, |
|
|
|
@ -188,244 +176,6 @@ export class AdminService { |
|
|
|
}; |
|
|
|
} |
|
|
|
|
|
|
|
public async getMarketData({ |
|
|
|
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<AdminMarketData> { |
|
|
|
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.getMarketDataForCurrencies(); |
|
|
|
} 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, |
|
|
|
isActive: true, |
|
|
|
isUsedByUsersWithSubscription: true, |
|
|
|
name: true, |
|
|
|
scraperConfiguration: true, |
|
|
|
sectors: true, |
|
|
|
symbol: true, |
|
|
|
SymbolProfileOverrides: true |
|
|
|
} |
|
|
|
}), |
|
|
|
this.prismaService.symbolProfile.count({ where }) |
|
|
|
]); |
|
|
|
const assetProfiles = 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: assetProfiles.map(({ dataSource }) => { |
|
|
|
return dataSource; |
|
|
|
}) |
|
|
|
}, |
|
|
|
symbol: { |
|
|
|
in: assetProfiles.map(({ symbol }) => { |
|
|
|
return symbol; |
|
|
|
}) |
|
|
|
} |
|
|
|
} |
|
|
|
}); |
|
|
|
|
|
|
|
const lastMarketPriceMap = new Map<string, number>(); |
|
|
|
|
|
|
|
for (const { dataSource, marketPrice, symbol } of lastMarketPrices) { |
|
|
|
lastMarketPriceMap.set( |
|
|
|
getAssetProfileIdentifier({ dataSource, symbol }), |
|
|
|
marketPrice |
|
|
|
); |
|
|
|
} |
|
|
|
|
|
|
|
let marketData: AdminMarketDataItem[] = await Promise.all( |
|
|
|
assetProfiles.map(async (assetProfile) => { |
|
|
|
const { |
|
|
|
_count, |
|
|
|
activities, |
|
|
|
comment, |
|
|
|
currency, |
|
|
|
dataSource, |
|
|
|
id, |
|
|
|
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, |
|
|
|
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') { |
|
|
|
marketData = marketData.filter(({ countriesCount }) => { |
|
|
|
return countriesCount === 0; |
|
|
|
}); |
|
|
|
} else if (presetId === 'ETF_WITHOUT_SECTORS') { |
|
|
|
marketData = marketData.filter(({ sectorsCount }) => { |
|
|
|
return sectorsCount === 0; |
|
|
|
}); |
|
|
|
} |
|
|
|
|
|
|
|
count = marketData.length; |
|
|
|
} |
|
|
|
|
|
|
|
return { |
|
|
|
count, |
|
|
|
marketData |
|
|
|
}; |
|
|
|
} |
|
|
|
|
|
|
|
public async getMarketDataBySymbol({ |
|
|
|
dataSource, |
|
|
|
symbol |
|
|
|
@ -667,138 +417,6 @@ export class AdminService { |
|
|
|
}); |
|
|
|
} |
|
|
|
|
|
|
|
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); |
|
|
|
} |
|
|
|
|
|
|
|
private async getMarketDataForCurrencies(): Promise<AdminMarketData> { |
|
|
|
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 marketDataPromise: Promise<AdminMarketDataItem>[] = 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 marketData = await Promise.all(marketDataPromise); |
|
|
|
return { marketData, count: marketData.length }; |
|
|
|
} |
|
|
|
|
|
|
|
private async getUsersWithAnalytics({ |
|
|
|
skip, |
|
|
|
take, |
|
|
|
|