Browse Source

Add quotes endpoint

pull/4016/head
Thomas Kaul 10 months ago
parent
commit
9610e1e627
  1. 10
      apps/api/src/app/endpoints/data-providers/ghostfolio/get-quotes.dto.ts
  2. 41
      apps/api/src/app/endpoints/data-providers/ghostfolio/ghostfolio.controller.ts
  3. 93
      apps/api/src/app/endpoints/data-providers/ghostfolio/ghostfolio.service.ts
  4. 20
      apps/client/src/app/pages/api/api-page.component.ts
  5. 21
      apps/client/src/app/pages/api/api-page.html
  6. 2
      libs/common/src/lib/interfaces/index.ts
  7. 5
      libs/common/src/lib/interfaces/responses/quotes-response.interface.ts

10
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[];
}

41
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 { PROPERTY_DATA_SOURCES_GHOSTFOLIO_DATA_PROVIDER_MAX_REQUESTS } from '@ghostfolio/common/config';
import { import {
DataProviderGhostfolioStatusResponse, DataProviderGhostfolioStatusResponse,
LookupResponse LookupResponse,
QuotesResponse
} from '@ghostfolio/common/interfaces'; } from '@ghostfolio/common/interfaces';
import { permissions } from '@ghostfolio/common/permissions'; import { permissions } from '@ghostfolio/common/permissions';
import { RequestWithUser } from '@ghostfolio/common/types'; import { RequestWithUser } from '@ghostfolio/common/types';
@ -22,6 +23,7 @@ import { REQUEST } from '@nestjs/core';
import { AuthGuard } from '@nestjs/passport'; import { AuthGuard } from '@nestjs/passport';
import { getReasonPhrase, StatusCodes } from 'http-status-codes'; import { getReasonPhrase, StatusCodes } from 'http-status-codes';
import { GetQuotesDto } from './get-quotes.dto';
import { GhostfolioService } from './ghostfolio.service'; import { GhostfolioService } from './ghostfolio.service';
@Controller('data-providers/ghostfolio') @Controller('data-providers/ghostfolio')
@ -33,6 +35,8 @@ export class GhostfolioController {
@Inject(REQUEST) private readonly request: RequestWithUser @Inject(REQUEST) private readonly request: RequestWithUser
) {} ) {}
// TODO: Get historical
@Get('lookup') @Get('lookup')
@HasPermission(permissions.enableDataProviderGhostfolio) @HasPermission(permissions.enableDataProviderGhostfolio)
@UseGuards(AuthGuard('jwt'), HasPermissionGuard) @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<QuotesResponse> {
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') @Get('status')
@HasPermission(permissions.enableDataProviderGhostfolio) @HasPermission(permissions.enableDataProviderGhostfolio)
@UseGuards(AuthGuard('jwt'), HasPermissionGuard) @UseGuards(AuthGuard('jwt'), HasPermissionGuard)

93
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 { ConfigurationService } from '@ghostfolio/api/services/configuration/configuration.service';
import { DataProviderService } from '@ghostfolio/api/services/data-provider/data-provider.service'; import { DataProviderService } from '@ghostfolio/api/services/data-provider/data-provider.service';
import {
DEFAULT_CURRENCY,
DERIVED_CURRENCIES
} from '@ghostfolio/common/config';
import { import {
DataProviderInfo, DataProviderInfo,
LookupItem, LookupItem,
LookupResponse LookupResponse,
QuotesResponse
} from '@ghostfolio/common/interfaces'; } from '@ghostfolio/common/interfaces';
import { Injectable, Logger } from '@nestjs/common'; import { Injectable, Logger } from '@nestjs/common';
import { DataSource } from '@prisma/client'; import { DataSource } from '@prisma/client';
import Big = require('big.js');
@Injectable() @Injectable()
export class GhostfolioService { export class GhostfolioService {
public constructor( public constructor(
@ -16,6 +23,90 @@ export class GhostfolioService {
private readonly dataProviderService: DataProviderService private readonly dataProviderService: DataProviderService
) {} ) {}
public async getQuotes({
requestTimeout,
symbols
}: {
requestTimeout?: number;
symbols: string[];
}) {
const promises: Promise<any>[] = [];
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({ public async lookup({
includeIndices = false, includeIndices = false,
query query

20
apps/client/src/app/pages/api/api-page.component.ts

@ -1,6 +1,7 @@
import { import {
DataProviderGhostfolioStatusResponse, DataProviderGhostfolioStatusResponse,
LookupResponse LookupResponse,
QuotesResponse
} from '@ghostfolio/common/interfaces'; } from '@ghostfolio/common/interfaces';
import { CommonModule } from '@angular/common'; import { CommonModule } from '@angular/common';
@ -17,6 +18,7 @@ import { map, Observable, Subject, takeUntil } from 'rxjs';
templateUrl: './api-page.html' templateUrl: './api-page.html'
}) })
export class GfApiPageComponent implements OnInit { export class GfApiPageComponent implements OnInit {
public quotes$: Observable<QuotesResponse['quotes']>;
public status$: Observable<DataProviderGhostfolioStatusResponse>; public status$: Observable<DataProviderGhostfolioStatusResponse>;
public symbols$: Observable<LookupResponse['items']>; public symbols$: Observable<LookupResponse['items']>;
@ -25,6 +27,7 @@ export class GfApiPageComponent implements OnInit {
public constructor(private http: HttpClient) {} public constructor(private http: HttpClient) {}
public ngOnInit() { public ngOnInit() {
this.quotes$ = this.fetchQuotes({ symbols: ['AAPL.US', 'VOO.US'] });
this.status$ = this.fetchStatus(); this.status$ = this.fetchStatus();
this.symbols$ = this.fetchSymbols({ query: 'apple' }); this.symbols$ = this.fetchSymbols({ query: 'apple' });
} }
@ -34,6 +37,21 @@ export class GfApiPageComponent implements OnInit {
this.unsubscribeSubject.complete(); this.unsubscribeSubject.complete();
} }
private fetchQuotes({ symbols }: { symbols: string[] }) {
const params = new HttpParams().set('symbols', symbols.join(','));
return this.http
.get<QuotesResponse>('/api/v1/data-providers/ghostfolio/quotes', {
params
})
.pipe(
map(({ quotes }) => {
return quotes;
}),
takeUntil(this.unsubscribeSubject)
);
}
private fetchStatus() { private fetchStatus() {
return this.http return this.http
.get<DataProviderGhostfolioStatusResponse>( .get<DataProviderGhostfolioStatusResponse>(

21
apps/client/src/app/pages/api/api-page.html

@ -3,12 +3,29 @@
<h2 class="text-center">Status</h2> <h2 class="text-center">Status</h2>
<div>{{ status$ | async | json }}</div> <div>{{ status$ | async | json }}</div>
</div> </div>
<div> <div class="mb-3">
<h2 class="text-center">Lookup</h2> <h2 class="text-center">Lookup</h2>
<ul *ngIf="symbols$ | async as symbols"> @if (symbols$) {
@let symbols = symbols$ | async;
<ul>
@for (item of symbols; track item.symbol) { @for (item of symbols; track item.symbol) {
<li>{{ item.name }} ({{ item.symbol }})</li> <li>{{ item.name }} ({{ item.symbol }})</li>
} }
</ul> </ul>
}
</div>
<div>
<h2 class="text-center">Quotes</h2>
@if (quotes$) {
@let quotes = quotes$ | async;
<ul>
@for (quote of quotes | keyvalue; track quote) {
<li>
{{ quote.key }}: {{ quote.value.marketPrice }}
{{ quote.value.currency }}
</li>
}
</ul>
}
</div> </div>
</div> </div>

2
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 { PortfolioHoldingsResponse } from './responses/portfolio-holdings-response.interface';
import type { PortfolioPerformanceResponse } from './responses/portfolio-performance-response.interface'; import type { PortfolioPerformanceResponse } from './responses/portfolio-performance-response.interface';
import type { PublicPortfolioResponse } from './responses/public-portfolio-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 { ScraperConfiguration } from './scraper-configuration.interface';
import type { Statistics } from './statistics.interface'; import type { Statistics } from './statistics.interface';
import type { SubscriptionOffer } from './subscription-offer.interface'; import type { SubscriptionOffer } from './subscription-offer.interface';
@ -107,6 +108,7 @@ export {
Position, Position,
Product, Product,
PublicPortfolioResponse, PublicPortfolioResponse,
QuotesResponse,
ResponseError, ResponseError,
ScraperConfiguration, ScraperConfiguration,
Statistics, Statistics,

5
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 };
}
Loading…
Cancel
Save