From 7544df5edf1bd80cc0ed52ed93ea4b3324704734 Mon Sep 17 00:00:00 2001 From: Cadu Date: Sun, 31 May 2026 09:42:14 -0300 Subject: [PATCH] Fix portfolio and benchmark charts on non-trading days --- .../benchmarks/benchmarks.service.spec.ts | 83 ++++++++ .../benchmarks/benchmarks.service.ts | 184 ++++++++++++++---- .../calculator/portfolio-calculator.ts | 20 +- .../calculator/roai/portfolio-calculator.ts | 21 +- .../portfolio/current-rate.service.spec.ts | 40 ++++ .../src/app/portfolio/current-rate.service.ts | 73 ++++--- apps/api/src/helper/portfolio.helper.spec.ts | 19 ++ apps/api/src/helper/portfolio.helper.ts | 25 +++ 8 files changed, 389 insertions(+), 76 deletions(-) create mode 100644 apps/api/src/app/endpoints/benchmarks/benchmarks.service.spec.ts create mode 100644 apps/api/src/helper/portfolio.helper.spec.ts diff --git a/apps/api/src/app/endpoints/benchmarks/benchmarks.service.spec.ts b/apps/api/src/app/endpoints/benchmarks/benchmarks.service.spec.ts new file mode 100644 index 000000000..582dc2921 --- /dev/null +++ b/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' + }); + }); +}); diff --git a/apps/api/src/app/endpoints/benchmarks/benchmarks.service.ts b/apps/api/src/app/endpoints/benchmarks/benchmarks.service.ts index 03ff32c21..45a9c5061 100644 --- a/apps/api/src/app/endpoints/benchmarks/benchmarks.service.ts +++ b/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>; + }) { + 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; + } } diff --git a/apps/api/src/app/portfolio/calculator/portfolio-calculator.ts b/apps/api/src/app/portfolio/calculator/portfolio-calculator.ts index d57b85d8c..d24caba29 100644 --- a/apps/api/src/app/portfolio/calculator/portfolio-calculator.ts +++ b/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({ diff --git a/apps/api/src/app/portfolio/calculator/roai/portfolio-calculator.ts b/apps/api/src/app/portfolio/calculator/roai/portfolio-calculator.ts index 2841e9975..02f9553de 100644 --- a/apps/api/src/app/portfolio/calculator/roai/portfolio-calculator.ts +++ b/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); diff --git a/apps/api/src/app/portfolio/current-rate.service.spec.ts b/apps/api/src/app/portfolio/current-rate.service.spec.ts index 5f2358679..bd42cc2b2 100644 --- a/apps/api/src/app/portfolio/current-rate.service.spec.ts +++ b/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); + }); }); diff --git a/apps/api/src/app/portfolio/current-rate.service.ts b/apps/api/src/app/portfolio/current-rate.service.ts index f0a451975..330bcb687 100644 --- a/apps/api/src/app/portfolio/current-rate.service.ts +++ b/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; diff --git a/apps/api/src/helper/portfolio.helper.spec.ts b/apps/api/src/helper/portfolio.helper.spec.ts new file mode 100644 index 000000000..488a374ee --- /dev/null +++ b/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)); + }); +}); diff --git a/apps/api/src/helper/portfolio.helper.ts b/apps/api/src/helper/portfolio.helper.ts index 6ebe48d3c..870971843 100644 --- a/apps/api/src/helper/portfolio.helper.ts +++ b/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;