|
|
@ -1,27 +1,27 @@ |
|
|
|
import { LookupItem } from '@ghostfolio/api/app/symbol/interfaces/lookup-item.interface'; |
|
|
|
import { CryptocurrencyService } from '@ghostfolio/api/services/cryptocurrency/cryptocurrency.service'; |
|
|
|
import { UNKNOWN_KEY, baseCurrency } from '@ghostfolio/common/config'; |
|
|
|
import { |
|
|
|
IDataProviderHistoricalResponse, |
|
|
|
IDataProviderResponse, |
|
|
|
MarketState |
|
|
|
} from '@ghostfolio/api/services/interfaces/interfaces'; |
|
|
|
import { baseCurrency } from '@ghostfolio/common/config'; |
|
|
|
import { DATE_FORMAT, isCurrency } from '@ghostfolio/common/helper'; |
|
|
|
import { Granularity } from '@ghostfolio/common/types'; |
|
|
|
import { Injectable, Logger } from '@nestjs/common'; |
|
|
|
import { AssetClass, AssetSubClass, DataSource } from '@prisma/client'; |
|
|
|
import { |
|
|
|
AssetClass, |
|
|
|
AssetSubClass, |
|
|
|
DataSource, |
|
|
|
SymbolProfile |
|
|
|
} from '@prisma/client'; |
|
|
|
import * as bent from 'bent'; |
|
|
|
import Big from 'big.js'; |
|
|
|
import { countries } from 'countries-list'; |
|
|
|
import { addDays, format, isSameDay } from 'date-fns'; |
|
|
|
import * as yahooFinance from 'yahoo-finance'; |
|
|
|
import yahooFinance2 from 'yahoo-finance2'; |
|
|
|
|
|
|
|
import { |
|
|
|
IDataProviderHistoricalResponse, |
|
|
|
IDataProviderResponse, |
|
|
|
MarketState |
|
|
|
} from '../../interfaces/interfaces'; |
|
|
|
import { DataProviderInterface } from '../interfaces/data-provider.interface'; |
|
|
|
import { |
|
|
|
IYahooFinanceHistoricalResponse, |
|
|
|
IYahooFinancePrice, |
|
|
|
IYahooFinanceQuoteResponse |
|
|
|
} from './interfaces/interfaces'; |
|
|
|
|
|
|
|
@Injectable() |
|
|
|
export class YahooFinanceService implements DataProviderInterface { |
|
|
@ -73,145 +73,113 @@ export class YahooFinanceService implements DataProviderInterface { |
|
|
|
return aSymbol; |
|
|
|
} |
|
|
|
|
|
|
|
public async get( |
|
|
|
aSymbols: string[] |
|
|
|
): Promise<{ [symbol: string]: IDataProviderResponse }> { |
|
|
|
if (aSymbols.length <= 0) { |
|
|
|
return {}; |
|
|
|
} |
|
|
|
const yahooFinanceSymbols = aSymbols.map((symbol) => |
|
|
|
this.convertToYahooFinanceSymbol(symbol) |
|
|
|
); |
|
|
|
public async getAssetProfile( |
|
|
|
aSymbol: string |
|
|
|
): Promise<Partial<SymbolProfile>> { |
|
|
|
const response: Partial<SymbolProfile> = {}; |
|
|
|
|
|
|
|
try { |
|
|
|
const response: { [symbol: string]: IDataProviderResponse } = {}; |
|
|
|
|
|
|
|
const data: { |
|
|
|
[symbol: string]: IYahooFinanceQuoteResponse; |
|
|
|
} = await yahooFinance.quote({ |
|
|
|
modules: ['price', 'summaryProfile'], |
|
|
|
symbols: yahooFinanceSymbols |
|
|
|
const symbol = this.convertToYahooFinanceSymbol(aSymbol); |
|
|
|
const assetProfile = await yahooFinance2.quoteSummary(symbol, { |
|
|
|
modules: ['price', 'summaryProfile'] |
|
|
|
}); |
|
|
|
|
|
|
|
for (const [yahooFinanceSymbol, value] of Object.entries(data)) { |
|
|
|
// Convert symbols back
|
|
|
|
const symbol = this.convertFromYahooFinanceSymbol(yahooFinanceSymbol); |
|
|
|
|
|
|
|
const { assetClass, assetSubClass } = this.parseAssetClass(value.price); |
|
|
|
const { assetClass, assetSubClass } = this.parseAssetClass( |
|
|
|
assetProfile.price |
|
|
|
); |
|
|
|
|
|
|
|
response[symbol] = { |
|
|
|
assetClass, |
|
|
|
assetSubClass, |
|
|
|
currency: value.price?.currency, |
|
|
|
dataSource: this.getName(), |
|
|
|
exchange: this.parseExchange(value.price?.exchangeName), |
|
|
|
marketState: |
|
|
|
value.price?.marketState === 'REGULAR' || |
|
|
|
this.cryptocurrencyService.isCryptocurrency(symbol) |
|
|
|
? MarketState.open |
|
|
|
: MarketState.closed, |
|
|
|
marketPrice: value.price?.regularMarketPrice || 0, |
|
|
|
name: value.price?.longName || value.price?.shortName || symbol |
|
|
|
}; |
|
|
|
response.assetClass = assetClass; |
|
|
|
response.assetSubClass = assetSubClass; |
|
|
|
response.currency = assetProfile.price.currency; |
|
|
|
response.dataSource = this.getName(); |
|
|
|
response.name = |
|
|
|
assetProfile.price.longName || assetProfile.price.shortName || symbol; |
|
|
|
response.symbol = aSymbol; |
|
|
|
|
|
|
|
if ( |
|
|
|
assetSubClass === AssetSubClass.STOCK && |
|
|
|
assetProfile.summaryProfile?.country |
|
|
|
) { |
|
|
|
// Add country if asset is stock and country available
|
|
|
|
|
|
|
|
if (value.price?.currency === 'GBp') { |
|
|
|
// Convert GBp (pence) to GBP
|
|
|
|
response[symbol].currency = 'GBP'; |
|
|
|
response[symbol].marketPrice = new Big( |
|
|
|
value.price?.regularMarketPrice ?? 0 |
|
|
|
) |
|
|
|
.div(100) |
|
|
|
.toNumber(); |
|
|
|
} |
|
|
|
try { |
|
|
|
const [code] = Object.entries(countries).find(([, country]) => { |
|
|
|
return country.name === assetProfile.summaryProfile?.country; |
|
|
|
}); |
|
|
|
|
|
|
|
// Add country if stock and available
|
|
|
|
if ( |
|
|
|
assetSubClass === AssetSubClass.STOCK && |
|
|
|
value.summaryProfile?.country |
|
|
|
) { |
|
|
|
try { |
|
|
|
const [code] = Object.entries(countries).find(([, country]) => { |
|
|
|
return country.name === value.summaryProfile?.country; |
|
|
|
}); |
|
|
|
|
|
|
|
if (code) { |
|
|
|
response[symbol].countries = [{ code, weight: 1 }]; |
|
|
|
} |
|
|
|
} catch {} |
|
|
|
|
|
|
|
if (value.summaryProfile?.sector) { |
|
|
|
response[symbol].sectors = [ |
|
|
|
{ name: value.summaryProfile?.sector, weight: 1 } |
|
|
|
]; |
|
|
|
if (code) { |
|
|
|
response.countries = [{ code, weight: 1 }]; |
|
|
|
} |
|
|
|
} |
|
|
|
} catch {} |
|
|
|
|
|
|
|
// Add url if available
|
|
|
|
const url = value.summaryProfile?.website; |
|
|
|
if (url) { |
|
|
|
response[symbol].url = url; |
|
|
|
if (assetProfile.summaryProfile?.sector) { |
|
|
|
response.sectors = [ |
|
|
|
{ name: assetProfile.summaryProfile?.sector, weight: 1 } |
|
|
|
]; |
|
|
|
} |
|
|
|
} |
|
|
|
|
|
|
|
return response; |
|
|
|
} catch (error) { |
|
|
|
Logger.error(error); |
|
|
|
const url = assetProfile.summaryProfile?.website; |
|
|
|
if (url) { |
|
|
|
response.url = url; |
|
|
|
} |
|
|
|
} catch {} |
|
|
|
|
|
|
|
return {}; |
|
|
|
} |
|
|
|
return response; |
|
|
|
} |
|
|
|
|
|
|
|
public async getHistorical( |
|
|
|
aSymbols: string[], |
|
|
|
aSymbol: string, |
|
|
|
aGranularity: Granularity = 'day', |
|
|
|
from: Date, |
|
|
|
to: Date |
|
|
|
): Promise<{ |
|
|
|
[symbol: string]: { [date: string]: IDataProviderHistoricalResponse }; |
|
|
|
}> { |
|
|
|
if (aSymbols.length <= 0) { |
|
|
|
return {}; |
|
|
|
} |
|
|
|
|
|
|
|
if (isSameDay(from, to)) { |
|
|
|
to = addDays(to, 1); |
|
|
|
} |
|
|
|
|
|
|
|
const yahooFinanceSymbols = aSymbols.map((symbol) => { |
|
|
|
return this.convertToYahooFinanceSymbol(symbol); |
|
|
|
}); |
|
|
|
const yahooFinanceSymbol = this.convertToYahooFinanceSymbol(aSymbol); |
|
|
|
|
|
|
|
try { |
|
|
|
const historicalData: { |
|
|
|
[symbol: string]: IYahooFinanceHistoricalResponse[]; |
|
|
|
} = await yahooFinance.historical({ |
|
|
|
symbols: yahooFinanceSymbols, |
|
|
|
from: format(from, DATE_FORMAT), |
|
|
|
to: format(to, DATE_FORMAT) |
|
|
|
}); |
|
|
|
const historicalResult = await yahooFinance2.historical( |
|
|
|
yahooFinanceSymbol, |
|
|
|
{ |
|
|
|
interval: '1d', |
|
|
|
period1: format(from, DATE_FORMAT), |
|
|
|
period2: format(to, DATE_FORMAT) |
|
|
|
} |
|
|
|
); |
|
|
|
|
|
|
|
const response: { |
|
|
|
[symbol: string]: { [date: string]: IDataProviderHistoricalResponse }; |
|
|
|
} = {}; |
|
|
|
|
|
|
|
for (const [yahooFinanceSymbol, timeSeries] of Object.entries( |
|
|
|
historicalData |
|
|
|
)) { |
|
|
|
// Convert symbols back
|
|
|
|
const symbol = this.convertFromYahooFinanceSymbol(yahooFinanceSymbol); |
|
|
|
response[symbol] = {}; |
|
|
|
// Convert symbol back
|
|
|
|
const symbol = this.convertFromYahooFinanceSymbol(yahooFinanceSymbol); |
|
|
|
|
|
|
|
timeSeries.forEach((timeSerie) => { |
|
|
|
response[symbol][format(timeSerie.date, DATE_FORMAT)] = { |
|
|
|
marketPrice: timeSerie.close, |
|
|
|
performance: timeSerie.open - timeSerie.close |
|
|
|
}; |
|
|
|
}); |
|
|
|
response[symbol] = {}; |
|
|
|
|
|
|
|
for (const historicalItem of historicalResult) { |
|
|
|
let marketPrice = historicalItem.close; |
|
|
|
|
|
|
|
if (symbol === 'USDGBp') { |
|
|
|
// Convert GPB to GBp (pence)
|
|
|
|
marketPrice = new Big(marketPrice).mul(100).toNumber(); |
|
|
|
} |
|
|
|
|
|
|
|
response[symbol][format(historicalItem.date, DATE_FORMAT)] = { |
|
|
|
marketPrice, |
|
|
|
performance: historicalItem.open - historicalItem.close |
|
|
|
}; |
|
|
|
} |
|
|
|
|
|
|
|
return response; |
|
|
|
} catch (error) { |
|
|
|
Logger.error(error); |
|
|
|
Logger.warn( |
|
|
|
`Skipping yahooFinance2.getHistorical("${aSymbol}"): [${error.name}] ${error.message}` |
|
|
|
); |
|
|
|
|
|
|
|
return {}; |
|
|
|
} |
|
|
@ -221,6 +189,56 @@ export class YahooFinanceService implements DataProviderInterface { |
|
|
|
return DataSource.YAHOO; |
|
|
|
} |
|
|
|
|
|
|
|
public async getQuotes( |
|
|
|
aSymbols: string[] |
|
|
|
): Promise<{ [symbol: string]: IDataProviderResponse }> { |
|
|
|
if (aSymbols.length <= 0) { |
|
|
|
return {}; |
|
|
|
} |
|
|
|
const yahooFinanceSymbols = aSymbols.map((symbol) => |
|
|
|
this.convertToYahooFinanceSymbol(symbol) |
|
|
|
); |
|
|
|
|
|
|
|
try { |
|
|
|
const response: { [symbol: string]: IDataProviderResponse } = {}; |
|
|
|
|
|
|
|
const quotes = await yahooFinance2.quote(yahooFinanceSymbols); |
|
|
|
|
|
|
|
for (const quote of quotes) { |
|
|
|
// Convert symbols back
|
|
|
|
const symbol = this.convertFromYahooFinanceSymbol(quote.symbol); |
|
|
|
|
|
|
|
response[symbol] = { |
|
|
|
currency: quote.currency, |
|
|
|
dataSource: this.getName(), |
|
|
|
marketState: |
|
|
|
quote.marketState === 'REGULAR' || |
|
|
|
this.cryptocurrencyService.isCryptocurrency(symbol) |
|
|
|
? MarketState.open |
|
|
|
: MarketState.closed, |
|
|
|
marketPrice: quote.regularMarketPrice || 0 |
|
|
|
}; |
|
|
|
|
|
|
|
if (symbol === 'USDGBP' && yahooFinanceSymbols.includes('USDGBp=X')) { |
|
|
|
// Convert GPB to GBp (pence)
|
|
|
|
response['USDGBp'] = { |
|
|
|
...response[symbol], |
|
|
|
currency: 'GBp', |
|
|
|
marketPrice: new Big(response[symbol].marketPrice) |
|
|
|
.mul(100) |
|
|
|
.toNumber() |
|
|
|
}; |
|
|
|
} |
|
|
|
} |
|
|
|
|
|
|
|
return response; |
|
|
|
} catch (error) { |
|
|
|
Logger.error(error); |
|
|
|
|
|
|
|
return {}; |
|
|
|
} |
|
|
|
} |
|
|
|
|
|
|
|
public async search(aQuery: string): Promise<{ items: LookupItem[] }> { |
|
|
|
const items: LookupItem[] = []; |
|
|
|
|
|
|
@ -236,7 +254,7 @@ export class YahooFinanceService implements DataProviderInterface { |
|
|
|
|
|
|
|
const searchResult = await get(); |
|
|
|
|
|
|
|
const symbols: string[] = searchResult.quotes |
|
|
|
const quotes = searchResult.quotes |
|
|
|
.filter((quote) => { |
|
|
|
// filter out undefined symbols
|
|
|
|
return quote.symbol; |
|
|
@ -247,8 +265,7 @@ export class YahooFinanceService implements DataProviderInterface { |
|
|
|
this.cryptocurrencyService.isCryptocurrency( |
|
|
|
symbol.replace(new RegExp(`-${baseCurrency}$`), baseCurrency) |
|
|
|
)) || |
|
|
|
quoteType === 'EQUITY' || |
|
|
|
quoteType === 'ETF' |
|
|
|
['EQUITY', 'ETF', 'MUTUALFUND'].includes(quoteType) |
|
|
|
); |
|
|
|
}) |
|
|
|
.filter(({ quoteType, symbol }) => { |
|
|
@ -259,19 +276,24 @@ export class YahooFinanceService implements DataProviderInterface { |
|
|
|
} |
|
|
|
|
|
|
|
return true; |
|
|
|
}) |
|
|
|
.map(({ symbol }) => { |
|
|
|
return symbol; |
|
|
|
}); |
|
|
|
|
|
|
|
const marketData = await this.get(symbols); |
|
|
|
const marketData = await this.getQuotes( |
|
|
|
quotes.map(({ symbol }) => { |
|
|
|
return symbol; |
|
|
|
}) |
|
|
|
); |
|
|
|
|
|
|
|
for (const [symbol, value] of Object.entries(marketData)) { |
|
|
|
const quote = quotes.find((currentQuote: any) => { |
|
|
|
return currentQuote.symbol === symbol; |
|
|
|
}); |
|
|
|
|
|
|
|
items.push({ |
|
|
|
symbol, |
|
|
|
currency: value.currency, |
|
|
|
dataSource: this.getName(), |
|
|
|
name: value.name |
|
|
|
name: quote?.longname || quote?.shortname || symbol |
|
|
|
}); |
|
|
|
} |
|
|
|
} catch (error) { |
|
|
@ -281,7 +303,7 @@ export class YahooFinanceService implements DataProviderInterface { |
|
|
|
return { items }; |
|
|
|
} |
|
|
|
|
|
|
|
private parseAssetClass(aPrice: IYahooFinancePrice): { |
|
|
|
private parseAssetClass(aPrice: any): { |
|
|
|
assetClass: AssetClass; |
|
|
|
assetSubClass: AssetSubClass; |
|
|
|
} { |
|
|
@ -301,16 +323,12 @@ export class YahooFinanceService implements DataProviderInterface { |
|
|
|
assetClass = AssetClass.EQUITY; |
|
|
|
assetSubClass = AssetSubClass.ETF; |
|
|
|
break; |
|
|
|
case 'mutualfund': |
|
|
|
assetClass = AssetClass.EQUITY; |
|
|
|
assetSubClass = AssetSubClass.MUTUALFUND; |
|
|
|
break; |
|
|
|
} |
|
|
|
|
|
|
|
return { assetClass, assetSubClass }; |
|
|
|
} |
|
|
|
|
|
|
|
private parseExchange(aString: string): string { |
|
|
|
if (aString?.toLowerCase() === 'ccc') { |
|
|
|
return UNKNOWN_KEY; |
|
|
|
} |
|
|
|
|
|
|
|
return aString; |
|
|
|
} |
|
|
|
} |
|
|
|