Your ROOT_URL in app.ini is https://git.cloudchain.link/ but you are visiting https://dash.bss.nz/open-source-mirrors/ghostfolio/commit/c02bcd9bd8b23fcee3c8e026941a384d9fc38645?style=split&whitespace=show-all You should set ROOT_URL correctly, otherwise the web may not work correctly.

Feature/migrate to yahoo finance2 ()

* Migrate to yahoo-finance2

* Add support for mutual funds

* Add url to symbol profile

* Clean up
pull/726/head
Thomas Kaul 3 years ago committed by GitHub
parent 6a4f1c0188
commit c02bcd9bd8
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23

@ -5,6 +5,21 @@ All notable changes to this project will be documented in this file.
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
## Unreleased
### Added
- Added support for mutual funds
- Added the url to the symbol profile model
### Changed
- Migrated from `yahoo-finance` to `yahoo-finance2`
### Todo
- Apply data migration (`yarn database:migrate`)
## 1.120.0 - 25.02.2022 ## 1.120.0 - 25.02.2022
### Changed ### Changed

@ -125,19 +125,19 @@ export class ImportService {
} }
if (dataSource !== 'MANUAL') { if (dataSource !== 'MANUAL') {
const result = await this.dataProviderService.get([ const quotes = await this.dataProviderService.getQuotes([
{ dataSource, symbol } { dataSource, symbol }
]); ]);
if (result[symbol] === undefined) { if (quotes[symbol] === undefined) {
throw new Error( throw new Error(
`orders.${index}.symbol ("${symbol}") is not valid for the specified data source ("${dataSource}")` `orders.${index}.symbol ("${symbol}") is not valid for the specified data source ("${dataSource}")`
); );
} }
if (result[symbol].currency !== currency) { if (quotes[symbol].currency !== currency) {
throw new Error( throw new Error(
`orders.${index}.currency ("${currency}") does not match with "${result[symbol].currency}"` `orders.${index}.currency ("${currency}") does not match with "${quotes[symbol].currency}"`
); );
} }
} }

