From 7c205fe10ac9020adc89810d32b46cef207fc106 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Sven=20G=C3=BCnther?= Date: Wed, 5 Nov 2025 17:27:53 +0100 Subject: [PATCH] Automatically gather exchange rates if needed #5897 --- apps/api/src/app/import/import.service.ts | 13 +++ apps/api/src/app/order/order.service.ts | 9 ++ .../src/events/asset-profile-changed.event.ts | 12 +++ .../events/asset-profile-changed.listener.ts | 92 +++++++++++++++++++ apps/api/src/events/events.module.ts | 17 +++- .../configuration/configuration.service.ts | 1 + .../interfaces/environment.interface.ts | 1 + 7 files changed, 143 insertions(+), 2 deletions(-) create mode 100644 apps/api/src/events/asset-profile-changed.event.ts create mode 100644 apps/api/src/events/asset-profile-changed.listener.ts diff --git a/apps/api/src/app/import/import.service.ts b/apps/api/src/app/import/import.service.ts index cac466192..dc60abd76 100644 --- a/apps/api/src/app/import/import.service.ts +++ b/apps/api/src/app/import/import.service.ts @@ -4,6 +4,7 @@ import { CreateOrderDto } from '@ghostfolio/api/app/order/create-order.dto'; import { OrderService } from '@ghostfolio/api/app/order/order.service'; import { PlatformService } from '@ghostfolio/api/app/platform/platform.service'; import { PortfolioService } from '@ghostfolio/api/app/portfolio/portfolio.service'; +import { AssetProfileChangedEvent } from '@ghostfolio/api/events/asset-profile-changed.event'; import { ConfigurationService } from '@ghostfolio/api/services/configuration/configuration.service'; import { DataProviderService } from '@ghostfolio/api/services/data-provider/data-provider.service'; import { MarketDataService } from '@ghostfolio/api/services/market-data/market-data.service'; @@ -28,6 +29,7 @@ import { } from '@ghostfolio/common/types'; import { Injectable } from '@nestjs/common'; +import { EventEmitter2 } from '@nestjs/event-emitter'; import { DataSource, Prisma, SymbolProfile } from '@prisma/client'; import { Big } from 'big.js'; import { endOfToday, isAfter, isSameSecond, parseISO } from 'date-fns'; @@ -44,6 +46,7 @@ export class ImportService { private readonly configurationService: ConfigurationService, private readonly dataGatheringService: DataGatheringService, private readonly dataProviderService: DataProviderService, + private readonly eventEmitter: EventEmitter2, private readonly marketDataService: MarketDataService, private readonly orderService: OrderService, private readonly platformService: PlatformService, @@ -605,6 +608,16 @@ export class ImportService { }), priority: DATA_GATHERING_QUEUE_PRIORITY_HIGH }); + + uniqueActivities.forEach(({ SymbolProfile }) => { + this.eventEmitter.emit( + AssetProfileChangedEvent.getName(), + new AssetProfileChangedEvent( + SymbolProfile.currency, + SymbolProfile.symbol + ) + ); + }); } return activities; diff --git a/apps/api/src/app/order/order.service.ts b/apps/api/src/app/order/order.service.ts index e4c642977..b02d5dfb8 100644 --- a/apps/api/src/app/order/order.service.ts +++ b/apps/api/src/app/order/order.service.ts @@ -1,4 +1,5 @@ import { AccountService } from '@ghostfolio/api/app/account/account.service'; +import { AssetProfileChangedEvent } from '@ghostfolio/api/events/asset-profile-changed.event'; import { PortfolioChangedEvent } from '@ghostfolio/api/events/portfolio-changed.event'; import { LogPerformance } from '@ghostfolio/api/interceptors/performance-logging/performance-logging.interceptor'; import { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-data/exchange-rate-data.service'; @@ -232,6 +233,14 @@ export class OrderService { }) ); + this.eventEmitter.emit( + AssetProfileChangedEvent.getName(), + new AssetProfileChangedEvent( + order.SymbolProfile.currency, + order.SymbolProfile.symbol + ) + ); + return order; } diff --git a/apps/api/src/events/asset-profile-changed.event.ts b/apps/api/src/events/asset-profile-changed.event.ts new file mode 100644 index 000000000..edb0a5949 --- /dev/null +++ b/apps/api/src/events/asset-profile-changed.event.ts @@ -0,0 +1,12 @@ +export class AssetProfileChangedEvent { + private static readonly eventName = 'asset-profile.changed'; + + public constructor( + public readonly currency: string, + public readonly symbol: string + ) {} + + public static getName(): string { + return AssetProfileChangedEvent.eventName; + } +} diff --git a/apps/api/src/events/asset-profile-changed.listener.ts b/apps/api/src/events/asset-profile-changed.listener.ts new file mode 100644 index 000000000..11bd0be81 --- /dev/null +++ b/apps/api/src/events/asset-profile-changed.listener.ts @@ -0,0 +1,92 @@ +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 { PrismaService } from '@ghostfolio/api/services/prisma/prisma.service'; +import { DataGatheringService } from '@ghostfolio/api/services/queues/data-gathering/data-gathering.service'; +import { DEFAULT_CURRENCY } from '@ghostfolio/common/config'; + +import { Injectable, Logger } from '@nestjs/common'; +import { OnEvent } from '@nestjs/event-emitter'; + +import { AssetProfileChangedEvent } from './asset-profile-changed.event'; + +@Injectable() +export class AssetProfileChangedListener { + public constructor( + private readonly configurationService: ConfigurationService, + private readonly dataGatheringService: DataGatheringService, + private readonly dataProviderService: DataProviderService, + private readonly exchangeRateDataService: ExchangeRateDataService, + private readonly prismaService: PrismaService + ) {} + + @OnEvent(AssetProfileChangedEvent.getName()) + public async handleAssetProfileChanged(event: AssetProfileChangedEvent) { + const isEnabled = this.configurationService.get( + 'ENABLE_FEATURE_GATHER_NEW_EXCHANGE_RATES' + ); + + if (isEnabled === false) { + return; + } + + if (event.currency === DEFAULT_CURRENCY) { + return; + } + + Logger.log( + `Asset profile changed: ${event.symbol} (${event.currency}). Checking if exchange rate gathering is needed.`, + 'AssetProfileChangedListener' + ); + + const existingCurrencies = this.exchangeRateDataService.getCurrencies(); + const currencyAlreadyExists = existingCurrencies.includes(event.currency); + + if (currencyAlreadyExists) { + return; + } + + Logger.log( + `New currency detected: ${event.currency}. Initializing exchange rate data service.`, + 'AssetProfileChangedListener' + ); + + await this.exchangeRateDataService.initialize(); + + if ( + !this.exchangeRateDataService.hasCurrencyPair( + DEFAULT_CURRENCY, + event.currency + ) + ) { + Logger.warn( + `Currency pair ${DEFAULT_CURRENCY}${event.currency} was not added after initialization.`, + 'AssetProfileChangedListener' + ); + return; + } + + const firstOrderWithCurrency = await this.prismaService.order.findFirst({ + orderBy: [{ date: 'asc' }], + select: { date: true }, + where: { + SymbolProfile: { + currency: event.currency + } + } + }); + + const startDate = firstOrderWithCurrency.date ?? new Date(); + + Logger.log( + `Triggering exchange rate data gathering for ${DEFAULT_CURRENCY}${event.currency} from ${startDate.toISOString()}.`, + 'AssetProfileChangedListener' + ); + + await this.dataGatheringService.gatherSymbol({ + dataSource: this.dataProviderService.getDataSourceForExchangeRates(), + symbol: `${DEFAULT_CURRENCY}${event.currency}`, + date: startDate + }); + } +} diff --git a/apps/api/src/events/events.module.ts b/apps/api/src/events/events.module.ts index 0e6b25ba4..aa9ccd596 100644 --- a/apps/api/src/events/events.module.ts +++ b/apps/api/src/events/events.module.ts @@ -1,11 +1,24 @@ import { RedisCacheModule } from '@ghostfolio/api/app/redis-cache/redis-cache.module'; +import { ConfigurationModule } from '@ghostfolio/api/services/configuration/configuration.module'; +import { DataProviderModule } from '@ghostfolio/api/services/data-provider/data-provider.module'; +import { ExchangeRateDataModule } from '@ghostfolio/api/services/exchange-rate-data/exchange-rate-data.module'; +import { PrismaModule } from '@ghostfolio/api/services/prisma/prisma.module'; +import { DataGatheringModule } from '@ghostfolio/api/services/queues/data-gathering/data-gathering.module'; import { Module } from '@nestjs/common'; +import { AssetProfileChangedListener } from './asset-profile-changed.listener'; import { PortfolioChangedListener } from './portfolio-changed.listener'; @Module({ - imports: [RedisCacheModule], - providers: [PortfolioChangedListener] + imports: [ + ConfigurationModule, + DataGatheringModule, + DataProviderModule, + ExchangeRateDataModule, + PrismaModule, + RedisCacheModule + ], + providers: [AssetProfileChangedListener, PortfolioChangedListener] }) export class EventsModule {} diff --git a/apps/api/src/services/configuration/configuration.service.ts b/apps/api/src/services/configuration/configuration.service.ts index cb9fde832..f37189569 100644 --- a/apps/api/src/services/configuration/configuration.service.ts +++ b/apps/api/src/services/configuration/configuration.service.ts @@ -43,6 +43,7 @@ export class ConfigurationService { ENABLE_FEATURE_AUTH_GOOGLE: bool({ default: false }), ENABLE_FEATURE_AUTH_TOKEN: bool({ default: true }), ENABLE_FEATURE_FEAR_AND_GREED_INDEX: bool({ default: false }), + ENABLE_FEATURE_GATHER_NEW_EXCHANGE_RATES: bool({ default: true }), ENABLE_FEATURE_READ_ONLY_MODE: bool({ default: false }), ENABLE_FEATURE_STATISTICS: bool({ default: false }), ENABLE_FEATURE_SUBSCRIPTION: bool({ default: false }), diff --git a/apps/api/src/services/interfaces/environment.interface.ts b/apps/api/src/services/interfaces/environment.interface.ts index f2ee84926..3a2ac687c 100644 --- a/apps/api/src/services/interfaces/environment.interface.ts +++ b/apps/api/src/services/interfaces/environment.interface.ts @@ -19,6 +19,7 @@ export interface Environment extends CleanedEnvAccessors { ENABLE_FEATURE_AUTH_GOOGLE: boolean; ENABLE_FEATURE_AUTH_TOKEN: boolean; ENABLE_FEATURE_FEAR_AND_GREED_INDEX: boolean; + ENABLE_FEATURE_GATHER_NEW_EXCHANGE_RATES: boolean; ENABLE_FEATURE_READ_ONLY_MODE: boolean; ENABLE_FEATURE_STATISTICS: boolean; ENABLE_FEATURE_SUBSCRIPTION: boolean;