Browse Source

Fix portfolio and benchmark charts on non-trading days

pull/6970/head
Cadu 3 months ago
parent
commit
7544df5edf
  1. 83
      apps/api/src/app/endpoints/benchmarks/benchmarks.service.spec.ts
  2. 184
      apps/api/src/app/endpoints/benchmarks/benchmarks.service.ts
  3. 20
      apps/api/src/app/portfolio/calculator/portfolio-calculator.ts
  4. 21
      apps/api/src/app/portfolio/calculator/roai/portfolio-calculator.ts
  5. 40
      apps/api/src/app/portfolio/current-rate.service.spec.ts
  6. 73
      apps/api/src/app/portfolio/current-rate.service.ts
  7. 19
      apps/api/src/helper/portfolio.helper.spec.ts
  8. 25
      apps/api/src/helper/portfolio.helper.ts

83
apps/api/src/app/endpoints/benchmarks/benchmarks.service.spec.ts

@ -0,0 +1,83 @@
import { DataSource } from '@prisma/client';
import { BenchmarksService } from './benchmarks.service';
describe('BenchmarksService', () => {
it('forward-fills non-trading days and uses the live quote for the end date', async () => {
const fridayCloseDate = new Date(2021, 4, 28);
const saturdayDate = new Date(2021, 4, 29);
const sundayDate = new Date(2021, 4, 30);
const benchmarkService = {
calculateChangeInPercentage: (startValue: number, endValue: number) => {
return endValue / startValue - 1;
}
};
const exchangeRateDataService = {
getExchangeRatesByCurrency: jest.fn().mockResolvedValue({
USDUSD: {
'2021-05-29': 1,
'2021-05-30': 1
}
})
};
const marketDataService = {
marketDataItems: jest
.fn()
.mockResolvedValueOnce([])
.mockResolvedValueOnce([
{
date: fridayCloseDate,
marketPrice: 100
}
])
};
const portfolioService = {
getPerformance: jest.fn().mockResolvedValue({
chart: [{ date: '2021-05-29' }, { date: '2021-05-30' }]
})
};
const prismaService = {
symbolProfile: {
findFirst: jest.fn().mockResolvedValue({ currency: 'USD' })
}
};
const symbolService = {
get: jest.fn().mockResolvedValue({ marketPrice: 110 })
};
const service = new BenchmarksService(
benchmarkService as never,
exchangeRateDataService as never,
marketDataService as never,
portfolioService as never,
prismaService as never,
symbolService as never
);
const { marketData } = await service.getMarketDataForUser({
dataSource: DataSource.YAHOO,
dateRange: 'max',
endDate: sundayDate,
impersonationId: undefined,
startDate: saturdayDate,
symbol: 'SPY',
user: {
id: 'user-id',
settings: { settings: { baseCurrency: 'USD' } }
} as never
});
expect(marketData).toHaveLength(2);
expect(marketData[0]).toEqual({ date: '2021-05-29', value: 0 });
expect(marketData[1].date).toBe('2021-05-30');
expect(marketData[1].value).toBeCloseTo(10);
expect(
exchangeRateDataService.getExchangeRatesByCurrency
).toHaveBeenCalledWith({
currencies: ['USD'],
endDate: new Date(Date.UTC(2021, 4, 30)),
startDate: new Date(Date.UTC(2021, 4, 29)),
targetCurrency: 'USD'
});
});
});

184
apps/api/src/app/endpoints/benchmarks/benchmarks.service.ts

