Feature/increase robustness if live data is missing (#1884)

* Continuously persist today's market data

* Add fallback to historical market data if data provider does not provide live data

* Update changelog
pull/1886/head
Thomas Kaul 2 years ago committed by GitHub
parent 32956ae04c
commit aafedd5f75
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23

@ -7,6 +7,14 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
## Unreleased ## Unreleased
### Added
- Added a fallback to historical market data if a data provider does not provide live data
### Changed
- Persisted today's market data continuously
### Fixed ### Fixed
- Fixed the alignment of the performance column header in the holdings table - Fixed the alignment of the performance column header in the holdings table

@ -1,9 +1,9 @@
import { parseDate, resetHours } from '@ghostfolio/common/helper'; import { parseDate, resetHours } from '@ghostfolio/common/helper';
import { DataProviderInfo } from '@ghostfolio/common/interfaces';
import { addDays, endOfDay, isBefore, isSameDay } from 'date-fns'; import { addDays, endOfDay, isBefore, isSameDay } from 'date-fns';
import { GetValueObject } from './interfaces/get-value-object.interface'; import { GetValueObject } from './interfaces/get-value-object.interface';
import { GetValuesParams } from './interfaces/get-values-params.interface'; import { GetValuesParams } from './interfaces/get-values-params.interface';
import { GetValuesObject } from './interfaces/get-values-object.interface';
function mockGetValue(symbol: string, date: Date) { function mockGetValue(symbol: string, date: Date) {
switch (symbol) { switch (symbol) {
@ -49,11 +49,9 @@ export const CurrentRateServiceMock = {
getValues: ({ getValues: ({
dataGatheringItems, dataGatheringItems,
dateQuery dateQuery
}: GetValuesParams): Promise<{ }: GetValuesParams): Promise<GetValuesObject> => {
dataProviderInfos: DataProviderInfo[];
values: GetValueObject[];
}> => {
const values: GetValueObject[] = []; const values: GetValueObject[] = [];
if (dateQuery.lt) { if (dateQuery.lt) {
for ( for (
let date = resetHours(dateQuery.gte); let date = resetHours(dateQuery.gte);
@ -85,6 +83,7 @@ export const CurrentRateServiceMock = {
} }
} }
} }
return Promise.resolve({ values, dataProviderInfos: [] });
return Promise.resolve({ values, dataProviderInfos: [], errors: [] });
} }
}; };

@ -1,12 +1,11 @@
import { DataProviderService } from '@ghostfolio/api/services/data-provider/data-provider.service'; import { DataProviderService } from '@ghostfolio/api/services/data-provider/data-provider.service';
import { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-data.service'; import { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-data.service';
import { MarketDataService } from '@ghostfolio/api/services/market-data.service'; import { MarketDataService } from '@ghostfolio/api/services/market-data.service';
import { DataProviderInfo } from '@ghostfolio/common/interfaces'; import { PropertyService } from '@ghostfolio/api/services/property/property.service';
import { DataSource, MarketData } from '@prisma/client'; import { DataSource, MarketData } from '@prisma/client';
import { CurrentRateService } from './current-rate.service'; import { CurrentRateService } from './current-rate.service';
import { GetValueObject } from './interfaces/get-value-object.interface'; import { GetValuesObject } from './interfaces/get-values-object.interface';
import { PropertyService } from '@ghostfolio/api/services/property/property.service';
jest.mock('@ghostfolio/api/services/market-data.service', () => { jest.mock('@ghostfolio/api/services/market-data.service', () => {
return { return {
@ -123,21 +122,14 @@ describe('CurrentRateService', () => {
}, },
userCurrency: 'CHF' userCurrency: 'CHF'
}) })
).toMatchObject<{ ).toMatchObject<GetValuesObject>({
dataProviderInfos: DataProviderInfo[];
values: GetValueObject[];
}>({
dataProviderInfos: [], dataProviderInfos: [],
errors: [],
values: [ values: [
{ {
date: undefined, date: undefined,
marketPriceInBaseCurrency: 1841.823902, marketPriceInBaseCurrency: 1841.823902,
symbol: 'AMZN' symbol: 'AMZN'
},
{
date: undefined,
marketPriceInBaseCurrency: 1847.839966,
symbol: 'AMZN'
} }
] ]
}); });

@ -2,13 +2,14 @@ import { DataProviderService } from '@ghostfolio/api/services/data-provider/data
import { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-data.service'; import { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-data.service';
import { MarketDataService } from '@ghostfolio/api/services/market-data.service'; import { MarketDataService } from '@ghostfolio/api/services/market-data.service';
import { resetHours } from '@ghostfolio/common/helper'; import { resetHours } from '@ghostfolio/common/helper';
import { DataProviderInfo } from '@ghostfolio/common/interfaces'; import { DataProviderInfo, ResponseError } from '@ghostfolio/common/interfaces';
import { Injectable } from '@nestjs/common'; import { Injectable } from '@nestjs/common';
import { isBefore, isToday } from 'date-fns'; import { isBefore, isToday } from 'date-fns';
import { flatten } from 'lodash'; import { flatten, isEmpty, uniqBy } from 'lodash';
import { GetValueObject } from './interfaces/get-value-object.interface'; import { GetValueObject } from './interfaces/get-value-object.interface';
import { GetValuesParams } from './interfaces/get-values-params.interface'; import { GetValuesParams } from './interfaces/get-values-params.interface';
import { GetValuesObject } from './interfaces/get-values-object.interface';
@Injectable() @Injectable()
export class CurrentRateService { export class CurrentRateService {
@ -23,10 +24,7 @@ export class CurrentRateService {
dataGatheringItems, dataGatheringItems,
dateQuery, dateQuery,
userCurrency userCurrency
}: GetValuesParams): Promise<{ }: GetValuesParams): Promise<GetValuesObject> {
dataProviderInfos: DataProviderInfo[];
values: GetValueObject[];
}> {
const dataProviderInfos: DataProviderInfo[] = []; const dataProviderInfos: DataProviderInfo[] = [];
const includeToday = const includeToday =
(!dateQuery.lt || isBefore(new Date(), dateQuery.lt)) && (!dateQuery.lt || isBefore(new Date(), dateQuery.lt)) &&
@ -34,9 +32,10 @@ export class CurrentRateService {
(!dateQuery.in || this.containsToday(dateQuery.in)); (!dateQuery.in || this.containsToday(dateQuery.in));
const promises: Promise<GetValueObject[]>[] = []; const promises: Promise<GetValueObject[]>[] = [];
const quoteErrors: ResponseError['errors'] = [];
const today = resetHours(new Date());
if (includeToday) { if (includeToday) {
const today = resetHours(new Date());
promises.push( promises.push(
this.dataProviderService this.dataProviderService
.getQuotes(dataGatheringItems) .getQuotes(dataGatheringItems)
@ -51,18 +50,26 @@ export class CurrentRateService {
); );
} }
result.push({ if (dataResultProvider?.[dataGatheringItem.symbol]?.marketPrice) {
date: today, result.push({
marketPriceInBaseCurrency: date: today,
this.exchangeRateDataService.toCurrency( marketPriceInBaseCurrency:
dataResultProvider?.[dataGatheringItem.symbol] this.exchangeRateDataService.toCurrency(
?.marketPrice ?? 0, dataResultProvider?.[dataGatheringItem.symbol]
dataResultProvider?.[dataGatheringItem.symbol]?.currency, ?.marketPrice,
userCurrency dataResultProvider?.[dataGatheringItem.symbol]?.currency,
), userCurrency
symbol: dataGatheringItem.symbol ),
}); symbol: dataGatheringItem.symbol
});
} else {
quoteErrors.push({
dataSource: dataGatheringItem.dataSource,
symbol: dataGatheringItem.symbol
});
}
} }
return result; return result;
}) })
); );
@ -94,10 +101,60 @@ export class CurrentRateService {
}) })
); );
return { const values = flatten(await Promise.all(promises));
const response: GetValuesObject = {
dataProviderInfos, dataProviderInfos,
values: flatten(await Promise.all(promises)) errors: quoteErrors.map(({ dataSource, symbol }) => {
return { dataSource, symbol };
}),
values: uniqBy(values, ({ date, symbol }) => `${date}-${symbol}`)
}; };
if (!isEmpty(quoteErrors)) {
for (const { symbol } of quoteErrors) {
try {
// If missing quote, fallback to the latest available historical market price
let value: GetValueObject = response.values.find((currentValue) => {
return currentValue.symbol === symbol && isToday(currentValue.date);
});
if (!value) {
value = {
symbol,
date: today,
marketPriceInBaseCurrency: 0
};
response.values.push(value);
}
const [latestValue] = response.values
.filter((currentValue) => {
return (
currentValue.symbol === symbol &&
currentValue.marketPriceInBaseCurrency
);
})
.sort((a, b) => {
if (a.date < b.date) {
return 1;
}
if (a.date > b.date) {
return -1;
}
return 0;
});
value.marketPriceInBaseCurrency =
latestValue.marketPriceInBaseCurrency;
} catch {}
}
}
return response;
} }
private containsToday(dates: Date[]): boolean { private containsToday(dates: Date[]): boolean {

@ -0,0 +1,9 @@
import { DataProviderInfo, ResponseError } from '@ghostfolio/common/interfaces';
import { GetValueObject } from './get-value-object.interface';
export interface GetValuesObject {
dataProviderInfos: DataProviderInfo[];
errors: ResponseError['errors'];
values: GetValueObject[];
}

@ -24,9 +24,10 @@ import {
isSameYear, isSameYear,
max, max,
min, min,
set set,
subDays
} from 'date-fns'; } from 'date-fns';
import { first, flatten, isNumber, last, sortBy } from 'lodash'; import { first, flatten, isNumber, last, sortBy, uniq } from 'lodash';
import { CurrentRateService } from './current-rate.service'; import { CurrentRateService } from './current-rate.service';
import { CurrentPositions } from './interfaces/current-positions.interface'; import { CurrentPositions } from './interfaces/current-positions.interface';
@ -360,7 +361,7 @@ export class PortfolioCalculator {
let firstTransactionPoint: TransactionPoint = null; let firstTransactionPoint: TransactionPoint = null;
let firstIndex = transactionPointsBeforeEndDate.length; let firstIndex = transactionPointsBeforeEndDate.length;
const dates = []; let dates = [];
const dataGatheringItems: IDataGatheringItem[] = []; const dataGatheringItems: IDataGatheringItem[] = [];
const currencies: { [symbol: string]: string } = {}; const currencies: { [symbol: string]: string } = {};
@ -389,15 +390,37 @@ export class PortfolioCalculator {
dates.push(resetHours(end)); dates.push(resetHours(end));
const { dataProviderInfos, values: marketSymbols } = // Add dates of last week for fallback
await this.currentRateService.getValues({ dates.push(subDays(resetHours(new Date()), 7));
currencies, dates.push(subDays(resetHours(new Date()), 6));
dataGatheringItems, dates.push(subDays(resetHours(new Date()), 5));
dateQuery: { dates.push(subDays(resetHours(new Date()), 4));
in: dates dates.push(subDays(resetHours(new Date()), 3));
}, dates.push(subDays(resetHours(new Date()), 2));
userCurrency: this.currency dates.push(subDays(resetHours(new Date()), 1));
}); dates.push(resetHours(new Date()));
dates = uniq(
dates.map((date) => {
return date.getTime();
})
).map((timestamp) => {
return new Date(timestamp);
});
dates.sort((a, b) => a.getTime() - b.getTime());
const {
dataProviderInfos,
errors: currentRateErrors,
values: marketSymbols
} = await this.currentRateService.getValues({
currencies,
dataGatheringItems,
dateQuery: {
in: dates
},
userCurrency: this.currency
});
this.dataProviderInfos = dataProviderInfos; this.dataProviderInfos = dataProviderInfos;
@ -472,7 +495,13 @@ export class PortfolioCalculator {
transactionCount: item.transactionCount transactionCount: item.transactionCount
}); });
if (hasErrors && item.investment.gt(0)) { if (
(hasErrors ||
currentRateErrors.find(({ dataSource, symbol }) => {
return dataSource === item.dataSource && symbol === item.symbol;
})) &&
item.investment.gt(0)
) {
errors.push({ dataSource: item.dataSource, symbol: item.symbol }); errors.push({ dataSource: item.dataSource, symbol: item.symbol });
} }
} }

