Browse Source

Fix market price fallback

bugfix/market-price-fallback
Thomas Kaul 1 month ago
parent
commit
1d640f83c3
  1. 8
      apps/api/src/app/activities/activities.service.ts
  2. 63
      apps/api/src/app/portfolio/current-rate.service.spec.ts
  3. 84
      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 } })
} }
}); });
} }

63
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 { resetHours } from '@ghostfolio/common/helper';
import { AssetProfileIdentifier } from '@ghostfolio/common/interfaces'; 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 { addDays, subDays } from 'date-fns';
import { CurrentRateService } from './current-rate.service'; import { CurrentRateService } from './current-rate.service';
@ -187,6 +192,53 @@ describe('CurrentRateService', () => {
jest.restoreAllMocks(); 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 () => { it('should fall back to the latest market price', async () => {
const getLatestActivity = jest const getLatestActivity = jest
.spyOn(activitiesService, 'getLatestActivity') .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 () => { it('should fall back to the unit price of the latest buy or sell activity without market data', async () => {
jest const getLatestActivity = jest
.spyOn(activitiesService, 'getLatestActivity') .spyOn(activitiesService, 'getLatestActivity')
.mockResolvedValue({ unitPrice: 1000 } as Order); .mockResolvedValue({ unitPrice: 1000 } as Order);
@ -232,6 +284,11 @@ describe('CurrentRateService', () => {
dateQuery dateQuery
}); });
expect(getLatestActivity).toHaveBeenCalledWith({
dataSource: DataSource.YAHOO,
symbol: 'AMZN',
types: [ActivityType.BUY, ActivityType.SELL]
});
expect(errors).toEqual(dataGatheringItems); expect(errors).toEqual(dataGatheringItems);
expect(values).toEqual([ expect(values).toEqual([
{ {

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

@ -12,8 +12,10 @@ import {
ResponseError ResponseError
} from '@ghostfolio/common/interfaces'; } from '@ghostfolio/common/interfaces';
import { utc } from '@date-fns/utc';
import { Injectable } from '@nestjs/common'; 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 { isEmpty, uniqBy } from 'lodash-es';
import { GetValueObject } from './interfaces/get-value-object.interface'; import { GetValueObject } from './interfaces/get-value-object.interface';
@ -122,44 +124,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) {
const latestMarketData = await this.marketDataService.getLatest({ continue;
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 missing quote, fallback to the latest available historical market price
const [latestValue] = response.values const [latestValue] = response.values
.filter((currentValue) => { .filter((currentValue) => {
return ( return (
@ -169,18 +146,43 @@ 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 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 {} } catch {}
} }
} }
@ -189,8 +191,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