mirror of https://github.com/ghostfolio/ghostfolio
You can not select more than 25 topics
Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
683 lines
19 KiB
683 lines
19 KiB
import { ActivitiesService } from '@ghostfolio/api/app/activities/activities.service';
|
|
import { PortfolioChangedEvent } from '@ghostfolio/api/events/portfolio-changed.event';
|
|
import { AssetProfileSplitService } from '@ghostfolio/api/services/asset-profile-split/asset-profile-split.service';
|
|
import { BenchmarkService } from '@ghostfolio/api/services/benchmark/benchmark.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';
|
|
import { MarketDataService } from '@ghostfolio/api/services/market-data/market-data.service';
|
|
import { PrismaService } from '@ghostfolio/api/services/prisma/prisma.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 { UpdateAssetProfileDataDto } from '@ghostfolio/common/dtos';
|
|
import {
|
|
applyAssetProfileOverrides,
|
|
getAssetProfileIdentifier,
|
|
getCurrencyFromSymbol,
|
|
isCurrency
|
|
} from '@ghostfolio/common/helper';
|
|
import {
|
|
AdminMarketDataDetails,
|
|
AssetProfileIdentifier,
|
|
AssetProfileItem,
|
|
AssetProfilesResponse,
|
|
DataProviderInfo,
|
|
EnhancedAssetProfile,
|
|
Filter
|
|
} from '@ghostfolio/common/interfaces';
|
|
import { MarketDataPreset } from '@ghostfolio/common/types';
|
|
|
|
import { Injectable, Logger, NotFoundException } from '@nestjs/common';
|
|
import { EventEmitter2 } from '@nestjs/event-emitter';
|
|
import { AssetClass, AssetSubClass, DataSource, Prisma } from '@prisma/client';
|
|
import { groupBy } from 'lodash-es';
|
|
|
|
@Injectable()
|
|
export class AssetProfilesService {
|
|
private readonly logger = new Logger(AssetProfilesService.name);
|
|
|
|
public constructor(
|
|
private readonly activitiesService: ActivitiesService,
|
|
private readonly assetProfileSplitService: AssetProfileSplitService,
|
|
private readonly benchmarkService: BenchmarkService,
|
|
private readonly dataGatheringService: DataGatheringService,
|
|
private readonly dataProviderService: DataProviderService,
|
|
private readonly eventEmitter: EventEmitter2,
|
|
private readonly exchangeRateDataService: ExchangeRateDataService,
|
|
private readonly marketDataService: MarketDataService,
|
|
private readonly prismaService: PrismaService,
|
|
private readonly symbolProfileService: SymbolProfileService
|
|
) {}
|
|
|
|
public async createSplit({
|
|
dataSource,
|
|
date,
|
|
denominator,
|
|
numerator,
|
|
symbol,
|
|
symbolProfileId
|
|
}: {
|
|
date: Date;
|
|
denominator: number;
|
|
numerator: number;
|
|
symbolProfileId: string;
|
|
} & AssetProfileIdentifier) {
|
|
const assetProfileSplit = await this.assetProfileSplitService.upsert({
|
|
date,
|
|
denominator,
|
|
numerator,
|
|
symbolProfileId
|
|
});
|
|
|
|
await this.gatherSymbolAndEmitPortfolioChangedEvents({
|
|
dataSource,
|
|
symbol,
|
|
symbolProfileId
|
|
});
|
|
|
|
return assetProfileSplit;
|
|
}
|
|
|
|
public async deleteSplit({
|
|
dataSource,
|
|
id,
|
|
symbol,
|
|
symbolProfileId
|
|
}: {
|
|
id: string;
|
|
symbolProfileId: string;
|
|
} & AssetProfileIdentifier) {
|
|
const isDeleted = await this.assetProfileSplitService.deleteById({
|
|
id,
|
|
symbolProfileId
|
|
});
|
|
|
|
if (!isDeleted) {
|
|
throw new NotFoundException();
|
|
}
|
|
|
|
await this.gatherSymbolAndEmitPortfolioChangedEvents({
|
|
dataSource,
|
|
symbol,
|
|
symbolProfileId
|
|
});
|
|
}
|
|
|
|
/**
|
|
* Gathers the market data of the given asset profile and invalidates the
|
|
* portfolio snapshots of the affected users as soon as it is available.
|
|
* Emitting the events earlier would recompute the snapshots from
|
|
* split-adjusted quantities and not yet split-adjusted market prices.
|
|
*
|
|
* With withImmediateInvalidation the snapshots are invalidated a second
|
|
* time, before the gathering. This is necessary if the snapshots are already
|
|
* wrong without new market data, because the invalidation after the
|
|
* gathering is held in memory and is therefore lost if the process restarts.
|
|
*/
|
|
public async gatherSymbolAndEmitPortfolioChangedEvents({
|
|
dataSource,
|
|
force = true,
|
|
symbol,
|
|
symbolProfileId,
|
|
withImmediateInvalidation = false
|
|
}: {
|
|
force?: boolean;
|
|
symbolProfileId: string;
|
|
withImmediateInvalidation?: boolean;
|
|
} & AssetProfileIdentifier) {
|
|
if (withImmediateInvalidation) {
|
|
await this.emitPortfolioChangedEvents(symbolProfileId);
|
|
}
|
|
|
|
const jobs = await this.dataGatheringService.gatherSymbol({
|
|
dataSource,
|
|
force,
|
|
symbol
|
|
});
|
|
|
|
void Promise.allSettled(
|
|
jobs.map((job) => {
|
|
return job.finished();
|
|
})
|
|
)
|
|
.then(() => {
|
|
return this.emitPortfolioChangedEvents(symbolProfileId);
|
|
})
|
|
.catch((error) => {
|
|
this.logger.error(
|
|
`Could not emit the portfolio changed events of the asset profile ${getAssetProfileIdentifier({ dataSource, symbol })}`,
|
|
error.stack
|
|
);
|
|
});
|
|
}
|
|
|
|
public async getAssetProfile({
|
|
dataSource,
|
|
symbol
|
|
}: AssetProfileIdentifier): Promise<AdminMarketDataDetails> {
|
|
let activitiesCount: EnhancedAssetProfile['activitiesCount'] = 0;
|
|
let currency: EnhancedAssetProfile['currency'] = '-';
|
|
let dateOfFirstActivity: EnhancedAssetProfile['dateOfFirstActivity'];
|
|
|
|
const isCurrencyAssetProfile = isCurrency(getCurrencyFromSymbol(symbol));
|
|
|
|
if (isCurrencyAssetProfile) {
|
|
currency = getCurrencyFromSymbol(symbol);
|
|
({ activitiesCount, dateOfFirstActivity } =
|
|
await this.activitiesService.getStatisticsByCurrency(currency));
|
|
}
|
|
|
|
const [[assetProfile], marketData, splits] = await Promise.all([
|
|
this.symbolProfileService.getSymbolProfiles([
|
|
{
|
|
dataSource,
|
|
symbol
|
|
}
|
|
]),
|
|
this.marketDataService.marketDataItems({
|
|
orderBy: {
|
|
date: 'asc'
|
|
},
|
|
where: {
|
|
dataSource,
|
|
symbol
|
|
}
|
|
}),
|
|
this.assetProfileSplitService.getSplits({ dataSource, symbol })
|
|
]);
|
|
|
|
if (assetProfile) {
|
|
assetProfile.dataProviderInfo = this.dataProviderService
|
|
.getDataProvider(assetProfile.dataSource)
|
|
.getDataProviderInfo();
|
|
}
|
|
|
|
return {
|
|
marketData,
|
|
splits,
|
|
assetProfile: assetProfile ?? {
|
|
activitiesCount,
|
|
currency,
|
|
dataSource,
|
|
dateOfFirstActivity,
|
|
symbol,
|
|
assetClass: isCurrencyAssetProfile ? AssetClass.LIQUIDITY : undefined,
|
|
assetSubClass: isCurrencyAssetProfile ? AssetSubClass.CASH : undefined,
|
|
isActive: true
|
|
}
|
|
};
|
|
}
|
|
|
|
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 {
|
|
ASSET_SUB_CLASS: [filterByAssetSubClass] = [],
|
|
DATA_SOURCE: [filterByDataSource] = [],
|
|
SEARCH_QUERY: [filterBySearchQuery] = []
|
|
} = groupBy(filters, ({ type }) => {
|
|
return type;
|
|
});
|
|
|
|
const marketDataItems = await this.prismaService.marketData.groupBy({
|
|
_count: true,
|
|
by: ['dataSource', 'symbol']
|
|
});
|
|
|
|
if (filterByAssetSubClass) {
|
|
where.assetSubClass = AssetSubClass[filterByAssetSubClass.id];
|
|
}
|
|
|
|
if (filterByDataSource) {
|
|
where.dataSource = DataSource[filterByDataSource.id];
|
|
}
|
|
|
|
if (filterBySearchQuery) {
|
|
where.OR = [
|
|
{ id: { mode: 'insensitive', startsWith: filterBySearchQuery.id } },
|
|
{ isin: { mode: 'insensitive', startsWith: filterBySearchQuery.id } },
|
|
{ name: { mode: 'insensitive', startsWith: filterBySearchQuery.id } },
|
|
{ symbol: { mode: 'insensitive', startsWith: filterBySearchQuery.id } }
|
|
];
|
|
}
|
|
|
|
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,
|
|
assetProfileOverrides: 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
|
|
}
|
|
}),
|
|
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
|
|
);
|
|
}
|
|
|
|
const dataProviderInfoMap = new Map<DataSource, DataProviderInfo>(
|
|
[
|
|
...new Set(
|
|
symbolProfiles.map(({ dataSource }) => {
|
|
return dataSource;
|
|
})
|
|
)
|
|
].map((dataSource) => {
|
|
return [
|
|
dataSource,
|
|
this.dataProviderService
|
|
.getDataProvider(dataSource)
|
|
.getDataProviderInfo()
|
|
];
|
|
})
|
|
);
|
|
|
|
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.assetProfileOverrides
|
|
);
|
|
|
|
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,
|
|
dataProviderInfo: dataProviderInfoMap.get(dataSource),
|
|
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<EnhancedAssetProfile> {
|
|
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 async emitPortfolioChangedEvents(symbolProfileId: string) {
|
|
const userIds =
|
|
await this.activitiesService.getUserIdsBySymbolProfileId(symbolProfileId);
|
|
|
|
for (const userId of userIds) {
|
|
this.eventEmitter.emit(
|
|
PortfolioChangedEvent.getName(),
|
|
new PortfolioChangedEvent({ userId })
|
|
);
|
|
}
|
|
}
|
|
|
|
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: EnhancedAssetProfile['activitiesCount'] = 0;
|
|
let currency: EnhancedAssetProfile['currency'] = '-';
|
|
let dateOfFirstActivity: EnhancedAssetProfile['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);
|
|
}
|
|
}
|
|
|