diff --git a/CHANGELOG.md b/CHANGELOG.md index a1fdd458a2..b2a930fbd9 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -13,6 +13,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Changed +- Improved the portfolio snapshot calculation to get the quotes of active holdings only - Improved the performance of getting the latest market data by optimizing the indexes of the market data table - Deprecated `SymbolProfile` in favor of `assetProfile` in the endpoint `POST api/v1/activities` - Upgraded `Nx` from version `23.1.1` to `23.2.1` diff --git a/apps/api/src/app/portfolio/calculator/portfolio-calculator.ts b/apps/api/src/app/portfolio/calculator/portfolio-calculator.ts index eb1e6f9fac..b9cb622713 100644 --- a/apps/api/src/app/portfolio/calculator/portfolio-calculator.ts +++ b/apps/api/src/app/portfolio/calculator/portfolio-calculator.ts @@ -268,6 +268,7 @@ export abstract class PortfolioCalculator { }; } + const assetProfileIdentifiersWithQuotes: AssetProfileIdentifier[] = []; const cashAssetProfileIdentifiers = new Set(); const currencies: { [assetProfileIdentifier: string]: string } = {}; const dataGatheringItems: DataGatheringItem[] = []; @@ -279,6 +280,7 @@ export abstract class PortfolioCalculator { assetSubClass, currency, dataSource, + quantity, symbol } of holdingBalancesByDate.at(-1).holdings) { // Gather data for all assets except CASH @@ -287,6 +289,13 @@ export abstract class PortfolioCalculator { dataSource, symbol }); + + if (!quantity.eq(0)) { + assetProfileIdentifiersWithQuotes.push({ + dataSource, + symbol + }); + } } currencies[getAssetProfileIdentifier({ dataSource, symbol })] = currency; @@ -305,6 +314,7 @@ export abstract class PortfolioCalculator { errors: currentRateErrors, values: marketSymbols } = await this.currentRateService.getValues({ + assetProfileIdentifiersWithQuotes, dataGatheringItems, dateQuery: { gte: this.startDate, 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 e7128b1389..4a7c1f10cb 100644 --- a/apps/api/src/app/portfolio/current-rate.service.spec.ts +++ b/apps/api/src/app/portfolio/current-rate.service.spec.ts @@ -144,6 +144,9 @@ describe('CurrentRateService', () => { it('getValues', async () => { expect( await currentRateService.getValues({ + assetProfileIdentifiersWithQuotes: [ + { dataSource: DataSource.YAHOO, symbol: 'AMZN' } + ], dataGatheringItems: [{ dataSource: DataSource.YAHOO, symbol: 'AMZN' }], dateQuery: { lt: new Date(Date.UTC(2020, 0, 2, 0, 0, 0)), @@ -210,7 +213,8 @@ describe('CurrentRateService', () => { const { errors, values } = await currentRateService.getValues({ dataGatheringItems, - dateQuery + dateQuery, + assetProfileIdentifiersWithQuotes: dataGatheringItems }); expect(getLatestActivity).not.toHaveBeenCalled(); @@ -234,7 +238,8 @@ describe('CurrentRateService', () => { const { errors, values } = await currentRateService.getValues({ dataGatheringItems, - dateQuery + dateQuery, + assetProfileIdentifiersWithQuotes: dataGatheringItems }); expect(getLatestActivity).toHaveBeenCalledWith({ @@ -252,5 +257,49 @@ describe('CurrentRateService', () => { } ]); }); + + it('should carry the latest market price forward without a quote request', async () => { + const getQuotes = jest.spyOn(dataProviderService, 'getQuotes'); + + jest.spyOn(marketDataService, 'getRangeCount').mockResolvedValue(1); + + jest.spyOn(marketDataService, 'getRange').mockResolvedValue([ + { + createdAt: yesterday, + dataSource: DataSource.YAHOO, + date: yesterday, + id: 'd51d4e0b-9d1f-4d2e-8a0c-9a0f5c6b1d22', + isCarriedForward: false, + marketPrice: 1841.823902, + state: 'CLOSE', + symbol: 'AMZN' + } + ]); + + const { errors, values } = await currentRateService.getValues({ + dataGatheringItems, + dateQuery, + assetProfileIdentifiersWithQuotes: [] + }); + + expect(getQuotes).toHaveBeenCalledWith( + expect.objectContaining({ items: [] }) + ); + expect(errors).toEqual([]); + expect(values).toEqual([ + { + dataSource: DataSource.YAHOO, + date: yesterday, + marketPrice: 1841.823902, + symbol: 'AMZN' + }, + { + dataSource: DataSource.YAHOO, + date: today, + marketPrice: 1841.823902, + symbol: 'AMZN' + } + ]); + }); }); }); diff --git a/apps/api/src/app/portfolio/current-rate.service.ts b/apps/api/src/app/portfolio/current-rate.service.ts index 151b0b14ab..df1ca987d0 100644 --- a/apps/api/src/app/portfolio/current-rate.service.ts +++ b/apps/api/src/app/portfolio/current-rate.service.ts @@ -16,7 +16,7 @@ import { utc } from '@date-fns/utc'; import { Injectable } from '@nestjs/common'; import { Type as ActivityType } from '@prisma/client'; import { isBefore, isSameDay } from 'date-fns'; -import { isEmpty, uniqBy } from 'lodash-es'; +import { uniqBy } from 'lodash-es'; import { GetValueObject } from './interfaces/get-value-object.interface'; import { GetValuesObject } from './interfaces/get-values-object.interface'; @@ -34,6 +34,7 @@ export class CurrentRateService { @LogPerformance public async getValues({ + assetProfileIdentifiersWithQuotes, dataGatheringItems, dateQuery, subscriptionType @@ -52,10 +53,10 @@ export class CurrentRateService { if (includesToday) { const quotes = await this.dataProviderService.getQuotes({ subscriptionType, - items: dataGatheringItems + items: assetProfileIdentifiersWithQuotes }); - for (const { dataSource, symbol } of dataGatheringItems) { + for (const { dataSource, symbol } of assetProfileIdentifiersWithQuotes) { const quote = quotes[getAssetProfileIdentifier({ dataSource, symbol })]; if (quote?.dataProviderInfo) { @@ -121,8 +122,25 @@ export class CurrentRateService { }) }; - if (!isEmpty(quoteErrors)) { - for (const { dataSource, symbol } of quoteErrors) { + if (includesToday) { + const assetProfileIdentifiersWithoutQuotes = [ + ...quoteErrors, + ...dataGatheringItems.filter(({ dataSource, symbol }) => { + return !assetProfileIdentifiersWithQuotes.some( + (assetProfileIdentifier) => { + return ( + assetProfileIdentifier.dataSource === dataSource && + assetProfileIdentifier.symbol === symbol + ); + } + ); + }) + ]; + + for (const { + dataSource, + symbol + } of assetProfileIdentifiersWithoutQuotes) { try { // If missing quote, fallback to the latest available historical market price let value: GetValueObject = response.values.find((currentValue) => { diff --git a/apps/api/src/app/portfolio/interfaces/get-values-params.interface.ts b/apps/api/src/app/portfolio/interfaces/get-values-params.interface.ts index e74c501be6..6f2b70b500 100644 --- a/apps/api/src/app/portfolio/interfaces/get-values-params.interface.ts +++ b/apps/api/src/app/portfolio/interfaces/get-values-params.interface.ts @@ -1,9 +1,11 @@ import { DataGatheringItem } from '@ghostfolio/api/services/interfaces/interfaces'; import { SubscriptionType } from '@ghostfolio/common/enums'; +import { AssetProfileIdentifier } from '@ghostfolio/common/interfaces'; import { DateQuery } from './date-query.interface'; export interface GetValuesParams { + assetProfileIdentifiersWithQuotes: AssetProfileIdentifier[]; dataGatheringItems: DataGatheringItem[]; dateQuery: DateQuery; subscriptionType?: SubscriptionType; diff --git a/apps/api/src/app/portfolio/portfolio.service.spec.ts b/apps/api/src/app/portfolio/portfolio.service.spec.ts index 19af3615bc..623f006eb7 100644 --- a/apps/api/src/app/portfolio/portfolio.service.spec.ts +++ b/apps/api/src/app/portfolio/portfolio.service.spec.ts @@ -997,6 +997,9 @@ describe('PortfolioService', () => { // The closed holding (MSFT) does not need a market price expect(getValues).toHaveBeenCalledWith( expect.objectContaining({ + assetProfileIdentifiersWithQuotes: [ + { dataSource: DataSource.YAHOO, symbol: 'AAPL' } + ], dataGatheringItems: [{ dataSource: DataSource.YAHOO, symbol: 'AAPL' }] }) ); diff --git a/apps/api/src/app/portfolio/portfolio.service.ts b/apps/api/src/app/portfolio/portfolio.service.ts index d906fd6aef..17beb67793 100644 --- a/apps/api/src/app/portfolio/portfolio.service.ts +++ b/apps/api/src/app/portfolio/portfolio.service.ts @@ -2545,6 +2545,16 @@ export class PortfolioService { return !quantity.eq(0); }); + const assetProfileIdentifiers: AssetProfileIdentifier[] = openHoldings.map( + ({ + latestActivity: { + assetProfile: { dataSource, symbol } + } + }) => { + return { dataSource, symbol }; + } + ); + const now = new Date(); // Get the market prices of today with the same fallback as the portfolio @@ -2553,15 +2563,8 @@ export class PortfolioService { openHoldings.length > 0 ? await this.currentRateService.getValues({ subscriptionType, - dataGatheringItems: openHoldings.map( - ({ - latestActivity: { - assetProfile: { dataSource, symbol } - } - }) => { - return { dataSource, symbol }; - } - ), + assetProfileIdentifiersWithQuotes: assetProfileIdentifiers, + dataGatheringItems: assetProfileIdentifiers, dateQuery: { gte: startOfDay(now), lt: endOfDay(now)