Browse Source

Merge branch 'main' into bugfix/interest-of-account-for-activities-with-quantity-other-than-one

pull/7989/head
Thomas Kaul 2 weeks ago
committed by GitHub
parent
commit
a3fe6fb657
No known key found for this signature in database GPG Key ID: B5690EEEBB952194
  1. 28
      CHANGELOG.md
  2. 18
      apps/api/src/app/activities/activities.controller.ts
  3. 14
      apps/api/src/app/activities/activities.service.ts
  4. 2
      apps/api/src/app/endpoints/mcp/mcp.controller.spec.ts
  5. 21
      apps/api/src/app/endpoints/mcp/mcp.controller.ts
  6. 225
      apps/api/src/app/endpoints/mcp/mcp.schemas.spec.ts
  7. 75
      apps/api/src/app/endpoints/mcp/mcp.schemas.ts
  8. 230
      apps/api/src/app/endpoints/mcp/mcp.service.spec.ts
  9. 72
      apps/api/src/app/endpoints/mcp/mcp.service.ts
  10. 7
      apps/api/src/app/endpoints/watchlist/watchlist.service.ts
  11. 2
      apps/api/src/app/import/import.service.ts
  12. 40
      apps/api/src/app/portfolio/calculator/portfolio-calculator-test-utils.ts
  13. 33
      apps/api/src/app/portfolio/calculator/portfolio-calculator.ts
  14. 43
      apps/api/src/app/portfolio/calculator/roai/portfolio-calculator-baln-buy-and-buy.spec.ts
  15. 43
      apps/api/src/app/portfolio/calculator/roai/portfolio-calculator-baln-buy-and-sell.spec.ts
  16. 94
      apps/api/src/app/portfolio/calculator/roai/portfolio-calculator-baln-buy.spec.ts
  17. 43
      apps/api/src/app/portfolio/calculator/roai/portfolio-calculator-btcusd-buy-and-sell-partially.spec.ts
  18. 35
      apps/api/src/app/portfolio/calculator/roai/portfolio-calculator-btcusd-short.spec.ts
  19. 35
      apps/api/src/app/portfolio/calculator/roai/portfolio-calculator-btcusd.spec.ts
  20. 37
      apps/api/src/app/portfolio/calculator/roai/portfolio-calculator-jnug-buy-and-sell-and-buy-and-sell.spec.ts
  21. 170
      apps/api/src/app/portfolio/calculator/roai/portfolio-calculator-manual-buy-with-market-price-between-chart-dates.spec.ts
  22. 43
      apps/api/src/app/portfolio/calculator/roai/portfolio-calculator-msft-buy-with-dividend.spec.ts
  23. 37
      apps/api/src/app/portfolio/calculator/roai/portfolio-calculator-novn-buy-and-sell-partially.spec.ts
  24. 37
      apps/api/src/app/portfolio/calculator/roai/portfolio-calculator-novn-buy-and-sell.spec.ts
  25. 14
      apps/api/src/app/portfolio/current-rate.service.mock.ts
  26. 123
      apps/api/src/helper/data-source.helper.spec.ts
  27. 80
      apps/api/src/helper/data-source.helper.ts
  28. 85
      apps/api/src/helper/object.helper.spec.ts
  29. 7
      apps/api/src/interceptors/transform-data-source-in-response/transform-data-source-in-response.interceptor.ts
  30. 10
      apps/api/src/services/benchmark/benchmark.service.spec.ts
  31. 13
      apps/api/src/services/benchmark/benchmark.service.ts
  32. 5
      apps/api/src/services/portfolio-table/interfaces/data-source-table-context.interface.ts
  33. 4
      apps/api/src/services/portfolio-table/interfaces/holdings-table-context.interface.ts
  34. 9
      apps/api/src/services/portfolio-table/portfolio-table.module.ts
  35. 290
      apps/api/src/services/portfolio-table/portfolio-table.service.spec.ts
  36. 227
      apps/api/src/services/portfolio-table/portfolio-table.service.ts
  37. 22
      apps/api/src/services/queues/data-gathering/data-gathering.service.ts
  38. 18
      apps/client/src/app/components/account-detail-dialog/account-detail-dialog.component.ts
  39. 19
      apps/client/src/app/components/home-overview/home-overview.component.ts
  40. 2
      apps/client/src/app/components/investment-chart/investment-chart.component.ts
  41. 26
      apps/client/src/app/components/portfolio-summary/portfolio-summary.component.ts
  42. 3
      apps/client/src/app/pages/portfolio/allocations/allocations-page.component.ts
  43. 32
      apps/client/src/app/pages/portfolio/analysis/analysis-page.component.ts
  44. 3
      apps/client/src/app/pages/public/public-page.component.ts
  45. 3
      libs/common/src/lib/config.ts
  46. 8
      libs/common/src/lib/dtos/create-asset-profile.dto.ts
  47. 5
      libs/common/src/lib/dtos/create-order.dto.ts
  48. 5
      libs/common/src/lib/dtos/update-order.dto.ts
  49. 47
      libs/common/src/lib/helper.spec.ts
  50. 16
      libs/common/src/lib/helper.ts
  51. 10
      libs/ui/src/lib/activities-table/activities-table.component.html
  52. 36
      libs/ui/src/lib/activities-table/activities-table.component.ts
  53. 42
      libs/ui/src/lib/fire-calculator/fire-calculator.component.ts
  54. 13
      libs/ui/src/lib/holdings-table/holdings-table.component.html
  55. 12
      libs/ui/src/lib/holdings-table/holdings-table.component.ts
  56. 10
      libs/ui/src/lib/top-holdings/top-holdings.component.ts
  57. 14
      libs/ui/src/lib/treemap-chart/treemap-chart.component.ts
  58. 6
      libs/ui/src/lib/value/value.component.html
  59. 10
      libs/ui/src/lib/world-map-chart/world-map-chart.component.ts
  60. 114
      package-lock.json
  61. 12
      package.json
  62. 36
      test/import/ok/baln-buy-and-buy.json
  63. 36
      test/import/ok/baln-buy-and-sell.json
  64. 25
      test/import/ok/baln-buy-with-unit-price-lower-than-closing-price.json
  65. 25
      test/import/ok/baln-buy.json
  66. 36
      test/import/ok/btcusd-buy-and-sell-partially.json
  67. 36
      test/import/ok/msft-buy-with-dividend.json

