From 2067e8ea403c5487ba8cd7b6652d0177944cbd86 Mon Sep 17 00:00:00 2001 From: Thomas Kaul <4159106+dtslvr@users.noreply.github.com> Date: Sat, 30 Nov 2024 11:49:24 +0100 Subject: [PATCH] Feature/add support for dividends in Ghostfolio data provider (#4081) * Add support for dividends --- .../ghostfolio/get-dividends.dto.ts | 15 ++++++ .../ghostfolio/ghostfolio.controller.ts | 41 +++++++++++++++ .../ghostfolio/ghostfolio.service.ts | 47 ++++++++++++++++- .../ghostfolio/ghostfolio.service.ts | 50 ++++++++++++++++++- .../interfaces/data-provider.interface.ts | 8 ++- .../src/app/pages/api/api-page.component.ts | 21 ++++++++ apps/client/src/app/pages/api/api-page.html | 14 ++++++ libs/common/src/lib/interfaces/index.ts | 2 + .../responses/dividends-response.interface.ts | 7 +++ 9 files changed, 201 insertions(+), 4 deletions(-) create mode 100644 apps/api/src/app/endpoints/data-providers/ghostfolio/get-dividends.dto.ts create mode 100644 libs/common/src/lib/interfaces/responses/dividends-response.interface.ts diff --git a/apps/api/src/app/endpoints/data-providers/ghostfolio/get-dividends.dto.ts b/apps/api/src/app/endpoints/data-providers/ghostfolio/get-dividends.dto.ts new file mode 100644 index 000000000..6df457c64 --- /dev/null +++ b/apps/api/src/app/endpoints/data-providers/ghostfolio/get-dividends.dto.ts @@ -0,0 +1,15 @@ +import { Granularity } from '@ghostfolio/common/types'; + +import { IsIn, IsISO8601, IsOptional } from 'class-validator'; + +export class GetDividendsDto { + @IsISO8601() + from: string; + + @IsIn(['day', 'month'] as Granularity[]) + @IsOptional() + granularity: Granularity; + + @IsISO8601() + to: string; +} 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 58a3224c1..3ccef28c8 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 @@ -3,6 +3,7 @@ import { HasPermissionGuard } from '@ghostfolio/api/guards/has-permission.guard' import { parseDate } from '@ghostfolio/common/helper'; import { DataProviderGhostfolioStatusResponse, + DividendsResponse, HistoricalResponse, LookupResponse, QuotesResponse @@ -23,6 +24,7 @@ import { REQUEST } from '@nestjs/core'; import { AuthGuard } from '@nestjs/passport'; import { getReasonPhrase, StatusCodes } from 'http-status-codes'; +import { GetDividendsDto } from './get-dividends.dto'; import { GetHistoricalDto } from './get-historical.dto'; import { GetQuotesDto } from './get-quotes.dto'; import { GhostfolioService } from './ghostfolio.service'; @@ -34,6 +36,45 @@ export class GhostfolioController { @Inject(REQUEST) private readonly request: RequestWithUser ) {} + @Get('dividends/:symbol') + @HasPermission(permissions.enableDataProviderGhostfolio) + @UseGuards(AuthGuard('jwt'), HasPermissionGuard) + public async getDividends( + @Param('symbol') symbol: string, + @Query() query: GetDividendsDto + ): 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 dividends = await this.ghostfolioService.getDividends({ + symbol, + from: parseDate(query.from), + granularity: query.granularity, + to: parseDate(query.to) + }); + + await this.ghostfolioService.incrementDailyRequests({ + userId: this.request.user.id + }); + + return dividends; + } catch { + throw new HttpException( + getReasonPhrase(StatusCodes.INTERNAL_SERVER_ERROR), + StatusCodes.INTERNAL_SERVER_ERROR + ); + } + } + @Get('historical/:symbol') @HasPermission(permissions.enableDataProviderGhostfolio) @UseGuards(AuthGuard('jwt'), HasPermissionGuard) 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 52baa10d6..875a13c98 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,6 +1,7 @@ import { ConfigurationService } from '@ghostfolio/api/services/configuration/configuration.service'; import { DataProviderService } from '@ghostfolio/api/services/data-provider/data-provider.service'; import { + GetDividendsParams, GetHistoricalParams, GetQuotesParams, GetSearchParams @@ -15,6 +16,7 @@ import { import { PROPERTY_DATA_SOURCES_GHOSTFOLIO_DATA_PROVIDER_MAX_REQUESTS } from '@ghostfolio/common/config'; import { DataProviderInfo, + DividendsResponse, HistoricalResponse, LookupItem, LookupResponse, @@ -34,6 +36,48 @@ export class GhostfolioService { private readonly propertyService: PropertyService ) {} + public async getDividends({ + from, + granularity, + requestTimeout = this.configurationService.get('REQUEST_TIMEOUT'), + symbol, + to + }: GetDividendsParams) { + const result: DividendsResponse = { dividends: {} }; + + try { + const promises: Promise<{ + [date: string]: IDataProviderHistoricalResponse; + }>[] = []; + + for (const dataProviderService of this.getDataProviderServices()) { + promises.push( + dataProviderService + .getDividends({ + from, + granularity, + requestTimeout, + symbol, + to + }) + .then((dividends) => { + result.dividends = dividends; + + return dividends; + }) + ); + } + + await Promise.all(promises); + + return result; + } catch (error) { + Logger.error(error, 'GhostfolioService'); + + throw error; + } + } + public async getHistorical({ from, granularity, @@ -86,10 +130,11 @@ export class GhostfolioService { } public async getQuotes({ requestTimeout, symbols }: GetQuotesParams) { - const promises: Promise[] = []; const results: QuotesResponse = { quotes: {} }; try { + const promises: Promise[] = []; + for (const dataProvider of this.getDataProviderServices()) { const maximumNumberOfSymbolsPerRequest = dataProvider.getMaxNumberOfSymbolsPerRequest?.() ?? 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 5be7d8312..25ffdc677 100644 --- a/apps/api/src/services/data-provider/ghostfolio/ghostfolio.service.ts +++ b/apps/api/src/services/data-provider/ghostfolio/ghostfolio.service.ts @@ -19,6 +19,7 @@ import { import { DATE_FORMAT } from '@ghostfolio/common/helper'; import { DataProviderInfo, + DividendsResponse, HistoricalResponse, LookupResponse, QuotesResponse @@ -71,8 +72,53 @@ export class GhostfolioService implements DataProviderInterface { }; } - public async getDividends({}: GetDividendsParams) { - return {}; + public async getDividends({ + from, + granularity = 'day', + requestTimeout = this.configurationService.get('REQUEST_TIMEOUT'), + symbol, + to + }: GetDividendsParams): Promise<{ + [date: string]: IDataProviderHistoricalResponse; + }> { + let response: { + [date: string]: IDataProviderHistoricalResponse; + } = {}; + + try { + const abortController = new AbortController(); + + setTimeout(() => { + abortController.abort(); + }, requestTimeout); + + const { dividends } = await got( + `${this.URL}/v1/data-providers/ghostfolio/dividends/${symbol}?from=${format(from, DATE_FORMAT)}&granularity=${granularity}&to=${format( + to, + DATE_FORMAT + )}`, + { + headers: await this.getRequestHeaders(), + // @ts-ignore + signal: abortController.signal + } + ).json(); + + response = dividends; + } catch (error) { + let message = error; + + if (error.response?.statusCode === StatusCodes.TOO_MANY_REQUESTS) { + message = 'RequestError: The daily request limit has been exceeded'; + } else if (error.response?.statusCode === StatusCodes.UNAUTHORIZED) { + message = + 'RequestError: The provided API key is invalid. Please update it in the Settings section of the Admin Control panel.'; + } + + Logger.error(message, 'GhostfolioService'); + } + + return response; } public async getHistorical({ 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 7352ce78a..e448b4e15 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 @@ -21,7 +21,13 @@ export interface DataProviderInterface { getDataProviderInfo(): DataProviderInfo; - getDividends({ from, granularity, symbol, to }: GetDividendsParams): Promise<{ + getDividends({ + from, + granularity, + requestTimeout, + symbol, + to + }: GetDividendsParams): Promise<{ [date: string]: IDataProviderHistoricalResponse; }>; 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 7b2d70aeb..aa176c0f0 100644 --- a/apps/client/src/app/pages/api/api-page.component.ts +++ b/apps/client/src/app/pages/api/api-page.component.ts @@ -1,6 +1,7 @@ import { DATE_FORMAT } from '@ghostfolio/common/helper'; import { DataProviderGhostfolioStatusResponse, + DividendsResponse, HistoricalResponse, LookupResponse, QuotesResponse @@ -21,6 +22,7 @@ import { map, Observable, Subject, takeUntil } from 'rxjs'; templateUrl: './api-page.html' }) export class GfApiPageComponent implements OnInit { + public dividends$: Observable; public historicalData$: Observable; public quotes$: Observable; public status$: Observable; @@ -31,6 +33,7 @@ export class GfApiPageComponent implements OnInit { public constructor(private http: HttpClient) {} public ngOnInit() { + this.dividends$ = this.fetchDividends({ symbol: 'KO' }); this.historicalData$ = this.fetchHistoricalData({ symbol: 'AAPL.US' }); this.quotes$ = this.fetchQuotes({ symbols: ['AAPL.US', 'VOO.US'] }); this.status$ = this.fetchStatus(); @@ -42,6 +45,24 @@ export class GfApiPageComponent implements OnInit { this.unsubscribeSubject.complete(); } + private fetchDividends({ symbol }: { symbol: string }) { + const params = new HttpParams() + .set('from', format(startOfYear(new Date()), DATE_FORMAT)) + .set('to', format(new Date(), DATE_FORMAT)); + + return this.http + .get( + `/api/v1/data-providers/ghostfolio/dividends/${symbol}`, + { params } + ) + .pipe( + map(({ dividends }) => { + return dividends; + }), + takeUntil(this.unsubscribeSubject) + ); + } + private fetchHistoricalData({ symbol }: { symbol: string }) { const params = new HttpParams() .set('from', format(startOfYear(new Date()), DATE_FORMAT)) diff --git a/apps/client/src/app/pages/api/api-page.html b/apps/client/src/app/pages/api/api-page.html index d7dca7fea..a1f286c07 100644 --- a/apps/client/src/app/pages/api/api-page.html +++ b/apps/client/src/app/pages/api/api-page.html @@ -45,4 +45,18 @@ } +
+

Dividends

+ @if (dividends$) { + @let dividends = dividends$ | async; +
    + @for (dividend of dividends | keyvalue; track dividend) { +
  • + {{ dividend.key }}: + {{ dividend.value.marketPrice }} +
  • + } +
+ } +
diff --git a/libs/common/src/lib/interfaces/index.ts b/libs/common/src/lib/interfaces/index.ts index 380937949..4d5ce66d0 100644 --- a/libs/common/src/lib/interfaces/index.ts +++ b/libs/common/src/lib/interfaces/index.ts @@ -41,6 +41,7 @@ import type { Product } from './product'; import type { AccountBalancesResponse } from './responses/account-balances-response.interface'; import type { BenchmarkResponse } from './responses/benchmark-response.interface'; import type { DataProviderGhostfolioStatusResponse } from './responses/data-provider-ghostfolio-status-response.interface'; +import type { DividendsResponse } from './responses/dividends-response.interface'; import type { ResponseError } from './responses/errors.interface'; import type { HistoricalResponse } from './responses/historical-response.interface'; import type { ImportResponse } from './responses/import-response.interface'; @@ -79,6 +80,7 @@ export { Coupon, DataProviderGhostfolioStatusResponse, DataProviderInfo, + DividendsResponse, EnhancedSymbolProfile, Export, Filter, diff --git a/libs/common/src/lib/interfaces/responses/dividends-response.interface.ts b/libs/common/src/lib/interfaces/responses/dividends-response.interface.ts new file mode 100644 index 000000000..f7cacf89a --- /dev/null +++ b/libs/common/src/lib/interfaces/responses/dividends-response.interface.ts @@ -0,0 +1,7 @@ +import { IDataProviderHistoricalResponse } from '@ghostfolio/api/services/interfaces/interfaces'; + +export interface DividendsResponse { + dividends: { + [date: string]: IDataProviderHistoricalResponse; + }; +}