mirror of https://github.com/ghostfolio/ghostfolio
committed by
GitHub
143 changed files with 10781 additions and 3485 deletions
@ -0,0 +1,279 @@ |
|||||
|
import { HAS_PERMISSION_KEY } from '@ghostfolio/api/decorators/has-permission.decorator'; |
||||
|
import { CallerFacingError } from '@ghostfolio/api/errors/caller-facing.error'; |
||||
|
import { CustomThrottlerGuard } from '@ghostfolio/api/guards/custom-throttler.guard'; |
||||
|
import { HasPermissionGuard } from '@ghostfolio/api/guards/has-permission.guard'; |
||||
|
import { ApiService } from '@ghostfolio/api/services/api/api.service'; |
||||
|
import { |
||||
|
AI_ACTIVITIES_TIMEOUT, |
||||
|
AI_MAX_ACTIVITIES, |
||||
|
THROTTLE_AI_ACTIVITIES_LIMIT, |
||||
|
THROTTLE_AI_ACTIVITIES_TTL |
||||
|
} from '@ghostfolio/common/config'; |
||||
|
import { permissions } from '@ghostfolio/common/permissions'; |
||||
|
import type { RequestWithUser } from '@ghostfolio/common/types'; |
||||
|
|
||||
|
import { HttpException, Logger } from '@nestjs/common'; |
||||
|
import { GUARDS_METADATA } from '@nestjs/common/constants'; |
||||
|
import { |
||||
|
THROTTLER_LIMIT, |
||||
|
THROTTLER_TRACKER, |
||||
|
THROTTLER_TTL |
||||
|
} from '@nestjs/throttler/dist/throttler.constants'; |
||||
|
import type { Response } from 'express'; |
||||
|
import { StatusCodes } from 'http-status-codes'; |
||||
|
import { EventEmitter } from 'node:events'; |
||||
|
|
||||
|
import { AiController } from './ai.controller'; |
||||
|
import { AiService } from './ai.service'; |
||||
|
|
||||
|
// The packages are ECMAScript modules, which Jest cannot load
|
||||
|
jest.mock('@openrouter/ai-sdk-provider', () => { |
||||
|
return {}; |
||||
|
}); |
||||
|
jest.mock('ai', () => { |
||||
|
return {}; |
||||
|
}); |
||||
|
|
||||
|
describe('AiController', () => { |
||||
|
const pdfFile = { |
||||
|
buffer: Buffer.from('%PDF-1.7'), |
||||
|
originalname: 'statement.pdf' |
||||
|
}; |
||||
|
|
||||
|
const user = { id: 'user-id' }; |
||||
|
|
||||
|
let aiController: AiController; |
||||
|
let aiService: AiService; |
||||
|
|
||||
|
function createCsvFile(dataRowCount: number) { |
||||
|
return { |
||||
|
buffer: Buffer.from( |
||||
|
[ |
||||
|
'Date,Symbol', |
||||
|
...Array.from({ length: dataRowCount }, () => { |
||||
|
return '2026-01-15,AAPL'; |
||||
|
}) |
||||
|
].join('\n') |
||||
|
), |
||||
|
originalname: 'statement.csv' |
||||
|
}; |
||||
|
} |
||||
|
|
||||
|
function createResponse() { |
||||
|
return Object.assign(new EventEmitter(), { |
||||
|
writableFinished: false |
||||
|
}) as unknown as Response; |
||||
|
} |
||||
|
|
||||
|
function getMetadataOfMethod<T>(metadataKey: string) { |
||||
|
return Reflect.getMetadata( |
||||
|
metadataKey, |
||||
|
AiController.prototype.getActivitiesFromFile |
||||
|
) as T; |
||||
|
} |
||||
|
|
||||
|
beforeEach(() => { |
||||
|
jest.spyOn(Logger.prototype, 'warn').mockImplementation(); |
||||
|
|
||||
|
aiService = { |
||||
|
getActivitiesFromFile: jest.fn().mockResolvedValue({ |
||||
|
activities: [], |
||||
|
assetProfiles: [], |
||||
|
rowsWithoutMatch: [] |
||||
|
}) |
||||
|
} as unknown as AiService; |
||||
|
|
||||
|
aiController = new AiController( |
||||
|
aiService, |
||||
|
{} as ApiService, |
||||
|
{ |
||||
|
user |
||||
|
} as unknown as RequestWithUser |
||||
|
); |
||||
|
}); |
||||
|
|
||||
|
describe('getActivitiesFromFile', () => { |
||||
|
it('Requires the permission and limits the requests of each user per week', () => { |
||||
|
expect(getMetadataOfMethod<string>(HAS_PERMISSION_KEY)).toBe( |
||||
|
permissions.enableGhostfolioAi |
||||
|
); |
||||
|
|
||||
|
const guards = getMetadataOfMethod<unknown[]>(GUARDS_METADATA); |
||||
|
|
||||
|
expect(guards.slice(1)).toEqual([ |
||||
|
HasPermissionGuard, |
||||
|
CustomThrottlerGuard |
||||
|
]); |
||||
|
|
||||
|
expect(getMetadataOfMethod<number>(`${THROTTLER_LIMIT}default`)).toBe( |
||||
|
THROTTLE_AI_ACTIVITIES_LIMIT |
||||
|
); |
||||
|
|
||||
|
expect(getMetadataOfMethod<number>(`${THROTTLER_TTL}default`)).toBe( |
||||
|
THROTTLE_AI_ACTIVITIES_TTL |
||||
|
); |
||||
|
|
||||
|
expect( |
||||
|
getMetadataOfMethod<(request: unknown) => string>( |
||||
|
`${THROTTLER_TRACKER}default` |
||||
|
)({ ip: '127.0.0.1', user }) |
||||
|
).toBe(user.id); |
||||
|
}); |
||||
|
|
||||
|
it('Refuses a request without a file or with a file of another type', async () => { |
||||
|
for (const file of [ |
||||
|
undefined, |
||||
|
{ buffer: Buffer.from('Date,Symbol'), originalname: 'statement.txt' } |
||||
|
]) { |
||||
|
await expect( |
||||
|
aiController.getActivitiesFromFile(createResponse(), file) |
||||
|
).rejects.toThrow(HttpException); |
||||
|
} |
||||
|
|
||||
|
expect(aiService.getActivitiesFromFile).not.toHaveBeenCalled(); |
||||
|
}); |
||||
|
|
||||
|
it(`Refuses a CSV file with more than ${AI_MAX_ACTIVITIES} rows before a model call`, async () => { |
||||
|
await expect( |
||||
|
aiController.getActivitiesFromFile( |
||||
|
createResponse(), |
||||
|
createCsvFile(AI_MAX_ACTIVITIES + 1) |
||||
|
) |
||||
|
).rejects.toThrow(HttpException); |
||||
|
|
||||
|
expect(aiService.getActivitiesFromFile).not.toHaveBeenCalled(); |
||||
|
|
||||
|
expect(Logger.prototype.warn).toHaveBeenCalledWith( |
||||
|
`Rejected a CSV file with ${AI_MAX_ACTIVITIES + 1} rows` |
||||
|
); |
||||
|
}); |
||||
|
|
||||
|
it(`Accepts a CSV file with ${AI_MAX_ACTIVITIES} rows`, async () => { |
||||
|
const file = createCsvFile(AI_MAX_ACTIVITIES); |
||||
|
|
||||
|
await aiController.getActivitiesFromFile(createResponse(), file); |
||||
|
|
||||
|
expect(aiService.getActivitiesFromFile).toHaveBeenCalledWith( |
||||
|
expect.objectContaining({ |
||||
|
file: { |
||||
|
buffer: file.buffer, |
||||
|
mediaType: 'text/csv', |
||||
|
name: 'statement.csv' |
||||
|
} |
||||
|
}) |
||||
|
); |
||||
|
}); |
||||
|
|
||||
|
it('Passes the file with its media type and its name and the user of the request', async () => { |
||||
|
await expect( |
||||
|
aiController.getActivitiesFromFile(createResponse(), pdfFile) |
||||
|
).resolves.toEqual({ |
||||
|
activities: [], |
||||
|
assetProfiles: [], |
||||
|
rowsWithoutMatch: [] |
||||
|
}); |
||||
|
|
||||
|
expect(aiService.getActivitiesFromFile).toHaveBeenCalledWith( |
||||
|
expect.objectContaining({ |
||||
|
user, |
||||
|
file: { |
||||
|
buffer: pdfFile.buffer, |
||||
|
mediaType: 'application/pdf', |
||||
|
name: 'statement.pdf' |
||||
|
} |
||||
|
}) |
||||
|
); |
||||
|
|
||||
|
const [[{ abortSignal }]] = jest.mocked(aiService.getActivitiesFromFile) |
||||
|
.mock.calls; |
||||
|
|
||||
|
expect(abortSignal).toBeInstanceOf(AbortSignal); |
||||
|
}); |
||||
|
|
||||
|
it('Stops the models if the client closes the request before the response', async () => { |
||||
|
const response = createResponse(); |
||||
|
|
||||
|
await aiController.getActivitiesFromFile(response, pdfFile); |
||||
|
|
||||
|
const [[{ abortSignal }]] = jest.mocked(aiService.getActivitiesFromFile) |
||||
|
.mock.calls; |
||||
|
|
||||
|
expect(abortSignal.aborted).toBe(false); |
||||
|
|
||||
|
response.emit('close'); |
||||
|
|
||||
|
expect(abortSignal.aborted).toBe(true); |
||||
|
}); |
||||
|
|
||||
|
it('Does not stop the models when the response is finished', async () => { |
||||
|
const response = createResponse(); |
||||
|
|
||||
|
await aiController.getActivitiesFromFile(response, pdfFile); |
||||
|
|
||||
|
const [[{ abortSignal }]] = jest.mocked(aiService.getActivitiesFromFile) |
||||
|
.mock.calls; |
||||
|
|
||||
|
Object.assign(response, { writableFinished: true }); |
||||
|
response.emit('close'); |
||||
|
|
||||
|
expect(abortSignal.aborted).toBe(false); |
||||
|
}); |
||||
|
|
||||
|
it('Gives the status 400 with the message of an error for the caller', async () => { |
||||
|
jest |
||||
|
.mocked(aiService.getActivitiesFromFile) |
||||
|
.mockRejectedValueOnce( |
||||
|
new CallerFacingError('The file has too many activities') |
||||
|
); |
||||
|
|
||||
|
await expect( |
||||
|
aiController.getActivitiesFromFile(createResponse(), pdfFile) |
||||
|
).rejects.toMatchObject({ |
||||
|
response: { message: ['The file has too many activities'] }, |
||||
|
status: StatusCodes.BAD_REQUEST |
||||
|
}); |
||||
|
}); |
||||
|
|
||||
|
it('Gives the status 400 with a message for the caller at the timeout', async () => { |
||||
|
const timeoutSignal = AbortSignal.abort( |
||||
|
new DOMException( |
||||
|
'The operation was aborted due to timeout', |
||||
|
'TimeoutError' |
||||
|
) |
||||
|
); |
||||
|
|
||||
|
jest.spyOn(AbortSignal, 'timeout').mockReturnValueOnce(timeoutSignal); |
||||
|
|
||||
|
jest |
||||
|
.mocked(aiService.getActivitiesFromFile) |
||||
|
.mockRejectedValueOnce(timeoutSignal.reason); |
||||
|
|
||||
|
await expect( |
||||
|
aiController.getActivitiesFromFile(createResponse(), pdfFile) |
||||
|
).rejects.toMatchObject({ |
||||
|
response: { |
||||
|
message: [ |
||||
|
'Ghostfolio AI needs too much time for the file. Split it into smaller files.' |
||||
|
] |
||||
|
}, |
||||
|
status: StatusCodes.BAD_REQUEST |
||||
|
}); |
||||
|
|
||||
|
expect(AbortSignal.timeout).toHaveBeenCalledWith(AI_ACTIVITIES_TIMEOUT); |
||||
|
|
||||
|
expect(Logger.prototype.warn).toHaveBeenCalledWith( |
||||
|
`Stopped reading a file of the type application/pdf at the timeout of ${AI_ACTIVITIES_TIMEOUT} ms` |
||||
|
); |
||||
|
}); |
||||
|
|
||||
|
it('Passes on every other error', async () => { |
||||
|
const error = new Error('Model error'); |
||||
|
|
||||
|
jest.mocked(aiService.getActivitiesFromFile).mockRejectedValueOnce(error); |
||||
|
|
||||
|
await expect( |
||||
|
aiController.getActivitiesFromFile(createResponse(), pdfFile) |
||||
|
).rejects.toBe(error); |
||||
|
}); |
||||
|
}); |
||||
|
}); |
||||
@ -0,0 +1,99 @@ |
|||||
|
import { |
||||
|
getDataRowCountOfCsvFile, |
||||
|
getMediaTypeOfActivityFile |
||||
|
} from './ai.helper'; |
||||
|
|
||||
|
describe('getDataRowCountOfCsvFile', () => { |
||||
|
it('Gives the number of the lines which are not empty, without the header', () => { |
||||
|
expect( |
||||
|
getDataRowCountOfCsvFile( |
||||
|
Buffer.from('Date,Symbol\n2026-01-15,AAPL\n\n2026-01-16,MSFT\n') |
||||
|
) |
||||
|
).toBe(2); |
||||
|
}); |
||||
|
|
||||
|
it('Counts each line of a value with a line break in quotes', () => { |
||||
|
expect( |
||||
|
getDataRowCountOfCsvFile( |
||||
|
Buffer.from('Date,Comment\r\n2026-01-15,"First\r\nsecond"\r\n') |
||||
|
) |
||||
|
).toBe(2); |
||||
|
}); |
||||
|
|
||||
|
it('Counts each line after a quote which is not closed', () => { |
||||
|
expect( |
||||
|
getDataRowCountOfCsvFile( |
||||
|
Buffer.from( |
||||
|
[ |
||||
|
'Date,Symbol,Quantity', |
||||
|
'2026-01-15,"AAPL,1', |
||||
|
...Array.from({ length: 200 }, () => { |
||||
|
return '2026-01-16,MSFT,1'; |
||||
|
}) |
||||
|
].join('\n') |
||||
|
) |
||||
|
) |
||||
|
).toBe(201); |
||||
|
}); |
||||
|
|
||||
|
it('Gives 0 for a file with a header only or without content', () => { |
||||
|
expect(getDataRowCountOfCsvFile(Buffer.from('Date,Symbol\n'))).toBe(0); |
||||
|
expect(getDataRowCountOfCsvFile(Buffer.from(''))).toBe(0); |
||||
|
}); |
||||
|
}); |
||||
|
|
||||
|
describe('getMediaTypeOfActivityFile', () => { |
||||
|
it('Identifies a JPEG, a PDF and a PNG file by the first bytes', () => { |
||||
|
expect( |
||||
|
getMediaTypeOfActivityFile({ |
||||
|
buffer: Buffer.from([0xff, 0xd8, 0xff, 0xe0]), |
||||
|
fileName: 'statement' |
||||
|
}) |
||||
|
).toBe('image/jpeg'); |
||||
|
|
||||
|
expect( |
||||
|
getMediaTypeOfActivityFile({ |
||||
|
buffer: Buffer.from('%PDF-1.7'), |
||||
|
fileName: 'statement.csv' |
||||
|
}) |
||||
|
).toBe('application/pdf'); |
||||
|
|
||||
|
expect( |
||||
|
getMediaTypeOfActivityFile({ |
||||
|
buffer: Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]), |
||||
|
fileName: 'statement.jpg' |
||||
|
}) |
||||
|
).toBe('image/png'); |
||||
|
}); |
||||
|
|
||||
|
it('Identifies a CSV file by the extension and the content in UTF-8', () => { |
||||
|
for (const fileName of ['statement.csv', 'STATEMENT.CSV']) { |
||||
|
expect( |
||||
|
getMediaTypeOfActivityFile({ |
||||
|
fileName, |
||||
|
buffer: Buffer.from('Datum;Stück\n15.01.2026;10\n') |
||||
|
}) |
||||
|
).toBe('text/csv'); |
||||
|
} |
||||
|
}); |
||||
|
|
||||
|
it('Refuses a CSV file which is not in UTF-8', () => { |
||||
|
expect( |
||||
|
getMediaTypeOfActivityFile({ |
||||
|
buffer: Buffer.from([0x53, 0x74, 0xfc, 0x63, 0x6b]), |
||||
|
fileName: 'statement.csv' |
||||
|
}) |
||||
|
).toBeUndefined(); |
||||
|
}); |
||||
|
|
||||
|
it('Refuses any other file', () => { |
||||
|
for (const { buffer, fileName } of [ |
||||
|
{ buffer: Buffer.from('Date,Symbol'), fileName: 'statement.txt' }, |
||||
|
{ buffer: Buffer.from('<html></html>'), fileName: 'statement.pdf' }, |
||||
|
{ buffer: Buffer.from([0xff, 0xd8]), fileName: 'statement.jpg' }, |
||||
|
{ buffer: Buffer.from(''), fileName: 'statement.png' } |
||||
|
]) { |
||||
|
expect(getMediaTypeOfActivityFile({ buffer, fileName })).toBeUndefined(); |
||||
|
} |
||||
|
}); |
||||
|
}); |
||||
@ -0,0 +1,57 @@ |
|||||
|
import { isUtf8 } from 'node:buffer'; |
||||
|
|
||||
|
const JPEG_SIGNATURE = [0xff, 0xd8, 0xff]; |
||||
|
const PDF_SIGNATURE = [0x25, 0x50, 0x44, 0x46, 0x2d]; |
||||
|
const PNG_SIGNATURE = [0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]; |
||||
|
|
||||
|
/** |
||||
|
* Gives the number of the lines of a CSV file which are not empty, without |
||||
|
* the first line. A line before the header and each line of a value with a |
||||
|
* line break are counted as data rows, hence the number is never too low, |
||||
|
* also for a file with a quote which is not closed. |
||||
|
*/ |
||||
|
export function getDataRowCountOfCsvFile(buffer: Buffer) { |
||||
|
const lineCount = buffer |
||||
|
.toString('utf8') |
||||
|
.split(/\r\n|\r|\n/) |
||||
|
.filter((line) => { |
||||
|
return line.trim().length > 0; |
||||
|
}).length; |
||||
|
|
||||
|
return Math.max(lineCount - 1, 0); |
||||
|
} |
||||
|
|
||||
|
/** |
||||
|
* Gives the media type of a file which Ghostfolio AI reads. A JPEG, a PDF or |
||||
|
* a PNG file is identified by its first bytes, a CSV file by its extension |
||||
|
* and by its content in UTF-8, as the media type which a client sends for a |
||||
|
* CSV file varies. |
||||
|
*/ |
||||
|
export function getMediaTypeOfActivityFile({ |
||||
|
buffer, |
||||
|
fileName |
||||
|
}: { |
||||
|
buffer: Buffer; |
||||
|
fileName: string; |
||||
|
}) { |
||||
|
if (startsWith(buffer, JPEG_SIGNATURE)) { |
||||
|
return 'image/jpeg'; |
||||
|
} else if (startsWith(buffer, PDF_SIGNATURE)) { |
||||
|
return 'application/pdf'; |
||||
|
} else if (startsWith(buffer, PNG_SIGNATURE)) { |
||||
|
return 'image/png'; |
||||
|
} else if (fileName.toLowerCase().endsWith('.csv') && isUtf8(buffer)) { |
||||
|
return 'text/csv'; |
||||
|
} |
||||
|
|
||||
|
return undefined; |
||||
|
} |
||||
|
|
||||
|
function startsWith(buffer: Buffer, signature: number[]) { |
||||
|
return ( |
||||
|
buffer.length >= signature.length && |
||||
|
signature.every((byte, index) => { |
||||
|
return buffer[index] === byte; |
||||
|
}) |
||||
|
); |
||||
|
} |
||||
@ -0,0 +1,101 @@ |
|||||
|
import { |
||||
|
AI_MAX_ACTIVITIES, |
||||
|
SEARCH_QUERY_MAXIMUM_LENGTH |
||||
|
} from '@ghostfolio/common/config'; |
||||
|
|
||||
|
import { isObject } from 'lodash-es'; |
||||
|
import { z } from 'zod'; |
||||
|
|
||||
|
import { ACTIVITY_ROWS_OUTPUT, ROW_MATCHES_OUTPUT } from './ai.schemas'; |
||||
|
import { createActivityRow } from './ai.test-utils'; |
||||
|
import { ActivityRow } from './types/activity-row.type'; |
||||
|
|
||||
|
describe('ACTIVITY_ROWS_OUTPUT', () => { |
||||
|
it('Accepts a row with null for the values which the file does not show', () => { |
||||
|
expect( |
||||
|
ACTIVITY_ROWS_OUTPUT.safeParse({ |
||||
|
broker: null, |
||||
|
rows: [ |
||||
|
createActivityRow({ |
||||
|
accountName: null, |
||||
|
currency: null, |
||||
|
isin: null, |
||||
|
symbol: null |
||||
|
}) |
||||
|
] |
||||
|
}).success |
||||
|
).toBe(true); |
||||
|
}); |
||||
|
|
||||
|
it('Refuses a row with a value which is left out instead of null', () => { |
||||
|
const row: Partial<ActivityRow> = createActivityRow(); |
||||
|
|
||||
|
delete row.isin; |
||||
|
|
||||
|
expect( |
||||
|
ACTIVITY_ROWS_OUTPUT.safeParse({ broker: null, rows: [row] }).success |
||||
|
).toBe(false); |
||||
|
}); |
||||
|
|
||||
|
it('Accepts a currency, a date, a number, a printed text and a number of rows which the join checks, so that one value does not refuse all rows', () => { |
||||
|
expect( |
||||
|
ACTIVITY_ROWS_OUTPUT.safeParse({ |
||||
|
broker: null, |
||||
|
rows: [ |
||||
|
createActivityRow({ |
||||
|
currency: 'GBX', |
||||
|
date: '15.01.2026', |
||||
|
name: 'A'.repeat(SEARCH_QUERY_MAXIMUM_LENGTH + 1) |
||||
|
}), |
||||
|
createActivityRow({ fee: -1, quantity: -1, unitPrice: -1 }), |
||||
|
...Array.from({ length: AI_MAX_ACTIVITIES }, () => { |
||||
|
return createActivityRow(); |
||||
|
}) |
||||
|
] |
||||
|
}).success |
||||
|
).toBe(true); |
||||
|
}); |
||||
|
|
||||
|
it('Trims a printed text', () => { |
||||
|
const { data } = ACTIVITY_ROWS_OUTPUT.safeParse({ |
||||
|
broker: ' Swissquote ', |
||||
|
rows: [createActivityRow({ name: ' Apple Inc. ' })] |
||||
|
}); |
||||
|
|
||||
|
expect(data.broker).toBe('Swissquote'); |
||||
|
expect(data.rows[0].name).toBe('Apple Inc.'); |
||||
|
}); |
||||
|
}); |
||||
|
|
||||
|
describe.each([ |
||||
|
['ACTIVITY_ROWS_OUTPUT', ACTIVITY_ROWS_OUTPUT], |
||||
|
['ROW_MATCHES_OUTPUT', ROW_MATCHES_OUTPUT] |
||||
|
])('%s', (_name, schema) => { |
||||
|
it('Gives a JSON schema which the strict mode of the structured outputs supports', () => { |
||||
|
// The options of the conversion in the AI SDK
|
||||
|
const jsonSchema = z.toJSONSchema(schema, { |
||||
|
io: 'input', |
||||
|
target: 'draft-7' |
||||
|
}); |
||||
|
|
||||
|
const objectJsonSchemas: { properties: object; required: string[] }[] = []; |
||||
|
|
||||
|
JSON.stringify(jsonSchema, (_key, value: unknown) => { |
||||
|
if (isObject(value) && 'properties' in value) { |
||||
|
objectJsonSchemas.push( |
||||
|
value as { properties: object; required: string[] } |
||||
|
); |
||||
|
} |
||||
|
|
||||
|
return value; |
||||
|
}); |
||||
|
|
||||
|
expect(objectJsonSchemas.length).toBeGreaterThan(1); |
||||
|
|
||||
|
for (const { properties, required } of objectJsonSchemas) { |
||||
|
expect([...required].sort()).toEqual(Object.keys(properties).sort()); |
||||
|
} |
||||
|
|
||||
|
expect(JSON.stringify(jsonSchema)).not.toMatch(/"(max|min)(imum|Length)"/); |
||||
|
}); |
||||
|
}); |
||||
@ -0,0 +1,87 @@ |
|||||
|
import { AI_MAX_ACTIVITIES } from '@ghostfolio/common/config'; |
||||
|
|
||||
|
import { Type as ActivityType } from '@prisma/client'; |
||||
|
import { z } from 'zod'; |
||||
|
|
||||
|
// The strict mode of the structured outputs requires every property, hence a
|
||||
|
// value which the file does not show is null instead of being left out. The
|
||||
|
// values are checked when the rows and the matches are joined to the
|
||||
|
// activities to import, so that a wrong value or a wrong match skips its rows
|
||||
|
// only
|
||||
|
const PRINTED_TEXT = z.string().trim().nullable(); |
||||
|
|
||||
|
export const ACTIVITY_ROWS_OUTPUT = z.object({ |
||||
|
broker: PRINTED_TEXT.describe( |
||||
|
'The name of the broker or of the bank which issued the file' |
||||
|
), |
||||
|
rows: z |
||||
|
.array( |
||||
|
z.object({ |
||||
|
accountName: PRINTED_TEXT.describe( |
||||
|
'The name or the number of the account, as printed' |
||||
|
), |
||||
|
currency: z |
||||
|
.string() |
||||
|
.nullable() |
||||
|
.describe( |
||||
|
'The currency of the fee and of the unit price, as an ISO 4217 code in upper case' |
||||
|
), |
||||
|
date: z |
||||
|
.string() |
||||
|
.describe('The date of the activity, as an ISO 8601 date'), |
||||
|
fee: z.number().describe('The fee of the activity'), |
||||
|
isin: PRINTED_TEXT.describe('The ISIN of the asset, as printed'), |
||||
|
name: PRINTED_TEXT.describe( |
||||
|
'The name of the asset, or the description of a fee, an interest or a liability, as printed' |
||||
|
), |
||||
|
quantity: z.number().describe('The quantity of the activity'), |
||||
|
symbol: PRINTED_TEXT.describe( |
||||
|
'The ticker symbol of the asset, as printed, not a national number such as a valor, a WKN or a CUSIP' |
||||
|
), |
||||
|
type: z.enum(ActivityType).describe('The type of the activity'), |
||||
|
unitPrice: z.number().describe('The unit price of the activity') |
||||
|
}) |
||||
|
) |
||||
|
.describe(`The activities of the file, at most ${AI_MAX_ACTIVITIES}`) |
||||
|
}); |
||||
|
|
||||
|
export const ROW_MATCHES_OUTPUT = z.object({ |
||||
|
accounts: z |
||||
|
.array( |
||||
|
z.object({ |
||||
|
accountId: z |
||||
|
.string() |
||||
|
.nullable() |
||||
|
.describe( |
||||
|
'The id of the account of the user which matches, as given in the accounts, or null if no account matches' |
||||
|
), |
||||
|
id: z.number().describe('The id of the account hint, as given') |
||||
|
}) |
||||
|
) |
||||
|
.describe('One entry for each account hint of the file'), |
||||
|
assets: z |
||||
|
.array( |
||||
|
z.object({ |
||||
|
currency: z |
||||
|
.string() |
||||
|
.nullable() |
||||
|
.describe( |
||||
|
'The currency of the asset profile, as given in the asset profiles of the asset' |
||||
|
), |
||||
|
dataSource: z |
||||
|
.string() |
||||
|
.nullable() |
||||
|
.describe( |
||||
|
'The data source of the asset profile, as given in the asset profiles of the asset' |
||||
|
), |
||||
|
id: z.number().describe('The id of the asset, as given'), |
||||
|
symbol: z |
||||
|
.string() |
||||
|
.nullable() |
||||
|
.describe( |
||||
|
'The symbol of the asset profile, as given in the asset profiles of the asset, or null if no asset profile matches' |
||||
|
) |
||||
|
}) |
||||
|
) |
||||
|
.describe('One entry for each asset of the file') |
||||
|
}); |
||||
File diff suppressed because it is too large
@ -0,0 +1,21 @@ |
|||||
|
import { Type as ActivityType } from '@prisma/client'; |
||||
|
|
||||
|
import { ActivityRow } from './types/activity-row.type'; |
||||
|
|
||||
|
export function createActivityRow( |
||||
|
overrides: Partial<ActivityRow> = {} |
||||
|
): ActivityRow { |
||||
|
return { |
||||
|
accountName: 'Brokerage', |
||||
|
currency: 'USD', |
||||
|
date: '2026-01-15', |
||||
|
fee: 1, |
||||
|
isin: 'US0378331005', |
||||
|
name: 'Apple Inc.', |
||||
|
quantity: 10, |
||||
|
symbol: 'AAPL', |
||||
|
type: ActivityType.BUY, |
||||
|
unitPrice: 230, |
||||
|
...overrides |
||||
|
}; |
||||
|
} |
||||
@ -0,0 +1,6 @@ |
|||||
|
import { ActivityRow } from './activity-row.type'; |
||||
|
|
||||
|
export type AccountHint = Pick<ActivityRow, 'accountName'> & { |
||||
|
broker: string | null; |
||||
|
id: number; |
||||
|
}; |
||||
@ -0,0 +1,3 @@ |
|||||
|
import { Account } from '@prisma/client'; |
||||
|
|
||||
|
export type AccountToMatch = Pick<Account, 'currency' | 'id' | 'name'>; |
||||
@ -0,0 +1,5 @@ |
|||||
|
import { z } from 'zod'; |
||||
|
|
||||
|
import { ACTIVITY_ROWS_OUTPUT } from '../ai.schemas'; |
||||
|
|
||||
|
export type ActivityRow = z.infer<typeof ACTIVITY_ROWS_OUTPUT>['rows'][number]; |
||||
@ -0,0 +1,6 @@ |
|||||
|
import { ActivityRow } from './activity-row.type'; |
||||
|
|
||||
|
export type AssetToMatch = Pick< |
||||
|
ActivityRow, |
||||
|
'currency' | 'isin' | 'name' | 'symbol' |
||||
|
> & { id: number }; |
||||
@ -0,0 +1,178 @@ |
|||||
|
import { PortfolioService } from '@ghostfolio/api/app/portfolio/portfolio.service'; |
||||
|
import { SymbolService } from '@ghostfolio/api/app/symbol/symbol.service'; |
||||
|
import { BenchmarkService } from '@ghostfolio/api/services/benchmark/benchmark.service'; |
||||
|
import { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-data/exchange-rate-data.service'; |
||||
|
import { MarketDataService } from '@ghostfolio/api/services/market-data/market-data.service'; |
||||
|
import { parseDate } from '@ghostfolio/common/helper'; |
||||
|
|
||||
|
import { DataSource } from '@prisma/client'; |
||||
|
|
||||
|
import { BenchmarksService } from './benchmarks.service'; |
||||
|
|
||||
|
describe('BenchmarksService', () => { |
||||
|
let benchmarksService: BenchmarksService; |
||||
|
let get: jest.Mock; |
||||
|
let getExchangeRatesByCurrency: jest.Mock; |
||||
|
let getPerformance: jest.Mock; |
||||
|
let getRange: jest.Mock; |
||||
|
let marketDataItems: jest.Mock; |
||||
|
|
||||
|
beforeEach(() => { |
||||
|
get = jest.fn().mockResolvedValue({ currency: 'USD', marketPrice: 200 }); |
||||
|
getExchangeRatesByCurrency = jest.fn().mockResolvedValue({}); |
||||
|
getPerformance = jest.fn().mockResolvedValue({ |
||||
|
chart: [ |
||||
|
{ date: '2009-01-01' }, |
||||
|
{ date: '2009-06-01' }, |
||||
|
{ date: '2010-07-18' }, |
||||
|
{ date: '2010-07-19' } |
||||
|
] |
||||
|
}); |
||||
|
getRange = jest.fn().mockResolvedValue([]); |
||||
|
marketDataItems = jest.fn(); |
||||
|
|
||||
|
benchmarksService = new BenchmarksService( |
||||
|
new BenchmarkService(null, null, null, null, null, null), |
||||
|
{ getExchangeRatesByCurrency } as unknown as ExchangeRateDataService, |
||||
|
{ getRange, marketDataItems } as unknown as MarketDataService, |
||||
|
{ getPerformance } as unknown as PortfolioService, |
||||
|
{ get } as unknown as SymbolService |
||||
|
); |
||||
|
}); |
||||
|
|
||||
|
describe('getMarketDataForUser', () => { |
||||
|
const getMarketDataForUser = ({ |
||||
|
endDate = parseDate('2010-07-19') |
||||
|
}: { endDate?: Date } = {}) => { |
||||
|
return benchmarksService.getMarketDataForUser({ |
||||
|
endDate, |
||||
|
dataSource: DataSource.COINGECKO, |
||||
|
dateRange: 'max', |
||||
|
startDate: parseDate('2009-01-02'), |
||||
|
symbol: 'bitcoin', |
||||
|
userId: 'user-id', |
||||
|
userSettings: { baseCurrency: 'CHF' } |
||||
|
}); |
||||
|
}; |
||||
|
|
||||
|
it('calculates the change since the baseline date', async () => { |
||||
|
marketDataItems.mockResolvedValue([ |
||||
|
{ date: new Date('2009-01-01'), marketPrice: 50 }, |
||||
|
{ date: new Date('2009-06-01'), marketPrice: 25 }, |
||||
|
{ date: new Date('2010-07-18'), marketPrice: 75 }, |
||||
|
{ date: new Date('2010-07-19'), marketPrice: 100 } |
||||
|
]); |
||||
|
|
||||
|
await expect(getMarketDataForUser()).resolves.toEqual({ |
||||
|
marketData: [ |
||||
|
{ date: '2009-01-01', value: 0 }, |
||||
|
{ date: '2009-06-01', value: -50 }, |
||||
|
{ date: '2010-07-18', value: 50 }, |
||||
|
{ date: '2010-07-19', value: 100 } |
||||
|
] |
||||
|
}); |
||||
|
|
||||
|
expect(getRange).not.toHaveBeenCalled(); |
||||
|
}); |
||||
|
|
||||
|
it('returns no values before the market data of the benchmark starts', async () => { |
||||
|
marketDataItems.mockResolvedValue([ |
||||
|
{ date: new Date('2010-07-18'), marketPrice: 50 }, |
||||
|
{ date: new Date('2010-07-19'), marketPrice: 100 } |
||||
|
]); |
||||
|
|
||||
|
await expect(getMarketDataForUser()).resolves.toEqual({ |
||||
|
marketData: [ |
||||
|
{ date: '2010-07-18', value: 0 }, |
||||
|
{ date: '2010-07-19', value: 100 } |
||||
|
] |
||||
|
}); |
||||
|
|
||||
|
expect(getRange).toHaveBeenCalledWith({ |
||||
|
assetProfileIdentifiers: [ |
||||
|
{ dataSource: DataSource.COINGECKO, symbol: 'bitcoin' } |
||||
|
], |
||||
|
dateQuery: { lt: new Date('2009-01-01') }, |
||||
|
take: 1 |
||||
|
}); |
||||
|
|
||||
|
expect(marketDataItems).toHaveBeenCalledWith( |
||||
|
expect.objectContaining({ orderBy: { date: 'asc' } }) |
||||
|
); |
||||
|
}); |
||||
|
|
||||
|
it('uses the exchange rate at the start of the market data', async () => { |
||||
|
getExchangeRatesByCurrency.mockResolvedValue({ |
||||
|
USDCHF: { |
||||
|
'2009-01-01': 2, |
||||
|
'2009-06-01': 2, |
||||
|
'2010-07-18': 1, |
||||
|
'2010-07-19': 1.5 |
||||
|
} |
||||
|
}); |
||||
|
|
||||
|
marketDataItems.mockResolvedValue([ |
||||
|
{ date: new Date('2010-07-18'), marketPrice: 50 }, |
||||
|
{ date: new Date('2010-07-19'), marketPrice: 100 } |
||||
|
]); |
||||
|
|
||||
|
await expect(getMarketDataForUser()).resolves.toEqual({ |
||||
|
marketData: [ |
||||
|
{ date: '2010-07-18', value: 0 }, |
||||
|
{ date: '2010-07-19', value: 200 } |
||||
|
] |
||||
|
}); |
||||
|
}); |
||||
|
|
||||
|
it('calculates the value at the end date since the start of the market data', async () => { |
||||
|
getExchangeRatesByCurrency.mockResolvedValue({ |
||||
|
USDCHF: { |
||||
|
'2009-01-01': 2, |
||||
|
'2010-07-18': 1, |
||||
|
'2010-07-19': 1, |
||||
|
'2010-07-20': 1.5 |
||||
|
} |
||||
|
}); |
||||
|
|
||||
|
marketDataItems.mockResolvedValue([ |
||||
|
{ date: new Date('2010-07-18'), marketPrice: 50 }, |
||||
|
{ date: new Date('2010-07-19'), marketPrice: 100 } |
||||
|
]); |
||||
|
|
||||
|
await expect( |
||||
|
getMarketDataForUser({ endDate: parseDate('2010-07-20') }) |
||||
|
).resolves.toEqual({ |
||||
|
marketData: [ |
||||
|
{ date: '2010-07-18', value: 0 }, |
||||
|
{ date: '2010-07-19', value: 100 }, |
||||
|
{ date: '2010-07-20', value: 500 } |
||||
|
] |
||||
|
}); |
||||
|
}); |
||||
|
|
||||
|
it('returns no market data if the market data at the baseline date is missing', async () => { |
||||
|
getRange.mockResolvedValue([ |
||||
|
{ date: new Date('2008-12-31'), marketPrice: 40 } |
||||
|
]); |
||||
|
|
||||
|
marketDataItems.mockResolvedValue([ |
||||
|
{ date: new Date('2010-07-18'), marketPrice: 50 }, |
||||
|
{ date: new Date('2010-07-19'), marketPrice: 100 } |
||||
|
]); |
||||
|
|
||||
|
await expect(getMarketDataForUser()).resolves.toEqual({ |
||||
|
marketData: [] |
||||
|
}); |
||||
|
|
||||
|
expect(getExchangeRatesByCurrency).not.toHaveBeenCalled(); |
||||
|
}); |
||||
|
|
||||
|
it('returns no market data if the benchmark has none', async () => { |
||||
|
marketDataItems.mockResolvedValue([]); |
||||
|
|
||||
|
await expect(getMarketDataForUser()).resolves.toEqual({ |
||||
|
marketData: [] |
||||
|
}); |
||||
|
}); |
||||
|
}); |
||||
|
}); |
||||
@ -0,0 +1,80 @@ |
|||||
|
/** |
||||
|
* @jest-environment <rootDir>/jest-environment-tz.js |
||||
|
* @jest-environment-options {"timeZone": "America/New_York"} |
||||
|
*/ |
||||
|
import { PortfolioService } from '@ghostfolio/api/app/portfolio/portfolio.service'; |
||||
|
import { SymbolService } from '@ghostfolio/api/app/symbol/symbol.service'; |
||||
|
import { BenchmarkService } from '@ghostfolio/api/services/benchmark/benchmark.service'; |
||||
|
import { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-data/exchange-rate-data.service'; |
||||
|
import { MarketDataService } from '@ghostfolio/api/services/market-data/market-data.service'; |
||||
|
import { parseDate } from '@ghostfolio/common/helper'; |
||||
|
|
||||
|
import { DataSource } from '@prisma/client'; |
||||
|
|
||||
|
import { BenchmarksService } from './benchmarks.service'; |
||||
|
|
||||
|
describe('BenchmarksService in a time zone behind UTC', () => { |
||||
|
let benchmarksService: BenchmarksService; |
||||
|
|
||||
|
beforeEach(() => { |
||||
|
benchmarksService = new BenchmarksService( |
||||
|
new BenchmarkService(null, null, null, null, null, null), |
||||
|
{ |
||||
|
getExchangeRatesByCurrency: jest.fn().mockResolvedValue({}) |
||||
|
} as unknown as ExchangeRateDataService, |
||||
|
{ |
||||
|
getRange: jest.fn().mockResolvedValue([]), |
||||
|
// The market data is stored at midnight (UTC)
|
||||
|
marketDataItems: jest.fn().mockResolvedValue([ |
||||
|
{ date: new Date('2010-07-18T00:00:00.000Z'), marketPrice: 50 }, |
||||
|
{ date: new Date('2010-07-19T00:00:00.000Z'), marketPrice: 100 } |
||||
|
]) |
||||
|
} as unknown as MarketDataService, |
||||
|
{ |
||||
|
getPerformance: jest.fn().mockResolvedValue({ |
||||
|
chart: [{ date: '2010-07-18' }, { date: '2010-07-19' }] |
||||
|
}) |
||||
|
} as unknown as PortfolioService, |
||||
|
{ |
||||
|
get: jest.fn().mockResolvedValue({ currency: 'USD', marketPrice: 200 }) |
||||
|
} as unknown as SymbolService |
||||
|
); |
||||
|
}); |
||||
|
|
||||
|
describe('getMarketDataForUser', () => { |
||||
|
const getMarketDataForUser = ({ |
||||
|
endDate = parseDate('2010-07-19') |
||||
|
}: { endDate?: Date } = {}) => { |
||||
|
return benchmarksService.getMarketDataForUser({ |
||||
|
endDate, |
||||
|
dataSource: DataSource.COINGECKO, |
||||
|
dateRange: 'max', |
||||
|
startDate: parseDate('2010-07-18'), |
||||
|
symbol: 'bitcoin', |
||||
|
userId: 'user-id', |
||||
|
userSettings: { baseCurrency: 'USD' } |
||||
|
}); |
||||
|
}; |
||||
|
|
||||
|
it('returns the dates of the market data without a shift', async () => { |
||||
|
await expect(getMarketDataForUser()).resolves.toEqual({ |
||||
|
marketData: [ |
||||
|
{ date: '2010-07-18', value: 0 }, |
||||
|
{ date: '2010-07-19', value: 100 } |
||||
|
] |
||||
|
}); |
||||
|
}); |
||||
|
|
||||
|
it('returns the end date after the market data without a shift', async () => { |
||||
|
await expect( |
||||
|
getMarketDataForUser({ endDate: parseDate('2010-07-20') }) |
||||
|
).resolves.toEqual({ |
||||
|
marketData: [ |
||||
|
{ date: '2010-07-18', value: 0 }, |
||||
|
{ date: '2010-07-19', value: 100 }, |
||||
|
{ date: '2010-07-20', value: 300 } |
||||
|
] |
||||
|
}); |
||||
|
}); |
||||
|
}); |
||||
|
}); |
||||
@ -0,0 +1,45 @@ |
|||||
|
import { isBrowserUserAgent } from './user-agent.helper'; |
||||
|
|
||||
|
describe('isBrowserUserAgent', () => { |
||||
|
it('should tell that the user agent of a browser is one', () => { |
||||
|
// Chrome on macOS
|
||||
|
expect( |
||||
|
isBrowserUserAgent( |
||||
|
'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/141.0.0.0 Safari/537.36' |
||||
|
) |
||||
|
).toBe(true); |
||||
|
|
||||
|
// Chrome on Android
|
||||
|
expect( |
||||
|
isBrowserUserAgent( |
||||
|
'Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/141.0.0.0 Mobile Safari/537.36' |
||||
|
) |
||||
|
).toBe(true); |
||||
|
|
||||
|
// Firefox on Windows
|
||||
|
expect( |
||||
|
isBrowserUserAgent( |
||||
|
'Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:143.0) Gecko/20100101 Firefox/143.0' |
||||
|
) |
||||
|
).toBe(true); |
||||
|
|
||||
|
// Safari on iPhone
|
||||
|
expect( |
||||
|
isBrowserUserAgent( |
||||
|
'Mozilla/5.0 (iPhone; CPU iPhone OS 18_6 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.6 Mobile/15E148 Safari/604.1' |
||||
|
) |
||||
|
).toBe(true); |
||||
|
}); |
||||
|
|
||||
|
it('should tell that the user agent of another client is not one', () => { |
||||
|
expect(isBrowserUserAgent('curl/8.7.1')).toBe(false); |
||||
|
expect(isBrowserUserAgent('node')).toBe(false); |
||||
|
expect(isBrowserUserAgent('python-requests/2.32.3')).toBe(false); |
||||
|
expect(isBrowserUserAgent('Dart/3.5 (dart:io)')).toBe(false); |
||||
|
}); |
||||
|
|
||||
|
it('should tell that a missing user agent is not one', () => { |
||||
|
expect(isBrowserUserAgent()).toBe(false); |
||||
|
expect(isBrowserUserAgent('')).toBe(false); |
||||
|
}); |
||||
|
}); |
||||
@ -0,0 +1,14 @@ |
|||||
|
const PREFIX_OF_BROWSER_USER_AGENT = 'Mozilla/'; |
||||
|
|
||||
|
/** |
||||
|
* Tells if the user agent is the one of a browser. Every major browser starts |
||||
|
* its user agent with the same prefix for compatibility, while other clients, |
||||
|
* such as scripts and native apps, give the name of their library instead. |
||||
|
* The value is for analytics only, because a client can send any user agent. |
||||
|
*/ |
||||
|
export function isBrowserUserAgent(userAgent?: string) { |
||||
|
return ( |
||||
|
typeof userAgent === 'string' && |
||||
|
userAgent.startsWith(PREFIX_OF_BROWSER_USER_AGENT) |
||||
|
); |
||||
|
} |
||||
@ -1,6 +1,16 @@ |
|||||
import type { Product } from '@ghostfolio/common/interfaces'; |
import type { Product } from '@ghostfolio/common/interfaces'; |
||||
|
|
||||
export type ResolvedProduct = Omit<Product, 'categories' | 'platforms'> & { |
export interface ResolvedRegion { |
||||
|
emojiFlag?: string; |
||||
|
name: string; |
||||
|
} |
||||
|
|
||||
|
export type ResolvedProduct = Omit< |
||||
|
Product, |
||||
|
'categories' | 'origin' | 'platforms' | 'regions' |
||||
|
> & { |
||||
categories?: string[]; |
categories?: string[]; |
||||
|
origin?: ResolvedRegion; |
||||
platforms?: string[]; |
platforms?: string[]; |
||||
|
regions?: ResolvedRegion[]; |
||||
}; |
}; |
||||
|
|||||
File diff suppressed because it is too large
Some files were not shown because too many files changed in this diff
Loading…
Reference in new issue