diff --git a/CHANGELOG.md b/CHANGELOG.md index 385b67120..375b48603 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 + +- Extended the _Fear & Greed Index_ (market mood) in the markets overview by cryptocurrencies (experimental) + ### Changed - Made the `getByKey()` function generic in the property service 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..4843536da 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,20 @@ 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 { + ghostfolioFearAndGreedIndexDataSourceCryptocurrencies, + ghostfolioFearAndGreedIndexDataSourceStocks, + ghostfolioFearAndGreedIndexSymbolCryptocurrencies, + ghostfolioFearAndGreedIndexSymbolStocks +} 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 +26,7 @@ import { Inject, Param, Post, + Query, UseGuards } from '@nestjs/common'; import { REQUEST } from '@nestjs/core'; @@ -30,9 +43,48 @@ 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: ghostfolioFearAndGreedIndexDataSourceCryptocurrencies, + symbol: ghostfolioFearAndGreedIndexSymbolCryptocurrencies + } + }), + this.symbolService.get({ + includeHistoricalData, + dataGatheringItem: { + dataSource: ghostfolioFearAndGreedIndexDataSourceStocks, + symbol: ghostfolioFearAndGreedIndexSymbolStocks + } + }) + ]); + + return { + fearAndGreedIndex: { + 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/api/src/app/info/info.service.ts b/apps/api/src/app/info/info.service.ts index 6b6c40fbb..c31f601e3 100644 --- a/apps/api/src/app/info/info.service.ts +++ b/apps/api/src/app/info/info.service.ts @@ -14,7 +14,7 @@ import { PROPERTY_DEMO_USER_ID, PROPERTY_IS_READ_ONLY_MODE, PROPERTY_SLACK_COMMUNITY_USERS, - ghostfolioFearAndGreedIndexDataSource + ghostfolioFearAndGreedIndexDataSourceStocks } from '@ghostfolio/common/config'; import { DATE_FORMAT, @@ -54,10 +54,11 @@ export class InfoService { if (this.configurationService.get('ENABLE_FEATURE_FEAR_AND_GREED_INDEX')) { if (this.configurationService.get('ENABLE_FEATURE_SUBSCRIPTION')) { info.fearAndGreedDataSource = encodeDataSource( - ghostfolioFearAndGreedIndexDataSource + ghostfolioFearAndGreedIndexDataSourceStocks ); } else { - info.fearAndGreedDataSource = ghostfolioFearAndGreedIndexDataSource; + info.fearAndGreedDataSource = + ghostfolioFearAndGreedIndexDataSourceStocks; } globalPermissions.push(permissions.enableFearAndGreedIndex); diff --git a/apps/api/src/app/user/user.service.ts b/apps/api/src/app/user/user.service.ts index 3ad43301e..7b55210eb 100644 --- a/apps/api/src/app/user/user.service.ts +++ b/apps/api/src/app/user/user.service.ts @@ -419,6 +419,7 @@ export class UserService { if (!hasRole(user, Role.DEMO)) { currentPermissions.push(permissions.createApiKey); currentPermissions.push(permissions.enableDataProviderGhostfolio); + currentPermissions.push(permissions.readMarketDataOfMarkets); currentPermissions.push(permissions.reportDataGlitch); } diff --git a/apps/api/src/services/twitter-bot/twitter-bot.service.ts b/apps/api/src/services/twitter-bot/twitter-bot.service.ts index a17585c5b..ee951820d 100644 --- a/apps/api/src/services/twitter-bot/twitter-bot.service.ts +++ b/apps/api/src/services/twitter-bot/twitter-bot.service.ts @@ -2,7 +2,7 @@ import { SymbolService } from '@ghostfolio/api/app/symbol/symbol.service'; import { BenchmarkService } from '@ghostfolio/api/services/benchmark/benchmark.service'; import { ConfigurationService } from '@ghostfolio/api/services/configuration/configuration.service'; import { - ghostfolioFearAndGreedIndexDataSource, + ghostfolioFearAndGreedIndexDataSourceStocks, ghostfolioFearAndGreedIndexSymbol } from '@ghostfolio/common/config'; import { @@ -44,7 +44,7 @@ export class TwitterBotService { try { const symbolItem = await this.symbolService.get({ dataGatheringItem: { - dataSource: ghostfolioFearAndGreedIndexDataSource, + dataSource: ghostfolioFearAndGreedIndexDataSourceStocks, symbol: ghostfolioFearAndGreedIndexSymbol } }); 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..2dc1eb3d3 --- /dev/null +++ b/apps/client/src/app/components/markets/markets.component.ts @@ -0,0 +1,129 @@ +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, + MarketDataOfMarketsResponse, + ToggleOption, + User +} from '@ghostfolio/common/interfaces'; +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['fearAndGreedIndex']; + public fearLabel = $localize`Fear`; + public greedLabel = $localize`Greed`; + public historicalDataItems: HistoricalDataItem[]; + public fearAndGreedIndexMode: FearAndGreedIndexMode = 'STOCKS'; + public fearAndGreedIndexModeOptions: ToggleOption[] = [ + { label: $localize`Stocks`, value: 'STOCKS' }, + { label: $localize`Cryptocurrencies`, value: 'CRYPTOCURRENCIES' } + ]; + 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.userService.stateChanged + .pipe(takeUntil(this.unsubscribeSubject)) + .subscribe((state) => { + if (state?.user) { + this.user = state.user; + + this.changeDetectorRef.markForCheck(); + } + }); + } + + public ngOnInit() { + this.dataService + .fetchMarketDataOfMarkets({ includeHistoricalData: this.numberOfDays }) + .pipe(takeUntil(this.unsubscribeSubject)) + .subscribe(({ fearAndGreedIndex }) => { + this.fearAndGreedIndexData = fearAndGreedIndex; + + 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 + } + ]; + } + + 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..909b072ba --- /dev/null +++ b/apps/client/src/app/components/markets/markets.html @@ -0,0 +1,60 @@ +
+

