From 9d25d5c5f490c8534ec8759c962ecd315af329b3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Sven=20G=C3=BCnther?= Date: Sat, 15 Nov 2025 10:51:48 +0100 Subject: [PATCH] Feature/automatically gather required exchange rates (#5917) * Automatically gather required exchange rates * Update changelog --- CHANGELOG.md | 4 ++ apps/api/src/app/order/order.service.ts | 10 +++ .../src/events/asset-profile-changed.event.ts | 11 ++++ .../events/asset-profile-changed.listener.ts | 61 +++++++++++++++++++ apps/api/src/events/events.module.ts | 17 +++++- .../configuration/configuration.service.ts | 1 + .../interfaces/environment.interface.ts | 1 + 7 files changed, 103 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/CHANGELOG.md b/CHANGELOG.md index 5e9e362cd..51ae02bdd 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## Unreleased +### Added + +- Introduced support for automatically gathering required exchange rates, exposed as an environment variable (`ENABLE_FEATURE_GATHER_NEW_EXCHANGE_RATES`) + ### Changed - Refactored the get holding functionality in the portfolio service diff --git a/apps/api/src/app/order/order.service.ts b/apps/api/src/app/order/order.service.ts index e4c642977..7dc6c646d 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'; @@ -225,6 +226,15 @@ export class OrderService { }); } + this.eventEmitter.emit( + AssetProfileChangedEvent.getName(), + new AssetProfileChangedEvent({ + currency: order.SymbolProfile.currency, + dataSource: order.SymbolProfile.dataSource, + symbol: order.SymbolProfile.symbol + }) + ); + this.eventEmitter.emit( PortfolioChangedEvent.getName(), new PortfolioChangedEvent({ 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..46a8c5db4 --- /dev/null +++ b/apps/api/src/events/asset-profile-changed.event.ts @@ -0,0 +1,11 @@ +import { AssetProfileIdentifier } from '@ghostfolio/common/interfaces'; + +export class AssetProfileChangedEvent { + public constructor( + public readonly data: AssetProfileIdentifier & { currency: string } + ) {} + + public static getName(): string { + return 'assetProfile.changed'; + } +} 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..ad80ee4a5 --- /dev/null +++ b/apps/api/src/events/asset-profile-changed.listener.ts @@ -0,0 +1,61 @@ +import { OrderService } from '@ghostfolio/api/app/order/order.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 { 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 orderService: OrderService + ) {} + + @OnEvent(AssetProfileChangedEvent.getName()) + public async handleAssetProfileChanged(event: AssetProfileChangedEvent) { + Logger.log( + `Asset profile of ${event.data.symbol} (${event.data.dataSource}) has changed`, + 'AssetProfileChangedListener' + ); + + if ( + this.configurationService.get( + 'ENABLE_FEATURE_GATHER_NEW_EXCHANGE_RATES' + ) === false || + event.data.currency === DEFAULT_CURRENCY + ) { + return; + } + + const existingCurrencies = this.exchangeRateDataService.getCurrencies(); + + if (!existingCurrencies.includes(event.data.currency)) { + Logger.log( + `New currency ${event.data.currency} has been detected`, + 'AssetProfileChangedListener' + ); + + await this.exchangeRateDataService.initialize(); + } + + const { dateOfFirstActivity } = + await this.orderService.getStatisticsByCurrency(event.data.currency); + + if (dateOfFirstActivity) { + await this.dataGatheringService.gatherSymbol({ + dataSource: this.dataProviderService.getDataSourceForExchangeRates(), + date: dateOfFirstActivity, + symbol: `${DEFAULT_CURRENCY}${event.data.currency}` + }); + } + } +} diff --git a/apps/api/src/events/events.module.ts b/apps/api/src/events/events.module.ts index 0e6b25ba4..ece67ebe0 100644 --- a/apps/api/src/events/events.module.ts +++ b/apps/api/src/events/events.module.ts @@ -1,11 +1,24 @@ +import { OrderModule } from '@ghostfolio/api/app/order/order.module'; 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 { 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, + OrderModule, + 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;