@ -40,7 +40,7 @@ export class CurrentRateService {
const today = resetHours(new Date()); const today = resetHours(new Date());
promises.push( promises.push(
this.dataProviderService this.dataProviderService
.get(dataGatheringItems) .getQuotes(dataGatheringItems)
.then((dataResultProvider) => { .then((dataResultProvider) => {
const result = []; const result = [];
for (const dataGatheringItem of dataGatheringItems) { for (const dataGatheringItem of dataGatheringItems) {

@ -327,7 +327,7 @@ export class PortfolioServiceNew {
); );
const [dataProviderResponses, symbolProfiles] = await Promise.all([ const [dataProviderResponses, symbolProfiles] = await Promise.all([
this.dataProviderService.get(dataGatheringItems), this.dataProviderService.getQuotes(dataGatheringItems),
this.symbolProfileService.getSymbolProfiles(symbols) this.symbolProfileService.getSymbolProfiles(symbols)
]); ]);
@ -358,7 +358,6 @@ export class PortfolioServiceNew {
countries: symbolProfile.countries, countries: symbolProfile.countries,
currency: item.currency, currency: item.currency,
dataSource: symbolProfile.dataSource, dataSource: symbolProfile.dataSource,
exchange: dataProviderResponse.exchange,
grossPerformance: item.grossPerformance?.toNumber() ?? 0, grossPerformance: item.grossPerformance?.toNumber() ?? 0,
grossPerformancePercent: grossPerformancePercent:
item.grossPerformancePercentage?.toNumber() ?? 0, item.grossPerformancePercentage?.toNumber() ?? 0,
@ -578,7 +577,7 @@ export class PortfolioServiceNew {
) )
}; };
} else { } else {
const currentData = await this.dataProviderService.get([ const currentData = await this.dataProviderService.getQuotes([
{ dataSource: DataSource.YAHOO, symbol: aSymbol } { dataSource: DataSource.YAHOO, symbol: aSymbol }
]); ]);
const marketPrice = currentData[aSymbol]?.marketPrice; const marketPrice = currentData[aSymbol]?.marketPrice;
@ -679,7 +678,7 @@ export class PortfolioServiceNew {
const symbols = positions.map((position) => position.symbol); const symbols = positions.map((position) => position.symbol);
const [dataProviderResponses, symbolProfiles] = await Promise.all([ const [dataProviderResponses, symbolProfiles] = await Promise.all([
this.dataProviderService.get(dataGatheringItem), this.dataProviderService.getQuotes(dataGatheringItem),
this.symbolProfileService.getSymbolProfiles(symbols) this.symbolProfileService.getSymbolProfiles(symbols)
]); ]);

@ -315,7 +315,7 @@ export class PortfolioService {
); );
const [dataProviderResponses, symbolProfiles] = await Promise.all([ const [dataProviderResponses, symbolProfiles] = await Promise.all([
this.dataProviderService.get(dataGatheringItems), this.dataProviderService.getQuotes(dataGatheringItems),
this.symbolProfileService.getSymbolProfiles(symbols) this.symbolProfileService.getSymbolProfiles(symbols)
]); ]);
@ -346,7 +346,6 @@ export class PortfolioService {
countries: symbolProfile.countries, countries: symbolProfile.countries,
currency: item.currency, currency: item.currency,
dataSource: symbolProfile.dataSource, dataSource: symbolProfile.dataSource,
exchange: dataProviderResponse.exchange,
grossPerformance: item.grossPerformance?.toNumber() ?? 0, grossPerformance: item.grossPerformance?.toNumber() ?? 0,
grossPerformancePercent: grossPerformancePercent:
item.grossPerformancePercentage?.toNumber() ?? 0, item.grossPerformancePercentage?.toNumber() ?? 0,
@ -552,9 +551,10 @@ export class PortfolioService {
SymbolProfile, SymbolProfile,
transactionCount, transactionCount,
averagePrice: averagePrice.toNumber(), averagePrice: averagePrice.toNumber(),
grossPerformancePercent: position.grossPerformancePercentage.toNumber(), grossPerformancePercent:
position.grossPerformancePercentage?.toNumber(),
historicalData: historicalDataArray, historicalData: historicalDataArray,
netPerformancePercent: position.netPerformancePercentage.toNumber(), netPerformancePercent: position.netPerformancePercentage?.toNumber(),
quantity: quantity.toNumber(), quantity: quantity.toNumber(),
value: this.exchangeRateDataService.toCurrency( value: this.exchangeRateDataService.toCurrency(
quantity.mul(marketPrice).toNumber(), quantity.mul(marketPrice).toNumber(),
@ -563,7 +563,7 @@ export class PortfolioService {
) )
}; };
} else { } else {
const currentData = await this.dataProviderService.get([ const currentData = await this.dataProviderService.getQuotes([
{ dataSource: DataSource.YAHOO, symbol: aSymbol } { dataSource: DataSource.YAHOO, symbol: aSymbol }
]); ]);
const marketPrice = currentData[aSymbol]?.marketPrice; const marketPrice = currentData[aSymbol]?.marketPrice;
@ -660,7 +660,7 @@ export class PortfolioService {
const symbols = positions.map((position) => position.symbol); const symbols = positions.map((position) => position.symbol);
const [dataProviderResponses, symbolProfiles] = await Promise.all([ const [dataProviderResponses, symbolProfiles] = await Promise.all([
this.dataProviderService.get(dataGatheringItem), this.dataProviderService.getQuotes(dataGatheringItem),
this.symbolProfileService.getSymbolProfiles(symbols) this.symbolProfileService.getSymbolProfiles(symbols)
]); ]);

@ -27,8 +27,10 @@ export class SymbolService {
dataGatheringItem: IDataGatheringItem; dataGatheringItem: IDataGatheringItem;
includeHistoricalData?: number; includeHistoricalData?: number;
}): Promise<SymbolItem> { }): Promise<SymbolItem> {
const response = await this.dataProviderService.get([dataGatheringItem]); const quotes = await this.dataProviderService.getQuotes([
const { currency, marketPrice } = response[dataGatheringItem.symbol] ?? {}; dataGatheringItem
]);
const { currency, marketPrice } = quotes[dataGatheringItem.symbol] ?? {};
if (dataGatheringItem.dataSource && marketPrice) { if (dataGatheringItem.dataSource && marketPrice) {
let historicalData: HistoricalDataItem[] = []; let historicalData: HistoricalDataItem[] = [];

@ -220,32 +220,41 @@ export class DataGatheringService {
Logger.log('Profile data gathering has been started.'); Logger.log('Profile data gathering has been started.');
console.time('data-gathering-profile'); console.time('data-gathering-profile');
let dataGatheringItems = aDataGatheringItems; let dataGatheringItems = aDataGatheringItems?.filter(
(dataGatheringItem) => {
return dataGatheringItem.dataSource !== 'MANUAL';
}
);
if (!dataGatheringItems) { if (!dataGatheringItems) {
dataGatheringItems = await this.getSymbolsProfileData(); dataGatheringItems = await this.getSymbolsProfileData();
} }
const currentData = await this.dataProviderService.get(dataGatheringItems); const assetProfiles = await this.dataProviderService.getAssetProfiles(
dataGatheringItems
);
const symbolProfiles = await this.symbolProfileService.getSymbolProfiles( const symbolProfiles = await this.symbolProfileService.getSymbolProfiles(
dataGatheringItems.map(({ symbol }) => { dataGatheringItems.map(({ symbol }) => {
return symbol; return symbol;
}) })
); );
for (const [symbol, response] of Object.entries(currentData)) { for (const [symbol, assetProfile] of Object.entries(assetProfiles)) {
const symbolMapping = symbolProfiles.find((symbolProfile) => { const symbolMapping = symbolProfiles.find((symbolProfile) => {
return symbolProfile.symbol === symbol; return symbolProfile.symbol === symbol;
})?.symbolMapping; })?.symbolMapping;
for (const dataEnhancer of this.dataEnhancers) { for (const dataEnhancer of this.dataEnhancers) {
try { try {
currentData[symbol] = await dataEnhancer.enhance({ assetProfiles[symbol] = await dataEnhancer.enhance({
response, response: assetProfile,
symbol: symbolMapping?.[dataEnhancer.getName()] ?? symbol symbol: symbolMapping?.[dataEnhancer.getName()] ?? symbol
}); });
} catch (error) { } catch (error) {
Logger.error(`Failed to enhance data for symbol ${symbol}`, error); Logger.error(
`Failed to enhance data for symbol ${symbol} by ${dataEnhancer.getName()}`,
error
);
} }
} }
@ -256,8 +265,9 @@ export class DataGatheringService {
currency, currency,
dataSource, dataSource,
name, name,
sectors sectors,
} = currentData[symbol]; url
} = assetProfiles[symbol];
try { try {
await this.prismaService.symbolProfile.upsert({ await this.prismaService.symbolProfile.upsert({
@ -269,7 +279,8 @@ export class DataGatheringService {
dataSource, dataSource,
name, name,
sectors, sectors,
symbol symbol,
url
}, },
update: { update: {
assetClass, assetClass,
@ -277,7 +288,8 @@ export class DataGatheringService {
countries, countries,
currency, currency,
name, name,
sectors sectors,
url
}, },
where: { where: {
dataSource_symbol: { dataSource_symbol: {
@ -300,6 +312,10 @@ export class DataGatheringService {
let symbolCounter = 0; let symbolCounter = 0;
for (const { dataSource, date, symbol } of aSymbolsWithStartDate) { for (const { dataSource, date, symbol } of aSymbolsWithStartDate) {
if (dataSource === 'MANUAL') {
continue;
}
this.dataGatheringProgress = symbolCounter / aSymbolsWithStartDate.length; this.dataGatheringProgress = symbolCounter / aSymbolsWithStartDate.length;
try { try {
@ -347,7 +363,7 @@ export class DataGatheringService {
} catch {} } catch {}
} else { } else {
Logger.warn( Logger.warn(
`Failed to gather data for symbol ${symbol} at ${format( `Failed to gather data for symbol ${symbol} from ${dataSource} at ${format(
currentDate, currentDate,
DATE_FORMAT DATE_FORMAT
)}.` )}.`

@ -1,15 +1,15 @@
import { LookupItem } from '@ghostfolio/api/app/symbol/interfaces/lookup-item.interface'; import { LookupItem } from '@ghostfolio/api/app/symbol/interfaces/lookup-item.interface';
import { ConfigurationService } from '@ghostfolio/api/services/configuration.service'; import { ConfigurationService } from '@ghostfolio/api/services/configuration.service';
import {
IDataProviderHistoricalResponse,
IDataProviderResponse
} from '@ghostfolio/api/services/interfaces/interfaces';
import { DATE_FORMAT } from '@ghostfolio/common/helper'; import { DATE_FORMAT } from '@ghostfolio/common/helper';
import { Granularity } from '@ghostfolio/common/types'; import { Granularity } from '@ghostfolio/common/types';
import { Injectable, Logger } from '@nestjs/common'; import { Injectable, Logger } from '@nestjs/common';
import { DataSource } from '@prisma/client'; import { DataSource, SymbolProfile } from '@prisma/client';
import { isAfter, isBefore, parse } from 'date-fns'; import { isAfter, isBefore, parse } from 'date-fns';
import {
IDataProviderHistoricalResponse,
IDataProviderResponse
} from '../../interfaces/interfaces';
import { DataProviderInterface } from '../interfaces/data-provider.interface'; import { DataProviderInterface } from '../interfaces/data-provider.interface';
import { IAlphaVantageHistoricalResponse } from './interfaces/interfaces'; import { IAlphaVantageHistoricalResponse } from './interfaces/interfaces';
@ -29,25 +29,23 @@ export class AlphaVantageService implements DataProviderInterface {
return !!this.configurationService.get('ALPHA_VANTAGE_API_KEY'); return !!this.configurationService.get('ALPHA_VANTAGE_API_KEY');
} }
public async get( public async getAssetProfile(
aSymbols: string[] aSymbol: string
): Promise<{ [symbol: string]: IDataProviderResponse }> { ): Promise<Partial<SymbolProfile>> {
return {}; return {
dataSource: this.getName()
};
} }
public async getHistorical( public async getHistorical(
aSymbols: string[], aSymbol: string,
aGranularity: Granularity = 'day', aGranularity: Granularity = 'day',
from: Date, from: Date,
to: Date to: Date
): Promise<{ ): Promise<{
[symbol: string]: { [date: string]: IDataProviderHistoricalResponse }; [symbol: string]: { [date: string]: IDataProviderHistoricalResponse };
}> { }> {
if (aSymbols.length <= 0) { const symbol = aSymbol;
return {};
}
const symbol = aSymbols[0];
try { try {
const historicalData: { const historicalData: {
@ -88,6 +86,12 @@ export class AlphaVantageService implements DataProviderInterface {
return DataSource.ALPHA_VANTAGE; return DataSource.ALPHA_VANTAGE;
} }
public async getQuotes(
aSymbols: string[]
): Promise<{ [symbol: string]: IDataProviderResponse }> {
return {};
}
public async search(aQuery: string): Promise<{ items: LookupItem[] }> { public async search(aQuery: string): Promise<{ items: LookupItem[] }> {
const result = await this.alphaVantage.data.search(aQuery); const result = await this.alphaVantage.data.search(aQuery);

@ -1,5 +1,7 @@
import { DataEnhancerInterface } from '@ghostfolio/api/services/data-provider/interfaces/data-enhancer.interface'; import { DataEnhancerInterface } from '@ghostfolio/api/services/data-provider/interfaces/data-enhancer.interface';
import { IDataProviderResponse } from '@ghostfolio/api/services/interfaces/interfaces'; import { Country } from '@ghostfolio/common/interfaces/country.interface';
import { Sector } from '@ghostfolio/common/interfaces/sector.interface';
import { SymbolProfile } from '@prisma/client';
import bent from 'bent'; import bent from 'bent';
const getJSON = bent('json'); const getJSON = bent('json');
@ -21,9 +23,9 @@ export class TrackinsightDataEnhancerService implements DataEnhancerInterface {
response, response,
symbol symbol
}: { }: {
response: IDataProviderResponse; response: Partial<SymbolProfile>;
symbol: string; symbol: string;
}): Promise<IDataProviderResponse> { }): Promise<Partial<SymbolProfile>> {
if ( if (
!(response.assetClass === 'EQUITY' && response.assetSubClass === 'ETF') !(response.assetClass === 'EQUITY' && response.assetSubClass === 'ETF')
) { ) {
@ -40,7 +42,10 @@ export class TrackinsightDataEnhancerService implements DataEnhancerInterface {
); );
}); });
if (!response.countries || response.countries.length === 0) { if (
!response.countries ||
(response.countries as unknown as Country[]).length === 0
) {
response.countries = []; response.countries = [];
for (const [name, value] of Object.entries<any>(holdings.countries)) { for (const [name, value] of Object.entries<any>(holdings.countries)) {
let countryCode: string; let countryCode: string;
@ -65,7 +70,10 @@ export class TrackinsightDataEnhancerService implements DataEnhancerInterface {
} }
} }
if (!response.sectors || response.sectors.length === 0) { if (
!response.sectors ||
(response.sectors as unknown as Sector[]).length === 0
) {
response.sectors = []; response.sectors = [];
for (const [name, value] of Object.entries<any>(holdings.sectors)) { for (const [name, value] of Object.entries<any>(holdings.sectors)) {
response.sectors.push({ response.sectors.push({

@ -10,7 +10,7 @@ import { PrismaService } from '@ghostfolio/api/services/prisma.service';
import { DATE_FORMAT } from '@ghostfolio/common/helper'; import { DATE_FORMAT } from '@ghostfolio/common/helper';
import { Granularity } from '@ghostfolio/common/types'; import { Granularity } from '@ghostfolio/common/types';
import { Inject, Injectable, Logger } from '@nestjs/common'; import { Inject, Injectable, Logger } from '@nestjs/common';
import { DataSource, MarketData } from '@prisma/client'; import { DataSource, MarketData, SymbolProfile } from '@prisma/client';
import { format, isValid } from 'date-fns'; import { format, isValid } from 'date-fns';
import { groupBy, isEmpty } from 'lodash'; import { groupBy, isEmpty } from 'lodash';
@ -23,42 +23,6 @@ export class DataProviderService {
private readonly prismaService: PrismaService private readonly prismaService: PrismaService
) {} ) {}
public async get(items: IDataGatheringItem[]): Promise<{
[symbol: string]: IDataProviderResponse;
}> {
const response: {
[symbol: string]: IDataProviderResponse;
} = {};
const itemsGroupedByDataSource = groupBy(items, (item) => item.dataSource);
const promises = [];
for (const [dataSource, dataGatheringItems] of Object.entries(
itemsGroupedByDataSource
)) {
const symbols = dataGatheringItems.map((dataGatheringItem) => {
return dataGatheringItem.symbol;
});
const promise = Promise.resolve(
this.getDataProvider(DataSource[dataSource]).get(symbols)
);
promises.push(
promise.then((result) => {
for (const [symbol, dataProviderResponse] of Object.entries(result)) {
response[symbol] = dataProviderResponse;
}
})
);
}
await Promise.all(promises);
return response;
}
public async getHistorical( public async getHistorical(
aItems: IDataGatheringItem[], aItems: IDataGatheringItem[],
aGranularity: Granularity = 'month', aGranularity: Granularity = 'month',
@ -144,7 +108,7 @@ export class DataProviderService {
if (dataProvider.canHandle(symbol)) { if (dataProvider.canHandle(symbol)) {
promises.push( promises.push(
dataProvider dataProvider
.getHistorical([symbol], undefined, from, to) .getHistorical(symbol, undefined, from, to)
.then((data) => ({ data: data?.[symbol], symbol })) .then((data) => ({ data: data?.[symbol], symbol }))
); );
} }
@ -158,6 +122,82 @@ export class DataProviderService {
return result; return result;
} }
public getPrimaryDataSource(): DataSource {
return DataSource[this.configurationService.get('DATA_SOURCE_PRIMARY')];
}
public async getAssetProfiles(items: IDataGatheringItem[]): Promise<{
[symbol: string]: Partial<SymbolProfile>;
}> {
const response: {
[symbol: string]: Partial<SymbolProfile>;
} = {};
const itemsGroupedByDataSource = groupBy(items, (item) => item.dataSource);
const promises = [];
for (const [dataSource, dataGatheringItems] of Object.entries(
itemsGroupedByDataSource
)) {
const symbols = dataGatheringItems.map((dataGatheringItem) => {
return dataGatheringItem.symbol;
});
for (const symbol of symbols) {
const promise = Promise.resolve(
this.getDataProvider(DataSource[dataSource]).getAssetProfile(symbol)
);
promises.push(
promise.then((symbolProfile) => {
response[symbol] = symbolProfile;
})
);
}
}
await Promise.all(promises);
return response;
}
public async getQuotes(items: IDataGatheringItem[]): Promise<{
[symbol: string]: IDataProviderResponse;
}> {
const response: {
[symbol: string]: IDataProviderResponse;
} = {};
const itemsGroupedByDataSource = groupBy(items, (item) => item.dataSource);
const promises = [];
for (const [dataSource, dataGatheringItems] of Object.entries(
itemsGroupedByDataSource
)) {
const symbols = dataGatheringItems.map((dataGatheringItem) => {
return dataGatheringItem.symbol;
});
const promise = Promise.resolve(
this.getDataProvider(DataSource[dataSource]).getQuotes(symbols)
);
promises.push(
promise.then((result) => {
for (const [symbol, dataProviderResponse] of Object.entries(result)) {
response[symbol] = dataProviderResponse;
}
})
);
}
await Promise.all(promises);
return response;
}
public async search(aQuery: string): Promise<{ items: LookupItem[] }> { public async search(aQuery: string): Promise<{ items: LookupItem[] }> {
const promises: Promise<{ items: LookupItem[] }>[] = []; const promises: Promise<{ items: LookupItem[] }>[] = [];
let lookupItems: LookupItem[] = []; let lookupItems: LookupItem[] = [];
@ -184,10 +224,6 @@ export class DataProviderService {
}; };
} }
public getPrimaryDataSource(): DataSource {
return DataSource[this.configurationService.get('DATA_SOURCE_PRIMARY')];
}
private getDataProvider(providerName: DataSource) { private getDataProvider(providerName: DataSource) {
for (const dataProviderInterface of this.dataProviderInterfaces) { for (const dataProviderInterface of this.dataProviderInterfaces) {
if (dataProviderInterface.getName() === providerName) { if (dataProviderInterface.getName() === providerName) {

@ -14,7 +14,7 @@ import {
} from '@ghostfolio/common/helper'; } from '@ghostfolio/common/helper';
import { Granularity } from '@ghostfolio/common/types'; import { Granularity } from '@ghostfolio/common/types';
import { Injectable, Logger } from '@nestjs/common'; import { Injectable, Logger } from '@nestjs/common';
import { DataSource } from '@prisma/client'; import { DataSource, SymbolProfile } from '@prisma/client';
import * as bent from 'bent'; import * as bent from 'bent';
import * as cheerio from 'cheerio'; import * as cheerio from 'cheerio';
import { format } from 'date-fns'; import { format } from 'date-fns';
@ -32,57 +32,25 @@ export class GhostfolioScraperApiService implements DataProviderInterface {
return isGhostfolioScraperApiSymbol(symbol); return isGhostfolioScraperApiSymbol(symbol);
} }
public async get( public async getAssetProfile(
aSymbols: string[] aSymbol: string
): Promise<{ [symbol: string]: IDataProviderResponse }> { ): Promise<Partial<SymbolProfile>> {
if (aSymbols.length <= 0) { return {
return {}; dataSource: this.getName()
} };
try {
const [symbol] = aSymbols;
const [symbolProfile] = await this.symbolProfileService.getSymbolProfiles(
[symbol]
);
const { marketPrice } = await this.prismaService.marketData.findFirst({
orderBy: {
date: 'desc'
},
where: {
symbol
}
});
return {
[symbol]: {
marketPrice,
currency: symbolProfile?.currency,
dataSource: this.getName(),
marketState: MarketState.delayed
}
};
} catch (error) {
Logger.error(error);
}
return {};
} }
public async getHistorical( public async getHistorical(
aSymbols: string[], aSymbol: string,
aGranularity: Granularity = 'day', aGranularity: Granularity = 'day',
from: Date, from: Date,
to: Date to: Date
): Promise<{ ): Promise<{
[symbol: string]: { [date: string]: IDataProviderHistoricalResponse }; [symbol: string]: { [date: string]: IDataProviderHistoricalResponse };
}> { }> {
if (aSymbols.length <= 0) {
return {};
}
try { try {
const [symbol] = aSymbols; const symbol = aSymbol;
const [symbolProfile] = await this.symbolProfileService.getSymbolProfiles( const [symbolProfile] = await this.symbolProfileService.getSymbolProfiles(
[symbol] [symbol]
); );
@ -115,6 +83,43 @@ export class GhostfolioScraperApiService implements DataProviderInterface {
return DataSource.GHOSTFOLIO; return DataSource.GHOSTFOLIO;
} }
public async getQuotes(
aSymbols: string[]
): Promise<{ [symbol: string]: IDataProviderResponse }> {
if (aSymbols.length <= 0) {
return {};
}
try {
const [symbol] = aSymbols;
const [symbolProfile] = await this.symbolProfileService.getSymbolProfiles(
[symbol]
);
const { marketPrice } = await this.prismaService.marketData.findFirst({
orderBy: {
date: 'desc'
},
where: {
symbol
}
});
return {
[symbol]: {
marketPrice,
currency: symbolProfile?.currency,
dataSource: this.getName(),
marketState: MarketState.delayed
}
};
} catch (error) {
Logger.error(error);
}
return {};
}
public async search(aQuery: string): Promise<{ items: LookupItem[] }> { public async search(aQuery: string): Promise<{ items: LookupItem[] }> {
const items = await this.prismaService.symbolProfile.findMany({ const items = await this.prismaService.symbolProfile.findMany({
select: { select: {

@ -11,7 +11,7 @@ import { SymbolProfileService } from '@ghostfolio/api/services/symbol-profile.se
import { DATE_FORMAT, parseDate } from '@ghostfolio/common/helper'; import { DATE_FORMAT, parseDate } from '@ghostfolio/common/helper';
import { Granularity } from '@ghostfolio/common/types'; import { Granularity } from '@ghostfolio/common/types';
import { Injectable, Logger } from '@nestjs/common'; import { Injectable, Logger } from '@nestjs/common';
import { DataSource } from '@prisma/client'; import { DataSource, SymbolProfile } from '@prisma/client';
import { format } from 'date-fns'; import { format } from 'date-fns';
import { GoogleSpreadsheet } from 'google-spreadsheet'; import { GoogleSpreadsheet } from 'google-spreadsheet';
@ -27,65 +27,24 @@ export class GoogleSheetsService implements DataProviderInterface {
return true; return true;
} }
public async get( public async getAssetProfile(
aSymbols: string[] aSymbol: string
): Promise<{ [symbol: string]: IDataProviderResponse }> { ): Promise<Partial<SymbolProfile>> {
if (aSymbols.length <= 0) { return {
return {}; dataSource: this.getName()
} };
try {
const response: { [symbol: string]: IDataProviderResponse } = {};
const symbolProfiles = await this.symbolProfileService.getSymbolProfiles(
aSymbols
);
const sheet = await this.getSheet({
sheetId: this.configurationService.get('GOOGLE_SHEETS_ID'),
symbol: 'Overview'
});
const rows = await sheet.getRows();
for (const row of rows) {
const marketPrice = parseFloat(row['marketPrice']);
const symbol = row['symbol'];
if (aSymbols.includes(symbol)) {
response[symbol] = {
marketPrice,
currency: symbolProfiles.find((symbolProfile) => {
return symbolProfile.symbol === symbol;
})?.currency,
dataSource: this.getName(),
marketState: MarketState.delayed
};
}
}
return response;
} catch (error) {
Logger.error(error);
}
return {};
} }
public async getHistorical( public async getHistorical(
aSymbols: string[], aSymbol: string,
aGranularity: Granularity = 'day', aGranularity: Granularity = 'day',
from: Date, from: Date,
to: Date to: Date
): Promise<{ ): Promise<{
[symbol: string]: { [date: string]: IDataProviderHistoricalResponse }; [symbol: string]: { [date: string]: IDataProviderHistoricalResponse };
}> { }> {
if (aSymbols.length <= 0) {
return {};
}
try { try {
const [symbol] = aSymbols; const symbol = aSymbol;
const sheet = await this.getSheet({ const sheet = await this.getSheet({
symbol, symbol,
@ -123,6 +82,51 @@ export class GoogleSheetsService implements DataProviderInterface {
return DataSource.GOOGLE_SHEETS; return DataSource.GOOGLE_SHEETS;
} }
public async getQuotes(
aSymbols: string[]
): Promise<{ [symbol: string]: IDataProviderResponse }> {
if (aSymbols.length <= 0) {
return {};
}
try {
const response: { [symbol: string]: IDataProviderResponse } = {};
const symbolProfiles = await this.symbolProfileService.getSymbolProfiles(
aSymbols
);
const sheet = await this.getSheet({
sheetId: this.configurationService.get('GOOGLE_SHEETS_ID'),
symbol: 'Overview'
});
const rows = await sheet.getRows();
for (const row of rows) {
const marketPrice = parseFloat(row['marketPrice']);
const symbol = row['symbol'];
if (aSymbols.includes(symbol)) {
response[symbol] = {
marketPrice,
currency: symbolProfiles.find((symbolProfile) => {
return symbolProfile.symbol === symbol;
})?.currency,
dataSource: this.getName(),
marketState: MarketState.delayed
};
}
}
return response;
} catch (error) {
Logger.error(error);
}
return {};
}
public async search(aQuery: string): Promise<{ items: LookupItem[] }> { public async search(aQuery: string): Promise<{ items: LookupItem[] }> {
const items = await this.prismaService.symbolProfile.findMany({ const items = await this.prismaService.symbolProfile.findMany({
select: { select: {

@ -1,13 +1,13 @@
import { IDataProviderResponse } from '@ghostfolio/api/services/interfaces/interfaces'; import { SymbolProfile } from '@prisma/client';
export interface DataEnhancerInterface { export interface DataEnhancerInterface {
enhance({ enhance({
response, response,
symbol symbol
}: { }: {
response: IDataProviderResponse; response: Partial<SymbolProfile>;
symbol: string; symbol: string;
}): Promise<IDataProviderResponse>; }): Promise<Partial<SymbolProfile>>;
getName(): string; getName(): string;
} }

@ -4,23 +4,27 @@ import {
IDataProviderResponse IDataProviderResponse
} from '@ghostfolio/api/services/interfaces/interfaces'; } from '@ghostfolio/api/services/interfaces/interfaces';
import { Granularity } from '@ghostfolio/common/types'; import { Granularity } from '@ghostfolio/common/types';
import { DataSource } from '@prisma/client'; import { DataSource, SymbolProfile } from '@prisma/client';
export interface DataProviderInterface { export interface DataProviderInterface {
canHandle(symbol: string): boolean; canHandle(symbol: string): boolean;
get(aSymbols: string[]): Promise<{ [symbol: string]: IDataProviderResponse }>; getAssetProfile(aSymbol: string): Promise<Partial<SymbolProfile>>;
getHistorical( getHistorical(
aSymbols: string[], aSymbol: string,
aGranularity: Granularity, aGranularity: Granularity,
from: Date, from: Date,
to: Date to: Date
): Promise<{ ): Promise<{
[symbol: string]: { [date: string]: IDataProviderHistoricalResponse }; [symbol: string]: { [date: string]: IDataProviderHistoricalResponse };
}>; }>; // TODO: Return only one symbol
getName(): DataSource; getName(): DataSource;
getQuotes(
aSymbols: string[]
): Promise<{ [symbol: string]: IDataProviderResponse }>;
search(aQuery: string): Promise<{ items: LookupItem[] }>; search(aQuery: string): Promise<{ items: LookupItem[] }>;
} }

@ -6,7 +6,7 @@ import {
} from '@ghostfolio/api/services/interfaces/interfaces'; } from '@ghostfolio/api/services/interfaces/interfaces';
import { Granularity } from '@ghostfolio/common/types'; import { Granularity } from '@ghostfolio/common/types';
import { Injectable } from '@nestjs/common'; import { Injectable } from '@nestjs/common';
import { DataSource } from '@prisma/client'; import { DataSource, SymbolProfile } from '@prisma/client';
@Injectable() @Injectable()
export class ManualService implements DataProviderInterface { export class ManualService implements DataProviderInterface {
@ -16,14 +16,16 @@ export class ManualService implements DataProviderInterface {
return false; return false;
} }
public async get( public async getAssetProfile(
aSymbols: string[] aSymbol: string
): Promise<{ [symbol: string]: IDataProviderResponse }> { ): Promise<Partial<SymbolProfile>> {
return {}; return {
dataSource: this.getName()
};
} }
public async getHistorical( public async getHistorical(
aSymbols: string[], aSymbol: string,
aGranularity: Granularity = 'day', aGranularity: Granularity = 'day',
from: Date, from: Date,
to: Date to: Date
@ -37,6 +39,12 @@ export class ManualService implements DataProviderInterface {
return DataSource.MANUAL; return DataSource.MANUAL;
} }
public async getQuotes(
aSymbols: string[]
): Promise<{ [symbol: string]: IDataProviderResponse }> {
return {};
}
public async search(aQuery: string): Promise<{ items: LookupItem[] }> { public async search(aQuery: string): Promise<{ items: LookupItem[] }> {
return { items: [] }; return { items: [] };
} }

@ -1,19 +1,19 @@
import { LookupItem } from '@ghostfolio/api/app/symbol/interfaces/lookup-item.interface'; import { LookupItem } from '@ghostfolio/api/app/symbol/interfaces/lookup-item.interface';
import { ConfigurationService } from '@ghostfolio/api/services/configuration.service'; import { ConfigurationService } from '@ghostfolio/api/services/configuration.service';
import {
IDataProviderHistoricalResponse,
IDataProviderResponse,
MarketState
} from '@ghostfolio/api/services/interfaces/interfaces';
import { PrismaService } from '@ghostfolio/api/services/prisma.service'; import { PrismaService } from '@ghostfolio/api/services/prisma.service';
import { ghostfolioFearAndGreedIndexSymbol } from '@ghostfolio/common/config'; import { ghostfolioFearAndGreedIndexSymbol } from '@ghostfolio/common/config';
import { DATE_FORMAT, getToday, getYesterday } from '@ghostfolio/common/helper'; import { DATE_FORMAT, getToday, getYesterday } from '@ghostfolio/common/helper';
import { Granularity } from '@ghostfolio/common/types'; import { Granularity } from '@ghostfolio/common/types';
import { Injectable, Logger } from '@nestjs/common'; import { Injectable, Logger } from '@nestjs/common';
import { DataSource } from '@prisma/client'; import { DataSource, SymbolProfile } from '@prisma/client';
import * as bent from 'bent'; import * as bent from 'bent';
import { format, subMonths, subWeeks, subYears } from 'date-fns'; import { format, subMonths, subWeeks, subYears } from 'date-fns';
import {
IDataProviderHistoricalResponse,
IDataProviderResponse,
MarketState
} from '../../interfaces/interfaces';
import { DataProviderInterface } from '../interfaces/data-provider.interface'; import { DataProviderInterface } from '../interfaces/data-provider.interface';
@Injectable() @Injectable()
@ -29,50 +29,24 @@ export class RakutenRapidApiService implements DataProviderInterface {
return !!this.configurationService.get('RAKUTEN_RAPID_API_KEY'); return !!this.configurationService.get('RAKUTEN_RAPID_API_KEY');
} }
public async get( public async getAssetProfile(
aSymbols: string[] aSymbol: string
): Promise<{ [symbol: string]: IDataProviderResponse }> { ): Promise<Partial<SymbolProfile>> {
if (aSymbols.length <= 0) { return {
return {}; dataSource: this.getName()
} };
try {
const symbol = aSymbols[0];
if (symbol === ghostfolioFearAndGreedIndexSymbol) {
const fgi = await this.getFearAndGreedIndex();
return {
[ghostfolioFearAndGreedIndexSymbol]: {
currency: undefined,
dataSource: this.getName(),
marketPrice: fgi.now.value,
marketState: MarketState.open,
name: RakutenRapidApiService.FEAR_AND_GREED_INDEX_NAME
}
};
}
} catch (error) {
Logger.error(error);
}
return {};
} }
public async getHistorical( public async getHistorical(
aSymbols: string[], aSymbol: string,
aGranularity: Granularity = 'day', aGranularity: Granularity = 'day',
from: Date, from: Date,
to: Date to: Date
): Promise<{ ): Promise<{
[symbol: string]: { [date: string]: IDataProviderHistoricalResponse }; [symbol: string]: { [date: string]: IDataProviderHistoricalResponse };
}> { }> {
if (aSymbols.length <= 0) {
return {};
}
try { try {
const symbol = aSymbols[0]; const symbol = aSymbol;
if (symbol === ghostfolioFearAndGreedIndexSymbol) { if (symbol === ghostfolioFearAndGreedIndexSymbol) {
const fgi = await this.getFearAndGreedIndex(); const fgi = await this.getFearAndGreedIndex();
@ -129,6 +103,35 @@ export class RakutenRapidApiService implements DataProviderInterface {
return DataSource.RAKUTEN; return DataSource.RAKUTEN;
} }
public async getQuotes(
aSymbols: string[]
): Promise<{ [symbol: string]: IDataProviderResponse }> {
if (aSymbols.length <= 0) {
return {};
}
try {
const symbol = aSymbols[0];
if (symbol === ghostfolioFearAndGreedIndexSymbol) {
const fgi = await this.getFearAndGreedIndex();
return {
[ghostfolioFearAndGreedIndexSymbol]: {
currency: undefined,
dataSource: this.getName(),
marketPrice: fgi.now.value,
marketState: MarketState.open
}
};
}
} catch (error) {
Logger.error(error);
}
return {};
}
public async search(aQuery: string): Promise<{ items: LookupItem[] }> { public async search(aQuery: string): Promise<{ items: LookupItem[] }> {
return { items: [] }; return { items: [] };
} }

@ -1,32 +0,0 @@
export interface IYahooFinanceHistoricalResponse {
adjClose: number;
close: number;
date: Date;
high: number;
low: number;
open: number;
symbol: string;
volume: number;
}
export interface IYahooFinanceQuoteResponse {
price: IYahooFinancePrice;
summaryProfile: IYahooFinanceSummaryProfile;
}
export interface IYahooFinancePrice {
currency: string;
exchangeName: string;
longName: string;
marketState: string;
quoteType: string;
regularMarketPrice: number;
shortName: string;
}
export interface IYahooFinanceSummaryProfile {
country?: string;
industry?: string;
sector?: string;
website?: string;
}

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

@ -2,7 +2,7 @@ import { PROPERTY_CURRENCIES, baseCurrency } from '@ghostfolio/common/config';
import { DATE_FORMAT, getYesterday } from '@ghostfolio/common/helper'; import { DATE_FORMAT, getYesterday } from '@ghostfolio/common/helper';
import { Injectable, Logger } from '@nestjs/common'; import { Injectable, Logger } from '@nestjs/common';
import { format } from 'date-fns'; import { format } from 'date-fns';
import { isEmpty, isNumber, uniq } from 'lodash'; import { isNumber, uniq } from 'lodash';
import { DataProviderService } from './data-provider/data-provider.service'; import { DataProviderService } from './data-provider/data-provider.service';
import { IDataGatheringItem } from './interfaces/interfaces'; import { IDataGatheringItem } from './interfaces/interfaces';
@ -61,7 +61,7 @@ export class ExchangeRateDataService {
if (Object.keys(result).length !== this.currencyPairs.length) { if (Object.keys(result).length !== this.currencyPairs.length) {
// Load currencies directly from data provider as a fallback // Load currencies directly from data provider as a fallback
// if historical data is not fully available // if historical data is not fully available
const historicalData = await this.dataProviderService.get( const historicalData = await this.dataProviderService.getQuotes(
this.currencyPairs.map(({ dataSource, symbol }) => { this.currencyPairs.map(({ dataSource, symbol }) => {
return { dataSource, symbol }; return { dataSource, symbol };
}) })

@ -33,19 +33,10 @@ export interface IDataProviderHistoricalResponse {
} }
export interface IDataProviderResponse { export interface IDataProviderResponse {
assetClass?: AssetClass;
assetSubClass?: AssetSubClass;
countries?: { code: string; weight: number }[];
currency: string; currency: string;
dataSource: DataSource; dataSource: DataSource;
exchange?: string;
marketChange?: number;
marketChangePercent?: number;
marketPrice: number; marketPrice: number;
marketState: MarketState; marketState: MarketState;
name?: string;
sectors?: { name: string; weight: number }[];
url?: string;
} }
export interface IDataGatheringItem { export interface IDataGatheringItem {

@ -39,11 +39,6 @@
<div class="h6 m-0 text-truncate">{{ position?.name }}</div> <div class="h6 m-0 text-truncate">{{ position?.name }}</div>
<div class="d-flex"> <div class="d-flex">
<span>{{ position?.symbol | gfSymbol }}</span> <span>{{ position?.symbol | gfSymbol }}</span>
<span
*ngIf="position?.exchange && position?.exchange !== unknownKey"
class="ml-2 text-muted"
>({{ position.exchange }})</span
>
</div> </div>
<div class="d-flex mt-1"> <div class="d-flex mt-1">
<gf-value <gf-value

@ -117,7 +117,7 @@
"tslib": "2.0.0", "tslib": "2.0.0",
"twitter-api-v2": "1.10.3", "twitter-api-v2": "1.10.3",
"uuid": "8.3.2", "uuid": "8.3.2",
"yahoo-finance": "0.3.6", "yahoo-finance2": "2.1.9",
"zone.js": "0.11.4" "zone.js": "0.11.4"
}, },
"devDependencies": { "devDependencies": {

@ -0,0 +1,2 @@
-- AlterEnum
ALTER TYPE "AssetSubClass" ADD VALUE 'MUTUALFUND';

@ -0,0 +1,2 @@
-- AlterTable
ALTER TABLE "SymbolProfile" ADD COLUMN "url" TEXT;

@ -129,6 +129,7 @@ model SymbolProfile {
sectors Json? sectors Json?
symbol String symbol String
symbolMapping Json? symbolMapping Json?
url String?
@@unique([dataSource, symbol]) @@unique([dataSource, symbol])
} }
@ -178,6 +179,7 @@ enum AssetClass {
enum AssetSubClass { enum AssetSubClass {
CRYPTOCURRENCY CRYPTOCURRENCY
ETF ETF
MUTUALFUND
STOCK STOCK
} }

@ -5720,6 +5720,16 @@ ajv-keywords@^5.0.0:
dependencies: dependencies:
fast-deep-equal "^3.1.3" fast-deep-equal "^3.1.3"
ajv@8.10.0:
version "8.10.0"
resolved "https://registry.yarnpkg.com/ajv/-/ajv-8.10.0.tgz#e573f719bd3af069017e3b66538ab968d040e54d"
integrity sha512-bzqAEZOjkrUMl2afH8dknrq5KEk2SrwdBROR+vH1EKVQTqaUbJVPdc/gEdggTMM0Se+s+Ja4ju4TlNcStKl2Hw==
dependencies:
fast-deep-equal "^3.1.1"
json-schema-traverse "^1.0.0"
require-from-string "^2.0.2"
uri-js "^4.2.2"
ajv@8.6.3: ajv@8.6.3:
version "8.6.3" version "8.6.3"
resolved "https://registry.yarnpkg.com/ajv/-/ajv-8.6.3.tgz#11a66527761dc3e9a3845ea775d2d3c0414e8764" resolved "https://registry.yarnpkg.com/ajv/-/ajv-8.6.3.tgz#11a66527761dc3e9a3845ea775d2d3c0414e8764"
@ -6561,7 +6571,7 @@ blob-util@2.0.2:
resolved "https://registry.yarnpkg.com/blob-util/-/blob-util-2.0.2.tgz#3b4e3c281111bb7f11128518006cdc60b403a1eb" resolved "https://registry.yarnpkg.com/blob-util/-/blob-util-2.0.2.tgz#3b4e3c281111bb7f11128518006cdc60b403a1eb"
integrity sha512-T7JQa+zsXXEa6/8ZhHcQEW1UFfVM49Ts65uBkFL6fz2QmrElqmbajIDJvuA0tEhRe5eIjpV9ZF+0RfZR9voJFQ== integrity sha512-T7JQa+zsXXEa6/8ZhHcQEW1UFfVM49Ts65uBkFL6fz2QmrElqmbajIDJvuA0tEhRe5eIjpV9ZF+0RfZR9voJFQ==
bluebird@^3.3.5, bluebird@^3.4.6, bluebird@^3.5.0, bluebird@^3.5.5, bluebird@^3.7.1, bluebird@^3.7.2: bluebird@^3.3.5, bluebird@^3.5.5, bluebird@^3.7.1, bluebird@^3.7.2:
version "3.7.2" version "3.7.2"
resolved "https://registry.yarnpkg.com/bluebird/-/bluebird-3.7.2.tgz#9f229c15be272454ffa973ace0dbee79a1b0c36f" resolved "https://registry.yarnpkg.com/bluebird/-/bluebird-3.7.2.tgz#9f229c15be272454ffa973ace0dbee79a1b0c36f"
integrity sha512-XpNj6GDQzdfW+r2Wnn7xiSAd7TM3jzkxGXBGTtWKuSXv1xUV+azxAm8jdWZN06QTQk+2N2XB9jRDkvbmQmcRtg== integrity sha512-XpNj6GDQzdfW+r2Wnn7xiSAd7TM3jzkxGXBGTtWKuSXv1xUV+azxAm8jdWZN06QTQk+2N2XB9jRDkvbmQmcRtg==
@ -12976,7 +12986,7 @@ lodash.uniq@4.5.0:
resolved "https://registry.yarnpkg.com/lodash.uniq/-/lodash.uniq-4.5.0.tgz#d0225373aeb652adc1bc82e4945339a842754773" resolved "https://registry.yarnpkg.com/lodash.uniq/-/lodash.uniq-4.5.0.tgz#d0225373aeb652adc1bc82e4945339a842754773"
integrity sha1-0CJTc662Uq3BvILklFM5qEJ1R3M= integrity sha1-0CJTc662Uq3BvILklFM5qEJ1R3M=
lodash@4.17.21, lodash@4.x, lodash@^4.17.10, lodash@^4.17.11, lodash@^4.17.12, lodash@^4.17.13, lodash@^4.17.14, lodash@^4.17.15, lodash@^4.17.19, lodash@^4.17.2, lodash@^4.17.20, lodash@^4.17.21, lodash@^4.7.0: lodash@4.17.21, lodash@4.x, lodash@^4.17.10, lodash@^4.17.11, lodash@^4.17.12, lodash@^4.17.13, lodash@^4.17.14, lodash@^4.17.15, lodash@^4.17.19, lodash@^4.17.20, lodash@^4.17.21, lodash@^4.7.0:
version "4.17.21" version "4.17.21"
resolved "https://registry.yarnpkg.com/lodash/-/lodash-4.17.21.tgz#679591c564c3bffaae8454cf0b3df370c3d6911c" resolved "https://registry.yarnpkg.com/lodash/-/lodash-4.17.21.tgz#679591c564c3bffaae8454cf0b3df370c3d6911c"
integrity sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg== integrity sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg==
@ -13507,14 +13517,14 @@ mkdirp@^1.0.3, mkdirp@^1.0.4:
resolved "https://registry.yarnpkg.com/mkdirp/-/mkdirp-1.0.4.tgz#3eb5ed62622756d79a5f0e2a221dfebad75c2f7e" resolved "https://registry.yarnpkg.com/mkdirp/-/mkdirp-1.0.4.tgz#3eb5ed62622756d79a5f0e2a221dfebad75c2f7e"
integrity sha512-vVqVZQyf3WLx2Shd0qJ9xuvqgAyKPLAiqITEtqW0oIUjzo3PePDd6fW9iFz30ef7Ysp/oiWqbhszeGWW2T6Gzw== integrity sha512-vVqVZQyf3WLx2Shd0qJ9xuvqgAyKPLAiqITEtqW0oIUjzo3PePDd6fW9iFz30ef7Ysp/oiWqbhszeGWW2T6Gzw==
moment-timezone@^0.5.10, moment-timezone@^0.5.x: moment-timezone@^0.5.x:
version "0.5.33" version "0.5.33"
resolved "https://registry.yarnpkg.com/moment-timezone/-/moment-timezone-0.5.33.tgz#b252fd6bb57f341c9b59a5ab61a8e51a73bbd22c" resolved "https://registry.yarnpkg.com/moment-timezone/-/moment-timezone-0.5.33.tgz#b252fd6bb57f341c9b59a5ab61a8e51a73bbd22c"
integrity sha512-PTc2vcT8K9J5/9rDEPe5czSIKgLoGsH8UNpA4qZTVw0Vd/Uz19geE9abbIOQKaAQFcnQ3v5YEXrbSc5BpshH+w== integrity sha512-PTc2vcT8K9J5/9rDEPe5czSIKgLoGsH8UNpA4qZTVw0Vd/Uz19geE9abbIOQKaAQFcnQ3v5YEXrbSc5BpshH+w==
dependencies: dependencies:
moment ">= 2.9.0" moment ">= 2.9.0"
"moment@>= 2.9.0", moment@^2.17.1, moment@^2.27.0: "moment@>= 2.9.0", moment@^2.27.0:
version "2.29.1" version "2.29.1"
resolved "https://registry.yarnpkg.com/moment/-/moment-2.29.1.tgz#b2be769fa31940be9eeea6469c075e35006fa3d3" resolved "https://registry.yarnpkg.com/moment/-/moment-2.29.1.tgz#b2be769fa31940be9eeea6469c075e35006fa3d3"
integrity sha512-kHmoybcPV8Sqy59DwNDY3Jefr64lK/by/da0ViFcuA4DH0vQg5Q6Ze5VimxkfQNSC+Mls/Kx53s7TjP1RhFEDQ== integrity sha512-kHmoybcPV8Sqy59DwNDY3Jefr64lK/by/da0ViFcuA4DH0vQg5Q6Ze5VimxkfQNSC+Mls/Kx53s7TjP1RhFEDQ==
@ -13985,11 +13995,6 @@ nx@13.8.1:
dependencies: dependencies:
"@nrwl/cli" "13.8.1" "@nrwl/cli" "13.8.1"
oauth-sign@~0.9.0:
version "0.9.0"
resolved "https://registry.yarnpkg.com/oauth-sign/-/oauth-sign-0.9.0.tgz#47a7b016baa68b5fa0ecf3dee08a85c679ac6455"
integrity sha512-fexhUFFPTGV8ybAtSIGbV6gOkSv8UtRbDBnAyLQw4QPKkgNlsH2ByPGtMUqdWkos6YCRmAqViwgZrJc/mRDzZQ==
oauth@0.9.x: oauth@0.9.x:
version "0.9.15" version "0.9.15"
resolved "https://registry.yarnpkg.com/oauth/-/oauth-0.9.15.tgz#bd1fefaf686c96b75475aed5196412ff60cfb9c1" resolved "https://registry.yarnpkg.com/oauth/-/oauth-0.9.15.tgz#bd1fefaf686c96b75475aed5196412ff60cfb9c1"
@ -15952,49 +15957,6 @@ request-progress@^3.0.0:
dependencies: dependencies:
throttleit "^1.0.0" throttleit "^1.0.0"
request-promise-core@1.1.4:
version "1.1.4"
resolved "https://registry.yarnpkg.com/request-promise-core/-/request-promise-core-1.1.4.tgz#3eedd4223208d419867b78ce815167d10593a22f"
integrity sha512-TTbAfBBRdWD7aNNOoVOBH4pN/KigV6LyapYNNlAPA8JwbovRti1E88m3sYAwsLi5ryhPKsE9APwnjFTgdUjTpw==
dependencies:
lodash "^4.17.19"
request-promise@^4.2.1:
version "4.2.6"
resolved "https://registry.yarnpkg.com/request-promise/-/request-promise-4.2.6.tgz#7e7e5b9578630e6f598e3813c0f8eb342a27f0a2"
integrity sha512-HCHI3DJJUakkOr8fNoCc73E5nU5bqITjOYFMDrKHYOXWXrgD/SBaC7LjwuPymUprRyuF06UK7hd/lMHkmUXglQ==
dependencies:
bluebird "^3.5.0"
request-promise-core "1.1.4"
stealthy-require "^1.1.1"
tough-cookie "^2.3.3"
request@^2.79.0:
version "2.88.2"
resolved "https://registry.yarnpkg.com/request/-/request-2.88.2.tgz#d73c918731cb5a87da047e207234146f664d12b3"
integrity sha512-MsvtOrfG9ZcrOwAW+Qi+F6HbD0CWXEh9ou77uOb7FM2WPhwT7smM833PzanhJLsgXjN89Ir6V2PczXNnMpwKhw==
dependencies:
aws-sign2 "~0.7.0"
aws4 "^1.8.0"
caseless "~0.12.0"
combined-stream "~1.0.6"
extend "~3.0.2"
forever-agent "~0.6.1"
form-data "~2.3.2"
har-validator "~5.1.3"
http-signature "~1.2.0"
is-typedarray "~1.0.0"
isstream "~0.1.2"
json-stringify-safe "~5.0.1"
mime-types "~2.1.19"
oauth-sign "~0.9.0"
performance-now "^2.1.0"
qs "~6.5.2"
safe-buffer "^5.1.2"
tough-cookie "~2.5.0"
tunnel-agent "^0.6.0"
uuid "^3.3.2"
require-directory@^2.1.1: require-directory@^2.1.1:
version "2.1.1" version "2.1.1"
resolved "https://registry.yarnpkg.com/require-directory/-/require-directory-2.1.1.tgz#8c64ad5fd30dab1c976e2344ffe7f792a6a6df42" resolved "https://registry.yarnpkg.com/require-directory/-/require-directory-2.1.1.tgz#8c64ad5fd30dab1c976e2344ffe7f792a6a6df42"
@ -16884,11 +16846,6 @@ static-extend@^0.1.1:
resolved "https://registry.yarnpkg.com/statuses/-/statuses-1.5.0.tgz#161c7dac177659fd9811f43771fa99381478628c" resolved "https://registry.yarnpkg.com/statuses/-/statuses-1.5.0.tgz#161c7dac177659fd9811f43771fa99381478628c"
integrity sha1-Fhx9rBd2Wf2YEfQ3cfqZOBR4Yow= integrity sha1-Fhx9rBd2Wf2YEfQ3cfqZOBR4Yow=
stealthy-require@^1.1.1:
version "1.1.1"
resolved "https://registry.yarnpkg.com/stealthy-require/-/stealthy-require-1.1.1.tgz#35b09875b4ff49f26a777e509b3090a3226bf24b"
integrity sha1-NbCYdbT/SfJqd35QmzCQoyJr8ks=
store2@^2.12.0: store2@^2.12.0:
version "2.12.0" version "2.12.0"
resolved "https://registry.yarnpkg.com/store2/-/store2-2.12.0.tgz#e1f1b7e1a59b6083b2596a8d067f6ee88fd4d3cf" resolved "https://registry.yarnpkg.com/store2/-/store2-2.12.0.tgz#e1f1b7e1a59b6083b2596a8d067f6ee88fd4d3cf"
@ -17031,11 +16988,6 @@ string.prototype.trimstart@^1.0.4:
call-bind "^1.0.2" call-bind "^1.0.2"
define-properties "^1.1.3" define-properties "^1.1.3"
string@^3.3.3:
version "3.3.3"
resolved "https://registry.yarnpkg.com/string/-/string-3.3.3.tgz#5ea211cd92d228e184294990a6cc97b366a77cb0"
integrity sha1-XqIRzZLSKOGEKUmQpsyXs2anfLA=
string_decoder@^1.0.0, string_decoder@^1.1.1: string_decoder@^1.0.0, string_decoder@^1.1.1:
version "1.3.0" version "1.3.0"
resolved "https://registry.yarnpkg.com/string_decoder/-/string_decoder-1.3.0.tgz#42f114594a46cf1a8e30b0a84f56c78c3edac21e" resolved "https://registry.yarnpkg.com/string_decoder/-/string_decoder-1.3.0.tgz#42f114594a46cf1a8e30b0a84f56c78c3edac21e"
@ -17526,14 +17478,6 @@ toidentifier@1.0.0:
resolved "https://registry.yarnpkg.com/toidentifier/-/toidentifier-1.0.0.tgz#7e1be3470f1e77948bc43d94a3c8f4d7752ba553" resolved "https://registry.yarnpkg.com/toidentifier/-/toidentifier-1.0.0.tgz#7e1be3470f1e77948bc43d94a3c8f4d7752ba553"
integrity sha512-yaOH/Pk/VEhBWWTlhI+qXxDFXlejDGcQipMlyxda9nthulaxLZUNcUqFxokp0vcYnvteJln5FNQDRrxj3YcbVw== integrity sha512-yaOH/Pk/VEhBWWTlhI+qXxDFXlejDGcQipMlyxda9nthulaxLZUNcUqFxokp0vcYnvteJln5FNQDRrxj3YcbVw==
tough-cookie@^2.3.2, tough-cookie@^2.3.3, tough-cookie@~2.5.0:
version "2.5.0"
resolved "https://registry.yarnpkg.com/tough-cookie/-/tough-cookie-2.5.0.tgz#cd9fb2a0aa1d5a12b473bd9fb96fa3dcff65ade2"
integrity sha512-nlLsUzgm1kfLXSXfRZMc1KLAugd4hqJHDTvc2hDIwS3mZAfMEuMbc03SujMF+GEcpaX/qboeycw6iO8JwVv2+g==
dependencies:
psl "^1.1.28"
punycode "^2.1.1"
tough-cookie@^4.0.0: tough-cookie@^4.0.0:
version "4.0.0" version "4.0.0"
resolved "https://registry.yarnpkg.com/tough-cookie/-/tough-cookie-4.0.0.tgz#d822234eeca882f991f0f908824ad2622ddbece4" resolved "https://registry.yarnpkg.com/tough-cookie/-/tough-cookie-4.0.0.tgz#d822234eeca882f991f0f908824ad2622ddbece4"
@ -17543,6 +17487,14 @@ tough-cookie@^4.0.0:
punycode "^2.1.1" punycode "^2.1.1"
universalify "^0.1.2" universalify "^0.1.2"
tough-cookie@~2.5.0:
version "2.5.0"
resolved "https://registry.yarnpkg.com/tough-cookie/-/tough-cookie-2.5.0.tgz#cd9fb2a0aa1d5a12b473bd9fb96fa3dcff65ade2"
integrity sha512-nlLsUzgm1kfLXSXfRZMc1KLAugd4hqJHDTvc2hDIwS3mZAfMEuMbc03SujMF+GEcpaX/qboeycw6iO8JwVv2+g==
dependencies:
psl "^1.1.28"
punycode "^2.1.1"
tr46@^2.1.0: tr46@^2.1.0:
version "2.1.0" version "2.1.0"
resolved "https://registry.yarnpkg.com/tr46/-/tr46-2.1.0.tgz#fa87aa81ca5d5941da8cbf1f9b749dc969a4e240" resolved "https://registry.yarnpkg.com/tr46/-/tr46-2.1.0.tgz#fa87aa81ca5d5941da8cbf1f9b749dc969a4e240"
@ -18764,20 +18716,14 @@ y18n@^5.0.5:
resolved "https://registry.yarnpkg.com/y18n/-/y18n-5.0.8.tgz#7f4934d0f7ca8c56f95314939ddcd2dd91ce1d55" resolved "https://registry.yarnpkg.com/y18n/-/y18n-5.0.8.tgz#7f4934d0f7ca8c56f95314939ddcd2dd91ce1d55"
integrity sha512-0pfFzegeDWJHJIAmTLRP2DwHjdF5s7jo9tuztdQxAhINCdvS+3nGINqPd00AphqJR/0LhANUS6/+7SCb98YOfA== integrity sha512-0pfFzegeDWJHJIAmTLRP2DwHjdF5s7jo9tuztdQxAhINCdvS+3nGINqPd00AphqJR/0LhANUS6/+7SCb98YOfA==
yahoo-finance@0.3.6: yahoo-finance2@2.1.9:
version "0.3.6" version "2.1.9"
resolved "https://registry.yarnpkg.com/yahoo-finance/-/yahoo-finance-0.3.6.tgz#c99fe8ff6c9a80babbb7e75881a244a862f6739f" resolved "https://registry.yarnpkg.com/yahoo-finance2/-/yahoo-finance2-2.1.9.tgz#28b157e1cddc5b56e6b354f6b00b453a41bbe8a4"
integrity sha512-SyXGhtvJvoU8E7XQJzviCBeuJNAMZoERJLfWwAERfDDgoPCu3/zBDDDt7l8hp3HmtIygLpqGuRJ7jzkip2AcZA== integrity sha512-xLlDqcbK+4Y4oSV7Vq1KcvNcjMuODHQrk2uLyBR4SlXDNjRV7XFpTrwMrDnSLu4pErenj0gXG3ARiCWidFjqzg==
dependencies: dependencies:
bluebird "^3.4.6" ajv "8.10.0"
debug "^2.3.3" ajv-formats "2.1.1"
lodash "^4.17.2" node-fetch "^2.6.1"
moment "^2.17.1"
moment-timezone "^0.5.10"
request "^2.79.0"
request-promise "^4.2.1"
string "^3.3.3"
tough-cookie "^2.3.2"
yallist@^3.0.2: yallist@^3.0.2:
version "3.1.1" version "3.1.1"

Loading…
Cancel
Save