From e15360636fc959217602172a37edb80203027a17 Mon Sep 17 00:00:00 2001 From: Thomas Kaul <4159106+dtslvr@users.noreply.github.com> Date: Tue, 1 Jul 2025 20:44:17 +0200 Subject: [PATCH] Extend markets --- .../market-data/market-data.controller.ts | 52 ++++++- .../market-data/market-data.module.ts | 8 +- .../components/markets/markets.component.ts | 141 ++++++++++++++++++ .../src/app/components/markets/markets.html | 64 ++++++++ .../src/app/components/markets/markets.scss | 7 + .../pages/home/home-page-routing.module.ts | 6 + .../src/app/pages/home/home-page.component.ts | 5 + apps/client/src/app/services/data.service.ts | 27 ++++ libs/common/src/lib/interfaces/index.ts | 2 + ...rket-data-of-markets-response.interface.ts | 6 + libs/common/src/lib/permissions.ts | 2 + .../lib/types/fear-and-greed-index.type.ts | 1 + libs/common/src/lib/types/index.ts | 2 + 13 files changed, 320 insertions(+), 3 deletions(-) create mode 100644 apps/client/src/app/components/markets/markets.component.ts create mode 100644 apps/client/src/app/components/markets/markets.html create mode 100644 apps/client/src/app/components/markets/markets.scss create mode 100644 libs/common/src/lib/interfaces/responses/market-data-of-markets-response.interface.ts create mode 100644 libs/common/src/lib/types/fear-and-greed-index.type.ts diff --git a/apps/api/src/app/endpoints/market-data/market-data.controller.ts b/apps/api/src/app/endpoints/market-data/market-data.controller.ts index 96fca1b3c..2744adab2 100644 --- a/apps/api/src/app/endpoints/market-data/market-data.controller.ts +++ b/apps/api/src/app/endpoints/market-data/market-data.controller.ts @@ -1,8 +1,18 @@ import { AdminService } from '@ghostfolio/api/app/admin/admin.service'; +import { SymbolService } from '@ghostfolio/api/app/symbol/symbol.service'; +import { HasPermission } from '@ghostfolio/api/decorators/has-permission.decorator'; +import { HasPermissionGuard } from '@ghostfolio/api/guards/has-permission.guard'; import { MarketDataService } from '@ghostfolio/api/services/market-data/market-data.service'; import { SymbolProfileService } from '@ghostfolio/api/services/symbol-profile/symbol-profile.service'; +import { + ghostfolioFearAndGreedIndexDataSource, + ghostfolioFearAndGreedIndexSymbol +} from '@ghostfolio/common/config'; import { getCurrencyFromSymbol, isCurrency } from '@ghostfolio/common/helper'; -import { MarketDataDetailsResponse } from '@ghostfolio/common/interfaces'; +import { + MarketDataDetailsResponse, + MarketDataOfMarketsResponse +} from '@ghostfolio/common/interfaces'; import { hasPermission, permissions } from '@ghostfolio/common/permissions'; import { RequestWithUser } from '@ghostfolio/common/types'; @@ -14,6 +24,7 @@ import { Inject, Param, Post, + Query, UseGuards } from '@nestjs/common'; import { REQUEST } from '@nestjs/core'; @@ -30,9 +41,46 @@ export class MarketDataController { private readonly adminService: AdminService, private readonly marketDataService: MarketDataService, @Inject(REQUEST) private readonly request: RequestWithUser, - private readonly symbolProfileService: SymbolProfileService + private readonly symbolProfileService: SymbolProfileService, + private readonly symbolService: SymbolService ) {} + @Get('markets') + @HasPermission(permissions.readMarketDataOfMarkets) + @UseGuards(AuthGuard('jwt'), HasPermissionGuard) + public async getMarketDataOfMarkets( + @Query('includeHistoricalData') includeHistoricalData = 0 + ): Promise { + const [ + marketDataFearAndGreedIndexCryptocurrencies, + marketDataFearAndGreedIndexStocks + ] = await Promise.all([ + this.symbolService.get({ + includeHistoricalData, + dataGatheringItem: { + dataSource: 'MANUAL', + symbol: 'GF_FEAR_AND_GREED_INDEX_CRYPTO' + } + }), + this.symbolService.get({ + includeHistoricalData, + dataGatheringItem: { + dataSource: ghostfolioFearAndGreedIndexDataSource, + symbol: ghostfolioFearAndGreedIndexSymbol + } + }) + ]); + + return { + CRYPTOCURRENCIES: { + ...marketDataFearAndGreedIndexCryptocurrencies + }, + STOCKS: { + ...marketDataFearAndGreedIndexStocks + } + }; + } + @Get(':dataSource/:symbol') @UseGuards(AuthGuard('jwt')) public async getMarketDataBySymbol( diff --git a/apps/api/src/app/endpoints/market-data/market-data.module.ts b/apps/api/src/app/endpoints/market-data/market-data.module.ts index 2050889fd..a8b355de3 100644 --- a/apps/api/src/app/endpoints/market-data/market-data.module.ts +++ b/apps/api/src/app/endpoints/market-data/market-data.module.ts @@ -1,4 +1,5 @@ import { AdminModule } from '@ghostfolio/api/app/admin/admin.module'; +import { SymbolModule } from '@ghostfolio/api/app/symbol/symbol.module'; import { MarketDataModule as MarketDataServiceModule } from '@ghostfolio/api/services/market-data/market-data.module'; import { SymbolProfileModule } from '@ghostfolio/api/services/symbol-profile/symbol-profile.module'; @@ -8,6 +9,11 @@ import { MarketDataController } from './market-data.controller'; @Module({ controllers: [MarketDataController], - imports: [AdminModule, MarketDataServiceModule, SymbolProfileModule] + imports: [ + AdminModule, + MarketDataServiceModule, + SymbolModule, + SymbolProfileModule + ] }) export class MarketDataModule {} diff --git a/apps/client/src/app/components/markets/markets.component.ts b/apps/client/src/app/components/markets/markets.component.ts new file mode 100644 index 000000000..48e4377c1 --- /dev/null +++ b/apps/client/src/app/components/markets/markets.component.ts @@ -0,0 +1,141 @@ +import { GfFearAndGreedIndexModule } from '@ghostfolio/client/components/fear-and-greed-index/fear-and-greed-index.module'; +import { GfToggleModule } from '@ghostfolio/client/components/toggle/toggle.module'; +import { DataService } from '@ghostfolio/client/services/data.service'; +import { UserService } from '@ghostfolio/client/services/user/user.service'; +import { resetHours } from '@ghostfolio/common/helper'; +import { + Benchmark, + HistoricalDataItem, + InfoItem, + MarketDataOfMarketsResponse, + ToggleOption, + User +} from '@ghostfolio/common/interfaces'; +import { hasPermission, permissions } from '@ghostfolio/common/permissions'; +import { FearAndGreedIndexMode } from '@ghostfolio/common/types'; +import { GfBenchmarkComponent } from '@ghostfolio/ui/benchmark'; +import { GfLineChartComponent } from '@ghostfolio/ui/line-chart'; + +import { CommonModule } from '@angular/common'; +import { + ChangeDetectionStrategy, + ChangeDetectorRef, + Component, + CUSTOM_ELEMENTS_SCHEMA, + OnDestroy, + OnInit +} from '@angular/core'; +import { DeviceDetectorService } from 'ngx-device-detector'; +import { Subject } from 'rxjs'; +import { takeUntil } from 'rxjs/operators'; + +@Component({ + changeDetection: ChangeDetectionStrategy.OnPush, + imports: [ + CommonModule, + GfBenchmarkComponent, + GfFearAndGreedIndexModule, + GfLineChartComponent, + GfToggleModule + ], + schemas: [CUSTOM_ELEMENTS_SCHEMA], + selector: 'gf-markets', + styleUrls: ['./markets.scss'], + templateUrl: './markets.html' +}) +export class MarketsComponent implements OnDestroy, OnInit { + public benchmarks: Benchmark[]; + public deviceType: string; + public fearAndGreedIndex: number; + public fearAndGreedIndexData: MarketDataOfMarketsResponse; + public fearLabel = $localize`Fear`; + public greedLabel = $localize`Greed`; + public hasPermissionToAccessFearAndGreedIndex: boolean; + public historicalDataItems: HistoricalDataItem[]; + public fearAndGreedIndexMode: FearAndGreedIndexMode = 'STOCKS'; + public fearAndGreedIndexModeOptions: ToggleOption[] = [ + { label: $localize`Stocks`, value: 'STOCKS' }, + { label: $localize`Cryptocurrencies`, value: 'CRYPTOCURRENCIES' } + ]; + public info: InfoItem; + public readonly numberOfDays = 365; + public user: User; + + private unsubscribeSubject = new Subject(); + + public constructor( + private changeDetectorRef: ChangeDetectorRef, + private dataService: DataService, + private deviceService: DeviceDetectorService, + private userService: UserService + ) { + this.deviceType = this.deviceService.getDeviceInfo().deviceType; + this.info = this.dataService.fetchInfo(); + + this.userService.stateChanged + .pipe(takeUntil(this.unsubscribeSubject)) + .subscribe((state) => { + if (state?.user) { + this.user = state.user; + + this.changeDetectorRef.markForCheck(); + } + }); + } + + public ngOnInit() { + this.hasPermissionToAccessFearAndGreedIndex = hasPermission( + this.info?.globalPermissions, + permissions.enableFearAndGreedIndex + ); + + if (this.hasPermissionToAccessFearAndGreedIndex) { + this.dataService + .fetchMarketDataOfMarkets({ includeHistoricalData: this.numberOfDays }) + .pipe(takeUntil(this.unsubscribeSubject)) + .subscribe((fearAndGreedIndexData) => { + this.fearAndGreedIndexData = fearAndGreedIndexData; + + this.initialize(); + + this.changeDetectorRef.markForCheck(); + }); + } + + this.dataService + .fetchBenchmarks() + .pipe(takeUntil(this.unsubscribeSubject)) + .subscribe(({ benchmarks }) => { + this.benchmarks = benchmarks; + + this.changeDetectorRef.markForCheck(); + }); + } + + public initialize() { + this.fearAndGreedIndex = + this.fearAndGreedIndexData[this.fearAndGreedIndexMode].marketPrice; + + this.historicalDataItems = [ + ...this.fearAndGreedIndexData[this.fearAndGreedIndexMode].historicalData, + { + date: resetHours(new Date()).toISOString(), + value: this.fearAndGreedIndex + } + ]; + } + + // TODO + public onChangeFearAndGreedIndexMode( + aFearAndGreedIndexMode: FearAndGreedIndexMode + ) { + this.fearAndGreedIndexMode = aFearAndGreedIndexMode; + + this.initialize(); + } + + public ngOnDestroy() { + this.unsubscribeSubject.next(); + this.unsubscribeSubject.complete(); + } +} diff --git a/apps/client/src/app/components/markets/markets.html b/apps/client/src/app/components/markets/markets.html new file mode 100644 index 000000000..d2c48c852 --- /dev/null +++ b/apps/client/src/app/components/markets/markets.html @@ -0,0 +1,64 @@ +
+

Markets

+ @if (hasPermissionToAccessFearAndGreedIndex) { +
+
+
+
+ +
+
+
+ Last {{ numberOfDays }} Days +
+ + +
+
+ } + +
+
+ + @if (benchmarks?.length > 0) { +
+ + Calculations are based on delayed market data and may not be + displayed in real-time. +
+ } +
+
+
diff --git a/apps/client/src/app/components/markets/markets.scss b/apps/client/src/app/components/markets/markets.scss new file mode 100644 index 000000000..5b523160d --- /dev/null +++ b/apps/client/src/app/components/markets/markets.scss @@ -0,0 +1,7 @@ +:host { + display: block; + + gf-line-chart { + aspect-ratio: 16 / 9; + } +} diff --git a/apps/client/src/app/pages/home/home-page-routing.module.ts b/apps/client/src/app/pages/home/home-page-routing.module.ts index 6fa7f1c27..1146213de 100644 --- a/apps/client/src/app/pages/home/home-page-routing.module.ts +++ b/apps/client/src/app/pages/home/home-page-routing.module.ts @@ -3,6 +3,7 @@ import { HomeMarketComponent } from '@ghostfolio/client/components/home-market/h import { HomeOverviewComponent } from '@ghostfolio/client/components/home-overview/home-overview.component'; import { HomeSummaryComponent } from '@ghostfolio/client/components/home-summary/home-summary.component'; import { HomeWatchlistComponent } from '@ghostfolio/client/components/home-watchlist/home-watchlist.component'; +import { MarketsComponent } from '@ghostfolio/client/components/markets/markets.component'; import { AuthGuard } from '@ghostfolio/client/core/auth.guard'; import { internalRoutes } from '@ghostfolio/common/routes/routes'; @@ -34,6 +35,11 @@ const routes: Routes = [ component: HomeMarketComponent, title: internalRoutes.home.subRoutes.markets.title }, + { + path: internalRoutes.home.subRoutes.markets.path + '-new', // TODO + component: MarketsComponent, + title: internalRoutes.home.subRoutes.markets.title + }, { path: internalRoutes.home.subRoutes.watchlist.path, component: HomeWatchlistComponent, diff --git a/apps/client/src/app/pages/home/home-page.component.ts b/apps/client/src/app/pages/home/home-page.component.ts index ad9a65a4c..a95e92584 100644 --- a/apps/client/src/app/pages/home/home-page.component.ts +++ b/apps/client/src/app/pages/home/home-page.component.ts @@ -58,6 +58,11 @@ export class HomePageComponent implements OnDestroy, OnInit { iconName: 'newspaper-outline', label: internalRoutes.home.subRoutes.markets.title, routerLink: internalRoutes.home.subRoutes.markets.routerLink + }, + { + iconName: 'newspaper-outline', + label: internalRoutes.home.subRoutes.markets.title + ' (new)', + routerLink: ['/home', 'markets-new'] // TODO } ]; diff --git a/apps/client/src/app/services/data.service.ts b/apps/client/src/app/services/data.service.ts index 3ae0971c5..504f9dce7 100644 --- a/apps/client/src/app/services/data.service.ts +++ b/apps/client/src/app/services/data.service.ts @@ -38,6 +38,7 @@ import { InfoItem, LookupResponse, MarketDataDetailsResponse, + MarketDataOfMarketsResponse, OAuthResponse, PortfolioDetails, PortfolioDividends, @@ -483,6 +484,32 @@ export class DataService { ); } + public fetchMarketDataOfMarkets({ + includeHistoricalData + }: { + includeHistoricalData?: number; + }): Observable { + let params = new HttpParams(); + + if (includeHistoricalData) { + params = params.append('includeHistoricalData', includeHistoricalData); + } + + return this.http.get('/api/v1/market-data/markets', { params }).pipe( + map((data) => { + for (const item of data.CRYPTOCURRENCIES.historicalData) { + item.date = parseISO(item.date); + } + + for (const item of data.STOCKS.historicalData) { + item.date = parseISO(item.date); + } + + return data; + }) + ); + } + public fetchSymbolItem({ dataSource, includeHistoricalData, diff --git a/libs/common/src/lib/interfaces/index.ts b/libs/common/src/lib/interfaces/index.ts index e7a0e7f76..611a5c963 100644 --- a/libs/common/src/lib/interfaces/index.ts +++ b/libs/common/src/lib/interfaces/index.ts @@ -51,6 +51,7 @@ import type { HistoricalResponse } from './responses/historical-response.interfa import type { ImportResponse } from './responses/import-response.interface'; import type { LookupResponse } from './responses/lookup-response.interface'; import type { MarketDataDetailsResponse } from './responses/market-data-details-response.interface'; +import type { MarketDataOfMarketsResponse } from './responses/market-data-of-markets-response.interface'; import type { OAuthResponse } from './responses/oauth-response.interface'; import { PortfolioHoldingResponse } from './responses/portfolio-holding-response.interface'; import type { PortfolioHoldingsResponse } from './responses/portfolio-holdings-response.interface'; @@ -111,6 +112,7 @@ export { LookupItem, LookupResponse, MarketDataDetailsResponse, + MarketDataOfMarketsResponse, OAuthResponse, PortfolioChart, PortfolioDetails, diff --git a/libs/common/src/lib/interfaces/responses/market-data-of-markets-response.interface.ts b/libs/common/src/lib/interfaces/responses/market-data-of-markets-response.interface.ts new file mode 100644 index 000000000..198f3e7a9 --- /dev/null +++ b/libs/common/src/lib/interfaces/responses/market-data-of-markets-response.interface.ts @@ -0,0 +1,6 @@ +import { SymbolItem } from '@ghostfolio/api/app/symbol/interfaces/symbol-item.interface'; + +export interface MarketDataOfMarketsResponse { + CRYPTOCURRENCIES: SymbolItem; + STOCKS: SymbolItem; +} diff --git a/libs/common/src/lib/permissions.ts b/libs/common/src/lib/permissions.ts index 1ad0bd760..919f51c99 100644 --- a/libs/common/src/lib/permissions.ts +++ b/libs/common/src/lib/permissions.ts @@ -40,6 +40,7 @@ export const permissions = { impersonateAllUsers: 'impersonateAllUsers', readAiPrompt: 'readAiPrompt', readMarketData: 'readMarketData', + readMarketDataOfMarkets: 'readMarketDataOfMarkets', // TODO readMarketDataOfOwnAssetProfile: 'readMarketDataOfOwnAssetProfile', readPlatforms: 'readPlatforms', readTags: 'readTags', @@ -87,6 +88,7 @@ export function getPermissions(aRole: Role): string[] { permissions.deleteUser, permissions.readAiPrompt, permissions.readMarketData, + permissions.readMarketDataOfMarkets, permissions.readMarketDataOfOwnAssetProfile, permissions.readPlatforms, permissions.readTags, diff --git a/libs/common/src/lib/types/fear-and-greed-index.type.ts b/libs/common/src/lib/types/fear-and-greed-index.type.ts new file mode 100644 index 000000000..0dc6655a8 --- /dev/null +++ b/libs/common/src/lib/types/fear-and-greed-index.type.ts @@ -0,0 +1 @@ +export type FearAndGreedIndexMode = 'CRYPTOCURRENCIES' | 'STOCKS'; diff --git a/libs/common/src/lib/types/index.ts b/libs/common/src/lib/types/index.ts index 8ffd345db..903d9c96a 100644 --- a/libs/common/src/lib/types/index.ts +++ b/libs/common/src/lib/types/index.ts @@ -6,6 +6,7 @@ import type { AiPromptMode } from './ai-prompt-mode.type'; import type { BenchmarkTrend } from './benchmark-trend.type'; import type { ColorScheme } from './color-scheme.type'; import type { DateRange } from './date-range.type'; +import type { FearAndGreedIndexMode } from './fear-and-greed-index.type'; import type { Granularity } from './granularity.type'; import type { GroupBy } from './group-by.type'; import type { HoldingType } from './holding-type.type'; @@ -30,6 +31,7 @@ export type { BenchmarkTrend, ColorScheme, DateRange, + FearAndGreedIndexMode, Granularity, GroupBy, HoldingType,