diff --git a/CHANGELOG.md b/CHANGELOG.md index 5ce08f83a7..53490339fd 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -11,6 +11,45 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Moved the performance calculation including dividends (total return) from experimental to general availability +## 3.78.0 - 2026-10-03 + +### Added + +- Added the server of the Model Context Protocol (MCP) to the features page (experimental) + +### Changed + +- Improved the label of the cash positions in the holdings charts and table +- Excluded the cash position in the base currency from the holdings table on the overview tab of the home page (experimental) +- Extended the tools to get the activities, the portfolio and the watchlist in the server of the Model Context Protocol (MCP) to include the data source (experimental) +- Removed the deprecated `SymbolProfile` field from the endpoints `GET api/v1/activities`, `GET api/v1/activities/:id` and `POST api/v1/import` +- Improved the language localization for German (`de`) +- Upgraded `@openrouter/ai-sdk-provider` from version `3.0.0` to `3.1.0` +- Upgraded `ai` from version `7.0.37` to `7.0.114` +- Upgraded `dotenv` from version `17.4.2` to `18.0.3` + +### Fixed + +- Fixed the calculation of the interest in the account detail dialog for activities with a quantity other than one +- Fixed the positive performance from the all time high in the watchlist +- Fixed the portfolio calculation for holdings with historical market prices between the chart dates +- Fixed the asset profile identifier in the historical market data gathering of the `POST api/v1/activities` endpoint + +## 3.77.0 - 2026-10-02 + +### Added + +- Added a tool to get the performance to the server of the Model Context Protocol (MCP) (experimental) + +### Changed + +- Upgraded `prettier` from version `3.9.6` to `3.9.9` +- Upgraded `undici` from version `8.10.0` to `8.11.2` + +### Fixed + +- Fixed the filtering by holding in the server of the Model Context Protocol (MCP) (experimental) + ## 3.76.0 - 2026-09-30 ### Added diff --git a/apps/api/src/app/activities/activities.controller.ts b/apps/api/src/app/activities/activities.controller.ts index 0ed1d781c1..90851b2ca4 100644 --- a/apps/api/src/app/activities/activities.controller.ts +++ b/apps/api/src/app/activities/activities.controller.ts @@ -2,6 +2,7 @@ import { HasPermission } from '@ghostfolio/api/decorators/has-permission.decorat import { Impersonation } from '@ghostfolio/api/decorators/impersonation.decorator'; import { RequiresScope } from '@ghostfolio/api/decorators/requires-scope.decorator'; import { isActivityInFuture } from '@ghostfolio/api/helper/activity.helper'; +import { isDataGatheringSupported } from '@ghostfolio/api/helper/data-source.helper'; import { RedactValuesInResponseInterceptor } from '@ghostfolio/api/interceptors/redact-values-in-response/redact-values-in-response.interceptor'; import { TransformDataSourceInRequestInterceptor } from '@ghostfolio/api/interceptors/transform-data-source-in-request/transform-data-source-in-request.interceptor'; import { TransformDataSourceInResponseInterceptor } from '@ghostfolio/api/interceptors/transform-data-source-in-response/transform-data-source-in-response.interceptor'; @@ -32,7 +33,7 @@ import { Query, UseInterceptors } from '@nestjs/common'; -import { Order } from '@prisma/client'; +import { Order, Prisma } from '@prisma/client'; import { parseISO } from 'date-fns'; import { StatusCodes, getReasonPhrase } from 'http-status-codes'; @@ -216,7 +217,7 @@ export class ActivitiesController { userId, userSubscription }: ImpersonationContext - ): Promise { + ): Promise> { // Evaluate the more restrictive subscription of the authenticated user // and the owner of the activity const subscription = @@ -284,15 +285,18 @@ export class ActivitiesController { user: { connect: { id: userId } } }); - if (dataSource && !isActivityInFuture({ date: activity.date })) { - // Gather symbol data in the background, if data source is set - // (not MANUAL) and the date is not in the future + if ( + isDataGatheringSupported(activity.SymbolProfile) && + !isActivityInFuture({ date: activity.date }) + ) { + // Gather symbol data in the background, if the asset profile supports + // data gathering and the date is not in the future this.dataGatheringService.gatherSymbols({ dataGatheringItems: [ { - dataSource, + dataSource: activity.SymbolProfile.dataSource, date: activity.date, - symbol: data.symbol + symbol: activity.SymbolProfile.symbol } ], priority: DATA_GATHERING_QUEUE_PRIORITY_HIGH diff --git a/apps/api/src/app/activities/activities.service.ts b/apps/api/src/app/activities/activities.service.ts index 59d1ba9c33..9e1a1b8d4c 100644 --- a/apps/api/src/app/activities/activities.service.ts +++ b/apps/api/src/app/activities/activities.service.ts @@ -60,7 +60,7 @@ import { Type as ActivityType } from '@prisma/client'; import { Big } from 'big.js'; -import { groupBy, uniqBy } from 'lodash-es'; +import { groupBy, omit, uniqBy } from 'lodash-es'; import { randomUUID } from 'node:crypto'; @Injectable() @@ -183,7 +183,7 @@ export class ActivitiesService { updateAccountBalance?: boolean; userId: string; } - ): Promise { + ): Promise> { const tags = data.tags ?? []; await this.tagService.validateTagIds({ @@ -655,7 +655,13 @@ export class ActivitiesService { } }, // eslint-disable-next-line @typescript-eslint/naming-convention - SymbolProfile: true, + SymbolProfile: { + select: { + currency: true, + dataSource: true, + symbol: true + } + }, tags: true }, orderBy: [...orderBy, { id: sortDirection }] @@ -736,7 +742,7 @@ export class ActivitiesService { ]); return { - ...order, + ...omit(order, ['SymbolProfile']), assetProfile, feeInAssetProfileCurrency, feeInBaseCurrency, diff --git a/apps/api/src/app/endpoints/mcp/mcp.controller.spec.ts b/apps/api/src/app/endpoints/mcp/mcp.controller.spec.ts index 4fcb92a9be..e3c60d2829 100644 --- a/apps/api/src/app/endpoints/mcp/mcp.controller.spec.ts +++ b/apps/api/src/app/endpoints/mcp/mcp.controller.spec.ts @@ -133,6 +133,7 @@ describe('GhostfolioMcpController', () => { ).toEqual([ 'get-accounts', 'get-activities', + 'get-performance', 'get-portfolio', 'get-watchlist' ]); @@ -154,6 +155,7 @@ describe('GhostfolioMcpController', () => { ).toEqual([ 'get-accounts', 'get-activities', + 'get-performance', 'get-portfolio', 'get-watchlist', 'import-activities', diff --git a/apps/api/src/app/endpoints/mcp/mcp.controller.ts b/apps/api/src/app/endpoints/mcp/mcp.controller.ts index 802567ad35..742080498b 100644 --- a/apps/api/src/app/endpoints/mcp/mcp.controller.ts +++ b/apps/api/src/app/endpoints/mcp/mcp.controller.ts @@ -15,6 +15,7 @@ import 'zod/compile'; import { GET_ACCOUNTS_PARAMETERS, GET_ACTIVITIES_PARAMETERS, + GET_PERFORMANCE_PARAMETERS, IMPORT_ACTIVITIES_PARAMETERS, SEARCH_ASSET_PROFILES_PARAMETERS } from './mcp.schemas'; @@ -69,6 +70,26 @@ export class GhostfolioMcpController { }); } + @RequiresScopeOfAccess(scopes.portfolioRead) + @Tool({ + annotations: { + openWorldHint: false, + readOnlyHint: true, + title: 'Get performance' + }, + description: `Gives the performance of the portfolio in the date range with these columns: ${PortfolioTableService.getPerformanceTableColumnNames().join( + ', ' + )}. The asset performance excludes the effect of the exchange rates, the currency performance is that effect, and the net performance is the sum of both in the base currency of the user. Each performance is the return on average investment (ROAI) and excludes the dividends. The accounts and the activities which are excluded from analysis are not part of the performance. The parameters limit the performance to the holdings of the accounts, of the asset classes or of the asset profile.`, + name: 'get-performance', + parameters: GET_PERFORMANCE_PARAMETERS + }) + public async getPerformance( + @Impersonation() { userId }: ImpersonationContext, + @Payload() parameters: z.infer + ) { + return this.mcpService.getPerformance({ ...parameters, userId }); + } + @RequiresScopeOfAccess(scopes.portfolioRead) @Tool({ annotations: { diff --git a/apps/api/src/app/endpoints/mcp/mcp.schemas.spec.ts b/apps/api/src/app/endpoints/mcp/mcp.schemas.spec.ts index c946a9d131..e4893c2a7b 100644 --- a/apps/api/src/app/endpoints/mcp/mcp.schemas.spec.ts +++ b/apps/api/src/app/endpoints/mcp/mcp.schemas.spec.ts @@ -1,20 +1,197 @@ +import { encodeDataSource } from '@ghostfolio/api/helper/data-source.helper'; import { + COMMENT_MAXIMUM_LENGTH, + DEFAULT_DATE_RANGE, MCP_MAX_ACTIVITIES, SEARCH_QUERY_MAXIMUM_LENGTH, - SEARCH_QUERY_MINIMUM_LENGTH + SEARCH_QUERY_MINIMUM_LENGTH, + SYMBOL_MAXIMUM_LENGTH } from '@ghostfolio/common/config'; +import { DataSource } from '@prisma/client'; + import { + GET_ACCOUNTS_PARAMETERS, + GET_ACTIVITIES_PARAMETERS, + GET_PERFORMANCE_PARAMETERS, IMPORT_ACTIVITIES_PARAMETERS, SEARCH_ASSET_PROFILES_PARAMETERS } from './mcp.schemas'; import { createActivity } from './mcp.test-utils'; +const UNKNOWN_ENCODED_DATA_SOURCE = 'ffffffff'; + +describe('GET_ACCOUNTS_PARAMETERS', () => { + it('Refuses an empty symbol of the holding', () => { + expect( + GET_ACCOUNTS_PARAMETERS.safeParse({ + holding: { dataSource: DataSource.YAHOO, symbol: '' } + }).success + ).toBe(false); + }); + + it('Refuses a symbol of the holding that contains only spaces', () => { + expect( + GET_ACCOUNTS_PARAMETERS.safeParse({ + holding: { dataSource: DataSource.YAHOO, symbol: ' ' } + }).success + ).toBe(false); + }); + + it('Accepts an encoded data source of the holding', () => { + expect( + GET_ACCOUNTS_PARAMETERS.safeParse({ + holding: { + dataSource: encodeDataSource(DataSource.YAHOO), + symbol: 'AAPL' + } + }).success + ).toBe(true); + }); + + it('Refuses an unknown encoded data source of the holding', () => { + expect( + GET_ACCOUNTS_PARAMETERS.safeParse({ + holding: { dataSource: UNKNOWN_ENCODED_DATA_SOURCE, symbol: 'AAPL' } + }).success + ).toBe(false); + }); + + it('Gives the valid data sources in the error of an unknown data source of the holding', () => { + for (const dataSource of ['yahoo', 1]) { + const { error } = GET_ACCOUNTS_PARAMETERS.safeParse({ + holding: { dataSource, symbol: 'AAPL' } + }); + + expect(error.issues).toEqual([ + expect.objectContaining({ + message: expect.stringContaining(`|${DataSource.YAHOO}`), + path: ['holding', 'dataSource'] + }) + ]); + } + }); +}); + +describe('GET_ACTIVITIES_PARAMETERS', () => { + it('Refuses an empty symbol of the holding', () => { + expect( + GET_ACTIVITIES_PARAMETERS.safeParse({ + holding: { dataSource: DataSource.YAHOO, symbol: '' } + }).success + ).toBe(false); + }); + + it('Refuses a symbol of the holding that contains only spaces', () => { + expect( + GET_ACTIVITIES_PARAMETERS.safeParse({ + holding: { dataSource: DataSource.YAHOO, symbol: ' ' } + }).success + ).toBe(false); + }); + + it('Accepts an encoded data source of the holding', () => { + expect( + GET_ACTIVITIES_PARAMETERS.safeParse({ + holding: { + dataSource: encodeDataSource(DataSource.YAHOO), + symbol: 'AAPL' + } + }).success + ).toBe(true); + }); + + it('Refuses an unknown encoded data source of the holding', () => { + expect( + GET_ACTIVITIES_PARAMETERS.safeParse({ + holding: { dataSource: UNKNOWN_ENCODED_DATA_SOURCE, symbol: 'AAPL' } + }).success + ).toBe(false); + }); +}); + +describe('GET_PERFORMANCE_PARAMETERS', () => { + it(`Gives the date range ${DEFAULT_DATE_RANGE} if the range is absent`, () => { + expect(GET_PERFORMANCE_PARAMETERS.parse({}).range).toBe(DEFAULT_DATE_RANGE); + }); + + it('Accepts a calendar year', () => { + expect( + GET_PERFORMANCE_PARAMETERS.safeParse({ range: '2024' }).success + ).toBe(true); + }); + + it('Refuses an unknown date range', () => { + expect(GET_PERFORMANCE_PARAMETERS.safeParse({ range: '2w' }).success).toBe( + false + ); + }); + + it('Refuses an empty symbol of the holding', () => { + expect( + GET_PERFORMANCE_PARAMETERS.safeParse({ + holding: { dataSource: DataSource.YAHOO, symbol: '' } + }).success + ).toBe(false); + }); + + it('Refuses a symbol of the holding that contains only spaces', () => { + expect( + GET_PERFORMANCE_PARAMETERS.safeParse({ + holding: { dataSource: DataSource.YAHOO, symbol: ' ' } + }).success + ).toBe(false); + }); + + it('Accepts an encoded data source of the holding', () => { + expect( + GET_PERFORMANCE_PARAMETERS.safeParse({ + holding: { + dataSource: encodeDataSource(DataSource.YAHOO), + symbol: 'AAPL' + } + }).success + ).toBe(true); + }); + + it('Refuses an unknown encoded data source of the holding', () => { + expect( + GET_PERFORMANCE_PARAMETERS.safeParse({ + holding: { dataSource: UNKNOWN_ENCODED_DATA_SOURCE, symbol: 'AAPL' } + }).success + ).toBe(false); + }); +}); + describe('IMPORT_ACTIVITIES_PARAMETERS', () => { function parse(activities: unknown[]) { return IMPORT_ACTIVITIES_PARAMETERS.safeParse({ activities }).success; } + it(`Accepts a comment of ${COMMENT_MAXIMUM_LENGTH} characters with spaces at the start and the end`, () => { + expect( + parse([ + createActivity({ comment: ` ${'A'.repeat(COMMENT_MAXIMUM_LENGTH)} ` }) + ]) + ).toBe(true); + }); + + it(`Refuses a comment longer than ${COMMENT_MAXIMUM_LENGTH} characters`, () => { + expect( + parse([ + createActivity({ comment: 'A'.repeat(COMMENT_MAXIMUM_LENGTH + 1) }) + ]) + ).toBe(false); + }); + + it('Removes spaces at the start and the end of a comment', () => { + expect( + IMPORT_ACTIVITIES_PARAMETERS.parse({ + activities: [createActivity({ comment: ' note ' })] + }).activities[0].comment + ).toBe('note'); + }); + it('Refuses a currency in lower case', () => { expect(parse([createActivity({ currency: 'usd' })])).toBe(false); }); @@ -23,6 +200,20 @@ describe('IMPORT_ACTIVITIES_PARAMETERS', () => { expect(parse([createActivity({ currency: 'USD' })])).toBe(true); }); + it('Accepts an encoded data source', () => { + expect( + parse([ + createActivity({ dataSource: encodeDataSource(DataSource.YAHOO) }) + ]) + ).toBe(true); + }); + + it('Refuses an unknown encoded data source', () => { + expect( + parse([createActivity({ dataSource: UNKNOWN_ENCODED_DATA_SOURCE })]) + ).toBe(false); + }); + it('Refuses a date at or before the epoch', () => { expect(parse([createActivity({ date: '0000-01-01' })])).toBe(false); }); @@ -31,6 +222,38 @@ describe('IMPORT_ACTIVITIES_PARAMETERS', () => { expect(parse([createActivity({ symbol: '' })])).toBe(false); }); + it('Refuses a symbol that contains only spaces', () => { + expect(parse([createActivity({ symbol: ' ' })])).toBe(false); + }); + + it(`Accepts a symbol of ${SYMBOL_MAXIMUM_LENGTH} characters`, () => { + expect( + parse([createActivity({ symbol: 'A'.repeat(SYMBOL_MAXIMUM_LENGTH) })]) + ).toBe(true); + }); + + it(`Accepts a symbol of ${SYMBOL_MAXIMUM_LENGTH} characters with spaces at the start and the end`, () => { + expect( + parse([ + createActivity({ symbol: ` ${'A'.repeat(SYMBOL_MAXIMUM_LENGTH)} ` }) + ]) + ).toBe(true); + }); + + it(`Refuses a symbol longer than ${SYMBOL_MAXIMUM_LENGTH} characters`, () => { + expect( + parse([createActivity({ symbol: 'A'.repeat(SYMBOL_MAXIMUM_LENGTH + 1) })]) + ).toBe(false); + }); + + it('Removes spaces at the start and the end of a symbol', () => { + expect( + IMPORT_ACTIVITIES_PARAMETERS.parse({ + activities: [createActivity({ symbol: ' AAPL ' })] + }).activities[0].symbol + ).toBe('AAPL'); + }); + it('Refuses an empty identifier of an account', () => { expect(parse([createActivity({ accountId: '' })])).toBe(false); }); diff --git a/apps/api/src/app/endpoints/mcp/mcp.schemas.ts b/apps/api/src/app/endpoints/mcp/mcp.schemas.ts index 2eb59264b3..1b200e0119 100644 --- a/apps/api/src/app/endpoints/mcp/mcp.schemas.ts +++ b/apps/api/src/app/endpoints/mcp/mcp.schemas.ts @@ -1,10 +1,14 @@ import { DATE_RANGE_PATTERN } from '@ghostfolio/api/dtos/date-range-filter.dto'; +import { isValidEncodedDataSource } from '@ghostfolio/api/helper/data-source.helper'; import { + COMMENT_MAXIMUM_LENGTH, DATE_RANGES, + DEFAULT_DATE_RANGE, MCP_MAX_ACCOUNTS, MCP_MAX_ACTIVITIES, SEARCH_QUERY_MAXIMUM_LENGTH, - SEARCH_QUERY_MINIMUM_LENGTH + SEARCH_QUERY_MINIMUM_LENGTH, + SYMBOL_MAXIMUM_LENGTH } from '@ghostfolio/common/config'; import { isValidCurrencyCode, @@ -14,11 +18,30 @@ import { import { AssetClass, DataSource, Type as ActivityType } from '@prisma/client'; import { z } from 'zod'; +const DATA_SOURCE_PARAMETER_ERROR = `Invalid option: expected one of ${Object.values(DataSource).join('|')} or a data source as given by a tool`; + +const DATA_SOURCE_PARAMETER = z + .union( + [ + z.enum(DataSource), + z.string().refine(isValidEncodedDataSource, { + error: DATA_SOURCE_PARAMETER_ERROR + }) + ], + { error: DATA_SOURCE_PARAMETER_ERROR } + ) + .describe('The data source of the asset profile'); + +const SYMBOL_PARAMETER = z + .string() + .trim() + .min(1) + .max(SYMBOL_MAXIMUM_LENGTH) + .describe('The symbol of the asset profile'); + 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') + dataSource: DATA_SOURCE_PARAMETER, + symbol: SYMBOL_PARAMETER }); export const GET_ACCOUNTS_PARAMETERS = z.object({ @@ -78,6 +101,34 @@ export const GET_ACTIVITIES_PARAMETERS = z.object({ .describe(`The number of activities to get, at most ${MCP_MAX_ACTIVITIES}`) }); +export const GET_PERFORMANCE_PARAMETERS = z.object({ + accountIds: z + .array(z.string().min(1)) + .min(1) + .max(MCP_MAX_ACCOUNTS) + .optional() + .describe( + `The identifiers of the accounts of the performance, at most ${MCP_MAX_ACCOUNTS}` + ), + assetClasses: z + .array(z.enum(AssetClass)) + .min(1) + .optional() + .describe('The asset classes of the performance'), + holding: HOLDING_PARAMETER.optional().describe( + 'The asset profile of the performance' + ), + range: z + .string() + .regex(DATE_RANGE_PATTERN) + .default(DEFAULT_DATE_RANGE) + .describe( + `The date range of the performance, either ${DATE_RANGES.join( + ', ' + )} or a calendar year like 2024` + ) +}); + export const IMPORT_ACTIVITIES_PARAMETERS = z.object({ activities: z .array( @@ -87,17 +138,19 @@ export const IMPORT_ACTIVITIES_PARAMETERS = z.object({ .min(1) .optional() .describe('The identifier of the account of the activity'), - comment: z.string().optional().describe('The comment of the activity'), + comment: z + .string() + .trim() + .max(COMMENT_MAXIMUM_LENGTH) + .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'), + dataSource: DATA_SOURCE_PARAMETER.optional(), date: z .string() .refine(isValidDateAfter1970) @@ -106,7 +159,7 @@ export const IMPORT_ACTIVITIES_PARAMETERS = z.object({ ), 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'), + symbol: SYMBOL_PARAMETER, type: z.enum(ActivityType).describe('The type of the activity'), unitPrice: z.number().min(0).describe('The unit price of the activity') }) diff --git a/apps/api/src/app/endpoints/mcp/mcp.service.spec.ts b/apps/api/src/app/endpoints/mcp/mcp.service.spec.ts index a29daaba93..a013341179 100644 --- a/apps/api/src/app/endpoints/mcp/mcp.service.spec.ts +++ b/apps/api/src/app/endpoints/mcp/mcp.service.spec.ts @@ -2,6 +2,7 @@ import { ImportValidationError } from '@ghostfolio/api/app/import/errors/import- 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 { encodeDataSource } 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'; @@ -73,6 +74,7 @@ describe('McpService', () => { getAccountsTable: jest.fn().mockResolvedValue('## Accounts'), getActivitiesTable: jest.fn().mockResolvedValue('## Activities'), getHoldingsTable: jest.fn().mockResolvedValue('## Holdings'), + getPerformanceTable: jest.fn().mockResolvedValue('## Performance'), getWatchlistTable: jest.fn().mockResolvedValue('## Watchlist') } as unknown as PortfolioTableService; @@ -113,6 +115,50 @@ describe('McpService', () => { }); }); + it('Resolves the mask of the data source of the Ghostfolio data provider', async () => { + configuration.DATA_SOURCES_GHOSTFOLIO_DATA_PROVIDER = [DataSource.YAHOO]; + configuration.ENABLE_FEATURE_SUBSCRIPTION = true; + + await mcpService.getAccounts({ + userId, + holding: { dataSource: DataSource.GHOSTFOLIO, symbol: 'AAPL' } + }); + + expect(apiService.buildFiltersFromQueryParams).toHaveBeenCalledWith( + expect.objectContaining({ filterByDataSource: DataSource.YAHOO }) + ); + }); + + it('Decodes an encoded data source', async () => { + configuration.ENABLE_FEATURE_SUBSCRIPTION = true; + + await mcpService.getAccounts({ + userId, + holding: { + dataSource: encodeDataSource(DataSource.YAHOO), + symbol: 'AAPL' + } + }); + + expect(apiService.buildFiltersFromQueryParams).toHaveBeenCalledWith( + expect.objectContaining({ filterByDataSource: DataSource.YAHOO }) + ); + }); + + it('Keeps the data source if the subscription is not enabled', async () => { + configuration.DATA_SOURCES_GHOSTFOLIO_DATA_PROVIDER = [DataSource.YAHOO]; + configuration.ENABLE_FEATURE_SUBSCRIPTION = false; + + await mcpService.getAccounts({ + userId, + holding: { dataSource: DataSource.GHOSTFOLIO, symbol: 'AAPL' } + }); + + expect(apiService.buildFiltersFromQueryParams).toHaveBeenCalledWith( + expect.objectContaining({ filterByDataSource: DataSource.GHOSTFOLIO }) + ); + }); + it('Gives the table of the accounts of the filters', async () => { expect(await mcpService.getAccounts({ userId })).toEqual({ content: [{ text: '## Accounts', type: 'text' }] @@ -150,6 +196,47 @@ describe('McpService', () => { }); }); + it('Resolves the mask of the data source of the Ghostfolio data provider', async () => { + configuration.DATA_SOURCES_GHOSTFOLIO_DATA_PROVIDER = [DataSource.YAHOO]; + configuration.ENABLE_FEATURE_SUBSCRIPTION = true; + + await getActivities({ + holding: { dataSource: DataSource.GHOSTFOLIO, symbol: 'AAPL' } + }); + + expect(apiService.buildFiltersFromQueryParams).toHaveBeenCalledWith( + expect.objectContaining({ filterByDataSource: DataSource.YAHOO }) + ); + }); + + it('Decodes an encoded data source', async () => { + configuration.ENABLE_FEATURE_SUBSCRIPTION = true; + + await getActivities({ + holding: { + dataSource: encodeDataSource(DataSource.YAHOO), + symbol: 'AAPL' + } + }); + + expect(apiService.buildFiltersFromQueryParams).toHaveBeenCalledWith( + expect.objectContaining({ filterByDataSource: DataSource.YAHOO }) + ); + }); + + it('Keeps the data source if the subscription is not enabled', async () => { + configuration.DATA_SOURCES_GHOSTFOLIO_DATA_PROVIDER = [DataSource.YAHOO]; + configuration.ENABLE_FEATURE_SUBSCRIPTION = false; + + await getActivities({ + holding: { dataSource: DataSource.GHOSTFOLIO, symbol: 'AAPL' } + }); + + expect(apiService.buildFiltersFromQueryParams).toHaveBeenCalledWith( + expect.objectContaining({ filterByDataSource: DataSource.GHOSTFOLIO }) + ); + }); + it('Changes the range into the start date and the end date', async () => { await getActivities({ range: '2024' }); @@ -187,15 +274,94 @@ describe('McpService', () => { }); }); + describe('getPerformance', () => { + it('Maps the parameters of the tool to the filters', async () => { + await mcpService.getPerformance({ + userId, + accountIds: ['account-id'], + assetClasses: [AssetClass.EQUITY], + holding: { dataSource: DataSource.YAHOO, symbol: 'AAPL' }, + range: 'max' + }); + + expect(apiService.buildFiltersFromQueryParams).toHaveBeenCalledWith({ + filterByAccounts: ['account-id'], + filterByAssetClasses: [AssetClass.EQUITY], + filterByDataSource: DataSource.YAHOO, + filterBySymbol: 'AAPL' + }); + }); + + it('Resolves the mask of the data source of the Ghostfolio data provider', async () => { + configuration.DATA_SOURCES_GHOSTFOLIO_DATA_PROVIDER = [DataSource.YAHOO]; + configuration.ENABLE_FEATURE_SUBSCRIPTION = true; + + await mcpService.getPerformance({ + userId, + holding: { dataSource: DataSource.GHOSTFOLIO, symbol: 'AAPL' }, + range: 'max' + }); + + expect(apiService.buildFiltersFromQueryParams).toHaveBeenCalledWith( + expect.objectContaining({ filterByDataSource: DataSource.YAHOO }) + ); + }); + + it('Decodes an encoded data source', async () => { + configuration.ENABLE_FEATURE_SUBSCRIPTION = true; + + await mcpService.getPerformance({ + userId, + holding: { + dataSource: encodeDataSource(DataSource.YAHOO), + symbol: 'AAPL' + }, + range: 'max' + }); + + expect(apiService.buildFiltersFromQueryParams).toHaveBeenCalledWith( + expect.objectContaining({ filterByDataSource: DataSource.YAHOO }) + ); + }); + + it('Keeps the data source if the subscription is not enabled', async () => { + configuration.DATA_SOURCES_GHOSTFOLIO_DATA_PROVIDER = [DataSource.YAHOO]; + configuration.ENABLE_FEATURE_SUBSCRIPTION = false; + + await mcpService.getPerformance({ + userId, + holding: { dataSource: DataSource.GHOSTFOLIO, symbol: 'AAPL' }, + range: 'max' + }); + + expect(apiService.buildFiltersFromQueryParams).toHaveBeenCalledWith( + expect.objectContaining({ filterByDataSource: DataSource.GHOSTFOLIO }) + ); + }); + + it('Gives the table of the performance of the filters in the range', async () => { + expect( + await mcpService.getPerformance({ userId, range: '2024' }) + ).toEqual({ content: [{ text: '## Performance', type: 'text' }] }); + + expect(portfolioTableService.getPerformanceTable).toHaveBeenCalledWith({ + filters, + userId, + dateRange: '2024' + }); + }); + }); + describe('getPortfolio', () => { - it('Gives the table of the holdings in the default language', async () => { + it('Gives the table of the holdings with the data source 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 + languageCode: DEFAULT_LANGUAGE_CODE, + withDataSource: true }); }); }); @@ -226,9 +392,6 @@ describe('McpService', () => { 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: [ { @@ -267,13 +430,47 @@ describe('McpService', () => { assetClass: AssetClass.EQUITY, assetSubClass: AssetSubClass.STOCK, currency: 'USD', - dataSource: DataSource.GHOSTFOLIO, + dataSource: DataSource.YAHOO, name: 'Apple Inc.', symbol: 'AAPL' } ] }); }); + + it('Encodes the data source if the subscription is enabled', async () => { + 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' + } + ] + }); + + const result = await mcpService.searchAssetProfiles({ + query: 'Apple', + userId + }); + + expect(JSON.parse(result.content[0].text)).toEqual({ + assetProfiles: [ + expect.objectContaining({ + dataSource: encodeDataSource(DataSource.YAHOO) + }) + ] + }); + }); }); describe('importActivities', () => { @@ -332,6 +529,27 @@ describe('McpService', () => { ); }); + it('Decodes an encoded data source', async () => { + setupUser([permissions.createActivity]); + + configuration.ENABLE_FEATURE_SUBSCRIPTION = true; + + await mcpService.importActivities({ + userId, + activities: [ + createActivity({ dataSource: encodeDataSource(DataSource.YAHOO) }) + ] + }); + + 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]); diff --git a/apps/api/src/app/endpoints/mcp/mcp.service.ts b/apps/api/src/app/endpoints/mcp/mcp.service.ts index 474c62e69c..70be380060 100644 --- a/apps/api/src/app/endpoints/mcp/mcp.service.ts +++ b/apps/api/src/app/endpoints/mcp/mcp.service.ts @@ -2,8 +2,8 @@ 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 + transformDataSourceInRequest, + transformDataSourceInResponse } 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'; @@ -19,6 +19,7 @@ import { z } from 'zod'; import { GET_ACCOUNTS_PARAMETERS, GET_ACTIVITIES_PARAMETERS, + GET_PERFORMANCE_PARAMETERS, IMPORT_ACTIVITIES_PARAMETERS, SEARCH_ASSET_PROFILES_PARAMETERS } from './mcp.schemas'; @@ -41,10 +42,9 @@ export class McpService { userId }: z.infer & { userId: string }) { const filters = this.apiService.buildFiltersFromQueryParams({ + ...this.getHoldingFilterParameters({ holding }), filterByAccounts: accountIds, - filterByAssetClasses: assetClasses, - filterByDataSource: holding?.dataSource, - filterBySymbol: holding?.symbol + filterByAssetClasses: assetClasses }); const table = await this.portfolioTableService.getAccountsTable({ @@ -78,9 +78,8 @@ export class McpService { } const filters = this.apiService.buildFiltersFromQueryParams({ - filterByAssetClasses: assetClasses, - filterByDataSource: holding?.dataSource, - filterBySymbol: holding?.symbol + ...this.getHoldingFilterParameters({ holding }), + filterByAssetClasses: assetClasses }); const table = await this.portfolioTableService.getActivitiesTable({ @@ -97,10 +96,33 @@ export class McpService { return this.getTextResult(table); } + public async getPerformance({ + accountIds, + assetClasses, + holding, + range, + userId + }: z.infer & { userId: string }) { + const filters = this.apiService.buildFiltersFromQueryParams({ + ...this.getHoldingFilterParameters({ holding }), + filterByAccounts: accountIds, + filterByAssetClasses: assetClasses + }); + + const table = await this.portfolioTableService.getPerformanceTable({ + filters, + userId, + dateRange: range + }); + + return this.getTextResult(table); + } + public async getPortfolio({ userId }: { userId: string }) { const table = await this.portfolioTableService.getHoldingsTable({ userId, - languageCode: DEFAULT_LANGUAGE_CODE + languageCode: DEFAULT_LANGUAGE_CODE, + withDataSource: true }); return this.getTextResult(table); @@ -123,17 +145,11 @@ export class McpService { 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: transformDataSourceInRequest({ + configurationService: this.configurationService, dataSource: activity.dataSource }) }; @@ -171,12 +187,6 @@ export class McpService { 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, @@ -198,9 +208,9 @@ export class McpService { currency, name, symbol, - dataSource: getMaskedGhostfolioDataSource({ + dataSource: transformDataSourceInResponse({ dataSource, - ghostfolioDataSources + configurationService: this.configurationService }) } ]; @@ -210,6 +220,18 @@ export class McpService { return this.getTextResult(JSON.stringify({ assetProfiles }, null, 2)); } + private getHoldingFilterParameters({ + holding + }: Pick, 'holding'>) { + return { + filterByDataSource: transformDataSourceInRequest({ + configurationService: this.configurationService, + dataSource: holding?.dataSource + }), + filterBySymbol: holding?.symbol + }; + } + private getTextResult(text: string) { return { content: [{ text, type: 'text' as const }] }; } diff --git a/apps/api/src/app/endpoints/watchlist/watchlist.service.ts b/apps/api/src/app/endpoints/watchlist/watchlist.service.ts index c69a31df82..925f49cd6b 100644 --- a/apps/api/src/app/endpoints/watchlist/watchlist.service.ts +++ b/apps/api/src/app/endpoints/watchlist/watchlist.service.ts @@ -140,8 +140,11 @@ export class WatchlistService { name: assetProfile?.name, performances: { allTimeHigh: { - performancePercent, - date: allTimeHigh?.date + date: allTimeHigh?.date, + performancePercent: + this.benchmarkService.getPerformancePercentFromAllTimeHigh( + performancePercent + ) } }, trend50d: trends.trend50d, diff --git a/apps/api/src/app/import/import-data.dto.spec.ts b/apps/api/src/app/import/import-data.dto.spec.ts new file mode 100644 index 0000000000..b6cce2a739 --- /dev/null +++ b/apps/api/src/app/import/import-data.dto.spec.ts @@ -0,0 +1,73 @@ +import { BadRequestException, ValidationPipe } from '@nestjs/common'; +import { DataSource, Type } from '@prisma/client'; +import { readFileSync } from 'node:fs'; +import { join } from 'node:path'; + +import { ImportDataDto } from './import-data.dto'; + +const validationPipe = new ValidationPipe({ + forbidNonWhitelisted: true, + transform: true, + whitelist: true +}); + +function loadImportData(directory: string, fileName: string): ImportDataDto { + const { accounts, activities, assetProfiles, platforms, tags } = JSON.parse( + readFileSync( + join(__dirname, '../../../../../test/import', directory, fileName), + 'utf8' + ) + ) as ImportDataDto; + + return { accounts, activities, assetProfiles, platforms, tags }; +} + +describe('ImportDataDto', () => { + it('accepts sample.json', async () => { + await expect( + validationPipe.transform(loadImportData('ok', 'sample.json'), { + metatype: ImportDataDto, + type: 'body' + }) + ).resolves.toBeInstanceOf(ImportDataDto); + }); + + it.each([ + { + fileName: 'invalid-data-source.json', + messages: [ + `activities.0.dataSource must be one of the following values: ${Object.values(DataSource).join(', ')}` + ] + }, + { + fileName: 'invalid-date-before-min.json', + messages: ['activities.0.date must be after 1970'] + }, + { + fileName: 'invalid-date.json', + messages: [ + 'activities.0.date must be after 1970', + 'activities.0.date must be a valid ISO 8601 date string' + ] + }, + { + fileName: 'invalid-type.json', + messages: [ + `activities.0.type must be one of the following values: ${Object.values(Type).join(', ')}` + ] + } + ])('refuses $fileName', async ({ fileName, messages }) => { + const error: unknown = await validationPipe + .transform(loadImportData('not-ok', fileName), { + metatype: ImportDataDto, + type: 'body' + }) + .catch((validationError: unknown) => validationError); + + expect(error).toBeInstanceOf(BadRequestException); + + expect((error as BadRequestException).getResponse()).toMatchObject({ + message: messages + }); + }); +}); diff --git a/apps/api/src/app/import/import.service.ts b/apps/api/src/app/import/import.service.ts index 5afa4c33dd..705066441f 100644 --- a/apps/api/src/app/import/import.service.ts +++ b/apps/api/src/app/import/import.service.ts @@ -1004,7 +1004,7 @@ export class ImportService { )) ?? 0; activities.push({ - ...order, + ...omit(order, ['SymbolProfile']), // @ts-ignore assetProfile, error, diff --git a/apps/api/src/app/portfolio/calculator/portfolio-calculator-test-utils.ts b/apps/api/src/app/portfolio/calculator/portfolio-calculator-test-utils.ts index 87474e806c..fb88c8a7f1 100644 --- a/apps/api/src/app/portfolio/calculator/portfolio-calculator-test-utils.ts +++ b/apps/api/src/app/portfolio/calculator/portfolio-calculator-test-utils.ts @@ -1,12 +1,15 @@ import { PortfolioCalculator } from '@ghostfolio/api/app/portfolio/calculator/portfolio-calculator'; import { getIntervalFromDateRange } from '@ghostfolio/common/calculation-helper'; +import { parseDate } from '@ghostfolio/common/helper'; import { + Activity, ExportResponse, HistoricalDataItem } from '@ghostfolio/common/interfaces'; import { DateRange } from '@ghostfolio/common/types'; import { readFileSync } from 'node:fs'; +import { join } from 'node:path'; export const activityDummyData = { accountId: undefined, @@ -71,6 +74,43 @@ export async function getPerformanceByDateRange({ return performanceByDateRange; } +/** + * Loads the activities of an export file in test/import/ok for the portfolio + * calculator. The user currency of the file is the base currency of the test. + * + * The activity currency must be the asset profile currency. The helper does + * not convert the fee to the base currency and does not load the tags. + */ +export function loadActivitiesFromExportFile(fileName: string): { + activities: Activity[]; + userCurrency: string; +} { + const exportResponse = loadExportFile( + join(__dirname, '../../../../../../test/import/ok', fileName) + ); + + const activities: Activity[] = exportResponse.activities.map((activity) => { + return { + ...activityDummyData, + ...activity, + assetProfile: { + ...assetProfileDummyData, + currency: activity.currency, + dataSource: activity.dataSource, + name: activity.symbol, + symbol: activity.symbol + }, + date: parseDate(activity.date), + feeInAssetProfileCurrency: activity.fee, + feeInBaseCurrency: activity.fee, + tags: [], + unitPriceInAssetProfileCurrency: activity.unitPrice + }; + }); + + return { activities, userCurrency: exportResponse.user.settings.currency }; +} + export function loadExportFile(filePath: string): ExportResponse { return JSON.parse(readFileSync(filePath, 'utf8')); } diff --git a/apps/api/src/app/portfolio/calculator/portfolio-calculator.ts b/apps/api/src/app/portfolio/calculator/portfolio-calculator.ts index 9c5f1a26e3..84ad648189 100644 --- a/apps/api/src/app/portfolio/calculator/portfolio-calculator.ts +++ b/apps/api/src/app/portfolio/calculator/portfolio-calculator.ts @@ -791,6 +791,16 @@ export abstract class PortfolioCalculator { activitiesByDate[activity.date].push(activity); } + // Carry forward the market prices of all dates, not only of the chart + // dates, as sparse market data (e.g. MANUAL) can be between two chart dates + const marketPriceDates = Object.keys(marketSymbolMap) + .filter((date) => { + return !!marketSymbolMap[date][assetProfileIdentifier]; + }) + .sort(); + + let indexOfMarketPriceDate = 0; + for (const dateString of chartDates) { if (dateString < startDateString) { continue; @@ -798,6 +808,18 @@ export abstract class PortfolioCalculator { break; } + while ( + indexOfMarketPriceDate < marketPriceDates.length && + marketPriceDates[indexOfMarketPriceDate] <= dateString + ) { + lastMarketPrice = + marketSymbolMap[marketPriceDates[indexOfMarketPriceDate]][ + assetProfileIdentifier + ]; + + indexOfMarketPriceDate += 1; + } + const activitiesOfDate = activitiesByDate[dateString]; if (!lastMarketPrice && activitiesOfDate?.length > 0) { @@ -808,13 +830,8 @@ export abstract class PortfolioCalculator { } } - const marketPrice = marketSymbolMap[dateString]?.[assetProfileIdentifier]; - const unitPrice = - marketPrice ?? - lastMarketPrice ?? - lastActivityUnitPrice ?? - unitPriceAtEndDate; + lastMarketPrice ?? lastActivityUnitPrice ?? unitPriceAtEndDate; if (activitiesOfDate?.length > 0) { for (const activity of activitiesOfDate) { @@ -832,10 +849,6 @@ export abstract class PortfolioCalculator { unitPriceFromMarketData: unitPrice }); } - - if (marketPrice) { - lastMarketPrice = marketPrice; - } } // Sort the activities so that the start and end placeholder activities diff --git a/apps/api/src/app/portfolio/calculator/roai/portfolio-calculator-baln-buy-and-buy.spec.ts b/apps/api/src/app/portfolio/calculator/roai/portfolio-calculator-baln-buy-and-buy.spec.ts index 1b2fee98d9..5d3cbcf320 100644 --- a/apps/api/src/app/portfolio/calculator/roai/portfolio-calculator-baln-buy-and-buy.spec.ts +++ b/apps/api/src/app/portfolio/calculator/roai/portfolio-calculator-baln-buy-and-buy.spec.ts @@ -1,6 +1,5 @@ import { - activityDummyData, - assetProfileDummyData, + loadActivitiesFromExportFile, userDummyData } from '@ghostfolio/api/app/portfolio/calculator/portfolio-calculator-test-utils'; import { PortfolioCalculatorFactory } from '@ghostfolio/api/app/portfolio/calculator/portfolio-calculator.factory'; @@ -13,7 +12,6 @@ import { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate- import { PortfolioSnapshotService } from '@ghostfolio/api/services/queues/portfolio-snapshot/portfolio-snapshot.service'; import { PortfolioSnapshotServiceMock } from '@ghostfolio/api/services/queues/portfolio-snapshot/portfolio-snapshot.service.mock'; import { parseDate } from '@ghostfolio/common/helper'; -import { Activity } from '@ghostfolio/common/interfaces'; import { PerformanceCalculationType } from '@ghostfolio/common/types/performance-calculation-type.type'; import { Big } from 'big.js'; @@ -85,45 +83,14 @@ describe('PortfolioCalculator', () => { it.only('with BALN.SW buy and buy', async () => { jest.useFakeTimers().setSystemTime(parseDate('2021-12-18').getTime()); - const activities: Activity[] = [ - { - ...activityDummyData, - assetProfile: { - ...assetProfileDummyData, - currency: 'CHF', - dataSource: 'YAHOO', - name: 'Bâloise Holding AG', - symbol: 'BALN.SW' - }, - date: new Date('2021-11-22'), - feeInAssetProfileCurrency: 1.55, - feeInBaseCurrency: 1.55, - quantity: 2, - type: 'BUY', - unitPriceInAssetProfileCurrency: 142.9 - }, - { - ...activityDummyData, - assetProfile: { - ...assetProfileDummyData, - currency: 'CHF', - dataSource: 'YAHOO', - name: 'Bâloise Holding AG', - symbol: 'BALN.SW' - }, - date: new Date('2021-11-30'), - feeInAssetProfileCurrency: 1.65, - feeInBaseCurrency: 1.65, - quantity: 2, - type: 'BUY', - unitPriceInAssetProfileCurrency: 136.6 - } - ]; + const { activities, userCurrency } = loadActivitiesFromExportFile( + 'baln-buy-and-buy.json' + ); const portfolioCalculator = portfolioCalculatorFactory.createCalculator({ activities, calculationType: PerformanceCalculationType.ROAI, - currency: 'CHF', + currency: userCurrency, userId: userDummyData.id }); diff --git a/apps/api/src/app/portfolio/calculator/roai/portfolio-calculator-baln-buy-and-sell.spec.ts b/apps/api/src/app/portfolio/calculator/roai/portfolio-calculator-baln-buy-and-sell.spec.ts index 408076717f..9ee4877180 100644 --- a/apps/api/src/app/portfolio/calculator/roai/portfolio-calculator-baln-buy-and-sell.spec.ts +++ b/apps/api/src/app/portfolio/calculator/roai/portfolio-calculator-baln-buy-and-sell.spec.ts @@ -1,7 +1,6 @@ import { - activityDummyData, - assetProfileDummyData, getPerformanceByDateRange, + loadActivitiesFromExportFile, userDummyData } from '@ghostfolio/api/app/portfolio/calculator/portfolio-calculator-test-utils'; import { PortfolioCalculatorFactory } from '@ghostfolio/api/app/portfolio/calculator/portfolio-calculator.factory'; @@ -14,7 +13,6 @@ import { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate- import { PortfolioSnapshotService } from '@ghostfolio/api/services/queues/portfolio-snapshot/portfolio-snapshot.service'; import { PortfolioSnapshotServiceMock } from '@ghostfolio/api/services/queues/portfolio-snapshot/portfolio-snapshot.service.mock'; import { parseDate } from '@ghostfolio/common/helper'; -import { Activity } from '@ghostfolio/common/interfaces'; import { PerformanceCalculationType } from '@ghostfolio/common/types/performance-calculation-type.type'; import { Big } from 'big.js'; @@ -86,45 +84,14 @@ describe('PortfolioCalculator', () => { it.only('with BALN.SW buy and sell', async () => { jest.useFakeTimers().setSystemTime(parseDate('2021-12-18').getTime()); - const activities: Activity[] = [ - { - ...activityDummyData, - assetProfile: { - ...assetProfileDummyData, - currency: 'CHF', - dataSource: 'YAHOO', - name: 'Bâloise Holding AG', - symbol: 'BALN.SW' - }, - date: new Date('2021-11-22'), - feeInAssetProfileCurrency: 1.55, - feeInBaseCurrency: 1.55, - quantity: 2, - type: 'BUY', - unitPriceInAssetProfileCurrency: 142.9 - }, - { - ...activityDummyData, - assetProfile: { - ...assetProfileDummyData, - currency: 'CHF', - dataSource: 'YAHOO', - name: 'Bâloise Holding AG', - symbol: 'BALN.SW' - }, - date: new Date('2021-11-30'), - feeInAssetProfileCurrency: 1.65, - feeInBaseCurrency: 1.65, - quantity: 2, - type: 'SELL', - unitPriceInAssetProfileCurrency: 136.6 - } - ]; + const { activities, userCurrency } = loadActivitiesFromExportFile( + 'baln-buy-and-sell.json' + ); const portfolioCalculator = portfolioCalculatorFactory.createCalculator({ activities, calculationType: PerformanceCalculationType.ROAI, - currency: 'CHF', + currency: userCurrency, usePortfolioSnapshotCache: false, userId: userDummyData.id }); diff --git a/apps/api/src/app/portfolio/calculator/roai/portfolio-calculator-baln-buy.spec.ts b/apps/api/src/app/portfolio/calculator/roai/portfolio-calculator-baln-buy.spec.ts index 19f3c64369..afae22c1c6 100644 --- a/apps/api/src/app/portfolio/calculator/roai/portfolio-calculator-baln-buy.spec.ts +++ b/apps/api/src/app/portfolio/calculator/roai/portfolio-calculator-baln-buy.spec.ts @@ -1,6 +1,5 @@ import { - activityDummyData, - assetProfileDummyData, + loadActivitiesFromExportFile, userDummyData } from '@ghostfolio/api/app/portfolio/calculator/portfolio-calculator-test-utils'; import { PortfolioCalculatorFactory } from '@ghostfolio/api/app/portfolio/calculator/portfolio-calculator.factory'; @@ -13,7 +12,6 @@ import { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate- import { PortfolioSnapshotService } from '@ghostfolio/api/services/queues/portfolio-snapshot/portfolio-snapshot.service'; import { PortfolioSnapshotServiceMock } from '@ghostfolio/api/services/queues/portfolio-snapshot/portfolio-snapshot.service.mock'; import { parseDate } from '@ghostfolio/common/helper'; -import { Activity } from '@ghostfolio/common/interfaces'; import { PerformanceCalculationType } from '@ghostfolio/common/types/performance-calculation-type.type'; import { Big } from 'big.js'; @@ -85,29 +83,13 @@ describe('PortfolioCalculator', () => { it.only('with BALN.SW buy', async () => { jest.useFakeTimers().setSystemTime(parseDate('2021-12-18').getTime()); - const activities: Activity[] = [ - { - ...activityDummyData, - assetProfile: { - ...assetProfileDummyData, - currency: 'CHF', - dataSource: 'YAHOO', - name: 'Bâloise Holding AG', - symbol: 'BALN.SW' - }, - date: new Date('2021-11-30'), - feeInAssetProfileCurrency: 1.55, - feeInBaseCurrency: 1.55, - quantity: 2, - type: 'BUY', - unitPriceInAssetProfileCurrency: 136.6 - } - ]; + const { activities, userCurrency } = + loadActivitiesFromExportFile('baln-buy.json'); const portfolioCalculator = portfolioCalculatorFactory.createCalculator({ activities, calculationType: PerformanceCalculationType.ROAI, - currency: 'CHF', + currency: userCurrency, userId: userDummyData.id }); @@ -217,29 +199,14 @@ describe('PortfolioCalculator', () => { it.only('with BALN.SW buy (with unit price lower than closing price)', async () => { jest.useFakeTimers().setSystemTime(parseDate('2021-12-18').getTime()); - const activities: Activity[] = [ - { - ...activityDummyData, - assetProfile: { - ...assetProfileDummyData, - currency: 'CHF', - dataSource: 'YAHOO', - name: 'Bâloise Holding AG', - symbol: 'BALN.SW' - }, - date: new Date('2021-11-30'), - feeInAssetProfileCurrency: 1.55, - feeInBaseCurrency: 1.55, - quantity: 2, - type: 'BUY', - unitPriceInAssetProfileCurrency: 135.0 - } - ]; + const { activities, userCurrency } = loadActivitiesFromExportFile( + 'baln-buy-with-unit-price-lower-than-closing-price.json' + ); const portfolioCalculator = portfolioCalculatorFactory.createCalculator({ activities, calculationType: PerformanceCalculationType.ROAI, - currency: 'CHF', + currency: userCurrency, userId: userDummyData.id }); @@ -257,29 +224,14 @@ describe('PortfolioCalculator', () => { it.only('with BALN.SW buy (with unit price lower than closing price), calculated on buy date', async () => { jest.useFakeTimers().setSystemTime(parseDate('2021-11-30').getTime()); - const activities: Activity[] = [ - { - ...activityDummyData, - assetProfile: { - ...assetProfileDummyData, - currency: 'CHF', - dataSource: 'YAHOO', - name: 'Bâloise Holding AG', - symbol: 'BALN.SW' - }, - date: new Date('2021-11-30'), - feeInAssetProfileCurrency: 1.55, - feeInBaseCurrency: 1.55, - quantity: 2, - type: 'BUY', - unitPriceInAssetProfileCurrency: 135.0 - } - ]; + const { activities, userCurrency } = loadActivitiesFromExportFile( + 'baln-buy-with-unit-price-lower-than-closing-price.json' + ); const portfolioCalculator = portfolioCalculatorFactory.createCalculator({ activities, calculationType: PerformanceCalculationType.ROAI, - currency: 'CHF', + currency: userCurrency, userId: userDummyData.id }); @@ -297,29 +249,13 @@ describe('PortfolioCalculator', () => { it.only('with BALN.SW buy (on a date without historical market data)', async () => { jest.useFakeTimers().setSystemTime(parseDate('2021-12-18').getTime()); - const activities: Activity[] = [ - { - ...activityDummyData, - assetProfile: { - ...assetProfileDummyData, - currency: 'CHF', - dataSource: 'YAHOO', - name: 'Bâloise Holding AG', - symbol: 'BALN.SW' - }, - date: new Date('2021-11-30'), - feeInAssetProfileCurrency: 1.55, - feeInBaseCurrency: 1.55, - quantity: 2, - type: 'BUY', - unitPriceInAssetProfileCurrency: 136.6 - } - ]; + const { activities, userCurrency } = + loadActivitiesFromExportFile('baln-buy.json'); const portfolioCalculator = portfolioCalculatorFactory.createCalculator({ activities, calculationType: PerformanceCalculationType.ROAI, - currency: 'CHF', + currency: userCurrency, userId: userDummyData.id }); diff --git a/apps/api/src/app/portfolio/calculator/roai/portfolio-calculator-btcusd-buy-and-sell-partially.spec.ts b/apps/api/src/app/portfolio/calculator/roai/portfolio-calculator-btcusd-buy-and-sell-partially.spec.ts index 6a9ed1f71c..15474a0236 100644 --- a/apps/api/src/app/portfolio/calculator/roai/portfolio-calculator-btcusd-buy-and-sell-partially.spec.ts +++ b/apps/api/src/app/portfolio/calculator/roai/portfolio-calculator-btcusd-buy-and-sell-partially.spec.ts @@ -1,7 +1,6 @@ import { - activityDummyData, - assetProfileDummyData, getPerformanceByDateRange, + loadActivitiesFromExportFile, userDummyData } from '@ghostfolio/api/app/portfolio/calculator/portfolio-calculator-test-utils'; import { PortfolioCalculatorFactory } from '@ghostfolio/api/app/portfolio/calculator/portfolio-calculator.factory'; @@ -15,7 +14,6 @@ import { ExchangeRateDataServiceMock } from '@ghostfolio/api/services/exchange-r import { PortfolioSnapshotService } from '@ghostfolio/api/services/queues/portfolio-snapshot/portfolio-snapshot.service'; import { PortfolioSnapshotServiceMock } from '@ghostfolio/api/services/queues/portfolio-snapshot/portfolio-snapshot.service.mock'; import { parseDate } from '@ghostfolio/common/helper'; -import { Activity } from '@ghostfolio/common/interfaces'; import { PerformanceCalculationType } from '@ghostfolio/common/types/performance-calculation-type.type'; import { Big } from 'big.js'; @@ -98,45 +96,14 @@ describe('PortfolioCalculator', () => { it.only('with BTCUSD buy and sell partially', async () => { jest.useFakeTimers().setSystemTime(parseDate('2018-01-01').getTime()); - const activities: Activity[] = [ - { - ...activityDummyData, - assetProfile: { - ...assetProfileDummyData, - currency: 'USD', - dataSource: 'YAHOO', - name: 'Bitcoin USD', - symbol: 'BTCUSD' - }, - date: new Date('2015-01-01'), - feeInAssetProfileCurrency: 0, - feeInBaseCurrency: 0, - quantity: 2, - type: 'BUY', - unitPriceInAssetProfileCurrency: 320.43 - }, - { - ...activityDummyData, - assetProfile: { - ...assetProfileDummyData, - currency: 'USD', - dataSource: 'YAHOO', - name: 'Bitcoin USD', - symbol: 'BTCUSD' - }, - date: new Date('2017-12-31'), - feeInAssetProfileCurrency: 0, - feeInBaseCurrency: 0, - quantity: 1, - type: 'SELL', - unitPriceInAssetProfileCurrency: 14156.4 - } - ]; + const { activities, userCurrency } = loadActivitiesFromExportFile( + 'btcusd-buy-and-sell-partially.json' + ); const portfolioCalculator = portfolioCalculatorFactory.createCalculator({ activities, calculationType: PerformanceCalculationType.ROAI, - currency: 'CHF', + currency: userCurrency, usePortfolioSnapshotCache: false, userId: userDummyData.id }); diff --git a/apps/api/src/app/portfolio/calculator/roai/portfolio-calculator-btcusd-short.spec.ts b/apps/api/src/app/portfolio/calculator/roai/portfolio-calculator-btcusd-short.spec.ts index 58dc545e49..356fdebd03 100644 --- a/apps/api/src/app/portfolio/calculator/roai/portfolio-calculator-btcusd-short.spec.ts +++ b/apps/api/src/app/portfolio/calculator/roai/portfolio-calculator-btcusd-short.spec.ts @@ -1,7 +1,5 @@ import { - activityDummyData, - assetProfileDummyData, - loadExportFile, + loadActivitiesFromExportFile, userDummyData } from '@ghostfolio/api/app/portfolio/calculator/portfolio-calculator-test-utils'; import { PortfolioCalculatorFactory } from '@ghostfolio/api/app/portfolio/calculator/portfolio-calculator.factory'; @@ -14,11 +12,9 @@ import { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate- import { PortfolioSnapshotService } from '@ghostfolio/api/services/queues/portfolio-snapshot/portfolio-snapshot.service'; import { PortfolioSnapshotServiceMock } from '@ghostfolio/api/services/queues/portfolio-snapshot/portfolio-snapshot.service.mock'; import { parseDate } from '@ghostfolio/common/helper'; -import { Activity, ExportResponse } from '@ghostfolio/common/interfaces'; import { PerformanceCalculationType } from '@ghostfolio/common/types/performance-calculation-type.type'; import { Big } from 'big.js'; -import { join } from 'node:path'; jest.mock('@ghostfolio/api/app/portfolio/current-rate.service', () => { return { @@ -48,8 +44,6 @@ jest.mock('@ghostfolio/api/app/redis-cache/redis-cache.service', () => { }); describe('PortfolioCalculator', () => { - let exportResponse: ExportResponse; - let configurationService: ConfigurationService; let currentRateService: CurrentRateService; let exchangeRateDataService: ExchangeRateDataService; @@ -57,12 +51,6 @@ describe('PortfolioCalculator', () => { let portfolioSnapshotService: PortfolioSnapshotService; let redisCacheService: RedisCacheService; - beforeAll(() => { - exportResponse = loadExportFile( - join(__dirname, '../../../../../../../test/import/ok/btcusd-short.json') - ); - }); - beforeEach(() => { PortfolioSnapshotServiceMock.reset(); RedisCacheServiceMock.reset(); @@ -95,28 +83,13 @@ describe('PortfolioCalculator', () => { it.only('with BTCUSD short sell (in USD)', async () => { jest.useFakeTimers().setSystemTime(parseDate('2022-01-14').getTime()); - const activities: Activity[] = exportResponse.activities.map( - (activity) => ({ - ...activityDummyData, - ...activity, - assetProfile: { - ...assetProfileDummyData, - currency: 'USD', - dataSource: activity.dataSource, - name: 'Bitcoin', - symbol: activity.symbol - }, - date: parseDate(activity.date), - feeInAssetProfileCurrency: activity.fee, - feeInBaseCurrency: activity.fee, - unitPriceInAssetProfileCurrency: activity.unitPrice - }) - ); + const { activities, userCurrency } = + loadActivitiesFromExportFile('btcusd-short.json'); const portfolioCalculator = portfolioCalculatorFactory.createCalculator({ activities, calculationType: PerformanceCalculationType.ROAI, - currency: exportResponse.user.settings.currency, + currency: userCurrency, userId: userDummyData.id }); diff --git a/apps/api/src/app/portfolio/calculator/roai/portfolio-calculator-btcusd.spec.ts b/apps/api/src/app/portfolio/calculator/roai/portfolio-calculator-btcusd.spec.ts index e0cfeb8227..0d0f6cfc3b 100644 --- a/apps/api/src/app/portfolio/calculator/roai/portfolio-calculator-btcusd.spec.ts +++ b/apps/api/src/app/portfolio/calculator/roai/portfolio-calculator-btcusd.spec.ts @@ -1,7 +1,5 @@ import { - activityDummyData, - assetProfileDummyData, - loadExportFile, + loadActivitiesFromExportFile, userDummyData } from '@ghostfolio/api/app/portfolio/calculator/portfolio-calculator-test-utils'; import { PortfolioCalculatorFactory } from '@ghostfolio/api/app/portfolio/calculator/portfolio-calculator.factory'; @@ -14,11 +12,9 @@ import { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate- import { PortfolioSnapshotService } from '@ghostfolio/api/services/queues/portfolio-snapshot/portfolio-snapshot.service'; import { PortfolioSnapshotServiceMock } from '@ghostfolio/api/services/queues/portfolio-snapshot/portfolio-snapshot.service.mock'; import { parseDate } from '@ghostfolio/common/helper'; -import { Activity, ExportResponse } from '@ghostfolio/common/interfaces'; import { PerformanceCalculationType } from '@ghostfolio/common/types/performance-calculation-type.type'; import { Big } from 'big.js'; -import { join } from 'node:path'; jest.mock('@ghostfolio/api/app/portfolio/current-rate.service', () => { return { @@ -48,8 +44,6 @@ jest.mock('@ghostfolio/api/app/redis-cache/redis-cache.service', () => { }); describe('PortfolioCalculator', () => { - let exportResponse: ExportResponse; - let configurationService: ConfigurationService; let currentRateService: CurrentRateService; let exchangeRateDataService: ExchangeRateDataService; @@ -57,12 +51,6 @@ describe('PortfolioCalculator', () => { let portfolioSnapshotService: PortfolioSnapshotService; let redisCacheService: RedisCacheService; - beforeAll(() => { - exportResponse = loadExportFile( - join(__dirname, '../../../../../../../test/import/ok/btcusd.json') - ); - }); - beforeEach(() => { PortfolioSnapshotServiceMock.reset(); RedisCacheServiceMock.reset(); @@ -95,28 +83,13 @@ describe('PortfolioCalculator', () => { it.only('with BTCUSD buy (in USD)', async () => { jest.useFakeTimers().setSystemTime(parseDate('2022-01-14').getTime()); - const activities: Activity[] = exportResponse.activities.map( - (activity) => ({ - ...activityDummyData, - ...activity, - assetProfile: { - ...assetProfileDummyData, - currency: 'USD', - dataSource: activity.dataSource, - name: 'Bitcoin', - symbol: activity.symbol - }, - date: parseDate(activity.date), - feeInAssetProfileCurrency: 4.46, - feeInBaseCurrency: 4.46, - unitPriceInAssetProfileCurrency: 44558.42 - }) - ); + const { activities, userCurrency } = + loadActivitiesFromExportFile('btcusd.json'); const portfolioCalculator = portfolioCalculatorFactory.createCalculator({ activities, calculationType: PerformanceCalculationType.ROAI, - currency: exportResponse.user.settings.currency, + currency: userCurrency, userId: userDummyData.id }); diff --git a/apps/api/src/app/portfolio/calculator/roai/portfolio-calculator-jnug-buy-and-sell-and-buy-and-sell.spec.ts b/apps/api/src/app/portfolio/calculator/roai/portfolio-calculator-jnug-buy-and-sell-and-buy-and-sell.spec.ts index ba2b7d05c6..b2e0ffab6b 100644 --- a/apps/api/src/app/portfolio/calculator/roai/portfolio-calculator-jnug-buy-and-sell-and-buy-and-sell.spec.ts +++ b/apps/api/src/app/portfolio/calculator/roai/portfolio-calculator-jnug-buy-and-sell-and-buy-and-sell.spec.ts @@ -1,7 +1,5 @@ import { - activityDummyData, - assetProfileDummyData, - loadExportFile, + loadActivitiesFromExportFile, userDummyData } from '@ghostfolio/api/app/portfolio/calculator/portfolio-calculator-test-utils'; import { PortfolioCalculatorFactory } from '@ghostfolio/api/app/portfolio/calculator/portfolio-calculator.factory'; @@ -14,11 +12,9 @@ import { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate- import { PortfolioSnapshotService } from '@ghostfolio/api/services/queues/portfolio-snapshot/portfolio-snapshot.service'; import { PortfolioSnapshotServiceMock } from '@ghostfolio/api/services/queues/portfolio-snapshot/portfolio-snapshot.service.mock'; import { parseDate } from '@ghostfolio/common/helper'; -import { Activity, ExportResponse } from '@ghostfolio/common/interfaces'; import { PerformanceCalculationType } from '@ghostfolio/common/types/performance-calculation-type.type'; import { Big } from 'big.js'; -import { join } from 'node:path'; jest.mock('@ghostfolio/api/app/portfolio/current-rate.service', () => { return { @@ -48,8 +44,6 @@ jest.mock('@ghostfolio/api/app/redis-cache/redis-cache.service', () => { }); describe('PortfolioCalculator', () => { - let exportResponse: ExportResponse; - let configurationService: ConfigurationService; let currentRateService: CurrentRateService; let exchangeRateDataService: ExchangeRateDataService; @@ -57,15 +51,6 @@ describe('PortfolioCalculator', () => { let portfolioSnapshotService: PortfolioSnapshotService; let redisCacheService: RedisCacheService; - beforeAll(() => { - exportResponse = loadExportFile( - join( - __dirname, - '../../../../../../../test/import/ok/jnug-buy-and-sell-and-buy-and-sell.json' - ) - ); - }); - beforeEach(() => { PortfolioSnapshotServiceMock.reset(); RedisCacheServiceMock.reset(); @@ -98,28 +83,14 @@ describe('PortfolioCalculator', () => { it.only('with JNUG buy and sell', async () => { jest.useFakeTimers().setSystemTime(parseDate('2025-12-28').getTime()); - const activities: Activity[] = exportResponse.activities.map( - (activity) => ({ - ...activityDummyData, - ...activity, - assetProfile: { - ...assetProfileDummyData, - currency: activity.currency, - dataSource: activity.dataSource, - name: 'Direxion Daily Junior Gold Miners Index Bull 2X Shares', - symbol: activity.symbol - }, - date: parseDate(activity.date), - feeInAssetProfileCurrency: activity.fee, - feeInBaseCurrency: activity.fee, - unitPriceInAssetProfileCurrency: activity.unitPrice - }) + const { activities, userCurrency } = loadActivitiesFromExportFile( + 'jnug-buy-and-sell-and-buy-and-sell.json' ); const portfolioCalculator = portfolioCalculatorFactory.createCalculator({ activities, calculationType: PerformanceCalculationType.ROAI, - currency: exportResponse.user.settings.currency, + currency: userCurrency, userId: userDummyData.id }); diff --git a/apps/api/src/app/portfolio/calculator/roai/portfolio-calculator-manual-buy-with-market-price-between-chart-dates.spec.ts b/apps/api/src/app/portfolio/calculator/roai/portfolio-calculator-manual-buy-with-market-price-between-chart-dates.spec.ts new file mode 100644 index 0000000000..989409e77b --- /dev/null +++ b/apps/api/src/app/portfolio/calculator/roai/portfolio-calculator-manual-buy-with-market-price-between-chart-dates.spec.ts @@ -0,0 +1,170 @@ +import { + activityDummyData, + assetProfileDummyData, + userDummyData +} from '@ghostfolio/api/app/portfolio/calculator/portfolio-calculator-test-utils'; +import { PortfolioCalculatorFactory } from '@ghostfolio/api/app/portfolio/calculator/portfolio-calculator.factory'; +import { CurrentRateService } from '@ghostfolio/api/app/portfolio/current-rate.service'; +import { CurrentRateServiceMock } from '@ghostfolio/api/app/portfolio/current-rate.service.mock'; +import { RedisCacheService } from '@ghostfolio/api/app/redis-cache/redis-cache.service'; +import { RedisCacheServiceMock } from '@ghostfolio/api/app/redis-cache/redis-cache.service.mock'; +import { ConfigurationService } from '@ghostfolio/api/services/configuration/configuration.service'; +import { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-data/exchange-rate-data.service'; +import { PortfolioSnapshotService } from '@ghostfolio/api/services/queues/portfolio-snapshot/portfolio-snapshot.service'; +import { PortfolioSnapshotServiceMock } from '@ghostfolio/api/services/queues/portfolio-snapshot/portfolio-snapshot.service.mock'; +import { parseDate } from '@ghostfolio/common/helper'; +import { Activity } from '@ghostfolio/common/interfaces'; +import { PerformanceCalculationType } from '@ghostfolio/common/types/performance-calculation-type.type'; + +import { Big } from 'big.js'; + +jest.mock('@ghostfolio/api/app/portfolio/current-rate.service', () => { + return { + CurrentRateService: jest.fn().mockImplementation(() => { + return CurrentRateServiceMock; + }) + }; +}); + +jest.mock( + '@ghostfolio/api/services/queues/portfolio-snapshot/portfolio-snapshot.service', + () => { + return { + PortfolioSnapshotService: jest.fn().mockImplementation(() => { + return PortfolioSnapshotServiceMock; + }) + }; + } +); + +jest.mock('@ghostfolio/api/app/redis-cache/redis-cache.service', () => { + return { + RedisCacheService: jest.fn().mockImplementation(() => { + return RedisCacheServiceMock; + }) + }; +}); + +describe('PortfolioCalculator', () => { + let configurationService: ConfigurationService; + let currentRateService: CurrentRateService; + let exchangeRateDataService: ExchangeRateDataService; + let portfolioCalculatorFactory: PortfolioCalculatorFactory; + let portfolioSnapshotService: PortfolioSnapshotService; + let redisCacheService: RedisCacheService; + + beforeEach(() => { + PortfolioSnapshotServiceMock.reset(); + RedisCacheServiceMock.reset(); + + configurationService = new ConfigurationService(); + + currentRateService = new CurrentRateService(null, null, null); + + exchangeRateDataService = new ExchangeRateDataService( + null, + null, + null, + null + ); + + portfolioSnapshotService = new PortfolioSnapshotService(null, null); + + redisCacheService = new RedisCacheService(null, null); + + portfolioCalculatorFactory = new PortfolioCalculatorFactory( + configurationService, + currentRateService, + exchangeRateDataService, + portfolioSnapshotService, + redisCacheService + ); + }); + + describe('get current positions', () => { + it('with MANUAL buy and market price between two chart dates', async () => { + jest.useFakeTimers().setSystemTime(parseDate('2024-01-31').getTime()); + + const activities: Activity[] = [ + { + ...activityDummyData, + assetProfile: { + ...assetProfileDummyData, + currency: 'USD', + dataSource: 'MANUAL', + name: 'Private Investment', + symbol: '6c0c5cee-0208-4975-b473-03baf2518497' + }, + date: parseDate('2021-01-04'), + feeInAssetProfileCurrency: 0, + feeInBaseCurrency: 0, + quantity: 10, + type: 'BUY', + unitPriceInAssetProfileCurrency: 50 + } + ]; + + const portfolioCalculator = portfolioCalculatorFactory.createCalculator({ + activities, + calculationType: PerformanceCalculationType.ROAI, + currency: 'USD', + userId: userDummyData.id + }); + + const portfolioSnapshot = await portfolioCalculator.computeSnapshot(); + + const historicalDataByDate = Object.fromEntries( + portfolioSnapshot.historicalData.map((historicalDataItem) => { + return [historicalDataItem.date, historicalDataItem]; + }) + ); + + /** + * The only historical market prices are on 2023-06-14 and 2023-06-15, + * which are not chart dates (every third day from 2021-01-03) + */ + expect(historicalDataByDate['2023-06-14']).toBeUndefined(); + expect(historicalDataByDate['2023-06-15']).toBeUndefined(); + + /** + * The chart dates before the market prices use the unit price of the + * activity: 50 + */ + expect(historicalDataByDate['2023-06-13']).toMatchObject({ + netPerformance: 0, // 10 * (50 - 50) = 0 + totalInvestment: 500, + value: 500 // 10 * 50 = 500 + }); + + /** + * The chart dates after the market prices carry the latest one forward + * (100), not the first one (80) + */ + expect(historicalDataByDate['2023-06-16']).toMatchObject({ + netPerformance: 500, // 10 * (100 - 50) = 500 + totalInvestment: 500, + value: 1000 // 10 * 100 = 1000 + }); + + expect(historicalDataByDate['2024-01-30']).toMatchObject({ + netPerformance: 500, // 10 * (100 - 50) = 500 + totalInvestment: 500, + value: 1000 // 10 * 100 = 1000 + }); + + /** + * The market price is unchanged since 2023-06-15, hence there is no + * performance today + */ + expect(portfolioSnapshot.positions[0]).toMatchObject({ + netPerformancePercentageWithCurrencyEffectMap: { + '1d': new Big(0) + }, + netPerformanceWithCurrencyEffectMap: { + '1d': new Big(0), + max: new Big(500) // 10 * (100 - 50) = 500 + } + }); + }); + }); +}); diff --git a/apps/api/src/app/portfolio/calculator/roai/portfolio-calculator-msft-buy-with-dividend.spec.ts b/apps/api/src/app/portfolio/calculator/roai/portfolio-calculator-msft-buy-with-dividend.spec.ts index 151a2a914c..0fdd0b3000 100644 --- a/apps/api/src/app/portfolio/calculator/roai/portfolio-calculator-msft-buy-with-dividend.spec.ts +++ b/apps/api/src/app/portfolio/calculator/roai/portfolio-calculator-msft-buy-with-dividend.spec.ts @@ -1,7 +1,6 @@ import { - activityDummyData, - assetProfileDummyData, getPerformanceByDateRange, + loadActivitiesFromExportFile, userDummyData } from '@ghostfolio/api/app/portfolio/calculator/portfolio-calculator-test-utils'; import { PortfolioCalculatorFactory } from '@ghostfolio/api/app/portfolio/calculator/portfolio-calculator.factory'; @@ -14,7 +13,6 @@ import { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate- import { PortfolioSnapshotService } from '@ghostfolio/api/services/queues/portfolio-snapshot/portfolio-snapshot.service'; import { PortfolioSnapshotServiceMock } from '@ghostfolio/api/services/queues/portfolio-snapshot/portfolio-snapshot.service.mock'; import { parseDate } from '@ghostfolio/common/helper'; -import { Activity } from '@ghostfolio/common/interfaces'; import { PerformanceCalculationType } from '@ghostfolio/common/types/performance-calculation-type.type'; import { Big } from 'big.js'; @@ -86,45 +84,14 @@ describe('PortfolioCalculator', () => { it.only('with MSFT buy', async () => { jest.useFakeTimers().setSystemTime(parseDate('2023-07-10').getTime()); - const activities: Activity[] = [ - { - ...activityDummyData, - assetProfile: { - ...assetProfileDummyData, - currency: 'USD', - dataSource: 'YAHOO', - name: 'Microsoft Inc.', - symbol: 'MSFT' - }, - date: new Date('2021-09-16'), - feeInAssetProfileCurrency: 19, - feeInBaseCurrency: 19, - quantity: 1, - type: 'BUY', - unitPriceInAssetProfileCurrency: 298.58 - }, - { - ...activityDummyData, - assetProfile: { - ...assetProfileDummyData, - currency: 'USD', - dataSource: 'YAHOO', - name: 'Microsoft Inc.', - symbol: 'MSFT' - }, - date: new Date('2021-11-16'), - feeInAssetProfileCurrency: 0, - feeInBaseCurrency: 0, - quantity: 1, - type: 'DIVIDEND', - unitPriceInAssetProfileCurrency: 0.62 - } - ]; + const { activities, userCurrency } = loadActivitiesFromExportFile( + 'msft-buy-with-dividend.json' + ); const portfolioCalculator = portfolioCalculatorFactory.createCalculator({ activities, calculationType: PerformanceCalculationType.ROAI, - currency: 'USD', + currency: userCurrency, usePortfolioSnapshotCache: false, userId: userDummyData.id }); diff --git a/apps/api/src/app/portfolio/calculator/roai/portfolio-calculator-novn-buy-and-sell-partially.spec.ts b/apps/api/src/app/portfolio/calculator/roai/portfolio-calculator-novn-buy-and-sell-partially.spec.ts index 92d078d811..e85a053e61 100644 --- a/apps/api/src/app/portfolio/calculator/roai/portfolio-calculator-novn-buy-and-sell-partially.spec.ts +++ b/apps/api/src/app/portfolio/calculator/roai/portfolio-calculator-novn-buy-and-sell-partially.spec.ts @@ -1,7 +1,5 @@ import { - activityDummyData, - assetProfileDummyData, - loadExportFile, + loadActivitiesFromExportFile, userDummyData } from '@ghostfolio/api/app/portfolio/calculator/portfolio-calculator-test-utils'; import { PortfolioCalculatorFactory } from '@ghostfolio/api/app/portfolio/calculator/portfolio-calculator.factory'; @@ -14,11 +12,9 @@ import { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate- import { PortfolioSnapshotService } from '@ghostfolio/api/services/queues/portfolio-snapshot/portfolio-snapshot.service'; import { PortfolioSnapshotServiceMock } from '@ghostfolio/api/services/queues/portfolio-snapshot/portfolio-snapshot.service.mock'; import { parseDate } from '@ghostfolio/common/helper'; -import { Activity, ExportResponse } from '@ghostfolio/common/interfaces'; import { PerformanceCalculationType } from '@ghostfolio/common/types/performance-calculation-type.type'; import { Big } from 'big.js'; -import { join } from 'node:path'; jest.mock('@ghostfolio/api/app/portfolio/current-rate.service', () => { return { @@ -48,8 +44,6 @@ jest.mock('@ghostfolio/api/app/redis-cache/redis-cache.service', () => { }); describe('PortfolioCalculator', () => { - let exportResponse: ExportResponse; - let configurationService: ConfigurationService; let currentRateService: CurrentRateService; let exchangeRateDataService: ExchangeRateDataService; @@ -57,15 +51,6 @@ describe('PortfolioCalculator', () => { let portfolioSnapshotService: PortfolioSnapshotService; let redisCacheService: RedisCacheService; - beforeAll(() => { - exportResponse = loadExportFile( - join( - __dirname, - '../../../../../../../test/import/ok/novn-buy-and-sell-partially.json' - ) - ); - }); - beforeEach(() => { PortfolioSnapshotServiceMock.reset(); RedisCacheServiceMock.reset(); @@ -98,28 +83,14 @@ describe('PortfolioCalculator', () => { it.only('with NOVN.SW buy and sell partially', async () => { jest.useFakeTimers().setSystemTime(parseDate('2022-04-11').getTime()); - const activities: Activity[] = exportResponse.activities.map( - (activity) => ({ - ...activityDummyData, - ...activity, - assetProfile: { - ...assetProfileDummyData, - currency: activity.currency, - dataSource: activity.dataSource, - name: 'Novartis AG', - symbol: activity.symbol - }, - date: parseDate(activity.date), - feeInAssetProfileCurrency: activity.fee, - feeInBaseCurrency: activity.fee, - unitPriceInAssetProfileCurrency: activity.unitPrice - }) + const { activities, userCurrency } = loadActivitiesFromExportFile( + 'novn-buy-and-sell-partially.json' ); const portfolioCalculator = portfolioCalculatorFactory.createCalculator({ activities, calculationType: PerformanceCalculationType.ROAI, - currency: exportResponse.user.settings.currency, + currency: userCurrency, userId: userDummyData.id }); diff --git a/apps/api/src/app/portfolio/calculator/roai/portfolio-calculator-novn-buy-and-sell.spec.ts b/apps/api/src/app/portfolio/calculator/roai/portfolio-calculator-novn-buy-and-sell.spec.ts index 46e1b14aa4..a0174b048e 100644 --- a/apps/api/src/app/portfolio/calculator/roai/portfolio-calculator-novn-buy-and-sell.spec.ts +++ b/apps/api/src/app/portfolio/calculator/roai/portfolio-calculator-novn-buy-and-sell.spec.ts @@ -1,7 +1,5 @@ import { - activityDummyData, - assetProfileDummyData, - loadExportFile, + loadActivitiesFromExportFile, userDummyData } from '@ghostfolio/api/app/portfolio/calculator/portfolio-calculator-test-utils'; import { PortfolioCalculatorFactory } from '@ghostfolio/api/app/portfolio/calculator/portfolio-calculator.factory'; @@ -14,11 +12,9 @@ import { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate- import { PortfolioSnapshotService } from '@ghostfolio/api/services/queues/portfolio-snapshot/portfolio-snapshot.service'; import { PortfolioSnapshotServiceMock } from '@ghostfolio/api/services/queues/portfolio-snapshot/portfolio-snapshot.service.mock'; import { parseDate } from '@ghostfolio/common/helper'; -import { Activity, ExportResponse } from '@ghostfolio/common/interfaces'; import { PerformanceCalculationType } from '@ghostfolio/common/types/performance-calculation-type.type'; import { Big } from 'big.js'; -import { join } from 'node:path'; jest.mock('@ghostfolio/api/app/portfolio/current-rate.service', () => { return { @@ -48,8 +44,6 @@ jest.mock('@ghostfolio/api/app/redis-cache/redis-cache.service', () => { }); describe('PortfolioCalculator', () => { - let exportResponse: ExportResponse; - let configurationService: ConfigurationService; let currentRateService: CurrentRateService; let exchangeRateDataService: ExchangeRateDataService; @@ -57,15 +51,6 @@ describe('PortfolioCalculator', () => { let portfolioSnapshotService: PortfolioSnapshotService; let redisCacheService: RedisCacheService; - beforeAll(() => { - exportResponse = loadExportFile( - join( - __dirname, - '../../../../../../../test/import/ok/novn-buy-and-sell.json' - ) - ); - }); - beforeEach(() => { PortfolioSnapshotServiceMock.reset(); RedisCacheServiceMock.reset(); @@ -98,28 +83,14 @@ describe('PortfolioCalculator', () => { it.only('with NOVN.SW buy and sell', async () => { jest.useFakeTimers().setSystemTime(parseDate('2022-04-11').getTime()); - const activities: Activity[] = exportResponse.activities.map( - (activity) => ({ - ...activityDummyData, - ...activity, - assetProfile: { - ...assetProfileDummyData, - currency: activity.currency, - dataSource: activity.dataSource, - name: 'Novartis AG', - symbol: activity.symbol - }, - date: parseDate(activity.date), - feeInAssetProfileCurrency: activity.fee, - feeInBaseCurrency: activity.fee, - unitPriceInAssetProfileCurrency: activity.unitPrice - }) + const { activities, userCurrency } = loadActivitiesFromExportFile( + 'novn-buy-and-sell.json' ); const portfolioCalculator = portfolioCalculatorFactory.createCalculator({ activities, calculationType: PerformanceCalculationType.ROAI, - currency: exportResponse.user.settings.currency, + currency: userCurrency, userId: userDummyData.id }); diff --git a/apps/api/src/app/portfolio/current-rate.service.mock.ts b/apps/api/src/app/portfolio/current-rate.service.mock.ts index 7fcacad610..18739ddab5 100644 --- a/apps/api/src/app/portfolio/current-rate.service.mock.ts +++ b/apps/api/src/app/portfolio/current-rate.service.mock.ts @@ -30,6 +30,20 @@ function mockGetValue({ return { marketPrice: 0 }; + case '6c0c5cee-0208-4975-b473-03baf2518497': + // Sparse market data with the quote of today, which falls back to the + // latest market price + if (isSameDay(parseDate('2023-06-14'), date)) { + return { marketPrice: 80 }; + } else if ( + isSameDay(parseDate('2023-06-15'), date) || + isSameDay(parseDate('2024-01-31'), date) + ) { + return { marketPrice: 100 }; + } + + return { marketPrice: 0 }; + case 'BALN.SW': if (isSameDay(parseDate('2021-11-12'), date)) { return { marketPrice: 146 }; diff --git a/apps/api/src/app/portfolio/portfolio.service.spec.ts b/apps/api/src/app/portfolio/portfolio.service.spec.ts index ccd26e0345..19af3615bc 100644 --- a/apps/api/src/app/portfolio/portfolio.service.spec.ts +++ b/apps/api/src/app/portfolio/portfolio.service.spec.ts @@ -260,6 +260,66 @@ describe('PortfolioService', () => { AND: [whereActivityOfAssetClass, whereActivityOfTag] }); }); + + it('should calculate the dividend and the interest of an account as quantity times unit price', async () => { + jest.spyOn(accountService, 'accounts').mockResolvedValue([ + { + activities: [ + { + currency: 'USD', + date: new Date('2024-01-02'), + quantity: 2, + SymbolProfile: { currency: 'USD' }, + tags: [], + type: 'DIVIDEND', + unitPrice: 10 + }, + { + currency: 'USD', + date: new Date('2024-01-03'), + quantity: 3, + SymbolProfile: { currency: 'USD' }, + tags: [], + type: 'INTEREST', + unitPrice: 5 + } + ], + balance: 0, + currency: 'USD', + id: 'account-id', + name: 'Account' + } + ] as unknown as Awaited>); + + jest.spyOn(portfolioService, 'getDetails').mockResolvedValue({ + accounts: {} + } as unknown as Awaited>); + + jest.spyOn(userService, 'user').mockResolvedValue({ + settings: { settings: { baseCurrency: 'USD' } } + } as unknown as Awaited>); + + jest + .spyOn(exchangeRateDataService, 'toCurrencyAtDate') + .mockImplementation(async (value) => { + return value; + }); + + jest + .spyOn(exchangeRateDataService, 'toCurrency') + .mockImplementation((value) => { + return value; + }); + + const [account] = await portfolioService.getAccounts({ + userId: userDummyData.id + }); + + expect(account).toMatchObject({ + dividendInBaseCurrency: 20, + interestInBaseCurrency: 15 + }); + }); }); describe('getAggregatedMarkets', () => { diff --git a/apps/api/src/app/portfolio/portfolio.service.ts b/apps/api/src/app/portfolio/portfolio.service.ts index 72c5b5946a..d906fd6aef 100644 --- a/apps/api/src/app/portfolio/portfolio.service.ts +++ b/apps/api/src/app/portfolio/portfolio.service.ts @@ -334,7 +334,7 @@ export class PortfolioService { case ActivityType.INTEREST: interestInBaseCurrency += (await this.exchangeRateDataService.toCurrencyAtDate( - unitPrice, + new Big(quantity).mul(unitPrice).toNumber(), currency ?? SymbolProfile.currency, userCurrency, date diff --git a/apps/api/src/helper/data-source.helper.spec.ts b/apps/api/src/helper/data-source.helper.spec.ts new file mode 100644 index 0000000000..3bfef82add --- /dev/null +++ b/apps/api/src/helper/data-source.helper.spec.ts @@ -0,0 +1,145 @@ +import { ConfigurationService } from '@ghostfolio/api/services/configuration/configuration.service'; + +import { DataSource } from '@prisma/client'; + +import { + encodeDataSource, + isValidEncodedDataSource, + transformDataSourceInRequest, + transformDataSourceInResponse +} from './data-source.helper'; + +function createConfigurationService({ + isSubscriptionEnabled +}: { + isSubscriptionEnabled: boolean; +}) { + const configuration: Record = { + DATA_SOURCES_GHOSTFOLIO_DATA_PROVIDER: [DataSource.YAHOO], + ENABLE_FEATURE_SUBSCRIPTION: isSubscriptionEnabled + }; + + return { + get: jest.fn((key: string) => { + return configuration[key]; + }) + } as unknown as ConfigurationService; +} + +describe('DataSourceHelper', () => { + describe('isValidEncodedDataSource', () => { + it('Accepts an encoded data source', () => { + expect(isValidEncodedDataSource(encodeDataSource(DataSource.YAHOO))).toBe( + true + ); + }); + + it('Refuses a data source which is not encoded', () => { + expect(isValidEncodedDataSource(DataSource.YAHOO)).toBe(false); + }); + + it('Refuses an unknown encoded data source', () => { + expect(isValidEncodedDataSource('ffffffff')).toBe(false); + }); + }); + + describe('transformDataSourceInRequest', () => { + const configurationService = createConfigurationService({ + isSubscriptionEnabled: true + }); + + it('Decodes an encoded data source', () => { + expect( + transformDataSourceInRequest({ + configurationService, + dataSource: encodeDataSource(DataSource.COINGECKO) + }) + ).toBe(DataSource.COINGECKO); + }); + + it('Resolves the mask of the data source of the Ghostfolio data provider', () => { + expect( + transformDataSourceInRequest({ + configurationService, + dataSource: DataSource.GHOSTFOLIO + }) + ).toBe(DataSource.YAHOO); + }); + + it('Keeps a data source which is not encoded', () => { + expect( + transformDataSourceInRequest({ + configurationService, + dataSource: DataSource.COINGECKO + }) + ).toBe(DataSource.COINGECKO); + }); + + it('Keeps an absent data source', () => { + expect( + transformDataSourceInRequest({ + configurationService, + dataSource: undefined + }) + ).toBeUndefined(); + }); + + it('Decodes an encoded data source if the subscription is not enabled', () => { + expect( + transformDataSourceInRequest({ + configurationService: createConfigurationService({ + isSubscriptionEnabled: false + }), + dataSource: encodeDataSource(DataSource.COINGECKO) + }) + ).toBe(DataSource.COINGECKO); + }); + + it('Keeps the data source GHOSTFOLIO if the subscription is not enabled', () => { + expect( + transformDataSourceInRequest({ + configurationService: createConfigurationService({ + isSubscriptionEnabled: false + }), + dataSource: DataSource.GHOSTFOLIO + }) + ).toBe(DataSource.GHOSTFOLIO); + }); + }); + + describe('transformDataSourceInResponse', () => { + it('Encodes the data source if the subscription is enabled', () => { + expect( + transformDataSourceInResponse({ + configurationService: createConfigurationService({ + isSubscriptionEnabled: true + }), + dataSource: DataSource.YAHOO + }) + ).toBe(encodeDataSource(DataSource.YAHOO)); + }); + + it('Keeps the data sources GHOSTFOLIO and MANUAL if the subscription is enabled', () => { + const configurationService = createConfigurationService({ + isSubscriptionEnabled: true + }); + + for (const dataSource of [DataSource.GHOSTFOLIO, DataSource.MANUAL]) { + expect( + transformDataSourceInResponse({ configurationService, dataSource }) + ).toBe(dataSource); + } + }); + + it('Keeps the data source if the subscription is not enabled', () => { + expect( + transformDataSourceInResponse({ + configurationService: createConfigurationService({ + isSubscriptionEnabled: false + }), + dataSource: DataSource.YAHOO + }) + ).toBe(DataSource.YAHOO); + }); + }); +}); diff --git a/apps/api/src/helper/data-source.helper.ts b/apps/api/src/helper/data-source.helper.ts index fff8eaac1d..fb0ded3f1f 100644 --- a/apps/api/src/helper/data-source.helper.ts +++ b/apps/api/src/helper/data-source.helper.ts @@ -1,6 +1,14 @@ -import { DataSource } from '@prisma/client'; +import { ConfigurationService } from '@ghostfolio/api/services/configuration/configuration.service'; + +import { DataSource, Prisma } from '@prisma/client'; +import { isEmpty } from 'lodash-es'; import { createHash } from 'node:crypto'; +const DATA_SOURCES_WITHOUT_ENCODING: DataSource[] = [ + DataSource.GHOSTFOLIO, + DataSource.MANUAL +]; + const encodedDataSourceByDataSource = new Map( Object.values(DataSource).map((dataSource) => { return [dataSource, hashDataSource(dataSource)]; @@ -30,6 +38,28 @@ function deprecatedHashDataSource(dataSource: DataSource) { return Buffer.from(dataSource, 'utf-8').toString('hex'); } +function getGhostfolioDataSources({ + configurationService +}: { + configurationService: ConfigurationService; +}) { + return configurationService.get('ENABLE_FEATURE_SUBSCRIPTION') + ? configurationService.get('DATA_SOURCES_GHOSTFOLIO_DATA_PROVIDER') + : []; +} + +function getUnmaskedGhostfolioDataSource({ + dataSource, + ghostfolioDataSources +}: { + dataSource?: DataSource; + ghostfolioDataSources: string[]; +}) { + return dataSource === DataSource.GHOSTFOLIO && ghostfolioDataSources?.[0] + ? (ghostfolioDataSources[0] as DataSource) + : dataSource; +} + function hashDataSource(dataSource: DataSource) { return createHash('sha256').update(dataSource).digest('hex').slice(0, 8); } @@ -66,14 +96,62 @@ export function getMaskedGhostfolioDataSource({ : dataSource; } -export function getUnmaskedGhostfolioDataSource({ +export function isDataGatheringSupported({ dataSource, - ghostfolioDataSources + scraperConfiguration }: { - dataSource?: DataSource; - ghostfolioDataSources: string[]; + dataSource: DataSource; + scraperConfiguration: Prisma.JsonValue; }) { - return dataSource === DataSource.GHOSTFOLIO && ghostfolioDataSources?.[0] - ? (ghostfolioDataSources[0] as DataSource) + // An asset profile with the MANUAL data source supports data gathering only + // with a scraper configuration + return dataSource !== DataSource.MANUAL || !isEmpty(scraperConfiguration); +} + +export function isDataSourceEncodedInResponse(dataSource: DataSource) { + return !DATA_SOURCES_WITHOUT_ENCODING.includes(dataSource); +} + +export function isValidEncodedDataSource(encodedDataSource: string) { + return dataSourceByEncodedDataSource.has(encodedDataSource); +} + +/** + * Gives the data source of a request without a transformation: an encoded data + * source is decoded, and the mask of the Ghostfolio data provider is resolved if + * the subscription is enabled (see the TransformDataSourceInRequestInterceptor) + */ +export function transformDataSourceInRequest({ + configurationService, + dataSource +}: { + configurationService: ConfigurationService; + dataSource?: string; +}) { + if (Object.hasOwn(DataSource, dataSource)) { + return getUnmaskedGhostfolioDataSource({ + dataSource: dataSource as DataSource, + ghostfolioDataSources: getGhostfolioDataSources({ configurationService }) + }); + } + + return decodeDataSource(dataSource) as DataSource; +} + +/** + * Gives the data source as a response gives it to a user who is not an admin: + * encoded if the subscription is enabled, except the data sources GHOSTFOLIO + * and MANUAL (see the TransformDataSourceInResponseInterceptor) + */ +export function transformDataSourceInResponse({ + configurationService, + dataSource +}: { + configurationService: ConfigurationService; + dataSource: DataSource; +}) { + return configurationService.get('ENABLE_FEATURE_SUBSCRIPTION') && + isDataSourceEncodedInResponse(dataSource) + ? encodeDataSource(dataSource) : dataSource; } diff --git a/apps/api/src/helper/object.helper.spec.ts b/apps/api/src/helper/object.helper.spec.ts index 9776232a0b..5543441dbc 100644 --- a/apps/api/src/helper/object.helper.spec.ts +++ b/apps/api/src/helper/object.helper.spec.ts @@ -3082,20 +3082,6 @@ describe('redactAttributes', () => { feeInBaseCurrency: 18.2, id: '8c623328-6035-4b5f-b6d5-702cc1c9c56b', quantity: 50, - // The relation of the database is part of the response next to the - // asset profile - SymbolProfile: { - comment: 'Reviewed by the administrator', - name: 'Apple Inc', - scraperConfiguration: { - headers: { Authorization: 'Bearer TOKEN' }, - mode: 'lazy', - selector: '.price', - url: 'https://example.org/get_price' - }, - symbol: 'AAPL', - symbolMapping: { YAHOO: 'AAPL' } - }, type: 'BUY', unitPrice: 220.79, value: 11039.5, @@ -3125,6 +3111,49 @@ describe('redactAttributes', () => { feeInBaseCurrency: null, id: '8c623328-6035-4b5f-b6d5-702cc1c9c56b', quantity: null, + type: 'BUY', + // A price per unit stays visible, like the average price and the market + // price of a holding + unitPrice: 220.79, + value: null, + valueInBaseCurrency: null + }); + }); + + // The endpoint POST api/v1/activities returns a row of the database with the + // relation to the symbol profile + it('should redact an activity with the relation to the symbol profile', () => { + expect( + redactPaths({ + object: { + comment: 'Bought on a dip', + currency: 'USD', + fee: 19.9, + id: '8c623328-6035-4b5f-b6d5-702cc1c9c56b', + quantity: 50, + SymbolProfile: { + comment: 'Reviewed by the administrator', + name: 'Apple Inc', + scraperConfiguration: { + headers: { Authorization: 'Bearer TOKEN' }, + mode: 'lazy', + selector: '.price', + url: 'https://example.org/get_price' + }, + symbol: 'AAPL', + symbolMapping: { YAHOO: 'AAPL' } + }, + type: 'BUY', + unitPrice: 220.79 + }, + paths: DEFAULT_REDACTED_PATHS + }) + ).toStrictEqual({ + comment: null, + currency: 'USD', + fee: null, + id: '8c623328-6035-4b5f-b6d5-702cc1c9c56b', + quantity: null, SymbolProfile: { comment: null, name: 'Apple Inc', @@ -3133,15 +3162,12 @@ describe('redactAttributes', () => { symbolMapping: null }, type: 'BUY', - // A price per unit stays visible, like the average price and the market - // price of a holding - unitPrice: 220.79, - value: null, - valueInBaseCurrency: null + unitPrice: 220.79 }); }); - // The write endpoints return a row of the database, which has no relation + // The endpoints PUT and DELETE api/v1/activities/:id return a row of the + // database without the relations it('should redact an activity without the relations', () => { expect( redactPaths({ @@ -3190,18 +3216,6 @@ describe('redactAttributes', () => { currency: 'USD', fee: 19.9, quantity: 50, - SymbolProfile: { - comment: 'Reviewed by the administrator', - name: 'Apple Inc', - scraperConfiguration: { - headers: { Authorization: 'Bearer TOKEN' }, - mode: 'lazy', - selector: '.price', - url: 'https://example.org/get_price' - }, - symbol: 'AAPL', - symbolMapping: { YAHOO: 'AAPL' } - }, type: 'BUY', unitPrice: 220.79, value: 11039.5, @@ -3227,13 +3241,6 @@ describe('redactAttributes', () => { currency: 'USD', fee: null, quantity: null, - SymbolProfile: { - comment: null, - name: 'Apple Inc', - scraperConfiguration: null, - symbol: 'AAPL', - symbolMapping: null - }, type: 'BUY', unitPrice: 220.79, value: null, diff --git a/apps/api/src/interceptors/transform-data-source-in-request/transform-data-source-in-request.interceptor.ts b/apps/api/src/interceptors/transform-data-source-in-request/transform-data-source-in-request.interceptor.ts index 7b537cd523..569ffc60ef 100644 --- a/apps/api/src/interceptors/transform-data-source-in-request/transform-data-source-in-request.interceptor.ts +++ b/apps/api/src/interceptors/transform-data-source-in-request/transform-data-source-in-request.interceptor.ts @@ -1,6 +1,6 @@ import { decodeDataSource, - getUnmaskedGhostfolioDataSource + transformDataSourceInRequest } from '@ghostfolio/api/helper/data-source.helper'; import { ConfigurationService } from '@ghostfolio/api/services/configuration/configuration.service'; @@ -28,30 +28,19 @@ export class TransformDataSourceInRequestInterceptor< const http = context.switchToHttp(); const request = http.getRequest(); - if (this.configurationService.get('ENABLE_FEATURE_SUBSCRIPTION')) { - if (request.body?.activities) { - const ghostfolioDataSources = this.configurationService.get( - 'DATA_SOURCES_GHOSTFOLIO_DATA_PROVIDER' - ); - - request.body.activities = request.body.activities.map((activity) => { - if (DataSource[activity.dataSource]) { - return { - ...activity, - dataSource: getUnmaskedGhostfolioDataSource({ - ghostfolioDataSources, - dataSource: activity.dataSource - }) - }; - } else { - return { - ...activity, - dataSource: decodeDataSource(activity.dataSource) - }; - } - }); - } + if (request.body?.activities) { + request.body.activities = request.body.activities.map((activity) => { + return { + ...activity, + dataSource: transformDataSourceInRequest({ + configurationService: this.configurationService, + dataSource: activity.dataSource + }) + }; + }); + } + if (this.configurationService.get('ENABLE_FEATURE_SUBSCRIPTION')) { for (const type of ['body', 'params', 'query']) { const dataSourceValue = request[type]?.dataSource; @@ -68,19 +57,6 @@ export class TransformDataSourceInRequestInterceptor< }); } } - } else { - if (request.body?.activities) { - request.body.activities = request.body.activities.map((activity) => { - if (DataSource[activity.dataSource]) { - return activity; - } else { - return { - ...activity, - dataSource: decodeDataSource(activity.dataSource) - }; - } - }); - } } return next.handle(); diff --git a/apps/api/src/interceptors/transform-data-source-in-response/transform-data-source-in-response.interceptor.ts b/apps/api/src/interceptors/transform-data-source-in-response/transform-data-source-in-response.interceptor.ts index 339b91a2f7..eedc808772 100644 --- a/apps/api/src/interceptors/transform-data-source-in-response/transform-data-source-in-response.interceptor.ts +++ b/apps/api/src/interceptors/transform-data-source-in-response/transform-data-source-in-response.interceptor.ts @@ -1,6 +1,7 @@ import { encodeDataSource, - getMaskedGhostfolioDataSource + getMaskedGhostfolioDataSource, + isDataSourceEncodedInResponse } from '@ghostfolio/api/helper/data-source.helper'; import { redactPaths } from '@ghostfolio/api/helper/object.helper'; import { ConfigurationService } from '@ghostfolio/api/services/configuration/configuration.service'; @@ -30,7 +31,7 @@ export class TransformDataSourceInResponseInterceptor< if (this.configurationService.get('ENABLE_FEATURE_SUBSCRIPTION')) { this.encodedDataSourceMap = Object.keys(DataSource).reduce( (encodedDataSourceMap, dataSource) => { - if (!['GHOSTFOLIO', 'MANUAL'].includes(dataSource)) { + if (isDataSourceEncodedInResponse(DataSource[dataSource])) { encodedDataSourceMap[dataSource] = encodeDataSource( DataSource[dataSource] ); @@ -83,7 +84,6 @@ export class TransformDataSourceInResponseInterceptor< '["filters.dataSource"]', 'activities[*].assetProfile.dataSource', 'activities[*].dataSource', - 'activities[*].SymbolProfile.dataSource', 'assetProfile.dataSource', 'benchmarks[*].dataSource', 'errors[*].dataSource', @@ -94,7 +94,6 @@ export class TransformDataSourceInResponseInterceptor< 'items[*].dataSource', 'latestActivities[*].assetProfile.dataSource', 'settings["filters.dataSource"]', - 'SymbolProfile.dataSource', 'watchlist[*].dataSource' ] }); diff --git a/apps/api/src/services/benchmark/benchmark.service.spec.ts b/apps/api/src/services/benchmark/benchmark.service.spec.ts index 0e7119b045..27124e89e4 100644 --- a/apps/api/src/services/benchmark/benchmark.service.spec.ts +++ b/apps/api/src/services/benchmark/benchmark.service.spec.ts @@ -24,4 +24,14 @@ describe('BenchmarkService', () => { ); expect(benchmarkService.getMarketCondition(-0.2)).toEqual('BEAR_MARKET'); }); + + it('getPerformancePercentFromAllTimeHigh', async () => { + expect(benchmarkService.getPerformancePercentFromAllTimeHigh(0.1)).toEqual( + 0 + ); + expect(benchmarkService.getPerformancePercentFromAllTimeHigh(0)).toEqual(0); + expect(benchmarkService.getPerformancePercentFromAllTimeHigh(-0.1)).toEqual( + -0.1 + ); + }); }); diff --git a/apps/api/src/services/benchmark/benchmark.service.ts b/apps/api/src/services/benchmark/benchmark.service.ts index 4ea27b2f2e..3c39848401 100644 --- a/apps/api/src/services/benchmark/benchmark.service.ts +++ b/apps/api/src/services/benchmark/benchmark.service.ts @@ -216,6 +216,12 @@ export class BenchmarkService { } } + public getPerformancePercentFromAllTimeHigh(performancePercent: number) { + const isAtAllTimeHigh = performancePercent >= 0; + + return isAtAllTimeHigh ? 0 : performancePercent; + } + public async isBenchmark(symbolProfileId: string): Promise { const benchmarks = await this.getBenchmarksProperty(); @@ -288,10 +294,9 @@ export class BenchmarkService { performances: { allTimeHigh: { date: allTimeHigh?.date, - performancePercent: - performancePercentFromAllTimeHigh >= 0 - ? 0 - : performancePercentFromAllTimeHigh + performancePercent: this.getPerformancePercentFromAllTimeHigh( + performancePercentFromAllTimeHigh + ) } }, symbol: benchmarkAssetProfiles[index].symbol, diff --git a/apps/api/src/services/portfolio-table/interfaces/data-source-table-context.interface.ts b/apps/api/src/services/portfolio-table/interfaces/data-source-table-context.interface.ts new file mode 100644 index 0000000000..927f32cda0 --- /dev/null +++ b/apps/api/src/services/portfolio-table/interfaces/data-source-table-context.interface.ts @@ -0,0 +1,5 @@ +import { ConfigurationService } from '@ghostfolio/api/services/configuration/configuration.service'; + +export interface DataSourceTableContext { + configurationService: ConfigurationService; +} diff --git a/apps/api/src/services/portfolio-table/interfaces/holdings-table-context.interface.ts b/apps/api/src/services/portfolio-table/interfaces/holdings-table-context.interface.ts index 4364b492e7..771cd10404 100644 --- a/apps/api/src/services/portfolio-table/interfaces/holdings-table-context.interface.ts +++ b/apps/api/src/services/portfolio-table/interfaces/holdings-table-context.interface.ts @@ -1,6 +1,8 @@ import { AssetClass, AssetSubClass } from '@prisma/client'; -export interface HoldingsTableContext { +import { DataSourceTableContext } from './data-source-table-context.interface'; + +export interface HoldingsTableContext extends DataSourceTableContext { assetClassTranslations: Record; assetSubClassTranslations: Record; } diff --git a/apps/api/src/services/portfolio-table/portfolio-table.module.ts b/apps/api/src/services/portfolio-table/portfolio-table.module.ts index 0995dc9980..01d0ebc64d 100644 --- a/apps/api/src/services/portfolio-table/portfolio-table.module.ts +++ b/apps/api/src/services/portfolio-table/portfolio-table.module.ts @@ -1,6 +1,7 @@ import { ActivitiesModule } from '@ghostfolio/api/app/activities/activities.module'; import { WatchlistModule } from '@ghostfolio/api/app/endpoints/watchlist/watchlist.module'; import { PortfolioModule } from '@ghostfolio/api/app/portfolio/portfolio.module'; +import { ConfigurationModule } from '@ghostfolio/api/services/configuration/configuration.module'; import { I18nModule } from '@ghostfolio/api/services/i18n/i18n.module'; import { Module } from '@nestjs/common'; @@ -9,7 +10,13 @@ import { PortfolioTableService } from './portfolio-table.service'; @Module({ exports: [PortfolioTableService], - imports: [ActivitiesModule, I18nModule, PortfolioModule, WatchlistModule], + imports: [ + ActivitiesModule, + ConfigurationModule, + I18nModule, + PortfolioModule, + WatchlistModule + ], providers: [PortfolioTableService] }) export class PortfolioTableModule {} diff --git a/apps/api/src/services/portfolio-table/portfolio-table.service.spec.ts b/apps/api/src/services/portfolio-table/portfolio-table.service.spec.ts index ba2be47cf6..3cfb9fe856 100644 --- a/apps/api/src/services/portfolio-table/portfolio-table.service.spec.ts +++ b/apps/api/src/services/portfolio-table/portfolio-table.service.spec.ts @@ -1,12 +1,18 @@ +import type { ActivitiesService } from '@ghostfolio/api/app/activities/activities.service'; import type { WatchlistService } from '@ghostfolio/api/app/endpoints/watchlist/watchlist.service'; import type { PortfolioService } from '@ghostfolio/api/app/portfolio/portfolio.service'; +import { encodeDataSource } from '@ghostfolio/api/helper/data-source.helper'; import type { TableParameters } from '@ghostfolio/api/helper/interfaces/table-parameters.interface'; +import type { ConfigurationService } from '@ghostfolio/api/services/configuration/configuration.service'; import type { I18nService } from '@ghostfolio/api/services/i18n/i18n.service'; import { DEFAULT_LANGUAGE_CODE, TAG_ID_EXCLUDE_FROM_ANALYSIS } from '@ghostfolio/common/config'; import { + Activity, + HistoricalDataItem, + PortfolioPerformanceResponse, PortfolioPosition, WatchlistResponse } from '@ghostfolio/common/interfaces'; @@ -58,6 +64,8 @@ jest.mock('@ghostfolio/api/helper/markdown-table.helper', () => { }; }); +const SUBSCRIPTION_CONFIGURATION = { ENABLE_FEATURE_SUBSCRIPTION: true }; + function createAccount({ id = 'account-a-id', isExcluded = false, @@ -80,15 +88,39 @@ function createAccount({ } as unknown as AccountWithValue; } +function createActivity({ + dataSource = DataSource.YAHOO, + symbol = 'AAPL' +}: { + dataSource?: DataSource; + symbol?: string; +} = {}) { + return { + account: { name: 'Account A' }, + assetProfile: { + dataSource, + symbol, + currency: 'CHF', + name: `Name of ${symbol}` + }, + currency: 'CHF', + date: new Date('2024-01-01'), + type: 'BUY', + unitPrice: 100 + } as unknown as Activity; +} + function createHolding({ allocationInPercentage = 0.75, assetClass = AssetClass.EQUITY, assetSubClass = AssetSubClass.STOCK, + dataSource = DataSource.YAHOO, symbol = 'AAPL' }: { allocationInPercentage?: number; assetClass?: AssetClass; assetSubClass?: AssetSubClass; + dataSource?: DataSource; symbol?: string; } = {}) { return { @@ -97,6 +129,7 @@ function createHolding({ assetProfile: { assetClass, assetSubClass, + dataSource, symbol, currency: 'CHF', name: `Name of ${symbol}` @@ -109,19 +142,41 @@ function createHolding({ } as unknown as PortfolioPosition; } +function createPerformance({ + netPerformancePercentage = 0.1, + netPerformancePercentageWithCurrencyEffect = 0.15 +}: { + netPerformancePercentage?: number; + netPerformancePercentageWithCurrencyEffect?: number; +} = {}): PortfolioPerformanceResponse['performance'] { + return { + netPerformancePercentage, + netPerformancePercentageWithCurrencyEffect, + currentNetWorth: 3000, + currentValueInBaseCurrency: 2000, + dividendInBaseCurrency: 50, + netPerformance: 200, + netPerformanceWithCurrencyEffect: 300, + totalInvestment: 1700, + totalInvestmentValueWithCurrencyEffect: 1700 + }; +} + function createWatchlistItem({ + dataSource = DataSource.YAHOO, name = 'Name of AAPL', performancePercent = -0.25, symbol = 'AAPL' }: { + dataSource?: DataSource; name?: string; performancePercent?: number; symbol?: string; } = {}): WatchlistResponse['watchlist'][number] { return { + dataSource, name, symbol, - dataSource: DataSource.YAHOO, marketCondition: 'BEAR_MARKET', performances: { allTimeHigh: { @@ -136,13 +191,33 @@ function createWatchlistItem({ function createPortfolioTableService({ accounts = [], + activities = [], + chart = [{ date: '2024-01-01' }], + configuration = { ENABLE_FEATURE_SUBSCRIPTION: false }, holdings = [], + performance = createPerformance(), watchlist = [] }: { accounts?: AccountWithValue[]; + activities?: Activity[]; + chart?: HistoricalDataItem[]; + configuration?: Record; holdings?: PortfolioPosition[]; + performance?: PortfolioPerformanceResponse['performance']; watchlist?: WatchlistResponse['watchlist']; } = {}) { + const activitiesService = { + getActivities: jest + .fn() + .mockResolvedValue({ activities, count: activities.length }) + } as unknown as ActivitiesService; + + const configurationService = { + get: jest.fn((key: string) => { + return configuration[key]; + }) + } as unknown as ConfigurationService; + // 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 = { @@ -153,7 +228,8 @@ function createPortfolioTableService({ const portfolioService = { getAccountsWithAggregations: jest.fn().mockResolvedValue({ accounts }), - getDetails: jest.fn().mockResolvedValue({ holdings }) + getDetails: jest.fn().mockResolvedValue({ holdings }), + getPerformance: jest.fn().mockResolvedValue({ chart, performance }) } as unknown as PortfolioService; const watchlistService = { @@ -161,7 +237,8 @@ function createPortfolioTableService({ } as unknown as WatchlistService; return new PortfolioTableService( - null, + activitiesService, + configurationService, i18nService, portfolioService, watchlistService @@ -193,6 +270,7 @@ describe('PortfolioTableService', () => { 'Type', 'Name', 'Symbol', + 'Data Source', 'Currency', 'Unit Price', 'Account' @@ -205,6 +283,7 @@ describe('PortfolioTableService', () => { expect(PortfolioTableService.getHoldingsTableColumnNames()).toEqual([ 'Name', 'Symbol', + 'Data Source', 'Currency', 'Asset Class', 'Asset Sub Class', @@ -215,11 +294,22 @@ describe('PortfolioTableService', () => { }); }); + describe('getPerformanceTableColumnNames', () => { + it('gives no column with a monetary value', () => { + expect(PortfolioTableService.getPerformanceTableColumnNames()).toEqual([ + 'Asset Performance in Percentage', + 'Currency Performance in Percentage', + 'Net Performance in Percentage' + ]); + }); + }); + describe('getWatchlistTableColumnNames', () => { it('gives no column with a monetary value', () => { expect(PortfolioTableService.getWatchlistTableColumnNames()).toEqual([ 'Name', 'Symbol', + 'Data Source', 'Trend 50 Days', 'Trend 200 Days', 'Date of Last All Time High', @@ -290,14 +380,118 @@ describe('PortfolioTableService', () => { expect(result).toContain('No accounts found.'); }); }); + + describe('getActivitiesTable', () => { + it('gives the data source, encoded if the subscription is enabled', async () => { + const result = await createPortfolioTableService({ + activities: [ + createActivity({ dataSource: DataSource.YAHOO, symbol: 'AAPL' }), + createActivity({ + dataSource: DataSource.MANUAL, + symbol: 'GF_GOLD' + }) + ], + configuration: SUBSCRIPTION_CONFIGURATION + }).getActivitiesTable({ + take: 50, + userCurrency: 'CHF', + userId: 'user-id' + }); + + const [rowOfAapl, rowOfGold] = result.split('\n').filter((line) => { + return line.startsWith('| 2024-01-01'); + }); + + expect(rowOfAapl).toContain(`| ${encodeDataSource(DataSource.YAHOO)} |`); + expect(rowOfGold).toContain(`| ${DataSource.MANUAL} |`); + }); + }); + describe('getHoldingsTable', () => { - function getHoldingsTable(holdings: PortfolioPosition[]) { - return createPortfolioTableService({ holdings }).getHoldingsTable({ + function getHoldingsTable( + holdings: PortfolioPosition[], + { + configuration, + withDataSource + }: { + configuration?: Record; + withDataSource?: boolean; + } = {} + ) { + return createPortfolioTableService({ + configuration, + holdings + }).getHoldingsTable({ + withDataSource, languageCode: DEFAULT_LANGUAGE_CODE, userId: 'user-id' }); } + it('gives no data source by default', async () => { + const result = await getHoldingsTable([createHolding()], { + configuration: SUBSCRIPTION_CONFIGURATION + }); + + expect(result).not.toContain('Data Source'); + expect(result).not.toContain(encodeDataSource(DataSource.YAHOO)); + }); + + it('gives the data source, encoded if the subscription is enabled', async () => { + const result = await getHoldingsTable( + [ + createHolding({ dataSource: DataSource.YAHOO, symbol: 'AAPL' }), + createHolding({ + allocationInPercentage: 0.25, + dataSource: DataSource.MANUAL, + symbol: 'GF_GOLD' + }) + ], + { configuration: SUBSCRIPTION_CONFIGURATION, withDataSource: true } + ); + + const [rowOfAapl, rowOfGold] = result.split('\n').filter((line) => { + return line.startsWith('| Name of'); + }); + + expect(rowOfAapl).toContain(`| ${encodeDataSource(DataSource.YAHOO)} |`); + expect(rowOfGold).toContain(`| ${DataSource.MANUAL} |`); + }); + + it('gives the data source unencoded if the subscription is disabled', async () => { + const result = await getHoldingsTable([createHolding()], { + configuration: { ENABLE_FEATURE_SUBSCRIPTION: false }, + withDataSource: true + }); + + const [row] = result.split('\n').filter((line) => { + return line.startsWith('| Name of AAPL'); + }); + + expect(row).toContain(`| ${DataSource.YAHOO} |`); + }); + + it('gives no data source of a cash position', async () => { + const result = await getHoldingsTable( + [ + createHolding({ + assetClass: AssetClass.LIQUIDITY, + assetSubClass: AssetSubClass.CASH, + dataSource: DataSource.YAHOO, + symbol: 'USD' + }) + ], + { configuration: SUBSCRIPTION_CONFIGURATION, withDataSource: true } + ); + + const [row] = result.split('\n').filter((line) => { + return line.startsWith('| Name of USD'); + }); + + expect(row).toContain('| USD | | CHF |'); + expect(row).not.toContain(encodeDataSource(DataSource.YAHOO)); + }); + it('gives the translation of the asset class and of the asset sub class', async () => { const result = await getHoldingsTable([createHolding()]); @@ -324,13 +518,95 @@ describe('PortfolioTableService', () => { }); }); + describe('getPerformanceTable', () => { + function getPerformanceTable( + parameters: Parameters[0] = {} + ) { + return createPortfolioTableService(parameters).getPerformanceTable({ + dateRange: 'ytd', + userId: 'user-id' + }); + } + + it('gives the currency performance as the difference of the net performance and the asset performance', async () => { + const result = await getPerformanceTable(); + + const [row] = result.split('\n').filter((line) => { + return line.startsWith('| 10.000%'); + }); + + expect(row).toBe('| 10.000% | 5.000% | 15.000% |'); + }); + + it('gives a currency performance of zero without a sign', async () => { + const result = await getPerformanceTable({ + performance: createPerformance({ + netPerformancePercentage: 0.10000000000000003, + netPerformancePercentageWithCurrencyEffect: 0.1 + }) + }); + + const [row] = result.split('\n').filter((line) => { + return line.startsWith('| 10.000%'); + }); + + expect(row).toBe('| 10.000% | 0.000% | 10.000% |'); + }); + + it('gives no monetary value', async () => { + const result = await getPerformanceTable(); + + const [, , row] = result.split('\n').filter((line) => { + return line.startsWith('|'); + }); + + for (const cell of row.split('|').slice(1, -1)) { + expect(cell.trim()).toMatch(/^-?\d+\.\d{3}%$/); + } + }); + + it('tells that no performance is found if the chart is empty', async () => { + const result = await getPerformanceTable({ chart: [] }); + + expect(result).toContain('No performance found.'); + expect(result).not.toContain('%'); + }); + }); + describe('getWatchlistTable', () => { - function getWatchlistTable(watchlist: WatchlistResponse['watchlist']) { - return createPortfolioTableService({ watchlist }).getWatchlistTable({ + function getWatchlistTable( + watchlist: WatchlistResponse['watchlist'], + configuration?: Record + ) { + return createPortfolioTableService({ + configuration, + watchlist + }).getWatchlistTable({ userId: 'user-id' }); } + it('gives the data source, encoded if the subscription is enabled', async () => { + const result = await getWatchlistTable( + [ + createWatchlistItem(), + createWatchlistItem({ + dataSource: DataSource.MANUAL, + name: 'Name of GF_GOLD', + symbol: 'GF_GOLD' + }) + ], + SUBSCRIPTION_CONFIGURATION + ); + + const [rowOfAapl, rowOfGold] = result.split('\n').filter((line) => { + return line.startsWith('| Name of'); + }); + + expect(rowOfAapl).toContain(`| ${encodeDataSource(DataSource.YAHOO)} |`); + expect(rowOfGold).toContain(`| ${DataSource.MANUAL} |`); + }); + it('gives the date and the change of the all time high', async () => { const result = await getWatchlistTable([createWatchlistItem()]); diff --git a/apps/api/src/services/portfolio-table/portfolio-table.service.ts b/apps/api/src/services/portfolio-table/portfolio-table.service.ts index 9f53bef065..9e63d133b5 100644 --- a/apps/api/src/services/portfolio-table/portfolio-table.service.ts +++ b/apps/api/src/services/portfolio-table/portfolio-table.service.ts @@ -1,35 +1,62 @@ import { ActivitiesService } from '@ghostfolio/api/app/activities/activities.service'; import { WatchlistService } from '@ghostfolio/api/app/endpoints/watchlist/watchlist.service'; import { PortfolioService } from '@ghostfolio/api/app/portfolio/portfolio.service'; +import { transformDataSourceInResponse } from '@ghostfolio/api/helper/data-source.helper'; import { TableColumnDefinition } from '@ghostfolio/api/helper/interfaces/table-column-definition.interface'; import { getMarkdownTable } from '@ghostfolio/api/helper/markdown-table.helper'; +import { ConfigurationService } from '@ghostfolio/api/services/configuration/configuration.service'; import { I18nService } from '@ghostfolio/api/services/i18n/i18n.service'; -import { DATE_FORMAT, isAccountExcluded } from '@ghostfolio/common/helper'; +import { + DATE_FORMAT, + isAccountExcluded, + isCashPosition +} from '@ghostfolio/common/helper'; import { Activity, Filter, + PortfolioPerformance, WatchlistResponse } from '@ghostfolio/common/interfaces'; -import { AccountWithValue } from '@ghostfolio/common/types'; +import { AccountWithValue, DateRange } from '@ghostfolio/common/types'; import { Injectable } from '@nestjs/common'; import { AssetClass, AssetSubClass, + DataSource, Type as ActivityType } from '@prisma/client'; import { format } from 'date-fns'; +import { DataSourceTableContext } from './interfaces/data-source-table-context.interface'; import { HoldingsTableColumnDefinition } from './types/holdings-table-column-definition.type'; +const DATA_SOURCE_COLUMN_NAME = 'Data Source'; + +function getDataSourceColumnDefinition( + getDataSource: (row: T) => DataSource | undefined +): TableColumnDefinition { + return { + getValue: (row, { configurationService }) => { + const dataSource = getDataSource(row); + + return dataSource + ? transformDataSourceInResponse({ configurationService, dataSource }) + : ''; + }, + name: DATA_SOURCE_COLUMN_NAME + }; +} + function getPercentage(value: number) { return `${(value * 100).toFixed(3)}%`; } /** - * Renders the accounts, the activities and the holdings of a portfolio and the - * watchlist of its user as a markdown table. No table has a column with a - * quantity or with a monetary value, except the unit price of an activity. + * Renders the accounts, the activities, the holdings and the performance of a + * portfolio and the watchlist of its user 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 { @@ -81,52 +108,57 @@ export class PortfolioTableService { } ]; - private static readonly ACTIVITIES_TABLE_COLUMN_DEFINITIONS: TableColumnDefinition[] = - [ - { - getValue: ({ date }) => { - return format(date, DATE_FORMAT); - }, - name: 'Date' + private static readonly ACTIVITIES_TABLE_COLUMN_DEFINITIONS: TableColumnDefinition< + Activity, + DataSourceTableContext + >[] = [ + { + getValue: ({ date }) => { + return format(date, DATE_FORMAT); }, - { - getValue: ({ type }) => { - return type; - }, - name: 'Type' + name: 'Date' + }, + { + getValue: ({ type }) => { + return type; }, - { - getValue: ({ assetProfile }) => { - return assetProfile.name ?? ''; - }, - name: 'Name' + name: 'Type' + }, + { + getValue: ({ assetProfile }) => { + return assetProfile.name ?? ''; }, - { - getValue: ({ assetProfile }) => { - return assetProfile.symbol; - }, - name: 'Symbol' + name: 'Name' + }, + { + getValue: ({ assetProfile }) => { + return assetProfile.symbol; }, - { - getValue: ({ assetProfile, currency }) => { - return currency ?? assetProfile.currency; - }, - name: 'Currency' + name: 'Symbol' + }, + getDataSourceColumnDefinition(({ assetProfile }) => { + return assetProfile.dataSource; + }), + { + getValue: ({ assetProfile, currency }) => { + return currency ?? assetProfile.currency; }, - { - align: 'right', - getValue: ({ unitPrice }) => { - return unitPrice.toString(); - }, - name: 'Unit Price' + name: 'Currency' + }, + { + align: 'right', + getValue: ({ unitPrice }) => { + return unitPrice.toString(); }, - { - getValue: ({ account }) => { - return account?.name ?? ''; - }, - name: 'Account' - } - ]; + name: 'Unit Price' + }, + { + getValue: ({ account }) => { + return account?.name ?? ''; + }, + name: 'Account' + } + ]; private static readonly HOLDINGS_TABLE_COLUMN_DEFINITIONS: HoldingsTableColumnDefinition[] = [ @@ -142,6 +174,11 @@ export class PortfolioTableService { }, name: 'Symbol' }, + getDataSourceColumnDefinition(({ assetProfile }) => { + return isCashPosition(assetProfile) + ? undefined + : assetProfile.dataSource; + }), { getValue: ({ assetProfile }) => { return assetProfile.currency; @@ -184,8 +221,44 @@ export class PortfolioTableService { } ]; + private static readonly PERFORMANCE_TABLE_COLUMN_DEFINITIONS: TableColumnDefinition[] = + [ + { + align: 'right', + getValue: ({ netPerformancePercentage }) => { + return getPercentage(netPerformancePercentage); + }, + name: 'Asset Performance in Percentage' + }, + { + align: 'right', + getValue: ({ + netPerformancePercentage, + netPerformancePercentageWithCurrencyEffect + }) => { + const currencyPerformancePercentage = getPercentage( + netPerformancePercentageWithCurrencyEffect - + netPerformancePercentage + ); + + return Number.parseFloat(currencyPerformancePercentage) === 0 + ? getPercentage(0) + : currencyPerformancePercentage; + }, + name: 'Currency Performance in Percentage' + }, + { + align: 'right', + getValue: ({ netPerformancePercentageWithCurrencyEffect }) => { + return getPercentage(netPerformancePercentageWithCurrencyEffect); + }, + name: 'Net Performance in Percentage' + } + ]; + private static readonly WATCHLIST_TABLE_COLUMN_DEFINITIONS: TableColumnDefinition< - WatchlistResponse['watchlist'][number] + WatchlistResponse['watchlist'][number], + DataSourceTableContext >[] = [ { getValue: ({ name }) => { @@ -199,6 +272,9 @@ export class PortfolioTableService { }, name: 'Symbol' }, + getDataSourceColumnDefinition(({ dataSource }) => { + return dataSource; + }), { getValue: ({ trend50d }) => { return trend50d; @@ -236,6 +312,7 @@ export class PortfolioTableService { public constructor( private readonly activitiesService: ActivitiesService, + private readonly configurationService: ConfigurationService, private readonly i18nService: I18nService, private readonly portfolioService: PortfolioService, private readonly watchlistService: WatchlistService @@ -265,6 +342,14 @@ export class PortfolioTableService { ); } + public static getPerformanceTableColumnNames() { + return PortfolioTableService.PERFORMANCE_TABLE_COLUMN_DEFINITIONS.map( + ({ name }) => { + return name; + } + ); + } + public static getWatchlistTableColumnNames() { return PortfolioTableService.WATCHLIST_TABLE_COLUMN_DEFINITIONS.map( ({ name }) => { @@ -354,6 +439,7 @@ export class PortfolioTableService { await getMarkdownTable({ columnDefinitions: PortfolioTableService.ACTIVITIES_TABLE_COLUMN_DEFINITIONS, + context: { configurationService: this.configurationService }, rows: activities }) ); @@ -365,11 +451,13 @@ export class PortfolioTableService { public async getHoldingsTable({ filters, languageCode, - userId + userId, + withDataSource = false }: { filters?: Filter[]; languageCode: string; userId: string; + withDataSource?: boolean; }) { const { holdings } = await this.portfolioService.getDetails({ filters, @@ -397,13 +485,53 @@ export class PortfolioTableService { '', await getMarkdownTable({ columnDefinitions: - PortfolioTableService.HOLDINGS_TABLE_COLUMN_DEFINITIONS, - context: { assetClassTranslations, assetSubClassTranslations }, + PortfolioTableService.HOLDINGS_TABLE_COLUMN_DEFINITIONS.filter( + ({ name }) => { + return withDataSource || name !== DATA_SOURCE_COLUMN_NAME; + } + ), + context: { + assetClassTranslations, + assetSubClassTranslations, + configurationService: this.configurationService + }, rows: sortedHoldings }) ].join('\n'); } + public async getPerformanceTable({ + dateRange, + filters, + userId + }: { + dateRange: DateRange; + filters?: Filter[]; + userId: string; + }) { + const { chart, performance } = await this.portfolioService.getPerformance({ + dateRange, + filters, + userId + }); + + const performanceSection = ['## Performance', '']; + + if (chart?.length > 0) { + performanceSection.push( + await getMarkdownTable({ + columnDefinitions: + PortfolioTableService.PERFORMANCE_TABLE_COLUMN_DEFINITIONS, + rows: [performance] + }) + ); + } else { + performanceSection.push('No performance found.'); + } + + return performanceSection.join('\n'); + } + public async getWatchlistTable({ userId }: { userId: string }) { const watchlist = await this.watchlistService.getWatchlistItems(userId); @@ -414,6 +542,7 @@ export class PortfolioTableService { await getMarkdownTable({ columnDefinitions: PortfolioTableService.WATCHLIST_TABLE_COLUMN_DEFINITIONS, + context: { configurationService: this.configurationService }, rows: watchlist }) ); diff --git a/apps/api/src/services/queues/data-gathering/data-gathering.service.ts b/apps/api/src/services/queues/data-gathering/data-gathering.service.ts index 3a98757591..1162143421 100644 --- a/apps/api/src/services/queues/data-gathering/data-gathering.service.ts +++ b/apps/api/src/services/queues/data-gathering/data-gathering.service.ts @@ -1,3 +1,4 @@ +import { isDataGatheringSupported } from '@ghostfolio/api/helper/data-source.helper'; import { DataProviderService } from '@ghostfolio/api/services/data-provider/data-provider.service'; import { DataEnhancerInterface } from '@ghostfolio/api/services/data-provider/interfaces/data-enhancer.interface'; import { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-data/exchange-rate-data.service'; @@ -33,7 +34,6 @@ import { Inject, Injectable, Logger } from '@nestjs/common'; import { Prisma } from '@prisma/client'; import { Job, JobOptions, Queue } from 'bull'; import { format, min, subDays, subMilliseconds, subYears } from 'date-fns'; -import { isEmpty } from 'lodash-es'; import ms, { StringValue } from 'ms'; @Injectable() @@ -506,12 +506,7 @@ export class DataGatheringService { return symbolProfiles .filter(({ dataSource, scraperConfiguration }) => { - const manualDataSourceWithScraperConfiguration = - dataSource === 'MANUAL' && !isEmpty(scraperConfiguration); - - return ( - dataSource !== 'MANUAL' || manualDataSourceWithScraperConfiguration - ); + return isDataGatheringSupported({ dataSource, scraperConfiguration }); }) .map(({ dataSource, symbol }) => { return { dataSource, symbol }; @@ -534,14 +529,10 @@ export class DataGatheringService { return symbolProfiles .filter(({ dataSource, scraperConfiguration, symbol }) => { - const manualDataSourceWithScraperConfiguration = - dataSource === 'MANUAL' && !isEmpty(scraperConfiguration); - return ( !assetProfileIdentifiersWithRecentMarketData.some((item) => { return item.dataSource === dataSource && item.symbol === symbol; - }) && - (dataSource !== 'MANUAL' || manualDataSourceWithScraperConfiguration) + }) && isDataGatheringSupported({ dataSource, scraperConfiguration }) ); }) .map((symbolProfile) => { @@ -598,12 +589,7 @@ export class DataGatheringService { }) ) .filter(({ dataSource, scraperConfiguration }) => { - const manualDataSourceWithScraperConfiguration = - dataSource === 'MANUAL' && !isEmpty(scraperConfiguration); - - return ( - dataSource !== 'MANUAL' || manualDataSourceWithScraperConfiguration - ); + return isDataGatheringSupported({ dataSource, scraperConfiguration }); }) .map((symbolProfile) => { let date = symbolProfile.activities?.[0]?.date ?? startDate; diff --git a/apps/api/tsconfig.spec.json b/apps/api/tsconfig.spec.json index e5f982722c..63aa6d4f95 100644 --- a/apps/api/tsconfig.spec.json +++ b/apps/api/tsconfig.spec.json @@ -2,6 +2,7 @@ "extends": "./tsconfig.json", "compilerOptions": { "outDir": "../../dist/out-tsc", + "target": "es2021", "module": "preserve", "types": ["jest", "node"] }, diff --git a/apps/client/src/app/components/account-detail-dialog/account-detail-dialog.component.ts b/apps/client/src/app/components/account-detail-dialog/account-detail-dialog.component.ts index 44f7280473..c95dfe7bc4 100644 --- a/apps/client/src/app/components/account-detail-dialog/account-detail-dialog.component.ts +++ b/apps/client/src/app/components/account-detail-dialog/account-detail-dialog.component.ts @@ -10,7 +10,7 @@ import { DATE_FORMAT, downloadAsFile } from '@ghostfolio/common/helper'; import { AccountBalancesResponse, Activity, - HistoricalDataItem, + LineChartItem, PortfolioPosition, User } from '@ghostfolio/common/interfaces'; @@ -99,7 +99,7 @@ export class GfAccountDetailDialogComponent implements OnInit { protected equity: number | null; protected equityPrecision = 2; protected hasPermissionToDeleteAccountBalance: boolean; - protected historicalDataItems: HistoricalDataItem[]; + protected historicalDataItems: LineChartItem[]; protected holdings: PortfolioPosition[]; protected interestInBaseCurrency: number; protected interestInBaseCurrencyPrecision = 2; @@ -364,12 +364,16 @@ export class GfAccountDetailDialogComponent implements OnInit { portfolioPerformance.chart && portfolioPerformance.chart.length > 0 ) { - this.historicalDataItems = portfolioPerformance.chart.map( - ({ date, netWorth, netWorthInPercentage }) => ({ - date, - value: isNumber(netWorth) ? netWorth : netWorthInPercentage + this.historicalDataItems = portfolioPerformance.chart + .map(({ date, netWorth, netWorthInPercentage }) => { + return { + date, + value: isNumber(netWorth) ? netWorth : netWorthInPercentage + }; }) - ); + .filter((item): item is LineChartItem => { + return isNumber(item.value); + }); } else { this.historicalDataItems = this.accountBalances.map( ({ date, valueInBaseCurrency }) => { diff --git a/apps/client/src/app/components/home-overview/home-overview.component.ts b/apps/client/src/app/components/home-overview/home-overview.component.ts index 7715ef5719..b677f88a39 100644 --- a/apps/client/src/app/components/home-overview/home-overview.component.ts +++ b/apps/client/src/app/components/home-overview/home-overview.component.ts @@ -8,6 +8,7 @@ import { DEFAULT_LOCALE, NUMERICAL_PRECISION_THRESHOLD_6_FIGURES } from '@ghostfolio/common/config'; +import { isCashPosition } from '@ghostfolio/common/helper'; import { AssetProfileIdentifier, LineChartItem, @@ -153,6 +154,8 @@ export class GfHomeOverviewComponent implements OnInit { } private update() { + const userSettings = this.user()?.settings; + this.historicalDataItems.set(null); this.isLoadingPerformance.set(true); @@ -162,17 +165,27 @@ export class GfHomeOverviewComponent implements OnInit { this.dataService .fetchPortfolioHoldings({ filters: [{ id: 'ACTIVE', type: 'HOLDING_TYPE' }], - range: this.user()?.settings?.dateRange + range: userSettings?.dateRange }) .pipe(takeUntilDestroyed(this.destroyRef)) .subscribe(({ holdings }) => { - this.holdings.set(holdings); + const baseCurrency = userSettings?.baseCurrency ?? DEFAULT_CURRENCY; + + this.holdings.set( + holdings.filter(({ assetProfile }) => { + return !( + isCashPosition(assetProfile) && + assetProfile.currency === baseCurrency && + assetProfile.symbol === baseCurrency + ); + }) + ); }); } this.dataService .fetchPortfolioPerformance({ - range: this.user()?.settings?.dateRange ?? DEFAULT_DATE_RANGE + range: userSettings?.dateRange ?? DEFAULT_DATE_RANGE }) .pipe(takeUntilDestroyed(this.destroyRef)) .subscribe(({ chart, errors, performance }) => { diff --git a/apps/client/src/app/components/investment-chart/investment-chart.component.ts b/apps/client/src/app/components/investment-chart/investment-chart.component.ts index a1dc6a28f9..34500422ed 100644 --- a/apps/client/src/app/components/investment-chart/investment-chart.component.ts +++ b/apps/client/src/app/components/investment-chart/investment-chart.component.ts @@ -59,7 +59,7 @@ export class GfInvestmentChartComponent implements OnChanges, OnDestroy { @Input() public benchmarkDataLabel = ''; @Input() public colorScheme?: ColorScheme; @Input() public currency?: string; - @Input() public groupBy: GroupBy; + @Input() public groupBy?: GroupBy; @Input() public historicalDataItems: LineChartItem[] = []; @Input() public isInPercentage?: boolean = false; @Input() public isLoading = false; diff --git a/apps/client/src/app/components/portfolio-summary/portfolio-summary.component.ts b/apps/client/src/app/components/portfolio-summary/portfolio-summary.component.ts index 8a0d83b941..6dea74eb5d 100644 --- a/apps/client/src/app/components/portfolio-summary/portfolio-summary.component.ts +++ b/apps/client/src/app/components/portfolio-summary/portfolio-summary.component.ts @@ -39,15 +39,15 @@ import { NgxSkeletonLoaderModule } from 'ngx-skeleton-loader'; templateUrl: './portfolio-summary.component.html' }) export class GfPortfolioSummaryComponent implements OnChanges { - @Input() baseCurrency: string; - @Input() deviceType: string; - @Input() hasImpersonationId: boolean; - @Input() hasPermissionToUpdateUserSettings: boolean; - @Input() isLoading: boolean; - @Input() language: string; - @Input() locale = getLocale(); - @Input() summary: PortfolioSummary; - @Input() user: User; + @Input() public baseCurrency?: string; + @Input() public deviceType: string; + @Input() public hasImpersonationId: boolean; + @Input() public hasPermissionToUpdateUserSettings: boolean; + @Input() public isLoading: boolean; + @Input() public language?: string; + @Input() public locale?: string = getLocale(); + @Input() public summary?: PortfolioSummary; + @Input() public user?: User; public emergencyFundChanged = output(); @@ -99,11 +99,11 @@ export class GfPortfolioSummaryComponent implements OnChanges { } protected get hasCashBreakdown() { - return !this.isLoading && this.summary?.emergencyFund?.cash > 0; + return !this.isLoading && (this.summary?.emergencyFund?.cash ?? 0) > 0; } protected get hasHoldingsBreakdown() { - return !this.isLoading && this.summary?.emergencyFund?.assets > 0; + return !this.isLoading && (this.summary?.emergencyFund?.assets ?? 0) > 0; } protected get holdingsInBaseCurrency() { @@ -133,7 +133,7 @@ export class GfPortfolioSummaryComponent implements OnChanges { } return ( - this.holdingsInBaseCurrency - (this.summary.emergencyFund?.assets ?? 0) + this.holdingsInBaseCurrency - (this.summary?.emergencyFund?.assets ?? 0) ); } @@ -170,7 +170,7 @@ export class GfPortfolioSummaryComponent implements OnChanges { this.emergencyFundChanged.emit(emergencyFund); }, confirmLabel: $localize`Save`, - defaultValue: this.summary.emergencyFund?.total?.toString() ?? '0', + defaultValue: this.summary?.emergencyFund?.total?.toString() ?? '0', title: $localize`Please set the amount of your emergency fund.`, valueSuffix: this.baseCurrency }); diff --git a/apps/client/src/app/pages/features/features-page.html b/apps/client/src/app/pages/features/features-page.html index 1682ce12b2..7cc80901f8 100644 --- a/apps/client/src/app/pages/features/features-page.html +++ b/apps/client/src/app/pages/features/features-page.html @@ -137,10 +137,10 @@
-

