mirror of https://github.com/ghostfolio/ghostfolio
Browse Source
* Set up Ghostfolio data provider * Update translations * Update changelogpull/4066/head
Thomas Kaul
2 months ago
committed by
GitHub
47 changed files with 2128 additions and 305 deletions
@ -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