diff --git a/apps/api/src/app/activities/activities.service.ts b/apps/api/src/app/activities/activities.service.ts index a0d9ffef5..cfc84df2f 100644 --- a/apps/api/src/app/activities/activities.service.ts +++ b/apps/api/src/app/activities/activities.service.ts @@ -581,14 +581,16 @@ export class ActivitiesService { public async getLatestActivity({ dataSource, - symbol - }: AssetProfileIdentifier) { + symbol, + types + }: AssetProfileIdentifier & { types?: ActivityType[] }) { return this.prismaService.order.findFirst({ orderBy: { date: 'desc' }, where: { - SymbolProfile: { dataSource, symbol } + 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 ea93e8b4f..49859b091 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,18 @@ +import type { 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 { utc } from '@date-fns/utc'; +import { + DataSource, + MarketData, + Order, + Type as ActivityType +} from '@prisma/client'; +import { endOfDay, subDays } from 'date-fns'; import { CurrentRateService } from './current-rate.service'; import { DateQuery } from './interfaces/date-query.interface'; @@ -97,12 +106,17 @@ 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: jest.fn() + } as unknown as ActivitiesService; + propertyService = new PropertyService(null); dataProviderService = new DataProviderService( @@ -117,13 +131,17 @@ describe('CurrentRateService', () => { marketDataService = new MarketDataService(null); currentRateService = new CurrentRateService( - null, + activitiesService, dataProviderService, marketDataService, null ); }); + afterEach(() => { + jest.restoreAllMocks(); + }); + it('getValues', async () => { expect( await currentRateService.getValues({ @@ -152,4 +170,82 @@ describe('CurrentRateService', () => { ] }); }); + + it('getValues with a missing quote falls back to the latest market price', async () => { + const today = resetHours(new Date()); + const yesterday = subDays(today, 1, { in: utc }); + + jest.spyOn(dataProviderService, 'getQuotes').mockResolvedValue({}); + + 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 getLatestActivitySpy = jest.spyOn( + activitiesService, + 'getLatestActivity' + ); + + const { errors, values } = await currentRateService.getValues({ + dataGatheringItems: [{ dataSource: DataSource.YAHOO, symbol: 'AMZN' }], + dateQuery: { gte: yesterday, lt: endOfDay(new Date()) } + }); + + expect(errors).toEqual([{ dataSource: DataSource.YAHOO, symbol: 'AMZN' }]); + + expect(values).toContainEqual({ + dataSource: DataSource.YAHOO, + date: today, + marketPrice: 1841.823902, + symbol: 'AMZN' + }); + + expect(getLatestActivitySpy).not.toHaveBeenCalled(); + }); + + it('getValues with a missing quote and without market data falls back to the latest buy or sell activity', async () => { + const today = resetHours(new Date()); + + jest.spyOn(dataProviderService, 'getQuotes').mockResolvedValue({}); + + jest.spyOn(marketDataService, 'getRangeCount').mockResolvedValue(0); + + const getLatestActivitySpy = jest + .spyOn(activitiesService, 'getLatestActivity') + .mockResolvedValue({ unitPrice: 1847.839966 } as Order); + + const { values } = await currentRateService.getValues({ + dataGatheringItems: [{ dataSource: DataSource.YAHOO, symbol: 'AMZN' }], + dateQuery: { + gte: subDays(today, 1, { in: utc }), + lt: endOfDay(new Date()) + } + }); + + expect(getLatestActivitySpy).toHaveBeenCalledWith({ + dataSource: DataSource.YAHOO, + symbol: 'AMZN', + types: [ActivityType.BUY, ActivityType.SELL] + }); + + expect(values).toEqual([ + { + dataSource: DataSource.YAHOO, + date: today, + marketPrice: 1847.839966, + 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 9cfeda3bd..6ad872ae4 100644 --- a/apps/api/src/app/portfolio/current-rate.service.ts +++ b/apps/api/src/app/portfolio/current-rate.service.ts @@ -13,9 +13,11 @@ import { } from '@ghostfolio/common/interfaces'; import type { RequestWithUser } from '@ghostfolio/common/types'; +import { utc } from '@date-fns/utc'; import { Inject, Injectable } from '@nestjs/common'; import { REQUEST } from '@nestjs/core'; -import { isBefore, isToday } from 'date-fns'; +import { Type as ActivityType } from '@prisma/client'; +import { compareDesc, isBefore, isSameDay } from 'date-fns'; import { isEmpty, uniqBy } from 'lodash'; import { GetValueObject } from './interfaces/get-value-object.interface'; @@ -125,33 +127,19 @@ export class CurrentRateService { if (!isEmpty(quoteErrors)) { 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) => { + const valueOfToday = response.values.find((currentValue) => { return ( currentValue.dataSource === dataSource && currentValue.symbol === symbol && - isToday(currentValue.date) + isSameDay(currentValue.date, today, { in: utc }) ); }); - if (!value) { - // Fallback to unit price of latest activity - const latestActivity = - await this.activitiesService.getLatestActivity({ - dataSource, - symbol - }); - - value = { - dataSource, - symbol, - date: today, - marketPrice: latestActivity?.unitPrice ?? 0 - }; - - response.values.push(value); + if (valueOfToday?.marketPrice) { + continue; } + // If missing quote, fallback to the latest available historical market price const [latestValue] = response.values .filter((currentValue) => { return ( @@ -161,18 +149,33 @@ export class CurrentRateService { ); }) .sort((a, b) => { - if (a.date < b.date) { - return 1; - } + return compareDesc(a.date, b.date); + }); - if (a.date > b.date) { - return -1; - } + let marketPrice = latestValue?.marketPrice; - return 0; - }); + 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.marketPrice = latestValue.marketPrice; + if (valueOfToday) { + valueOfToday.marketPrice = marketPrice; + } else { + response.values.push({ + dataSource, + marketPrice, + symbol, + date: today + }); + } } catch {} } } @@ -181,8 +184,10 @@ export class CurrentRateService { } private containsToday(dates: Date[]): boolean { + const today = resetHours(new Date()); + for (const date of dates) { - if (isToday(date)) { + if (isSameDay(date, today, { in: utc })) { return true; } }