Browse Source

Extend tools with data source

pull/8019/head
Thomas Kaul 2 weeks ago
parent
commit
7b5c738dff
  1. 15
      apps/api/src/app/endpoints/mcp/mcp.schemas.spec.ts
  2. 12
      apps/api/src/app/endpoints/mcp/mcp.schemas.ts
  3. 7
      apps/api/src/app/endpoints/mcp/mcp.service.spec.ts
  4. 3
      apps/api/src/app/endpoints/mcp/mcp.service.ts
  5. 2
      apps/api/src/helper/data-source.helper.ts
  6. 47
      apps/api/src/services/portfolio-table/portfolio-table.service.spec.ts
  7. 72
      apps/api/src/services/portfolio-table/portfolio-table.service.ts

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

@ -56,6 +56,21 @@ describe('GET_ACCOUNTS_PARAMETERS', () => {
}).success
).toBe(false);
});
it('Gives the valid data sources in the error of an unknown data source of the holding', () => {
for (const dataSource of ['yahoo', 1]) {
const { error } = GET_ACCOUNTS_PARAMETERS.safeParse({
holding: { dataSource, symbol: 'AAPL' }
});
expect(error.issues).toEqual([
expect.objectContaining({
message: expect.stringContaining(`|${DataSource.YAHOO}`),
path: ['holding', 'dataSource']
})
]);
}
});
});
describe('GET_ACTIVITIES_PARAMETERS', () => {

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

@ -18,8 +18,18 @@ import {
import { AssetClass, DataSource, Type as ActivityType } from '@prisma/client';
import { z } from 'zod';
const DATA_SOURCE_PARAMETER_ERROR = `Invalid option: expected one of ${Object.values(DataSource).join('|')} or a data source as given by a tool`;
const DATA_SOURCE_PARAMETER = z
.union([z.enum(DataSource), z.string().refine(isValidEncodedDataSource)])
.union(
[
z.enum(DataSource),
z.string().refine(isValidEncodedDataSource, {
error: DATA_SOURCE_PARAMETER_ERROR
})
],
{ error: DATA_SOURCE_PARAMETER_ERROR }
)
.describe('The data source of the asset profile');
const SYMBOL_PARAMETER = z

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

@ -353,14 +353,15 @@ describe('McpService', () => {
});
describe('getPortfolio', () => {
it('Gives the table of the holdings in the default language', async () => {
it('Gives the table of the holdings with the data source 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
languageCode: DEFAULT_LANGUAGE_CODE,
withDataSource: true
});
});
});
@ -437,8 +438,6 @@ describe('McpService', () => {
});
});
// A response of the instance encodes the data source for a user who is
// not an admin, and the client of the access is not a user
it('Encodes the data source if the subscription is enabled', async () => {
setupUser([permissions.createActivity]);

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

@ -121,7 +121,8 @@ export class McpService {
public async getPortfolio({ userId }: { userId: string }) {
const table = await this.portfolioTableService.getHoldingsTable({
userId,
languageCode: DEFAULT_LANGUAGE_CODE
languageCode: DEFAULT_LANGUAGE_CODE,
withDataSource: true
});
return this.getTextResult(table);

2
apps/api/src/helper/data-source.helper.ts

@ -115,7 +115,7 @@ export function transformDataSourceInRequest({
configurationService: ConfigurationService;
dataSource?: string;
}) {
if (DataSource[dataSource]) {
if (Object.hasOwn(DataSource, dataSource)) {
return getUnmaskedGhostfolioDataSource({
dataSource: dataSource as DataSource,
ghostfolioDataSources: getGhostfolioDataSources({ configurationService })

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

@ -380,9 +380,8 @@ describe('PortfolioTableService', () => {
expect(result).toContain('No accounts found.');
});
});
describe('getActivitiesTable', () => {
// The holding parameter of the tools takes the data source, which a
// response of the instance encodes, hence the table encodes it alike
it('gives the data source, encoded if the subscription is enabled', async () => {
const result = await createPortfolioTableService({
activities: [
@ -411,17 +410,33 @@ describe('PortfolioTableService', () => {
describe('getHoldingsTable', () => {
function getHoldingsTable(
holdings: PortfolioPosition[],
configuration?: Record<string, unknown>
{
configuration,
withDataSource
}: {
configuration?: Record<string, unknown>;
withDataSource?: boolean;
} = {}
) {
return createPortfolioTableService({
configuration,
holdings
}).getHoldingsTable({
withDataSource,
languageCode: DEFAULT_LANGUAGE_CODE,
userId: 'user-id'
});
}
it('gives no data source by default', async () => {
const result = await getHoldingsTable([createHolding()], {
configuration: SUBSCRIPTION_CONFIGURATION
});
expect(result).not.toContain('Data Source');
expect(result).not.toContain(encodeDataSource(DataSource.YAHOO));
});
it('gives the data source, encoded if the subscription is enabled', async () => {
const result = await getHoldingsTable(
[
@ -432,7 +447,7 @@ describe('PortfolioTableService', () => {
symbol: 'GF_GOLD'
})
],
SUBSCRIPTION_CONFIGURATION
{ configuration: SUBSCRIPTION_CONFIGURATION, withDataSource: true }
);
const [rowOfAapl, rowOfGold] = result.split('\n').filter((line) => {
@ -445,7 +460,8 @@ describe('PortfolioTableService', () => {
it('gives the data source unencoded if the subscription is disabled', async () => {
const result = await getHoldingsTable([createHolding()], {
ENABLE_FEATURE_SUBSCRIPTION: false
configuration: { ENABLE_FEATURE_SUBSCRIPTION: false },
withDataSource: true
});
const [row] = result.split('\n').filter((line) => {
@ -455,6 +471,27 @@ describe('PortfolioTableService', () => {
expect(row).toContain(`| ${DataSource.YAHOO} |`);
});
it('gives no data source of a cash position', async () => {
const result = await getHoldingsTable(
[
createHolding({
assetClass: AssetClass.LIQUIDITY,
assetSubClass: AssetSubClass.CASH,
dataSource: DataSource.YAHOO,
symbol: 'USD'
})
],
{ configuration: SUBSCRIPTION_CONFIGURATION, withDataSource: true }
);
const [row] = result.split('\n').filter((line) => {
return line.startsWith('| Name of USD');
});
expect(row).toContain('| USD | | CHF |');
expect(row).not.toContain(encodeDataSource(DataSource.YAHOO));
});
it('gives the translation of the asset class and of the asset sub class', async () => {
const result = await getHoldingsTable([createHolding()]);

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

@ -6,7 +6,11 @@ import { TableColumnDefinition } from '@ghostfolio/api/helper/interfaces/table-c
import { getMarkdownTable } from '@ghostfolio/api/helper/markdown-table.helper';
import { ConfigurationService } from '@ghostfolio/api/services/configuration/configuration.service';
import { I18nService } from '@ghostfolio/api/services/i18n/i18n.service';
import { DATE_FORMAT, isAccountExcluded } from '@ghostfolio/common/helper';
import {
DATE_FORMAT,
isAccountExcluded,
isCashPosition
} from '@ghostfolio/common/helper';
import {
Activity,
Filter,
@ -19,6 +23,7 @@ import { Injectable } from '@nestjs/common';
import {
AssetClass,
AssetSubClass,
DataSource,
Type as ActivityType
} from '@prisma/client';
import { format } from 'date-fns';
@ -26,6 +31,23 @@ import { format } from 'date-fns';
import { DataSourceTableContext } from './interfaces/data-source-table-context.interface';
import { HoldingsTableColumnDefinition } from './types/holdings-table-column-definition.type';
const DATA_SOURCE_COLUMN_NAME = 'Data Source';
function getDataSourceColumnDefinition<T>(
getDataSource: (row: T) => DataSource | undefined
): TableColumnDefinition<T, DataSourceTableContext> {
return {
getValue: (row, { configurationService }) => {
const dataSource = getDataSource(row);
return dataSource
? transformDataSourceInResponse({ configurationService, dataSource })
: '';
},
name: DATA_SOURCE_COLUMN_NAME
};
}
function getPercentage(value: number) {
return `${(value * 100).toFixed(3)}%`;
}
@ -114,15 +136,9 @@ export class PortfolioTableService {
},
name: 'Symbol'
},
{
getValue: ({ assetProfile }, { configurationService }) => {
return transformDataSourceInResponse({
configurationService,
dataSource: assetProfile.dataSource
});
},
name: 'Data Source'
},
getDataSourceColumnDefinition(({ assetProfile }) => {
return assetProfile.dataSource;
}),
{
getValue: ({ assetProfile, currency }) => {
return currency ?? assetProfile.currency;
@ -158,15 +174,11 @@ export class PortfolioTableService {
},
name: 'Symbol'
},
{
getValue: ({ assetProfile }, { configurationService }) => {
return transformDataSourceInResponse({
configurationService,
dataSource: assetProfile.dataSource
});
},
name: 'Data Source'
},
getDataSourceColumnDefinition(({ assetProfile }) => {
return isCashPosition(assetProfile)
? undefined
: assetProfile.dataSource;
}),
{
getValue: ({ assetProfile }) => {
return assetProfile.currency;
@ -260,15 +272,9 @@ export class PortfolioTableService {
},
name: 'Symbol'
},
{
getValue: ({ dataSource }, { configurationService }) => {
return transformDataSourceInResponse({
configurationService,
dataSource
});
},
name: 'Data Source'
},
getDataSourceColumnDefinition(({ dataSource }) => {
return dataSource;
}),
{
getValue: ({ trend50d }) => {
return trend50d;
@ -445,11 +451,13 @@ export class PortfolioTableService {
public async getHoldingsTable({
filters,
languageCode,
userId
userId,
withDataSource = false
}: {
filters?: Filter[];
languageCode: string;
userId: string;
withDataSource?: boolean;
}) {
const { holdings } = await this.portfolioService.getDetails({
filters,
@ -477,7 +485,11 @@ export class PortfolioTableService {
'',
await getMarkdownTable({
columnDefinitions:
PortfolioTableService.HOLDINGS_TABLE_COLUMN_DEFINITIONS,
PortfolioTableService.HOLDINGS_TABLE_COLUMN_DEFINITIONS.filter(
({ name }) => {
return withDataSource || name !== DATA_SOURCE_COLUMN_NAME;
}
),
context: {
assetClassTranslations,
assetSubClassTranslations,

Loading…
Cancel
Save