From cd9c0202f221453fb86cb284ba9d33e9d265739f Mon Sep 17 00:00:00 2001 From: Thomas Kaul <4159106+dtslvr@users.noreply.github.com> Date: Sat, 26 Sep 2026 09:43:21 +0200 Subject: [PATCH] Fix fallback to latest market price in current rate service --- .../portfolio/current-rate.service.spec.ts | 96 ++++++++++++++++++- .../src/app/portfolio/current-rate.service.ts | 27 ++++-- .../app/portfolio/portfolio.service.spec.ts | 59 ++++++++---- .../src/app/portfolio/portfolio.service.ts | 32 +++++-- .../manual/manual.service.spec.ts | 96 +++++++++++++++++++ .../data-provider/manual/manual.service.ts | 29 +++--- 6 files changed, 289 insertions(+), 50 deletions(-) create mode 100644 apps/api/src/services/data-provider/manual/manual.service.spec.ts 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 4bb3a1b8cf..b2ec6cd2d8 100644 --- a/apps/api/src/app/portfolio/current-rate.service.spec.ts +++ b/apps/api/src/app/portfolio/current-rate.service.spec.ts @@ -1,9 +1,12 @@ +import { ActivitiesService } from '@ghostfolio/api/app/activities/activities.service'; 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'; +import { DataSource, MarketData, Order } from '@prisma/client'; +import { addDays, subDays } from 'date-fns'; import { CurrentRateService } from './current-rate.service'; import { DateQuery } from './interfaces/date-query.interface'; @@ -25,6 +28,9 @@ jest.mock('@ghostfolio/api/services/market-data/market-data.service', () => { state: 'CLOSE' }); }, + getLatest: () => { + return Promise.resolve(null); + }, getRange: ({ assetProfileIdentifiers, dateQuery @@ -97,12 +103,19 @@ jest.mock('@ghostfolio/api/services/property/property.service', () => { }); describe('CurrentRateService', () => { + let activitiesService: ActivitiesService; let currentRateService: CurrentRateService; let dataProviderService: DataProviderService; let marketDataService: MarketDataService; let propertyService: PropertyService; beforeAll(async () => { + activitiesService = { + getLatestActivity: () => { + return Promise.resolve(null); + } + } as unknown as ActivitiesService; + propertyService = new PropertyService(null); dataProviderService = new DataProviderService( @@ -117,7 +130,7 @@ describe('CurrentRateService', () => { marketDataService = new MarketDataService(null); currentRateService = new CurrentRateService( - null, + activitiesService, dataProviderService, marketDataService ); @@ -151,4 +164,83 @@ describe('CurrentRateService', () => { ] }); }); + + describe('getValues without a quote', () => { + const today = resetHours(new Date()); + const yesterday = subDays(today, 1); + + const dataGatheringItems: AssetProfileIdentifier[] = [ + { dataSource: DataSource.YAHOO, symbol: 'AMZN' } + ]; + + const dateQuery: DateQuery = { + gte: yesterday, + lt: addDays(today, 1) + }; + + beforeEach(() => { + jest.spyOn(dataProviderService, 'getQuotes').mockResolvedValue({}); + jest.spyOn(marketDataService, 'getRangeCount').mockResolvedValue(0); + }); + + afterEach(() => { + jest.restoreAllMocks(); + }); + + it('should fall back to the latest market price', async () => { + const getLatestActivity = jest + .spyOn(activitiesService, 'getLatestActivity') + .mockResolvedValue({ unitPrice: 1000 } as Order); + + jest.spyOn(marketDataService, 'getLatest').mockResolvedValue({ + createdAt: yesterday, + dataSource: DataSource.YAHOO, + date: yesterday, + id: '4f2b8a1e-7c3d-4e5f-9a6b-1c2d3e4f5a6b', + isCarriedForward: false, + marketPrice: 1847.839966, + state: 'CLOSE', + symbol: 'AMZN' + }); + + const { errors, values } = await currentRateService.getValues({ + dataGatheringItems, + dateQuery + }); + + expect(getLatestActivity).not.toHaveBeenCalled(); + expect(errors).toEqual(dataGatheringItems); + expect(values).toEqual([ + { + dataSource: DataSource.YAHOO, + date: today, + marketPrice: 1847.839966, + symbol: 'AMZN' + } + ]); + }); + + it('should fall back to the unit price of the latest activity without market data', async () => { + jest + .spyOn(activitiesService, 'getLatestActivity') + .mockResolvedValue({ unitPrice: 1000 } as Order); + + jest.spyOn(marketDataService, 'getLatest').mockResolvedValue(null); + + const { errors, values } = await currentRateService.getValues({ + dataGatheringItems, + dateQuery + }); + + expect(errors).toEqual(dataGatheringItems); + expect(values).toEqual([ + { + dataSource: DataSource.YAHOO, + date: today, + marketPrice: 1000, + 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 66119a3c63..79363c6344 100644 --- a/apps/api/src/app/portfolio/current-rate.service.ts +++ b/apps/api/src/app/portfolio/current-rate.service.ts @@ -132,18 +132,29 @@ export class CurrentRateService { }); if (!value) { - // Fallback to unit price of latest activity - const latestActivity = - await this.activitiesService.getLatestActivity({ - dataSource, - symbol - }); + const latestMarketData = await this.marketDataService.getLatest({ + dataSource, + symbol + }); + + let marketPrice = latestMarketData?.marketPrice; + + if (!marketPrice) { + // Fallback to unit price of latest activity + const latestActivity = + await this.activitiesService.getLatestActivity({ + dataSource, + symbol + }); + + marketPrice = latestActivity?.unitPrice ?? 0; + } value = { dataSource, + marketPrice, symbol, - date: today, - marketPrice: latestActivity?.unitPrice ?? 0 + date: today }; response.values.push(value); diff --git a/apps/api/src/app/portfolio/portfolio.service.spec.ts b/apps/api/src/app/portfolio/portfolio.service.spec.ts index 223a8964c1..ccd26e0345 100644 --- a/apps/api/src/app/portfolio/portfolio.service.spec.ts +++ b/apps/api/src/app/portfolio/portfolio.service.spec.ts @@ -4,6 +4,7 @@ import { ActivitiesService } from '@ghostfolio/api/app/activities/activities.ser import { PortfolioCalculator } from '@ghostfolio/api/app/portfolio/calculator/portfolio-calculator'; import { userDummyData } from '@ghostfolio/api/app/portfolio/calculator/portfolio-calculator-test-utils'; import { PortfolioCalculatorFactory } from '@ghostfolio/api/app/portfolio/calculator/portfolio-calculator.factory'; +import { CurrentRateService } from '@ghostfolio/api/app/portfolio/current-rate.service'; import { UserService } from '@ghostfolio/api/app/user/user.service'; import { ConfigurationService } from '@ghostfolio/api/services/configuration/configuration.service'; import { DataProviderService } from '@ghostfolio/api/services/data-provider/data-provider.service'; @@ -33,6 +34,7 @@ describe('PortfolioService', () => { let accountService: AccountService; let activitiesService: ActivitiesService; let configurationService: ConfigurationService; + let currentRateService: CurrentRateService; let dataProviderService: DataProviderService; let exchangeRateDataService: ExchangeRateDataService; let portfolioCalculatorFactory: PortfolioCalculatorFactory; @@ -82,6 +84,12 @@ describe('PortfolioService', () => { null ); + currentRateService = new CurrentRateService( + activitiesService, + dataProviderService, + null + ); + portfolioCalculatorFactory = new PortfolioCalculatorFactory( configurationService, null, @@ -110,6 +118,7 @@ describe('PortfolioService', () => { activitiesService, null, portfolioCalculatorFactory, + currentRateService, dataProviderService, exchangeRateDataService, null, @@ -901,15 +910,19 @@ describe('PortfolioService', () => { count: 4 }); - const getQuotes = jest - .spyOn(dataProviderService, 'getQuotes') + const getValues = jest + .spyOn(currentRateService, 'getValues') .mockResolvedValue({ - 'YAHOO-AAPL': { - currency: 'CHF', - dataSource: DataSource.YAHOO, - marketPrice: 200, - marketState: 'open' - } + dataProviderInfos: [], + errors: [], + values: [ + { + dataSource: DataSource.YAHOO, + date: new Date(), + marketPrice: 200, + symbol: 'AAPL' + } + ] }); const summary = await getSummary({ @@ -921,10 +934,10 @@ describe('PortfolioService', () => { userId: userDummyData.id }); - // The closed holding (MSFT) does not need a quote - expect(getQuotes).toHaveBeenCalledWith( + // The closed holding (MSFT) does not need a market price + expect(getValues).toHaveBeenCalledWith( expect.objectContaining({ - items: [{ dataSource: DataSource.YAHOO, symbol: 'AAPL' }] + dataGatheringItems: [{ dataSource: DataSource.YAHOO, symbol: 'AAPL' }] }) ); @@ -961,13 +974,17 @@ describe('PortfolioService', () => { count: 2 }); - jest.spyOn(dataProviderService, 'getQuotes').mockResolvedValue({ - 'YAHOO-AAPL': { - currency: 'USD', - dataSource: DataSource.YAHOO, - marketPrice: 200, - marketState: 'open' - } + jest.spyOn(currentRateService, 'getValues').mockResolvedValue({ + dataProviderInfos: [], + errors: [], + values: [ + { + dataSource: DataSource.YAHOO, + date: new Date(), + marketPrice: 200, + symbol: 'AAPL' + } + ] }); jest @@ -1018,7 +1035,11 @@ describe('PortfolioService', () => { count: 2 }); - jest.spyOn(dataProviderService, 'getQuotes').mockResolvedValue({}); + jest.spyOn(currentRateService, 'getValues').mockResolvedValue({ + dataProviderInfos: [], + errors: [], + values: [] + }); const summary = await getSummary({ balanceInBaseCurrency: 1000, diff --git a/apps/api/src/app/portfolio/portfolio.service.ts b/apps/api/src/app/portfolio/portfolio.service.ts index 90de5ddc38..c368d72c46 100644 --- a/apps/api/src/app/portfolio/portfolio.service.ts +++ b/apps/api/src/app/portfolio/portfolio.service.ts @@ -94,18 +94,21 @@ import { import { Big } from 'big.js'; import { differenceInDays, + endOfDay, format, isAfter, isBefore, isSameMonth, isSameYear, parseISO, - set + set, + startOfDay } from 'date-fns'; import { groupBy } from 'lodash'; import { PortfolioCalculator } from './calculator/portfolio-calculator'; import { PortfolioCalculatorFactory } from './calculator/portfolio-calculator.factory'; +import { CurrentRateService } from './current-rate.service'; import { RulesService } from './rules.service'; const Fuse = require('fuse.js'); @@ -125,6 +128,7 @@ export class PortfolioService { private readonly activitiesService: ActivitiesService, private readonly benchmarkService: BenchmarkService, private readonly calculatorFactory: PortfolioCalculatorFactory, + private readonly currentRateService: CurrentRateService, private readonly dataProviderService: DataProviderService, private readonly exchangeRateDataService: ExchangeRateDataService, private readonly i18nService: I18nService, @@ -2530,11 +2534,15 @@ export class PortfolioService { return !quantity.eq(0); }); - const quotes = + const now = new Date(); + + // Get the market prices of today with the same fallback as the portfolio + // calculator + const { values } = openHoldings.length > 0 - ? await this.dataProviderService.getQuotes({ + ? await this.currentRateService.getValues({ subscriptionType, - items: openHoldings.map( + dataGatheringItems: openHoldings.map( ({ latestActivity: { assetProfile: { dataSource, symbol } @@ -2542,16 +2550,24 @@ export class PortfolioService { }) => { return { dataSource, symbol }; } - ) + ), + dateQuery: { + gte: startOfDay(now), + lt: endOfDay(now) + } }) - : {}; + : { values: [] }; return getSum( openHoldings.map(({ latestActivity, quantity }) => { const { assetProfile, currency, unitPrice } = latestActivity; - const marketPrice = - quotes[getAssetProfileIdentifier(assetProfile)]?.marketPrice; + const marketPrice = values.find((value) => { + return ( + getAssetProfileIdentifier(value) === + getAssetProfileIdentifier(assetProfile) + ); + })?.marketPrice; if (!marketPrice) { // Fall back to the unit price of the latest activity without a diff --git a/apps/api/src/services/data-provider/manual/manual.service.spec.ts b/apps/api/src/services/data-provider/manual/manual.service.spec.ts new file mode 100644 index 0000000000..2fc69b8b2d --- /dev/null +++ b/apps/api/src/services/data-provider/manual/manual.service.spec.ts @@ -0,0 +1,96 @@ +import { PrismaService } from '@ghostfolio/api/services/prisma/prisma.service'; +import { SymbolProfileService } from '@ghostfolio/api/services/symbol-profile/symbol-profile.service'; +import { EnhancedAssetProfile } from '@ghostfolio/common/interfaces'; + +import { DataSource } from '@prisma/client'; + +import { ManualService } from './manual.service'; + +jest.mock('@ghostfolio/api/services/fetch/fetch.service', () => { + return { + FetchService: jest.fn() + }; +}); + +describe('ManualService', () => { + let manualService: ManualService; + let prismaService: { + marketData: { + findFirst: jest.Mock; + }; + }; + let symbolProfileService: SymbolProfileService; + + beforeEach(() => { + prismaService = { + marketData: { + findFirst: jest.fn() + } + }; + + symbolProfileService = new SymbolProfileService(null); + + manualService = new ManualService( + null, + null, + prismaService as unknown as PrismaService, + symbolProfileService + ); + }); + + describe('getQuotes', () => { + it('should use the latest market price of each symbol of the manual data source', async () => { + const latestMarketPrices: { [symbol: string]: number } = { + A: 13, + B: 22 + }; + + prismaService.marketData.findFirst.mockImplementation( + ({ where: { symbol } }) => { + return Promise.resolve( + latestMarketPrices[symbol] + ? { symbol, marketPrice: latestMarketPrices[symbol] } + : null + ); + } + ); + + jest.spyOn(symbolProfileService, 'getSymbolProfiles').mockResolvedValue( + ['A', 'B', 'C'].map((symbol) => { + return { symbol, currency: 'USD' }; + }) as EnhancedAssetProfile[] + ); + + const quotes = await manualService.getQuotes({ + symbols: ['A', 'B', 'C'] + }); + + expect(prismaService.marketData.findFirst).toHaveBeenCalledWith({ + orderBy: { date: 'desc' }, + where: { dataSource: DataSource.MANUAL, symbol: 'A' } + }); + + expect(quotes).toEqual({ + A: { + currency: 'USD', + dataSource: DataSource.MANUAL, + marketPrice: 13, + marketState: 'delayed' + }, + B: { + currency: 'USD', + dataSource: DataSource.MANUAL, + marketPrice: 22, + marketState: 'delayed' + }, + // Without market data + C: { + currency: 'USD', + dataSource: DataSource.MANUAL, + marketPrice: 0, + marketState: 'delayed' + } + }); + }); + }); +}); diff --git a/apps/api/src/services/data-provider/manual/manual.service.ts b/apps/api/src/services/data-provider/manual/manual.service.ts index a187335a0e..30168cdef8 100644 --- a/apps/api/src/services/data-provider/manual/manual.service.ts +++ b/apps/api/src/services/data-provider/manual/manual.service.ts @@ -150,18 +150,21 @@ export class ManualService implements DataProviderInterface { }) ); - const marketData = await this.prismaService.marketData.findMany({ - distinct: ['symbol'], - orderBy: { - date: 'desc' - }, - take: symbols.length, - where: { - symbol: { - in: symbols - } - } - }); + // Query the latest market data per symbol because distinct loads all + // the market data of the symbols into memory + const marketData = await Promise.all( + symbols.map((symbol) => { + return this.prismaService.marketData.findFirst({ + orderBy: { + date: 'desc' + }, + where: { + symbol, + dataSource: this.getName() + } + }); + }) + ); const symbolProfilesToScrape = symbolProfiles.filter( ({ scraperConfiguration }) => { @@ -202,7 +205,7 @@ export class ManualService implements DataProviderInterface { marketPrice = marketPrice ?? marketData.find((marketDataItem) => { - return marketDataItem.symbol === symbol; + return marketDataItem?.symbol === symbol; })?.marketPrice ?? 0;