28
CHANGELOG.md

@ -7,9 +7,37 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
## Unreleased
### 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`
- 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

18
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<Order> {
): Promise<Prisma.OrderGetPayload<{ include: { SymbolProfile: true } }>> {
// 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

14
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<Order> {
): Promise<Prisma.OrderGetPayload<{ include: { SymbolProfile: true } }>> {
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,

2
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',

21
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<typeof GET_PERFORMANCE_PARAMETERS>
) {
return this.mcpService.getPerformance({ ...parameters, userId });
}
@RequiresScopeOfAccess(scopes.portfolioRead)
@Tool({
annotations: {

225
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);
});

75
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')
})

230
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]);

72
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<typeof GET_ACCOUNTS_PARAMETERS> & { 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<typeof GET_PERFORMANCE_PARAMETERS> & { 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<z.infer<typeof GET_ACCOUNTS_PARAMETERS>, 'holding'>) {
return {
filterByDataSource: transformDataSourceInRequest({
configurationService: this.configurationService,
dataSource: holding?.dataSource
}),
filterBySymbol: holding?.symbol
};
}
private getTextResult(text: string) {
return { content: [{ text, type: 'text' as const }] };
}

7
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,

2
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,

40
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'));
}

33
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

43
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
});

43
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
});

94
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
});

43
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
});

35
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
});

35
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
});

37
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
});

170
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
}
});
});
});
});

43
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
});

37
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
});

37
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
});

14
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 };

123
apps/api/src/helper/data-source.helper.spec.ts

@ -0,0 +1,123 @@
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<string, unknown> = {
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();
});
});
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);
});
});
});

