diff --git a/apps/api/src/services/exchange-rate-data/exchange-rate-data.service.ts b/apps/api/src/services/exchange-rate-data/exchange-rate-data.service.ts index 024bdf4e1..9e07256e1 100644 --- a/apps/api/src/services/exchange-rate-data/exchange-rate-data.service.ts +++ b/apps/api/src/services/exchange-rate-data/exchange-rate-data.service.ts @@ -16,6 +16,7 @@ import { } from '@ghostfolio/common/helper'; import { Injectable, Logger } from '@nestjs/common'; +import { OnEvent } from '@nestjs/event-emitter'; import { eachDayOfInterval, format, @@ -34,6 +35,8 @@ export class ExchangeRateDataService { private currencyPairs: DataGatheringItem[] = []; private derivedCurrencyFactors: { [currencyPair: string]: number } = {}; private exchangeRates: { [currencyPair: string]: number } = {}; + private exchangeRateCache = new Map(); + private pendingLoads = new Map>(); public constructor( private readonly dataProviderService: DataProviderService, @@ -284,56 +287,34 @@ export class ExchangeRateDataService { } else if (derivedCurrencyFactor) { factor = derivedCurrencyFactor; } else { - const dataSource = - this.dataProviderService.getDataSourceForExchangeRates(); - const symbol = `${aFromCurrency}${aToCurrency}`; - - const marketData = await this.marketDataService.get({ - dataSource, - symbol, - date: aDate - }); + const marketPrice = await this.getRateFromCache( + `${aFromCurrency}${aToCurrency}`, + aDate + ); - if (marketData?.marketPrice) { - factor = marketData?.marketPrice; + if (marketPrice !== undefined) { + factor = marketPrice; } else { - // Calculate indirectly via base currency - - let marketPriceBaseCurrencyFromCurrency: number; - let marketPriceBaseCurrencyToCurrency: number; - try { - if (aFromCurrency === DEFAULT_CURRENCY) { - marketPriceBaseCurrencyFromCurrency = 1; - } else { - marketPriceBaseCurrencyFromCurrency = ( - await this.marketDataService.get({ - dataSource, - date: aDate, - symbol: `${DEFAULT_CURRENCY}${aFromCurrency}` - }) - )?.marketPrice; + let baseFromPrice = 1; + let baseToPrice = 1; + + if (aFromCurrency !== DEFAULT_CURRENCY) { + baseFromPrice = await this.getRateFromCache( + `${DEFAULT_CURRENCY}${aFromCurrency}`, + aDate + ); } - } catch {} - try { - if (aToCurrency === DEFAULT_CURRENCY) { - marketPriceBaseCurrencyToCurrency = 1; - } else { - marketPriceBaseCurrencyToCurrency = ( - await this.marketDataService.get({ - dataSource, - date: aDate, - symbol: `${DEFAULT_CURRENCY}${aToCurrency}` - }) - )?.marketPrice; + if (aToCurrency !== DEFAULT_CURRENCY) { + baseToPrice = await this.getRateFromCache( + `${DEFAULT_CURRENCY}${aToCurrency}`, + aDate + ); } - } catch {} - // Calculate the opposite direction - factor = - (1 / marketPriceBaseCurrencyFromCurrency) * - marketPriceBaseCurrencyToCurrency; + factor = (1 / baseFromPrice) * baseToPrice; + } catch {} } } @@ -352,6 +333,97 @@ export class ExchangeRateDataService { return undefined; } + @OnEvent('market-data.updated') + public onMarketDataUpdated(event: { symbol: string }) { + this.exchangeRateCache.delete(event.symbol); + this.pendingLoads.delete(event.symbol); + } + + private getDaysSinceEpoch(aDate: Date) { + return Math.floor(aDate.getTime() / 86400000); + } + + private async loadCache(aSymbol: string): Promise { + const dataSource = this.dataProviderService.getDataSourceForExchangeRates(); + const marketData = await this.prismaService.marketData.findMany({ + where: { dataSource, symbol: aSymbol }, + orderBy: { date: 'asc' }, + select: { date: true, marketPrice: true } + }); + + const todayDays = this.getDaysSinceEpoch(new Date()); + const array = new Float32Array(todayDays + 1); + + if (marketData.length > 0) { + let lastRate = marketData[0].marketPrice; + let currentIndex = this.getDaysSinceEpoch(marketData[0].date); + + for (const data of marketData) { + const dataIndex = this.getDaysSinceEpoch(data.date); + while (currentIndex < dataIndex && currentIndex <= todayDays) { + array[currentIndex++] = lastRate; + } + lastRate = data.marketPrice; + array[dataIndex] = lastRate; + currentIndex = dataIndex + 1; + } + + while (currentIndex <= todayDays) { + array[currentIndex++] = lastRate; + } + } + + return array; + } + + private async getRateFromCache( + aSymbol: string, + aDate: Date + ): Promise { + let cache = this.exchangeRateCache.get(aSymbol); + + if (!cache) { + if (this.pendingLoads.has(aSymbol)) { + await this.pendingLoads.get(aSymbol); + cache = this.exchangeRateCache.get(aSymbol); + + if (!cache) { + cache = await this.loadAndCommit(aSymbol); + } + } else { + cache = await this.loadAndCommit(aSymbol); + } + } + + const days = Math.min(this.getDaysSinceEpoch(aDate), cache.length - 1); + + if (days >= 0) { + const rate = cache[days]; + return rate === 0 ? undefined : rate; + } + + return undefined; + } + + private async loadAndCommit(aSymbol: string): Promise { + const loadPromise = this.loadCache(aSymbol); + this.pendingLoads.set(aSymbol, loadPromise); + + try { + const cache = await loadPromise; + + if (this.pendingLoads.get(aSymbol) === loadPromise) { + this.exchangeRateCache.set(aSymbol, cache); + } + + return this.exchangeRateCache.get(aSymbol) ?? cache; + } finally { + if (this.pendingLoads.get(aSymbol) === loadPromise) { + this.pendingLoads.delete(aSymbol); + } + } + } + private async getExchangeRates({ currencyFrom, currencyTo, diff --git a/apps/api/src/services/market-data/market-data.service.ts b/apps/api/src/services/market-data/market-data.service.ts index 87b08e1bd..23db3adac 100644 --- a/apps/api/src/services/market-data/market-data.service.ts +++ b/apps/api/src/services/market-data/market-data.service.ts @@ -6,6 +6,7 @@ import { resetHours } from '@ghostfolio/common/helper'; import { AssetProfileIdentifier } from '@ghostfolio/common/interfaces'; import { Injectable } from '@nestjs/common'; +import { EventEmitter2 } from '@nestjs/event-emitter'; import { DataSource, MarketData, @@ -15,7 +16,10 @@ import { @Injectable() export class MarketDataService { - public constructor(private readonly prismaService: PrismaService) {} + public constructor( + private readonly eventEmitter: EventEmitter2, + private readonly prismaService: PrismaService + ) {} public async deleteMany({ dataSource, symbol }: AssetProfileIdentifier) { return this.prismaService.marketData.deleteMany({ @@ -185,6 +189,8 @@ export class MarketDataService { }); } }); + + this.eventEmitter.emit('market-data.updated', { symbol }); } public async updateAssetProfileIdentifier( @@ -211,7 +217,7 @@ export class MarketDataService { }): Promise { const { data, where } = params; - return this.prismaService.marketData.upsert({ + const result = await this.prismaService.marketData.upsert({ where, create: { dataSource: where.dataSource_date_symbol.dataSource, @@ -222,6 +228,12 @@ export class MarketDataService { }, update: { marketPrice: data.marketPrice, state: data.state } }); + + this.eventEmitter.emit('market-data.updated', { + symbol: where.dataSource_date_symbol.symbol + }); + + return result; } /** @@ -258,6 +270,13 @@ export class MarketDataService { } ); - return this.prismaService.$transaction(upsertPromises); + const result = await this.prismaService.$transaction(upsertPromises); + + const symbols = [...new Set(data.map((d) => d.symbol as string))]; + for (const symbol of symbols) { + this.eventEmitter.emit('market-data.updated', { symbol }); + } + + return result; } }