Markets

+
+
+ @if (user?.settings?.isExperimentalFeatures) { +
+ +
+ } +
+ 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..06dbfdf14 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.marketsPremium.path, + component: MarketsComponent, + title: internalRoutes.home.subRoutes.marketsPremium.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..18e5e9759 100644 --- a/apps/client/src/app/pages/home/home-page.component.ts +++ b/apps/client/src/app/pages/home/home-page.component.ts @@ -1,6 +1,7 @@ import { ImpersonationStorageService } from '@ghostfolio/client/services/impersonation-storage.service'; import { UserService } from '@ghostfolio/client/services/user/user.service'; import { TabConfiguration, User } from '@ghostfolio/common/interfaces'; +import { hasPermission, permissions } from '@ghostfolio/common/permissions'; import { internalRoutes } from '@ghostfolio/common/routes/routes'; import { ChangeDetectorRef, Component, OnDestroy, OnInit } from '@angular/core'; @@ -33,6 +34,8 @@ export class HomePageComponent implements OnDestroy, OnInit { .pipe(takeUntil(this.unsubscribeSubject)) .subscribe((state) => { if (state?.user) { + this.user = state.user; + this.tabs = [ { iconName: 'analytics-outline', @@ -56,13 +59,21 @@ export class HomePageComponent implements OnDestroy, OnInit { }, { iconName: 'newspaper-outline', - label: internalRoutes.home.subRoutes.markets.title, - routerLink: internalRoutes.home.subRoutes.markets.routerLink + label: hasPermission( + this.user?.permissions, + permissions.readMarketDataOfMarkets + ) + ? internalRoutes.home.subRoutes.marketsPremium.title + : internalRoutes.home.subRoutes.markets.title, + routerLink: hasPermission( + this.user?.permissions, + permissions.readMarketDataOfMarkets + ) + ? internalRoutes.home.subRoutes.marketsPremium.routerLink + : internalRoutes.home.subRoutes.markets.routerLink } ]; - this.user = state.user; - this.changeDetectorRef.markForCheck(); } }); diff --git a/apps/client/src/app/services/data.service.ts b/apps/client/src/app/services/data.service.ts index 3ae0971c5..820ad5e3c 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,34 @@ 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.fearAndGreedIndex.CRYPTOCURRENCIES + ?.historicalData ?? []) { + item.date = parseISO(item.date); + } + + for (const item of data.fearAndGreedIndex.STOCKS?.historicalData ?? + []) { + item.date = parseISO(item.date); + } + + return data; + }) + ); + } + public fetchSymbolItem({ dataSource, includeHistoricalData, diff --git a/libs/common/src/lib/config.ts b/libs/common/src/lib/config.ts index 336235e58..4f3bbd77c 100644 --- a/libs/common/src/lib/config.ts +++ b/libs/common/src/lib/config.ts @@ -4,8 +4,12 @@ import ms from 'ms'; export const ghostfolioPrefix = 'GF'; export const ghostfolioScraperApiSymbolPrefix = `_${ghostfolioPrefix}_`; -export const ghostfolioFearAndGreedIndexDataSource = DataSource.RAPID_API; +export const ghostfolioFearAndGreedIndexDataSourceCryptocurrencies = + DataSource.MANUAL; +export const ghostfolioFearAndGreedIndexDataSourceStocks = DataSource.RAPID_API; export const ghostfolioFearAndGreedIndexSymbol = `${ghostfolioScraperApiSymbolPrefix}FEAR_AND_GREED_INDEX`; +export const ghostfolioFearAndGreedIndexSymbolCryptocurrencies = `${ghostfolioPrefix}_FEAR_AND_GREED_INDEX_CRYPTOCURRENCIES`; +export const ghostfolioFearAndGreedIndexSymbolStocks = `${ghostfolioPrefix}_FEAR_AND_GREED_INDEX_STOCKS`; export const locale = 'en-US'; 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..aecfbb28b --- /dev/null +++ b/libs/common/src/lib/interfaces/responses/market-data-of-markets-response.interface.ts @@ -0,0 +1,8 @@ +import { SymbolItem } from '@ghostfolio/api/app/symbol/interfaces/symbol-item.interface'; + +export interface MarketDataOfMarketsResponse { + fearAndGreedIndex: { + CRYPTOCURRENCIES: SymbolItem; + STOCKS: SymbolItem; + }; +} diff --git a/libs/common/src/lib/permissions.ts b/libs/common/src/lib/permissions.ts index 1ad0bd760..5bc8664b8 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', readMarketDataOfOwnAssetProfile: 'readMarketDataOfOwnAssetProfile', readPlatforms: 'readPlatforms', readTags: 'readTags', diff --git a/libs/common/src/lib/routes/routes.ts b/libs/common/src/lib/routes/routes.ts index 9c6461a75..9a75f0bc6 100644 --- a/libs/common/src/lib/routes/routes.ts +++ b/libs/common/src/lib/routes/routes.ts @@ -94,6 +94,11 @@ export const internalRoutes: Record = { routerLink: ['/home', 'markets'], title: $localize`Markets` }, + marketsPremium: { + path: 'markets-premium', + routerLink: ['/home', 'markets-premium'], + title: $localize`Markets` + }, summary: { path: 'summary', routerLink: ['/home', 'summary'], 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,