diff --git a/apps/api/src/app/endpoints/mcp/mcp.schemas.spec.ts b/apps/api/src/app/endpoints/mcp/mcp.schemas.spec.ts index 98f49e0067..2d38923b91 100644 --- a/apps/api/src/app/endpoints/mcp/mcp.schemas.spec.ts +++ b/apps/api/src/app/endpoints/mcp/mcp.schemas.spec.ts @@ -1,21 +1,7 @@ import { MCP_MAX_ACTIVITIES } from '@ghostfolio/common/config'; -import { Type as ActivityType } from '@prisma/client'; - import { IMPORT_ACTIVITIES_PARAMETERS } from './mcp.schemas'; - -function createActivity(overrides: Record = {}) { - return { - currency: 'USD', - date: '2024-01-01', - fee: 0, - quantity: 1, - symbol: 'AAPL', - type: ActivityType.BUY, - unitPrice: 100, - ...overrides - }; -} +import { createActivity } from './mcp.test-utils'; describe('IMPORT_ACTIVITIES_PARAMETERS', () => { function parse(activities: unknown[]) { @@ -45,7 +31,7 @@ describe('IMPORT_ACTIVITIES_PARAMETERS', () => { it('Removes a tag, because the tool takes no tag', () => { expect( IMPORT_ACTIVITIES_PARAMETERS.parse({ - activities: [createActivity({ tags: ['tag-id'] })] + activities: [{ ...createActivity(), tags: ['tag-id'] }] }).activities[0] ).not.toHaveProperty('tags'); }); diff --git a/apps/api/src/app/endpoints/mcp/mcp.service.spec.ts b/apps/api/src/app/endpoints/mcp/mcp.service.spec.ts index 248de09981..ac074c7917 100644 --- a/apps/api/src/app/endpoints/mcp/mcp.service.spec.ts +++ b/apps/api/src/app/endpoints/mcp/mcp.service.spec.ts @@ -1,44 +1,34 @@ import { ImportValidationError } from '@ghostfolio/api/app/import/errors/import-validation.error'; import { ImportService } from '@ghostfolio/api/app/import/import.service'; import { UserService } from '@ghostfolio/api/app/user/user.service'; +import { ApiService } from '@ghostfolio/api/services/api/api.service'; import { ConfigurationService } from '@ghostfolio/api/services/configuration/configuration.service'; -import { Activity } from '@ghostfolio/common/interfaces'; +import { PortfolioTableService } from '@ghostfolio/api/services/portfolio-table/portfolio-table.service'; +import { + DEFAULT_LANGUAGE_CODE, + MCP_MAX_ACTIVITIES +} from '@ghostfolio/common/config'; +import { Activity, Filter } from '@ghostfolio/common/interfaces'; import { permissions } from '@ghostfolio/common/permissions'; import type { UserWithSettings } from '@ghostfolio/common/types'; import { HttpException } from '@nestjs/common'; -import { DataSource, Type as ActivityType } from '@prisma/client'; -import { z } from 'zod'; +import { AssetClass, DataSource, Type as ActivityType } from '@prisma/client'; -import { IMPORT_ACTIVITIES_PARAMETERS } from './mcp.schemas'; import { McpService } from './mcp.service'; - -type ActivityToImport = z.infer< - typeof IMPORT_ACTIVITIES_PARAMETERS ->['activities'][number]; - -function createActivity( - overrides: Partial = {} -): ActivityToImport { - return { - currency: 'USD', - date: '2024-01-01', - fee: 0, - quantity: 1, - symbol: 'AAPL', - type: ActivityType.BUY, - unitPrice: 100, - ...overrides - }; -} +import { createActivity } from './mcp.test-utils'; describe('McpService', () => { + const filters: Filter[] = [{ id: 'account-id', type: 'ACCOUNT' }]; + const userCurrency = 'USD'; const userId = 'user-id'; + let apiService: ApiService; let configuration: Record; let configurationService: ConfigurationService; let importService: ImportService; let mcpService: McpService; + let portfolioTableService: PortfolioTableService; let userService: UserService; function setupUser(userPermissions: string[]) { @@ -59,17 +49,27 @@ describe('McpService', () => { }) } as unknown as ConfigurationService; + apiService = { + buildFiltersFromQueryParams: jest.fn().mockReturnValue(filters) + } as unknown as ApiService; + importService = { import: jest.fn().mockResolvedValue([]) } as unknown as ImportService; + portfolioTableService = { + getAccountsTable: jest.fn().mockResolvedValue('## Accounts'), + getActivitiesTable: jest.fn().mockResolvedValue('## Activities'), + getHoldingsTable: jest.fn().mockResolvedValue('## Holdings') + } as unknown as PortfolioTableService; + userService = { user: jest.fn() } as unknown as UserService; mcpService = new McpService( - undefined, + apiService, configurationService, importService, - undefined, + portfolioTableService, userService ); }); @@ -78,6 +78,110 @@ describe('McpService', () => { jest.restoreAllMocks(); }); + describe('getAccounts', () => { + it('Maps the parameters of the tool to the filters', async () => { + await mcpService.getAccounts({ + userId, + accountIds: ['account-id'], + assetClasses: [AssetClass.EQUITY], + holding: { dataSource: DataSource.YAHOO, symbol: 'AAPL' } + }); + + expect(apiService.buildFiltersFromQueryParams).toHaveBeenCalledWith({ + filterByAccounts: ['account-id'], + filterByAssetClasses: [AssetClass.EQUITY], + filterByDataSource: DataSource.YAHOO, + filterBySymbol: 'AAPL' + }); + }); + + it('Gives the table of the accounts of the filters', async () => { + expect(await mcpService.getAccounts({ userId })).toEqual({ + content: [{ text: '## Accounts', type: 'text' }] + }); + + expect(portfolioTableService.getAccountsTable).toHaveBeenCalledWith({ + filters, + userId + }); + }); + }); + + describe('getActivities', () => { + function getActivities( + parameters: Partial[0]> = {} + ) { + return mcpService.getActivities({ + userCurrency, + userId, + take: MCP_MAX_ACTIVITIES, + ...parameters + }); + } + + it('Maps the parameters of the tool to the filters', async () => { + await getActivities({ + assetClasses: [AssetClass.EQUITY], + holding: { dataSource: DataSource.YAHOO, symbol: 'AAPL' } + }); + + expect(apiService.buildFiltersFromQueryParams).toHaveBeenCalledWith({ + filterByAssetClasses: [AssetClass.EQUITY], + filterByDataSource: DataSource.YAHOO, + filterBySymbol: 'AAPL' + }); + }); + + it('Changes the range into the start date and the end date', async () => { + await getActivities({ range: '2024' }); + + expect(portfolioTableService.getActivitiesTable).toHaveBeenCalledWith( + expect.objectContaining({ + endDate: new Date('2024-12-31T23:59:59.999Z'), + startDate: new Date('2023-12-31T23:59:59.999Z') + }) + ); + }); + + it('Gives no date if the range is absent', async () => { + await getActivities(); + + expect(portfolioTableService.getActivitiesTable).toHaveBeenCalledWith( + expect.objectContaining({ endDate: undefined, startDate: undefined }) + ); + }); + + it('Gives the table of the activities of the filters', async () => { + expect( + await getActivities({ activityTypes: [ActivityType.BUY], skip: 10 }) + ).toEqual({ content: [{ text: '## Activities', type: 'text' }] }); + + expect(portfolioTableService.getActivitiesTable).toHaveBeenCalledWith({ + filters, + userCurrency, + userId, + endDate: undefined, + skip: 10, + startDate: undefined, + take: MCP_MAX_ACTIVITIES, + types: [ActivityType.BUY] + }); + }); + }); + + describe('getPortfolio', () => { + it('Gives the table of the holdings 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 + }); + }); + }); + describe('importActivities', () => { it('Refuses a user without the permission to create an activity', async () => { setupUser([]); diff --git a/apps/api/src/app/endpoints/mcp/mcp.test-utils.ts b/apps/api/src/app/endpoints/mcp/mcp.test-utils.ts new file mode 100644 index 0000000000..84895a64ff --- /dev/null +++ b/apps/api/src/app/endpoints/mcp/mcp.test-utils.ts @@ -0,0 +1,18 @@ +import { Type as ActivityType } from '@prisma/client'; + +import { ActivityToImport } from './types/activity-to-import.type'; + +export function createActivity( + overrides: Partial = {} +): ActivityToImport { + return { + currency: 'USD', + date: '2024-01-01', + fee: 0, + quantity: 1, + symbol: 'AAPL', + type: ActivityType.BUY, + unitPrice: 100, + ...overrides + }; +} diff --git a/apps/api/src/app/endpoints/mcp/types/activity-to-import.type.ts b/apps/api/src/app/endpoints/mcp/types/activity-to-import.type.ts new file mode 100644 index 0000000000..06375d2002 --- /dev/null +++ b/apps/api/src/app/endpoints/mcp/types/activity-to-import.type.ts @@ -0,0 +1,7 @@ +import { z } from 'zod'; + +import { IMPORT_ACTIVITIES_PARAMETERS } from '../mcp.schemas'; + +export type ActivityToImport = z.infer< + typeof IMPORT_ACTIVITIES_PARAMETERS +>['activities'][number]; diff --git a/apps/api/src/helper/interfaces/table-column-definition.interface.ts b/apps/api/src/helper/interfaces/table-column-definition.interface.ts new file mode 100644 index 0000000000..569616623d --- /dev/null +++ b/apps/api/src/helper/interfaces/table-column-definition.interface.ts @@ -0,0 +1,6 @@ +import type { ColumnDescriptor } from 'tablemark'; + +export interface TableColumnDefinition extends ColumnDescriptor { + getValue: (item: T, context: C) => string; + name: string; +} diff --git a/apps/api/src/helper/interfaces/table-parameters.interface.ts b/apps/api/src/helper/interfaces/table-parameters.interface.ts new file mode 100644 index 0000000000..7ab9d7800d --- /dev/null +++ b/apps/api/src/helper/interfaces/table-parameters.interface.ts @@ -0,0 +1,7 @@ +import { TableColumnDefinition } from './table-column-definition.interface'; + +export interface TableParameters { + columnDefinitions: readonly TableColumnDefinition[]; + context?: C; + rows: readonly T[]; +} diff --git a/apps/api/src/helper/markdown-table.helper.spec.ts b/apps/api/src/helper/markdown-table.helper.spec.ts new file mode 100644 index 0000000000..fa1161738b --- /dev/null +++ b/apps/api/src/helper/markdown-table.helper.spec.ts @@ -0,0 +1,77 @@ +import { TableColumnDefinition } from './interfaces/table-column-definition.interface'; +import { getTableInput } from './markdown-table.helper'; + +interface Holding { + name: string; + quantity: number; +} + +interface HoldingContext { + currency: string; +} + +describe('getTableInput', () => { + const holdings: Holding[] = [ + { name: 'Apple', quantity: 2 }, + { name: 'Microsoft', quantity: 30 } + ]; + + const columnDefinitions: TableColumnDefinition[] = [ + { + getValue: ({ name }) => { + return name; + }, + name: 'Name' + }, + { + align: 'right', + getValue: ({ quantity }) => { + return quantity.toString(); + }, + name: 'Quantity' + }, + { + getValue: (_, { currency }) => { + return currency; + }, + name: 'Currency' + } + ]; + + function getInput(rows: Holding[] = holdings) { + return getTableInput({ + columnDefinitions, + rows, + context: { currency: 'USD' } + }); + } + + it('Gives a column for each definition, in the sequence of the definitions', () => { + expect(getInput().columns).toEqual([ + { align: 'left', name: 'Name' }, + { align: 'right', name: 'Quantity' }, + { align: 'left', name: 'Currency' } + ]); + }); + + it('Gives a row for each row, with the name of the column as the key', () => { + expect(getInput().rows).toEqual([ + { Currency: 'USD', Name: 'Apple', Quantity: '2' }, + { Currency: 'USD', Name: 'Microsoft', Quantity: '30' } + ]); + }); + + it('Gives the keys of a row in the sequence of the columns', () => { + const [firstRow] = getInput().rows; + + expect(Object.keys(firstRow)).toEqual( + getInput().columns.map(({ name }) => { + return name; + }) + ); + }); + + it('Gives no row if there is no row', () => { + expect(getInput([]).rows).toEqual([]); + }); +}); diff --git a/apps/api/src/helper/markdown-table.helper.ts b/apps/api/src/helper/markdown-table.helper.ts index 3de23d5393..3bc8a7a8c7 100644 --- a/apps/api/src/helper/markdown-table.helper.ts +++ b/apps/api/src/helper/markdown-table.helper.ts @@ -1,44 +1,44 @@ -import type { ColumnDescriptor } from 'tablemark'; - -export interface TableColumnDefinition extends ColumnDescriptor { - getValue: (item: T) => string; - name: string; -} - -// Dynamic import to load ESM module from CommonJS context -// eslint-disable-next-line @typescript-eslint/no-implied-eval -const dynamicImport = new Function('s', 'return import(s)') as ( - s: string -) => Promise; +import { TableParameters } from './interfaces/table-parameters.interface'; /** - * Renders the rows as a markdown table with one column per definition, which - * gives the title of the column and reads the value of a row + * Gives the columns and the rows in the form which the renderer takes, with + * one column per definition, which gives the title of the column and reads + * the value of a row */ -export async function getMarkdownTable({ +export function getTableInput({ columnDefinitions, + context, rows -}: { - columnDefinitions: readonly TableColumnDefinition[]; - rows: readonly T[]; -}) { - const { tablemark } = await dynamicImport('tablemark'); - - return tablemark( - rows.map((row) => { +}: TableParameters) { + return { + columns: columnDefinitions.map(({ align, name }) => { + return { name, align: align ?? 'left' }; + }), + rows: rows.map((row) => { return columnDefinitions.reduce( (tableRow, { getValue, name }) => { - tableRow[name] = getValue(row); + tableRow[name] = getValue(row, context); return tableRow; }, {} as Record ); - }), - { - columns: columnDefinitions.map(({ align, name }) => { - return { name, align: align ?? 'left' }; - }) - } - ); + }) + }; +} + +export async function getMarkdownTable( + parameters: TableParameters +) { + const { columns, rows } = getTableInput(parameters); + + // Dynamic import to load ESM module from CommonJS context + // eslint-disable-next-line @typescript-eslint/no-implied-eval + const dynamicImport = new Function('s', 'return import(s)') as ( + s: string + ) => Promise; + + const { tablemark } = await dynamicImport('tablemark'); + + return tablemark(rows, { columns }); } diff --git a/apps/api/src/services/portfolio-table/interfaces/holdings-table-context.interface.ts b/apps/api/src/services/portfolio-table/interfaces/holdings-table-context.interface.ts new file mode 100644 index 0000000000..4364b492e7 --- /dev/null +++ b/apps/api/src/services/portfolio-table/interfaces/holdings-table-context.interface.ts @@ -0,0 +1,6 @@ +import { AssetClass, AssetSubClass } from '@prisma/client'; + +export interface HoldingsTableContext { + assetClassTranslations: Record; + assetSubClassTranslations: Record; +} 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 ae1d2b9cae..2d9287051a 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 @@ -1,42 +1,53 @@ import type { PortfolioService } from '@ghostfolio/api/app/portfolio/portfolio.service'; -import { TAG_ID_EXCLUDE_FROM_ANALYSIS } from '@ghostfolio/common/config'; +import type { TableParameters } from '@ghostfolio/api/helper/interfaces/table-parameters.interface'; +import type { I18nService } from '@ghostfolio/api/services/i18n/i18n.service'; +import { + DEFAULT_LANGUAGE_CODE, + TAG_ID_EXCLUDE_FROM_ANALYSIS +} from '@ghostfolio/common/config'; +import { PortfolioPosition } from '@ghostfolio/common/interfaces'; import { AccountWithValue } from '@ghostfolio/common/types'; +import { AssetClass, AssetSubClass } from '@prisma/client'; + import { PortfolioTableService } from './portfolio-table.service'; /** * The markdown table is rendered by a package which ships as an ECMAScript - * module only, which Jest cannot run. The mock renders the columns and the - * rows in a simple form, so that the tests can read them. + * module only, which Jest cannot run. The mock keeps the mapping of the + * columns and of the rows and writes them in the same shape as the renderer */ jest.mock('@ghostfolio/api/helper/markdown-table.helper', () => { + const { getTableInput } = jest.requireActual< + typeof import('@ghostfolio/api/helper/markdown-table.helper') + >('@ghostfolio/api/helper/markdown-table.helper'); + return { + getTableInput, getMarkdownTable: jest.fn( - ({ - columnDefinitions, - rows - }: { - columnDefinitions: { - getValue: (row: unknown) => string; - name: string; - }[]; - rows: unknown[]; - }) => { + (parameters: TableParameters) => { + const { columns, rows } = getTableInput(parameters); + + const names = columns.map(({ name }) => { + return name; + }); + return Promise.resolve( [ - columnDefinitions - .map(({ name }) => { - return name; - }) - .join(' | '), + names, + names.map(() => { + return '---'; + }), ...rows.map((row) => { - return columnDefinitions - .map(({ getValue }) => { - return getValue(row); - }) - .join(' | '); + return names.map((name) => { + return row[name]; + }); }) - ].join('\n') + ] + .map((cells) => { + return `| ${cells.join(' | ')} |`; + }) + .join('\n') ); } ) @@ -65,12 +76,56 @@ function createAccount({ } as unknown as AccountWithValue; } -function createPortfolioTableService(accounts: AccountWithValue[]) { +function createHolding({ + allocationInPercentage = 0.75, + assetClass = AssetClass.EQUITY, + assetSubClass = AssetSubClass.STOCK, + symbol = 'AAPL' +}: { + allocationInPercentage?: number; + assetClass?: AssetClass; + assetSubClass?: AssetSubClass; + symbol?: string; +} = {}) { + return { + allocationInPercentage, + activitiesCount: 3, + assetProfile: { + assetClass, + assetSubClass, + symbol, + currency: 'CHF', + name: `Name of ${symbol}` + }, + dateOfFirstActivity: new Date('2024-01-01'), + grossPerformance: 100, + netPerformance: 90, + quantity: 5, + valueInBaseCurrency: 2000 + } as unknown as PortfolioPosition; +} + +function createPortfolioTableService({ + accounts = [], + holdings = [] +}: { + accounts?: AccountWithValue[]; + holdings?: PortfolioPosition[]; +} = {}) { + // 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 + const i18nService = { + getTranslation: jest.fn(({ id }: { id: string }) => { + return `translation of ${id}`; + }) + } as unknown as I18nService; + const portfolioService = { - getAccountsWithAggregations: jest.fn().mockResolvedValue({ accounts }) + getAccountsWithAggregations: jest.fn().mockResolvedValue({ accounts }), + getDetails: jest.fn().mockResolvedValue({ holdings }) } as unknown as PortfolioService; - return new PortfolioTableService(null, null, portfolioService); + return new PortfolioTableService(null, i18nService, portfolioService); } describe('PortfolioTableService', () => { @@ -122,9 +177,9 @@ describe('PortfolioTableService', () => { describe('getAccountsTable', () => { it('gives no cash balance and no value of an account', async () => { - const portfolioTableService = createPortfolioTableService([ - createAccount() - ]); + const portfolioTableService = createPortfolioTableService({ + accounts: [createAccount()] + }); const result = await portfolioTableService.getAccountsTable({ userId: 'user-id' @@ -138,9 +193,9 @@ describe('PortfolioTableService', () => { // The accountIds parameter of the tool takes the identifiers, hence the // table has to give them it('gives the identifier of an account', async () => { - const portfolioTableService = createPortfolioTableService([ - createAccount() - ]); + const portfolioTableService = createPortfolioTableService({ + accounts: [createAccount()] + }); const result = await portfolioTableService.getAccountsTable({ userId: 'user-id' @@ -150,10 +205,12 @@ describe('PortfolioTableService', () => { }); it('marks an account which is excluded from the analysis', async () => { - const portfolioTableService = createPortfolioTableService([ - createAccount({ isExcluded: true }), - createAccount({ id: 'account-b-id', name: 'Account B' }) - ]); + const portfolioTableService = createPortfolioTableService({ + accounts: [ + createAccount({ isExcluded: true }), + createAccount({ id: 'account-b-id', name: 'Account B' }) + ] + }); const result = await portfolioTableService.getAccountsTable({ userId: 'user-id' @@ -162,7 +219,7 @@ describe('PortfolioTableService', () => { const [rowOfAccountA, rowOfAccountB] = result .split('\n') .filter((line) => { - return line.startsWith('account-'); + return line.startsWith('| account-'); }); expect(rowOfAccountA).toContain('true'); @@ -170,7 +227,7 @@ describe('PortfolioTableService', () => { }); it('tells that no accounts are found if the result is empty', async () => { - const portfolioTableService = createPortfolioTableService([]); + const portfolioTableService = createPortfolioTableService(); const result = await portfolioTableService.getAccountsTable({ userId: 'user-id' @@ -179,4 +236,37 @@ describe('PortfolioTableService', () => { expect(result).toContain('No accounts found.'); }); }); + describe('getHoldingsTable', () => { + function getHoldingsTable(holdings: PortfolioPosition[]) { + return createPortfolioTableService({ holdings }).getHoldingsTable({ + languageCode: DEFAULT_LANGUAGE_CODE, + userId: 'user-id' + }); + } + + it('gives the translation of the asset class and of the asset sub class', async () => { + const result = await getHoldingsTable([createHolding()]); + + const [row] = result.split('\n').filter((line) => { + return line.startsWith('| Name of AAPL'); + }); + + expect(row).toContain('translation of assetClass.EQUITY'); + expect(row).toContain('translation of assetSubClass.STOCK'); + }); + + it('gives the holding with the largest allocation first', async () => { + const result = await getHoldingsTable([ + createHolding({ allocationInPercentage: 0.25, symbol: 'MSFT' }), + createHolding({ allocationInPercentage: 0.75, symbol: 'AAPL' }) + ]); + + const [firstRow, secondRow] = result.split('\n').filter((line) => { + return line.startsWith('| Name of'); + }); + + expect(firstRow).toContain('AAPL'); + expect(secondRow).toContain('MSFT'); + }); + }); }); 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 914b4f9911..f8cc3cfc67 100644 --- a/apps/api/src/services/portfolio-table/portfolio-table.service.ts +++ b/apps/api/src/services/portfolio-table/portfolio-table.service.ts @@ -1,16 +1,10 @@ import { ActivitiesService } from '@ghostfolio/api/app/activities/activities.service'; import { PortfolioService } from '@ghostfolio/api/app/portfolio/portfolio.service'; -import { - getMarkdownTable, - TableColumnDefinition -} from '@ghostfolio/api/helper/markdown-table.helper'; +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, - PortfolioPosition -} from '@ghostfolio/common/interfaces'; +import { Activity, Filter } from '@ghostfolio/common/interfaces'; import { AccountWithValue } from '@ghostfolio/common/types'; import { Injectable } from '@nestjs/common'; @@ -21,14 +15,7 @@ import { } from '@prisma/client'; import { format } from 'date-fns'; -/** - * A holding with the asset class and the asset sub class in the language of - * the caller, because the column of a table cannot translate a value itself - */ -type HoldingWithTranslations = PortfolioPosition & { - assetClassLabel: string; - assetSubClassLabel: string; -}; +import { HoldingsTableColumnDefinition } from './types/holdings-table-column-definition.type'; function getAllocationInPercentage(allocationInPercentage: number) { return `${(allocationInPercentage * 100).toFixed(3)}%`; @@ -136,7 +123,7 @@ export class PortfolioTableService { } ]; - private static readonly HOLDINGS_TABLE_COLUMN_DEFINITIONS: TableColumnDefinition[] = + private static readonly HOLDINGS_TABLE_COLUMN_DEFINITIONS: HoldingsTableColumnDefinition[] = [ { getValue: ({ assetProfile }) => { @@ -157,14 +144,14 @@ export class PortfolioTableService { name: 'Currency' }, { - getValue: ({ assetClassLabel }) => { - return assetClassLabel; + getValue: ({ assetProfile }, { assetClassTranslations }) => { + return assetClassTranslations[assetProfile.assetClass] ?? ''; }, name: 'Asset Class' }, { - getValue: ({ assetSubClassLabel }) => { - return assetSubClassLabel; + getValue: ({ assetProfile }, { assetSubClassTranslations }) => { + return assetSubClassTranslations[assetProfile.assetSubClass] ?? ''; }, name: 'Asset Sub Class' }, @@ -337,19 +324,9 @@ export class PortfolioTableService { values: Object.values(AssetSubClass) }); - const holdingsWithTranslations = [...holdings] - .sort((a, b) => { - return b.allocationInPercentage - a.allocationInPercentage; - }) - .map((holding) => { - return { - ...holding, - assetClassLabel: - assetClassTranslations[holding.assetProfile.assetClass] ?? '', - assetSubClassLabel: - assetSubClassTranslations[holding.assetProfile.assetSubClass] ?? '' - }; - }); + const sortedHoldings = [...holdings].sort((a, b) => { + return b.allocationInPercentage - a.allocationInPercentage; + }); return [ '## Holdings', @@ -357,7 +334,8 @@ export class PortfolioTableService { await getMarkdownTable({ columnDefinitions: PortfolioTableService.HOLDINGS_TABLE_COLUMN_DEFINITIONS, - rows: holdingsWithTranslations + context: { assetClassTranslations, assetSubClassTranslations }, + rows: sortedHoldings }) ].join('\n'); } diff --git a/apps/api/src/services/portfolio-table/types/holdings-table-column-definition.type.ts b/apps/api/src/services/portfolio-table/types/holdings-table-column-definition.type.ts new file mode 100644 index 0000000000..a8ad63fe6f --- /dev/null +++ b/apps/api/src/services/portfolio-table/types/holdings-table-column-definition.type.ts @@ -0,0 +1,9 @@ +import { TableColumnDefinition } from '@ghostfolio/api/helper/interfaces/table-column-definition.interface'; +import { PortfolioPosition } from '@ghostfolio/common/interfaces'; + +import { HoldingsTableContext } from '../interfaces/holdings-table-context.interface'; + +export type HoldingsTableColumnDefinition = TableColumnDefinition< + PortfolioPosition, + HoldingsTableContext +>;