From effae560ab0265e0a35757265939acf83f2f253e Mon Sep 17 00:00:00 2001 From: Thomas Kaul <4159106+dtslvr@users.noreply.github.com> Date: Sat, 10 Oct 2026 13:47:11 +0200 Subject: [PATCH 1/5] Bugfix/creation of custom asset profiles with ghostfolio prefix (#8070) * Fix creation of custom asset profiles with ghostfolio prefix * Update changelog --- CHANGELOG.md | 4 + .../app/activities/activities.service.spec.ts | 96 ++++++++++ .../src/app/activities/activities.service.ts | 27 ++- .../api/src/app/import/import.service.spec.ts | 164 +++++++++++++++++- apps/api/src/app/import/import.service.ts | 21 ++- libs/common/src/lib/helper.spec.ts | 45 +++++ libs/common/src/lib/helper.ts | 23 +++ 7 files changed, 366 insertions(+), 14 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index b9cbcd7e21..90e2175e59 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -17,6 +17,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Harmonized the colors in the investment chart component - Improved the language localization for Chinese (`zh`) +### Fixed + +- Fixed an issue where a user could create a custom asset profile with a symbol of the prefix `GF_` + ## 3.82.0 - 2026-10-09 ### Added diff --git a/apps/api/src/app/activities/activities.service.spec.ts b/apps/api/src/app/activities/activities.service.spec.ts index 09aa64b0e2..fb2c68ff40 100644 --- a/apps/api/src/app/activities/activities.service.spec.ts +++ b/apps/api/src/app/activities/activities.service.spec.ts @@ -8,6 +8,7 @@ import { AssetProfileSplitService } from '@ghostfolio/api/services/asset-profile import { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-data/exchange-rate-data.service'; import { PrismaService } from '@ghostfolio/api/services/prisma/prisma.service'; import { SymbolProfileService } from '@ghostfolio/api/services/symbol-profile/symbol-profile.service'; +import { TagService } from '@ghostfolio/api/services/tag/tag.service'; import { INVESTMENT_ACTIVITY_TYPES, NON_INVESTMENT_ACTIVITY_TYPES @@ -15,12 +16,15 @@ import { import { parseDate } from '@ghostfolio/common/helper'; import { Activity, Filter } from '@ghostfolio/common/interfaces'; +import { EventEmitter2 } from '@nestjs/event-emitter'; import { AssetProfileSplit, DataSource, + Prisma, Type as ActivityType } from '@prisma/client'; import { Big } from 'big.js'; +import { isUUID } from 'class-validator'; import { ActivitiesService } from './activities.service'; @@ -49,6 +53,98 @@ describe('ActivitiesService', () => { ); }); + describe('createActivity', () => { + it('creates a custom asset profile with a UUID if no asset profile exists for a symbol with the prefix', async () => { + const assetProfile = await getCreatedAssetProfile({ symbol: 'GF_COPX' }); + + expect(isUUID(assetProfile.symbol)).toBe(true); + expect(assetProfile.name).toBe('GF_COPX'); + }); + + it('connects to the existing asset profile of a symbol with the prefix', async () => { + const assetProfile = await getCreatedAssetProfile({ + existingAssetProfile: { id: 'asset-profile-id' }, + symbol: 'GF_COPX' + }); + + expect(assetProfile.symbol).toBe('GF_COPX'); + }); + + it('creates a custom asset profile with the requested UUID if no asset profile exists', async () => { + const symbol = '1ad7d4a2-6b2d-4e0f-9b1f-2c0f8d3e5a7b'; + + const assetProfile = await getCreatedAssetProfile({ symbol }); + + expect(assetProfile.symbol).toBe(symbol); + }); + + async function getCreatedAssetProfile({ + existingAssetProfile, + symbol + }: { + existingAssetProfile?: { id: string }; + symbol: string; + }) { + const create = jest.fn(({ data }: Prisma.OrderCreateArgs) => { + return { + ...data, + SymbolProfile: data.SymbolProfile.connectOrCreate.create + }; + }); + + const service = new ActivitiesService( + null, + null, + null, + null, + null, + null, + { emit: jest.fn() } as unknown as EventEmitter2, + null, + null, + { + order: { create }, + symbolProfile: { + findUnique: jest + .fn() + .mockResolvedValue(existingAssetProfile ?? null) + } + } as unknown as PrismaService, + null, + { validateTagIds: jest.fn() } as unknown as TagService + ); + + await service.createActivity({ + currency: 'USD', + date: parseDate('2024-01-01'), + fee: 0, + quantity: 1, + SymbolProfile: { + connectOrCreate: { + create: { symbol, currency: 'USD', dataSource: DataSource.MANUAL }, + where: { + dataSource_symbol: { symbol, dataSource: DataSource.MANUAL } + } + } + }, + type: 'BUY', + unitPrice: 100, + user: { connect: { id: 'user-id' } }, + userId: 'user-id' + }); + + const { create: assetProfile, where } = + create.mock.calls[0][0].data.SymbolProfile.connectOrCreate; + + expect(where.dataSource_symbol).toEqual({ + dataSource: DataSource.MANUAL, + symbol: assetProfile.symbol + }); + + return assetProfile; + } + }); + describe('getActivities', () => { it('returns the activities with the asset profile but without the relation to the symbol profile', async () => { const assetProfile = { diff --git a/apps/api/src/app/activities/activities.service.ts b/apps/api/src/app/activities/activities.service.ts index 1c933956a8..c592a44e76 100644 --- a/apps/api/src/app/activities/activities.service.ts +++ b/apps/api/src/app/activities/activities.service.ts @@ -34,10 +34,11 @@ import { } from '@ghostfolio/common/config'; import { canDeleteAssetProfile, + canUseCustomAssetProfileSymbol, getAssetProfileIdentifier, getStartOfUtcDateOfTomorrow, - isDraftActivity, - isValidCustomAssetProfileSymbol + hasGhostfolioPrefix, + isDraftActivity } from '@ghostfolio/common/helper'; import { ActivitiesResponse, @@ -220,19 +221,31 @@ export class ActivitiesService { const assetSubClass = data.assetSubClass; const dataSource: DataSource = 'MANUAL'; + const requestedSymbol = data.SymbolProfile.connectOrCreate.create.symbol; + let name = data.SymbolProfile.connectOrCreate.create.name; let symbol: string; + const existingAssetProfile = hasGhostfolioPrefix(requestedSymbol) + ? await this.prismaService.symbolProfile.findUnique({ + select: { id: true }, + where: { + dataSource_symbol: { dataSource, symbol: requestedSymbol } + } + }) + : null; + if ( - isValidCustomAssetProfileSymbol( - data.SymbolProfile.connectOrCreate.create.symbol - ) + canUseCustomAssetProfileSymbol({ + assetProfile: existingAssetProfile, + symbol: requestedSymbol + }) ) { // Connect custom asset profile (clone) - symbol = data.SymbolProfile.connectOrCreate.create.symbol; + symbol = requestedSymbol; } else { // Create custom asset profile - name = name ?? data.SymbolProfile.connectOrCreate.create.symbol; + name = name ?? requestedSymbol; symbol = randomUUID(); } diff --git a/apps/api/src/app/import/import.service.spec.ts b/apps/api/src/app/import/import.service.spec.ts index afabdbcb1f..5858cf0b65 100644 --- a/apps/api/src/app/import/import.service.spec.ts +++ b/apps/api/src/app/import/import.service.spec.ts @@ -3,10 +3,15 @@ import { ActivitiesService } from '@ghostfolio/api/app/activities/activities.ser import { ConfigurationService } from '@ghostfolio/api/services/configuration/configuration.service'; import { DataProviderService } from '@ghostfolio/api/services/data-provider/data-provider.service'; import { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-data/exchange-rate-data.service'; +import { MarketDataService } from '@ghostfolio/api/services/market-data/market-data.service'; import { DataGatheringService } from '@ghostfolio/api/services/queues/data-gathering/data-gathering.service'; +import { SymbolProfileService } from '@ghostfolio/api/services/symbol-profile/symbol-profile.service'; import { TagService } from '@ghostfolio/api/services/tag/tag.service'; import { NON_INVESTMENT_ACTIVITY_TYPES } from '@ghostfolio/common/config'; -import { CreateOrderDto } from '@ghostfolio/common/dtos'; +import { + CreateAssetProfileWithMarketDataDto, + CreateOrderDto +} from '@ghostfolio/common/dtos'; import { getAssetProfileIdentifier } from '@ghostfolio/common/helper'; import { Activity, @@ -15,6 +20,7 @@ import { import { UserWithSettings } from '@ghostfolio/common/types'; import { DataSource, SymbolProfile } from '@prisma/client'; +import { isUUID } from 'class-validator'; import { parseISO } from 'date-fns'; import { ImportService } from './import.service'; @@ -22,9 +28,11 @@ import { ImportService } from './import.service'; const CUSTOM_ASSET_PROFILE_SYMBOL = '1ad7d4a2-6b2d-4e0f-9b1f-2c0f8d3e5a7b'; describe('ImportService', () => { + let addSymbolProfile: jest.Mock; let createActivity: jest.Mock; let gatherSymbols: jest.Mock; let importService: ImportService; + let updateManyMarketData: jest.Mock; beforeEach(() => { const configuration = { @@ -33,8 +41,10 @@ describe('ImportService', () => { MAX_ACTIVITIES_TO_IMPORT: Number.MAX_SAFE_INTEGER }; + addSymbolProfile = jest.fn(); createActivity = jest.fn(); gatherSymbols = jest.fn(); + updateManyMarketData = jest.fn(); const validateActivities = jest.fn( ({ activitiesDto }: { activitiesDto: CreateOrderDto[] }) => { @@ -77,10 +87,14 @@ describe('ImportService', () => { { toCurrencyAtDate: jest.fn().mockResolvedValue(0) } as unknown as ExchangeRateDataService, + { updateMany: updateManyMarketData } as unknown as MarketDataService, null, null, - null, - null, + { + add: addSymbolProfile, + getCustomSymbolProfilesByNames: jest.fn().mockResolvedValue([]), + getSymbolProfiles: jest.fn().mockResolvedValue([]) + } as unknown as SymbolProfileService, { getTagsForUser: jest.fn().mockResolvedValue([]) } as unknown as TagService @@ -275,6 +289,146 @@ describe('ImportService', () => { ]); }); + it('creates a custom asset profile with a UUID instead of a symbol with the prefix', async () => { + mockCreatedAssetProfiles([ + { dataSource: DataSource.MANUAL, symbol: CUSTOM_ASSET_PROFILE_SYMBOL } + ]); + + await importActivities({ + activitiesDto: [ + createActivityDto({ + dataSource: DataSource.MANUAL, + date: '2024-01-01T00:00:00.000Z', + symbol: 'GF_COPX', + type: 'BUY' + }) + ], + assetProfilesWithMarketDataDto: [ + { + currency: 'USD', + dataSource: DataSource.MANUAL, + marketData: [], + name: 'Global X Copper Miners ETF', + symbol: 'GF_COPX' + } + ] + }); + + const { symbol } = addSymbolProfile.mock.calls[0][0]; + + expect(isUUID(symbol)).toBe(true); + + expect( + createActivity.mock.calls[0][0].SymbolProfile.connectOrCreate.create + ).toMatchObject({ symbol, dataSource: DataSource.MANUAL }); + }); + + it('keeps the symbol with the prefix as the name of a custom asset profile without a name', async () => { + mockCreatedAssetProfiles([ + { dataSource: DataSource.MANUAL, symbol: CUSTOM_ASSET_PROFILE_SYMBOL } + ]); + + await importActivities({ + activitiesDto: [ + createActivityDto({ + dataSource: DataSource.MANUAL, + date: '2024-01-01T00:00:00.000Z', + symbol: 'GF_COPX', + type: 'BUY' + }) + ], + assetProfilesWithMarketDataDto: [ + { + currency: 'USD', + dataSource: DataSource.MANUAL, + marketData: [], + symbol: 'GF_COPX' + } + ] + }); + + expect(addSymbolProfile.mock.calls[0][0].name).toBe('GF_COPX'); + }); + + it('merges the market data of asset profiles with the same symbol with the prefix', async () => { + mockCreatedAssetProfiles([ + { dataSource: DataSource.MANUAL, symbol: CUSTOM_ASSET_PROFILE_SYMBOL } + ]); + + await importActivities({ + activitiesDto: [ + createActivityDto({ + dataSource: DataSource.MANUAL, + date: '2024-01-01T00:00:00.000Z', + symbol: 'GF_COPX', + type: 'BUY' + }) + ], + assetProfilesWithMarketDataDto: [ + { + currency: 'USD', + dataSource: DataSource.MANUAL, + marketData: [{ date: '2024-01-01', marketPrice: 100 }], + name: 'Global X Copper Miners ETF', + symbol: 'GF_COPX' + }, + { + currency: 'USD', + dataSource: DataSource.MANUAL, + marketData: [{ date: '2024-01-02', marketPrice: 101 }], + name: 'Global X Copper Miners ETF', + symbol: 'GF_COPX' + } + ] + }); + + const { symbol } = addSymbolProfile.mock.calls[0][0]; + + expect(addSymbolProfile).toHaveBeenCalledTimes(1); + + expect(updateManyMarketData.mock.calls[0][0].data).toMatchObject([ + { symbol, date: '2024-01-01', marketPrice: 100 }, + { symbol, date: '2024-01-02', marketPrice: 101 } + ]); + + expect( + createActivity.mock.calls[0][0].SymbolProfile.connectOrCreate.create + ).toMatchObject({ symbol, dataSource: DataSource.MANUAL }); + }); + + it('refers the next activities with a symbol with the prefix to the custom asset profile which createActivity() has created', async () => { + mockCreatedAssetProfiles([ + { dataSource: DataSource.MANUAL, symbol: CUSTOM_ASSET_PROFILE_SYMBOL }, + { dataSource: DataSource.MANUAL, symbol: CUSTOM_ASSET_PROFILE_SYMBOL } + ]); + + await importActivities({ + activitiesDto: [ + createActivityDto({ + dataSource: DataSource.MANUAL, + date: '2024-01-01T00:00:00.000Z', + symbol: 'GF_COPX', + type: 'BUY' + }), + createActivityDto({ + dataSource: DataSource.MANUAL, + date: '2024-01-02T00:00:00.000Z', + symbol: 'GF_COPX', + type: 'BUY' + }) + ] + }); + + expect( + createActivity.mock.calls.map(([activity]) => { + return activity.SymbolProfile.connectOrCreate.create; + }) + ).toMatchObject([ + { dataSource: DataSource.MANUAL, symbol: 'GF_COPX' }, + { dataSource: DataSource.MANUAL, symbol: CUSTOM_ASSET_PROFILE_SYMBOL } + ]); + }); + it('keeps the asset profiles of the activities in a dry run', async () => { const activities = await importActivities({ activitiesDto: [ @@ -306,16 +460,18 @@ describe('ImportService', () => { function importActivities({ activitiesDto, + assetProfilesWithMarketDataDto = [], isDryRun }: { activitiesDto: CreateOrderDto[]; + assetProfilesWithMarketDataDto?: CreateAssetProfileWithMarketDataDto[]; isDryRun?: boolean; }) { return importService.import({ activitiesDto, + assetProfilesWithMarketDataDto, isDryRun, accountsWithBalancesDto: [], - assetProfilesWithMarketDataDto: [], platformsDto: [], tagsDto: [], user: { diff --git a/apps/api/src/app/import/import.service.ts b/apps/api/src/app/import/import.service.ts index 80637ea4d6..0f44fc2855 100644 --- a/apps/api/src/app/import/import.service.ts +++ b/apps/api/src/app/import/import.service.ts @@ -27,6 +27,7 @@ import { import { SubscriptionType } from '@ghostfolio/common/enums'; import { getAssetProfileIdentifier, + hasGhostfolioPrefix, isValidCustomAssetProfileSymbol, parseDate } from '@ghostfolio/common/helper'; @@ -649,9 +650,23 @@ export class ImportService { 'marketData' ); - // Asset profile belongs to a different user, generate a new symbol - if (existingAssetProfile && !isDryRun) { - symbol = randomUUID(); + if ( + (existingAssetProfile || hasGhostfolioPrefix(symbol)) && + !isDryRun + ) { + const isSymbolOfAssetProfileToCreate = assetProfilesToCreate.some( + ({ assetProfile: { symbol: symbolToCreate } }) => { + return symbolToCreate === assetProfileSymbolMapping[symbol]; + } + ); + + if (hasGhostfolioPrefix(symbol)) { + assetProfile.name ??= symbol; + } + + symbol = isSymbolOfAssetProfileToCreate + ? assetProfileSymbolMapping[symbol] + : randomUUID(); } assetProfile.symbol = symbol; diff --git a/libs/common/src/lib/helper.spec.ts b/libs/common/src/lib/helper.spec.ts index 71b934628c..d1ae262248 100644 --- a/libs/common/src/lib/helper.spec.ts +++ b/libs/common/src/lib/helper.spec.ts @@ -4,6 +4,7 @@ import { } from '@ghostfolio/common/config'; import { canApplyFiltersToAccess, + canUseCustomAssetProfileSymbol, extractNumberFromString, getCountryCodeFromCurrency, getEmojiFlag, @@ -39,6 +40,50 @@ describe('Helper', () => { }); }); + describe('Can use custom asset profile symbol', () => { + const assetProfile = { id: 'asset-profile-id' }; + const symbol = '7e91b7d4-1430-4212-8380-289a06c9bbc1'; + + it('Free-text symbol', () => { + expect( + canUseCustomAssetProfileSymbol({ + assetProfile: null, + symbol: 'Penthouse Apartment' + }) + ).toEqual(false); + }); + + it('New asset profile with UUID', () => { + expect( + canUseCustomAssetProfileSymbol({ symbol, assetProfile: null }) + ).toEqual(true); + }); + + it('New asset profile with Ghostfolio prefix', () => { + expect( + canUseCustomAssetProfileSymbol({ + assetProfile: null, + symbol: 'GF_PENTHOUSE_APARTMENT' + }) + ).toEqual(false); + }); + + it('Existing asset profile with UUID', () => { + expect(canUseCustomAssetProfileSymbol({ assetProfile, symbol })).toEqual( + true + ); + }); + + it('Existing asset profile with Ghostfolio prefix', () => { + expect( + canUseCustomAssetProfileSymbol({ + assetProfile, + symbol: 'GF_PENTHOUSE_APARTMENT' + }) + ).toEqual(true); + }); + }); + describe('Extract number from string', () => { it('Get decimal number', () => { expect(extractNumberFromString({ value: '999.99' })).toEqual(999.99); diff --git a/libs/common/src/lib/helper.ts b/libs/common/src/lib/helper.ts index 7e4e13de7e..0a3ec5ab85 100644 --- a/libs/common/src/lib/helper.ts +++ b/libs/common/src/lib/helper.ts @@ -237,6 +237,29 @@ export function canOpenHoldingDetail({ return assetProfile?.assetSubClass !== AssetSubClass.CASH; } +/** + * Checks if the symbol of a custom asset profile can be used, either to connect + * to the existing asset profile or to create a new one. The prefix is reserved + * for the asset profiles of the admin, thus a new asset profile cannot use it. + */ +export function canUseCustomAssetProfileSymbol({ + assetProfile, + symbol +}: { + assetProfile: Pick | null; + symbol: string; +}): boolean { + if (!isValidCustomAssetProfileSymbol(symbol)) { + return false; + } + + if (assetProfile) { + return true; + } + + return !hasGhostfolioPrefix(symbol); +} + export function capitalize(aString: string) { return aString.charAt(0).toUpperCase() + aString.slice(1).toLowerCase(); } From e001b153a69b9895b5748e3419dab35b6fa92baa Mon Sep 17 00:00:00 2001 From: Thomas Kaul <4159106+dtslvr@users.noreply.github.com> Date: Sat, 10 Oct 2026 14:06:17 +0200 Subject: [PATCH 2/5] Feature/benchmark comparator support for later benchmark start (#8106) * Support benchmarks whose market data starts later than portfolio * Update changelog --- CHANGELOG.md | 1 + .../benchmarks/benchmarks.service.spec.ts | 178 ++++++++++++++++++ .../benchmarks/benchmarks.service.ts | 63 +++++-- 3 files changed, 226 insertions(+), 16 deletions(-) create mode 100644 apps/api/src/app/endpoints/benchmarks/benchmarks.service.spec.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 90e2175e59..8e0c719a90 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -14,6 +14,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Changed +- Extended the benchmark comparator to support benchmarks whose market data starts later than the portfolio - Harmonized the colors in the investment chart component - Improved the language localization for Chinese (`zh`) diff --git a/apps/api/src/app/endpoints/benchmarks/benchmarks.service.spec.ts b/apps/api/src/app/endpoints/benchmarks/benchmarks.service.spec.ts new file mode 100644 index 0000000000..864f1d5a42 --- /dev/null +++ b/apps/api/src/app/endpoints/benchmarks/benchmarks.service.spec.ts @@ -0,0 +1,178 @@ +import { PortfolioService } from '@ghostfolio/api/app/portfolio/portfolio.service'; +import { SymbolService } from '@ghostfolio/api/app/symbol/symbol.service'; +import { BenchmarkService } from '@ghostfolio/api/services/benchmark/benchmark.service'; +import { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-data/exchange-rate-data.service'; +import { MarketDataService } from '@ghostfolio/api/services/market-data/market-data.service'; +import { parseDate } from '@ghostfolio/common/helper'; + +import { DataSource } from '@prisma/client'; + +import { BenchmarksService } from './benchmarks.service'; + +describe('BenchmarksService', () => { + let benchmarksService: BenchmarksService; + let get: jest.Mock; + let getExchangeRatesByCurrency: jest.Mock; + let getPerformance: jest.Mock; + let getRange: jest.Mock; + let marketDataItems: jest.Mock; + + beforeEach(() => { + get = jest.fn().mockResolvedValue({ currency: 'USD', marketPrice: 200 }); + getExchangeRatesByCurrency = jest.fn().mockResolvedValue({}); + getPerformance = jest.fn().mockResolvedValue({ + chart: [ + { date: '2009-01-01' }, + { date: '2009-06-01' }, + { date: '2010-07-18' }, + { date: '2010-07-19' } + ] + }); + getRange = jest.fn().mockResolvedValue([]); + marketDataItems = jest.fn(); + + benchmarksService = new BenchmarksService( + new BenchmarkService(null, null, null, null, null, null), + { getExchangeRatesByCurrency } as unknown as ExchangeRateDataService, + { getRange, marketDataItems } as unknown as MarketDataService, + { getPerformance } as unknown as PortfolioService, + { get } as unknown as SymbolService + ); + }); + + describe('getMarketDataForUser', () => { + const getMarketDataForUser = ({ + endDate = parseDate('2010-07-19') + }: { endDate?: Date } = {}) => { + return benchmarksService.getMarketDataForUser({ + endDate, + dataSource: DataSource.COINGECKO, + dateRange: 'max', + startDate: parseDate('2009-01-02'), + symbol: 'bitcoin', + userId: 'user-id', + userSettings: { baseCurrency: 'CHF' } + }); + }; + + it('calculates the change since the baseline date', async () => { + marketDataItems.mockResolvedValue([ + { date: new Date('2009-01-01'), marketPrice: 50 }, + { date: new Date('2009-06-01'), marketPrice: 25 }, + { date: new Date('2010-07-18'), marketPrice: 75 }, + { date: new Date('2010-07-19'), marketPrice: 100 } + ]); + + await expect(getMarketDataForUser()).resolves.toEqual({ + marketData: [ + { date: '2009-01-01', value: 0 }, + { date: '2009-06-01', value: -50 }, + { date: '2010-07-18', value: 50 }, + { date: '2010-07-19', value: 100 } + ] + }); + + expect(getRange).not.toHaveBeenCalled(); + }); + + it('returns no values before the market data of the benchmark starts', async () => { + marketDataItems.mockResolvedValue([ + { date: new Date('2010-07-18'), marketPrice: 50 }, + { date: new Date('2010-07-19'), marketPrice: 100 } + ]); + + await expect(getMarketDataForUser()).resolves.toEqual({ + marketData: [ + { date: '2010-07-18', value: 0 }, + { date: '2010-07-19', value: 100 } + ] + }); + + expect(getRange).toHaveBeenCalledWith({ + assetProfileIdentifiers: [ + { dataSource: DataSource.COINGECKO, symbol: 'bitcoin' } + ], + dateQuery: { lt: new Date('2009-01-01') }, + take: 1 + }); + + expect(marketDataItems).toHaveBeenCalledWith( + expect.objectContaining({ orderBy: { date: 'asc' } }) + ); + }); + + it('uses the exchange rate at the start of the market data', async () => { + getExchangeRatesByCurrency.mockResolvedValue({ + USDCHF: { + '2009-01-01': 2, + '2009-06-01': 2, + '2010-07-18': 1, + '2010-07-19': 1.5 + } + }); + + marketDataItems.mockResolvedValue([ + { date: new Date('2010-07-18'), marketPrice: 50 }, + { date: new Date('2010-07-19'), marketPrice: 100 } + ]); + + await expect(getMarketDataForUser()).resolves.toEqual({ + marketData: [ + { date: '2010-07-18', value: 0 }, + { date: '2010-07-19', value: 200 } + ] + }); + }); + + it('calculates the value at the end date since the start of the market data', async () => { + getExchangeRatesByCurrency.mockResolvedValue({ + USDCHF: { + '2009-01-01': 2, + '2010-07-18': 1, + '2010-07-19': 1, + '2010-07-20': 1.5 + } + }); + + marketDataItems.mockResolvedValue([ + { date: new Date('2010-07-18'), marketPrice: 50 }, + { date: new Date('2010-07-19'), marketPrice: 100 } + ]); + + await expect( + getMarketDataForUser({ endDate: parseDate('2010-07-20') }) + ).resolves.toEqual({ + marketData: [ + { date: '2010-07-18', value: 0 }, + { date: '2010-07-19', value: 100 }, + { date: '2010-07-20', value: 500 } + ] + }); + }); + + it('returns no market data if the market data at the baseline date is missing', async () => { + getRange.mockResolvedValue([ + { date: new Date('2008-12-31'), marketPrice: 40 } + ]); + + marketDataItems.mockResolvedValue([ + { date: new Date('2010-07-18'), marketPrice: 50 }, + { date: new Date('2010-07-19'), marketPrice: 100 } + ]); + + await expect(getMarketDataForUser()).resolves.toEqual({ + marketData: [] + }); + + expect(getExchangeRatesByCurrency).not.toHaveBeenCalled(); + }); + + it('returns no market data if the benchmark has none', async () => { + marketDataItems.mockResolvedValue([]); + + await expect(getMarketDataForUser()).resolves.toEqual({ + marketData: [] + }); + }); + }); +}); diff --git a/apps/api/src/app/endpoints/benchmarks/benchmarks.service.ts b/apps/api/src/app/endpoints/benchmarks/benchmarks.service.ts index d807b1bb6b..5104f01f35 100644 --- a/apps/api/src/app/endpoints/benchmarks/benchmarks.service.ts +++ b/apps/api/src/app/endpoints/benchmarks/benchmarks.service.ts @@ -94,25 +94,15 @@ export class BenchmarksService { const baselineDate = resetHours(parseDate(chart[0].date)); - const exchangeRates = - await this.exchangeRateDataService.getExchangeRatesByCurrency({ - currencies: [currentSymbolItem.currency], - startDate: min([baselineDate, startDate]), - targetCurrency: userCurrency - }); - - const exchangeRateAtStartDate = - exchangeRates[`${currentSymbolItem.currency}${userCurrency}`]?.[ - format(baselineDate, DATE_FORMAT) - ]; - - const marketPriceAtStartDate = marketDataItems?.find(({ date }) => { - return isSameDay(date, baselineDate); - })?.marketPrice; + // The market data of the benchmark can start after the baseline date. + // In this case, the first market data item is the start and there is no + // value before it. + const [firstMarketDataItem] = marketDataItems; + const marketPriceAtStartDate = firstMarketDataItem?.marketPrice; if (!marketPriceAtStartDate) { this.logger.error( - `No historical market data has been found for ${symbol} (${dataSource}) at ${format( + `No historical market data has been found for ${symbol} (${dataSource}) since ${format( baselineDate, DATE_FORMAT )}` @@ -121,6 +111,47 @@ export class BenchmarksService { return { marketData }; } + if (!isSameDay(firstMarketDataItem.date, baselineDate)) { + // If market data exists before the baseline date, the market data at + // the baseline date is missing and the benchmark does not start later + const [marketDataItemBeforeBaselineDate] = + await this.marketDataService.getRange({ + assetProfileIdentifiers: [{ dataSource, symbol }], + dateQuery: { lt: baselineDate }, + take: 1 + }); + + if (marketDataItemBeforeBaselineDate) { + this.logger.error( + `No historical market data has been found for ${symbol} (${dataSource}) at ${format( + baselineDate, + DATE_FORMAT + )}` + ); + + return { marketData }; + } + + this.logger.warn( + `The market data of ${symbol} (${dataSource}) starts at ${format( + firstMarketDataItem.date, + DATE_FORMAT + )}, after the baseline date ${format(baselineDate, DATE_FORMAT)}` + ); + } + + const exchangeRates = + await this.exchangeRateDataService.getExchangeRatesByCurrency({ + currencies: [currentSymbolItem.currency], + startDate: min([baselineDate, startDate]), + targetCurrency: userCurrency + }); + + const exchangeRateAtStartDate = + exchangeRates[`${currentSymbolItem.currency}${userCurrency}`]?.[ + format(firstMarketDataItem.date, DATE_FORMAT) + ]; + for (const marketDataItem of marketDataItems) { const exchangeRate = exchangeRates[`${currentSymbolItem.currency}${userCurrency}`]?.[ From 8e740c81fc087af23bc143f45ae077e502f7c28a Mon Sep 17 00:00:00 2001 From: Thomas Kaul <4159106+dtslvr@users.noreply.github.com> Date: Sat, 10 Oct 2026 14:45:47 +0200 Subject: [PATCH 3/5] Bugfix/horizontal overflow in overview on mobile (#8107) * Fix horizontal overflow * Update changelog --- CHANGELOG.md | 1 + .../home-overview/home-overview.html | 22 +++++++++---------- .../portfolio-performance.component.html | 4 ++-- .../portfolio-performance.component.scss | 1 - 4 files changed, 13 insertions(+), 15 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 8e0c719a90..a98179b3d2 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -21,6 +21,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Fixed - Fixed an issue where a user could create a custom asset profile with a symbol of the prefix `GF_` +- Fixed the horizontal overflow on the overview tab of the home page on mobile ## 3.82.0 - 2026-10-09 diff --git a/apps/client/src/app/components/home-overview/home-overview.html b/apps/client/src/app/components/home-overview/home-overview.html index 6a166f93fd..f8e696595c 100644 --- a/apps/client/src/app/components/home-overview/home-overview.html +++ b/apps/client/src/app/components/home-overview/home-overview.html @@ -83,18 +83,16 @@ [showYAxis]="false" /> -
-
- -
+
+
@if (showExperimentalHoldings()) { diff --git a/apps/client/src/app/components/portfolio-performance/portfolio-performance.component.html b/apps/client/src/app/components/portfolio-performance/portfolio-performance.component.html index 8aeb0c4335..191387223c 100644 --- a/apps/client/src/app/components/portfolio-performance/portfolio-performance.component.html +++ b/apps/client/src/app/components/portfolio-performance/portfolio-performance.component.html @@ -1,6 +1,6 @@
-
+
@if ((errors()?.length ?? 0) > 0 && !isLoading()) {
@if (showDetails()) { -
+
Date: Sat, 10 Oct 2026 14:47:07 +0200 Subject: [PATCH 4/5] Task/improve performance of historical market data query (#8061) * Improve performance of historical market data query * Update changelog --- CHANGELOG.md | 1 + .../services/data-provider/data-provider.service.ts | 11 +++-------- 2 files changed, 4 insertions(+), 8 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index a98179b3d2..73b61f15c0 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 - Extended the benchmark comparator to support benchmarks whose market data starts later than the portfolio - Harmonized the colors in the investment chart component +- Improved the performance of getting the historical market data by loading only the requested asset profiles - Improved the language localization for Chinese (`zh`) ### Fixed 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 cfacab3231..7bd06b1c99 100644 --- a/apps/api/src/services/data-provider/data-provider.service.ts +++ b/apps/api/src/services/data-provider/data-provider.service.ts @@ -403,12 +403,8 @@ export class DataProviderService implements OnModuleInit { })}::timestamp` : Prisma.empty; - const dataSources = aItems.map(({ dataSource }) => { - return dataSource; - }); - - const symbols = aItems.map(({ symbol }) => { - return symbol; + const assetProfileIdentifiers = aItems.map(({ dataSource, symbol }) => { + return Prisma.sql`(${dataSource}::"DataSource", ${symbol})`; }); try { @@ -416,8 +412,7 @@ export class DataProviderService implements OnModuleInit { .$queryRaw` SELECT * FROM "MarketData" - WHERE "dataSource"::text IN (${Prisma.join(dataSources)}) - AND "symbol" IN (${Prisma.join(symbols)}) + WHERE ("dataSource", "symbol") IN (${Prisma.join(assetProfileIdentifiers)}) ${granularityQuery} ${rangeQuery} ORDER BY date;`; From b244ede74a333f6bd803785670a176d3f544fc06 Mon Sep 17 00:00:00 2001 From: Thomas Kaul <4159106+dtslvr@users.noreply.github.com> Date: Sat, 10 Oct 2026 14:52:22 +0200 Subject: [PATCH 5/5] Release 3.83.0 (#8109) --- 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 73b61f15c0..475949ed76 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.83.0 - 2026-10-10 ### Added diff --git a/package-lock.json b/package-lock.json index b436a3c441..ecf8eb8822 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "ghostfolio", - "version": "3.82.0", + "version": "3.83.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "ghostfolio", - "version": "3.82.0", + "version": "3.83.0", "hasInstallScript": true, "license": "AGPL-3.0", "dependencies": { diff --git a/package.json b/package.json index 1725077d99..b230ac457e 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "ghostfolio", - "version": "3.82.0", + "version": "3.83.0", "homepage": "https://ghostfol.io", "license": "AGPL-3.0", "repository": "https://github.com/ghostfolio/ghostfolio",