80
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<DataSource, string>(
Object.values(DataSource).map((dataSource) => {
return [dataSource, hashDataSource(dataSource)];
@ -30,6 +38,16 @@ 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 hashDataSource(dataSource: DataSource) {
return createHash('sha256').update(dataSource).digest('hex').slice(0, 8);
}
@ -77,3 +95,63 @@ export function getUnmaskedGhostfolioDataSource({
? (ghostfolioDataSources[0] as DataSource)
: dataSource;
}
export function isDataGatheringSupported({
dataSource,
scraperConfiguration
}: {
dataSource: DataSource;
scraperConfiguration: Prisma.JsonValue;
}) {
// 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
* (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;
}

85
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,

7
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'
]
});

10
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
);
});
});

13
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<boolean> {
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,

5
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;
}

4
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<AssetClass, string>;
assetSubClassTranslations: Record<AssetSubClass, string>;
}

9
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 {}

290
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<string, unknown>;
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<string, unknown>;
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<typeof createPortfolioTableService>[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<string, unknown>
) {
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()]);

227
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<T>(
getDataSource: (row: T) => DataSource | undefined
): TableColumnDefinition<T, DataSourceTableContext> {
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<Activity>[] =
[
{
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<PortfolioPerformance>[] =
[
{
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
})
);

22
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;

18
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 }) => {

19
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 }) => {

2
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;

26
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<number>();
@ -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)
);
}
@ -200,7 +200,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
});

3
apps/client/src/app/pages/portfolio/allocations/allocations-page.component.ts

@ -11,6 +11,7 @@ import {
convertValuesToPercentagesOfTotal,
getAssetProfileIdentifier,
getCountryName,
getHoldingName,
isCashPosition
} from '@ghostfolio/common/helper';
import {
@ -533,7 +534,7 @@ export class GfAllocationsPageComponent implements OnInit {
value,
dataSource: position.assetProfile.dataSource,
isClickable: canOpenHoldingDetail(position),
name: position.assetProfile.name ?? ''
name: getHoldingName(position.assetProfile)
};
}
}

32
apps/client/src/app/pages/portfolio/analysis/analysis-page.component.ts

@ -7,8 +7,8 @@ import {
} from '@ghostfolio/common/config';
import { canOpenHoldingDetail } from '@ghostfolio/common/helper';
import {
HistoricalDataItem,
InvestmentItem,
LineChartItem,
PortfolioInvestmentsResponse,
PortfolioPerformance,
PortfolioPosition,
@ -79,7 +79,7 @@ import { forkJoin } from 'rxjs';
})
export class GfAnalysisPageComponent implements OnInit {
protected benchmark?: Partial<SymbolProfile>;
protected benchmarkDataItems: HistoricalDataItem[] = [];
protected benchmarkDataItems: LineChartItem[] = [];
protected readonly benchmarks: Partial<SymbolProfile>[];
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;
@ -378,18 +378,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 (

3
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)
};
}
}

3
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',

8
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()

5
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()

5
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()

47
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<string, [], any>;

16
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;
}

10
libs/ui/src/lib/activities-table/activities-table.component.html

@ -69,7 +69,7 @@
<ion-icon class="mr-2" name="cloud-download-outline" />
@if (canExportActivities()) {
<span i18n
>Export {{ totalItems > 1 ? totalItems : '' }}
>Export {{ (totalItems ?? 0) > 1 ? totalItems : '' }}
{totalItems, plural, =1 {Activity} other {Activities}}</span
>
} @else {
@ -107,7 +107,7 @@
<ion-icon class="mr-2" name="trash-outline" />
@if (canDeleteActivities()) {
<span i18n
>Delete {{ totalItems > 1 ? totalItems : '' }}
>Delete {{ (totalItems ?? 0) > 1 ? totalItems : '' }}
{totalItems, plural, =1 {Activity} other {Activities}}</span
>
} @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 @@
}
<mat-paginator
[class.d-none]="(isLoading() && !totalItems) || totalItems <= pageSize"
[class.d-none]="(totalItems ?? 0) <= pageSize"
[hidePageSize]="true"
[length]="totalItems"
[length]="totalItems ?? 0"
[pageIndex]="pageIndex"
[pageSize]="pageSize"
[showFirstLastButtons]="true"

36
libs/ui/src/lib/activities-table/activities-table.component.ts

