From 43d4ddf8aa5a3a632da1a62702dcd7024e888891 Mon Sep 17 00:00:00 2001 From: Thomas Kaul <4159106+dtslvr@users.noreply.github.com> Date: Thu, 1 Oct 2026 20:13:01 +0200 Subject: [PATCH] Add MCP tool to get performance --- .../src/app/endpoints/mcp/mcp.controller.ts | 2 +- .../portfolio-table.service.spec.ts | 52 ++++++++++++++---- .../portfolio-table.service.ts | 53 +++++++++++-------- 3 files changed, 75 insertions(+), 32 deletions(-) diff --git a/apps/api/src/app/endpoints/mcp/mcp.controller.ts b/apps/api/src/app/endpoints/mcp/mcp.controller.ts index 4c988d8cfe..742080498b 100644 --- a/apps/api/src/app/endpoints/mcp/mcp.controller.ts +++ b/apps/api/src/app/endpoints/mcp/mcp.controller.ts @@ -79,7 +79,7 @@ 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. The parameters limit the performance to the holdings of the accounts, of the asset classes or of the asset profile.`, + )}. 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 excludes the dividends. 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 }) 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 4fefc5022c..f4e64a9a08 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 @@ -7,6 +7,7 @@ import { TAG_ID_EXCLUDE_FROM_ANALYSIS } from '@ghostfolio/common/config'; import { + HistoricalDataItem, PortfolioPerformanceResponse, PortfolioPosition, WatchlistResponse @@ -157,12 +158,14 @@ function createWatchlistItem({ function createPortfolioTableService({ accounts = [], + chart = [{ date: '2024-01-01' }], errors = [], holdings = [], performance = createPerformance(), watchlist = [] }: { accounts?: AccountWithValue[]; + chart?: HistoricalDataItem[]; errors?: PortfolioPerformanceResponse['errors']; holdings?: PortfolioPosition[]; performance?: PortfolioPerformanceResponse['performance']; @@ -179,9 +182,12 @@ function createPortfolioTableService({ const portfolioService = { getAccountsWithAggregations: jest.fn().mockResolvedValue({ accounts }), getDetails: jest.fn().mockResolvedValue({ holdings }), - getPerformance: jest - .fn() - .mockResolvedValue({ errors, performance, hasErrors: errors.length > 0 }) + getPerformance: jest.fn().mockResolvedValue({ + chart, + errors, + performance, + hasErrors: errors.length > 0 + }) } as unknown as PortfolioService; const watchlistService = { @@ -382,23 +388,44 @@ describe('PortfolioTableService', () => { expect(row).toBe('| 10.000% | 5.000% | 15.000% |'); }); + it('gives a currency performance of zero without a sign', async () => { + const result = await getPerformanceTable({ + performance: createPerformance({ + netPerformancePercentage: 0.10000000000000003, + netPerformancePercentageWithCurrencyEffect: 0.1 + }) + }); + + const [row] = result.split('\n').filter((line) => { + return line.startsWith('| 10.000%'); + }); + + expect(row).toBe('| 10.000% | 0.000% | 10.000% |'); + }); + it('gives no monetary value', async () => { const result = await getPerformanceTable(); - for (const value of ['50', '200', '300', '1700', '2000', '3000']) { - expect(result).not.toContain(value); + const [, , row] = result.split('\n').filter((line) => { + return line.startsWith('|'); + }); + + for (const cell of row.split('|').slice(1, -1)) { + expect(cell.trim()).toMatch(/^-?\d+\.\d{3}%$/); } }); - it('tells the symbols of which the market data is delayed', async () => { + it('tells the asset profiles of which the market data is delayed', async () => { const result = await getPerformanceTable({ errors: [ - { dataSource: DataSource.YAHOO, symbol: 'AAPL' }, - { dataSource: DataSource.YAHOO, symbol: 'MSFT' } + { dataSource: DataSource.MANUAL, symbol: 'AAPL' }, + { dataSource: DataSource.YAHOO, symbol: 'AAPL' } ] }); - expect(result).toContain('Market data is delayed for: AAPL, MSFT'); + expect(result).toContain( + 'Market data is delayed for: AAPL (MANUAL), AAPL (YAHOO)' + ); }); it('tells no delay of the market data if there is no error', async () => { @@ -406,6 +433,13 @@ describe('PortfolioTableService', () => { expect(result).not.toContain('Market data is delayed'); }); + + it('tells that no performance is found if the chart is empty', async () => { + const result = await getPerformanceTable({ chart: [] }); + + expect(result).toContain('No performance found.'); + expect(result).not.toContain('%'); + }); }); describe('getWatchlistTable', () => { 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 55a5713b79..b9c87bbb55 100644 --- a/apps/api/src/services/portfolio-table/portfolio-table.service.ts +++ b/apps/api/src/services/portfolio-table/portfolio-table.service.ts @@ -201,10 +201,14 @@ export class PortfolioTableService { netPerformancePercentage, netPerformancePercentageWithCurrencyEffect }) => { - return getPercentage( + const currencyPerformancePercentage = getPercentage( netPerformancePercentageWithCurrencyEffect - netPerformancePercentage ); + + return Number.parseFloat(currencyPerformancePercentage) === 0 + ? getPercentage(0) + : currencyPerformancePercentage; }, name: 'Currency Performance in Percentage' }, @@ -454,31 +458,36 @@ export class PortfolioTableService { filters?: Filter[]; userId: string; }) { - const { errors, performance } = await this.portfolioService.getPerformance({ - dateRange, - filters, - userId - }); + const { chart, errors, performance } = + await this.portfolioService.getPerformance({ + dateRange, + filters, + userId + }); - const performanceSection = [ - '## Performance', - '', - await getMarkdownTable({ - columnDefinitions: - PortfolioTableService.PERFORMANCE_TABLE_COLUMN_DEFINITIONS, - rows: [performance] - }) - ]; + const performanceSection = ['## Performance', '']; - if (errors?.length > 0) { + if (chart?.length > 0) { performanceSection.push( - '', - `Market data is delayed for: ${errors - .map(({ symbol }) => { - return symbol; - }) - .join(', ')}` + await getMarkdownTable({ + columnDefinitions: + PortfolioTableService.PERFORMANCE_TABLE_COLUMN_DEFINITIONS, + rows: [performance] + }) ); + + if (errors?.length > 0) { + performanceSection.push( + '', + `Market data is delayed for: ${errors + .map(({ dataSource, symbol }) => { + return `${symbol} (${dataSource})`; + }) + .join(', ')}` + ); + } + } else { + performanceSection.push('No performance found.'); } return performanceSection.join('\n');