@ -7,13 +7,13 @@ import {
IDataProviderResponse IDataProviderResponse
} from '@ghostfolio/api/services/interfaces/interfaces'; } from '@ghostfolio/api/services/interfaces/interfaces';
import { PrismaService } from '@ghostfolio/api/services/prisma.service'; import { PrismaService } from '@ghostfolio/api/services/prisma.service';
import { DATE_FORMAT } from '@ghostfolio/common/helper'; import { DATE_FORMAT, getStartOfUtcDate } from '@ghostfolio/common/helper';
import { UserWithSettings } from '@ghostfolio/common/types'; import { UserWithSettings } from '@ghostfolio/common/types';
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, SymbolProfile } 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, isNumber } from 'lodash';
import { PropertyService } from '@ghostfolio/api/services/property/property.service'; import { PropertyService } from '@ghostfolio/api/services/property/property.service';
import { PROPERTY_DATA_SOURCE_MAPPING } from '@ghostfolio/common/config'; import { PROPERTY_DATA_SOURCE_MAPPING } from '@ghostfolio/common/config';
@ -241,7 +241,7 @@ export class DataProviderService {
const promise = Promise.resolve(dataProvider.getQuotes(symbolsChunk)); const promise = Promise.resolve(dataProvider.getQuotes(symbolsChunk));
promises.push( promises.push(
promise.then((result) => { promise.then(async (result) => {
for (const [symbol, dataProviderResponse] of Object.entries( for (const [symbol, dataProviderResponse] of Object.entries(
result result
)) { )) {
@ -256,6 +256,38 @@ export class DataProviderService {
1000 1000
).toFixed(3)} seconds` ).toFixed(3)} seconds`
); );
try {
const date = getStartOfUtcDate(new Date());
// Upsert quotes by imitating missing upsertMany functionality
// with $transaction
const upsertPromises = Object.keys(response)
.filter((symbol) => {
return (
isNumber(response[symbol].marketPrice) &&
response[symbol].marketPrice > 0
);
})
.map((symbol) =>
this.prismaService.marketData.upsert({
create: {
date,
symbol,
dataSource: response[symbol].dataSource,
marketPrice: response[symbol].marketPrice
},
update: {
marketPrice: response[symbol].marketPrice
},
where: {
date_symbol: { date, symbol }
}
})
);
await this.prismaService.$transaction(upsertPromises);
} catch {}
}) })
); );
} }

@ -152,6 +152,13 @@ export function getNumberFormatGroup(aLocale?: string) {
}).value; }).value;
} }
export function getStartOfUtcDate(aDate: Date) {
const date = new Date(aDate);
date.setUTCHours(0, 0, 0, 0);
return date;
}
export function getSum(aArray: Big[]) { export function getSum(aArray: Big[]) {
if (aArray?.length > 0) { if (aArray?.length > 0) {
return aArray.reduce((a, b) => a.plus(b), new Big(0)); return aArray.reduce((a, b) => a.plus(b), new Big(0));

Loading…
Cancel
Save