Browse Source

Feature/add MCP tool to get watchlist (#7920)

* Add MCP tool to get watchlist

* Update changelog
task/rename-net-performance-percentages-to-performance-percentages
Thomas Kaul 1 week ago
committed by GitHub
parent
commit
fa4b4cc8ba
No known key found for this signature in database GPG Key ID: B5690EEEBB952194
  1. 4
      CHANGELOG.md
  2. 16
      apps/api/src/app/endpoints/mcp/mcp.controller.ts
  3. 2
      apps/api/src/app/endpoints/mcp/mcp.module.ts
  4. 15
      apps/api/src/app/endpoints/mcp/mcp.service.spec.ts
  5. 8
      apps/api/src/app/endpoints/mcp/mcp.service.ts
  6. 1
      apps/api/src/app/endpoints/watchlist/watchlist.module.ts
  7. 3
      apps/api/src/services/portfolio-table/portfolio-table.module.ts
  8. 87
      apps/api/src/services/portfolio-table/portfolio-table.service.spec.ts
  9. 102
      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 watchlist to the server of the Model Context Protocol (MCP) (experimental)
### Changed ### Changed
- Extended the cache key of the portfolio snapshot by the performance calculation type - Extended the cache key of the portfolio snapshot by the performance calculation type

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

@ -85,6 +85,22 @@ export class GhostfolioMcpController {
return this.mcpService.getPortfolio({ userId }); return this.mcpService.getPortfolio({ userId });
} }
@RequiresScopeOfAccess(scopes.watchlistRead)
@Tool({
annotations: {
openWorldHint: false,
readOnlyHint: true,
title: 'Get watchlist'
},
description: `Gives the watchlist of the user, sorted by name, with these columns: ${PortfolioTableService.getWatchlistTableColumnNames().join(
', '
)}. A trend compares the average market price of the last 50 or 200 days with the average of the 50 or 200 days before. A trend is UNKNOWN if there is not sufficient market data. The change from the all time high is the difference between the current market price and the all time high in percentage.`,
name: 'get-watchlist'
})
public async getWatchlist(@Impersonation() { userId }: ImpersonationContext) {
return this.mcpService.getWatchlist({ userId });
}
/** /**
* The transport gives the tool to every client, because it filters the list * The transport gives the tool to every client, because it filters the list
* of the tools by the scopes of request.user, which a request of an access * of the tools by the scopes of request.user, which a request of an access

2
apps/api/src/app/endpoints/mcp/mcp.module.ts

@ -38,7 +38,7 @@ import { McpService } from './mcp.service';
return new McpStrategy({ return new McpStrategy({
instructions: instructions:
'Ghostfolio is a wealth management application. The tools read the portfolio of the user who granted the access and import activities into it. They give no quantity and no monetary value (except the unit price of an activity).', 'Ghostfolio is a wealth management application. The tools read the portfolio and the watchlist of the user who granted the access and import activities into the portfolio. They give no quantity and no monetary value (except the unit price of an activity).',
name: 'ghostfolio', name: 'ghostfolio',
title: 'Ghostfolio', title: 'Ghostfolio',
transports: [ transports: [

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

@ -72,7 +72,8 @@ describe('McpService', () => {
portfolioTableService = { portfolioTableService = {
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'),
getWatchlistTable: jest.fn().mockResolvedValue('## Watchlist')
} as unknown as PortfolioTableService; } as unknown as PortfolioTableService;
symbolService = { symbolService = {
@ -199,6 +200,18 @@ describe('McpService', () => {
}); });
}); });
describe('getWatchlist', () => {
it('Gives the table of the watchlist', async () => {
expect(await mcpService.getWatchlist({ userId })).toEqual({
content: [{ text: '## Watchlist', type: 'text' }]
});
expect(portfolioTableService.getWatchlistTable).toHaveBeenCalledWith({
userId
});
});
});
describe('searchAssetProfiles', () => { describe('searchAssetProfiles', () => {
it('Refuses a user without the permission to create an activity', async () => { it('Refuses a user without the permission to create an activity', async () => {
setupUser([]); setupUser([]);

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

@ -106,6 +106,14 @@ export class McpService {
return this.getTextResult(table); return this.getTextResult(table);
} }
public async getWatchlist({ userId }: { userId: string }) {
const table = await this.portfolioTableService.getWatchlistTable({
userId
});
return this.getTextResult(table);
}
public async importActivities({ public async importActivities({
activities, activities,
userId userId

1
apps/api/src/app/endpoints/watchlist/watchlist.module.ts

@ -15,6 +15,7 @@ import { WatchlistService } from './watchlist.service';
@Module({ @Module({
controllers: [WatchlistController], controllers: [WatchlistController],
exports: [WatchlistService],
imports: [ imports: [
BenchmarkModule, BenchmarkModule,
DataGatheringQueueModule, DataGatheringQueueModule,

3
apps/api/src/services/portfolio-table/portfolio-table.module.ts

@ -1,4 +1,5 @@
import { ActivitiesModule } from '@ghostfolio/api/app/activities/activities.module'; import { ActivitiesModule } from '@ghostfolio/api/app/activities/activities.module';
import { WatchlistModule } from '@ghostfolio/api/app/endpoints/watchlist/watchlist.module';
import { PortfolioModule } from '@ghostfolio/api/app/portfolio/portfolio.module'; import { PortfolioModule } from '@ghostfolio/api/app/portfolio/portfolio.module';
import { I18nModule } from '@ghostfolio/api/services/i18n/i18n.module'; import { I18nModule } from '@ghostfolio/api/services/i18n/i18n.module';
@ -8,7 +9,7 @@ import { PortfolioTableService } from './portfolio-table.service';
@Module({ @Module({
exports: [PortfolioTableService], exports: [PortfolioTableService],
imports: [ActivitiesModule, I18nModule, PortfolioModule], imports: [ActivitiesModule, I18nModule, PortfolioModule, WatchlistModule],
providers: [PortfolioTableService] providers: [PortfolioTableService]
}) })
export class PortfolioTableModule {} export class PortfolioTableModule {}

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

@ -1,3 +1,4 @@
import type { WatchlistService } from '@ghostfolio/api/app/endpoints/watchlist/watchlist.service';
import type { PortfolioService } from '@ghostfolio/api/app/portfolio/portfolio.service'; import type { PortfolioService } from '@ghostfolio/api/app/portfolio/portfolio.service';
import type { TableParameters } from '@ghostfolio/api/helper/interfaces/table-parameters.interface'; import type { TableParameters } from '@ghostfolio/api/helper/interfaces/table-parameters.interface';
import type { I18nService } from '@ghostfolio/api/services/i18n/i18n.service'; import type { I18nService } from '@ghostfolio/api/services/i18n/i18n.service';
@ -5,10 +6,13 @@ import {
DEFAULT_LANGUAGE_CODE, DEFAULT_LANGUAGE_CODE,
TAG_ID_EXCLUDE_FROM_ANALYSIS TAG_ID_EXCLUDE_FROM_ANALYSIS
} from '@ghostfolio/common/config'; } from '@ghostfolio/common/config';
import { PortfolioPosition } from '@ghostfolio/common/interfaces'; import {
PortfolioPosition,
WatchlistResponse
} from '@ghostfolio/common/interfaces';
import { AccountWithValue } from '@ghostfolio/common/types'; import { AccountWithValue } from '@ghostfolio/common/types';
import { AssetClass, AssetSubClass } from '@prisma/client'; import { AssetClass, AssetSubClass, DataSource } from '@prisma/client';
import { PortfolioTableService } from './portfolio-table.service'; import { PortfolioTableService } from './portfolio-table.service';
@ -105,12 +109,39 @@ function createHolding({
} as unknown as PortfolioPosition; } as unknown as PortfolioPosition;
} }
function createWatchlistItem({
name = 'Name of AAPL',
performancePercent = -0.25,
symbol = 'AAPL'
}: {
name?: string;
performancePercent?: number;
symbol?: string;
} = {}): WatchlistResponse['watchlist'][number] {
return {
name,
symbol,
dataSource: DataSource.YAHOO,
marketCondition: 'BEAR_MARKET',
performances: {
allTimeHigh: {
performancePercent,
date: new Date('2024-01-01T00:00:00')
}
},
trend50d: 'UP',
trend200d: 'DOWN'
};
}
function createPortfolioTableService({ function createPortfolioTableService({
accounts = [], accounts = [],
holdings = [] holdings = [],
watchlist = []
}: { }: {
accounts?: AccountWithValue[]; accounts?: AccountWithValue[];
holdings?: PortfolioPosition[]; holdings?: PortfolioPosition[];
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
// the translation of the asset class from that of the asset sub class // the translation of the asset class from that of the asset sub class
@ -125,7 +156,16 @@ function createPortfolioTableService({
getDetails: jest.fn().mockResolvedValue({ holdings }) getDetails: jest.fn().mockResolvedValue({ holdings })
} as unknown as PortfolioService; } as unknown as PortfolioService;
return new PortfolioTableService(null, i18nService, portfolioService); const watchlistService = {
getWatchlistItems: jest.fn().mockResolvedValue(watchlist)
} as unknown as WatchlistService;
return new PortfolioTableService(
null,
i18nService,
portfolioService,
watchlistService
);
} }
describe('PortfolioTableService', () => { describe('PortfolioTableService', () => {
@ -175,6 +215,20 @@ describe('PortfolioTableService', () => {
}); });
}); });
describe('getWatchlistTableColumnNames', () => {
it('gives no column with a monetary value', () => {
expect(PortfolioTableService.getWatchlistTableColumnNames()).toEqual([
'Name',
'Symbol',
'Trend 50 Days',
'Trend 200 Days',
'Date of Last All Time High',
'Change from All Time High',
'Market Condition'
]);
});
});
describe('getAccountsTable', () => { describe('getAccountsTable', () => {
it('gives no cash balance and no value of an account', async () => { it('gives no cash balance and no value of an account', async () => {
const portfolioTableService = createPortfolioTableService({ const portfolioTableService = createPortfolioTableService({
@ -269,4 +323,29 @@ describe('PortfolioTableService', () => {
expect(secondRow).toContain('MSFT'); expect(secondRow).toContain('MSFT');
}); });
}); });
describe('getWatchlistTable', () => {
function getWatchlistTable(watchlist: WatchlistResponse['watchlist']) {
return createPortfolioTableService({ watchlist }).getWatchlistTable({
userId: 'user-id'
});
}
it('gives the date and the change of the all time high', async () => {
const result = await getWatchlistTable([createWatchlistItem()]);
const [row] = result.split('\n').filter((line) => {
return line.startsWith('| Name of AAPL');
});
expect(row).toContain('2024-01-01');
expect(row).toContain('-25.000%');
});
it('tells that no watchlist items are found if the result is empty', async () => {
const result = await getWatchlistTable([]);
expect(result).toContain('No watchlist items found.');
});
});
}); });

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

@ -1,10 +1,15 @@
import { ActivitiesService } from '@ghostfolio/api/app/activities/activities.service'; import { ActivitiesService } from '@ghostfolio/api/app/activities/activities.service';
import { WatchlistService } from '@ghostfolio/api/app/endpoints/watchlist/watchlist.service';
import { PortfolioService } from '@ghostfolio/api/app/portfolio/portfolio.service'; import { PortfolioService } from '@ghostfolio/api/app/portfolio/portfolio.service';
import { TableColumnDefinition } from '@ghostfolio/api/helper/interfaces/table-column-definition.interface'; import { TableColumnDefinition } from '@ghostfolio/api/helper/interfaces/table-column-definition.interface';
import { getMarkdownTable } from '@ghostfolio/api/helper/markdown-table.helper'; import { getMarkdownTable } from '@ghostfolio/api/helper/markdown-table.helper';
import { I18nService } from '@ghostfolio/api/services/i18n/i18n.service'; import { I18nService } from '@ghostfolio/api/services/i18n/i18n.service';
import { DATE_FORMAT, isAccountExcluded } from '@ghostfolio/common/helper'; import { DATE_FORMAT, isAccountExcluded } from '@ghostfolio/common/helper';
import { Activity, Filter } from '@ghostfolio/common/interfaces'; import {
Activity,
Filter,
WatchlistResponse
} from '@ghostfolio/common/interfaces';
import { AccountWithValue } from '@ghostfolio/common/types'; import { AccountWithValue } from '@ghostfolio/common/types';
import { Injectable } from '@nestjs/common'; import { Injectable } from '@nestjs/common';
@ -17,14 +22,14 @@ import { format } from 'date-fns';
import { HoldingsTableColumnDefinition } from './types/holdings-table-column-definition.type'; import { HoldingsTableColumnDefinition } from './types/holdings-table-column-definition.type';
function getAllocationInPercentage(allocationInPercentage: number) { function getPercentage(value: number) {
return `${(allocationInPercentage * 100).toFixed(3)}%`; return `${(value * 100).toFixed(3)}%`;
} }
/** /**
* Renders the accounts, the activities and the holdings of a portfolio as a * Renders the accounts, the activities and the holdings of a portfolio and the
* markdown table. No table has a column with a quantity or with a monetary * watchlist of its user as a markdown table. No table has a column with a
* value, except the unit price of an activity. * quantity or with a monetary value, except the unit price of an activity.
*/ */
@Injectable() @Injectable()
export class PortfolioTableService { export class PortfolioTableService {
@ -64,7 +69,7 @@ export class PortfolioTableService {
{ {
align: 'right', align: 'right',
getValue: ({ allocationInPercentage }) => { getValue: ({ allocationInPercentage }) => {
return getAllocationInPercentage(allocationInPercentage); return getPercentage(allocationInPercentage);
}, },
name: 'Allocation in Percentage' name: 'Allocation in Percentage'
}, },
@ -173,16 +178,67 @@ export class PortfolioTableService {
{ {
align: 'right', align: 'right',
getValue: ({ allocationInPercentage }) => { getValue: ({ allocationInPercentage }) => {
return getAllocationInPercentage(allocationInPercentage); return getPercentage(allocationInPercentage);
}, },
name: 'Allocation in Percentage' name: 'Allocation in Percentage'
} }
]; ];
private static readonly WATCHLIST_TABLE_COLUMN_DEFINITIONS: TableColumnDefinition<
WatchlistResponse['watchlist'][number]
>[] = [
{
getValue: ({ name }) => {
return name ?? '';
},
name: 'Name'
},
{
getValue: ({ symbol }) => {
return symbol;
},
name: 'Symbol'
},
{
getValue: ({ trend50d }) => {
return trend50d;
},
name: 'Trend 50 Days'
},
{
getValue: ({ trend200d }) => {
return trend200d;
},
name: 'Trend 200 Days'
},
{
getValue: ({ performances }) => {
return performances.allTimeHigh.date
? format(performances.allTimeHigh.date, DATE_FORMAT)
: '';
},
name: 'Date of Last All Time High'
},
{
align: 'right',
getValue: ({ performances }) => {
return getPercentage(performances.allTimeHigh.performancePercent);
},
name: 'Change from All Time High'
},
{
getValue: ({ marketCondition }) => {
return marketCondition;
},
name: 'Market Condition'
}
];
public constructor( public constructor(
private readonly activitiesService: ActivitiesService, private readonly activitiesService: ActivitiesService,
private readonly i18nService: I18nService, private readonly i18nService: I18nService,
private readonly portfolioService: PortfolioService private readonly portfolioService: PortfolioService,
private readonly watchlistService: WatchlistService
) {} ) {}
public static getAccountsTableColumnNames() { public static getAccountsTableColumnNames() {
@ -209,6 +265,14 @@ export class PortfolioTableService {
); );
} }
public static getWatchlistTableColumnNames() {
return PortfolioTableService.WATCHLIST_TABLE_COLUMN_DEFINITIONS.map(
({ name }) => {
return name;
}
);
}
public async getAccountsTable({ public async getAccountsTable({
filters, filters,
userId userId
@ -340,6 +404,26 @@ export class PortfolioTableService {
].join('\n'); ].join('\n');
} }
public async getWatchlistTable({ userId }: { userId: string }) {
const watchlist = await this.watchlistService.getWatchlistItems(userId);
const watchlistSection = ['## Watchlist', ''];
if (watchlist.length > 0) {
watchlistSection.push(
await getMarkdownTable({
columnDefinitions:
PortfolioTableService.WATCHLIST_TABLE_COLUMN_DEFINITIONS,
rows: watchlist
})
);
} else {
watchlistSection.push('No watchlist items found.');
}
return watchlistSection.join('\n');
}
private getActivitiesSummary({ private getActivitiesSummary({
count, count,
numberOfActivities, numberOfActivities,

Loading…
Cancel
Save