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