diff --git a/CHANGELOG.md b/CHANGELOG.md index 6af0cf179b..f345550243 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -11,6 +11,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Upgraded `Nx` from version `23.1.1` to `23.2.1` +### Fixed + +- Fixed an issue where holdings without a market price have been valued at the unit price of a dividend, a fee, an interest or a liability + ## 3.79.0 - 2026-10-04 ### Added diff --git a/apps/api/src/app/activities/activities.service.spec.ts b/apps/api/src/app/activities/activities.service.spec.ts index d1eb1b4873..09aa64b0e2 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,43 @@ describe('ActivitiesService', () => { return result.activities[0]; } }); + + describe('getLatestActivity', () => { + it('filters by type and unit price 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' }, { createdAt: 'desc' }], + where: { + ...WHERE_ACTIVITY_NOT_DRAFT, + SymbolProfile: { dataSource: DataSource.YAHOO, symbol: 'AAPL' }, + type: { in: [ActivityType.BUY, ActivityType.SELL] }, + unitPrice: { gt: 0 } + } + }); + }); + }); }); function createActivity({ diff --git a/apps/api/src/app/activities/activities.service.ts b/apps/api/src/app/activities/activities.service.ts index 9e1a1b8d4c..1c933956a8 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' - }, + orderBy: [{ date: 'desc' }, { createdAt: 'desc' }], where: { - SymbolProfile: { dataSource, symbol } + ...WHERE_ACTIVITY_NOT_DRAFT, + SymbolProfile: { dataSource, symbol }, + type: { in: types }, + unitPrice: { gt: 0 } } }); } 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..e7128b1389 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'; @@ -220,8 +225,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 +237,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..5a3daddb57 100644 --- a/apps/api/src/app/portfolio/current-rate.service.ts +++ b/apps/api/src/app/portfolio/current-rate.service.ts @@ -13,6 +13,7 @@ import { } from '@ghostfolio/common/interfaces'; import { Injectable } from '@nestjs/common'; +import { Type as ActivityType } from '@prisma/client'; import { isBefore, isToday } from 'date-fns'; import { isEmpty, uniqBy } from 'lodash-es'; @@ -140,11 +141,12 @@ export class CurrentRateService { let marketPrice = latestMarketData?.marketPrice; if (!marketPrice) { - // Fallback to unit price of latest activity + // Fallback to unit price of latest buy or sell activity const latestActivity = await this.activitiesService.getLatestActivity({ dataSource, - symbol + symbol, + types: [ActivityType.BUY, ActivityType.SELL] }); marketPrice = latestActivity?.unitPrice ?? 0;