diff --git a/CHANGELOG.md b/CHANGELOG.md index a0a652c44..4586c668d 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 + +- Added the markets endpoint for the _Fear & Greed Index_ (market mood) to the `GHOSTFOLIO` data provider + ### Changed - Hardened the validation of the countries in the asset profile endpoints diff --git a/apps/api/src/app/endpoints/data-providers/ghostfolio/ghostfolio.controller.ts b/apps/api/src/app/endpoints/data-providers/ghostfolio/ghostfolio.controller.ts index 04165e9a1..5d6979acc 100644 --- a/apps/api/src/app/endpoints/data-providers/ghostfolio/ghostfolio.controller.ts +++ b/apps/api/src/app/endpoints/data-providers/ghostfolio/ghostfolio.controller.ts @@ -8,6 +8,7 @@ import { DividendsResponse, HistoricalResponse, LookupResponse, + MarketDataOfMarketsResponse, QuotesResponse } from '@ghostfolio/common/interfaces'; import { permissions } from '@ghostfolio/common/permissions'; @@ -19,6 +20,7 @@ import { HttpException, Inject, Param, + ParseIntPipe, Query, UseGuards, Version @@ -203,6 +205,43 @@ export class GhostfolioController { } } + @Get('markets') + @HasPermission(permissions.enableDataProviderGhostfolio) + @UseGuards(AuthGuard('api-key'), HasPermissionGuard) + public async getMarketDataOfMarkets( + @Query('includeHistoricalData', new ParseIntPipe({ optional: true })) + includeHistoricalData = 0 + ): Promise { + const maxDailyRequests = await this.ghostfolioService.getMaxDailyRequests(); + + if ( + this.request.user.dataProviderGhostfolioDailyRequests > maxDailyRequests + ) { + throw new HttpException( + getReasonPhrase(StatusCodes.TOO_MANY_REQUESTS), + StatusCodes.TOO_MANY_REQUESTS + ); + } + + try { + const marketDataOfMarkets = + await this.ghostfolioService.getMarketDataOfMarkets({ + includeHistoricalData + }); + + await this.ghostfolioService.incrementDailyRequests({ + userId: this.request.user.id + }); + + return marketDataOfMarkets; + } catch { + throw new HttpException( + getReasonPhrase(StatusCodes.INTERNAL_SERVER_ERROR), + StatusCodes.INTERNAL_SERVER_ERROR + ); + } + } + @Get('quotes') @HasPermission(permissions.enableDataProviderGhostfolio) @UseGuards(AuthGuard('api-key'), HasPermissionGuard) diff --git a/apps/api/src/app/endpoints/data-providers/ghostfolio/ghostfolio.module.ts b/apps/api/src/app/endpoints/data-providers/ghostfolio/ghostfolio.module.ts index 484f30ee3..1b8788ecb 100644 --- a/apps/api/src/app/endpoints/data-providers/ghostfolio/ghostfolio.module.ts +++ b/apps/api/src/app/endpoints/data-providers/ghostfolio/ghostfolio.module.ts @@ -1,4 +1,5 @@ import { RedisCacheModule } from '@ghostfolio/api/app/redis-cache/redis-cache.module'; +import { SymbolModule } from '@ghostfolio/api/app/symbol/symbol.module'; import { ConfigurationService } from '@ghostfolio/api/services/configuration/configuration.service'; import { CryptocurrencyModule } from '@ghostfolio/api/services/cryptocurrency/cryptocurrency.module'; import { AlphaVantageService } from '@ghostfolio/api/services/data-provider/alpha-vantage/alpha-vantage.service'; @@ -33,6 +34,7 @@ import { GhostfolioService } from './ghostfolio.service'; PrismaModule, PropertyModule, RedisCacheModule, + SymbolModule, SymbolProfileModule ], providers: [ diff --git a/apps/api/src/app/endpoints/data-providers/ghostfolio/ghostfolio.service.ts b/apps/api/src/app/endpoints/data-providers/ghostfolio/ghostfolio.service.ts index caff86ae0..abbb5cbf6 100644 --- a/apps/api/src/app/endpoints/data-providers/ghostfolio/ghostfolio.service.ts +++ b/apps/api/src/app/endpoints/data-providers/ghostfolio/ghostfolio.service.ts @@ -1,3 +1,4 @@ +import { SymbolService } from '@ghostfolio/api/app/symbol/symbol.service'; import { ConfigurationService } from '@ghostfolio/api/services/configuration/configuration.service'; import { DataProviderService } from '@ghostfolio/api/services/data-provider/data-provider.service'; import { GhostfolioService as GhostfolioDataProviderService } from '@ghostfolio/api/services/data-provider/ghostfolio/ghostfolio.service'; @@ -25,6 +26,7 @@ import { HistoricalResponse, LookupItem, LookupResponse, + MarketDataOfMarketsResponse, QuotesResponse } from '@ghostfolio/common/interfaces'; import { UserWithSettings } from '@ghostfolio/common/types'; @@ -32,6 +34,7 @@ import { UserWithSettings } from '@ghostfolio/common/types'; import { Injectable, Logger } from '@nestjs/common'; import { DataSource, SymbolProfile } from '@prisma/client'; import { Big } from 'big.js'; +import { isEmpty } from 'lodash'; @Injectable() export class GhostfolioService { @@ -42,7 +45,8 @@ export class GhostfolioService { private readonly dataProviderService: DataProviderService, private readonly fetchService: FetchService, private readonly prismaService: PrismaService, - private readonly propertyService: PropertyService + private readonly propertyService: PropertyService, + private readonly symbolService: SymbolService ) {} public async getAssetProfile({ symbol }: GetAssetProfileParams) { @@ -198,6 +202,33 @@ export class GhostfolioService { } } + public async getMarketDataOfMarkets({ + includeHistoricalData + }: { + includeHistoricalData: number; + }): Promise { + try { + const marketDataOfMarkets = + await this.symbolService.getMarketDataOfMarkets({ + includeHistoricalData + }); + + for (const symbolItem of Object.values( + marketDataOfMarkets.fearAndGreedIndex + )) { + if (!isEmpty(symbolItem)) { + symbolItem.dataSource = DataSource.GHOSTFOLIO; + } + } + + return marketDataOfMarkets; + } catch (error) { + this.logger.error(error); + + throw error; + } + } + public async getMaxDailyRequests() { return parseInt( (await this.propertyService.getByKey( 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 105bb0780..03d50c284 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,14 +1,8 @@ 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 { DataProviderService } from '@ghostfolio/api/services/data-provider/data-provider.service'; import { MarketDataService } from '@ghostfolio/api/services/market-data/market-data.service'; import { SymbolProfileService } from '@ghostfolio/api/services/symbol-profile/symbol-profile.service'; -import { - ghostfolioFearAndGreedIndexDataSourceCryptocurrencies, - ghostfolioFearAndGreedIndexSymbolCryptocurrencies, - ghostfolioFearAndGreedIndexSymbolStocks -} from '@ghostfolio/common/config'; import { UpdateBulkMarketDataDto } from '@ghostfolio/common/dtos'; import { getCurrencyFromSymbol, isCurrency } from '@ghostfolio/common/helper'; import { MarketDataOfMarketsResponse } from '@ghostfolio/common/interfaces'; @@ -36,7 +30,6 @@ import { getReasonPhrase, StatusCodes } from 'http-status-codes'; @Controller('market-data') export class MarketDataController { public constructor( - private readonly dataProviderService: DataProviderService, private readonly marketDataService: MarketDataService, @Inject(REQUEST) private readonly request: RequestWithUser, private readonly symbolProfileService: SymbolProfileService, @@ -50,39 +43,9 @@ export class MarketDataController { @Query('includeHistoricalData', new ParseIntPipe({ optional: true })) includeHistoricalData = 0 ): Promise { - const [ - marketDataFearAndGreedIndexCryptocurrencies, - marketDataFearAndGreedIndexStocks - ] = await Promise.all([ - this.symbolService.get({ - includeHistoricalData, - dataGatheringItem: { - dataSource: ghostfolioFearAndGreedIndexDataSourceCryptocurrencies, - symbol: ghostfolioFearAndGreedIndexSymbolCryptocurrencies - }, - useIntradayData: true - }), - this.symbolService.get({ - includeHistoricalData, - dataGatheringItem: { - dataSource: - this.dataProviderService.getDataSourceForFearAndGreedIndexStocks(), - symbol: ghostfolioFearAndGreedIndexSymbolStocks - }, - useIntradayData: true - }) - ]); - - return { - fearAndGreedIndex: { - CRYPTOCURRENCIES: { - ...marketDataFearAndGreedIndexCryptocurrencies - }, - STOCKS: { - ...marketDataFearAndGreedIndexStocks - } - } - }; + return this.symbolService.getMarketDataOfMarkets({ + includeHistoricalData + }); } @Post(':dataSource/:symbol') 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 47ce11502..1de10907b 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,5 +1,4 @@ import { SymbolModule } from '@ghostfolio/api/app/symbol/symbol.module'; -import { DataProviderModule } from '@ghostfolio/api/services/data-provider/data-provider.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'; @@ -9,11 +8,6 @@ import { MarketDataController } from './market-data.controller'; @Module({ controllers: [MarketDataController], - imports: [ - DataProviderModule, - MarketDataServiceModule, - SymbolModule, - SymbolProfileModule - ] + imports: [MarketDataServiceModule, SymbolModule, SymbolProfileModule] }) export class MarketDataModule {} diff --git a/apps/api/src/app/symbol/symbol.service.ts b/apps/api/src/app/symbol/symbol.service.ts index 39d279de1..2ace4feeb 100644 --- a/apps/api/src/app/symbol/symbol.service.ts +++ b/apps/api/src/app/symbol/symbol.service.ts @@ -1,6 +1,11 @@ import { DataProviderService } from '@ghostfolio/api/services/data-provider/data-provider.service'; import { DataGatheringItem } from '@ghostfolio/api/services/interfaces/interfaces'; import { MarketDataService } from '@ghostfolio/api/services/market-data/market-data.service'; +import { + ghostfolioFearAndGreedIndexDataSourceCryptocurrencies, + ghostfolioFearAndGreedIndexSymbolCryptocurrencies, + ghostfolioFearAndGreedIndexSymbolStocks +} from '@ghostfolio/common/config'; import { DATE_FORMAT, getAssetProfileIdentifier @@ -9,6 +14,7 @@ import { DataProviderHistoricalResponse, HistoricalDataItem, LookupResponse, + MarketDataOfMarketsResponse, SymbolItem } from '@ghostfolio/common/interfaces'; import { UserWithSettings } from '@ghostfolio/common/types'; @@ -122,6 +128,46 @@ export class SymbolService { }; } + public async getMarketDataOfMarkets({ + includeHistoricalData + }: { + includeHistoricalData: number; + }): Promise { + const [ + marketDataFearAndGreedIndexCryptocurrencies, + marketDataFearAndGreedIndexStocks + ] = await Promise.all([ + this.get({ + includeHistoricalData, + dataGatheringItem: { + dataSource: ghostfolioFearAndGreedIndexDataSourceCryptocurrencies, + symbol: ghostfolioFearAndGreedIndexSymbolCryptocurrencies + }, + useIntradayData: true + }), + this.get({ + includeHistoricalData, + dataGatheringItem: { + dataSource: + this.dataProviderService.getDataSourceForFearAndGreedIndexStocks(), + symbol: ghostfolioFearAndGreedIndexSymbolStocks + }, + useIntradayData: true + }) + ]); + + return { + fearAndGreedIndex: { + CRYPTOCURRENCIES: { + ...marketDataFearAndGreedIndexCryptocurrencies + }, + STOCKS: { + ...marketDataFearAndGreedIndexStocks + } + } + }; + } + public async lookup({ includeIndices = false, query, diff --git a/apps/client/src/app/pages/api/api-page.component.ts b/apps/client/src/app/pages/api/api-page.component.ts index 0f39c67d6..3373977cc 100644 --- a/apps/client/src/app/pages/api/api-page.component.ts +++ b/apps/client/src/app/pages/api/api-page.component.ts @@ -1,3 +1,4 @@ +import { GfFearAndGreedIndexComponent } from '@ghostfolio/client/components/fear-and-greed-index/fear-and-greed-index.component'; import { HEADER_KEY_SKIP_INTERCEPTOR, HEADER_KEY_TOKEN @@ -10,6 +11,7 @@ import { DividendsResponse, HistoricalResponse, LookupResponse, + MarketDataOfMarketsResponse, QuotesResponse } from '@ghostfolio/common/interfaces'; @@ -32,7 +34,12 @@ import { FetchFailure, FetchResult } from './interfaces/interfaces'; @Component({ host: { class: 'page' }, - imports: [CommonModule, MatCardModule, NgxSkeletonLoaderModule], + imports: [ + CommonModule, + GfFearAndGreedIndexComponent, + MatCardModule, + NgxSkeletonLoaderModule + ], selector: 'gf-api-page', styleUrls: ['./api-page.scss'], templateUrl: './api-page.html' @@ -48,6 +55,9 @@ export class GfApiPageComponent implements OnInit { >; protected isinLookupItems$: Observable>; protected lookupItems$: Observable>; + protected marketDataOfMarkets$: Observable< + FetchResult + >; protected quotes$: Observable>; protected status$: Observable< FetchResult @@ -68,6 +78,7 @@ export class GfApiPageComponent implements OnInit { this.historicalData$ = this.fetchHistoricalData({ symbol: 'AAPL' }); this.isinLookupItems$ = this.fetchLookupItems({ query: 'US0378331005' }); this.lookupItems$ = this.fetchLookupItems({ query: 'apple' }); + this.marketDataOfMarkets$ = this.fetchMarketDataOfMarkets(); this.quotes$ = this.fetchQuotes({ symbols: ['AAPL', 'VOO'] }); this.status$ = this.fetchStatus(); } @@ -172,6 +183,15 @@ export class GfApiPageComponent implements OnInit { ); } + private fetchMarketDataOfMarkets() { + return this.http + .get( + '/api/v1/data-providers/ghostfolio/markets', + { headers: this.getHeaders() } + ) + .pipe(this.catchFetchFailure(), takeUntilDestroyed(this.destroyRef)); + } + private fetchQuotes({ symbols }: { symbols: string[] }) { const params = new HttpParams().set('symbols', symbols.join(',')); diff --git a/apps/client/src/app/pages/api/api-page.html b/apps/client/src/app/pages/api/api-page.html index 07f5ec981..b2d9d03ac 100644 --- a/apps/client/src/app/pages/api/api-page.html +++ b/apps/client/src/app/pages/api/api-page.html @@ -168,6 +168,58 @@ } +
+ @let marketDataOfMarkets = marketDataOfMarkets$ | async; +
+ + + Markets + Stocks + + + @if (isFetchFailure(marketDataOfMarkets)) { + 🔴 {{ marketDataOfMarkets.fetchError }} + } @else if (marketDataOfMarkets) { + + } @else { + + } + + +
+
+ + + Markets + Cryptocurrencies + + + @if (isFetchFailure(marketDataOfMarkets)) { + 🔴 {{ marketDataOfMarkets.fetchError }} + } @else if (marketDataOfMarkets) { + + } @else { + + } + + +
+