Browse Source

Fix market price fallback

bugfix/market-price-fallback
Thomas Kaul 4 days ago
parent
commit
a0720bc457
  1. 8
      apps/api/src/app/activities/activities.service.ts
  2. 100
      apps/api/src/app/portfolio/current-rate.service.spec.ts
  3. 65
      apps/api/src/app/portfolio/current-rate.service.ts

8
apps/api/src/app/activities/activities.service.ts

@ -581,14 +581,16 @@ export class ActivitiesService {
public async getLatestActivity({ public async getLatestActivity({
dataSource, dataSource,
symbol symbol,
}: AssetProfileIdentifier) { types
}: AssetProfileIdentifier & { types?: ActivityType[] }) {
return this.prismaService.order.findFirst({ return this.prismaService.order.findFirst({
orderBy: { orderBy: {
date: 'desc' date: 'desc'
}, },
where: { where: {
SymbolProfile: { dataSource, symbol } SymbolProfile: { dataSource, symbol },
...(types?.length > 0 && { type: { in: types } })
} }
}); });
} }

100
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 { DataProviderService } from '@ghostfolio/api/services/data-provider/data-provider.service';
import { MarketDataService } from '@ghostfolio/api/services/market-data/market-data.service'; import { MarketDataService } from '@ghostfolio/api/services/market-data/market-data.service';
import { PropertyService } from '@ghostfolio/api/services/property/property.service'; import { PropertyService } from '@ghostfolio/api/services/property/property.service';
import { resetHours } from '@ghostfolio/common/helper';
import { AssetProfileIdentifier } from '@ghostfolio/common/interfaces'; 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 { CurrentRateService } from './current-rate.service';
import { DateQuery } from './interfaces/date-query.interface'; import { DateQuery } from './interfaces/date-query.interface';
@ -97,12 +106,17 @@ jest.mock('@ghostfolio/api/services/property/property.service', () => {
}); });
describe('CurrentRateService', () => { describe('CurrentRateService', () => {
let activitiesService: ActivitiesService;
let currentRateService: CurrentRateService; let currentRateService: CurrentRateService;
let dataProviderService: DataProviderService; let dataProviderService: DataProviderService;
let marketDataService: MarketDataService; let marketDataService: MarketDataService;
let propertyService: PropertyService; let propertyService: PropertyService;
beforeAll(async () => { beforeAll(async () => {
activitiesService = {
getLatestActivity: jest.fn()
} as unknown as ActivitiesService;
propertyService = new PropertyService(null); propertyService = new PropertyService(null);
dataProviderService = new DataProviderService( dataProviderService = new DataProviderService(
@ -117,13 +131,17 @@ describe('CurrentRateService', () => {
marketDataService = new MarketDataService(null); marketDataService = new MarketDataService(null);
currentRateService = new CurrentRateService( currentRateService = new CurrentRateService(
null, activitiesService,
dataProviderService, dataProviderService,
marketDataService, marketDataService,
null null
); );
}); });
afterEach(() => {
jest.restoreAllMocks();
});
it('getValues', async () => { it('getValues', async () => {
expect( expect(
await currentRateService.getValues({ 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'
}
]);
});
}); });

65
apps/api/src/app/portfolio/current-rate.service.ts

@ -13,9 +13,11 @@ import {
} from '@ghostfolio/common/interfaces'; } from '@ghostfolio/common/interfaces';
import type { RequestWithUser } from '@ghostfolio/common/types'; import type { RequestWithUser } from '@ghostfolio/common/types';
import { utc } from '@date-fns/utc';
import { Inject, Injectable } from '@nestjs/common'; import { Inject, Injectable } from '@nestjs/common';
import { REQUEST } from '@nestjs/core'; 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 { isEmpty, uniqBy } from 'lodash';
import { GetValueObject } from './interfaces/get-value-object.interface'; import { GetValueObject } from './interfaces/get-value-object.interface';
@ -125,33 +127,19 @@ export class CurrentRateService {
if (!isEmpty(quoteErrors)) { if (!isEmpty(quoteErrors)) {
for (const { dataSource, symbol } of quoteErrors) { for (const { dataSource, symbol } of quoteErrors) {
try { try {
// If missing quote, fallback to the latest available historical market price const valueOfToday = response.values.find((currentValue) => {
let value: GetValueObject = response.values.find((currentValue) => {
return ( return (
currentValue.dataSource === dataSource && currentValue.dataSource === dataSource &&
currentValue.symbol === symbol && currentValue.symbol === symbol &&
isToday(currentValue.date) isSameDay(currentValue.date, today, { in: utc })
); );
}); });
if (!value) { if (valueOfToday?.marketPrice) {
// Fallback to unit price of latest activity continue;
const latestActivity =
await this.activitiesService.getLatestActivity({
dataSource,
symbol
});
value = {
dataSource,
symbol,
date: today,
marketPrice: latestActivity?.unitPrice ?? 0
};
response.values.push(value);
} }
// If missing quote, fallback to the latest available historical market price
const [latestValue] = response.values const [latestValue] = response.values
.filter((currentValue) => { .filter((currentValue) => {
return ( return (
@ -161,18 +149,33 @@ export class CurrentRateService {
); );
}) })
.sort((a, b) => { .sort((a, b) => {
if (a.date < b.date) { return compareDesc(a.date, b.date);
return 1; });
}
if (a.date > b.date) { let marketPrice = latestValue?.marketPrice;
return -1;
}
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 {} } catch {}
} }
} }
@ -181,8 +184,10 @@ export class CurrentRateService {
} }
private containsToday(dates: Date[]): boolean { private containsToday(dates: Date[]): boolean {
const today = resetHours(new Date());
for (const date of dates) { for (const date of dates) {
if (isToday(date)) { if (isSameDay(date, today, { in: utc })) {
return true; return true;
} }
} }

Loading…
Cancel
Save