From 9610e1e627aaabb3c68741591a3f689188218e57 Mon Sep 17 00:00:00 2001 From: Thomas Kaul <4159106+dtslvr@users.noreply.github.com> Date: Wed, 6 Nov 2024 20:42:39 +0100 Subject: [PATCH] Add quotes endpoint --- .../ghostfolio/get-quotes.dto.ts | 10 ++ .../ghostfolio/ghostfolio.controller.ts | 41 +++++++- .../ghostfolio/ghostfolio.service.ts | 93 ++++++++++++++++++- .../src/app/pages/api/api-page.component.ts | 20 +++- apps/client/src/app/pages/api/api-page.html | 29 ++++-- libs/common/src/lib/interfaces/index.ts | 2 + .../responses/quotes-response.interface.ts | 5 + 7 files changed, 191 insertions(+), 9 deletions(-) create mode 100644 apps/api/src/app/endpoints/data-providers/ghostfolio/get-quotes.dto.ts create mode 100644 libs/common/src/lib/interfaces/responses/quotes-response.interface.ts diff --git a/apps/api/src/app/endpoints/data-providers/ghostfolio/get-quotes.dto.ts b/apps/api/src/app/endpoints/data-providers/ghostfolio/get-quotes.dto.ts new file mode 100644 index 000000000..e83c1be82 --- /dev/null +++ b/apps/api/src/app/endpoints/data-providers/ghostfolio/get-quotes.dto.ts @@ -0,0 +1,10 @@ +import { Transform } from 'class-transformer'; +import { IsString } from 'class-validator'; + +export class GetQuotesDto { + @IsString({ each: true }) + @Transform(({ value }) => + typeof value === 'string' ? value.split(',') : value + ) + symbols: 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 bfd5d3006..47059cf2c 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 @@ -5,7 +5,8 @@ import { PropertyService } from '@ghostfolio/api/services/property/property.serv import { PROPERTY_DATA_SOURCES_GHOSTFOLIO_DATA_PROVIDER_MAX_REQUESTS } from '@ghostfolio/common/config'; import { DataProviderGhostfolioStatusResponse, - LookupResponse + LookupResponse, + QuotesResponse } from '@ghostfolio/common/interfaces'; import { permissions } from '@ghostfolio/common/permissions'; import { RequestWithUser } from '@ghostfolio/common/types'; @@ -22,6 +23,7 @@ import { REQUEST } from '@nestjs/core'; import { AuthGuard } from '@nestjs/passport'; import { getReasonPhrase, StatusCodes } from 'http-status-codes'; +import { GetQuotesDto } from './get-quotes.dto'; import { GhostfolioService } from './ghostfolio.service'; @Controller('data-providers/ghostfolio') @@ -33,6 +35,8 @@ export class GhostfolioController { @Inject(REQUEST) private readonly request: RequestWithUser ) {} + // TODO: Get historical + @Get('lookup') @HasPermission(permissions.enableDataProviderGhostfolio) @UseGuards(AuthGuard('jwt'), HasPermissionGuard) @@ -71,6 +75,41 @@ export class GhostfolioController { } } + @Get('quotes') + @HasPermission(permissions.enableDataProviderGhostfolio) + @UseGuards(AuthGuard('jwt'), HasPermissionGuard) + public async getQuotes( + @Query() query: GetQuotesDto + ): Promise { + const maxDailyRequests = await this.getMaxDailyRequests(); + + if ( + this.request.user.dataProviderGhostfolioDailyRequests > maxDailyRequests + ) { + throw new HttpException( + getReasonPhrase(StatusCodes.TOO_MANY_REQUESTS), + StatusCodes.TOO_MANY_REQUESTS + ); + } + + try { + const quotes = await this.ghostfolioService.getQuotes({ + symbols: query.symbols + }); + + await this.incrementDailyRequests({ + userId: this.request.user.id + }); + + return quotes; + } catch { + throw new HttpException( + getReasonPhrase(StatusCodes.INTERNAL_SERVER_ERROR), + StatusCodes.INTERNAL_SERVER_ERROR + ); + } + } + @Get('status') @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 75031d447..82bc68dee 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,14 +1,21 @@ import { ConfigurationService } from '@ghostfolio/api/services/configuration/configuration.service'; import { DataProviderService } from '@ghostfolio/api/services/data-provider/data-provider.service'; +import { + DEFAULT_CURRENCY, + DERIVED_CURRENCIES +} from '@ghostfolio/common/config'; import { DataProviderInfo, LookupItem, - LookupResponse + LookupResponse, + QuotesResponse } from '@ghostfolio/common/interfaces'; import { Injectable, Logger } from '@nestjs/common'; import { DataSource } from '@prisma/client'; +import Big = require('big.js'); + @Injectable() export class GhostfolioService { public constructor( @@ -16,6 +23,90 @@ export class GhostfolioService { private readonly dataProviderService: DataProviderService ) {} + public async getQuotes({ + requestTimeout, + symbols + }: { + requestTimeout?: number; + symbols: string[]; + }) { + const promises: Promise[] = []; + const results: QuotesResponse = { quotes: {} }; + + try { + for (const dataProvider of this.getDataProviderServices()) { + const maximumNumberOfSymbolsPerRequest = + dataProvider.getMaxNumberOfSymbolsPerRequest?.() ?? + Number.MAX_SAFE_INTEGER; + + for ( + let i = 0; + i < symbols.length; + i += maximumNumberOfSymbolsPerRequest + ) { + const symbolsChunk = symbols.slice( + i, + i + maximumNumberOfSymbolsPerRequest + ); + + const promise = Promise.resolve( + dataProvider.getQuotes({ requestTimeout, symbols: symbolsChunk }) + ); + + promises.push( + promise.then(async (result) => { + for (const [symbol, dataProviderResponse] of Object.entries( + result + )) { + dataProviderResponse.dataSource = 'GHOSTFOLIO'; + + if ( + [ + ...DERIVED_CURRENCIES.map(({ currency }) => { + return `${DEFAULT_CURRENCY}${currency}`; + }), + `${DEFAULT_CURRENCY}USX` + ].includes(symbol) + ) { + continue; + } + + results.quotes[symbol] = dataProviderResponse; + + for (const { + currency, + factor, + rootCurrency + } of DERIVED_CURRENCIES) { + if (symbol === `${DEFAULT_CURRENCY}${rootCurrency}`) { + results.quotes[`${DEFAULT_CURRENCY}${currency}`] = { + ...dataProviderResponse, + currency, + marketPrice: new Big( + result[`${DEFAULT_CURRENCY}${rootCurrency}`].marketPrice + ) + .mul(factor) + .toNumber(), + marketState: 'open' + }; + } + } + } + }) + ); + } + + await Promise.all(promises); + } + + return results; + } catch (error) { + Logger.error(error, 'GhostfolioService'); + + throw error; + } + } + 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 36fc9e823..aeeed90cb 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 { DataProviderGhostfolioStatusResponse, - LookupResponse + LookupResponse, + QuotesResponse } from '@ghostfolio/common/interfaces'; import { CommonModule } from '@angular/common'; @@ -17,6 +18,7 @@ import { map, Observable, Subject, takeUntil } from 'rxjs'; templateUrl: './api-page.html' }) export class GfApiPageComponent implements OnInit { + public quotes$: Observable; public status$: Observable; public symbols$: Observable; @@ -25,6 +27,7 @@ export class GfApiPageComponent implements OnInit { public constructor(private http: HttpClient) {} public ngOnInit() { + this.quotes$ = this.fetchQuotes({ symbols: ['AAPL.US', 'VOO.US'] }); this.status$ = this.fetchStatus(); this.symbols$ = this.fetchSymbols({ query: 'apple' }); } @@ -34,6 +37,21 @@ export class GfApiPageComponent implements OnInit { this.unsubscribeSubject.complete(); } + private fetchQuotes({ symbols }: { symbols: string[] }) { + const params = new HttpParams().set('symbols', symbols.join(',')); + + return this.http + .get('/api/v1/data-providers/ghostfolio/quotes', { + params + }) + .pipe( + map(({ quotes }) => { + return quotes; + }), + takeUntil(this.unsubscribeSubject) + ); + } + private fetchStatus() { return this.http .get( diff --git a/apps/client/src/app/pages/api/api-page.html b/apps/client/src/app/pages/api/api-page.html index df0e820e3..854a95203 100644 --- a/apps/client/src/app/pages/api/api-page.html +++ b/apps/client/src/app/pages/api/api-page.html @@ -3,12 +3,29 @@

Status

{{ status$ | async | json }}
-
+

Lookup

-
    - @for (item of symbols; track item.symbol) { -
  • {{ item.name }} ({{ item.symbol }})
  • - } -
+ @if (symbols$) { + @let symbols = symbols$ | async; +
    + @for (item of symbols; track item.symbol) { +
  • {{ item.name }} ({{ item.symbol }})
  • + } +
+ } +
+
+

Quotes

+ @if (quotes$) { + @let quotes = quotes$ | async; +
    + @for (quote of quotes | keyvalue; track quote) { +
  • + {{ quote.key }}: {{ quote.value.marketPrice }} + {{ quote.value.currency }} +
  • + } +
+ }
diff --git a/libs/common/src/lib/interfaces/index.ts b/libs/common/src/lib/interfaces/index.ts index aab58e600..b5388cf15 100644 --- a/libs/common/src/lib/interfaces/index.ts +++ b/libs/common/src/lib/interfaces/index.ts @@ -48,6 +48,7 @@ import type { OAuthResponse } from './responses/oauth-response.interface'; import type { PortfolioHoldingsResponse } from './responses/portfolio-holdings-response.interface'; import type { PortfolioPerformanceResponse } from './responses/portfolio-performance-response.interface'; import type { PublicPortfolioResponse } from './responses/public-portfolio-response.interface'; +import type { QuotesResponse } from './responses/quotes-response.interface'; import type { ScraperConfiguration } from './scraper-configuration.interface'; import type { Statistics } from './statistics.interface'; import type { SubscriptionOffer } from './subscription-offer.interface'; @@ -107,6 +108,7 @@ export { Position, Product, PublicPortfolioResponse, + QuotesResponse, ResponseError, ScraperConfiguration, Statistics, diff --git a/libs/common/src/lib/interfaces/responses/quotes-response.interface.ts b/libs/common/src/lib/interfaces/responses/quotes-response.interface.ts new file mode 100644 index 000000000..79c9d3024 --- /dev/null +++ b/libs/common/src/lib/interfaces/responses/quotes-response.interface.ts @@ -0,0 +1,5 @@ +import { IDataProviderResponse } from '@ghostfolio/api/services/interfaces/interfaces'; + +export interface QuotesResponse { + quotes: { [symbol: string]: IDataProviderResponse }; +}