Feature/set up Ghostfolio data provider (#4016)
* Set up Ghostfolio data provider * Update translations * Update changelogpull/4066/head
parent
0bc52fd80e
commit
5f98dfa5d6
@ -0,0 +1,15 @@
|
|||||||
|
import { Granularity } from '@ghostfolio/common/types';
|
||||||
|
|
||||||
|
import { IsIn, IsISO8601, IsOptional } from 'class-validator';
|
||||||
|
|
||||||
|
export class GetHistoricalDto {
|
||||||
|
@IsISO8601()
|
||||||
|
from: string;
|
||||||
|
|
||||||
|
@IsIn(['day', 'month'] as Granularity[])
|
||||||
|
@IsOptional()
|
||||||
|
granularity: Granularity;
|
||||||
|
|
||||||
|
@IsISO8601()
|
||||||
|
to: string;
|
||||||
|
}
|
@ -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[];
|
||||||
|
}
|
@ -0,0 +1,158 @@
|
|||||||
|
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';
|
||||||
|
import { permissions } from '@ghostfolio/common/permissions';
|
||||||
|
import { RequestWithUser } from '@ghostfolio/common/types';
|
||||||
|
|
||||||
|
import {
|
||||||
|
Controller,
|
||||||
|
Get,
|
||||||
|
HttpException,
|
||||||
|
Inject,
|
||||||
|
Param,
|
||||||
|
Query,
|
||||||
|
UseGuards
|
||||||
|
} from '@nestjs/common';
|
||||||
|
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';
|
||||||
|
|
||||||
|
@Controller('data-providers/ghostfolio')
|
||||||
|
export class GhostfolioController {
|
||||||
|
public constructor(
|
||||||
|
private readonly ghostfolioService: GhostfolioService,
|
||||||
|
@Inject(REQUEST) private readonly request: RequestWithUser
|
||||||
|
) {}
|
||||||
|
|
||||||
|
@Get('historical/:symbol')
|
||||||
|
@HasPermission(permissions.enableDataProviderGhostfolio)
|
||||||
|
@UseGuards(AuthGuard('jwt'), HasPermissionGuard)
|
||||||
|
public async getHistorical(
|
||||||
|
@Param('symbol') symbol: string,
|
||||||
|
@Query() query: GetHistoricalDto
|
||||||
|
): Promise<HistoricalResponse> {
|
||||||
|
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),
|
||||||
|
granularity: query.granularity,
|
||||||
|
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)
|
||||||
|
@UseGuards(AuthGuard('jwt'), HasPermissionGuard)
|
||||||
|
public async lookupSymbol(
|
||||||
|
@Query('includeIndices') includeIndicesParam = 'false',
|
||||||
|
@Query('query') query = ''
|
||||||
|
): Promise<LookupResponse> {
|
||||||
|
const includeIndices = includeIndicesParam === 'true';
|
||||||
|
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 result = await this.ghostfolioService.lookup({
|
||||||
|
includeIndices,
|
||||||
|
query: query.toLowerCase()
|
||||||
|
});
|
||||||
|
|
||||||
|
await this.ghostfolioService.incrementDailyRequests({
|
||||||
|
userId: this.request.user.id
|
||||||
|
});
|
||||||
|
|
||||||
|
return result;
|
||||||
|
} catch {
|
||||||
|
throw new HttpException(
|
||||||
|
getReasonPhrase(StatusCodes.INTERNAL_SERVER_ERROR),
|
||||||
|
StatusCodes.INTERNAL_SERVER_ERROR
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Get('quotes')
|
||||||
|
@HasPermission(permissions.enableDataProviderGhostfolio)
|
||||||
|
@UseGuards(AuthGuard('jwt'), HasPermissionGuard)
|
||||||
|
public async getQuotes(
|
||||||
|
@Query() query: GetQuotesDto
|
||||||
|
): Promise<QuotesResponse> {
|
||||||
|
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 quotes = await this.ghostfolioService.getQuotes({
|
||||||
|
symbols: query.symbols
|
||||||
|
});
|
||||||
|
|
||||||
|
await this.ghostfolioService.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)
|
||||||
|
public async getStatus(): Promise<DataProviderGhostfolioStatusResponse> {
|
||||||
|
return {
|
||||||
|
dailyRequests: this.request.user.dataProviderGhostfolioDailyRequests,
|
||||||
|
dailyRequestsMax: await this.ghostfolioService.getMaxDailyRequests()
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
@ -0,0 +1,83 @@
|
|||||||
|
import { RedisCacheModule } from '@ghostfolio/api/app/redis-cache/redis-cache.module';
|
||||||
|
import { ConfigurationService } from '@ghostfolio/api/services/configuration/configuration.service';
|
||||||
|
import { CryptocurrencyModule } from '@ghostfolio/api/services/cryptocurrency/cryptocurrency.module';
|
||||||
|
import { AlphaVantageService } from '@ghostfolio/api/services/data-provider/alpha-vantage/alpha-vantage.service';
|
||||||
|
import { CoinGeckoService } from '@ghostfolio/api/services/data-provider/coingecko/coingecko.service';
|
||||||
|
import { YahooFinanceDataEnhancerService } from '@ghostfolio/api/services/data-provider/data-enhancer/yahoo-finance/yahoo-finance.service';
|
||||||
|
import { DataProviderModule } from '@ghostfolio/api/services/data-provider/data-provider.module';
|
||||||
|
import { DataProviderService } from '@ghostfolio/api/services/data-provider/data-provider.service';
|
||||||
|
import { EodHistoricalDataService } from '@ghostfolio/api/services/data-provider/eod-historical-data/eod-historical-data.service';
|
||||||
|
import { FinancialModelingPrepService } from '@ghostfolio/api/services/data-provider/financial-modeling-prep/financial-modeling-prep.service';
|
||||||
|
import { GoogleSheetsService } from '@ghostfolio/api/services/data-provider/google-sheets/google-sheets.service';
|
||||||
|
import { ManualService } from '@ghostfolio/api/services/data-provider/manual/manual.service';
|
||||||
|
import { RapidApiService } from '@ghostfolio/api/services/data-provider/rapid-api/rapid-api.service';
|
||||||
|
import { YahooFinanceService } from '@ghostfolio/api/services/data-provider/yahoo-finance/yahoo-finance.service';
|
||||||
|
import { MarketDataModule } from '@ghostfolio/api/services/market-data/market-data.module';
|
||||||
|
import { PrismaModule } from '@ghostfolio/api/services/prisma/prisma.module';
|
||||||
|
import { PropertyModule } from '@ghostfolio/api/services/property/property.module';
|
||||||
|
import { SymbolProfileModule } from '@ghostfolio/api/services/symbol-profile/symbol-profile.module';
|
||||||
|
|
||||||
|
import { Module } from '@nestjs/common';
|
||||||
|
|
||||||
|
import { GhostfolioController } from './ghostfolio.controller';
|
||||||
|
import { GhostfolioService } from './ghostfolio.service';
|
||||||
|
|
||||||
|
@Module({
|
||||||
|
controllers: [GhostfolioController],
|
||||||
|
imports: [
|
||||||
|
CryptocurrencyModule,
|
||||||
|
DataProviderModule,
|
||||||
|
MarketDataModule,
|
||||||
|
PrismaModule,
|
||||||
|
PropertyModule,
|
||||||
|
RedisCacheModule,
|
||||||
|
SymbolProfileModule
|
||||||
|
],
|
||||||
|
providers: [
|
||||||
|
AlphaVantageService,
|
||||||
|
CoinGeckoService,
|
||||||
|
ConfigurationService,
|
||||||
|
DataProviderService,
|
||||||
|
EodHistoricalDataService,
|
||||||
|
FinancialModelingPrepService,
|
||||||
|
GhostfolioService,
|
||||||
|
GoogleSheetsService,
|
||||||
|
ManualService,
|
||||||
|
RapidApiService,
|
||||||
|
YahooFinanceService,
|
||||||
|
YahooFinanceDataEnhancerService,
|
||||||
|
{
|
||||||
|
inject: [
|
||||||
|
AlphaVantageService,
|
||||||
|
CoinGeckoService,
|
||||||
|
EodHistoricalDataService,
|
||||||
|
FinancialModelingPrepService,
|
||||||
|
GoogleSheetsService,
|
||||||
|
ManualService,
|
||||||
|
RapidApiService,
|
||||||
|
YahooFinanceService
|
||||||
|
],
|
||||||
|
provide: 'DataProviderInterfaces',
|
||||||
|
useFactory: (
|
||||||
|
alphaVantageService,
|
||||||
|
coinGeckoService,
|
||||||
|
eodHistoricalDataService,
|
||||||
|
financialModelingPrepService,
|
||||||
|
googleSheetsService,
|
||||||
|
manualService,
|
||||||
|
rapidApiService,
|
||||||
|
yahooFinanceService
|
||||||
|
) => [
|
||||||
|
alphaVantageService,
|
||||||
|
coinGeckoService,
|
||||||
|
eodHistoricalDataService,
|
||||||
|
financialModelingPrepService,
|
||||||
|
googleSheetsService,
|
||||||
|
manualService,
|
||||||
|
rapidApiService,
|
||||||
|
yahooFinanceService
|
||||||
|
]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
})
|
||||||
|
export class GhostfolioModule {}
|
@ -0,0 +1,250 @@
|
|||||||
|
import { ConfigurationService } from '@ghostfolio/api/services/configuration/configuration.service';
|
||||||
|
import { DataProviderService } from '@ghostfolio/api/services/data-provider/data-provider.service';
|
||||||
|
import {
|
||||||
|
GetHistoricalParams,
|
||||||
|
GetQuotesParams,
|
||||||
|
GetSearchParams
|
||||||
|
} from '@ghostfolio/api/services/data-provider/interfaces/data-provider.interface';
|
||||||
|
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 {
|
||||||
|
DEFAULT_CURRENCY,
|
||||||
|
DERIVED_CURRENCIES
|
||||||
|
} from '@ghostfolio/common/config';
|
||||||
|
import { PROPERTY_DATA_SOURCES_GHOSTFOLIO_DATA_PROVIDER_MAX_REQUESTS } from '@ghostfolio/common/config';
|
||||||
|
import {
|
||||||
|
DataProviderInfo,
|
||||||
|
HistoricalResponse,
|
||||||
|
LookupItem,
|
||||||
|
LookupResponse,
|
||||||
|
QuotesResponse
|
||||||
|
} from '@ghostfolio/common/interfaces';
|
||||||
|
|
||||||
|
import { Injectable, Logger } from '@nestjs/common';
|
||||||
|
import { DataSource } from '@prisma/client';
|
||||||
|
import { Big } from 'big.js';
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class GhostfolioService {
|
||||||
|
public constructor(
|
||||||
|
private readonly configurationService: ConfigurationService,
|
||||||
|
private readonly dataProviderService: DataProviderService,
|
||||||
|
private readonly prismaService: PrismaService,
|
||||||
|
private readonly propertyService: PropertyService
|
||||||
|
) {}
|
||||||
|
|
||||||
|
public async getHistorical({
|
||||||
|
from,
|
||||||
|
granularity,
|
||||||
|
requestTimeout,
|
||||||
|
to,
|
||||||
|
symbol
|
||||||
|
}: GetHistoricalParams) {
|
||||||
|
const result: HistoricalResponse = { historicalData: {} };
|
||||||
|
|
||||||
|
try {
|
||||||
|
const promises: Promise<{
|
||||||
|
[symbol: string]: { [date: string]: IDataProviderHistoricalResponse };
|
||||||
|
}>[] = [];
|
||||||
|
|
||||||
|
for (const dataProviderService of this.getDataProviderServices()) {
|
||||||
|
promises.push(
|
||||||
|
dataProviderService
|
||||||
|
.getHistorical({
|
||||||
|
from,
|
||||||
|
granularity,
|
||||||
|
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(
|
||||||
|
PROPERTY_DATA_SOURCES_GHOSTFOLIO_DATA_PROVIDER_MAX_REQUESTS
|
||||||
|
)) as string) || '0',
|
||||||
|
10
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async getQuotes({ requestTimeout, symbols }: GetQuotesParams) {
|
||||||
|
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 incrementDailyRequests({ userId }: { userId: string }) {
|
||||||
|
await this.prismaService.analytics.update({
|
||||||
|
data: {
|
||||||
|
dataProviderGhostfolioDailyRequests: { increment: 1 },
|
||||||
|
lastRequestAt: new Date()
|
||||||
|
},
|
||||||
|
where: { userId }
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
public async lookup({
|
||||||
|
includeIndices = false,
|
||||||
|
query
|
||||||
|
}: GetSearchParams): Promise<LookupResponse> {
|
||||||
|
const results: LookupResponse = { items: [] };
|
||||||
|
|
||||||
|
if (!query) {
|
||||||
|
return results;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
let lookupItems: LookupItem[] = [];
|
||||||
|
const promises: Promise<{ items: LookupItem[] }>[] = [];
|
||||||
|
|
||||||
|
if (query?.length < 2) {
|
||||||
|
return { items: lookupItems };
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const dataProviderService of this.getDataProviderServices()) {
|
||||||
|
promises.push(
|
||||||
|
dataProviderService.search({
|
||||||
|
includeIndices,
|
||||||
|
query
|
||||||
|
})
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const searchResults = await Promise.all(promises);
|
||||||
|
|
||||||
|
for (const { items } of searchResults) {
|
||||||
|
if (items?.length > 0) {
|
||||||
|
lookupItems = lookupItems.concat(items);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const filteredItems = lookupItems
|
||||||
|
.filter(({ currency }) => {
|
||||||
|
// Only allow symbols with supported currency
|
||||||
|
return currency ? true : false;
|
||||||
|
})
|
||||||
|
.sort(({ name: name1 }, { name: name2 }) => {
|
||||||
|
return name1?.toLowerCase().localeCompare(name2?.toLowerCase());
|
||||||
|
})
|
||||||
|
.map((lookupItem) => {
|
||||||
|
lookupItem.dataProviderInfo = this.getDataProviderInfo();
|
||||||
|
lookupItem.dataSource = 'GHOSTFOLIO';
|
||||||
|
|
||||||
|
return lookupItem;
|
||||||
|
});
|
||||||
|
|
||||||
|
results.items = filteredItems;
|
||||||
|
return results;
|
||||||
|
} catch (error) {
|
||||||
|
Logger.error(error, 'GhostfolioService');
|
||||||
|
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private getDataProviderInfo(): DataProviderInfo {
|
||||||
|
return {
|
||||||
|
isPremium: false,
|
||||||
|
name: 'Ghostfolio Premium',
|
||||||
|
url: 'https://ghostfol.io'
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private getDataProviderServices() {
|
||||||
|
return this.configurationService
|
||||||
|
.get('DATA_SOURCES_GHOSTFOLIO_DATA_PROVIDER')
|
||||||
|
.map((dataSource) => {
|
||||||
|
return this.dataProviderService.getDataProvider(DataSource[dataSource]);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
@ -0,0 +1,221 @@
|
|||||||
|
import { environment } from '@ghostfolio/api/environments/environment';
|
||||||
|
import { ConfigurationService } from '@ghostfolio/api/services/configuration/configuration.service';
|
||||||
|
import {
|
||||||
|
DataProviderInterface,
|
||||||
|
GetDividendsParams,
|
||||||
|
GetHistoricalParams,
|
||||||
|
GetQuotesParams,
|
||||||
|
GetSearchParams
|
||||||
|
} from '@ghostfolio/api/services/data-provider/interfaces/data-provider.interface';
|
||||||
|
import {
|
||||||
|
IDataProviderHistoricalResponse,
|
||||||
|
IDataProviderResponse
|
||||||
|
} from '@ghostfolio/api/services/interfaces/interfaces';
|
||||||
|
import { PropertyService } from '@ghostfolio/api/services/property/property.service';
|
||||||
|
import {
|
||||||
|
HEADER_KEY_TOKEN,
|
||||||
|
PROPERTY_API_KEY_GHOSTFOLIO
|
||||||
|
} from '@ghostfolio/common/config';
|
||||||
|
import { DATE_FORMAT } from '@ghostfolio/common/helper';
|
||||||
|
import {
|
||||||
|
DataProviderInfo,
|
||||||
|
HistoricalResponse,
|
||||||
|
LookupResponse,
|
||||||
|
QuotesResponse
|
||||||
|
} from '@ghostfolio/common/interfaces';
|
||||||
|
|
||||||
|
import { Injectable, Logger } from '@nestjs/common';
|
||||||
|
import { DataSource, SymbolProfile } from '@prisma/client';
|
||||||
|
import { format } from 'date-fns';
|
||||||
|
import got from 'got';
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class GhostfolioService implements DataProviderInterface {
|
||||||
|
private apiKey: string;
|
||||||
|
private readonly URL = environment.production
|
||||||
|
? 'https://ghostfol.io/api'
|
||||||
|
: `${this.configurationService.get('ROOT_URL')}/api`;
|
||||||
|
|
||||||
|
public constructor(
|
||||||
|
private readonly configurationService: ConfigurationService,
|
||||||
|
private readonly propertyService: PropertyService
|
||||||
|
) {
|
||||||
|
void this.initialize();
|
||||||
|
}
|
||||||
|
|
||||||
|
public async initialize() {
|
||||||
|
this.apiKey = (await this.propertyService.getByKey(
|
||||||
|
PROPERTY_API_KEY_GHOSTFOLIO
|
||||||
|
)) as string;
|
||||||
|
}
|
||||||
|
|
||||||
|
public canHandle() {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
public async getAssetProfile({
|
||||||
|
symbol
|
||||||
|
}: {
|
||||||
|
symbol: string;
|
||||||
|
}): Promise<Partial<SymbolProfile>> {
|
||||||
|
const { items } = await this.search({ query: symbol });
|
||||||
|
const searchResult = items?.[0];
|
||||||
|
|
||||||
|
return {
|
||||||
|
symbol,
|
||||||
|
assetClass: searchResult?.assetClass,
|
||||||
|
assetSubClass: searchResult?.assetSubClass,
|
||||||
|
currency: searchResult?.currency,
|
||||||
|
dataSource: this.getName(),
|
||||||
|
name: searchResult?.name
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
public getDataProviderInfo(): DataProviderInfo {
|
||||||
|
return {
|
||||||
|
isPremium: true,
|
||||||
|
name: 'Ghostfolio',
|
||||||
|
url: 'https://ghostfo.io'
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
public async getDividends({}: GetDividendsParams) {
|
||||||
|
return {};
|
||||||
|
}
|
||||||
|
|
||||||
|
public async getHistorical({
|
||||||
|
from,
|
||||||
|
granularity = 'day',
|
||||||
|
requestTimeout = this.configurationService.get('REQUEST_TIMEOUT'),
|
||||||
|
symbol,
|
||||||
|
to
|
||||||
|
}: GetHistoricalParams): Promise<{
|
||||||
|
[symbol: string]: { [date: string]: IDataProviderHistoricalResponse };
|
||||||
|
}> {
|
||||||
|
try {
|
||||||
|
const abortController = new AbortController();
|
||||||
|
|
||||||
|
setTimeout(() => {
|
||||||
|
abortController.abort();
|
||||||
|
}, requestTimeout);
|
||||||
|
|
||||||
|
const { historicalData } = await got(
|
||||||
|
`${this.URL}/v1/data-providers/ghostfolio/historical/${symbol}?from=${format(from, DATE_FORMAT)}&granularity=${granularity}&to=${format(
|
||||||
|
to,
|
||||||
|
DATE_FORMAT
|
||||||
|
)}`,
|
||||||
|
{
|
||||||
|
headers: this.getRequestHeaders(),
|
||||||
|
// @ts-ignore
|
||||||
|
signal: abortController.signal
|
||||||
|
}
|
||||||
|
).json<HistoricalResponse>();
|
||||||
|
|
||||||
|
return {
|
||||||
|
[symbol]: historicalData
|
||||||
|
};
|
||||||
|
} catch (error) {
|
||||||
|
throw new Error(
|
||||||
|
`Could not get historical market data for ${symbol} (${this.getName()}) from ${format(
|
||||||
|
from,
|
||||||
|
DATE_FORMAT
|
||||||
|
)} to ${format(to, DATE_FORMAT)}: [${error.name}] ${error.message}`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public getMaxNumberOfSymbolsPerRequest() {
|
||||||
|
return 20;
|
||||||
|
}
|
||||||
|
|
||||||
|
public getName(): DataSource {
|
||||||
|
return DataSource.GHOSTFOLIO;
|
||||||
|
}
|
||||||
|
|
||||||
|
public async getQuotes({
|
||||||
|
requestTimeout = this.configurationService.get('REQUEST_TIMEOUT'),
|
||||||
|
symbols
|
||||||
|
}: GetQuotesParams): Promise<{
|
||||||
|
[symbol: string]: IDataProviderResponse;
|
||||||
|
}> {
|
||||||
|
let response: { [symbol: string]: IDataProviderResponse } = {};
|
||||||
|
|
||||||
|
if (symbols.length <= 0) {
|
||||||
|
return response;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const abortController = new AbortController();
|
||||||
|
|
||||||
|
setTimeout(() => {
|
||||||
|
abortController.abort();
|
||||||
|
}, requestTimeout);
|
||||||
|
|
||||||
|
const { quotes } = await got(
|
||||||
|
`${this.URL}/v1/data-providers/ghostfolio/quotes?symbols=${symbols.join(',')}`,
|
||||||
|
{
|
||||||
|
headers: this.getRequestHeaders(),
|
||||||
|
// @ts-ignore
|
||||||
|
signal: abortController.signal
|
||||||
|
}
|
||||||
|
).json<QuotesResponse>();
|
||||||
|
|
||||||
|
response = quotes;
|
||||||
|
} catch (error) {
|
||||||
|
let message = error;
|
||||||
|
|
||||||
|
if (error?.code === 'ABORT_ERR') {
|
||||||
|
message = `RequestError: The operation to get the quotes was aborted because the request to the data provider took more than ${(
|
||||||
|
this.configurationService.get('REQUEST_TIMEOUT') / 1000
|
||||||
|
).toFixed(3)} seconds`;
|
||||||
|
}
|
||||||
|
|
||||||
|
Logger.error(message, 'GhostfolioService');
|
||||||
|
}
|
||||||
|
|
||||||
|
return response;
|
||||||
|
}
|
||||||
|
|
||||||
|
public getTestSymbol() {
|
||||||
|
return 'AAPL.US';
|
||||||
|
}
|
||||||
|
|
||||||
|
public async search({ query }: GetSearchParams): Promise<LookupResponse> {
|
||||||
|
let searchResult: LookupResponse = { items: [] };
|
||||||
|
|
||||||
|
try {
|
||||||
|
const abortController = new AbortController();
|
||||||
|
|
||||||
|
setTimeout(() => {
|
||||||
|
abortController.abort();
|
||||||
|
}, this.configurationService.get('REQUEST_TIMEOUT'));
|
||||||
|
|
||||||
|
searchResult = await got(
|
||||||
|
`${this.URL}/v1/data-providers/ghostfolio/lookup?query=${query}`,
|
||||||
|
{
|
||||||
|
headers: this.getRequestHeaders(),
|
||||||
|
// @ts-ignore
|
||||||
|
signal: abortController.signal
|
||||||
|
}
|
||||||
|
).json<LookupResponse>();
|
||||||
|
} catch (error) {
|
||||||
|
let message = error;
|
||||||
|
|
||||||
|
if (error?.code === 'ABORT_ERR') {
|
||||||
|
message = `RequestError: The operation to search for ${query} was aborted because the request to the data provider took more than ${(
|
||||||
|
this.configurationService.get('REQUEST_TIMEOUT') / 1000
|
||||||
|
).toFixed(3)} seconds`;
|
||||||
|
}
|
||||||
|
|
||||||
|
Logger.error(message, 'GhostfolioService');
|
||||||
|
}
|
||||||
|
|
||||||
|
return searchResult;
|
||||||
|
}
|
||||||
|
|
||||||
|
private getRequestHeaders() {
|
||||||
|
return {
|
||||||
|
[HEADER_KEY_TOKEN]: `Bearer ${this.apiKey}`
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
@ -0,0 +1,110 @@
|
|||||||
|
import { DATE_FORMAT } from '@ghostfolio/common/helper';
|
||||||
|
import {
|
||||||
|
DataProviderGhostfolioStatusResponse,
|
||||||
|
HistoricalResponse,
|
||||||
|
LookupResponse,
|
||||||
|
QuotesResponse
|
||||||
|
} from '@ghostfolio/common/interfaces';
|
||||||
|
|
||||||
|
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({
|
||||||
|
host: { class: 'page' },
|
||||||
|
imports: [CommonModule],
|
||||||
|
selector: 'gf-api-page',
|
||||||
|
standalone: true,
|
||||||
|
styleUrls: ['./api-page.scss'],
|
||||||
|
templateUrl: './api-page.html'
|
||||||
|
})
|
||||||
|
export class GfApiPageComponent implements OnInit {
|
||||||
|
public historicalData$: Observable<HistoricalResponse['historicalData']>;
|
||||||
|
public quotes$: Observable<QuotesResponse['quotes']>;
|
||||||
|
public status$: Observable<DataProviderGhostfolioStatusResponse>;
|
||||||
|
public symbols$: Observable<LookupResponse['items']>;
|
||||||
|
|
||||||
|
private unsubscribeSubject = new Subject<void>();
|
||||||
|
|
||||||
|
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' });
|
||||||
|
}
|
||||||
|
|
||||||
|
public ngOnDestroy() {
|
||||||
|
this.unsubscribeSubject.next();
|
||||||
|
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<HistoricalResponse>(
|
||||||
|
`/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(','));
|
||||||
|
|
||||||
|
return this.http
|
||||||
|
.get<QuotesResponse>('/api/v1/data-providers/ghostfolio/quotes', {
|
||||||
|
params
|
||||||
|
})
|
||||||
|
.pipe(
|
||||||
|
map(({ quotes }) => {
|
||||||
|
return quotes;
|
||||||
|
}),
|
||||||
|
takeUntil(this.unsubscribeSubject)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
private fetchStatus() {
|
||||||
|
return this.http
|
||||||
|
.get<DataProviderGhostfolioStatusResponse>(
|
||||||
|
'/api/v1/data-providers/ghostfolio/status'
|
||||||
|
)
|
||||||
|
.pipe(takeUntil(this.unsubscribeSubject));
|
||||||
|
}
|
||||||
|
|
||||||
|
private fetchSymbols({
|
||||||
|
includeIndices = false,
|
||||||
|
query
|
||||||
|
}: {
|
||||||
|
includeIndices?: boolean;
|
||||||
|
query: string;
|
||||||
|
}) {
|
||||||
|
let params = new HttpParams().set('query', query);
|
||||||
|
|
||||||
|
if (includeIndices) {
|
||||||
|
params = params.append('includeIndices', includeIndices);
|
||||||
|
}
|
||||||
|
|
||||||
|
return this.http
|
||||||
|
.get<LookupResponse>('/api/v1/data-providers/ghostfolio/lookup', {
|
||||||
|
params
|
||||||
|
})
|
||||||
|
.pipe(
|
||||||
|
map(({ items }) => {
|
||||||
|
return items;
|
||||||
|
}),
|
||||||
|
takeUntil(this.unsubscribeSubject)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
@ -0,0 +1,48 @@
|
|||||||
|
<div class="container">
|
||||||
|
<div class="mb-3">
|
||||||
|
<h2 class="text-center">Status</h2>
|
||||||
|
<div>{{ status$ | async | json }}</div>
|
||||||
|
</div>
|
||||||
|
<div class="mb-3">
|
||||||
|
<h2 class="text-center">Lookup</h2>
|
||||||
|
@if (symbols$) {
|
||||||
|
@let symbols = symbols$ | async;
|
||||||
|
<ul>
|
||||||
|
@for (item of symbols; track item.symbol) {
|
||||||
|
<li>{{ item.name }} ({{ item.symbol }})</li>
|
||||||
|
}
|
||||||
|
</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>
|
||||||
|
<h2 class="text-center">Historical</h2>
|
||||||
|
@if (historicalData$) {
|
||||||
|
@let historicalData = historicalData$ | async;
|
||||||
|
<ul>
|
||||||
|
@for (
|
||||||
|
historicalDataItem of historicalData | keyvalue;
|
||||||
|
track historicalDataItem
|
||||||
|
) {
|
||||||
|
<li>
|
||||||
|
{{ historicalDataItem.key }}:
|
||||||
|
{{ historicalDataItem.value.marketPrice }}
|
||||||
|
</li>
|
||||||
|
}
|
||||||
|
</ul>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
</div>
|
@ -0,0 +1,3 @@
|
|||||||
|
:host {
|
||||||
|
display: block;
|
||||||
|
}
|
@ -0,0 +1,4 @@
|
|||||||
|
export interface DataProviderGhostfolioStatusResponse {
|
||||||
|
dailyRequests: number;
|
||||||
|
dailyRequestsMax: number;
|
||||||
|
}
|
@ -0,0 +1,7 @@
|
|||||||
|
import { IDataProviderHistoricalResponse } from '@ghostfolio/api/services/interfaces/interfaces';
|
||||||
|
|
||||||
|
export interface HistoricalResponse {
|
||||||
|
historicalData: {
|
||||||
|
[date: string]: IDataProviderHistoricalResponse;
|
||||||
|
};
|
||||||
|
}
|
@ -0,0 +1,5 @@
|
|||||||
|
import { IDataProviderResponse } from '@ghostfolio/api/services/interfaces/interfaces';
|
||||||
|
|
||||||
|
export interface QuotesResponse {
|
||||||
|
quotes: { [symbol: string]: IDataProviderResponse };
|
||||||
|
}
|
@ -0,0 +1,2 @@
|
|||||||
|
-- AlterEnum
|
||||||
|
ALTER TYPE "DataSource" ADD VALUE 'GHOSTFOLIO';
|
Loading…
Reference in new issue