From 1100d342782464bdbd31840dbcc05f48a6fc6628 Mon Sep 17 00:00:00 2001 From: Thomas Kaul <4159106+dtslvr@users.noreply.github.com> Date: Sat, 5 Sep 2026 08:49:42 +0200 Subject: [PATCH] Get quotes of active holdings only --- .../calculator/portfolio-calculator.ts | 11 ++++ .../portfolio/current-rate.service.spec.ts | 50 +++++++++++++++++++ .../src/app/portfolio/current-rate.service.ts | 30 +++++++++-- .../interfaces/get-values-params.interface.ts | 2 + 4 files changed, 88 insertions(+), 5 deletions(-) diff --git a/apps/api/src/app/portfolio/calculator/portfolio-calculator.ts b/apps/api/src/app/portfolio/calculator/portfolio-calculator.ts index 971d8b564..a2b87fda5 100644 --- a/apps/api/src/app/portfolio/calculator/portfolio-calculator.ts +++ b/apps/api/src/app/portfolio/calculator/portfolio-calculator.ts @@ -225,6 +225,7 @@ export abstract class PortfolioCalculator { }; } + const assetProfileIdentifiersWithQuotes: AssetProfileIdentifier[] = []; const cashAssetProfileIdentifiers = new Set(); const currencies: { [assetProfileIdentifier: string]: string } = {}; const dataGatheringItems: DataGatheringItem[] = []; @@ -238,6 +239,7 @@ export abstract class PortfolioCalculator { assetSubClass, currency, dataSource, + quantity, symbol } of transactionPoints[firstIndex - 1].items) { // Gather data for all assets except CASH @@ -246,6 +248,14 @@ export abstract class PortfolioCalculator { dataSource, symbol }); + + if (!quantity.eq(0)) { + // Get a quote for active holdings only + assetProfileIdentifiersWithQuotes.push({ + dataSource, + symbol + }); + } } currencies[getAssetProfileIdentifier({ dataSource, symbol })] = currency; @@ -274,6 +284,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 49859b091..6c47e18be 100644 --- a/apps/api/src/app/portfolio/current-rate.service.spec.ts +++ b/apps/api/src/app/portfolio/current-rate.service.spec.ts @@ -145,6 +145,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)), @@ -198,6 +201,9 @@ describe('CurrentRateService', () => { ); const { errors, values } = await currentRateService.getValues({ + assetProfileIdentifiersWithQuotes: [ + { dataSource: DataSource.YAHOO, symbol: 'AMZN' } + ], dataGatheringItems: [{ dataSource: DataSource.YAHOO, symbol: 'AMZN' }], dateQuery: { gte: yesterday, lt: endOfDay(new Date()) } }); @@ -226,6 +232,9 @@ describe('CurrentRateService', () => { .mockResolvedValue({ unitPrice: 1847.839966 } as Order); const { values } = await currentRateService.getValues({ + assetProfileIdentifiersWithQuotes: [ + { dataSource: DataSource.YAHOO, symbol: 'AMZN' } + ], dataGatheringItems: [{ dataSource: DataSource.YAHOO, symbol: 'AMZN' }], dateQuery: { gte: subDays(today, 1, { in: utc }), @@ -248,4 +257,45 @@ describe('CurrentRateService', () => { } ]); }); + + it('getValues without a quote request carries the latest market price forward', async () => { + const today = resetHours(new Date()); + const yesterday = subDays(today, 1, { in: utc }); + + const getQuotesSpy = 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({ + assetProfileIdentifiersWithQuotes: [], + dataGatheringItems: [{ dataSource: DataSource.YAHOO, symbol: 'AMZN' }], + dateQuery: { gte: yesterday, lt: endOfDay(new Date()) } + }); + + expect(getQuotesSpy).toHaveBeenCalledWith( + expect.objectContaining({ items: [] }) + ); + + expect(errors).toEqual([]); + + expect(values).toContainEqual({ + 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 6ad872ae4..6337dd193 100644 --- a/apps/api/src/app/portfolio/current-rate.service.ts +++ b/apps/api/src/app/portfolio/current-rate.service.ts @@ -18,7 +18,7 @@ import { Inject, Injectable } from '@nestjs/common'; import { REQUEST } from '@nestjs/core'; import { Type as ActivityType } from '@prisma/client'; import { compareDesc, isBefore, isSameDay } from 'date-fns'; -import { isEmpty, uniqBy } from 'lodash'; +import { uniqBy } from 'lodash'; import { GetValueObject } from './interfaces/get-value-object.interface'; import { GetValuesObject } from './interfaces/get-values-object.interface'; @@ -38,6 +38,7 @@ export class CurrentRateService { @LogPerformance // TODO: Pass user instead of using this.request.user public async getValues({ + assetProfileIdentifiersWithQuotes, dataGatheringItems, dateQuery }: GetValuesParams): Promise { @@ -54,11 +55,11 @@ export class CurrentRateService { if (includesToday) { const quotes = await this.dataProviderService.getQuotes({ - items: dataGatheringItems, + items: assetProfileIdentifiersWithQuotes, user: this.request?.user }); - for (const { dataSource, symbol } of dataGatheringItems) { + for (const { dataSource, symbol } of assetProfileIdentifiersWithQuotes) { const quote = quotes[getAssetProfileIdentifier({ dataSource, symbol })]; if (quote?.dataProviderInfo) { @@ -124,8 +125,27 @@ export class CurrentRateService { }) }; - if (!isEmpty(quoteErrors)) { - for (const { dataSource, symbol } of quoteErrors) { + if (includesToday) { + // A holding without a quote request also needs a market price of today, + // but it is not in error + 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 { const valueOfToday = response.values.find((currentValue) => { return ( 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 ffb74ee9b..1c220953b 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,8 +1,10 @@ import { DataGatheringItem } from '@ghostfolio/api/services/interfaces/interfaces'; +import { AssetProfileIdentifier } from '@ghostfolio/common/interfaces'; import { DateQuery } from './date-query.interface'; export interface GetValuesParams { + assetProfileIdentifiersWithQuotes: AssetProfileIdentifier[]; dataGatheringItems: DataGatheringItem[]; dateQuery: DateQuery; }