From 23012761081a80bc59b8ffc404cb35840330ccf1 Mon Sep 17 00:00:00 2001 From: Thomas Kaul <4159106+dtslvr@users.noreply.github.com> Date: Mon, 5 Oct 2026 14:55:31 +0200 Subject: [PATCH] Fix market price fallback --- .../app/activities/activities.service.spec.ts | 43 +++++++++- .../src/app/activities/activities.service.ts | 1 + .../portfolio/current-rate.service.spec.ts | 47 ----------- .../src/app/portfolio/current-rate.service.ts | 84 +++++++++---------- 4 files changed, 84 insertions(+), 91 deletions(-) diff --git a/apps/api/src/app/activities/activities.service.spec.ts b/apps/api/src/app/activities/activities.service.spec.ts index d1eb1b4873..8382888fcf 100644 --- a/apps/api/src/app/activities/activities.service.spec.ts +++ b/apps/api/src/app/activities/activities.service.spec.ts @@ -3,6 +3,7 @@ import { activityDummyData, assetProfileDummyData } from '@ghostfolio/api/app/portfolio/calculator/portfolio-calculator-test-utils'; +import { WHERE_ACTIVITY_NOT_DRAFT } from '@ghostfolio/api/helper/activity.helper'; import { AssetProfileSplitService } from '@ghostfolio/api/services/asset-profile-split/asset-profile-split.service'; import { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-data/exchange-rate-data.service'; import { PrismaService } from '@ghostfolio/api/services/prisma/prisma.service'; @@ -14,7 +15,11 @@ import { import { parseDate } from '@ghostfolio/common/helper'; import { Activity, Filter } from '@ghostfolio/common/interfaces'; -import { AssetProfileSplit, DataSource } from '@prisma/client'; +import { + AssetProfileSplit, + DataSource, + Type as ActivityType +} from '@prisma/client'; import { Big } from 'big.js'; import { ActivitiesService } from './activities.service'; @@ -381,6 +386,42 @@ describe('ActivitiesService', () => { return result.activities[0]; } }); + + describe('getLatestActivity', () => { + it('filters by type and excludes draft activities', async () => { + const findFirst = jest.fn().mockResolvedValue(null); + + const service = new ActivitiesService( + null, + null, + null, + null, + null, + null, + null, + null, + null, + { order: { findFirst } } as unknown as PrismaService, + null, + null + ); + + await service.getLatestActivity({ + dataSource: DataSource.YAHOO, + symbol: 'AAPL', + types: [ActivityType.BUY, ActivityType.SELL] + }); + + expect(findFirst).toHaveBeenCalledWith({ + orderBy: { date: 'desc' }, + where: { + ...WHERE_ACTIVITY_NOT_DRAFT, + SymbolProfile: { dataSource: DataSource.YAHOO, symbol: 'AAPL' }, + type: { in: [ActivityType.BUY, ActivityType.SELL] } + } + }); + }); + }); }); function createActivity({ diff --git a/apps/api/src/app/activities/activities.service.ts b/apps/api/src/app/activities/activities.service.ts index 5fc23989d3..99aaff40ba 100644 --- a/apps/api/src/app/activities/activities.service.ts +++ b/apps/api/src/app/activities/activities.service.ts @@ -589,6 +589,7 @@ export class ActivitiesService { date: 'desc' }, where: { + ...WHERE_ACTIVITY_NOT_DRAFT, SymbolProfile: { dataSource, symbol }, ...(types?.length > 0 && { type: { in: types } }) } 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 436d90c110..e7128b1389 100644 --- a/apps/api/src/app/portfolio/current-rate.service.spec.ts +++ b/apps/api/src/app/portfolio/current-rate.service.spec.ts @@ -192,53 +192,6 @@ describe('CurrentRateService', () => { jest.restoreAllMocks(); }); - it('should fall back to the latest market price in the date range', async () => { - const getLatestActivity = jest.spyOn( - activitiesService, - 'getLatestActivity' - ); - - const getLatestMarketData = jest.spyOn(marketDataService, 'getLatest'); - - jest.spyOn(marketDataService, 'getRangeCount').mockResolvedValue(1); - - jest.spyOn(marketDataService, 'getRange').mockResolvedValue([ - { - createdAt: yesterday, - dataSource: DataSource.YAHOO, - date: yesterday, - id: '3b1a3f4c-3d2b-4a19-9f5a-5c0f5b4a2e11', - isCarriedForward: false, - marketPrice: 1841.823902, - state: 'CLOSE', - symbol: 'AMZN' - } - ]); - - const { errors, values } = await currentRateService.getValues({ - dataGatheringItems, - dateQuery - }); - - expect(getLatestActivity).not.toHaveBeenCalled(); - expect(getLatestMarketData).not.toHaveBeenCalled(); - expect(errors).toEqual(dataGatheringItems); - expect(values).toEqual([ - { - dataSource: DataSource.YAHOO, - date: yesterday, - marketPrice: 1841.823902, - symbol: 'AMZN' - }, - { - dataSource: DataSource.YAHOO, - date: today, - marketPrice: 1841.823902, - symbol: 'AMZN' - } - ]); - }); - it('should fall back to the latest market price', async () => { const getLatestActivity = jest .spyOn(activitiesService, 'getLatestActivity') diff --git a/apps/api/src/app/portfolio/current-rate.service.ts b/apps/api/src/app/portfolio/current-rate.service.ts index c4327c70a1..5a3daddb57 100644 --- a/apps/api/src/app/portfolio/current-rate.service.ts +++ b/apps/api/src/app/portfolio/current-rate.service.ts @@ -12,10 +12,9 @@ import { ResponseError } from '@ghostfolio/common/interfaces'; -import { utc } from '@date-fns/utc'; import { Injectable } from '@nestjs/common'; import { Type as ActivityType } from '@prisma/client'; -import { compareDesc, isBefore, isSameDay } from 'date-fns'; +import { isBefore, isToday } from 'date-fns'; import { isEmpty, uniqBy } from 'lodash-es'; import { GetValueObject } from './interfaces/get-value-object.interface'; @@ -124,19 +123,45 @@ export class CurrentRateService { if (!isEmpty(quoteErrors)) { for (const { dataSource, symbol } of quoteErrors) { try { - const valueOfToday = response.values.find((currentValue) => { + // 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 && - isSameDay(currentValue.date, today, { in: utc }) + isToday(currentValue.date) ); }); - if (valueOfToday?.marketPrice) { - continue; + if (!value) { + const latestMarketData = await this.marketDataService.getLatest({ + dataSource, + symbol + }); + + let marketPrice = latestMarketData?.marketPrice; + + if (!marketPrice) { + // Fallback to unit price of latest buy or sell activity + const latestActivity = + await this.activitiesService.getLatestActivity({ + dataSource, + symbol, + types: [ActivityType.BUY, ActivityType.SELL] + }); + + marketPrice = latestActivity?.unitPrice ?? 0; + } + + value = { + dataSource, + marketPrice, + symbol, + date: today + }; + + response.values.push(value); } - // If missing quote, fallback to the latest available historical market price const [latestValue] = response.values .filter((currentValue) => { return ( @@ -146,43 +171,18 @@ export class CurrentRateService { ); }) .sort((a, b) => { - return compareDesc(a.date, b.date); - }); + if (a.date < b.date) { + return 1; + } - let marketPrice = latestValue?.marketPrice; + if (a.date > b.date) { + return -1; + } - if (!marketPrice) { - // Fallback to latest market price outside of the date range - const latestMarketData = await this.marketDataService.getLatest({ - dataSource, - symbol + return 0; }); - marketPrice = latestMarketData?.marketPrice; - } - - if (!marketPrice) { - // Fallback to unit price of latest buy or sell activity - const latestActivity = - await this.activitiesService.getLatestActivity({ - dataSource, - symbol, - types: [ActivityType.BUY, ActivityType.SELL] - }); - - marketPrice = latestActivity?.unitPrice ?? 0; - } - - if (valueOfToday) { - valueOfToday.marketPrice = marketPrice; - } else { - response.values.push({ - dataSource, - marketPrice, - symbol, - date: today - }); - } + value.marketPrice = latestValue.marketPrice; } catch {} } } @@ -191,10 +191,8 @@ export class CurrentRateService { } private containsToday(dates: Date[]): boolean { - const today = resetHours(new Date()); - for (const date of dates) { - if (isSameDay(date, today, { in: utc })) { + if (isToday(date)) { return true; } }