Browse Source

Feature/add MCP tool to get performance (#7994)

* Add MCP tool to get performance

* Update changelog
pull/8001/head
Thomas Kaul 7 days ago
committed by GitHub
parent
commit
f62cce7e8d
No known key found for this signature in database GPG Key ID: B5690EEEBB952194
  1. 4
      CHANGELOG.md
  2. 2
      apps/api/src/app/endpoints/mcp/mcp.controller.spec.ts
  3. 21
      apps/api/src/app/endpoints/mcp/mcp.controller.ts
  4. 20
      apps/api/src/app/endpoints/mcp/mcp.schemas.spec.ts
  5. 29
      apps/api/src/app/endpoints/mcp/mcp.schemas.ts
  6. 32
      apps/api/src/app/endpoints/mcp/mcp.service.spec.ts
  7. 24
      apps/api/src/app/endpoints/mcp/mcp.service.ts
  8. 94
      apps/api/src/services/portfolio-table/portfolio-table.service.spec.ts
  9. 85
      apps/api/src/services/portfolio-table/portfolio-table.service.ts

4
CHANGELOG.md

@ -7,6 +7,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
## Unreleased ## Unreleased
### Added
- Added a tool to get the performance to the server of the Model Context Protocol (MCP) (experimental)
### Changed ### Changed
- Upgraded `prettier` from version `3.9.6` to `3.9.9` - Upgraded `prettier` from version `3.9.6` to `3.9.9`

2
apps/api/src/app/endpoints/mcp/mcp.controller.spec.ts

@ -133,6 +133,7 @@ describe('GhostfolioMcpController', () => {
).toEqual([ ).toEqual([
'get-accounts', 'get-accounts',
'get-activities', 'get-activities',
'get-performance',
'get-portfolio', 'get-portfolio',
'get-watchlist' 'get-watchlist'
]); ]);
@ -154,6 +155,7 @@ describe('GhostfolioMcpController', () => {
).toEqual([ ).toEqual([
'get-accounts', 'get-accounts',
'get-activities', 'get-activities',
'get-performance',
'get-portfolio', 'get-portfolio',
'get-watchlist', 'get-watchlist',
'import-activities', 'import-activities',

21
apps/api/src/app/endpoints/mcp/mcp.controller.ts

@ -15,6 +15,7 @@ import 'zod/compile';
import { import {
GET_ACCOUNTS_PARAMETERS, GET_ACCOUNTS_PARAMETERS,
GET_ACTIVITIES_PARAMETERS, GET_ACTIVITIES_PARAMETERS,
GET_PERFORMANCE_PARAMETERS,
IMPORT_ACTIVITIES_PARAMETERS, IMPORT_ACTIVITIES_PARAMETERS,
SEARCH_ASSET_PROFILES_PARAMETERS SEARCH_ASSET_PROFILES_PARAMETERS
} from './mcp.schemas'; } from './mcp.schemas';
@ -69,6 +70,26 @@ export class GhostfolioMcpController {
}); });
} }
@RequiresScopeOfAccess(scopes.portfolioRead)
@Tool({
annotations: {
openWorldHint: false,
readOnlyHint: true,
title: 'Get performance'
},
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 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
})
public async getPerformance(
@Impersonation() { userId }: ImpersonationContext,
@Payload() parameters: z.infer<typeof GET_PERFORMANCE_PARAMETERS>
) {
return this.mcpService.getPerformance({ ...parameters, userId });
}
@RequiresScopeOfAccess(scopes.portfolioRead) @RequiresScopeOfAccess(scopes.portfolioRead)
@Tool({ @Tool({
annotations: { annotations: {

20
apps/api/src/app/endpoints/mcp/mcp.schemas.spec.ts

@ -1,4 +1,5 @@
import { import {
DEFAULT_DATE_RANGE,
MCP_MAX_ACTIVITIES, MCP_MAX_ACTIVITIES,
SEARCH_QUERY_MAXIMUM_LENGTH, SEARCH_QUERY_MAXIMUM_LENGTH,
SEARCH_QUERY_MINIMUM_LENGTH SEARCH_QUERY_MINIMUM_LENGTH
@ -9,6 +10,7 @@ import { DataSource } from '@prisma/client';
import { import {
GET_ACCOUNTS_PARAMETERS, GET_ACCOUNTS_PARAMETERS,
GET_ACTIVITIES_PARAMETERS, GET_ACTIVITIES_PARAMETERS,
GET_PERFORMANCE_PARAMETERS,
IMPORT_ACTIVITIES_PARAMETERS, IMPORT_ACTIVITIES_PARAMETERS,
SEARCH_ASSET_PROFILES_PARAMETERS SEARCH_ASSET_PROFILES_PARAMETERS
} from './mcp.schemas'; } from './mcp.schemas';
@ -50,6 +52,24 @@ describe('GET_ACTIVITIES_PARAMETERS', () => {
}); });
}); });
describe('GET_PERFORMANCE_PARAMETERS', () => {
it(`Gives the date range ${DEFAULT_DATE_RANGE} if the range is absent`, () => {
expect(GET_PERFORMANCE_PARAMETERS.parse({}).range).toBe(DEFAULT_DATE_RANGE);
});
it('Accepts a calendar year', () => {
expect(
GET_PERFORMANCE_PARAMETERS.safeParse({ range: '2024' }).success
).toBe(true);
});
it('Refuses an unknown date range', () => {
expect(GET_PERFORMANCE_PARAMETERS.safeParse({ range: '2w' }).success).toBe(
false
);
});
});
describe('IMPORT_ACTIVITIES_PARAMETERS', () => { describe('IMPORT_ACTIVITIES_PARAMETERS', () => {
function parse(activities: unknown[]) { function parse(activities: unknown[]) {
return IMPORT_ACTIVITIES_PARAMETERS.safeParse({ activities }).success; return IMPORT_ACTIVITIES_PARAMETERS.safeParse({ activities }).success;

29
apps/api/src/app/endpoints/mcp/mcp.schemas.ts

@ -1,6 +1,7 @@
import { DATE_RANGE_PATTERN } from '@ghostfolio/api/dtos/date-range-filter.dto'; import { DATE_RANGE_PATTERN } from '@ghostfolio/api/dtos/date-range-filter.dto';
import { import {
DATE_RANGES, DATE_RANGES,
DEFAULT_DATE_RANGE,
MCP_MAX_ACCOUNTS, MCP_MAX_ACCOUNTS,
MCP_MAX_ACTIVITIES, MCP_MAX_ACTIVITIES,
SEARCH_QUERY_MAXIMUM_LENGTH, SEARCH_QUERY_MAXIMUM_LENGTH,
@ -84,6 +85,34 @@ export const GET_ACTIVITIES_PARAMETERS = z.object({
.describe(`The number of activities to get, at most ${MCP_MAX_ACTIVITIES}`) .describe(`The number of activities to get, at most ${MCP_MAX_ACTIVITIES}`)
}); });
export const GET_PERFORMANCE_PARAMETERS = z.object({
accountIds: z
.array(z.string().min(1))
.min(1)
.max(MCP_MAX_ACCOUNTS)
.optional()
.describe(
`The identifiers of the accounts of the performance, at most ${MCP_MAX_ACCOUNTS}`
),
assetClasses: z
.array(z.enum(AssetClass))
.min(1)
.optional()
.describe('The asset classes of the performance'),
holding: HOLDING_PARAMETER.optional().describe(
'The asset profile of the performance'
),
range: z
.string()
.regex(DATE_RANGE_PATTERN)
.default(DEFAULT_DATE_RANGE)
.describe(
`The date range of the performance, either ${DATE_RANGES.join(
', '
)} or a calendar year like 2024`
)
});
export const IMPORT_ACTIVITIES_PARAMETERS = z.object({ export const IMPORT_ACTIVITIES_PARAMETERS = z.object({
activities: z activities: z
.array( .array(

32
apps/api/src/app/endpoints/mcp/mcp.service.spec.ts

@ -73,6 +73,7 @@ describe('McpService', () => {
getAccountsTable: jest.fn().mockResolvedValue('## Accounts'), getAccountsTable: jest.fn().mockResolvedValue('## Accounts'),
getActivitiesTable: jest.fn().mockResolvedValue('## Activities'), getActivitiesTable: jest.fn().mockResolvedValue('## Activities'),
getHoldingsTable: jest.fn().mockResolvedValue('## Holdings'), getHoldingsTable: jest.fn().mockResolvedValue('## Holdings'),
getPerformanceTable: jest.fn().mockResolvedValue('## Performance'),
getWatchlistTable: jest.fn().mockResolvedValue('## Watchlist') getWatchlistTable: jest.fn().mockResolvedValue('## Watchlist')
} as unknown as PortfolioTableService; } as unknown as PortfolioTableService;
@ -241,6 +242,37 @@ describe('McpService', () => {
}); });
}); });
describe('getPerformance', () => {
it('Maps the parameters of the tool to the filters', async () => {
await mcpService.getPerformance({
userId,
accountIds: ['account-id'],
assetClasses: [AssetClass.EQUITY],
holding: { dataSource: DataSource.YAHOO, symbol: 'AAPL' },
range: 'max'
});
expect(apiService.buildFiltersFromQueryParams).toHaveBeenCalledWith({
filterByAccounts: ['account-id'],
filterByAssetClasses: [AssetClass.EQUITY],
filterByDataSource: DataSource.YAHOO,
filterBySymbol: 'AAPL'
});
});
it('Gives the table of the performance of the filters in the range', async () => {
expect(
await mcpService.getPerformance({ userId, range: '2024' })
).toEqual({ content: [{ text: '## Performance', type: 'text' }] });
expect(portfolioTableService.getPerformanceTable).toHaveBeenCalledWith({
filters,
userId,
dateRange: '2024'
});
});
});
describe('getPortfolio', () => { describe('getPortfolio', () => {
it('Gives the table of the holdings in the default language', async () => { it('Gives the table of the holdings in the default language', async () => {
expect(await mcpService.getPortfolio({ userId })).toEqual({ expect(await mcpService.getPortfolio({ userId })).toEqual({

24
apps/api/src/app/endpoints/mcp/mcp.service.ts

@ -19,6 +19,7 @@ import { z } from 'zod';
import { import {
GET_ACCOUNTS_PARAMETERS, GET_ACCOUNTS_PARAMETERS,
GET_ACTIVITIES_PARAMETERS, GET_ACTIVITIES_PARAMETERS,
GET_PERFORMANCE_PARAMETERS,
IMPORT_ACTIVITIES_PARAMETERS, IMPORT_ACTIVITIES_PARAMETERS,
SEARCH_ASSET_PROFILES_PARAMETERS SEARCH_ASSET_PROFILES_PARAMETERS
} from './mcp.schemas'; } from './mcp.schemas';
@ -95,6 +96,29 @@ export class McpService {
return this.getTextResult(table); return this.getTextResult(table);
} }
public async getPerformance({
accountIds,
assetClasses,
holding,
range,
userId
}: z.infer<typeof GET_PERFORMANCE_PARAMETERS> & { userId: string }) {
const filters = this.apiService.buildFiltersFromQueryParams({
filterByAccounts: accountIds,
filterByAssetClasses: assetClasses,
filterByDataSource: holding?.dataSource,
filterBySymbol: holding?.symbol
});
const table = await this.portfolioTableService.getPerformanceTable({
filters,
userId,
dateRange: range
});
return this.getTextResult(table);
}
public async getPortfolio({ userId }: { userId: string }) { public async getPortfolio({ userId }: { userId: string }) {
const table = await this.portfolioTableService.getHoldingsTable({ const table = await this.portfolioTableService.getHoldingsTable({
userId, userId,

94
apps/api/src/services/portfolio-table/portfolio-table.service.spec.ts

@ -7,6 +7,8 @@ import {
TAG_ID_EXCLUDE_FROM_ANALYSIS TAG_ID_EXCLUDE_FROM_ANALYSIS
} from '@ghostfolio/common/config'; } from '@ghostfolio/common/config';
import { import {
HistoricalDataItem,
PortfolioPerformanceResponse,
PortfolioPosition, PortfolioPosition,
WatchlistResponse WatchlistResponse
} from '@ghostfolio/common/interfaces'; } from '@ghostfolio/common/interfaces';
@ -109,6 +111,26 @@ function createHolding({
} as unknown as PortfolioPosition; } as unknown as PortfolioPosition;
} }
function createPerformance({
netPerformancePercentage = 0.1,
netPerformancePercentageWithCurrencyEffect = 0.15
}: {
netPerformancePercentage?: number;
netPerformancePercentageWithCurrencyEffect?: number;
} = {}): PortfolioPerformanceResponse['performance'] {
return {
netPerformancePercentage,
netPerformancePercentageWithCurrencyEffect,
currentNetWorth: 3000,
currentValueInBaseCurrency: 2000,
dividendInBaseCurrency: 50,
netPerformance: 200,
netPerformanceWithCurrencyEffect: 300,
totalInvestment: 1700,
totalInvestmentValueWithCurrencyEffect: 1700
};
}
function createWatchlistItem({ function createWatchlistItem({
name = 'Name of AAPL', name = 'Name of AAPL',
performancePercent = -0.25, performancePercent = -0.25,
@ -136,11 +158,15 @@ function createWatchlistItem({
function createPortfolioTableService({ function createPortfolioTableService({
accounts = [], accounts = [],
chart = [{ date: '2024-01-01' }],
holdings = [], holdings = [],
performance = createPerformance(),
watchlist = [] watchlist = []
}: { }: {
accounts?: AccountWithValue[]; accounts?: AccountWithValue[];
chart?: HistoricalDataItem[];
holdings?: PortfolioPosition[]; holdings?: PortfolioPosition[];
performance?: PortfolioPerformanceResponse['performance'];
watchlist?: WatchlistResponse['watchlist']; watchlist?: WatchlistResponse['watchlist'];
} = {}) { } = {}) {
// The mock gives the identifier of the translation, so that a test can tell // The mock gives the identifier of the translation, so that a test can tell
@ -153,7 +179,8 @@ function createPortfolioTableService({
const portfolioService = { const portfolioService = {
getAccountsWithAggregations: jest.fn().mockResolvedValue({ accounts }), getAccountsWithAggregations: jest.fn().mockResolvedValue({ accounts }),
getDetails: jest.fn().mockResolvedValue({ holdings }) getDetails: jest.fn().mockResolvedValue({ holdings }),
getPerformance: jest.fn().mockResolvedValue({ chart, performance })
} as unknown as PortfolioService; } as unknown as PortfolioService;
const watchlistService = { const watchlistService = {
@ -215,6 +242,16 @@ describe('PortfolioTableService', () => {
}); });
}); });
describe('getPerformanceTableColumnNames', () => {
it('gives no column with a monetary value', () => {
expect(PortfolioTableService.getPerformanceTableColumnNames()).toEqual([
'Asset Performance in Percentage',
'Currency Performance in Percentage',
'Net Performance in Percentage'
]);
});
});
describe('getWatchlistTableColumnNames', () => { describe('getWatchlistTableColumnNames', () => {
it('gives no column with a monetary value', () => { it('gives no column with a monetary value', () => {
expect(PortfolioTableService.getWatchlistTableColumnNames()).toEqual([ expect(PortfolioTableService.getWatchlistTableColumnNames()).toEqual([
@ -324,6 +361,61 @@ describe('PortfolioTableService', () => {
}); });
}); });
describe('getPerformanceTable', () => {
function getPerformanceTable(
parameters: Parameters<typeof createPortfolioTableService>[0] = {}
) {
return createPortfolioTableService(parameters).getPerformanceTable({
dateRange: 'ytd',
userId: 'user-id'
});
}
it('gives the currency performance as the difference of the net performance and the asset performance', async () => {
const result = await getPerformanceTable();
const [row] = result.split('\n').filter((line) => {
return line.startsWith('| 10.000%');
});
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();
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 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', () => { describe('getWatchlistTable', () => {
function getWatchlistTable(watchlist: WatchlistResponse['watchlist']) { function getWatchlistTable(watchlist: WatchlistResponse['watchlist']) {
return createPortfolioTableService({ watchlist }).getWatchlistTable({ return createPortfolioTableService({ watchlist }).getWatchlistTable({

85
apps/api/src/services/portfolio-table/portfolio-table.service.ts

@ -8,9 +8,10 @@ import { DATE_FORMAT, isAccountExcluded } from '@ghostfolio/common/helper';
import { import {
Activity, Activity,
Filter, Filter,
PortfolioPerformance,
WatchlistResponse WatchlistResponse
} from '@ghostfolio/common/interfaces'; } from '@ghostfolio/common/interfaces';
import { AccountWithValue } from '@ghostfolio/common/types'; import { AccountWithValue, DateRange } from '@ghostfolio/common/types';
import { Injectable } from '@nestjs/common'; import { Injectable } from '@nestjs/common';
import { import {
@ -27,9 +28,10 @@ function getPercentage(value: number) {
} }
/** /**
* Renders the accounts, the activities and the holdings of a portfolio and the * Renders the accounts, the activities, the holdings and the performance of a
* watchlist of its user as a markdown table. No table has a column with a * portfolio and the watchlist of its user as a markdown table. No table has a
* quantity or with a monetary value, except the unit price of an activity. * column with a quantity or with a monetary value, except the unit price of an
* activity.
*/ */
@Injectable() @Injectable()
export class PortfolioTableService { export class PortfolioTableService {
@ -184,6 +186,41 @@ export class PortfolioTableService {
} }
]; ];
private static readonly PERFORMANCE_TABLE_COLUMN_DEFINITIONS: TableColumnDefinition<PortfolioPerformance>[] =
[
{
align: 'right',
getValue: ({ netPerformancePercentage }) => {
return getPercentage(netPerformancePercentage);
},
name: 'Asset Performance in Percentage'
},
{
align: 'right',
getValue: ({
netPerformancePercentage,
netPerformancePercentageWithCurrencyEffect
}) => {
const currencyPerformancePercentage = getPercentage(
netPerformancePercentageWithCurrencyEffect -
netPerformancePercentage
);
return Number.parseFloat(currencyPerformancePercentage) === 0
? getPercentage(0)
: currencyPerformancePercentage;
},
name: 'Currency Performance in Percentage'
},
{
align: 'right',
getValue: ({ netPerformancePercentageWithCurrencyEffect }) => {
return getPercentage(netPerformancePercentageWithCurrencyEffect);
},
name: 'Net Performance in Percentage'
}
];
private static readonly WATCHLIST_TABLE_COLUMN_DEFINITIONS: TableColumnDefinition< private static readonly WATCHLIST_TABLE_COLUMN_DEFINITIONS: TableColumnDefinition<
WatchlistResponse['watchlist'][number] WatchlistResponse['watchlist'][number]
>[] = [ >[] = [
@ -265,6 +302,14 @@ export class PortfolioTableService {
); );
} }
public static getPerformanceTableColumnNames() {
return PortfolioTableService.PERFORMANCE_TABLE_COLUMN_DEFINITIONS.map(
({ name }) => {
return name;
}
);
}
public static getWatchlistTableColumnNames() { public static getWatchlistTableColumnNames() {
return PortfolioTableService.WATCHLIST_TABLE_COLUMN_DEFINITIONS.map( return PortfolioTableService.WATCHLIST_TABLE_COLUMN_DEFINITIONS.map(
({ name }) => { ({ name }) => {
@ -404,6 +449,38 @@ export class PortfolioTableService {
].join('\n'); ].join('\n');
} }
public async getPerformanceTable({
dateRange,
filters,
userId
}: {
dateRange: DateRange;
filters?: Filter[];
userId: string;
}) {
const { chart, performance } = await this.portfolioService.getPerformance({
dateRange,
filters,
userId
});
const performanceSection = ['## Performance', ''];
if (chart?.length > 0) {
performanceSection.push(
await getMarkdownTable({
columnDefinitions:
PortfolioTableService.PERFORMANCE_TABLE_COLUMN_DEFINITIONS,
rows: [performance]
})
);
} else {
performanceSection.push('No performance found.');
}
return performanceSection.join('\n');
}
public async getWatchlistTable({ userId }: { userId: string }) { public async getWatchlistTable({ userId }: { userId: string }) {
const watchlist = await this.watchlistService.getWatchlistItems(userId); const watchlist = await this.watchlistService.getWatchlistItems(userId);

Loading…
Cancel
Save