diff --git a/src/helpers/helpers.ts b/src/helpers/helpers.ts index 608451bbd..93cd7c604 100644 --- a/src/helpers/helpers.ts +++ b/src/helpers/helpers.ts @@ -1,6 +1,10 @@ import { Address } from '@multiversx/sdk-core'; import { BigNumber } from 'bignumber.js'; import { BinaryUtils } from '@multiversx/sdk-nestjs-common'; +import moment from 'moment'; + +export const SECONDS_TIMESTAMP_LENGTH = 10; +export const MILLISECONDS_TIMESTAMP_LENGTH = 13; export function encodeTransactionData(data: string): string { const delimiter = '@'; @@ -60,3 +64,21 @@ export function awsOneYear(): string { export function delay(ms: number) { return new Promise((resolve) => setTimeout(resolve, ms)); } + +export function isValidUnixTimestamp(value: string): boolean { + if (/^\d+$/.test(value) === false) { + return false; + } + + const timestamp = Number(value); + + if (value.length === SECONDS_TIMESTAMP_LENGTH) { + return moment.unix(timestamp).isValid(); + } + + if (value.length === MILLISECONDS_TIMESTAMP_LENGTH) { + return moment(timestamp).isValid(); + } + + return false; +} diff --git a/src/modules/analytics/analytics.module.ts b/src/modules/analytics/analytics.module.ts index b63015239..eca81650a 100644 --- a/src/modules/analytics/analytics.module.ts +++ b/src/modules/analytics/analytics.module.ts @@ -21,6 +21,7 @@ import { RemoteConfigModule } from '../remote-config/remote-config.module'; import { AnalyticsModule as AnalyticsServicesModule } from 'src/services/analytics/analytics.module'; import { WeeklyRewardsSplittingModule } from 'src/submodules/weekly-rewards-splitting/weekly-rewards-splitting.module'; import { AnalyticsSetterService } from './services/analytics.setter.service'; +import { AnalyticsTokenService } from './services/analytics.token.service'; @Module({ imports: [ @@ -48,12 +49,14 @@ import { AnalyticsSetterService } from './services/analytics.setter.service'; AnalyticsSetterService, AnalyticsPairService, PairDayDataResolver, + AnalyticsTokenService, ], exports: [ AnalyticsAWSGetterService, AnalyticsAWSSetterService, AnalyticsComputeService, AnalyticsSetterService, + AnalyticsTokenService, ], }) export class AnalyticsModule {} diff --git a/src/modules/analytics/analytics.resolver.ts b/src/modules/analytics/analytics.resolver.ts index defc2e5db..a9c66f4d6 100644 --- a/src/modules/analytics/analytics.resolver.ts +++ b/src/modules/analytics/analytics.resolver.ts @@ -4,8 +4,13 @@ import { Args, Resolver } from '@nestjs/graphql'; import { CandleDataModel, HistoricDataModel, + TokenCandlesModel, } from 'src/modules/analytics/models/analytics.model'; -import { AnalyticsQueryArgs, PriceCandlesQueryArgs } from './models/query.args'; +import { + AnalyticsQueryArgs, + PriceCandlesQueryArgs, + TokenPriceCandlesQueryArgs, +} from './models/query.args'; import { AnalyticsAWSGetterService } from './services/analytics.aws.getter.service'; import { AnalyticsComputeService } from './services/analytics.compute.service'; import { PairComputeService } from '../pair/services/pair.compute.service'; @@ -177,7 +182,11 @@ export class AnalyticsResolver { return []; } - @Query(() => [CandleDataModel]) + @Query(() => [CandleDataModel], { + deprecationReason: + 'New optimized query is now available (tokensLast7dPrice).' + + 'It allows fetching price data for multiple tokens in a single request', + }) @UsePipes( new ValidationPipe({ skipNullProperties: true, @@ -197,4 +206,21 @@ export class AnalyticsResolver { args.resolution, ); } + + @Query(() => [TokenCandlesModel]) + @UsePipes( + new ValidationPipe({ + skipNullProperties: true, + skipMissingProperties: true, + skipUndefinedProperties: true, + }), + ) + async tokensLast7dPrice( + @Args({ type: () => TokenPriceCandlesQueryArgs }) + args: TokenPriceCandlesQueryArgs, + ): Promise { + return await this.analyticsAWSGetter.getTokensLast7dPrices( + args.identifiers, + ); + } } diff --git a/src/modules/analytics/models/analytics.model.ts b/src/modules/analytics/models/analytics.model.ts index 37c65a041..1f6d6686f 100644 --- a/src/modules/analytics/models/analytics.model.ts +++ b/src/modules/analytics/models/analytics.model.ts @@ -95,3 +95,16 @@ export class OhlcvDataModel { Object.assign(this, init); } } + +@ObjectType() +export class TokenCandlesModel { + @Field() + identifier: string; + + @Field(() => [OhlcvDataModel]) + candles: OhlcvDataModel[]; + + constructor(init?: Partial) { + Object.assign(this, init); + } +} diff --git a/src/modules/analytics/models/query.args.ts b/src/modules/analytics/models/query.args.ts index 396e446c2..d6dd4fbab 100644 --- a/src/modules/analytics/models/query.args.ts +++ b/src/modules/analytics/models/query.args.ts @@ -1,5 +1,12 @@ import { ArgsType, Field, registerEnumType } from '@nestjs/graphql'; -import { IsNotEmpty, Matches } from 'class-validator'; +import { + ArrayMaxSize, + ArrayMinSize, + IsArray, + IsNotEmpty, + Matches, + Min, +} from 'class-validator'; import { IsValidMetric } from 'src/helpers/validators/metric.validator'; import { IsValidSeries } from 'src/helpers/validators/series.validator'; import { IsValidUnixTime } from 'src/helpers/validators/unix.time.validator'; @@ -53,3 +60,12 @@ export class PriceCandlesQueryArgs { @Field(() => PriceCandlesResolutions) resolution: PriceCandlesResolutions; } + +@ArgsType() +export class TokenPriceCandlesQueryArgs { + @Field(() => [String]) + @IsArray() + @ArrayMinSize(1, { message: 'At least 1 token ID is required' }) + @ArrayMaxSize(10, { message: 'At most 10 token IDs can be provided' }) + identifiers: string[]; +} diff --git a/src/modules/analytics/services/analytics.aws.getter.service.ts b/src/modules/analytics/services/analytics.aws.getter.service.ts index 6145ba51e..6b6e83601 100644 --- a/src/modules/analytics/services/analytics.aws.getter.service.ts +++ b/src/modules/analytics/services/analytics.aws.getter.service.ts @@ -1,9 +1,14 @@ import { Injectable } from '@nestjs/common'; import { generateCacheKeyFromParams } from '../../../utils/generate-cache-key'; import { CacheService } from '@multiversx/sdk-nestjs-cache'; -import { HistoricDataModel } from '../models/analytics.model'; +import { + HistoricDataModel, + OhlcvDataModel, + TokenCandlesModel, +} from '../models/analytics.model'; import moment from 'moment'; import { ErrorLoggerAsync } from '@multiversx/sdk-nestjs-common'; +import { getMany } from 'src/utils/get.many.utils'; @Injectable() export class AnalyticsAWSGetterService { @@ -108,6 +113,28 @@ export class AnalyticsAWSGetterService { return data !== undefined ? data.slice(1) : []; } + @ErrorLoggerAsync() + async getTokensLast7dPrices( + identifiers: string[], + ): Promise { + const cacheKeys = identifiers.map((tokenID) => + this.getAnalyticsCacheKey('tokenLast7dPrices', tokenID), + ); + + const candles = await getMany( + this.cachingService, + cacheKeys, + ); + + return candles.map( + (tokenCandles, index) => + new TokenCandlesModel({ + identifier: identifiers[index], + candles: tokenCandles ?? [], + }), + ); + } + private getAnalyticsCacheKey(...args: any) { return generateCacheKeyFromParams('analytics', ...args); } diff --git a/src/modules/analytics/services/analytics.setter.service.ts b/src/modules/analytics/services/analytics.setter.service.ts index 477e11a62..c1ed369a6 100644 --- a/src/modules/analytics/services/analytics.setter.service.ts +++ b/src/modules/analytics/services/analytics.setter.service.ts @@ -4,6 +4,7 @@ import { Constants } from '@multiversx/sdk-nestjs-common'; import { CacheService } from '@multiversx/sdk-nestjs-cache'; import { GenericSetterService } from 'src/services/generics/generic.setter.service'; import { Logger } from 'winston'; +import { OhlcvDataModel } from '../models/analytics.model'; export class AnalyticsSetterService extends GenericSetterService { constructor( @@ -84,4 +85,16 @@ export class AnalyticsSetterService extends GenericSetterService { Constants.oneMinute() * 5, ); } + + async setTokenLast7dPrices( + tokenID: string, + values: OhlcvDataModel[], + ): Promise { + return await this.setData( + this.getCacheKey('tokenLast7dPrices', tokenID), + values, + Constants.oneMinute() * 10, + Constants.oneMinute() * 6, + ); + } } diff --git a/src/modules/analytics/services/analytics.token.service.ts b/src/modules/analytics/services/analytics.token.service.ts new file mode 100644 index 000000000..052cee0ef --- /dev/null +++ b/src/modules/analytics/services/analytics.token.service.ts @@ -0,0 +1,242 @@ +import { ErrorLoggerAsync } from '@multiversx/sdk-nestjs-common'; +import { Injectable } from '@nestjs/common'; +import moment from 'moment'; +import { AnalyticsQueryService } from 'src/services/analytics/services/analytics.query.service'; +import { OhlcvDataModel, TokenCandlesModel } from '../models/analytics.model'; +import { isValidUnixTimestamp } from 'src/helpers/helpers'; + +@Injectable() +export class AnalyticsTokenService { + constructor(private readonly analyticsQuery: AnalyticsQueryService) {} + + @ErrorLoggerAsync() + async computeTokensLast7dPrice( + identifiers: string[], + hoursResolution = 4, + ): Promise { + const endDate = moment().unix(); + const startDate = moment().subtract(7, 'days').startOf('hour').unix(); + + const tokenCandles = await this.analyticsQuery.getCandlesForTokens({ + identifiers, + start: startDate, + end: endDate, + resolution: `${hoursResolution} hours`, + }); + + const tokensNeedingGapfilling = this.identifyTokensNeedingGapfilling( + identifiers, + tokenCandles, + ); + + if (tokensNeedingGapfilling.length === 0) { + return tokenCandles.map((tokenData) => this.formatData(tokenData)); + } + + return this.handleGapFilling( + tokenCandles, + tokensNeedingGapfilling, + startDate, + endDate, + hoursResolution, + ); + } + + private identifyTokensNeedingGapfilling( + identifiers: string[], + tokenCandles: TokenCandlesModel[], + ): string[] { + return identifiers.filter((tokenID) => { + const tokenData = tokenCandles.find( + (elem) => elem.identifier === tokenID, + ); + return ( + !tokenData || + tokenData.candles.some((candle) => candle.ohlcv.includes(-1)) + ); + }); + } + + private async handleGapFilling( + tokenCandles: TokenCandlesModel[], + tokensNeedingGapfilling: string[], + startDate: number, + endDate: number, + hoursResolution: number, + ): Promise { + const earliestStartDate = + await this.analyticsQuery.getEarliestStartDate( + tokensNeedingGapfilling, + ); + + if (!earliestStartDate) { + return this.gapfillTokensWithEmptyData( + tokenCandles, + tokensNeedingGapfilling, + startDate, + endDate, + hoursResolution, + ); + } + + const lastCandles = await this.analyticsQuery.getLastCandleForTokens({ + identifiers: tokensNeedingGapfilling, + start: moment(earliestStartDate).utc().unix(), + end: startDate, + }); + + return this.gapfillTokens( + tokenCandles, + tokensNeedingGapfilling, + lastCandles, + startDate, + endDate, + hoursResolution, + ); + } + + private gapfillTokensWithEmptyData( + tokenCandles: TokenCandlesModel[], + tokensNeedingGapfilling: string[], + startTimestamp: number, + endTimestamp: number, + hoursResolution: number, + ): TokenCandlesModel[] { + tokensNeedingGapfilling.forEach((tokenID) => { + const emptyTokenData = this.gapfillTokenCandles( + new TokenCandlesModel({ identifier: tokenID, candles: [] }), + startTimestamp, + endTimestamp, + hoursResolution, + [0, 0, 0, 0, 0], + ); + tokenCandles.push(emptyTokenData); + }); + return tokenCandles.map((tokenData) => this.formatData(tokenData)); + } + + private gapfillTokens( + tokenCandles: TokenCandlesModel[], + tokensNeedingGapfilling: string[], + lastCandles: TokenCandlesModel[], + startTimestamp: number, + endTimestamp: number, + hoursResolution: number, + ): TokenCandlesModel[] { + const result = tokenCandles.filter( + (tokenData) => + !tokensNeedingGapfilling.includes(tokenData.identifier), + ); + + const gapfilledTokens = tokensNeedingGapfilling.map((tokenID) => { + let tokenData = tokenCandles.find( + (elem) => elem.identifier === tokenID, + ); + const lastCandle = lastCandles.find( + (elem) => elem.identifier === tokenID, + ); + + let gapfillOhlc = [0, 0, 0, 0, 0]; + if (lastCandle) { + // remove volume (last value in array) - not suitable for gapfilling + const adjustedCandle = lastCandle.candles[0].ohlcv; + adjustedCandle.pop(); + adjustedCandle.push(0); + + gapfillOhlc = adjustedCandle; + } + + if (!tokenData) { + tokenData = new TokenCandlesModel({ + identifier: tokenID, + candles: [], + }); + } + + return this.gapfillTokenCandles( + tokenData, + startTimestamp, + endTimestamp, + hoursResolution, + gapfillOhlc, + ); + }); + + return [...gapfilledTokens, ...result].map((tokenData) => + this.formatData(tokenData), + ); + } + + private gapfillTokenCandles( + tokenData: TokenCandlesModel, + startTimestamp: number, + endTimestamp: number, + hoursResolution: number, + gapfillOhlc: number[], + ): TokenCandlesModel { + if (tokenData.candles.length === 0) { + const timestamps = this.generateTimestampsForHoursInterval( + startTimestamp, + endTimestamp, + hoursResolution, + ); + + timestamps.forEach((timestamp) => { + tokenData.candles.push( + new OhlcvDataModel({ + time: timestamp, + ohlcv: [...gapfillOhlc], + }), + ); + }); + + return tokenData; + } + + tokenData.candles.forEach((candle) => { + if (candle.ohlcv.includes(-1)) { + candle.ohlcv = [...gapfillOhlc]; + } + }); + + return tokenData; + } + + private generateTimestampsForHoursInterval( + startTimestamp: number, + endTimestamp: number, + intervalHours: number, + ): string[] { + const timestamps: string[] = []; + + let start = moment.unix(startTimestamp); + const end = moment.unix(endTimestamp); + + // Align the start time with the next 4-hour boundary + const remainder = start.hour() % intervalHours; + if (remainder !== 0) { + start = start.add(intervalHours - remainder, 'hours'); + } + + start = start.startOf('hour'); + + // Generate timestamps at the specified interval until we reach the end time + while (start.isSameOrBefore(end)) { + timestamps.push(start.unix().toString()); + start = start.add(intervalHours, 'hours'); + } + + return timestamps; + } + + private formatData(tokenData: TokenCandlesModel): TokenCandlesModel { + tokenData.candles.forEach((candle) => { + const candleTime = isValidUnixTimestamp(candle.time) + ? candle.time + : moment(candle.time).unix().toString(); + + candle.time = candleTime; + }); + return tokenData; + } +} diff --git a/src/modules/farm/specs/farm.compute.service.spec.ts b/src/modules/farm/specs/farm.compute.service.spec.ts index 7db6dccf4..2f4cd3203 100644 --- a/src/modules/farm/specs/farm.compute.service.spec.ts +++ b/src/modules/farm/specs/farm.compute.service.spec.ts @@ -67,7 +67,7 @@ describe('FarmService', () => { const service = module.get( FarmComputeServiceV1_2, ); - const farmedTokenPriceUSD = await service.computeFarmedTokenPriceUSD( + const farmedTokenPriceUSD = await service.farmedTokenPriceUSD( Address.fromHex( '0000000000000000000000000000000000000000000000000000000000000021', ).bech32(), diff --git a/src/services/analytics/interfaces/analytics.query.interface.ts b/src/services/analytics/interfaces/analytics.query.interface.ts index c08ff9f84..bb750ebfd 100644 --- a/src/services/analytics/interfaces/analytics.query.interface.ts +++ b/src/services/analytics/interfaces/analytics.query.interface.ts @@ -2,6 +2,7 @@ import { CandleDataModel, HistoricDataModel, OhlcvDataModel, + TokenCandlesModel, } from 'src/modules/analytics/models/analytics.model'; import { AnalyticsQueryArgs } from '../entities/analytics.query.args'; @@ -46,5 +47,20 @@ export interface AnalyticsQueryInterface { end, }): Promise; + getCandlesForTokens({ + identifiers, + resolution, + start, + end, + }): Promise; + + getLastCandleForTokens({ + identifiers, + start, + end, + }): Promise; + getStartDate(series: string): Promise; + + getEarliestStartDate(series: string[]): Promise; } diff --git a/src/services/analytics/mocks/analytics.query.service.mock.ts b/src/services/analytics/mocks/analytics.query.service.mock.ts index 545cbe7ea..9a8b660de 100644 --- a/src/services/analytics/mocks/analytics.query.service.mock.ts +++ b/src/services/analytics/mocks/analytics.query.service.mock.ts @@ -2,6 +2,7 @@ import { CandleDataModel, HistoricDataModel, OhlcvDataModel, + TokenCandlesModel, } from 'src/modules/analytics/models/analytics.model'; import { AnalyticsQueryArgs } from '../entities/analytics.query.args'; import { AnalyticsQueryInterface } from '../interfaces/analytics.query.interface'; @@ -52,9 +53,27 @@ export class AnalyticsQueryServiceMock implements AnalyticsQueryInterface { getCandles({ series, metric, start, end }): Promise { throw new Error('Method not implemented.'); } + getCandlesForTokens({ + identifiers, + resolution, + start, + end, + }): Promise { + throw new Error('Method not implemented.'); + } + getLastCandleForTokens({ + identifiers, + start, + end, + }): Promise { + throw new Error('Method not implemented.'); + } getStartDate(series: string): Promise { throw new Error('Method not implemented.'); } + getEarliestStartDate(series: string[]): Promise { + throw new Error('Method not implemented.'); + } } export const AnalyticsQueryServiceProvider = { diff --git a/src/services/analytics/services/analytics.query.service.ts b/src/services/analytics/services/analytics.query.service.ts index 8251f0d17..cc28ab954 100644 --- a/src/services/analytics/services/analytics.query.service.ts +++ b/src/services/analytics/services/analytics.query.service.ts @@ -3,6 +3,7 @@ import { CandleDataModel, HistoricDataModel, OhlcvDataModel, + TokenCandlesModel, } from 'src/modules/analytics/models/analytics.model'; import { TimescaleDBQueryService } from '../timescaledb/timescaledb.query.service'; import { AnalyticsQueryInterface } from '../interfaces/analytics.query.interface'; @@ -116,11 +117,43 @@ export class AnalyticsQueryService implements AnalyticsQueryInterface { }); } + async getCandlesForTokens({ + identifiers, + resolution, + start, + end, + }): Promise { + const service = await this.getService(); + return await service.getCandlesForTokens({ + identifiers, + resolution, + start, + end, + }); + } + async getLastCandleForTokens({ + identifiers, + start, + end, + }): Promise { + const service = await this.getService(); + return await service.getLastCandleForTokens({ + identifiers, + start, + end, + }); + } + async getStartDate(series: string): Promise { const service = await this.getService(); return await service.getStartDate(series); } + async getEarliestStartDate(series: string[]): Promise { + const service = await this.getService(); + return await service.getEarliestStartDate(series); + } + private async getService(): Promise { return this.timescaleDBQuery; } diff --git a/src/services/analytics/timescaledb/timescaledb.query.service.ts b/src/services/analytics/timescaledb/timescaledb.query.service.ts index 945197c2c..106979b8a 100644 --- a/src/services/analytics/timescaledb/timescaledb.query.service.ts +++ b/src/services/analytics/timescaledb/timescaledb.query.service.ts @@ -4,6 +4,7 @@ import { CandleDataModel, HistoricDataModel, OhlcvDataModel, + TokenCandlesModel, } from 'src/modules/analytics/models/analytics.model'; import { computeTimeInterval } from 'src/utils/analytics.utils'; import { AnalyticsQueryArgs } from '../entities/analytics.query.args'; @@ -423,6 +424,40 @@ export class TimescaleDBQueryService implements AnalyticsQueryInterface { return moment.min(filteredTimestamps).toISOString(); } + async getEarliestStartDate(series: string[]): Promise { + const allStartDates = await this.allStartDates(); + + const filteredTimestamps = []; + + for (const currentSeries of series) { + if (!currentSeries.includes('%')) { + if (allStartDates[currentSeries] !== undefined) { + filteredTimestamps.push( + moment(allStartDates[currentSeries]), + ); + } + continue; + } + + const seriesWithoutWildcard = currentSeries.replace( + new RegExp('%', 'g'), + '', + ); + for (const [key, value] of Object.entries(allStartDates)) { + if (!key.includes(seriesWithoutWildcard)) { + continue; + } + filteredTimestamps.push(moment(value)); + } + } + + if (filteredTimestamps.length === 0) { + return undefined; + } + + return moment.min(filteredTimestamps).toISOString(); + } + @ErrorLoggerAsync({ logArgs: true, }) @@ -534,6 +569,135 @@ export class TimescaleDBQueryService implements AnalyticsQueryInterface { ); } + @TimescaleDBQuery() + async getCandlesForTokens({ + identifiers, + resolution, + start, + end, + }): Promise { + try { + const candleRepository = + this.getCandleRepositoryByResolutionAndMetric( + resolution, + 'priceUSD', + ); + const startDate = moment.unix(start).utc().toDate(); + const endDate = moment.unix(end).utc().toDate(); + + const queryResult = await candleRepository + .createQueryBuilder() + .select( + `time_bucket_gapfill('${resolution}', time) as bucket, series`, + ) + .addSelect('locf(first(open, time)) as open') + .addSelect('locf(max(high)) as high') + .addSelect('locf(min(low)) as low') + .addSelect('locf(last(close, time)) as close') + .addSelect('sum(volume) as volume') + .where('series in (:...identifiers)', { identifiers }) + .andWhere('time between :startDate and :endDate', { + startDate, + endDate, + }) + .groupBy('series') + .addGroupBy('bucket') + .getRawMany(); + + return this.processTokenCandles(queryResult, -1); + } catch (error) { + this.logger.error('getCandlesForTokens', { + identifiers, + resolution, + start, + end, + error, + }); + return []; + } + } + + @TimescaleDBQuery() + async getLastCandleForTokens({ + identifiers, + start, + end, + }): Promise { + try { + const startDate = moment.unix(start).utc().toDate(); + const endDate = moment.unix(end).utc().toDate(); + + const queryResult = await this.tokenCandlesMinute + .createQueryBuilder() + .select(`series`) + .addSelect('last(time, time) as time') + .addSelect('last(open, time) as open') + .addSelect('last(high, time) as high') + .addSelect('last(low, time) as low') + .addSelect('last(close, time) as close') + .addSelect('last(volume, time) as volume') + .where('series in (:...identifiers)', { identifiers }) + .andWhere('time between :startDate and :endDate', { + startDate, + endDate, + }) + .groupBy('series') + .getRawMany(); + + return this.processTokenCandles(queryResult, 0); + } catch (error) { + this.logger.error('getLastCandleForTokens', { + identifiers, + start, + end, + error, + }); + return []; + } + } + + private processTokenCandles( + queryResult: any[], + defaultValue: number, + ): TokenCandlesModel[] { + if (!queryResult || queryResult.length === 0) { + return []; + } + + const result: TokenCandlesModel[] = []; + + queryResult.forEach((row) => { + let tokenIndex = result.findIndex( + (elem) => elem.identifier === row.series, + ); + + if (tokenIndex === -1) { + result.push( + new TokenCandlesModel({ + identifier: row.series, + candles: [], + }), + ); + tokenIndex = result.length - 1; + } + + result[tokenIndex].candles.push( + new OhlcvDataModel({ + time: row.bucket ?? row.time, + ohlcv: [ + row.open ?? defaultValue, + row.high ?? defaultValue, + row.low ?? defaultValue, + row.close ?? defaultValue, + row.volume ?? 0, + ], + }), + ); + }); + + return result; + } + @TimescaleDBQuery() async getCandles({ series, diff --git a/src/services/crons/analytics.cache.warmer.service.ts b/src/services/crons/analytics.cache.warmer.service.ts index 999d1d94a..575889b31 100644 --- a/src/services/crons/analytics.cache.warmer.service.ts +++ b/src/services/crons/analytics.cache.warmer.service.ts @@ -7,6 +7,12 @@ import { RedisPubSub } from 'graphql-redis-subscriptions'; import { PUB_SUB } from '../redis.pubSub.module'; import { ApiConfigService } from 'src/helpers/api.config.service'; import { AnalyticsSetterService } from 'src/modules/analytics/services/analytics.setter.service'; +import { Lock } from '@multiversx/sdk-nestjs-common'; +import { TokenService } from 'src/modules/tokens/services/token.service'; +import { WINSTON_MODULE_PROVIDER } from 'nest-winston'; +import { Logger } from 'winston'; +import { PerformanceProfiler } from '@multiversx/sdk-nestjs-monitoring'; +import { AnalyticsTokenService } from 'src/modules/analytics/services/analytics.token.service'; @Injectable() export class AnalyticsCacheWarmerService { @@ -14,7 +20,10 @@ export class AnalyticsCacheWarmerService { private readonly analyticsCompute: AnalyticsComputeService, private readonly analyticsSetter: AnalyticsSetterService, private readonly apiConfig: ApiConfigService, + private readonly tokenService: TokenService, + private readonly analyticsTokenService: AnalyticsTokenService, @Inject(PUB_SUB) private pubSub: RedisPubSub, + @Inject(WINSTON_MODULE_PROVIDER) protected readonly logger: Logger, ) {} @Cron(CronExpression.EVERY_MINUTE) @@ -73,6 +82,41 @@ export class AnalyticsCacheWarmerService { await this.deleteCacheKeys(cachedKeys); } + @Cron(CronExpression.EVERY_5_MINUTES) + @Lock({ name: 'cacheTokensLast7dPrice', verbose: true }) + async cacheTokensLast7dPrice(): Promise { + const tokens = await this.tokenService.getUniqueTokenIDs(false); + this.logger.info('Start refresh tokens last 7 days price'); + const profiler = new PerformanceProfiler(); + + for (let i = 0; i < tokens.length; i += 10) { + const batch = tokens.slice(i, i + 10); + + const tokensCandles = + await this.analyticsTokenService.computeTokensLast7dPrice( + batch, + ); + + const promises = []; + tokensCandles.forEach((elem) => { + promises.push( + this.analyticsSetter.setTokenLast7dPrices( + elem.identifier, + elem.candles, + ), + ); + }); + const cachedKeys = await Promise.all(promises); + + await this.deleteCacheKeys(cachedKeys); + } + + profiler.stop(); + this.logger.info( + `Finish refresh tokens last 7 days price in ${profiler.duration}`, + ); + } + private async deleteCacheKeys(invalidatedKeys: string[]) { await this.pubSub.publish('deleteCacheKeys', invalidatedKeys); } diff --git a/src/utils/get.many.utils.ts b/src/utils/get.many.utils.ts index 98449e6ac..ae389b724 100644 --- a/src/utils/get.many.utils.ts +++ b/src/utils/get.many.utils.ts @@ -1,7 +1,7 @@ import { CacheService } from '@multiversx/sdk-nestjs-cache'; import { parseCachedNullOrUndefined } from './cache.utils'; -async function getMany( +export async function getMany( cacheService: CacheService, keys: string[], ): Promise<(T | undefined)[]> {