@ -3,6 +3,7 @@ import { SymbolService } from '@ghostfolio/api/app/symbol/symbol.service';
import { BenchmarkService } from '@ghostfolio/api/services/benchmark/benchmark.service';
import { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-data/exchange-rate-data.service';
import { MarketDataService } from '@ghostfolio/api/services/market-data/market-data.service';
import { PrismaService } from '@ghostfolio/api/services/prisma/prisma.service';
import { DATE_FORMAT, parseDate, resetHours } from '@ghostfolio/common/helper';
import {
AssetProfileIdentifier,
@ -12,6 +13,7 @@ import {
import { DateRange, UserWithSettings } from '@ghostfolio/common/types';
import { Injectable, Logger } from '@nestjs/common';
import { MarketData } from '@prisma/client';
import { format, isSameDay } from 'date-fns';
import { isNumber } from 'lodash';
@ -22,6 +24,7 @@ export class BenchmarksService {
private readonly exchangeRateDataService: ExchangeRateDataService,
private readonly marketDataService: MarketDataService,
private readonly portfolioService: PortfolioService,
private readonly prismaService: PrismaService,
private readonly symbolService: SymbolService
) {}
@ -56,7 +59,19 @@ export class BenchmarksService {
withExcludedAccounts
});
const [currentSymbolItem, marketDataItems] = await Promise.all([
const chartDates = chart.map(({ date }) => {
return format(parseDate(date), DATE_FORMAT);
});
const rangeStart = resetHours(startDate);
const rangeEnd = resetHours(endDate);
const [
currentSymbolItem,
marketDataItems,
startMarketDataItems,
symbolProfile
] = await Promise.all([
this.symbolService.get({
dataGatheringItem: {
dataSource,
@ -71,47 +86,95 @@ export class BenchmarksService {
dataSource,
symbol,
date: {
in: chart.map(({ date }) => {
return resetHours(parseDate(date));
})
gte: rangeStart,
lte: rangeEnd
}
}
}),
this.marketDataService.marketDataItems({
orderBy: {
date: 'desc'
},
take: 1,
where: {
dataSource,
symbol,
date: {
lte: rangeStart
}
}
}),
this.prismaService.symbolProfile.findFirst({
where: {
dataSource,
symbol
}
})
]);
const benchmarkCurrency =
currentSymbolItem?.currency ?? symbolProfile?.currency;
if (!benchmarkCurrency) {
Logger.error(
`No currency has been found for ${symbol} (${dataSource})`,
'BenchmarksService'
);
return { marketData };
}
const exchangeRates =
await this.exchangeRateDataService.getExchangeRatesByCurrency({
startDate,
currencies: [currentSymbolItem.currency],
startDate: rangeStart,
currencies: [benchmarkCurrency],
endDate: rangeEnd,
targetCurrency: userCurrency
});
const exchangeRateAtStartDate =
exchangeRates[`${currentSymbolItem.currency}${userCurrency}`]?.[
format(startDate, DATE_FORMAT)
];
const exchangeRateAtStartDate = this.getExchangeRateOnOrBefore({
currencyPair: `${benchmarkCurrency}${userCurrency}`,
date: rangeStart,
exchangeRates
});
const marketPriceAtStartDate = marketDataItems?.find(({ date }) => {
return isSameDay(date, startDate);
})?.marketPrice;
const marketDataItemsWithInitialValue = [
...startMarketDataItems,
...marketDataItems
];
const marketPriceAtStartDate = this.getMarketPriceOnOrBefore({
marketDataItems: marketDataItemsWithInitialValue,
targetDate: rangeStart
});
if (!marketPriceAtStartDate) {
if (!isNumber(marketPriceAtStartDate)) {
Logger.error(
`No historical market data has been found for ${symbol} (${dataSource}) at ${format(
startDate,
rangeStart,
DATE_FORMAT
)}`,
'BenchmarkService'
'BenchmarksService'
);
return { marketData };
}
for (const marketDataItem of marketDataItems) {
const exchangeRate =
exchangeRates[`${currentSymbolItem.currency}${userCurrency}`]?.[
format(marketDataItem.date, DATE_FORMAT)
];
for (const chartDate of chartDates) {
const targetDate = resetHours(parseDate(chartDate));
const marketPrice = this.getMarketPriceOnOrBefore({
marketDataItems: marketDataItemsWithInitialValue,
targetDate
});
if (!isNumber(marketPrice)) {
continue;
}
const exchangeRate = this.getExchangeRateOnOrBefore({
currencyPair: `${benchmarkCurrency}${userCurrency}`,
date: targetDate,
exchangeRates
});
const exchangeRateFactor =
isNumber(exchangeRateAtStartDate) && isNumber(exchangeRate)
@ -119,45 +182,98 @@ export class BenchmarksService {
: 1;
marketData.push({
date: format(marketDataItem.date, DATE_FORMAT),
date: chartDate,
value:
marketPriceAtStartDate === 0
? 0
: this.benchmarkService.calculateChangeInPercentage(
marketPriceAtStartDate,
marketDataItem.marketPrice * exchangeRateFactor
marketPrice * exchangeRateFactor
) * 100
});
}
const includesEndDate = isSameDay(
parseDate(marketData.at(-1).date),
endDate
);
const endDateIndex = marketData.findIndex(({ date }) => {
return isSameDay(parseDate(date), endDate);
});
if (currentSymbolItem?.marketPrice && !includesEndDate) {
const exchangeRate =
exchangeRates[`${currentSymbolItem.currency}${userCurrency}`]?.[
format(endDate, DATE_FORMAT)
];
if (currentSymbolItem?.marketPrice) {
const exchangeRate = this.getExchangeRateOnOrBefore({
currencyPair: `${benchmarkCurrency}${userCurrency}`,
date: resetHours(endDate),
exchangeRates
});
const exchangeRateFactor =
isNumber(exchangeRateAtStartDate) && isNumber(exchangeRate)
? exchangeRate / exchangeRateAtStartDate
: 1;
marketData.push({
const endDateMarketData = {
date: format(endDate, DATE_FORMAT),
value:
this.benchmarkService.calculateChangeInPercentage(
marketPriceAtStartDate,
currentSymbolItem.marketPrice * exchangeRateFactor
) * 100
});
};
if (endDateIndex >= 0) {
marketData[endDateIndex] = endDateMarketData;
} else {
marketData.push(endDateMarketData);
}
}
return {
marketData
};
}
private getExchangeRateOnOrBefore({
currencyPair,
date,
exchangeRates
}: {
currencyPair: string;
date: Date;
exchangeRates: Record<string, Record<string, number>>;
}) {
const ratesByDate = exchangeRates[currencyPair] ?? {};
const targetDateString = format(date, DATE_FORMAT);
let latestDate: string | undefined;
let latestRate: number | undefined;
for (const [dateString, rate] of Object.entries(ratesByDate)) {
if (
dateString <= targetDateString &&
(!latestDate || dateString > latestDate)
) {
latestDate = dateString;
latestRate = rate;
}
}
return latestRate;
}
private getMarketPriceOnOrBefore({
marketDataItems,
targetDate
}: {
marketDataItems: MarketData[];
targetDate: Date;
}) {
let latestMarketPrice: number | undefined;
for (const { date, marketPrice } of marketDataItems) {
if (date <= targetDate) {
latestMarketPrice = marketPrice;
} else {
break;
}
}
return latestMarketPrice;
}
}

20
apps/api/src/app/portfolio/calculator/portfolio-calculator.ts

@ -4,7 +4,10 @@ import { PortfolioSnapshotValue } from '@ghostfolio/api/app/portfolio/interfaces
import { TransactionPointSymbol } from '@ghostfolio/api/app/portfolio/interfaces/transaction-point-symbol.interface';
import { TransactionPoint } from '@ghostfolio/api/app/portfolio/interfaces/transaction-point.interface';
import { RedisCacheService } from '@ghostfolio/api/app/redis-cache/redis-cache.service';
import { getFactor } from '@ghostfolio/api/helper/portfolio.helper';
import {
getFactor,
getLatestMarketPriceOnOrBefore
} from '@ghostfolio/api/helper/portfolio.helper';
import { LogPerformance } from '@ghostfolio/api/interceptors/performance-logging/performance-logging.interceptor';
import { ConfigurationService } from '@ghostfolio/api/services/configuration/configuration.service';
import { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-data/exchange-rate-data.service';
@ -276,6 +279,21 @@ export abstract class PortfolioCalculator {
const endDateString = format(this.endDate, DATE_FORMAT);
for (const { symbol } of dataGatheringItems) {
if (!marketSymbolMap[endDateString]?.[symbol]) {
const latestMarketPrice = getLatestMarketPriceOnOrBefore({
dateString: endDateString,
marketSymbolMap,
symbol
});
if (latestMarketPrice) {
marketSymbolMap[endDateString] ??= {};
marketSymbolMap[endDateString][symbol] = latestMarketPrice;
}
}
}
const daysInMarket = differenceInDays(this.endDate, this.startDate);
const chartDateMap = this.getChartDateMap({

21
apps/api/src/app/portfolio/calculator/roai/portfolio-calculator.ts

@ -1,6 +1,9 @@
import { PortfolioCalculator } from '@ghostfolio/api/app/portfolio/calculator/portfolio-calculator';
import { PortfolioOrderItem } from '@ghostfolio/api/app/portfolio/interfaces/portfolio-order-item.interface';
import { getFactor } from '@ghostfolio/api/helper/portfolio.helper';
import {
getFactor,
getLatestMarketPriceOnOrBefore
} from '@ghostfolio/api/helper/portfolio.helper';
import { getIntervalFromDateRange } from '@ghostfolio/common/calculation-helper';
import { DATE_FORMAT } from '@ghostfolio/common/helper';
import {
@ -243,8 +246,20 @@ export class RoaiPortfolioCalculator extends PortfolioCalculator {
const endDateString = format(end, DATE_FORMAT);
const startDateString = format(start, DATE_FORMAT);
const unitPriceAtStartDate = marketSymbolMap[startDateString]?.[symbol];
let unitPriceAtEndDate = marketSymbolMap[endDateString]?.[symbol];
const unitPriceAtStartDate =
marketSymbolMap[startDateString]?.[symbol] ??
getLatestMarketPriceOnOrBefore({
dateString: startDateString,
marketSymbolMap,
symbol
});
let unitPriceAtEndDate =
marketSymbolMap[endDateString]?.[symbol] ??
getLatestMarketPriceOnOrBefore({
dateString: endDateString,
marketSymbolMap,
symbol
});
let latestActivity = orders.at(-1);

40
apps/api/src/app/portfolio/current-rate.service.spec.ts

@ -1,6 +1,7 @@
import { DataProviderService } from '@ghostfolio/api/services/data-provider/data-provider.service';
import { MarketDataService } from '@ghostfolio/api/services/market-data/market-data.service';
import { PropertyService } from '@ghostfolio/api/services/property/property.service';
import { resetHours } from '@ghostfolio/common/helper';
import { AssetProfileIdentifier } from '@ghostfolio/common/interfaces';
import { DataSource, MarketData } from '@prisma/client';
@ -149,4 +150,43 @@ describe('CurrentRateService', () => {
]
});
});
it('getValues should fallback to the latest historical price if live quote is missing', async () => {
jest.spyOn(dataProviderService, 'getQuotes').mockResolvedValueOnce({});
const today = resetHours(new Date());
const tomorrow = new Date(today);
tomorrow.setDate(tomorrow.getDate() + 1);
const yesterday = new Date(today);
yesterday.setDate(yesterday.getDate() - 1);
jest.spyOn(marketDataService, 'getRange').mockResolvedValueOnce([
{
createdAt: yesterday,
dataSource: DataSource.YAHOO,
date: yesterday,
id: '082d6893-df27-4c91-8a5d-092e84315b56',
marketPrice: 1847.839966,
state: 'CLOSE',
symbol: 'AMZN'
}
]);
const response = await currentRateService.getValues({
dataGatheringItems: [{ dataSource: DataSource.YAHOO, symbol: 'AMZN' }],
dateQuery: {
gte: new Date(Date.UTC(2020, 0, 1, 0, 0, 0)),
lt: tomorrow
}
});
expect(response.errors).toEqual([]);
const todayPrice = response.values.find(
({ date, symbol }) =>
symbol === 'AMZN' && date.getTime() === today.getTime()
);
expect(todayPrice?.marketPrice).toBe(1847.839966);
});
});

73
apps/api/src/app/portfolio/current-rate.service.ts

@ -123,58 +123,55 @@ export class CurrentRateService {
};
if (!isEmpty(quoteErrors)) {
const unresolvedErrors: ResponseError['errors'] = [];
for (const { dataSource, symbol } of quoteErrors) {
try {
// If missing quote, fallback to the latest available historical market price
let value: GetValueObject = response.values.find((currentValue) => {
return (
currentValue.dataSource === dataSource &&
currentValue.symbol === symbol &&
isToday(currentValue.date)
);
});
const latestHistoricalValue = response.values
.filter((currentValue) => {
return (
currentValue.dataSource === dataSource &&
isBefore(currentValue.date, today) &&
currentValue.marketPrice > 0 &&
currentValue.symbol === symbol
);
})
.sort((a, b) => b.date.getTime() - a.date.getTime())[0];
let marketPrice = latestHistoricalValue?.marketPrice ?? 0;
if (!value) {
// Fallback to unit price of latest activity
if (marketPrice <= 0) {
const latestActivity =
await this.activitiesService.getLatestActivity({
dataSource,
symbol
});
value = {
marketPrice = latestActivity?.unitPrice ?? 0;
}
if (marketPrice > 0) {
response.values.push({
dataSource,
symbol,
date: today,
marketPrice: latestActivity?.unitPrice ?? 0
};
response.values.push(value);
}
const [latestValue] = response.values
.filter((currentValue) => {
return (
currentValue.dataSource === dataSource &&
currentValue.marketPrice &&
currentValue.symbol === symbol
);
})
.sort((a, b) => {
if (a.date < b.date) {
return 1;
}
if (a.date > b.date) {
return -1;
}
return 0;
marketPrice
});
value.marketPrice = latestValue.marketPrice;
} catch {}
} else {
unresolvedErrors.push({ dataSource, symbol });
}
} catch {
unresolvedErrors.push({ dataSource, symbol });
}
}
response.errors = unresolvedErrors;
response.values = uniqBy(
response.values,
({ dataSource, date, symbol }) => {
return `${date}-${getAssetProfileIdentifier({ dataSource, symbol })}`;
}
);
}
return response;

19
apps/api/src/helper/portfolio.helper.spec.ts

@ -0,0 +1,19 @@
import { Big } from 'big.js';
import { getLatestMarketPriceOnOrBefore } from './portfolio.helper';
describe('getLatestMarketPriceOnOrBefore', () => {
it('returns the latest available price without using future values', () => {
expect(
getLatestMarketPriceOnOrBefore({
dateString: '2026-05-31',
marketSymbolMap: {
'2026-06-01': { SPY: new Big(103) },
'2026-05-29': { SPY: new Big(101) },
'2026-05-28': { SPY: new Big(100) }
},
symbol: 'SPY'
})
).toEqual(new Big(101));
});
});

25
apps/api/src/helper/portfolio.helper.ts

@ -1,4 +1,29 @@
import { Type as ActivityType } from '@prisma/client';
import { Big } from 'big.js';
export function getLatestMarketPriceOnOrBefore({
dateString,
marketSymbolMap,
symbol
}: {
dateString: string;
marketSymbolMap: { [date: string]: { [symbol: string]: Big } };
symbol: string;
}): Big | undefined {
let latestDate: string | undefined;
let latestPrice: Big | undefined;
for (const date of Object.keys(marketSymbolMap)) {
const price = marketSymbolMap[date]?.[symbol];
if (date <= dateString && price && (!latestDate || date > latestDate)) {
latestDate = date;
latestPrice = price;
}
}
return latestPrice;
}
export function getFactor(activityType: ActivityType) {
let factor: number;

Loading…
Cancel
Save