diff --git a/apps/api/src/app/activities/activities.service.ts b/apps/api/src/app/activities/activities.service.ts index 9e1a1b8d4c..5fc23989d3 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 b2ec6cd2d8..436d90c110 100644 --- a/apps/api/src/app/portfolio/current-rate.service.spec.ts +++ b/apps/api/src/app/portfolio/current-rate.service.spec.ts @@ -5,7 +5,12 @@ import { PropertyService } from '@ghostfolio/api/services/property/property.serv import { resetHours } from '@ghostfolio/common/helper'; import { AssetProfileIdentifier } from '@ghostfolio/common/interfaces'; -import { DataSource, MarketData, Order } from '@prisma/client'; +import { + DataSource, + MarketData, + Order, + Type as ActivityType +} from '@prisma/client'; import { addDays, subDays } from 'date-fns'; import { CurrentRateService } from './current-rate.service'; @@ -187,6 +192,53 @@ 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') @@ -220,8 +272,8 @@ describe('CurrentRateService', () => { ]); }); - it('should fall back to the unit price of the latest activity without market data', async () => { - jest + it('should fall back to the unit price of the latest buy or sell activity without market data', async () => { + const getLatestActivity = jest .spyOn(activitiesService, 'getLatestActivity') .mockResolvedValue({ unitPrice: 1000 } as Order); @@ -232,6 +284,11 @@ describe('CurrentRateService', () => { dateQuery }); + expect(getLatestActivity).toHaveBeenCalledWith({ + dataSource: DataSource.YAHOO, + symbol: 'AMZN', + types: [ActivityType.BUY, ActivityType.SELL] + }); expect(errors).toEqual(dataGatheringItems); expect(values).toEqual([ { diff --git a/apps/api/src/app/portfolio/current-rate.service.ts b/apps/api/src/app/portfolio/current-rate.service.ts index 80d37259a4..c4327c70a1 100644 --- a/apps/api/src/app/portfolio/current-rate.service.ts +++ b/apps/api/src/app/portfolio/current-rate.service.ts @@ -12,8 +12,10 @@ import { ResponseError } from '@ghostfolio/common/interfaces'; +import { utc } from '@date-fns/utc'; import { Injectable } from '@nestjs/common'; -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-es'; import { GetValueObject } from './interfaces/get-value-object.interface'; @@ -122,44 +124,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) { - 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 - }; - - 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 ( @@ -169,18 +146,43 @@ 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 latest market price outside of the date range + const latestMarketData = await this.marketDataService.getLatest({ + dataSource, + symbol }); - value.marketPrice = latestValue.marketPrice; + 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 + }); + } } catch {} } } @@ -189,8 +191,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; } }