Browse Source

Bugfix/ignore future-dated account balances in portfolio calculation (#7436)

* Ignore future-dated account balances in the portfolio calculation

* Update changelog
pull/7438/head^2
Thomas Kaul 1 week ago
committed by GitHub
parent
commit
b8f6037f8c
No known key found for this signature in database GPG Key ID: B5690EEEBB952194
  1. 4
      CHANGELOG.md
  2. 12
      apps/api/src/app/account-balance/account-balance.service.ts
  3. 32
      apps/api/src/app/account/account.service.ts
  4. 15
      apps/api/src/app/activities/activities.service.ts
  5. 8
      apps/api/src/app/portfolio/calculator/roai/portfolio-calculator-cash.spec.ts
  6. 17
      apps/api/src/helper/account.helper.ts

4
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 account detail dialog (experimental)
- Moved the tags to the overview tab of the holding detail dialog - 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 ## 3.36.0 - 2026-07-29
### Added ### Added

12
apps/api/src/app/account-balance/account-balance.service.ts

@ -1,5 +1,8 @@
import { PortfolioChangedEvent } from '@ghostfolio/api/events/portfolio-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 { LogPerformance } from '@ghostfolio/api/interceptors/performance-logging/performance-logging.interceptor';
import { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-data/exchange-rate-data.service'; import { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-data/exchange-rate-data.service';
import { PrismaService } from '@ghostfolio/api/services/prisma/prisma.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 { EventEmitter2 } from '@nestjs/event-emitter';
import { AccountBalance, Prisma } from '@prisma/client'; import { AccountBalance, Prisma } from '@prisma/client';
import { Big } from 'big.js'; import { Big } from 'big.js';
import { format, parseISO } from 'date-fns'; import { endOfToday, format, parseISO } from 'date-fns';
import { groupBy } from 'lodash'; import { groupBy } from 'lodash';
@Injectable() @Injectable()
@ -114,8 +117,13 @@ export class AccountBalanceService {
const accumulatedBalancesByDate: { [date: string]: HistoricalDataItem } = const accumulatedBalancesByDate: { [date: string]: HistoricalDataItem } =
{}; {};
const lastBalancesByAccount: { [accountId: string]: Big } = {}; const lastBalancesByAccount: { [accountId: string]: Big } = {};
const endOfTodayDate = endOfToday();
for (const { accountId, date, valueInBaseCurrency } of balances) { for (const { accountId, date, valueInBaseCurrency } of balances) {
if (isAccountBalanceInFuture({ date, endOfTodayDate })) {
continue;
}
const formattedDate = format(date, DATE_FORMAT); const formattedDate = format(date, DATE_FORMAT);
lastBalancesByAccount[accountId] = new Big(valueInBaseCurrency); lastBalancesByAccount[accountId] = new Big(valueInBaseCurrency);

32
apps/api/src/app/account/account.service.ts

@ -1,6 +1,10 @@
import { AccountBalanceService } from '@ghostfolio/api/app/account-balance/account-balance.service'; import { AccountBalanceService } from '@ghostfolio/api/app/account-balance/account-balance.service';
import { PortfolioChangedEvent } from '@ghostfolio/api/events/portfolio-changed.event'; 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 { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-data/exchange-rate-data.service';
import { PrismaService } from '@ghostfolio/api/services/prisma/prisma.service'; import { PrismaService } from '@ghostfolio/api/services/prisma/prisma.service';
import { TagService } from '@ghostfolio/api/services/tag/tag.service'; import { TagService } from '@ghostfolio/api/services/tag/tag.service';
@ -19,7 +23,7 @@ import {
Tag Tag
} from '@prisma/client'; } from '@prisma/client';
import { Big } from 'big.js'; import { Big } from 'big.js';
import { format } from 'date-fns'; import { endOfToday, format } from 'date-fns';
import { groupBy } from 'lodash'; import { groupBy } from 'lodash';
import { CashDetails } from './interfaces/cash-details.interface'; import { CashDetails } from './interfaces/cash-details.interface';
@ -41,7 +45,9 @@ export class AccountService {
include: { include: {
balances: { balances: {
orderBy: { date: 'desc' }, orderBy: { date: 'desc' },
take: 1 take: 1,
// Ignore account balances in the future
where: getWhereAccountBalanceNotInFuture()
} }
}, },
where: { id_userId } where: { id_userId }
@ -95,7 +101,16 @@ export class AccountService {
include.balances = { include.balances = {
orderBy: { date: 'desc' }, 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) { if (isTagsIncluded) {
@ -115,10 +130,17 @@ export class AccountService {
where where
}); });
const endOfTodayDate = endOfToday();
return accounts.map((account) => { return accounts.map((account) => {
const result = { const result = {
...account, ...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 tags: isTagsIncluded
? (account.tags as unknown as { tag: Tag }[]).map(({ tag }) => { ? (account.tags as unknown as { tag: Tag }[]).map(({ tag }) => {
return tag; return tag;

15
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 { CashDetails } from '@ghostfolio/api/app/account/interfaces/cash-details.interface';
import { AssetProfileChangedEvent } from '@ghostfolio/api/events/asset-profile-changed.event'; import { AssetProfileChangedEvent } from '@ghostfolio/api/events/asset-profile-changed.event';
import { PortfolioChangedEvent } from '@ghostfolio/api/events/portfolio-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 { LogPerformance } from '@ghostfolio/api/interceptors/performance-logging/performance-logging.interceptor';
import { BenchmarkService } from '@ghostfolio/api/services/benchmark/benchmark.service'; import { BenchmarkService } from '@ghostfolio/api/services/benchmark/benchmark.service';
import { DataProviderService } from '@ghostfolio/api/services/data-provider/data-provider.service'; import { DataProviderService } from '@ghostfolio/api/services/data-provider/data-provider.service';
@ -456,6 +459,7 @@ export class ActivitiesService {
} }
const activities: Activity[] = []; const activities: Activity[] = [];
const endOfTodayDate = endOfToday();
for (const account of cashDetails.accounts) { for (const account of cashDetails.accounts) {
const { balances } = await this.accountBalanceService.getAccountBalances({ const { balances } = await this.accountBalanceService.getAccountBalances({
@ -468,6 +472,15 @@ export class ActivitiesService {
let currentBalanceInBaseCurrency = 0; let currentBalanceInBaseCurrency = 0;
for (const balanceItem of balances) { for (const balanceItem of balances) {
if (
isAccountBalanceInFuture({
endOfTodayDate,
date: balanceItem.date
})
) {
continue;
}
const syntheticActivityTemplate: Activity = { const syntheticActivityTemplate: Activity = {
userId, userId,
accountId: account.id, accountId: account.id,

8
apps/api/src/app/portfolio/calculator/roai/portfolio-calculator-cash.spec.ts

@ -166,6 +166,14 @@ describe('PortfolioCalculator', () => {
id: randomUUID(), id: randomUUID(),
value: 2000, value: 2000,
valueInBaseCurrency: 1800 valueInBaseCurrency: 1800
},
{
// Ignored future account balance
accountId,
date: parseDate('2050-12-31'),
id: randomUUID(),
value: 0,
valueInBaseCurrency: 0
} }
] ]
}); });

17
apps/api/src/helper/account.helper.ts

@ -1,6 +1,7 @@
import { TAG_ID_EXCLUDE_FROM_ANALYSIS } from '@ghostfolio/common/config'; import { TAG_ID_EXCLUDE_FROM_ANALYSIS } from '@ghostfolio/common/config';
import { Prisma } from '@prisma/client'; import { Prisma } from '@prisma/client';
import { endOfToday, isAfter } from 'date-fns';
export const WHERE_ACCOUNT_NOT_EXCLUDED: Prisma.AccountWhereInput = { export const WHERE_ACCOUNT_NOT_EXCLUDED: Prisma.AccountWhereInput = {
isExcluded: false, 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);
}

Loading…
Cancel
Save