mirror of https://github.com/ghostfolio/ghostfolio
committed by
GitHub
36 changed files with 4623 additions and 4417 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 |
|||
>; |
|||
File diff suppressed because it is too large
File diff suppressed because it is too large
File diff suppressed because it is too large
File diff suppressed because it is too large
File diff suppressed because it is too large
File diff suppressed because it is too large
File diff suppressed because it is too large
File diff suppressed because it is too large
File diff suppressed because it is too large
File diff suppressed because it is too large
File diff suppressed because it is too large
File diff suppressed because it is too large
File diff suppressed because it is too large
File diff suppressed because it is too large
Loading…
Reference in new issue