From ae257f8acb2ea306eb21511879348236596dd5da Mon Sep 17 00:00:00 2001 From: Thomas Kaul <4159106+dtslvr@users.noreply.github.com> Date: Sat, 18 Jul 2026 09:26:38 +0200 Subject: [PATCH] Feature/support Fear & Greed index via Ghostfolio data provider in self-hosted environments (#7364) * Support Fear & Greed index via Ghostfolio data provider in self-hosted environments * Update changelog --- CHANGELOG.md | 1 + apps/api/src/app/symbol/symbol.service.ts | 6 ++ apps/api/src/app/user/user.service.ts | 7 +++ .../data-provider/data-provider.service.ts | 31 ++++++++-- .../ghostfolio/ghostfolio.service.ts | 62 ++++++++++++++++++- .../interfaces/data-provider.interface.ts | 13 +++- 6 files changed, 112 insertions(+), 8 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index a485db50b..045fc395d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Added +- Added support for the _Fear & Greed Index_ (market mood) via the `GHOSTFOLIO` data provider in self-hosted environments - Added a _Storybook_ story for the copy-to-clipboard functionality in the value component ### Changed diff --git a/apps/api/src/app/symbol/symbol.service.ts b/apps/api/src/app/symbol/symbol.service.ts index 2ace4feeb..98869797e 100644 --- a/apps/api/src/app/symbol/symbol.service.ts +++ b/apps/api/src/app/symbol/symbol.service.ts @@ -133,6 +133,12 @@ export class SymbolService { }: { includeHistoricalData: number; }): Promise { + if (await this.dataProviderService.isDataProviderGhostfolioConfigured()) { + return this.dataProviderService.getMarketDataOfMarkets({ + includeHistoricalData + }); + } + const [ marketDataFearAndGreedIndexCryptocurrencies, marketDataFearAndGreedIndexStocks diff --git a/apps/api/src/app/user/user.service.ts b/apps/api/src/app/user/user.service.ts index 4dcf16034..6304c81ea 100644 --- a/apps/api/src/app/user/user.service.ts +++ b/apps/api/src/app/user/user.service.ts @@ -29,6 +29,7 @@ import { DEFAULT_DATE_RANGE, DEFAULT_LANGUAGE_CODE, DEFAULT_LOCALE, + PROPERTY_API_KEY_GHOSTFOLIO, PROPERTY_IS_READ_ONLY_MODE, PROPERTY_REFERRAL_PARTNERS, PROPERTY_SYSTEM_MESSAGE, @@ -546,6 +547,12 @@ export class UserService { if (hasRole(user, Role.ADMIN)) { currentPermissions.push(permissions.syncDemoUserAccount); } + } else { + if ( + await this.propertyService.getByKey(PROPERTY_API_KEY_GHOSTFOLIO) + ) { + currentPermissions.push(permissions.readMarketDataOfMarkets); + } } if (this.configurationService.get('ENABLE_FEATURE_READ_ONLY_MODE')) { diff --git a/apps/api/src/services/data-provider/data-provider.service.ts b/apps/api/src/services/data-provider/data-provider.service.ts index 09bf1108e..35dc97d2f 100644 --- a/apps/api/src/services/data-provider/data-provider.service.ts +++ b/apps/api/src/services/data-provider/data-provider.service.ts @@ -27,7 +27,8 @@ import { DataProviderHistoricalResponse, DataProviderResponse, LookupItem, - LookupResponse + LookupResponse, + MarketDataOfMarketsResponse } from '@ghostfolio/common/interfaces'; import type { Granularity, UserWithSettings } from '@ghostfolio/common/types'; @@ -194,11 +195,7 @@ export class DataProviderService implements OnModuleInit { return DataSource[dataSource]; }); - const ghostfolioApiKey = await this.propertyService.getByKey( - PROPERTY_API_KEY_GHOSTFOLIO - ); - - if (ghostfolioApiKey) { + if (await this.isDataProviderGhostfolioConfigured()) { dataSources.push('GHOSTFOLIO'); } @@ -551,6 +548,22 @@ export class DataProviderService implements OnModuleInit { return result; } + public async getMarketDataOfMarkets({ + includeHistoricalData + }: { + includeHistoricalData: number; + }): Promise { + const dataProvider = this.getDataProvider(DataSource.GHOSTFOLIO); + + if (!dataProvider.getMarketDataOfMarkets) { + throw new Error( + `The data provider (${DataSource.GHOSTFOLIO}) does not support the market data of markets` + ); + } + + return dataProvider.getMarketDataOfMarkets({ includeHistoricalData }); + } + public async getQuotes({ items, requestTimeout, @@ -807,6 +820,12 @@ export class DataProviderService implements OnModuleInit { return response; } + public async isDataProviderGhostfolioConfigured(): Promise { + return !!(await this.propertyService.getByKey( + PROPERTY_API_KEY_GHOSTFOLIO + )); + } + public async search({ includeIndices = false, query, diff --git a/apps/api/src/services/data-provider/ghostfolio/ghostfolio.service.ts b/apps/api/src/services/data-provider/ghostfolio/ghostfolio.service.ts index 2b91855a6..81b997037 100644 --- a/apps/api/src/services/data-provider/ghostfolio/ghostfolio.service.ts +++ b/apps/api/src/services/data-provider/ghostfolio/ghostfolio.service.ts @@ -5,6 +5,7 @@ import { GetAssetProfileParams, GetDividendsParams, GetHistoricalParams, + GetMarketDataOfMarketsParams, GetQuotesParams, GetSearchParams } from '@ghostfolio/api/services/data-provider/interfaces/data-provider.interface'; @@ -23,7 +24,9 @@ import { DividendsResponse, HistoricalResponse, LookupResponse, - QuotesResponse + MarketDataOfMarketsResponse, + QuotesResponse, + SymbolItem } from '@ghostfolio/common/interfaces'; import { Injectable, Logger } from '@nestjs/common'; @@ -224,6 +227,63 @@ export class GhostfolioService implements DataProviderInterface { } } + public async getMarketDataOfMarkets({ + includeHistoricalData = 0, + requestTimeout = this.configurationService.get('REQUEST_TIMEOUT') + }: GetMarketDataOfMarketsParams): Promise { + let marketDataOfMarkets: MarketDataOfMarketsResponse = { + fearAndGreedIndex: { + CRYPTOCURRENCIES: {} as SymbolItem, + STOCKS: {} as SymbolItem + } + }; + + try { + const queryParams = new URLSearchParams({ + includeHistoricalData: includeHistoricalData.toString() + }); + + const response = await this.fetchService.fetch( + `${this.URL}/v1/data-providers/ghostfolio/markets?${queryParams.toString()}`, + { + headers: await this.getRequestHeaders(), + signal: AbortSignal.timeout(requestTimeout) + } + ); + + if (!response.ok) { + throw new Response(await response.text(), { + status: response.status, + statusText: response.statusText + }); + } + + marketDataOfMarkets = + (await response.json()) as MarketDataOfMarketsResponse; + } catch (error) { + let message = error; + + if (['AbortError', 'TimeoutError'].includes(error?.name)) { + message = `RequestError: The operation to get the market data of markets was aborted because the request to the data provider took more than ${( + requestTimeout / 1000 + ).toFixed(3)} seconds`; + } else if (error?.status === StatusCodes.TOO_MANY_REQUESTS) { + message = 'RequestError: The daily request limit has been exceeded'; + } else if ( + [StatusCodes.FORBIDDEN, StatusCodes.UNAUTHORIZED].includes( + error?.status + ) + ) { + message = + 'RequestError: The API key is invalid. Please update it in the Settings section of the Admin Control panel.'; + } + + this.logger.error(message); + } + + return marketDataOfMarkets; + } + public getMaxNumberOfSymbolsPerRequest() { return 20; } diff --git a/apps/api/src/services/data-provider/interfaces/data-provider.interface.ts b/apps/api/src/services/data-provider/interfaces/data-provider.interface.ts index 5002fa87e..8c2fb64d8 100644 --- a/apps/api/src/services/data-provider/interfaces/data-provider.interface.ts +++ b/apps/api/src/services/data-provider/interfaces/data-provider.interface.ts @@ -2,7 +2,8 @@ import { DataProviderHistoricalResponse, DataProviderInfo, DataProviderResponse, - LookupResponse + LookupResponse, + MarketDataOfMarketsResponse } from '@ghostfolio/common/interfaces'; import { Granularity } from '@ghostfolio/common/types'; @@ -37,6 +38,11 @@ export interface DataProviderInterface { [symbol: string]: { [date: string]: DataProviderHistoricalResponse }; }>; // TODO: Return only one symbol + getMarketDataOfMarkets?({ + includeHistoricalData, + requestTimeout + }: GetMarketDataOfMarketsParams): Promise; + getMaxNumberOfSymbolsPerRequest?(): number; getName(): DataSource; @@ -72,6 +78,11 @@ export interface GetHistoricalParams { to: Date; } +export interface GetMarketDataOfMarketsParams { + includeHistoricalData?: number; + requestTimeout?: number; +} + export interface GetQuotesParams { requestTimeout?: number; symbols: string[];