From 0b9df572ba042c57cc1f0bc9ca42d03121254410 Mon Sep 17 00:00:00 2001 From: Thomas Kaul <4159106+dtslvr@users.noreply.github.com> Date: Tue, 4 Aug 2026 19:52:15 +0200 Subject: [PATCH 1/6] Task/optimize performance of search in assistant (#7534) * Improve performance of search in assistant by reusing cached portfolio snapshot * Update changelog --- CHANGELOG.md | 10 +++++++ .../src/app/portfolio/portfolio.service.ts | 30 ++++++++++++------- .../portfolio-position.interface.ts | 1 + 3 files changed, 31 insertions(+), 10 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 6c90fdf30..7bd3340ec 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,16 @@ All notable changes to this project will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +## Unreleased + +### Changed + +- Optimized the performance of the search in the assistant by reusing the cached portfolio snapshot + +### Fixed + +- Fixed the fuzzy search for the holdings in the assistant + ## 3.41.0 - 2026-08-03 ### Added diff --git a/apps/api/src/app/portfolio/portfolio.service.ts b/apps/api/src/app/portfolio/portfolio.service.ts index 0a69cf32d..70106bdc1 100644 --- a/apps/api/src/app/portfolio/portfolio.service.ts +++ b/apps/api/src/app/portfolio/portfolio.service.ts @@ -165,6 +165,10 @@ export class PortfolioService { }; } + const filtersWithoutSearchQueryFilter = filters?.filter(({ type }) => { + return type !== 'SEARCH_QUERY'; + }); + const [accounts, details, user] = await Promise.all([ this.accountService.accounts({ where, @@ -176,8 +180,8 @@ export class PortfolioService { orderBy: { name: 'asc' } }), this.getDetails({ - filters, withExcludedAccounts, + filters: filtersWithoutSearchQueryFilter, impersonationId: userId, userId: this.request.user.id }), @@ -369,14 +373,6 @@ export class PortfolioService { userId: string; }) { userId = await this.getUserId(impersonationId, userId); - const { holdings: holdingsMap } = await this.getDetails({ - dateRange, - filters, - impersonationId, - userId - }); - - let holdings = Object.values(holdingsMap); const { SEARCH_QUERY: [filterBySearchQuery] = [] } = groupBy( filters, @@ -385,9 +381,22 @@ export class PortfolioService { } ); + const filtersWithoutSearchQueryFilter = filters?.filter(({ type }) => { + return type !== 'SEARCH_QUERY'; + }); + + const { holdings: holdingsMap } = await this.getDetails({ + dateRange, + impersonationId, + userId, + filters: filtersWithoutSearchQueryFilter + }); + + let holdings = Object.values(holdingsMap); + if (filterBySearchQuery) { const fuse = new Fuse(holdings, { - keys: ['isin', 'name', 'symbol'], + keys: ['assetProfile.isin', 'assetProfile.name', 'assetProfile.symbol'], threshold: 0.3 }); @@ -651,6 +660,7 @@ export class PortfolioService { }; } ), + isin: assetProfile.isin, name: assetProfile.name, sectors: assetProfile.sectors, symbol: assetProfile.symbol, diff --git a/libs/common/src/lib/interfaces/portfolio-position.interface.ts b/libs/common/src/lib/interfaces/portfolio-position.interface.ts index cf71b20ca..388e661f7 100644 --- a/libs/common/src/lib/interfaces/portfolio-position.interface.ts +++ b/libs/common/src/lib/interfaces/portfolio-position.interface.ts @@ -15,6 +15,7 @@ export interface PortfolioPosition { | 'currency' | 'dataSource' | 'holdings' + | 'isin' | 'name' | 'sectors' | 'symbol' From 851901916b568b3ff51cd7a46968a86999baea0a Mon Sep 17 00:00:00 2001 From: Thomas Kaul <4159106+dtslvr@users.noreply.github.com> Date: Tue, 4 Aug 2026 20:07:55 +0200 Subject: [PATCH 2/6] Task/improve handling of Exclude from Analysis tag (#7533) * Improve handling of Exclude from Analysis tag * Update changelog --- CHANGELOG.md | 1 + apps/api/src/app/user/user.service.ts | 8 +++---- ...eate-or-update-account-dialog.component.ts | 21 +++++++------------ .../portfolio-filter-form.util.ts | 5 +++-- 4 files changed, 15 insertions(+), 20 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 7bd3340ec..1de1e82b9 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 +- Extended the support of the _Exclude from Analysis_ tag from accounts to activities - Optimized the performance of the search in the assistant by reusing the cached portfolio snapshot ### Fixed diff --git a/apps/api/src/app/user/user.service.ts b/apps/api/src/app/user/user.service.ts index 5d60c571d..10e9c5240 100644 --- a/apps/api/src/app/user/user.service.ts +++ b/apps/api/src/app/user/user.service.ts @@ -186,15 +186,15 @@ export class UserService { systemMessage = systemMessageProperty; } - let tags = tagsForUser.filter((tag) => { - return tag.id !== TAG_ID_EXCLUDE_FROM_ANALYSIS; - }); + let tags = tagsForUser; if ( this.configurationService.get('ENABLE_FEATURE_SUBSCRIPTION') && subscription.type === SubscriptionType.Basic ) { - tags = []; + tags = tags.filter(({ id }) => { + return id === TAG_ID_EXCLUDE_FROM_ANALYSIS; + }); } return { diff --git a/apps/client/src/app/pages/accounts/create-or-update-account-dialog/create-or-update-account-dialog.component.ts b/apps/client/src/app/pages/accounts/create-or-update-account-dialog/create-or-update-account-dialog.component.ts index 2157ea5d3..86fc84711 100644 --- a/apps/client/src/app/pages/accounts/create-or-update-account-dialog/create-or-update-account-dialog.component.ts +++ b/apps/client/src/app/pages/accounts/create-or-update-account-dialog/create-or-update-account-dialog.component.ts @@ -1,5 +1,4 @@ import { UserService } from '@ghostfolio/client/services/user/user.service'; -import { TAG_ID_EXCLUDE_FROM_ANALYSIS } from '@ghostfolio/common/config'; import { CreateAccountDto, UpdateAccountDto } from '@ghostfolio/common/dtos'; import { getStringOrNull } from '@ghostfolio/common/helper'; import { hasPermission, permissions } from '@ghostfolio/common/permissions'; @@ -86,19 +85,13 @@ export class GfCreateOrUpdateAccountDialogComponent { permissions.createOwnTag ); - this.tagsAvailable = [ - ...(this.data.user?.tags ?? []), - { - id: TAG_ID_EXCLUDE_FROM_ANALYSIS, - name: 'EXCLUDE_FROM_ANALYSIS', - userId: null - } - ].map((tag) => { - return { - ...tag, - name: translate(tag.name) - }; - }); + this.tagsAvailable = + this.data.user?.tags?.map((tag) => { + return { + ...tag, + name: translate(tag.name) + }; + }) ?? []; this.accountForm = this.formBuilder.group({ accountId: [{ disabled: true, value: this.data.account.id }], diff --git a/libs/ui/src/lib/portfolio-filter-form/portfolio-filter-form.util.ts b/libs/ui/src/lib/portfolio-filter-form/portfolio-filter-form.util.ts index ec3114d41..193624c76 100644 --- a/libs/ui/src/lib/portfolio-filter-form/portfolio-filter-form.util.ts +++ b/libs/ui/src/lib/portfolio-filter-form/portfolio-filter-form.util.ts @@ -1,3 +1,4 @@ +import { TAG_ID_EXCLUDE_FROM_ANALYSIS } from '@ghostfolio/common/config'; import { getAssetProfileIdentifier } from '@ghostfolio/common/helper'; import { Filter, PortfolioPosition } from '@ghostfolio/common/interfaces'; @@ -105,8 +106,8 @@ export function getTagFilters( ): Filter[] { return ( tags - ?.filter(({ isUsed }) => { - return isUsed; + ?.filter(({ id, isUsed }) => { + return id !== TAG_ID_EXCLUDE_FROM_ANALYSIS && isUsed; }) ?.map(({ id, name }) => { return { From 3eb10cc2e12aea64a03273faa411825202f6cdb4 Mon Sep 17 00:00:00 2001 From: Thomas Kaul <4159106+dtslvr@users.noreply.github.com> Date: Tue, 4 Aug 2026 20:31:21 +0200 Subject: [PATCH 3/6] Task/remove unused exchange from portfolio position interface (#7537) Remove exchange --- .../pages/portfolio/allocations/allocations-page.component.ts | 3 +-- libs/common/src/lib/interfaces/portfolio-position.interface.ts | 1 - 2 files changed, 1 insertion(+), 3 deletions(-) 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 dd2c62f98..0931578cf 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 @@ -95,7 +95,7 @@ export class GfAllocationsPageComponent implements OnInit { | 'assetSubClassLabel' | 'currency' | 'name' - > & { etfProvider: string; exchange?: string; value: number }; + > & { etfProvider: string; value: number }; }; protected isLoading = false; protected markets: PortfolioDetails['markets']; @@ -381,7 +381,6 @@ export class GfAllocationsPageComponent implements OnInit { assetSubClass: position.assetProfile.assetSubClass, name: position.assetProfile.name }), - exchange: position.exchange, name: position.assetProfile.name, value: this.showValuesInPercentage() ? position.allocationInPercentage diff --git a/libs/common/src/lib/interfaces/portfolio-position.interface.ts b/libs/common/src/lib/interfaces/portfolio-position.interface.ts index 388e661f7..65d7ea140 100644 --- a/libs/common/src/lib/interfaces/portfolio-position.interface.ts +++ b/libs/common/src/lib/interfaces/portfolio-position.interface.ts @@ -26,7 +26,6 @@ export interface PortfolioPosition { }; dateOfFirstActivity: Date; dividend: number; - exchange?: string; grossPerformance: number; grossPerformancePercent: number; grossPerformancePercentWithCurrencyEffect: number; From 61cac99d54808bd703884801240397e93fd7602a Mon Sep 17 00:00:00 2001 From: Thomas Kaul <4159106+dtslvr@users.noreply.github.com> Date: Tue, 4 Aug 2026 20:31:57 +0200 Subject: [PATCH 4/6] Task/improve symbol validation for assets with manual data source (#7467) * Improve symbol validation for assets with manual data source * Update changelog --- CHANGELOG.md | 2 + .../src/app/activities/activities.service.ts | 12 +- apps/api/src/app/admin/admin.service.ts | 10 +- apps/api/src/app/import/import.service.ts | 166 +++++++++++++----- .../data-provider/data-provider.service.ts | 2 +- .../symbol-profile/symbol-profile.service.ts | 21 +++ .../app/services/import-activities.service.ts | 79 ++++++--- libs/common/src/lib/helper.spec.ts | 31 +++- libs/common/src/lib/helper.ts | 15 +- package-lock.json | 34 +++- package.json | 1 + ...nvalid-symbol-with-manual-data-source.json | 27 +++ test/import/ok/without-accounts.json | 24 ++- 13 files changed, 337 insertions(+), 87 deletions(-) create mode 100644 test/import/not-ok/invalid-symbol-with-manual-data-source.json diff --git a/CHANGELOG.md b/CHANGELOG.md index 1de1e82b9..538815f1d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -11,6 +11,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Extended the support of the _Exclude from Analysis_ tag from accounts to activities - Optimized the performance of the search in the assistant by reusing the cached portfolio snapshot +- Improved the validation of the import functionality when referencing an asset profile with the data source `MANUAL` +- Improved the validation of the endpoint to add a custom asset profile in the admin control panel ### Fixed diff --git a/apps/api/src/app/activities/activities.service.ts b/apps/api/src/app/activities/activities.service.ts index da3e99312..ef3839340 100644 --- a/apps/api/src/app/activities/activities.service.ts +++ b/apps/api/src/app/activities/activities.service.ts @@ -20,12 +20,12 @@ import { DATA_GATHERING_QUEUE_PRIORITY_HIGH, GATHER_ASSET_PROFILE_PROCESS_JOB_NAME, GATHER_ASSET_PROFILE_PROCESS_JOB_OPTIONS, - ghostfolioPrefix, TAG_ID_EXCLUDE_FROM_ANALYSIS } from '@ghostfolio/common/config'; import { canDeleteAssetProfile, - getAssetProfileIdentifier + getAssetProfileIdentifier, + isValidCustomAssetProfileSymbol } from '@ghostfolio/common/helper'; import { ActivitiesResponse, @@ -48,7 +48,6 @@ import { Type as ActivityType } from '@prisma/client'; import { Big } from 'big.js'; -import { isUUID } from 'class-validator'; import { endOfToday, isAfter } from 'date-fns'; import { groupBy, uniqBy } from 'lodash'; import { randomUUID } from 'node:crypto'; @@ -204,10 +203,9 @@ export class ActivitiesService { let symbol: string; if ( - data.SymbolProfile.connectOrCreate.create.symbol.startsWith( - `${ghostfolioPrefix}_` - ) || - isUUID(data.SymbolProfile.connectOrCreate.create.symbol) + isValidCustomAssetProfileSymbol( + data.SymbolProfile.connectOrCreate.create.symbol + ) ) { // Connect custom asset profile (clone) symbol = data.SymbolProfile.connectOrCreate.create.symbol; diff --git a/apps/api/src/app/admin/admin.service.ts b/apps/api/src/app/admin/admin.service.ts index 4c608e0fd..d384e0d55 100644 --- a/apps/api/src/app/admin/admin.service.ts +++ b/apps/api/src/app/admin/admin.service.ts @@ -7,6 +7,7 @@ import { PrismaService } from '@ghostfolio/api/services/prisma/prisma.service'; import { PropertyService } from '@ghostfolio/api/services/property/property.service'; import { SymbolProfileService } from '@ghostfolio/api/services/symbol-profile/symbol-profile.service'; import { + ghostfolioPrefix, PROPERTY_CURRENCIES, PROPERTY_IS_READ_ONLY_MODE, PROPERTY_IS_USER_SIGNUP_ENABLED @@ -14,7 +15,8 @@ import { import { applyAssetProfileOverrides, getAssetProfileIdentifier, - getCurrencyFromSymbol + getCurrencyFromSymbol, + hasGhostfolioPrefix } from '@ghostfolio/common/helper'; import { AdminData, @@ -63,6 +65,12 @@ export class AdminService { > { try { if (dataSource === 'MANUAL') { + if (!hasGhostfolioPrefix(symbol)) { + throw new BadRequestException( + `symbol ("${symbol}") must start with the prefix "${ghostfolioPrefix}_" for the data source ("${dataSource}")` + ); + } + return this.symbolProfileService.add({ currency, dataSource, diff --git a/apps/api/src/app/import/import.service.ts b/apps/api/src/app/import/import.service.ts index 52b0662d6..be511df44 100644 --- a/apps/api/src/app/import/import.service.ts +++ b/apps/api/src/app/import/import.service.ts @@ -11,11 +11,13 @@ import { SymbolProfileService } from '@ghostfolio/api/services/symbol-profile/sy import { TagService } from '@ghostfolio/api/services/tag/tag.service'; import { DATA_GATHERING_QUEUE_PRIORITY_HIGH, + ghostfolioPrefix, TAG_ID_EXCLUDE_FROM_ANALYSIS } from '@ghostfolio/common/config'; import { CreateAssetProfileDto, CreateOrderDto } from '@ghostfolio/common/dtos'; import { getAssetProfileIdentifier, + isValidCustomAssetProfileSymbol, parseDate } from '@ghostfolio/common/helper'; import { @@ -196,6 +198,41 @@ export class ImportService { const tagIdMapping: { [oldTagId: string]: string } = {}; const userCurrency = user.settings.settings.baseCurrency; + // Validate the symbols before any data is persisted + for (const [index, assetProfileWithMarketData] of ( + assetProfilesWithMarketDataDto ?? [] + ).entries()) { + if ( + assetProfileWithMarketData.dataSource === DataSource.MANUAL && + !isValidCustomAssetProfileSymbol(assetProfileWithMarketData.symbol) + ) { + throw new Error( + `assetProfiles.${index}.symbol ("${assetProfileWithMarketData.symbol}") must be a UUID or start with the prefix "${ghostfolioPrefix}_" for the data source ("${DataSource.MANUAL}")` + ); + } + } + + // Validate the symbols before any data is persisted. Activities without a + // data source are excluded, since a symbol is generated in + // createActivity() if needed. + for (const [index, activity] of activitiesDto.entries()) { + if (!activity.dataSource) { + if (['FEE', 'INTEREST', 'LIABILITY'].includes(activity.type)) { + activity.dataSource = DataSource.MANUAL; + } else { + activity.dataSource = + this.dataProviderService.getDataSourceForImport(); + } + } else if ( + activity.dataSource === DataSource.MANUAL && + !isValidCustomAssetProfileSymbol(activity.symbol) + ) { + throw new Error( + `activities.${index}.symbol ("${activity.symbol}") must be a UUID or start with the prefix "${ghostfolioPrefix}_" for the data source ("${DataSource.MANUAL}")` + ); + } + } + if (platformsDto?.length) { const canCreatePlatform = hasPermission( user.permissions, @@ -384,71 +421,117 @@ export class ImportService { } } - if (!isDryRun && assetProfilesWithMarketDataDto?.length) { - const existingAssetProfiles = - await this.symbolProfileService.getSymbolProfiles( - assetProfilesWithMarketDataDto.map(({ dataSource, symbol }) => { - return { dataSource, symbol }; + if (assetProfilesWithMarketDataDto?.length) { + const customAssetProfileNames = assetProfilesWithMarketDataDto + .filter(({ dataSource, name }) => { + return dataSource === DataSource.MANUAL && Boolean(name); + }) + .map(({ name }) => { + return name; + }); + + const [existingAssetProfiles, existingCustomAssetProfilesOfUser] = + await Promise.all([ + this.symbolProfileService.getSymbolProfiles( + assetProfilesWithMarketDataDto.map(({ dataSource, symbol }) => { + return { dataSource, symbol }; + }) + ), + this.symbolProfileService.getCustomSymbolProfilesByNames({ + names: customAssetProfileNames, + userId: user.id }) - ); + ]); for (const assetProfileWithMarketData of assetProfilesWithMarketDataDto) { + let symbol = assetProfileWithMarketData.symbol; + // Check if there is any existing asset profile const existingAssetProfile = existingAssetProfiles.find( - ({ dataSource, symbol }) => { + (assetProfile) => { return ( - dataSource === assetProfileWithMarketData.dataSource && - symbol === assetProfileWithMarketData.symbol + assetProfile.dataSource === + assetProfileWithMarketData.dataSource && + assetProfile.symbol === assetProfileWithMarketData.symbol ); } ); - // If there is no asset profile or if the asset profile belongs to a different user, then create a new asset profile + // If there is no asset profile or if the asset profile belongs to a + // different user, then reuse the custom asset profile of the user or + // create a new asset profile if (!existingAssetProfile || existingAssetProfile.userId !== user.id) { - const assetProfile: CreateAssetProfileDto = omit( - assetProfileWithMarketData, - 'marketData' - ); + // Check if the user has a custom asset profile with the same name. + // Skip asset profiles with a legacy free-text symbol as they would + // fail the symbol validation on a future import. + const existingCustomAssetProfileOfUser = + assetProfileWithMarketData.dataSource === DataSource.MANUAL + ? existingCustomAssetProfilesOfUser.find((customAssetProfile) => { + return ( + customAssetProfile.name === + assetProfileWithMarketData.name && + isValidCustomAssetProfileSymbol(customAssetProfile.symbol) + ); + }) + : undefined; + + if (existingCustomAssetProfileOfUser) { + // Reuse the custom asset profile of the user instead of creating a duplicate + symbol = existingCustomAssetProfileOfUser.symbol; + } else { + const assetProfile: CreateAssetProfileDto = omit( + assetProfileWithMarketData, + 'marketData' + ); + + // Asset profile belongs to a different user, generate a new symbol + if (existingAssetProfile && !isDryRun) { + symbol = randomUUID(); + } - // Asset profile belongs to a different user - if (existingAssetProfile) { - const symbol = randomUUID(); - assetProfileSymbolMapping[assetProfile.symbol] = symbol; assetProfile.symbol = symbol; + + if (!isDryRun) { + // Create a new asset profile + const assetProfileObject: Prisma.SymbolProfileCreateInput = { + ...assetProfile, + user: { connect: { id: user.id } } + }; + + await this.symbolProfileService.add(assetProfileObject); + } } - // Create a new asset profile - const assetProfileObject: Prisma.SymbolProfileCreateInput = { - ...assetProfile, - user: { connect: { id: user.id } } - }; + if (symbol !== assetProfileWithMarketData.symbol) { + assetProfileSymbolMapping[assetProfileWithMarketData.symbol] = + symbol; - await this.symbolProfileService.add(assetProfileObject); + // Keep the asset profile in sync with the activities to validate + assetProfileWithMarketData.symbol = symbol; + } } - // Insert or update market data - const marketDataObjects = assetProfileWithMarketData.marketData.map( - (marketData) => { + if (!isDryRun) { + // Insert or update market data + const marketDataObjects = ( + assetProfileWithMarketData.marketData ?? [] + ).map((marketData) => { return { ...marketData, - dataSource: assetProfileWithMarketData.dataSource, - symbol: assetProfileWithMarketData.symbol + symbol, + dataSource: assetProfileWithMarketData.dataSource } as Prisma.MarketDataUpdateInput; - } - ); + }); - await this.marketDataService.updateMany({ data: marketDataObjects }); + await this.marketDataService.updateMany({ data: marketDataObjects }); + } } } for (const activity of activitiesDto) { - if (!activity.dataSource) { - if (['FEE', 'INTEREST', 'LIABILITY'].includes(activity.type)) { - activity.dataSource = DataSource.MANUAL; - } else { - activity.dataSource = - this.dataProviderService.getDataSourceForImport(); - } + // If an asset profile is created or reused, then update the symbol in all activities + if (assetProfileSymbolMapping[activity.symbol]) { + activity.symbol = assetProfileSymbolMapping[activity.symbol]; } if (!isDryRun) { @@ -457,11 +540,6 @@ export class ImportService { activity.accountId = accountIdMapping[activity.accountId]; } - // If a new asset profile is created, then update the symbol in all activities - if (assetProfileSymbolMapping[activity.symbol]) { - activity.symbol = assetProfileSymbolMapping[activity.symbol]; - } - // If a new tag is created, then update the tag ID in all activities activity.tags = (activity.tags ?? []).map((tagId) => { return tagIdMapping[tagId] ?? tagId; 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 4c273f2da..6031a8e25 100644 --- a/apps/api/src/services/data-provider/data-provider.service.ts +++ b/apps/api/src/services/data-provider/data-provider.service.ts @@ -318,7 +318,7 @@ export class DataProviderService implements OnModuleInit { if (!assetProfile?.name) { throw new Error( - `activities.${index}.symbol ("${symbol}") is not valid for the specified data source ("${maskedDataSource}")` + `${activityPath}.symbol ("${symbol}") is not valid for the specified data source ("${maskedDataSource}")` ); } diff --git a/apps/api/src/services/symbol-profile/symbol-profile.service.ts b/apps/api/src/services/symbol-profile/symbol-profile.service.ts index 1854a9f93..ebc8a94c7 100644 --- a/apps/api/src/services/symbol-profile/symbol-profile.service.ts +++ b/apps/api/src/services/symbol-profile/symbol-profile.service.ts @@ -105,6 +105,27 @@ export class SymbolProfileService { }; } + public async getCustomSymbolProfilesByNames({ + names, + userId + }: { + names: string[]; + userId: string; + }): Promise[]> { + if (names.length === 0) { + return []; + } + + return this.prismaService.symbolProfile.findMany({ + select: { name: true, symbol: true }, + where: { + userId, + dataSource: DataSource.MANUAL, + name: { in: names } + } + }); + } + public async getSymbolProfiles( aAssetProfileIdentifiers: AssetProfileIdentifier[] ): Promise { diff --git a/apps/client/src/app/services/import-activities.service.ts b/apps/client/src/app/services/import-activities.service.ts index 6a8f99bda..503149a03 100644 --- a/apps/client/src/app/services/import-activities.service.ts +++ b/apps/client/src/app/services/import-activities.service.ts @@ -5,7 +5,10 @@ import { CreatePlatformDto, CreateTagDto } from '@ghostfolio/common/dtos'; -import { parseDate as parseDateHelper } from '@ghostfolio/common/helper'; +import { + isValidCustomAssetProfileSymbol, + parseDate as parseDateHelper +} from '@ghostfolio/common/helper'; import { Activity } from '@ghostfolio/common/interfaces'; import { HttpClient } from '@angular/common/http'; @@ -14,6 +17,7 @@ import { Account, DataSource, Type as ActivityType } from '@prisma/client'; import { isFinite, isNumber, isString } from 'lodash'; import { parse as csvToJson } from 'papaparse'; import { firstValueFrom } from 'rxjs'; +import { v4 as uuidv4 } from 'uuid'; @Injectable({ providedIn: 'root' @@ -57,13 +61,58 @@ export class ImportActivitiesService { const activities: CreateOrderDto[] = []; const assetProfiles: CreateAssetProfileWithMarketDataDto[] = []; + const assetProfileSymbolMapping = new Map(); for (const [index, item] of content.entries()) { const currency = this.parseCurrency({ content, index, item }); - const dataSource = this.parseDataSource({ item }); - const symbol = this.parseSymbol({ content, index, item }); const type = this.parseType({ content, index, item }); + let dataSource = this.parseDataSource({ item }); + let symbol = this.parseSymbol({ content, index, item }); + + if (!dataSource && ['FEE', 'INTEREST', 'LIABILITY'].includes(type)) { + // Apply the same data source as the import service + dataSource = DataSource.MANUAL; + } + + if (dataSource === DataSource.MANUAL) { + const name = symbol; + + if (!isValidCustomAssetProfileSymbol(symbol)) { + // Generate a symbol and keep the free text as the name + symbol = assetProfileSymbolMapping.get(name) ?? uuidv4(); + assetProfileSymbolMapping.set(name, symbol); + } + + const isExistingAssetProfile = assetProfiles.some((assetProfile) => { + return assetProfile.symbol === symbol; + }); + + if (!isExistingAssetProfile) { + // Create synthetic asset profile for MANUAL data source + assetProfiles.push({ + currency, + name, + symbol, + assetClass: undefined, + assetSubClass: undefined, + comment: undefined, + countries: [], + cusip: undefined, + dataSource: DataSource.MANUAL, + figi: undefined, + figiComposite: undefined, + figiShareClass: undefined, + holdings: [], + isActive: true, + isin: undefined, + marketData: [], + sectors: [], + url: undefined + }); + } + } + activities.push({ currency, dataSource, @@ -77,30 +126,6 @@ export class ImportActivitiesService { unitPrice: this.parseUnitPrice({ content, index, item }), updateAccountBalance: false }); - - if (dataSource === DataSource.MANUAL) { - // Create synthetic asset profile for MANUAL data source - assetProfiles.push({ - currency, - symbol, - assetClass: undefined, - assetSubClass: undefined, - comment: undefined, - countries: [], - cusip: undefined, - dataSource: DataSource.MANUAL, - figi: undefined, - figiComposite: undefined, - figiShareClass: undefined, - holdings: [], - isActive: true, - isin: undefined, - marketData: [], - name: symbol, - sectors: [], - url: undefined - }); - } } const result = await this.importJson({ diff --git a/libs/common/src/lib/helper.spec.ts b/libs/common/src/lib/helper.spec.ts index 42441a619..669c42e32 100644 --- a/libs/common/src/lib/helper.spec.ts +++ b/libs/common/src/lib/helper.spec.ts @@ -10,7 +10,8 @@ import { isAccountExcluded, isCurrency, isCurrencySymbol, - isSplitRatio + isSplitRatio, + isValidCustomAssetProfileSymbol } from '@ghostfolio/common/helper'; describe('Helper', () => { @@ -326,4 +327,32 @@ describe('Helper', () => { ); }); }); + + describe('Is valid custom asset profile symbol', () => { + it('Empty symbol', () => { + expect(isValidCustomAssetProfileSymbol('')).toEqual(false); + }); + + it('Free-text symbol', () => { + expect(isValidCustomAssetProfileSymbol('Penthouse Apartment')).toEqual( + false + ); + }); + + it('Stock symbol', () => { + expect(isValidCustomAssetProfileSymbol('AAPL')).toEqual(false); + }); + + it('Symbol with Ghostfolio prefix', () => { + expect(isValidCustomAssetProfileSymbol('GF_PENTHOUSE_APARTMENT')).toEqual( + true + ); + }); + + it('UUID', () => { + expect( + isValidCustomAssetProfileSymbol('7e91b7d4-1430-4212-8380-289a06c9bbc1') + ).toEqual(true); + }); + }); }); diff --git a/libs/common/src/lib/helper.ts b/libs/common/src/lib/helper.ts index 22f969d02..6c0d6ea45 100644 --- a/libs/common/src/lib/helper.ts +++ b/libs/common/src/lib/helper.ts @@ -8,7 +8,7 @@ import { SymbolProfile } from '@prisma/client'; import { Big } from 'big.js'; -import { isISO4217CurrencyCode } from 'class-validator'; +import { isISO4217CurrencyCode, isUUID } from 'class-validator'; import { getDate, getMonth, @@ -41,6 +41,7 @@ import { DERIVED_CURRENCIES, ghostfolioFearAndGreedIndexSymbolCryptocurrencies, ghostfolioFearAndGreedIndexSymbolStocks, + ghostfolioPrefix, SEARCH_QUERY_MINIMUM_LENGTH, TAG_ID_EXCLUDE_FROM_ANALYSIS } from './config'; @@ -466,6 +467,14 @@ export function getYesterday() { return subDays(new Date(Date.UTC(year, month, day)), 1); } +export function hasGhostfolioPrefix(aSymbol: string) { + if (!aSymbol) { + return false; + } + + return aSymbol.startsWith(`${ghostfolioPrefix}_`); +} + export function interpolate(template: string, context: any) { return template?.replace(/[$]{([^}]+)}/g, (_, objectPath) => { const properties = objectPath.split('.'); @@ -548,6 +557,10 @@ export function isSplitRatio({ ); } +export function isValidCustomAssetProfileSymbol(aSymbol: string) { + return hasGhostfolioPrefix(aSymbol) || isUUID(aSymbol); +} + export function isValidSearchQuery(aQuery: string) { return aQuery?.trim().length >= SEARCH_QUERY_MINIMUM_LENGTH; } diff --git a/package-lock.json b/package-lock.json index 87cad3a36..51ee52ea1 100644 --- a/package-lock.json +++ b/package-lock.json @@ -97,6 +97,7 @@ "tablemark": "4.1.0", "twitter-api-v2": "1.29.0", "undici": "8.5.0", + "uuid": "14.0.1", "yahoo-finance2": "4.0.0", "zod": "4.4.3", "zone.js": "0.16.1" @@ -15985,6 +15986,16 @@ "node": ">=12" } }, + "node_modules/bull/node_modules/uuid": { + "version": "8.3.2", + "resolved": "https://registry.npmjs.org/uuid/-/uuid-8.3.2.tgz", + "integrity": "sha512-+NYs2QeMWy+GWFOEm9xnn6HCDp0l7QBD7ml8zLUmJ+93Q5NF0NocErnwkTkXVFNiX3/fpC6afS8Dhb/gz7R7eg==", + "deprecated": "uuid@10 and below is no longer supported. For ESM codebases, update to uuid@latest. For CommonJS codebases, use uuid@11 (but be aware this version will likely be deprecated in 2028).", + "license": "MIT", + "bin": { + "uuid": "dist/bin/uuid" + } + }, "node_modules/bundle-name": { "version": "4.1.0", "resolved": "https://registry.npmjs.org/bundle-name/-/bundle-name-4.1.0.tgz", @@ -32154,6 +32165,17 @@ "websocket-driver": "^0.7.4" } }, + "node_modules/sockjs/node_modules/uuid": { + "version": "8.3.2", + "resolved": "https://registry.npmjs.org/uuid/-/uuid-8.3.2.tgz", + "integrity": "sha512-+NYs2QeMWy+GWFOEm9xnn6HCDp0l7QBD7ml8zLUmJ+93Q5NF0NocErnwkTkXVFNiX3/fpC6afS8Dhb/gz7R7eg==", + "deprecated": "uuid@10 and below is no longer supported. For ESM codebases, update to uuid@latest. For CommonJS codebases, use uuid@11 (but be aware this version will likely be deprecated in 2028).", + "dev": true, + "license": "MIT", + "bin": { + "uuid": "dist/bin/uuid" + } + }, "node_modules/socks": { "version": "2.8.7", "resolved": "https://registry.npmjs.org/socks/-/socks-2.8.7.tgz", @@ -34664,12 +34686,16 @@ } }, "node_modules/uuid": { - "version": "8.3.2", - "resolved": "https://registry.npmjs.org/uuid/-/uuid-8.3.2.tgz", - "integrity": "sha512-+NYs2QeMWy+GWFOEm9xnn6HCDp0l7QBD7ml8zLUmJ+93Q5NF0NocErnwkTkXVFNiX3/fpC6afS8Dhb/gz7R7eg==", + "version": "14.0.1", + "resolved": "https://registry.npmjs.org/uuid/-/uuid-14.0.1.tgz", + "integrity": "sha512-6ZxzVpzDXDa3bJWaHilVayA+BH/1zmxCJoVgvmqJnid/gPoKHxUrS/aC/T6LGQtNHT+XHG9fXPJB4d+IrU30Ew==", + "funding": [ + "https://github.com/sponsors/broofa", + "https://github.com/sponsors/ctavan" + ], "license": "MIT", "bin": { - "uuid": "dist/bin/uuid" + "uuid": "dist-node/bin/uuid" } }, "node_modules/v8-compile-cache-lib": { diff --git a/package.json b/package.json index 9ace2cbe8..61bf1c0d4 100644 --- a/package.json +++ b/package.json @@ -141,6 +141,7 @@ "tablemark": "4.1.0", "twitter-api-v2": "1.29.0", "undici": "8.5.0", + "uuid": "14.0.1", "yahoo-finance2": "4.0.0", "zod": "4.4.3", "zone.js": "0.16.1" diff --git a/test/import/not-ok/invalid-symbol-with-manual-data-source.json b/test/import/not-ok/invalid-symbol-with-manual-data-source.json new file mode 100644 index 000000000..08d070e61 --- /dev/null +++ b/test/import/not-ok/invalid-symbol-with-manual-data-source.json @@ -0,0 +1,27 @@ +{ + "meta": { + "date": "2023-02-05T00:00:00.000Z", + "version": "dev" + }, + "activities": [ + { + "accountId": null, + "comment": null, + "currency": "USD", + "dataSource": "MANUAL", + "date": "2022-01-01T00:00:00.000Z", + "fee": 0, + "quantity": 1, + "symbol": "Penthouse Apartment", + "tags": [], + "type": "BUY", + "unitPrice": 500000 + } + ], + "user": { + "settings": { + "currency": "USD", + "performanceCalculationType": "ROAI" + } + } +} diff --git a/test/import/ok/without-accounts.json b/test/import/ok/without-accounts.json index 2283dd889..cacbe153e 100644 --- a/test/import/ok/without-accounts.json +++ b/test/import/ok/without-accounts.json @@ -3,6 +3,28 @@ "date": "2022-04-01T00:00:00.000Z", "version": "dev" }, + "assetProfiles": [ + { + "assetClass": null, + "assetSubClass": null, + "comment": null, + "countries": [], + "currency": "USD", + "cusip": null, + "dataSource": "MANUAL", + "figi": null, + "figiComposite": null, + "figiShareClass": null, + "holdings": [], + "isActive": true, + "isin": null, + "marketData": [], + "name": "Penthouse Apartment", + "sectors": [], + "symbol": "7e91b7d4-1430-4212-8380-289a06c9bbc1", + "url": null + } + ], "activities": [ { "fee": 0, @@ -22,7 +44,7 @@ "currency": "USD", "dataSource": "MANUAL", "date": "2022-01-01T00:00:00.000Z", - "symbol": "Penthouse Apartment" + "symbol": "7e91b7d4-1430-4212-8380-289a06c9bbc1" }, { "fee": 0, From f8e8a4772304bbbf460d87f4a05c1ec87f76303c Mon Sep 17 00:00:00 2001 From: Thomas Kaul <4159106+dtslvr@users.noreply.github.com> Date: Tue, 4 Aug 2026 20:36:17 +0200 Subject: [PATCH 5/6] Task/improve usability of portfolio summary by collapsing breakdowns (#7536) * Improve usability by collapsing breakdowns * Update changelog --- CHANGELOG.md | 1 + .../portfolio-summary.component.html | 38 +++++++++++++++++-- .../portfolio-summary.component.scss | 13 +++++++ .../portfolio-summary.component.ts | 25 +++++++++++- 4 files changed, 72 insertions(+), 5 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 538815f1d..8109d54f7 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 usability of the portfolio summary by collapsing the _Holdings_ and _Cash_ breakdowns by default - Extended the support of the _Exclude from Analysis_ tag from accounts to activities - Optimized the performance of the search in the assistant by reusing the cached portfolio snapshot - Improved the validation of the import functionality when referencing an asset profile with the data source `MANUAL` diff --git a/apps/client/src/app/components/portfolio-summary/portfolio-summary.component.html b/apps/client/src/app/components/portfolio-summary/portfolio-summary.component.html index e4ff1ba93..9394d7624 100644 --- a/apps/client/src/app/components/portfolio-summary/portfolio-summary.component.html +++ b/apps/client/src/app/components/portfolio-summary/portfolio-summary.component.html @@ -164,7 +164,22 @@
-
+
+ @if (hasHoldingsBreakdown) { + + + + } Holdings @if ( !hasImpersonationId && @@ -192,7 +207,7 @@ />
- @if (isLoading || summary?.emergencyFund?.assets > 0) { + @if (hasHoldingsBreakdown && isHoldingsExpanded) {
Investments
@@ -223,7 +238,22 @@
}
-
+
+ @if (hasCashBreakdown) { + + + + } Cash @if ( !hasImpersonationId && @@ -251,7 +281,7 @@ />
- @if (isLoading || summary?.emergencyFund?.cash > 0) { + @if (hasCashBreakdown && isCashExpanded) {
Buying Power
diff --git a/apps/client/src/app/components/portfolio-summary/portfolio-summary.component.scss b/apps/client/src/app/components/portfolio-summary/portfolio-summary.component.scss index 6feaa22d1..534976f11 100644 --- a/apps/client/src/app/components/portfolio-summary/portfolio-summary.component.scss +++ b/apps/client/src/app/components/portfolio-summary/portfolio-summary.component.scss @@ -1,6 +1,19 @@ :host { display: block; + .caret-container { + width: 1rem; + + .caret { + font-size: 0.7rem; + transition: transform 150ms ease-in-out; + + &.caret-expanded { + transform: rotate(90deg); + } + } + } + .indent-1 { margin-left: 1rem; } diff --git a/apps/client/src/app/components/portfolio-summary/portfolio-summary.component.ts b/apps/client/src/app/components/portfolio-summary/portfolio-summary.component.ts index eaf71210f..eac5ef9a6 100644 --- a/apps/client/src/app/components/portfolio-summary/portfolio-summary.component.ts +++ b/apps/client/src/app/components/portfolio-summary/portfolio-summary.component.ts @@ -18,6 +18,7 @@ import { IonIcon } from '@ionic/angular/standalone'; import { formatDistanceToNow } from 'date-fns'; import { addIcons } from 'ionicons'; import { + caretForwardOutline, ellipsisHorizontalCircleOutline, informationCircleOutline } from 'ionicons/icons'; @@ -47,13 +48,19 @@ export class GfPortfolioSummaryComponent implements OnChanges { 'BUY_AND_SELL_ACTIVITIES_TOOLTIP' ); + protected isCashExpanded = false; + protected isHoldingsExpanded = false; protected precision = 2; protected timeInMarket: string | undefined; private readonly notificationService = inject(NotificationService); public constructor() { - addIcons({ ellipsisHorizontalCircleOutline, informationCircleOutline }); + addIcons({ + caretForwardOutline, + ellipsisHorizontalCircleOutline, + informationCircleOutline + }); } protected get cashPercentage() { @@ -77,6 +84,14 @@ export class GfPortfolioSummaryComponent implements OnChanges { : 0; } + protected get hasCashBreakdown() { + return !this.isLoading && this.summary?.emergencyFund?.cash > 0; + } + + protected get hasHoldingsBreakdown() { + return !this.isLoading && this.summary?.emergencyFund?.assets > 0; + } + protected get holdingsInBaseCurrency() { if ( !isNumber(this.summary?.totalAssetsInBaseCurrency) || @@ -145,4 +160,12 @@ export class GfPortfolioSummaryComponent implements OnChanges { title: $localize`Please set the amount of your emergency fund.` }); } + + protected onToggleCash() { + this.isCashExpanded = !this.isCashExpanded; + } + + protected onToggleHoldings() { + this.isHoldingsExpanded = !this.isHoldingsExpanded; + } } From 9f5c231a70d538878ede77df491a3fe98c7b91e0 Mon Sep 17 00:00:00 2001 From: Thomas Kaul <4159106+dtslvr@users.noreply.github.com> Date: Tue, 4 Aug 2026 20:38:37 +0200 Subject: [PATCH 6/6] Release 3.42.0 (#7539) --- CHANGELOG.md | 2 +- package-lock.json | 4 ++-- package.json | 2 +- 3 files changed, 4 insertions(+), 4 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 8109d54f7..0b5d73495 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,7 +5,7 @@ All notable changes to this project will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). -## Unreleased +## 3.42.0 - 2026-08-04 ### Changed diff --git a/package-lock.json b/package-lock.json index 51ee52ea1..06b0efda2 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "ghostfolio", - "version": "3.41.0", + "version": "3.42.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "ghostfolio", - "version": "3.41.0", + "version": "3.42.0", "hasInstallScript": true, "license": "AGPL-3.0", "dependencies": { diff --git a/package.json b/package.json index 61bf1c0d4..4bbda525e 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "ghostfolio", - "version": "3.41.0", + "version": "3.42.0", "homepage": "https://ghostfol.io", "license": "AGPL-3.0", "repository": "https://github.com/ghostfolio/ghostfolio",