mirror of https://github.com/ghostfolio/ghostfolio
committed by
GitHub
164 changed files with 8537 additions and 6712 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,93 @@ |
|||||
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', () => { |
it('Requires the scope to create an activity for the tool to import activities', () => { |
||||
expect(parse([createActivity({ accountId: '' })])).toBe(false); |
expect( |
||||
|
getMetadataOfMethod<Scope[]>(REQUIRES_SCOPE_KEY, 'importActivities') |
||||
|
).toEqual([scopes.activityCreate]); |
||||
}); |
}); |
||||
|
|
||||
it('Removes a tag, because the tool takes no tag', () => { |
it('Requires the scope to create an activity for the tool to search asset profiles', () => { |
||||
expect( |
expect( |
||||
IMPORT_ACTIVITIES_PARAMETERS.parse({ |
getMetadataOfMethod<Scope[]>(REQUIRES_SCOPE_KEY, 'searchAssetProfiles') |
||||
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,91 @@ |
|||||
|
import { |
||||
|
MCP_MAX_ACTIVITIES, |
||||
|
SEARCH_QUERY_MAXIMUM_LENGTH, |
||||
|
SEARCH_QUERY_MINIMUM_LENGTH |
||||
|
} from '@ghostfolio/common/config'; |
||||
|
|
||||
|
import { |
||||
|
IMPORT_ACTIVITIES_PARAMETERS, |
||||
|
SEARCH_ASSET_PROFILES_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); |
||||
|
}); |
||||
|
}); |
||||
|
|
||||
|
describe('SEARCH_ASSET_PROFILES_PARAMETERS', () => { |
||||
|
it('Refuses a query that contains only spaces', () => { |
||||
|
expect( |
||||
|
SEARCH_ASSET_PROFILES_PARAMETERS.safeParse({ query: ' ' }).success |
||||
|
).toBe(false); |
||||
|
}); |
||||
|
|
||||
|
it(`Refuses a query shorter than ${SEARCH_QUERY_MINIMUM_LENGTH} characters`, () => { |
||||
|
expect( |
||||
|
SEARCH_ASSET_PROFILES_PARAMETERS.safeParse({ query: 'A' }).success |
||||
|
).toBe(false); |
||||
|
}); |
||||
|
|
||||
|
it(`Refuses a query longer than ${SEARCH_QUERY_MAXIMUM_LENGTH} characters`, () => { |
||||
|
expect( |
||||
|
SEARCH_ASSET_PROFILES_PARAMETERS.safeParse({ |
||||
|
query: 'A'.repeat(SEARCH_QUERY_MAXIMUM_LENGTH + 1) |
||||
|
}).success |
||||
|
).toBe(false); |
||||
|
}); |
||||
|
|
||||
|
it('Accepts a name, symbol or ISIN', () => { |
||||
|
for (const query of ['Apple', 'AAPL', 'US0378331005']) { |
||||
|
expect( |
||||
|
SEARCH_ASSET_PROFILES_PARAMETERS.safeParse({ query }).success |
||||
|
).toBe(true); |
||||
|
} |
||||
|
}); |
||||
|
|
||||
|
it('Removes spaces at the start and the end of a query', () => { |
||||
|
expect( |
||||
|
SEARCH_ASSET_PROFILES_PARAMETERS.parse({ query: ' Apple ' }).query |
||||
|
).toBe('Apple'); |
||||
|
}); |
||||
|
}); |
||||
@ -0,0 +1,128 @@ |
|||||
|
import { DATE_RANGE_PATTERN } from '@ghostfolio/api/dtos/date-range-filter.dto'; |
||||
|
import { |
||||
|
DATE_RANGES, |
||||
|
MCP_MAX_ACCOUNTS, |
||||
|
MCP_MAX_ACTIVITIES, |
||||
|
SEARCH_QUERY_MAXIMUM_LENGTH, |
||||
|
SEARCH_QUERY_MINIMUM_LENGTH |
||||
|
} 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}`) |
||||
|
}); |
||||
|
|
||||
|
export const SEARCH_ASSET_PROFILES_PARAMETERS = z.object({ |
||||
|
query: z |
||||
|
.string() |
||||
|
.trim() |
||||
|
.min(SEARCH_QUERY_MINIMUM_LENGTH) |
||||
|
.max(SEARCH_QUERY_MAXIMUM_LENGTH) |
||||
|
.describe( |
||||
|
'The name, ticker symbol or ISIN of the financial asset, for example Apple, AAPL, Bitcoin or US0378331005' |
||||
|
) |
||||
|
}); |
||||
@ -0,0 +1,360 @@ |
|||||
|
import { ImportValidationError } from '@ghostfolio/api/app/import/errors/import-validation.error'; |
||||
|
import { ImportService } from '@ghostfolio/api/app/import/import.service'; |
||||
|
import { SymbolService } from '@ghostfolio/api/app/symbol/symbol.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, |
||||
|
AssetSubClass, |
||||
|
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 symbolService: SymbolService; |
||||
|
let userService: UserService; |
||||
|
|
||||
|
function setupUser(userPermissions: string[]) { |
||||
|
const user = { |
||||
|
id: userId, |
||||
|
permissions: userPermissions |
||||
|
} as UserWithSettings; |
||||
|
|
||||
|
jest.spyOn(userService, 'user').mockResolvedValue(user); |
||||
|
|
||||
|
return user; |
||||
|
} |
||||
|
|
||||
|
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; |
||||
|
|
||||
|
symbolService = { |
||||
|
lookup: jest.fn().mockResolvedValue({ items: [] }) |
||||
|
} as unknown as SymbolService; |
||||
|
|
||||
|
userService = { user: jest.fn() } as unknown as UserService; |
||||
|
|
||||
|
mcpService = new McpService( |
||||
|
apiService, |
||||
|
configurationService, |
||||
|
importService, |
||||
|
portfolioTableService, |
||||
|
symbolService, |
||||
|
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('searchAssetProfiles', () => { |
||||
|
it('Refuses a user without the permission to create an activity', async () => { |
||||
|
setupUser([]); |
||||
|
|
||||
|
await expect( |
||||
|
mcpService.searchAssetProfiles({ query: 'Apple', userId }) |
||||
|
).rejects.toThrow(HttpException); |
||||
|
|
||||
|
expect(symbolService.lookup).not.toHaveBeenCalled(); |
||||
|
}); |
||||
|
|
||||
|
it('Gives the import-ready asset profiles available to the user', async () => { |
||||
|
const user = setupUser([permissions.createActivity]); |
||||
|
|
||||
|
configuration.DATA_SOURCES_GHOSTFOLIO_DATA_PROVIDER = [DataSource.YAHOO]; |
||||
|
configuration.ENABLE_FEATURE_SUBSCRIPTION = true; |
||||
|
|
||||
|
jest.spyOn(symbolService, 'lookup').mockResolvedValue({ |
||||
|
items: [ |
||||
|
{ |
||||
|
assetClass: AssetClass.EQUITY, |
||||
|
assetSubClass: AssetSubClass.STOCK, |
||||
|
currency: 'USD', |
||||
|
dataProviderInfo: { isPremium: false }, |
||||
|
dataSource: DataSource.YAHOO, |
||||
|
name: 'Apple Inc.', |
||||
|
symbol: 'AAPL' |
||||
|
}, |
||||
|
{ |
||||
|
assetClass: AssetClass.EQUITY, |
||||
|
assetSubClass: AssetSubClass.STOCK, |
||||
|
currency: 'USD', |
||||
|
dataProviderInfo: { isPremium: true }, |
||||
|
dataSource: DataSource.GHOSTFOLIO, |
||||
|
name: 'Premium asset', |
||||
|
symbol: 'PREMIUM' |
||||
|
} |
||||
|
] |
||||
|
}); |
||||
|
|
||||
|
const result = await mcpService.searchAssetProfiles({ |
||||
|
query: 'Apple', |
||||
|
userId |
||||
|
}); |
||||
|
|
||||
|
expect(symbolService.lookup).toHaveBeenCalledWith({ |
||||
|
query: 'Apple', |
||||
|
user |
||||
|
}); |
||||
|
expect(JSON.parse(result.content[0].text)).toEqual({ |
||||
|
assetProfiles: [ |
||||
|
{ |
||||
|
assetClass: AssetClass.EQUITY, |
||||
|
assetSubClass: AssetSubClass.STOCK, |
||||
|
currency: 'USD', |
||||
|
dataSource: DataSource.GHOSTFOLIO, |
||||
|
name: 'Apple Inc.', |
||||
|
symbol: 'AAPL' |
||||
|
} |
||||
|
] |
||||
|
}); |
||||
|
}); |
||||
|
}); |
||||
|
|
||||
|
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,232 @@ |
|||||
|
import { ImportService } from '@ghostfolio/api/app/import/import.service'; |
||||
|
import { SymbolService } from '@ghostfolio/api/app/symbol/symbol.service'; |
||||
|
import { UserService } from '@ghostfolio/api/app/user/user.service'; |
||||
|
import { |
||||
|
getMaskedGhostfolioDataSource, |
||||
|
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, |
||||
|
SEARCH_ASSET_PROFILES_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 symbolService: SymbolService, |
||||
|
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); |
||||
|
} |
||||
|
|
||||
|
public async searchAssetProfiles({ |
||||
|
query, |
||||
|
userId |
||||
|
}: z.infer<typeof SEARCH_ASSET_PROFILES_PARAMETERS> & { userId: string }) { |
||||
|
const user = await this.getUserWithPermission({ |
||||
|
userId, |
||||
|
permission: permissions.createActivity |
||||
|
}); |
||||
|
|
||||
|
const { items } = await this.symbolService.lookup({ query, user }); |
||||
|
|
||||
|
const ghostfolioDataSources = this.configurationService.get( |
||||
|
'ENABLE_FEATURE_SUBSCRIPTION' |
||||
|
) |
||||
|
? this.configurationService.get('DATA_SOURCES_GHOSTFOLIO_DATA_PROVIDER') |
||||
|
: []; |
||||
|
|
||||
|
const assetProfiles = items.flatMap( |
||||
|
({ |
||||
|
assetClass, |
||||
|
assetSubClass, |
||||
|
currency, |
||||
|
dataProviderInfo, |
||||
|
dataSource, |
||||
|
name, |
||||
|
symbol |
||||
|
}) => { |
||||
|
if (!dataSource || dataProviderInfo.isPremium) { |
||||
|
return []; |
||||
|
} |
||||
|
|
||||
|
return [ |
||||
|
{ |
||||
|
assetClass, |
||||
|
assetSubClass, |
||||
|
currency, |
||||
|
name, |
||||
|
symbol, |
||||
|
dataSource: getMaskedGhostfolioDataSource({ |
||||
|
dataSource, |
||||
|
ghostfolioDataSources |
||||
|
}) |
||||
|
} |
||||
|
]; |
||||
|
} |
||||
|
); |
||||
|
|
||||
|
return this.getTextResult(JSON.stringify({ assetProfiles }, null, 2)); |
||||
|
} |
||||
|
|
||||
|
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]; |
||||
@ -1,8 +1,10 @@ |
|||||
import { DataGatheringItem } from '@ghostfolio/api/services/interfaces/interfaces'; |
import { DataGatheringItem } from '@ghostfolio/api/services/interfaces/interfaces'; |
||||
|
import { SubscriptionType } from '@ghostfolio/common/enums'; |
||||
|
|
||||
import { DateQuery } from './date-query.interface'; |
import { DateQuery } from './date-query.interface'; |
||||
|
|
||||
export interface GetValuesParams { |
export interface GetValuesParams { |
||||
dataGatheringItems: DataGatheringItem[]; |
dataGatheringItems: DataGatheringItem[]; |
||||
dateQuery: DateQuery; |
dateQuery: DateQuery; |
||||
|
subscriptionType?: SubscriptionType; |
||||
} |
} |
||||
|
|||||
@ -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 |
||||
|
>; |
||||
@ -0,0 +1,217 @@ |
|||||
|
import { UserService } from '@ghostfolio/client/services/user/user.service'; |
||||
|
import { |
||||
|
DEFAULT_COLOR_SCHEME, |
||||
|
DEFAULT_LOCALE |
||||
|
} from '@ghostfolio/common/config'; |
||||
|
import { AssetProfileIdentifier, User } from '@ghostfolio/common/interfaces'; |
||||
|
import { internalRoutes } from '@ghostfolio/common/routes/routes'; |
||||
|
import { AdminService } from '@ghostfolio/ui/services'; |
||||
|
|
||||
|
import { |
||||
|
ChangeDetectionStrategy, |
||||
|
Component, |
||||
|
computed, |
||||
|
DestroyRef, |
||||
|
inject, |
||||
|
OnDestroy, |
||||
|
OnInit |
||||
|
} from '@angular/core'; |
||||
|
import { takeUntilDestroyed } from '@angular/core/rxjs-interop'; |
||||
|
import { MatDialog, MatDialogRef } from '@angular/material/dialog'; |
||||
|
import { ActivatedRoute, Router } from '@angular/router'; |
||||
|
import { DataSource } from '@prisma/client'; |
||||
|
import { DeviceDetectorService } from 'ngx-device-detector'; |
||||
|
import { Subject } from 'rxjs'; |
||||
|
import { distinctUntilChanged, map, takeUntil, tap } from 'rxjs/operators'; |
||||
|
|
||||
|
import { AdminMarketDataService } from '../admin-market-data.service'; |
||||
|
import { GfAssetProfileDialogComponent } from '../asset-profile-dialog/asset-profile-dialog.component'; |
||||
|
import { AssetProfileDialogParams } from '../asset-profile-dialog/interfaces/interfaces'; |
||||
|
import { GfCreateAssetProfileDialogComponent } from '../create-asset-profile-dialog/create-asset-profile-dialog.component'; |
||||
|
import { CreateAssetProfileDialogParams } from '../create-asset-profile-dialog/interfaces/interfaces'; |
||||
|
import { AssetProfileDialogMode } from './types/asset-profile-dialog-mode.type'; |
||||
|
|
||||
|
@Component({ |
||||
|
changeDetection: ChangeDetectionStrategy.OnPush, |
||||
|
selector: 'gf-asset-profile-dialog-host', |
||||
|
template: '' |
||||
|
}) |
||||
|
export class GfAssetProfileDialogHostComponent implements OnDestroy, OnInit { |
||||
|
private dialogRef: MatDialogRef< |
||||
|
GfAssetProfileDialogComponent | GfCreateAssetProfileDialogComponent |
||||
|
>; |
||||
|
|
||||
|
private readonly deviceType = computed(() => { |
||||
|
return this.deviceDetectorService.deviceInfo().deviceType; |
||||
|
}); |
||||
|
|
||||
|
private readonly dialogClosed = new Subject<void>(); |
||||
|
|
||||
|
private readonly adminMarketDataService = inject(AdminMarketDataService); |
||||
|
private readonly adminService = inject(AdminService); |
||||
|
private readonly destroyRef = inject(DestroyRef); |
||||
|
private readonly deviceDetectorService = inject(DeviceDetectorService); |
||||
|
private readonly dialog = inject(MatDialog); |
||||
|
private readonly route = inject(ActivatedRoute); |
||||
|
private readonly router = inject(Router); |
||||
|
private readonly userService = inject(UserService); |
||||
|
|
||||
|
public ngOnInit() { |
||||
|
const mode = this.route.snapshot.data.mode as AssetProfileDialogMode; |
||||
|
|
||||
|
// The router reuses this component when only the asset profile identifier
|
||||
|
// changes, so the parameters are observed instead of read from the
|
||||
|
// snapshot once
|
||||
|
this.route.paramMap |
||||
|
.pipe( |
||||
|
map((paramMap) => { |
||||
|
return { |
||||
|
dataSource: paramMap.get('dataSource'), |
||||
|
symbol: paramMap.get('symbol') |
||||
|
}; |
||||
|
}), |
||||
|
distinctUntilChanged((previous, current) => { |
||||
|
return ( |
||||
|
previous.dataSource === current.dataSource && |
||||
|
previous.symbol === current.symbol |
||||
|
); |
||||
|
}), |
||||
|
tap(() => { |
||||
|
this.closeDialog(); |
||||
|
}), |
||||
|
takeUntilDestroyed(this.destroyRef) |
||||
|
) |
||||
|
.subscribe(({ dataSource, symbol }) => { |
||||
|
this.userService |
||||
|
.get() |
||||
|
.pipe(takeUntilDestroyed(this.destroyRef)) |
||||
|
.subscribe((user) => { |
||||
|
if (mode === 'create') { |
||||
|
this.openCreateAssetProfileDialog({ user }); |
||||
|
} else if (dataSource && symbol) { |
||||
|
this.openAssetProfileDialog({ |
||||
|
symbol, |
||||
|
user, |
||||
|
dataSource: dataSource as DataSource |
||||
|
}); |
||||
|
} else { |
||||
|
this.navigateBack(); |
||||
|
} |
||||
|
}); |
||||
|
}); |
||||
|
} |
||||
|
|
||||
|
public ngOnDestroy() { |
||||
|
// The dialog lives in an overlay outside of this component, so it needs to
|
||||
|
// be closed explicitly when leaving the route (for example via the browser
|
||||
|
// navigation)
|
||||
|
this.dialogRef?.close(); |
||||
|
|
||||
|
this.dialogClosed.complete(); |
||||
|
} |
||||
|
|
||||
|
private closeDialog() { |
||||
|
// Tear down the subscription of the dialog which is about to be replaced,
|
||||
|
// so that its result is not mistaken for the user closing it
|
||||
|
this.dialogClosed.next(); |
||||
|
|
||||
|
this.dialogRef?.close(); |
||||
|
} |
||||
|
|
||||
|
private navigateBack() { |
||||
|
void this.router.navigate( |
||||
|
internalRoutes.adminControl.subRoutes.marketData.routerLink |
||||
|
); |
||||
|
} |
||||
|
|
||||
|
private navigateToAssetProfileDialog({ |
||||
|
dataSource, |
||||
|
symbol |
||||
|
}: AssetProfileIdentifier) { |
||||
|
void this.router.navigate( |
||||
|
internalRoutes.adminControl.subRoutes.marketData.subRoutes.update.routerLink( |
||||
|
dataSource, |
||||
|
symbol |
||||
|
) |
||||
|
); |
||||
|
} |
||||
|
|
||||
|
private openAssetProfileDialog({ |
||||
|
dataSource, |
||||
|
symbol, |
||||
|
user |
||||
|
}: AssetProfileIdentifier & { user: User }) { |
||||
|
const dialogRef = this.dialog.open< |
||||
|
GfAssetProfileDialogComponent, |
||||
|
AssetProfileDialogParams, |
||||
|
AssetProfileIdentifier |
||||
|
>(GfAssetProfileDialogComponent, { |
||||
|
autoFocus: false, |
||||
|
data: { |
||||
|
dataSource, |
||||
|
symbol, |
||||
|
colorScheme: user?.settings.colorScheme ?? DEFAULT_COLOR_SCHEME, |
||||
|
deviceType: this.deviceType(), |
||||
|
locale: user?.settings?.locale ?? DEFAULT_LOCALE |
||||
|
} satisfies AssetProfileDialogParams, |
||||
|
height: this.deviceType() === 'mobile' ? '98vh' : '80vh', |
||||
|
width: this.deviceType() === 'mobile' ? '100vw' : '50rem' |
||||
|
}); |
||||
|
|
||||
|
this.dialogRef = dialogRef; |
||||
|
|
||||
|
dialogRef |
||||
|
.afterClosed() |
||||
|
.pipe(takeUntil(this.dialogClosed), takeUntilDestroyed(this.destroyRef)) |
||||
|
.subscribe((newAssetProfileIdentifier) => { |
||||
|
this.adminMarketDataService.triggerRefresh(); |
||||
|
|
||||
|
if (newAssetProfileIdentifier) { |
||||
|
this.navigateToAssetProfileDialog(newAssetProfileIdentifier); |
||||
|
} else { |
||||
|
this.navigateBack(); |
||||
|
} |
||||
|
}); |
||||
|
} |
||||
|
|
||||
|
private openCreateAssetProfileDialog({ user }: { user: User }) { |
||||
|
const dialogRef = this.dialog.open< |
||||
|
GfCreateAssetProfileDialogComponent, |
||||
|
CreateAssetProfileDialogParams, |
||||
|
AssetProfileIdentifier & { addAssetProfile: boolean } |
||||
|
>(GfCreateAssetProfileDialogComponent, { |
||||
|
autoFocus: false, |
||||
|
data: { |
||||
|
deviceType: this.deviceType(), |
||||
|
locale: user?.settings?.locale ?? DEFAULT_LOCALE |
||||
|
} satisfies CreateAssetProfileDialogParams, |
||||
|
width: this.deviceType() === 'mobile' ? '100vw' : '50rem' |
||||
|
}); |
||||
|
|
||||
|
this.dialogRef = dialogRef; |
||||
|
|
||||
|
dialogRef |
||||
|
.afterClosed() |
||||
|
.pipe(takeUntil(this.dialogClosed), takeUntilDestroyed(this.destroyRef)) |
||||
|
.subscribe((result) => { |
||||
|
if (!result) { |
||||
|
this.navigateBack(); |
||||
|
|
||||
|
return; |
||||
|
} |
||||
|
|
||||
|
const { addAssetProfile, dataSource, symbol } = result; |
||||
|
|
||||
|
if (addAssetProfile && dataSource && symbol) { |
||||
|
this.adminService |
||||
|
.addAssetProfile({ dataSource, symbol }) |
||||
|
.pipe(takeUntilDestroyed(this.destroyRef)) |
||||
|
.subscribe(() => { |
||||
|
this.navigateToAssetProfileDialog({ dataSource, symbol }); |
||||
|
}); |
||||
|
} else { |
||||
|
this.navigateToAssetProfileDialog({ dataSource, symbol }); |
||||
|
} |
||||
|
}); |
||||
|
} |
||||
|
} |
||||
@ -0,0 +1 @@ |
|||||
|
export type AssetProfileDialogMode = 'create' | 'update'; |
||||
@ -0,0 +1,15 @@ |
|||||
|
import { Service } from '@angular/core'; |
||||
|
import { Subject } from 'rxjs'; |
||||
|
|
||||
|
@Service({ autoProvided: false }) |
||||
|
export class HomeWatchlistService { |
||||
|
private readonly refreshSubject = new Subject<void>(); |
||||
|
|
||||
|
public get refresh$() { |
||||
|
return this.refreshSubject.asObservable(); |
||||
|
} |
||||
|
|
||||
|
public triggerRefresh() { |
||||
|
this.refreshSubject.next(); |
||||
|
} |
||||
|
} |
||||
Some files were not shown because too many files changed in this diff
Loading…
Reference in new issue