diff --git a/apps/api/src/app/endpoints/data-providers/ghostfolio/get-historical.dto.ts b/apps/api/src/app/endpoints/data-providers/ghostfolio/get-historical.dto.ts new file mode 100644 index 000000000..db1cd7a36 --- /dev/null +++ b/apps/api/src/app/endpoints/data-providers/ghostfolio/get-historical.dto.ts @@ -0,0 +1,9 @@ +import { IsISO8601 } from 'class-validator'; + +export class GetHistoricalDto { + @IsISO8601() + from: string; + + @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 6a85b9252..ddb48ef50 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 @@ -1,7 +1,9 @@ import { HasPermission } from '@ghostfolio/api/decorators/has-permission.decorator'; import { HasPermissionGuard } from '@ghostfolio/api/guards/has-permission.guard'; +import { parseDate } from '@ghostfolio/common/helper'; import { DataProviderGhostfolioStatusResponse, + HistoricalResponse, LookupResponse, QuotesResponse } from '@ghostfolio/common/interfaces'; @@ -13,6 +15,7 @@ import { Get, HttpException, Inject, + Param, Query, UseGuards } from '@nestjs/common'; @@ -20,6 +23,7 @@ import { REQUEST } from '@nestjs/core'; import { AuthGuard } from '@nestjs/passport'; import { getReasonPhrase, StatusCodes } from 'http-status-codes'; +import { GetHistoricalDto } from './get-historical.dto'; import { GetQuotesDto } from './get-quotes.dto'; import { GhostfolioService } from './ghostfolio.service'; @@ -30,7 +34,43 @@ export class GhostfolioController { @Inject(REQUEST) private readonly request: RequestWithUser ) {} - // TODO: Get historical + @Get('historical/:symbol') + @HasPermission(permissions.enableDataProviderGhostfolio) + @UseGuards(AuthGuard('jwt'), HasPermissionGuard) + public async getHistorical( + @Param('symbol') symbol: string, + @Query() query: GetHistoricalDto + ): 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 historicalData = await this.ghostfolioService.getHistorical({ + symbol, + from: parseDate(query.from), + to: parseDate(query.to) + }); + + await this.ghostfolioService.incrementDailyRequests({ + userId: this.request.user.id + }); + + return historicalData; + } catch { + throw new HttpException( + getReasonPhrase(StatusCodes.INTERNAL_SERVER_ERROR), + StatusCodes.INTERNAL_SERVER_ERROR + ); + } + } @Get('lookup') @HasPermission(permissions.enableDataProviderGhostfolio) 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 0b818cc0a..aa618abb1 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,5 +1,6 @@ import { ConfigurationService } from '@ghostfolio/api/services/configuration/configuration.service'; import { DataProviderService } from '@ghostfolio/api/services/data-provider/data-provider.service'; +import { IDataProviderHistoricalResponse } from '@ghostfolio/api/services/interfaces/interfaces'; import { PrismaService } from '@ghostfolio/api/services/prisma/prisma.service'; import { PropertyService } from '@ghostfolio/api/services/property/property.service'; import { @@ -9,6 +10,7 @@ import { import { PROPERTY_DATA_SOURCES_GHOSTFOLIO_DATA_PROVIDER_MAX_REQUESTS } from '@ghostfolio/common/config'; import { DataProviderInfo, + HistoricalResponse, LookupItem, LookupResponse, QuotesResponse @@ -27,6 +29,51 @@ export class GhostfolioService { private readonly propertyService: PropertyService ) {} + public async getHistorical({ + from, + requestTimeout, + to, + symbol + }: { + from: Date; + requestTimeout?: number; + symbol: string; + to: Date; + }) { + const result: HistoricalResponse = { historicalData: {} }; + + try { + const promises: Promise<{ + [symbol: string]: { [date: string]: IDataProviderHistoricalResponse }; + }>[] = []; + + for (const dataProviderService of this.getDataProviderServices()) { + promises.push( + dataProviderService + .getHistorical({ + from, + requestTimeout, + symbol, + to + }) + .then((historicalData) => { + result.historicalData = historicalData[symbol]; + + return historicalData; + }) + ); + } + + await Promise.all(promises); + + return result; + } catch (error) { + Logger.error(error, 'GhostfolioService'); + + throw error; + } + } + public async getMaxDailyRequests() { return parseInt( ((await this.propertyService.getByKey( 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 aeeed90cb..7b2d70aeb 100644 --- a/apps/client/src/app/pages/api/api-page.component.ts +++ b/apps/client/src/app/pages/api/api-page.component.ts @@ -1,5 +1,7 @@ +import { DATE_FORMAT } from '@ghostfolio/common/helper'; import { DataProviderGhostfolioStatusResponse, + HistoricalResponse, LookupResponse, QuotesResponse } from '@ghostfolio/common/interfaces'; @@ -7,6 +9,7 @@ import { import { CommonModule } from '@angular/common'; import { HttpClient, HttpParams } from '@angular/common/http'; import { Component, OnInit } from '@angular/core'; +import { format, startOfYear } from 'date-fns'; import { map, Observable, Subject, takeUntil } from 'rxjs'; @Component({ @@ -18,6 +21,7 @@ import { map, Observable, Subject, takeUntil } from 'rxjs'; templateUrl: './api-page.html' }) export class GfApiPageComponent implements OnInit { + public historicalData$: Observable; public quotes$: Observable; public status$: Observable; public symbols$: Observable; @@ -27,6 +31,7 @@ export class GfApiPageComponent implements OnInit { public constructor(private http: HttpClient) {} public ngOnInit() { + this.historicalData$ = this.fetchHistoricalData({ symbol: 'AAPL.US' }); this.quotes$ = this.fetchQuotes({ symbols: ['AAPL.US', 'VOO.US'] }); this.status$ = this.fetchStatus(); this.symbols$ = this.fetchSymbols({ query: 'apple' }); @@ -37,6 +42,24 @@ export class GfApiPageComponent implements OnInit { this.unsubscribeSubject.complete(); } + private fetchHistoricalData({ 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/historical/${symbol}`, + { params } + ) + .pipe( + map(({ historicalData }) => { + return historicalData; + }), + takeUntil(this.unsubscribeSubject) + ); + } + 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 854a95203..d7dca7fea 100644 --- a/apps/client/src/app/pages/api/api-page.html +++ b/apps/client/src/app/pages/api/api-page.html @@ -28,4 +28,21 @@ } +
+

Historical

+ @if (historicalData$) { + @let historicalData = historicalData$ | async; +
    + @for ( + historicalDataItem of historicalData | keyvalue; + track historicalDataItem + ) { +
  • + {{ historicalDataItem.key }}: + {{ historicalDataItem.value.marketPrice }} +
  • + } +
+ } +
diff --git a/libs/common/src/lib/interfaces/index.ts b/libs/common/src/lib/interfaces/index.ts index b5388cf15..380937949 100644 --- a/libs/common/src/lib/interfaces/index.ts +++ b/libs/common/src/lib/interfaces/index.ts @@ -42,6 +42,7 @@ import type { AccountBalancesResponse } from './responses/account-balances-respo import type { BenchmarkResponse } from './responses/benchmark-response.interface'; import type { DataProviderGhostfolioStatusResponse } from './responses/data-provider-ghostfolio-status-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'; import type { LookupResponse } from './responses/lookup-response.interface'; import type { OAuthResponse } from './responses/oauth-response.interface'; @@ -83,6 +84,7 @@ export { Filter, FilterGroup, HistoricalDataItem, + HistoricalResponse, Holding, HoldingWithParents, ImportResponse, diff --git a/libs/common/src/lib/interfaces/responses/historical-response.interface.ts b/libs/common/src/lib/interfaces/responses/historical-response.interface.ts new file mode 100644 index 000000000..12309a352 --- /dev/null +++ b/libs/common/src/lib/interfaces/responses/historical-response.interface.ts @@ -0,0 +1,7 @@ +import { IDataProviderHistoricalResponse } from '@ghostfolio/api/services/interfaces/interfaces'; + +export interface HistoricalResponse { + historicalData: { + [date: string]: IDataProviderHistoricalResponse; + }; +}