diff --git a/CHANGELOG.md b/CHANGELOG.md index 731e810a94..ef07252ee7 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,17 +7,33 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## Unreleased +### Changed + +- Extended the access to share the portfolio with the Model Context Protocol (MCP) to support the _View_ and the _View and manage_ permissions (experimental) +- Improved the performance of getting the historical market data by selecting only the required columns + +### Fixed + +- Fixed the start date of the date ranges in the portfolio performance calculation for instances in time zones with a negative UTC offset + +## 3.83.0 - 2026-10-10 + ### Added +- Added the performance timeline grouped by year to the analysis page (experimental) - Extended the `GET api/v2/portfolio/performance` endpoint by the `groupBy` query parameter (`year`) ### 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 performance of getting the historical market data by loading only the requested asset profiles - Improved the language localization for Chinese (`zh`) ### Fixed -- Fixed the start date of the date ranges in the portfolio performance calculation for instances in time zones with a negative UTC offset +- 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/README.md b/README.md index 95bf3a5ea3..88f6108340 100644 --- a/README.md +++ b/README.md @@ -407,7 +407,7 @@ The _Model Context Protocol_ (MCP) server lets an AI client read your portfolio - Set `ROOT_URL` to the public URL of your instance if a client calls the endpoint from a browser page. The host name of `ROOT_URL` is the only accepted origin. - Grant an access of the type _MCP_ in _My Ghostfolio_ under _Access_ and copy its token -An _MCP_ access has (restricted) read scopes and never reads the monetary values. Grant the _Restricted view and manage_ permission to let the client also import activities. +An _MCP_ access with the _Restricted view_ permission reads the portfolio without the monetary values. Grant the _View_ permission to let the client also read the monetary values. Grant the _Restricted view and manage_ or the _View and manage_ permission to let the client also import activities. ### Connect a client 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/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}`]?.[ diff --git a/apps/api/src/app/endpoints/mcp/mcp.controller.spec.ts b/apps/api/src/app/endpoints/mcp/mcp.controller.spec.ts index e3c60d2829..78e84bf53d 100644 --- a/apps/api/src/app/endpoints/mcp/mcp.controller.spec.ts +++ b/apps/api/src/app/endpoints/mcp/mcp.controller.spec.ts @@ -2,12 +2,17 @@ import { REQUIRES_SCOPE_KEY } from '@ghostfolio/api/decorators/requires-scope.de import { McpToolExceptionFilter } from '@ghostfolio/api/filters/mcp-tool-exception.filter'; import { AccessGuard } from '@ghostfolio/api/guards/access.guard'; import { getMcpUserOfBearerToken } from '@ghostfolio/api/helper/bearer-token.helper'; +import { SubscriptionType } from '@ghostfolio/common/enums'; import { getScopesOfAccess, getScopesOfAccessLevel, Scope, scopes } from '@ghostfolio/common/scopes'; +import type { + AccessLevel, + ImpersonationContext +} from '@ghostfolio/common/types'; import { EXCEPTION_FILTERS_METADATA, @@ -23,6 +28,7 @@ import { } from '@rekog/mcp-nest'; import { GhostfolioMcpController } from './mcp.controller'; +import { McpService } from './mcp.service'; /** * Gives the metadata which a decorator sets on the method of a tool. The @@ -83,6 +89,86 @@ function getNamesOfListedTools(request: unknown) { }); } +/** + * Gives for each tool which reads the portfolio whether the controller asks + * the service for the monetary values + */ +async function getWithValuesOfReadTools(accessLevel: AccessLevel) { + const mcpService = { + getAccounts: jest.fn(), + getActivities: jest.fn(), + getPerformance: jest.fn(), + getPortfolio: jest.fn() + }; + + const controller = new GhostfolioMcpController( + mcpService as unknown as McpService + ); + + const impersonationContext = { + isActive: true, + scopes: getScopesOfAccess({ + scopes: getScopesOfAccessLevel(accessLevel), + type: 'MCP' + }), + userId: 'user-id', + userSettings: { baseCurrency: 'CHF' } + } as ImpersonationContext; + + await controller.getAccounts(impersonationContext, {}); + await controller.getActivities( + impersonationContext, + {} as Parameters[1] + ); + await controller.getPerformance( + impersonationContext, + {} as Parameters[1] + ); + await controller.getPortfolio(impersonationContext); + + return Object.values(mcpService).map((method) => { + return (method.mock.calls[0][0] as { withValues: boolean }).withValues; + }); +} + +/** + * Gives whether the controller asks the service for the asset performance in + * base currency of an access with the permission "View" + */ +async function getWithAssetPerformanceInBaseCurrency( + subscriptionType?: SubscriptionType +) { + const mcpService = { getPerformance: jest.fn() }; + + const controller = new GhostfolioMcpController( + mcpService as unknown as McpService + ); + + await controller.getPerformance( + { + isActive: true, + scopes: getScopesOfAccess({ + scopes: getScopesOfAccessLevel('READ'), + type: 'MCP' + }), + userId: 'user-id', + userSettings: { baseCurrency: 'CHF' }, + userSubscription: subscriptionType + ? ({ + type: subscriptionType + } as ImpersonationContext['userSubscription']) + : undefined + }, + {} as Parameters[1] + ); + + return ( + mcpService.getPerformance.mock.calls[0][0] as { + withAssetPerformanceInBaseCurrency: boolean; + } + ).withAssetPerformanceInBaseCurrency; +} + describe('GhostfolioMcpController', () => { // A tool without the decorator of the scope would be open to every access, // hence a new tool has to declare its scope @@ -163,6 +249,82 @@ describe('GhostfolioMcpController', () => { ]); }); + it('Lists only the tools to read for an access with the permission "View"', () => { + expect( + getNamesOfListedTools({ + impersonationOfBearerToken: { + isActive: true, + scopes: getScopesOfAccess({ + scopes: getScopesOfAccessLevel('READ'), + type: 'MCP' + }) + } + }) + ).toEqual([ + 'get-accounts', + 'get-activities', + 'get-performance', + 'get-portfolio', + 'get-watchlist' + ]); + }); + + it('Lists every tool for an access with the permission "View and manage"', () => { + expect( + getNamesOfListedTools({ + impersonationOfBearerToken: { + isActive: true, + scopes: getScopesOfAccess({ + scopes: getScopesOfAccessLevel('CREATE_READ_UPDATE_DELETE'), + type: 'MCP' + }) + } + }) + ).toEqual([ + 'get-accounts', + 'get-activities', + 'get-performance', + 'get-portfolio', + 'get-watchlist', + 'import-activities', + 'search-asset-profiles' + ]); + }); + + it('Gives the monetary values for an access with the permission "View"', async () => { + expect(await getWithValuesOfReadTools('READ')).toEqual([ + true, + true, + true, + true + ]); + }); + + it('Gives no monetary values for an access with the permission "Restricted view"', async () => { + expect(await getWithValuesOfReadTools('READ_RESTRICTED')).toEqual([ + false, + false, + false, + false + ]); + }); + + it('Gives the asset performance in base currency for a Premium subscription', async () => { + expect( + await getWithAssetPerformanceInBaseCurrency(SubscriptionType.Premium) + ).toBe(true); + }); + + it('Gives the asset performance in base currency if the subscription is disabled', async () => { + expect(await getWithAssetPerformanceInBaseCurrency()).toBe(true); + }); + + it('Gives no asset performance in base currency for a Basic subscription', async () => { + expect( + await getWithAssetPerformanceInBaseCurrency(SubscriptionType.Basic) + ).toBe(false); + }); + it('Lists no tool for an inactive access', () => { expect( getNamesOfListedTools({ diff --git a/apps/api/src/app/endpoints/mcp/mcp.controller.ts b/apps/api/src/app/endpoints/mcp/mcp.controller.ts index 71aaeecccd..c6ec55d2d5 100644 --- a/apps/api/src/app/endpoints/mcp/mcp.controller.ts +++ b/apps/api/src/app/endpoints/mcp/mcp.controller.ts @@ -3,7 +3,8 @@ import { RequiresScopeOfAccess } from '@ghostfolio/api/decorators/requires-scope import { McpToolExceptionFilter } from '@ghostfolio/api/filters/mcp-tool-exception.filter'; import { PortfolioTableService } from '@ghostfolio/api/services/portfolio-table/portfolio-table.service'; import { MCP_MAX_ACTIVITIES } from '@ghostfolio/common/config'; -import { scopes } from '@ghostfolio/common/scopes'; +import { SubscriptionType } from '@ghostfolio/common/enums'; +import { hasScope, scopes } from '@ghostfolio/common/scopes'; import type { ImpersonationContext } from '@ghostfolio/common/types'; import { UseFilters } from '@nestjs/common'; @@ -35,15 +36,22 @@ export class GhostfolioMcpController { }, description: `Gives the accounts of the portfolio with these columns: ${PortfolioTableService.getAccountsTableColumnNames().join( ', ' - )}. The allocation in percentage is relative to the accounts of the result, hence the parameters change it.`, + )}. If the access reads the monetary values, these columns are given in addition: ${PortfolioTableService.getAccountsTableValueColumnNames().join( + ', ' + )}. The allocation in percentage is relative to the accounts of the result, hence the parameters change it. The parameters change the value in base currency as well. With the holding parameter, it is the value of the holding in the account without the cash balance. Without the holding parameter, it includes the full cash balance of the account, also with the assetClasses parameter. The balance is always the full cash balance of the account in the currency of the account.`, name: 'get-accounts', parameters: GET_ACCOUNTS_PARAMETERS }) public async getAccounts( - @Impersonation() { userId }: ImpersonationContext, + @Impersonation() + { scopes: impersonationScopes, userId }: ImpersonationContext, @Payload() parameters: z.infer ) { - return this.mcpService.getAccounts({ ...parameters, userId }); + return this.mcpService.getAccounts({ + ...parameters, + userId, + withValues: this.hasScopeToReadValues(impersonationScopes) + }); } @RequiresScopeOfAccess(scopes.activityRead) @@ -55,18 +63,22 @@ export class GhostfolioMcpController { }, description: `Gives the activities of the portfolio, the most recent first, with these columns: ${PortfolioTableService.getActivitiesTableColumnNames().join( ', ' - )}. At most ${MCP_MAX_ACTIVITIES} activities are given per call, hence narrow the result with the parameters or get the further activities with the skip parameter.`, + )}. If the access reads the monetary values, these columns are given in addition: ${PortfolioTableService.getActivitiesTableValueColumnNames().join( + ', ' + )}. The unit price and the fee are in the currency of the activity. At most ${MCP_MAX_ACTIVITIES} activities are given per call, hence narrow the result with the parameters or get the further activities with the skip parameter.`, name: 'get-activities', parameters: GET_ACTIVITIES_PARAMETERS }) public async getActivities( - @Impersonation() { userId, userSettings }: ImpersonationContext, + @Impersonation() + { scopes: impersonationScopes, userId, userSettings }: ImpersonationContext, @Payload() parameters: z.infer ) { return this.mcpService.getActivities({ ...parameters, userId, - userCurrency: userSettings.baseCurrency + userCurrency: userSettings.baseCurrency, + withValues: this.hasScopeToReadValues(impersonationScopes) }); } @@ -79,15 +91,28 @@ export class GhostfolioMcpController { }, description: `Gives the performance of the portfolio in the date range with these columns: ${PortfolioTableService.getPerformanceTableColumnNames().join( ', ' - )}. The asset performance excludes the effect of the exchange rates, the currency performance is that effect, and the net performance is the sum of both in the base currency of the user. Each performance is the return on average investment (ROAI) and includes the dividends (total return). The accounts and the activities which are excluded from analysis are not part of the performance. The parameters limit the performance to the holdings of the accounts, of the asset classes or of the asset profile.`, + )}. If the access reads the monetary values, these columns are given in addition: ${PortfolioTableService.getPerformanceTableValueColumnNames().join( + ', ' + )}. The value in base currency is the value at the end of the date range. The asset performance excludes the effect of the exchange rates, the currency performance is that effect, and the net performance is the sum of both in the base currency of the user. Each performance is the return on average investment (ROAI) and includes the dividends (total return). The accounts and the activities which are excluded from analysis are not part of the performance. The parameters limit the performance to the holdings of the accounts, of the asset classes or of the asset profile.`, name: 'get-performance', parameters: GET_PERFORMANCE_PARAMETERS }) public async getPerformance( - @Impersonation() { userId }: ImpersonationContext, + @Impersonation() + { + scopes: impersonationScopes, + userId, + userSubscription + }: ImpersonationContext, @Payload() parameters: z.infer ) { - return this.mcpService.getPerformance({ ...parameters, userId }); + return this.mcpService.getPerformance({ + ...parameters, + userId, + withAssetPerformanceInBaseCurrency: + userSubscription?.type !== SubscriptionType.Basic, + withValues: this.hasScopeToReadValues(impersonationScopes) + }); } @RequiresScopeOfAccess(scopes.portfolioRead) @@ -99,11 +124,19 @@ export class GhostfolioMcpController { }, description: `Gives the holdings of the portfolio with these columns: ${PortfolioTableService.getHoldingsTableColumnNames().join( ', ' + )}. If the access reads the monetary values, these columns are given in addition: ${PortfolioTableService.getHoldingsTableValueColumnNames().join( + ', ' )}.`, name: 'get-portfolio' }) - public async getPortfolio(@Impersonation() { userId }: ImpersonationContext) { - return this.mcpService.getPortfolio({ userId }); + public async getPortfolio( + @Impersonation() + { scopes: impersonationScopes, userId }: ImpersonationContext + ) { + return this.mcpService.getPortfolio({ + userId, + withValues: this.hasScopeToReadValues(impersonationScopes) + }); } @RequiresScopeOfAccess(scopes.watchlistRead) @@ -159,4 +192,8 @@ export class GhostfolioMcpController { ) { return this.mcpService.searchAssetProfiles({ ...parameters, userId }); } + + private hasScopeToReadValues(impersonationScopes: string[]) { + return hasScope(impersonationScopes, scopes.portfolioReadValues); + } } diff --git a/apps/api/src/app/endpoints/mcp/mcp.module.ts b/apps/api/src/app/endpoints/mcp/mcp.module.ts index 1da7a06823..347089e7a1 100644 --- a/apps/api/src/app/endpoints/mcp/mcp.module.ts +++ b/apps/api/src/app/endpoints/mcp/mcp.module.ts @@ -7,6 +7,7 @@ import { ApiModule } from '@ghostfolio/api/services/api/api.module'; import { ConfigurationModule } from '@ghostfolio/api/services/configuration/configuration.module'; import { ConfigurationService } from '@ghostfolio/api/services/configuration/configuration.service'; import { PortfolioTableModule } from '@ghostfolio/api/services/portfolio-table/portfolio-table.module'; +import { PortfolioTableService } from '@ghostfolio/api/services/portfolio-table/portfolio-table.service'; import { MCP_ENDPOINT } from '@ghostfolio/common/config'; import { Module } from '@nestjs/common'; @@ -37,9 +38,20 @@ import { McpService } from './mcp.service'; useFactory: (configurationService: ConfigurationService) => { const { hostname } = new URL(configurationService.get('ROOT_URL')); + const instructions = [ + 'Ghostfolio is a wealth management application. The tools read the portfolio and the watchlist of the user who granted the access and import activities into the portfolio. They give the quantities and the monetary values only if the access has the permission "View" or "View and manage", otherwise no quantity and no monetary value (except the unit price of an activity). Only the tools which the permission of the access covers are listed. To import activities, the access needs the permission "Restricted view and manage" or "View and manage".' + ]; + + if (configurationService.get('ENABLE_FEATURE_SUBSCRIPTION')) { + instructions.push( + `With the Basic subscription, the tool get-performance does not give these columns: ${PortfolioTableService.getAssetPerformanceInBaseCurrencyColumnNames().join( + ', ' + )}.` + ); + } + return new McpStrategy({ - instructions: - 'Ghostfolio is a wealth management application. The tools read the portfolio and the watchlist of the user who granted the access and import activities into the portfolio. They give no quantity and no monetary value (except the unit price of an activity). Only the tools which the permission of the access covers are listed. To import activities, the access needs the permission "Restricted view and manage".', + instructions: instructions.join(' '), name: 'ghostfolio', resolveUser: getMcpUserOfBearerToken, title: 'Ghostfolio', diff --git a/apps/api/src/app/endpoints/mcp/mcp.service.spec.ts b/apps/api/src/app/endpoints/mcp/mcp.service.spec.ts index a013341179..d6a3211144 100644 --- a/apps/api/src/app/endpoints/mcp/mcp.service.spec.ts +++ b/apps/api/src/app/endpoints/mcp/mcp.service.spec.ts @@ -169,6 +169,14 @@ describe('McpService', () => { userId }); }); + + it('Gives the values if the access reads them', async () => { + await mcpService.getAccounts({ userId, withValues: true }); + + expect(portfolioTableService.getAccountsTable).toHaveBeenCalledWith( + expect.objectContaining({ withValues: true }) + ); + }); }); describe('getActivities', () => { @@ -272,6 +280,14 @@ describe('McpService', () => { types: [ActivityType.BUY] }); }); + + it('Gives the values if the access reads them', async () => { + await getActivities({ withValues: true }); + + expect(portfolioTableService.getActivitiesTable).toHaveBeenCalledWith( + expect.objectContaining({ withValues: true }) + ); + }); }); describe('getPerformance', () => { @@ -350,6 +366,30 @@ describe('McpService', () => { dateRange: '2024' }); }); + + it('Gives the values if the access reads them', async () => { + await mcpService.getPerformance({ + userId, + range: '2024', + withValues: true + }); + + expect(portfolioTableService.getPerformanceTable).toHaveBeenCalledWith( + expect.objectContaining({ withValues: true }) + ); + }); + + it('Gives the asset performance in base currency if the subscription permits it', async () => { + await mcpService.getPerformance({ + userId, + range: '2024', + withAssetPerformanceInBaseCurrency: true + }); + + expect(portfolioTableService.getPerformanceTable).toHaveBeenCalledWith( + expect.objectContaining({ withAssetPerformanceInBaseCurrency: true }) + ); + }); }); describe('getPortfolio', () => { @@ -364,6 +404,14 @@ describe('McpService', () => { withDataSource: true }); }); + + it('Gives the values if the access reads them', async () => { + await mcpService.getPortfolio({ userId, withValues: true }); + + expect(portfolioTableService.getHoldingsTable).toHaveBeenCalledWith( + expect.objectContaining({ withValues: true }) + ); + }); }); describe('getWatchlist', () => { diff --git a/apps/api/src/app/endpoints/mcp/mcp.service.ts b/apps/api/src/app/endpoints/mcp/mcp.service.ts index 70be380060..4b4c32f6c5 100644 --- a/apps/api/src/app/endpoints/mcp/mcp.service.ts +++ b/apps/api/src/app/endpoints/mcp/mcp.service.ts @@ -39,8 +39,12 @@ export class McpService { accountIds, assetClasses, holding, - userId - }: z.infer & { userId: string }) { + userId, + withValues + }: z.infer & { + userId: string; + withValues?: boolean; + }) { const filters = this.apiService.buildFiltersFromQueryParams({ ...this.getHoldingFilterParameters({ holding }), filterByAccounts: accountIds, @@ -49,7 +53,8 @@ export class McpService { const table = await this.portfolioTableService.getAccountsTable({ filters, - userId + userId, + withValues }); return this.getTextResult(table); @@ -63,10 +68,12 @@ export class McpService { skip, take, userCurrency, - userId + userId, + withValues }: z.infer & { userCurrency: string; userId: string; + withValues?: boolean; }) { let endDate: Date | undefined; let startDate: Date | undefined; @@ -90,6 +97,7 @@ export class McpService { take, userCurrency, userId, + withValues, types: activityTypes }); @@ -101,8 +109,14 @@ export class McpService { assetClasses, holding, range, - userId - }: z.infer & { userId: string }) { + userId, + withAssetPerformanceInBaseCurrency, + withValues + }: z.infer & { + userId: string; + withAssetPerformanceInBaseCurrency?: boolean; + withValues?: boolean; + }) { const filters = this.apiService.buildFiltersFromQueryParams({ ...this.getHoldingFilterParameters({ holding }), filterByAccounts: accountIds, @@ -112,15 +126,24 @@ export class McpService { const table = await this.portfolioTableService.getPerformanceTable({ filters, userId, + withAssetPerformanceInBaseCurrency, + withValues, dateRange: range }); return this.getTextResult(table); } - public async getPortfolio({ userId }: { userId: string }) { + public async getPortfolio({ + userId, + withValues + }: { + userId: string; + withValues?: boolean; + }) { const table = await this.portfolioTableService.getHoldingsTable({ userId, + withValues, languageCode: DEFAULT_LANGUAGE_CODE, withDataSource: true }); 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/apps/api/src/app/portfolio/portfolio.controller.ts b/apps/api/src/app/portfolio/portfolio.controller.ts index 109b5cca59..72468f1d6e 100644 --- a/apps/api/src/app/portfolio/portfolio.controller.ts +++ b/apps/api/src/app/portfolio/portfolio.controller.ts @@ -4,7 +4,8 @@ import { Impersonation } from '@ghostfolio/api/decorators/impersonation.decorato import { RequiresScope } from '@ghostfolio/api/decorators/requires-scope.decorator'; import { hasNotDefinedValuesInObject, - nullifyValuesInObject + nullifyValuesInObject, + nullifyValuesInObjects } from '@ghostfolio/api/helper/object.helper'; import { convertValuesToPercentages } from '@ghostfolio/api/helper/portfolio.helper'; import { PerformanceLoggingInterceptor } from '@ghostfolio/api/interceptors/performance-logging/performance-logging.interceptor'; @@ -583,14 +584,22 @@ export class PortfolioController { this.configurationService.get('ENABLE_FEATURE_SUBSCRIPTION') && this.request.user.subscription?.type === SubscriptionType.Basic ) { - performanceInformation.chart = performanceInformation.chart.map( - (item) => { - return nullifyValuesInObject(item, [ - 'dividendInBaseCurrency', - 'totalInvestment', - 'value' - ]); - } + performanceInformation.chart = nullifyValuesInObjects( + performanceInformation.chart, + [ + 'dividendInBaseCurrency', + 'totalInvestment', + 'value', + ...(groupBy === 'year' + ? [ + 'investmentValueWithCurrencyEffect', + 'netPerformance', + 'netPerformanceInPercentage', + 'netPerformanceInPercentageWithCurrencyEffect', + 'netPerformanceWithCurrencyEffect' + ] + : []) + ] ); performanceInformation.performance = nullifyValuesInObject( performanceInformation.performance, diff --git a/apps/api/src/app/portfolio/portfolio.service.ts b/apps/api/src/app/portfolio/portfolio.service.ts index 8aea5ef0db..6e9f8c045a 100644 --- a/apps/api/src/app/portfolio/portfolio.service.ts +++ b/apps/api/src/app/portfolio/portfolio.service.ts @@ -1052,7 +1052,6 @@ export class PortfolioService { const historicalData = await this.dataProviderService.getHistorical( [{ dataSource, symbol }], - 'day', parseISO(dateOfFirstActivity, { in: utc }), new Date() ); 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..cf281f5f52 100644 --- a/apps/api/src/services/data-provider/data-provider.service.ts +++ b/apps/api/src/services/data-provider/data-provider.service.ts @@ -371,7 +371,6 @@ export class DataProviderService implements OnModuleInit { public async getHistorical( aItems: AssetProfileIdentifier[], - aGranularity: Granularity = 'month', from: Date, to: Date ): Promise<{ @@ -389,11 +388,6 @@ export class DataProviderService implements OnModuleInit { return response; } - const granularityQuery = - aGranularity === 'month' - ? Prisma.sql`AND (date_part('day', date) = 1 OR date >= TIMESTAMP 'yesterday')` - : Prisma.empty; - const rangeQuery = from && to ? Prisma.sql`AND date >= ${format(from, DATE_FORMAT, { @@ -403,22 +397,18 @@ 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 { - const marketDataByGranularity: MarketData[] = await this.prismaService - .$queryRaw` - SELECT * + const marketDataByGranularity: Pick< + MarketData, + 'dataSource' | 'date' | 'marketPrice' | 'symbol' + >[] = await this.prismaService.$queryRaw` + SELECT "dataSource", "date", "marketPrice", "symbol" FROM "MarketData" - WHERE "dataSource"::text IN (${Prisma.join(dataSources)}) - AND "symbol" IN (${Prisma.join(symbols)}) - ${granularityQuery} + WHERE ("dataSource", "symbol") IN (${Prisma.join(assetProfileIdentifiers)}) ${rangeQuery} ORDER BY date;`; diff --git a/apps/api/src/services/exchange-rate-data/exchange-rate-data.service.ts b/apps/api/src/services/exchange-rate-data/exchange-rate-data.service.ts index 824149e74f..59795cad6a 100644 --- a/apps/api/src/services/exchange-rate-data/exchange-rate-data.service.ts +++ b/apps/api/src/services/exchange-rate-data/exchange-rate-data.service.ts @@ -175,7 +175,6 @@ export class ExchangeRateDataService { const historicalData = await this.dataProviderService.getHistorical( this.currencyPairs, - 'day', startOfUtcDateOfYesterday, startOfUtcDateOfYesterday ); diff --git a/apps/api/src/services/portfolio-table/portfolio-table.service.spec.ts b/apps/api/src/services/portfolio-table/portfolio-table.service.spec.ts index 3cfb9fe856..c2ea2d868c 100644 --- a/apps/api/src/services/portfolio-table/portfolio-table.service.spec.ts +++ b/apps/api/src/services/portfolio-table/portfolio-table.service.spec.ts @@ -84,7 +84,8 @@ function createAccount({ currency: 'CHF', platform: { name: 'Platform A' }, tags: isExcluded ? [{ id: TAG_ID_EXCLUDE_FROM_ANALYSIS }] : [], - value: 2000 + value: 2000, + valueInBaseCurrency: 1800 } as unknown as AccountWithValue; } @@ -105,8 +106,11 @@ function createActivity({ }, currency: 'CHF', date: new Date('2024-01-01'), + fee: 1.5, + quantity: 5, type: 'BUY', - unitPrice: 100 + unitPrice: 100, + valueInBaseCurrency: 512.3456 } as unknown as Activity; } @@ -143,20 +147,24 @@ function createHolding({ } function createPerformance({ + netPerformance = 200, netPerformancePercentage = 0.1, - netPerformancePercentageWithCurrencyEffect = 0.15 + netPerformancePercentageWithCurrencyEffect = 0.15, + netPerformanceWithCurrencyEffect = 300 }: { + netPerformance?: number; netPerformancePercentage?: number; netPerformancePercentageWithCurrencyEffect?: number; + netPerformanceWithCurrencyEffect?: number; } = {}): PortfolioPerformanceResponse['performance'] { return { + netPerformance, netPerformancePercentage, netPerformancePercentageWithCurrencyEffect, + netPerformanceWithCurrencyEffect, currentNetWorth: 3000, currentValueInBaseCurrency: 2000, dividendInBaseCurrency: 50, - netPerformance: 200, - netPerformanceWithCurrencyEffect: 300, totalInvestment: 1700, totalInvestmentValueWithCurrencyEffect: 1700 }; @@ -246,9 +254,8 @@ function createPortfolioTableService({ } describe('PortfolioTableService', () => { - // The tools of the model context protocol are the only callers, and an - // access of that type never grants the scope to read the monetary values, - // hence no table has a column with such a value + // A column with a monetary value is given only with the values, hence the + // names of these columns are given separately describe('getAccountsTableColumnNames', () => { it('gives no column with a monetary value', () => { expect(PortfolioTableService.getAccountsTableColumnNames()).toEqual([ @@ -263,6 +270,15 @@ describe('PortfolioTableService', () => { }); }); + describe('getAccountsTableValueColumnNames', () => { + it('gives the columns with a monetary value', () => { + expect(PortfolioTableService.getAccountsTableValueColumnNames()).toEqual([ + 'Balance', + 'Value in Base Currency' + ]); + }); + }); + describe('getActivitiesTableColumnNames', () => { it('gives no column with a monetary value', () => { expect(PortfolioTableService.getActivitiesTableColumnNames()).toEqual([ @@ -278,6 +294,25 @@ describe('PortfolioTableService', () => { }); }); + describe('getActivitiesTableValueColumnNames', () => { + it('gives the columns with a quantity or with a monetary value', () => { + expect( + PortfolioTableService.getActivitiesTableValueColumnNames() + ).toEqual(['Quantity', 'Fee', 'Value in Base Currency']); + }); + }); + + describe('getAssetPerformanceInBaseCurrencyColumnNames', () => { + it('gives the value columns of the performance which give the asset performance', () => { + expect( + PortfolioTableService.getAssetPerformanceInBaseCurrencyColumnNames() + ).toEqual([ + 'Asset Performance in Base Currency', + 'Currency Performance in Base Currency' + ]); + }); + }); + describe('getHoldingsTableColumnNames', () => { it('gives no column with a monetary value', () => { expect(PortfolioTableService.getHoldingsTableColumnNames()).toEqual([ @@ -294,6 +329,15 @@ describe('PortfolioTableService', () => { }); }); + describe('getHoldingsTableValueColumnNames', () => { + it('gives the columns with a quantity or with a monetary value', () => { + expect(PortfolioTableService.getHoldingsTableValueColumnNames()).toEqual([ + 'Quantity', + 'Value in Base Currency' + ]); + }); + }); + describe('getPerformanceTableColumnNames', () => { it('gives no column with a monetary value', () => { expect(PortfolioTableService.getPerformanceTableColumnNames()).toEqual([ @@ -304,6 +348,19 @@ describe('PortfolioTableService', () => { }); }); + describe('getPerformanceTableValueColumnNames', () => { + it('gives the columns with a monetary value', () => { + expect( + PortfolioTableService.getPerformanceTableValueColumnNames() + ).toEqual([ + 'Value in Base Currency', + 'Asset Performance in Base Currency', + 'Currency Performance in Base Currency', + 'Net Performance in Base Currency' + ]); + }); + }); + describe('getWatchlistTableColumnNames', () => { it('gives no column with a monetary value', () => { expect(PortfolioTableService.getWatchlistTableColumnNames()).toEqual([ @@ -331,9 +388,27 @@ describe('PortfolioTableService', () => { expect(result).not.toContain('Cash Balance'); expect(result).not.toContain('1000'); + expect(result).not.toContain('1800'); expect(result).not.toContain('2000'); }); + it('gives the balance and the value with the values', async () => { + const portfolioTableService = createPortfolioTableService({ + accounts: [createAccount()] + }); + + const result = await portfolioTableService.getAccountsTable({ + userId: 'user-id', + withValues: true + }); + + const [row] = result.split('\n').filter((line) => { + return line.startsWith('| account-a-id'); + }); + + expect(row).toMatch(/\| false \| 1000 \| 1800\.00 \|$/); + }); + // The accountIds parameter of the tool takes the identifiers, hence the // table has to give them it('gives the identifier of an account', async () => { @@ -405,6 +480,40 @@ describe('PortfolioTableService', () => { expect(rowOfAapl).toContain(`| ${encodeDataSource(DataSource.YAHOO)} |`); expect(rowOfGold).toContain(`| ${DataSource.MANUAL} |`); }); + + it('gives no quantity, no fee and no value by default', async () => { + const result = await createPortfolioTableService({ + activities: [createActivity()] + }).getActivitiesTable({ + take: 50, + userCurrency: 'CHF', + userId: 'user-id' + }); + + const [row] = result.split('\n').filter((line) => { + return line.startsWith('| 2024-01-01'); + }); + + expect(result).not.toContain('Quantity'); + expect(row).toMatch(/\| 100 \| Account A \|$/); + }); + + it('gives the quantity, the fee and the value with the values', async () => { + const result = await createPortfolioTableService({ + activities: [createActivity()] + }).getActivitiesTable({ + take: 50, + userCurrency: 'CHF', + userId: 'user-id', + withValues: true + }); + + const [row] = result.split('\n').filter((line) => { + return line.startsWith('| 2024-01-01'); + }); + + expect(row).toMatch(/\| Account A \| 5 \| 1\.5 \| 512\.35 \|$/); + }); }); describe('getHoldingsTable', () => { @@ -412,10 +521,12 @@ describe('PortfolioTableService', () => { holdings: PortfolioPosition[], { configuration, - withDataSource + withDataSource, + withValues }: { configuration?: Record; withDataSource?: boolean; + withValues?: boolean; } = {} ) { return createPortfolioTableService({ @@ -423,6 +534,7 @@ describe('PortfolioTableService', () => { holdings }).getHoldingsTable({ withDataSource, + withValues, languageCode: DEFAULT_LANGUAGE_CODE, userId: 'user-id' }); @@ -516,13 +628,45 @@ describe('PortfolioTableService', () => { expect(firstRow).toContain('AAPL'); expect(secondRow).toContain('MSFT'); }); + + it('gives no quantity and no value by default', async () => { + const result = await getHoldingsTable([createHolding()]); + + const [row] = result.split('\n').filter((line) => { + return line.startsWith('| Name of AAPL'); + }); + + expect(result).not.toContain('Quantity'); + expect(row).toMatch(/\| 75\.000% \|$/); + }); + + it('gives the quantity and the value with the values', async () => { + const result = await getHoldingsTable([createHolding()], { + withValues: true + }); + + const [row] = result.split('\n').filter((line) => { + return line.startsWith('| Name of AAPL'); + }); + + expect(row).toMatch(/\| 75\.000% \| 5 \| 2000\.00 \|$/); + }); }); describe('getPerformanceTable', () => { function getPerformanceTable( - parameters: Parameters[0] = {} + parameters: Parameters[0] = {}, + { + withAssetPerformanceInBaseCurrency, + withValues + }: { + withAssetPerformanceInBaseCurrency?: boolean; + withValues?: boolean; + } = {} ) { return createPortfolioTableService(parameters).getPerformanceTable({ + withAssetPerformanceInBaseCurrency, + withValues, dateRange: 'ytd', userId: 'user-id' }); @@ -565,6 +709,51 @@ describe('PortfolioTableService', () => { } }); + it('gives the value and the performance in base currency with the values', async () => { + const result = await getPerformanceTable( + {}, + { withAssetPerformanceInBaseCurrency: true, withValues: true } + ); + + const [row] = result.split('\n').filter((line) => { + return line.startsWith('| 10.000%'); + }); + + expect(row).toBe( + '| 10.000% | 5.000% | 15.000% | 2000.00 | 200.00 | 100.00 | 300.00 |' + ); + }); + + it('gives no asset performance and no currency performance in base currency without the asset performance in base currency', async () => { + const result = await getPerformanceTable({}, { withValues: true }); + + const [row] = result.split('\n').filter((line) => { + return line.startsWith('| 10.000%'); + }); + + expect(result).not.toContain('Asset Performance in Base Currency'); + expect(result).not.toContain('Currency Performance in Base Currency'); + expect(row).toBe('| 10.000% | 5.000% | 15.000% | 2000.00 | 300.00 |'); + }); + + it('gives a currency performance in base currency of zero without a sign', async () => { + const result = await getPerformanceTable( + { + performance: createPerformance({ + netPerformance: 200.000001, + netPerformanceWithCurrencyEffect: 200 + }) + }, + { withAssetPerformanceInBaseCurrency: true, withValues: true } + ); + + const [row] = result.split('\n').filter((line) => { + return line.startsWith('| 10.000%'); + }); + + expect(row).toMatch(/\| 200\.00 \| 0\.00 \| 200\.00 \|$/); + }); + it('tells that no performance is found if the chart is empty', async () => { const result = await getPerformanceTable({ chart: [] }); diff --git a/apps/api/src/services/portfolio-table/portfolio-table.service.ts b/apps/api/src/services/portfolio-table/portfolio-table.service.ts index 9e63d133b5..e0d5f2ca08 100644 --- a/apps/api/src/services/portfolio-table/portfolio-table.service.ts +++ b/apps/api/src/services/portfolio-table/portfolio-table.service.ts @@ -31,6 +31,17 @@ import { format } from 'date-fns'; import { DataSourceTableContext } from './interfaces/data-source-table-context.interface'; import { HoldingsTableColumnDefinition } from './types/holdings-table-column-definition.type'; +const ASSET_PERFORMANCE_IN_BASE_CURRENCY_COLUMN_NAME = + 'Asset Performance in Base Currency'; + +const CURRENCY_PERFORMANCE_IN_BASE_CURRENCY_COLUMN_NAME = + 'Currency Performance in Base Currency'; + +const ASSET_PERFORMANCE_IN_BASE_CURRENCY_COLUMN_NAMES = [ + ASSET_PERFORMANCE_IN_BASE_CURRENCY_COLUMN_NAME, + CURRENCY_PERFORMANCE_IN_BASE_CURRENCY_COLUMN_NAME +]; + const DATA_SOURCE_COLUMN_NAME = 'Data Source'; function getDataSourceColumnDefinition( @@ -48,15 +59,21 @@ function getDataSourceColumnDefinition( }; } +function getAmount(value: number) { + const amount = value.toFixed(2); + + return Number.parseFloat(amount) === 0 ? (0).toFixed(2) : amount; +} + function getPercentage(value: number) { return `${(value * 100).toFixed(3)}%`; } /** * Renders the accounts, the activities, the holdings and the performance of a - * portfolio and the watchlist of its user as a markdown table. No table has a - * column with a quantity or with a monetary value, except the unit price of an - * activity. + * portfolio and the watchlist of its user as a markdown table. A column with a + * quantity or with a monetary value, except the unit price of an activity, is + * given only with the values. */ @Injectable() export class PortfolioTableService { @@ -108,6 +125,24 @@ export class PortfolioTableService { } ]; + private static readonly ACCOUNTS_TABLE_VALUE_COLUMN_DEFINITIONS: TableColumnDefinition[] = + [ + { + align: 'right', + getValue: ({ balance }) => { + return balance.toString(); + }, + name: 'Balance' + }, + { + align: 'right', + getValue: ({ valueInBaseCurrency }) => { + return getAmount(valueInBaseCurrency); + }, + name: 'Value in Base Currency' + } + ]; + private static readonly ACTIVITIES_TABLE_COLUMN_DEFINITIONS: TableColumnDefinition< Activity, DataSourceTableContext @@ -160,6 +195,33 @@ export class PortfolioTableService { } ]; + private static readonly ACTIVITIES_TABLE_VALUE_COLUMN_DEFINITIONS: TableColumnDefinition< + Activity, + DataSourceTableContext + >[] = [ + { + align: 'right', + getValue: ({ quantity }) => { + return quantity.toString(); + }, + name: 'Quantity' + }, + { + align: 'right', + getValue: ({ fee }) => { + return fee.toString(); + }, + name: 'Fee' + }, + { + align: 'right', + getValue: ({ valueInBaseCurrency }) => { + return getAmount(valueInBaseCurrency); + }, + name: 'Value in Base Currency' + } + ]; + private static readonly HOLDINGS_TABLE_COLUMN_DEFINITIONS: HoldingsTableColumnDefinition[] = [ { @@ -221,6 +283,26 @@ export class PortfolioTableService { } ]; + private static readonly HOLDINGS_TABLE_VALUE_COLUMN_DEFINITIONS: HoldingsTableColumnDefinition[] = + [ + { + align: 'right', + getValue: ({ quantity }) => { + return quantity.toString(); + }, + name: 'Quantity' + }, + { + align: 'right', + getValue: ({ valueInBaseCurrency }) => { + return valueInBaseCurrency === undefined + ? '' + : getAmount(valueInBaseCurrency); + }, + name: 'Value in Base Currency' + } + ]; + private static readonly PERFORMANCE_TABLE_COLUMN_DEFINITIONS: TableColumnDefinition[] = [ { @@ -256,6 +338,38 @@ export class PortfolioTableService { } ]; + private static readonly PERFORMANCE_TABLE_VALUE_COLUMN_DEFINITIONS: TableColumnDefinition[] = + [ + { + align: 'right', + getValue: ({ currentValueInBaseCurrency }) => { + return getAmount(currentValueInBaseCurrency); + }, + name: 'Value in Base Currency' + }, + { + align: 'right', + getValue: ({ netPerformance }) => { + return getAmount(netPerformance); + }, + name: ASSET_PERFORMANCE_IN_BASE_CURRENCY_COLUMN_NAME + }, + { + align: 'right', + getValue: ({ netPerformance, netPerformanceWithCurrencyEffect }) => { + return getAmount(netPerformanceWithCurrencyEffect - netPerformance); + }, + name: CURRENCY_PERFORMANCE_IN_BASE_CURRENCY_COLUMN_NAME + }, + { + align: 'right', + getValue: ({ netPerformanceWithCurrencyEffect }) => { + return getAmount(netPerformanceWithCurrencyEffect); + }, + name: 'Net Performance in Base Currency' + } + ]; + private static readonly WATCHLIST_TABLE_COLUMN_DEFINITIONS: TableColumnDefinition< WatchlistResponse['watchlist'][number], DataSourceTableContext @@ -326,6 +440,14 @@ export class PortfolioTableService { ); } + public static getAccountsTableValueColumnNames() { + return PortfolioTableService.ACCOUNTS_TABLE_VALUE_COLUMN_DEFINITIONS.map( + ({ name }) => { + return name; + } + ); + } + public static getActivitiesTableColumnNames() { return PortfolioTableService.ACTIVITIES_TABLE_COLUMN_DEFINITIONS.map( ({ name }) => { @@ -334,6 +456,18 @@ export class PortfolioTableService { ); } + public static getActivitiesTableValueColumnNames() { + return PortfolioTableService.ACTIVITIES_TABLE_VALUE_COLUMN_DEFINITIONS.map( + ({ name }) => { + return name; + } + ); + } + + public static getAssetPerformanceInBaseCurrencyColumnNames() { + return [...ASSET_PERFORMANCE_IN_BASE_CURRENCY_COLUMN_NAMES]; + } + public static getHoldingsTableColumnNames() { return PortfolioTableService.HOLDINGS_TABLE_COLUMN_DEFINITIONS.map( ({ name }) => { @@ -342,6 +476,14 @@ export class PortfolioTableService { ); } + public static getHoldingsTableValueColumnNames() { + return PortfolioTableService.HOLDINGS_TABLE_VALUE_COLUMN_DEFINITIONS.map( + ({ name }) => { + return name; + } + ); + } + public static getPerformanceTableColumnNames() { return PortfolioTableService.PERFORMANCE_TABLE_COLUMN_DEFINITIONS.map( ({ name }) => { @@ -350,6 +492,14 @@ export class PortfolioTableService { ); } + public static getPerformanceTableValueColumnNames() { + return PortfolioTableService.PERFORMANCE_TABLE_VALUE_COLUMN_DEFINITIONS.map( + ({ name }) => { + return name; + } + ); + } + public static getWatchlistTableColumnNames() { return PortfolioTableService.WATCHLIST_TABLE_COLUMN_DEFINITIONS.map( ({ name }) => { @@ -360,10 +510,12 @@ export class PortfolioTableService { public async getAccountsTable({ filters, - userId + userId, + withValues = false }: { filters?: Filter[]; userId: string; + withValues?: boolean; }) { const { accounts } = await this.portfolioService.getAccountsWithAggregations({ @@ -377,8 +529,13 @@ export class PortfolioTableService { if (accounts.length > 0) { accountsSection.push( await getMarkdownTable({ - columnDefinitions: - PortfolioTableService.ACCOUNTS_TABLE_COLUMN_DEFINITIONS, + columnDefinitions: this.getColumnDefinitions({ + withValues, + columnDefinitions: + PortfolioTableService.ACCOUNTS_TABLE_COLUMN_DEFINITIONS, + valueColumnDefinitions: + PortfolioTableService.ACCOUNTS_TABLE_VALUE_COLUMN_DEFINITIONS + }), rows: accounts }) ); @@ -397,7 +554,8 @@ export class PortfolioTableService { take, types, userCurrency, - userId + userId, + withValues = false }: { endDate?: Date; filters?: Filter[]; @@ -407,6 +565,7 @@ export class PortfolioTableService { types?: ActivityType[]; userCurrency: string; userId: string; + withValues?: boolean; }) { const { activities, count } = await this.activitiesService.getActivities({ endDate, @@ -437,8 +596,13 @@ export class PortfolioTableService { activitiesSection.push( '', await getMarkdownTable({ - columnDefinitions: - PortfolioTableService.ACTIVITIES_TABLE_COLUMN_DEFINITIONS, + columnDefinitions: this.getColumnDefinitions({ + withValues, + columnDefinitions: + PortfolioTableService.ACTIVITIES_TABLE_COLUMN_DEFINITIONS, + valueColumnDefinitions: + PortfolioTableService.ACTIVITIES_TABLE_VALUE_COLUMN_DEFINITIONS + }), context: { configurationService: this.configurationService }, rows: activities }) @@ -452,12 +616,14 @@ export class PortfolioTableService { filters, languageCode, userId, - withDataSource = false + withDataSource = false, + withValues = false }: { filters?: Filter[]; languageCode: string; userId: string; withDataSource?: boolean; + withValues?: boolean; }) { const { holdings } = await this.portfolioService.getDetails({ filters, @@ -484,12 +650,15 @@ export class PortfolioTableService { '## Holdings', '', await getMarkdownTable({ - columnDefinitions: - PortfolioTableService.HOLDINGS_TABLE_COLUMN_DEFINITIONS.filter( - ({ name }) => { - return withDataSource || name !== DATA_SOURCE_COLUMN_NAME; - } - ), + columnDefinitions: this.getColumnDefinitions({ + withValues, + columnDefinitions: + PortfolioTableService.HOLDINGS_TABLE_COLUMN_DEFINITIONS, + valueColumnDefinitions: + PortfolioTableService.HOLDINGS_TABLE_VALUE_COLUMN_DEFINITIONS + }).filter(({ name }) => { + return withDataSource || name !== DATA_SOURCE_COLUMN_NAME; + }), context: { assetClassTranslations, assetSubClassTranslations, @@ -503,11 +672,15 @@ export class PortfolioTableService { public async getPerformanceTable({ dateRange, filters, - userId + userId, + withAssetPerformanceInBaseCurrency = false, + withValues = false }: { dateRange: DateRange; filters?: Filter[]; userId: string; + withAssetPerformanceInBaseCurrency?: boolean; + withValues?: boolean; }) { const { chart, performance } = await this.portfolioService.getPerformance({ dateRange, @@ -520,8 +693,18 @@ export class PortfolioTableService { if (chart?.length > 0) { performanceSection.push( await getMarkdownTable({ - columnDefinitions: - PortfolioTableService.PERFORMANCE_TABLE_COLUMN_DEFINITIONS, + columnDefinitions: this.getColumnDefinitions({ + withValues, + columnDefinitions: + PortfolioTableService.PERFORMANCE_TABLE_COLUMN_DEFINITIONS, + valueColumnDefinitions: + PortfolioTableService.PERFORMANCE_TABLE_VALUE_COLUMN_DEFINITIONS + }).filter(({ name }) => { + return ( + withAssetPerformanceInBaseCurrency || + !ASSET_PERFORMANCE_IN_BASE_CURRENCY_COLUMN_NAMES.includes(name) + ); + }), rows: [performance] }) ); @@ -587,6 +770,20 @@ export class PortfolioTableService { return `${summary} Get the further activities by raising the skip parameter or narrow the result with the other parameters.`; } + private getColumnDefinitions({ + columnDefinitions, + valueColumnDefinitions, + withValues + }: { + columnDefinitions: T[]; + valueColumnDefinitions: T[]; + withValues: boolean; + }) { + return withValues + ? [...columnDefinitions, ...valueColumnDefinitions] + : columnDefinitions; + } + private getEnumTranslations({ id, languageCode, diff --git a/apps/client/src/app/app.component.html b/apps/client/src/app/app.component.html index 61d5023e2d..5e2b658a28 100644 --- a/apps/client/src/app/app.component.html +++ b/apps/client/src/app/app.component.html @@ -48,6 +48,6 @@ @if (showFooter) {
- +
} diff --git a/apps/client/src/app/components/account-detail-dialog/account-detail-dialog.component.ts b/apps/client/src/app/components/account-detail-dialog/account-detail-dialog.component.ts index c95dfe7bc4..9a1dfe9438 100644 --- a/apps/client/src/app/components/account-detail-dialog/account-detail-dialog.component.ts +++ b/apps/client/src/app/components/account-detail-dialog/account-detail-dialog.component.ts @@ -35,7 +35,6 @@ import { inject, OnInit } from '@angular/core'; -import { CUSTOM_ELEMENTS_SCHEMA } from '@angular/core'; import { takeUntilDestroyed } from '@angular/core/rxjs-interop'; import { MatButtonModule } from '@angular/material/button'; import { MAT_DIALOG_DATA, MatDialogRef } from '@angular/material/dialog'; @@ -82,7 +81,6 @@ import { MatTabsModule, NgxSkeletonLoaderModule ], - schemas: [CUSTOM_ELEMENTS_SCHEMA], selector: 'gf-account-detail-dialog', styleUrls: ['./account-detail-dialog.component.scss'], templateUrl: 'account-detail-dialog.html' diff --git a/apps/client/src/app/components/account-detail-dialog/account-detail-dialog.html b/apps/client/src/app/components/account-detail-dialog/account-detail-dialog.html index 5a86e4aa9b..7f50255012 100644 --- a/apps/client/src/app/components/account-detail-dialog/account-detail-dialog.html +++ b/apps/client/src/app/components/account-detail-dialog/account-detail-dialog.html @@ -153,7 +153,6 @@ [hasPermissionToCreateActivity]="data.hasPermissionToCreateActivity" [hasPermissionToDeleteActivity]="false" [hasPermissionToExportActivities]="!showValuesInPercentage()" - [hasPermissionToFilter]="false" [hasPermissionToOpenDetails]="false" [hasPermissionToUpdateActivity]="data.hasPermissionToUpdateActivity" [locale]="user?.settings?.locale" diff --git a/apps/client/src/app/components/admin-platform/create-or-update-platform-dialog/create-or-update-platform-dialog.html b/apps/client/src/app/components/admin-platform/create-or-update-platform-dialog/create-or-update-platform-dialog.html index f923b95a6e..4109fd3cb4 100644 --- a/apps/client/src/app/components/admin-platform/create-or-update-platform-dialog/create-or-update-platform-dialog.html +++ b/apps/client/src/app/components/admin-platform/create-or-update-platform-dialog/create-or-update-platform-dialog.html @@ -28,12 +28,8 @@ matInput (keydown.enter)="$event.stopPropagation()" /> - @if (platformForm.get('url')?.value) { - + @if (platformForm.get('url')?.value; as url) { + } diff --git a/apps/client/src/app/components/footer/footer.component.html b/apps/client/src/app/components/footer/footer.component.html index 5f0016317e..6dc44f35ee 100644 --- a/apps/client/src/app/components/footer/footer.component.html +++ b/apps/client/src/app/components/footer/footer.component.html @@ -6,7 +6,7 @@
Personal Finance

Fully managed Ghostfolio cloud offering.

-

- - @if (coupon) { - {{ baseCurrency }} {{ price }} {{ baseCurrency }} {{ - price - coupon - }} - } @else { - {{ baseCurrency }} {{ price }} - } -   - per year -

+ @if (price) { +

+ + @if (coupon) { + {{ baseCurrency }} {{ price }} {{ baseCurrency }} {{ + price - coupon + }} + } @else { + {{ baseCurrency }} {{ price }} + } +   + per year +

+ } @if ( hasPermissionToUpdateUserSettings && user?.subscription?.type === 'Basic' diff --git a/apps/client/src/app/util/date.util.ts b/apps/client/src/app/util/date.util.ts index a7dd0afd8c..02e42c529a 100644 --- a/apps/client/src/app/util/date.util.ts +++ b/apps/client/src/app/util/date.util.ts @@ -10,16 +10,17 @@ export function formatRelativeTime({ date, language }: { - date: string; + date: Date | string | null | undefined; language?: string; }) { if (date) { - const distanceString = formatDistanceToNowStrict(parseISO(date), { + const parsedDate = typeof date === 'string' ? parseISO(date) : date; + const distanceString = formatDistanceToNowStrict(parsedDate, { addSuffix: true, locale: getDateFnsLocale(language) }); - return Math.abs(differenceInSeconds(parseISO(date), new Date())) < 60 + return Math.abs(differenceInSeconds(parsedDate, new Date())) < 60 ? $localize`just now` : distanceString; } diff --git a/apps/client/tsconfig.json b/apps/client/tsconfig.json index a6815ec125..b009454a0d 100644 --- a/apps/client/tsconfig.json +++ b/apps/client/tsconfig.json @@ -14,20 +14,16 @@ } ], "angularCompilerOptions": { + "extendedDiagnostics": { + "checks": { + // TODO: Remove once the fields are typed as optional + "nullishCoalescingNotNullable": "suppress", + "optionalChainNotNullable": "suppress" + } + }, "strictInjectionParameters": true, - // TODO: Remove these subsets once strictTemplates is enabled - "strictAttributeTypes": true, - "strictContextGenerics": true, - "strictDomEventTypes": true, - "strictDomLocalRefTypes": true, "strictInputAccessModifiers": true, - "strictInputTypes": true, - "strictLiteralTypes": true, - "strictNullInputTypes": true, - "strictOutputEventTypes": true, - "strictSafeNavigationTypes": true, - // TODO: Enable stricter rules for this project - "strictTemplates": false + "strictTemplates": true }, "compilerOptions": { "module": "preserve", 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(); } diff --git a/libs/common/src/lib/scopes.spec.ts b/libs/common/src/lib/scopes.spec.ts index 877c5aa719..97d6ef1f8f 100644 --- a/libs/common/src/lib/scopes.spec.ts +++ b/libs/common/src/lib/scopes.spec.ts @@ -3,6 +3,7 @@ import { SCOPES_OF_READ_ACCESS, SCOPES_OF_READ_RESTRICTED_ACCESS, SCOPES_OF_WRITE_ACCESS, + canGrantUnrestrictedReadAccess, getAccessLevel, getScopesOfAccess, getScopesOfAccessLevel, @@ -239,12 +240,21 @@ describe('Scopes', () => { expect(scopesOfAccess).toContain(scopes.watchlistRead); }); - it('Cannot expose the monetary values', () => { + it('Allows reading the monetary values', () => { expect( getScopesOfAccess({ scopes: [...SCOPES_OF_READ_ACCESS], type: 'MCP' }) + ).toContain(scopes.portfolioReadValues); + }); + + it('Without the scope to read the values', () => { + expect( + getScopesOfAccess({ + scopes: [...SCOPES_OF_READ_RESTRICTED_ACCESS], + type: 'MCP' + }) ).not.toContain(scopes.portfolioReadValues); }); @@ -276,6 +286,31 @@ describe('Scopes', () => { ) ).toEqual('CREATE_READ_RESTRICTED_UPDATE_DELETE'); }); + + it('Keeps the access level to change the data with the monetary values', () => { + expect( + getAccessLevel( + getScopesOfAccess({ + scopes: getScopesOfAccessLevel('CREATE_READ_UPDATE_DELETE'), + type: 'MCP' + }) + ) + ).toEqual('CREATE_READ_UPDATE_DELETE'); + }); + }); + + describe('Can grant unrestricted read access', () => { + it('Model context protocol', () => { + expect(canGrantUnrestrictedReadAccess({ type: 'MCP' })).toEqual(true); + }); + + it('Private', () => { + expect(canGrantUnrestrictedReadAccess({ type: 'PRIVATE' })).toEqual(true); + }); + + it('Public', () => { + expect(canGrantUnrestrictedReadAccess({ type: 'PUBLIC' })).toEqual(false); + }); }); describe('Get scopes of own access', () => { diff --git a/libs/common/src/lib/scopes.ts b/libs/common/src/lib/scopes.ts index 635530a9e2..39b1954fc2 100644 --- a/libs/common/src/lib/scopes.ts +++ b/libs/common/src/lib/scopes.ts @@ -68,19 +68,26 @@ export const SCOPES_OF_READ_RESTRICTED_ACCESS: readonly Scope[] = * monetary values. */ const SCOPES_OF_TYPE: Record = { - MCP: [...SCOPES_OF_READ_RESTRICTED_ACCESS, scopes.activityCreate], + MCP: [...SCOPES_OF_READ_ACCESS, scopes.activityCreate], PRIVATE: Object.values(scopes), PUBLIC: SCOPES_OF_PUBLIC_ACCESS }; /** - * Access types which combine a write scope with the restricted read access, - * because their tools change data without exposing the monetary values + * Access types which can combine a write scope with the restricted read access, + * because their tools which change data do not expose the monetary values */ export function canGrantRestrictedWriteAccess({ type }: { type: AccessType }) { return type === 'MCP'; } +/** + * Access types which can expose the monetary values + */ +export function canGrantUnrestrictedReadAccess({ type }: { type: AccessType }) { + return SCOPES_OF_TYPE[type].includes(scopes.portfolioReadValues); +} + /** * Access level which the scopes of an access grant */ diff --git a/libs/ui/src/lib/line-chart/line-chart.component.ts b/libs/ui/src/lib/line-chart/line-chart.component.ts index e6f1b9b2e2..d910033bc0 100644 --- a/libs/ui/src/lib/line-chart/line-chart.component.ts +++ b/libs/ui/src/lib/line-chart/line-chart.component.ts @@ -60,7 +60,7 @@ export class GfLineChartComponent @Input() benchmarkLabel = ''; @Input() colorScheme?: ColorScheme; @Input() currency?: string; - @Input() historicalDataItems: LineChartItem[]; + @Input() historicalDataItems: LineChartItem[] | null; @Input() isAnimated = false; @Input() label: string; @Input() locale = getLocale(); diff --git a/libs/ui/src/lib/services/data.service.ts b/libs/ui/src/lib/services/data.service.ts index f18b3eef13..d7cea71864 100644 --- a/libs/ui/src/lib/services/data.service.ts +++ b/libs/ui/src/lib/services/data.service.ts @@ -728,14 +728,21 @@ export class DataService { public fetchPortfolioPerformance({ filters, + groupBy, range, withExcludedAccounts = false }: { filters?: Filter[]; + groupBy?: Extract; range: DateRange; withExcludedAccounts?: boolean; }): Observable { let params = this.buildFiltersAsQueryParams({ filters }); + + if (groupBy) { + params = params.append('groupBy', groupBy); + } + params = params.append('range', range); if (withExcludedAccounts) { 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",