mirror of https://github.com/ghostfolio/ghostfolio
committed by
GitHub
88 changed files with 5423 additions and 4863 deletions
@ -1,9 +1,11 @@ |
|||||
import { FilterDto } from '@ghostfolio/api/dtos/filter.dto'; |
import { FilterDto } from '@ghostfolio/api/dtos/filter.dto'; |
||||
|
import { SEARCH_QUERY_MAXIMUM_LENGTH } from '@ghostfolio/common/config'; |
||||
|
|
||||
import { IsOptional, IsString } from 'class-validator'; |
import { IsOptional, IsString, MaxLength } from 'class-validator'; |
||||
|
|
||||
export class GetAllAccountsDto extends FilterDto { |
export class GetAllAccountsDto extends FilterDto { |
||||
@IsOptional() |
@IsOptional() |
||||
@IsString() |
@IsString() |
||||
|
@MaxLength(SEARCH_QUERY_MAXIMUM_LENGTH) |
||||
query?: string; |
query?: string; |
||||
} |
} |
||||
|
|||||
@ -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,64 @@ |
|||||
|
import { SEARCH_QUERY_MAXIMUM_LENGTH } from '@ghostfolio/common/config'; |
||||
|
import { MarketDataPreset } from '@ghostfolio/common/types'; |
||||
|
|
||||
|
import { Prisma } from '@prisma/client'; |
||||
|
import { Type } from 'class-transformer'; |
||||
|
import { |
||||
|
IsIn, |
||||
|
IsInt, |
||||
|
IsOptional, |
||||
|
IsString, |
||||
|
MaxLength, |
||||
|
Min |
||||
|
} from 'class-validator'; |
||||
|
|
||||
|
export class GetAssetProfilesDto { |
||||
|
@IsOptional() |
||||
|
@IsString() |
||||
|
assetSubClasses?: string; |
||||
|
|
||||
|
@IsOptional() |
||||
|
@IsString() |
||||
|
dataSource?: string; |
||||
|
|
||||
|
@IsIn([ |
||||
|
'BENCHMARKS', |
||||
|
'CURRENCIES', |
||||
|
'ETF_WITHOUT_COUNTRIES', |
||||
|
'ETF_WITHOUT_SECTORS', |
||||
|
'NO_ACTIVITIES' |
||||
|
] as MarketDataPreset[]) |
||||
|
@IsOptional() |
||||
|
presetId?: MarketDataPreset; |
||||
|
|
||||
|
@IsOptional() |
||||
|
@IsString() |
||||
|
@MaxLength(SEARCH_QUERY_MAXIMUM_LENGTH) |
||||
|
query?: string; |
||||
|
|
||||
|
@IsInt() |
||||
|
@IsOptional() |
||||
|
@Min(0) |
||||
|
@Type(() => Number) |
||||
|
skip?: number; |
||||
|
|
||||
|
@IsIn([ |
||||
|
'activitiesCount', |
||||
|
'assetClass', |
||||
|
'assetSubClass', |
||||
|
'dataSource', |
||||
|
'symbol' |
||||
|
]) |
||||
|
@IsOptional() |
||||
|
sortColumn?: string; |
||||
|
|
||||
|
@IsIn(['asc', 'desc'] as Prisma.SortOrder[]) |
||||
|
@IsOptional() |
||||
|
sortDirection?: Prisma.SortOrder; |
||||
|
|
||||
|
@IsInt() |
||||
|
@IsOptional() |
||||
|
@Min(0) |
||||
|
@Type(() => Number) |
||||
|
take?: number; |
||||
|
} |
||||
@ -0,0 +1,16 @@ |
|||||
|
import { SEARCH_QUERY_MAXIMUM_LENGTH } from '@ghostfolio/common/config'; |
||||
|
|
||||
|
import { Transform, TransformFnParams } from 'class-transformer'; |
||||
|
import { IsBoolean, IsString, MaxLength } from 'class-validator'; |
||||
|
|
||||
|
export class GetLookupDto { |
||||
|
@IsBoolean() |
||||
|
@Transform(({ value }: TransformFnParams) => { |
||||
|
return value === 'true'; |
||||
|
}) |
||||
|
includeIndices? = false; |
||||
|
|
||||
|
@IsString() |
||||
|
@MaxLength(SEARCH_QUERY_MAXIMUM_LENGTH) |
||||
|
query? = ''; |
||||
|
} |
||||
@ -1,285 +1,87 @@ |
|||||
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 { REQUIRES_SCOPE_KEY } from '@ghostfolio/api/decorators/requires-scope.decorator'; |
import { REQUIRES_SCOPE_KEY } from '@ghostfolio/api/decorators/requires-scope.decorator'; |
||||
import { ConfigurationService } from '@ghostfolio/api/services/configuration/configuration.service'; |
import { McpToolExceptionFilter } from '@ghostfolio/api/filters/mcp-tool-exception.filter'; |
||||
import { MCP_MAX_ACTIVITIES } from '@ghostfolio/common/config'; |
import { AccessGuard } from '@ghostfolio/api/guards/access.guard'; |
||||
import { Activity } from '@ghostfolio/common/interfaces'; |
import { Scope, scopes } from '@ghostfolio/common/scopes'; |
||||
import { permissions } from '@ghostfolio/common/permissions'; |
|
||||
import { scopes } from '@ghostfolio/common/scopes'; |
|
||||
import type { |
|
||||
ImpersonationContext, |
|
||||
UserWithSettings |
|
||||
} from '@ghostfolio/common/types'; |
|
||||
|
|
||||
import { HttpException, Logger } from '@nestjs/common'; |
|
||||
import { RpcException } from '@nestjs/microservices'; |
|
||||
import { DataSource, Type as ActivityType } from '@prisma/client'; |
|
||||
import { getReasonPhrase, StatusCodes } from 'http-status-codes'; |
|
||||
|
|
||||
import { |
import { |
||||
GhostfolioMcpController, |
EXCEPTION_FILTERS_METADATA, |
||||
IMPORT_ACTIVITIES_PARAMETERS |
GUARDS_METADATA |
||||
} from './mcp.controller'; |
} from '@nestjs/common/constants'; |
||||
|
import { MCP_TOOL_METADATA_KEY, ToolMetadata } from '@rekog/mcp-nest'; |
||||
// The controller reads the columns of the tables from the AiService, which
|
|
||||
// imports two packages which ship as an ECMAScript module only, which Jest
|
import { GhostfolioMcpController } from './mcp.controller'; |
||||
// cannot transform. The mocks only make the imports resolvable, because no
|
|
||||
// test calls them.
|
/** |
||||
jest.mock('@openrouter/ai-sdk-provider', () => { |
* Gives the metadata which a decorator sets on the method of a tool. The |
||||
return { createOpenRouter: jest.fn() }; |
* prototype is read by the name of the method, hence the type of the metadata |
||||
}); |
* is given by the caller. |
||||
|
*/ |
||||
jest.mock('ai', () => { |
function getMetadataOfMethod<T>(metadataKey: string, methodName: string) { |
||||
return { generateText: jest.fn() }; |
const methodsByName = GhostfolioMcpController.prototype as unknown as Record< |
||||
}); |
string, |
||||
|
object |
||||
function createActivity(overrides: Record<string, unknown> = {}) { |
>; |
||||
return { |
|
||||
currency: 'USD', |
return Reflect.getMetadata(metadataKey, methodsByName[methodName]) as T; |
||||
date: '2024-01-01', |
|
||||
fee: 0, |
|
||||
quantity: 1, |
|
||||
symbol: 'AAPL', |
|
||||
type: ActivityType.BUY, |
|
||||
unitPrice: 100, |
|
||||
...overrides |
|
||||
}; |
|
||||
} |
} |
||||
|
|
||||
describe('GhostfolioMcpController', () => { |
function getToolMethodNames() { |
||||
const impersonation = { userId: 'user-id' } as ImpersonationContext; |
return Object.getOwnPropertyNames(GhostfolioMcpController.prototype).filter( |
||||
|
(methodName) => { |
||||
let configuration: Record<string, unknown>; |
return Boolean( |
||||
let configurationService: ConfigurationService; |
getMetadataOfMethod<ToolMetadata>(MCP_TOOL_METADATA_KEY, methodName) |
||||
let controller: GhostfolioMcpController; |
|
||||
let importService: ImportService; |
|
||||
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; |
|
||||
|
|
||||
importService = { |
|
||||
import: jest.fn().mockResolvedValue([]) |
|
||||
} as unknown as ImportService; |
|
||||
|
|
||||
userService = { user: jest.fn() } as unknown as UserService; |
|
||||
|
|
||||
controller = new GhostfolioMcpController( |
|
||||
undefined, |
|
||||
undefined, |
|
||||
configurationService, |
|
||||
importService, |
|
||||
userService |
|
||||
); |
); |
||||
}); |
|
||||
|
|
||||
afterEach(() => { |
|
||||
jest.restoreAllMocks(); |
|
||||
}); |
|
||||
|
|
||||
describe('Import activities', () => { |
|
||||
it('Requires the scope to create an activity', () => { |
|
||||
expect( |
|
||||
Reflect.getMetadata( |
|
||||
REQUIRES_SCOPE_KEY, |
|
||||
GhostfolioMcpController.prototype.importActivities |
|
||||
) |
|
||||
).toEqual([scopes.activityCreate]); |
|
||||
}); |
|
||||
|
|
||||
it('Refuses a user without the permission to create an activity', async () => { |
|
||||
setupUser([]); |
|
||||
|
|
||||
await expect( |
|
||||
controller.importActivities(impersonation, { |
|
||||
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 controller.importActivities(impersonation, { |
|
||||
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 controller.importActivities(impersonation, { |
|
||||
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 controller.importActivities(impersonation, { |
|
||||
activities: [createActivity({ dataSource: DataSource.GHOSTFOLIO })] |
|
||||
}); |
|
||||
|
|
||||
expect(importService.import).toHaveBeenCalledWith( |
|
||||
expect.objectContaining({ |
|
||||
activitiesDto: [ |
|
||||
expect.objectContaining({ dataSource: DataSource.GHOSTFOLIO }) |
|
||||
] |
|
||||
}) |
|
||||
); |
|
||||
}); |
|
||||
|
|
||||
it('Passes on the message of a validation only', async () => { |
describe('GhostfolioMcpController', () => { |
||||
setupUser([permissions.createActivity]); |
// A tool without the decorator of the scope would be open to every access,
|
||||
|
// hence a new tool has to declare its scope
|
||||
|
it('Requires a scope of access for each tool', () => { |
||||
|
const toolMethodNames = getToolMethodNames(); |
||||
|
|
||||
jest |
expect(toolMethodNames.length).toBeGreaterThan(0); |
||||
.spyOn(importService, 'import') |
|
||||
.mockRejectedValue( |
|
||||
new ImportValidationError('activities.0.symbol ("X") is not valid') |
|
||||
); |
|
||||
|
|
||||
await expect( |
const toolMethodNamesWithoutScope = toolMethodNames.filter((methodName) => { |
||||
controller.importActivities(impersonation, { |
return !getMetadataOfMethod<Scope[]>(REQUIRES_SCOPE_KEY, methodName) |
||||
activities: [createActivity()] |
?.length; |
||||
}) |
|
||||
).rejects.toThrow( |
|
||||
new RpcException('activities.0.symbol ("X") is not valid') |
|
||||
); |
|
||||
}); |
}); |
||||
|
|
||||
it('Hides the message of an unexpected error and writes it to the log', async () => { |
expect(toolMethodNamesWithoutScope).toEqual([]); |
||||
setupUser([permissions.createActivity]); |
|
||||
|
|
||||
const error = new Error( |
|
||||
'Unique constraint failed on the fields: (dataSource)' |
|
||||
); |
|
||||
|
|
||||
const logError = jest |
|
||||
.spyOn(Logger.prototype, 'error') |
|
||||
.mockImplementation(); |
|
||||
|
|
||||
jest.spyOn(importService, 'import').mockRejectedValue(error); |
|
||||
|
|
||||
await expect( |
|
||||
controller.importActivities(impersonation, { |
|
||||
activities: [createActivity()] |
|
||||
}) |
|
||||
).rejects.toThrow( |
|
||||
new RpcException(getReasonPhrase(StatusCodes.INTERNAL_SERVER_ERROR)) |
|
||||
); |
|
||||
|
|
||||
expect(logError).toHaveBeenCalledWith(error); |
|
||||
}); |
}); |
||||
|
|
||||
it('Does not write the message of a validation to the log', async () => { |
// The decorator RequiresScope sets the same metadata as the decorator
|
||||
setupUser([permissions.createActivity]); |
// RequiresScopeOfAccess, but applies AuthGuard('jwt'), which a request of
|
||||
|
// an access cannot pass, hence the guards tell the two decorators apart
|
||||
const logError = jest |
it('Applies the guard of the access to each tool', () => { |
||||
.spyOn(Logger.prototype, 'error') |
const toolMethodNames = getToolMethodNames(); |
||||
.mockImplementation(); |
|
||||
|
|
||||
jest |
expect(toolMethodNames.length).toBeGreaterThan(0); |
||||
.spyOn(importService, 'import') |
|
||||
.mockRejectedValue( |
|
||||
new ImportValidationError('activities.0.accountId ("X") is not valid') |
|
||||
); |
|
||||
|
|
||||
await expect( |
const toolMethodNamesWithoutGuardOfAccess = toolMethodNames.filter( |
||||
controller.importActivities(impersonation, { |
(methodName) => { |
||||
activities: [createActivity({ accountId: 'X' })] |
return !getMetadataOfMethod<unknown[]>( |
||||
}) |
GUARDS_METADATA, |
||||
).rejects.toThrow(RpcException); |
methodName |
||||
|
)?.includes(AccessGuard); |
||||
expect(logError).not.toHaveBeenCalled(); |
|
||||
}); |
|
||||
}); |
|
||||
|
|
||||
describe('Parameters of the tool to import activities', () => { |
|
||||
function parse(activities: unknown[]) { |
|
||||
return IMPORT_ACTIVITIES_PARAMETERS.safeParse({ activities }).success; |
|
||||
} |
} |
||||
|
); |
||||
|
|
||||
it('Refuses a currency in lower case', () => { |
expect(toolMethodNamesWithoutGuardOfAccess).toEqual([]); |
||||
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', () => { |
it('Requires the scope to create an activity for the tool to import activities', () => { |
||||
expect( |
expect( |
||||
IMPORT_ACTIVITIES_PARAMETERS.parse({ |
getMetadataOfMethod<Scope[]>(REQUIRES_SCOPE_KEY, 'importActivities') |
||||
activities: [createActivity({ tags: ['tag-id'] })] |
).toEqual([scopes.activityCreate]); |
||||
}).activities[0] |
|
||||
).not.toHaveProperty('tags'); |
|
||||
}); |
}); |
||||
|
|
||||
it(`Refuses more than ${MCP_MAX_ACTIVITIES} activities`, () => { |
// The tools have no try and catch, hence the filter is the only guarantee
|
||||
|
// that an unexpected exception does not expose internals
|
||||
|
it('Applies the filter of the exceptions of the tools', () => { |
||||
expect( |
expect( |
||||
parse( |
Reflect.getMetadata(EXCEPTION_FILTERS_METADATA, GhostfolioMcpController) |
||||
Array.from({ length: MCP_MAX_ACTIVITIES + 1 }, () => { |
).toEqual([McpToolExceptionFilter]); |
||||
return createActivity(); |
|
||||
}) |
|
||||
) |
|
||||
).toBe(false); |
|
||||
}); |
|
||||
}); |
}); |
||||
}); |
}); |
||||
|
|||||
@ -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,115 @@ |
|||||
|
import { DATE_RANGE_PATTERN } from '@ghostfolio/api/dtos/date-range-filter.dto'; |
||||
|
import { |
||||
|
DATE_RANGES, |
||||
|
MCP_MAX_ACCOUNTS, |
||||
|
MCP_MAX_ACTIVITIES |
||||
|
} from '@ghostfolio/common/config'; |
||||
|
import { |
||||
|
isValidCurrencyCode, |
||||
|
isValidDateAfter1970 |
||||
|
} from '@ghostfolio/common/helper'; |
||||
|
|
||||
|
import { AssetClass, DataSource, Type as ActivityType } from '@prisma/client'; |
||||
|
import { z } from 'zod'; |
||||
|
|
||||
|
const HOLDING_PARAMETER = z.object({ |
||||
|
dataSource: z |
||||
|
.enum(DataSource) |
||||
|
.describe('The data source of the asset profile'), |
||||
|
symbol: z.string().describe('The symbol of the asset profile') |
||||
|
}); |
||||
|
|
||||
|
export const GET_ACCOUNTS_PARAMETERS = z.object({ |
||||
|
accountIds: z |
||||
|
.array(z.string().min(1)) |
||||
|
.min(1) |
||||
|
.max(MCP_MAX_ACCOUNTS) |
||||
|
.optional() |
||||
|
.describe( |
||||
|
`The identifiers of the accounts to get, at most ${MCP_MAX_ACCOUNTS}` |
||||
|
), |
||||
|
assetClasses: z |
||||
|
.array(z.enum(AssetClass)) |
||||
|
.min(1) |
||||
|
.optional() |
||||
|
.describe('The asset classes of the accounts to get'), |
||||
|
holding: HOLDING_PARAMETER.optional().describe( |
||||
|
'The asset profile of the accounts to get' |
||||
|
) |
||||
|
}); |
||||
|
|
||||
|
export const GET_ACTIVITIES_PARAMETERS = z.object({ |
||||
|
activityTypes: z |
||||
|
.array(z.enum(ActivityType)) |
||||
|
.min(1) |
||||
|
.optional() |
||||
|
.describe('The types of the activities to get'), |
||||
|
assetClasses: z |
||||
|
.array(z.enum(AssetClass)) |
||||
|
.min(1) |
||||
|
.optional() |
||||
|
.describe('The asset classes of the activities to get'), |
||||
|
holding: HOLDING_PARAMETER.optional().describe( |
||||
|
'The asset profile of the activities to get' |
||||
|
), |
||||
|
range: z |
||||
|
.string() |
||||
|
.regex(DATE_RANGE_PATTERN) |
||||
|
.optional() |
||||
|
.describe( |
||||
|
`The date range of the activities to get, either ${DATE_RANGES.join( |
||||
|
', ' |
||||
|
)} or a calendar year like 2024` |
||||
|
), |
||||
|
skip: z |
||||
|
.number() |
||||
|
.int() |
||||
|
.min(0) |
||||
|
.optional() |
||||
|
.describe('The number of activities to skip'), |
||||
|
take: z |
||||
|
.number() |
||||
|
.int() |
||||
|
.min(1) |
||||
|
.max(MCP_MAX_ACTIVITIES) |
||||
|
.default(MCP_MAX_ACTIVITIES) |
||||
|
.describe(`The number of activities to get, at most ${MCP_MAX_ACTIVITIES}`) |
||||
|
}); |
||||
|
|
||||
|
export const IMPORT_ACTIVITIES_PARAMETERS = z.object({ |
||||
|
activities: z |
||||
|
.array( |
||||
|
z.object({ |
||||
|
accountId: z |
||||
|
.string() |
||||
|
.min(1) |
||||
|
.optional() |
||||
|
.describe('The identifier of the account of the activity'), |
||||
|
comment: z.string().optional().describe('The comment of the activity'), |
||||
|
currency: z |
||||
|
.string() |
||||
|
.refine(isValidCurrencyCode) |
||||
|
.describe( |
||||
|
'The currency of the fee and of the unit price, as an ISO 4217 code in upper case' |
||||
|
), |
||||
|
dataSource: z |
||||
|
.enum(DataSource) |
||||
|
.optional() |
||||
|
.describe('The data source of the asset profile'), |
||||
|
date: z |
||||
|
.string() |
||||
|
.refine(isValidDateAfter1970) |
||||
|
.describe( |
||||
|
'The date of the activity, as an ISO 8601 date or date and time' |
||||
|
), |
||||
|
fee: z.number().min(0).describe('The fee of the activity'), |
||||
|
quantity: z.number().min(0).describe('The quantity of the activity'), |
||||
|
symbol: z.string().min(1).describe('The symbol of the asset profile'), |
||||
|
type: z.enum(ActivityType).describe('The type of the activity'), |
||||
|
unitPrice: z.number().min(0).describe('The unit price of the activity') |
||||
|
}) |
||||
|
) |
||||
|
.min(1) |
||||
|
.max(MCP_MAX_ACTIVITIES) |
||||
|
.describe(`The activities to import, at most ${MCP_MAX_ACTIVITIES}`) |
||||
|
}); |
||||
@ -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); |
||||
|
}); |
||||
|
}); |
||||
|
}); |
||||
@ -0,0 +1,176 @@ |
|||||
|
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 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,16 @@ |
|||||
|
import { SEARCH_QUERY_MAXIMUM_LENGTH } from '@ghostfolio/common/config'; |
||||
|
|
||||
|
import { Transform, TransformFnParams } from 'class-transformer'; |
||||
|
import { IsBoolean, IsString, MaxLength } from 'class-validator'; |
||||
|
|
||||
|
export class GetLookupDto { |
||||
|
@IsBoolean() |
||||
|
@Transform(({ value }: TransformFnParams) => { |
||||
|
return value === 'true'; |
||||
|
}) |
||||
|
includeIndices? = false; |
||||
|
|
||||
|
@IsString() |
||||
|
@MaxLength(SEARCH_QUERY_MAXIMUM_LENGTH) |
||||
|
query? = ''; |
||||
|
} |
||||
@ -1,23 +1,44 @@ |
|||||
import { IsOptional, IsString } from 'class-validator'; |
import { SYMBOL_MAXIMUM_LENGTH } from '@ghostfolio/common/config'; |
||||
|
|
||||
|
import { AssetClass, DataSource } from '@prisma/client'; |
||||
|
import { Transform, TransformFnParams } from 'class-transformer'; |
||||
|
import { |
||||
|
IsEnum, |
||||
|
IsOptional, |
||||
|
IsString, |
||||
|
IsUUID, |
||||
|
MaxLength |
||||
|
} from 'class-validator'; |
||||
|
import { isString } from 'lodash'; |
||||
|
|
||||
export class FilterDto { |
export class FilterDto { |
||||
@IsOptional() |
@IsOptional() |
||||
@IsString() |
@IsUUID(undefined, { each: true }) |
||||
accounts?: string; |
@Transform(({ value }: TransformFnParams) => { |
||||
|
return isString(value) ? value.split(',') : value; |
||||
|
}) |
||||
|
accounts?: string[]; |
||||
|
|
||||
|
@IsEnum(AssetClass, { each: true }) |
||||
@IsOptional() |
@IsOptional() |
||||
@IsString() |
@Transform(({ value }: TransformFnParams) => { |
||||
assetClasses?: string; |
return isString(value) ? value.split(',') : value; |
||||
|
}) |
||||
|
assetClasses?: AssetClass[]; |
||||
|
|
||||
|
@IsEnum(DataSource) |
||||
@IsOptional() |
@IsOptional() |
||||
@IsString() |
dataSource?: DataSource; |
||||
dataSource?: string; |
|
||||
|
|
||||
@IsOptional() |
@IsOptional() |
||||
@IsString() |
@IsString() |
||||
|
@MaxLength(SYMBOL_MAXIMUM_LENGTH) |
||||
symbol?: string; |
symbol?: string; |
||||
|
|
||||
@IsOptional() |
@IsOptional() |
||||
@IsString() |
@IsUUID(undefined, { each: true }) |
||||
tags?: string; |
@Transform(({ value }: TransformFnParams) => { |
||||
|
return isString(value) ? value.split(',') : value; |
||||
|
}) |
||||
|
tags?: string[]; |
||||
} |
} |
||||
|
|||||
@ -0,0 +1,12 @@ |
|||||
|
/** |
||||
|
* An error whose message is written for the caller. A filter passes such a |
||||
|
* message on, while it hides the message of every other error, because that |
||||
|
* message can carry internals of the application. |
||||
|
*/ |
||||
|
export class CallerFacingError extends Error { |
||||
|
public constructor(message: string) { |
||||
|
super(message); |
||||
|
|
||||
|
this.name = 'CallerFacingError'; |
||||
|
} |
||||
|
} |
||||
@ -0,0 +1,83 @@ |
|||||
|
import { ImportValidationError } from '@ghostfolio/api/app/import/errors/import-validation.error'; |
||||
|
import { PortfolioSnapshotComputationError } from '@ghostfolio/api/app/portfolio/errors/portfolio-snapshot-computation.error'; |
||||
|
|
||||
|
import { ForbiddenException, Logger } from '@nestjs/common'; |
||||
|
import { getReasonPhrase, StatusCodes } from 'http-status-codes'; |
||||
|
import { firstValueFrom } from 'rxjs'; |
||||
|
|
||||
|
import { McpToolExceptionFilter } from './mcp-tool-exception.filter'; |
||||
|
|
||||
|
describe('McpToolExceptionFilter', () => { |
||||
|
let filter: McpToolExceptionFilter; |
||||
|
let logError: jest.SpyInstance; |
||||
|
|
||||
|
async function getErrorOfException(exception: unknown) { |
||||
|
try { |
||||
|
await firstValueFrom(filter.catch(exception)); |
||||
|
} catch (error) { |
||||
|
return error; |
||||
|
} |
||||
|
|
||||
|
throw new Error('The filter gave no error'); |
||||
|
} |
||||
|
|
||||
|
beforeEach(() => { |
||||
|
filter = new McpToolExceptionFilter(); |
||||
|
|
||||
|
logError = jest.spyOn(Logger.prototype, 'error').mockImplementation(); |
||||
|
}); |
||||
|
|
||||
|
afterEach(() => { |
||||
|
jest.restoreAllMocks(); |
||||
|
}); |
||||
|
|
||||
|
it('Passes on the message of an error which is written for the caller', async () => { |
||||
|
const exception = new ImportValidationError( |
||||
|
'activities.0.symbol ("X") is not valid' |
||||
|
); |
||||
|
|
||||
|
expect(await getErrorOfException(exception)).toEqual({ |
||||
|
message: 'activities.0.symbol ("X") is not valid', |
||||
|
status: 'error' |
||||
|
}); |
||||
|
|
||||
|
expect(logError).not.toHaveBeenCalled(); |
||||
|
}); |
||||
|
|
||||
|
it('Hides the message of an unexpected error and writes it to the log', async () => { |
||||
|
const exception = new Error( |
||||
|
'Unique constraint failed on the fields: (dataSource)' |
||||
|
); |
||||
|
|
||||
|
expect(await getErrorOfException(exception)).toEqual({ |
||||
|
message: getReasonPhrase(StatusCodes.INTERNAL_SERVER_ERROR), |
||||
|
status: 'error' |
||||
|
}); |
||||
|
|
||||
|
expect(logError).toHaveBeenCalledWith(exception); |
||||
|
}); |
||||
|
|
||||
|
// An access without the scope of a tool causes a refused call at each
|
||||
|
// attempt, which would fill the log
|
||||
|
it('Gives the reason phrase of the status of an HttpException and writes no log', async () => { |
||||
|
expect(await getErrorOfException(new ForbiddenException())).toEqual({ |
||||
|
message: getReasonPhrase(StatusCodes.FORBIDDEN), |
||||
|
status: 'error' |
||||
|
}); |
||||
|
|
||||
|
expect(logError).not.toHaveBeenCalled(); |
||||
|
}); |
||||
|
|
||||
|
it('Gives the reason phrase of a service which is not available if a snapshot cannot be computed', async () => { |
||||
|
const exception = new PortfolioSnapshotComputationError( |
||||
|
'The snapshot cannot be computed' |
||||
|
); |
||||
|
|
||||
|
expect(await getErrorOfException(exception)).toEqual({ |
||||
|
message: getReasonPhrase(StatusCodes.SERVICE_UNAVAILABLE), |
||||
|
status: 'error' |
||||
|
}); |
||||
|
|
||||
|
expect(logError).toHaveBeenCalledWith(exception); |
||||
|
}); |
||||
|
}); |
||||
@ -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 |
||||
|
>; |
||||
@ -1 +1,2 @@ |
|||||
export type AccountDialogMode = 'create' | 'detail' | 'update'; |
export type AccountDialogMode = |
||||
|
'create' | 'detail' | 'transferCashBalance' | 'update'; |
||||
|
|||||
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
@ -1,10 +1,13 @@ |
|||||
|
import { SYMBOL_MAXIMUM_LENGTH } from '@ghostfolio/common/config'; |
||||
|
|
||||
import { DataSource } from '@prisma/client'; |
import { DataSource } from '@prisma/client'; |
||||
import { IsEnum, IsString } from 'class-validator'; |
import { IsEnum, IsString, MaxLength } from 'class-validator'; |
||||
|
|
||||
export class CreateWatchlistItemDto { |
export class CreateWatchlistItemDto { |
||||
@IsEnum(DataSource) |
@IsEnum(DataSource) |
||||
dataSource: DataSource; |
dataSource: DataSource; |
||||
|
|
||||
@IsString() |
@IsString() |
||||
|
@MaxLength(SYMBOL_MAXIMUM_LENGTH) |
||||
symbol: string; |
symbol: string; |
||||
} |
} |
||||
|
|||||
Loading…
Reference in new issue