+

Portfolio Calculations @if (hasPermissionForSubscription) { - + }

@@ -157,10 +157,10 @@

-

+

Portfolio Allocations @if (hasPermissionForSubscription) { - + }

@@ -175,10 +175,10 @@

-

+

Static Analysis @if (hasPermissionForSubscription) { - + }

@@ -193,10 +193,10 @@

-

+

Watchlist @if (hasPermissionForSubscription) { - + }

@@ -206,14 +206,39 @@

+
+ + +
+

+ + Ghostfolio MCP + @if (hasPermissionForSubscription) { + + } + + new +

+

+ Let your AI client read your portfolio and import activities + into it via the Model Context Protocol (MCP). +

+
+
+
+
@if (hasPermissionForSubscription) {
-

+

Market Mood - +

Check the current market mood (; - protected benchmarkDataItems: HistoricalDataItem[] = []; + protected benchmarkDataItems: LineChartItem[] = []; protected readonly benchmarks: Partial[]; protected bottom3: PortfolioPosition[]; protected dividendsByGroup: InvestmentItem[]; @@ -101,8 +101,8 @@ export class GfAnalysisPageComponent implements OnInit { ]; protected performance: PortfolioPerformance; protected readonly PerformanceCalculationType = PerformanceCalculationType; - protected performanceDataItems: HistoricalDataItem[]; - protected performanceDataItemsInPercentage: HistoricalDataItem[]; + protected performanceDataItems: LineChartItem[]; + protected performanceDataItemsInPercentage: LineChartItem[]; protected readonly portfolioEvolutionDataLabel = $localize`Investment`; protected precision = 2; protected savingsRatePerMonth: number | undefined; @@ -348,18 +348,24 @@ export class GfAnalysisPageComponent implements OnInit { }); } - this.performanceDataItems.push({ + const value = isNumber(valueWithCurrencyEffect) + ? valueWithCurrencyEffect + : valueInPercentage; + + if (isNumber(value)) { + this.performanceDataItems.push({ + date, + value + }); + } + } + + if (isNumber(netPerformanceInPercentageWithCurrencyEffect)) { + this.performanceDataItemsInPercentage.push({ date, - value: isNumber(valueWithCurrencyEffect) - ? valueWithCurrencyEffect - : valueInPercentage + value: netPerformanceInPercentageWithCurrencyEffect }); } - - this.performanceDataItemsInPercentage.push({ - date, - value: netPerformanceInPercentageWithCurrencyEffect - }); } if ( diff --git a/apps/client/src/app/pages/pricing/pricing-page.html b/apps/client/src/app/pages/pricing/pricing-page.html index ddd92892a6..cf3bd2448a 100644 --- a/apps/client/src/app/pages/pricing/pricing-page.html +++ b/apps/client/src/app/pages/pricing/pricing-page.html @@ -58,6 +58,10 @@ FIRE Calculator +

  • + + Ghostfolio MCP +
  • Data Import and Export @@ -215,6 +219,10 @@ FIRE Calculator
  • +
  • + + Ghostfolio MCP +
  • Professional Data Provider diff --git a/apps/client/src/app/pages/public/public-page.component.ts b/apps/client/src/app/pages/public/public-page.component.ts index 46606753e4..8fa8abde18 100644 --- a/apps/client/src/app/pages/public/public-page.component.ts +++ b/apps/client/src/app/pages/public/public-page.component.ts @@ -3,6 +3,7 @@ import { convertValuesToPercentagesOfTotal, getAssetProfileIdentifier, getCountryName, + getHoldingName, isCashPosition } from '@ghostfolio/common/helper'; import { @@ -260,7 +261,7 @@ export class GfPublicPageComponent implements OnInit { this.symbols[symbol] = { symbol, value, - name: position.assetProfile.name ?? symbol + name: getHoldingName(position.assetProfile) }; } } diff --git a/apps/client/src/locales/messages.ca.xlf b/apps/client/src/locales/messages.ca.xlf index 9c541baa59..8fa63a4dc4 100644 --- a/apps/client/src/locales/messages.ca.xlf +++ b/apps/client/src/locales/messages.ca.xlf @@ -299,7 +299,7 @@ please apps/client/src/app/pages/pricing/pricing-page.html - 333 + 341 @@ -599,7 +599,7 @@ libs/ui/src/lib/holdings-table/holdings-table.component.html - 113 + 118 libs/ui/src/lib/top-holdings/top-holdings.component.html @@ -1043,7 +1043,7 @@ libs/ui/src/lib/holdings-table/holdings-table.component.html - 64 + 69 @@ -1899,7 +1899,7 @@ apps/client/src/app/pages/pricing/pricing-page.html - 281 + 289 @@ -1915,7 +1915,7 @@ apps/client/src/app/pages/pricing/pricing-page.html - 279 + 287 @@ -1983,7 +1983,7 @@ libs/ui/src/lib/holdings-table/holdings-table.component.html - 88 + 93 @@ -2311,7 +2311,7 @@ apps/client/src/app/pages/pricing/pricing-page.html - 326 + 334 apps/client/src/app/pages/register/register-page.html @@ -2499,7 +2499,7 @@ apps/client/src/app/pages/pricing/pricing-page.html - 204 + 208 @@ -2519,7 +2519,7 @@ apps/client/src/app/pages/pricing/pricing-page.html - 208 + 212 @@ -2535,7 +2535,7 @@ apps/client/src/app/pages/pricing/pricing-page.html - 212 + 216 @@ -2551,7 +2551,7 @@ apps/client/src/app/pages/pricing/pricing-page.html - 216 + 220 @@ -2563,7 +2563,7 @@ apps/client/src/app/pages/pricing/pricing-page.html - 220 + 228 @@ -2575,11 +2575,11 @@ apps/client/src/app/pages/pricing/pricing-page.html - 75 + 79 apps/client/src/app/pages/pricing/pricing-page.html - 243 + 251 @@ -2811,7 +2811,7 @@ apps/client/src/app/pages/pricing/pricing-page.html - 265 + 273 @@ -3035,7 +3035,7 @@ apps/client/src/app/pages/features/features-page.html - 246 + 271 @@ -3792,7 +3792,7 @@ Mode fosc apps/client/src/app/pages/features/features-page.html - 233 + 258 @@ -3800,7 +3800,7 @@ Estat d’ànim del mercat apps/client/src/app/pages/features/features-page.html - 215 + 240 @@ -3816,7 +3816,7 @@ Multi-idioma apps/client/src/app/pages/features/features-page.html - 259 + 284 @@ -3832,7 +3832,7 @@ Programari de codi obert apps/client/src/app/pages/features/features-page.html - 296 + 321 @@ -3844,7 +3844,7 @@ apps/client/src/app/pages/features/features-page.html - 321 + 346 apps/client/src/app/pages/landing/landing-page.html @@ -3856,7 +3856,7 @@ apps/client/src/app/pages/pricing/pricing-page.html - 360 + 368 apps/client/src/app/pages/public/public-page.html @@ -4544,7 +4544,7 @@ with your university e-mail address apps/client/src/app/pages/pricing/pricing-page.html - 348 + 356 @@ -5152,7 +5152,7 @@ Looking for a student discount? apps/client/src/app/pages/pricing/pricing-page.html - 342 + 350 @@ -5216,7 +5216,7 @@ here apps/client/src/app/pages/pricing/pricing-page.html - 347 + 355 @@ -5436,7 +5436,7 @@ apps/client/src/app/pages/pricing/pricing-page.html - 127 + 131 @@ -5448,7 +5448,7 @@ apps/client/src/app/pages/pricing/pricing-page.html - 131 + 135 @@ -5460,7 +5460,7 @@ apps/client/src/app/pages/pricing/pricing-page.html - 135 + 139 @@ -5468,11 +5468,11 @@ Importació i exportació de dades apps/client/src/app/pages/pricing/pricing-page.html - 63 + 67 apps/client/src/app/pages/pricing/pricing-page.html - 139 + 143 @@ -5480,7 +5480,7 @@ Suport comunitari apps/client/src/app/pages/pricing/pricing-page.html - 80 + 84 @@ -5488,7 +5488,7 @@ S’allotja automàticament, actualitza manualment. apps/client/src/app/pages/pricing/pricing-page.html - 84 + 88 @@ -5496,11 +5496,11 @@ Gratuït apps/client/src/app/pages/pricing/pricing-page.html - 86 + 90 apps/client/src/app/pages/pricing/pricing-page.html - 152 + 156 @@ -5508,7 +5508,7 @@ Per a nous inversors que acaben de començar a operar. apps/client/src/app/pages/pricing/pricing-page.html - 119 + 123 @@ -5516,11 +5516,11 @@ Oferta al núvol Ghostfolio totalment gestionada. apps/client/src/app/pages/pricing/pricing-page.html - 150 + 154 apps/client/src/app/pages/pricing/pricing-page.html - 252 + 260 @@ -5528,7 +5528,7 @@ Per a inversors ambiciosos que necessiten la imatge completa dels seus actius financers. apps/client/src/app/pages/pricing/pricing-page.html - 193 + 197 @@ -5548,7 +5548,7 @@ Suport per correu electrònic i xat apps/client/src/app/pages/pricing/pricing-page.html - 248 + 256 @@ -5556,7 +5556,7 @@ Pagament únic, sense renovació automàtica. apps/client/src/app/pages/pricing/pricing-page.html - 285 + 293 @@ -5576,7 +5576,7 @@ És gratuït. apps/client/src/app/pages/pricing/pricing-page.html - 362 + 370 @@ -6033,7 +6033,7 @@ Request it apps/client/src/app/pages/pricing/pricing-page.html - 344 + 352 @@ -6329,7 +6329,7 @@ contact us apps/client/src/app/pages/pricing/pricing-page.html - 336 + 344 @@ -6409,7 +6409,7 @@ Dipòsit libs/ui/src/lib/fire-calculator/fire-calculator.component.ts - 441 + 444 @@ -6425,7 +6425,7 @@ libs/ui/src/lib/fire-calculator/fire-calculator.component.ts - 451 + 454 libs/ui/src/lib/i18n.ts @@ -6437,7 +6437,7 @@ Estalvi libs/ui/src/lib/fire-calculator/fire-calculator.component.ts - 461 + 464 @@ -6449,11 +6449,11 @@ libs/ui/src/lib/holdings-table/holdings-table.component.html - 138 + 143 libs/ui/src/lib/holdings-table/holdings-table.component.html - 139 + 144 libs/ui/src/lib/top-holdings/top-holdings.component.html @@ -6473,7 +6473,7 @@ Mostra-ho tot libs/ui/src/lib/holdings-table/holdings-table.component.html - 243 + 248 @@ -6721,7 +6721,7 @@ Everything in Basic, plus apps/client/src/app/pages/pricing/pricing-page.html - 199 + 203 @@ -6761,8 +6761,8 @@ - Export - Export + Export + Export libs/ui/src/lib/activities-table/activities-table.component.html 72 @@ -7165,7 +7165,7 @@ apps/client/src/app/pages/features/features-page.html - 277 + 302 apps/client/src/app/pages/resources/personal-finance-tools/product-page.component.ts @@ -7509,7 +7509,7 @@ If you plan to open an account at apps/client/src/app/pages/pricing/pricing-page.html - 312 + 320 @@ -7641,7 +7641,7 @@ to use our referral link and get a Ghostfolio Premium membership for one year apps/client/src/app/pages/pricing/pricing-page.html - 340 + 348 @@ -8199,11 +8199,11 @@ Change libs/ui/src/lib/holdings-table/holdings-table.component.html - 160 + 165 libs/ui/src/lib/treemap-chart/treemap-chart.component.ts - 421 + 423 @@ -8231,23 +8231,23 @@ apps/client/src/app/components/home-overview/home-overview.component.ts - 64 + 65 libs/ui/src/lib/holdings-table/holdings-table.component.html - 185 + 190 libs/ui/src/lib/holdings-table/holdings-table.component.html - 186 + 191 libs/ui/src/lib/treemap-chart/treemap-chart.component.ts - 421 + 423 libs/ui/src/lib/treemap-chart/treemap-chart.component.ts - 434 + 436 @@ -8428,7 +8428,7 @@ with API access for apps/client/src/app/pages/pricing/pricing-page.html - 235 + 243 @@ -8476,7 +8476,7 @@ someone apps/client/src/app/pages/public/public-page.component.ts - 68 + 69 @@ -8791,7 +8791,7 @@ apps/client/src/app/pages/pricing/pricing-page.html - 294 + 302 @@ -8803,7 +8803,7 @@ apps/client/src/app/pages/pricing/pricing-page.html - 297 + 305 @@ -8830,6 +8830,14 @@ 265 + + new + new + + apps/client/src/app/pages/features/features-page.html + 223 + + Investment Investment diff --git a/apps/client/src/locales/messages.de.xlf b/apps/client/src/locales/messages.de.xlf index ac45a37c37..fabe62f3f2 100644 --- a/apps/client/src/locales/messages.de.xlf +++ b/apps/client/src/locales/messages.de.xlf @@ -42,7 +42,7 @@ bitte apps/client/src/app/pages/pricing/pricing-page.html - 333 + 341 @@ -230,7 +230,7 @@ libs/ui/src/lib/holdings-table/holdings-table.component.html - 113 + 118 libs/ui/src/lib/top-holdings/top-holdings.component.html @@ -570,7 +570,7 @@ libs/ui/src/lib/holdings-table/holdings-table.component.html - 64 + 69 @@ -818,7 +818,7 @@ apps/client/src/app/pages/features/features-page.html - 321 + 346 apps/client/src/app/pages/landing/landing-page.html @@ -830,7 +830,7 @@ apps/client/src/app/pages/pricing/pricing-page.html - 360 + 368 apps/client/src/app/pages/public/public-page.html @@ -994,7 +994,7 @@ apps/client/src/app/pages/pricing/pricing-page.html - 326 + 334 apps/client/src/app/pages/register/register-page.html @@ -1178,11 +1178,11 @@ libs/ui/src/lib/holdings-table/holdings-table.component.html - 138 + 143 libs/ui/src/lib/holdings-table/holdings-table.component.html - 139 + 144 libs/ui/src/lib/top-holdings/top-holdings.component.html @@ -1202,7 +1202,7 @@ Alle anzeigen libs/ui/src/lib/holdings-table/holdings-table.component.html - 243 + 248 @@ -1402,7 +1402,7 @@ apps/client/src/app/pages/pricing/pricing-page.html - 265 + 273 @@ -1482,7 +1482,7 @@ apps/client/src/app/pages/features/features-page.html - 246 + 271 @@ -2258,7 +2258,7 @@ libs/ui/src/lib/holdings-table/holdings-table.component.html - 88 + 93 @@ -2626,7 +2626,7 @@ kontaktiere uns apps/client/src/app/pages/pricing/pricing-page.html - 336 + 344 @@ -2826,7 +2826,7 @@ Einlage libs/ui/src/lib/fire-calculator/fire-calculator.component.ts - 441 + 444 @@ -2842,7 +2842,7 @@ libs/ui/src/lib/fire-calculator/fire-calculator.component.ts - 451 + 454 libs/ui/src/lib/i18n.ts @@ -2854,7 +2854,7 @@ Ersparnisse libs/ui/src/lib/fire-calculator/fire-calculator.component.ts - 461 + 464 @@ -3174,8 +3174,8 @@ - Export - exportieren + Export + exportieren libs/ui/src/lib/activities-table/activities-table.component.html 72 @@ -3482,7 +3482,7 @@ Suchst du nach einem Studentenrabatt? apps/client/src/app/pages/pricing/pricing-page.html - 342 + 350 @@ -3758,7 +3758,7 @@ Alles von Basic, plus apps/client/src/app/pages/pricing/pricing-page.html - 199 + 203 @@ -3822,7 +3822,7 @@ apps/client/src/app/pages/pricing/pricing-page.html - 204 + 208 @@ -3838,7 +3838,7 @@ apps/client/src/app/pages/pricing/pricing-page.html - 212 + 216 @@ -3854,7 +3854,7 @@ apps/client/src/app/pages/pricing/pricing-page.html - 216 + 220 @@ -3866,11 +3866,11 @@ apps/client/src/app/pages/pricing/pricing-page.html - 75 + 79 apps/client/src/app/pages/pricing/pricing-page.html - 243 + 251 @@ -3918,7 +3918,7 @@ apps/client/src/app/pages/pricing/pricing-page.html - 281 + 289 @@ -3946,7 +3946,7 @@ apps/client/src/app/pages/pricing/pricing-page.html - 127 + 131 @@ -3958,7 +3958,7 @@ apps/client/src/app/pages/pricing/pricing-page.html - 131 + 135 @@ -3970,7 +3970,7 @@ apps/client/src/app/pages/pricing/pricing-page.html - 135 + 139 @@ -3978,7 +3978,7 @@ Selbst gehostet, manuelles Update. apps/client/src/app/pages/pricing/pricing-page.html - 84 + 88 @@ -3986,11 +3986,11 @@ Kostenlos apps/client/src/app/pages/pricing/pricing-page.html - 86 + 90 apps/client/src/app/pages/pricing/pricing-page.html - 152 + 156 @@ -3998,7 +3998,7 @@ Für Einsteiger, die gerade mit dem Börsenhandel beginnen. apps/client/src/app/pages/pricing/pricing-page.html - 119 + 123 @@ -4006,11 +4006,11 @@ Vollständig verwaltetes Ghostfolio Cloud-Angebot. apps/client/src/app/pages/pricing/pricing-page.html - 150 + 154 apps/client/src/app/pages/pricing/pricing-page.html - 252 + 260 @@ -4018,7 +4018,7 @@ Für ambitionierte Anleger, die den vollständigen Überblick über ihr Anlagevermögen benötigen. apps/client/src/app/pages/pricing/pricing-page.html - 193 + 197 @@ -4038,7 +4038,7 @@ Einmalige Zahlung, keine automatische Erneuerung. apps/client/src/app/pages/pricing/pricing-page.html - 285 + 293 @@ -4058,7 +4058,7 @@ Es ist kostenlos. apps/client/src/app/pages/pricing/pricing-page.html - 362 + 370 @@ -4090,7 +4090,7 @@ apps/client/src/app/pages/pricing/pricing-page.html - 208 + 212 @@ -4106,11 +4106,11 @@ Datenimport und -export apps/client/src/app/pages/pricing/pricing-page.html - 63 + 67 apps/client/src/app/pages/pricing/pricing-page.html - 139 + 143 @@ -4126,7 +4126,7 @@ Community Support apps/client/src/app/pages/pricing/pricing-page.html - 80 + 84 @@ -4134,7 +4134,7 @@ E-Mail und Chat Support apps/client/src/app/pages/pricing/pricing-page.html - 248 + 256 @@ -4194,7 +4194,7 @@ apps/client/src/app/pages/pricing/pricing-page.html - 220 + 228 @@ -4218,7 +4218,7 @@ apps/client/src/app/pages/pricing/pricing-page.html - 279 + 287 @@ -4646,7 +4646,7 @@ Dark Mode apps/client/src/app/pages/features/features-page.html - 233 + 258 @@ -4654,7 +4654,7 @@ Marktstimmung apps/client/src/app/pages/features/features-page.html - 215 + 240 @@ -4670,7 +4670,7 @@ Mehrsprachigkeit apps/client/src/app/pages/features/features-page.html - 259 + 284 @@ -4686,7 +4686,7 @@ Open Source Software apps/client/src/app/pages/features/features-page.html - 296 + 321 @@ -5972,7 +5972,7 @@ mit deiner Universitäts-E-Mail-Adresse apps/client/src/app/pages/pricing/pricing-page.html - 348 + 356 @@ -6168,7 +6168,7 @@ Fordere ihn an apps/client/src/app/pages/pricing/pricing-page.html - 344 + 352 @@ -6616,7 +6616,7 @@ hier apps/client/src/app/pages/pricing/pricing-page.html - 347 + 355 @@ -7189,7 +7189,7 @@ apps/client/src/app/pages/features/features-page.html - 277 + 302 apps/client/src/app/pages/resources/personal-finance-tools/product-page.component.ts @@ -7533,7 +7533,7 @@ Wenn du die Eröffnung eines Kontos planst bei apps/client/src/app/pages/pricing/pricing-page.html - 312 + 320 @@ -7665,7 +7665,7 @@ um unseren Empfehlungslink zu verwenden und eine Ghostfolio Premium-Mitgliedschaft für ein Jahr zu erhalten apps/client/src/app/pages/pricing/pricing-page.html - 340 + 348 @@ -8223,11 +8223,11 @@ Änderung libs/ui/src/lib/holdings-table/holdings-table.component.html - 160 + 165 libs/ui/src/lib/treemap-chart/treemap-chart.component.ts - 421 + 423 @@ -8255,23 +8255,23 @@ apps/client/src/app/components/home-overview/home-overview.component.ts - 64 + 65 libs/ui/src/lib/holdings-table/holdings-table.component.html - 185 + 190 libs/ui/src/lib/holdings-table/holdings-table.component.html - 186 + 191 libs/ui/src/lib/treemap-chart/treemap-chart.component.ts - 421 + 423 libs/ui/src/lib/treemap-chart/treemap-chart.component.ts - 434 + 436 @@ -8452,7 +8452,7 @@ inklusive API-Zugriff für apps/client/src/app/pages/pricing/pricing-page.html - 235 + 243 @@ -8476,7 +8476,7 @@ jemand apps/client/src/app/pages/public/public-page.component.ts - 68 + 69 @@ -8791,7 +8791,7 @@ apps/client/src/app/pages/pricing/pricing-page.html - 294 + 302 @@ -8803,7 +8803,7 @@ apps/client/src/app/pages/pricing/pricing-page.html - 297 + 305 @@ -8830,6 +8830,14 @@ 265 + + new + neu + + apps/client/src/app/pages/features/features-page.html + 223 + + Investment Investition diff --git a/apps/client/src/locales/messages.es.xlf b/apps/client/src/locales/messages.es.xlf index b7a2182d07..5ca862e626 100644 --- a/apps/client/src/locales/messages.es.xlf +++ b/apps/client/src/locales/messages.es.xlf @@ -43,7 +43,7 @@ por favor apps/client/src/app/pages/pricing/pricing-page.html - 333 + 341 @@ -231,7 +231,7 @@ libs/ui/src/lib/holdings-table/holdings-table.component.html - 113 + 118 libs/ui/src/lib/top-holdings/top-holdings.component.html @@ -571,7 +571,7 @@ libs/ui/src/lib/holdings-table/holdings-table.component.html - 64 + 69 @@ -803,7 +803,7 @@ apps/client/src/app/pages/features/features-page.html - 321 + 346 apps/client/src/app/pages/landing/landing-page.html @@ -815,7 +815,7 @@ apps/client/src/app/pages/pricing/pricing-page.html - 360 + 368 apps/client/src/app/pages/public/public-page.html @@ -979,7 +979,7 @@ apps/client/src/app/pages/pricing/pricing-page.html - 326 + 334 apps/client/src/app/pages/register/register-page.html @@ -1163,11 +1163,11 @@ libs/ui/src/lib/holdings-table/holdings-table.component.html - 138 + 143 libs/ui/src/lib/holdings-table/holdings-table.component.html - 139 + 144 libs/ui/src/lib/top-holdings/top-holdings.component.html @@ -1187,7 +1187,7 @@ Mostrar todos libs/ui/src/lib/holdings-table/holdings-table.component.html - 243 + 248 @@ -1387,7 +1387,7 @@ apps/client/src/app/pages/pricing/pricing-page.html - 265 + 273 @@ -1467,7 +1467,7 @@ apps/client/src/app/pages/features/features-page.html - 246 + 271 @@ -2243,7 +2243,7 @@ libs/ui/src/lib/holdings-table/holdings-table.component.html - 88 + 93 @@ -2611,7 +2611,7 @@ contáctanos apps/client/src/app/pages/pricing/pricing-page.html - 336 + 344 @@ -2803,7 +2803,7 @@ Ahorros libs/ui/src/lib/fire-calculator/fire-calculator.component.ts - 461 + 464 @@ -2819,7 +2819,7 @@ libs/ui/src/lib/fire-calculator/fire-calculator.component.ts - 451 + 454 libs/ui/src/lib/i18n.ts @@ -2831,7 +2831,7 @@ Depósito libs/ui/src/lib/fire-calculator/fire-calculator.component.ts - 441 + 444 @@ -3159,8 +3159,8 @@ - Export - Exportar + Export + Exportar libs/ui/src/lib/activities-table/activities-table.component.html 72 @@ -3459,7 +3459,7 @@ ¿Buscas un descuento para estudiantes? apps/client/src/app/pages/pricing/pricing-page.html - 342 + 350 @@ -3743,7 +3743,7 @@ Todo lo incluido en Basic, más apps/client/src/app/pages/pricing/pricing-page.html - 199 + 203 @@ -3807,7 +3807,7 @@ apps/client/src/app/pages/pricing/pricing-page.html - 204 + 208 @@ -3823,7 +3823,7 @@ apps/client/src/app/pages/pricing/pricing-page.html - 212 + 216 @@ -3839,7 +3839,7 @@ apps/client/src/app/pages/pricing/pricing-page.html - 216 + 220 @@ -3851,11 +3851,11 @@ apps/client/src/app/pages/pricing/pricing-page.html - 75 + 79 apps/client/src/app/pages/pricing/pricing-page.html - 243 + 251 @@ -3903,7 +3903,7 @@ apps/client/src/app/pages/pricing/pricing-page.html - 281 + 289 @@ -3931,7 +3931,7 @@ apps/client/src/app/pages/pricing/pricing-page.html - 127 + 131 @@ -3943,7 +3943,7 @@ apps/client/src/app/pages/pricing/pricing-page.html - 131 + 135 @@ -3955,7 +3955,7 @@ apps/client/src/app/pages/pricing/pricing-page.html - 135 + 139 @@ -3963,7 +3963,7 @@ Autoalojado, actualiza manualmente. apps/client/src/app/pages/pricing/pricing-page.html - 84 + 88 @@ -3971,11 +3971,11 @@ Gratis apps/client/src/app/pages/pricing/pricing-page.html - 86 + 90 apps/client/src/app/pages/pricing/pricing-page.html - 152 + 156 @@ -3983,7 +3983,7 @@ Para nuevos inversores que están empezando con el trading. apps/client/src/app/pages/pricing/pricing-page.html - 119 + 123 @@ -3991,11 +3991,11 @@ Oferta de Ghostfolio en la nube totalmente gestionada. apps/client/src/app/pages/pricing/pricing-page.html - 150 + 154 apps/client/src/app/pages/pricing/pricing-page.html - 252 + 260 @@ -4003,7 +4003,7 @@ Para inversores ambiciosos que necesitan una visión completa de sus activos financieros. apps/client/src/app/pages/pricing/pricing-page.html - 193 + 197 @@ -4023,7 +4023,7 @@ Pago único, sin renovación automática. apps/client/src/app/pages/pricing/pricing-page.html - 285 + 293 @@ -4043,7 +4043,7 @@ Es gratis. apps/client/src/app/pages/pricing/pricing-page.html - 362 + 370 @@ -4075,7 +4075,7 @@ apps/client/src/app/pages/pricing/pricing-page.html - 208 + 212 @@ -4091,11 +4091,11 @@ Importación y exportación de datos apps/client/src/app/pages/pricing/pricing-page.html - 63 + 67 apps/client/src/app/pages/pricing/pricing-page.html - 139 + 143 @@ -4111,7 +4111,7 @@ Soporte de la comunidad apps/client/src/app/pages/pricing/pricing-page.html - 80 + 84 @@ -4119,7 +4119,7 @@ Soporte a través de correo electrónico y chat apps/client/src/app/pages/pricing/pricing-page.html - 248 + 256 @@ -4171,7 +4171,7 @@ apps/client/src/app/pages/pricing/pricing-page.html - 220 + 228 @@ -4195,7 +4195,7 @@ apps/client/src/app/pages/pricing/pricing-page.html - 279 + 287 @@ -4623,7 +4623,7 @@ Modo oscuro apps/client/src/app/pages/features/features-page.html - 233 + 258 @@ -4631,7 +4631,7 @@ Sentimiento del mercado apps/client/src/app/pages/features/features-page.html - 215 + 240 @@ -4647,7 +4647,7 @@ Multilingüe apps/client/src/app/pages/features/features-page.html - 259 + 284 @@ -4663,7 +4663,7 @@ Software de código abierto apps/client/src/app/pages/features/features-page.html - 296 + 321 @@ -5949,7 +5949,7 @@ con tu dirección de correo electrónico universitaria apps/client/src/app/pages/pricing/pricing-page.html - 348 + 356 @@ -6145,7 +6145,7 @@ Solicítalo apps/client/src/app/pages/pricing/pricing-page.html - 344 + 352 @@ -6593,7 +6593,7 @@ aquí apps/client/src/app/pages/pricing/pricing-page.html - 347 + 355 @@ -7166,7 +7166,7 @@ apps/client/src/app/pages/features/features-page.html - 277 + 302 apps/client/src/app/pages/resources/personal-finance-tools/product-page.component.ts @@ -7510,7 +7510,7 @@ Si planeas abrir una cuenta en apps/client/src/app/pages/pricing/pricing-page.html - 312 + 320 @@ -7642,7 +7642,7 @@ para utilizar nuestro enlace de recomendación y obtener una membresía de Ghostfolio Premium por un año apps/client/src/app/pages/pricing/pricing-page.html - 340 + 348 @@ -8200,11 +8200,11 @@ Cambio libs/ui/src/lib/holdings-table/holdings-table.component.html - 160 + 165 libs/ui/src/lib/treemap-chart/treemap-chart.component.ts - 421 + 423 @@ -8232,23 +8232,23 @@ apps/client/src/app/components/home-overview/home-overview.component.ts - 64 + 65 libs/ui/src/lib/holdings-table/holdings-table.component.html - 185 + 190 libs/ui/src/lib/holdings-table/holdings-table.component.html - 186 + 191 libs/ui/src/lib/treemap-chart/treemap-chart.component.ts - 421 + 423 libs/ui/src/lib/treemap-chart/treemap-chart.component.ts - 434 + 436 @@ -8429,7 +8429,7 @@ con acceso a la API para apps/client/src/app/pages/pricing/pricing-page.html - 235 + 243 @@ -8477,7 +8477,7 @@ alguien apps/client/src/app/pages/public/public-page.component.ts - 68 + 69 @@ -8792,7 +8792,7 @@ apps/client/src/app/pages/pricing/pricing-page.html - 294 + 302 @@ -8804,7 +8804,7 @@ apps/client/src/app/pages/pricing/pricing-page.html - 297 + 305 @@ -8831,6 +8831,14 @@ 265 + + new + new + + apps/client/src/app/pages/features/features-page.html + 223 + + Investment Inversión diff --git a/apps/client/src/locales/messages.fr.xlf b/apps/client/src/locales/messages.fr.xlf index 9c0cfa5ec9..2a01a5d5dc 100644 --- a/apps/client/src/locales/messages.fr.xlf +++ b/apps/client/src/locales/messages.fr.xlf @@ -34,7 +34,7 @@ please apps/client/src/app/pages/pricing/pricing-page.html - 333 + 341 @@ -294,7 +294,7 @@ libs/ui/src/lib/holdings-table/holdings-table.component.html - 113 + 118 libs/ui/src/lib/top-holdings/top-holdings.component.html @@ -642,7 +642,7 @@ libs/ui/src/lib/holdings-table/holdings-table.component.html - 64 + 69 @@ -1310,7 +1310,7 @@ apps/client/src/app/pages/pricing/pricing-page.html - 326 + 334 apps/client/src/app/pages/register/register-page.html @@ -1470,7 +1470,7 @@ libs/ui/src/lib/holdings-table/holdings-table.component.html - 88 + 93 @@ -1726,7 +1726,7 @@ apps/client/src/app/pages/pricing/pricing-page.html - 265 + 273 @@ -1866,7 +1866,7 @@ apps/client/src/app/pages/features/features-page.html - 246 + 271 @@ -2742,7 +2742,7 @@ Vous cherchez une réduction étudiant ? apps/client/src/app/pages/pricing/pricing-page.html - 342 + 350 @@ -2794,7 +2794,7 @@ Dépôt libs/ui/src/lib/fire-calculator/fire-calculator.component.ts - 441 + 444 @@ -2934,7 +2934,7 @@ apps/client/src/app/pages/features/features-page.html - 321 + 346 apps/client/src/app/pages/landing/landing-page.html @@ -2946,7 +2946,7 @@ apps/client/src/app/pages/pricing/pricing-page.html - 360 + 368 apps/client/src/app/pages/public/public-page.html @@ -3202,7 +3202,7 @@ contact us apps/client/src/app/pages/pricing/pricing-page.html - 336 + 344 @@ -3250,7 +3250,7 @@ libs/ui/src/lib/fire-calculator/fire-calculator.component.ts - 451 + 454 libs/ui/src/lib/i18n.ts @@ -3262,7 +3262,7 @@ Épargne libs/ui/src/lib/fire-calculator/fire-calculator.component.ts - 461 + 464 @@ -3274,11 +3274,11 @@ libs/ui/src/lib/holdings-table/holdings-table.component.html - 138 + 143 libs/ui/src/lib/holdings-table/holdings-table.component.html - 139 + 144 libs/ui/src/lib/top-holdings/top-holdings.component.html @@ -3298,7 +3298,7 @@ Montrer tout libs/ui/src/lib/holdings-table/holdings-table.component.html - 243 + 248 @@ -3450,8 +3450,8 @@ - Export - Export + Export + Export libs/ui/src/lib/activities-table/activities-table.component.html 72 @@ -3742,7 +3742,7 @@ Tout ce qui est inclus dans Basic, plus apps/client/src/app/pages/pricing/pricing-page.html - 199 + 203 @@ -3806,7 +3806,7 @@ apps/client/src/app/pages/pricing/pricing-page.html - 204 + 208 @@ -3822,7 +3822,7 @@ apps/client/src/app/pages/pricing/pricing-page.html - 212 + 216 @@ -3838,7 +3838,7 @@ apps/client/src/app/pages/pricing/pricing-page.html - 216 + 220 @@ -3850,11 +3850,11 @@ apps/client/src/app/pages/pricing/pricing-page.html - 75 + 79 apps/client/src/app/pages/pricing/pricing-page.html - 243 + 251 @@ -3902,7 +3902,7 @@ apps/client/src/app/pages/pricing/pricing-page.html - 281 + 289 @@ -3930,7 +3930,7 @@ apps/client/src/app/pages/pricing/pricing-page.html - 127 + 131 @@ -3942,7 +3942,7 @@ apps/client/src/app/pages/pricing/pricing-page.html - 131 + 135 @@ -3954,7 +3954,7 @@ apps/client/src/app/pages/pricing/pricing-page.html - 135 + 139 @@ -3962,7 +3962,7 @@ Hébergé localement, mises à jour manuelles. apps/client/src/app/pages/pricing/pricing-page.html - 84 + 88 @@ -3970,11 +3970,11 @@ Gratuit apps/client/src/app/pages/pricing/pricing-page.html - 86 + 90 apps/client/src/app/pages/pricing/pricing-page.html - 152 + 156 @@ -3982,7 +3982,7 @@ Pour les nouveaux investisseurs qui débutent en Bourse. apps/client/src/app/pages/pricing/pricing-page.html - 119 + 123 @@ -3990,11 +3990,11 @@ Offre Ghostfolio cloud complètement administrée. apps/client/src/app/pages/pricing/pricing-page.html - 150 + 154 apps/client/src/app/pages/pricing/pricing-page.html - 252 + 260 @@ -4002,7 +4002,7 @@ Pour les investisseurs ambitieux qui ont besoin d’une vue complète de leurs actifs financiers. apps/client/src/app/pages/pricing/pricing-page.html - 193 + 197 @@ -4022,7 +4022,7 @@ Paiement unique, sans auto-renouvellement. apps/client/src/app/pages/pricing/pricing-page.html - 285 + 293 @@ -4042,7 +4042,7 @@ C’est gratuit. apps/client/src/app/pages/pricing/pricing-page.html - 362 + 370 @@ -4074,7 +4074,7 @@ apps/client/src/app/pages/pricing/pricing-page.html - 208 + 212 @@ -4090,11 +4090,11 @@ Import et Export de Données apps/client/src/app/pages/pricing/pricing-page.html - 63 + 67 apps/client/src/app/pages/pricing/pricing-page.html - 139 + 143 @@ -4110,7 +4110,7 @@ Support de la Communauté apps/client/src/app/pages/pricing/pricing-page.html - 80 + 84 @@ -4118,7 +4118,7 @@ Support par E-mail et Tchat apps/client/src/app/pages/pricing/pricing-page.html - 248 + 256 @@ -4170,7 +4170,7 @@ apps/client/src/app/pages/pricing/pricing-page.html - 220 + 228 @@ -4194,7 +4194,7 @@ apps/client/src/app/pages/pricing/pricing-page.html - 279 + 287 @@ -4622,7 +4622,7 @@ Mode Sombre apps/client/src/app/pages/features/features-page.html - 233 + 258 @@ -4630,7 +4630,7 @@ Sentiment du marché apps/client/src/app/pages/features/features-page.html - 215 + 240 @@ -4646,7 +4646,7 @@ Multi-Langue apps/client/src/app/pages/features/features-page.html - 259 + 284 @@ -4662,7 +4662,7 @@ Logiciel Open Source apps/client/src/app/pages/features/features-page.html - 296 + 321 @@ -5948,7 +5948,7 @@ avec votre adresse e-mail universitaire apps/client/src/app/pages/pricing/pricing-page.html - 348 + 356 @@ -6144,7 +6144,7 @@ Faites-en la demande apps/client/src/app/pages/pricing/pricing-page.html - 344 + 352 @@ -6592,7 +6592,7 @@ ici apps/client/src/app/pages/pricing/pricing-page.html - 347 + 355 @@ -7165,7 +7165,7 @@ apps/client/src/app/pages/features/features-page.html - 277 + 302 apps/client/src/app/pages/resources/personal-finance-tools/product-page.component.ts @@ -7509,7 +7509,7 @@ Si vous prévoyez d’ouvrir un compte chez apps/client/src/app/pages/pricing/pricing-page.html - 312 + 320 @@ -7641,7 +7641,7 @@ to use our referral link and get a Ghostfolio Premium membership for one year apps/client/src/app/pages/pricing/pricing-page.html - 340 + 348 @@ -8199,11 +8199,11 @@ Variation libs/ui/src/lib/holdings-table/holdings-table.component.html - 160 + 165 libs/ui/src/lib/treemap-chart/treemap-chart.component.ts - 421 + 423 @@ -8231,23 +8231,23 @@ apps/client/src/app/components/home-overview/home-overview.component.ts - 64 + 65 libs/ui/src/lib/holdings-table/holdings-table.component.html - 185 + 190 libs/ui/src/lib/holdings-table/holdings-table.component.html - 186 + 191 libs/ui/src/lib/treemap-chart/treemap-chart.component.ts - 421 + 423 libs/ui/src/lib/treemap-chart/treemap-chart.component.ts - 434 + 436 @@ -8428,7 +8428,7 @@ avec accès API pour apps/client/src/app/pages/pricing/pricing-page.html - 235 + 243 @@ -8476,7 +8476,7 @@ quelqu’un apps/client/src/app/pages/public/public-page.component.ts - 68 + 69 @@ -8791,7 +8791,7 @@ apps/client/src/app/pages/pricing/pricing-page.html - 294 + 302 @@ -8803,7 +8803,7 @@ apps/client/src/app/pages/pricing/pricing-page.html - 297 + 305 @@ -8830,6 +8830,14 @@ 265 + + new + new + + apps/client/src/app/pages/features/features-page.html + 223 + + Investment Investissement diff --git a/apps/client/src/locales/messages.it.xlf b/apps/client/src/locales/messages.it.xlf index 63da41186c..5026711ec5 100644 --- a/apps/client/src/locales/messages.it.xlf +++ b/apps/client/src/locales/messages.it.xlf @@ -43,7 +43,7 @@ per favore apps/client/src/app/pages/pricing/pricing-page.html - 333 + 341 @@ -231,7 +231,7 @@ libs/ui/src/lib/holdings-table/holdings-table.component.html - 113 + 118 libs/ui/src/lib/top-holdings/top-holdings.component.html @@ -571,7 +571,7 @@ libs/ui/src/lib/holdings-table/holdings-table.component.html - 64 + 69 @@ -803,7 +803,7 @@ apps/client/src/app/pages/features/features-page.html - 321 + 346 apps/client/src/app/pages/landing/landing-page.html @@ -815,7 +815,7 @@ apps/client/src/app/pages/pricing/pricing-page.html - 360 + 368 apps/client/src/app/pages/public/public-page.html @@ -979,7 +979,7 @@ apps/client/src/app/pages/pricing/pricing-page.html - 326 + 334 apps/client/src/app/pages/register/register-page.html @@ -1163,11 +1163,11 @@ libs/ui/src/lib/holdings-table/holdings-table.component.html - 138 + 143 libs/ui/src/lib/holdings-table/holdings-table.component.html - 139 + 144 libs/ui/src/lib/top-holdings/top-holdings.component.html @@ -1187,7 +1187,7 @@ Mostra tutti libs/ui/src/lib/holdings-table/holdings-table.component.html - 243 + 248 @@ -1387,7 +1387,7 @@ apps/client/src/app/pages/pricing/pricing-page.html - 265 + 273 @@ -1467,7 +1467,7 @@ apps/client/src/app/pages/features/features-page.html - 246 + 271 @@ -2243,7 +2243,7 @@ libs/ui/src/lib/holdings-table/holdings-table.component.html - 88 + 93 @@ -2611,7 +2611,7 @@ contact us apps/client/src/app/pages/pricing/pricing-page.html - 336 + 344 @@ -2803,7 +2803,7 @@ Risparmio libs/ui/src/lib/fire-calculator/fire-calculator.component.ts - 461 + 464 @@ -2819,7 +2819,7 @@ libs/ui/src/lib/fire-calculator/fire-calculator.component.ts - 451 + 454 libs/ui/src/lib/i18n.ts @@ -2831,7 +2831,7 @@ Deposito libs/ui/src/lib/fire-calculator/fire-calculator.component.ts - 441 + 444 @@ -3159,8 +3159,8 @@ - Export - Export + Export + Export libs/ui/src/lib/activities-table/activities-table.component.html 72 @@ -3459,7 +3459,7 @@ Looking for a student discount? apps/client/src/app/pages/pricing/pricing-page.html - 342 + 350 @@ -3743,7 +3743,7 @@ Everything in Basic, plus apps/client/src/app/pages/pricing/pricing-page.html - 199 + 203 @@ -3807,7 +3807,7 @@ apps/client/src/app/pages/pricing/pricing-page.html - 204 + 208 @@ -3823,7 +3823,7 @@ apps/client/src/app/pages/pricing/pricing-page.html - 212 + 216 @@ -3839,7 +3839,7 @@ apps/client/src/app/pages/pricing/pricing-page.html - 216 + 220 @@ -3851,11 +3851,11 @@ apps/client/src/app/pages/pricing/pricing-page.html - 75 + 79 apps/client/src/app/pages/pricing/pricing-page.html - 243 + 251 @@ -3903,7 +3903,7 @@ apps/client/src/app/pages/pricing/pricing-page.html - 281 + 289 @@ -3931,7 +3931,7 @@ apps/client/src/app/pages/pricing/pricing-page.html - 127 + 131 @@ -3943,7 +3943,7 @@ apps/client/src/app/pages/pricing/pricing-page.html - 131 + 135 @@ -3955,7 +3955,7 @@ apps/client/src/app/pages/pricing/pricing-page.html - 135 + 139 @@ -3963,7 +3963,7 @@ Self-hosted, aggiornamento manuale. apps/client/src/app/pages/pricing/pricing-page.html - 84 + 88 @@ -3971,11 +3971,11 @@ Free apps/client/src/app/pages/pricing/pricing-page.html - 86 + 90 apps/client/src/app/pages/pricing/pricing-page.html - 152 + 156 @@ -3983,7 +3983,7 @@ Per i nuovi investitori che hanno appena iniziato a fare trading. apps/client/src/app/pages/pricing/pricing-page.html - 119 + 123 @@ -3991,11 +3991,11 @@ Offerta cloud Ghostfolio completamente gestita. apps/client/src/app/pages/pricing/pricing-page.html - 150 + 154 apps/client/src/app/pages/pricing/pricing-page.html - 252 + 260 @@ -4003,7 +4003,7 @@ Per gli investitori ambiziosi che hanno bisogno di un quadro completo dei propri asset finanziari. apps/client/src/app/pages/pricing/pricing-page.html - 193 + 197 @@ -4023,7 +4023,7 @@ Pagamento una tantum, senza rinnovo automatico. apps/client/src/app/pages/pricing/pricing-page.html - 285 + 293 @@ -4043,7 +4043,7 @@ È gratuito. apps/client/src/app/pages/pricing/pricing-page.html - 362 + 370 @@ -4075,7 +4075,7 @@ apps/client/src/app/pages/pricing/pricing-page.html - 208 + 212 @@ -4091,11 +4091,11 @@ Importazione ed esportazione dei dati apps/client/src/app/pages/pricing/pricing-page.html - 63 + 67 apps/client/src/app/pages/pricing/pricing-page.html - 139 + 143 @@ -4111,7 +4111,7 @@ Supporto della comunità apps/client/src/app/pages/pricing/pricing-page.html - 80 + 84 @@ -4119,7 +4119,7 @@ Supporto via email e chat apps/client/src/app/pages/pricing/pricing-page.html - 248 + 256 @@ -4171,7 +4171,7 @@ apps/client/src/app/pages/pricing/pricing-page.html - 220 + 228 @@ -4195,7 +4195,7 @@ apps/client/src/app/pages/pricing/pricing-page.html - 279 + 287 @@ -4623,7 +4623,7 @@ Modalità scura apps/client/src/app/pages/features/features-page.html - 233 + 258 @@ -4631,7 +4631,7 @@ Umore del mercato apps/client/src/app/pages/features/features-page.html - 215 + 240 @@ -4647,7 +4647,7 @@ Multilingue apps/client/src/app/pages/features/features-page.html - 259 + 284 @@ -4663,7 +4663,7 @@ Software open source apps/client/src/app/pages/features/features-page.html - 296 + 321 @@ -5949,7 +5949,7 @@ with your university e-mail address apps/client/src/app/pages/pricing/pricing-page.html - 348 + 356 @@ -6145,7 +6145,7 @@ Request it apps/client/src/app/pages/pricing/pricing-page.html - 344 + 352 @@ -6593,7 +6593,7 @@ here apps/client/src/app/pages/pricing/pricing-page.html - 347 + 355 @@ -7166,7 +7166,7 @@ apps/client/src/app/pages/features/features-page.html - 277 + 302 apps/client/src/app/pages/resources/personal-finance-tools/product-page.component.ts @@ -7510,7 +7510,7 @@ If you plan to open an account at apps/client/src/app/pages/pricing/pricing-page.html - 312 + 320 @@ -7642,7 +7642,7 @@ to use our referral link and get a Ghostfolio Premium membership for one year apps/client/src/app/pages/pricing/pricing-page.html - 340 + 348 @@ -8200,11 +8200,11 @@ Cambia libs/ui/src/lib/holdings-table/holdings-table.component.html - 160 + 165 libs/ui/src/lib/treemap-chart/treemap-chart.component.ts - 421 + 423 @@ -8232,23 +8232,23 @@ apps/client/src/app/components/home-overview/home-overview.component.ts - 64 + 65 libs/ui/src/lib/holdings-table/holdings-table.component.html - 185 + 190 libs/ui/src/lib/holdings-table/holdings-table.component.html - 186 + 191 libs/ui/src/lib/treemap-chart/treemap-chart.component.ts - 421 + 423 libs/ui/src/lib/treemap-chart/treemap-chart.component.ts - 434 + 436 @@ -8429,7 +8429,7 @@ con accesso API per apps/client/src/app/pages/pricing/pricing-page.html - 235 + 243 @@ -8477,7 +8477,7 @@ qualcuno apps/client/src/app/pages/public/public-page.component.ts - 68 + 69 @@ -8792,7 +8792,7 @@ apps/client/src/app/pages/pricing/pricing-page.html - 294 + 302 @@ -8804,7 +8804,7 @@ apps/client/src/app/pages/pricing/pricing-page.html - 297 + 305 @@ -8831,6 +8831,14 @@ 265 + + new + new + + apps/client/src/app/pages/features/features-page.html + 223 + + Investment Investimento diff --git a/apps/client/src/locales/messages.ja.xlf b/apps/client/src/locales/messages.ja.xlf index bd48a7dda4..a21e0eaa88 100644 --- a/apps/client/src/locales/messages.ja.xlf +++ b/apps/client/src/locales/messages.ja.xlf @@ -244,7 +244,7 @@ お願いします apps/client/src/app/pages/pricing/pricing-page.html - 333 + 341 @@ -528,7 +528,7 @@ libs/ui/src/lib/holdings-table/holdings-table.component.html - 113 + 118 libs/ui/src/lib/top-holdings/top-holdings.component.html @@ -932,7 +932,7 @@ libs/ui/src/lib/holdings-table/holdings-table.component.html - 64 + 69 @@ -2040,7 +2040,7 @@ apps/client/src/app/pages/pricing/pricing-page.html - 326 + 334 apps/client/src/app/pages/register/register-page.html @@ -2224,7 +2224,7 @@ libs/ui/src/lib/holdings-table/holdings-table.component.html - 88 + 93 @@ -2264,7 +2264,7 @@ apps/client/src/app/pages/pricing/pricing-page.html - 204 + 208 @@ -2284,7 +2284,7 @@ apps/client/src/app/pages/pricing/pricing-page.html - 208 + 212 @@ -2300,7 +2300,7 @@ apps/client/src/app/pages/pricing/pricing-page.html - 212 + 216 @@ -2316,7 +2316,7 @@ apps/client/src/app/pages/pricing/pricing-page.html - 216 + 220 @@ -2328,7 +2328,7 @@ apps/client/src/app/pages/pricing/pricing-page.html - 220 + 228 @@ -2340,11 +2340,11 @@ apps/client/src/app/pages/pricing/pricing-page.html - 75 + 79 apps/client/src/app/pages/pricing/pricing-page.html - 243 + 251 @@ -2400,7 +2400,7 @@ apps/client/src/app/pages/pricing/pricing-page.html - 281 + 289 @@ -2572,7 +2572,7 @@ apps/client/src/app/pages/pricing/pricing-page.html - 265 + 273 @@ -2740,7 +2740,7 @@ apps/client/src/app/pages/features/features-page.html - 246 + 271 @@ -3444,7 +3444,7 @@ ダークモード apps/client/src/app/pages/features/features-page.html - 233 + 258 @@ -3452,7 +3452,7 @@ 市場ムード apps/client/src/app/pages/features/features-page.html - 215 + 240 @@ -3468,7 +3468,7 @@ マルチ言語 apps/client/src/app/pages/features/features-page.html - 259 + 284 @@ -3484,7 +3484,7 @@ オープンソースソフトウェア apps/client/src/app/pages/features/features-page.html - 296 + 321 @@ -3496,7 +3496,7 @@ apps/client/src/app/pages/features/features-page.html - 321 + 346 apps/client/src/app/pages/landing/landing-page.html @@ -3508,7 +3508,7 @@ apps/client/src/app/pages/pricing/pricing-page.html - 360 + 368 apps/client/src/app/pages/public/public-page.html @@ -3972,7 +3972,7 @@ Basicのすべて、さらに apps/client/src/app/pages/pricing/pricing-page.html - 199 + 203 @@ -4188,7 +4188,7 @@ 大学のメールアドレスで apps/client/src/app/pages/pricing/pricing-page.html - 348 + 356 @@ -4780,7 +4780,7 @@ 学生割引をお探しですか? apps/client/src/app/pages/pricing/pricing-page.html - 342 + 350 @@ -4832,7 +4832,7 @@ 入金 libs/ui/src/lib/fire-calculator/fire-calculator.component.ts - 441 + 444 @@ -5012,7 +5012,7 @@ apps/client/src/app/pages/pricing/pricing-page.html - 127 + 131 @@ -5024,7 +5024,7 @@ apps/client/src/app/pages/pricing/pricing-page.html - 131 + 135 @@ -5036,7 +5036,7 @@ apps/client/src/app/pages/pricing/pricing-page.html - 135 + 139 @@ -5044,11 +5044,11 @@ データのインポートとエクスポート apps/client/src/app/pages/pricing/pricing-page.html - 63 + 67 apps/client/src/app/pages/pricing/pricing-page.html - 139 + 143 @@ -5056,7 +5056,7 @@ コミュニティサポート apps/client/src/app/pages/pricing/pricing-page.html - 80 + 84 @@ -5064,7 +5064,7 @@ セルフホスト、手動で更新。 apps/client/src/app/pages/pricing/pricing-page.html - 84 + 88 @@ -5072,11 +5072,11 @@ 無料 apps/client/src/app/pages/pricing/pricing-page.html - 86 + 90 apps/client/src/app/pages/pricing/pricing-page.html - 152 + 156 @@ -5084,7 +5084,7 @@ 取引を始めたばかりの新しい投資家のために。 apps/client/src/app/pages/pricing/pricing-page.html - 119 + 123 @@ -5092,11 +5092,11 @@ 完全マネージドのGhostfolioクラウドサービス。 apps/client/src/app/pages/pricing/pricing-page.html - 150 + 154 apps/client/src/app/pages/pricing/pricing-page.html - 252 + 260 @@ -5104,7 +5104,7 @@ 自分の金融資産の全体像を必要とする意欲的な投資家のために。 apps/client/src/app/pages/pricing/pricing-page.html - 193 + 197 @@ -5124,7 +5124,7 @@ メールおよびチャットサポート apps/client/src/app/pages/pricing/pricing-page.html - 248 + 256 @@ -5140,7 +5140,7 @@ apps/client/src/app/pages/pricing/pricing-page.html - 279 + 287 @@ -5148,7 +5148,7 @@ 一度きりの支払い、自動更新なし。 apps/client/src/app/pages/pricing/pricing-page.html - 285 + 293 @@ -5168,7 +5168,7 @@ 無料です。 apps/client/src/app/pages/pricing/pricing-page.html - 362 + 370 @@ -5617,7 +5617,7 @@ それをリクエスト apps/client/src/app/pages/pricing/pricing-page.html - 344 + 352 @@ -5793,7 +5793,7 @@ 私たちに連絡 apps/client/src/app/pages/pricing/pricing-page.html - 336 + 344 @@ -5881,7 +5881,7 @@ libs/ui/src/lib/fire-calculator/fire-calculator.component.ts - 451 + 454 libs/ui/src/lib/i18n.ts @@ -5893,7 +5893,7 @@ 貯蓄 libs/ui/src/lib/fire-calculator/fire-calculator.component.ts - 461 + 464 @@ -5905,11 +5905,11 @@ libs/ui/src/lib/holdings-table/holdings-table.component.html - 138 + 143 libs/ui/src/lib/holdings-table/holdings-table.component.html - 139 + 144 libs/ui/src/lib/top-holdings/top-holdings.component.html @@ -5929,7 +5929,7 @@ すべて表示 libs/ui/src/lib/holdings-table/holdings-table.component.html - 243 + 248 @@ -6201,8 +6201,8 @@ - Export - Export + Export + Export libs/ui/src/lib/activities-table/activities-table.component.html 72 @@ -6641,7 +6641,7 @@ こちら apps/client/src/app/pages/pricing/pricing-page.html - 347 + 355 @@ -7166,7 +7166,7 @@ apps/client/src/app/pages/features/features-page.html - 277 + 302 apps/client/src/app/pages/resources/personal-finance-tools/product-page.component.ts @@ -7542,7 +7542,7 @@ If you plan to open an account at apps/client/src/app/pages/pricing/pricing-page.html - 312 + 320 @@ -7666,7 +7666,7 @@ to use our referral link and get a Ghostfolio Premium membership for one year apps/client/src/app/pages/pricing/pricing-page.html - 340 + 348 @@ -8224,11 +8224,11 @@ Change libs/ui/src/lib/holdings-table/holdings-table.component.html - 160 + 165 libs/ui/src/lib/treemap-chart/treemap-chart.component.ts - 421 + 423 @@ -8256,23 +8256,23 @@ apps/client/src/app/components/home-overview/home-overview.component.ts - 64 + 65 libs/ui/src/lib/holdings-table/holdings-table.component.html - 185 + 190 libs/ui/src/lib/holdings-table/holdings-table.component.html - 186 + 191 libs/ui/src/lib/treemap-chart/treemap-chart.component.ts - 421 + 423 libs/ui/src/lib/treemap-chart/treemap-chart.component.ts - 434 + 436 @@ -8453,7 +8453,7 @@ with API access for apps/client/src/app/pages/pricing/pricing-page.html - 235 + 243 @@ -8477,7 +8477,7 @@ someone apps/client/src/app/pages/public/public-page.component.ts - 68 + 69 @@ -8792,7 +8792,7 @@ apps/client/src/app/pages/pricing/pricing-page.html - 294 + 302 @@ -8804,7 +8804,7 @@ apps/client/src/app/pages/pricing/pricing-page.html - 297 + 305 @@ -8831,6 +8831,14 @@ 265 + + new + new + + apps/client/src/app/pages/features/features-page.html + 223 + + Investment Investment diff --git a/apps/client/src/locales/messages.ko.xlf b/apps/client/src/locales/messages.ko.xlf index 80bf4c9006..76e92c7862 100644 --- a/apps/client/src/locales/messages.ko.xlf +++ b/apps/client/src/locales/messages.ko.xlf @@ -244,7 +244,7 @@ 부탁드립니다 apps/client/src/app/pages/pricing/pricing-page.html - 333 + 341 @@ -528,7 +528,7 @@ libs/ui/src/lib/holdings-table/holdings-table.component.html - 113 + 118 libs/ui/src/lib/top-holdings/top-holdings.component.html @@ -932,7 +932,7 @@ libs/ui/src/lib/holdings-table/holdings-table.component.html - 64 + 69 @@ -2040,7 +2040,7 @@ apps/client/src/app/pages/pricing/pricing-page.html - 326 + 334 apps/client/src/app/pages/register/register-page.html @@ -2224,7 +2224,7 @@ libs/ui/src/lib/holdings-table/holdings-table.component.html - 88 + 93 @@ -2264,7 +2264,7 @@ apps/client/src/app/pages/pricing/pricing-page.html - 204 + 208 @@ -2284,7 +2284,7 @@ apps/client/src/app/pages/pricing/pricing-page.html - 208 + 212 @@ -2300,7 +2300,7 @@ apps/client/src/app/pages/pricing/pricing-page.html - 212 + 216 @@ -2316,7 +2316,7 @@ apps/client/src/app/pages/pricing/pricing-page.html - 216 + 220 @@ -2328,7 +2328,7 @@ apps/client/src/app/pages/pricing/pricing-page.html - 220 + 228 @@ -2340,11 +2340,11 @@ apps/client/src/app/pages/pricing/pricing-page.html - 75 + 79 apps/client/src/app/pages/pricing/pricing-page.html - 243 + 251 @@ -2400,7 +2400,7 @@ apps/client/src/app/pages/pricing/pricing-page.html - 281 + 289 @@ -2572,7 +2572,7 @@ apps/client/src/app/pages/pricing/pricing-page.html - 265 + 273 @@ -2740,7 +2740,7 @@ apps/client/src/app/pages/features/features-page.html - 246 + 271 @@ -3444,7 +3444,7 @@ 다크 모드 apps/client/src/app/pages/features/features-page.html - 233 + 258 @@ -3452,7 +3452,7 @@ 시장 분위기 apps/client/src/app/pages/features/features-page.html - 215 + 240 @@ -3468,7 +3468,7 @@ 다국어 apps/client/src/app/pages/features/features-page.html - 259 + 284 @@ -3484,7 +3484,7 @@ 오픈 소스 소프트웨어 apps/client/src/app/pages/features/features-page.html - 296 + 321 @@ -3496,7 +3496,7 @@ apps/client/src/app/pages/features/features-page.html - 321 + 346 apps/client/src/app/pages/landing/landing-page.html @@ -3508,7 +3508,7 @@ apps/client/src/app/pages/pricing/pricing-page.html - 360 + 368 apps/client/src/app/pages/public/public-page.html @@ -4180,7 +4180,7 @@ 대학 이메일 주소로 apps/client/src/app/pages/pricing/pricing-page.html - 348 + 356 @@ -4772,7 +4772,7 @@ 학생 할인을 찾고 계십니까? apps/client/src/app/pages/pricing/pricing-page.html - 342 + 350 @@ -4824,7 +4824,7 @@ 보증금 libs/ui/src/lib/fire-calculator/fire-calculator.component.ts - 441 + 444 @@ -5004,7 +5004,7 @@ apps/client/src/app/pages/pricing/pricing-page.html - 127 + 131 @@ -5016,7 +5016,7 @@ apps/client/src/app/pages/pricing/pricing-page.html - 131 + 135 @@ -5028,7 +5028,7 @@ apps/client/src/app/pages/pricing/pricing-page.html - 135 + 139 @@ -5036,11 +5036,11 @@ 데이터 가져오기 및 내보내기 apps/client/src/app/pages/pricing/pricing-page.html - 63 + 67 apps/client/src/app/pages/pricing/pricing-page.html - 139 + 143 @@ -5048,7 +5048,7 @@ 커뮤니티 지원 apps/client/src/app/pages/pricing/pricing-page.html - 80 + 84 @@ -5056,7 +5056,7 @@ 자체 호스팅, 수동으로 업데이트합니다. apps/client/src/app/pages/pricing/pricing-page.html - 84 + 88 @@ -5064,11 +5064,11 @@ 무료 apps/client/src/app/pages/pricing/pricing-page.html - 86 + 90 apps/client/src/app/pages/pricing/pricing-page.html - 152 + 156 @@ -5076,7 +5076,7 @@ 이제 막 거래를 시작한 신규 투자자를 위한 제품입니다. apps/client/src/app/pages/pricing/pricing-page.html - 119 + 123 @@ -5084,11 +5084,11 @@ 완전 관리형 Ghostfolio 클라우드 제품. apps/client/src/app/pages/pricing/pricing-page.html - 150 + 154 apps/client/src/app/pages/pricing/pricing-page.html - 252 + 260 @@ -5096,7 +5096,7 @@ 자신의 금융 자산에 대한 전체 그림이 필요한 야심찬 투자자를 위한 제품입니다. apps/client/src/app/pages/pricing/pricing-page.html - 193 + 197 @@ -5116,7 +5116,7 @@ 이메일 및 채팅 지원 apps/client/src/app/pages/pricing/pricing-page.html - 248 + 256 @@ -5132,7 +5132,7 @@ apps/client/src/app/pages/pricing/pricing-page.html - 279 + 287 @@ -5140,7 +5140,7 @@ 일회성 결제, 자동 갱신 없음. apps/client/src/app/pages/pricing/pricing-page.html - 285 + 293 @@ -5160,7 +5160,7 @@ 무료입니다. apps/client/src/app/pages/pricing/pricing-page.html - 362 + 370 @@ -5609,7 +5609,7 @@ 요청하세요 apps/client/src/app/pages/pricing/pricing-page.html - 344 + 352 @@ -5785,7 +5785,7 @@ 저희에게 연락주세요 apps/client/src/app/pages/pricing/pricing-page.html - 336 + 344 @@ -5873,7 +5873,7 @@ libs/ui/src/lib/fire-calculator/fire-calculator.component.ts - 451 + 454 libs/ui/src/lib/i18n.ts @@ -5885,7 +5885,7 @@ 저금 libs/ui/src/lib/fire-calculator/fire-calculator.component.ts - 461 + 464 @@ -5897,11 +5897,11 @@ libs/ui/src/lib/holdings-table/holdings-table.component.html - 138 + 143 libs/ui/src/lib/holdings-table/holdings-table.component.html - 139 + 144 libs/ui/src/lib/top-holdings/top-holdings.component.html @@ -5921,7 +5921,7 @@ 모두 표시 libs/ui/src/lib/holdings-table/holdings-table.component.html - 243 + 248 @@ -6161,7 +6161,7 @@ Basic의 모든 기능에 더해 apps/client/src/app/pages/pricing/pricing-page.html - 199 + 203 @@ -6201,8 +6201,8 @@ - Export - Export + Export + Export libs/ui/src/lib/activities-table/activities-table.component.html 72 @@ -6641,7 +6641,7 @@ 여기 apps/client/src/app/pages/pricing/pricing-page.html - 347 + 355 @@ -7166,7 +7166,7 @@ apps/client/src/app/pages/features/features-page.html - 277 + 302 apps/client/src/app/pages/resources/personal-finance-tools/product-page.component.ts @@ -7542,7 +7542,7 @@ 에서 계좌를 개설할 계획이라면 apps/client/src/app/pages/pricing/pricing-page.html - 312 + 320 @@ -7666,7 +7666,7 @@ 추천 링크를 사용하고 1년 동안 Ghostfolio 프리미엄 멤버십을 얻으려면 apps/client/src/app/pages/pricing/pricing-page.html - 340 + 348 @@ -8224,11 +8224,11 @@ 변동 libs/ui/src/lib/holdings-table/holdings-table.component.html - 160 + 165 libs/ui/src/lib/treemap-chart/treemap-chart.component.ts - 421 + 423 @@ -8256,23 +8256,23 @@ apps/client/src/app/components/home-overview/home-overview.component.ts - 64 + 65 libs/ui/src/lib/holdings-table/holdings-table.component.html - 185 + 190 libs/ui/src/lib/holdings-table/holdings-table.component.html - 186 + 191 libs/ui/src/lib/treemap-chart/treemap-chart.component.ts - 421 + 423 libs/ui/src/lib/treemap-chart/treemap-chart.component.ts - 434 + 436 @@ -8453,7 +8453,7 @@ 다음에 대한 API 액세스 권한이 있는 apps/client/src/app/pages/pricing/pricing-page.html - 235 + 243 @@ -8477,7 +8477,7 @@ 누구 apps/client/src/app/pages/public/public-page.component.ts - 68 + 69 @@ -8792,7 +8792,7 @@ apps/client/src/app/pages/pricing/pricing-page.html - 294 + 302 @@ -8804,7 +8804,7 @@ apps/client/src/app/pages/pricing/pricing-page.html - 297 + 305 @@ -8831,6 +8831,14 @@ 265 + + new + new + + apps/client/src/app/pages/features/features-page.html + 223 + + Investment 투자 diff --git a/apps/client/src/locales/messages.nl.xlf b/apps/client/src/locales/messages.nl.xlf index 61da01f6db..6d27beb310 100644 --- a/apps/client/src/locales/messages.nl.xlf +++ b/apps/client/src/locales/messages.nl.xlf @@ -42,7 +42,7 @@ alsjeblieft apps/client/src/app/pages/pricing/pricing-page.html - 333 + 341 @@ -230,7 +230,7 @@ libs/ui/src/lib/holdings-table/holdings-table.component.html - 113 + 118 libs/ui/src/lib/top-holdings/top-holdings.component.html @@ -570,7 +570,7 @@ libs/ui/src/lib/holdings-table/holdings-table.component.html - 64 + 69 @@ -802,7 +802,7 @@ apps/client/src/app/pages/features/features-page.html - 321 + 346 apps/client/src/app/pages/landing/landing-page.html @@ -814,7 +814,7 @@ apps/client/src/app/pages/pricing/pricing-page.html - 360 + 368 apps/client/src/app/pages/public/public-page.html @@ -978,7 +978,7 @@ apps/client/src/app/pages/pricing/pricing-page.html - 326 + 334 apps/client/src/app/pages/register/register-page.html @@ -1162,11 +1162,11 @@ libs/ui/src/lib/holdings-table/holdings-table.component.html - 138 + 143 libs/ui/src/lib/holdings-table/holdings-table.component.html - 139 + 144 libs/ui/src/lib/top-holdings/top-holdings.component.html @@ -1186,7 +1186,7 @@ Toon alle libs/ui/src/lib/holdings-table/holdings-table.component.html - 243 + 248 @@ -1386,7 +1386,7 @@ apps/client/src/app/pages/pricing/pricing-page.html - 265 + 273 @@ -1466,7 +1466,7 @@ apps/client/src/app/pages/features/features-page.html - 246 + 271 @@ -2242,7 +2242,7 @@ libs/ui/src/lib/holdings-table/holdings-table.component.html - 88 + 93 @@ -2610,7 +2610,7 @@ contacteer ons apps/client/src/app/pages/pricing/pricing-page.html - 336 + 344 @@ -2802,7 +2802,7 @@ Besparingen libs/ui/src/lib/fire-calculator/fire-calculator.component.ts - 461 + 464 @@ -2818,7 +2818,7 @@ libs/ui/src/lib/fire-calculator/fire-calculator.component.ts - 451 + 454 libs/ui/src/lib/i18n.ts @@ -2830,7 +2830,7 @@ Storting libs/ui/src/lib/fire-calculator/fire-calculator.component.ts - 441 + 444 @@ -3158,8 +3158,8 @@ - Export - Export + Export + Export libs/ui/src/lib/activities-table/activities-table.component.html 72 @@ -3458,7 +3458,7 @@ Op zoek naar studentenkorting? apps/client/src/app/pages/pricing/pricing-page.html - 342 + 350 @@ -3742,7 +3742,7 @@ Alles van Basic, plus apps/client/src/app/pages/pricing/pricing-page.html - 199 + 203 @@ -3806,7 +3806,7 @@ apps/client/src/app/pages/pricing/pricing-page.html - 204 + 208 @@ -3822,7 +3822,7 @@ apps/client/src/app/pages/pricing/pricing-page.html - 212 + 216 @@ -3838,7 +3838,7 @@ apps/client/src/app/pages/pricing/pricing-page.html - 216 + 220 @@ -3850,11 +3850,11 @@ apps/client/src/app/pages/pricing/pricing-page.html - 75 + 79 apps/client/src/app/pages/pricing/pricing-page.html - 243 + 251 @@ -3902,7 +3902,7 @@ apps/client/src/app/pages/pricing/pricing-page.html - 281 + 289 @@ -3930,7 +3930,7 @@ apps/client/src/app/pages/pricing/pricing-page.html - 127 + 131 @@ -3942,7 +3942,7 @@ apps/client/src/app/pages/pricing/pricing-page.html - 131 + 135 @@ -3954,7 +3954,7 @@ apps/client/src/app/pages/pricing/pricing-page.html - 135 + 139 @@ -3962,7 +3962,7 @@ Zelf hosten, handmatig bijwerken. apps/client/src/app/pages/pricing/pricing-page.html - 84 + 88 @@ -3970,11 +3970,11 @@ Gratis apps/client/src/app/pages/pricing/pricing-page.html - 86 + 90 apps/client/src/app/pages/pricing/pricing-page.html - 152 + 156 @@ -3982,7 +3982,7 @@ Voor nieuwe beleggers die net beginnen met handelen. apps/client/src/app/pages/pricing/pricing-page.html - 119 + 123 @@ -3990,11 +3990,11 @@ Volledig beheerd Ghostfolio cloud-aanbod. apps/client/src/app/pages/pricing/pricing-page.html - 150 + 154 apps/client/src/app/pages/pricing/pricing-page.html - 252 + 260 @@ -4002,7 +4002,7 @@ Voor ambitieuze beleggers die een volledig beeld willen hebben van hun financiële assets. apps/client/src/app/pages/pricing/pricing-page.html - 193 + 197 @@ -4022,7 +4022,7 @@ Eenmalige betaling, geen automatische verlenging. apps/client/src/app/pages/pricing/pricing-page.html - 285 + 293 @@ -4042,7 +4042,7 @@ Het is gratis. apps/client/src/app/pages/pricing/pricing-page.html - 362 + 370 @@ -4074,7 +4074,7 @@ apps/client/src/app/pages/pricing/pricing-page.html - 208 + 212 @@ -4090,11 +4090,11 @@ Data Import and Export apps/client/src/app/pages/pricing/pricing-page.html - 63 + 67 apps/client/src/app/pages/pricing/pricing-page.html - 139 + 143 @@ -4110,7 +4110,7 @@ Steun van de Gemeenschap apps/client/src/app/pages/pricing/pricing-page.html - 80 + 84 @@ -4118,7 +4118,7 @@ Ondersteuning via e-mail en chat apps/client/src/app/pages/pricing/pricing-page.html - 248 + 256 @@ -4170,7 +4170,7 @@ apps/client/src/app/pages/pricing/pricing-page.html - 220 + 228 @@ -4194,7 +4194,7 @@ apps/client/src/app/pages/pricing/pricing-page.html - 279 + 287 @@ -4622,7 +4622,7 @@ Donker Thema apps/client/src/app/pages/features/features-page.html - 233 + 258 @@ -4630,7 +4630,7 @@ Marktsentiment apps/client/src/app/pages/features/features-page.html - 215 + 240 @@ -4646,7 +4646,7 @@ Meerdere talen apps/client/src/app/pages/features/features-page.html - 259 + 284 @@ -4662,7 +4662,7 @@ Open Source Software apps/client/src/app/pages/features/features-page.html - 296 + 321 @@ -5948,7 +5948,7 @@ met uw universitaire e-mailadres apps/client/src/app/pages/pricing/pricing-page.html - 348 + 356 @@ -6144,7 +6144,7 @@ Aanvragen apps/client/src/app/pages/pricing/pricing-page.html - 344 + 352 @@ -6592,7 +6592,7 @@ hier apps/client/src/app/pages/pricing/pricing-page.html - 347 + 355 @@ -7165,7 +7165,7 @@ apps/client/src/app/pages/features/features-page.html - 277 + 302 apps/client/src/app/pages/resources/personal-finance-tools/product-page.component.ts @@ -7509,7 +7509,7 @@ Als u van plan bent een rekening te openen bij apps/client/src/app/pages/pricing/pricing-page.html - 312 + 320 @@ -7641,7 +7641,7 @@ om onze referral link te gebruiken en een jaar lang Ghostfolio Premium-lidmaatschap te krijgen apps/client/src/app/pages/pricing/pricing-page.html - 340 + 348 @@ -8199,11 +8199,11 @@ Aanpassen libs/ui/src/lib/holdings-table/holdings-table.component.html - 160 + 165 libs/ui/src/lib/treemap-chart/treemap-chart.component.ts - 421 + 423 @@ -8231,23 +8231,23 @@ apps/client/src/app/components/home-overview/home-overview.component.ts - 64 + 65 libs/ui/src/lib/holdings-table/holdings-table.component.html - 185 + 190 libs/ui/src/lib/holdings-table/holdings-table.component.html - 186 + 191 libs/ui/src/lib/treemap-chart/treemap-chart.component.ts - 421 + 423 libs/ui/src/lib/treemap-chart/treemap-chart.component.ts - 434 + 436 @@ -8428,7 +8428,7 @@ met API toegang tot apps/client/src/app/pages/pricing/pricing-page.html - 235 + 243 @@ -8476,7 +8476,7 @@ iemand apps/client/src/app/pages/public/public-page.component.ts - 68 + 69 @@ -8791,7 +8791,7 @@ apps/client/src/app/pages/pricing/pricing-page.html - 294 + 302 @@ -8803,7 +8803,7 @@ apps/client/src/app/pages/pricing/pricing-page.html - 297 + 305 @@ -8830,6 +8830,14 @@ 265 + + new + new + + apps/client/src/app/pages/features/features-page.html + 223 + + Investment Investering diff --git a/apps/client/src/locales/messages.pl.xlf b/apps/client/src/locales/messages.pl.xlf index f91675891e..c077a7b867 100644 --- a/apps/client/src/locales/messages.pl.xlf +++ b/apps/client/src/locales/messages.pl.xlf @@ -243,7 +243,7 @@ proszę apps/client/src/app/pages/pricing/pricing-page.html - 333 + 341 @@ -519,7 +519,7 @@ libs/ui/src/lib/holdings-table/holdings-table.component.html - 113 + 118 libs/ui/src/lib/top-holdings/top-holdings.component.html @@ -923,7 +923,7 @@ libs/ui/src/lib/holdings-table/holdings-table.component.html - 64 + 69 @@ -2007,7 +2007,7 @@ apps/client/src/app/pages/pricing/pricing-page.html - 326 + 334 apps/client/src/app/pages/register/register-page.html @@ -2191,7 +2191,7 @@ libs/ui/src/lib/holdings-table/holdings-table.component.html - 88 + 93 @@ -2231,7 +2231,7 @@ apps/client/src/app/pages/pricing/pricing-page.html - 204 + 208 @@ -2251,7 +2251,7 @@ apps/client/src/app/pages/pricing/pricing-page.html - 208 + 212 @@ -2267,7 +2267,7 @@ apps/client/src/app/pages/pricing/pricing-page.html - 212 + 216 @@ -2283,7 +2283,7 @@ apps/client/src/app/pages/pricing/pricing-page.html - 216 + 220 @@ -2295,7 +2295,7 @@ apps/client/src/app/pages/pricing/pricing-page.html - 220 + 228 @@ -2307,11 +2307,11 @@ apps/client/src/app/pages/pricing/pricing-page.html - 75 + 79 apps/client/src/app/pages/pricing/pricing-page.html - 243 + 251 @@ -2367,7 +2367,7 @@ apps/client/src/app/pages/pricing/pricing-page.html - 281 + 289 @@ -2539,7 +2539,7 @@ apps/client/src/app/pages/pricing/pricing-page.html - 265 + 273 @@ -2707,7 +2707,7 @@ apps/client/src/app/pages/features/features-page.html - 246 + 271 @@ -3411,7 +3411,7 @@ Ciemny motyw apps/client/src/app/pages/features/features-page.html - 233 + 258 @@ -3419,7 +3419,7 @@ Nastrój rynku apps/client/src/app/pages/features/features-page.html - 215 + 240 @@ -3435,7 +3435,7 @@ Wielo-językowość apps/client/src/app/pages/features/features-page.html - 259 + 284 @@ -3451,7 +3451,7 @@ Oprogramowanie Open Source apps/client/src/app/pages/features/features-page.html - 296 + 321 @@ -3463,7 +3463,7 @@ apps/client/src/app/pages/features/features-page.html - 321 + 346 apps/client/src/app/pages/landing/landing-page.html @@ -3475,7 +3475,7 @@ apps/client/src/app/pages/pricing/pricing-page.html - 360 + 368 apps/client/src/app/pages/public/public-page.html @@ -4147,7 +4147,7 @@ używając swojego adresu e-mail uczelni apps/client/src/app/pages/pricing/pricing-page.html - 348 + 356 @@ -4739,7 +4739,7 @@ Szukasz zniżki studenckiej? apps/client/src/app/pages/pricing/pricing-page.html - 342 + 350 @@ -4791,7 +4791,7 @@ Depozyt libs/ui/src/lib/fire-calculator/fire-calculator.component.ts - 441 + 444 @@ -4971,7 +4971,7 @@ apps/client/src/app/pages/pricing/pricing-page.html - 127 + 131 @@ -4983,7 +4983,7 @@ apps/client/src/app/pages/pricing/pricing-page.html - 131 + 135 @@ -4995,7 +4995,7 @@ apps/client/src/app/pages/pricing/pricing-page.html - 135 + 139 @@ -5003,11 +5003,11 @@ Importowanie i Eksportowanie Danych apps/client/src/app/pages/pricing/pricing-page.html - 63 + 67 apps/client/src/app/pages/pricing/pricing-page.html - 139 + 143 @@ -5015,7 +5015,7 @@ Wsparcie Społeczności apps/client/src/app/pages/pricing/pricing-page.html - 80 + 84 @@ -5023,7 +5023,7 @@ Samodzielny hosting, aktualizacja ręczna. apps/client/src/app/pages/pricing/pricing-page.html - 84 + 88 @@ -5031,11 +5031,11 @@ Bezpłatnie apps/client/src/app/pages/pricing/pricing-page.html - 86 + 90 apps/client/src/app/pages/pricing/pricing-page.html - 152 + 156 @@ -5043,7 +5043,7 @@ Dla początkujących inwestorów, którzy dopiero zaczynają swoją przygodę z tradingiem. apps/client/src/app/pages/pricing/pricing-page.html - 119 + 123 @@ -5051,11 +5051,11 @@ W pełni zarządzana oferta Ghostfolio w chmurze. apps/client/src/app/pages/pricing/pricing-page.html - 150 + 154 apps/client/src/app/pages/pricing/pricing-page.html - 252 + 260 @@ -5063,7 +5063,7 @@ Dla ambitnych inwestorów, którzy potrzebują pełnego obrazu swoich aktywów finansowych. apps/client/src/app/pages/pricing/pricing-page.html - 193 + 197 @@ -5083,7 +5083,7 @@ Wsparcie przez E-mail i Czat apps/client/src/app/pages/pricing/pricing-page.html - 248 + 256 @@ -5099,7 +5099,7 @@ apps/client/src/app/pages/pricing/pricing-page.html - 279 + 287 @@ -5107,7 +5107,7 @@ Płatność jednorazowa, bez automatycznego odnawiania. apps/client/src/app/pages/pricing/pricing-page.html - 285 + 293 @@ -5127,7 +5127,7 @@ Jest bezpłatny. apps/client/src/app/pages/pricing/pricing-page.html - 362 + 370 @@ -5564,7 +5564,7 @@ Porpoś o apps/client/src/app/pages/pricing/pricing-page.html - 344 + 352 @@ -5708,7 +5708,7 @@ skontaktuj się z nami apps/client/src/app/pages/pricing/pricing-page.html - 336 + 344 @@ -5796,7 +5796,7 @@ libs/ui/src/lib/fire-calculator/fire-calculator.component.ts - 451 + 454 libs/ui/src/lib/i18n.ts @@ -5808,7 +5808,7 @@ Oszczędności libs/ui/src/lib/fire-calculator/fire-calculator.component.ts - 461 + 464 @@ -5820,11 +5820,11 @@ libs/ui/src/lib/holdings-table/holdings-table.component.html - 138 + 143 libs/ui/src/lib/holdings-table/holdings-table.component.html - 139 + 144 libs/ui/src/lib/top-holdings/top-holdings.component.html @@ -5844,7 +5844,7 @@ Pokaż wszystko libs/ui/src/lib/holdings-table/holdings-table.component.html - 243 + 248 @@ -6084,7 +6084,7 @@ Everything in Basic, plus apps/client/src/app/pages/pricing/pricing-page.html - 199 + 203 @@ -6124,8 +6124,8 @@ - Export - Export + Export + Export libs/ui/src/lib/activities-table/activities-table.component.html 72 @@ -6592,7 +6592,7 @@ tutaj apps/client/src/app/pages/pricing/pricing-page.html - 347 + 355 @@ -7165,7 +7165,7 @@ apps/client/src/app/pages/features/features-page.html - 277 + 302 apps/client/src/app/pages/resources/personal-finance-tools/product-page.component.ts @@ -7509,7 +7509,7 @@ Jeśli planujesz otworzyć konto w apps/client/src/app/pages/pricing/pricing-page.html - 312 + 320 @@ -7641,7 +7641,7 @@ aby skorzystać z naszego linku polecającego i otrzymać roczną subskrypcję Ghostfolio Premium apps/client/src/app/pages/pricing/pricing-page.html - 340 + 348 @@ -8199,11 +8199,11 @@ Zmiana libs/ui/src/lib/holdings-table/holdings-table.component.html - 160 + 165 libs/ui/src/lib/treemap-chart/treemap-chart.component.ts - 421 + 423 @@ -8231,23 +8231,23 @@ apps/client/src/app/components/home-overview/home-overview.component.ts - 64 + 65 libs/ui/src/lib/holdings-table/holdings-table.component.html - 185 + 190 libs/ui/src/lib/holdings-table/holdings-table.component.html - 186 + 191 libs/ui/src/lib/treemap-chart/treemap-chart.component.ts - 421 + 423 libs/ui/src/lib/treemap-chart/treemap-chart.component.ts - 434 + 436 @@ -8428,7 +8428,7 @@ z dostępem API dla apps/client/src/app/pages/pricing/pricing-page.html - 235 + 243 @@ -8476,7 +8476,7 @@ ktoś apps/client/src/app/pages/public/public-page.component.ts - 68 + 69 @@ -8791,7 +8791,7 @@ apps/client/src/app/pages/pricing/pricing-page.html - 294 + 302 @@ -8803,7 +8803,7 @@ apps/client/src/app/pages/pricing/pricing-page.html - 297 + 305 @@ -8830,6 +8830,14 @@ 265 + + new + new + + apps/client/src/app/pages/features/features-page.html + 223 + + Investment Inwestycja diff --git a/apps/client/src/locales/messages.pt.xlf b/apps/client/src/locales/messages.pt.xlf index 8181afb0f6..30b412b8d7 100644 --- a/apps/client/src/locales/messages.pt.xlf +++ b/apps/client/src/locales/messages.pt.xlf @@ -34,7 +34,7 @@ please apps/client/src/app/pages/pricing/pricing-page.html - 333 + 341 @@ -294,7 +294,7 @@ libs/ui/src/lib/holdings-table/holdings-table.component.html - 113 + 118 libs/ui/src/lib/top-holdings/top-holdings.component.html @@ -642,7 +642,7 @@ libs/ui/src/lib/holdings-table/holdings-table.component.html - 64 + 69 @@ -1058,7 +1058,7 @@ Depósito libs/ui/src/lib/fire-calculator/fire-calculator.component.ts - 441 + 444 @@ -1150,7 +1150,7 @@ apps/client/src/app/pages/pricing/pricing-page.html - 326 + 334 apps/client/src/app/pages/register/register-page.html @@ -1310,7 +1310,7 @@ libs/ui/src/lib/holdings-table/holdings-table.component.html - 88 + 93 @@ -1422,11 +1422,11 @@ libs/ui/src/lib/holdings-table/holdings-table.component.html - 138 + 143 libs/ui/src/lib/holdings-table/holdings-table.component.html - 139 + 144 libs/ui/src/lib/top-holdings/top-holdings.component.html @@ -1446,7 +1446,7 @@ Mostrar tudo libs/ui/src/lib/holdings-table/holdings-table.component.html - 243 + 248 @@ -1694,7 +1694,7 @@ apps/client/src/app/pages/pricing/pricing-page.html - 265 + 273 @@ -1794,7 +1794,7 @@ apps/client/src/app/pages/features/features-page.html - 246 + 271 @@ -2834,7 +2834,7 @@ apps/client/src/app/pages/features/features-page.html - 321 + 346 apps/client/src/app/pages/landing/landing-page.html @@ -2846,7 +2846,7 @@ apps/client/src/app/pages/pricing/pricing-page.html - 360 + 368 apps/client/src/app/pages/public/public-page.html @@ -3062,7 +3062,7 @@ contact us apps/client/src/app/pages/pricing/pricing-page.html - 336 + 344 @@ -3110,7 +3110,7 @@ libs/ui/src/lib/fire-calculator/fire-calculator.component.ts - 451 + 454 libs/ui/src/lib/i18n.ts @@ -3122,7 +3122,7 @@ Poupanças libs/ui/src/lib/fire-calculator/fire-calculator.component.ts - 461 + 464 @@ -3246,8 +3246,8 @@ - Export - Export + Export + Export libs/ui/src/lib/activities-table/activities-table.component.html 72 @@ -3542,7 +3542,7 @@ Looking for a student discount? apps/client/src/app/pages/pricing/pricing-page.html - 342 + 350 @@ -3742,7 +3742,7 @@ Everything in Basic, plus apps/client/src/app/pages/pricing/pricing-page.html - 199 + 203 @@ -3806,7 +3806,7 @@ apps/client/src/app/pages/pricing/pricing-page.html - 204 + 208 @@ -3822,7 +3822,7 @@ apps/client/src/app/pages/pricing/pricing-page.html - 212 + 216 @@ -3838,7 +3838,7 @@ apps/client/src/app/pages/pricing/pricing-page.html - 216 + 220 @@ -3850,11 +3850,11 @@ apps/client/src/app/pages/pricing/pricing-page.html - 75 + 79 apps/client/src/app/pages/pricing/pricing-page.html - 243 + 251 @@ -3902,7 +3902,7 @@ apps/client/src/app/pages/pricing/pricing-page.html - 281 + 289 @@ -3930,7 +3930,7 @@ apps/client/src/app/pages/pricing/pricing-page.html - 127 + 131 @@ -3942,7 +3942,7 @@ apps/client/src/app/pages/pricing/pricing-page.html - 131 + 135 @@ -3954,7 +3954,7 @@ apps/client/src/app/pages/pricing/pricing-page.html - 135 + 139 @@ -3962,7 +3962,7 @@ Hospedado localmente, atualização manual. apps/client/src/app/pages/pricing/pricing-page.html - 84 + 88 @@ -3970,11 +3970,11 @@ Grátis apps/client/src/app/pages/pricing/pricing-page.html - 86 + 90 apps/client/src/app/pages/pricing/pricing-page.html - 152 + 156 @@ -3982,7 +3982,7 @@ Para novos investidores que estão a começar a investir agora. apps/client/src/app/pages/pricing/pricing-page.html - 119 + 123 @@ -3990,11 +3990,11 @@ Ghostfolio hospedado na nuvem, totalmente gerido. apps/client/src/app/pages/pricing/pricing-page.html - 150 + 154 apps/client/src/app/pages/pricing/pricing-page.html - 252 + 260 @@ -4002,7 +4002,7 @@ Para investidores ambiciosos que precisam de ter uma visão completa de seus ativos financeiros. apps/client/src/app/pages/pricing/pricing-page.html - 193 + 197 @@ -4022,7 +4022,7 @@ Pagamento único, sem renovação automática. apps/client/src/app/pages/pricing/pricing-page.html - 285 + 293 @@ -4042,7 +4042,7 @@ É gratuito. apps/client/src/app/pages/pricing/pricing-page.html - 362 + 370 @@ -4074,7 +4074,7 @@ apps/client/src/app/pages/pricing/pricing-page.html - 208 + 212 @@ -4090,11 +4090,11 @@ Importação e Exportação de Dados apps/client/src/app/pages/pricing/pricing-page.html - 63 + 67 apps/client/src/app/pages/pricing/pricing-page.html - 139 + 143 @@ -4110,7 +4110,7 @@ Suporte da Comunidade apps/client/src/app/pages/pricing/pricing-page.html - 80 + 84 @@ -4118,7 +4118,7 @@ Suporte por Email e Chat apps/client/src/app/pages/pricing/pricing-page.html - 248 + 256 @@ -4170,7 +4170,7 @@ apps/client/src/app/pages/pricing/pricing-page.html - 220 + 228 @@ -4194,7 +4194,7 @@ apps/client/src/app/pages/pricing/pricing-page.html - 279 + 287 @@ -4622,7 +4622,7 @@ Modo escuro apps/client/src/app/pages/features/features-page.html - 233 + 258 @@ -4630,7 +4630,7 @@ Humor do mercado apps/client/src/app/pages/features/features-page.html - 215 + 240 @@ -4646,7 +4646,7 @@ Multilíngua apps/client/src/app/pages/features/features-page.html - 259 + 284 @@ -4662,7 +4662,7 @@ Software de código aberto apps/client/src/app/pages/features/features-page.html - 296 + 321 @@ -5948,7 +5948,7 @@ with your university e-mail address apps/client/src/app/pages/pricing/pricing-page.html - 348 + 356 @@ -6144,7 +6144,7 @@ Request it apps/client/src/app/pages/pricing/pricing-page.html - 344 + 352 @@ -6592,7 +6592,7 @@ here apps/client/src/app/pages/pricing/pricing-page.html - 347 + 355 @@ -7165,7 +7165,7 @@ apps/client/src/app/pages/features/features-page.html - 277 + 302 apps/client/src/app/pages/resources/personal-finance-tools/product-page.component.ts @@ -7509,7 +7509,7 @@ If you plan to open an account at apps/client/src/app/pages/pricing/pricing-page.html - 312 + 320 @@ -7641,7 +7641,7 @@ to use our referral link and get a Ghostfolio Premium membership for one year apps/client/src/app/pages/pricing/pricing-page.html - 340 + 348 @@ -8199,11 +8199,11 @@ Mudar libs/ui/src/lib/holdings-table/holdings-table.component.html - 160 + 165 libs/ui/src/lib/treemap-chart/treemap-chart.component.ts - 421 + 423 @@ -8231,23 +8231,23 @@ apps/client/src/app/components/home-overview/home-overview.component.ts - 64 + 65 libs/ui/src/lib/holdings-table/holdings-table.component.html - 185 + 190 libs/ui/src/lib/holdings-table/holdings-table.component.html - 186 + 191 libs/ui/src/lib/treemap-chart/treemap-chart.component.ts - 421 + 423 libs/ui/src/lib/treemap-chart/treemap-chart.component.ts - 434 + 436 @@ -8428,7 +8428,7 @@ with API access for apps/client/src/app/pages/pricing/pricing-page.html - 235 + 243 @@ -8476,7 +8476,7 @@ someone apps/client/src/app/pages/public/public-page.component.ts - 68 + 69 @@ -8791,7 +8791,7 @@ apps/client/src/app/pages/pricing/pricing-page.html - 294 + 302 @@ -8803,7 +8803,7 @@ apps/client/src/app/pages/pricing/pricing-page.html - 297 + 305 @@ -8830,6 +8830,14 @@ 265 + + new + new + + apps/client/src/app/pages/features/features-page.html + 223 + + Investment Investment diff --git a/apps/client/src/locales/messages.tr.xlf b/apps/client/src/locales/messages.tr.xlf index ff324951da..dc518742c1 100644 --- a/apps/client/src/locales/messages.tr.xlf +++ b/apps/client/src/locales/messages.tr.xlf @@ -215,7 +215,7 @@ please apps/client/src/app/pages/pricing/pricing-page.html - 333 + 341 @@ -475,7 +475,7 @@ libs/ui/src/lib/holdings-table/holdings-table.component.html - 113 + 118 libs/ui/src/lib/top-holdings/top-holdings.component.html @@ -867,7 +867,7 @@ libs/ui/src/lib/holdings-table/holdings-table.component.html - 64 + 69 @@ -1847,7 +1847,7 @@ apps/client/src/app/pages/pricing/pricing-page.html - 326 + 334 apps/client/src/app/pages/register/register-page.html @@ -2019,7 +2019,7 @@ libs/ui/src/lib/holdings-table/holdings-table.component.html - 88 + 93 @@ -2071,7 +2071,7 @@ apps/client/src/app/pages/pricing/pricing-page.html - 204 + 208 @@ -2091,7 +2091,7 @@ apps/client/src/app/pages/pricing/pricing-page.html - 208 + 212 @@ -2107,7 +2107,7 @@ apps/client/src/app/pages/pricing/pricing-page.html - 212 + 216 @@ -2123,7 +2123,7 @@ apps/client/src/app/pages/pricing/pricing-page.html - 216 + 220 @@ -2135,7 +2135,7 @@ apps/client/src/app/pages/pricing/pricing-page.html - 220 + 228 @@ -2147,11 +2147,11 @@ apps/client/src/app/pages/pricing/pricing-page.html - 75 + 79 apps/client/src/app/pages/pricing/pricing-page.html - 243 + 251 @@ -2207,7 +2207,7 @@ apps/client/src/app/pages/pricing/pricing-page.html - 281 + 289 @@ -2883,7 +2883,7 @@ Karanlık Mod apps/client/src/app/pages/features/features-page.html - 233 + 258 @@ -2895,7 +2895,7 @@ apps/client/src/app/pages/features/features-page.html - 246 + 271 @@ -2903,7 +2903,7 @@ Piyasa Modu apps/client/src/app/pages/features/features-page.html - 215 + 240 @@ -2919,7 +2919,7 @@ Çoklu Dil apps/client/src/app/pages/features/features-page.html - 259 + 284 @@ -2935,7 +2935,7 @@ Açık Kaynak Yazılım apps/client/src/app/pages/features/features-page.html - 296 + 321 @@ -2947,7 +2947,7 @@ apps/client/src/app/pages/features/features-page.html - 321 + 346 apps/client/src/app/pages/landing/landing-page.html @@ -2959,7 +2959,7 @@ apps/client/src/app/pages/pricing/pricing-page.html - 360 + 368 apps/client/src/app/pages/public/public-page.html @@ -3979,7 +3979,7 @@ Looking for a student discount? apps/client/src/app/pages/pricing/pricing-page.html - 342 + 350 @@ -4031,7 +4031,7 @@ Para Yatırma libs/ui/src/lib/fire-calculator/fire-calculator.component.ts - 441 + 444 @@ -4211,7 +4211,7 @@ apps/client/src/app/pages/pricing/pricing-page.html - 127 + 131 @@ -4223,7 +4223,7 @@ apps/client/src/app/pages/pricing/pricing-page.html - 131 + 135 @@ -4235,7 +4235,7 @@ apps/client/src/app/pages/pricing/pricing-page.html - 135 + 139 @@ -4243,11 +4243,11 @@ Veri İçe Aktarma ve Dışa Aktarma apps/client/src/app/pages/pricing/pricing-page.html - 63 + 67 apps/client/src/app/pages/pricing/pricing-page.html - 139 + 143 @@ -4255,7 +4255,7 @@ Topluluk Desteği apps/client/src/app/pages/pricing/pricing-page.html - 80 + 84 @@ -4263,7 +4263,7 @@ Tarafınızca barındırılıyor, elle güncelleyiniz. apps/client/src/app/pages/pricing/pricing-page.html - 84 + 88 @@ -4271,11 +4271,11 @@ Ücretsiz apps/client/src/app/pages/pricing/pricing-page.html - 86 + 90 apps/client/src/app/pages/pricing/pricing-page.html - 152 + 156 @@ -4283,7 +4283,7 @@ Alım satıma henüz başlamış yeni yatırımcılar için. apps/client/src/app/pages/pricing/pricing-page.html - 119 + 123 @@ -4291,11 +4291,11 @@ Eksiksiz yönetilen Ghostfolio bulut teklifi. apps/client/src/app/pages/pricing/pricing-page.html - 150 + 154 apps/client/src/app/pages/pricing/pricing-page.html - 252 + 260 @@ -4303,7 +4303,7 @@ Finansal varlıklarının tamamını görmeye ihtiyaç duyan hırslı yatırımcılar için. apps/client/src/app/pages/pricing/pricing-page.html - 193 + 197 @@ -4323,7 +4323,7 @@ E-posta ve Sohbet Desteği apps/client/src/app/pages/pricing/pricing-page.html - 248 + 256 @@ -4339,7 +4339,7 @@ apps/client/src/app/pages/pricing/pricing-page.html - 279 + 287 @@ -4347,7 +4347,7 @@ Tek seferlik ödeme, otomatik yenileme yok. apps/client/src/app/pages/pricing/pricing-page.html - 285 + 293 @@ -4367,7 +4367,7 @@ Ücretsiz. apps/client/src/app/pages/pricing/pricing-page.html - 362 + 370 @@ -4924,7 +4924,7 @@ apps/client/src/app/pages/pricing/pricing-page.html - 265 + 273 @@ -5244,7 +5244,7 @@ contact us apps/client/src/app/pages/pricing/pricing-page.html - 336 + 344 @@ -5332,7 +5332,7 @@ libs/ui/src/lib/fire-calculator/fire-calculator.component.ts - 451 + 454 libs/ui/src/lib/i18n.ts @@ -5344,7 +5344,7 @@ Tasarruflar libs/ui/src/lib/fire-calculator/fire-calculator.component.ts - 461 + 464 @@ -5356,11 +5356,11 @@ libs/ui/src/lib/holdings-table/holdings-table.component.html - 138 + 143 libs/ui/src/lib/holdings-table/holdings-table.component.html - 139 + 144 libs/ui/src/lib/top-holdings/top-holdings.component.html @@ -5380,7 +5380,7 @@ Tümünü göster libs/ui/src/lib/holdings-table/holdings-table.component.html - 243 + 248 @@ -5620,7 +5620,7 @@ Everything in Basic, plus apps/client/src/app/pages/pricing/pricing-page.html - 199 + 203 @@ -5660,8 +5660,8 @@ - Export - Export + Export + Export libs/ui/src/lib/activities-table/activities-table.component.html 72 @@ -5956,7 +5956,7 @@ with your university e-mail address apps/client/src/app/pages/pricing/pricing-page.html - 348 + 356 @@ -6144,7 +6144,7 @@ Request it apps/client/src/app/pages/pricing/pricing-page.html - 344 + 352 @@ -6592,7 +6592,7 @@ here apps/client/src/app/pages/pricing/pricing-page.html - 347 + 355 @@ -7165,7 +7165,7 @@ apps/client/src/app/pages/features/features-page.html - 277 + 302 apps/client/src/app/pages/resources/personal-finance-tools/product-page.component.ts @@ -7509,7 +7509,7 @@ If you plan to open an account at apps/client/src/app/pages/pricing/pricing-page.html - 312 + 320 @@ -7641,7 +7641,7 @@ to use our referral link and get a Ghostfolio Premium membership for one year apps/client/src/app/pages/pricing/pricing-page.html - 340 + 348 @@ -8199,11 +8199,11 @@ Değişim libs/ui/src/lib/holdings-table/holdings-table.component.html - 160 + 165 libs/ui/src/lib/treemap-chart/treemap-chart.component.ts - 421 + 423 @@ -8231,23 +8231,23 @@ apps/client/src/app/components/home-overview/home-overview.component.ts - 64 + 65 libs/ui/src/lib/holdings-table/holdings-table.component.html - 185 + 190 libs/ui/src/lib/holdings-table/holdings-table.component.html - 186 + 191 libs/ui/src/lib/treemap-chart/treemap-chart.component.ts - 421 + 423 libs/ui/src/lib/treemap-chart/treemap-chart.component.ts - 434 + 436 @@ -8428,7 +8428,7 @@ API erişimi için apps/client/src/app/pages/pricing/pricing-page.html - 235 + 243 @@ -8476,7 +8476,7 @@ birisi apps/client/src/app/pages/public/public-page.component.ts - 68 + 69 @@ -8791,7 +8791,7 @@ apps/client/src/app/pages/pricing/pricing-page.html - 294 + 302 @@ -8803,7 +8803,7 @@ apps/client/src/app/pages/pricing/pricing-page.html - 297 + 305 @@ -8830,6 +8830,14 @@ 265 + + new + new + + apps/client/src/app/pages/features/features-page.html + 223 + + Investment Yatırım diff --git a/apps/client/src/locales/messages.uk.xlf b/apps/client/src/locales/messages.uk.xlf index 133c1b35ac..ede339d5d4 100644 --- a/apps/client/src/locales/messages.uk.xlf +++ b/apps/client/src/locales/messages.uk.xlf @@ -299,7 +299,7 @@ будь ласка apps/client/src/app/pages/pricing/pricing-page.html - 333 + 341 @@ -623,7 +623,7 @@ libs/ui/src/lib/holdings-table/holdings-table.component.html - 113 + 118 libs/ui/src/lib/top-holdings/top-holdings.component.html @@ -1031,7 +1031,7 @@ libs/ui/src/lib/holdings-table/holdings-table.component.html - 64 + 69 @@ -1807,7 +1807,7 @@ apps/client/src/app/pages/pricing/pricing-page.html - 326 + 334 apps/client/src/app/pages/register/register-page.html @@ -2023,7 +2023,7 @@ apps/client/src/app/pages/pricing/pricing-page.html - 281 + 289 @@ -2039,7 +2039,7 @@ apps/client/src/app/pages/pricing/pricing-page.html - 279 + 287 @@ -2083,7 +2083,7 @@ Якщо ви плануєте відкрити рахунок у apps/client/src/app/pages/pricing/pricing-page.html - 312 + 320 @@ -2131,7 +2131,7 @@ libs/ui/src/lib/holdings-table/holdings-table.component.html - 88 + 93 @@ -2711,7 +2711,7 @@ apps/client/src/app/pages/pricing/pricing-page.html - 204 + 208 @@ -2731,7 +2731,7 @@ apps/client/src/app/pages/pricing/pricing-page.html - 208 + 212 @@ -2747,7 +2747,7 @@ apps/client/src/app/pages/pricing/pricing-page.html - 212 + 216 @@ -2763,7 +2763,7 @@ apps/client/src/app/pages/pricing/pricing-page.html - 216 + 220 @@ -2775,7 +2775,7 @@ apps/client/src/app/pages/pricing/pricing-page.html - 220 + 228 @@ -2787,11 +2787,11 @@ apps/client/src/app/pages/pricing/pricing-page.html - 75 + 79 apps/client/src/app/pages/pricing/pricing-page.html - 243 + 251 @@ -3119,7 +3119,7 @@ apps/client/src/app/pages/pricing/pricing-page.html - 265 + 273 @@ -3343,7 +3343,7 @@ apps/client/src/app/pages/features/features-page.html - 246 + 271 @@ -4096,7 +4096,7 @@ Темний режим apps/client/src/app/pages/features/features-page.html - 233 + 258 @@ -4104,7 +4104,7 @@ Ринковий настрій apps/client/src/app/pages/features/features-page.html - 215 + 240 @@ -4120,7 +4120,7 @@ Багатомовність apps/client/src/app/pages/features/features-page.html - 259 + 284 @@ -4136,7 +4136,7 @@ Програмне забезпечення з відкритим кодом apps/client/src/app/pages/features/features-page.html - 296 + 321 @@ -4148,7 +4148,7 @@ apps/client/src/app/pages/features/features-page.html - 321 + 346 apps/client/src/app/pages/landing/landing-page.html @@ -4160,7 +4160,7 @@ apps/client/src/app/pages/pricing/pricing-page.html - 360 + 368 apps/client/src/app/pages/public/public-page.html @@ -4848,7 +4848,7 @@ за допомогою вашої університетської електронної адреси apps/client/src/app/pages/pricing/pricing-page.html - 348 + 356 @@ -5508,7 +5508,7 @@ Шукаєте студентську знижку? apps/client/src/app/pages/pricing/pricing-page.html - 342 + 350 @@ -5572,7 +5572,7 @@ тут apps/client/src/app/pages/pricing/pricing-page.html - 347 + 355 @@ -5816,7 +5816,7 @@ apps/client/src/app/pages/pricing/pricing-page.html - 127 + 131 @@ -5828,7 +5828,7 @@ apps/client/src/app/pages/pricing/pricing-page.html - 131 + 135 @@ -5840,7 +5840,7 @@ apps/client/src/app/pages/pricing/pricing-page.html - 135 + 139 @@ -5848,11 +5848,11 @@ Імпорт та експорт даних apps/client/src/app/pages/pricing/pricing-page.html - 63 + 67 apps/client/src/app/pages/pricing/pricing-page.html - 139 + 143 @@ -5860,7 +5860,7 @@ Підтримка спільноти apps/client/src/app/pages/pricing/pricing-page.html - 80 + 84 @@ -5868,7 +5868,7 @@ Самохостинг, оновлення вручну. apps/client/src/app/pages/pricing/pricing-page.html - 84 + 88 @@ -5876,11 +5876,11 @@ Безкоштовно apps/client/src/app/pages/pricing/pricing-page.html - 86 + 90 apps/client/src/app/pages/pricing/pricing-page.html - 152 + 156 @@ -5888,7 +5888,7 @@ Для нових інвесторів, які тільки починають з торгівлі. apps/client/src/app/pages/pricing/pricing-page.html - 119 + 123 @@ -5896,11 +5896,11 @@ Повністю керована хмарна пропозиція Ghostfolio. apps/client/src/app/pages/pricing/pricing-page.html - 150 + 154 apps/client/src/app/pages/pricing/pricing-page.html - 252 + 260 @@ -5908,7 +5908,7 @@ Для амбітних інвесторів, яким потрібна повна картина їхніх фінансових активів. apps/client/src/app/pages/pricing/pricing-page.html - 193 + 197 @@ -5928,7 +5928,7 @@ Підтримка електронної пошти та чату apps/client/src/app/pages/pricing/pricing-page.html - 248 + 256 @@ -5936,7 +5936,7 @@ Разова оплата, без автоматичного поновлення. apps/client/src/app/pages/pricing/pricing-page.html - 285 + 293 @@ -5956,7 +5956,7 @@ Це безкоштовно. apps/client/src/app/pages/pricing/pricing-page.html - 362 + 370 @@ -6287,7 +6287,7 @@ apps/client/src/app/pages/features/features-page.html - 277 + 302 apps/client/src/app/pages/resources/personal-finance-tools/product-page.component.ts @@ -6635,7 +6635,7 @@ щоб скористатися нашим реферальним посиланням і отримати підписку Ghostfolio Premium на один рік apps/client/src/app/pages/pricing/pricing-page.html - 340 + 348 @@ -6867,7 +6867,7 @@ Request it apps/client/src/app/pages/pricing/pricing-page.html - 344 + 352 @@ -7163,7 +7163,7 @@ зв'яжіться з нами apps/client/src/app/pages/pricing/pricing-page.html - 336 + 344 @@ -7243,7 +7243,7 @@ Депозит libs/ui/src/lib/fire-calculator/fire-calculator.component.ts - 441 + 444 @@ -7259,7 +7259,7 @@ libs/ui/src/lib/fire-calculator/fire-calculator.component.ts - 451 + 454 libs/ui/src/lib/i18n.ts @@ -7271,7 +7271,7 @@ Заощадження libs/ui/src/lib/fire-calculator/fire-calculator.component.ts - 461 + 464 @@ -7307,11 +7307,11 @@ libs/ui/src/lib/holdings-table/holdings-table.component.html - 138 + 143 libs/ui/src/lib/holdings-table/holdings-table.component.html - 139 + 144 libs/ui/src/lib/top-holdings/top-holdings.component.html @@ -7331,7 +7331,7 @@ Показати все libs/ui/src/lib/holdings-table/holdings-table.component.html - 243 + 248 @@ -7695,7 +7695,7 @@ Усе, що входить у Базовий, плюс apps/client/src/app/pages/pricing/pricing-page.html - 199 + 203 @@ -7735,8 +7735,8 @@ - Export - Export + Export + Export libs/ui/src/lib/activities-table/activities-table.component.html 72 @@ -8199,11 +8199,11 @@ Змінити libs/ui/src/lib/holdings-table/holdings-table.component.html - 160 + 165 libs/ui/src/lib/treemap-chart/treemap-chart.component.ts - 421 + 423 @@ -8231,23 +8231,23 @@ apps/client/src/app/components/home-overview/home-overview.component.ts - 64 + 65 libs/ui/src/lib/holdings-table/holdings-table.component.html - 185 + 190 libs/ui/src/lib/holdings-table/holdings-table.component.html - 186 + 191 libs/ui/src/lib/treemap-chart/treemap-chart.component.ts - 421 + 423 libs/ui/src/lib/treemap-chart/treemap-chart.component.ts - 434 + 436 @@ -8428,7 +8428,7 @@ з доступом до API на apps/client/src/app/pages/pricing/pricing-page.html - 235 + 243 @@ -8476,7 +8476,7 @@ когось apps/client/src/app/pages/public/public-page.component.ts - 68 + 69 @@ -8791,7 +8791,7 @@ apps/client/src/app/pages/pricing/pricing-page.html - 294 + 302 @@ -8803,7 +8803,7 @@ apps/client/src/app/pages/pricing/pricing-page.html - 297 + 305 @@ -8830,6 +8830,14 @@ 265 + + new + new + + apps/client/src/app/pages/features/features-page.html + 223 + + Investment Інвестиція diff --git a/apps/client/src/locales/messages.xlf b/apps/client/src/locales/messages.xlf index 4e1652fc8c..9250c9276d 100644 --- a/apps/client/src/locales/messages.xlf +++ b/apps/client/src/locales/messages.xlf @@ -228,7 +228,7 @@ please apps/client/src/app/pages/pricing/pricing-page.html - 333 + 341 @@ -497,7 +497,7 @@ libs/ui/src/lib/holdings-table/holdings-table.component.html - 113 + 118 libs/ui/src/lib/top-holdings/top-holdings.component.html @@ -869,7 +869,7 @@ libs/ui/src/lib/holdings-table/holdings-table.component.html - 64 + 69 @@ -1868,7 +1868,7 @@ apps/client/src/app/pages/pricing/pricing-page.html - 326 + 334 apps/client/src/app/pages/register/register-page.html @@ -2034,7 +2034,7 @@ libs/ui/src/lib/holdings-table/holdings-table.component.html - 88 + 93 @@ -2070,7 +2070,7 @@ apps/client/src/app/pages/pricing/pricing-page.html - 204 + 208 @@ -2089,7 +2089,7 @@ apps/client/src/app/pages/pricing/pricing-page.html - 208 + 212 @@ -2104,7 +2104,7 @@ apps/client/src/app/pages/pricing/pricing-page.html - 212 + 216 @@ -2119,7 +2119,7 @@ apps/client/src/app/pages/pricing/pricing-page.html - 216 + 220 @@ -2130,7 +2130,7 @@ apps/client/src/app/pages/pricing/pricing-page.html - 220 + 228 @@ -2141,11 +2141,11 @@ apps/client/src/app/pages/pricing/pricing-page.html - 75 + 79 apps/client/src/app/pages/pricing/pricing-page.html - 243 + 251 @@ -2198,7 +2198,7 @@ apps/client/src/app/pages/pricing/pricing-page.html - 281 + 289 @@ -2353,7 +2353,7 @@ apps/client/src/app/pages/pricing/pricing-page.html - 265 + 273 @@ -2502,7 +2502,7 @@ apps/client/src/app/pages/features/features-page.html - 246 + 271 @@ -3154,14 +3154,14 @@ Dark Mode apps/client/src/app/pages/features/features-page.html - 233 + 258 Market Mood apps/client/src/app/pages/features/features-page.html - 215 + 240 @@ -3175,7 +3175,7 @@ Multi-Language apps/client/src/app/pages/features/features-page.html - 259 + 284 @@ -3189,7 +3189,7 @@ Open Source Software apps/client/src/app/pages/features/features-page.html - 296 + 321 @@ -3200,7 +3200,7 @@ apps/client/src/app/pages/features/features-page.html - 321 + 346 apps/client/src/app/pages/landing/landing-page.html @@ -3212,7 +3212,7 @@ apps/client/src/app/pages/pricing/pricing-page.html - 360 + 368 apps/client/src/app/pages/public/public-page.html @@ -3629,7 +3629,7 @@ Everything in Basic, plus apps/client/src/app/pages/pricing/pricing-page.html - 199 + 203 @@ -3818,7 +3818,7 @@ with your university e-mail address apps/client/src/app/pages/pricing/pricing-page.html - 348 + 356 @@ -4350,7 +4350,7 @@ Looking for a student discount? apps/client/src/app/pages/pricing/pricing-page.html - 342 + 350 @@ -4399,7 +4399,7 @@ Deposit libs/ui/src/lib/fire-calculator/fire-calculator.component.ts - 441 + 444 @@ -4560,7 +4560,7 @@ apps/client/src/app/pages/pricing/pricing-page.html - 127 + 131 @@ -4571,7 +4571,7 @@ apps/client/src/app/pages/pricing/pricing-page.html - 131 + 135 @@ -4582,68 +4582,68 @@ apps/client/src/app/pages/pricing/pricing-page.html - 135 + 139 Data Import and Export apps/client/src/app/pages/pricing/pricing-page.html - 63 + 67 apps/client/src/app/pages/pricing/pricing-page.html - 139 + 143 Community Support apps/client/src/app/pages/pricing/pricing-page.html - 80 + 84 Self-hosted, update manually. apps/client/src/app/pages/pricing/pricing-page.html - 84 + 88 Free apps/client/src/app/pages/pricing/pricing-page.html - 86 + 90 apps/client/src/app/pages/pricing/pricing-page.html - 152 + 156 For new investors who are just getting started with trading. apps/client/src/app/pages/pricing/pricing-page.html - 119 + 123 Fully managed Ghostfolio cloud offering. apps/client/src/app/pages/pricing/pricing-page.html - 150 + 154 apps/client/src/app/pages/pricing/pricing-page.html - 252 + 260 For ambitious investors who need the full picture of their financial assets. apps/client/src/app/pages/pricing/pricing-page.html - 193 + 197 @@ -4661,7 +4661,7 @@ Email and Chat Support apps/client/src/app/pages/pricing/pricing-page.html - 248 + 256 @@ -4676,14 +4676,14 @@ apps/client/src/app/pages/pricing/pricing-page.html - 279 + 287 One-time payment, no auto-renewal. apps/client/src/app/pages/pricing/pricing-page.html - 285 + 293 @@ -4701,7 +4701,7 @@ It’s free. apps/client/src/app/pages/pricing/pricing-page.html - 362 + 370 @@ -5107,7 +5107,7 @@ Request it apps/client/src/app/pages/pricing/pricing-page.html - 344 + 352 @@ -5264,7 +5264,7 @@ contact us apps/client/src/app/pages/pricing/pricing-page.html - 336 + 344 @@ -5342,7 +5342,7 @@ libs/ui/src/lib/fire-calculator/fire-calculator.component.ts - 451 + 454 libs/ui/src/lib/i18n.ts @@ -5353,7 +5353,7 @@ Savings libs/ui/src/lib/fire-calculator/fire-calculator.component.ts - 461 + 464 @@ -5364,11 +5364,11 @@ libs/ui/src/lib/holdings-table/holdings-table.component.html - 138 + 143 libs/ui/src/lib/holdings-table/holdings-table.component.html - 139 + 144 libs/ui/src/lib/top-holdings/top-holdings.component.html @@ -5387,7 +5387,7 @@ Show all libs/ui/src/lib/holdings-table/holdings-table.component.html - 243 + 248 @@ -5638,7 +5638,7 @@ - Export + Export libs/ui/src/lib/activities-table/activities-table.component.html 72 @@ -6031,7 +6031,7 @@ here apps/client/src/app/pages/pricing/pricing-page.html - 347 + 355 @@ -6502,7 +6502,7 @@ apps/client/src/app/pages/features/features-page.html - 277 + 302 apps/client/src/app/pages/resources/personal-finance-tools/product-page.component.ts @@ -6845,7 +6845,7 @@ If you plan to open an account at apps/client/src/app/pages/pricing/pricing-page.html - 312 + 320 @@ -6955,7 +6955,7 @@ to use our referral link and get a Ghostfolio Premium membership for one year apps/client/src/app/pages/pricing/pricing-page.html - 340 + 348 @@ -7456,11 +7456,11 @@ Change libs/ui/src/lib/holdings-table/holdings-table.component.html - 160 + 165 libs/ui/src/lib/treemap-chart/treemap-chart.component.ts - 421 + 423 @@ -7485,23 +7485,23 @@ apps/client/src/app/components/home-overview/home-overview.component.ts - 64 + 65 libs/ui/src/lib/holdings-table/holdings-table.component.html - 185 + 190 libs/ui/src/lib/holdings-table/holdings-table.component.html - 186 + 191 libs/ui/src/lib/treemap-chart/treemap-chart.component.ts - 421 + 423 libs/ui/src/lib/treemap-chart/treemap-chart.component.ts - 434 + 436 @@ -7661,7 +7661,7 @@ with API access for apps/client/src/app/pages/pricing/pricing-page.html - 235 + 243 @@ -7682,7 +7682,7 @@ someone apps/client/src/app/pages/public/public-page.component.ts - 68 + 69 @@ -7963,7 +7963,7 @@ apps/client/src/app/pages/pricing/pricing-page.html - 294 + 302 @@ -7974,7 +7974,7 @@ apps/client/src/app/pages/pricing/pricing-page.html - 297 + 305 @@ -7998,6 +7998,13 @@ 265 + + new + + apps/client/src/app/pages/features/features-page.html + 223 + + Investment diff --git a/apps/client/src/locales/messages.zh.xlf b/apps/client/src/locales/messages.zh.xlf index 2889ceb609..1f4cc36b00 100644 --- a/apps/client/src/locales/messages.zh.xlf +++ b/apps/client/src/locales/messages.zh.xlf @@ -244,7 +244,7 @@ 请 apps/client/src/app/pages/pricing/pricing-page.html - 333 + 341 @@ -528,7 +528,7 @@ libs/ui/src/lib/holdings-table/holdings-table.component.html - 113 + 118 libs/ui/src/lib/top-holdings/top-holdings.component.html @@ -932,7 +932,7 @@ libs/ui/src/lib/holdings-table/holdings-table.component.html - 64 + 69 @@ -2016,7 +2016,7 @@ apps/client/src/app/pages/pricing/pricing-page.html - 326 + 334 apps/client/src/app/pages/register/register-page.html @@ -2200,7 +2200,7 @@ libs/ui/src/lib/holdings-table/holdings-table.component.html - 88 + 93 @@ -2240,7 +2240,7 @@ apps/client/src/app/pages/pricing/pricing-page.html - 204 + 208 @@ -2260,7 +2260,7 @@ apps/client/src/app/pages/pricing/pricing-page.html - 208 + 212 @@ -2276,7 +2276,7 @@ apps/client/src/app/pages/pricing/pricing-page.html - 212 + 216 @@ -2292,7 +2292,7 @@ apps/client/src/app/pages/pricing/pricing-page.html - 216 + 220 @@ -2304,7 +2304,7 @@ apps/client/src/app/pages/pricing/pricing-page.html - 220 + 228 @@ -2316,11 +2316,11 @@ apps/client/src/app/pages/pricing/pricing-page.html - 75 + 79 apps/client/src/app/pages/pricing/pricing-page.html - 243 + 251 @@ -2376,7 +2376,7 @@ apps/client/src/app/pages/pricing/pricing-page.html - 281 + 289 @@ -2548,7 +2548,7 @@ apps/client/src/app/pages/pricing/pricing-page.html - 265 + 273 @@ -2716,7 +2716,7 @@ apps/client/src/app/pages/features/features-page.html - 246 + 271 @@ -3420,7 +3420,7 @@ 深色模式 apps/client/src/app/pages/features/features-page.html - 233 + 258 @@ -3428,7 +3428,7 @@ 市场情绪 apps/client/src/app/pages/features/features-page.html - 215 + 240 @@ -3444,7 +3444,7 @@ 多语言 apps/client/src/app/pages/features/features-page.html - 259 + 284 @@ -3460,7 +3460,7 @@ 开源软件 apps/client/src/app/pages/features/features-page.html - 296 + 321 @@ -3472,7 +3472,7 @@ apps/client/src/app/pages/features/features-page.html - 321 + 346 apps/client/src/app/pages/landing/landing-page.html @@ -3484,7 +3484,7 @@ apps/client/src/app/pages/pricing/pricing-page.html - 360 + 368 apps/client/src/app/pages/public/public-page.html @@ -3948,7 +3948,7 @@ 包含 Basic 所有功能,以及 apps/client/src/app/pages/pricing/pricing-page.html - 199 + 203 @@ -4164,7 +4164,7 @@ 使用您的学校电子邮件地址 apps/client/src/app/pages/pricing/pricing-page.html - 348 + 356 @@ -4756,7 +4756,7 @@ 寻找学生折扣? apps/client/src/app/pages/pricing/pricing-page.html - 342 + 350 @@ -4808,7 +4808,7 @@ 存款 libs/ui/src/lib/fire-calculator/fire-calculator.component.ts - 441 + 444 @@ -4988,7 +4988,7 @@ apps/client/src/app/pages/pricing/pricing-page.html - 127 + 131 @@ -5000,7 +5000,7 @@ apps/client/src/app/pages/pricing/pricing-page.html - 131 + 135 @@ -5012,7 +5012,7 @@ apps/client/src/app/pages/pricing/pricing-page.html - 135 + 139 @@ -5020,11 +5020,11 @@ 数据导入与导出 apps/client/src/app/pages/pricing/pricing-page.html - 63 + 67 apps/client/src/app/pages/pricing/pricing-page.html - 139 + 143 @@ -5032,7 +5032,7 @@ 社区支持 apps/client/src/app/pages/pricing/pricing-page.html - 80 + 84 @@ -5040,7 +5040,7 @@ 自托管,手动更新。 apps/client/src/app/pages/pricing/pricing-page.html - 84 + 88 @@ -5048,11 +5048,11 @@ 免费 apps/client/src/app/pages/pricing/pricing-page.html - 86 + 90 apps/client/src/app/pages/pricing/pricing-page.html - 152 + 156 @@ -5060,7 +5060,7 @@ 适合刚开始交易的新投资者。 apps/client/src/app/pages/pricing/pricing-page.html - 119 + 123 @@ -5068,11 +5068,11 @@ 完全托管的 Ghostfolio 云产品。 apps/client/src/app/pages/pricing/pricing-page.html - 150 + 154 apps/client/src/app/pages/pricing/pricing-page.html - 252 + 260 @@ -5080,7 +5080,7 @@ 适合需要全面了解其金融资产的雄心勃勃的投资者。 apps/client/src/app/pages/pricing/pricing-page.html - 193 + 197 @@ -5100,7 +5100,7 @@ 电子邮件和聊天支持 apps/client/src/app/pages/pricing/pricing-page.html - 248 + 256 @@ -5116,7 +5116,7 @@ apps/client/src/app/pages/pricing/pricing-page.html - 279 + 287 @@ -5124,7 +5124,7 @@ 一次性付款,无自动续订。 apps/client/src/app/pages/pricing/pricing-page.html - 285 + 293 @@ -5144,7 +5144,7 @@ 免费。 apps/client/src/app/pages/pricing/pricing-page.html - 362 + 370 @@ -5593,7 +5593,7 @@ 请求它 apps/client/src/app/pages/pricing/pricing-page.html - 344 + 352 @@ -5769,7 +5769,7 @@ 联系我们 apps/client/src/app/pages/pricing/pricing-page.html - 336 + 344 @@ -5857,7 +5857,7 @@ libs/ui/src/lib/fire-calculator/fire-calculator.component.ts - 451 + 454 libs/ui/src/lib/i18n.ts @@ -5869,7 +5869,7 @@ 储蓄 libs/ui/src/lib/fire-calculator/fire-calculator.component.ts - 461 + 464 @@ -5881,11 +5881,11 @@ libs/ui/src/lib/holdings-table/holdings-table.component.html - 138 + 143 libs/ui/src/lib/holdings-table/holdings-table.component.html - 139 + 144 libs/ui/src/lib/top-holdings/top-holdings.component.html @@ -5905,7 +5905,7 @@ 显示所有 libs/ui/src/lib/holdings-table/holdings-table.component.html - 243 + 248 @@ -6177,8 +6177,8 @@ - Export - 导出 + Export + 导出 libs/ui/src/lib/activities-table/activities-table.component.html 72 @@ -6617,7 +6617,7 @@ 这里 apps/client/src/app/pages/pricing/pricing-page.html - 347 + 355 @@ -7166,7 +7166,7 @@ apps/client/src/app/pages/features/features-page.html - 277 + 302 apps/client/src/app/pages/resources/personal-finance-tools/product-page.component.ts @@ -7510,7 +7510,7 @@ 如果您计划开通账户在 apps/client/src/app/pages/pricing/pricing-page.html - 312 + 320 @@ -7642,7 +7642,7 @@ 使用我们的推荐链接并获得一年的Ghostfolio Premium会员资格 apps/client/src/app/pages/pricing/pricing-page.html - 340 + 348 @@ -8200,11 +8200,11 @@ 涨跌 libs/ui/src/lib/holdings-table/holdings-table.component.html - 160 + 165 libs/ui/src/lib/treemap-chart/treemap-chart.component.ts - 421 + 423 @@ -8232,23 +8232,23 @@ apps/client/src/app/components/home-overview/home-overview.component.ts - 64 + 65 libs/ui/src/lib/holdings-table/holdings-table.component.html - 185 + 190 libs/ui/src/lib/holdings-table/holdings-table.component.html - 186 + 191 libs/ui/src/lib/treemap-chart/treemap-chart.component.ts - 421 + 423 libs/ui/src/lib/treemap-chart/treemap-chart.component.ts - 434 + 436 @@ -8429,7 +8429,7 @@ 包含 API 访问权限,适用于 apps/client/src/app/pages/pricing/pricing-page.html - 235 + 243 @@ -8477,7 +8477,7 @@ 某人 apps/client/src/app/pages/public/public-page.component.ts - 68 + 69 @@ -8792,7 +8792,7 @@ apps/client/src/app/pages/pricing/pricing-page.html - 294 + 302 @@ -8804,7 +8804,7 @@ apps/client/src/app/pages/pricing/pricing-page.html - 297 + 305 @@ -8831,6 +8831,14 @@ 265 + + new + new + + apps/client/src/app/pages/features/features-page.html + 223 + + Investment 投资 diff --git a/libs/common/src/lib/config.ts b/libs/common/src/lib/config.ts index 5a8aecff36..dbf873df2a 100644 --- a/libs/common/src/lib/config.ts +++ b/libs/common/src/lib/config.ts @@ -138,9 +138,6 @@ export const DEFAULT_REDACTED_PATHS = [ 'activities[*].feeInAssetProfileCurrency', 'activities[*].feeInBaseCurrency', 'activities[*].quantity', - 'activities[*].SymbolProfile.comment', - 'activities[*].SymbolProfile.scraperConfiguration', - 'activities[*].SymbolProfile.symbolMapping', 'activities[*].value', 'activities[*].valueInBaseCurrency', 'assetProfile.comment', diff --git a/libs/common/src/lib/dtos/create-asset-profile.dto.ts b/libs/common/src/lib/dtos/create-asset-profile.dto.ts index b4fe347071..5d75b39a1d 100644 --- a/libs/common/src/lib/dtos/create-asset-profile.dto.ts +++ b/libs/common/src/lib/dtos/create-asset-profile.dto.ts @@ -10,17 +10,19 @@ import { DataSource } from '@ghostfolio/prisma/enums'; -import { Type } from 'class-transformer'; +import { Transform, TransformFnParams, Type } from 'class-transformer'; import { IsArray, IsBoolean, IsEnum, + IsNotEmpty, IsOptional, IsString, IsUrl, MaxLength, ValidateNested } from 'class-validator'; +import { isString } from 'lodash-es'; import { CountryDto } from './country.dto'; import { HoldingDto } from './holding.dto'; @@ -92,8 +94,12 @@ export class CreateAssetProfileDto { @ValidateNested({ each: true }) sectors?: Prisma.InputJsonArray; + @IsNotEmpty() @IsString() @MaxLength(SYMBOL_MAXIMUM_LENGTH) + @Transform(({ value }: TransformFnParams) => + isString(value) ? value.trim() : value + ) symbol: string; @IsOptional() diff --git a/libs/common/src/lib/dtos/create-order.dto.ts b/libs/common/src/lib/dtos/create-order.dto.ts index d473e8e7b6..d2374e9b1a 100644 --- a/libs/common/src/lib/dtos/create-order.dto.ts +++ b/libs/common/src/lib/dtos/create-order.dto.ts @@ -18,6 +18,7 @@ import { IsBoolean, IsEnum, IsISO8601, + IsNotEmpty, IsNumber, IsOptional, IsString, @@ -71,8 +72,12 @@ export class CreateOrderDto { @Min(0) quantity: number; + @IsNotEmpty() @IsString() @MaxLength(SYMBOL_MAXIMUM_LENGTH) + @Transform(({ value }: TransformFnParams) => + isString(value) ? value.trim() : value + ) symbol: string; @ArrayUnique() diff --git a/libs/common/src/lib/dtos/update-order.dto.ts b/libs/common/src/lib/dtos/update-order.dto.ts index f7dfb0df53..77e4a42286 100644 --- a/libs/common/src/lib/dtos/update-order.dto.ts +++ b/libs/common/src/lib/dtos/update-order.dto.ts @@ -17,6 +17,7 @@ import { IsArray, IsEnum, IsISO8601, + IsNotEmpty, IsNumber, IsOptional, IsString, @@ -72,8 +73,12 @@ export class UpdateOrderDto { @Min(0) quantity: number; + @IsNotEmpty() @IsString() @MaxLength(SYMBOL_MAXIMUM_LENGTH) + @Transform(({ value }: TransformFnParams) => + isString(value) ? value.trim() : value + ) symbol: string; @ArrayUnique() diff --git a/libs/common/src/lib/helper.spec.ts b/libs/common/src/lib/helper.spec.ts index b7e7635670..71b934628c 100644 --- a/libs/common/src/lib/helper.spec.ts +++ b/libs/common/src/lib/helper.spec.ts @@ -7,6 +7,7 @@ import { extractNumberFromString, getCountryCodeFromCurrency, getEmojiFlag, + getHoldingName, getNumberFormatGroup, getStringOrNull, getStringOrUndefined, @@ -143,6 +144,52 @@ describe('Helper', () => { }); }); + describe('Get holding name', () => { + it('Cash position', () => { + expect( + getHoldingName({ + assetSubClass: 'CASH', + assetSubClassLabel: 'Cash', + name: 'CHF', + symbol: 'CHF' + }) + ).toEqual('Cash (CHF)'); + }); + + it('Cash position without asset sub class label', () => { + expect( + getHoldingName({ + assetSubClass: 'CASH', + assetSubClassLabel: undefined, + name: 'CHF', + symbol: 'CHF' + }) + ).toEqual('CHF'); + }); + + it('Holding with name', () => { + expect( + getHoldingName({ + assetSubClass: 'STOCK', + assetSubClassLabel: 'Stock', + name: 'Apple Inc.', + symbol: 'AAPL' + }) + ).toEqual('Apple Inc.'); + }); + + it('Holding without name', () => { + expect( + getHoldingName({ + assetSubClass: 'STOCK', + assetSubClassLabel: 'Stock', + name: undefined, + symbol: 'AAPL' + }) + ).toEqual('AAPL'); + }); + }); + describe('Get number format group', () => { let languageGetter: jest.SpyInstance; diff --git a/libs/common/src/lib/helper.ts b/libs/common/src/lib/helper.ts index 2a76261dae..7e4e13de7e 100644 --- a/libs/common/src/lib/helper.ts +++ b/libs/common/src/lib/helper.ts @@ -453,6 +453,22 @@ export function getEmojiFlag(aCountryCode: string) { }); } +export function getHoldingName({ + assetSubClass, + assetSubClassLabel, + name, + symbol +}: Pick< + PortfolioPosition['assetProfile'], + 'assetSubClass' | 'assetSubClassLabel' | 'name' | 'symbol' +>) { + if (isCashPosition({ assetSubClass }) && assetSubClassLabel) { + return `${assetSubClassLabel} (${symbol})`; + } + + return name ?? symbol; +} + export function getLocale() { return navigator.language ?? DEFAULT_LOCALE; } diff --git a/libs/common/src/lib/validator-constraints/is-after-1970.ts b/libs/common/src/lib/validator-constraints/is-after-1970.ts index d55dc67c54..a3ba006308 100644 --- a/libs/common/src/lib/validator-constraints/is-after-1970.ts +++ b/libs/common/src/lib/validator-constraints/is-after-1970.ts @@ -1,5 +1,6 @@ import { isValidDateAfter1970 } from '@ghostfolio/common/helper'; +import { utc } from '@date-fns/utc'; import { ValidatorConstraint, ValidatorConstraintInterface @@ -9,7 +10,7 @@ import { format } from 'date-fns'; @ValidatorConstraint({ name: 'isAfter1970' }) export class IsAfter1970Constraint implements ValidatorConstraintInterface { public defaultMessage() { - return `date must be after ${format(new Date(0), 'yyyy')}`; + return `date must be after ${format(new Date(0), 'yyyy', { in: utc })}`; } public validate(aDate: Date | string) { diff --git a/libs/ui/src/lib/activities-table/activities-table.component.html b/libs/ui/src/lib/activities-table/activities-table.component.html index ded8fe8c83..a093eef399 100644 --- a/libs/ui/src/lib/activities-table/activities-table.component.html +++ b/libs/ui/src/lib/activities-table/activities-table.component.html @@ -69,7 +69,7 @@ @if (canExportActivities()) { Export {{ totalItems > 1 ? totalItems : '' }} + >Export {{ (totalItems ?? 0) > 1 ? totalItems : '' }} {totalItems, plural, =1 {Activity} other {Activities}} } @else { @@ -107,7 +107,7 @@ @if (canDeleteActivities()) { Delete {{ totalItems > 1 ? totalItems : '' }} + >Delete {{ (totalItems ?? 0) > 1 ? totalItems : '' }} {totalItems, plural, =1 {Activity} other {Activities}} } @else { @@ -126,7 +126,7 @@ class="gf-table w-100" mat-table matSort - [dataSource]="dataSource()" + [dataSource]="dataSource() ?? []" [matSortActive]="sortColumn" [matSortDirection]="sortDirection" [matSortDisabled]="sortDisabled" @@ -574,9 +574,9 @@ } (); @Output() activityClicked = new EventEmitter(); diff --git a/libs/ui/src/lib/fire-calculator/fire-calculator.component.ts b/libs/ui/src/lib/fire-calculator/fire-calculator.component.ts index bdd65d1668..64bcec2934 100644 --- a/libs/ui/src/lib/fire-calculator/fire-calculator.component.ts +++ b/libs/ui/src/lib/fire-calculator/fire-calculator.component.ts @@ -2,7 +2,7 @@ import { getTooltipOptions, transformTickToAbbreviation } from '@ghostfolio/common/chart-helper'; -import { primaryColorRgb } from '@ghostfolio/common/config'; +import { DEFAULT_CURRENCY, primaryColorRgb } from '@ghostfolio/common/config'; import { formatMonthAndYear, getLocale } from '@ghostfolio/common/helper'; import { FireCalculationCompleteEvent } from '@ghostfolio/common/interfaces'; import { ColorScheme } from '@ghostfolio/common/types'; @@ -53,6 +53,7 @@ import { addDays, addYears, getMonth, + parseISO, setMonth, setYear, startOfMonth, @@ -60,7 +61,7 @@ import { } from 'date-fns'; import { addIcons } from 'ionicons'; import { calendarClearOutline } from 'ionicons/icons'; -import { isNumber } from 'lodash-es'; +import { isNumber, isString } from 'lodash-es'; import { NgxSkeletonLoaderModule } from 'ngx-skeleton-loader'; import { debounceTime } from 'rxjs'; @@ -86,16 +87,16 @@ import { FireCalculatorService } from './fire-calculator.service'; templateUrl: './fire-calculator.component.html' }) export class GfFireCalculatorComponent implements OnChanges, OnDestroy { - @Input() annualInterestRate = 0; - @Input() colorScheme?: ColorScheme; - @Input() currency: string; - @Input() deviceType: string; - @Input() fireWealth = 0; - @Input() hasPermissionToUpdateUserSettings: boolean; - @Input() locale = getLocale(); - @Input() projectedTotalAmount = 0; - @Input() retirementDate: Date; - @Input() savingsRate = 0; + @Input() public annualInterestRate?: number = 0; + @Input() public colorScheme?: ColorScheme; + @Input() public currency?: string; + @Input() public deviceType: string; + @Input() public fireWealth = 0; + @Input() public hasPermissionToUpdateUserSettings: boolean; + @Input() public locale?: string = getLocale(); + @Input() public projectedTotalAmount?: number = 0; + @Input() public retirementDate?: Date | string; + @Input() public savingsRate?: number = 0; public calculatorForm = this.formBuilder.group({ annualInterestRate: new FormControl(null), @@ -243,11 +244,13 @@ export class GfFireCalculatorComponent implements OnChanges, OnDestroy { if (isNumber(this.fireWealth) && this.fireWealth >= 0) { this.calculatorForm.setValue( { - annualInterestRate: this.annualInterestRate, - paymentPerPeriod: this.savingsRate, + annualInterestRate: this.annualInterestRate ?? 0, + paymentPerPeriod: this.savingsRate ?? 0, principalInvestmentAmount: this.fireWealth, - projectedTotalAmount: this.projectedTotalAmount, - retirementDate: this.retirementDate ?? this.DEFAULT_RETIREMENT_DATE + projectedTotalAmount: this.projectedTotalAmount ?? 0, + retirementDate: isString(this.retirementDate) + ? parseISO(this.retirementDate) + : (this.retirementDate ?? this.DEFAULT_RETIREMENT_DATE) }, { emitEvent: false @@ -353,7 +356,7 @@ export class GfFireCalculatorComponent implements OnChanges, OnDestroy { ); return `Total: ${new Intl.NumberFormat(this.locale, { - currency: this.currency, + currency: this.currency ?? DEFAULT_CURRENCY, currencyDisplay: 'code', style: 'currency' }).format(totalAmount)}`; @@ -367,7 +370,7 @@ export class GfFireCalculatorComponent implements OnChanges, OnDestroy { if (context.parsed.y !== null) { label += new Intl.NumberFormat(this.locale, { - currency: this.currency, + currency: this.currency ?? DEFAULT_CURRENCY, currencyDisplay: 'code', style: 'currency' }).format(context.parsed.y); @@ -510,7 +513,8 @@ export class GfFireCalculatorComponent implements OnChanges, OnDestroy { } else { const today = new Date(); const retirementDate = - this.retirementDate ?? this.DEFAULT_RETIREMENT_DATE; + this.calculatorForm.get('retirementDate')?.value ?? + this.DEFAULT_RETIREMENT_DATE; return ( 12 * (retirementDate.getFullYear() - today.getFullYear()) + diff --git a/libs/ui/src/lib/holdings-table/holdings-table.component.html b/libs/ui/src/lib/holdings-table/holdings-table.component.html index 2001a1b990..e1c012ab15 100644 --- a/libs/ui/src/lib/holdings-table/holdings-table.component.html +++ b/libs/ui/src/lib/holdings-table/holdings-table.component.html @@ -20,7 +20,7 @@ [countryCode]="getCountryCodeForCashPosition(element)" [dataSource]="element.assetProfile.dataSource" [symbol]="element.assetProfile.symbol" - [tooltip]="element.assetProfile.name" + [tooltip]="getHoldingName(element.assetProfile)" /> @@ -40,9 +40,14 @@ mat-cell [class.w-100]="mode() === 'simple'" > -
    - {{ element.assetProfile.name }} - @if (element.assetProfile.name === element.assetProfile.symbol) { + @let holdingName = getHoldingName(element.assetProfile); + +
    + {{ holdingName }} + @if ( + !isCashPosition(element.assetProfile) && + element.assetProfile.name === element.assetProfile.symbol + ) { ({{ element.assetProfile.assetSubClassLabel }}) }
    diff --git a/libs/ui/src/lib/holdings-table/holdings-table.component.ts b/libs/ui/src/lib/holdings-table/holdings-table.component.ts index eac1c9d918..1894260b78 100644 --- a/libs/ui/src/lib/holdings-table/holdings-table.component.ts +++ b/libs/ui/src/lib/holdings-table/holdings-table.component.ts @@ -1,6 +1,7 @@ import { canOpenHoldingDetail, getCountryCodeFromCurrency, + getHoldingName, getLocale, getLowercase, isCashPosition @@ -89,6 +90,9 @@ export class GfHoldingsTableComponent { return columns; }); + protected readonly getHoldingName = getHoldingName; + protected readonly isCashPosition = isCashPosition; + protected readonly isLoading = computed(() => { return !this.holdings(); }); @@ -104,7 +108,13 @@ export class GfHoldingsTableComponent { }); public constructor() { - this.dataSource.sortingDataAccessor = getLowercase; + this.dataSource.sortingDataAccessor = (holding, path) => { + if (path === 'assetProfile.name') { + return getHoldingName(holding.assetProfile).toLocaleLowerCase(); + } + + return getLowercase(holding, path) as number | string; + }; // Reactive data update effect(() => { diff --git a/libs/ui/src/lib/top-holdings/top-holdings.component.ts b/libs/ui/src/lib/top-holdings/top-holdings.component.ts index 1a675a5531..80caca9185 100644 --- a/libs/ui/src/lib/top-holdings/top-holdings.component.ts +++ b/libs/ui/src/lib/top-holdings/top-holdings.component.ts @@ -38,11 +38,11 @@ import { GfValueComponent } from '../value/value.component'; templateUrl: './top-holdings.component.html' }) export class GfTopHoldingsComponent implements OnChanges { - @Input() baseCurrency: string; - @Input() hasPermissionToShowValues = true; - @Input() locale = getLocale(); - @Input() pageSize = Number.MAX_SAFE_INTEGER; - @Input() topHoldings: HoldingWithParents[]; + @Input() public baseCurrency?: string; + @Input() public hasPermissionToShowValues = true; + @Input() public locale?: string = getLocale(); + @Input() public pageSize = Number.MAX_SAFE_INTEGER; + @Input() public topHoldings?: HoldingWithParents[]; @Output() holdingClicked = new EventEmitter(); diff --git a/libs/ui/src/lib/treemap-chart/treemap-chart.component.ts b/libs/ui/src/lib/treemap-chart/treemap-chart.component.ts index 83b9916c6e..01037f77f4 100644 --- a/libs/ui/src/lib/treemap-chart/treemap-chart.component.ts +++ b/libs/ui/src/lib/treemap-chart/treemap-chart.component.ts @@ -3,7 +3,11 @@ import { getIntervalFromDateRange } from '@ghostfolio/common/calculation-helper'; import { getTooltipOptions } from '@ghostfolio/common/chart-helper'; -import { canOpenHoldingDetail, getLocale } from '@ghostfolio/common/helper'; +import { + canOpenHoldingDetail, + getHoldingName, + getLocale +} from '@ghostfolio/common/helper'; import { AssetProfileIdentifier, PortfolioPosition @@ -399,20 +403,18 @@ export class GfTreemapChartComponent const raw = context.raw as GfTreemapDataPoint; const allocationInPercentage = `${(raw._data.allocationInPercentage * 100).toFixed(2)}%`; - const name = raw._data.assetProfile.name; + const name = getHoldingName(raw._data.assetProfile); const sign = raw._data.netPerformancePercentWithCurrencyEffect > 0 ? '+' : ''; - const symbol = raw._data.assetProfile.symbol; - const netPerformanceInPercentageWithSign = `${sign}${(raw._data.netPerformancePercentWithCurrencyEffect * 100).toFixed(2)}%`; if (raw._data.valueInBaseCurrency !== null) { const value = raw._data.valueInBaseCurrency; return [ - `${name ?? symbol} (${allocationInPercentage})`, + `${name} (${allocationInPercentage})`, `${value?.toLocaleString(this.locale(), { maximumFractionDigits: 2, minimumFractionDigits: 2 @@ -429,7 +431,7 @@ export class GfTreemapChartComponent ]; } else { return [ - `${name ?? symbol} (${allocationInPercentage})`, + `${name} (${allocationInPercentage})`, '', $localize`Performance`, netPerformanceInPercentageWithSign diff --git a/libs/ui/src/lib/value/value.component.html b/libs/ui/src/lib/value/value.component.html index c5ebe4e2cf..385a49ac10 100644 --- a/libs/ui/src/lib/value/value.component.html +++ b/libs/ui/src/lib/value/value.component.html @@ -27,11 +27,7 @@ } - - @if (isLoading() || value === undefined) { + @if (isLoading()) { =22" @@ -235,6 +236,15 @@ "zod": "^3.25.76 || ^4.1.8" } }, + "node_modules/@ai-sdk/provider-utils/node_modules/undici": { + "version": "7.30.0", + "resolved": "https://registry.npmjs.org/undici/-/undici-7.30.0.tgz", + "integrity": "sha512-dkrQXeHSaoamnItlYbmzG0wFYrM0ZwDxCIg0A7aKjTyyhh9svRzCNFEzV+Vm05/yehjCzjDZ31KXfGEjYSztDQ==", + "license": "MIT", + "engines": { + "node": ">=20.18.1" + } + }, "node_modules/@ampproject/remapping": { "version": "2.3.0", "resolved": "https://registry.npmjs.org/@ampproject/remapping/-/remapping-2.3.0.tgz", @@ -11951,9 +11961,9 @@ } }, "node_modules/@openrouter/ai-sdk-provider": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/@openrouter/ai-sdk-provider/-/ai-sdk-provider-3.0.0.tgz", - "integrity": "sha512-m9XTSWoODH2RM5OsZpaGiN7QRR8cdP5paBWq699Tu3JVmGPBKT8xF8XwV0ZBVVsjikD/JgWfak4VSsTR4wAVbg==", + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/@openrouter/ai-sdk-provider/-/ai-sdk-provider-3.1.0.tgz", + "integrity": "sha512-BQy1TA9fKrh47s9ivsO6FY5QimN7GUF0i8Qon3pkxQclEgShso0dClpcWn8OIyPLEZZPFaFKgI8TW4S/O0p0Dw==", "license": "Apache-2.0", "engines": { "node": ">=22" @@ -16840,14 +16850,14 @@ } }, "node_modules/ai": { - "version": "7.0.37", - "resolved": "https://registry.npmjs.org/ai/-/ai-7.0.37.tgz", - "integrity": "sha512-stF+SEQJgKY3Qfe3FwNzqUrehHviOp2l7LemoI8YMOa0Zk5PxKsRNgUk3cHXz0RAue9RRyCAis2fz6LSCP6EKw==", + "version": "7.0.114", + "resolved": "https://registry.npmjs.org/ai/-/ai-7.0.114.tgz", + "integrity": "sha512-jRMOw7tPMWVYomoHWY5xHBXPQDyKTY1Pn3BX0Rh39UOP6y9FYq6xCIB6khgIC/b9CRg4+IxadwIbie+Eu7yU3g==", "license": "Apache-2.0", "dependencies": { - "@ai-sdk/gateway": "4.0.28", - "@ai-sdk/provider": "4.0.3", - "@ai-sdk/provider-utils": "5.0.12" + "@ai-sdk/gateway": "4.0.92", + "@ai-sdk/provider": "4.0.18", + "@ai-sdk/provider-utils": "5.0.47" }, "engines": { "node": ">=22" @@ -18022,6 +18032,19 @@ "devOptional": true, "license": "MIT" }, + "node_modules/c12/node_modules/dotenv": { + "version": "17.4.2", + "resolved": "https://registry.npmjs.org/dotenv/-/dotenv-17.4.2.tgz", + "integrity": "sha512-nI4U3TottKAcAD9LLud4Cb7b2QztQMUEfHbvhTH09bqXTxnSie8WnjPALV/WMCrJZ6UV/qHJ6L03OqO3LcdYZw==", + "devOptional": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://dotenvx.com" + } + }, "node_modules/c12/node_modules/jiti": { "version": "2.6.1", "resolved": "https://registry.npmjs.org/jiti/-/jiti-2.6.1.tgz", @@ -20710,10 +20733,13 @@ } }, "node_modules/dotenv": { - "version": "17.4.2", - "resolved": "https://registry.npmjs.org/dotenv/-/dotenv-17.4.2.tgz", - "integrity": "sha512-nI4U3TottKAcAD9LLud4Cb7b2QztQMUEfHbvhTH09bqXTxnSie8WnjPALV/WMCrJZ6UV/qHJ6L03OqO3LcdYZw==", + "version": "18.0.3", + "resolved": "https://registry.npmjs.org/dotenv/-/dotenv-18.0.3.tgz", + "integrity": "sha512-/8CJTyL817qPnfbQrJgOEcrU3fDrJkRIoXBE566wHuvIXKOCHUZhPqGPbCPi9SogjdGnreF0TlvnTgK6J/8zpA==", "license": "BSD-2-Clause", + "bin": { + "dotenv": "dist/index.cjs" + }, "engines": { "node": ">=12" }, @@ -20736,6 +20762,18 @@ "url": "https://dotenvx.com" } }, + "node_modules/dotenv-expand/node_modules/dotenv": { + "version": "17.4.2", + "resolved": "https://registry.npmjs.org/dotenv/-/dotenv-17.4.2.tgz", + "integrity": "sha512-nI4U3TottKAcAD9LLud4Cb7b2QztQMUEfHbvhTH09bqXTxnSie8WnjPALV/WMCrJZ6UV/qHJ6L03OqO3LcdYZw==", + "license": "BSD-2-Clause", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://dotenvx.com" + } + }, "node_modules/dunder-proto": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz", @@ -30600,9 +30638,9 @@ } }, "node_modules/prettier": { - "version": "3.9.6", - "resolved": "https://registry.npmjs.org/prettier/-/prettier-3.9.6.tgz", - "integrity": "sha512-OpN0zzVdiaiAhxpuuj5efpIS4sY9j7bY6uR5mnj5yPzGkdkjNKSJeUThPb60Jw29QuAZgA4o+/iB49kFiaBX6g==", + "version": "3.9.9", + "resolved": "https://registry.npmjs.org/prettier/-/prettier-3.9.9.tgz", + "integrity": "sha512-Z/CJHIkdujO/OtN7nXUii0Rf3VT5SRuhjBA82Xvu2XhBUgX3nhP67T0LHceBdQLex7OOFGTox+Q5Yg8Jk2Qivg==", "dev": true, "license": "MIT", "bin": { @@ -35034,9 +35072,9 @@ "license": "MIT" }, "node_modules/undici": { - "version": "8.10.0", - "resolved": "https://registry.npmjs.org/undici/-/undici-8.10.0.tgz", - "integrity": "sha512-HvltHd7avK13QIw/oLe4qoOLyoVSoafqJ2jYOrtMRBkbYT31eiBQ8O0ehRKZiEZCMEyLFQNIADpgCWC5fALvYQ==", + "version": "8.11.2", + "resolved": "https://registry.npmjs.org/undici/-/undici-8.11.2.tgz", + "integrity": "sha512-u4UB2/IrKdU6lFxumHmmo1a3fCQO5tzQllRorfoRS63txhrB7xTpSn1PftwC4qEHkOaqP95fCWW4lJzwErwzhQ==", "license": "MIT", "engines": { "node": ">=22.19.0" diff --git a/package.json b/package.json index fcb94759f7..b19db0a4bc 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "ghostfolio", - "version": "3.76.0", + "version": "3.78.0", "homepage": "https://ghostfol.io", "license": "AGPL-3.0", "repository": "https://github.com/ghostfolio/ghostfolio", @@ -90,13 +90,13 @@ "@nestjs/schedule": "6.1.3", "@nestjs/serve-static": "5.0.5", "@nestjs/throttler": "6.5.0", - "@openrouter/ai-sdk-provider": "3.0.0", + "@openrouter/ai-sdk-provider": "3.1.0", "@prisma/adapter-pg": "7.10.0", "@prisma/client": "7.10.0", "@rekog/mcp-nest": "2.0.7", "@simplewebauthn/browser": "13.3.0", "@simplewebauthn/server": "13.3.1", - "ai": "7.0.37", + "ai": "7.0.114", "alphavantage": "2.2.0", "big.js": "7.0.1", "bootstrap": "4.6.2", @@ -115,7 +115,7 @@ "countries-list": "3.4.1", "countup.js": "2.10.1", "date-fns": "4.4.0", - "dotenv": "17.4.2", + "dotenv": "18.0.3", "dotenv-expand": "13.0.0", "envalid": "8.2.0", "express": "5.2.1", @@ -148,7 +148,7 @@ "svgmap": "2.21.0", "tablemark": "4.1.0", "twitter-api-v2": "1.29.1", - "undici": "8.10.0", + "undici": "8.11.2", "uuid": "14.0.2", "yahoo-finance2": "4.0.2", "zod": "4.6.5", @@ -206,7 +206,7 @@ "jest-environment-node": "30.3.0", "jest-preset-angular": "17.0.0", "nx": "23.1.1", - "prettier": "3.9.6", + "prettier": "3.9.9", "prettier-plugin-organize-attributes": "1.0.0", "prisma": "7.10.0", "react": "18.2.0", diff --git a/test/import/ok/baln-buy-and-buy.json b/test/import/ok/baln-buy-and-buy.json new file mode 100644 index 0000000000..c5c33764b1 --- /dev/null +++ b/test/import/ok/baln-buy-and-buy.json @@ -0,0 +1,36 @@ +{ + "meta": { + "date": "2026-10-01T00:00:00.000Z", + "version": "dev" + }, + "activities": [ + { + "currency": "CHF", + "dataSource": "YAHOO", + "date": "2021-11-22T00:00:00.000Z", + "fee": 1.55, + "id": "fe7d42ee-e607-58b7-a183-9f2f894fe204", + "quantity": 2, + "symbol": "BALN.SW", + "type": "BUY", + "unitPrice": 142.9 + }, + { + "currency": "CHF", + "dataSource": "YAHOO", + "date": "2021-11-30T00:00:00.000Z", + "fee": 1.65, + "id": "716b3742-6326-5fb1-a420-5f21b97316ea", + "quantity": 2, + "symbol": "BALN.SW", + "type": "BUY", + "unitPrice": 136.6 + } + ], + "user": { + "settings": { + "currency": "CHF", + "performanceCalculationType": "ROAI" + } + } +} diff --git a/test/import/ok/baln-buy-and-sell.json b/test/import/ok/baln-buy-and-sell.json new file mode 100644 index 0000000000..7bb096e51c --- /dev/null +++ b/test/import/ok/baln-buy-and-sell.json @@ -0,0 +1,36 @@ +{ + "meta": { + "date": "2026-10-01T00:00:00.000Z", + "version": "dev" + }, + "activities": [ + { + "currency": "CHF", + "dataSource": "YAHOO", + "date": "2021-11-22T00:00:00.000Z", + "fee": 1.55, + "id": "9269a332-dec6-51b5-9c9d-60dec1cf3d25", + "quantity": 2, + "symbol": "BALN.SW", + "type": "BUY", + "unitPrice": 142.9 + }, + { + "currency": "CHF", + "dataSource": "YAHOO", + "date": "2021-11-30T00:00:00.000Z", + "fee": 1.65, + "id": "aafb6d62-3486-5106-bff5-9e06b91b8d89", + "quantity": 2, + "symbol": "BALN.SW", + "type": "SELL", + "unitPrice": 136.6 + } + ], + "user": { + "settings": { + "currency": "CHF", + "performanceCalculationType": "ROAI" + } + } +} diff --git a/test/import/ok/baln-buy-with-unit-price-lower-than-closing-price.json b/test/import/ok/baln-buy-with-unit-price-lower-than-closing-price.json new file mode 100644 index 0000000000..bef2f1eaa8 --- /dev/null +++ b/test/import/ok/baln-buy-with-unit-price-lower-than-closing-price.json @@ -0,0 +1,25 @@ +{ + "meta": { + "date": "2026-10-01T00:00:00.000Z", + "version": "dev" + }, + "activities": [ + { + "currency": "CHF", + "dataSource": "YAHOO", + "date": "2021-11-30T00:00:00.000Z", + "fee": 1.55, + "id": "99a0844c-53ff-58b9-b120-e9339ea3ba22", + "quantity": 2, + "symbol": "BALN.SW", + "type": "BUY", + "unitPrice": 135.0 + } + ], + "user": { + "settings": { + "currency": "CHF", + "performanceCalculationType": "ROAI" + } + } +} diff --git a/test/import/ok/baln-buy.json b/test/import/ok/baln-buy.json new file mode 100644 index 0000000000..8435b79137 --- /dev/null +++ b/test/import/ok/baln-buy.json @@ -0,0 +1,25 @@ +{ + "meta": { + "date": "2026-10-01T00:00:00.000Z", + "version": "dev" + }, + "activities": [ + { + "currency": "CHF", + "dataSource": "YAHOO", + "date": "2021-11-30T00:00:00.000Z", + "fee": 1.55, + "id": "d82cae72-04b1-5beb-9d84-8e7740cd1b44", + "quantity": 2, + "symbol": "BALN.SW", + "type": "BUY", + "unitPrice": 136.6 + } + ], + "user": { + "settings": { + "currency": "CHF", + "performanceCalculationType": "ROAI" + } + } +} diff --git a/test/import/ok/btcusd-buy-and-sell-partially.json b/test/import/ok/btcusd-buy-and-sell-partially.json new file mode 100644 index 0000000000..b91741245d --- /dev/null +++ b/test/import/ok/btcusd-buy-and-sell-partially.json @@ -0,0 +1,36 @@ +{ + "meta": { + "date": "2026-10-01T00:00:00.000Z", + "version": "dev" + }, + "activities": [ + { + "currency": "USD", + "dataSource": "YAHOO", + "date": "2015-01-01T00:00:00.000Z", + "fee": 0, + "id": "0d9ac134-2329-5234-8b59-8aaa6424e79d", + "quantity": 2, + "symbol": "BTCUSD", + "type": "BUY", + "unitPrice": 320.43 + }, + { + "currency": "USD", + "dataSource": "YAHOO", + "date": "2017-12-31T00:00:00.000Z", + "fee": 0, + "id": "f7a55970-57b0-52b3-9846-c7c306645050", + "quantity": 1, + "symbol": "BTCUSD", + "type": "SELL", + "unitPrice": 14156.4 + } + ], + "user": { + "settings": { + "currency": "CHF", + "performanceCalculationType": "ROAI" + } + } +} diff --git a/test/import/ok/msft-buy-with-dividend.json b/test/import/ok/msft-buy-with-dividend.json new file mode 100644 index 0000000000..c4f20e2898 --- /dev/null +++ b/test/import/ok/msft-buy-with-dividend.json @@ -0,0 +1,36 @@ +{ + "meta": { + "date": "2026-10-01T00:00:00.000Z", + "version": "dev" + }, + "activities": [ + { + "currency": "USD", + "dataSource": "YAHOO", + "date": "2021-09-16T00:00:00.000Z", + "fee": 19, + "id": "dcec53cf-61d7-522b-985f-fd94b1f074a0", + "quantity": 1, + "symbol": "MSFT", + "type": "BUY", + "unitPrice": 298.58 + }, + { + "currency": "USD", + "dataSource": "YAHOO", + "date": "2021-11-16T00:00:00.000Z", + "fee": 0, + "id": "ac5970cf-4bb9-5385-8faf-9dd1efaca883", + "quantity": 1, + "symbol": "MSFT", + "type": "DIVIDEND", + "unitPrice": 0.62 + } + ], + "user": { + "settings": { + "currency": "USD", + "performanceCalculationType": "ROAI" + } + } +}