Browse Source

Add MCP tool to get watchlist

pull/7920/head
Thomas Kaul 4 weeks ago
parent
commit
964897c1d8
  1. 16
      apps/api/src/app/endpoints/mcp/mcp.controller.ts
  2. 2
      apps/api/src/app/endpoints/mcp/mcp.module.ts
  3. 15
      apps/api/src/app/endpoints/mcp/mcp.service.spec.ts
  4. 8
      apps/api/src/app/endpoints/mcp/mcp.service.ts
  5. 1
      apps/api/src/app/endpoints/watchlist/watchlist.module.ts
  6. 3
      apps/api/src/services/portfolio-table/portfolio-table.module.ts
  7. 87
      apps/api/src/services/portfolio-table/portfolio-table.service.spec.ts
  8. 102
      apps/api/src/services/portfolio-table/portfolio-table.service.ts

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

@ -85,6 +85,22 @@ export class GhostfolioMcpController {
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. 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
* 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({
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',
title: 'Ghostfolio',
transports: [

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

@ -72,7 +72,8 @@ describe('McpService', () => {
portfolioTableService = {
getAccountsTable: jest.fn().mockResolvedValue('## Accounts'),
getActivitiesTable: jest.fn().mockResolvedValue('## Activities'),
getHoldingsTable: jest.fn().mockResolvedValue('## Holdings')
getHoldingsTable: jest.fn().mockResolvedValue('## Holdings'),
getWatchlistTable: jest.fn().mockResolvedValue('## Watchlist')
} as unknown as PortfolioTableService;
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', () => {
it('Refuses a user without the permission to create an activity', async () => {
setupUser([]);

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

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

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

@ -15,6 +15,7 @@ import { WatchlistService } from './watchlist.service';
@Module({
controllers: [WatchlistController],
exports: [WatchlistService],
imports: [
BenchmarkModule,
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 { WatchlistModule } from '@ghostfolio/api/app/endpoints/watchlist/watchlist.module';
import { PortfolioModule } from '@ghostfolio/api/app/portfolio/portfolio.module';
import { I18nModule } from '@ghostfolio/api/services/i18n/i18n.module';
@ -8,7 +9,7 @@ import { PortfolioTableService } from './portfolio-table.service';
@Module({
exports: [PortfolioTableService],
imports: [ActivitiesModule, I18nModule, PortfolioModule],
imports: [ActivitiesModule, I18nModule, PortfolioModule, WatchlistModule],
providers: [PortfolioTableService]
})
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 { TableParameters } from '@ghostfolio/api/helper/interfaces/table-parameters.interface';
import type { I18nService } from '@ghostfolio/api/services/i18n/i18n.service';
@ -5,10 +6,13 @@ import {
DEFAULT_LANGUAGE_CODE,
TAG_ID_EXCLUDE_FROM_ANALYSIS
} from '@ghostfolio/common/config';
import { PortfolioPosition } from '@ghostfolio/common/interfaces';
import {
PortfolioPosition,
WatchlistResponse
} from '@ghostfolio/common/interfaces';
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';
@ -105,12 +109,39 @@ function createHolding({
} 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-01')
}
},
trend50d: 'UP',
trend200d: 'DOWN'
};
}
function createPortfolioTableService({
accounts = [],
holdings = []
holdings = [],
watchlist = []
}: {
accounts?: AccountWithValue[];
holdings?: PortfolioPosition[];
watchlist?: WatchlistResponse['watchlist'];
} = {}) {
// 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
@ -125,7 +156,16 @@ function createPortfolioTableService({
getDetails: jest.fn().mockResolvedValue({ holdings })
} 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', () => {
@ -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', () => {
it('gives no cash balance and no value of an account', async () => {
const portfolioTableService = createPortfolioTableService({
@ -269,4 +323,29 @@ describe('PortfolioTableService', () => {
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 { WatchlistService } from '@ghostfolio/api/app/endpoints/watchlist/watchlist.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 {
Activity,
Filter,
WatchlistResponse
} from '@ghostfolio/common/interfaces';
import { AccountWithValue } from '@ghostfolio/common/types';
import { Injectable } from '@nestjs/common';
@ -17,14 +22,14 @@ import { format } from 'date-fns';
import { HoldingsTableColumnDefinition } from './types/holdings-table-column-definition.type';
function getAllocationInPercentage(allocationInPercentage: number) {
return `${(allocationInPercentage * 100).toFixed(3)}%`;
function getPercentage(value: number) {
return `${(value * 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.
* Renders the accounts, the activities and the holdings of a portfolio and the
* watchlist of its user as a markdown table. No table has a column with a
* quantity or with a monetary value, except the unit price of an activity.
*/
@Injectable()
export class PortfolioTableService {
@ -64,7 +69,7 @@ export class PortfolioTableService {
{
align: 'right',
getValue: ({ allocationInPercentage }) => {
return getAllocationInPercentage(allocationInPercentage);
return getPercentage(allocationInPercentage);
},
name: 'Allocation in Percentage'
},
@ -173,16 +178,67 @@ export class PortfolioTableService {
{
align: 'right',
getValue: ({ allocationInPercentage }) => {
return getAllocationInPercentage(allocationInPercentage);
return getPercentage(allocationInPercentage);
},
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(
private readonly activitiesService: ActivitiesService,
private readonly i18nService: I18nService,
private readonly portfolioService: PortfolioService
private readonly portfolioService: PortfolioService,
private readonly watchlistService: WatchlistService
) {}
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({
filters,
userId
@ -340,6 +404,26 @@ export class PortfolioTableService {
].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({
count,
numberOfActivities,

Loading…
Cancel
Save