@ -100,24 +100,24 @@ import { GfValueComponent } from '../value/value.component';
templateUrl: './activities-table.component.html'
})
export class GfActivitiesTableComponent implements AfterViewInit, OnInit {
@Input() baseCurrency: string;
@Input() deviceType: string;
@Input() hasActivities: boolean;
@Input() hasPermissionToCreateActivity: boolean;
@Input() hasPermissionToDeleteActivity: boolean;
@Input() hasPermissionToExportActivities: boolean;
@Input() hasPermissionToFilterByType: boolean;
@Input() hasPermissionToImportActivities: boolean;
@Input() hasPermissionToOpenDetails = true;
@Input() hasPermissionToUpdateActivity: boolean;
@Input() locale = getLocale();
@Input() pageIndex: number;
@Input() pageSize = DEFAULT_PAGE_SIZE;
@Input() showActions = true;
@Input() sortColumn: string;
@Input() sortDirection: SortDirection;
@Input() sortDisabled = false;
@Input() totalItems = Number.MAX_SAFE_INTEGER;
@Input() public baseCurrency?: string;
@Input() public deviceType: string;
@Input() public hasActivities: boolean;
@Input() public hasPermissionToCreateActivity: boolean;
@Input() public hasPermissionToDeleteActivity: boolean;
@Input() public hasPermissionToExportActivities: boolean;
@Input() public hasPermissionToFilterByType?: boolean = false;
@Input() public hasPermissionToImportActivities: boolean;
@Input() public hasPermissionToOpenDetails = true;
@Input() public hasPermissionToUpdateActivity: boolean;
@Input() public locale?: string = getLocale();
@Input() public pageIndex: number;
@Input() public pageSize = DEFAULT_PAGE_SIZE;
@Input() public showActions = true;
@Input() public sortColumn: string;
@Input() public sortDirection: SortDirection;
@Input() public sortDisabled = false;
@Input() public totalItems?: number = Number.MAX_SAFE_INTEGER;
@Output() activitiesDeleted = new EventEmitter<void>();
@Output() activityClicked = new EventEmitter<AssetProfileIdentifier>();

42
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<number | null>(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()) +

13
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)"
/>
</td>
</ng-container>
@ -40,9 +40,14 @@
mat-cell
[class.w-100]="mode() === 'simple'"
>
<div class="text-truncate" [title]="element.assetProfile.name">
{{ element.assetProfile.name }}
@if (element.assetProfile.name === element.assetProfile.symbol) {
@let holdingName = getHoldingName(element.assetProfile);
<div class="text-truncate" [title]="holdingName">
{{ holdingName }}
@if (
!isCashPosition(element.assetProfile) &&
element.assetProfile.name === element.assetProfile.symbol
) {
<span>({{ element.assetProfile.assetSubClassLabel }})</span>
}
</div>

12
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(() => {

10
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<AssetProfileIdentifier>();

14
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

6
libs/ui/src/lib/value/value.component.html

@ -27,11 +27,7 @@
}
</ng-template>
<!--
@deprecated Rendering the loading state for an undefined value is kept for
backward compatibility. Use the isLoading attribute instead.
-->
@if (isLoading() || value === undefined) {
@if (isLoading()) {
<ngx-skeleton-loader
animation="pulse"
[theme]="{

10
libs/ui/src/lib/world-map-chart/world-map-chart.component.ts

@ -23,10 +23,12 @@ import svgMap from 'svgmap';
templateUrl: './world-map-chart.component.html'
})
export class GfWorldMapChartComponent implements OnChanges, OnDestroy {
@Input() countries: { [code: string]: { name?: string; value: number } };
@Input() format: string;
@Input() isInPercentage = false;
@Input() locale = getLocale();
@Input() public countries: {
[code: string]: { name?: string; value: number };
};
@Input() public format: string;
@Input() public isInPercentage?: boolean = false;
@Input() public locale?: string = getLocale();
public isLoading = true;
public svgMapElement: any;

114
package-lock.json

@ -1,12 +1,12 @@
{
"name": "ghostfolio",
"version": "3.76.0",
"version": "3.77.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "ghostfolio",
"version": "3.76.0",
"version": "3.77.0",
"hasInstallScript": true,
"license": "AGPL-3.0",
"dependencies": {
@ -46,13 +46,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",
@ -71,7 +71,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",
@ -104,7 +104,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",
@ -162,7 +162,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",
@ -189,13 +189,13 @@
"license": "MIT"
},
"node_modules/@ai-sdk/gateway": {
"version": "4.0.28",
"resolved": "https://registry.npmjs.org/@ai-sdk/gateway/-/gateway-4.0.28.tgz",
"integrity": "sha512-ee9TsNO3mkgHDWmTmJ8Fvltr6PFh52zhrV2+FaKJY3F3iu7kWZi5tCRJCR1HVhhlpj6lHniUb28nLQdPHkA/1Q==",
"version": "4.0.92",
"resolved": "https://registry.npmjs.org/@ai-sdk/gateway/-/gateway-4.0.92.tgz",
"integrity": "sha512-awX24+QedfcIDTAdwkCUNG08vPJjkQI9KZLGh4Ua+vNbgeaNFJre96OIbBefOD9XPHSVpdXjwqtWmo4Yh7oczw==",
"license": "Apache-2.0",
"dependencies": {
"@ai-sdk/provider": "4.0.3",
"@ai-sdk/provider-utils": "5.0.12",
"@ai-sdk/provider": "4.0.18",
"@ai-sdk/provider-utils": "5.0.47",
"@vercel/oidc": "3.2.0"
},
"engines": {
@ -206,9 +206,9 @@
}
},
"node_modules/@ai-sdk/provider": {
"version": "4.0.3",
"resolved": "https://registry.npmjs.org/@ai-sdk/provider/-/provider-4.0.3.tgz",
"integrity": "sha512-e0CpNWJUY7OxAFAnCZkw+ri9QOHWwTs1tXP42782KFGCU07qt8NiXCrCVowyCB5dP2r5/Uls+g2oPd8kOJn9dw==",
"version": "4.0.18",
"resolved": "https://registry.npmjs.org/@ai-sdk/provider/-/provider-4.0.18.tgz",
"integrity": "sha512-+GZJIgz1jk86pwEbb3f1BD2bdoSKyWE4Jg4YUc7NMnMozbWemSKYZcw2F4nMaO5qwsL5A8RmioAKW81YktRpIQ==",
"license": "Apache-2.0",
"dependencies": {
"json-schema": "^0.4.0"
@ -218,15 +218,16 @@
}
},
"node_modules/@ai-sdk/provider-utils": {
"version": "5.0.12",
"resolved": "https://registry.npmjs.org/@ai-sdk/provider-utils/-/provider-utils-5.0.12.tgz",
"integrity": "sha512-bbhlOgHeYwrIGheLkM6fhS8hVger8uFPmcOLg+kxc9EFh7y30XYorWhthlYAgpadO3SJhFZrIcEknN7qEqEVvA==",
"version": "5.0.47",
"resolved": "https://registry.npmjs.org/@ai-sdk/provider-utils/-/provider-utils-5.0.47.tgz",
"integrity": "sha512-NZQ1hMxY6Hd4ZiinjpU5HUOsyCHiTFkHw7AiM1EVN+Cd3kNTi47flZWNvFwGqnBojntB1+2eBRTeHRuVLtPhEg==",
"license": "Apache-2.0",
"dependencies": {
"@ai-sdk/provider": "4.0.3",
"@ai-sdk/provider": "4.0.18",
"@standard-schema/spec": "^1.1.0",
"@workflow/serde": "4.1.0",
"eventsource-parser": "^3.0.8"
"eventsource-parser": "^3.0.8",
"undici": "^7.29.0"
},
"engines": {
"node": ">=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"

12
package.json

@ -1,6 +1,6 @@
{
"name": "ghostfolio",
"version": "3.76.0",
"version": "3.77.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",

36
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"
}
}
}

36
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"
}
}
}

25
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"
}
}
}

25
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"
}
}
}

36
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"
}
}
}

36
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"
}
}
}
Loading…
Cancel
Save