From 6a1cbccc4391eec81a3e4481e128e6bab3a24c2e Mon Sep 17 00:00:00 2001 From: Thomas Kaul <4159106+dtslvr@users.noreply.github.com> Date: Sat, 10 Oct 2026 20:33:47 +0200 Subject: [PATCH 1/4] Bugfix/AI prompt in impersonation mode (#8115) * Fix AI prompt in impersonation mode * Update changelog --- CHANGELOG.md | 4 ++++ .../api/src/app/endpoints/ai/ai.controller.ts | 19 ++++++++++++------- apps/api/src/app/endpoints/ai/ai.module.ts | 2 ++ 3 files changed, 18 insertions(+), 7 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 5216b1726a..a5aea753b4 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -13,6 +13,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Extended the access to share the portfolio with the Model Context Protocol (MCP) to support the _View_ and the _View and manage_ permissions (experimental) - Improved the performance of getting the historical market data by selecting only the required columns +### Fixed + +- Fixed the _Copy AI prompt to clipboard_ actions on the analysis page in impersonation mode to be based on the impersonated user (experimental) + ## 3.83.0 - 2026-10-10 ### Added diff --git a/apps/api/src/app/endpoints/ai/ai.controller.ts b/apps/api/src/app/endpoints/ai/ai.controller.ts index 9691e211de..3c7f6661d7 100644 --- a/apps/api/src/app/endpoints/ai/ai.controller.ts +++ b/apps/api/src/app/endpoints/ai/ai.controller.ts @@ -1,11 +1,17 @@ import { HasPermission } from '@ghostfolio/api/decorators/has-permission.decorator'; +import { Impersonation } from '@ghostfolio/api/decorators/impersonation.decorator'; +import { RequiresScope } from '@ghostfolio/api/decorators/requires-scope.decorator'; import { FilterDto } from '@ghostfolio/api/dtos/filter.dto'; -import { HasPermissionGuard } from '@ghostfolio/api/guards/has-permission.guard'; import { TransformDataSourceInRequestInterceptor } from '@ghostfolio/api/interceptors/transform-data-source-in-request/transform-data-source-in-request.interceptor'; import { ApiService } from '@ghostfolio/api/services/api/api.service'; import { AiPromptResponse } from '@ghostfolio/common/interfaces'; import { permissions } from '@ghostfolio/common/permissions'; -import type { AiPromptMode, RequestWithUser } from '@ghostfolio/common/types'; +import { scopes } from '@ghostfolio/common/scopes'; +import type { + AiPromptMode, + ImpersonationContext, + RequestWithUser +} from '@ghostfolio/common/types'; import { Controller, @@ -13,11 +19,9 @@ import { Inject, Param, Query, - UseGuards, UseInterceptors } from '@nestjs/common'; import { REQUEST } from '@nestjs/core'; -import { AuthGuard } from '@nestjs/passport'; import { AiService } from './ai.service'; @@ -31,9 +35,10 @@ export class AiController { @Get('prompt/:mode') @HasPermission(permissions.readAiPrompt) - @UseGuards(AuthGuard('jwt'), HasPermissionGuard) + @RequiresScope(scopes.portfolioRead) @UseInterceptors(TransformDataSourceInRequestInterceptor) public async getPrompt( + @Impersonation() { userId, userSettings }: ImpersonationContext, @Param('mode') mode: AiPromptMode, @Query() { accounts, assetClasses, dataSource, symbol, tags }: FilterDto @@ -49,9 +54,9 @@ export class AiController { const prompt = await this.aiService.getPrompt({ filters, mode, + userId, languageCode: this.request.user.settings.settings.language, - userCurrency: this.request.user.settings.settings.baseCurrency, - userId: this.request.user.id + userCurrency: userSettings.baseCurrency }); return { prompt }; diff --git a/apps/api/src/app/endpoints/ai/ai.module.ts b/apps/api/src/app/endpoints/ai/ai.module.ts index f46ed1df88..756f6914fd 100644 --- a/apps/api/src/app/endpoints/ai/ai.module.ts +++ b/apps/api/src/app/endpoints/ai/ai.module.ts @@ -1,6 +1,7 @@ import { TransformDataSourceInRequestModule } from '@ghostfolio/api/interceptors/transform-data-source-in-request/transform-data-source-in-request.module'; import { ApiModule } from '@ghostfolio/api/services/api/api.module'; import { ConfigurationModule } from '@ghostfolio/api/services/configuration/configuration.module'; +import { ImpersonationModule } from '@ghostfolio/api/services/impersonation/impersonation.module'; import { PortfolioTableModule } from '@ghostfolio/api/services/portfolio-table/portfolio-table.module'; import { PropertyModule } from '@ghostfolio/api/services/property/property.module'; @@ -15,6 +16,7 @@ import { AiService } from './ai.service'; imports: [ ApiModule, ConfigurationModule, + ImpersonationModule, PortfolioTableModule, PropertyModule, TransformDataSourceInRequestModule From 8fa4f6a6739afee82461b3aa48f47dfa6a396dc8 Mon Sep 17 00:00:00 2001 From: Thomas Kaul <4159106+dtslvr@users.noreply.github.com> Date: Sat, 10 Oct 2026 20:41:52 +0200 Subject: [PATCH 2/4] Bugfix/date ranges in time zones other than UTC (#8099) * Fix start date of date ranges for instances in time zones with negative UTC offset * Update changelog --- CHANGELOG.md | 1 + .../calculator/portfolio-calculator.ts | 9 +--- .../roai/portfolio-calculator.spec.ts | 44 +++++++++++++++++++ .../app/portfolio/portfolio.service.spec.ts | 8 ++-- .../src/app/portfolio/portfolio.service.ts | 10 ++++- .../common/src/lib/calculation-helper.spec.ts | 24 ++++++++++ libs/common/src/lib/calculation-helper.ts | 20 +++------ 7 files changed, 90 insertions(+), 26 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index a5aea753b4..dc2c6193e1 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -16,6 +16,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Fixed - Fixed the _Copy AI prompt to clipboard_ actions on the analysis page in impersonation mode to be based on the impersonated user (experimental) +- Fixed the start date of the date ranges for instances in time zones with a negative UTC offset ## 3.83.0 - 2026-10-10 diff --git a/apps/api/src/app/portfolio/calculator/portfolio-calculator.ts b/apps/api/src/app/portfolio/calculator/portfolio-calculator.ts index ca440c6d2a..d172652a83 100644 --- a/apps/api/src/app/portfolio/calculator/portfolio-calculator.ts +++ b/apps/api/src/app/portfolio/calculator/portfolio-calculator.ts @@ -64,7 +64,6 @@ import { format, isAfter, isBefore, - isEqual, isFuture, isPast, isWithinInterval, @@ -1418,8 +1417,8 @@ export abstract class PortfolioCalculator { let netPerformanceAtStartDate: number; let netPerformanceWithCurrencyEffectAtStartDate: number; - const endDate = this.toChartDate(end); - const startDate = this.toChartDate(start); + const endDate = resetHours(end); + const startDate = resetHours(start); for (const historicalDataItem of historicalData) { const date = resetHours(parseDate(historicalDataItem.date)); @@ -1914,8 +1913,4 @@ export abstract class PortfolioCalculator { await this.initialize(attempt + 1); } } - - private toChartDate(aDate: Date) { - return isEqual(aDate, endOfDay(aDate)) ? resetHours(aDate) : aDate; - } } diff --git a/apps/api/src/app/portfolio/calculator/roai/portfolio-calculator.spec.ts b/apps/api/src/app/portfolio/calculator/roai/portfolio-calculator.spec.ts index 43ff160a78..c2779c2c14 100644 --- a/apps/api/src/app/portfolio/calculator/roai/portfolio-calculator.spec.ts +++ b/apps/api/src/app/portfolio/calculator/roai/portfolio-calculator.spec.ts @@ -190,6 +190,10 @@ describe('PortfolioCalculator', () => { }); describe('get performance', () => { + afterEach(() => { + jest.useRealTimers(); + }); + it('with a calendar year date range', async () => { const snapshot = await portfolioCalculator.getSnapshot(); @@ -226,5 +230,45 @@ describe('PortfolioCalculator', () => { }) ).toEqual(['2016-12-31', '2017-12-31']); }); + + it('with a year to date date range', async () => { + const snapshot = await portfolioCalculator.getSnapshot(); + + snapshot.historicalData = [ + getHistoricalDataItem({ + date: '2016-12-30', + investment: 100, + netPerformance: 0 + }), + getHistoricalDataItem({ + date: '2016-12-31', + investment: 100, + netPerformance: 10 + }), + getHistoricalDataItem({ + date: '2017-01-01', + investment: 100, + netPerformance: 20 + }) + ]; + + // 1 January 2017, 10:00 in New York + jest.useFakeTimers().setSystemTime(new Date('2017-01-01T15:00:00.000Z')); + + const { endDate, startDate } = getIntervalFromDateRange({ + dateRange: 'ytd' + }); + + const { chart } = await portfolioCalculator.getPerformance({ + end: endDate, + start: startDate + }); + + expect( + chart.map(({ date }) => { + return date; + }) + ).toEqual(['2016-12-31', '2017-01-01']); + }); }); }); diff --git a/apps/api/src/app/portfolio/portfolio.service.spec.ts b/apps/api/src/app/portfolio/portfolio.service.spec.ts index f562966b06..c408f51934 100644 --- a/apps/api/src/app/portfolio/portfolio.service.spec.ts +++ b/apps/api/src/app/portfolio/portfolio.service.spec.ts @@ -16,7 +16,7 @@ import { TAG_ID_EXCLUDE_FROM_ANALYSIS, UNKNOWN_KEY } from '@ghostfolio/common/config'; -import { parseDate, resetHours } from '@ghostfolio/common/helper'; +import { parseDate } from '@ghostfolio/common/helper'; import { Activity, AssetProfileIdentifier, @@ -1045,7 +1045,7 @@ describe('PortfolioService', () => { expect(getPerformanceOfCalculator).toHaveBeenCalledTimes(3); expect(getPerformanceOfCalculator).toHaveBeenNthCalledWith(2, { end: endOfDay(parseDate('2024-12-31')), - start: resetHours(parseDate('2024-06-15')) + start: parseDate('2024-06-15') }); expect(getPerformanceOfCalculator).toHaveBeenNthCalledWith(3, { end: endOfDay(parseDate('2025-06-15')), @@ -1099,11 +1099,11 @@ describe('PortfolioService', () => { expect(getPerformanceOfCalculator).toHaveBeenCalledTimes(3); expect(getPerformanceOfCalculator).toHaveBeenNthCalledWith(1, { end: endOfDay(parseDate('2026-01-01')), - start: resetHours(parseDate('2025-01-01')) + start: parseDate('2025-01-01') }); expect(getPerformanceOfCalculator).toHaveBeenNthCalledWith(2, { end: endOfDay(parseDate('2025-12-31')), - start: resetHours(parseDate('2025-01-01')) + start: parseDate('2025-01-01') }); expect(getPerformanceOfCalculator).toHaveBeenNthCalledWith(3, { end: endOfDay(parseDate('2026-01-01')), diff --git a/apps/api/src/app/portfolio/portfolio.service.ts b/apps/api/src/app/portfolio/portfolio.service.ts index 6e9f8c045a..ab2c0de2d4 100644 --- a/apps/api/src/app/portfolio/portfolio.service.ts +++ b/apps/api/src/app/portfolio/portfolio.service.ts @@ -47,6 +47,7 @@ import { isAccountExcluded, isDraftActivity, parseDate, + resetHours, resolveUserSettings } from '@ghostfolio/common/helper'; import { @@ -559,8 +560,13 @@ export class PortfolioService { const { historicalData } = await portfolioCalculator.getSnapshot(); - const items = historicalData.filter(({ date }) => { - return !isBefore(date, startDate) && !isAfter(date, endDate); + const items = historicalData.filter((historicalDataItem) => { + const date = resetHours(parseDate(historicalDataItem.date)); + + return ( + !isBefore(date, resetHours(startDate)) && + !isAfter(date, resetHours(endDate)) + ); }); let investments: InvestmentItem[]; diff --git a/libs/common/src/lib/calculation-helper.spec.ts b/libs/common/src/lib/calculation-helper.spec.ts index 75a24edbab..7924d72544 100644 --- a/libs/common/src/lib/calculation-helper.spec.ts +++ b/libs/common/src/lib/calculation-helper.spec.ts @@ -6,9 +6,14 @@ import { getIntervalFromDateRange } from './calculation-helper'; import { DATE_FORMAT } from './helper'; +import { DateRange } from './types'; describe('CalculationHelper', () => { describe('interval from date range', () => { + afterEach(() => { + jest.useRealTimers(); + }); + it('Get interval of a calendar year', async () => { const { endDate, startDate } = getIntervalFromDateRange({ dateRange: '2024' @@ -23,6 +28,25 @@ describe('CalculationHelper', () => { // part of the interval expect(startDate.getTime()).toEqual(new Date(2024, 0, 1).getTime() - 1); }); + + it('Get interval of a date range relative to today', () => { + jest.useFakeTimers().setSystemTime(new Date(2024, 9, 10, 22, 0)); + + expect( + (['1d', 'mtd', 'wtd', 'ytd', '1y', '5y'] as DateRange[]).map( + (dateRange) => { + return getIntervalFromDateRange({ dateRange }).startDate; + } + ) + ).toEqual([ + new Date(2024, 9, 9), + new Date(2024, 8, 30), + new Date(2024, 9, 6), + new Date(2023, 11, 31), + new Date(2023, 9, 10), + new Date(2019, 9, 10) + ]); + }); }); describe('annualized performance percentage', () => { diff --git a/libs/common/src/lib/calculation-helper.ts b/libs/common/src/lib/calculation-helper.ts index 2cc1fa2d1c..6ee06af857 100644 --- a/libs/common/src/lib/calculation-helper.ts +++ b/libs/common/src/lib/calculation-helper.ts @@ -3,6 +3,7 @@ import { endOfDay, endOfYear, max, + startOfDay, startOfMonth, startOfWeek, startOfYear, @@ -12,7 +13,6 @@ import { } from 'date-fns'; import { isFinite, isNumber } from 'lodash-es'; -import { resetHours } from './helper'; import { DateRange } from './types'; export function getAnnualizedPerformancePercent({ @@ -48,31 +48,25 @@ export function getIntervalFromDateRange(params: { switch (dateRange) { case '1d': - startDate = max([startDate, subDays(resetHours(new Date()), 1)]); + startDate = max([startDate, subDays(startOfDay(new Date()), 1)]); break; case 'mtd': - startDate = max([ - startDate, - subDays(startOfMonth(resetHours(new Date())), 1) - ]); + startDate = max([startDate, subDays(startOfMonth(new Date()), 1)]); break; case 'wtd': startDate = max([ startDate, - subDays(startOfWeek(resetHours(new Date()), { weekStartsOn: 1 }), 1) + subDays(startOfWeek(new Date(), { weekStartsOn: 1 }), 1) ]); break; case 'ytd': - startDate = max([ - startDate, - subDays(startOfYear(resetHours(new Date())), 1) - ]); + startDate = max([startDate, subDays(startOfYear(new Date()), 1)]); break; case '1y': - startDate = max([startDate, subYears(resetHours(new Date()), 1)]); + startDate = max([startDate, subYears(startOfDay(new Date()), 1)]); break; case '5y': - startDate = max([startDate, subYears(resetHours(new Date()), 5)]); + startDate = max([startDate, subYears(startOfDay(new Date()), 5)]); break; case 'max': break; From 52a1128d7b6d63b8eb61f97c3181003a23672721 Mon Sep 17 00:00:00 2001 From: Thomas Kaul <4159106+dtslvr@users.noreply.github.com> Date: Sun, 11 Oct 2026 09:23:13 +0200 Subject: [PATCH 3/4] Bugfix/value of accounts filtered by asset class (#8113) * Fix value of accounts filtered by asset class * Update changelog --- CHANGELOG.md | 2 + .../src/app/activities/activities.service.ts | 22 +++-- .../src/app/endpoints/mcp/mcp.controller.ts | 2 +- .../app/portfolio/portfolio.service.spec.ts | 83 ++++++++++++++++++- .../src/app/portfolio/portfolio.service.ts | 23 +++-- 5 files changed, 116 insertions(+), 16 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index dc2c6193e1..82fe51ead1 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -17,6 +17,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Fixed the _Copy AI prompt to clipboard_ actions on the analysis page in impersonation mode to be based on the impersonated user (experimental) - Fixed the start date of the date ranges for instances in time zones with a negative UTC offset +- Fixed the value of the accounts by excluding the cash balance when filtering by an asset class other than liquidity on the accounts page and the allocations page +- Fixed the value of the accounts by excluding the cash balance when filtering by an asset class other than liquidity in the server of the Model Context Protocol (MCP) (experimental) ## 3.83.0 - 2026-10-10 diff --git a/apps/api/src/app/activities/activities.service.ts b/apps/api/src/app/activities/activities.service.ts index c592a44e76..0de3f7c3c4 100644 --- a/apps/api/src/app/activities/activities.service.ts +++ b/apps/api/src/app/activities/activities.service.ts @@ -83,7 +83,6 @@ export class ActivitiesService { public areCashActivitiesExcludedByFilters(filters: Filter[] = []) { const { - ASSET_CLASS: filtersByAssetClass = [], DATA_SOURCE: [filterByDataSource] = [], SYMBOL: [filterBySymbol] = [], TAG: filtersByTag = [] @@ -92,10 +91,7 @@ export class ActivitiesService { }); const isFilteredByAssetClassOtherThanLiquidity = - filtersByAssetClass.length > 0 && - !filtersByAssetClass.some(({ id }) => { - return id === AssetClass.LIQUIDITY; - }); + this.isFilteredByAssetClassOtherThanLiquidity(filters); const isFilteredByAssetProfile = !!(filterByDataSource || filterBySymbol); const isFilteredByTag = filtersByTag.length > 0; @@ -875,6 +871,22 @@ export class ActivitiesService { }); } + public isFilteredByAssetClassOtherThanLiquidity(filters: Filter[] = []) { + const { ASSET_CLASS: filtersByAssetClass = [] } = groupBy( + filters, + ({ type }) => { + return type; + } + ); + + return ( + filtersByAssetClass.length > 0 && + !filtersByAssetClass.some(({ id }) => { + return id === AssetClass.LIQUIDITY; + }) + ); + } + public async order( orderWhereUniqueInput: Prisma.OrderWhereUniqueInput ): Promise { diff --git a/apps/api/src/app/endpoints/mcp/mcp.controller.ts b/apps/api/src/app/endpoints/mcp/mcp.controller.ts index c6ec55d2d5..66e1ff7098 100644 --- a/apps/api/src/app/endpoints/mcp/mcp.controller.ts +++ b/apps/api/src/app/endpoints/mcp/mcp.controller.ts @@ -38,7 +38,7 @@ export class GhostfolioMcpController { ', ' )}. If the access reads the monetary values, these columns are given in addition: ${PortfolioTableService.getAccountsTableValueColumnNames().join( ', ' - )}. The allocation in percentage is relative to the accounts of the result, hence the parameters change it. The parameters change the value in base currency as well. With the holding parameter, it is the value of the holding in the account without the cash balance. Without the holding parameter, it includes the full cash balance of the account, also with the assetClasses parameter. The balance is always the full cash balance of the account in the currency of the account.`, + )}. The allocation in percentage is relative to the accounts of the result, hence the parameters change it. The parameters change the value in base currency as well. With the holding parameter or with asset classes which do not include LIQUIDITY, it is the value of the matching holdings in the account without the cash balance. Otherwise, it includes the full cash balance of the account. The balance is always the full cash balance of the account in the currency of the account.`, name: 'get-accounts', parameters: GET_ACCOUNTS_PARAMETERS }) diff --git a/apps/api/src/app/portfolio/portfolio.service.spec.ts b/apps/api/src/app/portfolio/portfolio.service.spec.ts index c408f51934..ac5dfebd49 100644 --- a/apps/api/src/app/portfolio/portfolio.service.spec.ts +++ b/apps/api/src/app/portfolio/portfolio.service.spec.ts @@ -1454,7 +1454,11 @@ describe('PortfolioService', () => { getValueOfAccountsAndPlatforms: (aArgs: object) => Promise<{ accounts: Record< string, - { quantity?: number; valueInBaseCurrency: number } + { + balance: number; + quantity?: number; + valueInBaseCurrency: number; + } >; platforms: Record; }>; @@ -1675,6 +1679,83 @@ describe('PortfolioService', () => { expect(platforms[account.platformId].valueInBaseCurrency).toBe(10); }); + it('should exclude the cash balance if the activities are filtered by an asset class other than liquidity', async () => { + const { accounts, platforms } = await getValueOfAccountsAndPlatforms({ + activities: [ + { + account, + accountId: account.id, + assetProfile: { dataSource: DataSource.YAHOO, symbol: 'AAPL' }, + quantity: 1, + type: 'BUY' + } + ], + filters: [{ id: AssetClass.EQUITY, type: 'ASSET_CLASS' }], + portfolioItemsNow: { + 'YAHOO-AAPL': { marketPriceInBaseCurrency: 10 } + }, + userCurrency: 'USD', + userId: userDummyData.id + }); + + expect(accounts[account.id].balance).toBe(100); + + // 1 * 10 (activity), without the balance of 100 + expect(accounts[account.id].valueInBaseCurrency).toBe(10); + expect(platforms[account.platformId].valueInBaseCurrency).toBe(10); + }); + + it('should include the cash balance if the activities are filtered by asset classes including liquidity', async () => { + const { accounts, platforms } = await getValueOfAccountsAndPlatforms({ + activities: [ + { + account, + accountId: account.id, + assetProfile: { dataSource: DataSource.YAHOO, symbol: 'AAPL' }, + quantity: 1, + type: 'BUY' + } + ], + filters: [ + { id: AssetClass.EQUITY, type: 'ASSET_CLASS' }, + { id: AssetClass.LIQUIDITY, type: 'ASSET_CLASS' } + ], + portfolioItemsNow: { + 'YAHOO-AAPL': { marketPriceInBaseCurrency: 10 } + }, + userCurrency: 'USD', + userId: userDummyData.id + }); + + // 100 (balance) + 1 * 10 (activity) + expect(accounts[account.id].valueInBaseCurrency).toBe(110); + expect(platforms[account.platformId].valueInBaseCurrency).toBe(110); + }); + + it('should include the cash balance if the activities are filtered by a tag', async () => { + const { accounts, platforms } = await getValueOfAccountsAndPlatforms({ + activities: [ + { + account, + accountId: account.id, + assetProfile: { dataSource: DataSource.YAHOO, symbol: 'AAPL' }, + quantity: 1, + type: 'BUY' + } + ], + filters: [{ id: randomUUID(), type: 'TAG' }], + portfolioItemsNow: { + 'YAHOO-AAPL': { marketPriceInBaseCurrency: 10 } + }, + userCurrency: 'USD', + userId: userDummyData.id + }); + + // 100 (balance) + 1 * 10 (activity) + expect(accounts[account.id].valueInBaseCurrency).toBe(110); + expect(platforms[account.platformId].valueInBaseCurrency).toBe(110); + }); + it('should not accumulate rounding errors of the balances of accounts sharing a platform', async () => { const platformId = randomUUID(); diff --git a/apps/api/src/app/portfolio/portfolio.service.ts b/apps/api/src/app/portfolio/portfolio.service.ts index ab2c0de2d4..343da076fa 100644 --- a/apps/api/src/app/portfolio/portfolio.service.ts +++ b/apps/api/src/app/portfolio/portfolio.service.ts @@ -2438,6 +2438,9 @@ export class PortfolioService { return type; }); + const isFilteredByAssetClassOtherThanLiquidity = + this.activitiesService.isFilteredByAssetClassOtherThanLiquidity(filters); + let currentAccounts: (AccountWithBalance & { Order?: Order[]; platform?: Platform; @@ -2485,15 +2488,17 @@ export class PortfolioService { }); if (account) { - // The cash balance is not part of a holding and would distort the value - // and thus the allocation per account and platform - const balanceInBaseCurrency = filterBySymbol - ? 0 - : this.exchangeRateDataService.toCurrency( - account.balance, - account.currency, - userCurrency - ); + // The cash balance is not part of a holding or of an asset class other + // than liquidity and would distort the value and thus the allocation + // per account and platform + const balanceInBaseCurrency = + filterBySymbol || isFilteredByAssetClassOtherThanLiquidity + ? 0 + : this.exchangeRateDataService.toCurrency( + account.balance, + account.currency, + userCurrency + ); accounts[currentAccountId] = { balance: account.balance, From c88ff79d0bb215db131023035323528a31d19971 Mon Sep 17 00:00:00 2001 From: Thomas Kaul <4159106+dtslvr@users.noreply.github.com> Date: Sun, 11 Oct 2026 09:37:43 +0200 Subject: [PATCH 4/4] Task/refactor historical market data query (#8116) Refactor historical market data query --- .../src/app/portfolio/portfolio.service.ts | 10 ++--- .../data-provider/data-provider.service.ts | 42 +++++++++---------- .../exchange-rate-data.service.ts | 10 ++--- 3 files changed, 30 insertions(+), 32 deletions(-) diff --git a/apps/api/src/app/portfolio/portfolio.service.ts b/apps/api/src/app/portfolio/portfolio.service.ts index 343da076fa..c67bb51064 100644 --- a/apps/api/src/app/portfolio/portfolio.service.ts +++ b/apps/api/src/app/portfolio/portfolio.service.ts @@ -1056,11 +1056,11 @@ export class PortfolioService { ); }); - const historicalData = await this.dataProviderService.getHistorical( - [{ dataSource, symbol }], - parseISO(dateOfFirstActivity, { in: utc }), - new Date() - ); + const historicalData = await this.dataProviderService.getHistorical({ + assetProfileIdentifiers: [{ dataSource, symbol }], + from: parseISO(dateOfFirstActivity, { in: utc }), + to: new Date() + }); const [firstActivity] = activitiesOfHolding; const referenceUnitPrice = diff --git a/apps/api/src/services/data-provider/data-provider.service.ts b/apps/api/src/services/data-provider/data-provider.service.ts index cf281f5f52..e5a4f6c845 100644 --- a/apps/api/src/services/data-provider/data-provider.service.ts +++ b/apps/api/src/services/data-provider/data-provider.service.ts @@ -369,11 +369,15 @@ export class DataProviderService implements OnModuleInit { }); } - public async getHistorical( - aItems: AssetProfileIdentifier[], - from: Date, - to: Date - ): Promise<{ + public async getHistorical({ + assetProfileIdentifiers, + from, + to + }: { + assetProfileIdentifiers: AssetProfileIdentifier[]; + from: Date; + to: Date; + }): Promise<{ [assetProfileIdentifier: string]: { [date: string]: DataProviderHistoricalResponse; }; @@ -384,35 +388,29 @@ export class DataProviderService implements OnModuleInit { }; } = {}; - if (isEmpty(aItems) || !isValid(from) || !isValid(to)) { + if (isEmpty(assetProfileIdentifiers) || !isValid(from) || !isValid(to)) { return response; } - const rangeQuery = - from && to - ? Prisma.sql`AND date >= ${format(from, DATE_FORMAT, { - in: utc - })}::timestamp AND date <= ${format(to, DATE_FORMAT, { - in: utc - })}::timestamp` - : Prisma.empty; - - const assetProfileIdentifiers = aItems.map(({ dataSource, symbol }) => { - return Prisma.sql`(${dataSource}::"DataSource", ${symbol})`; - }); + const assetProfileIdentifierTuples = assetProfileIdentifiers.map( + ({ dataSource, symbol }) => { + return Prisma.sql`(${dataSource}::"DataSource", ${symbol})`; + } + ); try { - const marketDataByGranularity: Pick< + const marketDataItems: Pick< MarketData, 'dataSource' | 'date' | 'marketPrice' | 'symbol' >[] = await this.prismaService.$queryRaw` SELECT "dataSource", "date", "marketPrice", "symbol" FROM "MarketData" - WHERE ("dataSource", "symbol") IN (${Prisma.join(assetProfileIdentifiers)}) - ${rangeQuery} + WHERE ("dataSource", "symbol") IN (${Prisma.join(assetProfileIdentifierTuples)}) + AND date >= ${format(from, DATE_FORMAT, { in: utc })}::timestamp + AND date <= ${format(to, DATE_FORMAT, { in: utc })}::timestamp ORDER BY date;`; - response = marketDataByGranularity.reduce((r, marketData) => { + response = marketDataItems.reduce((r, marketData) => { const { dataSource, date, marketPrice, symbol } = marketData; const assetProfileIdentifier = getAssetProfileIdentifier({ diff --git a/apps/api/src/services/exchange-rate-data/exchange-rate-data.service.ts b/apps/api/src/services/exchange-rate-data/exchange-rate-data.service.ts index 59795cad6a..b68ed2fb15 100644 --- a/apps/api/src/services/exchange-rate-data/exchange-rate-data.service.ts +++ b/apps/api/src/services/exchange-rate-data/exchange-rate-data.service.ts @@ -173,11 +173,11 @@ export class ExchangeRateDataService { { in: utc } ); - const historicalData = await this.dataProviderService.getHistorical( - this.currencyPairs, - startOfUtcDateOfYesterday, - startOfUtcDateOfYesterday - ); + const historicalData = await this.dataProviderService.getHistorical({ + assetProfileIdentifiers: this.currencyPairs, + from: startOfUtcDateOfYesterday, + to: startOfUtcDateOfYesterday + }); const quotes = await this.dataProviderService.getQuotes({ items: this.currencyPairs.map(({ dataSource, symbol }) => {