mirror of https://github.com/ghostfolio/ghostfolio
Browse Source
* Move the logic of the tools to the McpService * Move the tables of the portfolio to the PortfolioTableService * Render the tables from one column definition with a value functionpull/7754/head
committed by
GitHub
20 changed files with 1426 additions and 1055 deletions
@ -1,164 +0,0 @@ |
|||
import type { PortfolioService } from '@ghostfolio/api/app/portfolio/portfolio.service'; |
|||
import { TAG_ID_EXCLUDE_FROM_ANALYSIS } from '@ghostfolio/common/config'; |
|||
import { AccountWithValue } from '@ghostfolio/common/types'; |
|||
|
|||
import { AiService } from './ai.service'; |
|||
|
|||
// The service imports two packages which ship as an ECMAScript module only,
|
|||
// which Jest cannot transform. The mocks only make the imports resolvable,
|
|||
// because no test calls them.
|
|||
jest.mock('@openrouter/ai-sdk-provider', () => { |
|||
return { createOpenRouter: jest.fn() }; |
|||
}); |
|||
|
|||
jest.mock('ai', () => { |
|||
return { generateText: jest.fn() }; |
|||
}); |
|||
|
|||
/** |
|||
* The markdown table is rendered by a package which ships as an ECMAScript |
|||
* module only, hence the service loads it with a dynamic import which Jest |
|||
* cannot run. The tests replace the method by a simple renderer, so that they |
|||
* can read the columns and the rows which the service gives to it. |
|||
*/ |
|||
interface AiServiceWithMarkdownTable { |
|||
getMarkdownTable(parameters: { |
|||
columnDefinitions: readonly { name: string }[]; |
|||
rows: Record<string, string>[]; |
|||
}): Promise<string>; |
|||
} |
|||
|
|||
function createAccount({ |
|||
id = 'account-a-id', |
|||
isExcluded = false, |
|||
name = 'Account A' |
|||
}: { |
|||
id?: string; |
|||
isExcluded?: boolean; |
|||
name?: string; |
|||
} = {}) { |
|||
return { |
|||
id, |
|||
name, |
|||
activitiesCount: 3, |
|||
allocationInPercentage: 0.25, |
|||
balance: 1000, |
|||
currency: 'CHF', |
|||
platform: { name: 'Platform A' }, |
|||
tags: isExcluded ? [{ id: TAG_ID_EXCLUDE_FROM_ANALYSIS }] : [], |
|||
value: 2000 |
|||
} as unknown as AccountWithValue; |
|||
} |
|||
|
|||
function createAiService(accounts: AccountWithValue[]) { |
|||
const portfolioService = { |
|||
getAccountsWithAggregations: jest.fn().mockResolvedValue({ accounts }) |
|||
} as unknown as PortfolioService; |
|||
|
|||
const aiService = new AiService(null, null, null, portfolioService, null); |
|||
|
|||
jest |
|||
.spyOn( |
|||
aiService as unknown as AiServiceWithMarkdownTable, |
|||
'getMarkdownTable' |
|||
) |
|||
.mockImplementation(async ({ columnDefinitions, rows }) => { |
|||
const columnNames = columnDefinitions.map(({ name }) => { |
|||
return name; |
|||
}); |
|||
|
|||
return [ |
|||
columnNames.join(' | '), |
|||
...rows.map((row) => { |
|||
return columnNames |
|||
.map((columnName) => { |
|||
return row[columnName]; |
|||
}) |
|||
.join(' | '); |
|||
}) |
|||
].join('\n'); |
|||
}); |
|||
|
|||
return aiService; |
|||
} |
|||
|
|||
describe('AiService', () => { |
|||
// The tools of the model context protocol are the only callers, and an
|
|||
// access of that type never grants the scope to read the monetary values,
|
|||
// hence no table has a column with such a value
|
|||
describe('getAccountsTableColumnNames', () => { |
|||
it('gives no column with a monetary value', () => { |
|||
expect(AiService.getAccountsTableColumnNames()).toEqual([ |
|||
'Id', |
|||
'Name', |
|||
'Currency', |
|||
'Platform', |
|||
'Activities Count', |
|||
'Allocation in Percentage', |
|||
'Excluded from Analysis' |
|||
]); |
|||
}); |
|||
}); |
|||
|
|||
describe('getActivitiesTableColumnNames', () => { |
|||
it('gives no column with a monetary value', () => { |
|||
expect(AiService.getActivitiesTableColumnNames()).toEqual([ |
|||
'Date', |
|||
'Type', |
|||
'Name', |
|||
'Symbol', |
|||
'Currency', |
|||
'Unit Price', |
|||
'Account' |
|||
]); |
|||
}); |
|||
}); |
|||
|
|||
describe('getAccountsTable', () => { |
|||
it('gives no cash balance and no value of an account', async () => { |
|||
const aiService = createAiService([createAccount()]); |
|||
|
|||
const result = await aiService.getAccountsTable({ userId: 'user-id' }); |
|||
|
|||
expect(result).not.toContain('Cash Balance'); |
|||
expect(result).not.toContain('1000'); |
|||
expect(result).not.toContain('2000'); |
|||
}); |
|||
|
|||
// 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 aiService = createAiService([createAccount()]); |
|||
|
|||
const result = await aiService.getAccountsTable({ userId: 'user-id' }); |
|||
|
|||
expect(result).toContain('account-a-id'); |
|||
}); |
|||
|
|||
it('marks an account which is excluded from the analysis', async () => { |
|||
const aiService = createAiService([ |
|||
createAccount({ isExcluded: true }), |
|||
createAccount({ id: 'account-b-id', name: 'Account B' }) |
|||
]); |
|||
|
|||
const result = await aiService.getAccountsTable({ userId: 'user-id' }); |
|||
|
|||
const [rowOfAccountA, rowOfAccountB] = result |
|||
.split('\n') |
|||
.filter((line) => { |
|||
return line.startsWith('account-'); |
|||
}); |
|||
|
|||
expect(rowOfAccountA).toContain('true'); |
|||
expect(rowOfAccountB).toContain('false'); |
|||
}); |
|||
|
|||
it('tells that no accounts are found if the result is empty', async () => { |
|||
const aiService = createAiService([]); |
|||
|
|||
const result = await aiService.getAccountsTable({ userId: 'user-id' }); |
|||
|
|||
expect(result).toContain('No accounts found.'); |
|||
}); |
|||
}); |
|||
}); |
|||
@ -0,0 +1,48 @@ |
|||
import { MCP_MAX_ACTIVITIES } from '@ghostfolio/common/config'; |
|||
|
|||
import { IMPORT_ACTIVITIES_PARAMETERS } from './mcp.schemas'; |
|||
import { createActivity } from './mcp.test-utils'; |
|||
|
|||
describe('IMPORT_ACTIVITIES_PARAMETERS', () => { |
|||
function parse(activities: unknown[]) { |
|||
return IMPORT_ACTIVITIES_PARAMETERS.safeParse({ activities }).success; |
|||
} |
|||
|
|||
it('Refuses a currency in lower case', () => { |
|||
expect(parse([createActivity({ currency: 'usd' })])).toBe(false); |
|||
}); |
|||
|
|||
it('Accepts a currency in upper case', () => { |
|||
expect(parse([createActivity({ currency: 'USD' })])).toBe(true); |
|||
}); |
|||
|
|||
it('Refuses a date at or before the epoch', () => { |
|||
expect(parse([createActivity({ date: '0000-01-01' })])).toBe(false); |
|||
}); |
|||
|
|||
it('Refuses an empty symbol', () => { |
|||
expect(parse([createActivity({ symbol: '' })])).toBe(false); |
|||
}); |
|||
|
|||
it('Refuses an empty identifier of an account', () => { |
|||
expect(parse([createActivity({ accountId: '' })])).toBe(false); |
|||
}); |
|||
|
|||
it('Removes a tag, because the tool takes no tag', () => { |
|||
expect( |
|||
IMPORT_ACTIVITIES_PARAMETERS.parse({ |
|||
activities: [{ ...createActivity(), tags: ['tag-id'] }] |
|||
}).activities[0] |
|||
).not.toHaveProperty('tags'); |
|||
}); |
|||
|
|||
it(`Refuses more than ${MCP_MAX_ACTIVITIES} activities`, () => { |
|||
expect( |
|||
parse( |
|||
Array.from({ length: MCP_MAX_ACTIVITIES + 1 }, () => { |
|||
return createActivity(); |
|||
}) |
|||
) |
|||
).toBe(false); |
|||
}); |
|||
}); |
|||
@ -0,0 +1,279 @@ |
|||
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 { 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 { AssetClass, DataSource, Type as ActivityType } from '@prisma/client'; |
|||
|
|||
import { McpService } from './mcp.service'; |
|||
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<string, unknown>; |
|||
let configurationService: ConfigurationService; |
|||
let importService: ImportService; |
|||
let mcpService: McpService; |
|||
let portfolioTableService: PortfolioTableService; |
|||
let userService: UserService; |
|||
|
|||
function setupUser(userPermissions: string[]) { |
|||
jest.spyOn(userService, 'user').mockResolvedValue({ |
|||
permissions: userPermissions |
|||
} as UserWithSettings); |
|||
} |
|||
|
|||
beforeEach(() => { |
|||
configuration = { |
|||
DATA_SOURCES_GHOSTFOLIO_DATA_PROVIDER: [], |
|||
ENABLE_FEATURE_SUBSCRIPTION: false |
|||
}; |
|||
|
|||
configurationService = { |
|||
get: jest.fn().mockImplementation((key: string) => { |
|||
return configuration[key]; |
|||
}) |
|||
} 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( |
|||
apiService, |
|||
configurationService, |
|||
importService, |
|||
portfolioTableService, |
|||
userService |
|||
); |
|||
}); |
|||
|
|||
afterEach(() => { |
|||
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<Parameters<McpService['getActivities']>[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([]); |
|||
|
|||
await expect( |
|||
mcpService.importActivities({ |
|||
userId, |
|||
activities: [createActivity()] |
|||
}) |
|||
).rejects.toThrow(HttpException); |
|||
|
|||
expect(importService.import).not.toHaveBeenCalled(); |
|||
}); |
|||
|
|||
it('Gives the number of the imported and of the skipped activities', async () => { |
|||
setupUser([permissions.createActivity]); |
|||
|
|||
jest |
|||
.spyOn(importService, 'import') |
|||
.mockResolvedValue([{ id: 'activity-id' } as Activity]); |
|||
|
|||
expect( |
|||
await mcpService.importActivities({ |
|||
userId, |
|||
activities: [createActivity(), createActivity({ quantity: 2 })] |
|||
}) |
|||
).toEqual({ |
|||
content: [ |
|||
{ |
|||
text: 'Imported activities: 1\nSkipped duplicate activities: 1', |
|||
type: 'text' |
|||
} |
|||
] |
|||
}); |
|||
}); |
|||
|
|||
it('Resolves the mask of the data source of the Ghostfolio data provider', async () => { |
|||
setupUser([permissions.createActivity]); |
|||
|
|||
configuration.DATA_SOURCES_GHOSTFOLIO_DATA_PROVIDER = [DataSource.YAHOO]; |
|||
configuration.ENABLE_FEATURE_SUBSCRIPTION = true; |
|||
|
|||
await mcpService.importActivities({ |
|||
userId, |
|||
activities: [createActivity({ dataSource: DataSource.GHOSTFOLIO })] |
|||
}); |
|||
|
|||
expect(importService.import).toHaveBeenCalledWith( |
|||
expect.objectContaining({ |
|||
activitiesDto: [ |
|||
expect.objectContaining({ dataSource: DataSource.YAHOO }) |
|||
] |
|||
}) |
|||
); |
|||
}); |
|||
|
|||
it('Keeps the data source if the subscription is not enabled', async () => { |
|||
setupUser([permissions.createActivity]); |
|||
|
|||
configuration.DATA_SOURCES_GHOSTFOLIO_DATA_PROVIDER = [DataSource.YAHOO]; |
|||
configuration.ENABLE_FEATURE_SUBSCRIPTION = false; |
|||
|
|||
await mcpService.importActivities({ |
|||
userId, |
|||
activities: [createActivity({ dataSource: DataSource.GHOSTFOLIO })] |
|||
}); |
|||
|
|||
expect(importService.import).toHaveBeenCalledWith( |
|||
expect.objectContaining({ |
|||
activitiesDto: [ |
|||
expect.objectContaining({ dataSource: DataSource.GHOSTFOLIO }) |
|||
] |
|||
}) |
|||
); |
|||
}); |
|||
|
|||
// The McpToolExceptionFilter maps the error, hence the tool passes it on
|
|||
it('Passes on an error of the import', async () => { |
|||
setupUser([permissions.createActivity]); |
|||
|
|||
const error = new ImportValidationError( |
|||
'activities.0.symbol ("X") is not valid' |
|||
); |
|||
|
|||
jest.spyOn(importService, 'import').mockRejectedValue(error); |
|||
|
|||
await expect( |
|||
mcpService.importActivities({ |
|||
userId, |
|||
activities: [createActivity()] |
|||
}) |
|||
).rejects.toBe(error); |
|||
}); |
|||
}); |
|||
}); |
|||
@ -1,8 +1,176 @@ |
|||
import { Injectable } from '@nestjs/common'; |
|||
import { ImportService } from '@ghostfolio/api/app/import/import.service'; |
|||
import { UserService } from '@ghostfolio/api/app/user/user.service'; |
|||
import { getUnmaskedGhostfolioDataSource } from '@ghostfolio/api/helper/data-source.helper'; |
|||
import { ApiService } from '@ghostfolio/api/services/api/api.service'; |
|||
import { ConfigurationService } from '@ghostfolio/api/services/configuration/configuration.service'; |
|||
import { PortfolioTableService } from '@ghostfolio/api/services/portfolio-table/portfolio-table.service'; |
|||
import { getIntervalFromDateRange } from '@ghostfolio/common/calculation-helper'; |
|||
import { DEFAULT_LANGUAGE_CODE } from '@ghostfolio/common/config'; |
|||
import { hasPermission, permissions } from '@ghostfolio/common/permissions'; |
|||
|
|||
import { HttpException, Injectable } from '@nestjs/common'; |
|||
import { getReasonPhrase, StatusCodes } from 'http-status-codes'; |
|||
import { z } from 'zod'; |
|||
|
|||
import { |
|||
GET_ACCOUNTS_PARAMETERS, |
|||
GET_ACTIVITIES_PARAMETERS, |
|||
IMPORT_ACTIVITIES_PARAMETERS |
|||
} from './mcp.schemas'; |
|||
|
|||
@Injectable() |
|||
export class McpService { |
|||
public getTextResult(text: string) { |
|||
public constructor( |
|||
private readonly apiService: ApiService, |
|||
private readonly configurationService: ConfigurationService, |
|||
private readonly importService: ImportService, |
|||
private readonly portfolioTableService: PortfolioTableService, |
|||
private readonly userService: UserService |
|||
) {} |
|||
|
|||
public async getAccounts({ |
|||
accountIds, |
|||
assetClasses, |
|||
holding, |
|||
userId |
|||
}: z.infer<typeof GET_ACCOUNTS_PARAMETERS> & { userId: string }) { |
|||
const filters = this.apiService.buildFiltersFromQueryParams({ |
|||
filterByAccounts: accountIds, |
|||
filterByAssetClasses: assetClasses, |
|||
filterByDataSource: holding?.dataSource, |
|||
filterBySymbol: holding?.symbol |
|||
}); |
|||
|
|||
const table = await this.portfolioTableService.getAccountsTable({ |
|||
filters, |
|||
userId |
|||
}); |
|||
|
|||
return this.getTextResult(table); |
|||
} |
|||
|
|||
public async getActivities({ |
|||
activityTypes, |
|||
assetClasses, |
|||
holding, |
|||
range, |
|||
skip, |
|||
take, |
|||
userCurrency, |
|||
userId |
|||
}: z.infer<typeof GET_ACTIVITIES_PARAMETERS> & { |
|||
userCurrency: string; |
|||
userId: string; |
|||
}) { |
|||
let endDate: Date | undefined; |
|||
let startDate: Date | undefined; |
|||
|
|||
if (range) { |
|||
({ endDate, startDate } = getIntervalFromDateRange({ |
|||
dateRange: range |
|||
})); |
|||
} |
|||
|
|||
const filters = this.apiService.buildFiltersFromQueryParams({ |
|||
filterByAssetClasses: assetClasses, |
|||
filterByDataSource: holding?.dataSource, |
|||
filterBySymbol: holding?.symbol |
|||
}); |
|||
|
|||
const table = await this.portfolioTableService.getActivitiesTable({ |
|||
endDate, |
|||
filters, |
|||
skip, |
|||
startDate, |
|||
take, |
|||
userCurrency, |
|||
userId, |
|||
types: activityTypes |
|||
}); |
|||
|
|||
return this.getTextResult(table); |
|||
} |
|||
|
|||
public async getPortfolio({ userId }: { userId: string }) { |
|||
const table = await this.portfolioTableService.getHoldingsTable({ |
|||
userId, |
|||
languageCode: DEFAULT_LANGUAGE_CODE |
|||
}); |
|||
|
|||
return this.getTextResult(table); |
|||
} |
|||
|
|||
public async importActivities({ |
|||
activities, |
|||
userId |
|||
}: z.infer<typeof IMPORT_ACTIVITIES_PARAMETERS> & { userId: string }) { |
|||
const user = await this.getUserWithPermission({ |
|||
userId, |
|||
permission: permissions.createActivity |
|||
}); |
|||
|
|||
const ghostfolioDataSources = this.configurationService.get( |
|||
'ENABLE_FEATURE_SUBSCRIPTION' |
|||
) |
|||
? this.configurationService.get('DATA_SOURCES_GHOSTFOLIO_DATA_PROVIDER') |
|||
: []; |
|||
|
|||
const activitiesDto = activities.map((activity) => { |
|||
return { |
|||
...activity, |
|||
dataSource: getUnmaskedGhostfolioDataSource({ |
|||
ghostfolioDataSources, |
|||
dataSource: activity.dataSource |
|||
}) |
|||
}; |
|||
}); |
|||
|
|||
// The filter passes on the message of a CallerFacingError, which is
|
|||
// written for the caller, and hides the message of every other error
|
|||
const importedActivities = await this.importService.import({ |
|||
activitiesDto, |
|||
user, |
|||
accountsWithBalancesDto: [], |
|||
assetProfilesWithMarketDataDto: [], |
|||
platformsDto: [], |
|||
tagsDto: [] |
|||
}); |
|||
|
|||
const text = [ |
|||
`Imported activities: ${importedActivities.length}`, |
|||
`Skipped duplicate activities: ${ |
|||
activities.length - importedActivities.length |
|||
}` |
|||
].join('\n'); |
|||
|
|||
return this.getTextResult(text); |
|||
} |
|||
|
|||
private getTextResult(text: string) { |
|||
return { content: [{ text, type: 'text' as const }] }; |
|||
} |
|||
|
|||
/** |
|||
* Gives the user of the access, if the role of the user has the permission. |
|||
* The scope of the access is evaluated separately by the ScopeGuard, hence a |
|||
* tool which changes data has to call this. |
|||
*/ |
|||
private async getUserWithPermission({ |
|||
permission, |
|||
userId |
|||
}: { |
|||
permission: string; |
|||
userId: string; |
|||
}) { |
|||
const user = await this.userService.user({ id: userId }); |
|||
|
|||
if (!hasPermission(user?.permissions, permission)) { |
|||
throw new HttpException( |
|||
getReasonPhrase(StatusCodes.FORBIDDEN), |
|||
StatusCodes.FORBIDDEN |
|||
); |
|||
} |
|||
|
|||
return user; |
|||
} |
|||
} |
|||
|
|||
@ -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> = {} |
|||
): ActivityToImport { |
|||
return { |
|||
currency: 'USD', |
|||
date: '2024-01-01', |
|||
fee: 0, |
|||
quantity: 1, |
|||
symbol: 'AAPL', |
|||
type: ActivityType.BUY, |
|||
unitPrice: 100, |
|||
...overrides |
|||
}; |
|||
} |
|||
@ -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]; |
|||
@ -0,0 +1,6 @@ |
|||
import type { ColumnDescriptor } from 'tablemark'; |
|||
|
|||
export interface TableColumnDefinition<T, C = void> extends ColumnDescriptor { |
|||
getValue: (item: T, context: C) => string; |
|||
name: string; |
|||
} |
|||
@ -0,0 +1,7 @@ |
|||
import { TableColumnDefinition } from './table-column-definition.interface'; |
|||
|
|||
export interface TableParameters<T, C> { |
|||
columnDefinitions: readonly TableColumnDefinition<T, C>[]; |
|||
context?: C; |
|||
rows: readonly T[]; |
|||
} |
|||
@ -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<Holding, HoldingContext>[] = [ |
|||
{ |
|||
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([]); |
|||
}); |
|||
}); |
|||
@ -0,0 +1,44 @@ |
|||
import { TableParameters } from './interfaces/table-parameters.interface'; |
|||
|
|||
/** |
|||
* 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 function getTableInput<T, C = void>({ |
|||
columnDefinitions, |
|||
context, |
|||
rows |
|||
}: TableParameters<T, C>) { |
|||
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, context); |
|||
|
|||
return tableRow; |
|||
}, |
|||
{} as Record<string, string> |
|||
); |
|||
}) |
|||
}; |
|||
} |
|||
|
|||
export async function getMarkdownTable<T, C = void>( |
|||
parameters: TableParameters<T, C> |
|||
) { |
|||
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<typeof import('tablemark')>; |
|||
|
|||
const { tablemark } = await dynamicImport('tablemark'); |
|||
|
|||
return tablemark(rows, { columns }); |
|||
} |
|||
@ -0,0 +1,6 @@ |
|||
import { AssetClass, AssetSubClass } from '@prisma/client'; |
|||
|
|||
export interface HoldingsTableContext { |
|||
assetClassTranslations: Record<AssetClass, string>; |
|||
assetSubClassTranslations: Record<AssetSubClass, string>; |
|||
} |
|||
@ -0,0 +1,14 @@ |
|||
import { ActivitiesModule } from '@ghostfolio/api/app/activities/activities.module'; |
|||
import { PortfolioModule } from '@ghostfolio/api/app/portfolio/portfolio.module'; |
|||
import { I18nModule } from '@ghostfolio/api/services/i18n/i18n.module'; |
|||
|
|||
import { Module } from '@nestjs/common'; |
|||
|
|||
import { PortfolioTableService } from './portfolio-table.service'; |
|||
|
|||
@Module({ |
|||
exports: [PortfolioTableService], |
|||
imports: [ActivitiesModule, I18nModule, PortfolioModule], |
|||
providers: [PortfolioTableService] |
|||
}) |
|||
export class PortfolioTableModule {} |
|||
@ -0,0 +1,272 @@ |
|||
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'; |
|||
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 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( |
|||
(parameters: TableParameters<unknown, unknown>) => { |
|||
const { columns, rows } = getTableInput(parameters); |
|||
|
|||
const names = columns.map(({ name }) => { |
|||
return name; |
|||
}); |
|||
|
|||
return Promise.resolve( |
|||
[ |
|||
names, |
|||
names.map(() => { |
|||
return '---'; |
|||
}), |
|||
...rows.map((row) => { |
|||
return names.map((name) => { |
|||
return row[name]; |
|||
}); |
|||
}) |
|||
] |
|||
.map((cells) => { |
|||
return `| ${cells.join(' | ')} |`; |
|||
}) |
|||
.join('\n') |
|||
); |
|||
} |
|||
) |
|||
}; |
|||
}); |
|||
|
|||
function createAccount({ |
|||
id = 'account-a-id', |
|||
isExcluded = false, |
|||
name = 'Account A' |
|||
}: { |
|||
id?: string; |
|||
isExcluded?: boolean; |
|||
name?: string; |
|||
} = {}) { |
|||
return { |
|||
id, |
|||
name, |
|||
activitiesCount: 3, |
|||
allocationInPercentage: 0.25, |
|||
balance: 1000, |
|||
currency: 'CHF', |
|||
platform: { name: 'Platform A' }, |
|||
tags: isExcluded ? [{ id: TAG_ID_EXCLUDE_FROM_ANALYSIS }] : [], |
|||
value: 2000 |
|||
} as unknown as 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 }), |
|||
getDetails: jest.fn().mockResolvedValue({ holdings }) |
|||
} as unknown as PortfolioService; |
|||
|
|||
return new PortfolioTableService(null, i18nService, portfolioService); |
|||
} |
|||
|
|||
describe('PortfolioTableService', () => { |
|||
// The tools of the model context protocol are the only callers, and an
|
|||
// access of that type never grants the scope to read the monetary values,
|
|||
// hence no table has a column with such a value
|
|||
describe('getAccountsTableColumnNames', () => { |
|||
it('gives no column with a monetary value', () => { |
|||
expect(PortfolioTableService.getAccountsTableColumnNames()).toEqual([ |
|||
'Id', |
|||
'Name', |
|||
'Currency', |
|||
'Platform', |
|||
'Activities Count', |
|||
'Allocation in Percentage', |
|||
'Excluded from Analysis' |
|||
]); |
|||
}); |
|||
}); |
|||
|
|||
describe('getActivitiesTableColumnNames', () => { |
|||
it('gives no column with a monetary value', () => { |
|||
expect(PortfolioTableService.getActivitiesTableColumnNames()).toEqual([ |
|||
'Date', |
|||
'Type', |
|||
'Name', |
|||
'Symbol', |
|||
'Currency', |
|||
'Unit Price', |
|||
'Account' |
|||
]); |
|||
}); |
|||
}); |
|||
|
|||
describe('getHoldingsTableColumnNames', () => { |
|||
it('gives no column with a monetary value', () => { |
|||
expect(PortfolioTableService.getHoldingsTableColumnNames()).toEqual([ |
|||
'Name', |
|||
'Symbol', |
|||
'Currency', |
|||
'Asset Class', |
|||
'Asset Sub Class', |
|||
'Date of First Activity', |
|||
'Activities Count', |
|||
'Allocation in Percentage' |
|||
]); |
|||
}); |
|||
}); |
|||
|
|||
describe('getAccountsTable', () => { |
|||
it('gives no cash balance and no value of an account', async () => { |
|||
const portfolioTableService = createPortfolioTableService({ |
|||
accounts: [createAccount()] |
|||
}); |
|||
|
|||
const result = await portfolioTableService.getAccountsTable({ |
|||
userId: 'user-id' |
|||
}); |
|||
|
|||
expect(result).not.toContain('Cash Balance'); |
|||
expect(result).not.toContain('1000'); |
|||
expect(result).not.toContain('2000'); |
|||
}); |
|||
|
|||
// 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({ |
|||
accounts: [createAccount()] |
|||
}); |
|||
|
|||
const result = await portfolioTableService.getAccountsTable({ |
|||
userId: 'user-id' |
|||
}); |
|||
|
|||
expect(result).toContain('account-a-id'); |
|||
}); |
|||
|
|||
it('marks an account which is excluded from the analysis', async () => { |
|||
const portfolioTableService = createPortfolioTableService({ |
|||
accounts: [ |
|||
createAccount({ isExcluded: true }), |
|||
createAccount({ id: 'account-b-id', name: 'Account B' }) |
|||
] |
|||
}); |
|||
|
|||
const result = await portfolioTableService.getAccountsTable({ |
|||
userId: 'user-id' |
|||
}); |
|||
|
|||
const [rowOfAccountA, rowOfAccountB] = result |
|||
.split('\n') |
|||
.filter((line) => { |
|||
return line.startsWith('| account-'); |
|||
}); |
|||
|
|||
expect(rowOfAccountA).toContain('true'); |
|||
expect(rowOfAccountB).toContain('false'); |
|||
}); |
|||
|
|||
it('tells that no accounts are found if the result is empty', async () => { |
|||
const portfolioTableService = createPortfolioTableService(); |
|||
|
|||
const result = await portfolioTableService.getAccountsTable({ |
|||
userId: 'user-id' |
|||
}); |
|||
|
|||
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'); |
|||
}); |
|||
}); |
|||
}); |
|||
@ -0,0 +1,399 @@ |
|||
import { ActivitiesService } from '@ghostfolio/api/app/activities/activities.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 { AccountWithValue } from '@ghostfolio/common/types'; |
|||
|
|||
import { Injectable } from '@nestjs/common'; |
|||
import { |
|||
AssetClass, |
|||
AssetSubClass, |
|||
Type as ActivityType |
|||
} from '@prisma/client'; |
|||
import { format } from 'date-fns'; |
|||
|
|||
import { HoldingsTableColumnDefinition } from './types/holdings-table-column-definition.type'; |
|||
|
|||
function getAllocationInPercentage(allocationInPercentage: number) { |
|||
return `${(allocationInPercentage * 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. |
|||
*/ |
|||
@Injectable() |
|||
export class PortfolioTableService { |
|||
private static readonly ACCOUNTS_TABLE_COLUMN_DEFINITIONS: TableColumnDefinition<AccountWithValue>[] = |
|||
[ |
|||
{ |
|||
getValue: ({ id }) => { |
|||
return id; |
|||
}, |
|||
name: 'Id' |
|||
}, |
|||
{ |
|||
getValue: ({ name }) => { |
|||
return name ?? ''; |
|||
}, |
|||
name: 'Name' |
|||
}, |
|||
{ |
|||
getValue: ({ currency }) => { |
|||
return currency ?? ''; |
|||
}, |
|||
name: 'Currency' |
|||
}, |
|||
{ |
|||
getValue: ({ platform }) => { |
|||
return platform?.name ?? ''; |
|||
}, |
|||
name: 'Platform' |
|||
}, |
|||
{ |
|||
align: 'right', |
|||
getValue: ({ activitiesCount }) => { |
|||
return activitiesCount.toString(); |
|||
}, |
|||
name: 'Activities Count' |
|||
}, |
|||
{ |
|||
align: 'right', |
|||
getValue: ({ allocationInPercentage }) => { |
|||
return getAllocationInPercentage(allocationInPercentage); |
|||
}, |
|||
name: 'Allocation in Percentage' |
|||
}, |
|||
{ |
|||
getValue: ({ tags }) => { |
|||
return isAccountExcluded({ tags }).toString(); |
|||
}, |
|||
name: 'Excluded from Analysis' |
|||
} |
|||
]; |
|||
|
|||
private static readonly ACTIVITIES_TABLE_COLUMN_DEFINITIONS: TableColumnDefinition<Activity>[] = |
|||
[ |
|||
{ |
|||
getValue: ({ date }) => { |
|||
return format(date, DATE_FORMAT); |
|||
}, |
|||
name: 'Date' |
|||
}, |
|||
{ |
|||
getValue: ({ type }) => { |
|||
return type; |
|||
}, |
|||
name: 'Type' |
|||
}, |
|||
{ |
|||
getValue: ({ assetProfile }) => { |
|||
return assetProfile.name ?? ''; |
|||
}, |
|||
name: 'Name' |
|||
}, |
|||
{ |
|||
getValue: ({ assetProfile }) => { |
|||
return assetProfile.symbol; |
|||
}, |
|||
name: 'Symbol' |
|||
}, |
|||
{ |
|||
getValue: ({ assetProfile, currency }) => { |
|||
return currency ?? assetProfile.currency; |
|||
}, |
|||
name: 'Currency' |
|||
}, |
|||
{ |
|||
align: 'right', |
|||
getValue: ({ unitPrice }) => { |
|||
return unitPrice.toString(); |
|||
}, |
|||
name: 'Unit Price' |
|||
}, |
|||
{ |
|||
getValue: ({ account }) => { |
|||
return account?.name ?? ''; |
|||
}, |
|||
name: 'Account' |
|||
} |
|||
]; |
|||
|
|||
private static readonly HOLDINGS_TABLE_COLUMN_DEFINITIONS: HoldingsTableColumnDefinition[] = |
|||
[ |
|||
{ |
|||
getValue: ({ assetProfile }) => { |
|||
return assetProfile.name; |
|||
}, |
|||
name: 'Name' |
|||
}, |
|||
{ |
|||
getValue: ({ assetProfile }) => { |
|||
return assetProfile.symbol; |
|||
}, |
|||
name: 'Symbol' |
|||
}, |
|||
{ |
|||
getValue: ({ assetProfile }) => { |
|||
return assetProfile.currency; |
|||
}, |
|||
name: 'Currency' |
|||
}, |
|||
{ |
|||
getValue: ({ assetProfile }, { assetClassTranslations }) => { |
|||
return assetClassTranslations[assetProfile.assetClass] ?? ''; |
|||
}, |
|||
name: 'Asset Class' |
|||
}, |
|||
{ |
|||
getValue: ({ assetProfile }, { assetSubClassTranslations }) => { |
|||
return assetSubClassTranslations[assetProfile.assetSubClass] ?? ''; |
|||
}, |
|||
name: 'Asset Sub Class' |
|||
}, |
|||
{ |
|||
getValue: ({ dateOfFirstActivity }) => { |
|||
return dateOfFirstActivity |
|||
? format(dateOfFirstActivity, DATE_FORMAT) |
|||
: ''; |
|||
}, |
|||
name: 'Date of First Activity' |
|||
}, |
|||
{ |
|||
align: 'right', |
|||
getValue: ({ activitiesCount }) => { |
|||
return activitiesCount.toString(); |
|||
}, |
|||
name: 'Activities Count' |
|||
}, |
|||
{ |
|||
align: 'right', |
|||
getValue: ({ allocationInPercentage }) => { |
|||
return getAllocationInPercentage(allocationInPercentage); |
|||
}, |
|||
name: 'Allocation in Percentage' |
|||
} |
|||
]; |
|||
|
|||
public constructor( |
|||
private readonly activitiesService: ActivitiesService, |
|||
private readonly i18nService: I18nService, |
|||
private readonly portfolioService: PortfolioService |
|||
) {} |
|||
|
|||
public static getAccountsTableColumnNames() { |
|||
return PortfolioTableService.ACCOUNTS_TABLE_COLUMN_DEFINITIONS.map( |
|||
({ name }) => { |
|||
return name; |
|||
} |
|||
); |
|||
} |
|||
|
|||
public static getActivitiesTableColumnNames() { |
|||
return PortfolioTableService.ACTIVITIES_TABLE_COLUMN_DEFINITIONS.map( |
|||
({ name }) => { |
|||
return name; |
|||
} |
|||
); |
|||
} |
|||
|
|||
public static getHoldingsTableColumnNames() { |
|||
return PortfolioTableService.HOLDINGS_TABLE_COLUMN_DEFINITIONS.map( |
|||
({ name }) => { |
|||
return name; |
|||
} |
|||
); |
|||
} |
|||
|
|||
public async getAccountsTable({ |
|||
filters, |
|||
userId |
|||
}: { |
|||
filters?: Filter[]; |
|||
userId: string; |
|||
}) { |
|||
const { accounts } = |
|||
await this.portfolioService.getAccountsWithAggregations({ |
|||
filters, |
|||
userId, |
|||
withExcludedAccounts: true |
|||
}); |
|||
|
|||
const accountsSection = ['## Accounts', '']; |
|||
|
|||
if (accounts.length > 0) { |
|||
accountsSection.push( |
|||
await getMarkdownTable({ |
|||
columnDefinitions: |
|||
PortfolioTableService.ACCOUNTS_TABLE_COLUMN_DEFINITIONS, |
|||
rows: accounts |
|||
}) |
|||
); |
|||
} else { |
|||
accountsSection.push('No accounts found.'); |
|||
} |
|||
|
|||
return accountsSection.join('\n'); |
|||
} |
|||
|
|||
public async getActivitiesTable({ |
|||
endDate, |
|||
filters, |
|||
skip = 0, |
|||
startDate, |
|||
take, |
|||
types, |
|||
userCurrency, |
|||
userId |
|||
}: { |
|||
endDate?: Date; |
|||
filters?: Filter[]; |
|||
skip?: number; |
|||
startDate?: Date; |
|||
take: number; |
|||
types?: ActivityType[]; |
|||
userCurrency: string; |
|||
userId: string; |
|||
}) { |
|||
const { activities, count } = await this.activitiesService.getActivities({ |
|||
endDate, |
|||
filters, |
|||
skip, |
|||
startDate, |
|||
take, |
|||
types, |
|||
userCurrency, |
|||
userId, |
|||
includeDrafts: true, |
|||
sortColumn: 'date', |
|||
sortDirection: 'desc', |
|||
withExcludedAccountsAndActivities: true |
|||
}); |
|||
|
|||
const activitiesSection = [ |
|||
'## Activities', |
|||
'', |
|||
this.getActivitiesSummary({ |
|||
count, |
|||
skip, |
|||
numberOfActivities: activities.length |
|||
}) |
|||
]; |
|||
|
|||
if (activities.length > 0) { |
|||
activitiesSection.push( |
|||
'', |
|||
await getMarkdownTable({ |
|||
columnDefinitions: |
|||
PortfolioTableService.ACTIVITIES_TABLE_COLUMN_DEFINITIONS, |
|||
rows: activities |
|||
}) |
|||
); |
|||
} |
|||
|
|||
return activitiesSection.join('\n'); |
|||
} |
|||
|
|||
public async getHoldingsTable({ |
|||
filters, |
|||
languageCode, |
|||
userId |
|||
}: { |
|||
filters?: Filter[]; |
|||
languageCode: string; |
|||
userId: string; |
|||
}) { |
|||
const { holdings } = await this.portfolioService.getDetails({ |
|||
filters, |
|||
userId |
|||
}); |
|||
|
|||
const assetClassTranslations = this.getEnumTranslations({ |
|||
languageCode, |
|||
id: 'assetClass', |
|||
values: Object.values(AssetClass) |
|||
}); |
|||
|
|||
const assetSubClassTranslations = this.getEnumTranslations({ |
|||
languageCode, |
|||
id: 'assetSubClass', |
|||
values: Object.values(AssetSubClass) |
|||
}); |
|||
|
|||
const sortedHoldings = [...holdings].sort((a, b) => { |
|||
return b.allocationInPercentage - a.allocationInPercentage; |
|||
}); |
|||
|
|||
return [ |
|||
'## Holdings', |
|||
'', |
|||
await getMarkdownTable({ |
|||
columnDefinitions: |
|||
PortfolioTableService.HOLDINGS_TABLE_COLUMN_DEFINITIONS, |
|||
context: { assetClassTranslations, assetSubClassTranslations }, |
|||
rows: sortedHoldings |
|||
}) |
|||
].join('\n'); |
|||
} |
|||
|
|||
private getActivitiesSummary({ |
|||
count, |
|||
numberOfActivities, |
|||
skip |
|||
}: { |
|||
count: number; |
|||
numberOfActivities: number; |
|||
skip: number; |
|||
}) { |
|||
if (count === 0) { |
|||
return 'No activities found.'; |
|||
} |
|||
|
|||
if (numberOfActivities === 0) { |
|||
return `No activities beyond the ${count} which match the parameters, hence lower the skip parameter.`; |
|||
} |
|||
|
|||
if (numberOfActivities === count) { |
|||
return `Showing all ${count} activities, the most recent first.`; |
|||
} |
|||
|
|||
const lastActivity = skip + numberOfActivities; |
|||
|
|||
const summary = `Showing the activities ${ |
|||
skip + 1 |
|||
} to ${lastActivity} of ${count}, the most recent first.`;
|
|||
|
|||
if (lastActivity === count) { |
|||
return summary; |
|||
} |
|||
|
|||
return `${summary} Get the further activities by raising the skip parameter or narrow the result with the other parameters.`; |
|||
} |
|||
|
|||
private getEnumTranslations<T extends string>({ |
|||
id, |
|||
languageCode, |
|||
values |
|||
}: { |
|||
id: string; |
|||
languageCode: string; |
|||
values: T[]; |
|||
}) { |
|||
return values.reduce( |
|||
(translations, value) => { |
|||
translations[value] = |
|||
this.i18nService.getTranslation({ |
|||
languageCode, |
|||
id: `${id}.${value}` |
|||
}) || value; |
|||
|
|||
return translations; |
|||
}, |
|||
{} as Record<T, string> |
|||
); |
|||
} |
|||
} |
|||
@ -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 |
|||
>; |
|||
Loading…
Reference in new issue