From c84fd0df8d7329598c835010f8de9443cc69e16d Mon Sep 17 00:00:00 2001 From: Thomas Kaul <4159106+dtslvr@users.noreply.github.com> Date: Fri, 2 Oct 2026 21:48:52 +0200 Subject: [PATCH 1/5] Task/upgrade ai to version 7.0.114 (#8011) * Update ai to version 7.0.114 * Update changelog --- CHANGELOG.md | 2 ++ package-lock.json | 58 +++++++++++++++++++++++++++-------------------- package.json | 4 ++-- 3 files changed, 38 insertions(+), 26 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 987e11433a..ff764bf4a8 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Changed +- Upgraded `@openrouter/ai-sdk-provider` from version `3.0.0` to `3.1.0` +- Upgraded `ai` from version `7.0.37` to `7.0.114` - Upgraded `dotenv` from version `17.4.2` to `18.0.3` ## 3.77.0 - 2026-10-02 diff --git a/package-lock.json b/package-lock.json index b01c143378..be96328456 100644 --- a/package-lock.json +++ b/package-lock.json @@ -46,13 +46,13 @@ "@nestjs/schedule": "6.1.3", "@nestjs/serve-static": "5.0.5", "@nestjs/throttler": "6.5.0", - "@openrouter/ai-sdk-provider": "3.0.0", + "@openrouter/ai-sdk-provider": "3.1.0", "@prisma/adapter-pg": "7.10.0", "@prisma/client": "7.10.0", "@rekog/mcp-nest": "2.0.7", "@simplewebauthn/browser": "13.3.0", "@simplewebauthn/server": "13.3.1", - "ai": "7.0.37", + "ai": "7.0.114", "alphavantage": "2.2.0", "big.js": "7.0.1", "bootstrap": "4.6.2", @@ -189,13 +189,13 @@ "license": "MIT" }, "node_modules/@ai-sdk/gateway": { - "version": "4.0.28", - "resolved": "https://registry.npmjs.org/@ai-sdk/gateway/-/gateway-4.0.28.tgz", - "integrity": "sha512-ee9TsNO3mkgHDWmTmJ8Fvltr6PFh52zhrV2+FaKJY3F3iu7kWZi5tCRJCR1HVhhlpj6lHniUb28nLQdPHkA/1Q==", + "version": "4.0.92", + "resolved": "https://registry.npmjs.org/@ai-sdk/gateway/-/gateway-4.0.92.tgz", + "integrity": "sha512-awX24+QedfcIDTAdwkCUNG08vPJjkQI9KZLGh4Ua+vNbgeaNFJre96OIbBefOD9XPHSVpdXjwqtWmo4Yh7oczw==", "license": "Apache-2.0", "dependencies": { - "@ai-sdk/provider": "4.0.3", - "@ai-sdk/provider-utils": "5.0.12", + "@ai-sdk/provider": "4.0.18", + "@ai-sdk/provider-utils": "5.0.47", "@vercel/oidc": "3.2.0" }, "engines": { @@ -206,9 +206,9 @@ } }, "node_modules/@ai-sdk/provider": { - "version": "4.0.3", - "resolved": "https://registry.npmjs.org/@ai-sdk/provider/-/provider-4.0.3.tgz", - "integrity": "sha512-e0CpNWJUY7OxAFAnCZkw+ri9QOHWwTs1tXP42782KFGCU07qt8NiXCrCVowyCB5dP2r5/Uls+g2oPd8kOJn9dw==", + "version": "4.0.18", + "resolved": "https://registry.npmjs.org/@ai-sdk/provider/-/provider-4.0.18.tgz", + "integrity": "sha512-+GZJIgz1jk86pwEbb3f1BD2bdoSKyWE4Jg4YUc7NMnMozbWemSKYZcw2F4nMaO5qwsL5A8RmioAKW81YktRpIQ==", "license": "Apache-2.0", "dependencies": { "json-schema": "^0.4.0" @@ -218,15 +218,16 @@ } }, "node_modules/@ai-sdk/provider-utils": { - "version": "5.0.12", - "resolved": "https://registry.npmjs.org/@ai-sdk/provider-utils/-/provider-utils-5.0.12.tgz", - "integrity": "sha512-bbhlOgHeYwrIGheLkM6fhS8hVger8uFPmcOLg+kxc9EFh7y30XYorWhthlYAgpadO3SJhFZrIcEknN7qEqEVvA==", + "version": "5.0.47", + "resolved": "https://registry.npmjs.org/@ai-sdk/provider-utils/-/provider-utils-5.0.47.tgz", + "integrity": "sha512-NZQ1hMxY6Hd4ZiinjpU5HUOsyCHiTFkHw7AiM1EVN+Cd3kNTi47flZWNvFwGqnBojntB1+2eBRTeHRuVLtPhEg==", "license": "Apache-2.0", "dependencies": { - "@ai-sdk/provider": "4.0.3", + "@ai-sdk/provider": "4.0.18", "@standard-schema/spec": "^1.1.0", "@workflow/serde": "4.1.0", - "eventsource-parser": "^3.0.8" + "eventsource-parser": "^3.0.8", + "undici": "^7.29.0" }, "engines": { "node": ">=22" @@ -235,6 +236,15 @@ "zod": "^3.25.76 || ^4.1.8" } }, + "node_modules/@ai-sdk/provider-utils/node_modules/undici": { + "version": "7.30.0", + "resolved": "https://registry.npmjs.org/undici/-/undici-7.30.0.tgz", + "integrity": "sha512-dkrQXeHSaoamnItlYbmzG0wFYrM0ZwDxCIg0A7aKjTyyhh9svRzCNFEzV+Vm05/yehjCzjDZ31KXfGEjYSztDQ==", + "license": "MIT", + "engines": { + "node": ">=20.18.1" + } + }, "node_modules/@ampproject/remapping": { "version": "2.3.0", "resolved": "https://registry.npmjs.org/@ampproject/remapping/-/remapping-2.3.0.tgz", @@ -11951,9 +11961,9 @@ } }, "node_modules/@openrouter/ai-sdk-provider": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/@openrouter/ai-sdk-provider/-/ai-sdk-provider-3.0.0.tgz", - "integrity": "sha512-m9XTSWoODH2RM5OsZpaGiN7QRR8cdP5paBWq699Tu3JVmGPBKT8xF8XwV0ZBVVsjikD/JgWfak4VSsTR4wAVbg==", + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/@openrouter/ai-sdk-provider/-/ai-sdk-provider-3.1.0.tgz", + "integrity": "sha512-BQy1TA9fKrh47s9ivsO6FY5QimN7GUF0i8Qon3pkxQclEgShso0dClpcWn8OIyPLEZZPFaFKgI8TW4S/O0p0Dw==", "license": "Apache-2.0", "engines": { "node": ">=22" @@ -16840,14 +16850,14 @@ } }, "node_modules/ai": { - "version": "7.0.37", - "resolved": "https://registry.npmjs.org/ai/-/ai-7.0.37.tgz", - "integrity": "sha512-stF+SEQJgKY3Qfe3FwNzqUrehHviOp2l7LemoI8YMOa0Zk5PxKsRNgUk3cHXz0RAue9RRyCAis2fz6LSCP6EKw==", + "version": "7.0.114", + "resolved": "https://registry.npmjs.org/ai/-/ai-7.0.114.tgz", + "integrity": "sha512-jRMOw7tPMWVYomoHWY5xHBXPQDyKTY1Pn3BX0Rh39UOP6y9FYq6xCIB6khgIC/b9CRg4+IxadwIbie+Eu7yU3g==", "license": "Apache-2.0", "dependencies": { - "@ai-sdk/gateway": "4.0.28", - "@ai-sdk/provider": "4.0.3", - "@ai-sdk/provider-utils": "5.0.12" + "@ai-sdk/gateway": "4.0.92", + "@ai-sdk/provider": "4.0.18", + "@ai-sdk/provider-utils": "5.0.47" }, "engines": { "node": ">=22" diff --git a/package.json b/package.json index f40bfb50cf..7a32e9bdf5 100644 --- a/package.json +++ b/package.json @@ -90,13 +90,13 @@ "@nestjs/schedule": "6.1.3", "@nestjs/serve-static": "5.0.5", "@nestjs/throttler": "6.5.0", - "@openrouter/ai-sdk-provider": "3.0.0", + "@openrouter/ai-sdk-provider": "3.1.0", "@prisma/adapter-pg": "7.10.0", "@prisma/client": "7.10.0", "@rekog/mcp-nest": "2.0.7", "@simplewebauthn/browser": "13.3.0", "@simplewebauthn/server": "13.3.1", - "ai": "7.0.37", + "ai": "7.0.114", "alphavantage": "2.2.0", "big.js": "7.0.1", "bootstrap": "4.6.2", From beb8f9de78950f373375193bb090ee9587d70881 Mon Sep 17 00:00:00 2001 From: Thomas Kaul <4159106+dtslvr@users.noreply.github.com> Date: Sat, 3 Oct 2026 09:13:08 +0200 Subject: [PATCH 2/5] Bugfix/carry forward of market prices between chart dates (#8009) * Carry forward of market prices between chart dates * Update changelog --- CHANGELOG.md | 4 + .../calculator/portfolio-calculator.ts | 33 ++-- ...h-market-price-between-chart-dates.spec.ts | 170 ++++++++++++++++++ .../portfolio/current-rate.service.mock.ts | 14 ++ 4 files changed, 211 insertions(+), 10 deletions(-) create mode 100644 apps/api/src/app/portfolio/calculator/roai/portfolio-calculator-manual-buy-with-market-price-between-chart-dates.spec.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index ff764bf4a8..0d3af3346e 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 - Upgraded `ai` from version `7.0.37` to `7.0.114` - Upgraded `dotenv` from version `17.4.2` to `18.0.3` +### Fixed + +- Fixed the portfolio calculation for holdings with historical market prices between the chart dates + ## 3.77.0 - 2026-10-02 ### Added diff --git a/apps/api/src/app/portfolio/calculator/portfolio-calculator.ts b/apps/api/src/app/portfolio/calculator/portfolio-calculator.ts index e03b530258..eb1e6f9fac 100644 --- a/apps/api/src/app/portfolio/calculator/portfolio-calculator.ts +++ b/apps/api/src/app/portfolio/calculator/portfolio-calculator.ts @@ -791,6 +791,16 @@ export abstract class PortfolioCalculator { activitiesByDate[activity.date].push(activity); } + // Carry forward the market prices of all dates, not only of the chart + // dates, as sparse market data (e.g. MANUAL) can be between two chart dates + const marketPriceDates = Object.keys(marketSymbolMap) + .filter((date) => { + return !!marketSymbolMap[date][assetProfileIdentifier]; + }) + .sort(); + + let indexOfMarketPriceDate = 0; + for (const dateString of chartDates) { if (dateString < startDateString) { continue; @@ -798,6 +808,18 @@ export abstract class PortfolioCalculator { break; } + while ( + indexOfMarketPriceDate < marketPriceDates.length && + marketPriceDates[indexOfMarketPriceDate] <= dateString + ) { + lastMarketPrice = + marketSymbolMap[marketPriceDates[indexOfMarketPriceDate]][ + assetProfileIdentifier + ]; + + indexOfMarketPriceDate += 1; + } + const activitiesOfDate = activitiesByDate[dateString]; if (!lastMarketPrice && activitiesOfDate?.length > 0) { @@ -808,13 +830,8 @@ export abstract class PortfolioCalculator { } } - const marketPrice = marketSymbolMap[dateString]?.[assetProfileIdentifier]; - const unitPrice = - marketPrice ?? - lastMarketPrice ?? - lastActivityUnitPrice ?? - unitPriceAtEndDate; + lastMarketPrice ?? lastActivityUnitPrice ?? unitPriceAtEndDate; if (activitiesOfDate?.length > 0) { for (const activity of activitiesOfDate) { @@ -832,10 +849,6 @@ export abstract class PortfolioCalculator { unitPriceFromMarketData: unitPrice }); } - - if (marketPrice) { - lastMarketPrice = marketPrice; - } } // Sort the activities so that the start and end placeholder activities diff --git a/apps/api/src/app/portfolio/calculator/roai/portfolio-calculator-manual-buy-with-market-price-between-chart-dates.spec.ts b/apps/api/src/app/portfolio/calculator/roai/portfolio-calculator-manual-buy-with-market-price-between-chart-dates.spec.ts new file mode 100644 index 0000000000..989409e77b --- /dev/null +++ b/apps/api/src/app/portfolio/calculator/roai/portfolio-calculator-manual-buy-with-market-price-between-chart-dates.spec.ts @@ -0,0 +1,170 @@ +import { + activityDummyData, + assetProfileDummyData, + userDummyData +} from '@ghostfolio/api/app/portfolio/calculator/portfolio-calculator-test-utils'; +import { PortfolioCalculatorFactory } from '@ghostfolio/api/app/portfolio/calculator/portfolio-calculator.factory'; +import { CurrentRateService } from '@ghostfolio/api/app/portfolio/current-rate.service'; +import { CurrentRateServiceMock } from '@ghostfolio/api/app/portfolio/current-rate.service.mock'; +import { RedisCacheService } from '@ghostfolio/api/app/redis-cache/redis-cache.service'; +import { RedisCacheServiceMock } from '@ghostfolio/api/app/redis-cache/redis-cache.service.mock'; +import { ConfigurationService } from '@ghostfolio/api/services/configuration/configuration.service'; +import { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-data/exchange-rate-data.service'; +import { PortfolioSnapshotService } from '@ghostfolio/api/services/queues/portfolio-snapshot/portfolio-snapshot.service'; +import { PortfolioSnapshotServiceMock } from '@ghostfolio/api/services/queues/portfolio-snapshot/portfolio-snapshot.service.mock'; +import { parseDate } from '@ghostfolio/common/helper'; +import { Activity } from '@ghostfolio/common/interfaces'; +import { PerformanceCalculationType } from '@ghostfolio/common/types/performance-calculation-type.type'; + +import { Big } from 'big.js'; + +jest.mock('@ghostfolio/api/app/portfolio/current-rate.service', () => { + return { + CurrentRateService: jest.fn().mockImplementation(() => { + return CurrentRateServiceMock; + }) + }; +}); + +jest.mock( + '@ghostfolio/api/services/queues/portfolio-snapshot/portfolio-snapshot.service', + () => { + return { + PortfolioSnapshotService: jest.fn().mockImplementation(() => { + return PortfolioSnapshotServiceMock; + }) + }; + } +); + +jest.mock('@ghostfolio/api/app/redis-cache/redis-cache.service', () => { + return { + RedisCacheService: jest.fn().mockImplementation(() => { + return RedisCacheServiceMock; + }) + }; +}); + +describe('PortfolioCalculator', () => { + let configurationService: ConfigurationService; + let currentRateService: CurrentRateService; + let exchangeRateDataService: ExchangeRateDataService; + let portfolioCalculatorFactory: PortfolioCalculatorFactory; + let portfolioSnapshotService: PortfolioSnapshotService; + let redisCacheService: RedisCacheService; + + beforeEach(() => { + PortfolioSnapshotServiceMock.reset(); + RedisCacheServiceMock.reset(); + + configurationService = new ConfigurationService(); + + currentRateService = new CurrentRateService(null, null, null); + + exchangeRateDataService = new ExchangeRateDataService( + null, + null, + null, + null + ); + + portfolioSnapshotService = new PortfolioSnapshotService(null, null); + + redisCacheService = new RedisCacheService(null, null); + + portfolioCalculatorFactory = new PortfolioCalculatorFactory( + configurationService, + currentRateService, + exchangeRateDataService, + portfolioSnapshotService, + redisCacheService + ); + }); + + describe('get current positions', () => { + it('with MANUAL buy and market price between two chart dates', async () => { + jest.useFakeTimers().setSystemTime(parseDate('2024-01-31').getTime()); + + const activities: Activity[] = [ + { + ...activityDummyData, + assetProfile: { + ...assetProfileDummyData, + currency: 'USD', + dataSource: 'MANUAL', + name: 'Private Investment', + symbol: '6c0c5cee-0208-4975-b473-03baf2518497' + }, + date: parseDate('2021-01-04'), + feeInAssetProfileCurrency: 0, + feeInBaseCurrency: 0, + quantity: 10, + type: 'BUY', + unitPriceInAssetProfileCurrency: 50 + } + ]; + + const portfolioCalculator = portfolioCalculatorFactory.createCalculator({ + activities, + calculationType: PerformanceCalculationType.ROAI, + currency: 'USD', + userId: userDummyData.id + }); + + const portfolioSnapshot = await portfolioCalculator.computeSnapshot(); + + const historicalDataByDate = Object.fromEntries( + portfolioSnapshot.historicalData.map((historicalDataItem) => { + return [historicalDataItem.date, historicalDataItem]; + }) + ); + + /** + * The only historical market prices are on 2023-06-14 and 2023-06-15, + * which are not chart dates (every third day from 2021-01-03) + */ + expect(historicalDataByDate['2023-06-14']).toBeUndefined(); + expect(historicalDataByDate['2023-06-15']).toBeUndefined(); + + /** + * The chart dates before the market prices use the unit price of the + * activity: 50 + */ + expect(historicalDataByDate['2023-06-13']).toMatchObject({ + netPerformance: 0, // 10 * (50 - 50) = 0 + totalInvestment: 500, + value: 500 // 10 * 50 = 500 + }); + + /** + * The chart dates after the market prices carry the latest one forward + * (100), not the first one (80) + */ + expect(historicalDataByDate['2023-06-16']).toMatchObject({ + netPerformance: 500, // 10 * (100 - 50) = 500 + totalInvestment: 500, + value: 1000 // 10 * 100 = 1000 + }); + + expect(historicalDataByDate['2024-01-30']).toMatchObject({ + netPerformance: 500, // 10 * (100 - 50) = 500 + totalInvestment: 500, + value: 1000 // 10 * 100 = 1000 + }); + + /** + * The market price is unchanged since 2023-06-15, hence there is no + * performance today + */ + expect(portfolioSnapshot.positions[0]).toMatchObject({ + netPerformancePercentageWithCurrencyEffectMap: { + '1d': new Big(0) + }, + netPerformanceWithCurrencyEffectMap: { + '1d': new Big(0), + max: new Big(500) // 10 * (100 - 50) = 500 + } + }); + }); + }); +}); diff --git a/apps/api/src/app/portfolio/current-rate.service.mock.ts b/apps/api/src/app/portfolio/current-rate.service.mock.ts index 7fcacad610..18739ddab5 100644 --- a/apps/api/src/app/portfolio/current-rate.service.mock.ts +++ b/apps/api/src/app/portfolio/current-rate.service.mock.ts @@ -30,6 +30,20 @@ function mockGetValue({ return { marketPrice: 0 }; + case '6c0c5cee-0208-4975-b473-03baf2518497': + // Sparse market data with the quote of today, which falls back to the + // latest market price + if (isSameDay(parseDate('2023-06-14'), date)) { + return { marketPrice: 80 }; + } else if ( + isSameDay(parseDate('2023-06-15'), date) || + isSameDay(parseDate('2024-01-31'), date) + ) { + return { marketPrice: 100 }; + } + + return { marketPrice: 0 }; + case 'BALN.SW': if (isSameDay(parseDate('2021-11-12'), date)) { return { marketPrice: 146 }; From acd14c6298f1e18d3d17d57ea94d87b23e3748e8 Mon Sep 17 00:00:00 2001 From: Thomas Kaul <4159106+dtslvr@users.noreply.github.com> Date: Sat, 3 Oct 2026 09:14:54 +0200 Subject: [PATCH 3/5] Task/improve label of cash positions (#8007) * Improve label of cash positions * Update changelog --- CHANGELOG.md | 1 + .../allocations/allocations-page.component.ts | 3 +- .../app/pages/public/public-page.component.ts | 3 +- libs/common/src/lib/helper.spec.ts | 47 +++++++++++++++++++ libs/common/src/lib/helper.ts | 16 +++++++ .../holdings-table.component.html | 13 +++-- .../holdings-table.component.ts | 12 ++++- .../treemap-chart/treemap-chart.component.ts | 14 +++--- 8 files changed, 96 insertions(+), 13 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 0d3af3346e..959f6f09ef 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Changed +- Improved the label of the cash positions in the holdings charts and table - Upgraded `@openrouter/ai-sdk-provider` from version `3.0.0` to `3.1.0` - Upgraded `ai` from version `7.0.37` to `7.0.114` - Upgraded `dotenv` from version `17.4.2` to `18.0.3` diff --git a/apps/client/src/app/pages/portfolio/allocations/allocations-page.component.ts b/apps/client/src/app/pages/portfolio/allocations/allocations-page.component.ts index cbdacaa3f1..915c6833ab 100644 --- a/apps/client/src/app/pages/portfolio/allocations/allocations-page.component.ts +++ b/apps/client/src/app/pages/portfolio/allocations/allocations-page.component.ts @@ -11,6 +11,7 @@ import { convertValuesToPercentagesOfTotal, getAssetProfileIdentifier, getCountryName, + getHoldingName, isCashPosition } from '@ghostfolio/common/helper'; import { @@ -533,7 +534,7 @@ export class GfAllocationsPageComponent implements OnInit { value, dataSource: position.assetProfile.dataSource, isClickable: canOpenHoldingDetail(position), - name: position.assetProfile.name ?? '' + name: getHoldingName(position.assetProfile) }; } } diff --git a/apps/client/src/app/pages/public/public-page.component.ts b/apps/client/src/app/pages/public/public-page.component.ts index 46606753e4..8fa8abde18 100644 --- a/apps/client/src/app/pages/public/public-page.component.ts +++ b/apps/client/src/app/pages/public/public-page.component.ts @@ -3,6 +3,7 @@ import { convertValuesToPercentagesOfTotal, getAssetProfileIdentifier, getCountryName, + getHoldingName, isCashPosition } from '@ghostfolio/common/helper'; import { @@ -260,7 +261,7 @@ export class GfPublicPageComponent implements OnInit { this.symbols[symbol] = { symbol, value, - name: position.assetProfile.name ?? symbol + name: getHoldingName(position.assetProfile) }; } } diff --git a/libs/common/src/lib/helper.spec.ts b/libs/common/src/lib/helper.spec.ts index b7e7635670..71b934628c 100644 --- a/libs/common/src/lib/helper.spec.ts +++ b/libs/common/src/lib/helper.spec.ts @@ -7,6 +7,7 @@ import { extractNumberFromString, getCountryCodeFromCurrency, getEmojiFlag, + getHoldingName, getNumberFormatGroup, getStringOrNull, getStringOrUndefined, @@ -143,6 +144,52 @@ describe('Helper', () => { }); }); + describe('Get holding name', () => { + it('Cash position', () => { + expect( + getHoldingName({ + assetSubClass: 'CASH', + assetSubClassLabel: 'Cash', + name: 'CHF', + symbol: 'CHF' + }) + ).toEqual('Cash (CHF)'); + }); + + it('Cash position without asset sub class label', () => { + expect( + getHoldingName({ + assetSubClass: 'CASH', + assetSubClassLabel: undefined, + name: 'CHF', + symbol: 'CHF' + }) + ).toEqual('CHF'); + }); + + it('Holding with name', () => { + expect( + getHoldingName({ + assetSubClass: 'STOCK', + assetSubClassLabel: 'Stock', + name: 'Apple Inc.', + symbol: 'AAPL' + }) + ).toEqual('Apple Inc.'); + }); + + it('Holding without name', () => { + expect( + getHoldingName({ + assetSubClass: 'STOCK', + assetSubClassLabel: 'Stock', + name: undefined, + symbol: 'AAPL' + }) + ).toEqual('AAPL'); + }); + }); + describe('Get number format group', () => { let languageGetter: jest.SpyInstance; diff --git a/libs/common/src/lib/helper.ts b/libs/common/src/lib/helper.ts index 2a76261dae..7e4e13de7e 100644 --- a/libs/common/src/lib/helper.ts +++ b/libs/common/src/lib/helper.ts @@ -453,6 +453,22 @@ export function getEmojiFlag(aCountryCode: string) { }); } +export function getHoldingName({ + assetSubClass, + assetSubClassLabel, + name, + symbol +}: Pick< + PortfolioPosition['assetProfile'], + 'assetSubClass' | 'assetSubClassLabel' | 'name' | 'symbol' +>) { + if (isCashPosition({ assetSubClass }) && assetSubClassLabel) { + return `${assetSubClassLabel} (${symbol})`; + } + + return name ?? symbol; +} + export function getLocale() { return navigator.language ?? DEFAULT_LOCALE; } diff --git a/libs/ui/src/lib/holdings-table/holdings-table.component.html b/libs/ui/src/lib/holdings-table/holdings-table.component.html index 2001a1b990..e1c012ab15 100644 --- a/libs/ui/src/lib/holdings-table/holdings-table.component.html +++ b/libs/ui/src/lib/holdings-table/holdings-table.component.html @@ -20,7 +20,7 @@ [countryCode]="getCountryCodeForCashPosition(element)" [dataSource]="element.assetProfile.dataSource" [symbol]="element.assetProfile.symbol" - [tooltip]="element.assetProfile.name" + [tooltip]="getHoldingName(element.assetProfile)" /> @@ -40,9 +40,14 @@ mat-cell [class.w-100]="mode() === 'simple'" > -
- {{ element.assetProfile.name }} - @if (element.assetProfile.name === element.assetProfile.symbol) { + @let holdingName = getHoldingName(element.assetProfile); + +
+ {{ holdingName }} + @if ( + !isCashPosition(element.assetProfile) && + element.assetProfile.name === element.assetProfile.symbol + ) { ({{ element.assetProfile.assetSubClassLabel }}) }
diff --git a/libs/ui/src/lib/holdings-table/holdings-table.component.ts b/libs/ui/src/lib/holdings-table/holdings-table.component.ts index eac1c9d918..1894260b78 100644 --- a/libs/ui/src/lib/holdings-table/holdings-table.component.ts +++ b/libs/ui/src/lib/holdings-table/holdings-table.component.ts @@ -1,6 +1,7 @@ import { canOpenHoldingDetail, getCountryCodeFromCurrency, + getHoldingName, getLocale, getLowercase, isCashPosition @@ -89,6 +90,9 @@ export class GfHoldingsTableComponent { return columns; }); + protected readonly getHoldingName = getHoldingName; + protected readonly isCashPosition = isCashPosition; + protected readonly isLoading = computed(() => { return !this.holdings(); }); @@ -104,7 +108,13 @@ export class GfHoldingsTableComponent { }); public constructor() { - this.dataSource.sortingDataAccessor = getLowercase; + this.dataSource.sortingDataAccessor = (holding, path) => { + if (path === 'assetProfile.name') { + return getHoldingName(holding.assetProfile).toLocaleLowerCase(); + } + + return getLowercase(holding, path) as number | string; + }; // Reactive data update effect(() => { diff --git a/libs/ui/src/lib/treemap-chart/treemap-chart.component.ts b/libs/ui/src/lib/treemap-chart/treemap-chart.component.ts index 83b9916c6e..01037f77f4 100644 --- a/libs/ui/src/lib/treemap-chart/treemap-chart.component.ts +++ b/libs/ui/src/lib/treemap-chart/treemap-chart.component.ts @@ -3,7 +3,11 @@ import { getIntervalFromDateRange } from '@ghostfolio/common/calculation-helper'; import { getTooltipOptions } from '@ghostfolio/common/chart-helper'; -import { canOpenHoldingDetail, getLocale } from '@ghostfolio/common/helper'; +import { + canOpenHoldingDetail, + getHoldingName, + getLocale +} from '@ghostfolio/common/helper'; import { AssetProfileIdentifier, PortfolioPosition @@ -399,20 +403,18 @@ export class GfTreemapChartComponent const raw = context.raw as GfTreemapDataPoint; const allocationInPercentage = `${(raw._data.allocationInPercentage * 100).toFixed(2)}%`; - const name = raw._data.assetProfile.name; + const name = getHoldingName(raw._data.assetProfile); const sign = raw._data.netPerformancePercentWithCurrencyEffect > 0 ? '+' : ''; - const symbol = raw._data.assetProfile.symbol; - const netPerformanceInPercentageWithSign = `${sign}${(raw._data.netPerformancePercentWithCurrencyEffect * 100).toFixed(2)}%`; if (raw._data.valueInBaseCurrency !== null) { const value = raw._data.valueInBaseCurrency; return [ - `${name ?? symbol} (${allocationInPercentage})`, + `${name} (${allocationInPercentage})`, `${value?.toLocaleString(this.locale(), { maximumFractionDigits: 2, minimumFractionDigits: 2 @@ -429,7 +431,7 @@ export class GfTreemapChartComponent ]; } else { return [ - `${name ?? symbol} (${allocationInPercentage})`, + `${name} (${allocationInPercentage})`, '', $localize`Performance`, netPerformanceInPercentageWithSign From f260f1572ecaf971ff3a4ca530d08510bb5ecf88 Mon Sep 17 00:00:00 2001 From: Thomas Kaul <4159106+dtslvr@users.noreply.github.com> Date: Sat, 3 Oct 2026 09:15:30 +0200 Subject: [PATCH 4/5] Task/improve symbol validation of create and update order DTO (#8005) Improve symbol validation --- libs/common/src/lib/dtos/create-order.dto.ts | 5 +++++ libs/common/src/lib/dtos/update-order.dto.ts | 5 +++++ 2 files changed, 10 insertions(+) diff --git a/libs/common/src/lib/dtos/create-order.dto.ts b/libs/common/src/lib/dtos/create-order.dto.ts index d473e8e7b6..d2374e9b1a 100644 --- a/libs/common/src/lib/dtos/create-order.dto.ts +++ b/libs/common/src/lib/dtos/create-order.dto.ts @@ -18,6 +18,7 @@ import { IsBoolean, IsEnum, IsISO8601, + IsNotEmpty, IsNumber, IsOptional, IsString, @@ -71,8 +72,12 @@ export class CreateOrderDto { @Min(0) quantity: number; + @IsNotEmpty() @IsString() @MaxLength(SYMBOL_MAXIMUM_LENGTH) + @Transform(({ value }: TransformFnParams) => + isString(value) ? value.trim() : value + ) symbol: string; @ArrayUnique() diff --git a/libs/common/src/lib/dtos/update-order.dto.ts b/libs/common/src/lib/dtos/update-order.dto.ts index f7dfb0df53..77e4a42286 100644 --- a/libs/common/src/lib/dtos/update-order.dto.ts +++ b/libs/common/src/lib/dtos/update-order.dto.ts @@ -17,6 +17,7 @@ import { IsArray, IsEnum, IsISO8601, + IsNotEmpty, IsNumber, IsOptional, IsString, @@ -72,8 +73,12 @@ export class UpdateOrderDto { @Min(0) quantity: number; + @IsNotEmpty() @IsString() @MaxLength(SYMBOL_MAXIMUM_LENGTH) + @Transform(({ value }: TransformFnParams) => + isString(value) ? value.trim() : value + ) symbol: string; @ArrayUnique() From f987e4160ed80c39d552f9eb9c94f783fe3fb92b Mon Sep 17 00:00:00 2001 From: Thomas Kaul <4159106+dtslvr@users.noreply.github.com> Date: Sat, 3 Oct 2026 09:16:07 +0200 Subject: [PATCH 5/5] Task/improve symbol validation of create asset profile DTO (#8013) Improve symbol validation --- libs/common/src/lib/dtos/create-asset-profile.dto.ts | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/libs/common/src/lib/dtos/create-asset-profile.dto.ts b/libs/common/src/lib/dtos/create-asset-profile.dto.ts index b4fe347071..5d75b39a1d 100644 --- a/libs/common/src/lib/dtos/create-asset-profile.dto.ts +++ b/libs/common/src/lib/dtos/create-asset-profile.dto.ts @@ -10,17 +10,19 @@ import { DataSource } from '@ghostfolio/prisma/enums'; -import { Type } from 'class-transformer'; +import { Transform, TransformFnParams, Type } from 'class-transformer'; import { IsArray, IsBoolean, IsEnum, + IsNotEmpty, IsOptional, IsString, IsUrl, MaxLength, ValidateNested } from 'class-validator'; +import { isString } from 'lodash-es'; import { CountryDto } from './country.dto'; import { HoldingDto } from './holding.dto'; @@ -92,8 +94,12 @@ export class CreateAssetProfileDto { @ValidateNested({ each: true }) sectors?: Prisma.InputJsonArray; + @IsNotEmpty() @IsString() @MaxLength(SYMBOL_MAXIMUM_LENGTH) + @Transform(({ value }: TransformFnParams) => + isString(value) ? value.trim() : value + ) symbol: string; @IsOptional()