From 932f3e18dd25bf46adf066c7b4685d1dddc8295f Mon Sep 17 00:00:00 2001 From: Thomas Kaul <4159106+dtslvr@users.noreply.github.com> Date: Sat, 5 Sep 2026 08:37:49 +0200 Subject: [PATCH] Task/improve MCP (part 2) (#7789) * Move the logic of the tools to the McpService * Move the tables of the portfolio to the PortfolioTableService * Render the tables from one column definition with a value function --- apps/api/src/app/endpoints/ai/ai.module.ts | 48 +- .../src/app/endpoints/ai/ai.service.spec.ts | 164 ------- apps/api/src/app/endpoints/ai/ai.service.ts | 454 +----------------- .../app/endpoints/mcp/mcp.controller.spec.ts | 298 ++---------- .../src/app/endpoints/mcp/mcp.controller.ts | 149 +----- apps/api/src/app/endpoints/mcp/mcp.module.ts | 10 +- .../src/app/endpoints/mcp/mcp.schemas.spec.ts | 48 ++ .../src/app/endpoints/mcp/mcp.service.spec.ts | 279 +++++++++++ apps/api/src/app/endpoints/mcp/mcp.service.ts | 172 ++++++- .../src/app/endpoints/mcp/mcp.test-utils.ts | 18 + .../mcp/types/activity-to-import.type.ts | 7 + .../table-column-definition.interface.ts | 6 + .../interfaces/table-parameters.interface.ts | 7 + .../src/helper/markdown-table.helper.spec.ts | 77 +++ apps/api/src/helper/markdown-table.helper.ts | 44 ++ .../holdings-table-context.interface.ts | 6 + .../portfolio-table/portfolio-table.module.ts | 14 + .../portfolio-table.service.spec.ts | 272 +++++++++++ .../portfolio-table.service.ts | 399 +++++++++++++++ .../holdings-table-column-definition.type.ts | 9 + 20 files changed, 1426 insertions(+), 1055 deletions(-) delete mode 100644 apps/api/src/app/endpoints/ai/ai.service.spec.ts create mode 100644 apps/api/src/app/endpoints/mcp/mcp.schemas.spec.ts create mode 100644 apps/api/src/app/endpoints/mcp/mcp.service.spec.ts create mode 100644 apps/api/src/app/endpoints/mcp/mcp.test-utils.ts create mode 100644 apps/api/src/app/endpoints/mcp/types/activity-to-import.type.ts create mode 100644 apps/api/src/helper/interfaces/table-column-definition.interface.ts create mode 100644 apps/api/src/helper/interfaces/table-parameters.interface.ts create mode 100644 apps/api/src/helper/markdown-table.helper.spec.ts create mode 100644 apps/api/src/helper/markdown-table.helper.ts create mode 100644 apps/api/src/services/portfolio-table/interfaces/holdings-table-context.interface.ts create mode 100644 apps/api/src/services/portfolio-table/portfolio-table.module.ts create mode 100644 apps/api/src/services/portfolio-table/portfolio-table.service.spec.ts create mode 100644 apps/api/src/services/portfolio-table/portfolio-table.service.ts create mode 100644 apps/api/src/services/portfolio-table/types/holdings-table-column-definition.type.ts diff --git a/apps/api/src/app/endpoints/ai/ai.module.ts b/apps/api/src/app/endpoints/ai/ai.module.ts index 844737cc6..f46ed1df8 100644 --- a/apps/api/src/app/endpoints/ai/ai.module.ts +++ b/apps/api/src/app/endpoints/ai/ai.module.ts @@ -1,27 +1,8 @@ -import { AccountBalanceService } from '@ghostfolio/api/app/account-balance/account-balance.service'; -import { AccountService } from '@ghostfolio/api/app/account/account.service'; -import { ActivitiesModule } from '@ghostfolio/api/app/activities/activities.module'; -import { PortfolioCalculatorFactory } from '@ghostfolio/api/app/portfolio/calculator/portfolio-calculator.factory'; -import { CurrentRateService } from '@ghostfolio/api/app/portfolio/current-rate.service'; -import { PortfolioService } from '@ghostfolio/api/app/portfolio/portfolio.service'; -import { RulesService } from '@ghostfolio/api/app/portfolio/rules.service'; -import { RedisCacheModule } from '@ghostfolio/api/app/redis-cache/redis-cache.module'; -import { UserModule } from '@ghostfolio/api/app/user/user.module'; import { TransformDataSourceInRequestModule } from '@ghostfolio/api/interceptors/transform-data-source-in-request/transform-data-source-in-request.module'; import { ApiModule } from '@ghostfolio/api/services/api/api.module'; -import { BenchmarkModule } from '@ghostfolio/api/services/benchmark/benchmark.module'; import { ConfigurationModule } from '@ghostfolio/api/services/configuration/configuration.module'; -import { DataProviderModule } from '@ghostfolio/api/services/data-provider/data-provider.module'; -import { ExchangeRateDataModule } from '@ghostfolio/api/services/exchange-rate-data/exchange-rate-data.module'; -import { I18nModule } from '@ghostfolio/api/services/i18n/i18n.module'; -import { ImpersonationModule } from '@ghostfolio/api/services/impersonation/impersonation.module'; -import { MarketDataModule } from '@ghostfolio/api/services/market-data/market-data.module'; -import { MarketDataService } from '@ghostfolio/api/services/market-data/market-data.service'; -import { PrismaModule } from '@ghostfolio/api/services/prisma/prisma.module'; +import { PortfolioTableModule } from '@ghostfolio/api/services/portfolio-table/portfolio-table.module'; import { PropertyModule } from '@ghostfolio/api/services/property/property.module'; -import { PortfolioSnapshotQueueModule } from '@ghostfolio/api/services/queues/portfolio-snapshot/portfolio-snapshot.module'; -import { SymbolProfileModule } from '@ghostfolio/api/services/symbol-profile/symbol-profile.module'; -import { TagModule } from '@ghostfolio/api/services/tag/tag.module'; import { Module } from '@nestjs/common'; @@ -32,33 +13,12 @@ import { AiService } from './ai.service'; controllers: [AiController], exports: [AiService], imports: [ - ActivitiesModule, ApiModule, - BenchmarkModule, ConfigurationModule, - DataProviderModule, - ExchangeRateDataModule, - I18nModule, - ImpersonationModule, - MarketDataModule, - PortfolioSnapshotQueueModule, - PrismaModule, + PortfolioTableModule, PropertyModule, - RedisCacheModule, - SymbolProfileModule, - TagModule, - TransformDataSourceInRequestModule, - UserModule + TransformDataSourceInRequestModule ], - providers: [ - AccountBalanceService, - AccountService, - AiService, - CurrentRateService, - MarketDataService, - PortfolioCalculatorFactory, - PortfolioService, - RulesService - ] + providers: [AiService] }) export class AiModule {} diff --git a/apps/api/src/app/endpoints/ai/ai.service.spec.ts b/apps/api/src/app/endpoints/ai/ai.service.spec.ts deleted file mode 100644 index 6328df195..000000000 --- a/apps/api/src/app/endpoints/ai/ai.service.spec.ts +++ /dev/null @@ -1,164 +0,0 @@ -import type { PortfolioService } from '@ghostfolio/api/app/portfolio/portfolio.service'; -import { TAG_ID_EXCLUDE_FROM_ANALYSIS } from '@ghostfolio/common/config'; -import { AccountWithValue } from '@ghostfolio/common/types'; - -import { AiService } from './ai.service'; - -// The service imports two packages which ship as an ECMAScript module only, -// which Jest cannot transform. The mocks only make the imports resolvable, -// because no test calls them. -jest.mock('@openrouter/ai-sdk-provider', () => { - return { createOpenRouter: jest.fn() }; -}); - -jest.mock('ai', () => { - return { generateText: jest.fn() }; -}); - -/** - * The markdown table is rendered by a package which ships as an ECMAScript - * module only, hence the service loads it with a dynamic import which Jest - * cannot run. The tests replace the method by a simple renderer, so that they - * can read the columns and the rows which the service gives to it. - */ -interface AiServiceWithMarkdownTable { - getMarkdownTable(parameters: { - columnDefinitions: readonly { name: string }[]; - rows: Record[]; - }): Promise; -} - -function createAccount({ - id = 'account-a-id', - isExcluded = false, - name = 'Account A' -}: { - id?: string; - isExcluded?: boolean; - name?: string; -} = {}) { - return { - id, - name, - activitiesCount: 3, - allocationInPercentage: 0.25, - balance: 1000, - currency: 'CHF', - platform: { name: 'Platform A' }, - tags: isExcluded ? [{ id: TAG_ID_EXCLUDE_FROM_ANALYSIS }] : [], - value: 2000 - } as unknown as AccountWithValue; -} - -function createAiService(accounts: AccountWithValue[]) { - const portfolioService = { - getAccountsWithAggregations: jest.fn().mockResolvedValue({ accounts }) - } as unknown as PortfolioService; - - const aiService = new AiService(null, null, null, portfolioService, null); - - jest - .spyOn( - aiService as unknown as AiServiceWithMarkdownTable, - 'getMarkdownTable' - ) - .mockImplementation(async ({ columnDefinitions, rows }) => { - const columnNames = columnDefinitions.map(({ name }) => { - return name; - }); - - return [ - columnNames.join(' | '), - ...rows.map((row) => { - return columnNames - .map((columnName) => { - return row[columnName]; - }) - .join(' | '); - }) - ].join('\n'); - }); - - return aiService; -} - -describe('AiService', () => { - // 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 - describe('getAccountsTableColumnNames', () => { - it('gives no column with a monetary value', () => { - expect(AiService.getAccountsTableColumnNames()).toEqual([ - 'Id', - 'Name', - 'Currency', - 'Platform', - 'Activities Count', - 'Allocation in Percentage', - 'Excluded from Analysis' - ]); - }); - }); - - describe('getActivitiesTableColumnNames', () => { - it('gives no column with a monetary value', () => { - expect(AiService.getActivitiesTableColumnNames()).toEqual([ - 'Date', - 'Type', - 'Name', - 'Symbol', - 'Currency', - 'Unit Price', - 'Account' - ]); - }); - }); - - describe('getAccountsTable', () => { - it('gives no cash balance and no value of an account', async () => { - const aiService = createAiService([createAccount()]); - - const result = await aiService.getAccountsTable({ userId: 'user-id' }); - - expect(result).not.toContain('Cash Balance'); - expect(result).not.toContain('1000'); - expect(result).not.toContain('2000'); - }); - - // The accountIds parameter of the tool takes the identifiers, hence the - // table has to give them - it('gives the identifier of an account', async () => { - const aiService = createAiService([createAccount()]); - - const result = await aiService.getAccountsTable({ userId: 'user-id' }); - - expect(result).toContain('account-a-id'); - }); - - it('marks an account which is excluded from the analysis', async () => { - const aiService = createAiService([ - createAccount({ isExcluded: true }), - createAccount({ id: 'account-b-id', name: 'Account B' }) - ]); - - const result = await aiService.getAccountsTable({ userId: 'user-id' }); - - const [rowOfAccountA, rowOfAccountB] = result - .split('\n') - .filter((line) => { - return line.startsWith('account-'); - }); - - expect(rowOfAccountA).toContain('true'); - expect(rowOfAccountB).toContain('false'); - }); - - it('tells that no accounts are found if the result is empty', async () => { - const aiService = createAiService([]); - - const result = await aiService.getAccountsTable({ userId: 'user-id' }); - - expect(result).toContain('No accounts found.'); - }); - }); -}); diff --git a/apps/api/src/app/endpoints/ai/ai.service.ts b/apps/api/src/app/endpoints/ai/ai.service.ts index 758499c6e..401bee7d0 100644 --- a/apps/api/src/app/endpoints/ai/ai.service.ts +++ b/apps/api/src/app/endpoints/ai/ai.service.ts @@ -1,122 +1,25 @@ -import { ActivitiesService } from '@ghostfolio/api/app/activities/activities.service'; -import { PortfolioService } from '@ghostfolio/api/app/portfolio/portfolio.service'; import { ConfigurationService } from '@ghostfolio/api/services/configuration/configuration.service'; -import { I18nService } from '@ghostfolio/api/services/i18n/i18n.service'; +import { PortfolioTableService } from '@ghostfolio/api/services/portfolio-table/portfolio-table.service'; import { PropertyService } from '@ghostfolio/api/services/property/property.service'; import { PROPERTY_API_KEY_OPENROUTER, PROPERTY_OPENROUTER_MODEL } from '@ghostfolio/common/config'; -import { DATE_FORMAT, isAccountExcluded } from '@ghostfolio/common/helper'; import { Filter } from '@ghostfolio/common/interfaces'; import type { AiPromptMode } from '@ghostfolio/common/types'; import { Injectable } from '@nestjs/common'; import { createOpenRouter } from '@openrouter/ai-sdk-provider'; -import { - AssetClass, - AssetSubClass, - Type as ActivityType -} from '@prisma/client'; import { generateText } from 'ai'; -import { format } from 'date-fns'; -import type { ColumnDescriptor } from 'tablemark'; @Injectable() export class AiService { - private static readonly ACCOUNTS_TABLE_COLUMN_DEFINITIONS: ({ - key: - | 'ACTIVITIES_COUNT' - | 'ALLOCATION_PERCENTAGE' - | 'CURRENCY' - | 'EXCLUDED_FROM_ANALYSIS' - | 'ID' - | 'NAME' - | 'PLATFORM'; - } & ColumnDescriptor)[] = [ - { key: 'ID', name: 'Id' }, - { key: 'NAME', name: 'Name' }, - { key: 'CURRENCY', name: 'Currency' }, - { key: 'PLATFORM', name: 'Platform' }, - { align: 'right', key: 'ACTIVITIES_COUNT', name: 'Activities Count' }, - { - align: 'right', - key: 'ALLOCATION_PERCENTAGE', - name: 'Allocation in Percentage' - }, - { key: 'EXCLUDED_FROM_ANALYSIS', name: 'Excluded from Analysis' } - ]; - - private static readonly ACTIVITIES_TABLE_COLUMN_DEFINITIONS: ({ - key: - | 'ACCOUNT' - | 'CURRENCY' - | 'DATE' - | 'NAME' - | 'SYMBOL' - | 'TYPE' - | 'UNIT_PRICE'; - } & ColumnDescriptor)[] = [ - { key: 'DATE', name: 'Date' }, - { key: 'TYPE', name: 'Type' }, - { key: 'NAME', name: 'Name' }, - { key: 'SYMBOL', name: 'Symbol' }, - { key: 'CURRENCY', name: 'Currency' }, - { align: 'right', key: 'UNIT_PRICE', name: 'Unit Price' }, - { key: 'ACCOUNT', name: 'Account' } - ]; - - private static readonly HOLDINGS_TABLE_COLUMN_DEFINITIONS: ({ - key: - | 'ACTIVITIES_COUNT' - | 'ALLOCATION_PERCENTAGE' - | 'ASSET_CLASS' - | 'ASSET_SUB_CLASS' - | 'CURRENCY' - | 'DATE_OF_FIRST_ACTIVITY' - | 'NAME' - | 'SYMBOL'; - } & ColumnDescriptor)[] = [ - { key: 'NAME', name: 'Name' }, - { key: 'SYMBOL', name: 'Symbol' }, - { key: 'CURRENCY', name: 'Currency' }, - { key: 'ASSET_CLASS', name: 'Asset Class' }, - { key: 'ASSET_SUB_CLASS', name: 'Asset Sub Class' }, - { key: 'DATE_OF_FIRST_ACTIVITY', name: 'Date of First Activity' }, - { align: 'right', key: 'ACTIVITIES_COUNT', name: 'Activities Count' }, - { - align: 'right', - key: 'ALLOCATION_PERCENTAGE', - name: 'Allocation in Percentage' - } - ]; - public constructor( - private readonly activitiesService: ActivitiesService, private readonly configurationService: ConfigurationService, - private readonly i18nService: I18nService, - private readonly portfolioService: PortfolioService, + private readonly portfolioTableService: PortfolioTableService, private readonly propertyService: PropertyService ) {} - public static getAccountsTableColumnNames() { - return AiService.ACCOUNTS_TABLE_COLUMN_DEFINITIONS.map(({ name }) => { - return name; - }); - } - - public static getActivitiesTableColumnNames() { - return AiService.ACTIVITIES_TABLE_COLUMN_DEFINITIONS.map(({ name }) => { - return name; - }); - } - - public static getHoldingsTableColumnNames() { - return AiService.HOLDINGS_TABLE_COLUMN_DEFINITIONS.map(({ name }) => { - return name; - }); - } - public async generateText({ prompt, requestTimeout = this.configurationService.get('REQUEST_TIMEOUT') @@ -143,191 +46,6 @@ export class AiService { }); } - public async getAccountsTable({ - filters, - userId - }: { - filters?: Filter[]; - userId: string; - }) { - const { accounts } = - await this.portfolioService.getAccountsWithAggregations({ - filters, - userId, - withExcludedAccounts: true - }); - - const accountsTableRows = accounts.map( - ({ - activitiesCount, - allocationInPercentage, - currency, - id, - name: label, - platform, - tags - }) => { - return AiService.ACCOUNTS_TABLE_COLUMN_DEFINITIONS.reduce( - (row, { key, name }) => { - switch (key) { - case 'ACTIVITIES_COUNT': - row[name] = activitiesCount.toString(); - break; - - case 'ALLOCATION_PERCENTAGE': - row[name] = `${(allocationInPercentage * 100).toFixed(3)}%`; - break; - - case 'CURRENCY': - row[name] = currency ?? ''; - break; - - case 'EXCLUDED_FROM_ANALYSIS': - row[name] = isAccountExcluded({ tags }).toString(); - break; - - case 'ID': - row[name] = id; - break; - - case 'NAME': - row[name] = label ?? ''; - break; - - case 'PLATFORM': - row[name] = platform?.name ?? ''; - break; - - default: - row[name] = ''; - break; - } - - return row; - }, - {} as Record - ); - } - ); - - const accountsSection = ['## Accounts', '']; - - if (accountsTableRows.length > 0) { - accountsSection.push( - await this.getMarkdownTable({ - columnDefinitions: AiService.ACCOUNTS_TABLE_COLUMN_DEFINITIONS, - rows: accountsTableRows - }) - ); - } else { - accountsSection.push('No accounts found.'); - } - - return accountsSection.join('\n'); - } - - public async getActivitiesTable({ - endDate, - filters, - skip = 0, - startDate, - take, - types, - userCurrency, - userId - }: { - endDate?: Date; - filters?: Filter[]; - skip?: number; - startDate?: Date; - take: number; - types?: ActivityType[]; - userCurrency: string; - userId: string; - }) { - const { activities, count } = await this.activitiesService.getActivities({ - endDate, - filters, - skip, - startDate, - take, - types, - userCurrency, - userId, - includeDrafts: true, - sortColumn: 'date', - sortDirection: 'desc', - withExcludedAccountsAndActivities: true - }); - - const activitiesTableRows = activities.map( - ({ account, assetProfile, currency, date, type, unitPrice }) => { - return AiService.ACTIVITIES_TABLE_COLUMN_DEFINITIONS.reduce( - (row, { key, name }) => { - switch (key) { - case 'ACCOUNT': - row[name] = account?.name ?? ''; - break; - - case 'CURRENCY': - row[name] = currency ?? assetProfile.currency; - break; - - case 'DATE': - row[name] = format(date, DATE_FORMAT); - break; - - case 'NAME': - row[name] = assetProfile.name ?? ''; - break; - - case 'SYMBOL': - row[name] = assetProfile.symbol; - break; - - case 'TYPE': - row[name] = type; - break; - - case 'UNIT_PRICE': - row[name] = unitPrice.toString(); - break; - - default: - row[name] = ''; - break; - } - - return row; - }, - {} as Record - ); - } - ); - - const activitiesSection = [ - '## Activities', - '', - this.getActivitiesSummary({ - count, - skip, - numberOfActivities: activities.length - }) - ]; - - if (activitiesTableRows.length > 0) { - activitiesSection.push( - '', - await this.getMarkdownTable({ - columnDefinitions: AiService.ACTIVITIES_TABLE_COLUMN_DEFINITIONS, - rows: activitiesTableRows - }) - ); - } - - return activitiesSection.join('\n'); - } - public async getPrompt({ filters, languageCode, @@ -341,98 +59,12 @@ export class AiService { userCurrency: string; userId: string; }) { - const { holdings } = await this.portfolioService.getDetails({ + const holdingsSection = await this.portfolioTableService.getHoldingsTable({ filters, - userId - }); - - const assetClassTranslations = this.getEnumTranslations({ languageCode, - id: 'assetClass', - values: Object.values(AssetClass) - }); - - const assetSubClassTranslations = this.getEnumTranslations({ - languageCode, - id: 'assetSubClass', - values: Object.values(AssetSubClass) + userId }); - const holdingsTableRows = [...holdings] - .sort((a, b) => { - return b.allocationInPercentage - a.allocationInPercentage; - }) - .map( - ({ - activitiesCount, - allocationInPercentage, - assetProfile: { - assetClass, - assetSubClass, - currency, - name: label, - symbol - }, - dateOfFirstActivity - }) => { - return AiService.HOLDINGS_TABLE_COLUMN_DEFINITIONS.reduce( - (row, { key, name }) => { - switch (key) { - case 'ACTIVITIES_COUNT': - row[name] = activitiesCount.toString(); - break; - - case 'ALLOCATION_PERCENTAGE': - row[name] = `${(allocationInPercentage * 100).toFixed(3)}%`; - break; - - case 'ASSET_CLASS': - row[name] = assetClassTranslations[assetClass] ?? ''; - break; - - case 'ASSET_SUB_CLASS': - row[name] = assetSubClassTranslations[assetSubClass] ?? ''; - break; - - case 'CURRENCY': - row[name] = currency; - break; - - case 'DATE_OF_FIRST_ACTIVITY': - row[name] = dateOfFirstActivity - ? format(dateOfFirstActivity, DATE_FORMAT) - : ''; - break; - - case 'NAME': - row[name] = label; - break; - - case 'SYMBOL': - row[name] = symbol; - break; - - default: - row[name] = ''; - break; - } - - return row; - }, - {} as Record - ); - } - ); - - const holdingsSection = [ - '## Holdings', - '', - await this.getMarkdownTable({ - columnDefinitions: AiService.HOLDINGS_TABLE_COLUMN_DEFINITIONS, - rows: holdingsTableRows - }) - ].join('\n'); - if (mode === 'portfolio') { return holdingsSection; } @@ -451,82 +83,4 @@ export class AiService { `Provide your answer in the following language: ${languageCode}.` ].join('\n'); } - - private getActivitiesSummary({ - count, - numberOfActivities, - skip - }: { - count: number; - numberOfActivities: number; - skip: number; - }) { - if (count === 0) { - return 'No activities found.'; - } - - if (numberOfActivities === 0) { - return `No activities beyond the ${count} which match the parameters, hence lower the skip parameter.`; - } - - if (numberOfActivities === count) { - return `Showing all ${count} activities, the most recent first.`; - } - - const lastActivity = skip + numberOfActivities; - - const summary = `Showing the activities ${ - skip + 1 - } to ${lastActivity} of ${count}, the most recent first.`; - - if (lastActivity === count) { - return summary; - } - - return `${summary} Get the further activities by raising the skip parameter or narrow the result with the other parameters.`; - } - - private getEnumTranslations({ - id, - languageCode, - values - }: { - id: string; - languageCode: string; - values: T[]; - }) { - return values.reduce( - (translations, value) => { - translations[value] = - this.i18nService.getTranslation({ - languageCode, - id: `${id}.${value}` - }) || value; - - return translations; - }, - {} as Record - ); - } - - private async getMarkdownTable({ - columnDefinitions, - rows - }: { - columnDefinitions: readonly ColumnDescriptor[]; - rows: Record[]; - }) { - // Dynamic import to load ESM module from CommonJS context - // eslint-disable-next-line @typescript-eslint/no-implied-eval - const dynamicImport = new Function('s', 'return import(s)') as ( - s: string - ) => Promise; - const { tablemark } = await dynamicImport('tablemark'); - - return tablemark(rows, { - columns: columnDefinitions.map(({ align, name }) => { - return { name, align: align ?? 'left' }; - }) - }); - } } 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 2c84df6d0..741e2a1ae 100644 --- a/apps/api/src/app/endpoints/mcp/mcp.controller.spec.ts +++ b/apps/api/src/app/endpoints/mcp/mcp.controller.spec.ts @@ -1,42 +1,15 @@ -import { ImportValidationError } from '@ghostfolio/api/app/import/errors/import-validation.error'; -import { ImportService } from '@ghostfolio/api/app/import/import.service'; -import { UserService } from '@ghostfolio/api/app/user/user.service'; import { REQUIRES_SCOPE_KEY } from '@ghostfolio/api/decorators/requires-scope.decorator'; import { McpToolExceptionFilter } from '@ghostfolio/api/filters/mcp-tool-exception.filter'; import { AccessGuard } from '@ghostfolio/api/guards/access.guard'; -import { ConfigurationService } from '@ghostfolio/api/services/configuration/configuration.service'; -import { MCP_MAX_ACTIVITIES } from '@ghostfolio/common/config'; -import { Activity } from '@ghostfolio/common/interfaces'; -import { permissions } from '@ghostfolio/common/permissions'; import { Scope, scopes } from '@ghostfolio/common/scopes'; -import type { - ImpersonationContext, - UserWithSettings -} from '@ghostfolio/common/types'; -import { HttpException } from '@nestjs/common'; import { EXCEPTION_FILTERS_METADATA, GUARDS_METADATA } from '@nestjs/common/constants'; -import { DataSource, Type as ActivityType } from '@prisma/client'; import { MCP_TOOL_METADATA_KEY, ToolMetadata } from '@rekog/mcp-nest'; import { GhostfolioMcpController } from './mcp.controller'; -import { IMPORT_ACTIVITIES_PARAMETERS } from './mcp.schemas'; -import { McpService } from './mcp.service'; - -// The controller reads the columns of the tables from the AiService, which -// imports two packages which ship as an ECMAScript module only, which Jest -// cannot transform. The mocks only make the imports resolvable, because no -// test calls them. -jest.mock('@openrouter/ai-sdk-provider', () => { - return { createOpenRouter: jest.fn() }; -}); - -jest.mock('ai', () => { - return { generateText: jest.fn() }; -}); /** * Gives the metadata which a decorator sets on the method of a tool. The @@ -62,250 +35,53 @@ function getToolMethodNames() { ); } -function createActivity(overrides: Record = {}) { - return { - currency: 'USD', - date: '2024-01-01', - fee: 0, - quantity: 1, - symbol: 'AAPL', - type: ActivityType.BUY, - unitPrice: 100, - ...overrides - }; -} - describe('GhostfolioMcpController', () => { - const impersonation = { userId: 'user-id' } as ImpersonationContext; - - let configuration: Record; - let configurationService: ConfigurationService; - let controller: GhostfolioMcpController; - let importService: ImportService; - let userService: UserService; - - function setupUser(userPermissions: string[]) { - jest.spyOn(userService, 'user').mockResolvedValue({ - permissions: userPermissions - } as UserWithSettings); - } - - beforeEach(() => { - configuration = { - DATA_SOURCES_GHOSTFOLIO_DATA_PROVIDER: [], - ENABLE_FEATURE_SUBSCRIPTION: false - }; - - configurationService = { - get: jest.fn().mockImplementation((key: string) => { - return configuration[key]; - }) - } as unknown as ConfigurationService; - - importService = { - import: jest.fn().mockResolvedValue([]) - } as unknown as ImportService; - - userService = { user: jest.fn() } as unknown as UserService; - - controller = new GhostfolioMcpController( - undefined, - undefined, - configurationService, - importService, - new McpService(), - userService - ); - }); - - afterEach(() => { - jest.restoreAllMocks(); - }); - - describe('Tools', () => { - // A tool without the decorator of the scope would be open to every access, - // hence a new tool has to declare its scope - it('Requires a scope of access for each tool', () => { - const toolMethodNames = getToolMethodNames(); + // A tool without the decorator of the scope would be open to every access, + // hence a new tool has to declare its scope + it('Requires a scope of access for each tool', () => { + const toolMethodNames = getToolMethodNames(); - expect(toolMethodNames.length).toBeGreaterThan(0); + expect(toolMethodNames.length).toBeGreaterThan(0); - const toolMethodNamesWithoutScope = toolMethodNames.filter( - (methodName) => { - return !getMetadataOfMethod(REQUIRES_SCOPE_KEY, methodName) - ?.length; - } - ); - - expect(toolMethodNamesWithoutScope).toEqual([]); + const toolMethodNamesWithoutScope = toolMethodNames.filter((methodName) => { + return !getMetadataOfMethod(REQUIRES_SCOPE_KEY, methodName) + ?.length; }); - // The decorator RequiresScope sets the same metadata as the decorator - // RequiresScopeOfAccess, but applies AuthGuard('jwt'), which a request of - // an access cannot pass, hence the guards tell the two decorators apart - it('Applies the guard of the access to each tool', () => { - const toolMethodNames = getToolMethodNames(); - - expect(toolMethodNames.length).toBeGreaterThan(0); - - const toolMethodNamesWithoutGuardOfAccess = toolMethodNames.filter( - (methodName) => { - return !getMetadataOfMethod( - GUARDS_METADATA, - methodName - )?.includes(AccessGuard); - } - ); - - expect(toolMethodNamesWithoutGuardOfAccess).toEqual([]); - }); - - // The tools have no try and catch, hence the filter is the only guarantee - // that an unexpected exception does not expose internals - it('Applies the filter of the exceptions of the tools', () => { - expect( - Reflect.getMetadata(EXCEPTION_FILTERS_METADATA, GhostfolioMcpController) - ).toEqual([McpToolExceptionFilter]); - }); + expect(toolMethodNamesWithoutScope).toEqual([]); }); - describe('Import activities', () => { - it('Requires the scope to create an activity', () => { - expect( - getMetadataOfMethod(REQUIRES_SCOPE_KEY, 'importActivities') - ).toEqual([scopes.activityCreate]); - }); - - it('Refuses a user without the permission to create an activity', async () => { - setupUser([]); - - await expect( - controller.importActivities(impersonation, { - activities: [createActivity()] - }) - ).rejects.toThrow(HttpException); - - expect(importService.import).not.toHaveBeenCalled(); - }); - - it('Gives the number of the imported and of the skipped activities', async () => { - setupUser([permissions.createActivity]); - - jest - .spyOn(importService, 'import') - .mockResolvedValue([{ id: 'activity-id' } as Activity]); - - expect( - await controller.importActivities(impersonation, { - activities: [createActivity(), createActivity({ quantity: 2 })] - }) - ).toEqual({ - content: [ - { - text: 'Imported activities: 1\nSkipped duplicate activities: 1', - type: 'text' - } - ] - }); - }); - - it('Resolves the mask of the data source of the Ghostfolio data provider', async () => { - setupUser([permissions.createActivity]); - - configuration.DATA_SOURCES_GHOSTFOLIO_DATA_PROVIDER = [DataSource.YAHOO]; - configuration.ENABLE_FEATURE_SUBSCRIPTION = true; - - await controller.importActivities(impersonation, { - activities: [createActivity({ dataSource: DataSource.GHOSTFOLIO })] - }); - - expect(importService.import).toHaveBeenCalledWith( - expect.objectContaining({ - activitiesDto: [ - expect.objectContaining({ dataSource: DataSource.YAHOO }) - ] - }) - ); - }); - - it('Keeps the data source if the subscription is not enabled', async () => { - setupUser([permissions.createActivity]); - - configuration.DATA_SOURCES_GHOSTFOLIO_DATA_PROVIDER = [DataSource.YAHOO]; - configuration.ENABLE_FEATURE_SUBSCRIPTION = false; - - await controller.importActivities(impersonation, { - activities: [createActivity({ dataSource: DataSource.GHOSTFOLIO })] - }); - - expect(importService.import).toHaveBeenCalledWith( - expect.objectContaining({ - activitiesDto: [ - expect.objectContaining({ dataSource: DataSource.GHOSTFOLIO }) - ] - }) - ); - }); - - // The McpToolExceptionFilter maps the error, hence the tool passes it on - it('Passes on an error of the import', async () => { - setupUser([permissions.createActivity]); - - const error = new ImportValidationError( - 'activities.0.symbol ("X") is not valid' - ); - - jest.spyOn(importService, 'import').mockRejectedValue(error); + // The decorator RequiresScope sets the same metadata as the decorator + // RequiresScopeOfAccess, but applies AuthGuard('jwt'), which a request of + // an access cannot pass, hence the guards tell the two decorators apart + it('Applies the guard of the access to each tool', () => { + const toolMethodNames = getToolMethodNames(); + + expect(toolMethodNames.length).toBeGreaterThan(0); + + const toolMethodNamesWithoutGuardOfAccess = toolMethodNames.filter( + (methodName) => { + return !getMetadataOfMethod( + GUARDS_METADATA, + methodName + )?.includes(AccessGuard); + } + ); - await expect( - controller.importActivities(impersonation, { - activities: [createActivity()] - }) - ).rejects.toBe(error); - }); + expect(toolMethodNamesWithoutGuardOfAccess).toEqual([]); }); - describe('Parameters of the tool to import activities', () => { - function parse(activities: unknown[]) { - return IMPORT_ACTIVITIES_PARAMETERS.safeParse({ activities }).success; - } - - it('Refuses a currency in lower case', () => { - expect(parse([createActivity({ currency: 'usd' })])).toBe(false); - }); - - it('Accepts a currency in upper case', () => { - expect(parse([createActivity({ currency: 'USD' })])).toBe(true); - }); - - it('Refuses a date at or before the epoch', () => { - expect(parse([createActivity({ date: '0000-01-01' })])).toBe(false); - }); - - it('Refuses an empty symbol', () => { - expect(parse([createActivity({ symbol: '' })])).toBe(false); - }); - - it('Refuses an empty identifier of an account', () => { - expect(parse([createActivity({ accountId: '' })])).toBe(false); - }); - - it('Removes a tag, because the tool takes no tag', () => { - expect( - IMPORT_ACTIVITIES_PARAMETERS.parse({ - activities: [createActivity({ tags: ['tag-id'] })] - }).activities[0] - ).not.toHaveProperty('tags'); - }); + it('Requires the scope to create an activity for the tool to import activities', () => { + expect( + getMetadataOfMethod(REQUIRES_SCOPE_KEY, 'importActivities') + ).toEqual([scopes.activityCreate]); + }); - it(`Refuses more than ${MCP_MAX_ACTIVITIES} activities`, () => { - expect( - parse( - Array.from({ length: MCP_MAX_ACTIVITIES + 1 }, () => { - return createActivity(); - }) - ) - ).toBe(false); - }); + // The tools have no try and catch, hence the filter is the only guarantee + // that an unexpected exception does not expose internals + it('Applies the filter of the exceptions of the tools', () => { + expect( + Reflect.getMetadata(EXCEPTION_FILTERS_METADATA, GhostfolioMcpController) + ).toEqual([McpToolExceptionFilter]); }); }); diff --git a/apps/api/src/app/endpoints/mcp/mcp.controller.ts b/apps/api/src/app/endpoints/mcp/mcp.controller.ts index 420acab3e..150347a9b 100644 --- a/apps/api/src/app/endpoints/mcp/mcp.controller.ts +++ b/apps/api/src/app/endpoints/mcp/mcp.controller.ts @@ -1,25 +1,14 @@ -import { AiService } from '@ghostfolio/api/app/endpoints/ai/ai.service'; -import { ImportService } from '@ghostfolio/api/app/import/import.service'; -import { UserService } from '@ghostfolio/api/app/user/user.service'; import { Impersonation } from '@ghostfolio/api/decorators/impersonation.decorator'; import { RequiresScopeOfAccess } from '@ghostfolio/api/decorators/requires-scope-of-access.decorator'; import { McpToolExceptionFilter } from '@ghostfolio/api/filters/mcp-tool-exception.filter'; -import { getUnmaskedGhostfolioDataSource } from '@ghostfolio/api/helper/data-source.helper'; -import { ApiService } from '@ghostfolio/api/services/api/api.service'; -import { ConfigurationService } from '@ghostfolio/api/services/configuration/configuration.service'; -import { getIntervalFromDateRange } from '@ghostfolio/common/calculation-helper'; -import { - DEFAULT_LANGUAGE_CODE, - MCP_MAX_ACTIVITIES -} from '@ghostfolio/common/config'; -import { hasPermission, permissions } from '@ghostfolio/common/permissions'; +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 type { ImpersonationContext } from '@ghostfolio/common/types'; -import { HttpException, UseFilters } from '@nestjs/common'; +import { UseFilters } from '@nestjs/common'; import { Payload } from '@nestjs/microservices'; import { McpController, Tool } from '@rekog/mcp-nest'; -import { getReasonPhrase, StatusCodes } from 'http-status-codes'; import { z } from 'zod'; import { @@ -32,14 +21,7 @@ import { McpService } from './mcp.service'; @McpController() @UseFilters(McpToolExceptionFilter) export class GhostfolioMcpController { - public constructor( - private readonly aiService: AiService, - private readonly apiService: ApiService, - private readonly configurationService: ConfigurationService, - private readonly importService: ImportService, - private readonly mcpService: McpService, - private readonly userService: UserService - ) {} + public constructor(private readonly mcpService: McpService) {} @RequiresScopeOfAccess(scopes.accountRead) @Tool({ @@ -48,7 +30,7 @@ export class GhostfolioMcpController { readOnlyHint: true, title: 'Get accounts' }, - description: `Gives the accounts of the portfolio with these columns: ${AiService.getAccountsTableColumnNames().join( + 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.`, name: 'get-accounts', @@ -56,23 +38,9 @@ export class GhostfolioMcpController { }) public async getAccounts( @Impersonation() { userId }: ImpersonationContext, - @Payload() - { - accountIds, - assetClasses, - holding - }: z.infer + @Payload() parameters: z.infer ) { - const filters = this.apiService.buildFiltersFromQueryParams({ - filterByAccounts: accountIds, - filterByAssetClasses: assetClasses, - filterByDataSource: holding?.dataSource, - filterBySymbol: holding?.symbol - }); - - const table = await this.aiService.getAccountsTable({ filters, userId }); - - return this.mcpService.getTextResult(table); + return this.mcpService.getAccounts({ ...parameters, userId }); } @RequiresScopeOfAccess(scopes.activityRead) @@ -82,52 +50,21 @@ export class GhostfolioMcpController { readOnlyHint: true, title: 'Get activities' }, - description: `Gives the activities of the portfolio, the most recent first, with these columns: ${AiService.getActivitiesTableColumnNames().join( + 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.`, name: 'get-activities', parameters: GET_ACTIVITIES_PARAMETERS }) public async getActivities( - @Impersonation() - { userId, userSettings }: ImpersonationContext, - @Payload() - { - activityTypes, - assetClasses, - holding, - range, - skip, - take - }: z.infer + @Impersonation() { userId, userSettings }: ImpersonationContext, + @Payload() parameters: z.infer ) { - let endDate: Date | undefined; - let startDate: Date | undefined; - - if (range) { - ({ endDate, startDate } = getIntervalFromDateRange({ - dateRange: range - })); - } - - const filters = this.apiService.buildFiltersFromQueryParams({ - filterByAssetClasses: assetClasses, - filterByDataSource: holding?.dataSource, - filterBySymbol: holding?.symbol - }); - - const table = await this.aiService.getActivitiesTable({ - endDate, - filters, - skip, - startDate, - take, + return this.mcpService.getActivities({ + ...parameters, userId, - types: activityTypes, userCurrency: userSettings.baseCurrency }); - - return this.mcpService.getTextResult(table); } @RequiresScopeOfAccess(scopes.portfolioRead) @@ -137,22 +74,13 @@ export class GhostfolioMcpController { readOnlyHint: true, title: 'Get portfolio' }, - description: `Gives the holdings of the portfolio with these columns: ${AiService.getHoldingsTableColumnNames().join( + description: `Gives the holdings of the portfolio with these columns: ${PortfolioTableService.getHoldingsTableColumnNames().join( ', ' )}.`, name: 'get-portfolio' }) - public async getPortfolio( - @Impersonation() { userId, userSettings }: ImpersonationContext - ) { - const prompt = await this.aiService.getPrompt({ - userId, - languageCode: DEFAULT_LANGUAGE_CODE, - mode: 'portfolio', - userCurrency: userSettings.baseCurrency - }); - - return this.mcpService.getTextResult(prompt); + public async getPortfolio(@Impersonation() { userId }: ImpersonationContext) { + return this.mcpService.getPortfolio({ userId }); } /** @@ -175,51 +103,8 @@ export class GhostfolioMcpController { }) public async importActivities( @Impersonation() { userId }: ImpersonationContext, - @Payload() { activities }: z.infer + @Payload() parameters: z.infer ) { - const user = await this.userService.user({ id: userId }); - - if (!hasPermission(user?.permissions, permissions.createActivity)) { - throw new HttpException( - getReasonPhrase(StatusCodes.FORBIDDEN), - StatusCodes.FORBIDDEN - ); - } - - const ghostfolioDataSources = this.configurationService.get( - 'ENABLE_FEATURE_SUBSCRIPTION' - ) - ? this.configurationService.get('DATA_SOURCES_GHOSTFOLIO_DATA_PROVIDER') - : []; - - const activitiesDto = activities.map((activity) => { - return { - ...activity, - dataSource: getUnmaskedGhostfolioDataSource({ - ghostfolioDataSources, - dataSource: activity.dataSource - }) - }; - }); - - // The filter passes on the message of a CallerFacingError, which is - // written for the caller, and hides the message of every other error - const importedActivities = await this.importService.import({ - activitiesDto, - user, - accountsWithBalancesDto: [], - assetProfilesWithMarketDataDto: [], - platformsDto: [], - tagsDto: [] - }); - - const text = [ - `Imported activities: ${importedActivities.length}`, - `Skipped duplicate activities: ${ - activities.length - importedActivities.length - }` - ].join('\n'); - - return this.mcpService.getTextResult(text); + return this.mcpService.importActivities({ ...parameters, userId }); } } diff --git a/apps/api/src/app/endpoints/mcp/mcp.module.ts b/apps/api/src/app/endpoints/mcp/mcp.module.ts index 0c054c7fb..b782736c8 100644 --- a/apps/api/src/app/endpoints/mcp/mcp.module.ts +++ b/apps/api/src/app/endpoints/mcp/mcp.module.ts @@ -1,10 +1,10 @@ -import { AiModule } from '@ghostfolio/api/app/endpoints/ai/ai.module'; import { ImportModule } from '@ghostfolio/api/app/import/import.module'; import { UserModule } from '@ghostfolio/api/app/user/user.module'; import { environment } from '@ghostfolio/api/environments/environment'; 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 { MCP_ENDPOINT } from '@ghostfolio/common/config'; import { Module } from '@nestjs/common'; @@ -19,7 +19,13 @@ import { McpService } from './mcp.service'; @Module({ controllers: [GhostfolioMcpController], - imports: [AiModule, ApiModule, ConfigurationModule, ImportModule, UserModule], + imports: [ + ApiModule, + ConfigurationModule, + ImportModule, + PortfolioTableModule, + UserModule + ], providers: [ McpService, { diff --git a/apps/api/src/app/endpoints/mcp/mcp.schemas.spec.ts b/apps/api/src/app/endpoints/mcp/mcp.schemas.spec.ts new file mode 100644 index 000000000..2d38923b9 --- /dev/null +++ b/apps/api/src/app/endpoints/mcp/mcp.schemas.spec.ts @@ -0,0 +1,48 @@ +import { MCP_MAX_ACTIVITIES } from '@ghostfolio/common/config'; + +import { IMPORT_ACTIVITIES_PARAMETERS } from './mcp.schemas'; +import { createActivity } from './mcp.test-utils'; + +describe('IMPORT_ACTIVITIES_PARAMETERS', () => { + function parse(activities: unknown[]) { + return IMPORT_ACTIVITIES_PARAMETERS.safeParse({ activities }).success; + } + + it('Refuses a currency in lower case', () => { + expect(parse([createActivity({ currency: 'usd' })])).toBe(false); + }); + + it('Accepts a currency in upper case', () => { + expect(parse([createActivity({ currency: 'USD' })])).toBe(true); + }); + + it('Refuses a date at or before the epoch', () => { + expect(parse([createActivity({ date: '0000-01-01' })])).toBe(false); + }); + + it('Refuses an empty symbol', () => { + expect(parse([createActivity({ symbol: '' })])).toBe(false); + }); + + it('Refuses an empty identifier of an account', () => { + expect(parse([createActivity({ accountId: '' })])).toBe(false); + }); + + it('Removes a tag, because the tool takes no tag', () => { + expect( + IMPORT_ACTIVITIES_PARAMETERS.parse({ + activities: [{ ...createActivity(), tags: ['tag-id'] }] + }).activities[0] + ).not.toHaveProperty('tags'); + }); + + it(`Refuses more than ${MCP_MAX_ACTIVITIES} activities`, () => { + expect( + parse( + Array.from({ length: MCP_MAX_ACTIVITIES + 1 }, () => { + return createActivity(); + }) + ) + ).toBe(false); + }); +}); diff --git a/apps/api/src/app/endpoints/mcp/mcp.service.spec.ts b/apps/api/src/app/endpoints/mcp/mcp.service.spec.ts new file mode 100644 index 000000000..ac074c791 --- /dev/null +++ b/apps/api/src/app/endpoints/mcp/mcp.service.spec.ts @@ -0,0 +1,279 @@ +import { ImportValidationError } from '@ghostfolio/api/app/import/errors/import-validation.error'; +import { ImportService } from '@ghostfolio/api/app/import/import.service'; +import { UserService } from '@ghostfolio/api/app/user/user.service'; +import { ApiService } from '@ghostfolio/api/services/api/api.service'; +import { ConfigurationService } from '@ghostfolio/api/services/configuration/configuration.service'; +import { PortfolioTableService } from '@ghostfolio/api/services/portfolio-table/portfolio-table.service'; +import { + DEFAULT_LANGUAGE_CODE, + MCP_MAX_ACTIVITIES +} from '@ghostfolio/common/config'; +import { Activity, Filter } from '@ghostfolio/common/interfaces'; +import { permissions } from '@ghostfolio/common/permissions'; +import type { UserWithSettings } from '@ghostfolio/common/types'; + +import { HttpException } from '@nestjs/common'; +import { AssetClass, DataSource, Type as ActivityType } from '@prisma/client'; + +import { McpService } from './mcp.service'; +import { createActivity } from './mcp.test-utils'; + +describe('McpService', () => { + const filters: Filter[] = [{ id: 'account-id', type: 'ACCOUNT' }]; + const userCurrency = 'USD'; + const userId = 'user-id'; + + let apiService: ApiService; + let configuration: Record; + let configurationService: ConfigurationService; + let importService: ImportService; + let mcpService: McpService; + let portfolioTableService: PortfolioTableService; + let userService: UserService; + + function setupUser(userPermissions: string[]) { + jest.spyOn(userService, 'user').mockResolvedValue({ + permissions: userPermissions + } as UserWithSettings); + } + + beforeEach(() => { + configuration = { + DATA_SOURCES_GHOSTFOLIO_DATA_PROVIDER: [], + ENABLE_FEATURE_SUBSCRIPTION: false + }; + + configurationService = { + get: jest.fn().mockImplementation((key: string) => { + return configuration[key]; + }) + } as unknown as ConfigurationService; + + apiService = { + buildFiltersFromQueryParams: jest.fn().mockReturnValue(filters) + } as unknown as ApiService; + + importService = { + import: jest.fn().mockResolvedValue([]) + } as unknown as ImportService; + + portfolioTableService = { + getAccountsTable: jest.fn().mockResolvedValue('## Accounts'), + getActivitiesTable: jest.fn().mockResolvedValue('## Activities'), + getHoldingsTable: jest.fn().mockResolvedValue('## Holdings') + } as unknown as PortfolioTableService; + + userService = { user: jest.fn() } as unknown as UserService; + + mcpService = new McpService( + apiService, + configurationService, + importService, + portfolioTableService, + userService + ); + }); + + afterEach(() => { + jest.restoreAllMocks(); + }); + + describe('getAccounts', () => { + it('Maps the parameters of the tool to the filters', async () => { + await mcpService.getAccounts({ + userId, + accountIds: ['account-id'], + assetClasses: [AssetClass.EQUITY], + holding: { dataSource: DataSource.YAHOO, symbol: 'AAPL' } + }); + + expect(apiService.buildFiltersFromQueryParams).toHaveBeenCalledWith({ + filterByAccounts: ['account-id'], + filterByAssetClasses: [AssetClass.EQUITY], + filterByDataSource: DataSource.YAHOO, + filterBySymbol: 'AAPL' + }); + }); + + it('Gives the table of the accounts of the filters', async () => { + expect(await mcpService.getAccounts({ userId })).toEqual({ + content: [{ text: '## Accounts', type: 'text' }] + }); + + expect(portfolioTableService.getAccountsTable).toHaveBeenCalledWith({ + filters, + userId + }); + }); + }); + + describe('getActivities', () => { + function getActivities( + parameters: Partial[0]> = {} + ) { + return mcpService.getActivities({ + userCurrency, + userId, + take: MCP_MAX_ACTIVITIES, + ...parameters + }); + } + + it('Maps the parameters of the tool to the filters', async () => { + await getActivities({ + assetClasses: [AssetClass.EQUITY], + holding: { dataSource: DataSource.YAHOO, symbol: 'AAPL' } + }); + + expect(apiService.buildFiltersFromQueryParams).toHaveBeenCalledWith({ + filterByAssetClasses: [AssetClass.EQUITY], + filterByDataSource: DataSource.YAHOO, + filterBySymbol: 'AAPL' + }); + }); + + it('Changes the range into the start date and the end date', async () => { + await getActivities({ range: '2024' }); + + expect(portfolioTableService.getActivitiesTable).toHaveBeenCalledWith( + expect.objectContaining({ + endDate: new Date('2024-12-31T23:59:59.999Z'), + startDate: new Date('2023-12-31T23:59:59.999Z') + }) + ); + }); + + it('Gives no date if the range is absent', async () => { + await getActivities(); + + expect(portfolioTableService.getActivitiesTable).toHaveBeenCalledWith( + expect.objectContaining({ endDate: undefined, startDate: undefined }) + ); + }); + + it('Gives the table of the activities of the filters', async () => { + expect( + await getActivities({ activityTypes: [ActivityType.BUY], skip: 10 }) + ).toEqual({ content: [{ text: '## Activities', type: 'text' }] }); + + expect(portfolioTableService.getActivitiesTable).toHaveBeenCalledWith({ + filters, + userCurrency, + userId, + endDate: undefined, + skip: 10, + startDate: undefined, + take: MCP_MAX_ACTIVITIES, + types: [ActivityType.BUY] + }); + }); + }); + + describe('getPortfolio', () => { + it('Gives the table of the holdings in the default language', async () => { + expect(await mcpService.getPortfolio({ userId })).toEqual({ + content: [{ text: '## Holdings', type: 'text' }] + }); + + expect(portfolioTableService.getHoldingsTable).toHaveBeenCalledWith({ + userId, + languageCode: DEFAULT_LANGUAGE_CODE + }); + }); + }); + + describe('importActivities', () => { + it('Refuses a user without the permission to create an activity', async () => { + setupUser([]); + + await expect( + mcpService.importActivities({ + userId, + activities: [createActivity()] + }) + ).rejects.toThrow(HttpException); + + expect(importService.import).not.toHaveBeenCalled(); + }); + + it('Gives the number of the imported and of the skipped activities', async () => { + setupUser([permissions.createActivity]); + + jest + .spyOn(importService, 'import') + .mockResolvedValue([{ id: 'activity-id' } as Activity]); + + expect( + await mcpService.importActivities({ + userId, + activities: [createActivity(), createActivity({ quantity: 2 })] + }) + ).toEqual({ + content: [ + { + text: 'Imported activities: 1\nSkipped duplicate activities: 1', + type: 'text' + } + ] + }); + }); + + it('Resolves the mask of the data source of the Ghostfolio data provider', async () => { + setupUser([permissions.createActivity]); + + configuration.DATA_SOURCES_GHOSTFOLIO_DATA_PROVIDER = [DataSource.YAHOO]; + configuration.ENABLE_FEATURE_SUBSCRIPTION = true; + + await mcpService.importActivities({ + userId, + activities: [createActivity({ dataSource: DataSource.GHOSTFOLIO })] + }); + + expect(importService.import).toHaveBeenCalledWith( + expect.objectContaining({ + activitiesDto: [ + expect.objectContaining({ dataSource: DataSource.YAHOO }) + ] + }) + ); + }); + + it('Keeps the data source if the subscription is not enabled', async () => { + setupUser([permissions.createActivity]); + + configuration.DATA_SOURCES_GHOSTFOLIO_DATA_PROVIDER = [DataSource.YAHOO]; + configuration.ENABLE_FEATURE_SUBSCRIPTION = false; + + await mcpService.importActivities({ + userId, + activities: [createActivity({ dataSource: DataSource.GHOSTFOLIO })] + }); + + expect(importService.import).toHaveBeenCalledWith( + expect.objectContaining({ + activitiesDto: [ + expect.objectContaining({ dataSource: DataSource.GHOSTFOLIO }) + ] + }) + ); + }); + + // The McpToolExceptionFilter maps the error, hence the tool passes it on + it('Passes on an error of the import', async () => { + setupUser([permissions.createActivity]); + + const error = new ImportValidationError( + 'activities.0.symbol ("X") is not valid' + ); + + jest.spyOn(importService, 'import').mockRejectedValue(error); + + await expect( + mcpService.importActivities({ + userId, + activities: [createActivity()] + }) + ).rejects.toBe(error); + }); + }); +}); diff --git a/apps/api/src/app/endpoints/mcp/mcp.service.ts b/apps/api/src/app/endpoints/mcp/mcp.service.ts index 2da6003b4..3cd95aaa0 100644 --- a/apps/api/src/app/endpoints/mcp/mcp.service.ts +++ b/apps/api/src/app/endpoints/mcp/mcp.service.ts @@ -1,8 +1,176 @@ -import { Injectable } from '@nestjs/common'; +import { ImportService } from '@ghostfolio/api/app/import/import.service'; +import { UserService } from '@ghostfolio/api/app/user/user.service'; +import { getUnmaskedGhostfolioDataSource } from '@ghostfolio/api/helper/data-source.helper'; +import { ApiService } from '@ghostfolio/api/services/api/api.service'; +import { ConfigurationService } from '@ghostfolio/api/services/configuration/configuration.service'; +import { PortfolioTableService } from '@ghostfolio/api/services/portfolio-table/portfolio-table.service'; +import { getIntervalFromDateRange } from '@ghostfolio/common/calculation-helper'; +import { DEFAULT_LANGUAGE_CODE } from '@ghostfolio/common/config'; +import { hasPermission, permissions } from '@ghostfolio/common/permissions'; + +import { HttpException, Injectable } from '@nestjs/common'; +import { getReasonPhrase, StatusCodes } from 'http-status-codes'; +import { z } from 'zod'; + +import { + GET_ACCOUNTS_PARAMETERS, + GET_ACTIVITIES_PARAMETERS, + IMPORT_ACTIVITIES_PARAMETERS +} from './mcp.schemas'; @Injectable() export class McpService { - public getTextResult(text: string) { + public constructor( + private readonly apiService: ApiService, + private readonly configurationService: ConfigurationService, + private readonly importService: ImportService, + private readonly portfolioTableService: PortfolioTableService, + private readonly userService: UserService + ) {} + + public async getAccounts({ + accountIds, + assetClasses, + holding, + userId + }: z.infer & { userId: string }) { + const filters = this.apiService.buildFiltersFromQueryParams({ + filterByAccounts: accountIds, + filterByAssetClasses: assetClasses, + filterByDataSource: holding?.dataSource, + filterBySymbol: holding?.symbol + }); + + const table = await this.portfolioTableService.getAccountsTable({ + filters, + userId + }); + + return this.getTextResult(table); + } + + public async getActivities({ + activityTypes, + assetClasses, + holding, + range, + skip, + take, + userCurrency, + userId + }: z.infer & { + userCurrency: string; + userId: string; + }) { + let endDate: Date | undefined; + let startDate: Date | undefined; + + if (range) { + ({ endDate, startDate } = getIntervalFromDateRange({ + dateRange: range + })); + } + + const filters = this.apiService.buildFiltersFromQueryParams({ + filterByAssetClasses: assetClasses, + filterByDataSource: holding?.dataSource, + filterBySymbol: holding?.symbol + }); + + const table = await this.portfolioTableService.getActivitiesTable({ + endDate, + filters, + skip, + startDate, + take, + userCurrency, + userId, + types: activityTypes + }); + + return this.getTextResult(table); + } + + public async getPortfolio({ userId }: { userId: string }) { + const table = await this.portfolioTableService.getHoldingsTable({ + userId, + languageCode: DEFAULT_LANGUAGE_CODE + }); + + return this.getTextResult(table); + } + + public async importActivities({ + activities, + userId + }: z.infer & { userId: string }) { + const user = await this.getUserWithPermission({ + userId, + permission: permissions.createActivity + }); + + const ghostfolioDataSources = this.configurationService.get( + 'ENABLE_FEATURE_SUBSCRIPTION' + ) + ? this.configurationService.get('DATA_SOURCES_GHOSTFOLIO_DATA_PROVIDER') + : []; + + const activitiesDto = activities.map((activity) => { + return { + ...activity, + dataSource: getUnmaskedGhostfolioDataSource({ + ghostfolioDataSources, + dataSource: activity.dataSource + }) + }; + }); + + // The filter passes on the message of a CallerFacingError, which is + // written for the caller, and hides the message of every other error + const importedActivities = await this.importService.import({ + activitiesDto, + user, + accountsWithBalancesDto: [], + assetProfilesWithMarketDataDto: [], + platformsDto: [], + tagsDto: [] + }); + + const text = [ + `Imported activities: ${importedActivities.length}`, + `Skipped duplicate activities: ${ + activities.length - importedActivities.length + }` + ].join('\n'); + + return this.getTextResult(text); + } + + private getTextResult(text: string) { return { content: [{ text, type: 'text' as const }] }; } + + /** + * Gives the user of the access, if the role of the user has the permission. + * The scope of the access is evaluated separately by the ScopeGuard, hence a + * tool which changes data has to call this. + */ + private async getUserWithPermission({ + permission, + userId + }: { + permission: string; + userId: string; + }) { + const user = await this.userService.user({ id: userId }); + + if (!hasPermission(user?.permissions, permission)) { + throw new HttpException( + getReasonPhrase(StatusCodes.FORBIDDEN), + StatusCodes.FORBIDDEN + ); + } + + return user; + } } diff --git a/apps/api/src/app/endpoints/mcp/mcp.test-utils.ts b/apps/api/src/app/endpoints/mcp/mcp.test-utils.ts new file mode 100644 index 000000000..84895a64f --- /dev/null +++ b/apps/api/src/app/endpoints/mcp/mcp.test-utils.ts @@ -0,0 +1,18 @@ +import { Type as ActivityType } from '@prisma/client'; + +import { ActivityToImport } from './types/activity-to-import.type'; + +export function createActivity( + overrides: Partial = {} +): ActivityToImport { + return { + currency: 'USD', + date: '2024-01-01', + fee: 0, + quantity: 1, + symbol: 'AAPL', + type: ActivityType.BUY, + unitPrice: 100, + ...overrides + }; +} diff --git a/apps/api/src/app/endpoints/mcp/types/activity-to-import.type.ts b/apps/api/src/app/endpoints/mcp/types/activity-to-import.type.ts new file mode 100644 index 000000000..06375d200 --- /dev/null +++ b/apps/api/src/app/endpoints/mcp/types/activity-to-import.type.ts @@ -0,0 +1,7 @@ +import { z } from 'zod'; + +import { IMPORT_ACTIVITIES_PARAMETERS } from '../mcp.schemas'; + +export type ActivityToImport = z.infer< + typeof IMPORT_ACTIVITIES_PARAMETERS +>['activities'][number]; diff --git a/apps/api/src/helper/interfaces/table-column-definition.interface.ts b/apps/api/src/helper/interfaces/table-column-definition.interface.ts new file mode 100644 index 000000000..569616623 --- /dev/null +++ b/apps/api/src/helper/interfaces/table-column-definition.interface.ts @@ -0,0 +1,6 @@ +import type { ColumnDescriptor } from 'tablemark'; + +export interface TableColumnDefinition extends ColumnDescriptor { + getValue: (item: T, context: C) => string; + name: string; +} diff --git a/apps/api/src/helper/interfaces/table-parameters.interface.ts b/apps/api/src/helper/interfaces/table-parameters.interface.ts new file mode 100644 index 000000000..7ab9d7800 --- /dev/null +++ b/apps/api/src/helper/interfaces/table-parameters.interface.ts @@ -0,0 +1,7 @@ +import { TableColumnDefinition } from './table-column-definition.interface'; + +export interface TableParameters { + columnDefinitions: readonly TableColumnDefinition[]; + context?: C; + rows: readonly T[]; +} diff --git a/apps/api/src/helper/markdown-table.helper.spec.ts b/apps/api/src/helper/markdown-table.helper.spec.ts new file mode 100644 index 000000000..fa1161738 --- /dev/null +++ b/apps/api/src/helper/markdown-table.helper.spec.ts @@ -0,0 +1,77 @@ +import { TableColumnDefinition } from './interfaces/table-column-definition.interface'; +import { getTableInput } from './markdown-table.helper'; + +interface Holding { + name: string; + quantity: number; +} + +interface HoldingContext { + currency: string; +} + +describe('getTableInput', () => { + const holdings: Holding[] = [ + { name: 'Apple', quantity: 2 }, + { name: 'Microsoft', quantity: 30 } + ]; + + const columnDefinitions: TableColumnDefinition[] = [ + { + getValue: ({ name }) => { + return name; + }, + name: 'Name' + }, + { + align: 'right', + getValue: ({ quantity }) => { + return quantity.toString(); + }, + name: 'Quantity' + }, + { + getValue: (_, { currency }) => { + return currency; + }, + name: 'Currency' + } + ]; + + function getInput(rows: Holding[] = holdings) { + return getTableInput({ + columnDefinitions, + rows, + context: { currency: 'USD' } + }); + } + + it('Gives a column for each definition, in the sequence of the definitions', () => { + expect(getInput().columns).toEqual([ + { align: 'left', name: 'Name' }, + { align: 'right', name: 'Quantity' }, + { align: 'left', name: 'Currency' } + ]); + }); + + it('Gives a row for each row, with the name of the column as the key', () => { + expect(getInput().rows).toEqual([ + { Currency: 'USD', Name: 'Apple', Quantity: '2' }, + { Currency: 'USD', Name: 'Microsoft', Quantity: '30' } + ]); + }); + + it('Gives the keys of a row in the sequence of the columns', () => { + const [firstRow] = getInput().rows; + + expect(Object.keys(firstRow)).toEqual( + getInput().columns.map(({ name }) => { + return name; + }) + ); + }); + + it('Gives no row if there is no row', () => { + expect(getInput([]).rows).toEqual([]); + }); +}); diff --git a/apps/api/src/helper/markdown-table.helper.ts b/apps/api/src/helper/markdown-table.helper.ts new file mode 100644 index 000000000..3bc8a7a8c --- /dev/null +++ b/apps/api/src/helper/markdown-table.helper.ts @@ -0,0 +1,44 @@ +import { TableParameters } from './interfaces/table-parameters.interface'; + +/** + * Gives the columns and the rows in the form which the renderer takes, with + * one column per definition, which gives the title of the column and reads + * the value of a row + */ +export function getTableInput({ + columnDefinitions, + context, + rows +}: TableParameters) { + return { + columns: columnDefinitions.map(({ align, name }) => { + return { name, align: align ?? 'left' }; + }), + rows: rows.map((row) => { + return columnDefinitions.reduce( + (tableRow, { getValue, name }) => { + tableRow[name] = getValue(row, context); + + return tableRow; + }, + {} as Record + ); + }) + }; +} + +export async function getMarkdownTable( + parameters: TableParameters +) { + const { columns, rows } = getTableInput(parameters); + + // Dynamic import to load ESM module from CommonJS context + // eslint-disable-next-line @typescript-eslint/no-implied-eval + const dynamicImport = new Function('s', 'return import(s)') as ( + s: string + ) => Promise; + + const { tablemark } = await dynamicImport('tablemark'); + + return tablemark(rows, { columns }); +} diff --git a/apps/api/src/services/portfolio-table/interfaces/holdings-table-context.interface.ts b/apps/api/src/services/portfolio-table/interfaces/holdings-table-context.interface.ts new file mode 100644 index 000000000..4364b492e --- /dev/null +++ b/apps/api/src/services/portfolio-table/interfaces/holdings-table-context.interface.ts @@ -0,0 +1,6 @@ +import { AssetClass, AssetSubClass } from '@prisma/client'; + +export interface HoldingsTableContext { + assetClassTranslations: Record; + assetSubClassTranslations: Record; +} diff --git a/apps/api/src/services/portfolio-table/portfolio-table.module.ts b/apps/api/src/services/portfolio-table/portfolio-table.module.ts new file mode 100644 index 000000000..4411bf9c8 --- /dev/null +++ b/apps/api/src/services/portfolio-table/portfolio-table.module.ts @@ -0,0 +1,14 @@ +import { ActivitiesModule } from '@ghostfolio/api/app/activities/activities.module'; +import { PortfolioModule } from '@ghostfolio/api/app/portfolio/portfolio.module'; +import { I18nModule } from '@ghostfolio/api/services/i18n/i18n.module'; + +import { Module } from '@nestjs/common'; + +import { PortfolioTableService } from './portfolio-table.service'; + +@Module({ + exports: [PortfolioTableService], + imports: [ActivitiesModule, I18nModule, PortfolioModule], + providers: [PortfolioTableService] +}) +export class PortfolioTableModule {} 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 new file mode 100644 index 000000000..2d9287051 --- /dev/null +++ b/apps/api/src/services/portfolio-table/portfolio-table.service.spec.ts @@ -0,0 +1,272 @@ +import type { PortfolioService } from '@ghostfolio/api/app/portfolio/portfolio.service'; +import type { TableParameters } from '@ghostfolio/api/helper/interfaces/table-parameters.interface'; +import type { I18nService } from '@ghostfolio/api/services/i18n/i18n.service'; +import { + DEFAULT_LANGUAGE_CODE, + TAG_ID_EXCLUDE_FROM_ANALYSIS +} from '@ghostfolio/common/config'; +import { PortfolioPosition } from '@ghostfolio/common/interfaces'; +import { AccountWithValue } from '@ghostfolio/common/types'; + +import { AssetClass, AssetSubClass } from '@prisma/client'; + +import { PortfolioTableService } from './portfolio-table.service'; + +/** + * The markdown table is rendered by a package which ships as an ECMAScript + * module only, which Jest cannot run. The mock keeps the mapping of the + * columns and of the rows and writes them in the same shape as the renderer + */ +jest.mock('@ghostfolio/api/helper/markdown-table.helper', () => { + const { getTableInput } = jest.requireActual< + typeof import('@ghostfolio/api/helper/markdown-table.helper') + >('@ghostfolio/api/helper/markdown-table.helper'); + + return { + getTableInput, + getMarkdownTable: jest.fn( + (parameters: TableParameters) => { + const { columns, rows } = getTableInput(parameters); + + const names = columns.map(({ name }) => { + return name; + }); + + return Promise.resolve( + [ + names, + names.map(() => { + return '---'; + }), + ...rows.map((row) => { + return names.map((name) => { + return row[name]; + }); + }) + ] + .map((cells) => { + return `| ${cells.join(' | ')} |`; + }) + .join('\n') + ); + } + ) + }; +}); + +function createAccount({ + id = 'account-a-id', + isExcluded = false, + name = 'Account A' +}: { + id?: string; + isExcluded?: boolean; + name?: string; +} = {}) { + return { + id, + name, + activitiesCount: 3, + allocationInPercentage: 0.25, + balance: 1000, + currency: 'CHF', + platform: { name: 'Platform A' }, + tags: isExcluded ? [{ id: TAG_ID_EXCLUDE_FROM_ANALYSIS }] : [], + value: 2000 + } as unknown as AccountWithValue; +} + +function createHolding({ + allocationInPercentage = 0.75, + assetClass = AssetClass.EQUITY, + assetSubClass = AssetSubClass.STOCK, + symbol = 'AAPL' +}: { + allocationInPercentage?: number; + assetClass?: AssetClass; + assetSubClass?: AssetSubClass; + symbol?: string; +} = {}) { + return { + allocationInPercentage, + activitiesCount: 3, + assetProfile: { + assetClass, + assetSubClass, + symbol, + currency: 'CHF', + name: `Name of ${symbol}` + }, + dateOfFirstActivity: new Date('2024-01-01'), + grossPerformance: 100, + netPerformance: 90, + quantity: 5, + valueInBaseCurrency: 2000 + } as unknown as PortfolioPosition; +} + +function createPortfolioTableService({ + accounts = [], + holdings = [] +}: { + accounts?: AccountWithValue[]; + holdings?: PortfolioPosition[]; +} = {}) { + // The mock gives the identifier of the translation, so that a test can tell + // the translation of the asset class from that of the asset sub class + const i18nService = { + getTranslation: jest.fn(({ id }: { id: string }) => { + return `translation of ${id}`; + }) + } as unknown as I18nService; + + const portfolioService = { + getAccountsWithAggregations: jest.fn().mockResolvedValue({ accounts }), + getDetails: jest.fn().mockResolvedValue({ holdings }) + } as unknown as PortfolioService; + + return new PortfolioTableService(null, i18nService, portfolioService); +} + +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 + describe('getAccountsTableColumnNames', () => { + it('gives no column with a monetary value', () => { + expect(PortfolioTableService.getAccountsTableColumnNames()).toEqual([ + 'Id', + 'Name', + 'Currency', + 'Platform', + 'Activities Count', + 'Allocation in Percentage', + 'Excluded from Analysis' + ]); + }); + }); + + describe('getActivitiesTableColumnNames', () => { + it('gives no column with a monetary value', () => { + expect(PortfolioTableService.getActivitiesTableColumnNames()).toEqual([ + 'Date', + 'Type', + 'Name', + 'Symbol', + 'Currency', + 'Unit Price', + 'Account' + ]); + }); + }); + + describe('getHoldingsTableColumnNames', () => { + it('gives no column with a monetary value', () => { + expect(PortfolioTableService.getHoldingsTableColumnNames()).toEqual([ + 'Name', + 'Symbol', + 'Currency', + 'Asset Class', + 'Asset Sub Class', + 'Date of First Activity', + 'Activities Count', + 'Allocation in Percentage' + ]); + }); + }); + + describe('getAccountsTable', () => { + it('gives no cash balance and no value of an account', async () => { + const portfolioTableService = createPortfolioTableService({ + accounts: [createAccount()] + }); + + const result = await portfolioTableService.getAccountsTable({ + userId: 'user-id' + }); + + expect(result).not.toContain('Cash Balance'); + expect(result).not.toContain('1000'); + expect(result).not.toContain('2000'); + }); + + // The accountIds parameter of the tool takes the identifiers, hence the + // table has to give them + it('gives the identifier of an account', async () => { + const portfolioTableService = createPortfolioTableService({ + accounts: [createAccount()] + }); + + const result = await portfolioTableService.getAccountsTable({ + userId: 'user-id' + }); + + expect(result).toContain('account-a-id'); + }); + + it('marks an account which is excluded from the analysis', async () => { + const portfolioTableService = createPortfolioTableService({ + accounts: [ + createAccount({ isExcluded: true }), + createAccount({ id: 'account-b-id', name: 'Account B' }) + ] + }); + + const result = await portfolioTableService.getAccountsTable({ + userId: 'user-id' + }); + + const [rowOfAccountA, rowOfAccountB] = result + .split('\n') + .filter((line) => { + return line.startsWith('| account-'); + }); + + expect(rowOfAccountA).toContain('true'); + expect(rowOfAccountB).toContain('false'); + }); + + it('tells that no accounts are found if the result is empty', async () => { + const portfolioTableService = createPortfolioTableService(); + + const result = await portfolioTableService.getAccountsTable({ + userId: 'user-id' + }); + + expect(result).toContain('No accounts found.'); + }); + }); + describe('getHoldingsTable', () => { + function getHoldingsTable(holdings: PortfolioPosition[]) { + return createPortfolioTableService({ holdings }).getHoldingsTable({ + languageCode: DEFAULT_LANGUAGE_CODE, + userId: 'user-id' + }); + } + + it('gives the translation of the asset class and of the asset sub class', async () => { + const result = await getHoldingsTable([createHolding()]); + + const [row] = result.split('\n').filter((line) => { + return line.startsWith('| Name of AAPL'); + }); + + expect(row).toContain('translation of assetClass.EQUITY'); + expect(row).toContain('translation of assetSubClass.STOCK'); + }); + + it('gives the holding with the largest allocation first', async () => { + const result = await getHoldingsTable([ + createHolding({ allocationInPercentage: 0.25, symbol: 'MSFT' }), + createHolding({ allocationInPercentage: 0.75, symbol: 'AAPL' }) + ]); + + const [firstRow, secondRow] = result.split('\n').filter((line) => { + return line.startsWith('| Name of'); + }); + + expect(firstRow).toContain('AAPL'); + expect(secondRow).toContain('MSFT'); + }); + }); +}); diff --git a/apps/api/src/services/portfolio-table/portfolio-table.service.ts b/apps/api/src/services/portfolio-table/portfolio-table.service.ts new file mode 100644 index 000000000..f8cc3cfc6 --- /dev/null +++ b/apps/api/src/services/portfolio-table/portfolio-table.service.ts @@ -0,0 +1,399 @@ +import { ActivitiesService } from '@ghostfolio/api/app/activities/activities.service'; +import { PortfolioService } from '@ghostfolio/api/app/portfolio/portfolio.service'; +import { TableColumnDefinition } from '@ghostfolio/api/helper/interfaces/table-column-definition.interface'; +import { getMarkdownTable } from '@ghostfolio/api/helper/markdown-table.helper'; +import { I18nService } from '@ghostfolio/api/services/i18n/i18n.service'; +import { DATE_FORMAT, isAccountExcluded } from '@ghostfolio/common/helper'; +import { Activity, Filter } from '@ghostfolio/common/interfaces'; +import { AccountWithValue } from '@ghostfolio/common/types'; + +import { Injectable } from '@nestjs/common'; +import { + AssetClass, + AssetSubClass, + Type as ActivityType +} from '@prisma/client'; +import { format } from 'date-fns'; + +import { HoldingsTableColumnDefinition } from './types/holdings-table-column-definition.type'; + +function getAllocationInPercentage(allocationInPercentage: number) { + return `${(allocationInPercentage * 100).toFixed(3)}%`; +} + +/** + * Renders the accounts, the activities and the holdings of a portfolio as a + * markdown table. No table has a column with a quantity or with a monetary + * value, except the unit price of an activity. + */ +@Injectable() +export class PortfolioTableService { + private static readonly ACCOUNTS_TABLE_COLUMN_DEFINITIONS: TableColumnDefinition[] = + [ + { + getValue: ({ id }) => { + return id; + }, + name: 'Id' + }, + { + getValue: ({ name }) => { + return name ?? ''; + }, + name: 'Name' + }, + { + getValue: ({ currency }) => { + return currency ?? ''; + }, + name: 'Currency' + }, + { + getValue: ({ platform }) => { + return platform?.name ?? ''; + }, + name: 'Platform' + }, + { + align: 'right', + getValue: ({ activitiesCount }) => { + return activitiesCount.toString(); + }, + name: 'Activities Count' + }, + { + align: 'right', + getValue: ({ allocationInPercentage }) => { + return getAllocationInPercentage(allocationInPercentage); + }, + name: 'Allocation in Percentage' + }, + { + getValue: ({ tags }) => { + return isAccountExcluded({ tags }).toString(); + }, + name: 'Excluded from Analysis' + } + ]; + + private static readonly ACTIVITIES_TABLE_COLUMN_DEFINITIONS: TableColumnDefinition[] = + [ + { + getValue: ({ date }) => { + return format(date, DATE_FORMAT); + }, + name: 'Date' + }, + { + getValue: ({ type }) => { + return type; + }, + name: 'Type' + }, + { + getValue: ({ assetProfile }) => { + return assetProfile.name ?? ''; + }, + name: 'Name' + }, + { + getValue: ({ assetProfile }) => { + return assetProfile.symbol; + }, + name: 'Symbol' + }, + { + getValue: ({ assetProfile, currency }) => { + return currency ?? assetProfile.currency; + }, + name: 'Currency' + }, + { + align: 'right', + getValue: ({ unitPrice }) => { + return unitPrice.toString(); + }, + name: 'Unit Price' + }, + { + getValue: ({ account }) => { + return account?.name ?? ''; + }, + name: 'Account' + } + ]; + + private static readonly HOLDINGS_TABLE_COLUMN_DEFINITIONS: HoldingsTableColumnDefinition[] = + [ + { + getValue: ({ assetProfile }) => { + return assetProfile.name; + }, + name: 'Name' + }, + { + getValue: ({ assetProfile }) => { + return assetProfile.symbol; + }, + name: 'Symbol' + }, + { + getValue: ({ assetProfile }) => { + return assetProfile.currency; + }, + name: 'Currency' + }, + { + getValue: ({ assetProfile }, { assetClassTranslations }) => { + return assetClassTranslations[assetProfile.assetClass] ?? ''; + }, + name: 'Asset Class' + }, + { + getValue: ({ assetProfile }, { assetSubClassTranslations }) => { + return assetSubClassTranslations[assetProfile.assetSubClass] ?? ''; + }, + name: 'Asset Sub Class' + }, + { + getValue: ({ dateOfFirstActivity }) => { + return dateOfFirstActivity + ? format(dateOfFirstActivity, DATE_FORMAT) + : ''; + }, + name: 'Date of First Activity' + }, + { + align: 'right', + getValue: ({ activitiesCount }) => { + return activitiesCount.toString(); + }, + name: 'Activities Count' + }, + { + align: 'right', + getValue: ({ allocationInPercentage }) => { + return getAllocationInPercentage(allocationInPercentage); + }, + name: 'Allocation in Percentage' + } + ]; + + public constructor( + private readonly activitiesService: ActivitiesService, + private readonly i18nService: I18nService, + private readonly portfolioService: PortfolioService + ) {} + + public static getAccountsTableColumnNames() { + return PortfolioTableService.ACCOUNTS_TABLE_COLUMN_DEFINITIONS.map( + ({ name }) => { + return name; + } + ); + } + + public static getActivitiesTableColumnNames() { + return PortfolioTableService.ACTIVITIES_TABLE_COLUMN_DEFINITIONS.map( + ({ name }) => { + return name; + } + ); + } + + public static getHoldingsTableColumnNames() { + return PortfolioTableService.HOLDINGS_TABLE_COLUMN_DEFINITIONS.map( + ({ name }) => { + return name; + } + ); + } + + public async getAccountsTable({ + filters, + userId + }: { + filters?: Filter[]; + userId: string; + }) { + const { accounts } = + await this.portfolioService.getAccountsWithAggregations({ + filters, + userId, + withExcludedAccounts: true + }); + + const accountsSection = ['## Accounts', '']; + + if (accounts.length > 0) { + accountsSection.push( + await getMarkdownTable({ + columnDefinitions: + PortfolioTableService.ACCOUNTS_TABLE_COLUMN_DEFINITIONS, + rows: accounts + }) + ); + } else { + accountsSection.push('No accounts found.'); + } + + return accountsSection.join('\n'); + } + + public async getActivitiesTable({ + endDate, + filters, + skip = 0, + startDate, + take, + types, + userCurrency, + userId + }: { + endDate?: Date; + filters?: Filter[]; + skip?: number; + startDate?: Date; + take: number; + types?: ActivityType[]; + userCurrency: string; + userId: string; + }) { + const { activities, count } = await this.activitiesService.getActivities({ + endDate, + filters, + skip, + startDate, + take, + types, + userCurrency, + userId, + includeDrafts: true, + sortColumn: 'date', + sortDirection: 'desc', + withExcludedAccountsAndActivities: true + }); + + const activitiesSection = [ + '## Activities', + '', + this.getActivitiesSummary({ + count, + skip, + numberOfActivities: activities.length + }) + ]; + + if (activities.length > 0) { + activitiesSection.push( + '', + await getMarkdownTable({ + columnDefinitions: + PortfolioTableService.ACTIVITIES_TABLE_COLUMN_DEFINITIONS, + rows: activities + }) + ); + } + + return activitiesSection.join('\n'); + } + + public async getHoldingsTable({ + filters, + languageCode, + userId + }: { + filters?: Filter[]; + languageCode: string; + userId: string; + }) { + const { holdings } = await this.portfolioService.getDetails({ + filters, + userId + }); + + const assetClassTranslations = this.getEnumTranslations({ + languageCode, + id: 'assetClass', + values: Object.values(AssetClass) + }); + + const assetSubClassTranslations = this.getEnumTranslations({ + languageCode, + id: 'assetSubClass', + values: Object.values(AssetSubClass) + }); + + const sortedHoldings = [...holdings].sort((a, b) => { + return b.allocationInPercentage - a.allocationInPercentage; + }); + + return [ + '## Holdings', + '', + await getMarkdownTable({ + columnDefinitions: + PortfolioTableService.HOLDINGS_TABLE_COLUMN_DEFINITIONS, + context: { assetClassTranslations, assetSubClassTranslations }, + rows: sortedHoldings + }) + ].join('\n'); + } + + private getActivitiesSummary({ + count, + numberOfActivities, + skip + }: { + count: number; + numberOfActivities: number; + skip: number; + }) { + if (count === 0) { + return 'No activities found.'; + } + + if (numberOfActivities === 0) { + return `No activities beyond the ${count} which match the parameters, hence lower the skip parameter.`; + } + + if (numberOfActivities === count) { + return `Showing all ${count} activities, the most recent first.`; + } + + const lastActivity = skip + numberOfActivities; + + const summary = `Showing the activities ${ + skip + 1 + } to ${lastActivity} of ${count}, the most recent first.`; + + if (lastActivity === count) { + return summary; + } + + return `${summary} Get the further activities by raising the skip parameter or narrow the result with the other parameters.`; + } + + private getEnumTranslations({ + id, + languageCode, + values + }: { + id: string; + languageCode: string; + values: T[]; + }) { + return values.reduce( + (translations, value) => { + translations[value] = + this.i18nService.getTranslation({ + languageCode, + id: `${id}.${value}` + }) || value; + + return translations; + }, + {} as Record + ); + } +} diff --git a/apps/api/src/services/portfolio-table/types/holdings-table-column-definition.type.ts b/apps/api/src/services/portfolio-table/types/holdings-table-column-definition.type.ts new file mode 100644 index 000000000..a8ad63fe6 --- /dev/null +++ b/apps/api/src/services/portfolio-table/types/holdings-table-column-definition.type.ts @@ -0,0 +1,9 @@ +import { TableColumnDefinition } from '@ghostfolio/api/helper/interfaces/table-column-definition.interface'; +import { PortfolioPosition } from '@ghostfolio/common/interfaces'; + +import { HoldingsTableContext } from '../interfaces/holdings-table-context.interface'; + +export type HoldingsTableColumnDefinition = TableColumnDefinition< + PortfolioPosition, + HoldingsTableContext +>;