From b8f6037f8c0612bf719ad1a3c7d84312407f57b1 Mon Sep 17 00:00:00 2001 From: Thomas Kaul <4159106+dtslvr@users.noreply.github.com> Date: Wed, 29 Jul 2026 18:04:12 +0200 Subject: [PATCH] Bugfix/ignore future-dated account balances in portfolio calculation (#7436) * Ignore future-dated account balances in the portfolio calculation * Update changelog --- CHANGELOG.md | 4 +++ .../account-balance.service.ts | 12 +++++-- apps/api/src/app/account/account.service.ts | 32 ++++++++++++++++--- .../src/app/activities/activities.service.ts | 15 ++++++++- .../roai/portfolio-calculator-cash.spec.ts | 8 +++++ apps/api/src/helper/account.helper.ts | 17 ++++++++++ 6 files changed, 80 insertions(+), 8 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 2b33b1405..f78b9dad5 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -12,6 +12,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Moved the tags to the overview tab of the account detail dialog (experimental) - Moved the tags to the overview tab of the holding detail dialog +### Fixed + +- Ignored future-dated account balances in the portfolio calculation + ## 3.36.0 - 2026-07-29 ### Added diff --git a/apps/api/src/app/account-balance/account-balance.service.ts b/apps/api/src/app/account-balance/account-balance.service.ts index 84932f429..29c7f2887 100644 --- a/apps/api/src/app/account-balance/account-balance.service.ts +++ b/apps/api/src/app/account-balance/account-balance.service.ts @@ -1,5 +1,8 @@ import { PortfolioChangedEvent } from '@ghostfolio/api/events/portfolio-changed.event'; -import { WHERE_ACCOUNT_NOT_EXCLUDED } from '@ghostfolio/api/helper/account.helper'; +import { + isAccountBalanceInFuture, + WHERE_ACCOUNT_NOT_EXCLUDED +} from '@ghostfolio/api/helper/account.helper'; import { LogPerformance } from '@ghostfolio/api/interceptors/performance-logging/performance-logging.interceptor'; import { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-data/exchange-rate-data.service'; import { PrismaService } from '@ghostfolio/api/services/prisma/prisma.service'; @@ -15,7 +18,7 @@ import { Injectable } from '@nestjs/common'; import { EventEmitter2 } from '@nestjs/event-emitter'; import { AccountBalance, Prisma } from '@prisma/client'; import { Big } from 'big.js'; -import { format, parseISO } from 'date-fns'; +import { endOfToday, format, parseISO } from 'date-fns'; import { groupBy } from 'lodash'; @Injectable() @@ -114,8 +117,13 @@ export class AccountBalanceService { const accumulatedBalancesByDate: { [date: string]: HistoricalDataItem } = {}; const lastBalancesByAccount: { [accountId: string]: Big } = {}; + const endOfTodayDate = endOfToday(); for (const { accountId, date, valueInBaseCurrency } of balances) { + if (isAccountBalanceInFuture({ date, endOfTodayDate })) { + continue; + } + const formattedDate = format(date, DATE_FORMAT); lastBalancesByAccount[accountId] = new Big(valueInBaseCurrency); diff --git a/apps/api/src/app/account/account.service.ts b/apps/api/src/app/account/account.service.ts index 209806234..7f0451101 100644 --- a/apps/api/src/app/account/account.service.ts +++ b/apps/api/src/app/account/account.service.ts @@ -1,6 +1,10 @@ import { AccountBalanceService } from '@ghostfolio/api/app/account-balance/account-balance.service'; import { PortfolioChangedEvent } from '@ghostfolio/api/events/portfolio-changed.event'; -import { WHERE_ACCOUNT_NOT_EXCLUDED } from '@ghostfolio/api/helper/account.helper'; +import { + getWhereAccountBalanceNotInFuture, + isAccountBalanceInFuture, + WHERE_ACCOUNT_NOT_EXCLUDED +} from '@ghostfolio/api/helper/account.helper'; import { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-data/exchange-rate-data.service'; import { PrismaService } from '@ghostfolio/api/services/prisma/prisma.service'; import { TagService } from '@ghostfolio/api/services/tag/tag.service'; @@ -19,7 +23,7 @@ import { Tag } from '@prisma/client'; import { Big } from 'big.js'; -import { format } from 'date-fns'; +import { endOfToday, format } from 'date-fns'; import { groupBy } from 'lodash'; import { CashDetails } from './interfaces/cash-details.interface'; @@ -41,7 +45,9 @@ export class AccountService { include: { balances: { orderBy: { date: 'desc' }, - take: 1 + take: 1, + // Ignore account balances in the future + where: getWhereAccountBalanceNotInFuture() } }, where: { id_userId } @@ -95,7 +101,16 @@ export class AccountService { include.balances = { orderBy: { date: 'desc' }, - ...(isBalancesIncluded ? {} : { take: 1 }) + // If the balances are included, they are returned as-is (including the + // ones in the future) because the client renders the full history. The + // balance is derived below and skips the account balances in the future. + ...(isBalancesIncluded + ? {} + : { + take: 1, + // Ignore account balances in the future + where: getWhereAccountBalanceNotInFuture() + }) }; if (isTagsIncluded) { @@ -115,10 +130,17 @@ export class AccountService { where }); + const endOfTodayDate = endOfToday(); + return accounts.map((account) => { const result = { ...account, - balance: account.balances[0]?.value ?? 0, + balance: + // The balances are ordered by date descending, hence the first account + // balance which is not in the future reflects the current balance + account.balances.find(({ date }) => { + return !isAccountBalanceInFuture({ date, endOfTodayDate }); + })?.value ?? 0, tags: isTagsIncluded ? (account.tags as unknown as { tag: Tag }[]).map(({ tag }) => { return tag; diff --git a/apps/api/src/app/activities/activities.service.ts b/apps/api/src/app/activities/activities.service.ts index 0a390d4ec..7d6e822c5 100644 --- a/apps/api/src/app/activities/activities.service.ts +++ b/apps/api/src/app/activities/activities.service.ts @@ -3,7 +3,10 @@ import { AccountService } from '@ghostfolio/api/app/account/account.service'; import { CashDetails } from '@ghostfolio/api/app/account/interfaces/cash-details.interface'; import { AssetProfileChangedEvent } from '@ghostfolio/api/events/asset-profile-changed.event'; import { PortfolioChangedEvent } from '@ghostfolio/api/events/portfolio-changed.event'; -import { WHERE_ACCOUNT_NOT_EXCLUDED } from '@ghostfolio/api/helper/account.helper'; +import { + isAccountBalanceInFuture, + WHERE_ACCOUNT_NOT_EXCLUDED +} from '@ghostfolio/api/helper/account.helper'; import { LogPerformance } from '@ghostfolio/api/interceptors/performance-logging/performance-logging.interceptor'; import { BenchmarkService } from '@ghostfolio/api/services/benchmark/benchmark.service'; import { DataProviderService } from '@ghostfolio/api/services/data-provider/data-provider.service'; @@ -456,6 +459,7 @@ export class ActivitiesService { } const activities: Activity[] = []; + const endOfTodayDate = endOfToday(); for (const account of cashDetails.accounts) { const { balances } = await this.accountBalanceService.getAccountBalances({ @@ -468,6 +472,15 @@ export class ActivitiesService { let currentBalanceInBaseCurrency = 0; for (const balanceItem of balances) { + if ( + isAccountBalanceInFuture({ + endOfTodayDate, + date: balanceItem.date + }) + ) { + continue; + } + const syntheticActivityTemplate: Activity = { userId, accountId: account.id, diff --git a/apps/api/src/app/portfolio/calculator/roai/portfolio-calculator-cash.spec.ts b/apps/api/src/app/portfolio/calculator/roai/portfolio-calculator-cash.spec.ts index 9bef6ad39..1fc705b46 100644 --- a/apps/api/src/app/portfolio/calculator/roai/portfolio-calculator-cash.spec.ts +++ b/apps/api/src/app/portfolio/calculator/roai/portfolio-calculator-cash.spec.ts @@ -166,6 +166,14 @@ describe('PortfolioCalculator', () => { id: randomUUID(), value: 2000, valueInBaseCurrency: 1800 + }, + { + // Ignored future account balance + accountId, + date: parseDate('2050-12-31'), + id: randomUUID(), + value: 0, + valueInBaseCurrency: 0 } ] }); diff --git a/apps/api/src/helper/account.helper.ts b/apps/api/src/helper/account.helper.ts index 0800c022e..8e7520d0a 100644 --- a/apps/api/src/helper/account.helper.ts +++ b/apps/api/src/helper/account.helper.ts @@ -1,6 +1,7 @@ import { TAG_ID_EXCLUDE_FROM_ANALYSIS } from '@ghostfolio/common/config'; import { Prisma } from '@prisma/client'; +import { endOfToday, isAfter } from 'date-fns'; export const WHERE_ACCOUNT_NOT_EXCLUDED: Prisma.AccountWhereInput = { isExcluded: false, @@ -10,3 +11,19 @@ export const WHERE_ACCOUNT_NOT_EXCLUDED: Prisma.AccountWhereInput = { } } }; + +export function getWhereAccountBalanceNotInFuture(): Prisma.AccountBalanceWhereInput { + return { + date: { lte: endOfToday() } + }; +} + +export function isAccountBalanceInFuture({ + date, + endOfTodayDate = endOfToday() +}: { + date: Date; + endOfTodayDate?: Date; +}) { + return isAfter(date, endOfTodayDate); +}