* Set prefer-const to error in eslint configuration * Update changelog --------- Signed-off-by: Dominik Willner <th33xitus@gmail.com>
468 lines
12 KiB
TypeScript
468 lines
12 KiB
TypeScript
import { RedisCacheService } from '@ghostfolio/api/app/redis-cache/redis-cache.service';
|
|
import { SymbolService } from '@ghostfolio/api/app/symbol/symbol.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';
|
|
import { MarketDataService } from '@ghostfolio/api/services/market-data/market-data.service';
|
|
import { PrismaService } from '@ghostfolio/api/services/prisma/prisma.service';
|
|
import { PropertyService } from '@ghostfolio/api/services/property/property.service';
|
|
import { SymbolProfileService } from '@ghostfolio/api/services/symbol-profile/symbol-profile.service';
|
|
import {
|
|
CACHE_TTL_INFINITE,
|
|
PROPERTY_BENCHMARKS
|
|
} from '@ghostfolio/common/config';
|
|
import {
|
|
DATE_FORMAT,
|
|
calculateBenchmarkTrend,
|
|
parseDate,
|
|
resetHours
|
|
} from '@ghostfolio/common/helper';
|
|
import {
|
|
AssetProfileIdentifier,
|
|
Benchmark,
|
|
BenchmarkMarketDataDetails,
|
|
BenchmarkProperty,
|
|
BenchmarkResponse
|
|
} from '@ghostfolio/common/interfaces';
|
|
import { BenchmarkTrend } from '@ghostfolio/common/types';
|
|
|
|
import { Injectable, Logger } from '@nestjs/common';
|
|
import { SymbolProfile } from '@prisma/client';
|
|
import { Big } from 'big.js';
|
|
import {
|
|
addHours,
|
|
differenceInDays,
|
|
eachDayOfInterval,
|
|
format,
|
|
isAfter,
|
|
isSameDay,
|
|
subDays
|
|
} from 'date-fns';
|
|
import { isNumber, last, uniqBy } from 'lodash';
|
|
import ms from 'ms';
|
|
|
|
import { BenchmarkValue } from './interfaces/benchmark-value.interface';
|
|
|
|
@Injectable()
|
|
export class BenchmarkService {
|
|
private readonly CACHE_KEY_BENCHMARKS = 'BENCHMARKS';
|
|
|
|
public constructor(
|
|
private readonly configurationService: ConfigurationService,
|
|
private readonly dataProviderService: DataProviderService,
|
|
private readonly exchangeRateDataService: ExchangeRateDataService,
|
|
private readonly marketDataService: MarketDataService,
|
|
private readonly prismaService: PrismaService,
|
|
private readonly propertyService: PropertyService,
|
|
private readonly redisCacheService: RedisCacheService,
|
|
private readonly symbolProfileService: SymbolProfileService,
|
|
private readonly symbolService: SymbolService
|
|
) {}
|
|
|
|
public calculateChangeInPercentage(baseValue: number, currentValue: number) {
|
|
if (baseValue && currentValue) {
|
|
return new Big(currentValue).div(baseValue).minus(1).toNumber();
|
|
}
|
|
|
|
return 0;
|
|
}
|
|
|
|
public async getBenchmarkTrends({
|
|
dataSource,
|
|
symbol
|
|
}: AssetProfileIdentifier) {
|
|
const historicalData = await this.marketDataService.marketDataItems({
|
|
orderBy: {
|
|
date: 'desc'
|
|
},
|
|
where: {
|
|
dataSource,
|
|
symbol,
|
|
date: { gte: subDays(new Date(), 400) }
|
|
}
|
|
});
|
|
|
|
const fiftyDayAverage = calculateBenchmarkTrend({
|
|
historicalData,
|
|
days: 50
|
|
});
|
|
const twoHundredDayAverage = calculateBenchmarkTrend({
|
|
historicalData,
|
|
days: 200
|
|
});
|
|
|
|
return { trend50d: fiftyDayAverage, trend200d: twoHundredDayAverage };
|
|
}
|
|
|
|
public async getBenchmarks({
|
|
enableSharing = false,
|
|
useCache = true
|
|
} = {}): Promise<BenchmarkResponse['benchmarks']> {
|
|
if (useCache) {
|
|
try {
|
|
const cachedBenchmarkValue = await this.redisCacheService.get(
|
|
this.CACHE_KEY_BENCHMARKS
|
|
);
|
|
|
|
const { benchmarks, expiration }: BenchmarkValue =
|
|
JSON.parse(cachedBenchmarkValue);
|
|
|
|
Logger.debug('Fetched benchmarks from cache', 'BenchmarkService');
|
|
|
|
if (isAfter(new Date(), new Date(expiration))) {
|
|
this.calculateAndCacheBenchmarks({
|
|
enableSharing
|
|
});
|
|
}
|
|
|
|
return benchmarks;
|
|
} catch {}
|
|
}
|
|
|
|
return this.calculateAndCacheBenchmarks({ enableSharing });
|
|
}
|
|
|
|
public async getBenchmarkAssetProfiles({
|
|
enableSharing = false
|
|
} = {}): Promise<Partial<SymbolProfile>[]> {
|
|
const symbolProfileIds: string[] = (
|
|
((await this.propertyService.getByKey(
|
|
PROPERTY_BENCHMARKS
|
|
)) as BenchmarkProperty[]) ?? []
|
|
)
|
|
.filter((benchmark) => {
|
|
if (enableSharing) {
|
|
return benchmark.enableSharing;
|
|
}
|
|
|
|
return true;
|
|
})
|
|
.map(({ symbolProfileId }) => {
|
|
return symbolProfileId;
|
|
});
|
|
|
|
const assetProfiles =
|
|
await this.symbolProfileService.getSymbolProfilesByIds(symbolProfileIds);
|
|
|
|
return assetProfiles
|
|
.map(({ dataSource, id, name, symbol }) => {
|
|
return {
|
|
dataSource,
|
|
id,
|
|
name,
|
|
symbol
|
|
};
|
|
})
|
|
.sort((a, b) => a.name.localeCompare(b.name));
|
|
}
|
|
|
|
public async getMarketDataForUser({
|
|
dataSource,
|
|
endDate = new Date(),
|
|
startDate,
|
|
symbol,
|
|
userCurrency
|
|
}: {
|
|
endDate?: Date;
|
|
startDate: Date;
|
|
userCurrency: string;
|
|
} & AssetProfileIdentifier): Promise<BenchmarkMarketDataDetails> {
|
|
const marketData: { date: string; value: number }[] = [];
|
|
|
|
const days = differenceInDays(endDate, startDate) + 1;
|
|
const dates = eachDayOfInterval(
|
|
{
|
|
start: startDate,
|
|
end: endDate
|
|
},
|
|
{
|
|
step: Math.round(
|
|
days /
|
|
Math.min(days, this.configurationService.get('MAX_CHART_ITEMS'))
|
|
)
|
|
}
|
|
).map((date) => {
|
|
return resetHours(date);
|
|
});
|
|
|
|
const [currentSymbolItem, marketDataItems] = await Promise.all([
|
|
this.symbolService.get({
|
|
dataGatheringItem: {
|
|
dataSource,
|
|
symbol
|
|
}
|
|
}),
|
|
this.marketDataService.marketDataItems({
|
|
orderBy: {
|
|
date: 'asc'
|
|
},
|
|
where: {
|
|
dataSource,
|
|
symbol,
|
|
date: {
|
|
in: dates
|
|
}
|
|
}
|
|
})
|
|
]);
|
|
|
|
const exchangeRates =
|
|
await this.exchangeRateDataService.getExchangeRatesByCurrency({
|
|
startDate,
|
|
currencies: [currentSymbolItem.currency],
|
|
targetCurrency: userCurrency
|
|
});
|
|
|
|
const exchangeRateAtStartDate =
|
|
exchangeRates[`${currentSymbolItem.currency}${userCurrency}`]?.[
|
|
format(startDate, DATE_FORMAT)
|
|
];
|
|
|
|
const marketPriceAtStartDate = marketDataItems?.find(({ date }) => {
|
|
return isSameDay(date, startDate);
|
|
})?.marketPrice;
|
|
|
|
if (!marketPriceAtStartDate) {
|
|
Logger.error(
|
|
`No historical market data has been found for ${symbol} (${dataSource}) at ${format(
|
|
startDate,
|
|
DATE_FORMAT
|
|
)}`,
|
|
'BenchmarkService'
|
|
);
|
|
|
|
return { marketData };
|
|
}
|
|
|
|
for (const marketDataItem of marketDataItems) {
|
|
const exchangeRate =
|
|
exchangeRates[`${currentSymbolItem.currency}${userCurrency}`]?.[
|
|
format(marketDataItem.date, DATE_FORMAT)
|
|
];
|
|
|
|
const exchangeRateFactor =
|
|
isNumber(exchangeRateAtStartDate) && isNumber(exchangeRate)
|
|
? exchangeRate / exchangeRateAtStartDate
|
|
: 1;
|
|
|
|
marketData.push({
|
|
date: format(marketDataItem.date, DATE_FORMAT),
|
|
value:
|
|
marketPriceAtStartDate === 0
|
|
? 0
|
|
: this.calculateChangeInPercentage(
|
|
marketPriceAtStartDate,
|
|
marketDataItem.marketPrice * exchangeRateFactor
|
|
) * 100
|
|
});
|
|
}
|
|
|
|
const includesEndDate = isSameDay(
|
|
parseDate(last(marketData).date),
|
|
endDate
|
|
);
|
|
|
|
if (currentSymbolItem?.marketPrice && !includesEndDate) {
|
|
const exchangeRate =
|
|
exchangeRates[`${currentSymbolItem.currency}${userCurrency}`]?.[
|
|
format(endDate, DATE_FORMAT)
|
|
];
|
|
|
|
const exchangeRateFactor =
|
|
isNumber(exchangeRateAtStartDate) && isNumber(exchangeRate)
|
|
? exchangeRate / exchangeRateAtStartDate
|
|
: 1;
|
|
|
|
marketData.push({
|
|
date: format(endDate, DATE_FORMAT),
|
|
value:
|
|
this.calculateChangeInPercentage(
|
|
marketPriceAtStartDate,
|
|
currentSymbolItem.marketPrice * exchangeRateFactor
|
|
) * 100
|
|
});
|
|
}
|
|
|
|
return {
|
|
marketData
|
|
};
|
|
}
|
|
|
|
public async addBenchmark({
|
|
dataSource,
|
|
symbol
|
|
}: AssetProfileIdentifier): Promise<Partial<SymbolProfile>> {
|
|
const assetProfile = await this.prismaService.symbolProfile.findFirst({
|
|
where: {
|
|
dataSource,
|
|
symbol
|
|
}
|
|
});
|
|
|
|
if (!assetProfile) {
|
|
return;
|
|
}
|
|
|
|
let benchmarks =
|
|
((await this.propertyService.getByKey(
|
|
PROPERTY_BENCHMARKS
|
|
)) as BenchmarkProperty[]) ?? [];
|
|
|
|
benchmarks.push({ symbolProfileId: assetProfile.id });
|
|
|
|
benchmarks = uniqBy(benchmarks, 'symbolProfileId');
|
|
|
|
await this.propertyService.put({
|
|
key: PROPERTY_BENCHMARKS,
|
|
value: JSON.stringify(benchmarks)
|
|
});
|
|
|
|
return {
|
|
dataSource,
|
|
symbol,
|
|
id: assetProfile.id,
|
|
name: assetProfile.name
|
|
};
|
|
}
|
|
|
|
public async deleteBenchmark({
|
|
dataSource,
|
|
symbol
|
|
}: AssetProfileIdentifier): Promise<Partial<SymbolProfile>> {
|
|
const assetProfile = await this.prismaService.symbolProfile.findFirst({
|
|
where: {
|
|
dataSource,
|
|
symbol
|
|
}
|
|
});
|
|
|
|
if (!assetProfile) {
|
|
return null;
|
|
}
|
|
|
|
let benchmarks =
|
|
((await this.propertyService.getByKey(
|
|
PROPERTY_BENCHMARKS
|
|
)) as BenchmarkProperty[]) ?? [];
|
|
|
|
benchmarks = benchmarks.filter(({ symbolProfileId }) => {
|
|
return symbolProfileId !== assetProfile.id;
|
|
});
|
|
|
|
await this.propertyService.put({
|
|
key: PROPERTY_BENCHMARKS,
|
|
value: JSON.stringify(benchmarks)
|
|
});
|
|
|
|
return {
|
|
dataSource,
|
|
symbol,
|
|
id: assetProfile.id,
|
|
name: assetProfile.name
|
|
};
|
|
}
|
|
|
|
private async calculateAndCacheBenchmarks({
|
|
enableSharing = false
|
|
}): Promise<BenchmarkResponse['benchmarks']> {
|
|
Logger.debug('Calculate benchmarks', 'BenchmarkService');
|
|
|
|
const benchmarkAssetProfiles = await this.getBenchmarkAssetProfiles({
|
|
enableSharing
|
|
});
|
|
|
|
const promisesAllTimeHighs: Promise<{ date: Date; marketPrice: number }>[] =
|
|
[];
|
|
const promisesBenchmarkTrends: Promise<{
|
|
trend50d: BenchmarkTrend;
|
|
trend200d: BenchmarkTrend;
|
|
}>[] = [];
|
|
|
|
const quotes = await this.dataProviderService.getQuotes({
|
|
items: benchmarkAssetProfiles.map(({ dataSource, symbol }) => {
|
|
return { dataSource, symbol };
|
|
}),
|
|
requestTimeout: ms('30 seconds'),
|
|
useCache: false
|
|
});
|
|
|
|
for (const { dataSource, symbol } of benchmarkAssetProfiles) {
|
|
promisesAllTimeHighs.push(
|
|
this.marketDataService.getMax({ dataSource, symbol })
|
|
);
|
|
promisesBenchmarkTrends.push(
|
|
this.getBenchmarkTrends({ dataSource, symbol })
|
|
);
|
|
}
|
|
|
|
const [allTimeHighs, benchmarkTrends] = await Promise.all([
|
|
Promise.all(promisesAllTimeHighs),
|
|
Promise.all(promisesBenchmarkTrends)
|
|
]);
|
|
let storeInCache = true;
|
|
|
|
const benchmarks = allTimeHighs.map((allTimeHigh, index) => {
|
|
const { marketPrice } =
|
|
quotes[benchmarkAssetProfiles[index].symbol] ?? {};
|
|
|
|
let performancePercentFromAllTimeHigh = 0;
|
|
|
|
if (allTimeHigh?.marketPrice && marketPrice) {
|
|
performancePercentFromAllTimeHigh = this.calculateChangeInPercentage(
|
|
allTimeHigh.marketPrice,
|
|
marketPrice
|
|
);
|
|
} else {
|
|
storeInCache = false;
|
|
}
|
|
|
|
return {
|
|
dataSource: benchmarkAssetProfiles[index].dataSource,
|
|
marketCondition: this.getMarketCondition(
|
|
performancePercentFromAllTimeHigh
|
|
),
|
|
name: benchmarkAssetProfiles[index].name,
|
|
performances: {
|
|
allTimeHigh: {
|
|
date: allTimeHigh?.date,
|
|
performancePercent:
|
|
performancePercentFromAllTimeHigh >= 0
|
|
? 0
|
|
: performancePercentFromAllTimeHigh
|
|
}
|
|
},
|
|
symbol: benchmarkAssetProfiles[index].symbol,
|
|
trend50d: benchmarkTrends[index].trend50d,
|
|
trend200d: benchmarkTrends[index].trend200d
|
|
};
|
|
});
|
|
|
|
if (storeInCache) {
|
|
const expiration = addHours(new Date(), 2);
|
|
|
|
await this.redisCacheService.set(
|
|
this.CACHE_KEY_BENCHMARKS,
|
|
JSON.stringify(<BenchmarkValue>{
|
|
benchmarks,
|
|
expiration: expiration.getTime()
|
|
}),
|
|
CACHE_TTL_INFINITE
|
|
);
|
|
}
|
|
|
|
return benchmarks;
|
|
}
|
|
|
|
private getMarketCondition(
|
|
aPerformanceInPercent: number
|
|
): Benchmark['marketCondition'] {
|
|
if (aPerformanceInPercent >= 0) {
|
|
return 'ALL_TIME_HIGH';
|
|
} else if (aPerformanceInPercent <= -0.2) {
|
|
return 'BEAR_MARKET';
|
|
} else {
|
|
return 'NEUTRAL_MARKET';
|
|
}
|
|
}
|
|
}
|