diff --git a/CHANGELOG.md b/CHANGELOG.md index 513d04645..7bbdaf434 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,12 +9,17 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Added +- Extended the watchlist by the date of the last all time high, the current change to the all time high and the current market condition (experimental) - Added support for the impersonation mode in the watchlist (experimental) ### Changed - Improved the language localization for Français (`fr`) +### Fixed + +- Fixed the currency code validation by allowing `GBp` + ## 2.158.0 - 2025-04-30 ### Added diff --git a/apps/api/src/app/endpoints/watchlist/watchlist.module.ts b/apps/api/src/app/endpoints/watchlist/watchlist.module.ts index 462001aef..ce9ae12bb 100644 --- a/apps/api/src/app/endpoints/watchlist/watchlist.module.ts +++ b/apps/api/src/app/endpoints/watchlist/watchlist.module.ts @@ -1,7 +1,9 @@ import { TransformDataSourceInRequestModule } from '@ghostfolio/api/interceptors/transform-data-source-in-request/transform-data-source-in-request.module'; import { TransformDataSourceInResponseModule } from '@ghostfolio/api/interceptors/transform-data-source-in-response/transform-data-source-in-response.module'; +import { BenchmarkModule } from '@ghostfolio/api/services/benchmark/benchmark.module'; import { DataProviderModule } from '@ghostfolio/api/services/data-provider/data-provider.module'; import { ImpersonationModule } from '@ghostfolio/api/services/impersonation/impersonation.module'; +import { MarketDataModule } from '@ghostfolio/api/services/market-data/market-data.module'; import { PrismaModule } from '@ghostfolio/api/services/prisma/prisma.module'; import { DataGatheringModule } from '@ghostfolio/api/services/queues/data-gathering/data-gathering.module'; import { SymbolProfileModule } from '@ghostfolio/api/services/symbol-profile/symbol-profile.module'; @@ -14,9 +16,11 @@ import { WatchlistService } from './watchlist.service'; @Module({ controllers: [WatchlistController], imports: [ + BenchmarkModule, DataGatheringModule, DataProviderModule, ImpersonationModule, + MarketDataModule, PrismaModule, SymbolProfileModule, TransformDataSourceInRequestModule, diff --git a/apps/api/src/app/endpoints/watchlist/watchlist.service.ts b/apps/api/src/app/endpoints/watchlist/watchlist.service.ts index 6ff71ec50..36a498e1d 100644 --- a/apps/api/src/app/endpoints/watchlist/watchlist.service.ts +++ b/apps/api/src/app/endpoints/watchlist/watchlist.service.ts @@ -1,8 +1,10 @@ +import { BenchmarkService } from '@ghostfolio/api/services/benchmark/benchmark.service'; import { DataProviderService } from '@ghostfolio/api/services/data-provider/data-provider.service'; +import { MarketDataService } from '@ghostfolio/api/services/market-data/market-data.service'; import { PrismaService } from '@ghostfolio/api/services/prisma/prisma.service'; import { DataGatheringService } from '@ghostfolio/api/services/queues/data-gathering/data-gathering.service'; import { SymbolProfileService } from '@ghostfolio/api/services/symbol-profile/symbol-profile.service'; -import { AssetProfileIdentifier } from '@ghostfolio/common/interfaces'; +import { WatchlistResponse } from '@ghostfolio/common/interfaces'; import { BadRequestException, Injectable } from '@nestjs/common'; import { DataSource, Prisma } from '@prisma/client'; @@ -10,8 +12,10 @@ import { DataSource, Prisma } from '@prisma/client'; @Injectable() export class WatchlistService { public constructor( + private readonly benchmarkService: BenchmarkService, private readonly dataGatheringService: DataGatheringService, private readonly dataProviderService: DataProviderService, + private readonly marketDataService: MarketDataService, private readonly prismaService: PrismaService, private readonly symbolProfileService: SymbolProfileService ) {} @@ -87,7 +91,7 @@ export class WatchlistService { public async getWatchlistItems( userId: string - ): Promise { + ): Promise { const user = await this.prismaService.user.findUnique({ select: { watchlist: { @@ -97,6 +101,50 @@ export class WatchlistService { where: { id: userId } }); - return user.watchlist ?? []; + const [assetProfiles, quotes] = await Promise.all([ + this.symbolProfileService.getSymbolProfiles(user.watchlist), + this.dataProviderService.getQuotes({ + items: user.watchlist.map(({ dataSource, symbol }) => { + return { dataSource, symbol }; + }) + }) + ]); + + const watchlist = await Promise.all( + user.watchlist.map(async ({ dataSource, symbol }) => { + const assetProfile = assetProfiles.find((profile) => { + return profile.dataSource === dataSource && profile.symbol === symbol; + }); + + const allTimeHigh = await this.marketDataService.getMax({ + dataSource, + symbol + }); + + const performancePercent = + this.benchmarkService.calculateChangeInPercentage( + allTimeHigh?.marketPrice, + quotes[symbol]?.marketPrice + ); + + return { + dataSource, + symbol, + marketCondition: + this.benchmarkService.getMarketCondition(performancePercent), + name: assetProfile?.name, + performances: { + allTimeHigh: { + performancePercent, + date: allTimeHigh?.date + } + } + }; + }) + ); + + return watchlist.sort((a, b) => { + return a.name.localeCompare(b.name); + }); } } diff --git a/apps/api/src/services/benchmark/benchmark.service.ts b/apps/api/src/services/benchmark/benchmark.service.ts index 95cb9e5d2..f37f26bfc 100644 --- a/apps/api/src/services/benchmark/benchmark.service.ts +++ b/apps/api/src/services/benchmark/benchmark.service.ts @@ -212,6 +212,18 @@ export class BenchmarkService { }; } + public getMarketCondition( + aPerformanceInPercent: number + ): Benchmark['marketCondition'] { + if (aPerformanceInPercent >= 0) { + return 'ALL_TIME_HIGH'; + } else if (aPerformanceInPercent <= -0.2) { + return 'BEAR_MARKET'; + } else { + return 'NEUTRAL_MARKET'; + } + } + private async calculateAndCacheBenchmarks({ enableSharing = false }): Promise { @@ -302,16 +314,4 @@ export class BenchmarkService { 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'; - } - } } diff --git a/apps/api/src/validators/is-currency-code.ts b/apps/api/src/validators/is-currency-code.ts index d04da7808..771818b05 100644 --- a/apps/api/src/validators/is-currency-code.ts +++ b/apps/api/src/validators/is-currency-code.ts @@ -1,4 +1,4 @@ -import { DERIVED_CURRENCIES } from '@ghostfolio/common/config'; +import { isDerivedCurrency } from '@ghostfolio/common/helper'; import { registerDecorator, @@ -28,17 +28,11 @@ export class IsExtendedCurrencyConstraint return '$property must be a valid ISO4217 currency code'; } - public validate(currency: any) { - // Return true if currency is a standard ISO 4217 code or a derived currency + public validate(currency: string) { + // Return true if currency is a derived currency or a standard ISO 4217 code return ( - this.isUpperCase(currency) && - (isISO4217CurrencyCode(currency) || - [ - ...DERIVED_CURRENCIES.map((derivedCurrency) => { - return derivedCurrency.currency; - }), - 'USX' - ].includes(currency)) + isDerivedCurrency(currency) || + (this.isUpperCase(currency) && isISO4217CurrencyCode(currency)) ); } diff --git a/apps/client/src/app/components/home-watchlist/home-watchlist.component.ts b/apps/client/src/app/components/home-watchlist/home-watchlist.component.ts index 61df6311d..5c0b3fa50 100644 --- a/apps/client/src/app/components/home-watchlist/home-watchlist.component.ts +++ b/apps/client/src/app/components/home-watchlist/home-watchlist.component.ts @@ -7,6 +7,7 @@ import { User } from '@ghostfolio/common/interfaces'; import { hasPermission, permissions } from '@ghostfolio/common/permissions'; +import { BenchmarkTrend } from '@ghostfolio/common/types'; import { GfBenchmarkComponent } from '@ghostfolio/ui/benchmark'; import { GfPremiumIndicatorComponent } from '@ghostfolio/ui/premium-indicator'; @@ -132,15 +133,17 @@ export class HomeWatchlistComponent implements OnDestroy, OnInit { .fetchWatchlist() .pipe(takeUntil(this.unsubscribeSubject)) .subscribe(({ watchlist }) => { - this.watchlist = watchlist.map(({ dataSource, symbol }) => ({ - dataSource, - symbol, - marketCondition: null, - name: symbol, - performances: null, - trend50d: 'UNKNOWN', - trend200d: 'UNKNOWN' - })); + this.watchlist = watchlist.map( + ({ dataSource, marketCondition, name, performances, symbol }) => ({ + dataSource, + marketCondition, + name, + performances, + symbol, + trend50d: 'UNKNOWN' as BenchmarkTrend, + trend200d: 'UNKNOWN' as BenchmarkTrend + }) + ); this.changeDetectorRef.markForCheck(); }); diff --git a/apps/client/src/app/components/home-watchlist/home-watchlist.html b/apps/client/src/app/components/home-watchlist/home-watchlist.html index bfbe36b99..9149eab91 100644 --- a/apps/client/src/app/components/home-watchlist/home-watchlist.html +++ b/apps/client/src/app/components/home-watchlist/home-watchlist.html @@ -7,7 +7,7 @@ } -
+
{ + return DERIVED_CURRENCIES.some(({ currency }) => { return currency === aCurrency; }); } diff --git a/libs/common/src/lib/interfaces/responses/watchlist-response.interface.ts b/libs/common/src/lib/interfaces/responses/watchlist-response.interface.ts index 3cdc834b4..6994d73f7 100644 --- a/libs/common/src/lib/interfaces/responses/watchlist-response.interface.ts +++ b/libs/common/src/lib/interfaces/responses/watchlist-response.interface.ts @@ -1,5 +1,12 @@ -import { AssetProfileIdentifier } from '@ghostfolio/common/interfaces'; +import { + AssetProfileIdentifier, + Benchmark +} from '@ghostfolio/common/interfaces'; export interface WatchlistResponse { - watchlist: AssetProfileIdentifier[]; + watchlist: (AssetProfileIdentifier & { + marketCondition: Benchmark['marketCondition']; + name: string; + performances: Benchmark['performances']; + })[]; }